POS API Reference
Complete API contracts for the POS module, including routes, auth, DTOs, responses, errors, examples, and integration notes.
POS - API Reference
Audience: Frontend engineers (admin panel), backend engineers, QA, and API consumers.
Scope: The 13 admin-only counter routes under /api/admin/pos/ — sale drafting, completion, cancellation, and customer/product lookup.
1. Documentation Evidence
| Area | Files Inspected | What Was Verified |
|---|---|---|
| Controllers | apps/api/src/modules/pos/admin/sale/pos-sale-admin.controller.ts, admin/lookup/pos-lookup-admin.controller.ts | Routes, methods, guards, permissions, rate limits |
| DTOs | admin/sale/dto/*.ts, admin/lookup/dto/pos-lookup.dto.ts | Request, query, response, validation |
| Services | shared/*.service.ts | Behavior, transactions, error codes |
| Schema | packages/db/src/schema/pos/*.ts | Tables, enums, constraints |
| Jobs/cache | packages/jobs/src/index.ts, cache-invalidation.tags.ts | Outbox jobs, inventory domain |
| Existing docs | consumer-handoff.md, Fumadocs formats | Frozen contract and format baseline |
2. Module Summary
| Field | Value |
|---|---|
| Module name | pos |
| Module slug | pos |
| Primary actors | admin (operator), system (sweep) |
| API surfaces | admin only — no customer-facing route exists |
| Base route prefixes | /api/admin/pos/sales, /api/admin/pos/lookup |
| Auth model | JwtAuthGuard + RoleGuard + Pos_* permissions |
| Persistence | PostgreSQL (pos_sale, pos_sale_item, pos_sale_event + the online pipeline rows) |
| Runtime source of truth | pos_sale row; the order is the Order module's |
| Sibling docs | Backend, Features and flows |
3. Concepts and Terminology
| Term | Meaning | Source File | Used By |
|---|---|---|---|
saleNumber | POS-2026-000042, the till-roll reference printed on the receipt | pos-sale-number.service.ts | Every response |
status | draft → picked_up/ordered or cancelled; no awaiting_payment | enums.ts | Every response |
fulfilment | pickup/delivery — the discriminator behind every branch | enums.ts | Fulfilment + complete |
customerCreated | §48 Customer Type — TRUE when this sale created the account | pos_sale.customer_created | Sale response |
paymentReference | The terminal/QR slip number; recorded, never validated | pos_sale.payment_reference | Complete |
channel | "pos" on the order/session; a label, never a branch | sales_channel enum | Read-only |
invitationQueued | The walk-in invite is queued; the token is never returned | PosWalkInCreatedDto | Walk-in response |
orderNumber | HS-2026-000042, printed on the receipt | Order module | Completion response |
4. API Surface Map
| Surface | Method | Path | Actor | Auth/Guard | Permission | Controller | Purpose |
|---|---|---|---|---|---|---|---|
| Admin | GET | /api/admin/pos/lookup/customers | Operator | JWT + Role | Pos_READ | PosLookupAdminController | Find a customer by name/email/phone |
| Admin | GET | /api/admin/pos/lookup/products | Operator | JWT + Role | Pos_READ | PosLookupAdminController | Find a sellable product by name/SKU |
| Admin | POST | /api/admin/pos/sales | Operator | JWT + Role | Pos_CREATE | PosSaleAdminController | Open a draft (always pickup) |
| Admin | GET | /api/admin/pos/sales | Operator | JWT + Role | Pos_READ | PosSaleAdminController | List and filter sales |
| Admin | GET | /api/admin/pos/sales/{publicId} | Operator | JWT + Role | Pos_READ | PosSaleAdminController | One sale with its lines |
| Admin | GET | /api/admin/pos/sales/{publicId}/timeline | Operator | JWT + Role | Pos_READ | PosSaleAdminController | The audit trail |
| Admin | PATCH | /api/admin/pos/sales/{publicId}/customer | Operator | JWT + Role | Pos_UPDATE | PosSaleAdminController | Attach an existing customer |
| Admin | POST | /api/admin/pos/sales/{publicId}/customer | Operator | JWT + Role | Pos_CREATE | PosSaleAdminController | Create a walk-in customer |
| Admin | PUT | /api/admin/pos/sales/{publicId}/items/{productPublicId} | Operator | JWT + Role | Pos_UPDATE | PosSaleAdminController | Set a line's FINAL quantity |
| Admin | DELETE | /api/admin/pos/sales/{publicId}/items/{productPublicId} | Operator | JWT + Role | Pos_UPDATE | PosSaleAdminController | Remove a line |
| Admin | PATCH | /api/admin/pos/sales/{publicId}/fulfilment | Operator | JWT + Role | Pos_UPDATE | PosSaleAdminController | Pickup or delivery (+ address) |
| Admin | POST | /api/admin/pos/sales/{publicId}/complete | Operator | JWT + Role | Pos_CREATE | PosSaleAdminController | Take payment, create the order |
| Admin | POST | /api/admin/pos/sales/{publicId}/cancel | Operator | JWT + Role | Pos_DELETE | PosSaleAdminController | Abandon a draft |
5. Auth, Identity, and Permissions
| Surface | Guard/Decorator | Identity Shape | Permission | Guest Allowed | Notes |
|---|---|---|---|---|---|
| Admin | JwtAuthGuard, RoleGuard | req.user.id | Pos_CREATE/Pos_READ/Pos_UPDATE/Pos_DELETE | No | The permission module is spelled Pos, never POS; Pos_READ exposes customer lookup, so it is grantable separately from Users_READ |
ParseUUIDPipeon every:publicIdand:productPublicId— a malformed id is a clean 400.- Rate limits key on the user, not the IP — every till in a shop shares one public address.
6. DTO and Model Reference
6.1 OpenPosSaleDto — POST /sales
| Field | Type | Required | Validation | Example |
|---|---|---|---|---|
paymentMethod | string | Yes | @IsIn(POS_PAYMENT_METHODS) — pos_cash/pos_card/pos_qr | "pos_cash" |
customerId | uuid | Yes | @IsUUID | 018f1e2a-...-000000000001 |
Deliberately no fulfilment field. Every sale opens as a pickup; delivery is chosen with
PATCH /fulfilment, the only endpoint that can take an address in the same request. Sending
fulfilment here is rejected by forbidNonWhitelisted.
6.2 AttachPosCustomerDto — PATCH /sales/{publicId}/customer
| Field | Type | Required | Validation |
|---|---|---|---|
customerId | uuid | Yes | @IsUUID |
6.3 CreatePosWalkInDto — POST /sales/{publicId}/customer
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
name | string | Yes | ≤255, trimmed | — |
email | string | Yes | @IsEmail, ≤320, trimmed + lowercased | The account identifier and invite destination |
phone | string | No | ≤20 | — |
6.4 UpsertPosLineDto — PUT /sales/{publicId}/items/{productPublicId}
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
quantity | number | Yes | @IsInt, 1–99 | The FINAL quantity, not a delta. Scanning the same product twice sends 2, not two calls of 1 |
6.5 SetPosFulfilmentDto — PATCH /sales/{publicId}/fulfilment
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
fulfilment | string | Yes | pickup/delivery | — |
addressPublicId | uuid | No | @IsUUID | Mutually exclusive with address |
address | object | No | PosDeliveryAddressDto | Mutually exclusive with addressPublicId |
shippingAmount | number | No | @IsInt, ≥ 0 | Minor units; forced 0 for pickup |
PosDeliveryAddressDto: districtId (int ≥1), municipalityName (≤120), ward (int 1–40),
recipientName (≤120), recipientPhone (E.164 ^\+[1-9][0-9]{7,14}$), street (≤200),
landmark (optional ≤200), postalCode (optional, five digits).
6.6 CompletePosSaleDto — POST /sales/{publicId}/complete
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
paymentReference | string | No | ≤128, trimmed | Permitted for pos_card/pos_qr only — refused beside pos_cash. Recorded, never validated |
6.7 CancelPosSaleDto — POST /sales/{publicId}/cancel
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
reason | string | Yes | @IsNotEmpty, ≤500, trimmed | Mandatory — an audit entry without a why answers nothing |
6.8 ListPosSalesQueryDto — GET /sales
| Field | Type | Default | Validation | Notes |
|---|---|---|---|---|
page | number | 1 | ≥1 | — |
size | number | 20 | 1–100 | — |
status | enum | — | draft/picked_up/ordered/cancelled | — |
fulfilment | enum | — | pickup/delivery | — |
paymentMethod | enum | — | any payment_method | §54 tender split |
customerId | uuid | — | — | — |
createdByAdminId | uuid | — | — | §51/§54 sales-by-administrator |
from | ISO date | — | @IsDateString | Inclusive lower bound on created_at |
to | ISO date | — | @IsDateString | Inclusive upper bound |
6.9 PosLookupQueryDto — both lookup routes
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
search | string | Yes | ≤100, trimmed; ≥2 chars (else 400 VALIDATION_FAILED) | Name/email/phone (customers), name/SKU (products) |
page | number | No | ≥1 | — |
size | number | No | 1–100 | — |
6.10 Response DTOs
| DTO | Shape | Notes |
|---|---|---|
PosSaleDto | publicId, saleNumber, status, fulfilment, currency, customer { customerId, name, email, createdByThisSale }, payment { method, reference }, totals { subtotal, discountAmount, shippingAmount, grandTotal, totalQuantity, lineCount }, items[], orderNumber?, orderPublicId?, createdByAdminName?, createdAt, completedAt?, cancelledAt? | Grouped by §47 business objects, not table rows; no integer PKs anywhere |
PosSaleListItemDto | publicId, saleNumber, status, fulfilment, paymentMethod, grandTotal, totalQuantity, customerName?, createdByAdminName?, orderNumber?, createdAt | List row |
PosSaleEventDto | publicId, eventType, fromStatus?, toStatus?, actorName? (null after offboarding), reason?, createdAt | Timeline row |
PosSaleCompletionDto | sale, orderNumber (print on the receipt), orderPublicId | Completion |
PosWalkInCreatedDto | sale, customerId, invitationQueued: true | Never a token — a credential does not belong in a response body |
PosCustomerMatchDto | customerId, name, email?, phone? | Lookup row — no addresses, no history |
PosProductMatchDto | productPublicId, name, sku?, mrp, sellingPrice | Lookup row |
7. Enum Reference
| Enum | Value | Meaning | Runtime Effect | Source |
|---|---|---|---|---|
pos_sale_status | draft | Being rung up; only editable state | Lines may change; cancel is free | enums.ts |
pos_sale_status | picked_up | Pickup sale, goods handed over | Terminal; order already delivered | |
pos_sale_status | ordered | Delivery sale, order at confirmed | Terminal for POS; Order owns the rest | |
pos_sale_status | cancelled | Abandoned before completion | Only reachable from draft | |
pos_fulfilment_type | pickup | Customer takes the goods | No address, no shipping | |
pos_fulfilment_type | delivery | Delivered to an address | Address + shipping required | |
pos_sale_event_type | created / customer_assigned / customer_created / item_added / item_quantity_changed / item_removed / fulfilment_set / payment_recorded / completed / cancelled | What happened | Timeline rows | |
sales_channel | online / pos | Where the order came from | Label only — never a branch | checkout/enums.ts |
8. Endpoint Reference
8.1 GET /api/admin/pos/lookup/customers?search=
Purpose
Find a returning customer at the till by name, email or phone. Bounded and paginated: two characters minimum, and it returns identity fields only — never addresses or purchase history, which belong on the customer's own detail endpoint.
Auth and Permissions
- Auth:
JwtAuthGuard+RoleGuard - Permission:
Pos_READ - Rate limit:
PUBLIC_SEARCH
Request
| Part | Required | Details |
|---|---|---|
| Query | Yes | search (≥2 chars), page?, size? (≤100) |
Response
200 — { items: [{ customerId, name, email?, phone? }] }. Only status = 'active', not
soft-deleted, customers match.
Side Effects
None — a read over customers.
Error Cases
| HTTP | Code | Condition |
|---|---|---|
| 400 | VALIDATION_FAILED | search shorter than 2 characters |
8.2 GET /api/admin/pos/lookup/products?search=
Purpose
Find a sellable product by name or SKU. Only published, non-deleted products with
selling_price <= mrp match — a counter must not become the way unpublished stock leaves the
building.
Auth and Permissions
- Auth:
JwtAuthGuard+RoleGuard· Permission:Pos_READ· Rate limit:PUBLIC_SEARCH
Request
| Part | Required | Details |
|---|---|---|
| Query | Yes | search (≥2 chars), page?, size? |
Response
200 — { items: [{ productPublicId, name, sku?, mrp, sellingPrice }] }. All money in minor
units.
Side Effects
None.
Error Cases
| HTTP | Code | Condition |
|---|---|---|
| 400 | VALIDATION_FAILED | search shorter than 2 characters |
8.3 POST /api/admin/pos/sales
Purpose
Open a new draft. customerId is required — a sale cannot exist without a customer, so an
unknown walk-in is created via POST /sales/{id}/customer first. Always opens as a pickup;
delivery is chosen later with PATCH /fulfilment.
Auth and Permissions
- Auth:
JwtAuthGuard+RoleGuard· Permission:Pos_CREATE· Rate limit:ADMIN_POS_WRITE
Request
{ "paymentMethod": "pos_cash", "customerId": "018f1e2a-0000-7000-8000-000000000001" }Response
201 — the PosSaleDto with empty items, status: "draft", fulfilment: "pickup".
Side Effects
pos_sale row + created audit event, same transaction.
Error Cases
| HTTP | Code | Condition |
|---|---|---|
| 400 | validation | Bad method/customer |
| 404 | POS_SALE_NO_CUSTOMER | The customer does not exist or is deleted |
Edge Cases
paymentMethodis changeable until completion — the field is on the sale, not fixed at open.- Sending
fulfilmentis rejected outright (forbidNonWhitelisted).
8.4 GET /api/admin/pos/sales
Purpose
The reconciliation list (§51). Filters: status, fulfilment, payment method, customer,
administrator, and a from/to date range on created_at.
from and to are read in Nepal time, and a date-only value means the whole local day.
@IsDateString() accepts a bare 2026-08-31, and new Date("2026-08-31") is midnight UTC —
05:45 in Kathmandu. As an inclusive upper bound that dropped everything sold after 05:45 on the
last day asked for: a month-end reconciliation missed roughly three quarters of the 31st and read
as a quiet day rather than a broken query. Nothing errored, and the total was plausible.
parseReportBoundary now expands from to the first instant of that day in Asia/Kathmandu and
to to its last millisecond, so the two are contiguous — one day's end and the next day's start
are 1ms apart, with no gap and no overlap.
A full timestamp is passed through untouched. A caller who sent an instant meant it, and widening that to a whole day is the mirror-image defect.
Auth and Permissions
- Auth:
JwtAuthGuard+RoleGuard· Permission:Pos_READ· Rate limit:ADMIN_READ
Request
| Part | Required | Details |
|---|---|---|
| Query | No | page, size (≤100), status, fulfilment, paymentMethod, customerId, createdByAdminId, from, to |
Response
200 — { items: [PosSaleListItemDto], metadata: { count, page, size } }, newest first.
Side Effects
None. Page and total come from one transaction so they cannot disagree.
8.5 GET /api/admin/pos/sales/{publicId}
Purpose
One sale with its lines, for the detail view and the receipt.
Auth and Permissions
- Auth:
JwtAuthGuard+RoleGuard· Permission:Pos_READ· Rate limit:ADMIN_READ
Response
200 — the full PosSaleDto. orderNumber/orderPublicId are NULL until completion.
Error Cases
| HTTP | Code | Condition |
|---|---|---|
| 404 | POS_SALE_NOT_FOUND | The sale id does not resolve |
8.6 GET /api/admin/pos/sales/{publicId}/timeline
Purpose
The §42 audit trail, oldest first — who did what to this sale, in the order it happened.
Auth and Permissions
- Auth:
JwtAuthGuard+RoleGuard· Permission:Pos_READ· Rate limit:ADMIN_READ
Response
200 — { events: [{ publicId, eventType, fromStatus?, toStatus?, actorName?, reason?, createdAt }] }.
actorName is NULL for sweep cancellations and for offboarded administrators.
Error Cases
| HTTP | Code | Condition |
|---|---|---|
| 404 | POS_SALE_NOT_FOUND | The sale id does not resolve |
8.7 PATCH /api/admin/pos/sales/{publicId}/customer
Purpose
Point the draft at a customer who already has an account (e.g. after correcting a mistaken walk-in creation).
Auth and Permissions
- Auth:
JwtAuthGuard+RoleGuard· Permission:Pos_UPDATE· Rate limit:ADMIN_POS_WRITE
Request
{ "customerId": "018f1e2a-0000-7000-8000-000000000001" }Response
200 — the refreshed PosSaleDto.
Side Effects
Changing the customer resets the sale to a pickup: the saved address belonged to one
person, so fulfilment, deliveryAddressId and shippingAmount are cleared together
(CLEAR_DELIVERY_ON_CUSTOMER_CHANGE). The operator must re-choose delivery for the new
customer — re-render the fulfilment step from the response, never assume it survived.
customerCreated is reset to false.
This applies to POST /customer (create a walk-in) exactly as it does to PATCH /customer.
Until 2026-08-17 it did not: creating a walk-in customer cleared only the address, leaving
fulfilment = 'delivery' beside a NULL delivery_address_id, which
chk_pos_sale_fulfilment_address refuses. The ordinary sequence — choose delivery, type the
address, then discover the customer has no account — produced a 23514 at the till, and because the
account, its account row and the outbox invite all rolled back with it, retrying hit the same
error indefinitely. The reset had been applied to one of the two call sites.
Error Cases
| HTTP | Code | Condition |
|---|---|---|
| 404 | POS_SALE_NOT_FOUND / POS_SALE_NO_CUSTOMER | Missing sale / missing customer |
| 409 | POS_SALE_NOT_DRAFT | Cancelled |
| 409 | POS_SALE_ALREADY_COMPLETED | Money already taken |
8.8 POST /api/admin/pos/sales/{publicId}/customer
Purpose
Create a walk-in account and attach it to the sale — account, invitation and sale update are ONE transaction.
Auth and Permissions
- Auth:
JwtAuthGuard+RoleGuard· Permission:Pos_CREATE· Rate limit:ADMIN_POS_WRITE
Request
{ "name": "Sita Sharma", "email": "sita.sharma@example.com", "phone": "+9779812345678" }Response
200 — { sale, customerId, invitationQueued: true }.
Side Effects
customers+accountrows (email verified FALSE — the operator typed it, nobody proved it; setting a password through the link is what proves it).- Outbox row
pos.walk_in_created(invite queued at creation, not completion — the account exists either way). - Audit event
customer_created(with the email; never the token).
The account is created with NO password. The customer receives an emailed set-password link that never expires but is single-use. There is nothing for a client to display, copy or resend. Never suggest a password is emailed.
Error Cases
| HTTP | Code | Condition |
|---|---|---|
| 409 | POS_CUSTOMER_EMAIL_TAKEN | The email already has an account (live or closed) — attach it instead |
| 404 | POS_SALE_NOT_FOUND | Missing sale |
| 409 | POS_SALE_NOT_DRAFT / POS_SALE_ALREADY_COMPLETED | Not a draft |
8.9 PUT /api/admin/pos/sales/{publicId}/items/{productPublicId}
Purpose
Set the quantity of one product on the draft. The body is the FINAL quantity, not a delta —
scanning the same barcode twice sends quantity: 2, not two calls of 1. Idempotent: sending
the same quantity twice changes nothing.
Auth and Permissions
- Auth:
JwtAuthGuard+RoleGuard· Permission:Pos_UPDATE· Rate limit:ADMIN_POS_WRITE
Request
{ "quantity": 2 }Response
200 — the refreshed PosSaleDto with recomputed totals. The line freezes the product's
current sellingPrice (and mrp) — a counter line is a price snapshot, unlike a cart line.
Side Effects
pos_sale_item upsert (ON CONFLICT (pos_sale_id, product_id) DO UPDATE), totals recomputed
from the persisted lines, audit event item_added/item_quantity_changed.
Error Cases
| HTTP | Code | Condition |
|---|---|---|
| 400 | POS_QUANTITY_OUT_OF_RANGE | Not a whole number in 1–99 |
| 404 | POS_PRODUCT_NOT_SELLABLE | Product missing, unpublished or deleted |
| 409 | POS_PRODUCT_NOT_SELLABLE | Product priced above its MRP |
| 404 | POS_SALE_NOT_FOUND | Missing sale |
| 409 | POS_SALE_NOT_DRAFT / POS_SALE_ALREADY_COMPLETED | Not a draft |
8.10 DELETE /api/admin/pos/sales/{publicId}/items/{productPublicId}
Purpose
Take an item back off the counter before payment. The line is hard-deleted — the record of what
the customer actually bought is checkout_session_item and then order_item.
Auth and Permissions
- Auth:
JwtAuthGuard+RoleGuard· Permission:Pos_UPDATE· Rate limit:ADMIN_POS_WRITE
Response
200 — the refreshed PosSaleDto with recomputed totals.
Error Cases
| HTTP | Code | Condition |
|---|---|---|
| 404 | POS_LINE_NOT_FOUND | The product is not on this sale |
| 404 | POS_SALE_NOT_FOUND | Missing sale |
| 409 | POS_SALE_NOT_DRAFT / POS_SALE_ALREADY_COMPLETED | Not a draft |
8.11 PATCH /api/admin/pos/sales/{publicId}/fulfilment
Purpose
Choose how the goods leave the shop. The only endpoint that can take an address in the same
request — either a saved one (addressPublicId) or a new one (address), which is saved to the
customer's own address book.
Auth and Permissions
- Auth:
JwtAuthGuard+RoleGuard· Permission:Pos_UPDATE· Rate limit:ADMIN_POS_WRITE
Request
Pickup:
{ "fulfilment": "pickup" }Delivery with a new address:
{
"fulfilment": "delivery",
"shippingAmount": 15000,
"address": {
"districtId": 27, "municipalityName": "Lalitpur Metropolitan City", "ward": 5,
"recipientName": "Sita Sharma", "recipientPhone": "+9779812345678",
"street": "Jhamsikhel, Ward 5", "landmark": "Opposite the community school",
"postalCode": "44700"
}
}Delivery with a saved address:
{ "fulfilment": "delivery", "addressPublicId": "018f1e2a-0000-7000-8000-000000000009" }Response
200 — the refreshed PosSaleDto.
Side Effects
- Pickup: clears the address and forces
shippingAmountto 0. - Delivery: resolves the saved address (scoped to THIS customer in the query) or inserts a new
customer_addressesrow withis_default: false— the POS transaction deliberately does not take the default-address advisory lock. grandTotalis recomputed in the same statement that writesshippingAmount, from the row's ownsubtotalanddiscountAmount.
That last point is not an optimisation. chk_pos_sale_grand_total_matches asserts
grand_total = subtotal − discount_amount + shipping_amount, and the constraint is not
deferrable — PostgreSQL evaluates it at the end of the statement, not at commit.
Writing shipping_amount alone and repairing the total on the next line therefore cannot work: the
first statement is already rejected and the whole transaction aborts, taking the newly-inserted
address row with it. That was the behaviour until 2026-08-17, which meant no non-zero delivery
charge could ever be saved — every attempt was a bare 23514 at the till.
It survived type-check, lint, build, five rule gates and the full integration suite because every
delivery fixture used shippingAmount: 0, where the identity holds trivially. When adding a
fixture for a money path, give it a non-zero value.
Error Cases
| HTTP | Code | Condition |
|---|---|---|
| 409 | POS_DELIVERY_ADDRESS_REQUIRED | Delivery with no address — or both addressPublicId AND address (mutually exclusive) |
| 404 | POS_ADDRESS_NOT_FOUND | The address is not this customer's |
| 404 | POS_SALE_NOT_FOUND | Missing sale |
| 409 | POS_SALE_NOT_DRAFT / POS_SALE_ALREADY_COMPLETED | Not a draft |
8.12 POST /api/admin/pos/sales/{publicId}/complete
Purpose
Take the money and produce the order — ONE transaction: stock moves, the payment is recorded, the order is created, and for a pickup the goods are handed over. There is no state in which a customer has paid and no order exists. Returns the order number for the receipt.
Auth and Permissions
- Auth:
JwtAuthGuard+RoleGuard· Permission:Pos_CREATE· Rate limit:ADMIN_POS_COMPLETE(30/min — each call takes money)
Request
{ "paymentReference": "TXN-88213" }paymentReference is optional and permitted only for pos_card and pos_qr. Sending one with
pos_cash is rejected. It is recorded, never validated — the platform does not check it
against any provider, and a client must not present it as verified.
Response
200 — { sale (status picked_uporordered), orderNumber, orderPublicId }.
Side Effects
Cart (born converted), checkout session (born completed, channel: "pos"), frozen
checkout_session_items, settled payment_attempt (born succeeded, gateway_transaction_id = POS-{saleNumber}), reservation finalize, order (createFromCheckout with executor: tx),
pickup hand-over to delivered, outbox pos.sale_completed, events payment_recorded +
completed, and post-commit invalidation of the inventory cache domain.
Error Cases
| HTTP | Code | Condition |
|---|---|---|
| 409 | POS_SALE_EMPTY | No lines |
| 409 | POS_SALE_NO_CUSTOMER | No customer attached |
| 409 | POS_DELIVERY_ADDRESS_REQUIRED | Delivery with no address |
| 409 | POS_PRODUCT_NOT_SELLABLE | A product is no longer published |
| 409 | POS_PRODUCT_PRICE_CHANGED | A price moved mid-sale — tell the operator to speak to the customer; remove and re-add to charge the new price |
| 409 | POS_INSUFFICIENT_STOCK | Someone else took the last one — show remaining stock, let the operator reduce the quantity |
| 409 | POS_PAYMENT_REFERENCE_NOT_ALLOWED | Reference sent with cash |
| 409 | POS_SALE_ALREADY_COMPLETED | The money was already taken — do not retry, show the existing order number |
| 409 | POS_ADDRESS_NOT_FOUND | The configured store district does not exist (pickup shipping resolve) |
POS_PRODUCT_PRICE_CHANGED and POS_INSUFFICIENT_STOCK mean nothing was charged and nothing
was reserved — the whole completion rolled back, so the sale is still a live draft.
Edge Cases
- Replayed completion: the four partial unique indexes (
uq_pos_sale_{cart,checkout_session,payment_attempt,order}_id) plus the guardedWHERE status = 'draft'finalise make a retry a 409, never a second charge. - A delivery sale's order stays at
confirmedfor the normal lifecycle; a pickup's order is driven straight todelivered(return window starts, purchase reviewable).
8.13 POST /api/admin/pos/sales/{publicId}/cancel
Purpose
Abandon a draft before any money has moved. Only reachable from draft — once completed, the
correction is an order cancellation and refund, owned by the Order module.
Auth and Permissions
- Auth:
JwtAuthGuard+RoleGuard· Permission:Pos_DELETE· Rate limit:ADMIN_POS_WRITE
Request
{ "reason": "Customer changed their mind" }Response
200 — the PosSaleDto with status: "cancelled".
Error Cases
| HTTP | Code | Condition |
|---|---|---|
| 400 | validation | Blank or missing reason |
| 404 | POS_SALE_NOT_FOUND | Missing sale |
| 409 | POS_SALE_NOT_DRAFT / POS_SALE_ALREADY_COMPLETED | Not a draft |
9. Flow Diagrams
9.1 Route Ownership
9.2 Request Sequence (complete)
9.3 Error Branch (completion)
10. Pagination, Sorting, Filtering, and Search
| Endpoint | Pagination Type | Default Size | Max Size | Sort Fields | Filters | Result Cap |
|---|---|---|---|---|---|---|
GET /api/admin/pos/sales | offset page/size | 20 | 100 | createdAt DESC, id DESC (fixed) | status, fulfilment, paymentMethod, customerId, createdByAdminId, from, to | 10k/100k rows (offset cap, ~3 years at 100 sales/day) |
GET /api/admin/pos/lookup/customers | offset page/size | 20 | 100 | name ASC, id ASC (fixed) | search (≥2 chars, ILIKE) | — |
GET /api/admin/pos/lookup/products | offset page/size | 20 | 100 | name ASC, id ASC (fixed) | search (≥2 chars, ILIKE on name/SKU) | — |
11. Caching, Jobs, and External Integrations
| Integration | Used? | Details | Source |
|---|---|---|---|
| Redis cache | Yes (invalidation only) | inventory domain cleared after a completed sale (triggerForWrite, post-commit, fire-and-forget) | pos.constants.ts |
| BullMQ | Yes | QueueName.POS — pos.send_receipt, pos.send_walk_in_invite (outbox, same tx), pos.sweep_abandoned_drafts (hourly cron, direct). One @Processor (concurrency 3) dispatches via an exhaustive record; HttpException = deterministic failure, never retried | pos-queue.processor.ts |
| External API | No | Payment references are recorded, never validated | — |
13. Mandatory Deep API Documentation Pack
13.1 Route-by-Route Completeness Matrix
| Route | Controller Method | DTOs | Service Method | Guards | Permissions | Cache | Jobs | DB Touches | Errors | Documented? |
|---|---|---|---|---|---|---|---|---|---|---|
GET /api/admin/pos/lookup/customers | searchCustomers | PosLookupQueryDto | PosLookupService.searchCustomers | JWT+Role+Throttle | Pos_READ | — | — | customers | 400 | Yes |
GET /api/admin/pos/lookup/products | searchProducts | PosLookupQueryDto | PosLookupService.searchProducts | JWT+Role+Throttle | Pos_READ | — | — | products | 400 | Yes |
POST /api/admin/pos/sales | open | OpenPosSaleDto | PosSaleDraftService.open | JWT+Role+Throttle | Pos_CREATE | — | — | pos_sale, event | 400/404 | Yes |
GET /api/admin/pos/sales | findAll | ListPosSalesQueryDto | PosSaleQueryService.findAll | JWT+Role+Throttle | Pos_READ | — | — | pos_sale + joins | — | Yes |
GET /api/admin/pos/sales/{publicId} | findOne | — | PosSaleQueryService.findOne | JWT+Role+Throttle | Pos_READ | — | — | pos_sale, items | 404 | Yes |
GET /api/admin/pos/sales/{publicId}/timeline | findTimeline | — | PosSaleQueryService.findTimeline | JWT+Role+Throttle | Pos_READ | — | — | events | 404 | Yes |
PATCH /api/admin/pos/sales/{publicId}/customer | attachCustomer | AttachPosCustomerDto | PosSaleDraftService.attachCustomer | JWT+Role+Throttle | Pos_UPDATE | — | — | pos_sale, event | 404/409 | Yes |
POST /api/admin/pos/sales/{publicId}/customer | createWalkIn | CreatePosWalkInDto | PosSaleDraftService.createWalkInCustomer | JWT+Role+Throttle | Pos_CREATE | — | outbox invite | customers, account, pos_sale, outbox, event | 404/409 | Yes |
PUT /api/admin/pos/sales/{publicId}/items/{productPublicId} | upsertLine | UpsertPosLineDto | PosSaleLineService.upsertLine | JWT+Role+Throttle | Pos_UPDATE | — | — | pos_sale_item, pos_sale, event | 400/404/409 | Yes |
DELETE /api/admin/pos/sales/{publicId}/items/{productPublicId} | removeLine | — | PosSaleLineService.removeLine | JWT+Role+Throttle | Pos_UPDATE | — | — | pos_sale_item, pos_sale, event | 404/409 | Yes |
PATCH /api/admin/pos/sales/{publicId}/fulfilment | setFulfilment | SetPosFulfilmentDto | PosSaleDraftService.setFulfilment | JWT+Role+Throttle | Pos_UPDATE | — | — | pos_sale, address, event | 404/409 | Yes |
POST /api/admin/pos/sales/{publicId}/complete | complete | CompletePosSaleDto | PosSaleCompletionService.complete | JWT+Role+Throttle | Pos_CREATE | inventory (after commit) | outbox receipt | 7 tables + events + outbox | 409 set | Yes |
POST /api/admin/pos/sales/{publicId}/cancel | cancel | CancelPosSaleDto | PosSaleDraftService.cancel | JWT+Role+Throttle | Pos_DELETE | — | — | pos_sale, event | 400/404/409 | Yes |
13.2 Request/Response Exhaustiveness
Covered in §8: minimal and full payloads for every body endpoint (§6, §8.3/8.8/8.11/8.12), the
final-quantity semantics of PUT (§8.9), the mutually-exclusive address fields (§8.11), the
pickup-reset on customer change (§8.7), the never-expiring single-use invite (§8.8), the
full PosSaleDto shape (§6.10), every nullable field, and per-endpoint error tables with the
exact codes and the "nothing was charged" meaning of the two completion conflicts (§8.12).
13.3 API Diagram Pack
Route ownership (§9.1), completion sequence (§9.2), completion error tree (§9.3), plus the activity/till flow and queue topology in the feature and backend docs.
13.4 Consumer Integration Notes
| Consumer | Required Knowledge | Failure Handling | Contract Stability |
|---|---|---|---|
| Admin panel (till) | 13 routes, Pos_* permissions, final-quantity PUT, pickup-first drafts, recorded:false-style semantics for references | 409s → re-fetch the sale; POS_SALE_ALREADY_COMPLETED → show the order number, never retry | Stable |
| Admin panel (reports) | GET /sales filters, integer minor units, customerCreated, createdByAdminId | Offset cap at 10k rows | Stable |
| Admin panel (walk-in) | No password ever; invitationQueued: true is the whole answer; never expect a token | 409 POS_CUSTOMER_EMAIL_TAKEN → switch to attach | Stable |
| Storefront frontend | Nothing changes. order.channel is "online" for every customer-placed order; the new payment methods never appear in the customer-selectable list | — | Stable |
| Admin panel (order rendering) | order.payment_method may now be pos_cash/pos_card/pos_qr — add labels or it shows a raw enum | Degrades to an ugly label, never an error | Stable |
| QA | Constraint invariants (status/key pairs, pickup no-shipping), one-transaction completion, replay safety | Reproduce via the exact codes | Stable |
13.5 API Tradeoffs and Rationale
| Decision | Chosen Behavior | Alternatives Considered | Why This Tradeoff | Risk | Mitigation |
|---|---|---|---|---|---|
No fulfilment on open | Drafts always open pickup | Accepting delivery | open() cannot supply an address; the old field was a guaranteed 23514 | One extra tap | Re-render fulfilment from responses |
PUT replaces quantity | Idempotent final quantity | POST increment | A till types a number | Double-scan confusion | Documented; ON CONFLICT |
customerId required at open | No customerless draft | Anonymous sales | Every sale belongs to an account | Extra step for new walk-ins | Walk-in endpoint first |
| Reference never validated | Recorded as entered | Provider check | §17 leaves verification with the operator | False "verified" impression | Documented, refused beside cash |
| Completion is one tx | All-or-nothing | Pipeline with compensation | The paid-without-order state is not cleanable | Long lock scope | Ascending lock order |
| Customer change resets to pickup | Address/fulfilment/shipping cleared | Partial clear | The old address is not this person's | Re-picking delivery | Explicit in docs |
| Walk-in invite never expires | Far-future sentinel, single use | 24h TTL | Owner policy; support dead-end | Long-lived if stolen | Single use, no OTP |
| New address not the default | is_default: false | Taking the default lock | POS tx must not race the address advisory lock | Minor UX | Customer promotes later |
13.6 API Change Impact
| Change | Affected Consumers | Backend Impact | Data Impact | Migration Needed? | Compatibility Plan |
|---|---|---|---|---|---|
order.channel / checkout_session.channel added | Storefront (ignores), reporting | Column + enum | None — defaults online | No | Additive; existing rows unaffected |
payment_method gains 3 values | Admin order renderer | Enum + gateway labels | None | No | Label the new values; storefront list unchanged |
| Any future lookup change | Till | PosLookupService | None | No | Contract frozen in consumer-handoff |
| Future barcode column | Till | Product schema | Migration | Yes (backend change, not made) | Scanner emits SKU today |
14. Zero-Omission API Checklist
- Every controller route is documented (§4, §8, §13.1).
- Every parent route prefix and runtime URL is documented (§2, §4).
- Every DTO field, nested field, enum, default, transform, and validator is documented (§6, §7).
- Every response field, nullable field, generated field, and omitted raw entity field is documented (§6.10, §8).
- Every auth, guard, permission, role, public decorator, and guest identity branch is documented (§5) — there is no public/guest surface by design.
- Every success, validation, auth, permission, not-found, conflict, rate-limit, and server-error branch is documented (§8).
- Every database read/write, cache invalidation, queue job, notification, and audit event is documented (§8 side effects, §11).
- Every route has examples for minimal request, full request, success response, and representative failures (§8).
- Every endpoint family has route, sequence, and error diagrams (§9).
- Every tradeoff and compatibility risk is documented (§13.5, §13.6).
- The API doc links to backend and features/flows (See Also).
15. Integration Checklist
- Every route from controllers is documented.
- Every DTO field is documented.
- Every enum value is documented.
- Every response envelope is documented.
- Every error code is documented.
- Every auth guard and permission is documented.
- Every cache key, queue job, and external call is documented.
- Every diagram matches the current code.
- The API doc links to backend and features/flows.
See Also
- Backend doc:
/docs/developer/pos/backend - Features and flows doc:
/docs/developer/pos/feature - TDD: not yet published
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.
POS Backend Documentation
Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for POS.