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 .ledger import CardStatement, InstallmentPlan, Transaction, Transfer
from .provenance import AuditEvent, SourceRecord from .provenance import AuditEvent, SourceRecord
from .reconciliation import ReconciliationCheckpoint
from .reference import Account, Category, Payee from .reference import Account, Category, Payee
__all__ = [ __all__ = [
"Account", "AuditEvent", "CardStatement", "Category", "InstallmentPlan", "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 .base import Actor, DomainError
from .reference import create_account, create_payee, create_category, archive, create_statement, create_plan from .reference import create_account, create_payee, create_category, archive, create_statement, create_plan
from .posting import record_transaction, annotate_transaction, attach_source from .posting import record_transaction, annotate_transaction, attach_source
from .reconciliation import create_reconciliation_checkpoint
from .transfers import create_transfer, match_transfer 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