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

1"""Materialise recurring fixed-cost expenses (hangar rent, insurance, …). 

2 

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. 

8 

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""" 

15 

16import calendar 

17import logging 

18from datetime import date 

19 

20log = logging.getLogger(__name__) 

21 

22 

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) 

30 

31 

32def _months_between(a: date, b: date) -> int: 

33 return (b.year - a.year) * 12 + b.month - a.month 

34 

35 

36def materialize_recurring_expenses(today: date | None = None) -> int: 

37 """Create the concrete Expense rows for every due recurrence period. 

38 

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 

44 

45 if today is None: 

46 today = date.today() 

47 

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