From fcd666946f13f85b1235f83971b10ce4600ca674 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Sat, 31 Oct 2026 17:56:36 -0300 Subject: [PATCH] feat(accounts): reconcile balances against a date or reference month cutoff Credit cards often have paid transactions dated after the invoice being closed, which made reconciliation against all paid transactions wrong. The reconciliation panel now lets you balance against all paid transactions (previous behavior), paid transactions up to a date, or paid transactions up to a reference month. The adjustment transaction is dated at the cutoff so later reconciliations with the same or a later cutoff include it. An invalid cutoff blocks the reconciliation instead of silently falling back. --- app/apps/accounts/forms.py | 75 +++++++++ app/apps/accounts/services.py | 15 +- app/apps/accounts/tests.py | 154 +++++++++++++++++- app/apps/accounts/views/balance.py | 27 +-- .../fragments/account_reconciliation.html | 44 +++++ 5 files changed, 303 insertions(+), 12 deletions(-) diff --git a/app/apps/accounts/forms.py b/app/apps/accounts/forms.py index 801ff125..59030301 100644 --- a/app/apps/accounts/forms.py +++ b/app/apps/accounts/forms.py @@ -1,3 +1,5 @@ +import calendar + from apps.accounts.models import Account, AccountGroup from apps.common.fields.forms.dynamic_select import ( DynamicModelChoiceField, @@ -5,6 +7,7 @@ from apps.common.fields.forms.dynamic_select import ( ) from apps.common.widgets.crispy.daisyui import Switch from apps.common.widgets.crispy.submit import NoClassSubmit +from apps.common.widgets.datepicker import AirDatePickerInput, AirMonthYearPickerInput from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput from apps.common.widgets.tom_select import TomSelect from apps.currencies.models import Currency @@ -14,6 +17,7 @@ from crispy_forms.helper import FormHelper from crispy_forms.layout import Column, Field, Layout, Row from django import forms from django.db.models import Q +from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -163,4 +167,75 @@ class AccountBalanceForm(forms.Form): self.fields["tags"].queryset = TransactionTag.objects.filter(active=True) +class ReconciliationCutoffForm(forms.Form): + """Which transactions the reconciliation balance is computed against.""" + + MODE_CURRENT = "current" + MODE_DATE = "date" + MODE_MONTH = "month" + + mode = forms.ChoiceField( + choices=[ + (MODE_CURRENT, _("All paid transactions")), + (MODE_DATE, _("Up to date")), + (MODE_MONTH, _("Up to reference month")), + ], + required=False, + initial=MODE_CURRENT, + label=_("Balance against"), + ) + date = forms.DateField( + required=False, + label=_("Date"), + widget=AirDatePickerInput(clear_button=False), + ) + month = forms.DateField( + required=False, + label=_("Reference month"), + widget=AirMonthYearPickerInput(clear_button=False), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, prefix="cutoff", **kwargs) + today = timezone.localdate(timezone.now()) + self.today = today + self.fields["date"].initial = today + self.fields["month"].initial = today.replace(day=1) + + def _value(self, name): + # Bound but invalid/empty values fall back to the field's initial. + if self.is_bound and self.is_valid() and self.cleaned_data.get(name): + return self.cleaned_data[name] + return self.fields[name].initial + + @property + def selected_mode(self): + return self._value("mode") + + def balance_filters(self) -> dict: + """Extra kwargs for get_account_balance.""" + if self.selected_mode == self.MODE_DATE: + return {"date_lte": self._value("date")} + if self.selected_mode == self.MODE_MONTH: + return {"reference_date_lte": self._value("month")} + return {} + + def adjustment_dates(self): + """(date, reference_date) for the reconciliation transaction. + + Dated at the cutoff so a later reconcile with the same or later cutoff + includes it, and an earlier one doesn't. + """ + if self.selected_mode == self.MODE_DATE: + cutoff = self._value("date") + return cutoff, cutoff.replace(day=1) + if self.selected_mode == self.MODE_MONTH: + month = self._value("month") + last_day = month.replace( + day=calendar.monthrange(month.year, month.month)[1] + ) + return min(self.today, last_day), month + return self.today, self.today.replace(day=1) + + AccountBalanceFormSet = forms.formset_factory(AccountBalanceForm, extra=0) diff --git a/app/apps/accounts/services.py b/app/apps/accounts/services.py index 8966fb67..f701deee 100644 --- a/app/apps/accounts/services.py +++ b/app/apps/accounts/services.py @@ -1,3 +1,4 @@ +from datetime import date from decimal import Decimal from django.db import models @@ -6,7 +7,12 @@ from apps.accounts.models import Account from apps.transactions.models import Transaction -def get_account_balance(account: Account, paid_only: bool = True) -> Decimal: +def get_account_balance( + account: Account, + paid_only: bool = True, + date_lte: date | None = None, + reference_date_lte: date | None = None, +) -> Decimal: """ Calculate account balance (income - expense). @@ -14,6 +20,9 @@ def get_account_balance(account: Account, paid_only: bool = True) -> Decimal: account: Account instance to calculate balance for. paid_only: If True, only count paid transactions (current balance). If False, count all transactions (projected balance). + date_lte: If set, only count transactions dated on or before it. + reference_date_lte: If set, only count transactions whose reference + month is on or before it. Returns: Decimal: The calculated balance (income - expense). @@ -21,6 +30,10 @@ def get_account_balance(account: Account, paid_only: bool = True) -> Decimal: filters = {"account": account} if paid_only: filters["is_paid"] = True + if date_lte: + filters["date__lte"] = date_lte + if reference_date_lte: + filters["reference_date__lte"] = reference_date_lte income = Transaction.objects.filter( type=Transaction.Type.INCOME, **filters diff --git a/app/apps/accounts/tests.py b/app/apps/accounts/tests.py index 34aab3fd..de8a9161 100644 --- a/app/apps/accounts/tests.py +++ b/app/apps/accounts/tests.py @@ -1,6 +1,9 @@ from datetime import date +from decimal import Decimal -from django.test import TestCase +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from django.urls import reverse from apps.accounts.models import Account, AccountGroup from apps.currencies.models import Currency @@ -173,3 +176,152 @@ class GetAccountBalanceServiceTests(TestCase): balance = get_account_balance(self.account) # defaults to paid_only=True self.assertEqual(balance, Decimal("100.00")) + + +class GetAccountBalanceCutoffTests(TestCase): + """get_account_balance with date / reference month cutoffs.""" + + def setUp(self): + from apps.transactions.models import Transaction + + self.Transaction = Transaction + currency = Currency.objects.create(code="BRL", name="Real", decimal_places=2) + self.account = Account.objects.create(name="Card", currency=currency) + # Paid expenses: Oct 10 (ref Oct), Oct 20 (ref Nov), Nov 5 (ref Nov) + for day, ref, amount in [ + (date(2026, 10, 10), date(2026, 10, 1), "10"), + (date(2026, 10, 20), date(2026, 11, 1), "20"), + (date(2026, 11, 5), date(2026, 11, 1), "40"), + ]: + Transaction.objects.create( + account=self.account, + type=Transaction.Type.EXPENSE, + amount=Decimal(amount), + is_paid=True, + date=day, + reference_date=ref, + description="x", + ) + + def test_date_cutoff(self): + from apps.accounts.services import get_account_balance + + self.assertEqual( + get_account_balance(self.account, date_lte=date(2026, 10, 20)), + Decimal("-30"), + ) + + def test_reference_month_cutoff(self): + from apps.accounts.services import get_account_balance + + self.assertEqual( + get_account_balance(self.account, reference_date_lte=date(2026, 10, 1)), + Decimal("-10"), + ) + + def test_no_cutoff_counts_everything_paid(self): + from apps.accounts.services import get_account_balance + + self.assertEqual(get_account_balance(self.account), Decimal("-70")) + + +@override_settings( + STORAGES={ + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage" + }, + }, + WHITENOISE_AUTOREFRESH=True, +) +class AccountReconciliationCutoffViewTests(TestCase): + HTMX = {"HTTP_HX_REQUEST": "true"} + + def setUp(self): + from apps.transactions.models import Transaction + + self.Transaction = Transaction + self.user = get_user_model().objects.create_user( + email="u@test.com", password="testpass123" + ) + self.client.force_login(self.user) + currency = Currency.objects.create(code="BRL", name="Real", decimal_places=2) + self.account = Account.objects.create( + name="Card", currency=currency, owner=self.user + ) + for day, ref, amount in [ + (date(2026, 10, 10), date(2026, 10, 1), "10"), + (date(2026, 12, 5), date(2026, 12, 1), "40"), # paid, in the future + ]: + Transaction.objects.create( + account=self.account, + type=Transaction.Type.EXPENSE, + amount=Decimal(amount), + is_paid=True, + date=day, + reference_date=ref, + description="x", + owner=self.user, + ) + self.url = reverse("account_reconciliation") + + def post(self, cutoff, new_balance): + return self.client.post( + self.url, + { + **cutoff, + "form-TOTAL_FORMS": "1", + "form-INITIAL_FORMS": "1", + "form-0-account_id": str(self.account.id), + "form-0-new_balance": new_balance, + }, + **self.HTMX, + ) + + def adjustment(self): + return self.Transaction.objects.get( + account=self.account, description="Balance reconciliation" + ) + + def test_get_shows_balance_for_month_cutoff(self): + response = self.client.get( + self.url, + {"cutoff-mode": "month", "cutoff-month": "September 2026"}, + **self.HTMX, + ) + self.assertEqual(response.status_code, 200) + # The current-month fallback would give -10, so 0 proves the month was read. + self.assertEqual(response.context["form"].forms[0].current_balance, 0) + + def test_post_date_cutoff_dates_adjustment_at_cutoff(self): + response = self.post( + {"cutoff-mode": "date", "cutoff-date": "2026-10-15"}, "-25" + ) + self.assertEqual(response.status_code, 204) + adj = self.adjustment() + # Balance up to Oct 15 is -10, so -15 more is needed. + self.assertEqual(adj.type, self.Transaction.Type.EXPENSE) + self.assertEqual(adj.amount, Decimal("15")) + self.assertEqual(adj.date, date(2026, 10, 15)) + self.assertEqual(adj.reference_date, date(2026, 10, 1)) + + def test_post_month_cutoff_dates_adjustment_in_month(self): + self.post({"cutoff-mode": "month", "cutoff-month": "September 2026"}, "5") + adj = self.adjustment() + self.assertEqual(adj.type, self.Transaction.Type.INCOME) + self.assertEqual(adj.amount, Decimal("5")) + self.assertEqual(adj.date, date(2026, 9, 30)) + self.assertEqual(adj.reference_date, date(2026, 9, 1)) + + def test_post_with_invalid_cutoff_date_does_not_reconcile(self): + response = self.post({"cutoff-mode": "date", "cutoff-date": "garbage"}, "5") + self.assertEqual(response.status_code, 200) + self.assertFalse( + self.Transaction.objects.filter(description="Balance reconciliation").exists() + ) + + def test_post_without_cutoff_keeps_current_behavior(self): + self.post({}, "-50") + self.assertFalse( + self.Transaction.objects.filter(description="Balance reconciliation").exists() + ) diff --git a/app/apps/accounts/views/balance.py b/app/apps/accounts/views/balance.py index 2749593a..df534000 100644 --- a/app/apps/accounts/views/balance.py +++ b/app/apps/accounts/views/balance.py @@ -6,10 +6,9 @@ from django.db import models from django.db import transaction from django.http import HttpResponse from django.shortcuts import render -from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from apps.accounts.forms import AccountBalanceFormSet +from apps.accounts.forms import AccountBalanceFormSet, ReconciliationCutoffForm from apps.accounts.models import Account, Transaction from apps.accounts.services import get_account_balance from apps.common.decorators.htmx import only_htmx @@ -18,6 +17,11 @@ from apps.common.decorators.htmx import only_htmx @only_htmx @login_required def account_reconciliation(request): + cutoff = ReconciliationCutoffForm( + request.POST if request.method == "POST" else request.GET or None + ) + balance_filters = cutoff.balance_filters() + initial_data = [ { "account_id": account.id, @@ -26,7 +30,7 @@ def account_reconciliation(request): "decimal_places": account.currency.decimal_places, "suffix": account.currency.suffix, "prefix": account.currency.prefix, - "current_balance": get_account_balance(account), + "current_balance": get_account_balance(account, **balance_filters), } for account in Account.objects.filter(is_archived=False) .select_related("currency", "group") @@ -35,7 +39,8 @@ def account_reconciliation(request): if request.method == "POST": formset = AccountBalanceFormSet(request.POST, initial=initial_data) - if formset.is_valid(): + if formset.is_valid() and cutoff.is_valid(): + adjustment_date, adjustment_reference_date = cutoff.adjustment_dates() with transaction.atomic(): for form in formset: if form.is_valid(): @@ -48,7 +53,9 @@ def account_reconciliation(request): if new_balance is None: continue - current_balance = get_account_balance(account) + current_balance = get_account_balance( + account, **balance_filters + ) difference = new_balance - current_balance if difference != 0: @@ -60,10 +67,8 @@ def account_reconciliation(request): else Transaction.Type.EXPENSE ), amount=abs(difference), - date=timezone.localdate(timezone.now()), - reference_date=timezone.localdate( - timezone.now() - ).replace(day=1), + date=adjustment_date, + reference_date=adjustment_reference_date, description=_("Balance reconciliation"), is_paid=True, category=category, @@ -82,5 +87,7 @@ def account_reconciliation(request): formset = AccountBalanceFormSet(initial=initial_data) return render( - request, "accounts/fragments/account_reconciliation.html", {"form": formset} + request, + "accounts/fragments/account_reconciliation.html", + {"form": formset, "cutoff": cutoff}, ) diff --git a/app/templates/accounts/fragments/account_reconciliation.html b/app/templates/accounts/fragments/account_reconciliation.html index 8ad2656e..94e7abb0 100644 --- a/app/templates/accounts/fragments/account_reconciliation.html +++ b/app/templates/accounts/fragments/account_reconciliation.html @@ -9,6 +9,49 @@
{% csrf_token %} {{ form.management_form }} + {# Picking a cutoff shows its field and re-fetches only the accounts list, with balances computed against it #} +
+
+ {% for value, label in cutoff.fields.mode.choices %} + + {% endfor %} +
+
+ {{ cutoff.date|as_crispy_field }} +
+
+ {{ cutoff.month|as_crispy_field }} +
+
+ {# Swapped rows bring fresh selects; the offcanvas only initializes TomSelect on first load #} +
{% for form in form.forms %} @@ -59,6 +102,7 @@ {% endfor %}
+