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 /// The authenticated caller 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 caller key) skips the per-caller 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(¢er(&self.register_name)); o.push('\n');
420 o.push_str(¢er(&format!("Receipt {}", self.receipt_number))); o.push('\n');
421 o.push_str(¢er(&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(¢er("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 /// A tender other than cash would take the ticket past its rounded total. Change is only ever
468 /// given in cash, so a card, QRIS or transfer tender may cover at most what is still due.
469 NonCashOverpayment { method: String, remaining: Decimal },
470 NotReturnable(String),
471 MissingAccount(&'static str),
472 DuplicateNumber(String),
473 ProfileNotFound(Uuid),
474 InvoiceNotFound(Uuid),
475 SessionNotFound(Uuid),
476 BillingRejected { code: String, message: String },
477 PaymentRejected { code: String, message: String },
478 PricingRejected { code: String, message: String },
479 InventoryRejected { code: String, message: String },
480 InvalidCashMovement(&'static str),
481 /// Forward-compatible seam (council 2026-07-26, #5): `return_sale` accepts an optional
482 /// `PartialCredit`, but partial-return behavior is not yet implemented — it lands when
483 /// backbone-billing's credit note honors a line subset (ADR-001 park; gate: merchant demand).
484 PartialReturnsNotImplemented,
485
486 // --- document-grade tax + register configuration -------------------------
487 /// The register carries no tax templates — a ticket cannot be priced. Configure at least one
488 /// template on the profile (zero-rated included for a non-PKP register).
489 ProfileTaxTemplatesMissing(Uuid),
490 /// The tax compute port refused the request (propagates the seam's code + message).
491 TaxRejected { code: String, message: String },
492 /// The session the client rang under has since closed and no open rescue session was offered.
493 SessionClosedRescueRequired(Uuid),
494
495 // --- offline sync ---------------------------------------------------------
496 /// The replay would reassign the ticket to a different customer than it was first synced with.
497 SyncPartnerMismatch,
498 /// The replay names a session the ticket does not belong to (and the original is still open).
499 SyncSessionMismatch,
500 /// A refund replay's parent uuid does not resolve to a live ticket of this company.
501 RefundParentNotFound(Uuid),
502 /// A refund ticket already recorded against a different parent refuses to be re-pointed.
503 RefundLineageConflict,
504 /// A tender replay carries a method the payment-method enum does not know.
505 InvalidTenderMethod(String),
506 /// The client uuid is already held by another live ticket (the sync identity namespaces inside
507 /// the acting unit's scope once composed; a collision is a client bug or a replay of someone
508 /// else's uuid).
509 DuplicateClientUuid(Uuid),
510
511 // --- session close guards --------------------------------------------------
512 /// The session still holds DRAFT tickets — post or void them before closing.
513 SessionHasDraftOrders(i64),
514 /// The session holds invoiced tickets whose posting did not complete (billing linked, ticket
515 /// not paid) — retry recognition before closing.
516 SessionHasUnpostedInvoices(i64),
517 /// A second session is already open on this register (enforced by the one-open-session unique).
518 SessionAlreadyOpen,
519
520 // --- session close variance ------------------------------------------------
521 /// The variance seam refused the booking (propagates the seam's code + message). Nothing has been
522 /// written when this surfaces — the close rolls back entirely.
523 VarianceRejected { code: String, message: String },
524
525 // --- manager PIN -----------------------------------------------------------
526 /// A privileged mutation was attempted without manager authorization.
527 ManagerAuthRequired,
528 /// No live PIN is set for that manager.
529 PinNotFound,
530 /// The presented PIN does not match the stored hash.
531 PinInvalid,
532 /// The manager's PIN is locked: too many consecutive failures. Carries the unlock instant.
533 PinLocked { until: chrono::DateTime<chrono::Utc> },
534 /// Too many verification attempts from one caller inside the throttle window.
535 PinThrottled,
536 /// The chosen PIN does not satisfy the policy (digits + length window).
537 WeakPin(&'static str),
538
539 // --- restaurant seating ------------------------------------------------------
540 /// The named dining table does not exist (in this tenant).
541 TableNotFound(Uuid),
542 /// The named dining table already holds a live DRAFT ticket — one draft per table; resume,
543 /// settle, or move that ticket before opening a second one on the table. Carries the occupying
544 /// ticket so the client can jump to it. (The DB partial unique is the backstop; this is the
545 /// friendly typed refusal.)
546 TableOccupied { pos_table_id: Uuid, draft_invoice_id: Uuid },
547
548 // --- order-level discount ----------------------------------------------------
549 /// The named discount master row does not exist (in this tenant) or is retired.
550 DiscountNotFound(Uuid),
551 /// The register is configured discount-off (`allow_discount = false`) — a ring naming a
552 /// discount master is refused.
553 DiscountNotAllowed,
554 /// The discount master's stored percentage is unusable (over 100%). The master row is
555 /// misconfigured; nothing was rung.
556 DiscountInvalid(&'static str),
557 Db(sqlx::Error),
558}
559
560impl PosError {
561 pub fn code(&self) -> String {
562 match self {
563 PosError::EmptyDocument => "empty_document".into(),
564 PosError::NegativeAmount => "negative_amount".into(),
565 PosError::SessionNotOpen => "session_not_open".into(),
566 PosError::SessionRegisterMismatch => "session_register_mismatch".into(),
567 PosError::NotDraft => "not_draft".into(),
568 PosError::NotFullyTendered { .. } => "not_fully_tendered".into(),
569 PosError::NonCashOverpayment { .. } => "non_cash_overpayment".into(),
570 PosError::NotReturnable(_) => "not_returnable".into(),
571 PosError::MissingAccount(_) => "missing_account".into(),
572 PosError::DuplicateNumber(_) => "duplicate_number".into(),
573 PosError::ProfileNotFound(_) => "profile_not_found".into(),
574 PosError::InvoiceNotFound(_) => "invoice_not_found".into(),
575 PosError::SessionNotFound(_) => "session_not_found".into(),
576 PosError::BillingRejected { code, .. } => code.clone(),
577 PosError::PaymentRejected { code, .. } => code.clone(),
578 PosError::PricingRejected { code, .. } => code.clone(),
579 PosError::InventoryRejected { code, .. } => code.clone(),
580 PosError::InvalidCashMovement(_) => "invalid_cash_movement".into(),
581 PosError::PartialReturnsNotImplemented => "partial_returns_not_implemented".into(),
582 PosError::ProfileTaxTemplatesMissing(_) => "profile_tax_templates_missing".into(),
583 PosError::TaxRejected { code, .. } => code.clone(),
584 PosError::SessionClosedRescueRequired(_) => "session_closed_rescue_required".into(),
585 PosError::SyncPartnerMismatch => "sync_partner_mismatch".into(),
586 PosError::SyncSessionMismatch => "sync_session_mismatch".into(),
587 PosError::RefundParentNotFound(_) => "refund_parent_not_found".into(),
588 PosError::RefundLineageConflict => "refund_lineage_conflict".into(),
589 PosError::InvalidTenderMethod(_) => "invalid_tender_method".into(),
590 PosError::DuplicateClientUuid(_) => "duplicate_client_uuid".into(),
591 PosError::SessionHasDraftOrders(_) => "session_has_draft_orders".into(),
592 PosError::SessionHasUnpostedInvoices(_) => "session_has_unposted_invoices".into(),
593 PosError::SessionAlreadyOpen => "session_already_open".into(),
594 PosError::VarianceRejected { code, .. } => code.clone(),
595 PosError::ManagerAuthRequired => "manager_authorization_required".into(),
596 PosError::PinNotFound => "manager_pin_not_set".into(),
597 PosError::PinInvalid => "manager_pin_invalid".into(),
598 PosError::PinLocked { .. } => "manager_pin_locked".into(),
599 PosError::PinThrottled => "manager_pin_throttled".into(),
600 PosError::WeakPin(_) => "weak_pin".into(),
601 PosError::TableNotFound(_) => "table_not_found".into(),
602 PosError::TableOccupied { .. } => "table_occupied".into(),
603 PosError::DiscountNotFound(_) => "discount_not_found".into(),
604 PosError::DiscountNotAllowed => "discount_not_allowed".into(),
605 PosError::DiscountInvalid(_) => "discount_invalid".into(),
606 PosError::Db(_) => "internal_error".into(),
607 }
608 }
609 pub fn http_status(&self) -> u16 {
610 match self {
611 PosError::ProfileNotFound(_)
612 | PosError::InvoiceNotFound(_)
613 | PosError::SessionNotFound(_)
614 | PosError::TableNotFound(_)
615 | PosError::DiscountNotFound(_) => 404,
616 // Failed/absent manager credentials are authorization outcomes, not validation ones.
617 PosError::ManagerAuthRequired | PosError::PinInvalid => 403,
618 // Lockout + source throttling are rate-limit outcomes.
619 PosError::PinLocked { .. } | PosError::PinThrottled => 429,
620 // A table already holding a draft is a state conflict on a named resource (PSX-1 said
621 // loudly, not silently).
622 PosError::TableOccupied { .. } => 409,
623 PosError::Db(_) => 500,
624 _ => 422,
625 }
626 }
627}
628impl std::fmt::Display for PosError {
629 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
630 match self {
631 PosError::BillingRejected { code, message }
632 | PosError::PaymentRejected { code, message }
633 | PosError::TaxRejected { code, message }
634 | PosError::VarianceRejected { code, message } => write!(f, "{code}: {message}"),
635 PosError::PinLocked { until } => write!(f, "manager_pin_locked until {until}"),
636 other => write!(f, "{}", other.code()),
637 }
638 }
639}
640impl std::error::Error for PosError {}
641impl From<sqlx::Error> for PosError {
642 fn from(e: sqlx::Error) -> Self { PosError::Db(e) }
643}
644
645/// The constraint name of a unique violation, when it is one. Discriminating over a bare
646/// is-unique check: the ticket table now carries several partial uniques (receipt number, sync
647/// uuid, one draft per dining table), and the service maps each to its OWN typed refusal
648/// instead of guessing.
649pub(super) fn dup_constraint(e: &sqlx::Error) -> Option<String> {
650 let db = e.as_database_error()?;
651 if !db.is_unique_violation() {
652 return None;
653 }
654 db.constraint().map(|c| c.to_string())
655}
656
657/// Map a ticket-header unique violation to its typed refusal by constraint name, enriching the
658/// one-draft-per-table collision with the occupying ticket (read on a FRESH connection — the
659/// caller's transaction is aborted by the failed insert). Unmapped unique violations surface as the
660/// raw DB error (`internal_error`) — an honest 500 for an index this module did not declare, never
661/// a mislabeled 4xx.
662pub(super) async fn map_invoice_dup(
663 e: sqlx::Error,
664 receipt_number: &str,
665 client_uuid: Option<Uuid>,
666 pos_table_id: Option<Uuid>,
667 invoices: &crate::infrastructure::persistence::PosInvoiceRepository,
668 pool: &sqlx::PgPool,
669 exclude_invoice_id: Option<Uuid>,
670) -> PosError {
671 match dup_constraint(&e).as_deref() {
672 Some(c) if c.contains("receipt_number") => PosError::DuplicateNumber(receipt_number.to_string()),
673 Some(c) if c.contains("client_uuid") => PosError::DuplicateClientUuid(client_uuid.unwrap_or_default()),
674 Some(c) if c.contains("pos_table_id") => {
675 let table = pos_table_id.unwrap_or_default();
676 let draft = invoices
677 .find_draft_on_table_scoped(pool, table, exclude_invoice_id)
678 .await
679 .ok()
680 .flatten()
681 .unwrap_or_default();
682 PosError::TableOccupied { pos_table_id: table, draft_invoice_id: draft }
683 }
684 _ => PosError::Db(e),
685 }
686}
687
688/// The manager-PIN policy: every window is configuration, not code — a probe process must be able to
689/// shorten the lockout without sleeping a production timeout, and a production deployment must be able
690/// to tighten it. Defaults are deliberately short (the values below are what tests and local dev run
691/// with) so the shipped library is usable out of the box; `from_env` reads overrides.
692#[derive(Debug, Clone)]
693pub struct PinPolicy {
694 /// Consecutive failures before a manager's PIN locks.
695 pub max_attempts: u32,
696 /// How long a lockout holds, in seconds.
697 pub lockout_secs: i64,
698 /// Shortest acceptable PIN length (digits).
699 pub min_digits: usize,
700 /// Longest acceptable PIN length (digits).
701 pub max_digits: usize,
702 /// Per-source-address verification window, in seconds.
703 pub ip_window_secs: i64,
704 /// Verifications allowed per caller inside `ip_window_secs` (successful or not).
705 pub ip_max_attempts: u32,
706}
707
708impl Default for PinPolicy {
709 fn default() -> Self {
710 Self {
711 max_attempts: 5,
712 lockout_secs: 15,
713 min_digits: 4,
714 max_digits: 8,
715 ip_window_secs: 60,
716 ip_max_attempts: 20,
717 }
718 }
719}
720
721impl PinPolicy {
722 /// Read overrides from the environment, falling back to [`PinPolicy::default`] per variable:
723 /// `POS_PIN_MAX_ATTEMPTS`, `POS_PIN_LOCKOUT_SECS`, `POS_PIN_MIN_DIGITS`, `POS_PIN_MAX_DIGITS`,
724 /// `POS_PIN_IP_WINDOW_SECS`, `POS_PIN_IP_MAX_ATTEMPTS`.
725 pub fn from_env() -> Self {
726 let d = Self::default();
727 // Each override parses in its own field's type; a bad or missing value falls back per-field
728 // (never a zeroed window — a typo cannot silently disable the lockout).
729 let get = |k: &str| std::env::var(k).ok();
730 Self {
731 max_attempts: get("POS_PIN_MAX_ATTEMPTS").and_then(|v| v.trim().parse().ok()).unwrap_or(d.max_attempts),
732 lockout_secs: get("POS_PIN_LOCKOUT_SECS").and_then(|v| v.trim().parse().ok()).unwrap_or(d.lockout_secs),
733 min_digits: get("POS_PIN_MIN_DIGITS").and_then(|v| v.trim().parse().ok()).unwrap_or(d.min_digits),
734 max_digits: get("POS_PIN_MAX_DIGITS").and_then(|v| v.trim().parse().ok()).unwrap_or(d.max_digits),
735 ip_window_secs: get("POS_PIN_IP_WINDOW_SECS").and_then(|v| v.trim().parse().ok()).unwrap_or(d.ip_window_secs),
736 ip_max_attempts: get("POS_PIN_IP_MAX_ATTEMPTS").and_then(|v| v.trim().parse().ok()).unwrap_or(d.ip_max_attempts),
737 }
738 }
739}
740
741/// The repositories are held behind `Arc` only so this service stays `Clone` (its HTTP surface clones
742/// it per request) — `GenericCrudRepository` is not itself `Clone`. They are stateless handles over the
743/// same pool; the `Arc` carries no shared mutable state. The PIN attempt ring is the one piece of
744/// genuinely shared mutable state — `Mutex`, never held across an `.await`.
745#[derive(Clone)]
746pub struct PosWriteService {
747 pub(super) db_pool: PgPool,
748 pub(super) sink: Arc<dyn PosEventSink>,
749 /// When set, `add_tender` stages `PosTenderCompleted` into this outbox schema
750 /// **inside the tender's transaction** (durable) so a relay can recognise the sale even if the
751 /// process dies between commit and the in-process `sink` spawn. `None` (default) preserves the
752 /// historical fire-and-forget behaviour. Opt in via [`Self::with_outbox`].
753 pub(super) outbox_schema: Option<String>,
754 pub(super) invoices: Arc<PosInvoiceRepository>,
755 pub(super) items: Arc<PosInvoiceItemRepository>,
756 pub(super) payments: Arc<PosPaymentRepository>,
757 pub(super) profiles: Arc<PosProfileRepository>,
758 pub(super) openings: Arc<PosOpeningEntryRepository>,
759 pub(super) closings: Arc<PosClosingEntryRepository>,
760 pub(super) movements: Arc<PosCashMovementRepository>,
761 /// Manager-PIN credentials (argon2 hashes + attempt counters + lockout).
762 pub(super) pins: Arc<PosManagerPinRepository>,
763 /// Dining tables (restaurant lane: seating validation on ring/sync).
764 pub(super) tables: Arc<PosTableRepository>,
765 /// Order-level discount masters (restaurant lane: the server-side discount RATE source).
766 pub(super) discounts: Arc<PosDiscountRepository>,
767 /// The PIN windows above; [`Self::with_pin_policy`] overrides the defaults.
768 pub(super) pin_policy: PinPolicy,
769 /// Rolling per-caller verification ring: `caller -> (window_start_unix, attempts)`. A process
770 /// -local guard in front of the persisted counters (which key on the manager identity).
771 pub(super) ip_attempts: Arc<Mutex<HashMap<String, (i64, u32)>>>,
772}
773
774impl PosWriteService {
775 pub fn new(db_pool: PgPool) -> Self {
776 Self::with_sink(db_pool, Arc::new(LoggingSink))
777 }
778 pub fn with_sink(db_pool: PgPool, sink: Arc<dyn PosEventSink>) -> Self {
779 Self {
780 invoices: Arc::new(PosInvoiceRepository::new(db_pool.clone())),
781 items: Arc::new(PosInvoiceItemRepository::new(db_pool.clone())),
782 payments: Arc::new(PosPaymentRepository::new(db_pool.clone())),
783 profiles: Arc::new(PosProfileRepository::new(db_pool.clone())),
784 openings: Arc::new(PosOpeningEntryRepository::new(db_pool.clone())),
785 closings: Arc::new(PosClosingEntryRepository::new(db_pool.clone())),
786 movements: Arc::new(PosCashMovementRepository::new(db_pool.clone())),
787 pins: Arc::new(PosManagerPinRepository::new(db_pool.clone())),
788 tables: Arc::new(PosTableRepository::new(db_pool.clone())),
789 discounts: Arc::new(PosDiscountRepository::new(db_pool.clone())),
790 db_pool,
791 sink,
792 outbox_schema: None,
793 pin_policy: PinPolicy::default(),
794 ip_attempts: Arc::new(Mutex::new(HashMap::new())),
795 }
796 }
797
798 /// The database this call runs on: the composer's request pool when one
799 /// is bound (a tenant mount, or a relay consumer wrapped by the host),
800 /// else the composed pool (ADR-0029 pool law).
801 pub(super) fn rpool(&self) -> PgPool {
802 crate::request_pool::current().unwrap_or_else(|| self.db_pool.clone())
803 }
804
805 /// Opt into durable, outbox-backed `PosTenderCompleted` staging: `add_tender` will insert the
806 /// event into `{schema}.outbox_events` inside the tender transaction (so it commits atomically
807 /// with the tender). A composing service runs `backbone_outbox::runner` to drain the queue and
808 /// recognise the sale — surviving a crash between commit and the in-process `sink` spawn.
809 /// `None`/unset keeps the fire-and-forget behaviour. The schema must already exist (the service
810 /// runs `backbone_outbox::outbox::migrate(pool, schema)` at startup).
811 pub fn with_outbox(mut self, schema: impl Into<String>) -> Self {
812 self.outbox_schema = Some(schema.into());
813 self
814 }
815
816 /// Override the manager-PIN windows (lockout, throttling, strength). Callers that want the
817 /// environment to decide can pass [`PinPolicy::from_env`].
818 pub fn with_pin_policy(mut self, policy: PinPolicy) -> Self {
819 self.pin_policy = policy;
820 self
821 }
822
823
824 // ---- session ------------------------------------------------------------
825
826 pub async fn open_session(&self, s: NewSession) -> Result<Uuid, PosError> {
827 // Tenancy is composition-installed (ADR-0029): the verb opens its OWN transaction and
828 // re-binds the caller's ambient org scope onto it, so under a composed host the fill
829 // trigger stamps the acting unit and the fence admits the row — while a standalone
830 // deployment (no scope bound) runs plain, exactly as before. Every read below rides the
831 // same transaction, so the validation sees what the insert will see.
832 let mut tx = self.rpool().begin().await?;
833 relay_ambient_scope(&mut tx).await?;
834
835 // The register must exist before anything is written: without the check a session opens
836 // against a uuid that does not exist, and the close later fails when its register joins
837 // resolve nothing. Under a composed fence a register owned by another unit reads as plain
838 // absence — the refusal cannot distinguish whose it is, and must not (that distinction
839 // would be a cross-unit oracle).
840 let profile_known = self.profiles.exists(&mut *tx, s.pos_profile_id).await.map_err(PosError::from)?;
841 if !profile_known {
842 return Err(PosError::ProfileNotFound(s.pos_profile_id));
843 }
844
845 // One open session per register: the service's own arm. The per-(unit, register) slot
846 // unique is composition-installed (the decorator re-declares it org-scoped — its
847 // tenant-free shape is deliberately NOT declared module-side), so until a host composes,
848 // this read is the only guard; once composed, it also gives the friendly refusal for the
849 // race the constraint aborts.
850 let already_open = self
851 .openings
852 .has_open_session(&mut *tx, s.pos_profile_id)
853 .await
854 .map_err(PosError::from)?;
855 if already_open {
856 return Err(PosError::SessionAlreadyOpen);
857 }
858
859 let id = Uuid::new_v4();
860 let opening = serde_json::Value::Array(s.opening_balances.iter().map(|(m, a)| {
861 serde_json::json!({ "method": m, "amount": a.to_string() })
862 }).collect());
863 let r = self.openings.insert_opening_entry(&mut *tx, &NewOpeningEntryRow {
864 id,
865 pos_profile_id: s.pos_profile_id,
866 branch_id: s.branch_id,
867 cashier_party_id: s.cashier_party_id,
868 opened_at: s.opened_at,
869 opening_balances: opening,
870 }).await;
871 if let Err(e) = r {
872 // A unique violation here is the race on the composition-installed register slot
873 // (its index name carries the "pos_profile_id" fragment this matcher keys on);
874 // map it to the typed refusal instead of an internal error.
875 return Err(match dup_constraint(&e).as_deref() {
876 Some(c) if c.contains("pos_profile_id") => PosError::SessionAlreadyOpen,
877 _ => e.into(),
878 });
879 }
880 tx.commit().await?;
881 self.sink.publish(PosEvent::PosSessionOpened(PosSessionOpened {
882 opening_entry_id: id,
883 pos_profile_id: s.pos_profile_id,
884 company_id: legacy_company_echo(),
885 }));
886 Ok(id)
887 }
888}