05/31 Phase 7

This commit is contained in:
2026-05-31 17:04:18 -04:00
parent 46a604a73a
commit 84f48a7b4d
14 changed files with 1202 additions and 1 deletions
+115
View File
@@ -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