Skip to main content

backbone_pos/application/service/
pos_write_service.rs

1//! Validated write path + retail orchestrator for POS (hand-authored, user-owned).
2//!
3//! POS owns the cashier session + the counter ticket, but posts NO GL: on `recognize_sale` it drives
4//! `backbone-billing` (raise + post the real Sales Invoice — revenue) and `backbone-payment` (settle
5//! the tender) through the `BillingPort`/`PaymentPort`, so the retail path reuses the same GL emitters
6//! as web/B2B sales. A cash sale therefore books `Dr A/R · Cr Revenue` (billing) then `Dr Cash · Cr
7//! A/R` (payment) — A/R nets to zero at the counter. Close reconciles the drawer per tender method
8//! and books any variance through the `PosCashVariancePort`.
9//!
10//! **Layering (the module's 4-layer rule):** this service ORCHESTRATES — it validates, computes the
11//! money, owns the unit of work (`begin`/`commit`), drives the ports, and publishes events. It holds
12//! no SQL: every statement lives on the repositories in `infrastructure::persistence`, whose custom
13//! methods take the caller's transaction so a cross-entity write (ticket header + its lines) commits
14//! as one unit. Tenancy is composition-installed (ADR-0029): the module is tenant-agnostic, every
15//! write verb opens its own transaction and re-binds the caller's AMBIENT org scope onto it
16//! ([`PosWriteService::relay_ambient_scope`]) — plain when none is bound — and outbound wires that
17//! still carry a company field read the scope's legacy echo
18//! ([`PosWriteService::legacy_company_echo`]).
19//!
20//! **This file is the hub:** it holds the module's vocabulary (input structs, outcomes, errors) and
21//! the session-open path. The rest of the write surface is chunked into focused siblings, each an
22//! `impl PosWriteService` block over these same types:
23//!
24//! - [`super::pos_compute`] — the server-owned ticket computation (document-grade tax + register
25//!   cash rounding) shared by every entry point that rings a ticket.
26//! - [`super::pos_sale`] — ring a ticket (`ring_sale`, `ring_sale_priced`).
27//! - [`super::pos_tender`] — take payment at the counter (`add_tender`).
28//! - [`super::pos_recognition`] — the retail seam: `recognize_sale` / `return_sale`.
29//! - [`super::pos_drawer`] — cash movements, the X/Z drawer read, `close_session`.
30//! - [`super::pos_receipt`] — assemble the printed slip.
31//! - [`super::pos_sync`] — the offline reconciliation verb (`sync_from_ui`).
32//! - [`super::pos_manager_pin`] — the manager-PIN credential path (`set_pin`, `verify_pin`).
33//! - [`super::pos_discount`] — the order-level discount resolution + fold (percentage from the
34//!   company's discount master, never a client-echoed rate).
35//! - [`super::pos_session_alert`] — the old-session alert scheduler handler (pickup-locked).
36
37use backbone_orm::org_scope;
38use rust_decimal::{Decimal, RoundingStrategy};
39use sqlx::PgPool;
40use std::collections::HashMap;
41use std::sync::{Arc, Mutex};
42use uuid::Uuid;
43
44use crate::infrastructure::persistence::{
45    NewOpeningEntryRow, PosCashMovementRepository, PosClosingEntryRepository, PosDiscountRepository,
46    PosInvoiceItemRepository, PosInvoiceRepository, PosManagerPinRepository, PosOpeningEntryRepository,
47    PosPaymentRepository, PosProfileRepository, PosTableRepository,
48};
49
50use super::pos_events::{PosEvent, PosEventSink, PosSessionOpened, LoggingSink};
51
52pub(super) fn money(v: Decimal) -> Decimal {
53    v.round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero)
54}
55
56/// The legacy tenancy twin echo (ADR-0029): outbound wire shapes that still carry a `company_id`
57/// (the events, the durable-outbox record, the stock-issue and variance port requests) get the
58/// ambient org scope's legacy company id when the composing service bound one; nil otherwise.
59/// Nothing in this module keys a statement on it, and an undecorated deployment is unfenced
60/// by design.
61pub(super) fn legacy_company_echo() -> Uuid {
62    org_scope::current_org_scope()
63        .and_then(|s| s.legacy_company_id())
64        .unwrap_or(Uuid::nil())
65}
66
67/// Re-bind the caller's ambient org scope onto a transaction this service opened itself — the
68/// scope is task-local and a fresh pool transaction carries none of it. With no ambient scope
69/// (standalone deployment, jobs) the transaction stays plain: the module is tenant-agnostic and
70/// the composed decorator owns isolation.
71pub(super) async fn relay_ambient_scope(conn: &mut sqlx::PgConnection) -> Result<(), sqlx::Error> {
72    if let Some(scope) = org_scope::current_org_scope() {
73        org_scope::bind_org_scope_on(conn, &scope).await?;
74    }
75    Ok(())
76}
77/// Round `v` to the nearest `step` (IDR receipt rounding; `step == 0` means no rounding).
78pub(super) fn round_to(v: Decimal, step: Decimal) -> Decimal {
79    if step <= Decimal::ZERO { return money(v); }
80    let n = (v / step).round_dp_with_strategy(0, RoundingStrategy::MidpointAwayFromZero);
81    money(n * step)
82}
83
84// --- input structs -----------------------------------------------------------
85
86#[derive(Debug, Clone)]
87pub struct NewSession {
88    pub pos_profile_id: Uuid,
89    pub branch_id: Option<Uuid>,
90    pub cashier_party_id: Uuid,
91    pub opened_at: chrono::NaiveDateTime,
92    /// Opening float per method — e.g. [("cash", 500000)].
93    pub opening_balances: Vec<(String, Decimal)>,
94}
95
96#[derive(Debug, Clone)]
97pub struct NewSaleLine {
98    pub item_id: Uuid,
99    pub revenue_account_id: Option<Uuid>,
100    pub description: Option<String>,
101    /// Course grouping for kitchen routing (restaurant): lines sharing a course number fire
102    /// together. `None` = not course-grouped (a plain counter line).
103    pub course: Option<i32>,
104    pub quantity: Decimal,
105    pub unit_price: Decimal,
106    pub discount_amount: Decimal,
107}
108
109/// A ticket rung online. Totals are NEVER taken from here: tax is computed document-grade through
110/// the register's templates (`PosTaxComputePort`) and cash rounding comes from the register's
111/// configuration — the shared compute core in [`super::pos_compute`] owns every money field written.
112/// `pos_table_id` seats the ticket at a dining table (one live draft per table is enforced — the
113/// restaurant invariant); `discount_id` names an order-level discount MASTER row whose stored
114/// percentage the server applies (a client-echoed rate is never read).
115#[derive(Debug, Clone)]
116pub struct NewSale {
117    pub pos_profile_id: Uuid,
118    pub opening_entry_id: Uuid,
119    pub branch_id: Option<Uuid>,
120    pub customer_id: Option<Uuid>,
121    pub pos_table_id: Option<Uuid>,
122    pub discount_id: Option<Uuid>,
123    pub receipt_number: String,
124    pub posting_at: chrono::NaiveDateTime,
125    pub lines: Vec<NewSaleLine>,
126}
127
128/// One ticket line to be priced by the promo cart pricer — list price + the dimensions promo matches
129/// bundles/rules on (item group, brand), which a plain `NewSaleLine` does not carry.
130#[derive(Debug, Clone)]
131pub struct CartSaleLine {
132    pub item_id: Uuid,
133    pub item_group_id: Option<Uuid>,
134    pub brand_id: Option<Uuid>,
135    pub revenue_account_id: Option<Uuid>,
136    pub description: Option<String>,
137    /// Course grouping for kitchen routing (restaurant), carried through the pricing round-trip.
138    pub course: Option<i32>,
139    pub list_price: Decimal,
140    pub quantity: Decimal,
141}
142
143/// A ticket rung through the promo cart seam (`ring_sale_priced`). Totals are server-owned exactly
144/// as on [`NewSale`]; `pos_table_id` / `discount_id` carry the same meaning.
145#[derive(Debug, Clone)]
146pub struct NewCartSale {
147    pub pos_profile_id: Uuid,
148    pub opening_entry_id: Uuid,
149    pub branch_id: Option<Uuid>,
150    pub customer_id: Option<Uuid>,
151    pub customer_group_id: Option<Uuid>,
152    pub coupon_code: Option<String>,
153    pub pos_table_id: Option<Uuid>,
154    pub discount_id: Option<Uuid>,
155    pub receipt_number: String,
156    pub posting_at: chrono::NaiveDateTime,
157    pub lines: Vec<CartSaleLine>,
158}
159
160/// A privileged mutation's proof of manager identity: the employee whose PIN it is + the PIN itself.
161/// The PIN is verified SERVER-SIDE against the stored argon2 hash on every privileged verb — a
162/// client-side "manager mode" flag is a UI affordance and never authorizes anything here.
163#[derive(Debug, Clone)]
164pub struct ManagerAuth {
165    pub employee_party_id: Uuid,
166    pub pin: String,
167}
168
169#[derive(Debug, Clone)]
170pub struct NewClose {
171    pub opening_entry_id: Uuid,
172    pub cashier_party_id: Uuid,
173    pub closed_at: chrono::NaiveDateTime,
174    /// Counted drawer per method (the cash statement legs).
175    pub counted: Vec<(String, Decimal)>,
176    /// Closing the till is a privileged mutation: the manager's PIN is verified server-side before
177    /// anything is written (variance is booked to the GL through the register's write-off account).
178    pub manager: ManagerAuth,
179    /// Best-effort source address feeding the PIN throttle ring (`None` = service-to-service caller).
180    pub source_ip: Option<String>,
181}
182
183// --- offline sync (sync_from_ui) ----------------------------------------------
184
185/// One ticket line as an offline client replays it. `client_uuid` is the line's sync identity
186/// (DISTINCT from the server-side row id); `unit_price`/`quantity`/`discount_amount` are inputs to
187/// pricing resolution exactly as `ring_sale` treats them — the totals are always server-derived.
188#[derive(Debug, Clone)]
189pub struct SyncSaleLine {
190    pub client_uuid: Uuid,
191    pub item_id: Uuid,
192    pub revenue_account_id: Option<Uuid>,
193    pub description: Option<String>,
194    /// Course grouping for kitchen routing (restaurant), replayed with the basket.
195    pub course: Option<i32>,
196    pub quantity: Decimal,
197    pub unit_price: Decimal,
198    pub discount_amount: Decimal,
199}
200
201/// One tender as an offline client replays it: the method + the amount actually taken at the
202/// counter (the client is the record of what changed hands; the SERVER re-sums what these imply).
203#[derive(Debug, Clone)]
204pub struct SyncTender {
205    pub client_uuid: Uuid,
206    pub method: String,
207    pub amount: Decimal,
208    pub reference_no: Option<String>,
209}
210
211/// An offline ticket replayed at the server. Client totals (`amount_paid` / `amount_total` /
212/// `amount_tax` / `amount_return`) are DISCARDED by design — the server recomputes every money
213/// field from the lines + tenders through the same compute core `ring_sale` uses. Identity is the
214/// ticket's `client_uuid`: a replay whose uuid already exists UPDATES that ticket (when still
215/// mutable) instead of creating a second one.
216#[derive(Debug, Clone)]
217pub struct NewSyncSale {
218    pub client_uuid: Uuid,
219    pub pos_profile_id: Uuid,
220    /// The session the client rang the ticket under. Validated, not trusted: if that session has
221    /// since CLOSED, the ticket is refused unless `rescue_opening_entry_id` names a session that is
222    /// currently open (the rescue).
223    pub opening_entry_id: Uuid,
224    /// Optional open session to attach the ticket to when the original session has closed.
225    pub rescue_opening_entry_id: Option<Uuid>,
226    pub branch_id: Option<Uuid>,
227    pub customer_id: Option<Uuid>,
228    /// The dining table the replay seats the ticket at. On CREATE it is the seating; on UPDATE a
229    /// changed value is a table TRANSFER — validated against the one-draft-per-table guard.
230    pub pos_table_id: Option<Uuid>,
231    /// Names the order-level discount MASTER row; the server reads the percentage from the master
232    /// (never the replay's echo of one).
233    pub discount_id: Option<Uuid>,
234    pub posting_at: chrono::NaiveDateTime,
235    pub lines: Vec<SyncSaleLine>,
236    pub tenders: Vec<SyncTender>,
237    /// For a refund replay: the PARENT ticket's client uuid. At most one parent per refund ticket
238    /// (enforced structurally — a ticket referencing more than one distinct refunded parent is
239    /// refused). `Some(_)` makes the sync a privileged mutation: `manager` is then required.
240    pub refund_of_client_uuid: Option<Uuid>,
241    /// Manager authorization — required iff `refund_of_client_uuid` is set.
242    pub manager: Option<ManagerAuth>,
243    /// Where the replay claims to come from — feeds the PIN throttle ring on privileged replays.
244    /// Optional: a service-to-service caller (no address) skips the per-address budget.
245    pub source_ip: Option<String>,
246}
247
248/// How a `sync_from_ui` call landed.
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub enum SyncAction {
251    /// No live ticket carried the uuid — a new one was created.
252    Created,
253    /// A live DRAFT ticket carried the uuid — its lines/tenders/totals were rewritten.
254    Updated,
255    /// The ticket carrying the uuid is already finalized server-side (paid/returned) — the replay
256    /// changed nothing; the server's state is authoritative.
257    ReplayFinalized,
258}
259
260#[derive(Debug, Clone)]
261pub struct SyncOutcome {
262    pub pos_invoice_id: Uuid,
263    pub action: SyncAction,
264    pub totals: TicketTotals,
265}
266
267/// The server-derived money of a ticket — every field computed by [`super::pos_compute`], none of
268/// them ever read from a client payload.
269#[derive(Debug, Clone, PartialEq)]
270pub struct TicketTotals {
271    pub net_total: Decimal,
272    pub tax_total: Decimal,
273    pub grand_total: Decimal,
274    pub rounding_adjustment: Decimal,
275    pub rounded_total: Decimal,
276    pub paid_total: Decimal,
277    pub change_due: Decimal,
278}
279
280#[derive(Debug, Clone)]
281pub struct NewCashMovement {
282    pub pos_profile_id: Uuid,
283    pub opening_entry_id: Uuid,
284    pub cashier_party_id: Uuid,
285    /// One of: `pay_in`, `pay_out`, `drop`, `no_sale`.
286    pub movement_type: String,
287    /// Positive cash amount; must be 0 for `no_sale`.
288    pub amount: Decimal,
289    pub reason: Option<String>,
290    pub moved_at: chrono::NaiveDateTime,
291}
292
293#[derive(Debug, Clone)]
294pub struct TenderOutcome {
295    pub paid_total: Decimal,
296    pub change_due: Decimal,
297    pub fully_tendered: bool,
298}
299
300#[derive(Debug, Clone)]
301pub struct RecognizeOutcome {
302    pub pos_invoice_id: Uuid,
303    pub billing_invoice_id: Uuid,
304    pub payment_id: Uuid,
305}
306
307#[derive(Debug, Clone)]
308pub struct ReturnOutcome {
309    pub pos_invoice_id: Uuid,
310    pub return_ticket_id: Uuid,
311    pub billing_invoice_id: Uuid,
312}
313
314#[derive(Debug, Clone)]
315pub struct MethodRecon {
316    pub method: String,
317    pub expected: Decimal,
318    pub counted: Decimal,
319    pub difference: Decimal,
320}
321
322/// The booked drawer variance of a close: which journal absorbed it and in which direction.
323#[derive(Debug, Clone)]
324pub struct VarianceBooking {
325    pub journal_id: Uuid,
326    pub amount: Decimal,
327    pub direction: super::pos_ports::CashVarianceDirection,
328}
329
330#[derive(Debug, Clone)]
331pub struct CloseOutcome {
332    pub closing_id: Uuid,
333    pub difference_total: Decimal,
334    pub by_method: Vec<MethodRecon>,
335    /// The GL correction booked for a non-zero drawer difference (`None` when the drawer balanced
336    /// exactly — no journal is raised for a zero variance).
337    pub variance: Option<VarianceBooking>,
338}
339
340#[derive(Debug, Clone)]
341pub struct MethodExpected {
342    pub method: String,
343    pub expected: Decimal,
344}
345
346/// A mid-shift drawer read (X-report): expected drawer per method + running session totals, with no
347/// counting and no close.
348#[derive(Debug, Clone)]
349pub struct XReport {
350    pub opening_entry_id: Uuid,
351    pub by_method: Vec<MethodExpected>,
352    pub grand_total: Decimal,
353    pub invoice_count: i64,
354}
355
356#[derive(Debug, Clone, serde::Serialize)]
357pub struct ReceiptLine {
358    pub description: String,
359    /// Course grouping for kitchen routing (restaurant) — surfaced on the read side so kitchen
360    /// displays and receipt renderers can group firing lines; `None` on counter lines.
361    #[serde(skip_serializing_if = "Option::is_none")]
362    pub course: Option<i32>,
363    pub quantity: Decimal,
364    pub unit_price: Decimal,
365    pub discount_amount: Decimal,
366    pub net_amount: Decimal,
367}
368
369#[derive(Debug, Clone, serde::Serialize)]
370pub struct ReceiptTender {
371    pub method: String,
372    pub amount: Decimal,
373}
374
375/// A rendered sale receipt: the line items + money breakdown (incl. PPN) + tender/change a printer or
376/// display needs. `render_text()` produces a monospace slip.
377#[derive(Debug, Clone, serde::Serialize)]
378#[serde(rename_all = "camelCase")]
379pub struct Receipt {
380    pub pos_invoice_id: Uuid,
381    pub receipt_number: String,
382    pub register_name: String,
383    pub posting_at: chrono::DateTime<chrono::Utc>,
384    pub currency: String,
385    pub status: String,
386    /// Whether this ticket has been recognized into a billing invoice — DERIVED from the ticket's
387    /// billing link (`billing_invoice_id IS NOT NULL`), never a stored state column. A receipt is
388    /// "invoiced" the moment recognition attached the real Sales Invoice.
389    pub is_invoiced: bool,
390    pub lines: Vec<ReceiptLine>,
391    pub net_total: Decimal,
392    pub tax_rate: Decimal,
393    pub tax_total: Decimal,
394    pub rounding_adjustment: Decimal,
395    pub grand_total: Decimal,
396    pub tenders: Vec<ReceiptTender>,
397    pub paid_total: Decimal,
398    pub change_due: Decimal,
399}
400
401impl Receipt {
402    /// Render a 32-column monospace slip: header, line items, subtotal, PPN (if any), rounding (if any),
403    /// total, tenders, change. Deliberately printer-agnostic text — an ESC/POS driver wraps it.
404    pub fn render_text(&self) -> String {
405        const W: usize = 32;
406        let money = |d: Decimal| format!("{:.2}", d);
407        let row = |left: &str, right: &str| -> String {
408            let r = right.to_string();
409            let l_max = W.saturating_sub(r.len() + 1);
410            let l: String = left.chars().take(l_max).collect();
411            format!("{l}{}{r}", " ".repeat(W.saturating_sub(l.chars().count() + r.len())))
412        };
413        let center = |s: &str| -> String {
414            let pad = W.saturating_sub(s.chars().count()) / 2;
415            format!("{}{s}", " ".repeat(pad))
416        };
417        let rule = "-".repeat(W);
418        let mut o = String::new();
419        o.push_str(&center(&self.register_name)); o.push('\n');
420        o.push_str(&center(&format!("Receipt {}", self.receipt_number))); o.push('\n');
421        o.push_str(&center(&self.posting_at.format("%Y-%m-%d %H:%M").to_string())); o.push('\n');
422        o.push_str(&rule); o.push('\n');
423        for l in &self.lines {
424            o.push_str(&l.description); o.push('\n');
425            let qty_price = format!("  {} x {}", l.quantity.normalize(), money(l.unit_price));
426            o.push_str(&row(&qty_price, &money(l.net_amount))); o.push('\n');
427            if l.discount_amount > Decimal::ZERO {
428                o.push_str(&row("  disc", &format!("-{}", money(l.discount_amount)))); o.push('\n');
429            }
430        }
431        o.push_str(&rule); o.push('\n');
432        o.push_str(&row("Subtotal", &money(self.net_total))); o.push('\n');
433        if self.tax_total > Decimal::ZERO {
434            let pct = (self.tax_rate * Decimal::from(100)).normalize();
435            o.push_str(&row(&format!("PPN {pct}%"), &money(self.tax_total))); o.push('\n');
436        }
437        if self.rounding_adjustment != Decimal::ZERO {
438            o.push_str(&row("Rounding", &money(self.rounding_adjustment))); o.push('\n');
439        }
440        o.push_str(&row("TOTAL", &money(self.grand_total))); o.push('\n');
441        o.push_str(&rule); o.push('\n');
442        for t in &self.tenders {
443            o.push_str(&row(&t.method, &money(t.amount))); o.push('\n');
444        }
445        if self.change_due > Decimal::ZERO {
446            o.push_str(&row("Change", &money(self.change_due))); o.push('\n');
447        }
448        o.push_str(&rule); o.push('\n');
449        o.push_str(&center("Terima kasih")); o.push('\n');
450        o
451    }
452}
453
454// --- errors ------------------------------------------------------------------
455
456#[derive(Debug)]
457pub enum PosError {
458    EmptyDocument,
459    NegativeAmount,
460    SessionNotOpen,
461    /// The open session belongs to a different register than the ticket names — a ticket must
462    /// settle on its own register's drawer; pairing one register's config with another's till
463    /// would misattribute cash between registers.
464    SessionRegisterMismatch,
465    NotDraft,
466    NotFullyTendered { rounded_total: Decimal, paid_total: Decimal },
467    NotReturnable(String),
468    MissingAccount(&'static str),
469    DuplicateNumber(String),
470    ProfileNotFound(Uuid),
471    InvoiceNotFound(Uuid),
472    SessionNotFound(Uuid),
473    BillingRejected { code: String, message: String },
474    PaymentRejected { code: String, message: String },
475    PricingRejected { code: String, message: String },
476    InventoryRejected { code: String, message: String },
477    InvalidCashMovement(&'static str),
478    /// Forward-compatible seam (council 2026-07-26, #5): `return_sale` accepts an optional
479    /// `PartialCredit`, but partial-return behavior is not yet implemented — it lands when
480    /// backbone-billing's credit note honors a line subset (ADR-001 park; gate: merchant demand).
481    PartialReturnsNotImplemented,
482
483    // --- document-grade tax + register configuration -------------------------
484    /// The register carries no tax templates — a ticket cannot be priced. Configure at least one
485    /// template on the profile (zero-rated included for a non-PKP register).
486    ProfileTaxTemplatesMissing(Uuid),
487    /// The tax compute port refused the request (propagates the seam's code + message).
488    TaxRejected { code: String, message: String },
489    /// The session the client rang under has since closed and no open rescue session was offered.
490    SessionClosedRescueRequired(Uuid),
491
492    // --- offline sync ---------------------------------------------------------
493    /// The replay would reassign the ticket to a different customer than it was first synced with.
494    SyncPartnerMismatch,
495    /// The replay names a session the ticket does not belong to (and the original is still open).
496    SyncSessionMismatch,
497    /// A refund replay's parent uuid does not resolve to a live ticket of this company.
498    RefundParentNotFound(Uuid),
499    /// A refund ticket already recorded against a different parent refuses to be re-pointed.
500    RefundLineageConflict,
501    /// A tender replay carries a method the payment-method enum does not know.
502    InvalidTenderMethod(String),
503    /// The client uuid is already held by another live ticket (the sync identity namespaces inside
504    /// the acting unit's scope once composed; a collision is a client bug or a replay of someone
505    /// else's uuid).
506    DuplicateClientUuid(Uuid),
507
508    // --- session close guards --------------------------------------------------
509    /// The session still holds DRAFT tickets — post or void them before closing.
510    SessionHasDraftOrders(i64),
511    /// The session holds invoiced tickets whose posting did not complete (billing linked, ticket
512    /// not paid) — retry recognition before closing.
513    SessionHasUnpostedInvoices(i64),
514    /// A second session is already open on this register (enforced by the one-open-session unique).
515    SessionAlreadyOpen,
516
517    // --- session close variance ------------------------------------------------
518    /// The variance seam refused the booking (propagates the seam's code + message). Nothing has been
519    /// written when this surfaces — the close rolls back entirely.
520    VarianceRejected { code: String, message: String },
521
522    // --- manager PIN -----------------------------------------------------------
523    /// A privileged mutation was attempted without manager authorization.
524    ManagerAuthRequired,
525    /// No live PIN is set for that manager.
526    PinNotFound,
527    /// The presented PIN does not match the stored hash.
528    PinInvalid,
529    /// The manager's PIN is locked: too many consecutive failures. Carries the unlock instant.
530    PinLocked { until: chrono::DateTime<chrono::Utc> },
531    /// Too many verification attempts from one source address inside the throttle window.
532    PinThrottled,
533    /// The chosen PIN does not satisfy the policy (digits + length window).
534    WeakPin(&'static str),
535
536    // --- restaurant seating ------------------------------------------------------
537    /// The named dining table does not exist (in this tenant).
538    TableNotFound(Uuid),
539    /// The named dining table already holds a live DRAFT ticket — one draft per table; resume,
540    /// settle, or move that ticket before opening a second one on the table. Carries the occupying
541    /// ticket so the client can jump to it. (The DB partial unique is the backstop; this is the
542    /// friendly typed refusal.)
543    TableOccupied { pos_table_id: Uuid, draft_invoice_id: Uuid },
544
545    // --- order-level discount ----------------------------------------------------
546    /// The named discount master row does not exist (in this tenant) or is retired.
547    DiscountNotFound(Uuid),
548    /// The register is configured discount-off (`allow_discount = false`) — a ring naming a
549    /// discount master is refused.
550    DiscountNotAllowed,
551    /// The discount master's stored percentage is unusable (over 100%). The master row is
552    /// misconfigured; nothing was rung.
553    DiscountInvalid(&'static str),
554    Db(sqlx::Error),
555}
556
557impl PosError {
558    pub fn code(&self) -> String {
559        match self {
560            PosError::EmptyDocument => "empty_document".into(),
561            PosError::NegativeAmount => "negative_amount".into(),
562            PosError::SessionNotOpen => "session_not_open".into(),
563            PosError::SessionRegisterMismatch => "session_register_mismatch".into(),
564            PosError::NotDraft => "not_draft".into(),
565            PosError::NotFullyTendered { .. } => "not_fully_tendered".into(),
566            PosError::NotReturnable(_) => "not_returnable".into(),
567            PosError::MissingAccount(_) => "missing_account".into(),
568            PosError::DuplicateNumber(_) => "duplicate_number".into(),
569            PosError::ProfileNotFound(_) => "profile_not_found".into(),
570            PosError::InvoiceNotFound(_) => "invoice_not_found".into(),
571            PosError::SessionNotFound(_) => "session_not_found".into(),
572            PosError::BillingRejected { code, .. } => code.clone(),
573            PosError::PaymentRejected { code, .. } => code.clone(),
574            PosError::PricingRejected { code, .. } => code.clone(),
575            PosError::InventoryRejected { code, .. } => code.clone(),
576            PosError::InvalidCashMovement(_) => "invalid_cash_movement".into(),
577            PosError::PartialReturnsNotImplemented => "partial_returns_not_implemented".into(),
578            PosError::ProfileTaxTemplatesMissing(_) => "profile_tax_templates_missing".into(),
579            PosError::TaxRejected { code, .. } => code.clone(),
580            PosError::SessionClosedRescueRequired(_) => "session_closed_rescue_required".into(),
581            PosError::SyncPartnerMismatch => "sync_partner_mismatch".into(),
582            PosError::SyncSessionMismatch => "sync_session_mismatch".into(),
583            PosError::RefundParentNotFound(_) => "refund_parent_not_found".into(),
584            PosError::RefundLineageConflict => "refund_lineage_conflict".into(),
585            PosError::InvalidTenderMethod(_) => "invalid_tender_method".into(),
586            PosError::DuplicateClientUuid(_) => "duplicate_client_uuid".into(),
587            PosError::SessionHasDraftOrders(_) => "session_has_draft_orders".into(),
588            PosError::SessionHasUnpostedInvoices(_) => "session_has_unposted_invoices".into(),
589            PosError::SessionAlreadyOpen => "session_already_open".into(),
590            PosError::VarianceRejected { code, .. } => code.clone(),
591            PosError::ManagerAuthRequired => "manager_authorization_required".into(),
592            PosError::PinNotFound => "manager_pin_not_set".into(),
593            PosError::PinInvalid => "manager_pin_invalid".into(),
594            PosError::PinLocked { .. } => "manager_pin_locked".into(),
595            PosError::PinThrottled => "manager_pin_throttled".into(),
596            PosError::WeakPin(_) => "weak_pin".into(),
597            PosError::TableNotFound(_) => "table_not_found".into(),
598            PosError::TableOccupied { .. } => "table_occupied".into(),
599            PosError::DiscountNotFound(_) => "discount_not_found".into(),
600            PosError::DiscountNotAllowed => "discount_not_allowed".into(),
601            PosError::DiscountInvalid(_) => "discount_invalid".into(),
602            PosError::Db(_) => "internal_error".into(),
603        }
604    }
605    pub fn http_status(&self) -> u16 {
606        match self {
607            PosError::ProfileNotFound(_)
608            | PosError::InvoiceNotFound(_)
609            | PosError::SessionNotFound(_)
610            | PosError::TableNotFound(_)
611            | PosError::DiscountNotFound(_) => 404,
612            // Failed/absent manager credentials are authorization outcomes, not validation ones.
613            PosError::ManagerAuthRequired | PosError::PinInvalid => 403,
614            // Lockout + source throttling are rate-limit outcomes.
615            PosError::PinLocked { .. } | PosError::PinThrottled => 429,
616            // A table already holding a draft is a state conflict on a named resource (PSX-1 said
617            // loudly, not silently).
618            PosError::TableOccupied { .. } => 409,
619            PosError::Db(_) => 500,
620            _ => 422,
621        }
622    }
623}
624impl std::fmt::Display for PosError {
625    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
626        match self {
627            PosError::BillingRejected { code, message }
628            | PosError::PaymentRejected { code, message }
629            | PosError::TaxRejected { code, message }
630            | PosError::VarianceRejected { code, message } => write!(f, "{code}: {message}"),
631            PosError::PinLocked { until } => write!(f, "manager_pin_locked until {until}"),
632            other => write!(f, "{}", other.code()),
633        }
634    }
635}
636impl std::error::Error for PosError {}
637impl From<sqlx::Error> for PosError {
638    fn from(e: sqlx::Error) -> Self { PosError::Db(e) }
639}
640
641/// The constraint name of a unique violation, when it is one. Discriminating over a bare
642/// is-unique check: the ticket table now carries several partial uniques (receipt number, sync
643/// uuid, one draft per dining table), and the service maps each to its OWN typed refusal
644/// instead of guessing.
645pub(super) fn dup_constraint(e: &sqlx::Error) -> Option<String> {
646    let db = e.as_database_error()?;
647    if !db.is_unique_violation() {
648        return None;
649    }
650    db.constraint().map(|c| c.to_string())
651}
652
653/// Map a ticket-header unique violation to its typed refusal by constraint name, enriching the
654/// one-draft-per-table collision with the occupying ticket (read on a FRESH connection — the
655/// caller's transaction is aborted by the failed insert). Unmapped unique violations surface as the
656/// raw DB error (`internal_error`) — an honest 500 for an index this module did not declare, never
657/// a mislabeled 4xx.
658pub(super) async fn map_invoice_dup(
659    e: sqlx::Error,
660    receipt_number: &str,
661    client_uuid: Option<Uuid>,
662    pos_table_id: Option<Uuid>,
663    invoices: &crate::infrastructure::persistence::PosInvoiceRepository,
664    pool: &sqlx::PgPool,
665    exclude_invoice_id: Option<Uuid>,
666) -> PosError {
667    match dup_constraint(&e).as_deref() {
668        Some(c) if c.contains("receipt_number") => PosError::DuplicateNumber(receipt_number.to_string()),
669        Some(c) if c.contains("client_uuid") => PosError::DuplicateClientUuid(client_uuid.unwrap_or_default()),
670        Some(c) if c.contains("pos_table_id") => {
671            let table = pos_table_id.unwrap_or_default();
672            let draft = invoices
673                .find_draft_on_table_scoped(pool, table, exclude_invoice_id)
674                .await
675                .ok()
676                .flatten()
677                .unwrap_or_default();
678            PosError::TableOccupied { pos_table_id: table, draft_invoice_id: draft }
679        }
680        _ => PosError::Db(e),
681    }
682}
683
684/// The manager-PIN policy: every window is configuration, not code — a probe process must be able to
685/// shorten the lockout without sleeping a production timeout, and a production deployment must be able
686/// to tighten it. Defaults are deliberately short (the values below are what tests and local dev run
687/// with) so the shipped library is usable out of the box; `from_env` reads overrides.
688#[derive(Debug, Clone)]
689pub struct PinPolicy {
690    /// Consecutive failures before a manager's PIN locks.
691    pub max_attempts: u32,
692    /// How long a lockout holds, in seconds.
693    pub lockout_secs: i64,
694    /// Shortest acceptable PIN length (digits).
695    pub min_digits: usize,
696    /// Longest acceptable PIN length (digits).
697    pub max_digits: usize,
698    /// Per-source-address verification window, in seconds.
699    pub ip_window_secs: i64,
700    /// Verifications allowed per source address inside `ip_window_secs` (successful or not).
701    pub ip_max_attempts: u32,
702}
703
704impl Default for PinPolicy {
705    fn default() -> Self {
706        Self {
707            max_attempts: 5,
708            lockout_secs: 15,
709            min_digits: 4,
710            max_digits: 8,
711            ip_window_secs: 60,
712            ip_max_attempts: 20,
713        }
714    }
715}
716
717impl PinPolicy {
718    /// Read overrides from the environment, falling back to [`PinPolicy::default`] per variable:
719    /// `POS_PIN_MAX_ATTEMPTS`, `POS_PIN_LOCKOUT_SECS`, `POS_PIN_MIN_DIGITS`, `POS_PIN_MAX_DIGITS`,
720    /// `POS_PIN_IP_WINDOW_SECS`, `POS_PIN_IP_MAX_ATTEMPTS`.
721    pub fn from_env() -> Self {
722        let d = Self::default();
723        // Each override parses in its own field's type; a bad or missing value falls back per-field
724        // (never a zeroed window — a typo cannot silently disable the lockout).
725        let get = |k: &str| std::env::var(k).ok();
726        Self {
727            max_attempts: get("POS_PIN_MAX_ATTEMPTS").and_then(|v| v.trim().parse().ok()).unwrap_or(d.max_attempts),
728            lockout_secs: get("POS_PIN_LOCKOUT_SECS").and_then(|v| v.trim().parse().ok()).unwrap_or(d.lockout_secs),
729            min_digits: get("POS_PIN_MIN_DIGITS").and_then(|v| v.trim().parse().ok()).unwrap_or(d.min_digits),
730            max_digits: get("POS_PIN_MAX_DIGITS").and_then(|v| v.trim().parse().ok()).unwrap_or(d.max_digits),
731            ip_window_secs: get("POS_PIN_IP_WINDOW_SECS").and_then(|v| v.trim().parse().ok()).unwrap_or(d.ip_window_secs),
732            ip_max_attempts: get("POS_PIN_IP_MAX_ATTEMPTS").and_then(|v| v.trim().parse().ok()).unwrap_or(d.ip_max_attempts),
733        }
734    }
735}
736
737/// The repositories are held behind `Arc` only so this service stays `Clone` (its HTTP surface clones
738/// it per request) — `GenericCrudRepository` is not itself `Clone`. They are stateless handles over the
739/// same pool; the `Arc` carries no shared mutable state. The PIN attempt ring is the one piece of
740/// genuinely shared mutable state — `Mutex`, never held across an `.await`.
741#[derive(Clone)]
742pub struct PosWriteService {
743    pub(super) db_pool: PgPool,
744    pub(super) sink: Arc<dyn PosEventSink>,
745    /// When set, `add_tender` stages `PosTenderCompleted` into this outbox schema
746    /// **inside the tender's transaction** (durable) so a relay can recognise the sale even if the
747    /// process dies between commit and the in-process `sink` spawn. `None` (default) preserves the
748    /// historical fire-and-forget behaviour. Opt in via [`Self::with_outbox`].
749    pub(super) outbox_schema: Option<String>,
750    pub(super) invoices: Arc<PosInvoiceRepository>,
751    pub(super) items: Arc<PosInvoiceItemRepository>,
752    pub(super) payments: Arc<PosPaymentRepository>,
753    pub(super) profiles: Arc<PosProfileRepository>,
754    pub(super) openings: Arc<PosOpeningEntryRepository>,
755    pub(super) closings: Arc<PosClosingEntryRepository>,
756    pub(super) movements: Arc<PosCashMovementRepository>,
757    /// Manager-PIN credentials (argon2 hashes + attempt counters + lockout).
758    pub(super) pins: Arc<PosManagerPinRepository>,
759    /// Dining tables (restaurant lane: seating validation on ring/sync).
760    pub(super) tables: Arc<PosTableRepository>,
761    /// Order-level discount masters (restaurant lane: the server-side discount RATE source).
762    pub(super) discounts: Arc<PosDiscountRepository>,
763    /// The PIN windows above; [`Self::with_pin_policy`] overrides the defaults.
764    pub(super) pin_policy: PinPolicy,
765    /// Rolling per-source-address verification ring: `ip -> (window_start_unix, attempts)`. A process
766    /// -local guard in front of the persisted counters (which key on the manager identity).
767    pub(super) ip_attempts: Arc<Mutex<HashMap<String, (i64, u32)>>>,
768}
769
770impl PosWriteService {
771    pub fn new(db_pool: PgPool) -> Self {
772        Self::with_sink(db_pool, Arc::new(LoggingSink))
773    }
774    pub fn with_sink(db_pool: PgPool, sink: Arc<dyn PosEventSink>) -> Self {
775        Self {
776            invoices: Arc::new(PosInvoiceRepository::new(db_pool.clone())),
777            items: Arc::new(PosInvoiceItemRepository::new(db_pool.clone())),
778            payments: Arc::new(PosPaymentRepository::new(db_pool.clone())),
779            profiles: Arc::new(PosProfileRepository::new(db_pool.clone())),
780            openings: Arc::new(PosOpeningEntryRepository::new(db_pool.clone())),
781            closings: Arc::new(PosClosingEntryRepository::new(db_pool.clone())),
782            movements: Arc::new(PosCashMovementRepository::new(db_pool.clone())),
783            pins: Arc::new(PosManagerPinRepository::new(db_pool.clone())),
784            tables: Arc::new(PosTableRepository::new(db_pool.clone())),
785            discounts: Arc::new(PosDiscountRepository::new(db_pool.clone())),
786            db_pool,
787            sink,
788            outbox_schema: None,
789            pin_policy: PinPolicy::default(),
790            ip_attempts: Arc::new(Mutex::new(HashMap::new())),
791        }
792    }
793
794    /// The database this call runs on: the composer's request pool when one
795    /// is bound (a tenant mount, or a relay consumer wrapped by the host),
796    /// else the composed pool (ADR-0029 pool law).
797    pub(super) fn rpool(&self) -> PgPool {
798        crate::request_pool::current().unwrap_or_else(|| self.db_pool.clone())
799    }
800
801    /// Opt into durable, outbox-backed `PosTenderCompleted` staging: `add_tender` will insert the
802    /// event into `{schema}.outbox_events` inside the tender transaction (so it commits atomically
803    /// with the tender). A composing service runs `backbone_outbox::runner` to drain the queue and
804    /// recognise the sale — surviving a crash between commit and the in-process `sink` spawn.
805    /// `None`/unset keeps the fire-and-forget behaviour. The schema must already exist (the service
806    /// runs `backbone_outbox::outbox::migrate(pool, schema)` at startup).
807    pub fn with_outbox(mut self, schema: impl Into<String>) -> Self {
808        self.outbox_schema = Some(schema.into());
809        self
810    }
811
812    /// Override the manager-PIN windows (lockout, throttling, strength). Callers that want the
813    /// environment to decide can pass [`PinPolicy::from_env`].
814    pub fn with_pin_policy(mut self, policy: PinPolicy) -> Self {
815        self.pin_policy = policy;
816        self
817    }
818
819
820    // ---- session ------------------------------------------------------------
821
822    pub async fn open_session(&self, s: NewSession) -> Result<Uuid, PosError> {
823        // Tenancy is composition-installed (ADR-0029): the verb opens its OWN transaction and
824        // re-binds the caller's ambient org scope onto it, so under a composed host the fill
825        // trigger stamps the acting unit and the fence admits the row — while a standalone
826        // deployment (no scope bound) runs plain, exactly as before. Every read below rides the
827        // same transaction, so the validation sees what the insert will see.
828        let mut tx = self.rpool().begin().await?;
829        relay_ambient_scope(&mut tx).await?;
830
831        // The register must exist before anything is written: without the check a session opens
832        // against a uuid that does not exist, and the close later fails when its register joins
833        // resolve nothing. Under a composed fence a register owned by another unit reads as plain
834        // absence — the refusal cannot distinguish whose it is, and must not (that distinction
835        // would be a cross-unit oracle).
836        let profile_known = self.profiles.exists(&mut *tx, s.pos_profile_id).await.map_err(PosError::from)?;
837        if !profile_known {
838            return Err(PosError::ProfileNotFound(s.pos_profile_id));
839        }
840
841        // One open session per register: the service's own arm. The per-(unit, register) slot
842        // unique is composition-installed (the decorator re-declares it org-scoped — its
843        // tenant-free shape is deliberately NOT declared module-side), so until a host composes,
844        // this read is the only guard; once composed, it also gives the friendly refusal for the
845        // race the constraint aborts.
846        let already_open = self
847            .openings
848            .has_open_session(&mut *tx, s.pos_profile_id)
849            .await
850            .map_err(PosError::from)?;
851        if already_open {
852            return Err(PosError::SessionAlreadyOpen);
853        }
854
855        let id = Uuid::new_v4();
856        let opening = serde_json::Value::Array(s.opening_balances.iter().map(|(m, a)| {
857            serde_json::json!({ "method": m, "amount": a.to_string() })
858        }).collect());
859        let r = self.openings.insert_opening_entry(&mut *tx, &NewOpeningEntryRow {
860            id,
861            pos_profile_id: s.pos_profile_id,
862            branch_id: s.branch_id,
863            cashier_party_id: s.cashier_party_id,
864            opened_at: s.opened_at,
865            opening_balances: opening,
866        }).await;
867        if let Err(e) = r {
868            // A unique violation here is the race on the composition-installed register slot
869            // (its index name carries the "pos_profile_id" fragment this matcher keys on);
870            // map it to the typed refusal instead of an internal error.
871            return Err(match dup_constraint(&e).as_deref() {
872                Some(c) if c.contains("pos_profile_id") => PosError::SessionAlreadyOpen,
873                _ => e.into(),
874            });
875        }
876        tx.commit().await?;
877        self.sink.publish(PosEvent::PosSessionOpened(PosSessionOpened {
878            opening_entry_id: id,
879            pos_profile_id: s.pos_profile_id,
880            company_id: legacy_company_echo(),
881        }));
882        Ok(id)
883    }
884}