9 changed files with 419 additions and 2 deletions
@ -0,0 +1,9 @@
|
||||
{% extends "core/base.html" %} |
||||
{% block title %}{{ account.name }} reconciliation · Gizmoduck{% endblock %} |
||||
{% 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 %} |
||||
</tbody></table></div>{% else %}<p>No reconciliation checkpoints for this account.</p>{% endif %} |
||||
{% endblock %} |
||||
@ -0,0 +1,29 @@
|
||||
{% extends "core/base.html" %} |
||||
{% block title %}Reconciliation · Gizmoduck{% endblock %} |
||||
{% block content %} |
||||
<h1>Reconciliation</h1> |
||||
<p>Compare an institution balance with the ledger through an inclusive date.</p> |
||||
<form method="post"> |
||||
{% csrf_token %} |
||||
{{ form.non_field_errors }} |
||||
<p><label for="id_account">Account</label>{{ form.account }}{% for error in form.account.errors %}<span class="error">{{ error }}</span>{% endfor %}</p> |
||||
<p><label for="id_as_of_date">As-of date</label>{{ form.as_of_date }}{% for error in form.as_of_date.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> |
||||
{% if preview %} |
||||
<h2>Preview</h2> |
||||
<dl class="details"> |
||||
<dt>Account</dt><dd>{{ preview.account.name }}</dd> |
||||
<dt>As-of date</dt><dd>{{ preview.as_of_date|date:"Y-m-d" }}</dd> |
||||
<dt>Institution balance</dt><dd class="number">{{ preview.institution_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> |
||||
</dl> |
||||
<button type="submit" name="action" value="confirm">Confirm checkpoint</button> |
||||
{% endif %} |
||||
</form> |
||||
<h2>Latest reconciliation status</h2> |
||||
{% if latest_statuses %}<div class="table-wrap"><table><thead><tr><th>Account</th><th>As of</th><th>Status</th><th class="number">Live difference</th></tr></thead><tbody> |
||||
{% for row in latest_statuses %}<tr><td><a href="{% url 'reconciliation-history' row.account_id %}">{{ row.account_name }}</a></td><td>{{ row.as_of_date|date:"Y-m-d" }}</td><td>{% if row.state == "reconciled" %}Reconciled{% elif row.state == "changed_since_reconciliation" %}Changed since reconciliation{% else %}Difference{% endif %}</td><td class="number">{{ row.live_difference|floatformat:"2g" }} {{ row.currency }}</td></tr>{% endfor %} |
||||
</tbody></table></div>{% else %}<p>No reconciliation checkpoints for active accounts.</p>{% endif %} |
||||
{% endblock %} |
||||
@ -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</a>' in navigation_page.content.decode() |
||||
assert response.status_code == 200 |
||||
body = response.content.decode() |
||||
assert "<h1>Reconciliation</h1>" in body |
||||
assert f'<option value="{active.pk}">Synthetic active bank</option>' 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, |
||||
] |
||||
Loading…
Reference in new issue