POS Module Overview
The admin-only counter till — draft sales, walk-in customers, pickup or delivery, and completion that takes money, moves stock and creates an ordinary order in one transaction.
Audience: Product owners, QA, frontend and backend developers Scope: The counter till, walk-in accounts, completion semantics, and how a counter sale becomes an ordinary order
POS Module - Overview
1. What the module is
POS is the counter till: an administrator rings up a walk-in sale — find or create the
customer, scan products, choose pickup or delivery, take cash, card or QR, and complete. The
result is an ordinary order in the ordinary order lifecycle, distinguishable from an online
one only by the channel it records.
There is no customer-facing POS route, and there will not be one. A walk-in customer never touches this API. Every route is an administrator acting on their behalf — that is the one thing that distinguishes a counter sale from an online one.
2. The routes — 13, all admin
| Surface | Route | Permission |
|---|---|---|
| Lookup | GET /api/admin/pos/lookup/customers · /lookup/products | Pos_READ |
| Draft | POST /api/admin/pos/sales · GET /sales · GET /sales/{publicId} · GET /sales/{publicId}/timeline | Pos_CREATE / Pos_READ |
| Draft | PATCH /sales/{publicId}/customer · POST /sales/{publicId}/customer | Pos_UPDATE / Pos_CREATE |
| Lines | PUT /sales/{publicId}/items/{productPublicId} · DELETE /sales/{publicId}/items/{productPublicId} | Pos_UPDATE |
| Fulfilment | PATCH /sales/{publicId}/fulfilment | Pos_UPDATE |
| Terminal | POST /sales/{publicId}/complete · POST /sales/{publicId}/cancel | Pos_CREATE / Pos_DELETE |
The permission module is spelled Pos — Pos_CREATE, Pos_READ, Pos_UPDATE,
Pos_DELETE. Not POS. A role must be granted these before an operator can use the till.
3. The one idea that shapes everything: POS is not a second kind of purchase
A counter sale drives the same cart → checkout_session → payment_attempt → order pipeline
a customer drives from their phone. It does not copy the commercial facts anywhere: money and
lines live on checkout_session_item and then order_item, stock moves through
inventory_reservation, the payment is a payment_attempt.
pos_sale is the orchestration record — it adds the four things that pipeline genuinely
cannot answer, because online purchases have no such concept:
- Which administrator made the sale (
created_by_admin_id, RESTRICT — an offboarded operator whose sales became unattributed would silently rewrite last quarter's numbers). - How the goods leave the shop (
fulfilment— the discriminator behind every branch). - Whether the sale created the customer account (
customer_created— stored, because it cannot be reconstructed afterwards). - The draft — a state the online pipeline has no equivalent of.
The downstream keys (cart_id, checkout_session_id, payment_attempt_id, order_id) are
nullable while drafting, NOT NULL once completed, each under its own partial unique index —
which is what makes a replayed completion a no-op rather than a second charge.
4. Completion is ONE transaction
Stock moves, the payment is recorded, the order is created and — for a pickup — the goods are handed over, together or not at all. There is no state in which a customer has paid and no order exists, which is the state that cannot be cleaned up afterwards.
The lock order matters: pos_sale → products ASCENDING → cart → checkout_session → payment_attempt → order. Ascending product id is what online checkout already does, so a
counter sale and an online checkout for the same popular item cannot deadlock.
5. The walk-in account — no password, a single-use link that never expires
A walk-in customer is created with NO password — deliberately, and by explicit owner
decision. The customer receives an emailed set-password link that never expires but is
single-use: verification.consumed_at is stamped the moment a password is set, and a
consumed token is refused from then on.
Never suggest a password is emailed. Nothing usable is ever emailed, and no administrator ever holds a credential that would still work tomorrow. The invitation deliberately mints no OTP — a six-digit code that never expires is a brute-forcible credential; the long random token is not.
6. Pickup and delivery
| Fulfilment | Address | Shipping | Terminal sale status | Order |
|---|---|---|---|---|
pickup | none — the store's own address is stamped | 0 | picked_up | driven straight to delivered via a synthetic Counter shipment — which starts the return window and makes the purchase reviewable |
delivery | required — saved to the customer's address book | operator-set | ordered | left at confirmed for the normal lifecycle |
Changing the customer resets the sale to a pickup: the saved address belonged to one person, so the address, the fulfilment and the shipping charge are cleared together.
7. Money
All money is integer minor units. 500000 is NPR 5,000.00. Never parse it as a float and
never do arithmetic on a formatted string. The sale total is recomputed from the persisted
lines on every mutation — never supplied by a client — and chk_pos_sale_grand_total_matches
refuses to store a total that does not follow from its parts.
8. What the module deliberately does not do
- No own inventory, order or payment logic. It orchestrates the existing modules.
- No barcode search.
producthasskubut nobarcodecolumn, so barcode search was not built. A scanner that emits the SKU works against product lookup today. - No refund or return path. A completed sale's correction is an order cancellation or return, owned by the order module.
- Drafts hold no stock. POS reserves only at completion — the abandoned-draft sweep is housekeeping, not a release path.
9. Where to go next
| Page | For |
|---|---|
| Features and flows | The till flow end to end, the state machine, and the edge-case matrix |
| Backend | Schema, services, the one-transaction completion, queues and operations |
| API reference | All 13 endpoints with request and response shapes and error codes |