Browse Source

feat: add reconciliation workflow

master
Codex 2 months ago
parent
commit
df8fd97f09
  1. 6
      config/urls.py
  2. 1
      core/context.py
  3. 10
      core/forms.py
  4. 4
      core/templates/core/dashboard.html
  5. 9
      core/templates/core/reconciliations/history.html
  6. 29
      core/templates/core/reconciliations/index.html
  7. 92
      core/views.py
  8. 233
      tests/test_reconciliation_views.py
  9. 37
      tests/test_report_views.py

6
config/urls.py

@ -7,6 +7,12 @@ urlpatterns = [
path("", views.home, name="home"),
path("health/", views.health, name="health"),
path("reports/monthly/", views.monthly_report, name="monthly-report"),
path("reconciliations/", views.reconciliation_index, name="reconciliation-index"),
path(
"reconciliations/<uuid:account_id>/",
views.reconciliation_history,
name="reconciliation-history",
),
path("exports/", views.export_index, name="export-index"),
path("exports/download/", views.export_download, name="export-download"),
path("transactions/", views.transaction_list, name="transaction-list"),

1
core/context.py

@ -5,6 +5,7 @@ def navigation(_request):
("transaction-list", "Transactions"),
("category-list", "Categories"),
("payee-list", "Payees"),
("reconciliation-index", "Reconciliation"),
("monthly-report", "Reports"),
("export-index", "Exports"),
)}

10
core/forms.py

@ -91,6 +91,16 @@ class PayeeForm(forms.Form):
name = forms.CharField(max_length=255)
class ReconciliationForm(forms.Form):
account = forms.TypedChoiceField(coerce=UUID)
as_of_date = forms.DateField(widget=forms.DateInput(attrs={"type": "date"}))
institution_balance = forms.DecimalField(max_digits=18, decimal_places=2)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["account"].choices = _reference_choices(Account)
class ReportSelectionForm(forms.Form):
month = forms.CharField(
error_messages={"required": "invalid_month"},

4
core/templates/core/dashboard.html

@ -25,5 +25,9 @@
{% if balances %}<div class="table-wrap"><table><thead><tr><th>Account</th><th class="number">Balance</th></tr></thead><tbody>
{% for balance in balances %}<tr><td>{{ balance.account_name }}</td><td class="number">{% if balance.balance < 0 %}<span>Balance (negative): </span>{% endif %}{{ balance.balance|floatformat:"2g" }} {{ summary.currency }}</td></tr>{% endfor %}
</tbody></table></div>{% else %}<p>No accounts use {{ summary.currency }}.</p>{% endif %}
<h2>Reconciliation status</h2>
{% if reconciliation_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 reconciliation_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 BRL accounts.</p>{% endif %}
{% endif %}
{% endblock %}

9
core/templates/core/reconciliations/history.html

@ -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 %}

29
core/templates/core/reconciliations/index.html

@ -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 %}

92
core/views.py

