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`, and emit `PosInvoicePaid`. Idempotent on `status='paid'`.
35    pub async fn recognize_sale(&self, pos_invoice_id: Uuid, billing: &dyn BillingPort, payment: &dyn PaymentPort, inventory: Option<&dyn InventoryPort>) -> Result<RecognizeOutcome, PosError> {
36        // Short-circuit if already recognised.
37        if let Some(o) = self.short_circuit_paid(pos_invoice_id).await? { return Ok(o); }
38        // Tenancy (ADR-0029), ID-only pattern: identified by ticket id alone. Under HTTP the
39        // request-dedicated connection carries the ambient org scope. When driven by an EVENT (the
40        // recognition sink subscribing to PosTenderCompleted), the caller must wrap this in the
41        // composing service's org request scope — bound from the event's `company_id` twin (the
42        // acting unit) — otherwise these reads fail closed.
43        let inv = self.invoices
44            .fetch_for_recognition(&self.rpool(), pos_invoice_id).await?
45            .ok_or(PosError::InvoiceNotFound(pos_invoice_id))?;
46
47        let rounded_total = inv.rounded_total;
48        let paid_total = inv.paid_total;
49        if paid_total < rounded_total {
50            return Err(PosError::NotFullyTendered { rounded_total, paid_total });
51        }
52        let tax_total = inv.tax_total;
53        let tax_account_id = inv.tax_account_id;
54        let tax_rate = inv.tax_rate;
55        // A ticket carrying PPN must have a tax liability account on its register to credit it to.
56        if tax_total > Decimal::ZERO && tax_account_id.is_none() {
57            return Err(PosError::MissingAccount("tax_account_id (register charges PPN but has no tax account)"));
58        }
59        let company_echo = legacy_company_echo();
60        let currency = inv.currency.clone();
61        let receivable: Uuid = inv.receivable_account_id.ok_or(PosError::MissingAccount("receivable_account_id"))?;
62        let income: Option<Uuid> = inv.income_account_id;
63        let cash: Uuid = inv.cash_account_id.ok_or(PosError::MissingAccount("cash_account_id"))?;
64        let customer: Uuid = inv.customer_id
65            .or(inv.default_customer_id)
66            .ok_or(PosError::MissingAccount("customer_id (no ticket or profile default)"))?;
67
68        let posting_at = inv.posting_at;
69        let posting_date = posting_at.date_naive();
70
71        // Billing at-most-once (council 2026-07-05): raise the Sales Invoice only if it was not already
72        // raised on a prior attempt. Persisting `billing_invoice_id` on the still-draft ticket the
73        // instant `raise_and_post` returns makes a retry after a settle failure (declined card / closed
74        // period) REUSE the invoice instead of raising a second one — otherwise the counter's ordinary
75        // decline-then-retry doubles the revenue journal (billing does not dedup on the POS ticket).
76        let existing_billing: Option<Uuid> = inv.billing_invoice_id;
77        let invoice_id = if let Some(id) = existing_billing {
78            id
79        } else {
80            // Sale lines → invoice request (revenue = line account, else profile income).
81            let item_rows = self.items.fetch_revenue_lines(&self.rpool(), pos_invoice_id).await?;
82            let mut lines = Vec::with_capacity(item_rows.len());
83            for r in &item_rows {
84                let rev = r.revenue_account_id.or(income).ok_or(PosError::MissingAccount("revenue account (line or profile income)"))?;
85                // Pass the NET as the unit_price at qty 1 so billing's line net == POS net (discounts already applied).
86                lines.push(SaleLine { item_id: r.item_id, revenue_account_id: rev, quantity: Decimal::ONE, unit_price: r.net_amount });
87            }
88            let ack = billing.raise_and_post(&SaleInvoiceRequest {
89                customer_id: customer, currency: currency.clone(), source_pos_id: pos_invoice_id,
90                receivable_account_id: receivable, posting_date, lines, tax_total, tax_account_id, tax_rate,
91            }).await.map_err(|e| PosError::BillingRejected { code: e.code, message: e.message })?;
92            // Persist the link WHILE STILL DRAFT — before settle — so any retry reuses this invoice.
93            self.invoices.link_billing_invoice(&self.rpool(), pos_invoice_id, ack.invoice_id).await?;
94            ack.invoice_id
95        };
96
97        // Settle the rounded_total against the raised invoice (cash sale → A/R nets to zero).
98        let sack = payment.settle(&SettlementRequest {
99            customer_id: customer, currency, invoice_ref: invoice_id,
100            bank_account_id: cash, party_account_id: receivable, posting_date, amount: rounded_total,
101        }).await.map_err(|e| PosError::PaymentRejected { code: e.code, message: e.message })?;
102
103        // Gate the event on THIS invocation performing the draft→paid transition. Persist the settling
104        // `payment_entry_id` in the same flip so a later return refunds the tender from the ticket (no
105        // cross-schema lookup) and a crash-retry has a durable skip-gate (ADR-001 `settle`-idempotency).
106        let rows_affected = self.invoices.mark_paid(&self.rpool(), pos_invoice_id, invoice_id, sack.payment_id).await?;
107        if rows_affected == 1 {
108            self.sink.publish(PosEvent::PosInvoicePaid(PosInvoicePaid {
109                pos_invoice_id, company_id: company_echo, grand_total: inv.grand_total, rounded_total,
110                billing_invoice_id: invoice_id, payment_id: sack.payment_id,
111            }));
112            // Decrement stock (an outward Delivery Note: on-hand relieved, `Dr COGS · Cr Inventory`)
113            // when the register is stock-tracking. Gated on this once-only flip, so it issues exactly
114            // once even if recognise is retried. Non-stock registers (no warehouse) skip it.
115            if let (Some(inv_port), Some(warehouse), Some(cogs), Some(inv_acct)) = (
116                inventory,
117                inv.warehouse_id,
118                inv.cogs_account_id,
119                inv.inventory_account_id,
120            ) {
121                let qty_rows = self.items.fetch_quantity_lines(&self.rpool(), pos_invoice_id).await?;
122                let lines: Vec<StockIssueLine> = qty_rows.iter()
123                    .map(|r| StockIssueLine { item_id: r.item_id, quantity: r.quantity })
124                    .filter(|l| l.quantity > Decimal::ZERO)
125                    .collect();
126                if !lines.is_empty() {
127                    inv_port.issue(&StockIssueRequest {
128                        company_id: company_echo, branch_id: inv.branch_id, customer_id: customer,
129                        source_pos_id: pos_invoice_id, warehouse_id: warehouse, cogs_account_id: cogs,
130                        inventory_account_id: inv_acct, posting_date, lines,
131                    }).await.map_err(|e| PosError::InventoryRejected { code: e.code, message: e.message })?;
132                }
133            }
134        }
135        Ok(RecognizeOutcome { pos_invoice_id, billing_invoice_id: invoice_id, payment_id: sack.payment_id })
136    }
137
138    async fn short_circuit_paid(&self, pos_invoice_id: Uuid) -> Result<Option<RecognizeOutcome>, PosError> {
139        let row = self.invoices
140            .fetch_paid_state(&self.rpool(), pos_invoice_id).await?
141            .ok_or(PosError::InvoiceNotFound(pos_invoice_id))?;
142        if row.status == "paid" {
143            if let Some(bid) = row.billing_invoice_id {
144                // Return the real persisted payment_id on replay (was fabricated as Uuid::nil() before
145                // payment_entry_id was persisted — nil only for pre-2026-07-14 legacy tickets).
146                let payment_id = row.payment_entry_id.unwrap_or(Uuid::nil());
147                return Ok(Some(RecognizeOutcome { pos_invoice_id, billing_invoice_id: bid, payment_id }));
148            }
149        }
150        Ok(None)
151    }
152
153    /// Return a recognised sale (council 2026-07-05, the KEEP returns flow). Drives billing (credit
154    /// note — `Dr Revenue · Cr A/R`, invoice → cancelled) and payment (refund — `Dr A/R · Cr Cash`),
155    /// so revenue, cash, and A/R all net back to zero. Records an `is_return` ticket linked via
156    /// `return_against`, flips the original → `returned`, emits `PosInvoiceReturned`. **Idempotent:**
157    /// both downstream reversals are idempotent, and the ticket/event are gated on the original's
158    /// `paid→returned` transition — a repeat return refunds/credits at most once. POS posts no GL.
159    pub async fn return_sale(&self, original_pos_invoice_id: Uuid, billing: &dyn BillingPort, payment: &dyn PaymentPort, partial: Option<PartialCredit>) -> Result<ReturnOutcome, PosError> {
160        // Forward-compatible seam (council 2026-07-26, #5): the optional line-subset is accepted but
161        // not yet honored — partial returns need backbone-billing's credit note to take a line subset
162        // (ADR-001 park; gate: merchant demand). Today only full-ticket returns are supported.
163        if partial.is_some() {
164            return Err(PosError::PartialReturnsNotImplemented);
165        }
166        // Tenancy (ADR-0029), ID-only pattern — see `recognize_sale`: the lookup rides the ambient
167        // org scope, so another unit's ticket is simply not found.
168        let o = self.invoices
169            .fetch_return_source(&self.rpool(), original_pos_invoice_id).await?
170            .ok_or(PosError::InvoiceNotFound(original_pos_invoice_id))?;
171        let status = o.status.clone();
172        if status != "paid" && status != "returned" {
173            return Err(PosError::NotReturnable(status));
174        }
175        let billing_invoice_id: Uuid = o.billing_invoice_id.ok_or(PosError::NotReturnable("no billing invoice".into()))?;
176        let company_echo = legacy_company_echo();
177        let rounded_total = o.rounded_total;
178        // The tender to reverse — persisted at recognition (nil only for pre-2026-07-14 legacy tickets).
179        let payment_id: Uuid = o.payment_entry_id.unwrap_or(Uuid::nil());
180
181        // Drive the two reversals (both idempotent downstream): refund the tender + credit-note the sale.
182        payment.refund(&RefundRequest { invoice_ref: billing_invoice_id, payment_id, amount: rounded_total })
183            .await.map_err(|e| PosError::PaymentRejected { code: e.code, message: e.message })?;
184        billing.credit_note(&CreditNoteRequest { invoice_ref: billing_invoice_id, partial: None })
185            .await.map_err(|e| PosError::BillingRejected { code: e.code, message: e.message })?;
186
187        // Gate the return ticket + event on the original's paid→returned transition (exactly-once).
188        let rows_affected = self.invoices.mark_returned(&self.rpool(), original_pos_invoice_id).await?;
189        let return_ticket_id = if rows_affected == 1 {
190            let rt = Uuid::new_v4();
191            self.invoices.insert_return_ticket(&self.rpool(), &NewReturnInvoiceRow {
192                id: rt,
193                pos_profile_id: o.pos_profile_id,
194                opening_entry_id: o.opening_entry_id,
195                branch_id: o.branch_id,
196                customer_id: o.customer_id,
197                receipt_number: format!("RET-{}", &original_pos_invoice_id.simple().to_string()[..12]),
198                posting_at: o.posting_at,
199                net_total: o.net_total,
200                tax_total: o.tax_total,
201                grand_total: o.grand_total,
202                rounded_total,
203                billing_invoice_id,
204                return_against: original_pos_invoice_id,
205            }).await?;
206            self.sink.publish(PosEvent::PosInvoiceReturned(PosInvoiceReturned {
207                pos_invoice_id: original_pos_invoice_id, return_ticket_id: rt, company_id: company_echo,
208                billing_invoice_id, amount: rounded_total,
209            }));
210            rt
211        } else {
212            // Already returned — reuse the existing return ticket (idempotent replay).
213            self.invoices
214                .find_return_ticket(&self.rpool(), original_pos_invoice_id).await?
215                .unwrap_or(original_pos_invoice_id)
216        };
217        Ok(ReturnOutcome { pos_invoice_id: original_pos_invoice_id, return_ticket_id, billing_invoice_id })
218    }
219}