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