05/31 Phase 7
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user