220 lines
7.6 KiB
Python
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
|