diff --git a/finance/reconciliation.py b/finance/reconciliation.py new file mode 100644 index 0000000..0edd863 --- /dev/null +++ b/finance/reconciliation.py @@ -0,0 +1,88 @@ +"""Read models and shared calculations for reconciliation checkpoints.""" + +from dataclasses import dataclass +from datetime import date +from decimal import Decimal +from uuid import UUID + +from django.db.models import DecimalField, Sum, Value +from django.db.models.functions import Coalesce + +from finance.models import ReconciliationCheckpoint, Transaction + + +_MONEY_FIELD = DecimalField(max_digits=24, decimal_places=2) +_ZERO = Decimal("0.00") + + +@dataclass(frozen=True) +class CheckpointStatus: + checkpoint_id: UUID + account_id: UUID + account_name: str + currency: str + as_of_date: date + institution_balance: Decimal + snapshot_ledger_balance: Decimal + snapshot_difference: Decimal + live_ledger_balance: Decimal + live_difference: Decimal + state: str + + +def ledger_balance_through(account_id: UUID, as_of_date: date) -> Decimal: + from finance.services.base import money + + 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) + + +def checkpoint_status(checkpoint: ReconciliationCheckpoint) -> CheckpointStatus: + account = checkpoint.account + live_ledger_balance = ledger_balance_through(checkpoint.account_id, checkpoint.as_of_date) + live_difference = checkpoint.institution_balance - live_ledger_balance + if live_difference == _ZERO: + state = "reconciled" + elif checkpoint.difference_at_creation == _ZERO: + state = "changed_since_reconciliation" + else: + state = "difference" + return CheckpointStatus( + checkpoint_id=checkpoint.pk, + account_id=account.pk, + account_name=account.name, + currency=account.currency, + as_of_date=checkpoint.as_of_date, + institution_balance=checkpoint.institution_balance, + snapshot_ledger_balance=checkpoint.ledger_balance_at_creation, + snapshot_difference=checkpoint.difference_at_creation, + live_ledger_balance=live_ledger_balance, + live_difference=live_difference, + state=state, + ) + + +def checkpoint_history(account_id: UUID) -> list[CheckpointStatus]: + checkpoints = ReconciliationCheckpoint.objects.filter( + account_id=account_id, + ).select_related("account").order_by("-as_of_date", "-created_at", "-id") + return [checkpoint_status(checkpoint) for checkpoint in checkpoints] + + +def latest_checkpoint_statuses(currency: str) -> list[CheckpointStatus]: + checkpoints = ReconciliationCheckpoint.objects.filter( + account__archived=False, + account__currency=currency, + ).select_related("account").order_by( + "account_id", "-as_of_date", "-created_at", "-id" + ).distinct("account_id") + return [checkpoint_status(checkpoint) for checkpoint in checkpoints] diff --git a/finance/services/reconciliation.py b/finance/services/reconciliation.py index 616215a..678ac73 100644 --- a/finance/services/reconciliation.py +++ b/finance/services/reconciliation.py @@ -1,36 +1,14 @@ -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 finance.models import Account, ReconciliationCheckpoint +from finance.reconciliation import ledger_balance_through 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) + ledger_balance = ledger_balance_through(account.pk, as_of_date) checkpoint = save(ReconciliationCheckpoint( account=account, as_of_date=as_of_date, diff --git a/tests/finance/test_reconciliation.py b/tests/finance/test_reconciliation.py index 653d244..8e4dd41 100644 --- a/tests/finance/test_reconciliation.py +++ b/tests/finance/test_reconciliation.py @@ -6,6 +6,11 @@ from decimal import Decimal import pytest from finance.models import AuditEvent, ReconciliationCheckpoint +from finance.reconciliation import ( + checkpoint_history, + latest_checkpoint_statuses, + ledger_balance_through, +) from finance.services import ( Actor, DomainError, @@ -25,11 +30,11 @@ def account(name="Synthetic reconciliation account"): return create_account(name=name, kind="BANK", actor=ACTOR) -def post(account, amount, posting_date): +def post(account, amount, posting_date, kind="EXPENSE"): return record_transaction( account_id=account.pk, amount=Decimal(amount), - kind="EXPENSE", + kind=kind, posting_date=posting_date, actor=ACTOR, ) @@ -67,6 +72,18 @@ def test_checkpoint_snapshots_inclusive_ledger_balance_and_audit_metadata(): ] +def test_ledger_balance_through_returns_zero_and_includes_opening_entries(): + bank = account() + + assert ledger_balance_through(bank.pk, AS_OF_DATE) == Decimal("0.00") + + post(bank, "100.00", date(2026, 8, 31), kind="OPENING") + post(bank, "-40.00", AS_OF_DATE) + post(bank, "-7.50", date(2026, 9, 2)) + + assert ledger_balance_through(bank.pk, AS_OF_DATE) == Decimal("60.00") + + def test_checkpoint_correction_retains_same_date_history(): bank = account() post(bank, "-40.00", AS_OF_DATE) @@ -113,3 +130,127 @@ def test_archived_account_rejects_checkpoint_without_creating_one(): assert failed.operation == "reconciliation_checkpoint.created" assert failed.outcome == "FAILURE" assert failed.error_code == "archived_reference" + + +@pytest.mark.parametrize( + ( + "institution_balance", + "later_amount", + "expected_live_ledger_balance", + "expected_state", + "expected_live_difference", + ), + [ + ("-40.00", None, Decimal("-40.00"), "reconciled", Decimal("0.00")), + ("-39.00", None, Decimal("-40.00"), "difference", Decimal("1.00")), + ("-40.00", "-2.00", Decimal("-42.00"), "changed_since_reconciliation", Decimal("2.00")), + ], +) +def test_checkpoint_history_reports_live_reconciliation_state( + institution_balance, + later_amount, + expected_live_ledger_balance, + expected_state, + expected_live_difference, +): + bank = account(name=f"Synthetic status {expected_state}") + post(bank, "-40.00", AS_OF_DATE) + checkpoint = create_reconciliation_checkpoint( + account_id=bank.pk, + as_of_date=AS_OF_DATE, + institution_balance=Decimal(institution_balance), + actor=ACTOR, + ) + if later_amount is not None: + post(bank, later_amount, AS_OF_DATE) + + [status] = checkpoint_history(bank.pk) + + assert status.checkpoint_id == checkpoint.pk + assert status.snapshot_ledger_balance == Decimal("-40.00") + assert status.live_ledger_balance == expected_live_ledger_balance + assert status.live_difference == expected_live_difference + assert status.state == expected_state + + +def test_checkpoint_history_retains_same_date_corrections_newest_first(): + 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, + ) + + history = checkpoint_history(bank.pk) + + assert [status.checkpoint_id for status in history] == [correction.pk, first.pk] + assert history[0].snapshot_difference == Decimal("0.00") + assert history[1].snapshot_difference == Decimal("1.00") + + +def test_latest_checkpoint_statuses_select_by_as_of_date_then_creation_for_active_currency(): + by_date = account(name="Synthetic latest by date") + post(by_date, "-40.00", AS_OF_DATE) + newer_as_of_date = create_reconciliation_checkpoint( + account_id=by_date.pk, + as_of_date=date(2026, 9, 2), + institution_balance=Decimal("-40.00"), + actor=ACTOR, + ) + create_reconciliation_checkpoint( + account_id=by_date.pk, + as_of_date=AS_OF_DATE, + institution_balance=Decimal("-39.00"), + actor=ACTOR, + ) + + by_creation = account(name="Synthetic latest by creation") + post(by_creation, "-20.00", AS_OF_DATE) + create_reconciliation_checkpoint( + account_id=by_creation.pk, + as_of_date=AS_OF_DATE, + institution_balance=Decimal("-19.00"), + actor=ACTOR, + ) + correction = create_reconciliation_checkpoint( + account_id=by_creation.pk, + as_of_date=AS_OF_DATE, + institution_balance=Decimal("-20.00"), + actor=ACTOR, + ) + + archived = account(name="Synthetic archived status") + post(archived, "-5.00", AS_OF_DATE) + create_reconciliation_checkpoint( + account_id=archived.pk, + as_of_date=AS_OF_DATE, + institution_balance=Decimal("-5.00"), + actor=ACTOR, + ) + archive(archived, actor=ACTOR) + + usd = create_account( + name="Synthetic USD status", kind="BANK", currency="USD", actor=ACTOR + ) + post(usd, "-7.00", AS_OF_DATE) + create_reconciliation_checkpoint( + account_id=usd.pk, + as_of_date=AS_OF_DATE, + institution_balance=Decimal("-7.00"), + actor=ACTOR, + ) + + statuses = latest_checkpoint_statuses("BRL") + + assert {status.account_id: status.checkpoint_id for status in statuses} == { + by_date.pk: newer_as_of_date.pk, + by_creation.pk: correction.pk, + }