diff --git a/backend/invoices/models.py b/backend/invoices/models.py index 4c8bcbc6..5e582f2a 100644 --- a/backend/invoices/models.py +++ b/backend/invoices/models.py @@ -1264,7 +1264,11 @@ class RecurringInvoice(AssignableMixin, BaseModel): return self._add_months(current, 1) def _add_months(self, current, months): - """Add months, restoring the start_date day after a month-end clamp.""" + """Add months, restoring the start_date day after a month-end clamp. + + start_date.day is the billing anchor: a next_generation_date on the + last day of a short month is treated as clamped and moves back to it. + """ import calendar from dateutil.relativedelta import relativedelta diff --git a/backend/invoices/tests/test_invoices_api.py b/backend/invoices/tests/test_invoices_api.py index 66317679..1aecd21d 100644 --- a/backend/invoices/tests/test_invoices_api.py +++ b/backend/invoices/tests/test_invoices_api.py @@ -4830,6 +4830,25 @@ class TestRecurringInvoiceModel: recurring_invoice.next_generation_date = datetime.date(2026, 2, 15) assert recurring_invoice.calculate_next_date() == datetime.date(2026, 3, 15) + def test_calculate_next_date_month_end_anchors(self, recurring_invoice): + d = datetime.date + cases = [ + ("MONTHLY", d(2026, 1, 30), d(2026, 2, 28), d(2026, 3, 30)), + ("MONTHLY", d(2026, 1, 31), d(2026, 12, 31), d(2027, 1, 31)), + ("QUARTERLY", d(2026, 1, 31), d(2026, 4, 30), d(2026, 7, 31)), + ("SEMI_ANNUALLY", d(2026, 8, 31), d(2026, 2, 28), d(2026, 8, 31)), + ("MONTHLY", d(2026, 1, 31), d(2026, 2, 28), d(2026, 3, 31)), + ] + for frequency, start, current, expected in cases: + recurring_invoice.frequency = frequency + recurring_invoice.start_date = start + recurring_invoice.next_generation_date = current + assert recurring_invoice.calculate_next_date() == expected, ( + frequency, + start, + current, + ) + def test_calculate_next_date_custom(self, recurring_invoice): recurring_invoice.frequency = "CUSTOM" recurring_invoice.custom_days = 10