6 changed files with 209 additions and 2 deletions
@ -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, |
||||||
|
}, |
||||||
|
), |
||||||
|
] |
||||||
@ -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", |
||||||
] |
] |
||||||
|
|||||||
@ -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) |
||||||
@ -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", |
||||||
|
] |
||||||
@ -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…
Reference in new issue