# Orders & payments ## Basket Server-side basket so web, app and kiosk validate the same way (prices, availability, modifier rules). `id, location_id, service_type, customer_id?, device_id?, requested_slot?, asap, lines json, expires_at`. Priced on every change by the same pricing service that prices orders. Baskets are disposable; only orders are permanent. ## Order | Field | Notes | |---|---| | id | ULID | | number | human: `BR-042` (prefix + per-location daily counter, resets at midnight local) | | public_token | random, for `/track/{token}` without login | | location_id, service_type | `collection`, `kiosk_takeaway`, `kiosk_eat_in`, `catering` | | channel | `web`, `app_ios`, `app_android`, `kiosk`, `admin` | | customer_id? / guest_name, guest_email, guest_phone | guest checkout allowed | | device_id? | kiosk that created it | | status | see state machine | | asap, requested_for, promised_for | requested slot vs. time we committed to | | subtotal, discount_total, service_charge, tax_total, total, currency | pence | | customer_note | per order (Storekit `enableOrderNotes`) | | placed_at, accepted_at, ready_at, collected_at, cancelled_at | denormalised from history for reporting | | cancel_reason, cancelled_by | | | loyalty_ref? | points earned / claimed (future) | ### OrderLine (snapshot tree) Same shape as Ordit's `OrderingItem`: a product line with child lines for each chosen modifier (children can have children for nested groups). `id, order_id, parent_id?, kind (product | modifier), product_id?, modifier_id?, group_name, name, quantity, unit_price, total_price, tax_rate, tax_amount, allergens (json snapshot), note, position`. Everything the customer saw is **copied** — editing the menu later never changes a past order. Removal modifiers are stored as lines with `unit_price 0` and kind `modifier` + group kind `removal`, so kitchen tickets can print "NO AIOLI". ### Order state machine ```mermaid stateDiagram-v2 [*] --> pending_payment : place order pending_payment --> payment_failed : PSP declined / timeout payment_failed --> pending_payment : retry payment_failed --> abandoned : 30 min pending_payment --> scheduled : paid, slot > now + prep pending_payment --> placed : paid, ASAP / slot soon scheduled --> placed : release time (slot − prep − buffer) placed --> accepted : Stream / store ack placed --> rejected : store rejects / dispatch failed past SLA accepted --> preparing : kitchen starts preparing --> ready : kitchen bumps accepted --> ready : (store skips preparing) ready --> collected : handed over / QR scanned ready --> no_show : not collected by close placed --> cancelled : customer (within window) / staff scheduled --> cancelled : customer / staff accepted --> cancelled : staff only rejected --> refunded cancelled --> refunded refunded --> [*] collected --> [*] ``` Rules: - Transitions only through an `OrderStateMachine` service; each transition writes an `OrderStatusChange` (`from, to, actor, reason, at`) **and** an `AuditEvent`. No `setStatus()` anywhere else (Ordit's mistake). - Customer may cancel while `scheduled`, or `placed` within N minutes; after `accepted` only staff. - `placed` without ack after the SLA (e.g. 3 min) → alert on admin board + fallback dispatch channel (email / kitchen screen), not auto-reject. - Every status change is broadcast on `order.{id}` (tracking page) and `location.{id}.orders` (board, kiosk). ## Payment `id, order_id, provider (stripe | adyen), method (card | apple_pay | google_pay | terminal | pay_in_store | voucher), status (pending | authorised | captured | failed | cancelled), amount, psp_reference, terminal_id?, device_id?, raw_response json, created_at`. - Online: authorise at place order, capture on `accepted` (so rejected orders cost no fees), or immediate capture if the PSP setup prefers it **[Q]**. - Providers behind one `PaymentGateway` interface; Stripe first, Adyen second. - Kiosk: terminal payment (Stripe Terminal / Adyen Terminal API) with an idempotency key = order id. States mirror Atis' START / IN_PROGRESS / SUCCESS / FAILED, plus "already paid" on cancel race. - PSP webhooks are the source of truth; client callbacks only update the UI. ## Refund `id, payment_id, amount, reason, lines json (optional partial), status, psp_reference, actor`. Partial refunds by line supported. Permission `orders.refund` with optional per-role limit (e.g. shift lead ≤ £20). ## Slots & capacity Ordit computed times on the fly; we model capacity explicitly: - Slot grid from `LocationService.slot_length_minutes` within opening hours. - Capacity per slot = `slot_capacity` orders (v1) → later item-weighted (a 20-bowl catering order takes more kitchen time than one wrap). - Kiosk orders consume capacity "now", so web ASAP times move out when the store is slammed. - Staff can close a slot or set "busy +15 min" from the board → `Location.paused_until` / prep time bump.