Skip to main content

backbone_banking/application/service/
banking_write_service.rs

1//! Validated write path + clearing engine for banking (hand-authored, user-owned).
2//!
3//! Banking ingests a statement, matches each line to a settled document (candidates supplied by a
4//! composition layer — banking never reads payment/billing tables), and CLEARS it through the GL:
5//!   - **received (deposit):** `Dr Bank · Cr Bank Clearing` — the bank-side leg payment left open.
6//!   - **paid (withdrawal):** `Dr Bank Clearing · Cr Bank`.
7//!   - **bank charge:** `Dr Bank Charges · Cr Bank`.
8//! The clearing account nets to zero once a payment's settlement (which debited/credited clearing) is
9//! confirmed by the statement. Balanced-or-refuse; IDR-only for now; clearing is bounded per line.
10//!
11//! **This file is the hub:** it holds the module's vocabulary (input structs, outcomes, errors) and
12//! the masters path (`create_bank` / `create_bank_account`). The rest of the write surface is chunked
13//! into focused siblings, each an `impl BankingWriteService` block over these same types:
14//!
15//! - [`super::banking_statement`] — `import_statement` (balance continuity + header + lines) and
16//!   `propose_match` (exact/fuzzy match against supplied candidates).
17//! - [`super::banking_clearance`] — the GL bank-side leg: `clear_transaction` (received/paid) and
18//!   `recognize_bank_charge`, both bounded by the line and (for clear) the settlement.
19//! - [`super::banking_reconciliation`] — the reconciliation session open/close with the
20//!   line-completeness close-gate.
21//!
22//! Tenancy: none, by design (ADR-0029). Every statement rides the ambient request org scope the
23//! composing service bound (its decorator installs the row-level fences); an undecorated
24//! deployment is unfenced by design. No verb takes or binds a tenant key.
25
26use rust_decimal::{Decimal, RoundingStrategy};
27use sqlx::PgPool;
28use std::sync::Arc;
29use uuid::Uuid;
30
31use crate::infrastructure::persistence::{
32    BankAccountRepository, BankClearanceRepository, BankReconciliationRepository, BankRepository,
33    BankStatementImportRepository, BankTransactionRepository, NewBankAccountRow, NewBankRow,
34};
35
36use super::banking_events::{BankingEventSink, LoggingSink};
37use super::iban_validation::{is_iban_shaped, normalize_iban, validate_iban_with, IbanError, IbanValidationPolicy};
38
39pub(super) fn money(v: Decimal) -> Decimal {
40    v.round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero)
41}
42
43// --- input structs -----------------------------------------------------------
44
45#[derive(Debug, Clone)]
46pub struct NewBank {
47    pub name: String,
48    pub swift_bic: Option<String>,
49    pub country: Option<String>,
50}
51
52#[derive(Debug, Clone)]
53pub struct NewBankAccount {
54    pub branch_id: Option<Uuid>,
55    pub bank_id: Uuid,
56    pub account_name: String,
57    pub account_number: String,
58    pub gl_account_id: Uuid,
59    pub clearing_account_id: Uuid,
60    pub currency: Option<String>,
61    pub account_type: Option<String>,
62}
63
64#[derive(Debug, Clone)]
65pub struct NewStatementLine {
66    pub txn_date: chrono::NaiveDate,
67    pub description: Option<String>,
68    pub reference_no: Option<String>,
69    pub deposit: Decimal,
70    pub withdrawal: Decimal,
71}
72
73#[derive(Debug, Clone)]
74pub struct NewStatementImport {
75    pub bank_account_id: Uuid,
76    pub source_format: Option<String>,
77    pub period_start: chrono::NaiveDate,
78    pub period_end: chrono::NaiveDate,
79    pub opening_balance: Decimal,
80    pub closing_balance: Decimal,
81    pub file_ref: Option<String>,
82    pub lines: Vec<NewStatementLine>,
83}
84
85/// A candidate document a statement line might settle (supplied by the composition — banking does not
86/// read payment/billing). `amount` is the settlement's cash amount; `reference` is a match key.
87#[derive(Debug, Clone)]
88pub struct MatchCandidate {
89    pub source_type: String,
90    pub source_id: Uuid,
91    pub amount: Decimal,
92    pub reference: Option<String>,
93}
94
95#[derive(Debug, Clone)]
96pub struct NewClearance {
97    pub bank_transaction_id: Uuid,
98    pub matched_source_type: String,
99    pub matched_source_id: Uuid,
100    /// The settled document's TOTAL amount (from `MatchCandidate.amount`). Banking bounds the sum of
101    /// all clearances against a settlement by this, so one payment can't be cleared twice.
102    pub matched_source_amount: Decimal,
103    pub matched_amount: Decimal,
104    pub match_method: Option<String>,
105    pub clearance_date: chrono::NaiveDate,
106}
107
108#[derive(Debug, Clone)]
109pub struct NewCharge {
110    pub bank_transaction_id: Uuid,
111    pub charge_account_id: Uuid,
112    pub amount: Decimal,
113    pub clearance_date: chrono::NaiveDate,
114}
115
116#[derive(Debug, Clone)]
117pub struct NewReconciliation {
118    pub bank_account_id: Uuid,
119    pub from_date: chrono::NaiveDate,
120    pub to_date: chrono::NaiveDate,
121    pub statement_closing_balance: Decimal,
122    pub ledger_balance: Decimal,
123}
124
125#[derive(Debug, Clone)]
126pub struct ClearOutcome {
127    pub clearance_id: Uuid,
128    pub post_id: Uuid,
129    pub journal_id: Uuid,
130    pub fully_reconciled: bool,
131}
132
133#[derive(Debug, Clone)]
134pub struct ReconcileOutcome {
135    pub id: Uuid,
136    pub difference: Decimal,
137    /// Open lines (unreconciled/partly_reconciled) in the period — the exceptions blocking close.
138    pub unreconciled_count: i64,
139    /// "open" (numbers disagree) | "balanced" (agree, exceptions outstanding) | "closed" (agree + clean).
140    pub status: String,
141}
142
143// --- errors ------------------------------------------------------------------
144
145#[derive(Debug)]
146pub enum BankingError {
147    EmptyStatement,
148    BalanceMismatch {
149        expected: Decimal,
150        computed: Decimal,
151    },
152    NegativeAmount,
153    NonPositiveAmount,
154    OverAllocated {
155        line_net: Decimal,
156        already: Decimal,
157        attempted: Decimal,
158    },
159    SettlementOverCleared {
160        settlement_amount: Decimal,
161        already_cleared: Decimal,
162        attempted: Decimal,
163    },
164    UnbalancedPost,
165    UnsupportedCurrency(String),
166    TransactionNotFound(Uuid),
167    AccountNotFound(Uuid),
168    GlRejected {
169        code: String,
170        message: String,
171    },
172    /// The reconciliation-graph sink refused the clearing's edge (unposted payment journal, a
173    /// non-reconcilable clearing account, or a clamp disagreement). Clearing is fail-closed on
174    /// this: the clearance row and the allocation advance roll back with the refused edge.
175    ReconcileRefused {
176        code: String,
177        message: String,
178    },
179    PresetNotFound(Uuid),
180    PresetInactive(Uuid),
181    PresetMissingTolerance(Uuid),
182    PresetMissingDaysWindow(Uuid),
183    UnknownMatchOn(String),
184    /// The candidate-pool read refused: the payment schema is absent (standalone module database,
185    /// mis-composed host). Ordering ranks nothing rather than present fiction as candidates.
186    CandidatePoolRefused(String),
187    /// An IBAN-shaped account number whose country is KNOWN but whose length/structure or
188    /// mod-97 checksum is wrong.
189    InvalidIban(String),
190    /// An IBAN-shaped account number claiming a country that is not in the reviewed IBAN
191    /// registry — refused fail-closed (distinct from `InvalidIban` so the escape-able
192    /// refusal is visible as its own error code).
193    IbanUnknownCountry(String),
194    Db(sqlx::Error),
195}
196
197impl BankingError {
198    pub fn code(&self) -> String {
199        match self {
200            BankingError::EmptyStatement => "empty_statement".into(),
201            BankingError::BalanceMismatch { .. } => "balance_mismatch".into(),
202            BankingError::NegativeAmount => "negative_amount".into(),
203            BankingError::NonPositiveAmount => "non_positive_amount".into(),
204            BankingError::OverAllocated { .. } => "over_allocated".into(),
205            BankingError::SettlementOverCleared { .. } => "settlement_over_cleared".into(),
206            BankingError::UnbalancedPost => "unbalanced_post".into(),
207            BankingError::UnsupportedCurrency(_) => "unsupported_currency".into(),
208            BankingError::TransactionNotFound(_) => "transaction_not_found".into(),
209            BankingError::AccountNotFound(_) => "account_not_found".into(),
210            BankingError::GlRejected { code, .. } => code.clone(),
211            BankingError::ReconcileRefused { code, .. } => code.clone(),
212            BankingError::PresetNotFound(_) => "preset_not_found".into(),
213            BankingError::PresetInactive(_) => "preset_inactive".into(),
214            BankingError::PresetMissingTolerance(_) => "preset_missing_tolerance".into(),
215            BankingError::PresetMissingDaysWindow(_) => "preset_missing_days_window".into(),
216            BankingError::UnknownMatchOn(_) => "unknown_match_on".into(),
217            BankingError::CandidatePoolRefused(_) => "candidate_pool_refused".into(),
218            BankingError::InvalidIban(_) => "invalid_iban".into(),
219            BankingError::IbanUnknownCountry(_) => "iban_unknown_country".into(),
220            BankingError::Db(_) => "internal_error".into(),
221        }
222    }
223    pub fn http_status(&self) -> u16 {
224        match self {
225            BankingError::TransactionNotFound(_)
226            | BankingError::AccountNotFound(_)
227            | BankingError::PresetNotFound(_) => 404,
228            BankingError::CandidatePoolRefused(_) | BankingError::Db(_) => 500,
229            _ => 422,
230        }
231    }
232}
233impl std::fmt::Display for BankingError {
234    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235        match self {
236            BankingError::GlRejected { code, message } => write!(f, "{code}: {message}"),
237            BankingError::ReconcileRefused { code, message } => write!(f, "{code}: {message}"),
238            BankingError::BalanceMismatch { expected, computed } => write!(
239                f,
240                "balance_mismatch: expected {expected}, computed {computed}"
241            ),
242            other => write!(f, "{}", other.code()),
243        }
244    }
245}
246impl std::error::Error for BankingError {}
247impl From<sqlx::Error> for BankingError {
248    fn from(e: sqlx::Error) -> Self {
249        BankingError::Db(e)
250    }
251}
252
253/// The repositories this service orchestrates. Bundled behind one `Arc` so the service stays cheap
254/// to `Clone` (it is cloned per request) without requiring the repository newtypes to be `Clone`.
255/// Fields are `pub(super)` so the sibling `impl BankingWriteService` blocks (banking_statement /
256/// banking_clearance / banking_reconciliation) can drive them — they share this service's vocabulary.
257pub(super) struct Repos {
258    pub(super) banks: BankRepository,
259    pub(super) bank_accounts: BankAccountRepository,
260    pub(super) imports: BankStatementImportRepository,
261    pub(super) transactions: BankTransactionRepository,
262    pub(super) clearances: BankClearanceRepository,
263    pub(super) reconciliations: BankReconciliationRepository,
264}
265
266impl Repos {
267    fn new(db_pool: &PgPool) -> Self {
268        Self {
269            banks: BankRepository::new(db_pool.clone()),
270            bank_accounts: BankAccountRepository::new(db_pool.clone()),
271            imports: BankStatementImportRepository::new(db_pool.clone()),
272            transactions: BankTransactionRepository::new(db_pool.clone()),
273            clearances: BankClearanceRepository::new(db_pool.clone()),
274            reconciliations: BankReconciliationRepository::new(db_pool.clone()),
275        }
276    }
277}
278
279#[derive(Clone)]
280pub struct BankingWriteService {
281    pub(super) db_pool: PgPool,
282    pub(super) repos: Arc<Repos>,
283    pub(super) sink: Arc<dyn BankingEventSink>,
284    /// When set, a payment-matched `clear_transaction` stages `BankClearanceRecorded` into
285    /// `<schema>.outbox_events` **inside the clearance transaction** (crash-safe emission — the
286    /// payment module's bank-confirmation drift consumes it). When `None`, only the legacy in-proc
287    /// sink fires (existing behaviour). The relay drains the outbox to the real bus.
288    pub(super) outbox_schema: Option<String>,
289    /// The candidate pool the reconcile-preset ordering ranks. Defaults to the guarded
290    /// payment-header read; tests inject their own.
291    pub(super) candidates: Arc<dyn super::reconcile_preset_candidates::CandidatePoolPort>,
292    /// IBAN validation posture for `create_bank_account`. Default fail-closed for
293    /// IBAN-shaped numbers claiming unregistered countries (see `iban_validation`).
294    pub(super) iban_policy: IbanValidationPolicy,
295}
296
297impl BankingWriteService {
298    /// The database this verb runs on: the composer's request pool when the
299    /// tenant router installed one, else the composed pool (ADR-0029).
300    pub(super) fn rpool(&self) -> PgPool {
301        crate::request_pool::current().unwrap_or_else(|| self.db_pool.clone())
302    }
303
304    pub fn new(db_pool: PgPool) -> Self {
305        Self::with_sink(db_pool, Arc::new(LoggingSink))
306    }
307    pub fn with_sink(db_pool: PgPool, sink: Arc<dyn BankingEventSink>) -> Self {
308        let repos = Arc::new(Repos::new(&db_pool));
309        Self {
310            db_pool,
311            repos,
312            sink,
313            outbox_schema: None,
314            candidates: Arc::new(super::reconcile_preset_candidates::PaymentPoolRead),
315            iban_policy: IbanValidationPolicy::FAIL_CLOSED,
316        }
317    }
318    /// Explicit IBAN validation posture for this service instance. Hosts wiring the named
319    /// escape pass `IbanValidationPolicy::ALLOW_UNKNOWN_COUNTRIES` (or
320    /// `IbanValidationPolicy::from_env()` to honor `BANKING_IBAN_ALLOW_UNKNOWN_COUNTRIES`).
321    pub fn with_iban_policy(mut self, policy: IbanValidationPolicy) -> Self {
322        self.iban_policy = policy;
323        self
324    }
325    /// Enable crash-safe `BankClearanceRecorded` emission via the durable outbox in `schema`
326    /// (e.g. `"banking"`). Requires `backbone_outbox::outbox::migrate` to have created
327    /// `<schema>.outbox_events`.
328    pub fn with_outbox_schema(mut self, schema: impl Into<String>) -> Self {
329        self.outbox_schema = Some(schema.into());
330        self
331    }
332    /// Inject the reconcile-preset candidate-pool read (module tests inject a stub; the default
333    /// reads posted payment headers through a `to_regclass` guard).
334    pub fn with_candidate_port(
335        mut self,
336        port: Arc<dyn super::reconcile_preset_candidates::CandidatePoolPort>,
337    ) -> Self {
338        self.candidates = port;
339        self
340    }
341
342    // ---- masters ------------------------------------------------------------
343
344    pub async fn create_bank(&self, b: NewBank) -> Result<Uuid, BankingError> {
345        // Tenancy (ADR-0029): the module carries none. The insert rides the ambient request
346        // org scope when the composing service bound one (the composing decorator's row-level
347        // fence decides the WITH CHECK), and runs unfenced on a bare deployment.
348        let id = Uuid::new_v4();
349        let country = b.country.unwrap_or_else(|| "ID".into());
350        self.repos
351            .banks
352            .insert_bank(
353                &self.rpool(),
354                &NewBankRow {
355                    id,
356                    name: &b.name,
357                    swift_bic: b.swift_bic.as_deref(),
358                    country: &country,
359                },
360            )
361            .await?;
362        Ok(id)
363    }
364
365    pub async fn create_bank_account(&self, a: NewBankAccount) -> Result<Uuid, BankingError> {
366        // IBAN validation, fail-closed: a value that CLAIMS to be an IBAN (two letters
367        // + two digits) must carry a registered country, the right length/structure,
368        // and a valid mod-97 checksum. Unknown countries are refused with the distinct
369        // `iban_unknown_country` code (loudly logged; escape-able via the named config).
370        // Non-IBAN-shaped values are LOCAL account numbers (the field's pre-existing
371        // contract — Indonesian accounts are not IBAN-based) and pass through unchanged.
372        let account_number = if is_iban_shaped(&normalize_iban(&a.account_number)) {
373            match validate_iban_with(&self.iban_policy, &a.account_number) {
374                Ok(canonical) => canonical,
375                Err(IbanError::UnknownCountry(country)) => {
376                    return Err(BankingError::IbanUnknownCountry(country));
377                }
378                Err(e) => {
379                    return Err(BankingError::InvalidIban(format!("{} ({e})", a.account_number)));
380                }
381            }
382        } else {
383            a.account_number.clone()
384        };
385        // Tenancy: none of the module's own (ADR-0029) — see `create_bank`.
386        let id = Uuid::new_v4();
387        let currency = a.currency.unwrap_or_else(|| "IDR".into());
388        let account_type = a.account_type.unwrap_or_else(|| "checking".into());
389        self.repos
390            .bank_accounts
391            .insert_bank_account(
392                &self.rpool(),
393                &NewBankAccountRow {
394                    id,
395                    branch_id: a.branch_id,
396                    bank_id: a.bank_id,
397                    account_name: &a.account_name,
398                    account_number: &account_number,
399                    gl_account_id: a.gl_account_id,
400                    clearing_account_id: a.clearing_account_id,
401                    currency: &currency,
402                    account_type: &account_type,
403                },
404            )
405            .await?;
406        Ok(id)
407    }
408}