diff --git a/app/apps/transactions/models.py b/app/apps/transactions/models.py index 8093b2e1..3129244a 100644 --- a/app/apps/transactions/models.py +++ b/app/apps/transactions/models.py @@ -1,3 +1,4 @@ +import calendar import decimal import logging import uuid @@ -1183,7 +1184,21 @@ class RecurringTransaction(TransferTemplate): return relativedelta(years=self.recurrence_interval) def get_next_date(self, current_date): - return current_date + self.get_recurrence_delta() + next_date = current_date + self.get_recurrence_delta() + + # A start on the 29th-31st is clamped in shorter months; return to that day + # afterwards instead of keeping the clamped one. + if ( + self.recurrence_type + in (self.RecurrenceType.MONTH, self.RecurrenceType.YEAR) + and self.start_date.day > current_date.day + and current_date.day + == calendar.monthrange(current_date.year, current_date.month)[1] + ): + last_day = calendar.monthrange(next_date.year, next_date.month)[1] + next_date = next_date.replace(day=min(self.start_date.day, last_day)) + + return next_date @classmethod def generate_upcoming_transactions(cls): diff --git a/app/apps/transactions/tests/test_models.py b/app/apps/transactions/tests/test_models.py index 72e43402..c50c0811 100644 --- a/app/apps/transactions/tests/test_models.py +++ b/app/apps/transactions/tests/test_models.py @@ -242,6 +242,30 @@ class RecurringTransactionTests(TestCase): self.assertEqual(recurring.recurrence_interval, 1) self.assertEqual(recurring.account.currency.code, "USD") + def test_month_end_recurrence_returns_to_its_day(self): + """A recurrence on the 31st goes back to it after a short month""" + recurring = RecurringTransaction.objects.create( + account=self.account, + type=Transaction.Type.EXPENSE, + amount=Decimal("100.00"), + description="Rent", + start_date=datetime.date(2026, 1, 31), + recurrence_type=RecurringTransaction.RecurrenceType.MONTH, + recurrence_interval=1, + ) + + feb = recurring.get_next_date(datetime.date(2026, 1, 31)) + march = recurring.get_next_date(feb) + april = recurring.get_next_date(march) + + self.assertEqual(feb, datetime.date(2026, 2, 28)) + self.assertEqual(march, datetime.date(2026, 3, 31)) + self.assertEqual(april, datetime.date(2026, 4, 30)) + self.assertEqual( + recurring.get_next_date(datetime.date(2026, 2, 1)), + datetime.date(2026, 3, 1), + ) + def test_generate_upcoming_transactions_keeps_tags_and_entities(self): """Generation must copy tags/entities even with no current user""" tag = TransactionTag.objects.create(name="Essential")