diff --git a/config/urls.py b/config/urls.py index 1a36501..1d3d586 100644 --- a/config/urls.py +++ b/config/urls.py @@ -7,6 +7,12 @@ urlpatterns = [ path("", views.home, name="home"), path("health/", views.health, name="health"), path("reports/monthly/", views.monthly_report, name="monthly-report"), + path("reconciliations/", views.reconciliation_index, name="reconciliation-index"), + path( + "reconciliations//", + views.reconciliation_history, + name="reconciliation-history", + ), path("exports/", views.export_index, name="export-index"), path("exports/download/", views.export_download, name="export-download"), path("transactions/", views.transaction_list, name="transaction-list"), diff --git a/core/context.py b/core/context.py index 73967c1..5c43033 100644 --- a/core/context.py +++ b/core/context.py @@ -5,6 +5,7 @@ def navigation(_request): ("transaction-list", "Transactions"), ("category-list", "Categories"), ("payee-list", "Payees"), + ("reconciliation-index", "Reconciliation"), ("monthly-report", "Reports"), ("export-index", "Exports"), )} diff --git a/core/forms.py b/core/forms.py index 2699f3e..a97971f 100644 --- a/core/forms.py +++ b/core/forms.py @@ -91,6 +91,16 @@ class PayeeForm(forms.Form): name = forms.CharField(max_length=255) +class ReconciliationForm(forms.Form): + account = forms.TypedChoiceField(coerce=UUID) + as_of_date = forms.DateField(widget=forms.DateInput(attrs={"type": "date"})) + institution_balance = forms.DecimalField(max_digits=18, decimal_places=2) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["account"].choices = _reference_choices(Account) + + class ReportSelectionForm(forms.Form): month = forms.CharField( error_messages={"required": "invalid_month"}, diff --git a/core/templates/core/dashboard.html b/core/templates/core/dashboard.html index 232c803..b3d6ad0 100644 --- a/core/templates/core/dashboard.html +++ b/core/templates/core/dashboard.html @@ -25,5 +25,9 @@ {% if balances %}
{% for balance in balances %}{% endfor %}
AccountBalance
{{ balance.account_name }}{% if balance.balance < 0 %}Balance (negative): {% endif %}{{ balance.balance|floatformat:"2g" }} {{ summary.currency }}
{% else %}

No accounts use {{ summary.currency }}.

{% endif %} +

Reconciliation status

+{% if reconciliation_statuses %}
+{% for row in reconciliation_statuses %}{% endfor %} +
AccountAs ofStatusLive difference
{{ row.account_name }}{{ row.as_of_date|date:"Y-m-d" }}{% if row.state == "reconciled" %}Reconciled{% elif row.state == "changed_since_reconciliation" %}Changed since reconciliation{% else %}Difference{% endif %}{{ row.live_difference|floatformat:"2g" }} {{ row.currency }}
{% else %}

No reconciliation checkpoints for active BRL accounts.

{% endif %} {% endif %} {% endblock %} diff --git a/core/templates/core/reconciliations/history.html b/core/templates/core/reconciliations/history.html new file mode 100644 index 0000000..6c6949c --- /dev/null +++ b/core/templates/core/reconciliations/history.html @@ -0,0 +1,9 @@ +{% extends "core/base.html" %} +{% block title %}{{ account.name }} reconciliation · Gizmoduck{% endblock %} +{% block content %} +

{{ account.name }} reconciliation history

New checkpoint
+{% if account.archived %}

Archived account

{% endif %} +{% if history %}
+{% for row in history %}{% endfor %} +
As ofInstitution balanceSnapshot ledger balanceSnapshot differenceLive ledger balanceLive differenceStatus
{{ row.as_of_date|date:"Y-m-d" }}{{ row.institution_balance|floatformat:"2g" }} {{ row.currency }}{{ row.snapshot_ledger_balance|floatformat:"2g" }} {{ row.currency }}{{ row.snapshot_difference|floatformat:"2g" }} {{ row.currency }}{{ row.live_ledger_balance|floatformat:"2g" }} {{ row.currency }}{{ row.live_difference|floatformat:"2g" }} {{ row.currency }}{% if row.state == "reconciled" %}Reconciled{% elif row.state == "changed_since_reconciliation" %}Changed since reconciliation{% else %}Difference{% endif %}
{% else %}

No reconciliation checkpoints for this account.

{% endif %} +{% endblock %} diff --git a/core/templates/core/reconciliations/index.html b/core/templates/core/reconciliations/index.html new file mode 100644 index 0000000..cdf398d --- /dev/null +++ b/core/templates/core/reconciliations/index.html @@ -0,0 +1,29 @@ +{% extends "core/base.html" %} +{% block title %}Reconciliation · Gizmoduck{% endblock %} +{% block content %} +

Reconciliation

+

Compare an institution balance with the ledger through an inclusive date.

+
+ {% csrf_token %} + {{ form.non_field_errors }} +

{{ form.account }}{% for error in form.account.errors %}{{ error }}{% endfor %}

+

{{ form.as_of_date }}{% for error in form.as_of_date.errors %}{{ error }}{% endfor %}

+

{{ form.institution_balance }}{% for error in form.institution_balance.errors %}{{ error }}{% endfor %}

+ + {% if preview %} +

Preview

+
+
Account
{{ preview.account.name }}
+
As-of date
{{ preview.as_of_date|date:"Y-m-d" }}
+
Institution balance
{{ preview.institution_balance|floatformat:"2g" }} {{ preview.account.currency }}
+
Ledger balance
{{ preview.ledger_balance|floatformat:"2g" }} {{ preview.account.currency }}
+
Difference
{{ preview.difference|floatformat:"2g" }} {{ preview.account.currency }}
+
+ + {% endif %} +
+

Latest reconciliation status

+{% if latest_statuses %}
+{% for row in latest_statuses %}{% endfor %} +
AccountAs ofStatusLive difference
{{ row.account_name }}{{ row.as_of_date|date:"Y-m-d" }}{% if row.state == "reconciled" %}Reconciled{% elif row.state == "changed_since_reconciliation" %}Changed since reconciliation{% else %}Difference{% endif %}{{ row.live_difference|floatformat:"2g" }} {{ row.currency }}
{% else %}

No reconciliation checkpoints for active accounts.

{% endif %} +{% endblock %} diff --git a/core/views.py b/core/views.py index 84f9eba..80f71e8 100644 --- a/core/views.py +++ b/core/views.py @@ -1,3 +1,7 @@ +from dataclasses import dataclass +from datetime import date +from decimal import Decimal + from django.contrib import messages from django.core.paginator import Paginator from django.db import DatabaseError, connection @@ -12,15 +16,21 @@ from finance.queries import ( QueryInputError, TransactionFilters, filtered_transactions, listed_accounts, listed_categories, listed_payees, ) +from finance.reconciliation import ( + checkpoint_history, + latest_checkpoint_statuses, + ledger_balance_through, +) from finance.reports import account_balances, available_currencies, monthly_summary, spending_by_category from finance.services import ( Actor, DomainError, annotate_transaction, archive, create_account, - create_category, create_payee, record_transaction, + create_category, create_payee, create_reconciliation_checkpoint, + record_transaction, ) from .forms import ( AccountForm, AnnotationForm, CategoryForm, ManualTransactionForm, PayeeForm, - ReportSelectionForm, + ReconciliationForm, ReportSelectionForm, ) @@ -38,6 +48,18 @@ DOMAIN_MESSAGES = { UNKNOWN_DOMAIN_MESSAGE = "Unable to save this change." +@dataclass(frozen=True) +class ReconciliationPreview: + account: Account + as_of_date: date + institution_balance: Decimal + ledger_balance: Decimal + + @property + def difference(self) -> Decimal: + return self.institution_balance - self.ledger_balance + + def health(request): try: with connection.cursor() as cursor: @@ -56,6 +78,56 @@ def monthly_report(request): return _monthly_report(request, "core/reports/monthly.html") +def reconciliation_index(request): + form = ReconciliationForm(request.POST or None) + preview = None + if request.method == "POST": + action = request.POST.get("action") + if action == "preview" and form.is_valid(): + values = form.cleaned_data + selected_account = get_object_or_404( + Account, + pk=values["account"], + archived=False, + ) + preview = ReconciliationPreview( + account=selected_account, + as_of_date=values["as_of_date"], + institution_balance=values["institution_balance"], + ledger_balance=ledger_balance_through( + selected_account.pk, + values["as_of_date"], + ), + ) + elif action == "confirm" and form.is_valid(): + values = form.cleaned_data + try: + saved = create_reconciliation_checkpoint( + account_id=values["account"], + as_of_date=values["as_of_date"], + institution_balance=values["institution_balance"], + actor=LOCAL_ACTOR, + ) + except DomainError as exc: + form.add_error(None, _domain_message(exc)) + else: + messages.success(request, "Reconciliation checkpoint recorded.") + return redirect("reconciliation-history", account_id=saved.account_id) + return render(request, "core/reconciliations/index.html", { + "form": form, + "preview": preview, + "latest_statuses": _latest_reconciliation_statuses(), + }) + + +def reconciliation_history(request, account_id): + selected_account = get_object_or_404(Account, pk=account_id) + return render(request, "core/reconciliations/history.html", { + "account": selected_account, + "history": checkpoint_history(selected_account.pk), + }) + + def export_index(request): return render(request, "core/exports.html") @@ -236,6 +308,8 @@ def _monthly_report(request, template_name): } form = ReportSelectionForm(parameters, currencies=currencies) context = {"selection_form": form} + if template_name == "core/dashboard.html": + context["reconciliation_statuses"] = latest_checkpoint_statuses("BRL") status = 200 if form.is_valid(): month = form.cleaned_data["month"] @@ -248,3 +322,17 @@ def _monthly_report(request, template_name): else: status = 400 return render(request, template_name, context, status=status) + + +def _latest_reconciliation_statuses(): + currencies = ( + Account.objects.filter(archived=False) + .order_by("currency") + .values_list("currency", flat=True) + .distinct() + ) + return [ + status + for currency in currencies + for status in latest_checkpoint_statuses(currency) + ] diff --git a/tests/test_reconciliation_views.py b/tests/test_reconciliation_views.py new file mode 100644 index 0000000..f32fdd4 --- /dev/null +++ b/tests/test_reconciliation_views.py @@ -0,0 +1,233 @@ +"""Synthetic end-to-end checks for the owner reconciliation pages.""" + +from datetime import date +from decimal import Decimal + +import pytest +from django.test import Client +from django.urls import reverse + +from finance.models import AuditEvent, ReconciliationCheckpoint +from finance.services import ( + Actor, + archive, + create_account, + create_reconciliation_checkpoint, + record_transaction, +) + + +pytestmark = pytest.mark.django_db(transaction=True) +SETUP_ACTOR = Actor(kind="AGENT", identifier="synthetic-reconciliation-view-test") +AS_OF_DATE = date(2026, 9, 1) + + +def account(name="Synthetic reconciliation bank"): + return create_account( + name=name, + kind="BANK", + currency="BRL", + actor=SETUP_ACTOR, + ) + + +def post(target, amount, *, posting_date=AS_OF_DATE, kind="EXPENSE"): + return record_transaction( + account_id=target.pk, + amount=Decimal(amount), + posting_date=posting_date, + kind=kind, + actor=SETUP_ACTOR, + ) + + +def checkpoint(target, institution_balance): + return create_reconciliation_checkpoint( + account_id=target.pk, + as_of_date=AS_OF_DATE, + institution_balance=Decimal(institution_balance), + actor=SETUP_ACTOR, + ) + + +def test_reconciliation_is_linked_and_landing_form_lists_only_active_accounts(client): + active = account("Synthetic active bank") + archived = account("Synthetic archived bank") + checkpoint(active, "0.00") + archive(archived, actor=SETUP_ACTOR) + + navigation_page = client.get(reverse("transaction-list")) + response = client.get(reverse("reconciliation-index")) + + assert navigation_page.status_code == 200 + assert f'href="{reverse("reconciliation-index")}">Reconciliation' in navigation_page.content.decode() + assert response.status_code == 200 + body = response.content.decode() + assert "

Reconciliation

" in body + assert f'' in body + assert str(archived.pk) not in body + assert "Latest reconciliation status" in body + assert "Reconciled" in body + assert reverse("reconciliation-history", args=[active.pk]) in body + + +def test_preview_renders_ledger_balance_and_difference_without_writing(client): + bank = account() + post(bank, "100.00", kind="OPENING", posting_date=date(2026, 8, 31)) + post(bank, "-40.00") + + response = client.post( + reverse("reconciliation-index"), + { + "action": "preview", + "account": bank.pk, + "as_of_date": "2026-09-01", + "institution_balance": "0.00", + }, + ) + + assert response.status_code == 200 + body = response.content.decode() + assert "Preview" in body + assert "Ledger balance" in body and "60.00 BRL" in body + assert "Difference" in body and "-60.00 BRL" in body + assert 'name="action" value="confirm"' in body + assert ReconciliationCheckpoint.objects.count() == 0 + + +def test_confirm_revalidates_and_service_snapshots_fresh_ledger_then_redirects(client): + bank = account() + post(bank, "-40.00") + + preview = client.post( + reverse("reconciliation-index"), + { + "action": "preview", + "account": bank.pk, + "as_of_date": "2026-09-01", + "institution_balance": "-40.00", + }, + ) + assert preview.status_code == 200 + assert "0.00 BRL" in preview.content.decode() + + post(bank, "-2.00") + response = client.post( + reverse("reconciliation-index"), + { + "action": "confirm", + "account": bank.pk, + "as_of_date": "2026-09-01", + "institution_balance": "-40.00", + "ledger_balance": "999999.00", + "difference": "999999.00", + }, + ) + + saved = ReconciliationCheckpoint.objects.get() + assert response.status_code == 302 + assert response.url == reverse("reconciliation-history", args=[bank.pk]) + assert saved.ledger_balance_at_creation == Decimal("-42.00") + assert saved.difference_at_creation == Decimal("2.00") + event = AuditEvent.objects.get( + operation="reconciliation_checkpoint.created", + target_id=saved.pk, + ) + assert (event.actor_kind, event.actor_identifier) == ("HUMAN", "local-owner") + + +@pytest.mark.parametrize( + ("field", "value", "expected_error"), + [ + ("as_of_date", "not-a-date", "Enter a valid date."), + ("institution_balance", "not-money", "Enter a number."), + ("institution_balance", "1.001", "Ensure that there are no more than 2 decimal places."), + ], +) +def test_malformed_values_show_field_errors_without_writing( + client, field, value, expected_error +): + bank = account() + payload = { + "action": "confirm", + "account": bank.pk, + "as_of_date": "2026-09-01", + "institution_balance": "0.00", + } + payload[field] = value + + response = client.post(reverse("reconciliation-index"), payload) + + assert response.status_code == 200 + assert expected_error in response.content.decode() + assert field in response.context["form"].errors + assert ReconciliationCheckpoint.objects.count() == 0 + + +def test_reconciliation_confirmation_requires_csrf(): + bank = account() + guarded = Client(enforce_csrf_checks=True) + + response = guarded.post( + reverse("reconciliation-index"), + { + "action": "confirm", + "account": bank.pk, + "as_of_date": "2026-09-01", + "institution_balance": "0.00", + }, + ) + + assert response.status_code == 403 + assert ReconciliationCheckpoint.objects.count() == 0 + + +def test_archived_account_confirmation_fails_safely_without_writing(client): + archived = account() + archive(archived, actor=SETUP_ACTOR) + + response = client.post( + reverse("reconciliation-index"), + { + "action": "confirm", + "account": archived.pk, + "as_of_date": "2026-09-01", + "institution_balance": "0.00", + }, + ) + + assert response.status_code == 200 + body = response.content.decode() + assert "Select a valid choice." in body + assert "synthetic_private" not in body + assert ReconciliationCheckpoint.objects.count() == 0 + + +def test_history_keeps_corrected_same_date_snapshots_and_shows_live_drift(client): + bank = account() + post(bank, "-40.00") + first = checkpoint(bank, "-39.25") + correction = checkpoint(bank, "-40.00") + post(bank, "-2.00") + archive(bank, actor=SETUP_ACTOR) + + response = client.get(reverse("reconciliation-history", args=[bank.pk])) + + assert response.status_code == 200 + body = response.content.decode() + assert "Synthetic reconciliation bank" in body + assert "Archived" in body + assert "Institution balance" in body + assert "Snapshot ledger balance" in body + assert "Snapshot difference" in body + assert "Live ledger balance" in body + assert "Live difference" in body + assert "Changed since reconciliation" in body + assert "Difference" in body + assert "-42.00 BRL" in body + assert "2.00 BRL" in body + assert "2.75 BRL" in body + assert [row.checkpoint_id for row in response.context["history"]] == [ + correction.pk, + first.pk, + ] diff --git a/tests/test_report_views.py b/tests/test_report_views.py index e921869..aa26230 100644 --- a/tests/test_report_views.py +++ b/tests/test_report_views.py @@ -6,9 +6,11 @@ from django.urls import reverse from django.utils import timezone from finance.models import Account, Category, Transaction +from finance.services import Actor, create_reconciliation_checkpoint, record_transaction pytestmark = pytest.mark.django_db +SETUP_ACTOR = Actor(kind="AGENT", identifier="synthetic-report-view-test") def make_account(name="Synthetic Bank", *, currency="BRL"): @@ -121,3 +123,38 @@ def test_negative_spending_and_balance_keep_their_sign_and_are_labeled(client): assert "-25.00" in body assert "Balance (negative)" in body assert "-75.00" in body + + +@pytest.mark.django_db(transaction=True) +def test_dashboard_displays_current_reconciliation_status_after_backdated_activity(client): + bank = make_account("Synthetic reconciled bank") + record_transaction( + account_id=bank.pk, + amount=Decimal("-40.00"), + posting_date=date(2026, 1, 15), + kind="EXPENSE", + actor=SETUP_ACTOR, + ) + create_reconciliation_checkpoint( + account_id=bank.pk, + as_of_date=date(2026, 1, 31), + institution_balance=Decimal("-40.00"), + actor=SETUP_ACTOR, + ) + record_transaction( + account_id=bank.pk, + amount=Decimal("-2.50"), + posting_date=date(2026, 1, 20), + kind="EXPENSE", + actor=SETUP_ACTOR, + ) + + response = client.get(reverse("home"), {"month": "2026-01", "currency": "BRL"}) + + assert response.status_code == 200 + body = response.content.decode() + assert "Reconciliation status" in body + assert "Synthetic reconciled bank" in body + assert "Changed since reconciliation" in body + assert "2.50 BRL" in body + assert reverse("reconciliation-history", args=[bank.pk]) in body