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}