122 lines
4.1 KiB
Python
122 lines
4.1 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 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
|