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(¢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 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 /// Opt into durable, outbox-backed `PosTenderCompleted` staging: `add_tender` will insert the
795 /// event into `{schema}.outbox_events` inside the tender transaction (so it commits atomically
796 /// with the tender). A composing service runs `backbone_outbox::runner` to drain the queue and
797 /// recognise the sale — surviving a crash between commit and the in-process `sink` spawn.
798 /// `None`/unset keeps the fire-and-forget behaviour. The schema must already exist (the service
799 /// runs `backbone_outbox::outbox::migrate(pool, schema)` at startup).
800 pub fn with_outbox(mut self, schema: impl Into<String>) -> Self {
801 self.outbox_schema = Some(schema.into());
802 self
803 }
804
805 /// Override the manager-PIN windows (lockout, throttling, strength). Callers that want the
806 /// environment to decide can pass [`PinPolicy::from_env`].
807 pub fn with_pin_policy(mut self, policy: PinPolicy) -> Self {
808 self.pin_policy = policy;
809 self
810 }
811
812
813 // ---- session ------------------------------------------------------------
814
815 pub async fn open_session(&self, s: NewSession) -> Result<Uuid, PosError> {
816 // Tenancy is composition-installed (ADR-0029): the verb opens its OWN transaction and
817 // re-binds the caller's ambient org scope onto it, so under a composed host the fill
818 // trigger stamps the acting unit and the fence admits the row — while a standalone
819 // deployment (no scope bound) runs plain, exactly as before. Every read below rides the
820 // same transaction, so the validation sees what the insert will see.
821 let mut tx = self.db_pool.begin().await?;
822 relay_ambient_scope(&mut tx).await?;
823
824 // The register must exist before anything is written: without the check a session opens
825 // against a uuid that does not exist, and the close later fails when its register joins
826 // resolve nothing. Under a composed fence a register owned by another unit reads as plain
827 // absence — the refusal cannot distinguish whose it is, and must not (that distinction
828 // would be a cross-unit oracle).
829 let profile_known = self.profiles.exists(&mut *tx, s.pos_profile_id).await.map_err(PosError::from)?;
830 if !profile_known {
831 return Err(PosError::ProfileNotFound(s.pos_profile_id));
832 }
833
834 // One open session per register: the service's own arm. The per-(unit, register) slot
835 // unique is composition-installed (the decorator re-declares it org-scoped — its
836 // tenant-free shape is deliberately NOT declared module-side), so until a host composes,
837 // this read is the only guard; once composed, it also gives the friendly refusal for the
838 // race the constraint aborts.
839 let already_open = self
840 .openings
841 .has_open_session(&mut *tx, s.pos_profile_id)
842 .await
843 .map_err(PosError::from)?;
844 if already_open {
845 return Err(PosError::SessionAlreadyOpen);
846 }
847
848 let id = Uuid::new_v4();
849 let opening = serde_json::Value::Array(s.opening_balances.iter().map(|(m, a)| {
850 serde_json::json!({ "method": m, "amount": a.to_string() })
851 }).collect());
852 let r = self.openings.insert_opening_entry(&mut *tx, &NewOpeningEntryRow {
853 id,
854 pos_profile_id: s.pos_profile_id,
855 branch_id: s.branch_id,
856 cashier_party_id: s.cashier_party_id,
857 opened_at: s.opened_at,
858 opening_balances: opening,
859 }).await;
860 if let Err(e) = r {
861 // A unique violation here is the race on the composition-installed register slot
862 // (its index name carries the "pos_profile_id" fragment this matcher keys on);
863 // map it to the typed refusal instead of an internal error.
864 return Err(match dup_constraint(&e).as_deref() {
865 Some(c) if c.contains("pos_profile_id") => PosError::SessionAlreadyOpen,
866 _ => e.into(),
867 });
868 }
869 tx.commit().await?;
870 self.sink.publish(PosEvent::PosSessionOpened(PosSessionOpened {
871 opening_entry_id: id,
872 pos_profile_id: s.pos_profile_id,
873 company_id: legacy_company_echo(),
874 }));
875 Ok(id)
876 }
877}