05/31 Phase 7

This commit is contained in:
2026-05-31 17:04:18 -04:00
parent 46a604a73a
commit 84f48a7b4d
14 changed files with 1202 additions and 1 deletions
+169
View File
@@ -0,0 +1,169 @@
"""
Import Service — parses CSV files for bulk transaction import.
Expected columns: date, type, description, category, account, amount, notes
Date formats: YYYY-MM-DD, MM/DD/YYYY, DD/MM/YYYY
"""
import csv
import io
import logging
from datetime import datetime
from app.extensions import db
from app.models.transaction import Transaction
from app.models.category import Category
from app.models.account import Account
from app.services.account_service import calc_balance
log = logging.getLogger(__name__)
REQUIRED_COLS = {'date', 'type', 'description', 'amount'}
DATE_FORMATS = ['%Y-%m-%d', '%m/%d/%Y', '%d/%m/%Y', '%Y/%m/%d', '%d-%m-%Y']
def _parse_date(s):
s = s.strip()
for fmt in DATE_FORMATS:
try:
return datetime.strptime(s, fmt).date()
except ValueError:
continue
raise ValueError(f'Unrecognised date format: {s!r}')
def _match_category(name, txn_type):
if not name:
return None
cat = Category.query.filter(
Category.name.ilike(name.strip()),
Category.category_type.in_([txn_type, 'both']),
Category.is_active == True,
).first()
return cat.id if cat else None
def _match_account(name):
if not name:
return None
acct = Account.query.filter(
Account.name.ilike(name.strip()),
Account.is_active == True,
).first()
return acct.id if acct else None
def parse_csv(file_content, default_account_id=None):
"""
Parse CSV content (str or bytes).
Returns (preview_rows, errors, column_map)
preview_rows: list of dicts ready for import
errors: list of error strings
"""
if isinstance(file_content, bytes):
file_content = file_content.decode('utf-8-sig') # handle BOM
reader = csv.DictReader(io.StringIO(file_content))
headers = {h.strip().lower() for h in (reader.fieldnames or [])}
missing = REQUIRED_COLS - headers
if missing:
return [], [f'Missing required columns: {", ".join(missing)}'], {}
rows = []
errors = []
for i, row in enumerate(reader, start=2):
clean = {k.strip().lower(): v.strip() for k, v in row.items()}
row_errors = []
# Date
try:
txn_date = _parse_date(clean.get('date', ''))
except ValueError as e:
row_errors.append(f'Row {i}: {e}')
continue
# Type
txn_type = clean.get('type', '').lower()
if txn_type not in ('income', 'expense'):
row_errors.append(f'Row {i}: type must be "income" or "expense", got {txn_type!r}')
continue
# Amount
try:
amount = float(clean.get('amount', '0').replace(',', '').replace('$', '').strip())
if amount <= 0:
raise ValueError('Amount must be > 0')
except ValueError as e:
row_errors.append(f'Row {i}: invalid amount — {e}')
continue
# Description
description = clean.get('description', '').strip()
if not description:
row_errors.append(f'Row {i}: description is required')
continue
# Optional fields
category_id = _match_category(clean.get('category', ''), txn_type)
account_id = _match_account(clean.get('account', '')) or default_account_id
notes = clean.get('notes', '')
if row_errors:
errors.extend(row_errors)
else:
rows.append({
'date': txn_date,
'transaction_type': txn_type,
'description': description,
'amount': amount,
'category_id': category_id,
'account_id': account_id,
'notes': notes,
'category_name': clean.get('category', ''),
'account_name': clean.get('account', ''),
})
return rows, errors
def import_rows(rows, skip_duplicates=True):
"""
Insert parsed rows into DB.
Returns (imported_count, skipped_count)
"""
imported = 0
skipped = 0
affected_accounts = set()
for row in rows:
if skip_duplicates:
existing = Transaction.query.filter_by(
date=row['date'],
description=row['description'],
amount=row['amount'],
transaction_type=row['transaction_type'],
).first()
if existing:
skipped += 1
continue
txn = Transaction(
date=row['date'],
transaction_type=row['transaction_type'],
description=row['description'],
amount=row['amount'],
category_id=row.get('category_id'),
account_id=row.get('account_id'),
notes=row.get('notes', ''),
)
db.session.add(txn)
if row.get('account_id'):
affected_accounts.add(row['account_id'])
imported += 1
db.session.commit()
for account_id in affected_accounts:
calc_balance(account_id)
return imported, skipped
+115
View File
@@ -0,0 +1,115 @@
"""
Recurring Service — processes recurring transaction rules and creates
due transactions. Called by cron daily at 6AM.
"""
import logging
from datetime import date, timedelta
from dateutil.relativedelta import relativedelta
from app.extensions import db
from app.models.recurring_rule import RecurringRule
from app.models.transaction import Transaction
from app.services.account_service import calc_balance
log = logging.getLogger(__name__)
def next_occurrence(last_run, frequency):
"""Calculate the next due date given the last run date and frequency."""
if frequency == 'daily':
return last_run + timedelta(days=1)
elif frequency == 'weekly':
return last_run + timedelta(weeks=1)
elif frequency == 'biweekly':
return last_run + timedelta(weeks=2)
elif frequency == 'monthly':
return last_run + relativedelta(months=1)
elif frequency == 'quarterly':
return last_run + relativedelta(months=3)
elif frequency == 'yearly':
return last_run + relativedelta(years=1)
return last_run + relativedelta(months=1)
def process_due_rules(dry_run=False):
"""
Find all active recurring rules that are due today or overdue.
Create transactions for each due occurrence.
Returns list of created transaction descriptions.
"""
today = date.today()
created = []
rules = RecurringRule.query.filter(
RecurringRule.is_active == True,
RecurringRule.next_run <= today,
).all()
for rule in rules:
# Check end date
if rule.end_date and today > rule.end_date:
rule.is_active = False
if not dry_run:
db.session.commit()
continue
# Create transaction for each missed occurrence up to today
run_date = rule.next_run or rule.start_date
affected_accounts = set()
while run_date <= today:
if not dry_run:
txn = Transaction(
transaction_type=rule.transaction_type,
account_id=rule.account_id,
category_id=rule.category_id,
amount=rule.amount,
description=rule.description,
date=run_date,
is_recurring=True,
recurring_rule_id=rule.id,
)
db.session.add(txn)
affected_accounts.add(rule.account_id)
created.append(f'{rule.description} ({rule.transaction_type}) on {run_date}')
log.info(f'[recurring] {"DRY " if dry_run else ""}Created: {rule.description} on {run_date}')
rule.last_run = run_date
run_date = next_occurrence(run_date, rule.frequency)
rule.next_run = run_date
if not dry_run:
db.session.commit()
for account_id in affected_accounts:
calc_balance(account_id)
return created
def get_upcoming(days=30):
"""Return list of upcoming recurring transactions in the next N days."""
today = date.today()
cutoff = today + timedelta(days=days)
rules = RecurringRule.query.filter(
RecurringRule.is_active == True,
).all()
upcoming = []
for rule in rules:
next_date = rule.next_run or rule.start_date
while next_date <= cutoff:
if next_date >= today:
upcoming.append({
'rule': rule,
'date': next_date,
'description': rule.description,
'amount': float(rule.amount),
'type': rule.transaction_type,
})
next_date = next_occurrence(next_date, rule.frequency)
upcoming.sort(key=lambda x: x['date'])
return upcoming