05/31 Phase 7
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user