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 %} {% block content %}
<div class="heading"><h1>{{ account.name }} reconciliation history</h1><a class="button secondary" href="{% url 'reconciliation-index' %}">New checkpoint</a></div> <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 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> {% 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 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 %} {% 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 %} </tbody></table></div>{% else %}<p>No reconciliation checkpoints for this account.</p>{% endif %}
{% endblock %} {% 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> <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> <button type="submit" name="action" value="preview">Preview</button>
{% if preview %} {% if preview %}
<input type="hidden" name="preview_token" value="{{ preview_token }}">
<h2>Preview</h2> <h2>Preview</h2>
<dl class="details"> <dl class="details">
<dt>Account</dt><dd>{{ preview.account.name }}</dd> <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>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> <dt>Difference</dt><dd class="number">{{ preview.difference|floatformat:"2g" }} {{ preview.account.currency }}</dd>
</dl> </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> <button type="submit" name="action" value="confirm">Confirm checkpoint</button>
{% endif %} {% endif %}
</form> </form>

46
core/views.py

@ -3,6 +3,7 @@ from datetime import date
from decimal import Decimal from decimal import Decimal
from django.contrib import messages from django.contrib import messages
from django.core import signing
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db import DatabaseError, connection from django.db import DatabaseError, connection
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
@ -81,10 +82,16 @@ def monthly_report(request):
def reconciliation_index(request): def reconciliation_index(request):
form = ReconciliationForm(request.POST or None) form = ReconciliationForm(request.POST or None)
preview = None preview = None
if request.method == "POST": preview_token = None
if request.method == "POST" and form.is_valid():
action = request.POST.get("action") 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( selected_account = get_object_or_404(
Account, Account,
pk=values["account"], pk=values["account"],
@ -99,23 +106,34 @@ def reconciliation_index(request):
values["as_of_date"], values["as_of_date"],
), ),
) )
elif action == "confirm" and form.is_valid(): preview_token = signing.dumps(preview_inputs, salt="reconciliation-preview")
values = form.cleaned_data elif action == "confirm":
try: try:
saved = create_reconciliation_checkpoint( confirmed_inputs = signing.loads(
account_id=values["account"], request.POST.get("preview_token", ""),
as_of_date=values["as_of_date"], salt="reconciliation-preview",
institution_balance=values["institution_balance"],
actor=LOCAL_ACTOR,
) )
except DomainError as exc: except signing.BadSignature:
form.add_error(None, _domain_message(exc)) confirmed_inputs = None
if confirmed_inputs != preview_inputs:
form.add_error(None, "Preview these values before confirming the checkpoint.")
else: else:
messages.success(request, "Reconciliation checkpoint recorded.") try:
return redirect("reconciliation-history", account_id=saved.account_id) 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", { return render(request, "core/reconciliations/index.html", {
"form": form, "form": form,
"preview": preview, "preview": preview,
"preview_token": preview_token,
"latest_statuses": _latest_reconciliation_statuses(), "latest_statuses": _latest_reconciliation_statuses(),
}) })

4
finance/reconciliation.py

@ -1,7 +1,7 @@
"""Read models and shared calculations for reconciliation checkpoints.""" """Read models and shared calculations for reconciliation checkpoints."""
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from uuid import UUID from uuid import UUID
@ -22,6 +22,7 @@ class CheckpointStatus:
account_name: str account_name: str
currency: str currency: str
as_of_date: date as_of_date: date
created_at: datetime
institution_balance: Decimal institution_balance: Decimal
snapshot_ledger_balance: Decimal snapshot_ledger_balance: Decimal
snapshot_difference: Decimal snapshot_difference: Decimal
@ -62,6 +63,7 @@ def checkpoint_status(checkpoint: ReconciliationCheckpoint) -> CheckpointStatus:
account_name=account.name, account_name=account.name,
currency=account.currency, currency=account.currency,
as_of_date=checkpoint.as_of_date, as_of_date=checkpoint.as_of_date,
created_at=checkpoint.created_at,
institution_balance=checkpoint.institution_balance, institution_balance=checkpoint.institution_balance,
snapshot_ledger_balance=checkpoint.ledger_balance_at_creation, snapshot_ledger_balance=checkpoint.ledger_balance_at_creation,
snapshot_difference=checkpoint.difference_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( confirmed = browser.post(
reverse("reconciliation-index"), reverse("reconciliation-index"),
{"action": "confirm", **checkpoint}, {"action": "confirm", "preview_token": preview.context["preview_token"], **checkpoint},
) )
assert confirmed.status_code == 302 assert confirmed.status_code == 302
assert confirmed.url == reverse("reconciliation-history", args=[account.pk]) 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.""" """Synthetic end-to-end checks for the owner reconciliation pages."""
from datetime import date from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
import re
import pytest import pytest
from django.test import Client from django.test import Client
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from finance.models import AuditEvent, ReconciliationCheckpoint from finance.models import AuditEvent, ReconciliationCheckpoint
from finance.services import ( 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): def test_reconciliation_is_linked_and_landing_form_lists_only_active_accounts(client):
active = account("Synthetic active bank") active = account("Synthetic active bank")
archived = account("Synthetic archived 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", "institution_balance": "-40.00",
"ledger_balance": "999999.00", "ledger_balance": "999999.00",
"difference": "999999.00", "difference": "999999.00",
"preview_token": preview_token(preview),
}, },
) )

Loading…
Cancel
Save