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