Browse Source

fix: bind reconciliation confirmation to preview

master
Codex 2 months ago
parent
commit
19b2bf1c90
  1. 4
      core/templates/core/reconciliations/history.html
  2. 2
      core/templates/core/reconciliations/index.html
  3. 46
      core/views.py
  4. 4
      finance/reconciliation.py
  5. 2
      tests/test_mvp_acceptance.py
  6. 89
      tests/test_reconciliation_views.py

4
core/templates/core/reconciliations/history.html

@ -3,7 +3,7 @@
{% block content %}
<div class="heading"><h1>{{ account.name }} reconciliation history</h1><a class="button secondary" href="{% url 'reconciliation-index' %}">New checkpoint</a></div>
{% if account.archived %}<p>Archived account</p>{% endif %}
{% if history %}<div class="table-wrap"><table><thead><tr><th>As of</th><th class="number">Institution balance</th><th class="number">Snapshot ledger balance</th><th class="number">Snapshot difference</th><th class="number">Live ledger balance</th><th class="number">Live difference</th><th>Status</th></tr></thead><tbody>
{% for row in history %}<tr><td>{{ row.as_of_date|date:"Y-m-d" }}</td><td class="number">{{ row.institution_balance|floatformat:"2g" }} {{ row.currency }}</td><td class="number">{{ row.snapshot_ledger_balance|floatformat:"2g" }} {{ row.currency }}</td><td class="number">{{ row.snapshot_difference|floatformat:"2g" }} {{ row.currency }}</td><td class="number">{{ row.live_ledger_balance|floatformat:"2g" }} {{ row.currency }}</td><td class="number">{{ row.live_difference|floatformat:"2g" }} {{ row.currency }}</td><td>{% if row.state == "reconciled" %}Reconciled{% elif row.state == "changed_since_reconciliation" %}Changed since reconciliation{% else %}Difference{% endif %}</td></tr>{% endfor %}
{% if history %}<div class="table-wrap"><table><thead><tr><th>As of</th><th>Created at</th><th class="number">Institution balance</th><th class="number">Snapshot ledger balance</th><th class="number">Snapshot difference</th><th class="number">Live ledger balance</th><th class="number">Live difference</th><th>Status</th></tr></thead><tbody>
{% for row in history %}<tr><td>{{ row.as_of_date|date:"Y-m-d" }}</td><td>{{ row.created_at|date:"Y-m-d H:i:s T" }}</td><td class="number">{{ row.institution_balance|floatformat:"2g" }} {{ row.currency }}</td><td class="number">{{ row.snapshot_ledger_balance|floatformat:"2g" }} {{ row.currency }}</td><td class="number">{{ row.snapshot_difference|floatformat:"2g" }} {{ row.currency }}</td><td class="number">{{ row.live_ledger_balance|floatformat:"2g" }} {{ row.currency }}</td><td class="number">{{ row.live_difference|floatformat:"2g" }} {{ row.currency }}</td><td>{% if row.state == "reconciled" %}Reconciled{% elif row.state == "changed_since_reconciliation" %}Changed since reconciliation{% else %}Difference{% endif %}</td></tr>{% endfor %}
</tbody></table></div>{% else %}<p>No reconciliation checkpoints for this account.</p>{% endif %}
{% endblock %}

2
core/templates/core/reconciliations/index.html

@ -11,6 +11,7 @@
<p><label for="id_institution_balance">Institution balance</label>{{ form.institution_balance }}{% for error in form.institution_balance.errors %}<span class="error">{{ error }}</span>{% endfor %}</p>
<button type="submit" name="action" value="preview">Preview</button>
{% if preview %}
<input type="hidden" name="preview_token" value="{{ preview_token }}">
<h2>Preview</h2>
<dl class="details">
<dt>Account</dt><dd>{{ preview.account.name }}</dd>
@ -19,6 +20,7 @@
<dt>Ledger balance</dt><dd class="number">{{ preview.ledger_balance|floatformat:"2g" }} {{ preview.account.currency }}</dd>
<dt>Difference</dt><dd class="number">{{ preview.difference|floatformat:"2g" }} {{ preview.account.currency }}</dd>
</dl>
<p>If you change the account, date, or institution balance, choose Preview again before confirming.</p>
<button type="submit" name="action" value="confirm">Confirm checkpoint</button>
{% endif %}
</form>

46
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(),
})

4
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,

2
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])

89
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),
},
)

Loading…
Cancel
Save