""" 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. # Cap catchup at 90 days to prevent runaway loops on long-dormant rules. catchup_floor = today - timedelta(days=90) run_date = rule.next_run or rule.start_date if run_date < catchup_floor: log.warning('[recurring] rule "%s" is >90 days overdue; starting catchup from %s', rule.description, catchup_floor) run_date = catchup_floor 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 projected_cash_flow(days=90): """ Build a projected cash flow from all active recurring rules over the next N days. Returns weekly-bucketed chart data plus a flat event list. Returns dict: labels — list of 'Mon DD' strings (week-start dates) income — list of floats (income per week bucket) expense — list of floats (expense per week bucket) balance — list of floats (running balance at end of each bucket) events — list of {date, description, amount, type, rule_id} starting_balance — float ending_balance — float total_income — float total_expense — float net — float """ from app.models.account import Account from sqlalchemy import func from app.extensions import db today = date.today() cutoff = today + timedelta(days=days) # Starting balance = sum of all active account balances starting_balance = float( db.session.query(func.coalesce(func.sum(Account.balance), 0)) .filter(Account.is_active == True) .scalar() ) # Enumerate all occurrences of active rules within the window rules = RecurringRule.query.filter_by(is_active=True).all() events = [] for rule in rules: run_date = rule.next_run or rule.start_date # Advance to window start if rule fires before today while run_date < today: run_date = next_occurrence(run_date, rule.frequency) while run_date <= cutoff: if rule.end_date and run_date > rule.end_date: break events.append({ 'date': run_date, 'description': rule.description, 'amount': float(rule.amount), 'type': rule.transaction_type, 'rule_id': rule.id, }) run_date = next_occurrence(run_date, rule.frequency) events.sort(key=lambda e: e['date']) # Build weekly buckets: each bucket starts on Monday # Find the Monday on or before today week_start = today - timedelta(days=today.weekday()) buckets = [] ws = week_start while ws <= cutoff: buckets.append(ws) ws += timedelta(weeks=1) bucket_income = [0.0] * len(buckets) bucket_expense = [0.0] * len(buckets) for ev in events: # Find which bucket this event falls in idx = (ev['date'] - week_start).days // 7 if 0 <= idx < len(buckets): if ev['type'] == 'income': bucket_income[idx] += ev['amount'] else: bucket_expense[idx] += ev['amount'] # Running balance running = starting_balance bucket_balance = [] for inc, exp in zip(bucket_income, bucket_expense): running += inc - exp bucket_balance.append(round(running, 2)) total_income = sum(bucket_income) total_expense = sum(bucket_expense) return { 'labels': [b.strftime('%b %d') for b in buckets], 'income': [round(v, 2) for v in bucket_income], 'expense': [round(v, 2) for v in bucket_expense], 'balance': bucket_balance, 'events': events, 'starting_balance': round(starting_balance, 2), 'ending_balance': round(bucket_balance[-1], 2) if bucket_balance else round(starting_balance, 2), 'total_income': round(total_income, 2), 'total_expense': round(total_expense, 2), 'net': round(total_income - total_expense, 2), } 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