You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

311 lines
9.7 KiB

"""Synthetic checks for immutable, audited reconciliation checkpoints."""
from datetime import date, datetime
from decimal import Decimal
from uuid import UUID
import pytest
from django.utils import timezone
from finance.models import Account, AuditEvent, ReconciliationCheckpoint
from finance.reconciliation import (
checkpoint_history,
latest_checkpoint_statuses,
ledger_balance_through,
)
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, kind="EXPENSE"):
return record_transaction(
account_id=account.pk,
amount=Decimal(amount),
kind=kind,
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_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)
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"
@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,
}
def test_latest_checkpoint_statuses_order_selected_accounts_by_checkpoint_recency():
oldest_account = Account.objects.create(
id=UUID("00000000-0000-0000-0000-000000000001"),
name="Synthetic oldest account",
kind="BANK",
)
older_same_date_account = Account.objects.create(
id=UUID("00000000-0000-0000-0000-000000000002"),
name="Synthetic older same-date account",
kind="BANK",
)
newest_account = Account.objects.create(
id=UUID("00000000-0000-0000-0000-000000000003"),
name="Synthetic newest account",
kind="BANK",
)
oldest = create_reconciliation_checkpoint(
account_id=oldest_account.pk,
as_of_date=AS_OF_DATE,
institution_balance=Decimal("0.00"),
actor=ACTOR,
)
older_same_date = create_reconciliation_checkpoint(
account_id=older_same_date_account.pk,
as_of_date=date(2026, 9, 2),
institution_balance=Decimal("0.00"),
actor=ACTOR,
)
newest = create_reconciliation_checkpoint(
account_id=newest_account.pk,
as_of_date=date(2026, 9, 2),
institution_balance=Decimal("0.00"),
actor=ACTOR,
)
ReconciliationCheckpoint.objects.filter(pk=oldest.pk).update(
created_at=timezone.make_aware(datetime(2026, 9, 1, 9, 0))
)
ReconciliationCheckpoint.objects.filter(pk=older_same_date.pk).update(
created_at=timezone.make_aware(datetime(2026, 9, 1, 10, 0))
)
ReconciliationCheckpoint.objects.filter(pk=newest.pk).update(
created_at=timezone.make_aware(datetime(2026, 9, 1, 11, 0))
)
statuses = latest_checkpoint_statuses("BRL")
assert [status.checkpoint_id for status in statuses] == [
newest.pk,
older_same_date.pk,
oldest.pk,
]