Skip to main content

backbone_pos/application/service/
pos_recognition.rs

1//! The retail seam: recognising and returning a sale (hand-authored, user-owned).
2//!
3//! An `impl PosWriteService` chunk over the vocabulary in [`super::pos_write_service`]. POS posts no
4//! GL itself — it drives `backbone-billing` and `backbone-payment` through their ports, and both
5//! directions net cleanly: recognise books `Dr A/R · Cr Revenue` then `Dr Cash · Cr A/R`; return
6//! reverses both. Every state flip is gated on its own `UPDATE ... WHERE status=<prior>` so a retry
7//! recognises/returns at most once.
8//!
9//! Per the module's 4-layer rule this file holds no SQL — the statements live on
10//! `PosInvoiceRepository` / `PosInvoiceItemRepository`. These paths are ID-only (no unit argument),
11//! so their ambient-scope contract is documented on each method and on the repo methods they call.
12//!
13//! Tenancy is composition-installed (ADR-0029): the module is tenant-agnostic, and every read and
14//! write here rides the composing caller's ambient org scope (under HTTP the request-dedicated
15//! connection carries it). The `company_id` fields still on the outbound wires — the stock-issue
16//! request to inventory (which keeps the company axis until its own strip) and the event twins —
17//! are sourced from the ambient scope's legacy company echo.
18
19use rust_decimal::Decimal;
20use uuid::Uuid;
21
22use crate::infrastructure::persistence::NewReturnInvoiceRow;
23
24use super::pos_events::{PosEvent, PosInvoicePaid, PosInvoiceReturned};
25use super::pos_ports::{
26    BillingPort, CreditNoteRequest, InventoryPort, PartialCredit, PaymentPort, RefundRequest,
27    SaleInvoiceRequest, SaleLine, SettlementRequest, StockIssueLine, StockIssueRequest,
28};
29use super::pos_write_service::{legacy_company_echo, PosError, PosWriteService, RecognizeOutcome, ReturnOutcome};
30
31impl PosWriteService {
32    /// Recognise a fully-tendered sale: drive billing (raise + post the Sales Invoice → revenue) then
33    /// payment (settle the `rounded_total` against it → `Dr Cash · Cr A/R`), link the results, mark the
34    /// ticket `paid`, emit `PosInvoicePaid`, and relieve the stock. Idempotent on `status='paid'`.
35    ///
36    /// One recognition per ticket at a time: a transaction-scoped advisory lock on the ticket id is
37    /// held for the whole call, so a second delivery of the same trigger waits, then finds the
38    /// invoice already linked or the ticket already paid, instead of raising a second invoice. A
39    /// row lock would do the same but be held across billing and payment, which write elsewhere.
40    pub async fn recognize_sale(&self, pos_invoice_id: Uuid, billing: &dyn BillingPort, payment: &dyn PaymentPort, inventory: Option<&dyn InventoryPort>) -> Result<RecognizeOutcome, PosError> {
41        let mut guard = self.rpool().begin().await?;
42        sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))")
43            .bind(format!("pos.recognize:{pos_invoice_id}"))
44            .execute(&mut *guard)
45            .await?;
46        let out = self.recognize_sale_locked(pos_invoice_id, billing, payment, inventory).await;
47        // Committing or rolling back releases the lock; neither touches a row.
48        guard.commit().await?;
49        out
50    }
51
52    async fn recognize_sale_locked(&self, pos_invoice_id: Uuid, billing: &dyn BillingPort, payment: &dyn PaymentPort, inventory: Option<&dyn InventoryPort>) -> Result<RecognizeOutcome, PosError> {
53        // Already recognised: the money is booked, and only a relief that has not landed is left.
54        if let Some(o) = self.short_circuit_paid(pos_invoice_id).await? {
55            self.relieve_stock_once(pos_invoice_id, inventory).await?;
56            return Ok(o);
57        }
58        // Tenancy (ADR-0029), ID-only pattern: identified by ticket id alone. Under HTTP the
59        // request-dedicated connection carries the ambient org scope. When driven by an EVENT (the
60        // recognition sink subscribing to PosTenderCompleted), the caller must wrap this in the
61        // composing service's org request scope — bound from the event's `company_id` twin (the
62        // acting unit) — otherwise these reads fail closed.
63        let inv = self.invoices
64            .fetch_for_recognition(&self.rpool(), pos_invoice_id).await?
65            .ok_or(PosError::InvoiceNotFound(pos_invoice_id))?;
66
67        let rounded_total = inv.rounded_total;
68        let paid_total = inv.paid_total;
69        if paid_total < rounded_total {
70            return Err(PosError::NotFullyTendered { rounded_total, paid_total });
71        }
72        let tax_total = inv.tax_total;
73        let tax_account_id = inv.tax_account_id;
74        let tax_rate = inv.tax_rate;
75        // A ticket carrying PPN must have a tax liability account on its register to credit it to.
76        if tax_total > Decimal::ZERO && tax_account_id.is_none() {
77            return Err(PosError::MissingAccount("tax_account_id (register charges PPN but has no tax account)"));
78        }
79        let company_echo = legacy_company_echo();
80        let currency = inv.currency.clone();
81        let receivable: Uuid = inv.receivable_account_id.ok_or(PosError::MissingAccount("receivable_account_id"))?;
82        let income: Option<Uuid> = inv.income_account_id;
83        let cash: Uuid = inv.cash_account_id.ok_or(PosError::MissingAccount("cash_account_id"))?;
84        let customer: Uuid = inv.customer_id
85            .or(inv.default_customer_id)
86            .ok_or(PosError::MissingAccount("customer_id (no ticket or profile default)"))?;
87
88        let posting_at = inv.posting_at;
89        let posting_date = posting_at.date_naive();
90
91        // Billing at-most-once (council 2026-07-05): raise the Sales Invoice only if it was not already
92        // raised on a prior attempt. Persisting `billing_invoice_id` on the still-draft ticket the
93        // instant `raise_and_post` returns makes a retry after a settle failure (declined card / closed
94        // period) REUSE the invoice instead of raising a second one — otherwise the counter's ordinary
95        // decline-then-retry doubles the revenue journal (billing does not dedup on the POS ticket).
96        let existing_billing: Option<Uuid> = inv.billing_invoice_id;
97        let invoice_id = if let Some(id) = existing_billing {
98            id
99        } else {
100            // Sale lines → invoice request (revenue = line account, else profile income).
101            let item_rows = self.items.fetch_revenue_lines(&self.rpool(), pos_invoice_id).await?;
102            let mut lines = Vec::with_capacity(item_rows.len());
103            for r in &item_rows {
104                let rev = r.revenue_account_id.or(income).ok_or(PosError::MissingAccount("revenue account (line or profile income)"))?;
105                // Pass the NET as the unit_price at qty 1 so billing's line net == POS net (discounts already applied).
106                lines.push(SaleLine { item_id: r.item_id, revenue_account_id: rev, quantity: Decimal::ONE, unit_price: r.net_amount });
107            }
108            let ack = billing.raise_and_post(&SaleInvoiceRequest {
109                customer_id: customer, currency: currency.clone(), source_pos_id: pos_invoice_id,
110                receivable_account_id: receivable, posting_date, lines, tax_total, tax_account_id, tax_rate,
111            }).await.map_err(|e| PosError::BillingRejected { code: e.code, message: e.message })?;
112            // Persist the link WHILE STILL DRAFT — before settle — so any retry reuses this invoice.
113            // Under the recognition lock nothing else links meanwhile; a link that does not take
114            // is refused rather than settled against an invoice the ticket does not name.
115            if self.invoices.link_billing_invoice(&self.rpool(), pos_invoice_id, ack.invoice_id).await? != 1 {
116                return Err(PosError::NotDraft);
117            }
118            ack.invoice_id
119        };
120
121        // Settle the rounded_total against the raised invoice (cash sale → A/R nets to zero).
122        let sack = payment.settle(&SettlementRequest {
123            customer_id: customer, currency, invoice_ref: invoice_id,
124            bank_account_id: cash, party_account_id: receivable, posting_date, amount: rounded_total,
125        }).await.map_err(|e| PosError::PaymentRejected { code: e.code, message: e.message })?;
126
127        // Gate the event on THIS invocation performing the draft→paid transition. Persist the settling
128        // `payment_entry_id` in the same flip so a later return refunds the tender from the ticket (no
129        // cross-schema lookup) and a crash-retry has a durable skip-gate (ADR-001 `settle`-idempotency).
130        let rows_affected = self.invoices.mark_paid(&self.rpool(), pos_invoice_id, invoice_id, sack.payment_id).await?;
131        if rows_affected == 1 {
132            self.sink.publish(PosEvent::PosInvoicePaid(PosInvoicePaid {
133                pos_invoice_id, company_id: company_echo, grand_total: inv.grand_total, rounded_total,
134                billing_invoice_id: invoice_id, payment_id: sack.payment_id,
135            }));
136        }
137        // Decrement stock (an outward Delivery Note: on-hand relieved, `Dr COGS · Cr Inventory`)
138        // when the register is stock-tracking. Driven by the ticket's own record of its relief, not
139        // by this call's flip, so a relief refused after the ticket was marked paid is retried by
140        // the next delivery instead of skipped.
141        self.relieve_stock_once(pos_invoice_id, inventory).await?;
142        Ok(RecognizeOutcome { pos_invoice_id, billing_invoice_id: invoice_id, payment_id: sack.payment_id })
143    }
144
145    /// Relieve a paid ticket's stock exactly once. Issues the delivery note when the register is
146    /// stock-tracking and the ticket records none, then records it; a ticket that has one, an
147    /// unpaid ticket, a non-stock register, or a deployment without an inventory port is left as
148    /// is. A refusal propagates, so the caller's retry drives the relief again.
149    async fn relieve_stock_once(&self, pos_invoice_id: Uuid, inventory: Option<&dyn InventoryPort>) -> Result<(), PosError> {
150        let Some(inv_port) = inventory else { return Ok(()) };
151        let Some(t) = self.invoices.fetch_for_stock_relief(&self.rpool(), pos_invoice_id).await? else { return Ok(()) };
152        if t.stock_delivery_note_id.is_some() {
153            return Ok(());
154        }
155        let (Some(warehouse), Some(cogs), Some(inv_acct), Some(customer)) =
156            (t.warehouse_id, t.cogs_account_id, t.inventory_account_id, t.customer_id)
157        else {
158            return Ok(());
159        };
160        let qty_rows = self.items.fetch_quantity_lines(&self.rpool(), pos_invoice_id).await?;
161        let lines: Vec<StockIssueLine> = qty_rows.iter()
162            .map(|r| StockIssueLine { item_id: r.item_id, quantity: r.quantity })
163            .filter(|l| l.quantity > Decimal::ZERO)
164            .collect();
165        if lines.is_empty() {
166            return Ok(());
167        }
168        let ack = inv_port.issue(&StockIssueRequest {
169            company_id: legacy_company_echo(), branch_id: t.branch_id, customer_id: customer,
170            source_pos_id: pos_invoice_id, warehouse_id: warehouse, cogs_account_id: cogs,
171            inventory_account_id: inv_acct, posting_date: t.posting_at.date_naive(), lines,
172        }).await.map_err(|e| PosError::InventoryRejected { code: e.code, message: e.message })?;
173        self.invoices.link_stock_delivery_note(&self.rpool(), pos_invoice_id, ack.delivery_note_id).await?;
174        Ok(())
175    }
176
177    async fn short_circuit_paid(&self, pos_invoice_id: Uuid) -> Result<Option<RecognizeOutcome>, PosError> {
178        let row = self.invoices
179            .fetch_paid_state(&self.rpool(), pos_invoice_id).await?
180            .ok_or(PosError::InvoiceNotFound(pos_invoice_id))?;
181        if row.status == "paid" {
182            if let Some(bid) = row.billing_invoice_id {
183                // Return the real persisted payment_id on replay (was fabricated as Uuid::nil() before
184                // payment_entry_id was persisted — nil only for pre-2026-07-14 legacy tickets).
185                let payment_id = row.payment_entry_id.unwrap_or(Uuid::nil());
186                return Ok(Some(RecognizeOutcome { pos_invoice_id, billing_invoice_id: bid, payment_id }));
187            }
188        }
189        Ok(None)
190    }
191
192    /// Return a recognised sale (council 2026-07-05, the KEEP returns flow). Drives billing (credit
193    /// note — `Dr Revenue · Cr A/R`, invoice → cancelled) and payment (refund — `Dr A/R · Cr Cash`),
194    /// so revenue, cash, and A/R all net back to zero. Records an `is_return` ticket linked via
195    /// `return_against`, flips the original → `returned`, emits `PosInvoiceReturned`. **Idempotent:**
196    /// both downstream reversals are idempotent, and the ticket/event are gated on the original's
197    /// `paid→returned` transition — a repeat return refunds/credits at most once. POS posts no GL.
198    pub async fn return_sale(&self, original_pos_invoice_id: Uuid, billing: &dyn BillingPort, payment: &dyn PaymentPort, partial: Option<PartialCredit>) -> Result<ReturnOutcome, PosError> {
199        // Forward-compatible seam (council 2026-07-26, #5): the optional line-subset is accepted but
200        // not yet honored — partial returns need backbone-billing's credit note to take a line subset
201        // (ADR-001 park; gate: merchant demand). Today only full-ticket returns are supported.
202        if partial.is_some() {
203            return Err(PosError::PartialReturnsNotImplemented);
204        }
205        // Tenancy (ADR-0029), ID-only pattern — see `recognize_sale`: the lookup rides the ambient
206        // org scope, so another unit's ticket is simply not found.
207        let o = self.invoices
208            .fetch_return_source(&self.rpool(), original_pos_invoice_id).await?
209            .ok_or(PosError::InvoiceNotFound(original_pos_invoice_id))?;
210        let status = o.status.clone();
211        if status != "paid" && status != "returned" {
212            return Err(PosError::NotReturnable(status));
213        }
214        let billing_invoice_id: Uuid = o.billing_invoice_id.ok_or(PosError::NotReturnable("no billing invoice".into()))?;
215        let company_echo = legacy_company_echo();
216        let rounded_total = o.rounded_total;
217        // The tender to reverse — persisted at recognition (nil only for pre-2026-07-14 legacy tickets).
218        let payment_id: Uuid = o.payment_entry_id.unwrap_or(Uuid::nil());
219
220        // Drive the two reversals (both idempotent downstream): refund the tender + credit-note the sale.
221        payment.refund(&RefundRequest { invoice_ref: billing_invoice_id, payment_id, amount: rounded_total })
222            .await.map_err(|e| PosError::PaymentRejected { code: e.code, message: e.message })?;
223        billing.credit_note(&CreditNoteRequest { invoice_ref: billing_invoice_id, partial: None })
224            .await.map_err(|e| PosError::BillingRejected { code: e.code, message: e.message })?;
225
226        // Gate the return ticket + event on the original's paid→returned transition (exactly-once).
227        let rows_affected = self.invoices.mark_returned(&self.rpool(), original_pos_invoice_id).await?;
228        let return_ticket_id = if rows_affected == 1 {
229            let rt = Uuid::new_v4();
230            self.invoices.insert_return_ticket(&self.rpool(), &NewReturnInvoiceRow {
231                id: rt,
232                pos_profile_id: o.pos_profile_id,
233                opening_entry_id: o.opening_entry_id,
234                branch_id: o.branch_id,
235                customer_id: o.customer_id,
236                receipt_number: format!("RET-{}", &original_pos_invoice_id.simple().to_string()[..12]),
237                posting_at: o.posting_at,
238                net_total: o.net_total,
239                tax_total: o.tax_total,
240                grand_total: o.grand_total,
241                rounded_total,
242                billing_invoice_id,
243                return_against: original_pos_invoice_id,
244            }).await?;
245            self.sink.publish(PosEvent::PosInvoiceReturned(PosInvoiceReturned {
246                pos_invoice_id: original_pos_invoice_id, return_ticket_id: rt, company_id: company_echo,
247                billing_invoice_id, amount: rounded_total,
248            }));
249            rt
250        } else {
251            // Already returned — reuse the existing return ticket (idempotent replay).
252            self.invoices
253                .find_return_ticket(&self.rpool(), original_pos_invoice_id).await?
254                .unwrap_or(original_pos_invoice_id)
255        };
256        Ok(ReturnOutcome { pos_invoice_id: original_pos_invoice_id, return_ticket_id, billing_invoice_id })
257    }
258}