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