Skip to main content

backbone_banking/application/service/
banking_statement.rs

1//! Statement ingest + match proposal (hand-authored, user-owned).
2//!
3//! An `impl BankingWriteService` chunk over the vocabulary in [`super::banking_write_service`]:
4//! validate a statement's balance continuity (`opening + Σdeposit − Σwithdrawal = closing`), persist the
5//! import header + its lines as ONE unit of work, and emit `BankStatementImported`; then propose a match
6//! for a line from supplied candidates (exact amount + reference, else exact amount). Pure selection —
7//! `propose_match` persists nothing; `clear_transaction` (see [`super::banking_clearance`]) consumes
8//! its output.
9//!
10//! Per the module's 4-layer rule this file holds no SQL — the statements live on
11//! `BankStatementImportRepository` / `BankTransactionRepository`, whose custom methods take this
12//! service's transaction so an import's header + lines commit as one unit.
13
14use backbone_orm::org_scope;
15use rust_decimal::Decimal;
16use uuid::Uuid;
17
18use crate::infrastructure::persistence::{NewBankTransactionRow, NewStatementImportRow};
19
20use super::banking_events::{BankStatementImported, BankingEvent};
21use super::banking_write_service::{
22    money, BankingError, BankingWriteService, MatchCandidate, NewStatementImport,
23};
24
25impl BankingWriteService {
26    /// Import a statement: validate balance continuity (`opening + Σdeposit − Σwithdrawal = closing`),
27    /// persist the import + its lines, and emit `BankStatementImported`.
28    pub async fn import_statement(&self, imp: NewStatementImport) -> Result<Uuid, BankingError> {
29        if imp.lines.is_empty() {
30            return Err(BankingError::EmptyStatement);
31        }
32        let mut net = Decimal::ZERO;
33        for l in &imp.lines {
34            if l.deposit < Decimal::ZERO || l.withdrawal < Decimal::ZERO {
35                return Err(BankingError::NegativeAmount);
36            }
37            net += l.deposit - l.withdrawal;
38        }
39        let computed = money(imp.opening_balance + net);
40        let closing = money(imp.closing_balance);
41        if computed != closing {
42            return Err(BankingError::BalanceMismatch {
43                expected: closing,
44                computed,
45            });
46        }
47        let id = Uuid::new_v4();
48        let fmt = imp.source_format.clone().unwrap_or_else(|| "manual".into());
49        // Tenancy (ADR-0029): relay the AMBIENT request org scope onto this transaction when the
50        // composing service bound one, so the import header and every line insert evaluate under
51        // the decorator's row-level fences. An undecorated deployment has no ambient scope and
52        // skips this entirely (unfenced by design).
53        let mut tx = self.rpool().begin().await?;
54        if let Some(scope) = org_scope::current_org_scope() {
55            org_scope::bind_org_scope_on(&mut tx, &scope).await?;
56            backbone_orm::audit_context::relay_ambient_audit_on(&mut tx).await?;
57        }
58        self.repos
59            .imports
60            .insert_import(
61                &mut tx,
62                &NewStatementImportRow {
63                    id,
64                    bank_account_id: imp.bank_account_id,
65                    source_format: &fmt,
66                    statement_period_start: imp.period_start,
67                    statement_period_end: imp.period_end,
68                    opening_balance: money(imp.opening_balance),
69                    closing_balance: closing,
70                    file_ref: imp.file_ref.as_deref(),
71                    row_count: imp.lines.len() as i32,
72                },
73            )
74            .await?;
75        for l in &imp.lines {
76            self.repos
77                .transactions
78                .insert_transaction(
79                    &mut tx,
80                    &NewBankTransactionRow {
81                        id: Uuid::new_v4(),
82                        bank_account_id: imp.bank_account_id,
83                        import_id: id,
84                        txn_date: l.txn_date,
85                        description: l.description.as_deref(),
86                        reference_no: l.reference_no.as_deref(),
87                        deposit: money(l.deposit),
88                        withdrawal: money(l.withdrawal),
89                    },
90                )
91                .await?;
92        }
93        tx.commit().await?;
94        self.sink
95            .publish(BankingEvent::BankStatementImported(BankStatementImported {
96                import_id: id,
97                bank_account_id: imp.bank_account_id,
98                row_count: imp.lines.len() as i32,
99            }));
100        Ok(id)
101    }
102
103    /// Propose a match for a statement line from supplied candidates: prefer an exact amount + exact
104    /// reference (`exact`), else an exact amount (`fuzzy`). Pure selection — persists nothing.
105    pub async fn propose_match(
106        &self,
107        bank_transaction_id: Uuid,
108        candidates: &[MatchCandidate],
109    ) -> Result<Option<MatchCandidate>, BankingError> {
110        // Tenancy (ADR-0029), ID-only pattern: identified by the line id alone. The read rides the
111        // request-dedicated connection carrying the composing service's org scope, so the decorator's
112        // row-level fence decides visibility — another unit's line is simply not found.
113        let row = self
114            .repos
115            .transactions
116            .fetch_match_basis(&self.rpool(), bank_transaction_id)
117            .await?
118            .ok_or(BankingError::TransactionNotFound(bank_transaction_id))?;
119        let net = row.deposit + row.withdrawal;
120        let txn_ref: Option<String> = row.reference_no;
121        // exact amount + reference first
122        if let Some(c) = candidates
123            .iter()
124            .find(|c| c.amount == net && c.reference.is_some() && c.reference == txn_ref)
125        {
126            return Ok(Some(c.clone()));
127        }
128        Ok(candidates.iter().find(|c| c.amount == net).cloned())
129    }
130}