Skip to main content

backbone_banking/application/service/
banking_reconciliation.rs

1//! Reconciliation session — the close-gate (hand-authored, user-owned).
2//!
3//! An `impl BankingWriteService` chunk over the vocabulary in [`super::banking_write_service`]. Open
4//! or close a reconciliation session under the council 2026-07-05 line-completeness gate: a session
5//! **cannot close on the two supplied balances agreeing alone** — it must also have zero open lines in
6//! the period, or it would sign off "the bank agrees with our books" while transactions sit
7//! unreconciled (a false attestation). Three-state (the persisted `status`):
8//!   - `computed_difference ≠ 0` → **open** (numbers disagree)
9//!   - `= 0` but exceptions remain → **balanced** (agree, NOT finalized, no event)
10//!   - `= 0` and zero exceptions → **closed** (+ emit `BankReconciliationClosed`)
11//!
12//! Per the module's 4-layer rule this file holds no SQL — the exception COUNT (the close-gate's
13//! assertion) lives on `BankTransactionRepository`, and the session row on `BankReconciliationRepository`.
14
15use uuid::Uuid;
16
17use crate::infrastructure::persistence::NewReconciliationRow;
18
19use super::banking_events::{BankReconciliationClosed, BankingEvent};
20use super::banking_write_service::{
21    money, BankingError, BankingWriteService, NewReconciliation, ReconcileOutcome,
22};
23
24impl BankingWriteService {
25    /// Open/close a reconciliation session (council 2026-07-05 — line-completeness close-gate). A
26    /// session **cannot close on the two supplied balances agreeing alone** — it must also have zero
27    /// open lines in the period, or it would sign off "the bank agrees with our books" while
28    /// transactions sit unreconciled (a false attestation). Three-state (activating `ReconStatus`):
29    ///   - `computed_difference ≠ 0` → **open** (numbers disagree)
30    ///   - `= 0` but exceptions remain → **balanced** (agree, NOT finalized, no event)
31    ///   - `= 0` and zero exceptions → **closed** (+ emit `BankReconciliationClosed`)
32    /// `unreconciled_count` is persisted as the exception snapshot at sign-off (audit-reconstructable).
33    /// Note: period-scoped by `txn_date ∈ [from_date, to_date]`. `ledger_balance` is still supplied
34    /// (recompute is parked — see ADR-001); the line-count is `reconcile`'s real assertion.
35    pub async fn reconcile(&self, r: NewReconciliation) -> Result<ReconcileOutcome, BankingError> {
36        // Tenancy (ADR-0029): the exception COUNT is the close-gate's assertion, so it MUST be
37        // fenced: an unfenced count would read other units' lines (or, over-read, refuse a
38        // legitimate close) and never the caller's own in isolation. Both statements ride the
39        // request-dedicated connection carrying the composing service's org scope, so the
40        // decorator's row-level fence scopes the count and admits the insert. An undecorated
41        // deployment runs both unfenced (by design; nothing to fence against).
42        let diff = money(r.statement_closing_balance - r.ledger_balance);
43        let id = Uuid::new_v4();
44        let unreconciled: i64 = self
45            .repos
46            .transactions
47            .count_open_in_period(&self.rpool(), r.bank_account_id, r.from_date, r.to_date)
48            .await?;
49        let status = if !diff.is_zero() {
50            "open"
51        } else if unreconciled > 0 {
52            "balanced"
53        } else {
54            "closed"
55        };
56        self.repos
57            .reconciliations
58            .insert_reconciliation(
59                &self.rpool(),
60                &NewReconciliationRow {
61                    id,
62                    bank_account_id: r.bank_account_id,
63                    from_date: r.from_date,
64                    to_date: r.to_date,
65                    statement_closing_balance: money(r.statement_closing_balance),
66                    ledger_balance: money(r.ledger_balance),
67                    computed_difference: diff,
68                    unreconciled_count: unreconciled as i32,
69                    status,
70                },
71            )
72            .await?;
73        // Attest "the bank agrees with our books" ONLY when the session actually closes.
74        if status == "closed" {
75            self.sink.publish(BankingEvent::BankReconciliationClosed(
76                BankReconciliationClosed {
77                    reconciliation_id: id,
78                    bank_account_id: r.bank_account_id,
79                    difference: diff,
80                },
81            ));
82        }
83        Ok(ReconcileOutcome {
84            id,
85            difference: diff,
86            unreconciled_count: unreconciled,
87            status: status.to_string(),
88        })
89    }
90}