backbone-pos 0.7.1

Point of sale — cashier session, ticket + multi-tender; orchestrates billing (revenue) + payment (settlement), posts no GL directly
Documentation
//! Repository for PosPayment 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 tender SQL (4-layer rule: services orchestrate, repos hold SQL).
//!
//! Thin newtype over `backbone_orm::GenericCrudRepository<PosPayment, backbone_orm::SoftDelete>`.
//! All standard CRUD methods are available via `Deref`.
//!
//! Tenancy is composition-installed (ADR-0029): the tender write and its in-transaction re-sum run
//! on the CALLER'S scope-relayed transaction; the pool reads ride the ambient org scope's
//! request-dedicated connection — the fence is the whole guard, no statement keys on tenancy.

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

// The multi-row read twin rides the org-scope module: the request-dedicated connection
// when the composing service bound one, plain pool otherwise, no scope invented. The
// legacy `company_scope` twin would open a transaction and bind a company this module
// no longer sets (ADR-0029 stripped it), so the org-scope twin is the honest read lane.
use backbone_orm::org_scope::fetch_all_rows_scoped;

use crate::domain::entity::PosPayment;

/// Table name for PosPayment entities
pub const TABLE_NAME: &str = "pos.pos_payments";

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

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

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

/// The exact row a tender writes. Mirrors the raw column shape rather than the `PosPayment` entity:
/// `payment_method` binds as `&str` with a DB-side cast (`$3::pos_payment_method`), so an unknown
/// method fails as a DB error rather than a deserialize panic.
pub struct NewTenderRow<'a> {
    pub id: Uuid,
    pub pos_invoice_id: Uuid,
    /// The tender's offline-sync identity (None for server-originated tenders).
    pub client_uuid: Option<Uuid>,
    pub payment_method: &'a str,
    pub amount: Decimal,
    pub reference_no: Option<&'a str>,
}

/// Sum of tenders taken under one method, for the drawer read.
pub struct MethodTotalRow {
    pub method: String,
    pub total: Decimal,
}

/// A tender as the printed receipt reads it.
pub struct ReceiptTenderRow {
    pub method: String,
    pub amount: Decimal,
}

/// Hand-written tender SQL. Lives here (not in the write service) per the module's 4-layer rule:
/// services orchestrate and own the unit of work, repositories hold the SQL.
impl PosPaymentRepository {
    /// Insert one tender line.
    ///
    /// Takes the CALLER'S connection so the tender and the header's recomputed `paid_total`/`change_due`
    /// commit as one unit. The caller has already relayed the ambient org scope onto it — don't
    /// re-bind here.
    pub async fn insert_tender(
        &self,
        conn: &mut sqlx::PgConnection,
        t: &NewTenderRow<'_>,
    ) -> Result<(), sqlx::Error> {
        sqlx::query(
            r#"INSERT INTO pos.pos_payments (id, pos_invoice_id, client_uuid, payment_method, amount, reference_no)
               VALUES ($1,$2,$3,$4::pos_payment_method,$5,$6)"#,
        )
        .bind(t.id).bind(t.pos_invoice_id).bind(t.client_uuid).bind(t.payment_method).bind(t.amount).bind(t.reference_no)
        .execute(conn)
        .await?;
        Ok(())
    }

    /// Soft-delete a DRAFT ticket's tenders so an offline replay can rewrite them (full-snapshot
    /// semantics: the replay carries the ticket's complete tender set). Soft-delete keeps the audit
    /// trail and frees the partial-unique on `client_uuid`. Takes the CALLER'S connection.
    pub async fn soft_delete_tenders_for_ticket(
        &self,
        conn: &mut sqlx::PgConnection,
        pos_invoice_id: Uuid,
        now: chrono::DateTime<chrono::Utc>,
    ) -> Result<(), sqlx::Error> {
        sqlx::query(
            r#"UPDATE pos.pos_payments SET metadata = jsonb_set(metadata, '{deleted_at}', to_jsonb($2::timestamptz))
               WHERE pos_invoice_id=$1 AND (metadata->>'deleted_at') IS NULL"#,
        )
        .bind(pos_invoice_id).bind(now)
        .execute(conn)
        .await?;
        Ok(())
    }

    /// The payment-method enum's live variants, for tender-method validation (a replay naming an
    /// unknown method is a typed 422, not a DB cast error). A catalog read — runs directly on the
    /// CALLER'S connection inside the replay's validation transaction.
    pub async fn valid_methods(
        &self,
        conn: &mut sqlx::PgConnection,
    ) -> Result<Vec<String>, sqlx::Error> {
        let rows = sqlx::query("SELECT unnest(enum_range(NULL::pos_payment_method))::text AS m")
            .fetch_all(conn)
            .await?;
        Ok(rows.iter().map(|r| r.get::<String, _>("m")).collect())
    }

    /// Re-sum a ticket's tenders. Takes the CALLER'S connection so it reads the tender just inserted in
    /// the same transaction — reading this off the pool would miss the uncommitted row.
    pub async fn sum_paid_total_on(
        &self,
        conn: &mut sqlx::PgConnection,
        pos_invoice_id: Uuid,
    ) -> Result<Decimal, sqlx::Error> {
        sqlx::query_scalar(
            "SELECT COALESCE(SUM(amount),0) FROM pos.pos_payments WHERE pos_invoice_id=$1 AND (metadata->>'deleted_at') IS NULL",
        )
        .bind(pos_invoice_id)
        .fetch_one(conn)
        .await
    }

    /// Sum recognised (paid) tenders per method across a session — the drawer's tender side.
    ///
    /// ID-only: rides the ambient org scope (the fence bounds the session join to the caller's
    /// entitled units).
    pub async fn sum_by_method_for_session(
        &self,
        pool: &PgPool,
        opening_entry_id: Uuid,
    ) -> Result<Vec<MethodTotalRow>, sqlx::Error> {
        let rows = fetch_all_rows_scoped(
            pool,
            sqlx::query(
                r#"SELECT pay.payment_method::text AS method, COALESCE(SUM(pay.amount),0) AS total
                   FROM pos.pos_payments pay JOIN pos.pos_invoices i ON i.id = pay.pos_invoice_id
                   WHERE i.opening_entry_id=$1 AND i.status='paid'::pos_invoice_status AND (pay.metadata->>'deleted_at') IS NULL
                   GROUP BY pay.payment_method"#,
            )
            .bind(opening_entry_id),
        )
        .await?;
        Ok(rows.iter().map(|r| MethodTotalRow {
            method: r.get("method"), total: r.get("total"),
        }).collect())
    }

    /// Read a ticket's tenders for the printed receipt, in tender order. ID-only: rides the ambient
    /// org scope — the fence is the whole guard.
    pub async fn fetch_receipt_tenders(
        &self,
        pool: &PgPool,
        pos_invoice_id: Uuid,
    ) -> Result<Vec<ReceiptTenderRow>, sqlx::Error> {
        let rows = fetch_all_rows_scoped(
            pool,
            sqlx::query(
                "SELECT payment_method::text AS method, amount FROM pos.pos_payments
                 WHERE pos_invoice_id=$1 AND (metadata->>'deleted_at') IS NULL ORDER BY (metadata->>'created_at')",
            )
            .bind(pos_invoice_id),
        )
        .await?;
        Ok(rows.iter().map(|r| ReceiptTenderRow {
            method: r.get("method"), amount: r.get("amount"),
        }).collect())
    }
}

backbone_core::impl_crud_repository!(PosPaymentRepository, PosPayment, soft_delete);