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}