mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-11-13 12:23:44 +01:00
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:
5 files changed
+303
-12
No files matched your search
@@ -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)
|
||||
@@ -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
@@ -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()
|
||||
)
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user