diff --git a/backend/app/services/budget_projection.py b/backend/app/services/budget_projection.py index 695cec4..fcb748f 100644 --- a/backend/app/services/budget_projection.py +++ b/backend/app/services/budget_projection.py @@ -65,8 +65,14 @@ def get_effective_amount(item: RecurringItem, month: int, year: int) -> float | return None +def _validate_month(month: int) -> None: + if not 1 <= month <= 12: + raise ValueError(f"month must be 1-12, got {month}") + + def get_month_range(year: int, month: int) -> tuple[datetime, datetime]: """Return (start, end) for a calendar month.""" + _validate_month(month) start = datetime(year, month, 1) if month == 12: end = datetime(year + 1, 1, 1) @@ -76,7 +82,15 @@ def get_month_range(year: int, month: int) -> tuple[datetime, datetime]: def get_cycle_range(year: int, month: int) -> tuple[datetime, datetime]: - """Return (start, end) for billing cycle: month/18 to month+1/18.""" + """Return (start, end) of the billing cycle that STARTS on the 18th of + `month`: [month/18, month+1/18). + + Note the labeling convention: budget month M covers the cycle that ENDS + on the 18th of M, so callers compose get_previous_cycle(year, M) with + this function — see compute_actuals_by_source. Pinned by + tests/test_cycle_math.py. + """ + _validate_month(month) start = datetime(year, month, 18) if month == 12: end = datetime(year + 1, 1, 18)