Files
2026-06-05 15:23:23 -04:00

220 lines
7.6 KiB
Python

"""
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