Skip to main content

backbone_pos/infrastructure/persistence/
pos_payment_repository.rs

1//! Repository for PosPayment 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 tender SQL (4-layer rule: services orchestrate, repos hold SQL).
6//!
7//! Thin newtype over `backbone_orm::GenericCrudRepository<PosPayment, backbone_orm::SoftDelete>`.
8//! All standard CRUD methods are available via `Deref`.
9//!
10//! Tenancy is composition-installed (ADR-0029): the tender write and its in-transaction re-sum run
11//! on the CALLER'S scope-relayed transaction; the pool reads ride the ambient org scope's
12//! request-dedicated connection — the fence is the whole guard, no statement keys on tenancy.
13
14use anyhow::Result;
15use rust_decimal::Decimal;
16use sqlx::{PgPool, Row};
17use uuid::Uuid;
18
19// The multi-row read twin lives only in the legacy `company_scope` module; what this repository
20// needs from it is the connection discipline — request-dedicated connection when the composing
21// service bound one, plain pool otherwise. Its legacy task-local branch never fires: this module
22// sets no legacy scope of its own (ADR-0029).
23use backbone_orm::company_scope::fetch_all_rows_scoped;
24
25use crate::domain::entity::PosPayment;
26
27/// Table name for PosPayment entities
28pub const TABLE_NAME: &str = "pos.pos_payments";
29
30/// Repository for PosPayment entities.
31///
32/// All standard CRUD, soft-delete, pagination, and bulk methods are
33/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
34pub struct PosPaymentRepository(
35    backbone_orm::GenericCrudRepository<PosPayment, backbone_orm::SoftDelete>,
36);
37
38impl std::ops::Deref for PosPaymentRepository {
39    type Target = backbone_orm::GenericCrudRepository<PosPayment, backbone_orm::SoftDelete>;
40    fn deref(&self) -> &Self::Target { &self.0 }
41}
42
43impl PosPaymentRepository {
44    /// Create a new repository instance.
45    pub fn new(pool: PgPool) -> Self {
46        Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
47    }
48}
49
50/// The exact row a tender writes. Mirrors the raw column shape rather than the `PosPayment` entity:
51/// `payment_method` binds as `&str` with a DB-side cast (`$3::pos_payment_method`), so an unknown
52/// method fails as a DB error rather than a deserialize panic.
53pub struct NewTenderRow<'a> {
54    pub id: Uuid,
55    pub pos_invoice_id: Uuid,
56    /// The tender's offline-sync identity (None for server-originated tenders).
57    pub client_uuid: Option<Uuid>,
58    pub payment_method: &'a str,
59    pub amount: Decimal,
60    pub reference_no: Option<&'a str>,
61}
62
63/// Sum of tenders taken under one method, for the drawer read.
64pub struct MethodTotalRow {
65    pub method: String,
66    pub total: Decimal,
67}
68
69/// A tender as the printed receipt reads it.
70pub struct ReceiptTenderRow {
71    pub method: String,
72    pub amount: Decimal,
73}
74
75/// Hand-written tender SQL. Lives here (not in the write service) per the module's 4-layer rule:
76/// services orchestrate and own the unit of work, repositories hold the SQL.
77impl PosPaymentRepository {
78    /// Insert one tender line.
79    ///
80    /// Takes the CALLER'S connection so the tender and the header's recomputed `paid_total`/`change_due`
81    /// commit as one unit. The caller has already relayed the ambient org scope onto it — don't
82    /// re-bind here.
83    pub async fn insert_tender(
84        &self,
85        conn: &mut sqlx::PgConnection,
86        t: &NewTenderRow<'_>,
87    ) -> Result<(), sqlx::Error> {
88        sqlx::query(
89            r#"INSERT INTO pos.pos_payments (id, pos_invoice_id, client_uuid, payment_method, amount, reference_no)
90               VALUES ($1,$2,$3,$4::pos_payment_method,$5,$6)"#,
91        )
92        .bind(t.id).bind(t.pos_invoice_id).bind(t.client_uuid).bind(t.payment_method).bind(t.amount).bind(t.reference_no)
93        .execute(conn)
94        .await?;
95        Ok(())
96    }
97
98    /// Soft-delete a DRAFT ticket's tenders so an offline replay can rewrite them (full-snapshot
99    /// semantics: the replay carries the ticket's complete tender set). Soft-delete keeps the audit
100    /// trail and frees the partial-unique on `client_uuid`. Takes the CALLER'S connection.
101    pub async fn soft_delete_tenders_for_ticket(
102        &self,
103        conn: &mut sqlx::PgConnection,
104        pos_invoice_id: Uuid,
105        now: chrono::DateTime<chrono::Utc>,
106    ) -> Result<(), sqlx::Error> {
107        sqlx::query(
108            r#"UPDATE pos.pos_payments SET metadata = jsonb_set(metadata, '{deleted_at}', to_jsonb($2::timestamptz))
109               WHERE pos_invoice_id=$1 AND (metadata->>'deleted_at') IS NULL"#,
110        )
111        .bind(pos_invoice_id).bind(now)
112        .execute(conn)
113        .await?;
114        Ok(())
115    }
116
117    /// The payment-method enum's live variants, for tender-method validation (a replay naming an
118    /// unknown method is a typed 422, not a DB cast error). A catalog read — runs directly on the
119    /// CALLER'S connection inside the replay's validation transaction.
120    pub async fn valid_methods(
121        &self,
122        conn: &mut sqlx::PgConnection,
123    ) -> Result<Vec<String>, sqlx::Error> {
124        let rows = sqlx::query("SELECT unnest(enum_range(NULL::pos_payment_method))::text AS m")
125            .fetch_all(conn)
126            .await?;
127        Ok(rows.iter().map(|r| r.get::<String, _>("m")).collect())
128    }
129
130    /// Re-sum a ticket's tenders. Takes the CALLER'S connection so it reads the tender just inserted in
131    /// the same transaction — reading this off the pool would miss the uncommitted row.
132    pub async fn sum_paid_total_on(
133        &self,
134        conn: &mut sqlx::PgConnection,
135        pos_invoice_id: Uuid,
136    ) -> Result<Decimal, sqlx::Error> {
137        sqlx::query_scalar(
138            "SELECT COALESCE(SUM(amount),0) FROM pos.pos_payments WHERE pos_invoice_id=$1 AND (metadata->>'deleted_at') IS NULL",
139        )
140        .bind(pos_invoice_id)
141        .fetch_one(conn)
142        .await
143    }
144
145    /// Sum recognised (paid) tenders per method across a session — the drawer's tender side.
146    ///
147    /// ID-only: rides the ambient org scope (the fence bounds the session join to the caller's
148    /// entitled units).
149    pub async fn sum_by_method_for_session(
150        &self,
151        pool: &PgPool,
152        opening_entry_id: Uuid,
153    ) -> Result<Vec<MethodTotalRow>, sqlx::Error> {
154        let rows = fetch_all_rows_scoped(
155            pool,
156            sqlx::query(
157                r#"SELECT pay.payment_method::text AS method, COALESCE(SUM(pay.amount),0) AS total
158                   FROM pos.pos_payments pay JOIN pos.pos_invoices i ON i.id = pay.pos_invoice_id
159                   WHERE i.opening_entry_id=$1 AND i.status='paid'::pos_invoice_status AND (pay.metadata->>'deleted_at') IS NULL
160                   GROUP BY pay.payment_method"#,
161            )
162            .bind(opening_entry_id),
163        )
164        .await?;
165        Ok(rows.iter().map(|r| MethodTotalRow {
166            method: r.get("method"), total: r.get("total"),
167        }).collect())
168    }
169
170    /// Read a ticket's tenders for the printed receipt, in tender order. ID-only: rides the ambient
171    /// org scope — the fence is the whole guard.
172    pub async fn fetch_receipt_tenders(
173        &self,
174        pool: &PgPool,
175        pos_invoice_id: Uuid,
176    ) -> Result<Vec<ReceiptTenderRow>, sqlx::Error> {
177        let rows = fetch_all_rows_scoped(
178            pool,
179            sqlx::query(
180                "SELECT payment_method::text AS method, amount FROM pos.pos_payments
181                 WHERE pos_invoice_id=$1 AND (metadata->>'deleted_at') IS NULL ORDER BY (metadata->>'created_at')",
182            )
183            .bind(pos_invoice_id),
184        )
185        .await?;
186        Ok(rows.iter().map(|r| ReceiptTenderRow {
187            method: r.get("method"), amount: r.get("amount"),
188        }).collect())
189    }
190}
191
192backbone_core::impl_crud_repository!(PosPaymentRepository, PosPayment, soft_delete);