Skip to main content

backbone_banking/application/service/
reconcile_preset_candidates.rs

1//! Reconcile-preset candidate ordering — a READ, never a match (hand-authored, user-owned).
2//!
3//! An `impl BankingWriteService` chunk over the vocabulary in [`super::banking_write_service`].
4//! A [`ReconcilePreset`](crate::domain::entity::ReconcilePreset) ranks the open settlements a
5//! statement line could clear against, with a human-readable reason per candidate. Matching stays
6//! where it was: the operator confirms through the existing `clear_transaction` verb. This path is
7//! **side-effect-free by construction** — it takes no sink, writes nothing, and advances no
8//! allocation watermark; the ordering probe pins that.
9//!
10//! The candidate POOL comes from [`CandidatePoolPort`]. Banking never imports payment (zero cargo
11//! edge); the default [`PaymentPoolRead`] reads the posted, still-open payment headers through a
12//! `to_regclass`-guarded cross-schema query (the reconcilability-read precedent), and the composing
13//! host may inject its own. The open amount of each candidate is computed from BANKING's own
14//! `bank_clearances` — the same already-cleared sum the clear verb bounds against — so ordering and
15//! clearing can never disagree about what is open.
16//!
17//! Signals: `reference_exact`, `amount_exact`, `amount_within_tolerance`, and `days_window` rank
18//! from the line + candidate data banking holds. `party` is vocabulary-only for now — a statement
19//! line carries no counterparty until a composition supplies one, so that preset ranks nothing and
20//! says so in its reason (an empty lie would be worse).
21
22use rust_decimal::{Decimal, RoundingStrategy};
23use sqlx::{PgPool, Row};
24use uuid::Uuid;
25
26use backbone_orm::org_scope;
27
28use super::banking_write_service::{BankingError, BankingWriteService};
29
30/// One open settlement the line might clear against, as the ordering sees it.
31#[derive(Debug, Clone)]
32pub struct OpenCandidate {
33    pub payment_id: Uuid,
34    pub payment_number: String,
35    pub paid_amount: Decimal,
36    pub posting_date: chrono::NaiveDate,
37    pub reference_no: Option<String>,
38}
39
40/// A ranked candidate, as the route returns it.
41#[derive(Debug, Clone, PartialEq)]
42pub struct RankedCandidate {
43    pub payment_id: Uuid,
44    pub payment_number: String,
45    pub open_amount: Decimal,
46    pub posting_date: chrono::NaiveDate,
47    pub score: i64,
48    pub reason: String,
49}
50
51/// The line side of the ordering, read from banking's own statement tables.
52#[derive(Debug, Clone)]
53pub struct CandidateLineBasis {
54    pub bank_account_id: Uuid,
55    pub deposit: Decimal,
56    pub withdrawal: Decimal,
57    pub txn_date: chrono::NaiveDate,
58    pub reference_no: Option<String>,
59}
60
61/// Where the candidate pool comes from. Injectable so module tests (and non-payment compositions)
62/// substitute their own; the default is the guarded payment-header read.
63#[async_trait::async_trait]
64pub trait CandidatePoolPort: Send + Sync {
65    async fn open_candidates(
66        &self,
67        pool: &PgPool,
68        bank_account_id: Uuid,
69    ) -> Result<Vec<OpenCandidate>, BankingError>;
70}
71
72/// The default pool: posted payments against the same bank account, read from
73/// `payment.payment_entries` through a `to_regclass` guard. Schema absent (standalone module
74/// database, mis-composed host) ⇒ REFUSE — ranking against a pool banking cannot see would present
75/// fiction as candidates (fail-closed, the reconcilability-read precedent).
76pub struct PaymentPoolRead;
77
78#[async_trait::async_trait]
79impl CandidatePoolPort for PaymentPoolRead {
80    async fn open_candidates(
81        &self,
82        pool: &PgPool,
83        bank_account_id: Uuid,
84    ) -> Result<Vec<OpenCandidate>, BankingError> {
85        let present: Option<String> =
86            sqlx::query_scalar("SELECT to_regclass('payment.payment_entries')::text")
87                .fetch_optional(pool)
88                .await
89                .map_err(BankingError::Db)?
90                .flatten();
91        if present.is_none() {
92            return Err(BankingError::CandidatePoolRefused(
93                "payment.payment_entries is absent — the candidate pool cannot be read".into(),
94            ));
95        }
96        // Tenancy (ADR-0029): relay the AMBIENT request org scope onto a short transaction and
97        // read there. A raw pool fetch binds nothing on the connection, so whichever fence the
98        // payment schema carries (its legacy company lane today, its decorator's org fence after
99        // its own strip) would filter every row and the pool would read as empty — the ambient
100        // scope binds all the fence variables the remote schema evaluates. No scope bound
101        // (undecorated deployment) reads unfenced, by design.
102        //
103        // Join-key note: payment's `bank_account_id` is a GL ACCOUNT reference (its model says
104        // "Bank/Cash GL account"), not a banking bank-account id — comparing the two ids directly
105        // matches nothing, ever. A payment destined for this bank account touches one of ITS two
106        // GL accounts: a cash-style payment lands on the bank GL, a transfer sits in the clearing
107        // GL until a clearance moves it. The pool therefore matches payments whose GL is either.
108        let mut tx = pool.begin().await.map_err(BankingError::Db)?;
109        if let Some(scope) = org_scope::current_org_scope() {
110            org_scope::bind_org_scope_on(&mut tx, &scope)
111                .await
112                .map_err(BankingError::Db)?;
113            backbone_orm::audit_context::relay_ambient_audit_on(&mut tx).await?;
114        }
115        let rows = sqlx::query(
116            r#"SELECT id, payment_number, paid_amount, posting_date, reference_no
117                   FROM payment.payment_entries
118                   WHERE bank_account_id IN (
119                       SELECT gl_account_id FROM banking.bank_accounts WHERE id=$1
120                       UNION
121                       SELECT clearing_account_id FROM banking.bank_accounts WHERE id=$1
122                     )
123                     AND posting_state='posted' AND status IN ('in_flight','paid')
124                     AND (metadata->>'deleted_at') IS NULL"#,
125        )
126        .bind(bank_account_id)
127        .fetch_all(&mut *tx)
128        .await
129        .map_err(BankingError::Db)?;
130        tx.commit().await.map_err(BankingError::Db)?;
131        Ok(rows
132            .into_iter()
133            .map(|r| OpenCandidate {
134                payment_id: r.get("id"),
135                payment_number: r.get("payment_number"),
136                paid_amount: r.get("paid_amount"),
137                posting_date: r.get("posting_date"),
138                reference_no: r.get("reference_no"),
139            })
140            .collect())
141    }
142}
143
144impl BankingWriteService {
145    /// Order the candidates for one statement line under one preset. Pure ranking over reads —
146    /// no writes, no watermark, no events. Deterministic: equal scores break on `payment_number`
147    /// ascending, so the same committed state always yields the same list (the ordering probe's
148    /// pin).
149    pub async fn order_candidates(
150        &self,
151        preset_id: Uuid,
152        line_id: Uuid,
153    ) -> Result<Vec<RankedCandidate>, BankingError> {
154        // Preset (fenced read, ADR-0029): the read rides the request-dedicated connection carrying
155        // the composing service's org scope, so the decorator's row-level fence decides visibility —
156        // another unit's preset is indistinguishable from absence, which maps to the 404 a missing
157        // preset owes the caller (not the 500 a RowNotFound would surface).
158        let preset = org_scope::fetch_optional_row_scoped(
159            &self.rpool(),
160            sqlx::query(
161                r#"SELECT name, match_on::text AS mo, tolerance_percent, days_window, status::text AS st
162                   FROM banking.reconcile_presets WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
163            )
164            .bind(preset_id),
165        )
166        .await
167        .map_err(BankingError::Db)?
168        .ok_or(BankingError::PresetNotFound(preset_id))?;
169        let preset_status: String = preset.get("st");
170        if preset_status != "active" {
171            return Err(BankingError::PresetInactive(preset_id));
172        }
173        let preset_name: String = preset.get("name");
174        let match_on: String = preset.get("mo");
175        let tolerance: Option<Decimal> = preset.get("tolerance_percent");
176        let days_window: Option<i32> = preset.get("days_window");
177
178        // Line (fenced read, same discipline).
179        let row = self
180            .repos
181            .transactions
182            .fetch_candidate_basis(&self.rpool(), line_id)
183            .await?
184            .ok_or(BankingError::TransactionNotFound(line_id))?;
185        let line = CandidateLineBasis {
186            bank_account_id: row.bank_account_id,
187            deposit: row.deposit,
188            withdrawal: row.withdrawal,
189            txn_date: row.txn_date,
190            reference_no: row.reference_no,
191        };
192
193        // Pool + per-candidate already-cleared (banking's own table — same sum the clear verb uses).
194        let pool_rows = self
195            .candidates
196            .open_candidates(&self.rpool(), line.bank_account_id)
197            .await?;
198        let ids: Vec<Uuid> = pool_rows.iter().map(|c| c.payment_id).collect();
199        let cleared = self
200            .repos
201            .clearances
202            .sums_cleared_for_payments(&self.rpool(), &ids)
203            .await?;
204
205        let line_amount = if line.deposit > Decimal::ZERO {
206            line.deposit
207        } else {
208            line.withdrawal
209        };
210        let mut ranked: Vec<RankedCandidate> = Vec::new();
211        for c in pool_rows {
212            let already = cleared.get(&c.payment_id).copied().unwrap_or(Decimal::ZERO);
213            let open = c.paid_amount - already;
214            if open <= Decimal::ZERO {
215                continue; // fully cleared against banking's own watermark — not a candidate
216            }
217            let (score, reason) = match match_on.as_str() {
218                "reference_exact" => {
219                    let hit = line
220                        .reference_no
221                        .as_deref()
222                        .zip(c.reference_no.as_deref())
223                        .map(|(l, p)| l == p)
224                        .unwrap_or(false);
225                    if !hit {
226                        continue;
227                    }
228                    (
229                        100,
230                        format!(
231                            "reference_exact: line ref '{}' equals payment ref '{}'",
232                            line.reference_no.clone().unwrap_or_default(),
233                            c.reference_no.clone().unwrap_or_default()
234                        ),
235                    )
236                }
237                "amount_exact" => {
238                    if open != line_amount {
239                        continue;
240                    }
241                    (
242                        90,
243                        format!("amount_exact: open {open} equals line amount {line_amount}"),
244                    )
245                }
246                "amount_within_tolerance" => {
247                    let tol = tolerance.ok_or(BankingError::PresetMissingTolerance(preset_id))?;
248                    if line_amount <= Decimal::ZERO {
249                        continue;
250                    }
251                    let deviation = ((open - line_amount).abs() / line_amount * Decimal::from(100))
252                        .round_dp_with_strategy(6, RoundingStrategy::MidpointAwayFromZero);
253                    if deviation > tol {
254                        continue;
255                    }
256                    (
257                        80,
258                        format!(
259                            "amount_within_tolerance: open {open} deviates {deviation}% from line {line_amount} (tolerance {tol}%)"
260                        ),
261                    )
262                }
263                "days_window" => {
264                    let window =
265                        days_window.ok_or(BankingError::PresetMissingDaysWindow(preset_id))? as i64;
266                    let delta = (c.posting_date - line.txn_date).num_days().abs();
267                    if delta > window {
268                        continue;
269                    }
270                    (70, format!("days_window: payment dated {} is {delta}d from line date {} (window {window}d)", c.posting_date, line.txn_date))
271                }
272                // Vocabulary-only until a composition supplies line counterparties: rank nothing,
273                // say why — an empty silent list reads as "no candidates", which would be a lie.
274                "party" => {
275                    continue;
276                }
277                other => return Err(BankingError::UnknownMatchOn(other.to_string())),
278            };
279            ranked.push(RankedCandidate {
280                payment_id: c.payment_id,
281                payment_number: c.payment_number,
282                open_amount: open,
283                posting_date: c.posting_date,
284                score,
285                reason: format!("[{preset_name}] {reason}"),
286            });
287        }
288        ranked.sort_by(|a, b| {
289            b.score
290                .cmp(&a.score)
291                .then_with(|| a.payment_number.cmp(&b.payment_number))
292        });
293        Ok(ranked)
294    }
295}