Skip to main content

backbone_pos/infrastructure/persistence/
pos_invoice_item_repository.rs

1//! Repository for PosInvoiceItem 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 ticket-line SQL (4-layer rule: services orchestrate, repos hold SQL).
6//!
7//! Thin newtype over `backbone_orm::GenericCrudRepository<PosInvoiceItem, backbone_orm::SoftDelete>`.
8//! All standard CRUD methods are available via `Deref`.
9//!
10//! Tenancy is composition-installed (ADR-0029): the line write runs on the CALLER'S scope-relayed
11//! transaction; the pool reads ride the ambient org scope's request-dedicated connection — the
12//! 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::PosInvoiceItem;
26
27/// Table name for PosInvoiceItem entities
28pub const TABLE_NAME: &str = "pos.pos_invoice_items";
29
30/// Repository for PosInvoiceItem entities.
31///
32/// All standard CRUD, soft-delete, pagination, and bulk methods are
33/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
34pub struct PosInvoiceItemRepository(
35    backbone_orm::GenericCrudRepository<PosInvoiceItem, backbone_orm::SoftDelete>,
36);
37
38impl std::ops::Deref for PosInvoiceItemRepository {
39    type Target = backbone_orm::GenericCrudRepository<PosInvoiceItem, backbone_orm::SoftDelete>;
40    fn deref(&self) -> &Self::Target { &self.0 }
41}
42
43impl PosInvoiceItemRepository {
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 ticket line writes. Mirrors the raw column shape rather than the `PosInvoiceItem`
51/// entity: `net_amount` is the already-computed line net (qty x price - discount), not re-derived here.
52pub struct NewInvoiceItemRow<'a> {
53    pub id: Uuid,
54    pub pos_invoice_id: Uuid,
55    /// The line's offline-sync identity (None for server-originated rings).
56    pub client_uuid: Option<Uuid>,
57    pub item_id: Uuid,
58    pub description: Option<&'a str>,
59    /// Course grouping for kitchen routing (restaurant): lines sharing a course number fire
60    /// together; NULL = not course-grouped (counter sale).
61    pub course: Option<i32>,
62    pub quantity: Decimal,
63    pub unit_price: Decimal,
64    pub discount_amount: Decimal,
65    pub net_amount: Decimal,
66    pub revenue_account_id: Option<Uuid>,
67}
68
69/// A ticket line as the billing hand-off reads it: the net to invoice + its revenue account.
70pub struct RevenueLineRow {
71    pub item_id: Uuid,
72    pub net_amount: Decimal,
73    pub revenue_account_id: Option<Uuid>,
74}
75
76/// A ticket line as the stock issue reads it: what to relieve from the warehouse.
77pub struct QuantityLineRow {
78    pub item_id: Uuid,
79    pub quantity: Decimal,
80}
81
82/// A ticket line as the printed receipt reads it.
83pub struct ReceiptLineRow {
84    pub description: Option<String>,
85    pub item_id: Uuid,
86    /// Course grouping (restaurant) — surfaced so kitchen-display / receipt consumers can group.
87    pub course: Option<i32>,
88    pub quantity: Decimal,
89    pub unit_price: Decimal,
90    pub discount_amount: Decimal,
91    pub net_amount: Decimal,
92}
93
94/// Hand-written ticket-line SQL. Lives here (not in the write service) per the module's 4-layer rule:
95/// services orchestrate and own the unit of work, repositories hold the SQL.
96impl PosInvoiceItemRepository {
97    /// Insert one ticket line.
98    ///
99    /// Takes the CALLER'S connection so every line and its invoice header commit as one unit — a ticket
100    /// is never half-rung. The caller has already relayed the ambient org scope onto it — don't
101    /// re-bind here.
102    pub async fn insert_line(
103        &self,
104        conn: &mut sqlx::PgConnection,
105        l: &NewInvoiceItemRow<'_>,
106    ) -> Result<(), sqlx::Error> {
107        sqlx::query(
108            r#"INSERT INTO pos.pos_invoice_items
109                (id, pos_invoice_id, client_uuid, item_id, description, course, quantity, unit_price, discount_amount, net_amount, revenue_account_id)
110               VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)"#,
111        )
112        .bind(l.id).bind(l.pos_invoice_id).bind(l.client_uuid).bind(l.item_id).bind(l.description).bind(l.course).bind(l.quantity)
113        .bind(l.unit_price).bind(l.discount_amount).bind(l.net_amount).bind(l.revenue_account_id)
114        .execute(conn)
115        .await?;
116        Ok(())
117    }
118
119    /// Read the lines the billing hand-off invoices.
120    ///
121    /// ID-only: rides the ambient org scope's request-dedicated connection, so another unit's lines
122    /// simply are not found. A non-request caller (the recognition sink driven by
123    /// `PosTenderCompleted`) MUST wrap this in the composing service's org request scope — bound
124    /// from the event's `company_id` twin (the acting unit) — otherwise it fails closed and returns
125    /// no lines.
126    pub async fn fetch_revenue_lines(
127        &self,
128        pool: &PgPool,
129        pos_invoice_id: Uuid,
130    ) -> Result<Vec<RevenueLineRow>, sqlx::Error> {
131        let rows = fetch_all_rows_scoped(
132            pool,
133            sqlx::query(
134                "SELECT item_id, net_amount, revenue_account_id FROM pos.pos_invoice_items WHERE pos_invoice_id=$1 AND (metadata->>'deleted_at') IS NULL",
135            )
136            .bind(pos_invoice_id),
137        )
138        .await?;
139        Ok(rows.iter().map(|r| RevenueLineRow {
140            item_id: r.get("item_id"), net_amount: r.get("net_amount"),
141            revenue_account_id: r.get("revenue_account_id"),
142        }).collect())
143    }
144
145    /// Read the lines the stock issue relieves. Same ID-only scope contract as
146    /// [`Self::fetch_revenue_lines`].
147    pub async fn fetch_quantity_lines(
148        &self,
149        pool: &PgPool,
150        pos_invoice_id: Uuid,
151    ) -> Result<Vec<QuantityLineRow>, sqlx::Error> {
152        let rows = fetch_all_rows_scoped(
153            pool,
154            sqlx::query(
155                "SELECT item_id, quantity FROM pos.pos_invoice_items WHERE pos_invoice_id=$1 AND (metadata->>'deleted_at') IS NULL",
156            )
157            .bind(pos_invoice_id),
158        )
159        .await?;
160        Ok(rows.iter().map(|r| QuantityLineRow {
161            item_id: r.get("item_id"), quantity: r.get("quantity"),
162        }).collect())
163    }
164
165    /// Retire every live line of a ticket (soft delete — the audit trail stays, the offline-sync
166    /// uniqueness on `client_uuid` is freed for the replay's replacement lines).
167    ///
168    /// Takes the CALLER'S connection so the retire and its replacements commit as one unit. The
169    /// caller has already relayed the ambient org scope onto it.
170    pub async fn soft_delete_lines_for_ticket(
171        &self,
172        conn: &mut sqlx::PgConnection,
173        pos_invoice_id: Uuid,
174        now: chrono::DateTime<chrono::Utc>,
175    ) -> Result<(), sqlx::Error> {
176        sqlx::query(
177            r#"UPDATE pos.pos_invoice_items
178               SET metadata = jsonb_set(COALESCE(metadata, '{}'::jsonb), '{deleted_at}', to_jsonb($2::timestamptz))
179               WHERE pos_invoice_id=$1 AND (metadata->>'deleted_at') IS NULL"#,
180        )
181        .bind(pos_invoice_id)
182        .bind(now)
183        .execute(conn)
184        .await?;
185        Ok(())
186    }
187
188    /// Read the lines for the printed receipt, in ring order. Same ID-only scope contract as
189    /// [`Self::fetch_revenue_lines`].
190    pub async fn fetch_receipt_lines(
191        &self,
192        pool: &PgPool,
193        pos_invoice_id: Uuid,
194    ) -> Result<Vec<ReceiptLineRow>, sqlx::Error> {
195        let rows = fetch_all_rows_scoped(
196            pool,
197            sqlx::query(
198                "SELECT description, item_id, course, quantity, unit_price, discount_amount, net_amount
199                 FROM pos.pos_invoice_items WHERE pos_invoice_id=$1 AND (metadata->>'deleted_at') IS NULL
200                 ORDER BY (metadata->>'created_at')",
201            )
202            .bind(pos_invoice_id),
203        )
204        .await?;
205        Ok(rows.iter().map(|r| ReceiptLineRow {
206            description: r.get("description"), item_id: r.get("item_id"), course: r.get("course"),
207            quantity: r.get("quantity"),
208            unit_price: r.get("unit_price"), discount_amount: r.get("discount_amount"),
209            net_amount: r.get("net_amount"),
210        }).collect())
211    }
212}
213
214backbone_core::impl_crud_repository!(PosInvoiceItemRepository, PosInvoiceItem, soft_delete);