Skip to main content

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);