Developer Resources POS Module Overview POS Features and Flows Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for the POS module.
Source Type Files or Docs What Was Extracted Technical design .omc/plans/POS/master-plan.md, consumer-handoff.mdState model, validation, timing, the frozen contract API pos-sale-admin.controller.ts, pos-lookup-admin.controller.tsRoute surface, permissions, rate limits Backend pos-sale-draft.service.ts, pos-sale-completion.service.ts, pos-sale-line.service.ts, pos-customer.service.ts, pos-lookup.service.tsBusiness behavior and side effects Schema packages/db/src/schema/pos/*.tsTables, enums, constraints, the state machine as a CHECK
Field Value Module posSubmodule sale (admin), lookup (admin)Primary user value A shop rings up a walk-in sale at the counter and produces an ordinary order with one request Actors admin (the operator), system (the draft sweep)Main entry points 13 admin routes under /api/admin/pos/; the hourly draft sweep Main outputs A draft sale, a completed picked_up/ordered sale, an order, a payment_attempt, a receipt email, a walk-in invitation email Related docs Backend , API
Actor Can Do Cannot Do Auth Requirement Notes Guest Nothing Touch any route — There is no customer-facing POS route Admin (operator) Open a draft, attach/create a customer, scan lines, choose fulfilment, complete, cancel Re-price a line, modify a completed sale JwtAuthGuard + RoleGuard + Pos_*Every mutation is audited with the acting administrator Admin (reader) List, filter, view one sale, view the timeline, look up customers/products Mutate anything Pos_READLookup is Pos_READ, grantable separately from Users_READ System (sweep) Cancel drafts idle past 24h Touch a sale being edited, complete a sale Queue/internal One transaction per draft, guarded UPDATE
Capability Surface Actor Route/Trigger State Read State Written Linked API Section Find a customer Admin Operator GET /api/admin/pos/lookup/customerscustomers— §8.1 Find a sellable product Admin Operator GET /api/admin/pos/lookup/productsproducts— §8.2 Open a draft Admin Operator POST /api/admin/pos/salescustomerspos_sale (draft, pickup), event created§8.3 List sales Admin Operator GET /api/admin/pos/salespos_sale + joins— §8.4 Read one sale Admin Operator GET /api/admin/pos/sales/{publicId}pos_sale, pos_sale_item— §8.5 Read the timeline Admin Operator GET /api/admin/pos/sales/{publicId}/timelinepos_sale_event— §8.6 Attach a customer Admin Operator PATCH /sales/{publicId}/customercustomerspos_sale (customer + reset to pickup), event customer_assigned§8.7 Create a walk-in Admin Operator POST /sales/{publicId}/customer— customers, account (no password), outbox invite, event customer_created§8.8 Set a line quantity Admin Operator PUT /sales/{publicId}/items/{productPublicId}productspos_sale_item (upsert), pos_sale totals, event item_added/item_quantity_changed§8.9 Remove a line Admin Operator DELETE /sales/{publicId}/items/{productPublicId}— pos_sale_item (hard delete), totals, event item_removed§8.10 Choose fulfilment Admin Operator PATCH /sales/{publicId}/fulfilmentcustomer_addressespos_sale, customer_addresses (new address), event fulfilment_set§8.11 Complete the sale Admin Operator POST /sales/{publicId}/completepos_sale, lines, products (prices), inventorycart, session, payment_attempt, order, reservation finalize, pos_sale terminal, outbox receipt, events §8.12 Cancel a draft Admin Operator POST /sales/{publicId}/cancel— pos_sale (cancelled), event cancelled (reason required)§8.13 Sweep abandoned drafts System Sweep hourly cron → pos.sweep_abandoned_drafts pos_salepos_sale (cancelled), event cancelled§9
There is no customer-facing flow in this module. The walk-in customer is present at the counter
but every action is performed by the administrator on their behalf ; the customer-facing
"flows" below are what the customer experiences through the operator.
A customer walks in without an account. The operator finds or creates the customer, scans the
items, decides pickup or delivery, takes the money and hands the goods over — one request at the
end does the money, the stock and the order together.
The operator holds Pos_CREATE/Pos_UPDATE/Pos_READ and the till has connectivity.
The customer either already has an account, or supplies a name and email.
Step Actor/System Action Result Source 1 Operator Look up the customer by name/email/phone Match list pos-lookup.service.ts2 Operator Open the draft with payment method + customer pos_sale status draft, fulfilment pickuppos-sale-draft.service.ts3 Operator (New customer) create the walk-in Account + email invitation queued pos-customer.service.ts4 Operator Scan each item, typing the final quantity Line frozen with the price, totals recomputed pos-sale-line.service.ts5 Operator (Delivery only) choose fulfilment + address delivery with address and shipping chargepos-sale-draft.service.ts6 Operator Complete Money, stock, order, hand-over in one transaction pos-sale-completion.service.ts7 System Receipt email Receipt queued via outbox → NOTIFICATIONS pos-email.processor.ts
Branch Condition Behavior Error/Result No matching customer Search too short 400 VALIDATION_FAILED Type ≥ 2 characters Customer has no account Unknown walk-in Create walk-in first customerId required at openEmail already taken Existing customer 409 POS_CUSTOMER_EMAIL_TAKEN Attach the existing customer instead Same item scanned twice Second PUT Quantity REPLACED, not added Line ends at the typed number Price moved mid-sale sellingPrice != frozen unitPrice409 POS_PRODUCT_PRICE_CHANGED Remove and re-add; nothing charged Someone else took the stock Reserve fails 409 POS_INSUFFICIENT_STOCK Nothing charged, sale still a draft Completion replayed Same request twice Second attempt collides on unique order/cart/session keys 409 POS_SALE_ALREADY_COMPLETED, no second charge
The walk-in account has no password. The customer receives an emailed link to set one. The link
never expires but is single-use — it stops working the moment a password is successfully set.
The operator created the walk-in (which queued the invitation in the same transaction).
Step Actor/System Action Result Source 1 Worker Runs pos.send_walk_in_invite Mints token, renders email, queues to NOTIFICATIONS pos-email.processor.ts2 Customer Opens the emailed link Land on /set-password?token=... pos-walk-in-invite.email.ts3 Customer Sets a password verification.consumed_at stamped; token refused from then onVerificationTokenService
Branch Condition Behavior Error/Result Customer deleted the account Between sale and job Worker skips (outcome: "skip") No retry of an invitation to a closed account No email on file Customer without email Worker skips Nothing to send to Link used twice After first set Token consumed Refused; customer logs in normally Link never used Weeks later Still valid — never expires By owner policy; bounded by single use
Step Action Result 1 POST /sales/{publicId}/cancel with a reasonpos_sale → cancelled, event cancelled with mandatory reason2 Operator opens a new draft Fresh POS-2026-... number
A draft may also be cancelled by the hourly sweep after 24h idle. Both paths record
cancelled; only the operator's carries actor_admin_id (the sweep writes null).
Entity From Event/Action To Guard Condition Side Effects pos_saledraftComplete (pickup) picked_upLines present, customer set, fulfilment pickup Stock, payment, order, hand-over pos_saledraftComplete (delivery) orderedLines present, customer set, address set Stock, payment, order pos_saledraftCancel (operator) cancelledReason non-blank Event with actor + reason pos_saledraftSweep (24h idle) cancelledupdated_at <= now() - 24h, guarded WHERE status='draft'Event with sweep reason, no actor
There is no awaiting_payment. An online checkout needs that state because the customer
leaves for a gateway and comes back; a counter sale has no such gap — the operator takes the
money and records it in the same request. Modelling a wait that cannot happen would create a
state every query has to handle and no code path can ever produce.
picked_up is terminal for POS but the order is already delivered; ordered is terminal for
POS but the order is at confirmed and the Order module owns every state after it.
Flow DB Writes Cache Effects Jobs Realtime Analytics Notifications Open draft pos_sale + event— — — — — Create walk-in customers, account, event— outbox → pos.send_walk_in_invite — — Set-password email Line upsert/remove pos_sale_item (+hard delete), totals, event— — — — — Set fulfilment pos_sale, maybe customer_addresses, event— — — — — Complete pos_sale terminal, cart, session, payment_attempt, order, reservation finalize, eventsinventory domain after commitoutbox → pos.send_receipt — — Receipt email Cancel pos_sale + event— — — — — Sweep pos_sale + event per draft— direct enqueue (cron exemption) — — —
Scenario Trigger User/System Experience Recovery Source Rate limited > 60 draft writes/min or > 30 completes/min 429Wait; the limits are far above genuine till use ip-throttler.config.tsPrice changed Catalogue moved mid-sale 409 POS_PRODUCT_PRICE_CHANGEDRemove and re-add the line; nothing was charged pos-sale-completion.service.tsStock short Two tills, one unit 409 POS_INSUFFICIENT_STOCKShow remaining stock, reduce the quantity same Completion replay Retried after commit 409 POS_SALE_ALREADY_COMPLETEDShow the existing order number; do not retry unique indexes Queue outage Redis/BullMQ down Sale still commits; receipt/invite delayed Outbox row is durable; dispatcher relays later pos-mail-queue.service.tsDeterministic job failure e.g. sale not found Job returns { success: false, retryable: false, errorCode } Never retried; visible in BullMQ return value pos-queue.processor.tsTransient job failure DB connection drop Rethrown for BullMQ retry Automatic same
Feature Minor Behavior Actor Trigger User/System Result Backend Side Effect Source Customer lookup Search min 2 chars Operator GET /lookup/customersBounded, paginated matches ILIKE on name/email/phone, active customers onlypos-lookup.service.tsProduct lookup SKU or name Operator GET /lookup/productsBounded, paginated matches published + not deleted + price ≤ MRP only same Open draft Always pickup Operator POST /salesDraft with fulfilment: "pickup" fulfilment deliberately not accepted in DTOpos-sale-request.dto.tsAttach customer Resets to pickup Operator PATCH /customerSale drops address/fulfilment/shipping CLEAR_DELIVERY_ON_CUSTOMER_CHANGEpos-sale-draft.service.tsWalk-in create Email lowercased Operator POST /customerAccount + invitation No password; email_verified: false pos-customer.service.tsLine upsert Replaces quantity Operator PUT /items/{id}Line at typed quantity ON CONFLICT DO UPDATEpos-sale-line.service.tsLine remove Hard delete Operator DELETE /items/{id}Line gone; totals recomputed Hard delete — the sale of record is order_item same New address Saved to customer book Operator PATCH /fulfilmentAddress reusable later is_default: false deliberatelypos-sale-draft.service.tsPayment reference Recorded, never validated Operator POST /completeReference on the receipt Refused beside pos_cash pos-sale-completion.service.tsReceipt Skipped without email System pos.send_receiptNo email, job logged skip outcome: "skip"pos-email.processor.tsInvite Skipped for deleted account System pos.send_walk_in_inviteNo email, job logged skip outcome: "skip"same Sweep Batch cap System hourly cron Stops at 100 Warns when the batch fills pos-draft-sweep.processor.ts
The full till flow diagram (§6.1), the sale lifecycle (§7), the completion sequence (§5.1) and
the queue topology below cover the module's business processes.
Rule Business Reason Actor Impact Enforced In API Impact Backend Impact Tests A sale always opens as a pickup open() cannot know an address yet; delivery needs oneOperator re-chooses delivery DTO omits the field No fulfilment on POST /sales Schema CHECK pos-sale.int.spec.tsOne live review needs a delivered order — (POS enables it) — — — Pickup drives order to delivered order-counter-handover.service.tsEvery completed sale has every downstream key No paid sale without an order Operator sees orderNumber chk_pos_sale_status_completion— — constraint probe A cancelled sale needs a non-blank reason Audit answers why Operator must type a reason DTO + chk_pos_sale_event_cancel_reason Mandatory body field — probe Cash has no reference A reference beside cash is a mis-selected method Operator clears the field DTO + chk_pos_sale_payment_reference_method 409 — probe A pickup charges no shipping Money for a service not rendered Operator sees shipping 0 chk_pos_sale_fulfilment_address— — probe The walk-in has no password Credentials are the customer's alone Customer sets own password PosCustomerServiceinvitationQueued only, never a tokenpassword: nullpos-customer pathA draft holds no inventory Reserves only at completion — POS_DRAFT_ABANDON_HOURS semantics— Sweep is housekeeping —
Product Decision User Benefit Engineering Benefit Alternative Tradeoff Risk POS reuses the online pipeline One order domain, one return/refund path No second ledger A parallel counter-only record Materializer complexity Channel is a label, never a branch Drafts are separate rows, not carts A phone cart and a till sale coexist No collision with uq_cart_customer_id_live Reusing cart_item Cart born converted is unusual Documented on the schema No awaiting_payment Operator never sees a stuck state One less state everywhere Modelling the wait — — Never-expiring single-use link A walk-in can always get in No support backlog 24h TTL Long-lived link if email is stolen Single use; no OTP Price freeze + loud refusal The quoted price is the price paid No silent re-pricing Charge stale price Mid-sale 409s Operator + customer are both present Hard-deleted draft lines — Simpler Soft delete Draft history lost order_item is the record of sale
Flow Edge Case Trigger Expected Behavior User/System Feedback Source Open Customer soft-deleted Attach a closed account 404 POS_SALE_NO_CUSTOMER Operator looks elsewhere pos-sale-draft.service.tsWalk-in Email of a soft-deleted account Create walk-in 409 POS_CUSTOMER_EMAIL_TAKEN (closed-account message) Use a different address pos-customer.service.tsFulfilment Both addressPublicId and address Send both 409 POS_DELIVERY_ADDRESS_REQUIRED Pick one pos-sale-draft.service.tsFulfilment Address not this customer's Send another's addressPublicId 404 POS_ADDRESS_NOT_FOUND Re-fetch their addresses same Line Quantity 0 or 100 PUT with bad number400 POS_QUANTITY_OUT_OF_RANGE Clamp in UI pos-sale-line.service.tsLine Product unpublished mid-sale PUT a delisted product404/409 POS_PRODUCT_NOT_SELLABLE Remove the line same Complete Empty basket Complete with no lines 409 POS_SALE_EMPTY Block until a line exists completion service Complete Delivery with no address Complete delivery draft 409 POS_DELIVERY_ADDRESS_REQUIRED Open the address form same Complete Sale already terminal Replay completion 409 POS_SALE_ALREADY_COMPLETED Show existing order number unique indexes Sweep Operator resumes a claimed draft Race between sweep and operator Guarded UPDATE cancels nothing; operator wins Draft stays live pos-draft-sweep.processor.ts
Flow Reads Writes Cache Jobs/Events Response Fields Open customerspos_sale, event— — PosSaleDto (empty items)Walk-in — customers, account, event, outbox— pos.send_walk_in_invitesale, customerId, invitationQueuedLine upsert productspos_sale_item, totals, event— — PosSaleDtoComplete sale, lines, products, inventory cart, session, payment_attempt, order, reservation, pos_sale, events, outbox inventory (after commit)pos.send_receiptsale, orderNumber, orderPublicId Timeline pos_sale_event— — — events[]
The doc explains what the operator is trying to accomplish (a walk-in sale end to end).
The doc explains what the backend does the actor does not see (one transaction, unique-key replay protection, outbox emails).
Every minor flow and branch is covered (lookup minimum, reset-on-customer-change, skip outcomes, batch caps).
Admin and system flows are included; there is no user flow by design.
Business logic, tradeoffs and rationale are explained (§12.3, §12.4).
Every flow maps to API routes and backend side effects (§4, §9, §12.6).
Diagrams fit each flow type (sequence, activity, state, queue topology).
Edge cases and failure recovery are covered (§5.1, §10, §12.5).
API doc: /docs/developer/pos/api
Backend doc: /docs/developer/pos/backend
TDD: not yet published