Browse Source

feat: report reconciliation status

master
Codex 2 months ago
parent
commit
521f9c2299
  1. 88
      finance/reconciliation.py
  2. 28
      finance/services/reconciliation.py
  3. 145
      tests/finance/test_reconciliation.py

88
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]

28
finance/services/reconciliation.py

@ -1,36 +1,14 @@
from decimal import Decimal from finance.models import Account, ReconciliationCheckpoint
from finance.reconciliation import ledger_balance_through
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 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") @write("reconciliation_checkpoint.created")
def create_reconciliation_checkpoint(*, account_id, as_of_date, institution_balance): def create_reconciliation_checkpoint(*, account_id, as_of_date, institution_balance):
account = lock(Account, account_id, active=True) account = lock(Account, account_id, active=True)
institution_balance = money(institution_balance, allow_zero=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( checkpoint = save(ReconciliationCheckpoint(
account=account, account=account,
as_of_date=as_of_date, as_of_date=as_of_date,

145
tests/finance/test_reconciliation.py

@ -6,6 +6,11 @@ from decimal import Decimal
import pytest import pytest
from finance.models import AuditEvent, ReconciliationCheckpoint from finance.models import AuditEvent, ReconciliationCheckpoint
from finance.reconciliation import (
checkpoint_history,
latest_checkpoint_statuses,
ledger_balance_through,
)
from finance.services import ( from finance.services import (
Actor, Actor,
DomainError, DomainError,
@ -25,11 +30,11 @@ def account(name="Synthetic reconciliation account"):
return create_account(name=name, kind="BANK", actor=ACTOR) 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( return record_transaction(
account_id=account.pk, account_id=account.pk,
amount=Decimal(amount), amount=Decimal(amount),
kind="EXPENSE", kind=kind,
posting_date=posting_date, posting_date=posting_date,
actor=ACTOR, 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(): def test_checkpoint_correction_retains_same_date_history():
bank = account() bank = account()
post(bank, "-40.00", AS_OF_DATE) 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.operation == "reconciliation_checkpoint.created"
assert failed.outcome == "FAILURE" assert failed.outcome == "FAILURE"
assert failed.error_code == "archived_reference" 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,
}

Loading…
Cancel
Save