Coverage for app/services/recurring_expense_service.py: 100%
38 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-31 10:13 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-31 10:13 +0000
1"""Materialise recurring fixed-cost expenses (hangar rent, insurance, …).
3A template expense (Expense.recurrence set) describes the recurring cost.
4The daily pass creates an ordinary, editable Expense row for every period
5whose date has arrived, copying the template's current amount/type/category
6and advancing the coverage span — so editing the template changes future
7occurrences only.
9Occurrence dates are derived from the template date by whole-month
10arithmetic (index-based, not cursor-based), so a template dated Jan 31
11yields Feb 28/29 and then Mar 31 — no end-of-month drift. The
12recurrence_last_date cursor on the template records how far materialisation
13has progressed; deleting a generated row therefore never resurrects it.
14"""
16import calendar
17import logging
18from datetime import date
20log = logging.getLogger(__name__)
23def _add_months(d: date, months: int) -> date:
24 """d shifted by `months` whole months, day clamped to the target month."""
25 month_index = d.month - 1 + months
26 year = d.year + month_index // 12
27 month = month_index % 12 + 1
28 day = min(d.day, calendar.monthrange(year, month)[1])
29 return date(year, month, day)
32def _months_between(a: date, b: date) -> int:
33 return (b.year - a.year) * 12 + b.month - a.month
36def materialize_recurring_expenses(today: date | None = None) -> int:
37 """Create the concrete Expense rows for every due recurrence period.
39 Returns the number of rows created. Skips templates on archived
40 aircraft. Must run under the daily-checks advisory lock so only one
41 gunicorn worker materialises per day.
42 """
43 from models import Aircraft, Expense, ExpenseRecurrence, db
45 if today is None:
46 today = date.today()
48 created = 0
49 templates = (
50 Expense.query.join(Aircraft, Expense.aircraft_id == Aircraft.id)
51 .filter(Expense.recurrence.isnot(None), Aircraft.archived_at.is_(None))
52 .all()
53 )
54 for tpl in templates:
55 months = ExpenseRecurrence.MONTHS.get(tpl.recurrence)
56 if months is None:
57 log.warning(
58 "Expense %s has unknown recurrence %r — skipping",
59 tpl.id,
60 tpl.recurrence,
61 )
62 continue
63 # Periods already materialised, derived from the cursor.
64 k = (
65 0
66 if tpl.recurrence_last_date is None
67 else max(
68 0, round(_months_between(tpl.date, tpl.recurrence_last_date) / months)
69 )
70 )
71 while True:
72 k += 1
73 next_date = _add_months(tpl.date, k * months)
74 if next_date > today:
75 break
76 if tpl.recurrence_end is not None and next_date > tpl.recurrence_end:
77 break
78 db.session.add(
79 Expense(
80 aircraft_id=tpl.aircraft_id,
81 date=next_date,
82 expense_type=tpl.expense_type,
83 expense_category=tpl.expense_category,
84 description=tpl.description,
85 amount=tpl.amount,
86 currency=tpl.currency,
87 quantity=tpl.quantity,
88 unit=tpl.unit,
89 coverage_start=_add_months(tpl.coverage_start, k * months)
90 if tpl.coverage_start
91 else None,
92 coverage_end=_add_months(tpl.coverage_end, k * months)
93 if tpl.coverage_end
94 else None,
95 recurring_template_id=tpl.id,
96 )
97 )
98 tpl.recurrence_last_date = next_date
99 created += 1
100 if created:
101 log.info("Materialised %d recurring expense(s)", created)
102 db.session.commit()
103 return created