backbone-banking 0.7.1

Cash & bank — bank/account master, statement import, reconciliation, and clearing postings (5th GL producer)
Documentation
//! Repository for BankClearance entities
//!
//! Originally generated by metaphor-schema; now **user-owned** — this exact path is declared under
//! `user_owned` in `metaphor.codegen.yaml`, so the generator skips it wholesale. The custom methods
//! below hold the hand-written BankClearance SQL — the clearance inserts plus the settlement-dimension
//! bound's advisory lock and already-cleared SUM (4-layer rule: services orchestrate, repos hold SQL).
//!
//! Thin newtype over `backbone_orm::GenericCrudRepository<BankClearance, backbone_orm::SoftDelete>`.
//! All standard CRUD methods are available via `Deref`.

use anyhow::Result;
use rust_decimal::Decimal;
use sqlx::{PgPool, Row};
use uuid::Uuid;

use backbone_orm::org_scope;

use crate::domain::entity::BankClearance;

/// Table name for BankClearance entities
pub const TABLE_NAME: &str = "banking.bank_clearances";

/// Repository for BankClearance entities.
///
/// All standard CRUD, soft-delete, pagination, and bulk methods are
/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
pub struct BankClearanceRepository(
    backbone_orm::GenericCrudRepository<BankClearance, backbone_orm::SoftDelete>,
);

impl std::ops::Deref for BankClearanceRepository {
    type Target = backbone_orm::GenericCrudRepository<BankClearance, backbone_orm::SoftDelete>;
    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

impl BankClearanceRepository {
    /// Create a new repository instance.
    pub fn new(pool: PgPool) -> Self {
        Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
    }
}

/// The exact row a clearance writes.
///
/// Mirrors the raw column shape rather than the `BankClearance` entity: `matched_source_type` and
/// `match_method` bind as `&str` and are cast at the DB (`$3::matched_source_type`,
/// `$6::match_method`), so a bad value fails as a DB error rather than a deserialize panic.
/// `match_method` is already defaulted by the caller, so it is not optional here. The post/journal ids
/// come from the GL's ack — a clearance is only ever written against an accepted posting.
pub struct NewClearanceRow<'a> {
    pub id: Uuid,
    pub bank_transaction_id: Uuid,
    pub matched_source_type: &'a str,
    pub matched_source_id: Uuid,
    pub matched_amount: Decimal,
    pub match_method: &'a str,
    pub clearance_date: chrono::NaiveDate,
    pub accounting_post_id: Uuid,
    pub journal_id: Uuid,
}

/// The exact row a bank-charge clearance writes.
///
/// Deliberately NOT the same shape as [`NewClearanceRow`]: a charge hard-codes
/// `'charge'::matched_source_type` and `'manual'::match_method`, and its `matched_source_id` is the
/// CHARGE ACCOUNT rather than a settled document.
pub struct NewChargeClearanceRow {
    pub id: Uuid,
    pub bank_transaction_id: Uuid,
    /// The expense account the charge is booked to — a charge settles no document.
    pub charge_account_id: Uuid,
    pub matched_amount: Decimal,
    pub clearance_date: chrono::NaiveDate,
    pub accounting_post_id: Uuid,
    pub journal_id: Uuid,
}

/// Hand-written BankClearance SQL. Lives here (not in the write service) per the module's 4-layer
/// rule. The settlement-dimension DECISION (does this clearance overrun the settled document?) stays
/// in the service — this layer only locks and sums.
impl BankClearanceRepository {
    /// Serialize clearances against one settlement with an advisory xact lock, so two concurrent
    /// Sum of cleared amounts per payment, over a candidate set — the reconcile-preset ordering's
    /// open-amount input. Reads banking's OWN clearance table (the same amounts the clear verb's
    /// settlement bound counts), so ordering and clearing can never disagree about what is open.
    /// Pool-based read; an empty id list returns an empty map.
    ///
    /// Tenancy (ADR-0029): when the request carries an ambient org scope it is relayed onto a short
    /// read transaction (`bind_org_scope_on`), so the composing service's row-level fence applies —
    /// an unscoped read under a live fence would return an empty cleared-map and let candidate
    /// ordering present fully-open amounts for payments already cleared. Unfenced deployments read
    /// the pool plain. (backbone_orm's org scope offers no fetch-all helper, so the scoped path is a
    /// short read-only transaction here.)
    pub async fn sums_cleared_for_payments(
        &self,
        pool: &PgPool,
        payment_ids: &[Uuid],
    ) -> Result<std::collections::HashMap<Uuid, Decimal>, sqlx::Error> {
        if payment_ids.is_empty() {
            return Ok(std::collections::HashMap::new());
        }
        let query = sqlx::query(
            r#"SELECT matched_source_id, SUM(matched_amount) AS cleared
               FROM banking.bank_clearances
               WHERE matched_source_type='payment' AND matched_source_id = ANY($1)
               GROUP BY matched_source_id"#,
        )
        .bind(payment_ids);
        let rows = if let Some(scope) = org_scope::current_org_scope() {
            let mut tx = pool.begin().await?;
            org_scope::bind_org_scope_on(&mut tx, &scope).await?;
            backbone_orm::audit_context::relay_ambient_audit_on(&mut tx).await?;
            let rows = query.fetch_all(&mut *tx).await?;
            tx.commit().await?; // read-only: nothing but the relayed scope to close out
            rows
        } else {
            query.fetch_all(pool).await?
        };
        Ok(rows
            .into_iter()
            .map(|r| {
                let id: Uuid = r.get("matched_source_id");
                let cleared: Decimal = r.get("cleared");
                (id, cleared)
            })
            .collect())
    }

