Merge pull request #634 from eitchtee/feat/reconciliation-cutoff

feat(accounts): reconcile balances against a date or reference month cutoff
This commit is contained in:
Herculino Trotta authored and GitHub committed 2026-10-31 18:00:23 -03:00
commit 809564a1d3
5 files changed
+303 -12

No files matched your search

+75
View File
@@ -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)
+14 -1
View File
@@ -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
+153 -1
View File
@@ -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()
)
+17 -10
View File
@@ -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},
)
@@ -9,6 +9,49 @@
<form hx-post="{% url 'account_reconciliation' %}">
{% csrf_token %}
{{ form.management_form }}
{# Picking a cutoff shows its field and re-fetches only the accounts list, with balances computed against it #}
<div class="mb-3"
hx-get="{% url 'account_reconciliation' %}"
hx-trigger="change"
hx-target="#reconciliation-accounts"
hx-select="#balanceAccordionFlush"
hx-include="this"
_="on change
set mode to (the first <input[name='cutoff-mode']:checked/> in me).value
for wrapper in <[data-cutoff-mode]/> in me
if wrapper's @data-cutoff-mode is mode
remove .hidden from wrapper
else
add .hidden to wrapper
end
end">
<div class="join w-full mb-3" role="group" aria-label="{{ cutoff.mode.label }}">
{% for value, label in cutoff.fields.mode.choices %}
<input type="radio"
class="join-item btn btn-primary btn-outline w-1/3"
name="{{ cutoff.mode.html_name }}"
id="{{ cutoff.mode.html_name }}_{{ forloop.counter }}"
value="{{ value }}"
aria-label="{{ label }}"
{% if value == cutoff.selected_mode %}checked{% endif %}>
{% endfor %}
</div>
<div data-cutoff-mode="date" {% if cutoff.selected_mode != "date" %}class="hidden"{% endif %}>
{{ cutoff.date|as_crispy_field }}
</div>
<div data-cutoff-mode="month" {% if cutoff.selected_mode != "month" %}class="hidden"{% endif %}>
{{ cutoff.month|as_crispy_field }}
</div>
</div>
{# Swapped rows bring fresh selects; the offcanvas only initializes TomSelect on first load #}
<div id="reconciliation-accounts"
_="on htmx:afterSettle
for x in <select/> in me
if not x.tomselect
remove .select from x
js(x) TomSelect(x) end
end
end">
<div class="join join-vertical w-full" id="balanceAccordionFlush">
{% for form in form.forms %}
<c-ui.components.collapse>
@@ -59,6 +102,7 @@
</c-ui.components.collapse>
{% endfor %}
</div>
</div>
<div class="mt-3">
<div>
<input type="submit" name="submit" value="{% translate 'Reconcile balances' %}" class="btn btn-primary w-full" id="submit-id-submit">