backbone_banking/infrastructure/persistence/bank_clearance_repository.rs
1//! Repository for BankClearance entities
2//!
3//! Originally generated by metaphor-schema; now **user-owned** — this exact path is declared under
4//! `user_owned` in `metaphor.codegen.yaml`, so the generator skips it wholesale. The custom methods
5//! below hold the hand-written BankClearance SQL — the clearance inserts plus the settlement-dimension
6//! bound's advisory lock and already-cleared SUM (4-layer rule: services orchestrate, repos hold SQL).
7//!
8//! Thin newtype over `backbone_orm::GenericCrudRepository<BankClearance, backbone_orm::SoftDelete>`.
9//! All standard CRUD methods are available via `Deref`.
10
11use anyhow::Result;
12use rust_decimal::Decimal;
13use sqlx::{PgPool, Row};
14use uuid::Uuid;
15
16use backbone_orm::org_scope;
17
18use crate::domain::entity::BankClearance;
19
20/// Table name for BankClearance entities
21pub const TABLE_NAME: &str = "banking.bank_clearances";
22
23/// Repository for BankClearance entities.
24///
25/// All standard CRUD, soft-delete, pagination, and bulk methods are
26/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
27pub struct BankClearanceRepository(
28 backbone_orm::GenericCrudRepository<BankClearance, backbone_orm::SoftDelete>,
29);
30
31impl std::ops::Deref for BankClearanceRepository {
32 type Target = backbone_orm::GenericCrudRepository<BankClearance, backbone_orm::SoftDelete>;
33 fn deref(&self) -> &Self::Target {
34 &self.0
35 }
36}
37
38impl BankClearanceRepository {
39 /// Create a new repository instance.
40 pub fn new(pool: PgPool) -> Self {
41 Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
42 }
43}
44
45/// The exact row a clearance writes.
46///
47/// Mirrors the raw column shape rather than the `BankClearance` entity: `matched_source_type` and
48/// `match_method` bind as `&str` and are cast at the DB (`$3::matched_source_type`,
49/// `$6::match_method`), so a bad value fails as a DB error rather than a deserialize panic.
50/// `match_method` is already defaulted by the caller, so it is not optional here. The post/journal ids
51/// come from the GL's ack — a clearance is only ever written against an accepted posting.
52pub struct NewClearanceRow<'a> {
53 pub id: Uuid,
54 pub bank_transaction_id: Uuid,
55 pub matched_source_type: &'a str,
56 pub matched_source_id: Uuid,
57 pub matched_amount: Decimal,
58 pub match_method: &'a str,
59 pub clearance_date: chrono::NaiveDate,
60 pub accounting_post_id: Uuid,
61 pub journal_id: Uuid,
62}
63
64/// The exact row a bank-charge clearance writes.
65///
66/// Deliberately NOT the same shape as [`NewClearanceRow`]: a charge hard-codes
67/// `'charge'::matched_source_type` and `'manual'::match_method`, and its `matched_source_id` is the
68/// CHARGE ACCOUNT rather than a settled document.
69pub struct NewChargeClearanceRow {
70 pub id: Uuid,
71 pub bank_transaction_id: Uuid,
72 /// The expense account the charge is booked to — a charge settles no document.
73 pub charge_account_id: Uuid,
74 pub matched_amount: Decimal,
75 pub clearance_date: chrono::NaiveDate,
76 pub accounting_post_id: Uuid,
77 pub journal_id: Uuid,
78}
79
80/// Hand-written BankClearance SQL. Lives here (not in the write service) per the module's 4-layer
81/// rule. The settlement-dimension DECISION (does this clearance overrun the settled document?) stays
82/// in the service — this layer only locks and sums.
83impl BankClearanceRepository {
84 /// Serialize clearances against one settlement with an advisory xact lock, so two concurrent
85 /// Sum of cleared amounts per payment, over a candidate set — the reconcile-preset ordering's
86 /// open-amount input. Reads banking's OWN clearance table (the same amounts the clear verb's
87 /// settlement bound counts), so ordering and clearing can never disagree about what is open.
88 /// Pool-based read; an empty id list returns an empty map.
89 ///
90 /// Tenancy (ADR-0029): when the request carries an ambient org scope it is relayed onto a short
91 /// read transaction (`bind_org_scope_on`), so the composing service's row-level fence applies —
92 /// an unscoped read under a live fence would return an empty cleared-map and let candidate
93 /// ordering present fully-open amounts for payments already cleared. Unfenced deployments read
94 /// the pool plain. (backbone_orm's org scope offers no fetch-all helper, so the scoped path is a
95 /// short read-only transaction here.)
96 pub async fn sums_cleared_for_payments(
97 &self,
98 pool: &PgPool,
99 payment_ids: &[Uuid],
100 ) -> Result<std::collections::HashMap<Uuid, Decimal>, sqlx::Error> {
101 if payment_ids.is_empty() {
102 return Ok(std::collections::HashMap::new());
103 }
104 let query = sqlx::query(
105 r#"SELECT matched_source_id, SUM(matched_amount) AS cleared
106 FROM banking.bank_clearances
107 WHERE matched_source_type='payment' AND matched_source_id = ANY($1)
108 GROUP BY matched_source_id"#,
109 )
110 .bind(payment_ids);
111 let rows = if let Some(scope) = org_scope::current_org_scope() {
112 let mut tx = pool.begin().await?;
113 org_scope::bind_org_scope_on(&mut tx, &scope).await?;
114 backbone_orm::audit_context::relay_ambient_audit_on(&mut tx).await?;
115 let rows = query.fetch_all(&mut *tx).await?;
116 tx.commit().await?; // read-only: nothing but the relayed scope to close out
117 rows
118 } else {
119 query.fetch_all(pool).await?
120 };
121 Ok(rows
122 .into_iter()
123 .map(|r| {
124 let id: Uuid = r.get("matched_source_id");
125 let cleared: Decimal = r.get("cleared");
126 (id, cleared)
127 })
128 .collect())
129 }
130
131 /// first-clears cannot race the [`Self::sum_cleared_against_settlement`] phantom-insert.
132 ///
133 /// Takes the CALLER'S connection: an xact lock is released at commit, so it MUST be taken on the
134 /// same transaction that runs the SUM and writes the clearance.
135 ///
136 /// The lock keys on the settlement id alone: a settlement id is a globally unique UUID, so two
137 /// operators — in any unit — clearing the same settlement serialize against each other. The org
138 /// fence itself is the composing service's tenancy decorator (ADR-0029), not this lock.
139 pub async fn lock_settlement(
140 &self,
141 conn: &mut sqlx::PgConnection,
142 matched_source_id: Uuid,
143 ) -> Result<(), sqlx::Error> {
144 sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))")
145 .bind(matched_source_id)
146 .execute(conn)
147 .await?;
148 Ok(())
149 }
150
151 /// Sum every clearance already booked against one settlement — the settlement-dimension bound's
152 /// input, which is what stops one payment being cleared twice (a re-imported line, a retry, two
153 /// operators).
154 ///
155 /// Takes the CALLER'S connection: it must read under the same transaction (and the same
156 /// [`Self::lock_settlement`] lock) as the clearance it guards. The caller relays the ambient org
157 /// scope onto that tx first, so a decorated deployment's row-level fence decides which
158 /// clearances the SUM sees.
159 ///
160 /// The settlement grain needs no extra filter: a settlement id is a globally unique UUID, so
161 /// `(matched_source_type, matched_source_id)` already selects exactly one settlement's
162 /// clearances, wherever they were written.
163 pub async fn sum_cleared_against_settlement(
164 &self,
165 conn: &mut sqlx::PgConnection,
166 matched_source_type: &str,
167 matched_source_id: Uuid,
168 ) -> Result<Decimal, sqlx::Error> {
169 sqlx::query_scalar(
170 "SELECT COALESCE(SUM(matched_amount),0) FROM banking.bank_clearances WHERE matched_source_type=$1::matched_source_type AND matched_source_id=$2 AND (metadata->>'deleted_at') IS NULL",
171 )
172 .bind(matched_source_type).bind(matched_source_id)
173 .fetch_one(conn)
174 .await
175 }
176
177 /// Record a clearance against a settled document.
178 ///
179 /// Takes the CALLER'S connection so the clearance and the line's allocation watermark commit as
180 /// one unit — and so the bound check above stays in the same transaction as this write. The
181 /// caller relays the ambient org scope onto it — don't re-bind here.
182 pub async fn insert_clearance(
183 &self,
184 conn: &mut sqlx::PgConnection,
185 c: &NewClearanceRow<'_>,
186 ) -> Result<(), sqlx::Error> {
187 sqlx::query(
188 r#"INSERT INTO banking.bank_clearances
189 (id, bank_transaction_id, matched_source_type, matched_source_id,
190 matched_amount, match_method, clearance_date, accounting_post_id, journal_id)
191 VALUES ($1,$2,$3::matched_source_type,$4,$5,$6::match_method,$7,$8,$9)"#,
192 )
193 .bind(c.id)
194 .bind(c.bank_transaction_id)
195 .bind(c.matched_source_type)
196 .bind(c.matched_source_id)
197 .bind(c.matched_amount)
198 .bind(c.match_method)
199 .bind(c.clearance_date)
200 .bind(c.accounting_post_id)
201 .bind(c.journal_id)
202 .execute(conn)
203 .await?;
204 Ok(())
205 }
206
207 /// Record a bank charge as a clearance. Same caller-owned-tx contract as
208 /// [`Self::insert_clearance`].
209 pub async fn insert_charge_clearance(
210 &self,
211 conn: &mut sqlx::PgConnection,
212 c: &NewChargeClearanceRow,
213 ) -> Result<(), sqlx::Error> {
214 sqlx::query(
215 r#"INSERT INTO banking.bank_clearances
216 (id, bank_transaction_id, matched_source_type, matched_source_id,
217 matched_amount, match_method, clearance_date, accounting_post_id, journal_id)
218 VALUES ($1,$2,'charge'::matched_source_type,$3,$4,'manual'::match_method,$5,$6,$7)"#,
219 )
220 .bind(c.id).bind(c.bank_transaction_id).bind(c.charge_account_id)
221 .bind(c.matched_amount).bind(c.clearance_date).bind(c.accounting_post_id).bind(c.journal_id)
222 .execute(conn)
223 .await?;
224 Ok(())
225 }
226}
227
228backbone_core::impl_crud_repository!(BankClearanceRepository, BankClearance, soft_delete);