    /// first-clears cannot race the [`Self::sum_cleared_against_settlement`] phantom-insert.
    ///
    /// Takes the CALLER'S connection: an xact lock is released at commit, so it MUST be taken on the
    /// same transaction that runs the SUM and writes the clearance.
    ///
    /// The lock keys on the settlement id alone: a settlement id is a globally unique UUID, so two
    /// operators — in any unit — clearing the same settlement serialize against each other. The org
    /// fence itself is the composing service's tenancy decorator (ADR-0029), not this lock.
    pub async fn lock_settlement(
        &self,
        conn: &mut sqlx::PgConnection,
        matched_source_id: Uuid,
    ) -> Result<(), sqlx::Error> {
        sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))")
            .bind(matched_source_id)
            .execute(conn)
            .await?;
        Ok(())
    }

    /// Sum every clearance already booked against one settlement — the settlement-dimension bound's
    /// input, which is what stops one payment being cleared twice (a re-imported line, a retry, two
    /// operators).
    ///
    /// Takes the CALLER'S connection: it must read under the same transaction (and the same
    /// [`Self::lock_settlement`] lock) as the clearance it guards. The caller relays the ambient org
    /// scope onto that tx first, so a decorated deployment's row-level fence decides which
    /// clearances the SUM sees.
    ///
    /// The settlement grain needs no extra filter: a settlement id is a globally unique UUID, so
    /// `(matched_source_type, matched_source_id)` already selects exactly one settlement's
    /// clearances, wherever they were written.
    pub async fn sum_cleared_against_settlement(
        &self,
        conn: &mut sqlx::PgConnection,
        matched_source_type: &str,
        matched_source_id: Uuid,
    ) -> Result<Decimal, sqlx::Error> {
        sqlx::query_scalar(
            "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",
        )
        .bind(matched_source_type).bind(matched_source_id)
        .fetch_one(conn)
        .await
    }

    /// Record a clearance against a settled document.
    ///
    /// Takes the CALLER'S connection so the clearance and the line's allocation watermark commit as
    /// one unit — and so the bound check above stays in the same transaction as this write. The
    /// caller relays the ambient org scope onto it — don't re-bind here.
    pub async fn insert_clearance(
        &self,
        conn: &mut sqlx::PgConnection,
        c: &NewClearanceRow<'_>,
    ) -> Result<(), sqlx::Error> {
        sqlx::query(
            r#"INSERT INTO banking.bank_clearances
                (id, bank_transaction_id, matched_source_type, matched_source_id,
                 matched_amount, match_method, clearance_date, accounting_post_id, journal_id)
               VALUES ($1,$2,$3::matched_source_type,$4,$5,$6::match_method,$7,$8,$9)"#,
        )
        .bind(c.id)
        .bind(c.bank_transaction_id)
        .bind(c.matched_source_type)
        .bind(c.matched_source_id)
        .bind(c.matched_amount)
        .bind(c.match_method)
        .bind(c.clearance_date)
        .bind(c.accounting_post_id)
        .bind(c.journal_id)
        .execute(conn)
        .await?;
        Ok(())
    }

    /// Record a bank charge as a clearance. Same caller-owned-tx contract as
    /// [`Self::insert_clearance`].
    pub async fn insert_charge_clearance(
        &self,
        conn: &mut sqlx::PgConnection,
        c: &NewChargeClearanceRow,
    ) -> Result<(), sqlx::Error> {
        sqlx::query(
            r#"INSERT INTO banking.bank_clearances
                (id, bank_transaction_id, matched_source_type, matched_source_id,
                 matched_amount, match_method, clearance_date, accounting_post_id, journal_id)
               VALUES ($1,$2,'charge'::matched_source_type,$3,$4,'manual'::match_method,$5,$6,$7)"#,
        )
        .bind(c.id).bind(c.bank_transaction_id).bind(c.charge_account_id)
        .bind(c.matched_amount).bind(c.clearance_date).bind(c.accounting_post_id).bind(c.journal_id)
        .execute(conn)
        .await?;
        Ok(())
    }
}

backbone_core::impl_crud_repository!(BankClearanceRepository, BankClearance, soft_delete);