@ -1,3 +1,7 @@
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from django.contrib import messages
from django.core.paginator import Paginator
from django.db import DatabaseError, connection
@ -12,15 +16,21 @@ from finance.queries import (
QueryInputError, TransactionFilters, filtered_transactions,
listed_accounts, listed_categories, listed_payees,
)
from finance.reconciliation import (
checkpoint_history,
latest_checkpoint_statuses,
ledger_balance_through,
)
from finance.reports import account_balances, available_currencies, monthly_summary, spending_by_category
from finance.services import (
Actor, DomainError, annotate_transaction, archive, create_account,
create_category, create_payee, record_transaction,
create_category, create_payee, create_reconciliation_checkpoint,
record_transaction,
)
from .forms import (
AccountForm, AnnotationForm, CategoryForm, ManualTransactionForm, PayeeForm,
ReportSelectionForm,
ReconciliationForm, ReportSelectionForm,
)
@ -38,6 +48,18 @@ DOMAIN_MESSAGES = {
UNKNOWN_DOMAIN_MESSAGE = "Unable to save this change."
@dataclass(frozen=True)
class ReconciliationPreview:
account: Account
as_of_date: date
institution_balance: Decimal
ledger_balance: Decimal
@property
def difference(self) -> Decimal:
return self.institution_balance - self.ledger_balance
def health(request):
try:
with connection.cursor() as cursor:
@ -56,6 +78,56 @@ def monthly_report(request):
return _monthly_report(request, "core/reports/monthly.html")
def reconciliation_index(request):
form = ReconciliationForm(request.POST or None)
preview = None
if request.method == "POST":
action = request.POST.get("action")
if action == "preview" and form.is_valid():
values = form.cleaned_data
selected_account = get_object_or_404(
Account,
pk=values["account"],
archived=False,
)
preview = ReconciliationPreview(
account=selected_account,
as_of_date=values["as_of_date"],
institution_balance=values["institution_balance"],
ledger_balance=ledger_balance_through(
selected_account.pk,
values["as_of_date"],
),
)
elif action == "confirm" and form.is_valid():
values = form.cleaned_data
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,
"latest_statuses": _latest_reconciliation_statuses(),
})
def reconciliation_history(request, account_id):
selected_account = get_object_or_404(Account, pk=account_id)
return render(request, "core/reconciliations/history.html", {
"account": selected_account,
"history": checkpoint_history(selected_account.pk),
})
def export_index(request):
return render(request, "core/exports.html")
@ -236,6 +308,8 @@ def _monthly_report(request, template_name):
}
form = ReportSelectionForm(parameters, currencies=currencies)
context = {"selection_form": form}
if template_name == "core/dashboard.html":
context["reconciliation_statuses"] = latest_checkpoint_statuses("BRL")
status = 200
if form.is_valid():
month = form.cleaned_data["month"]
@ -248,3 +322,17 @@ def _monthly_report(request, template_name):
else:
status = 400
return render(request, template_name, context, status=status)
def _latest_reconciliation_statuses():
currencies = (
Account.objects.filter(archived=False)
.order_by("currency")
.values_list("currency", flat=True)
.distinct()
)
return [
status
for currency in currencies
for status in latest_checkpoint_statuses(currency)
]

233
tests/test_reconciliation_views.py

@ -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,
]

37
tests/test_report_views.py

@ -6,9 +6,11 @@ from django.urls import reverse
from django.utils import timezone
from finance.models import Account, Category, Transaction
from finance.services import Actor, create_reconciliation_checkpoint, record_transaction
pytestmark = pytest.mark.django_db
SETUP_ACTOR = Actor(kind="AGENT", identifier="synthetic-report-view-test")
def make_account(name="Synthetic Bank", *, currency="BRL"):
@ -121,3 +123,38 @@ def test_negative_spending_and_balance_keep_their_sign_and_are_labeled(client):
assert "-25.00" in body
assert "Balance (negative)" in body
assert "-75.00" in body
@pytest.mark.django_db(transaction=True)
def test_dashboard_displays_current_reconciliation_status_after_backdated_activity(client):
bank = make_account("Synthetic reconciled bank")
record_transaction(
account_id=bank.pk,
amount=Decimal("-40.00"),
posting_date=date(2026, 1, 15),
kind="EXPENSE",
actor=SETUP_ACTOR,
)
create_reconciliation_checkpoint(
account_id=bank.pk,
as_of_date=date(2026, 1, 31),
institution_balance=Decimal("-40.00"),
actor=SETUP_ACTOR,
)
record_transaction(
account_id=bank.pk,
amount=Decimal("-2.50"),
posting_date=date(2026, 1, 20),
kind="EXPENSE",
actor=SETUP_ACTOR,
)
response = client.get(reverse("home"), {"month": "2026-01", "currency": "BRL"})
assert response.status_code == 200
body = response.content.decode()
assert "Reconciliation status" in body
assert "Synthetic reconciled bank" in body
assert "Changed since reconciliation" in body
assert "2.50 BRL" in body
assert reverse("reconciliation-history", args=[bank.pk]) in body

Loading…
Cancel
Save