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