Skip to main content

backbone_pos/infrastructure/persistence/
pos_invoice_repository.rs

1//! Repository for PosInvoice 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 counter-ticket SQL (4-layer rule: services orchestrate, repos hold SQL).
6//!
7//! Thin newtype over `backbone_orm::GenericCrudRepository<PosInvoice, backbone_orm::SoftDelete>`.
8//! All standard CRUD methods are available via `Deref`.
9//!
10//! Tenancy is composition-installed (ADR-0029): pool reads ride the ambient org scope's
11//! request-dedicated connection (fail-narrow, never leaky — a row the scope cannot see is simply
12//! absent); connection-taking methods run on the CALLER'S open, scope-relayed transaction.
13
14use anyhow::Result;
15use rust_decimal::Decimal;
16use sqlx::{PgPool, Row};
17use uuid::Uuid;
18
19use backbone_orm::org_scope;
20// The row read twins ride the org-scope module (no scope invented); the scalar twins
21// still live only in the legacy `company_scope` module — what this repository needs
22// from both is the same connection discipline, the request-dedicated connection when
23// the composing service bound one, plain pool otherwise (ADR-0029).
24use backbone_orm::company_scope::{fetch_one_scalar_scoped, fetch_optional_scalar_scoped};
25use backbone_orm::org_scope::fetch_one_row_scoped;
26
27use crate::domain::entity::PosInvoice;
28
29/// Table name for PosInvoice entities
30pub const TABLE_NAME: &str = "pos.pos_invoices";
31
32/// Repository for PosInvoice entities.
33///
34/// All standard CRUD, soft-delete, pagination, and bulk methods are
35/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
36pub struct PosInvoiceRepository(
37    backbone_orm::GenericCrudRepository<PosInvoice, backbone_orm::SoftDelete>,
38);
39
40impl std::ops::Deref for PosInvoiceRepository {
41    type Target = backbone_orm::GenericCrudRepository<PosInvoice, backbone_orm::SoftDelete>;
42    fn deref(&self) -> &Self::Target { &self.0 }
43}
44
45impl PosInvoiceRepository {
46    /// Create a new repository instance.
47    pub fn new(pool: PgPool) -> Self {
48        Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
49    }
50}
51
52/// The exact row a rung ticket writes. Mirrors the raw column shape rather than the `PosInvoice`
53/// entity: the money fields are already computed by the service, and `paid_total`/`change_due`/
54/// `is_return`/`status` are fixed by the SQL (`0,0,false,'draft'`). `client_uuid` carries the offline
55/// sync identity when the ticket is being replayed from a client (server-originated rings pass None).
56/// `pos_table_id` seats the ticket at a dining table (restaurant lanes); counter sales pass None.
57pub struct NewDraftInvoiceRow<'a> {
58    pub id: Uuid,
59    pub client_uuid: Option<Uuid>,
60    pub pos_profile_id: Uuid,
61    pub opening_entry_id: Uuid,
62    pub branch_id: Option<Uuid>,
63    pub customer_id: Option<Uuid>,
64    pub pos_table_id: Option<Uuid>,
65    pub receipt_number: &'a str,
66    pub posting_at: chrono::NaiveDateTime,
67    pub net_total: Decimal,
68    pub tax_total: Decimal,
69    pub grand_total: Decimal,
70    pub rounding_adjustment: Decimal,
71    pub rounded_total: Decimal,
72}
73
74/// A ticket looked up by its offline-sync identity — everything `sync_from_ui` needs to decide
75/// create-vs-update-vs-replay: identity anchors (session, partner), the refund lineage, and the
76/// persisted money (returned as the replay's totals when the ticket is already finalized).
77pub struct SyncLookupRow {
78    pub id: Uuid,
79    pub status: String,
80    pub pos_profile_id: Uuid,
81    pub opening_entry_id: Uuid,
82    pub branch_id: Option<Uuid>,
83    pub customer_id: Option<Uuid>,
84    /// The table the draft is currently seated at (for transfer validation on replay).
85    pub pos_table_id: Option<Uuid>,
86    pub is_return: bool,
87    /// For a refund ticket: the server id of the parent it was recorded against.
88    pub return_against: Option<Uuid>,
89    pub net_total: Decimal,
90    pub tax_total: Decimal,
91    pub grand_total: Decimal,
92    pub rounding_adjustment: Decimal,
93    pub rounded_total: Decimal,
94    pub paid_total: Decimal,
95    pub change_due: Decimal,
96}
97
98/// The exact row a return ticket writes. Mirrors the raw column shape: the SQL fixes
99/// `rounding_adjustment` to 0, binds `rounded_total` to both the rounded and paid totals ($12), fixes
100/// `change_due` to 0, and fixes `is_return`/`status` to `true`/`'returned'`.
101pub struct NewReturnInvoiceRow {
102    pub id: Uuid,
103    pub pos_profile_id: Uuid,
104    pub opening_entry_id: Uuid,
105    pub branch_id: Option<Uuid>,
106    pub customer_id: Option<Uuid>,
107    pub receipt_number: String,
108    pub posting_at: chrono::DateTime<chrono::Utc>,
109    pub net_total: Decimal,
110    pub tax_total: Decimal,
111    pub grand_total: Decimal,
112    pub rounded_total: Decimal,
113    pub billing_invoice_id: Uuid,
114    pub return_against: Uuid,
115}
116
117/// A ticket header as `add_tender` reads it: the draft gate and the target total.
118pub struct TenderHeaderRow {
119    pub status: String,
120    pub rounded_total: Decimal,
121}
122
123/// A ticket's already-recognised state, as the recognise short-circuit reads it.
124pub struct PaidStateRow {
125    pub status: String,
126    pub billing_invoice_id: Option<Uuid>,
127    pub payment_entry_id: Option<Uuid>,
128}
129
130/// Everything recognition needs about a draft ticket + its register, in one read: the money to settle,
131/// the accounts to post to, and the stock dimensions to relieve.
132pub struct RecognitionRow {
133    pub customer_id: Option<Uuid>,
134    pub branch_id: Option<Uuid>,
135    pub posting_at: chrono::DateTime<chrono::Utc>,
136    pub rounded_total: Decimal,
137    pub grand_total: Decimal,
138    pub tax_total: Decimal,
139    pub paid_total: Decimal,
140    pub billing_invoice_id: Option<Uuid>,
141    pub receivable_account_id: Option<Uuid>,
142    pub income_account_id: Option<Uuid>,
143    pub cash_account_id: Option<Uuid>,
144    pub tax_account_id: Option<Uuid>,
145    pub tax_rate: Decimal,
146    pub warehouse_id: Option<Uuid>,
147    pub cogs_account_id: Option<Uuid>,
148    pub inventory_account_id: Option<Uuid>,
149    pub currency: String,
150    pub default_customer_id: Option<Uuid>,
151}
152
153/// A ticket's state and money as a sync replay found it, under its row lock.
154pub struct SyncLockRow {
155    pub status: String,
156    pub paid_total: Decimal,
157    pub rounded_total: Decimal,
158}
159
160/// What a paid ticket's stock relief reads (see `fetch_for_stock_relief`).
161pub struct StockReliefRow {
162    pub stock_delivery_note_id: Option<Uuid>,
163    pub branch_id: Option<Uuid>,
164    pub customer_id: Option<Uuid>,
165    pub posting_at: chrono::DateTime<chrono::Utc>,
166    pub warehouse_id: Option<Uuid>,
167    pub cogs_account_id: Option<Uuid>,
168    pub inventory_account_id: Option<Uuid>,
169}
170
171/// The original ticket as the returns path reads it — the source of every field the return ticket
172/// copies, plus the tender/invoice refs to reverse.
173pub struct ReturnSourceRow {
174    pub pos_profile_id: Uuid,
175    pub opening_entry_id: Uuid,
176    pub branch_id: Option<Uuid>,
177    pub customer_id: Option<Uuid>,
178    pub billing_invoice_id: Option<Uuid>,
179    pub payment_entry_id: Option<Uuid>,
180    pub rounded_total: Decimal,
181    pub net_total: Decimal,
182    pub tax_total: Decimal,
183    pub grand_total: Decimal,
184    pub posting_at: chrono::DateTime<chrono::Utc>,
185    pub status: String,
186}
187
188/// A ticket header as the printed receipt reads it (register name/currency joined in). `tax_rate` is
189/// the EFFECTIVE rate derived from the ticket's own totals — the register's flat `tax_rate` column is
190/// retired from the compute path, so the slip reports what the document-grade compute actually
191/// produced. `is_invoiced` derives from the billing link (never a stored state column).
192pub struct ReceiptHeaderRow {
193    pub receipt_number: String,
194    pub posting_at: chrono::DateTime<chrono::Utc>,
195    pub net_total: Decimal,
196    pub tax_total: Decimal,
197    pub rounding_adjustment: Decimal,
198    pub rounded_total: Decimal,
199    pub paid_total: Decimal,
200    pub change_due: Decimal,
201    pub status: String,
202    pub register_name: String,
203    pub currency: String,
204    pub tax_rate: Decimal,
205    pub is_invoiced: bool,
206}
207
208/// Hand-written counter-ticket SQL. Lives here (not in the write service) per the module's 4-layer
209/// rule: services orchestrate and own the unit of work, repositories hold the SQL.
210impl PosInvoiceRepository {
211    /// Insert a rung ticket in `draft`.
212    ///
213    /// Takes the CALLER'S connection so the header and its lines commit as one unit. The caller has
214    /// already relayed the ambient org scope onto it — don't re-bind here.
215    ///
216    /// Returns the raw `sqlx::Error` deliberately: the caller inspects it for a unique violation to turn
217    /// a re-used receipt number (or a re-used sync `client_uuid`) into a domain error.
218    pub async fn insert_draft(
219        &self,
220        conn: &mut sqlx::PgConnection,
221        i: &NewDraftInvoiceRow<'_>,
222    ) -> Result<(), sqlx::Error> {
223        sqlx::query(
224            r#"INSERT INTO pos.pos_invoices
225                (id, client_uuid, pos_profile_id, opening_entry_id, branch_id, customer_id, pos_table_id, receipt_number,
226                 posting_at, net_total, tax_total, grand_total, rounding_adjustment, rounded_total,
227                 paid_total, change_due, is_return, status)
228               VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,0,0,false,'draft'::pos_invoice_status)"#,
229        )
230        .bind(i.id).bind(i.client_uuid).bind(i.pos_profile_id).bind(i.opening_entry_id).bind(i.branch_id)
231        .bind(i.customer_id).bind(i.pos_table_id).bind(i.receipt_number).bind(i.posting_at).bind(i.net_total).bind(i.tax_total)
232        .bind(i.grand_total).bind(i.rounding_adjustment).bind(i.rounded_total)
233        .execute(conn)
234        .await?;
235        Ok(())
236    }
237
238    /// Find the live ticket carrying an offline client uuid. The uuid only namespaces inside the
239    /// caller's scope: the ambient org scope's fence is the whole guard — another unit's ticket
240    /// carrying the same uuid is simply not found.
241    pub async fn find_by_client_uuid(
242        &self,
243        pool: &PgPool,
244        client_uuid: Uuid,
245    ) -> Result<Option<SyncLookupRow>, sqlx::Error> {
246        let row = org_scope::fetch_optional_row_scoped(
247            pool,
248            sqlx::query(
249                r#"SELECT id, status::text AS st, pos_profile_id, opening_entry_id, branch_id, customer_id,
250                          pos_table_id, is_return, return_against, net_total, tax_total, grand_total, rounding_adjustment,
251                          rounded_total, paid_total, change_due
252                   FROM pos.pos_invoices WHERE client_uuid=$1 AND (metadata->>'deleted_at') IS NULL"#,
253            )
254            .bind(client_uuid),
255        )
256        .await?;
257        Ok(row.map(|r| SyncLookupRow {
258            id: r.get("id"), status: r.get("st"), pos_profile_id: r.get("pos_profile_id"),
259            opening_entry_id: r.get("opening_entry_id"), branch_id: r.get("branch_id"),
260            customer_id: r.get("customer_id"), pos_table_id: r.get("pos_table_id"), is_return: r.get("is_return"),
261            return_against: r.get("return_against"), net_total: r.get("net_total"),
262            tax_total: r.get("tax_total"), grand_total: r.get("grand_total"),
263            rounding_adjustment: r.get("rounding_adjustment"), rounded_total: r.get("rounded_total"),
264            paid_total: r.get("paid_total"), change_due: r.get("change_due"),
265        }))
266    }
267
268    /// Rewrite a DRAFT ticket's header for an offline replay: re-point it at the (possibly rescued)
269    /// session, re-seat it at the replay's table (a changed `pos_table_id` is a table TRANSFER), and
270    /// replace its server-derived money + partner with the replay's re-derived values.
271    /// State-guarded on `draft` — a finalized ticket is never rewritten (the replay short-circuits
272    /// before this). Takes the CALLER'S connection; the caller has already relayed the ambient scope.
273    /// Returns the raw `sqlx::Error` so the caller can map the one-draft-per-table partial unique.
274    pub async fn update_draft_from_sync(
275        &self,
276        conn: &mut sqlx::PgConnection,
277        pos_invoice_id: Uuid,
278        opening_entry_id: Uuid,
279        branch_id: Option<Uuid>,
280        customer_id: Option<Uuid>,
281        pos_table_id: Option<Uuid>,
282        net_total: Decimal,
283        tax_total: Decimal,
284        grand_total: Decimal,
285        rounding_adjustment: Decimal,
286        rounded_total: Decimal,
287        paid_total: Decimal,
288        change_due: Decimal,
289    ) -> Result<u64, sqlx::Error> {
290        let res = sqlx::query(
291            r#"UPDATE pos.pos_invoices SET
292                 opening_entry_id=$2, branch_id=$3, customer_id=$4, pos_table_id=$5,
293                 net_total=$6, tax_total=$7, grand_total=$8, rounding_adjustment=$9,
294                 rounded_total=$10, paid_total=$11, change_due=$12
295               WHERE id=$1 AND status='draft'::pos_invoice_status"#,
296        )
297        .bind(pos_invoice_id).bind(opening_entry_id).bind(branch_id).bind(customer_id).bind(pos_table_id)
298        .bind(net_total).bind(tax_total).bind(grand_total).bind(rounding_adjustment)
299        .bind(rounded_total).bind(paid_total).bind(change_due)
300        .execute(conn)
301        .await?;
302        Ok(res.rows_affected())
303    }
304
305    /// Read and LOCK a ticket a sync replay is about to rewrite: its state and the money it held
306    /// before the replay. Taken first in the replay's transaction, so recognition cannot flip the
307    /// ticket to paid between the replay's check and its rewrite, and the replay knows whether it
308    /// is the one that completes the payment.
309    pub async fn lock_for_sync_on(
310        &self,
311        conn: &mut sqlx::PgConnection,
312        pos_invoice_id: Uuid,
313    ) -> Result<Option<SyncLockRow>, sqlx::Error> {
314        let row = sqlx::query(
315            "SELECT status::text AS st, paid_total, rounded_total FROM pos.pos_invoices WHERE id=$1 AND (metadata->>'deleted_at') IS NULL FOR UPDATE",
316        )
317        .bind(pos_invoice_id)
318        .fetch_optional(conn)
319        .await?;
320        Ok(row.map(|r| SyncLockRow {
321            status: r.get("st"), paid_total: r.get("paid_total"), rounded_total: r.get("rounded_total"),
322        }))
323    }
324
325    /// The draft ticket currently seated at a dining table — the friendly half of the
326    /// one-draft-per-table guard (the DB partial unique `idx_pos_invoices_pos_table_id` is the
327    /// backstop; this read turns the collision into a typed refusal naming the occupying ticket).
328    /// `exclude_invoice_id` skips the ticket being re-seated (a replay that keeps its own table is
329    /// not a collision). `Ok(None)` = the table is free. Takes the CALLER'S scope-relayed
330    /// connection — the fence is the whole guard.
331    pub async fn find_draft_on_table(
332        &self,
333        conn: &mut sqlx::PgConnection,
334        pos_table_id: Uuid,
335        exclude_invoice_id: Option<Uuid>,
336    ) -> Result<Option<Uuid>, sqlx::Error> {
337        let row = sqlx::query(
338            r#"SELECT id FROM pos.pos_invoices
339               WHERE pos_table_id=$1 AND status='draft'::pos_invoice_status
340                 AND (metadata->>'deleted_at') IS NULL AND ($2::uuid IS NULL OR id <> $2)
341               LIMIT 1"#,
342        )
343        .bind(pos_table_id)
344        .bind(exclude_invoice_id)
345        .fetch_optional(conn)
346        .await?;
347        Ok(row.map(|r| r.get("id")))
348    }
349
350    /// The pool twin of [`Self::find_draft_on_table`] for the duplicate-mapping enrichment: after a
351    /// failed insert the caller's transaction is ABORTED, so the occupying ticket must be read on a
352    /// fresh connection. Rides the ambient org scope (ADR-0029) — under a composed fence the read
353    /// stays inside the acting unit, and a fence-blind read would enrich the 409 with a nil id.
354    pub async fn find_draft_on_table_scoped(
355        &self,
356        pool: &PgPool,
357        pos_table_id: Uuid,
358        exclude_invoice_id: Option<Uuid>,
359    ) -> Result<Option<Uuid>, sqlx::Error> {
360        let row = org_scope::fetch_optional_row_scoped(
361            pool,
362            sqlx::query(
363                r#"SELECT id FROM pos.pos_invoices
364                   WHERE pos_table_id=$1 AND status='draft'::pos_invoice_status
365                     AND (metadata->>'deleted_at') IS NULL AND ($2::uuid IS NULL OR id <> $2)
366                   LIMIT 1"#,
367            )
368            .bind(pos_table_id)
369            .bind(exclude_invoice_id),
370        )
371        .await?;
372        Ok(row.map(|r| r.get("id")))
373    }
374
375    /// Soft-delete a DRAFT ticket's lines so an offline replay can rewrite the basket (full-snapshot
376    /// semantics: the replay carries the complete ticket). Soft-delete keeps the audit trail and frees
377    /// the partial-unique on `client_uuid`. Takes the CALLER'S connection.
378    pub async fn soft_delete_lines_for_ticket(
379        &self,
380        conn: &mut sqlx::PgConnection,
381        pos_invoice_id: Uuid,
382        now: chrono::DateTime<chrono::Utc>,
383    ) -> Result<(), sqlx::Error> {
384        sqlx::query(
385            r#"UPDATE pos.pos_invoice_items SET metadata = jsonb_set(metadata, '{deleted_at}', to_jsonb($2::timestamptz))
386               WHERE pos_invoice_id=$1 AND (metadata->>'deleted_at') IS NULL"#,
387        )
388        .bind(pos_invoice_id).bind(now)
389        .execute(conn)
390        .await?;
391        Ok(())
392    }
393
394    /// Count a session's DRAFT tickets — the close guard. ID-only: rides the ambient org scope.
395    pub async fn count_draft_orders_for_session(
396        &self,
397        pool: &PgPool,
398        opening_entry_id: Uuid,
399    ) -> Result<i64, sqlx::Error> {
400        fetch_one_scalar_scoped(
401            pool,
402            sqlx::query_scalar(
403                "SELECT COUNT(*) FROM pos.pos_invoices WHERE opening_entry_id=$1 AND status='draft'::pos_invoice_status AND is_return=false AND (metadata->>'deleted_at') IS NULL",
404            )
405            .bind(opening_entry_id),
406        )
407        .await
408    }
409
410    /// Count a session's half-recognised tickets — billing linked but the draft→paid flip never
411    /// landed (a settle failed after the invoice was raised). The close guard refuses on these: they
412    /// need recognition retried, not skipped. ID-only scope as above.
413    pub async fn count_linked_unposted_for_session(
414        &self,
415        pool: &PgPool,
416        opening_entry_id: Uuid,
417    ) -> Result<i64, sqlx::Error> {
418        fetch_one_scalar_scoped(
419            pool,
420            sqlx::query_scalar(
421                "SELECT COUNT(*) FROM pos.pos_invoices WHERE opening_entry_id=$1 AND status='draft'::pos_invoice_status AND billing_invoice_id IS NOT NULL AND (metadata->>'deleted_at') IS NULL",
422            )
423            .bind(opening_entry_id),
424        )
425        .await
426    }
427
428    /// Read and LOCK the header `add_tender` gates on.
429    ///
430    /// The row lock is what makes a tender one step: two tenders on the same ticket queue, so the
431    /// second re-sums with the first one's row in it, and the paid flip (an UPDATE of this row)
432    /// waits for a tender in flight, after which the next tender sees `paid` and is refused.
433    ///
434    /// ID-only: rides the ambient org scope's request-dedicated connection, so a ticket the scope
435    /// cannot see simply is not found. The caller runs this on its own scope-relayed transaction
436    /// (`fetch_tender_header_on` shape inlined: same statement, caller's connection).
437    pub async fn fetch_tender_header(
438        &self,
439        conn: &mut sqlx::PgConnection,
440        pos_invoice_id: Uuid,
441    ) -> Result<Option<TenderHeaderRow>, sqlx::Error> {
442        let row = sqlx::query(
443            "SELECT status::text AS st, rounded_total FROM pos.pos_invoices WHERE id=$1 AND (metadata->>'deleted_at') IS NULL FOR UPDATE",
444        )
445        .bind(pos_invoice_id)
446        .fetch_optional(conn)
447        .await?;
448        Ok(row.map(|r| TenderHeaderRow {
449            status: r.get("st"), rounded_total: r.get("rounded_total"),
450        }))
451    }
452
453    /// Write back a ticket's recomputed tender totals. Takes the CALLER'S connection so this and the
454    /// tender insert commit as one unit; the caller has already relayed the ambient scope on it.
455    pub async fn update_tender_totals(
456        &self,
457        conn: &mut sqlx::PgConnection,
458        pos_invoice_id: Uuid,
459        paid_total: Decimal,
460        change_due: Decimal,
461    ) -> Result<(), sqlx::Error> {
462        sqlx::query("UPDATE pos.pos_invoices SET paid_total=$2, change_due=$3 WHERE id=$1")
463            .bind(pos_invoice_id).bind(paid_total).bind(change_due)
464            .execute(conn)
465            .await?;
466        Ok(())
467    }
468
469    /// Read a ticket's recognised state, for the recognise short-circuit. Same ID-only scope contract
470    /// as [`Self::fetch_tender_header`].
471    pub async fn fetch_paid_state(
472        &self,
473        pool: &PgPool,
474        pos_invoice_id: Uuid,
475    ) -> Result<Option<PaidStateRow>, sqlx::Error> {
476        let row = org_scope::fetch_optional_row_scoped(
477            pool,
478            sqlx::query(
479                "SELECT status::text AS st, billing_invoice_id, payment_entry_id FROM pos.pos_invoices WHERE id=$1 AND (metadata->>'deleted_at') IS NULL",
480            )
481            .bind(pos_invoice_id),
482        )
483        .await?;
484        Ok(row.map(|r| PaidStateRow {
485            status: r.get("st"), billing_invoice_id: r.get("billing_invoice_id"),
486            payment_entry_id: r.get("payment_entry_id"),
487        }))
488    }
489
490    /// Read a DRAFT ticket joined to its register — everything recognition needs to drive billing,
491    /// payment, and the optional stock issue. `Ok(None)` = absent, already recognised, or outside the
492    /// caller's scope.
493    ///
494    /// ID-only: rides the ambient org scope. Under HTTP the request-dedicated connection carries the
495    /// scope. When driven by an EVENT (the recognition sink subscribing to `PosTenderCompleted`), the
496    /// caller MUST wrap this in the composing service's org request scope — bound from the event's
497    /// `company_id` twin (the acting unit) — otherwise this read fails closed.
498    pub async fn fetch_for_recognition(
499        &self,
500        pool: &PgPool,
501        pos_invoice_id: Uuid,
502    ) -> Result<Option<RecognitionRow>, sqlx::Error> {
503        let row = org_scope::fetch_optional_row_scoped(
504            pool,
505            sqlx::query(
506                r#"SELECT i.customer_id, i.pos_profile_id, i.branch_id, i.posting_at, i.rounded_total, i.grand_total,
507                          i.tax_total, i.paid_total, i.billing_invoice_id,
508                          p.receivable_account_id, p.income_account_id, p.cash_account_id, p.tax_account_id,
509                          ROUND(COALESCE(i.tax_total / NULLIF(i.net_total, 0), 0), 6) AS tax_rate,
510                          p.warehouse_id, p.cogs_account_id, p.inventory_account_id,
511                          p.currency, p.default_customer_id
512                   FROM pos.pos_invoices i JOIN pos.pos_profiles p ON p.id = i.pos_profile_id
513                   WHERE i.id=$1 AND i.status='draft'::pos_invoice_status AND (i.metadata->>'deleted_at') IS NULL"#,
514            )
515            .bind(pos_invoice_id),
516        )
517        .await?;
518        Ok(row.map(|r| RecognitionRow {
519            customer_id: r.get("customer_id"), branch_id: r.get("branch_id"),
520            posting_at: r.get("posting_at"), rounded_total: r.get("rounded_total"), grand_total: r.get("grand_total"),
521            tax_total: r.get("tax_total"), paid_total: r.get("paid_total"),
522            billing_invoice_id: r.get("billing_invoice_id"),
523            receivable_account_id: r.get("receivable_account_id"), income_account_id: r.get("income_account_id"),
524            cash_account_id: r.get("cash_account_id"), tax_account_id: r.get("tax_account_id"),
525            tax_rate: r.get("tax_rate"), warehouse_id: r.get("warehouse_id"), cogs_account_id: r.get("cogs_account_id"),
526            inventory_account_id: r.get("inventory_account_id"), currency: r.get("currency"),
527            default_customer_id: r.get("default_customer_id"),
528        }))
529    }
530
531    /// Persist the raised Sales Invoice onto the STILL-DRAFT ticket, before settle.
532    ///
533    /// This is billing's at-most-once gate (council 2026-07-05): writing the link the instant
534    /// `raise_and_post` returns makes a retry after a settle failure (declined card / closed period)
535    /// REUSE the invoice instead of raising a second one — otherwise the counter's ordinary
536    /// decline-then-retry doubles the revenue journal. State-guarded on `draft` and on no invoice
537    /// being linked yet; returns the rows affected, so a caller that finds 0 knows another
538    /// recognition linked first. Rides the ambient org scope.
539    pub async fn link_billing_invoice(
540        &self,
541        pool: &PgPool,
542        pos_invoice_id: Uuid,
543        billing_invoice_id: Uuid,
544    ) -> Result<u64, sqlx::Error> {
545        let res = org_scope::execute_scoped(
546            pool,
547            sqlx::query("UPDATE pos.pos_invoices SET billing_invoice_id=$2 WHERE id=$1 AND status='draft'::pos_invoice_status AND billing_invoice_id IS NULL")
548                .bind(pos_invoice_id).bind(billing_invoice_id),
549        )
550        .await?;
551        Ok(res.rows_affected())
552    }
553
554    /// What a paid ticket's stock relief needs: the register's stock seats, the ticket's
555    /// counterparty and date, and the delivery note already recorded, if any. `Ok(None)` = the
556    /// ticket is not paid (or not visible to the scope). Rides the ambient org scope.
557    pub async fn fetch_for_stock_relief(
558        &self,
559        pool: &PgPool,
560        pos_invoice_id: Uuid,
561    ) -> Result<Option<StockReliefRow>, sqlx::Error> {
562        let row = org_scope::fetch_optional_row_scoped(
563            pool,
564            sqlx::query(
565                r#"SELECT i.stock_delivery_note_id, i.branch_id, COALESCE(i.customer_id, p.default_customer_id) AS customer_id,
566                          i.posting_at, p.warehouse_id, p.cogs_account_id, p.inventory_account_id
567                   FROM pos.pos_invoices i JOIN pos.pos_profiles p ON p.id = i.pos_profile_id
568                   WHERE i.id=$1 AND i.status='paid'::pos_invoice_status AND (i.metadata->>'deleted_at') IS NULL"#,
569            )
570            .bind(pos_invoice_id),
571        )
572        .await?;
573        Ok(row.map(|r| StockReliefRow {
574            stock_delivery_note_id: r.get("stock_delivery_note_id"), branch_id: r.get("branch_id"),
575            customer_id: r.get("customer_id"), posting_at: r.get("posting_at"),
576            warehouse_id: r.get("warehouse_id"), cogs_account_id: r.get("cogs_account_id"),
577            inventory_account_id: r.get("inventory_account_id"),
578        }))
579    }
580
581    /// Record the delivery note that relieved a paid ticket's stock. Guarded on none being
582    /// recorded yet. Rides the ambient org scope.
583    pub async fn link_stock_delivery_note(
584        &self,
585        pool: &PgPool,
586        pos_invoice_id: Uuid,
587        delivery_note_id: Uuid,
588    ) -> Result<u64, sqlx::Error> {
589        let res = org_scope::execute_scoped(
590            pool,
591            sqlx::query("UPDATE pos.pos_invoices SET stock_delivery_note_id=$2 WHERE id=$1 AND stock_delivery_note_id IS NULL")
592                .bind(pos_invoice_id).bind(delivery_note_id),
593        )
594        .await?;
595        Ok(res.rows_affected())
596    }
597
598    /// Flip a ticket draft→paid, persisting both downstream refs. Returns the rows affected: the caller
599    /// gates the `PosInvoicePaid` event on this being 1, so it is published exactly once even if
600    /// recognise is retried.
601    ///
602    /// Persisting `payment_entry_id` in the same flip is what lets a later return refund the tender from
603    /// the ticket (no cross-schema lookup) and gives a crash-retry a durable skip-gate (ADR-001
604    /// `settle`-idempotency). Rides the ambient org scope.
605    pub async fn mark_paid(
606        &self,
607        pool: &PgPool,
608        pos_invoice_id: Uuid,
609        billing_invoice_id: Uuid,
610        payment_entry_id: Uuid,
611    ) -> Result<u64, sqlx::Error> {
612        let res = org_scope::execute_scoped(
613            pool,
614            sqlx::query(
615                "UPDATE pos.pos_invoices SET status='paid'::pos_invoice_status, billing_invoice_id=$2, payment_entry_id=$3 WHERE id=$1 AND status='draft'::pos_invoice_status",
616            )
617            .bind(pos_invoice_id).bind(billing_invoice_id).bind(payment_entry_id),
618        )
619        .await?;
620        Ok(res.rows_affected())
621    }
622
623    /// Read the original ticket the returns path reverses and copies. Same ID-only scope contract as
624    /// [`Self::fetch_tender_header`].
625    pub async fn fetch_return_source(
626        &self,
627        pool: &PgPool,
628        pos_invoice_id: Uuid,
629    ) -> Result<Option<ReturnSourceRow>, sqlx::Error> {
630        let row = org_scope::fetch_optional_row_scoped(
631            pool,
632            sqlx::query(
633                r#"SELECT pos_profile_id, opening_entry_id, branch_id, customer_id, billing_invoice_id,
634                          payment_entry_id, rounded_total, net_total, tax_total, grand_total, posting_at, status::text AS st
635                   FROM pos.pos_invoices WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
636            )
637            .bind(pos_invoice_id),
638        )
639        .await?;
640        Ok(row.map(|r| ReturnSourceRow {
641            pos_profile_id: r.get("pos_profile_id"),
642            opening_entry_id: r.get("opening_entry_id"), branch_id: r.get("branch_id"),
643            customer_id: r.get("customer_id"), billing_invoice_id: r.get("billing_invoice_id"),
644            payment_entry_id: r.get("payment_entry_id"), rounded_total: r.get("rounded_total"),
645            net_total: r.get("net_total"), tax_total: r.get("tax_total"), grand_total: r.get("grand_total"),
646            posting_at: r.get("posting_at"), status: r.get("st"),
647        }))
648    }
649
650    /// Flip the original ticket paid→returned. Returns the rows affected: the caller gates the return
651    /// ticket and the `PosInvoiceReturned` event on this being 1, so a repeat return records at most
652    /// once. Rides the ambient org scope.
653    pub async fn mark_returned(
654        &self,
655        pool: &PgPool,
656        pos_invoice_id: Uuid,
657    ) -> Result<u64, sqlx::Error> {
658        let res = org_scope::execute_scoped(
659            pool,
660            sqlx::query("UPDATE pos.pos_invoices SET status='returned'::pos_invoice_status WHERE id=$1 AND status='paid'::pos_invoice_status")
661                .bind(pos_invoice_id),
662        )
663        .await?;
664        Ok(res.rows_affected())
665    }
666
667    /// Insert the mirror return ticket, linked to the original via `return_against`. Rides the ambient
668    /// org scope.
669    pub async fn insert_return_ticket(
670        &self,
671        pool: &PgPool,
672        r: &NewReturnInvoiceRow,
673    ) -> Result<(), sqlx::Error> {
674        org_scope::execute_scoped(
675            pool,
676            sqlx::query(
677                r#"INSERT INTO pos.pos_invoices
678                    (id, pos_profile_id, opening_entry_id, branch_id, customer_id, receipt_number,
679                     posting_at, net_total, tax_total, grand_total, rounding_adjustment, rounded_total,
680                     paid_total, change_due, billing_invoice_id, is_return, return_against, status)
681                   VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,0,$11,$11,0,$12,true,$13,'returned'::pos_invoice_status)"#,
682            )
683            .bind(r.id).bind(r.pos_profile_id).bind(r.opening_entry_id)
684            .bind(r.branch_id).bind(r.customer_id).bind(&r.receipt_number)
685            .bind(r.posting_at).bind(r.net_total).bind(r.tax_total).bind(r.grand_total).bind(r.rounded_total)
686            .bind(r.billing_invoice_id).bind(r.return_against),
687        )
688        .await?;
689        Ok(())
690    }
691
692    /// Find the return ticket already recorded against an original — the idempotent-replay path when the
693    /// paid→returned flip found nothing to do. Rides the ambient org scope.
694    pub async fn find_return_ticket(
695        &self,
696        pool: &PgPool,
697        original_pos_invoice_id: Uuid,
698    ) -> Result<Option<Uuid>, sqlx::Error> {
699        fetch_optional_scalar_scoped(
700            pool,
701            sqlx::query_scalar(
702                "SELECT id FROM pos.pos_invoices WHERE return_against=$1 AND is_return=true AND (metadata->>'deleted_at') IS NULL LIMIT 1",
703            )
704            .bind(original_pos_invoice_id),
705        )
706        .await
707    }
708
709    /// Sum the cash handed back as change across a session's recognised tickets — it reduces the drawer.
710    /// ID-only: rides the ambient org scope.
711    pub async fn sum_change_due_for_session(
712        &self,
713        pool: &PgPool,
714        opening_entry_id: Uuid,
715    ) -> Result<Decimal, sqlx::Error> {
716        fetch_one_scalar_scoped(
717            pool,
718            sqlx::query_scalar(
719                "SELECT COALESCE(SUM(change_due),0) FROM pos.pos_invoices WHERE opening_entry_id=$1 AND status='paid'::pos_invoice_status AND (metadata->>'deleted_at') IS NULL",
720            )
721            .bind(opening_entry_id),
722        )
723        .await
724    }
725
726    /// A session's recognised turnover + paid-ticket count, for the X/Z report. ID-only: rides the
727    /// ambient org scope.
728    pub async fn session_totals(
729        &self,
730        pool: &PgPool,
731        opening_entry_id: Uuid,
732    ) -> Result<(Decimal, i64), sqlx::Error> {
733        let row = fetch_one_row_scoped(
734            pool,
735            sqlx::query(
736                "SELECT COALESCE(SUM(rounded_total),0), COUNT(*) FROM pos.pos_invoices WHERE opening_entry_id=$1 AND status='paid'::pos_invoice_status AND (metadata->>'deleted_at') IS NULL",
737            )
738            .bind(opening_entry_id),
739        )
740        .await?;
741        Ok((row.get(0), row.get(1)))
742    }
743
744    /// Read a ticket header joined to its register for the printed receipt. `Ok(None)` = no such
745    /// ticket in the caller's scope. Rides the ambient org scope — the fence is the whole guard.
746    pub async fn fetch_receipt_header(
747        &self,
748        pool: &PgPool,
749        pos_invoice_id: Uuid,
750    ) -> Result<Option<ReceiptHeaderRow>, sqlx::Error> {
751        let row = org_scope::fetch_optional_row_scoped(
752            pool,
753            sqlx::query(
754                r#"SELECT i.receipt_number, i.posting_at, i.net_total, i.tax_total, i.rounding_adjustment,
755                          i.rounded_total, i.paid_total, i.change_due, i.status::text AS status,
756                          p.name AS register_name, p.currency,
757                          ROUND(COALESCE(i.tax_total / NULLIF(i.net_total, 0), 0), 6) AS tax_rate,
758                          (i.billing_invoice_id IS NOT NULL) AS is_invoiced
759                   FROM pos.pos_invoices i JOIN pos.pos_profiles p ON p.id = i.pos_profile_id
760                   WHERE i.id=$1 AND (i.metadata->>'deleted_at') IS NULL"#,
761            )
762            .bind(pos_invoice_id),
763        )
764        .await?;
765        Ok(row.map(|r| ReceiptHeaderRow {
766            receipt_number: r.get("receipt_number"), posting_at: r.get("posting_at"),
767            net_total: r.get("net_total"), tax_total: r.get("tax_total"),
768            rounding_adjustment: r.get("rounding_adjustment"), rounded_total: r.get("rounded_total"),
769            paid_total: r.get("paid_total"), change_due: r.get("change_due"), status: r.get("status"),
770            register_name: r.get("register_name"), currency: r.get("currency"), tax_rate: r.get("tax_rate"),
771            is_invoiced: r.get("is_invoiced"),
772        }))
773    }
774}
775
776backbone_core::impl_crud_repository!(PosInvoiceRepository, PosInvoice, soft_delete);