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 rides the org-scope module: the request-dedicated connection
20// when the composing service bound one, plain pool otherwise, no scope invented. The
21// legacy `company_scope` twin would open a transaction and bind a company this module
22// no longer sets (ADR-0029 stripped it), so the org-scope twin is the honest read lane.
23use backbone_orm::org_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);