diff --git a/core/templates/core/reconciliations/history.html b/core/templates/core/reconciliations/history.html index 6c6949c..c18e25a 100644 --- a/core/templates/core/reconciliations/history.html +++ b/core/templates/core/reconciliations/history.html @@ -3,7 +3,7 @@ {% block content %}

{{ account.name }} reconciliation history

New checkpoint
{% if account.archived %}

Archived account

{% endif %} -{% if history %}
-{% for row in history %}{% endfor %} +{% if history %}
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 %}
+{% for row in history %}{% endfor %}
As ofCreated atInstitution balanceSnapshot ledger balanceSnapshot differenceLive ledger balanceLive differenceStatus
{{ row.as_of_date|date:"Y-m-d" }}{{ row.created_at|date:"Y-m-d H:i:s T" }}{{ 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 index cdf398d..e546b81 100644 --- a/core/templates/core/reconciliations/index.html +++ b/core/templates/core/reconciliations/index.html @@ -11,6 +11,7 @@

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

{% if preview %} +

Preview

Account
{{ preview.account.name }}
@@ -19,6 +20,7 @@
Ledger balance
{{ preview.ledger_balance|floatformat:"2g" }} {{ preview.account.currency }}
Difference
{{ preview.difference|floatformat:"2g" }} {{ preview.account.currency }}
+

If you change the account, date, or institution balance, choose Preview again before confirming.

{% endif %} diff --git a/core/views.py b/core/views.py index 80f71e8..8c599c8 100644 --- a/core/views.py +++ b/core/views.py @@ -3,6 +3,7 @@ from datetime import date from decimal import Decimal from django.contrib import messages +from django.core import signing from django.core.paginator import Paginator from django.db import DatabaseError, connection from django.http import HttpResponse, JsonResponse @@ -81,10 +82,16 @@ def monthly_report(request): def reconciliation_index(request): form = ReconciliationForm(request.POST or None) preview = None - if request.method == "POST": + preview_token = None + if request.method == "POST" and form.is_valid(): action = request.POST.get("action") - if action == "preview" and form.is_valid(): - values = form.cleaned_data + values = form.cleaned_data + preview_inputs = { + "account": str(values["account"]), + "as_of_date": values["as_of_date"].isoformat(), + "institution_balance": format(values["institution_balance"], ".2f"), + } + if action == "preview": selected_account = get_object_or_404( Account, pk=values["account"], @@ -99,23 +106,34 @@ def reconciliation_index(request): values["as_of_date"], ), ) - elif action == "confirm" and form.is_valid(): - values = form.cleaned_data + preview_token = signing.dumps(preview_inputs, salt="reconciliation-preview") + elif action == "confirm": try: - saved = create_reconciliation_checkpoint( - account_id=values["account"], - as_of_date=values["as_of_date"], - institution_balance=values["institution_balance"], - actor=LOCAL_ACTOR, + confirmed_inputs = signing.loads( + request.POST.get("preview_token", ""), + salt="reconciliation-preview", ) - except DomainError as exc: - form.add_error(None, _domain_message(exc)) + except signing.BadSignature: + confirmed_inputs = None + if confirmed_inputs != preview_inputs: + form.add_error(None, "Preview these values before confirming the checkpoint.") else: - messages.success(request, "Reconciliation checkpoint recorded.") - return redirect("reconciliation-history", account_id=saved.account_id) + 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, + "preview_token": preview_token, "latest_statuses": _latest_reconciliation_statuses(), }) diff --git a/finance/reconciliation.py b/finance/reconciliation.py index 4969349..16948b1 100644 --- a/finance/reconciliation.py +++ b/finance/reconciliation.py @@ -1,7 +1,7 @@ """Read models and shared calculations for reconciliation checkpoints.""" from dataclasses import dataclass -from datetime import date +from datetime import date, datetime from decimal import Decimal from uuid import UUID @@ -22,6 +22,7 @@ class CheckpointStatus: account_name: str currency: str as_of_date: date + created_at: datetime institution_balance: Decimal snapshot_ledger_balance: Decimal snapshot_difference: Decimal @@ -62,6 +63,7 @@ def checkpoint_status(checkpoint: ReconciliationCheckpoint) -> CheckpointStatus: account_name=account.name, currency=account.currency, as_of_date=checkpoint.as_of_date, + created_at=checkpoint.created_at, institution_balance=checkpoint.institution_balance, snapshot_ledger_balance=checkpoint.ledger_balance_at_creation, snapshot_difference=checkpoint.difference_at_creation, diff --git a/tests/test_mvp_acceptance.py b/tests/test_mvp_acceptance.py index a5bae9f..10f99f0 100644 --- a/tests/test_mvp_acceptance.py +++ b/tests/test_mvp_acceptance.py @@ -61,7 +61,7 @@ def test_owner_reconciliation_workflow_reports_backdated_activity(): confirmed = browser.post( reverse("reconciliation-index"), - {"action": "confirm", **checkpoint}, + {"action": "confirm", "preview_token": preview.context["preview_token"], **checkpoint}, ) assert confirmed.status_code == 302 assert confirmed.url == reverse("reconciliation-history", args=[account.pk]) diff --git a/tests/test_reconciliation_views.py b/tests/test_reconciliation_views.py index f32fdd4..ba8bf60 100644 --- a/tests/test_reconciliation_views.py +++ b/tests/test_reconciliation_views.py @@ -1,11 +1,13 @@ """Synthetic end-to-end checks for the owner reconciliation pages.""" -from datetime import date +from datetime import date, datetime from decimal import Decimal +import re import pytest from django.test import Client from django.urls import reverse +from django.utils import timezone from finance.models import AuditEvent, ReconciliationCheckpoint from finance.services import ( @@ -50,6 +52,90 @@ def checkpoint(target, institution_balance): ) +def preview_token(response): + match = re.search(r'name="preview_token" value="([^"]+)"', response.content.decode()) + return match.group(1) if match else "" + + +@pytest.mark.parametrize("field", ["account", "as_of_date", "institution_balance"]) +def test_confirm_requires_new_preview_after_input_changes(client, field): + bank = account() + other = account("Synthetic other reconciliation bank") + values = { + "account": bank.pk, + "as_of_date": "2026-09-01", + "institution_balance": "0.00", + } + url = reverse("reconciliation-index") + preview = client.post(url, {"action": "preview", **values}) + changed = {**values, field: { + "account": other.pk, + "as_of_date": "2026-09-02", + "institution_balance": "12.50", + }[field]} + + response = client.post(url, { + "action": "confirm", "preview_token": preview_token(preview), **changed, + }) + + assert ReconciliationCheckpoint.objects.count() == 0 + assert response.status_code == 200 + assert response.context["form"].non_field_errors() + assert 'name="action" value="confirm"' not in response.content.decode() + + refreshed = client.post(url, {"action": "preview", **changed}) + confirmed = client.post(url, { + "action": "confirm", "preview_token": preview_token(refreshed), **changed, + }) + assert confirmed.status_code == 302 + saved = ReconciliationCheckpoint.objects.get() + assert str(saved.account_id) == str(changed["account"]) + assert saved.as_of_date.isoformat() == changed["as_of_date"] + assert saved.institution_balance == Decimal(changed["institution_balance"]) + + +@pytest.mark.parametrize("token_kind", ["missing", "tampered"]) +def test_confirm_rejects_missing_or_tampered_preview_token(client, token_kind): + bank = account() + values = { + "account": bank.pk, + "as_of_date": "2026-09-01", + "institution_balance": "0.00", + } + url = reverse("reconciliation-index") + preview = client.post(url, {"action": "preview", **values}) + token = "" if token_kind == "missing" else preview_token(preview) + "tampered" + + response = client.post(url, { + "action": "confirm", "preview_token": token, **values, + }) + + assert ReconciliationCheckpoint.objects.count() == 0 + assert response.status_code == 200 + assert response.context["form"].non_field_errors() + + +def test_history_exposes_creation_timestamps_for_same_date_corrections(client): + bank = account() + first = checkpoint(bank, "0.00") + correction = checkpoint(bank, "1.00") + first_time = timezone.make_aware(datetime(2026, 9, 2, 10, 11, 12)) + correction_time = timezone.make_aware(datetime(2026, 9, 2, 10, 11, 13)) + ReconciliationCheckpoint.objects.filter(pk=first.pk).update(created_at=first_time) + ReconciliationCheckpoint.objects.filter(pk=correction.pk).update(created_at=correction_time) + + response = client.get(reverse("reconciliation-history", args=[bank.pk])) + + body = response.content.decode() + assert "Created at" in body + assert "2026-09-02 10:11:12 UTC" in body + assert "2026-09-02 10:11:13 UTC" in body + assert body.index("10:11:13 UTC") < body.index("10:11:12 UTC") + assert [row.created_at for row in response.context["history"]] == [ + correction_time, first_time, + ] + + def test_reconciliation_is_linked_and_landing_form_lists_only_active_accounts(client): active = account("Synthetic active bank") archived = account("Synthetic archived bank") @@ -121,6 +207,7 @@ def test_confirm_revalidates_and_service_snapshots_fresh_ledger_then_redirects(c "institution_balance": "-40.00", "ledger_balance": "999999.00", "difference": "999999.00", + "preview_token": preview_token(preview), }, )