"""B-44 ledger event layer tests: bank reconciliation, revision chain, reversal/adjustment, projection rebuild and immutability boundaries.""" from __future__ import annotations from decimal import Decimal import sqlite3 import unittest from bank_importer import ledger_events, matching, subjects from ledger_helpers import LedgerBase class BankReconcileTests(LedgerBase): def test_paired_eligible_event_creates_pending_subject_only(self) -> None: self.pair(self.company_a, self.company_b, "100.00") self.assertEqual(1, len(self.eligible())) stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin) self.assertEqual(1, stats["created"]) events = self.ledger_events() self.assertEqual(1, len(events)) revision = self.current(events[0]["id"]) self.assertEqual("pending_subject", revision["state"]) self.assertEqual(Decimal("100.00"), Decimal(revision["amount"])) self.assertEqual("CNY", revision["currency"]) # Nothing confirmed yet -> no position event. self.assertEqual([], self.position_events()) # The event is unresolved as subject_review. from bank_importer import positions unresolved = positions.unresolved_for_company(self.connection, self.company_a, "2026-12-31") self.assertEqual("100.00", unresolved["by_reason"]["subject_review"]["gross_amount"]) def test_unlocked_single_never_enters_ledger(self) -> None: self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", ) matching.reconcile_rows(self.connection, [1]) self.assertEqual([], self.eligible()) stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin) self.assertEqual(0, stats["created"]) self.assertEqual([], self.ledger_events()) def test_same_company_transfer_never_enters_ledger(self) -> None: self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000001", expense="100.00", ) self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000001", income="100.00", ) matching.reconcile_rows(self.connection, [1, 2]) eligible = self.eligible() self.assertEqual(0, len([e for e in eligible if e["payer_company_id"] == e["payee_company_id"]])) stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin) self.assertEqual(0, stats["created"]) def test_reconcile_is_idempotent(self) -> None: self.pair(self.company_a, self.company_b, "100.00") ledger_events.reconcile_bank_events(self.connection, actor=self.admin) events = self.ledger_events() self.assertEqual(1, len(events)) before_revisions = self.connection.execute( "SELECT COUNT(*) AS n FROM ledger_event_revisions" ).fetchone()["n"] stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin) self.assertEqual(1, stats["unchanged"]) after_revisions = self.connection.execute( "SELECT COUNT(*) AS n FROM ledger_event_revisions" ).fetchone()["n"] self.assertEqual(before_revisions, after_revisions) def test_reconcile_picks_up_newly_linked_rows_as_new_pending_event(self) -> None: # A single observation locked as intercompany -> pending ledger event. row_a = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="100.00", ) matching.reconcile_rows(self.connection, [row_a]) single = self.connection.execute( "SELECT * FROM current_transfer_decisions" ).fetchone() event_id = single["event_id"] decision = self.connection.execute( "SELECT * FROM transfer_match_decisions WHERE id = ?", (single["decision_id"],), ).fetchone() matching.apply_manual_decision( self.connection, event_id, "assign_participant", reason="函证确认对方", expected_revision=decision["revision"], request_key="assign-1", actor=self.admin, participant={"role": "payee", "company_id": self.company_b}, ) self.assertEqual(1, len(self.eligible())) ledger_events.reconcile_bank_events(self.connection, actor=self.admin) self.assertEqual(1, len(self.ledger_events())) # The administrator links two fresh rows with a different amount onto a # new canonical event; reconcile creates a second pending event. row_a2 = self.add_row( self.company_a, own_account="6222000000000001", cp_account="6222000000000002", expense="150.00", at="2026-02-01T10:00:00", ) row_b2 = self.add_row( self.company_b, own_account="6222000000000002", cp_account="6222000000000001", income="150.00", at="2026-02-01T11:00:00", ) current_row = self.connection.execute( "SELECT d.* FROM current_transfer_decisions c " "JOIN transfer_match_decisions d ON d.id = c.decision_id " "WHERE c.event_id = ?", (event_id,), ).fetchone() matching.apply_manual_decision( self.connection, event_id, "link_rows", reason="补录双边流水", expected_revision=current_row["revision"], request_key="link-1", actor=self.admin, source_row_ids=[row_a2, row_b2], ) ledger_events.reconcile_bank_events(self.connection, actor=self.admin) amounts = sorted( row["amount"] for row in self.connection.execute( "SELECT amount FROM ledger_event_revisions WHERE state = 'pending_subject'" ).fetchall() ) self.assertEqual(["100.00", "150.00"], amounts) def test_confirm_subject_enters_position_view(self) -> None: self.pair(self.company_a, self.company_b, "100.00") ledger_events.reconcile_bank_events(self.connection, actor=self.admin) event_id = self.ledger_events()[0]["id"] subjects.confirm_subject( self.connection, event_id, perspective_company_id=self.company_a, subject_code="other_receivable", reason="借款建议其他应收", expected_revision=1, request_key="k1", actor=self.admin, ) positions = self.position_events() self.assertEqual(1, len(positions)) self.assertEqual("other_receivable", positions[0]["subject_code"]) self.assertEqual(self.company_a, positions[0]["perspective_company_id"]) def test_confirm_stale_revision_conflicts(self) -> None: self.pair(self.company_a, self.company_b, "100.00") ledger_events.reconcile_bank_events(self.connection, actor=self.admin) event_id = self.ledger_events()[0]["id"] subjects.confirm_subject( self.connection, event_id, perspective_company_id=self.company_a, subject_code="receivable", reason="确认应收", expected_revision=1, request_key="k1", actor=self.admin, ) with self.assertRaises(subjects.SubjectConflictError): subjects.confirm_subject( self.connection, event_id, perspective_company_id=self.company_a, subject_code="receivable", reason="重复确认", expected_revision=1, request_key="k2", actor=self.admin, ) def test_reversal_mirrors_direction_and_subject(self) -> None: self.pair(self.company_a, self.company_b, "100.00") ledger_events.reconcile_bank_events(self.connection, actor=self.admin) event_id = self.ledger_events()[0]["id"] subjects.confirm_subject( self.connection, event_id, perspective_company_id=self.company_a, subject_code="other_receivable", reason="借款", expected_revision=1, request_key="k1", actor=self.admin, ) reversal_id, _ = ledger_events.create_reversal( self.connection, event_id, source_kind="adjustment", reason="冲销错误确认", actor=self.admin, ) reversal = self.current(reversal_id) self.assertEqual("reversal", reversal["posting_kind"]) self.assertEqual(self.company_b, reversal["payer_company_id"]) self.assertEqual(self.company_a, reversal["payee_company_id"]) self.assertEqual("other_payable", reversal["subject_code"]) self.assertEqual(self.company_b, reversal["perspective_company_id"]) # Original event stays confirmed and still in the position view. self.assertEqual("confirmed", self.current(event_id)["state"]) positions = self.position_events() self.assertEqual(2, len(positions)) signed = sum( Decimal(position["amount"]) * ( 1 if position["payer_company_id"] == self.company_a else -1 ) for position in positions ) self.assertEqual(Decimal("0"), signed) def test_evidence_rows_stay_immutable_under_reversal(self) -> None: self.pair(self.company_a, self.company_b, "100.00") ledger_events.reconcile_bank_events(self.connection, actor=self.admin) event_id = self.ledger_events()[0]["id"] subjects.confirm_subject( self.connection, event_id, perspective_company_id=self.company_a, subject_code="receivable", reason="确认", expected_revision=1, request_key="k1", actor=self.admin, ) before = self.connection.execute( "SELECT income, expense FROM source_rows ORDER BY id" ).fetchall() ledger_events.create_reversal( self.connection, event_id, source_kind="adjustment", reason="冲销", actor=self.admin, ) after = self.connection.execute( "SELECT income, expense FROM source_rows ORDER BY id" ).fetchall() self.assertEqual(before, after) def test_reversal_idempotent_guard(self) -> None: self.pair(self.company_a, self.company_b, "100.00") ledger_events.reconcile_bank_events(self.connection, actor=self.admin) event_id = self.ledger_events()[0]["id"] subjects.confirm_subject( self.connection, event_id, perspective_company_id=self.company_a, subject_code="receivable", reason="确认", expected_revision=1, request_key="k1", actor=self.admin, ) # Simulate the B-43 event leaving the eligible set. with self.connection: self.connection.execute( "UPDATE canonical_transfer_events SET lifecycle = 'superseded' WHERE id = ?", (self.eligible()[0]["event_id"],), ) stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin) self.assertEqual(1, stats["reversal"]) reversals = self.connection.execute( "SELECT COUNT(*) AS n FROM ledger_event_revisions WHERE posting_kind = 'reversal'" ).fetchone()["n"] self.assertEqual(1, reversals) # Re-run does not create a second reversal. stats = ledger_events.reconcile_bank_events(self.connection, actor=self.admin) self.assertEqual(0, stats["reversal"]) class ProjectionTests(LedgerBase): def test_rebuild_current_projection(self) -> None: self.pair(self.company_a, self.company_b, "100.00") ledger_events.reconcile_bank_events(self.connection, actor=self.admin) event_id = self.ledger_events()[0]["id"] subjects.confirm_subject( self.connection, event_id, perspective_company_id=self.company_a, subject_code="receivable", reason="确认", expected_revision=1, request_key="k1", actor=self.admin, ) self.connection.execute("DELETE FROM current_ledger_event_revisions") rebuilt = ledger_events.rebuild_current_ledger_projection(self.connection) self.assertGreaterEqual(rebuilt, 1) revision = self.current(event_id) self.assertEqual("confirmed", revision["state"]) self.assertEqual("receivable", revision["subject_code"]) class SubjectSuggestionTests(LedgerBase): def test_mirror_mapping_is_symmetric(self) -> None: for subject, mirror in ( ("receivable", "payable"), ("payable", "receivable"), ("other_receivable", "other_payable"), ("other_payable", "other_receivable"), ): self.assertEqual(mirror, subjects.mirror_subject(subject)) self.assertEqual(subject, subjects.mirror_subject(mirror)) def test_loan_keyword_suggests_payer_other_receivable(self) -> None: self.pair(self.company_a, self.company_b, "100.00", summary="借款", purpose="往来款") ledger_events.reconcile_bank_events(self.connection, actor=self.admin) event_id = self.ledger_events()[0]["id"] suggestions = subjects.compute_suggestions(self.connection, event_id) self.assertEqual(1, len(suggestions)) self.assertEqual(self.company_a, suggestions[0]["suggested_perspective_company_id"]) self.assertEqual("other_receivable", suggestions[0]["suggested_subject_code"]) self.assertFalse(suggestions[0]["evidence"]["approved"]) def test_repay_keyword_suggests_payee(self) -> None: self.pair(self.company_a, self.company_b, "100.00", summary="归还往来款", purpose="还款") ledger_events.reconcile_bank_events(self.connection, actor=self.admin) event_id = self.ledger_events()[0]["id"] suggestions = subjects.compute_suggestions(self.connection, event_id) self.assertEqual(1, len(suggestions)) self.assertEqual(self.company_b, suggestions[0]["suggested_perspective_company_id"]) def test_trade_keyword_never_suggests(self) -> None: # Trade vocabulary needs group approval; no dictionary hit -> review. self.pair(self.company_a, self.company_b, "100.00", summary="货款", purpose="采购货款") ledger_events.reconcile_bank_events(self.connection, actor=self.admin) event_id = self.ledger_events()[0]["id"] self.assertEqual([], subjects.compute_suggestions(self.connection, event_id)) def test_name_alone_never_confirms(self) -> None: # A name match on the counterparty cannot decide the subject. self.pair(self.company_a, self.company_b, "100.00", summary="某客户往来", purpose=None) ledger_events.reconcile_bank_events(self.connection, actor=self.admin) event_id = self.ledger_events()[0]["id"] current = self.current(event_id) self.assertEqual("pending_subject", current["state"]) self.assertEqual([], subjects.compute_suggestions(self.connection, event_id)) if __name__ == "__main__": unittest.main()