POS Backend Documentation
Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for POS.
POS - Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/pos/*.module.ts | Aggregate + two admin leaves + worker; imports of Auth, Inventory, Order, Outbox, Payment shared modules |
| Controllers | admin/sale/pos-sale-admin.controller.ts, admin/lookup/pos-lookup-admin.controller.ts | 13 routes, guards, permissions, rate limits, ParseUUIDPipe on every id |
| Services | shared/*.service.ts, pos-totals.util.ts | One-transaction completion, lock order, outbox mails, lookup semantics |
| DTOs | admin/sale/dto/*.ts, admin/lookup/dto/*.ts | Request, query, response validation and shapes |
| Schema | packages/db/src/schema/pos/*.ts | pos_sale, pos_sale_item, pos_sale_event; enums; CHECK constraints |
| Jobs | packages/jobs/src/index.ts | QueueName.POS, PosJob, payloads; the token is not in the payload |
| Cache | cache-invalidation.tags.ts, pos.constants.ts | product_review-style domain: inventory cleared after completion |
| Infra | env.validation.ts | POS_STORE_* config; order-creation.service.ts executor option; order-counter-handover.service.ts |
2. Backend Scope and Boundaries
Owns
- The counter draft: opening a sale, attaching/creating the customer, lines, fulfilment.
- Completion — the one transaction that reserves stock, materialises the checkout pipeline, creates the order and (for pickup) hands over.
- Walk-in account provisioning (no password) and the set-password invitation.
- The
pos_saleaudit timeline. - The hourly abandoned-draft sweep.
- The
channellabel on orders and checkout sessions (defaultsonline). - The
pos_cash/pos_card/pos_qrpayment methods and their counter gateway.
Does Not Own
- Inventory movement (delegated to
InventoryReservationService). - Order creation and lifecycle (delegated to
OrderCreationService; the Order module owns every state afterconfirmed). - Payment gateway logic (a
payment_attemptrow is written directly, but the Payment module owns the table and its admin surface). - Refunds and returns (Order module).
- Barcode search (does not exist —
producthassku, nobarcodecolumn). - Receipt/invitation sending (delegated to the Notifications queue).
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| Sale state | pos_sale row | Status + fulfilment + downstream keys, guarded by chk_pos_sale_status_completion |
| What was sold | pos_sale_item (draft) → checkout_session_item → order_item | Frozen at the till, copied not converted |
| Money taken | payment_attempt | Same shape as online payments; the sale number is in gateway_transaction_id |
| Order | order | The Order module owns it; POS references it |
| Audit | pos_sale_event | Append-only, same transaction as the change |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
PosModule | Aggregate | apps/api/src/modules/pos/pos.module.ts | None | — | PosAdminAggregateModule | Composes the admin leaves with shared services |
PosAdminAggregateModule | Aggregate | .../admin/pos-admin-aggregate.module.ts | None | — | Sale + Lookup leaves | Two leaves, no controllers of its own |
PosSaleAdminModule | Leaf | .../admin/sale/pos-sale-admin.module.ts | PosSaleAdminController | PosSaleAdminService | — | The till routes |
PosLookupAdminModule | Leaf | .../admin/lookup/pos-lookup-admin.module.ts | PosLookupAdminController | — | — | Customer/product lookup |
PosSharedModule | Shared | .../pos-shared.module.ts | None | 11 services | 8 services | Every decision in the module |
PosWorkerModule | Worker | .../pos-worker.module.ts | None | Processor + handlers + scheduler | — | One @Processor on QueueName.POS |
4. File and Directory Map
apps/api/src/modules/pos/
pos.module.ts
pos-shared.module.ts
pos-worker.module.ts
admin/
pos-admin-aggregate.module.ts
sale/
pos-sale-admin.controller.ts
pos-sale-admin.module.ts
pos-sale-admin.service.ts
dto/ # request, response, list-query
lookup/
pos-lookup-admin.controller.ts
pos-lookup-admin.module.ts
dto/pos-lookup.dto.ts
shared/
pos.constants.ts
pos-customer.service.ts
pos-lookup.service.ts
pos-mail-queue.service.ts
pos-sale-access.service.ts
pos-sale-completion.service.ts
pos-sale-draft.service.ts
pos-sale-event.service.ts
pos-sale-line.service.ts
pos-sale-number.service.ts
pos-sale-query.service.ts
pos-totals.util.ts
workers/
pos-draft-sweep.processor.ts
pos-email.processor.ts
pos-maintenance.scheduler.ts
pos-queue.processor.ts
emails/
pos-receipt.email.ts
pos-walk-in-invite.email.ts| File | Purpose | Key Exports | Notes |
|---|---|---|---|
pos.constants.ts | Fixed values | POS_SALE_NUMBER_PREFIX, POS_MAX_QUANTITY_PER_LINE (99), POS_DRAFT_ABANDON_HOURS (24), POS_INVITE_NEVER_EXPIRES_AT, POS_CACHE_DOMAIN (inventory) | The never-expiring invite is policy, documented as not-a-bug |
pos-customer.service.ts | Walk-in provisioning + token minting | provisionWalkIn, issueInvite | No password; createEmailVerification, never createPasswordReset (no OTP) |
pos-lookup.service.ts | Bounded ILIKE searches | searchCustomers, searchProducts | 2-char minimum; escapeLikePattern; active customers only |
pos-mail-queue.service.ts | Outbox enqueues | queueReceipt, queueWalkInInvite | Same transaction as the sale; dedupe on sale/customer id |
pos-sale-access.service.ts | Locks + totals | lockSale, lockDraft, loadLines, recomputeTotals | FOR UPDATE; totals recomputed, never incremental |
pos-sale-completion.service.ts | The one transaction | complete | Lock order sale → products asc → cart → session → payment → order |
pos-sale-draft.service.ts | Draft mutations | open, attachCustomer, createWalkInCustomer, setFulfilment, cancel | Customer change resets to pickup; cancel reason mandatory |
pos-sale-event.service.ts | Append-only audit | record | No update path anywhere |
pos-sale-line.service.ts | Lines on the draft | upsertLine, removeLine | PUT replaces the quantity; ON CONFLICT DO UPDATE |
pos-sale-number.service.ts | POS-2026-000042 | issue | Own sequence, DB clock in Asia/Kathmandu, gaps expected |
pos-sale-query.service.ts | Admin reads | findAll, findOne, findTimeline | Page + total in one transaction; offset pagination (10k-row cap noted) |
pos-totals.util.ts | Pure total math | computePosSaleTotals, computePosLineAmounts | Integer minor units; subtotal = Σ lineTotal, not netAmount |
pos-checkout-materializer.service.ts | Pipeline rows | resolveShipping, materialize | Cart born converted, session born completed, attempt born succeeded |
5. Data Model
5.1 Schema Source
packages/db/src/schema/pos/
index.ts
enums.ts
pos-sale.ts
pos-sale-item.ts
pos-sale-event.tsAlso modified: sales_channel enum in packages/db/src/schema/checkout/enums.ts,
payment_method enum gains pos_cash/pos_card/pos_qr, order.channel and
checkout_session.channel columns (both default online).
5.2 Tables and Collections
pos_sale
| Column/Field | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | serial | No | generated | PK | N/A | Internal |
public_id | uuid | No | uuid7 | unique | N/A | External handle |
sale_number | varchar(32) | No | — | unique | N/A | POS-2026-000042 |
status | enum | No | draft | — | N/A | draft/picked_up/ordered/cancelled |
fulfilment | enum | No | none | — | N/A | pickup/delivery; unset is unrepresentable |
customer_id | uuid | No | — | index + FK RESTRICT | customers | Every sale belongs to an account |
customer_created | boolean | No | false | — | N/A | §48 Customer Type, stored not derived |
created_by_admin_id | uuid | No | — | index + FK RESTRICT | adminUsers | Sales-by-administrator must not drift |
cart_id | integer | Yes (draft) | NULL | partial unique | carts (RESTRICT) | Born converted |
checkout_session_id | integer | Yes (draft) | NULL | partial unique | checkoutSessions (RESTRICT) | Born completed |
payment_attempt_id | integer | Yes (draft) | NULL | partial unique | paymentAttempts (RESTRICT) | Born succeeded |
order_id | integer | Yes (draft) | NULL | partial unique | orders (RESTRICT) | Set at completion |
delivery_address_id | integer | Yes | NULL | index + FK RESTRICT | customerAddresses | NULL for pickup |
payment_method | enum | No | — | — | N/A | pos_cash/pos_card/pos_qr |
payment_reference | varchar(128) | Yes | NULL | — | N/A | Recorded, never validated; refused beside cash |
currency | varchar(3) | No | NPR | — | N/A | Uppercased, length 3 |
subtotal/discount_amount/shipping_amount/grand_total | bigint | No | 0 | — | N/A | Integer minor units |
total_quantity/line_count | integer | No | 0 | — | N/A | Derived |
correlation_id | uuid | No | uuid7 | — | N/A | Ties logs/events/outbox |
version | integer | No | 1 | — | N/A | Bumped on every change |
completed_at/cancelled_at | timestamp tz | Yes | NULL | — | N/A | Terminal stamps |
created_at/updated_at | timestamp tz | No | now | — | N/A | — |
Key constraints:
uq_pos_sale_{cart,checkout_session,payment_attempt,order}_id— partial on NOT NULL, so a replayed completion collides instead of double-charging.chk_pos_sale_status_completion— one exhaustive disjunction: a completed sale must hold every downstream key;picked_uponly withpickup,orderedonly withdelivery;cancelledhas no order.chk_pos_sale_fulfilment_address— pickup ⇒ no address and shipping 0; delivery ⇒ address present.chk_pos_sale_payment_reference_method— reference NULL or method ispos_card/pos_qr(compared::textbecause a new enum value cannot be used in the transaction that adds it).chk_pos_sale_grand_total_matches—grand_total = subtotal − discount + shipping, written as an equality.chk_pos_sale_completed_has_contents— a completed sale has lines; drafts may be empty.chk_pos_sale_currency_normalized,chk_pos_sale_number_not_blank,chk_pos_sale_version_positive,chk_pos_sale_money_non_negative,chk_pos_sale_discount_within_subtotal,chk_pos_sale_counts_non_negative.
pos_sale_item
| Column/Field | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id / public_id | serial / uuid | No | uuid7 | PK / unique | N/A | — |
pos_sale_id | integer | No | — | FK CASCADE + index | posSales | Deleting a sale deletes its lines |
product_id | integer | No | — | FK RESTRICT + index | products | A counter line is money that changed hands |
product_public_id | uuid | No | — | — | N/A | Denormalised |
product_name | varchar(255) | No | — | — | N/A | Snapshot; survives rename/delist |
sku | varchar(64) | Yes | NULL | — | N/A | Snapshot |
unit_price/mrp/line_total/discount_amount/net_amount | bigint | No | — | — | N/A | Price snapshot, unlike the cart |
quantity | integer | No | — | — | N/A | 1–99 (chk_pos_sale_item_quantity_range) |
uq_pos_sale_item_pos_sale_id_product_id— one line per product; the upsert isON CONFLICT DO UPDATE.chk_pos_sale_item_line_total_matches(line_total = unit_price × quantity),chk_pos_sale_item_net_amount_matches(net_amount = line_total − discount),chk_pos_sale_item_discount_within_line,chk_pos_sale_item_price_not_above_mrp,chk_pos_sale_item_money_non_negative,chk_pos_sale_item_product_name_not_blank.
pos_sale_event
| Column/Field | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id / public_id | serial / uuid | No | uuid7 | PK / unique | N/A | — |
pos_sale_id | integer | No | — | FK CASCADE + composite index | posSales | Timeline read |
event_type | enum | No | — | — | N/A | 10 values from created to cancelled |
from_status/to_status | enum | Yes | NULL | — | N/A | Both or neither |
actor_admin_id | uuid | Yes | NULL | FK SET NULL + composite index | adminUsers | Audit survives offboarding; differs from RESTRICT on the sale |
reason | text | Yes | NULL | — | N/A | Mandatory non-blank for cancelled |
metadata | jsonb | Yes | NULL | — | N/A | Product/quantity/old-new values; never the invite token |
correlation_id | uuid | No | — | — | N/A | — |
created_at | timestamp tz | No | now | — | N/A | No updated_at — append-only |
chk_pos_sale_event_transition_pair,chk_pos_sale_event_transition_moves,chk_pos_sale_event_cancel_reason(theIS NOT NULLarm is load-bearing — the original constraint passed on NULL reason, fixed by migration 0015),chk_pos_sale_event_reason_not_blank(~ '[^[:space:]]', notbtrim).
5.3 Relationship Diagram
6. Services and Responsibilities
6.1 PosSaleAccessService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
lockSale | All mutations | pos_sale FOR UPDATE | — | — | 404 POS_SALE_NOT_FOUND |
lockDraft | All draft mutations | pos_sale | — | — | 409 POS_SALE_ALREADY_COMPLETED / POS_SALE_NOT_DRAFT |
loadLines | completion, totals | pos_sale_item | — | — | — |
recomputeTotals | line mutations, fulfilment | lines | pos_sale totals | — | — |
FOR UPDATE rather than optimistic version: the caller is about to make several dependent
writes, and a version mismatch at the end would discard them all.
6.2 PosSaleEventService
One method: record. Written in the SAME transaction as the change (16-observability-rules).
No update, no amend, no soft delete — a correction is a new row.
6.3 PosSaleNumberService
issue → POS-2026-000042 from pos_sale_number_seq (cache: 1), year from
timezone('Asia/Kathmandu', now()) in the DB. Its own sequence so till numbers and order
numbers never interleave. Gaps are expected and correct — a rolled-back sale consumed its
number.
6.4 PosCustomerService
| Method | Reads | Writes | Notes |
|---|---|---|---|
provisionWalkIn | customers (email check) | customers, account | password: null; email_verified: false; same rows as registration, minus the password |
issueInvite | — | verification token | createEmailVerification (no OTP), expiresInMs = far-future sentinel; minted by the worker, never carried in outbox payload |
The email check exists only to name the remedy; customers_email_unique enforces. It matches
the index exactly — no deleted_at filter, because the unique index is not partial.
6.5 PosLookupService
ILIKE %term% on name/email/phone (customers) and name/SKU (products), min 2 characters,
escapeLikePattern + normalizeSearchTerm, PaginationUtil offset. Customers restricted to
status = 'active' and not deleted; products to published, not deleted. Deliberately not
trigram: an operator wants the exact row, not the closest miss.
6.6 PosSaleDraftService
| Method | Transaction | Behavior |
|---|---|---|
open | own | Inserts draft + pickup; customerId required; event created |
attachCustomer | own | Locks, validates customer, applies patch + CLEAR_DELIVERY_ON_CUSTOMER_CHANGE (fulfilment: pickup, address NULL, shipping 0), event customer_assigned |
createWalkInCustomer | own | Locks, provisions account, updates sale (customerCreated: true), enqueues invite, event customer_created |
setFulfilment | own | Pickup: clears address + shipping. Delivery: resolves saved address (scoped to this customer) or inserts a new one (is_default: false), applies patch, event fulfilment_set |
cancel | own | Locks, sets cancelled + cancelled_at, event cancelled with mandatory reason |
6.7 PosSaleLineService
upsertLine: validates quantity (1–99), locks, loads sellable product (published, not deleted, price ≤ MRP), computes amounts,INSERT ... ON CONFLICT DO UPDATE, eventitem_added/item_quantity_changed, recomputes totals.removeLine: locks, hard deletes by(pos_sale_id, product_public_id), eventitem_removed, recomputes totals. 404POS_LINE_NOT_FOUNDif absent.
6.8 PosSaleCompletionService
complete — one transaction:
lockDraft(first lock, the start of the global order).applyPaymentReference— trims, refuses besidepos_cash.assertCompletable— lines, customer, delivery address.assertPricesUnchanged— currentsellingPricevs frozenunitPrice; refuse loudly.reserveAll— ascending product id; insufficient stock translated toPOS_INSUFFICIENT_STOCK(narrowed on the code, never the class); nothing queried after a failed statement (25P02).resolveShipping+materialize— cart bornconverted, session borncompleted(channel: "pos"), frozencheckout_session_items,payment_attemptbornsucceededwithgateway_transaction_id = POS-{saleNumber}and a real random callback token hash.reservations.finalizeper line — holds become deductions.orders.createFromCheckout(..., { executor: tx, suppressOpeningEmail: pickup })— the order inside the sale's transaction.handover.handOverfor pickup — drives the order todelivered.finalise— statuspicked_up/ordered, all keys,version + 1, guardedWHERE status = 'draft'(replay →POS_SALE_ALREADY_COMPLETED).mail.queueReceipt— outbox row in the same transaction.- Events
payment_recorded+completed.
After commit: cache.triggerForWrite({ domain: "inventory", reason: "pos_sale_completed" }) —
never inside the transaction (a network call must not hold the locks).
6.9 PosCheckoutMaterializerService
Writes the rows the Order module needs: cart (born converted to dodge
uq_cart_customer_id_live), session (born completed to dodge
uq_checkout_session_customer_live; channel: "pos"; payment_started_at/completed_at
honest — the operator did take the money), lines frozen 1:1, and a settled payment_attempt
whose shape, terminal status and audit trail match an online one. chk_checkout_session_status_timestamps still applies in full.
6.10 PosSaleQueryService
findAll: offset pagination, filters (status, fulfilment, payment method, customer, administrator,from/to), page + total in ONE transaction,created_at DESC, id DESC. 09-pagination-rules: offset is fine here, cap at 10k/100k rows noted (~3 years at 100 sales/day).findOne: sale + lines + customer/admin names + order refs; 404POS_SALE_NOT_FOUND.findTimeline: events oldest first, actor name left-joined.
6.11 PosMailQueueService
Both mails enqueued through the outbox in the sale's transaction: queueReceipt
(pos.sale_completed, dedupe on the sale id — a sale completes exactly once) and
queueWalkInInvite (pos.walk_in_created, dedupe on the customer id). No token in the
payload.
The outbox dedupes the enqueue; the HANDLER needed keying too. Both handlers are
at-least-once, and their own queue.add into the notifications queue carried no jobId — so a
worker SIGKILLed after adding the email but before BullMQ acknowledged completion re-ran and
queued a second one. Both now pass a deterministic id (pos-receipt-<salePublicId>,
pos-walkin-invite-<customerId>), hyphen-separated because BullMQ rejects a custom id containing
:.
For the invite that was more than a duplicate email. The handler MINTS the set-password token
before enqueuing, and VerificationTokenService.createVerification clears any existing
verification for the same (user, purpose) first — so a replay minted a second token and
invalidated the one already in the customer's inbox.
The invite link is deliberately non-expiring (POS_INVITE_NEVER_EXPIRES_AT) because a walk-in
"may not open their email for weeks", which makes them very likely to open the older message, get
an invalid token, and have no password, no other route in, and no self-service way to request
another — precisely the stranding the non-expiry policy exists to prevent.
An operator deliberately re-inviting is a different action with its own outbox row, and that one should supersede the old token.
6.11a What a receipt line says
The line query reads variant_name and net_amount, not just product_name and line_total.
variant_name is frozen onto pos_sale_item at the sale (migration 0030) so a receipt can say
which configuration left the shop. Without it a sale holding the 256GB and the 512GB of one
phone printed the same product name on both lines at two different prices, and a support query
about "the wrong one" was unanswerable from the receipt. It is read from the frozen column rather
than joined from the live variant, because the variant may since have been renamed or withdrawn
and the receipt must say what was sold.
net_amount equals line_total today — no path sets a per-line discount — so this is not a
visible change. It is correct by construction if one is ever added, where line_total would
print the pre-discount line beside a post-discount total.
The meta string is built by buildLineMeta in common/email/email-components.ts, shared with
the order-confirmation email. Both surfaces had made the same omission independently, which is the
case for one function rather than a third copy — and the shape it produces
("256GB · Qty 1") is the one EmailLineItem's own doc comment had specified all along.
6.12 PosTotalsUtil
Pure functions: computePosSaleTotals (Σ lineTotal, discount, shipping; grandTotal = subtotal − discount + shipping) and computePosLineAmounts (lineTotal = unitPrice × quantity). Integer arithmetic only — no division, no rounding. The client never supplies a
total.
7. Runtime Flows
7.1 Completion — the one transaction
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | lockDraft | Serialises on the sale | 404/409 on missing/terminal |
| 2 | assertCompletable | Empty/uncustomered/addressless refused | 409 POS_SALE_EMPTY / POS_SALE_NO_CUSTOMER / POS_DELIVERY_ADDRESS_REQUIRED |
| 3 | assertPricesUnchanged | Frozen price vs live price | 409 POS_PRODUCT_PRICE_CHANGED — nothing charged |
| 4 | reserveAll | Stock held, ascending ids | 409 POS_INSUFFICIENT_STOCK — nothing charged |
| 5 | createFromCheckout | Order inside the tx | Rollback of the whole sale |
| 6 | handOver | Pickup → delivered via recompute | Rollback |
| 7 | finalise | Terminal status + keys | 409 POS_SALE_ALREADY_COMPLETED on replay |
7.2 The walk-in creation + invitation
The token is minted by the worker at send time — a raw non-expiring credential must never sit
in outbox_events.payload, whose dead rows are never auto-purged.
7.3 The abandoned-draft sweep
Hourly cron (pos-draft-abandon-sweep, Asia/Kathmandu) enqueues pos.sweep_abandoned_drafts
directly — the documented outbox exemption for a cron with no accompanying write. The handler
claims up to 100 drafts (FOR UPDATE SKIP LOCKED, updated_at <= now() − 24h, oldest first)
and cancels each in its OWN transaction with a guarded WHERE status = 'draft' update — an
operator resuming the sale beats the sweep. Event reason: POS_DRAFT_ABANDONED_REASON.
8. Caching
| Cache Key Pattern | Builder | Value | TTL | Invalidation | Caller |
|---|---|---|---|---|---|
inventory:* (existing) | existing inventory keys | — | existing | triggerForWrite({ domain: "inventory" }) after a completed sale | PosSaleCompletionService |
POS has no cache keys of its own. Its one cache interaction is clearing the inventory domain
after completion (stock moved), and it is deliberately product_review-style: the write-side
triggerForWrite runs AFTER commit, fire-and-forget by contract — a committed sale must never
5xx because a cache did not clear.
9. BullMQ, Schedulers, and Async Work
| Queue | Job | Producer | Processor | Payload | Retry/Backoff | Idempotency |
|---|---|---|---|---|---|---|
QueueName.POS | pos.send_receipt | outbox (sale tx) | PosReceiptHandler | { correlationId, posSalePublicId } | BullMQ policy; HttpException = not retried | outbox dedupe on sale id |
QueueName.POS | pos.send_walk_in_invite | outbox (sale tx) | PosWalkInInviteHandler | { correlationId, posSalePublicId, customerId } — no token | same | outbox dedupe on customer id |
QueueName.POS | pos.sweep_abandoned_drafts | cron (direct) | PosDraftSweepHandler | { correlationId, batchSize } | same | jobId per hour |
PosQueueProcessor is the ONE @Processor on the queue (concurrency: 3), dispatching via an
exhaustive Record<PosJob, handler> — a second decorated class would race and silently drop
jobs (it happened on QueueName.INVENTORY). HttpException with an errorCode is a
deterministic failure returned as { success: false, retryable: false, errorCode }; anything
else is rethrown for retry.
Receipt and invite handlers render the email, enqueue NotificationJob.SEND_EMAIL onto
QueueName.NOTIFICATIONS, and may return outcome: "skip" (customer deleted themselves, no
email on file) — expected, no remedy, never thrown.
That downstream enqueue carries its own deterministic jobId — pos-receipt-<salePublicId> and
pos-walkin-invite-<customerId>. The outbox dedupes the POS job; nothing deduped the notification
job it produced, so a handler that died between the add and BullMQ's completion ack re-ran and
queued a second email. Hyphens, not colons: BullMQ builds its keys as bull:<queue>:<jobId> and
rejects a custom id containing : — the failure mode that once stopped the newsletter sweep from
ever running (see pnpm rules:job-ids, which now catches it statically).
9.1 The cron entry point catches its own enqueue
PosMaintenanceScheduler wraps posQueue.add in a try/catch that logs and returns.
This is the one place in the module where swallowing is the correct behaviour and the reason is
specific to @Cron: an exception escaping a scheduled handler propagates into the scheduler
itself, and the job stops firing. The sweep would then not merely miss the tick that failed — it
would never run again, with abandoned drafts holding their stock reservations indefinitely and no
error after the first one.
A caught enqueue failure is invisible to every gate. pnpm check-types, pnpm build, pnpm test
and pnpm rules all observe an exit code or an assertion, and none of them can see a catch that
behaved as designed. The only channel this failure has is the log line, which is why the sweep's
ERROR must be read as a finding and not as noise.
10. Realtime and Events
| Event | Producer | Room/Target | Payload | Consumer | Reliability Notes |
|---|---|---|---|---|---|
pos.sale_completed (outbox) | PosMailQueueService | QueueName.POS | { correlationId, posSalePublicId } | PosReceiptHandler → NOTIFICATIONS | Row in the sale's transaction |
pos.walk_in_created (outbox) | PosMailQueueService | QueueName.POS | { correlationId, posSalePublicId, customerId } | PosWalkInInviteHandler → NOTIFICATIONS | Deduped on customer |
pos_sale_event rows | every mutation service | pos_sale_event table | type + statuses + actor + metadata | timeline endpoint, audits | Same transaction as the change |
No WebSocket/realtime surface exists in this module.
11. Security, Auth, and Abuse Controls
- Guards:
JwtAuthGuard+RoleGuardon both controllers;IpThrottlerGuardper route. - Permissions:
Pos_CREATE/Pos_READ/Pos_UPDATE/Pos_DELETE, from thePospermission module — spell itPos, neverPOS.Pos_READalso exposes customer lookup, so it is grantable separately fromUsers_READ. - Identity:
@CurrentUser() user.idis the operator; every mutation records them on the event. The sweep recordsactor_admin_id: null. - Rate limits:
ADMIN_POS_WRITE60/min per user (every draft mutation — 10/minADMIN_WRITEwould throttle a 15-item basket mid-sale),ADMIN_POS_COMPLETE30/min per user, both keyed on the user because every till in a shop shares one IP. Lookup and reads usePUBLIC_SEARCH/ADMIN_READ. - PII: customer lookup is bounded — 2-char minimum, paginated, never an unbounded list; returns no addresses or history; never the invite token.
- Ownership-scoped reads: a saved address is checked
WHERE customer_id = sale.customer_idin the query — another customer's address returns 404, never 403. - IDOR/leak hygiene:
ParseUUIDPipeon every id (its absence caused 500s in Reviews); responses expose onlypublic_ids, never integer PKs; a malformed id is a clean 400. - Fail-closed: the transaction aborts on any guard;
finaliseis guarded onstatus = 'draft'; a completed sale cannot be re-priced or re-charged. - Audit: every mutation writes
pos_sale_eventin the same transaction; the event table is append-only with noupdated_at.
13. Error Handling
| Error Code | HTTP Status | Thrown By | Condition | Client Action |
|---|---|---|---|---|
POS_SALE_NOT_FOUND | 404 | access/query/email handlers | Sale id does not resolve | Return to the sales list |
POS_SALE_NOT_DRAFT | 409 | lockDraft, applyToDraft | Cancelled (or otherwise not draft) | Start a new sale |
POS_SALE_ALREADY_COMPLETED | 409 | lockDraft, finalise | Money already taken | Do not retry — show the order number |
POS_SALE_EMPTY | 409 | assertCompletable | No lines | Block complete until a line exists |
POS_SALE_NO_CUSTOMER | 409 / 404 | assertCompletable / assertCustomerExists | No customer attached / customer missing | Send the operator to lookup |
POS_DELIVERY_ADDRESS_REQUIRED | 409 | draft + completion | Delivery without an address, or both address forms | Open the address form |
POS_ADDRESS_NOT_FOUND | 404 | draft / completion | Address not this customer's / store district missing | Re-fetch addresses / fix POS_STORE_DISTRICT_ID |
POS_PRODUCT_NOT_SELLABLE | 404 / 409 | line + completion | Unpublished, deleted or mispriced | Remove the line, name the product |
POS_PRODUCT_PRICE_CHANGED | 409 | assertPricesUnchanged | Price moved mid-sale | Tell the operator; remove and re-add |
POS_INSUFFICIENT_STOCK | 409 | reserveAll | Someone else took the last one | Show remaining; reduce quantity |
POS_PAYMENT_REFERENCE_NOT_ALLOWED | 409 | applyPaymentReference | Reference sent with cash | Clear the field or change method |
POS_CUSTOMER_EMAIL_TAKEN | 409 | assertEmailFree | Email already has an account (incl. closed) | Attach the existing customer / use another address |
POS_LINE_NOT_FOUND | 404 | removeLine | Removing a line that is not there | Re-fetch the sale |
POS_QUANTITY_OUT_OF_RANGE | 400 | assertQuantity | Not a whole number in 1–99 | Clamp in the UI |
POS_PRODUCT_PRICE_CHANGED and POS_INSUFFICIENT_STOCK mean nothing was charged and nothing
was reserved — the whole completion rolled back, the sale is still a live draft.
14. Observability
| Signal | Location | Purpose |
|---|---|---|
| Log | Logger in completion, sweep, queue processor, customer service | [pos] ... state-change and failure visibility |
| Audit | pos_sale_event table | §42 timeline; append-only, same-tx |
| Trace | correlation_id on sale, events, outbox payloads, payment attempt | One sale across every log line |
| Queue visibility | BullMQ job.returnvalue | Deterministic failures recorded as { success: false, retryable: false, errorCode } |
15. Testing and Validation
| Test Type | Files | Coverage |
|---|---|---|
| Unit | pos-totals.util.spec.ts | Pure total/line arithmetic |
| Integration | pos-sale.int.spec.ts | Draft mutations, completion, walk-in, error branches |
| Constraint probe | .omc/plans/POS/probe-constraints.mjs | CHECK constraints both directions against real PostgreSQL (97/0) |
| Live HTTP | .omc/plans/POS/checks-pos.sh | 24/0 against a booted API |
Validation: pnpm check-types · pnpm check (Biome) · pnpm build · pnpm test (serial,
maxWorkers: 1) · pnpm rules — all passed in the feature's verification run.
16. Mandatory Backend Deep-Dive Pack
16.1 Submodule Coverage Matrix
| Unit | Type | Owns | Depends On | Called By | Calls | State Touched | Failure Modes |
|---|---|---|---|---|---|---|---|
PosSaleAdminService | service | orchestration + DTO mapping | draft, line, completion, query | controller | 4 shared services | — | Mapping misses |
PosSaleAccessService | service | locks + totals | DB | all mutators | — | pos_sale, lines | 404/409 codes |
PosSaleEventService | service | audit rows | DB | every mutation | insert | pos_sale_event | — |
PosSaleNumberService | service | sale numbers | DB sequence | draft | nextval | sequence | missing sequence |
PosCustomerService | service | walk-in accounts + tokens | VerificationTokenService | draft, worker | inserts | customers, account, verification | 409 email taken |
PosLookupService | service | bounded search | DB | lookup controller | select | — | 400 short term |
PosSaleDraftService | service | draft lifecycle | access, events, numbers, customer, mail | admin service | 5 services | pos_sale, addresses, outbox | 409/404 set |
PosSaleLineService | service | draft lines | access, events, totals | admin service | 3 services | pos_sale_item, totals | 400/404/409 |
PosSaleCompletionService | service | the one transaction | access, events, materializer, reservations, orders, handover, mail, cache | admin service | 8 collaborators | 7 tables + outbox | 409 set; rollback |
PosCheckoutMaterializerService | service | pipeline rows | config, payment events | completion | 3 inserts + event | cart, session, items, attempt | missing row errors |
PosSaleQueryService | service | admin reads | DB | admin service | selects | — | 404 |
PosMailQueueService | service | outbox rows | outbox | draft, completion | enqueue | outbox_events | — |
PosTotalsUtil | util | pure math | none | line, access | — | — | — |
PosQueueProcessor | worker | dispatch | 3 handlers | BullMQ | handler record | jobs | deterministic vs retryable |
PosReceiptHandler | handler | receipt email | DB, bull, notifications | processor | enqueue | outbox-consumed | skip outcomes |
PosWalkInInviteHandler | handler | invite email | DB, bull, customer, config | processor | mint + enqueue | verification | skip outcomes |
PosDraftSweepHandler | handler | abandon sweep | DB, events | processor | cancel | pos_sale, events | per-row try/catch |
PosMaintenanceScheduler | scheduler | cron trigger | queue | cron | enqueue | jobs | batch-cap warning |
16.2 UML and Architecture Diagram Pack
- Component diagram: §3 composition graph.
- ER diagram: §5.3.
- Sequence diagrams: §7.1 completion, §7.2 walk-in invite.
- Activity diagram: §7.3 sweep + feature §6.1 till flow.
- State diagram: feature §7 sale lifecycle.
- Deployment/runtime diagram: §9 queue topology.
16.3 Code Flow Narrative
The completion method (PosSaleCompletionService.complete) is the module's critical path and
its full 15-step narrative is in §7.1. Walk-in creation in §7.2. The remaining mutation
methods share one shape: db.transaction → lockDraft → (validate/load) → write → event → recomputeTotals → commit, with applyToDraft guarding every UPDATE on status = 'draft'.
16.4 Data Layer Deep Dive
Field-level detail for all three tables is in §5.2. Index rationale:
| Index/Constraint | Columns | Type | Query/Invariant Supported | Tradeoff |
|---|---|---|---|---|
uq_pos_sale_{cart,session,payment,order}_id | each FK | partial unique | Replay-safe completion; one downstream row per sale | NULLs in draft rows (by design) |
idx_pos_sale_status_created_at | status, created_at | btree | §51 list default order + date filter | write overhead |
idx_pos_sale_payment_method_created_at | method, created_at | btree | §54 reconciliation by tender | write overhead |
idx_pos_sale_event_pos_sale_id_created_at | sale, created_at | btree | timeline read | write overhead |
idx_pos_sale_event_actor_admin_id_created_at | actor, created_at | btree | per-admin audit | write overhead |
uq_pos_sale_item_pos_sale_id_product_id | sale, product | unique | one line per product; upsert target | — |
| every FK index | FK column | btree | FK checks / join paths | — |
16.5 Business Logic and Invariant Catalog
| Invariant | Enforced By | Why It Exists | Failure Error | Tests |
|---|---|---|---|---|
| A completed sale holds every downstream key | chk_pos_sale_status_completion | No paid sale without an order | 23514 (probe-verified) | probe |
| Terminal status matches fulfilment | same | No delivery-ordered pickup | 23514 | probe |
| Pickup: no address, no shipping; delivery: address | chk_pos_sale_fulfilment_address | No money for a service not rendered | 23514 | probe |
| Reference only beside card/QR | DTO + chk_pos_sale_payment_reference_method | Cash has nothing to reference | 409 / 23514 | probe |
| Total follows from parts | chk_pos_sale_grand_total_matches | Unforgeable till total | 23514 | probe |
| Line amounts follow from price × qty | 3 line checks | Operator cannot mistype a price | 23514 | probe |
| One line per product | unique index + ON CONFLICT | Scanning twice = quantity, not duplicate | — | int spec |
| Cancellation carries a non-blank reason | DTO + chk_pos_sale_event_cancel_reason | Audit answers why | 400 / 23514 | probe |
| Walk-in account has no password | PosCustomerService | Credentials are the customer's alone | — | int spec |
| Price quoted is the price charged | assertPricesUnchanged | No silent re-pricing | POS_PRODUCT_PRICE_CHANGED | int spec |
| Reserves only at completion | sweep + constants | Drafts hold no stock | — | int spec |
16.6 Tradeoffs, Alternatives, and ADR Notes
| Decision | Context | Chosen Option | Alternatives | Why Chosen | Tradeoffs | Revisit Trigger |
|---|---|---|---|---|---|---|
| Reuse the online pipeline | Counter sale = same commercial record | Materialise cart/session/attempt, call createFromCheckout | A parallel counter ledger | One order domain, one return/refund path | Materializer complexity; channel label | Order module diverges |
| Nullable+unique downstream keys | Draft has no order yet | Partial unique indexes + status CHECK | Placeholder rows | No phantom sessions/payments; replay-safe | NULLs to handle in queries | — |
| Cart/session born terminal | Live-cart uniques collide with a phone cart | Insert converted/completed | Narrow the partial index predicates | Predicate inference is exact (42P10 otherwise) | Unusual shape, documented | Cart module changes predicate |
No awaiting_payment | No gateway round trip at a till | 4 states only | Model the wait | A state no path can produce | — | — |
| Price freeze + loud refusal | Quoted price is agreed price | Refuse on drift | Charge old/new silently | The two people are both present | Mid-sale 409s | — |
| Never-expiring single-use invite | Walk-in may not read email for weeks | Far-future sentinel + consumed_at | 24h TTL | Owner policy; no support dead-end | Long-lived if stolen | Policy change → config |
| No OTP on the invite | 6-digit code that never expires is brute-forcible | createEmailVerification only | createPasswordReset | Search space | — | — |
| Token minted at send time | Outbox dead rows are never purged | Mint in the worker | Carry token in payload | A credential must not sit in an operator-facing table | Retry replaces token (fine) | — |
pos_sale_event.actor_admin_id SET NULL | Offboarding must not deadlock | SET NULL + attribution CHECK | RESTRICT like the sale | Trail survives; retention ≠ deadlock | Lost attribution | — |
| Own sequence for sale numbers | Two documents counted by two people | pos_sale_number_seq | Shared order_number_seq | Contiguous till roll | Gaps (expected) | — |
| Lookup is ILIKE, not trigram | Operator wants the exact row | ILIKE + escaping | Trigram similarity | Fuzzy match could sell the wrong SKU | >10k rows needs prefix index | Catalogue grows |
| Offset pagination | Till list is small for years | PaginationUtil offset | Keyset | Simplicity | 10k/100k-row cap | ~3 years at 100 sales/day |
| Cache invalidation after commit | Network call in a tx is forbidden | triggerForWrite post-commit | Inside the tx | Must not hold locks for an HTTP timeout | Brief staleness | — |
16.7 Operational Runbook
| Operation | How to Inspect | Healthy State | Failure Signal | Recovery |
|---|---|---|---|---|
| Queue | Bull Board / logs [start]/[success]/[failure]/[skip] | Jobs completing; skips are expected and logged | errorCode returns or rethrows | Retry via BullMQ for transient; deterministic failures need a code fix |
| Sweep | Logs examined N, cancelled M, failed K | M > 0 with due drafts | Batch-cap warning (filled its batch) | Next hourly tick picks up the rest |
| DB | pos_sale / pos_sale_event queries | Constraints satisfied; no 23514 in API logs | Deadlock 40P01 (rare) | Lock order is the mitigation; retry |
| Cache | inventory domain invalidation | Post-completion clear fires | No clear (silent) | triggerForWrite is fire-and-forget; re-trigger |
| Timeline | GET /sales/{id}/timeline | Events match the sale's history | Missing events | Events are same-tx; a gap means a bug |
16.8 Backend Risk Register
| Risk | Area | Impact | Current Mitigation | Remaining Gap |
|---|---|---|---|---|
| Two tills complete one sale | completion | Double charge | lockDraft + unique keys + guarded finalise | — |
| Counter and online checkout deadlock | completion | 40P01 | Ascending product lock order matching checkout | Distributed tx isolation not tested at load |
| Replayed completion | completion | Second order | Four partial unique indexes + WHERE status='draft' | — |
| Email outage | async | No receipt/invite | Outbox durability; queue retry | Provider blackhole (job retries) |
| Price drift | completion | Wrong price charged | Loud refusal at the till | None (by design) |
| Offboarded operator | reporting | Unattributed sales | RESTRICT FK on the sale; SET NULL on events | Sale blocks account removal |
| Catalogue growth | lookup | Slow fuzzy search | ILIKE + bounded pagination | Prefix index when >10k rows |
17. Zero-Omission Backend Checklist
- Every file in the module directory is represented or explicitly marked non-runtime (§4, §16.1).
- Every controller, service, provider, processor, scheduler, helper, mapper, DTO, enum, and schema is documented.
- Every method with business behavior has a code-flow narrative (§6, §7, §16.3).
- Every table/cache object/job payload has field-level detail (§5.2, §9).
- Every index, constraint, relation, and delete behavior has rationale (§5.2, §16.4).
- Every lifecycle/status transition has a state diagram and transition table (feature §7).
- Every read/write/action/job flow has sequence and activity diagrams (§7, §9).
- Every business invariant is cataloged (§16.5).
- Every cache key, invalidation path, queue job, realtime event, and external call is documented (§8, §9, §10).
- Every architectural tradeoff is documented with alternatives and revisit triggers (§16.6).
- Every operational failure mode has a runbook entry (§16.7).
18. Backend Completion Checklist
- Module boundaries are documented (§2).
- Every controller, service, DTO, schema file, job, cache key, and event is covered (§3–§11).
- Every database table has a field table and relationship diagram (§5).
- Every runtime flow has a diagram and branch notes (§7).
- API and features/flows docs are linked (See Also).
- No claim is made without a source file or documented source reference.
See Also
- API doc:
/docs/developer/pos/api - Features and flows doc:
/docs/developer/pos/feature - TDD: not yet published