Browse Source

feat: record reconciliation checkpoints

master
Codex 2 months ago
parent
commit
83c2bfea2f
  1. 31
      finance/migrations/0003_reconciliationcheckpoint.py
  2. 3
      finance/models/__init__.py
  3. 12
      finance/models/reconciliation.py
  4. 3
      finance/services/__init__.py
  5. 47
      finance/services/reconciliation.py
  6. 115
      tests/finance/test_reconciliation.py

31
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,
},
),
]

3
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",
]

12
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)

3
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"]

47
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",
]

115
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"
Loading…
Cancel
Save