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 %}
+