Skip to main content

backbone_banking/application/service/
banking_clearance.rs

1//! Clearing engine — the bank-side GL leg (hand-authored, user-owned).
2//!
3//! An `impl BankingWriteService` chunk over the vocabulary in [`super::banking_write_service`]. Two
4//! symmetric seams that CLEAR a matched statement line through the GL:
5//!   - **`clear_transaction`** (received/paid): `Dr Bank · Cr Clearing` (or its mirror), bounded by
6//!     BOTH the line's un-allocated remainder AND the settlement's un-cleared remainder (a payment
7//!     cannot be cleared twice).
8//!   - **`recognize_bank_charge`**: `Dr Bank Charges · Cr Bank`.
9//! Both post through the supplied `GlPostSink`, write a `BankClearance` row, advance the line's
10//! `allocated_amount` + status, and emit the corresponding event.
11//!
12//! Per the module's 4-layer rule this file holds no SQL — the line reads, the settlement advisory
13//! lock + already-cleared SUM, the clearance inserts, and the allocation watermark live on
14//! `BankTransactionRepository` / `BankClearanceRepository`, whose methods take this service's
15//! transaction so the post + clearance + allocation advance commit as one unit.
16
17use backbone_orm::org_scope;
18use rust_decimal::Decimal;
19use uuid::Uuid;
20
21use crate::domain::entity::TxnStatus;
22use crate::infrastructure::persistence::{NewChargeClearanceRow, NewClearanceRow};
23
24use super::banking_events::{
25    BankChargeRecognized, BankTransactionCleared, BankTransactionMatched, BankingEvent,
26};
27use super::banking_gl::{
28    AccountingPostEnvelope, GlPostLine, GlPostSink, ReconcileLine, ReconcileOrigin,
29    ReconcilePairRequest, ReconcileSink,
30};
31use super::banking_write_service::{
32    money, BankingError, BankingWriteService, ClearOutcome, NewCharge, NewClearance,
33};
34
35/// The legacy tenancy twin (ADR-0029): the cleared tables hold no company column, but the GL
36/// envelope, the reconcile-graph pair request, the durable outbox record, and the published
37/// events still carry a `company_id` lane for unstripped consumers. Echo the ambient org
38/// scope's legacy company id when the composing service bound one; nil otherwise. Nothing in
39/// this module keys a statement on it.
40fn legacy_company_echo() -> Uuid {
41    org_scope::current_org_scope()
42        .and_then(|s| s.legacy_company_id())
43        .unwrap_or(Uuid::nil())
44}
45
46impl BankingWriteService {
47    /// Clear a matched statement line through the GL — the bank-side leg. received (deposit):
48    /// `Dr Bank · Cr Clearing`; paid (withdrawal): `Dr Clearing · Cr Bank`. Bounded: the clearance
49    /// cannot exceed the line's un-allocated remainder. Records a `BankClearance`, advances the line's
50    /// `allocated_amount` + status, and emits `BankTransactionMatched` + `BankTransactionCleared`.
51    ///
52    /// When the line was matched to a **payment**, the same unit of work also writes the
53    /// reconciliation-graph edge on the clearing account: the payment's clearing leg meets the
54    /// clearance's clearing leg (the pair that nets the undeposited-funds position to zero), through
55    /// the shared [`ReconcileSink`] port with origin `Clearing`. Receipt: the payment DEPOSITED into
56    /// clearing (`Dr Clearing`) and the clearance withdraws it (`Cr Clearing`); paid-out mirrors.
57    /// **Fail-closed:** if the sink refuses the pair (unposted payment journal, a clearing account
58    /// that is not flagged reconcilable, a clamp disagreement) the clearance row and the allocation
59    /// advance roll back — banking refuses to record a clear whose ledger-side edge cannot exist.
60    /// The clearing post itself is committed by its own sink before the edge; a refusal therefore
61    /// strands that journal. The stranded post is NOT re-posted on retry: the clearance identity and
62    /// its posting idempotency key are derived deterministically from the fenced cumulative-cleared
63    /// position, so the retry reuses the stranded journal (the accounting sink dedups the key), the
64    /// edge locator resolves against its lines, and the clear completes — the strand heals itself
65    /// once the operator fixes the refusal's cause. (Re-clearing a DIFFERENT amount after a
66    /// stranded attempt still lands as a residual mismatch — an integrity-probe finding, not silent
67    /// money.) Matched-source kinds other than `payment` keep the bounded clearance only — their
68    /// graph convergence is the statement-side reconciliation surface.
69    pub async fn clear_transaction(
70        &self,
71        c: NewClearance,
72        sink: &dyn GlPostSink,
73        reconcile: &dyn ReconcileSink,
74    ) -> Result<ClearOutcome, BankingError> {
75        if c.matched_amount <= Decimal::ZERO {
76            return Err(BankingError::NonPositiveAmount);
77        }
78        let matched = money(c.matched_amount);
79        // Load the line + its account's GL/clearing accounts.
80        // Tenancy (ADR-0029), ID-only pattern — see `propose_match`. The read rides the ambient
81        // request org scope; having read the line we relay that same scope onto the clearing
82        // transaction below.
83        let row = self
84            .repos
85            .transactions
86            .fetch_clearing_line(&self.rpool(), c.bank_transaction_id)
87            .await?
88            .ok_or(BankingError::TransactionNotFound(c.bank_transaction_id))?;
89        let currency: String = row.currency;
90        if currency != "IDR" {
91            return Err(BankingError::UnsupportedCurrency(currency));
92        }
93        let company_id = legacy_company_echo();
94        let deposit = row.deposit;
95        let withdrawal = row.withdrawal;
96        let allocated = row.allocated_amount;
97        let bank_acct = row.gl_account_id;
98        let clearing = row.clearing_account_id;
99        let line_net = deposit + withdrawal;
100        if matched > line_net - allocated {
101            return Err(BankingError::OverAllocated {
102                line_net,
103                already: allocated,
104                attempted: matched,
105            });
106        }
107        let is_receipt = deposit > Decimal::ZERO;
108
109        // Settlement-dimension bound (council 2026-07-05): the SUM of all clearances against this
110        // settlement cannot exceed the settled document's amount — so one payment cannot be cleared
111        // twice (a re-imported line, a retry, two operators). The line bound above only limits the
112        // LINE; without this, two lines each matching one payment both pass and strand the clearing
113        // account. Serialize per settlement with an advisory lock so concurrent first-clears can't race
114        // the phantom-insert, and hold the tx across the post so the check + write are one unit.
115        let mut tx = self.rpool().begin().await?;
116        // Tenancy (ADR-0029): relay the AMBIENT request org scope onto this transaction when the
117        // composing service bound one, so the already-cleared SUM and the clearance insert evaluate
118        // under the decorator's row-level fences. An undecorated deployment has no ambient scope
119        // and skips this entirely (unfenced by design).
120        if let Some(scope) = org_scope::current_org_scope() {
121            org_scope::bind_org_scope_on(&mut tx, &scope).await?;
122            backbone_orm::audit_context::relay_ambient_audit_on(&mut tx).await?;
123        }
124        self.repos
125            .clearances
126            .lock_settlement(&mut tx, c.matched_source_id)
127            .await?;
128        let already_cleared = self
129            .repos
130            .clearances
131            .sum_cleared_against_settlement(&mut tx, &c.matched_source_type, c.matched_source_id)
132            .await?;
133        if already_cleared + matched > money(c.matched_source_amount) {
134            return Err(BankingError::SettlementOverCleared {
135                settlement_amount: money(c.matched_source_amount),
136                already_cleared,
137                attempted: matched,
138            });
139        }
140
141        // Deterministic clearance identity, derived from (line, settlement, cumulative-cleared) read
142        // UNDER the settlement lock above. A refused clear rolls the whole unit back — the failed
143        // attempt contributes nothing to `already_cleared` — so the operator's retry derives the SAME
144        // id and the SAME posting idempotency key: the clearing post dedups at the accounting sink
145        // instead of committing a second `Dr Bank · Cr Clearing`, and the edge locator resolves
146        // against the first attempt's (stranded) journal lines. Legitimate sequential clears of one
147        // settlement see a strictly increasing cumulative sum under the lock, so their identities
148        // never collide.
149        let identity = format!(
150            "bankclr:{}:{}:{}:{}",
151            c.bank_transaction_id, c.matched_source_type, c.matched_source_id, already_cleared
152        );
153        let clearance_id = Uuid::new_v5(&Uuid::NAMESPACE_URL, identity.as_bytes());
154        let lines = if is_receipt {
155            vec![
156                GlPostLine::debit(bank_acct, matched).with_description("Bank clearing (received)"),
157                GlPostLine::credit(clearing, matched).with_description("Clear undeposited funds"),
158            ]
159        } else {
160            vec![
161                GlPostLine::debit(clearing, matched).with_description("Clear undeposited funds"),
162                GlPostLine::credit(bank_acct, matched).with_description("Bank clearing (paid)"),
163            ]
164        };
165        let env = AccountingPostEnvelope {
166            idempotency_key: identity,
167            // The legacy tenancy twin (ADR-0029) — the composing host's GL ACL maps it onto
168            // accounting's `PostingRequest.company_id`, which keeps the same legacy lane.
169            company_id,
170            branch_id: None,
171            source_type: "settlement".into(),
172            source_id: clearance_id,
173            source_reference: Some(format!("clear {}", c.bank_transaction_id)),
174            posting_date: c.clearance_date,
175            currency,
176            posting_type: "original".into(),
177            reverses_post_id: None,
178            description: Some("Bank clearing".into()),
179            lines,
180        };
181        if !env.is_balanced() {
182            return Err(BankingError::UnbalancedPost);
183        }
184
185        match sink.post(&env).await {
186            Ok(ack) => {
187                // The clearing edge (payment-matched only). The post above has committed the
188                // clearance's clearing leg into the ledger; this pairs it with the payment's own
189                // clearing leg on the same account, on the SAME transaction as the clearance row +
190                // allocation advance below — so the graph edge and the banking bookkeeping commit
191                // or roll back together. Direction follows the flow: a receipt deposits INTO
192                // clearing on the payment (its leg is the debit) and the clearance withdraws
193                // (credit); a payout mirrors.
194                if c.matched_source_type == "payment" {
195                    let payment_leg = ReconcileLine::new("payment", c.matched_source_id, clearing);
196                    let clearance_leg = ReconcileLine::new("settlement", clearance_id, clearing);
197                    let (debit, credit) = if is_receipt {
198                        (payment_leg, clearance_leg)
199                    } else {
200                        (clearance_leg, payment_leg)
201                    };
202                    let edge = reconcile
203                        .reconcile_pair_on(
204                            &mut *tx,
205                            &ReconcilePairRequest {
206                                // The legacy tenancy twin (ADR-0029) — the shared
207                                // `backbone_gl_posting` wire shape keeps its `company_id` lane
208                                // for unstripped producers; nothing keys the edge on it.
209                                company_id,
210                                debit,
211                                credit,
212                                amount: matched,
213                                origin: ReconcileOrigin::Clearing,
214                            },
215                        )
216                        .await
217                        .map_err(|r| BankingError::ReconcileRefused {
218                            code: r.code,
219                            message: r.message,
220                        })?;
221                    if edge.applied != matched {
222                        // The graph clamped to a different amount than banking's own bound — the
223                        // bank bookkeeping and the ledger disagree about what is clearable. Refuse
224                        // rather than drift.
225                        return Err(BankingError::ReconcileRefused {
226                            code: "reconcile_clamp_mismatch".into(),
227                            message: format!(
228                                "banking cleared {matched} but the graph applied {}",
229                                edge.applied
230                            ),
231                        });
232                    }
233                }
234                let match_method = c.match_method.clone().unwrap_or_else(|| "manual".into());
235                self.repos
236                    .clearances
237                    .insert_clearance(
238                        &mut tx,
239                        &NewClearanceRow {
240                            id: clearance_id,
241                            bank_transaction_id: c.bank_transaction_id,
242                            matched_source_type: &c.matched_source_type,
243                            matched_source_id: c.matched_source_id,
244                            matched_amount: matched,
245                            match_method: &match_method,
246                            clearance_date: c.clearance_date,
247                            accounting_post_id: ack.post_id,
248                            journal_id: ack.journal_id,
249                        },
250                    )
251                    .await?;
252                let new_alloc = allocated + matched;
253                let fully = new_alloc >= line_net;
254                let status = if fully {
255                    TxnStatus::Reconciled
256                } else {
257                    TxnStatus::PartlyReconciled
258                };
259                self.repos
260                    .transactions
261                    .set_allocation(&mut tx, c.bank_transaction_id, new_alloc, status)
262                    .await?;
263                // Durable `BankClearanceRecorded` staging (payment-matched only — the event's only
264                // consumer is payment's bank-confirmation drift). Inside THIS transaction: a crash
265                // after the clearance can never lose the confirmation event, and a rolled-back clear
266                // never emits one. The relay later delivers it; payment's `confirm_cash_once`
267                // consumer dedups on the event id.
268                if let Some(schema) = self.outbox_schema.clone() {
269                    if c.matched_source_type == "payment" {
270                        // `company_id` in the payload and on the outbox record is the legacy
271                        // tenancy twin (ADR-0029): the consumer (payment's bank-confirmation
272                        // drift) keeps the lane; nothing keys delivery on it.
273                        let payload = serde_json::json!({
274                            "payment_id": c.matched_source_id.to_string(),
275                            "company_id": company_id.to_string(),
276                            "bank_transaction_id": c.bank_transaction_id.to_string(),
277                            "matched_source_type": c.matched_source_type,
278                            "matched_source_id": c.matched_source_id.to_string(),
279                            "amount": matched.to_string(),
280                            "journal_id": ack.journal_id.to_string(),
281                            "post_id": ack.post_id.to_string(),
282                        });
283                        let rec = backbone_outbox::OutboxRecord::new(
284                            "BankClearanceRecorded",
285                            "BankClearance",
286                            clearance_id.to_string(),
287                            company_id,
288                            payload,
289                            chrono::Utc::now(),
290                        );
291                        backbone_outbox::outbox::stage(&mut *tx, &schema, &rec)
292                            .await
293                            .map_err(|e| BankingError::Db(sqlx::Error::Protocol(e.to_string())))?;
294                    }
295                }
296                tx.commit().await?;
297
298                self.sink.publish(BankingEvent::BankTransactionMatched(
299                    BankTransactionMatched {
300                        bank_transaction_id: c.bank_transaction_id,
301                        matched_source_type: c.matched_source_type.clone(),
302                        matched_source_id: c.matched_source_id,
303                        amount: matched,
304                    },
305                ));
306                self.sink.publish(BankingEvent::BankTransactionCleared(
307                    BankTransactionCleared {
308                        bank_transaction_id: c.bank_transaction_id,
309                        matched_source_type: c.matched_source_type,
310                        matched_source_id: c.matched_source_id,
311                        company_id,
312                        journal_id: ack.journal_id,
313                        post_id: ack.post_id,
314                        amount: matched,
315                    },
316                ));
317                Ok(ClearOutcome {
318                    clearance_id,
319                    post_id: ack.post_id,
320                    journal_id: ack.journal_id,
321                    fully_reconciled: fully,
322                })
323            }
324            Err(rej) => Err(BankingError::GlRejected {
325                code: rej.code,
326                message: rej.message,
327            }),
328        }
329    }
330
331    /// Recognise an outflow line as a bank charge: `Dr Bank Charges · Cr Bank`. Marks the line
332    /// reconciled and emits `BankChargeRecognized`.
333    pub async fn recognize_bank_charge(
334        &self,
335        ch: NewCharge,
336        sink: &dyn GlPostSink,
337    ) -> Result<ClearOutcome, BankingError> {
338        if ch.amount <= Decimal::ZERO {
339            return Err(BankingError::NonPositiveAmount);
340        }
341        let amount = money(ch.amount);
342        // Tenancy (ADR-0029), ID-only pattern — see `propose_match`; the charge tx below relays
343        // the ambient request org scope.
344        let row = self
345            .repos
346            .transactions
347            .fetch_charge_line(&self.rpool(), ch.bank_transaction_id)
348            .await?
349            .ok_or(BankingError::TransactionNotFound(ch.bank_transaction_id))?;
350        let currency: String = row.currency;
351        if currency != "IDR" {
352            return Err(BankingError::UnsupportedCurrency(currency));
353        }
354        let company_id = legacy_company_echo();
355        let allocated = row.allocated_amount;
356        let line_net = row.deposit + row.withdrawal;
357        let bank_acct = row.gl_account_id;
358        if amount > line_net - allocated {
359            return Err(BankingError::OverAllocated {
360                line_net,
361                already: allocated,
362                attempted: amount,
363            });
364        }
365        let clearance_id = Uuid::new_v4();
366        let env = AccountingPostEnvelope {
367            idempotency_key: format!("bankchg:{}:{}", ch.bank_transaction_id, clearance_id),
368            // The legacy tenancy twin (ADR-0029) — see `clear_transaction`.
369            company_id,
370            branch_id: None,
371            source_type: "settlement".into(),
372            source_id: clearance_id,
373            source_reference: Some(format!("charge {}", ch.bank_transaction_id)),
374            posting_date: ch.clearance_date,
375            currency,
376            posting_type: "original".into(),
377            reverses_post_id: None,
378            description: Some("Bank charge".into()),
379            lines: vec![
380                GlPostLine::debit(ch.charge_account_id, amount).with_description("Bank charges"),
381                GlPostLine::credit(bank_acct, amount).with_description("Bank"),
382            ],
383        };
384        if !env.is_balanced() {
385            return Err(BankingError::UnbalancedPost);
386        }
387        match sink.post(&env).await {
388            Ok(ack) => {
389                let mut tx = self.rpool().begin().await?;
390                // Tenancy (ADR-0029): relay the AMBIENT request org scope — see `clear_transaction`.
391                if let Some(scope) = org_scope::current_org_scope() {
392                    org_scope::bind_org_scope_on(&mut tx, &scope).await?;
393                    backbone_orm::audit_context::relay_ambient_audit_on(&mut tx).await?;
394                }
395                self.repos
396                    .clearances
397                    .insert_charge_clearance(
398                        &mut tx,
399                        &NewChargeClearanceRow {
400                            id: clearance_id,
401                            bank_transaction_id: ch.bank_transaction_id,
402                            charge_account_id: ch.charge_account_id,
403                            matched_amount: amount,
404                            clearance_date: ch.clearance_date,
405                            accounting_post_id: ack.post_id,
406                            journal_id: ack.journal_id,
407                        },
408                    )
409                    .await?;
410                let new_alloc = allocated + amount;
411                let fully = new_alloc >= line_net;
412                self.repos
413                    .transactions
414                    .set_allocation(
415                        &mut tx,
416                        ch.bank_transaction_id,
417                        new_alloc,
418                        if fully {
419                            TxnStatus::Reconciled
420                        } else {
421                            TxnStatus::PartlyReconciled
422                        },
423                    )
424                    .await?;
425                tx.commit().await?;
426                self.sink
427                    .publish(BankingEvent::BankChargeRecognized(BankChargeRecognized {
428                        bank_transaction_id: ch.bank_transaction_id,
429                        company_id,
430                        amount,
431                        journal_id: ack.journal_id,
432                        post_id: ack.post_id,
433                    }));
434                Ok(ClearOutcome {
435                    clearance_id,
436                    post_id: ack.post_id,
437                    journal_id: ack.journal_id,
438                    fully_reconciled: fully,
439                })
440            }
441            Err(rej) => Err(BankingError::GlRejected {
442                code: rej.code,
443                message: rej.message,
444            }),
445        }
446    }
447}