Happy House - Ecommerce Docs
Developer ResourcesPOS Module Overview

POS Backend Documentation

Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for POS.

POS - Backend Documentation

1. Documentation Evidence

AreaFiles InspectedVerified Details
Module wiringapps/api/src/modules/pos/*.module.tsAggregate + two admin leaves + worker; imports of Auth, Inventory, Order, Outbox, Payment shared modules
Controllersadmin/sale/pos-sale-admin.controller.ts, admin/lookup/pos-lookup-admin.controller.ts13 routes, guards, permissions, rate limits, ParseUUIDPipe on every id
Servicesshared/*.service.ts, pos-totals.util.tsOne-transaction completion, lock order, outbox mails, lookup semantics
DTOsadmin/sale/dto/*.ts, admin/lookup/dto/*.tsRequest, query, response validation and shapes
Schemapackages/db/src/schema/pos/*.tspos_sale, pos_sale_item, pos_sale_event; enums; CHECK constraints
Jobspackages/jobs/src/index.tsQueueName.POS, PosJob, payloads; the token is not in the payload
Cachecache-invalidation.tags.ts, pos.constants.tsproduct_review-style domain: inventory cleared after completion
Infraenv.validation.tsPOS_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_sale audit timeline.
  • The hourly abandoned-draft sweep.
  • The channel label on orders and checkout sessions (defaults online).
  • The pos_cash / pos_card / pos_qr payment 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 after confirmed).
  • Payment gateway logic (a payment_attempt row is written directly, but the Payment module owns the table and its admin surface).
  • Refunds and returns (Order module).
  • Barcode search (does not exist — product has sku, no barcode column).
  • Receipt/invitation sending (delegated to the Notifications queue).

Source of Truth

ConcernSource of TruthNotes
Sale statepos_sale rowStatus + fulfilment + downstream keys, guarded by chk_pos_sale_status_completion
What was soldpos_sale_item (draft) → checkout_session_itemorder_itemFrozen at the till, copied not converted
Money takenpayment_attemptSame shape as online payments; the sale number is in gateway_transaction_id
OrderorderThe Order module owns it; POS references it
Auditpos_sale_eventAppend-only, same transaction as the change

3. Module Composition

ModuleTypePathControllersProvidersExportsResponsibility
PosModuleAggregateapps/api/src/modules/pos/pos.module.tsNonePosAdminAggregateModuleComposes the admin leaves with shared services
PosAdminAggregateModuleAggregate.../admin/pos-admin-aggregate.module.tsNoneSale + Lookup leavesTwo leaves, no controllers of its own
PosSaleAdminModuleLeaf.../admin/sale/pos-sale-admin.module.tsPosSaleAdminControllerPosSaleAdminServiceThe till routes
PosLookupAdminModuleLeaf.../admin/lookup/pos-lookup-admin.module.tsPosLookupAdminControllerCustomer/product lookup
PosSharedModuleShared.../pos-shared.module.tsNone11 services8 servicesEvery decision in the module
PosWorkerModuleWorker.../pos-worker.module.tsNoneProcessor + handlers + schedulerOne @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
FilePurposeKey ExportsNotes
pos.constants.tsFixed valuesPOS_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.tsWalk-in provisioning + token mintingprovisionWalkIn, issueInviteNo password; createEmailVerification, never createPasswordReset (no OTP)
pos-lookup.service.tsBounded ILIKE searchessearchCustomers, searchProducts2-char minimum; escapeLikePattern; active customers only
pos-mail-queue.service.tsOutbox enqueuesqueueReceipt, queueWalkInInviteSame transaction as the sale; dedupe on sale/customer id
pos-sale-access.service.tsLocks + totalslockSale, lockDraft, loadLines, recomputeTotalsFOR UPDATE; totals recomputed, never incremental
pos-sale-completion.service.tsThe one transactioncompleteLock order sale → products asc → cart → session → payment → order
pos-sale-draft.service.tsDraft mutationsopen, attachCustomer, createWalkInCustomer, setFulfilment, cancelCustomer change resets to pickup; cancel reason mandatory
pos-sale-event.service.tsAppend-only auditrecordNo update path anywhere
pos-sale-line.service.tsLines on the draftupsertLine, removeLinePUT replaces the quantity; ON CONFLICT DO UPDATE
pos-sale-number.service.tsPOS-2026-000042issueOwn sequence, DB clock in Asia/Kathmandu, gaps expected
pos-sale-query.service.tsAdmin readsfindAll, findOne, findTimelinePage + total in one transaction; offset pagination (10k-row cap noted)
pos-totals.util.tsPure total mathcomputePosSaleTotals, computePosLineAmountsInteger minor units; subtotal = Σ lineTotal, not netAmount
pos-checkout-materializer.service.tsPipeline rowsresolveShipping, materializeCart 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.ts

Also 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/FieldTypeNullableDefaultIndex/ConstraintRelationNotes
idserialNogeneratedPKN/AInternal
public_iduuidNouuid7uniqueN/AExternal handle
sale_numbervarchar(32)NouniqueN/APOS-2026-000042
statusenumNodraftN/Adraft/picked_up/ordered/cancelled
fulfilmentenumNononeN/Apickup/delivery; unset is unrepresentable
customer_iduuidNoindex + FK RESTRICTcustomersEvery sale belongs to an account
customer_createdbooleanNofalseN/A§48 Customer Type, stored not derived
created_by_admin_iduuidNoindex + FK RESTRICTadminUsersSales-by-administrator must not drift
cart_idintegerYes (draft)NULLpartial uniquecarts (RESTRICT)Born converted
checkout_session_idintegerYes (draft)NULLpartial uniquecheckoutSessions (RESTRICT)Born completed
payment_attempt_idintegerYes (draft)NULLpartial uniquepaymentAttempts (RESTRICT)Born succeeded
order_idintegerYes (draft)NULLpartial uniqueorders (RESTRICT)Set at completion
delivery_address_idintegerYesNULLindex + FK RESTRICTcustomerAddressesNULL for pickup
payment_methodenumNoN/Apos_cash/pos_card/pos_qr
payment_referencevarchar(128)YesNULLN/ARecorded, never validated; refused beside cash
currencyvarchar(3)NoNPRN/AUppercased, length 3
subtotal/discount_amount/shipping_amount/grand_totalbigintNo0N/AInteger minor units
total_quantity/line_countintegerNo0N/ADerived
correlation_iduuidNouuid7N/ATies logs/events/outbox
versionintegerNo1N/ABumped on every change
completed_at/cancelled_attimestamp tzYesNULLN/ATerminal stamps
created_at/updated_attimestamp tzNonowN/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_up only with pickup, ordered only with delivery; cancelled has 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 is pos_card/pos_qr (compared ::text because a new enum value cannot be used in the transaction that adds it).
  • chk_pos_sale_grand_total_matchesgrand_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/FieldTypeNullableDefaultIndex/ConstraintRelationNotes
id / public_idserial / uuidNouuid7PK / uniqueN/A
pos_sale_idintegerNoFK CASCADE + indexposSalesDeleting a sale deletes its lines
product_idintegerNoFK RESTRICT + indexproductsA counter line is money that changed hands
product_public_iduuidNoN/ADenormalised
product_namevarchar(255)NoN/ASnapshot; survives rename/delist
skuvarchar(64)YesNULLN/ASnapshot
unit_price/mrp/line_total/discount_amount/net_amountbigintNoN/APrice snapshot, unlike the cart
quantityintegerNoN/A1–99 (chk_pos_sale_item_quantity_range)
  • uq_pos_sale_item_pos_sale_id_product_id — one line per product; the upsert is ON 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/FieldTypeNullableDefaultIndex/ConstraintRelationNotes
id / public_idserial / uuidNouuid7PK / uniqueN/A
pos_sale_idintegerNoFK CASCADE + composite indexposSalesTimeline read
event_typeenumNoN/A10 values from created to cancelled
from_status/to_statusenumYesNULLN/ABoth or neither
actor_admin_iduuidYesNULLFK SET NULL + composite indexadminUsersAudit survives offboarding; differs from RESTRICT on the sale
reasontextYesNULLN/AMandatory non-blank for cancelled
metadatajsonbYesNULLN/AProduct/quantity/old-new values; never the invite token
correlation_iduuidNoN/A
created_attimestamp tzNonowN/ANo updated_at — append-only
  • chk_pos_sale_event_transition_pair, chk_pos_sale_event_transition_moves, chk_pos_sale_event_cancel_reason (the IS NOT NULL arm is load-bearing — the original constraint passed on NULL reason, fixed by migration 0015), chk_pos_sale_event_reason_not_blank (~ '[^[:space:]]', not btrim).

5.3 Relationship Diagram

6. Services and Responsibilities

6.1 PosSaleAccessService

MethodCalled ByReadsWritesSide EffectsErrors
lockSaleAll mutationspos_sale FOR UPDATE404 POS_SALE_NOT_FOUND
lockDraftAll draft mutationspos_sale409 POS_SALE_ALREADY_COMPLETED / POS_SALE_NOT_DRAFT
loadLinescompletion, totalspos_sale_item
recomputeTotalsline mutations, fulfilmentlinespos_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

issuePOS-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

MethodReadsWritesNotes
provisionWalkIncustomers (email check)customers, accountpassword: null; email_verified: false; same rows as registration, minus the password
issueInviteverification tokencreateEmailVerification (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

MethodTransactionBehavior
openownInserts draft + pickup; customerId required; event created
attachCustomerownLocks, validates customer, applies patch + CLEAR_DELIVERY_ON_CUSTOMER_CHANGE (fulfilment: pickup, address NULL, shipping 0), event customer_assigned
createWalkInCustomerownLocks, provisions account, updates sale (customerCreated: true), enqueues invite, event customer_created
setFulfilmentownPickup: clears address + shipping. Delivery: resolves saved address (scoped to this customer) or inserts a new one (is_default: false), applies patch, event fulfilment_set
cancelownLocks, 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, event item_added/item_quantity_changed, recomputes totals.
  • removeLine: locks, hard deletes by (pos_sale_id, product_public_id), event item_removed, recomputes totals. 404 POS_LINE_NOT_FOUND if absent.

6.8 PosSaleCompletionService

complete — one transaction:

  1. lockDraft (first lock, the start of the global order).
  2. applyPaymentReference — trims, refuses beside pos_cash.
  3. assertCompletable — lines, customer, delivery address.
  4. assertPricesUnchanged — current sellingPrice vs frozen unitPrice; refuse loudly.
  5. reserveAll — ascending product id; insufficient stock translated to POS_INSUFFICIENT_STOCK (narrowed on the code, never the class); nothing queried after a failed statement (25P02).
  6. resolveShipping + materialize — cart born converted, session born completed (channel: "pos"), frozen checkout_session_items, payment_attempt born succeeded with gateway_transaction_id = POS-{saleNumber} and a real random callback token hash.
  7. reservations.finalize per line — holds become deductions.
  8. orders.createFromCheckout(..., { executor: tx, suppressOpeningEmail: pickup }) — the order inside the sale's transaction.
  9. handover.handOver for pickup — drives the order to delivered.
  10. finalise — status picked_up/ordered, all keys, version + 1, guarded WHERE status = 'draft' (replay → POS_SALE_ALREADY_COMPLETED).
  11. mail.queueReceipt — outbox row in the same transaction.
  12. 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; 404 POS_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: computePosSaleTotalslineTotal, 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

StepCode PathBehaviorFailure Case
1lockDraftSerialises on the sale404/409 on missing/terminal
2assertCompletableEmpty/uncustomered/addressless refused409 POS_SALE_EMPTY / POS_SALE_NO_CUSTOMER / POS_DELIVERY_ADDRESS_REQUIRED
3assertPricesUnchangedFrozen price vs live price409 POS_PRODUCT_PRICE_CHANGED — nothing charged
4reserveAllStock held, ascending ids409 POS_INSUFFICIENT_STOCK — nothing charged
5createFromCheckoutOrder inside the txRollback of the whole sale
6handOverPickup → delivered via recomputeRollback
7finaliseTerminal status + keys409 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 PatternBuilderValueTTLInvalidationCaller
inventory:* (existing)existing inventory keysexistingtriggerForWrite({ domain: "inventory" }) after a completed salePosSaleCompletionService

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

QueueJobProducerProcessorPayloadRetry/BackoffIdempotency
QueueName.POSpos.send_receiptoutbox (sale tx)PosReceiptHandler{ correlationId, posSalePublicId }BullMQ policy; HttpException = not retriedoutbox dedupe on sale id
QueueName.POSpos.send_walk_in_inviteoutbox (sale tx)PosWalkInInviteHandler{ correlationId, posSalePublicId, customerId }no tokensameoutbox dedupe on customer id
QueueName.POSpos.sweep_abandoned_draftscron (direct)PosDraftSweepHandler{ correlationId, batchSize }samejobId 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 jobIdpos-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

EventProducerRoom/TargetPayloadConsumerReliability Notes
pos.sale_completed (outbox)PosMailQueueServiceQueueName.POS{ correlationId, posSalePublicId }PosReceiptHandlerNOTIFICATIONSRow in the sale's transaction
pos.walk_in_created (outbox)PosMailQueueServiceQueueName.POS{ correlationId, posSalePublicId, customerId }PosWalkInInviteHandlerNOTIFICATIONSDeduped on customer
pos_sale_event rowsevery mutation servicepos_sale_event tabletype + statuses + actor + metadatatimeline endpoint, auditsSame transaction as the change

No WebSocket/realtime surface exists in this module.

11. Security, Auth, and Abuse Controls

  • Guards: JwtAuthGuard + RoleGuard on both controllers; IpThrottlerGuard per route.
  • Permissions: Pos_CREATE / Pos_READ / Pos_UPDATE / Pos_DELETE, from the Pos permission module — spell it Pos, never POS. Pos_READ also exposes customer lookup, so it is grantable separately from Users_READ.
  • Identity: @CurrentUser() user.id is the operator; every mutation records them on the event. The sweep records actor_admin_id: null.
  • Rate limits: ADMIN_POS_WRITE 60/min per user (every draft mutation — 10/min ADMIN_WRITE would throttle a 15-item basket mid-sale), ADMIN_POS_COMPLETE 30/min per user, both keyed on the user because every till in a shop shares one IP. Lookup and reads use PUBLIC_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_id in the query — another customer's address returns 404, never 403.
  • IDOR/leak hygiene: ParseUUIDPipe on every id (its absence caused 500s in Reviews); responses expose only public_ids, never integer PKs; a malformed id is a clean 400.
  • Fail-closed: the transaction aborts on any guard; finalise is guarded on status = 'draft'; a completed sale cannot be re-priced or re-charged.
  • Audit: every mutation writes pos_sale_event in the same transaction; the event table is append-only with no updated_at.

13. Error Handling

Error CodeHTTP StatusThrown ByConditionClient Action
POS_SALE_NOT_FOUND404access/query/email handlersSale id does not resolveReturn to the sales list
POS_SALE_NOT_DRAFT409lockDraft, applyToDraftCancelled (or otherwise not draft)Start a new sale
POS_SALE_ALREADY_COMPLETED409lockDraft, finaliseMoney already takenDo not retry — show the order number
POS_SALE_EMPTY409assertCompletableNo linesBlock complete until a line exists
POS_SALE_NO_CUSTOMER409 / 404assertCompletable / assertCustomerExistsNo customer attached / customer missingSend the operator to lookup
POS_DELIVERY_ADDRESS_REQUIRED409draft + completionDelivery without an address, or both address formsOpen the address form
POS_ADDRESS_NOT_FOUND404draft / completionAddress not this customer's / store district missingRe-fetch addresses / fix POS_STORE_DISTRICT_ID
POS_PRODUCT_NOT_SELLABLE404 / 409line + completionUnpublished, deleted or mispricedRemove the line, name the product
POS_PRODUCT_PRICE_CHANGED409assertPricesUnchangedPrice moved mid-saleTell the operator; remove and re-add
POS_INSUFFICIENT_STOCK409reserveAllSomeone else took the last oneShow remaining; reduce quantity
POS_PAYMENT_REFERENCE_NOT_ALLOWED409applyPaymentReferenceReference sent with cashClear the field or change method
POS_CUSTOMER_EMAIL_TAKEN409assertEmailFreeEmail already has an account (incl. closed)Attach the existing customer / use another address
POS_LINE_NOT_FOUND404removeLineRemoving a line that is not thereRe-fetch the sale
POS_QUANTITY_OUT_OF_RANGE400assertQuantityNot a whole number in 1–99Clamp 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

SignalLocationPurpose
LogLogger in completion, sweep, queue processor, customer service[pos] ... state-change and failure visibility
Auditpos_sale_event table§42 timeline; append-only, same-tx
Tracecorrelation_id on sale, events, outbox payloads, payment attemptOne sale across every log line
Queue visibilityBullMQ job.returnvalueDeterministic failures recorded as { success: false, retryable: false, errorCode }

15. Testing and Validation

Test TypeFilesCoverage
Unitpos-totals.util.spec.tsPure total/line arithmetic
Integrationpos-sale.int.spec.tsDraft mutations, completion, walk-in, error branches
Constraint probe.omc/plans/POS/probe-constraints.mjsCHECK constraints both directions against real PostgreSQL (97/0)
Live HTTP.omc/plans/POS/checks-pos.sh24/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

UnitTypeOwnsDepends OnCalled ByCallsState TouchedFailure Modes
PosSaleAdminServiceserviceorchestration + DTO mappingdraft, line, completion, querycontroller4 shared servicesMapping misses
PosSaleAccessServiceservicelocks + totalsDBall mutatorspos_sale, lines404/409 codes
PosSaleEventServiceserviceaudit rowsDBevery mutationinsertpos_sale_event
PosSaleNumberServiceservicesale numbersDB sequencedraftnextvalsequencemissing sequence
PosCustomerServiceservicewalk-in accounts + tokensVerificationTokenServicedraft, workerinsertscustomers, account, verification409 email taken
PosLookupServiceservicebounded searchDBlookup controllerselect400 short term
PosSaleDraftServiceservicedraft lifecycleaccess, events, numbers, customer, mailadmin service5 servicespos_sale, addresses, outbox409/404 set
PosSaleLineServiceservicedraft linesaccess, events, totalsadmin service3 servicespos_sale_item, totals400/404/409
PosSaleCompletionServiceservicethe one transactionaccess, events, materializer, reservations, orders, handover, mail, cacheadmin service8 collaborators7 tables + outbox409 set; rollback
PosCheckoutMaterializerServiceservicepipeline rowsconfig, payment eventscompletion3 inserts + eventcart, session, items, attemptmissing row errors
PosSaleQueryServiceserviceadmin readsDBadmin serviceselects404
PosMailQueueServiceserviceoutbox rowsoutboxdraft, completionenqueueoutbox_events
PosTotalsUtilutilpure mathnoneline, access
PosQueueProcessorworkerdispatch3 handlersBullMQhandler recordjobsdeterministic vs retryable
PosReceiptHandlerhandlerreceipt emailDB, bull, notificationsprocessorenqueueoutbox-consumedskip outcomes
PosWalkInInviteHandlerhandlerinvite emailDB, bull, customer, configprocessormint + enqueueverificationskip outcomes
PosDraftSweepHandlerhandlerabandon sweepDB, eventsprocessorcancelpos_sale, eventsper-row try/catch
PosMaintenanceSchedulerschedulercron triggerqueuecronenqueuejobsbatch-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/ConstraintColumnsTypeQuery/Invariant SupportedTradeoff
uq_pos_sale_{cart,session,payment,order}_ideach FKpartial uniqueReplay-safe completion; one downstream row per saleNULLs in draft rows (by design)
idx_pos_sale_status_created_atstatus, created_atbtree§51 list default order + date filterwrite overhead
idx_pos_sale_payment_method_created_atmethod, created_atbtree§54 reconciliation by tenderwrite overhead
idx_pos_sale_event_pos_sale_id_created_atsale, created_atbtreetimeline readwrite overhead
idx_pos_sale_event_actor_admin_id_created_atactor, created_atbtreeper-admin auditwrite overhead
uq_pos_sale_item_pos_sale_id_product_idsale, productuniqueone line per product; upsert target
every FK indexFK columnbtreeFK checks / join paths

16.5 Business Logic and Invariant Catalog

InvariantEnforced ByWhy It ExistsFailure ErrorTests
A completed sale holds every downstream keychk_pos_sale_status_completionNo paid sale without an order23514 (probe-verified)probe
Terminal status matches fulfilmentsameNo delivery-ordered pickup23514probe
Pickup: no address, no shipping; delivery: addresschk_pos_sale_fulfilment_addressNo money for a service not rendered23514probe
Reference only beside card/QRDTO + chk_pos_sale_payment_reference_methodCash has nothing to reference409 / 23514probe
Total follows from partschk_pos_sale_grand_total_matchesUnforgeable till total23514probe
Line amounts follow from price × qty3 line checksOperator cannot mistype a price23514probe
One line per productunique index + ON CONFLICTScanning twice = quantity, not duplicateint spec
Cancellation carries a non-blank reasonDTO + chk_pos_sale_event_cancel_reasonAudit answers why400 / 23514probe
Walk-in account has no passwordPosCustomerServiceCredentials are the customer's aloneint spec
Price quoted is the price chargedassertPricesUnchangedNo silent re-pricingPOS_PRODUCT_PRICE_CHANGEDint spec
Reserves only at completionsweep + constantsDrafts hold no stockint spec

16.6 Tradeoffs, Alternatives, and ADR Notes

DecisionContextChosen OptionAlternativesWhy ChosenTradeoffsRevisit Trigger
Reuse the online pipelineCounter sale = same commercial recordMaterialise cart/session/attempt, call createFromCheckoutA parallel counter ledgerOne order domain, one return/refund pathMaterializer complexity; channel labelOrder module diverges
Nullable+unique downstream keysDraft has no order yetPartial unique indexes + status CHECKPlaceholder rowsNo phantom sessions/payments; replay-safeNULLs to handle in queries
Cart/session born terminalLive-cart uniques collide with a phone cartInsert converted/completedNarrow the partial index predicatesPredicate inference is exact (42P10 otherwise)Unusual shape, documentedCart module changes predicate
No awaiting_paymentNo gateway round trip at a till4 states onlyModel the waitA state no path can produce
Price freeze + loud refusalQuoted price is agreed priceRefuse on driftCharge old/new silentlyThe two people are both presentMid-sale 409s
Never-expiring single-use inviteWalk-in may not read email for weeksFar-future sentinel + consumed_at24h TTLOwner policy; no support dead-endLong-lived if stolenPolicy change → config
No OTP on the invite6-digit code that never expires is brute-forciblecreateEmailVerification onlycreatePasswordResetSearch space
Token minted at send timeOutbox dead rows are never purgedMint in the workerCarry token in payloadA credential must not sit in an operator-facing tableRetry replaces token (fine)
pos_sale_event.actor_admin_id SET NULLOffboarding must not deadlockSET NULL + attribution CHECKRESTRICT like the saleTrail survives; retention ≠ deadlockLost attribution
Own sequence for sale numbersTwo documents counted by two peoplepos_sale_number_seqShared order_number_seqContiguous till rollGaps (expected)
Lookup is ILIKE, not trigramOperator wants the exact rowILIKE + escapingTrigram similarityFuzzy match could sell the wrong SKU>10k rows needs prefix indexCatalogue grows
Offset paginationTill list is small for yearsPaginationUtil offsetKeysetSimplicity10k/100k-row cap~3 years at 100 sales/day
Cache invalidation after commitNetwork call in a tx is forbiddentriggerForWrite post-commitInside the txMust not hold locks for an HTTP timeoutBrief staleness

16.7 Operational Runbook

OperationHow to InspectHealthy StateFailure SignalRecovery
QueueBull Board / logs [start]/[success]/[failure]/[skip]Jobs completing; skips are expected and loggederrorCode returns or rethrowsRetry via BullMQ for transient; deterministic failures need a code fix
SweepLogs examined N, cancelled M, failed KM > 0 with due draftsBatch-cap warning (filled its batch)Next hourly tick picks up the rest
DBpos_sale / pos_sale_event queriesConstraints satisfied; no 23514 in API logsDeadlock 40P01 (rare)Lock order is the mitigation; retry
Cacheinventory domain invalidationPost-completion clear firesNo clear (silent)triggerForWrite is fire-and-forget; re-trigger
TimelineGET /sales/{id}/timelineEvents match the sale's historyMissing eventsEvents are same-tx; a gap means a bug

16.8 Backend Risk Register

RiskAreaImpactCurrent MitigationRemaining Gap
Two tills complete one salecompletionDouble chargelockDraft + unique keys + guarded finalise
Counter and online checkout deadlockcompletion40P01Ascending product lock order matching checkoutDistributed tx isolation not tested at load
Replayed completioncompletionSecond orderFour partial unique indexes + WHERE status='draft'
Email outageasyncNo receipt/inviteOutbox durability; queue retryProvider blackhole (job retries)
Price driftcompletionWrong price chargedLoud refusal at the tillNone (by design)
Offboarded operatorreportingUnattributed salesRESTRICT FK on the sale; SET NULL on eventsSale blocks account removal
Catalogue growthlookupSlow fuzzy searchILIKE + bounded paginationPrefix 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