From 83c2bfea2f84f192a790cc60edfd17c4d13e19ef Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 8 Sep 2026 21:16:28 -0300 Subject: [PATCH] feat: record reconciliation checkpoints --- .../0003_reconciliationcheckpoint.py | 31 +++++ finance/models/__init__.py | 3 +- finance/models/reconciliation.py | 12 ++ finance/services/__init__.py | 3 +- finance/services/reconciliation.py | 47 +++++++ tests/finance/test_reconciliation.py | 115 ++++++++++++++++++ 6 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 finance/migrations/0003_reconciliationcheckpoint.py create mode 100644 finance/models/reconciliation.py create mode 100644 finance/services/reconciliation.py create mode 100644 tests/finance/test_reconciliation.py diff --git a/finance/migrations/0003_reconciliationcheckpoint.py b/finance/migrations/0003_reconciliationcheckpoint.py new file mode 100644 index 0000000..2b8d540 --- /dev/null +++ b/finance/migrations/0003_reconciliationcheckpoint.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.17 on 2026-09-09 00:13 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0002_transaction_report_indexes'), + ] + + operations = [ + migrations.CreateModel( + name='ReconciliationCheckpoint', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('as_of_date', models.DateField()), + ('institution_balance', models.DecimalField(decimal_places=2, max_digits=18)), + ('ledger_balance_at_creation', models.DecimalField(decimal_places=2, max_digits=18)), + ('difference_at_creation', models.DecimalField(decimal_places=2, max_digits=18)), + ('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='finance.account')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/finance/models/__init__.py b/finance/models/__init__.py index 1677961..bf9d0f5 100644 --- a/finance/models/__init__.py +++ b/finance/models/__init__.py @@ -1,8 +1,9 @@ from .ledger import CardStatement, InstallmentPlan, Transaction, Transfer from .provenance import AuditEvent, SourceRecord +from .reconciliation import ReconciliationCheckpoint from .reference import Account, Category, Payee __all__ = [ "Account", "AuditEvent", "CardStatement", "Category", "InstallmentPlan", - "Payee", "SourceRecord", "Transaction", "Transfer", + "Payee", "ReconciliationCheckpoint", "SourceRecord", "Transaction", "Transfer", ] diff --git a/finance/models/reconciliation.py b/finance/models/reconciliation.py new file mode 100644 index 0000000..07ceae0 --- /dev/null +++ b/finance/models/reconciliation.py @@ -0,0 +1,12 @@ +from django.db import models + +from .base import TimestampedModel +from .reference import Account + + +class ReconciliationCheckpoint(TimestampedModel): + account = models.ForeignKey(Account, on_delete=models.PROTECT) + as_of_date = models.DateField() + institution_balance = models.DecimalField(max_digits=18, decimal_places=2) + ledger_balance_at_creation = models.DecimalField(max_digits=18, decimal_places=2) + difference_at_creation = models.DecimalField(max_digits=18, decimal_places=2) diff --git a/finance/services/__init__.py b/finance/services/__init__.py index 95e290b..bf03072 100644 --- a/finance/services/__init__.py +++ b/finance/services/__init__.py @@ -2,6 +2,7 @@ from .base import Actor, DomainError from .reference import create_account, create_payee, create_category, archive, create_statement, create_plan from .posting import record_transaction, annotate_transaction, attach_source +from .reconciliation import create_reconciliation_checkpoint from .transfers import create_transfer, match_transfer -__all__ = ["Actor", "DomainError", "create_account", "create_payee", "create_category", "archive", "create_statement", "create_plan", "record_transaction", "annotate_transaction", "attach_source", "create_transfer", "match_transfer"] +__all__ = ["Actor", "DomainError", "create_account", "create_payee", "create_category", "archive", "create_statement", "create_plan", "record_transaction", "annotate_transaction", "attach_source", "create_reconciliation_checkpoint", "create_transfer", "match_transfer"] diff --git a/finance/services/reconciliation.py b/finance/services/reconciliation.py new file mode 100644 index 0000000..616215a --- /dev/null +++ b/finance/services/reconciliation.py @@ -0,0 +1,47 @@ +from decimal import Decimal + +from django.db.models import DecimalField, Sum, Value +from django.db.models.functions import Coalesce + +from finance.models import Account, ReconciliationCheckpoint, Transaction + +from .base import lock, money, save, write + + +_MONEY_FIELD = DecimalField(max_digits=24, decimal_places=2) +_ZERO = Decimal("0.00") + + +def _ledger_balance_through(account_id, as_of_date): + total = Transaction.objects.filter( + account_id=account_id, + posting_date__lte=as_of_date, + ).aggregate( + total=Coalesce( + Sum("amount"), + Value(_ZERO), + output_field=_MONEY_FIELD, + ) + )["total"] + return money(total, allow_zero=True) + + +@write("reconciliation_checkpoint.created") +def create_reconciliation_checkpoint(*, account_id, as_of_date, institution_balance): + account = lock(Account, account_id, active=True) + institution_balance = money(institution_balance, allow_zero=True) + ledger_balance = _ledger_balance_through(account.pk, as_of_date) + checkpoint = save(ReconciliationCheckpoint( + account=account, + as_of_date=as_of_date, + institution_balance=institution_balance, + ledger_balance_at_creation=ledger_balance, + difference_at_creation=money(institution_balance - ledger_balance, allow_zero=True), + )) + return checkpoint, [ + "account", + "as_of_date", + "institution_balance", + "ledger_balance_at_creation", + "difference_at_creation", + ] diff --git a/tests/finance/test_reconciliation.py b/tests/finance/test_reconciliation.py new file mode 100644 index 0000000..653d244 --- /dev/null +++ b/tests/finance/test_reconciliation.py @@ -0,0 +1,115 @@ +"""Synthetic checks for immutable, audited reconciliation checkpoints.""" + +from datetime import date +from decimal import Decimal + +import pytest + +from finance.models import AuditEvent, ReconciliationCheckpoint +from finance.services import ( + Actor, + DomainError, + archive, + create_account, + create_reconciliation_checkpoint, + record_transaction, +) + + +pytestmark = pytest.mark.django_db(transaction=True) +ACTOR = Actor(kind="HUMAN", identifier="synthetic-local-owner") +AS_OF_DATE = date(2026, 9, 1) + + +def account(name="Synthetic reconciliation account"): + return create_account(name=name, kind="BANK", actor=ACTOR) + + +def post(account, amount, posting_date): + return record_transaction( + account_id=account.pk, + amount=Decimal(amount), + kind="EXPENSE", + posting_date=posting_date, + actor=ACTOR, + ) + + +def test_checkpoint_snapshots_inclusive_ledger_balance_and_audit_metadata(): + bank = account() + post(bank, "-40.00", date(2026, 8, 31)) + post(bank, "-2.50", AS_OF_DATE) + post(bank, "-7.50", date(2026, 9, 2)) + + checkpoint = create_reconciliation_checkpoint( + account_id=bank.pk, + as_of_date=AS_OF_DATE, + institution_balance=Decimal("0.00"), + actor=ACTOR, + ) + + assert checkpoint.institution_balance == Decimal("0.00") + assert checkpoint.ledger_balance_at_creation == Decimal("-42.50") + assert checkpoint.difference_at_creation == Decimal("42.50") + event = AuditEvent.objects.get( + operation="reconciliation_checkpoint.created", + target_id=checkpoint.pk, + ) + assert event.outcome == "SUCCESS" + assert event.actor_kind == "HUMAN" + assert event.actor_identifier == "synthetic-local-owner" + assert event.changed_fields == [ + "account", + "as_of_date", + "institution_balance", + "ledger_balance_at_creation", + "difference_at_creation", + ] + + +def test_checkpoint_correction_retains_same_date_history(): + bank = account() + post(bank, "-40.00", AS_OF_DATE) + + first = create_reconciliation_checkpoint( + account_id=bank.pk, + as_of_date=AS_OF_DATE, + institution_balance=Decimal("-39.00"), + actor=ACTOR, + ) + correction = create_reconciliation_checkpoint( + account_id=bank.pk, + as_of_date=AS_OF_DATE, + institution_balance=Decimal("-40.00"), + actor=ACTOR, + ) + + assert first.pk != correction.pk + assert ReconciliationCheckpoint.objects.filter( + account=bank, + as_of_date=AS_OF_DATE, + ).count() == 2 + assert correction.ledger_balance_at_creation == Decimal("-40.00") + assert correction.difference_at_creation == Decimal("0.00") + + +def test_archived_account_rejects_checkpoint_without_creating_one(): + bank = account() + archive(bank, actor=ACTOR) + checkpoint_count = ReconciliationCheckpoint.objects.count() + audit_count = AuditEvent.objects.count() + + with pytest.raises(DomainError, match="archived_reference"): + create_reconciliation_checkpoint( + account_id=bank.pk, + as_of_date=AS_OF_DATE, + institution_balance=Decimal("0.00"), + actor=ACTOR, + ) + + assert ReconciliationCheckpoint.objects.count() == checkpoint_count + failed = AuditEvent.objects.exclude(operation="archive").latest("created_at") + assert AuditEvent.objects.count() == audit_count + 1 + assert failed.operation == "reconciliation_checkpoint.created" + assert failed.outcome == "FAILURE" + assert failed.error_code == "archived_reference"