Order Backend Documentation
Backend architecture, data model, services, and operational behavior for the Order module.
Order - Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/order/ module files | Customer/admin/worker leaves, email |
| Controllers | customer/order-customer.controller.ts, admin/{order,return,refund,cod}/*.controller.ts | Routes, permissions |
| Services | customer + admin services, return/refund/cod services, order/shared/order-lifecycle.service.ts, workers | Transitions, money, restock, cash collection |
| Schema | packages/db/src/schema/order/*.ts | 12 tables, 9 statuses, CHECKs |
| Money | apps/api/src/utils/money/money.util.ts | allocateProportionally, valueOfUnits |
| Cross-module | apps/api/src/modules/checkout/shared/checkout-payment.service.ts | The order.eligible outbox row |
| Error registry | apps/api/src/common/types/error-codes.ts (// ORDER) | ORDER_* codes |
2. Backend Scope and Boundaries
Owns
- The 12 order tables and the 30 routes.
- The whole post-purchase lifecycle: dispatch, delivery, cancellation, returns, refunds, receipts, emails.
Does Not Own
- Order creation from the client — it is a background job consuming the
order.eligibleoutbox row written inside the checkout-completion transaction. - Gateway refunds — an approved refund waits for a human; settlement requires a reference by constraint.
- Fulfillment as a separate module — order owns dispatch because none exists; the rows are shaped so one can take it over later.
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| Commercial record | Order + item rows (snapshots from the frozen checkout) | |
| Status | Recomputed from line quantities | Nine values, none returned |
| Returns/refunds | Separate aggregates with own statuses | |
| Money | Minor units; frozen at creation | Both refund ceilings are CHECKs |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
OrderModule | Aggregate | order.module.ts | None | — | Leaves | Composes customer/admin/worker |
OrderCustomerModule | Leaf | customer/ | OrderCustomerController | customer service | — | The 8 customer routes |
OrderAdminModule | Leaf | admin/order/ | OrderAdminController | admin service | — | The 10 order routes |
OrderCodAdminModule | Leaf | admin/cod/ | OrderCodAdminController | OrderCodAdminService | — | The 3 cash collection routes |
OrderReturnAdminModule | Leaf | admin/return/ | OrderReturnAdminController | return service | — | The 5 return routes |
OrderRefundAdminModule | Leaf | admin/refund/ | create + admin controllers | refund services | — | The 5 refund routes |
OrderWorkerModule | Leaf | order-worker.module.ts | None | processors | — | CREATE_ORDER + email |
4. File and Directory Map
apps/api/src/modules/order/
customer/
order-customer.controller.ts
order-customer.service.ts
order-response.builder.ts
dto/
shared/
order-lifecycle.service.ts # transitions, incl. record/correct/reverseCodCollection
admin/
order/order-admin.controller.ts + service
cod/order-cod-admin.controller.ts + service + dto/
return/order-return-admin.controller.ts + service
refund/order-refund-create-admin.controller.ts
order-refund-admin.controller.ts + services
workers/
order-create.processor.ts
order-email.processor.ts (or equivalent)
packages/db/src/schema/order/
order.ts order-item.ts order-event.ts order-note.ts order-promotion.ts
order-shipment.ts order-shipment-item.ts order-item-serial.ts
order-return.ts order-return-item.ts order-refund.ts order-refund-item.ts
enums.ts
packages/db/src/migrations/0012_order.sqlKey files:
| File | Purpose | Key Exports | Notes |
|---|---|---|---|
workers/order-create.processor.ts | Build the order | processor | Consumes order.eligible |
admin/refund/ services | Refund lifecycle | create/approve/reject/settle | Settlement reference required |
admin/cod/order-cod-admin.service.ts | Cash collection API surface | OrderCodAdminService | Thin — reloads the response DTO and writes the activity row; every rule lives in OrderLifecycleService |
shared/order-lifecycle.service.ts | Cash collection rules | recordCodCollection, correctCodCollection, reverseCodCollection | Row lock, one transaction, one audit event, per call |
money.util.ts | Allocation | allocateProportionally, valueOfUnits | Largest-remainder, frozen |
5. Data Model
5.1 Schema Source
packages/db/src/schema/order/ (12 tables + enums)5.2 Tables
| Table | Purpose |
|---|---|
order | The commercial record — checkout snapshot, money, status, codCollectedAt/codCollectedAmount/codCollectedByAdminId, refundedAmount |
order_item | Lines with quantities as truth (no status column): quantity, unavailableQuantity, cancelledQuantity, shippedQuantity, deliveredQuantity, returnedQuantity, returnPendingQuantity. Also the frozen configuration: variant_id (real key), variant_public_id (NOT NULL since migration 0028), variant_name (nullable — null when the variant was the product's sole configuration and carried no label of its own) |
order_event | One append-only table serving two projections — the customer timeline (customer_visible = true) and the status history (to_status IS NOT NULL); no updated_at, on purpose |
order_note | Operator notes |
order_promotion | Frozen promotion snapshot |
order_shipment | A parcel; courier + tracking strings |
order_shipment_item | Per-parcel quantities |
order_item_serial | Device serial numbers — unique per (product_id, serial_number), entered at dispatch |
order_return / order_return_item | The return aggregate and its lines |
order_refund / order_refund_item | The refund aggregate and its lines |
Key invariants:
chk_order_refunded_within_paid(order) andchk_order_item_refunded_within_net(line) — the two refund ceilings, and neither implies the other.- Returns as a budget:
return_pending_quantity + returned_quantity <= delivered_quantity. - Serial uniqueness is per-product, not global — a serial like
SN-001is meaningful only within one product line; global uniqueness would force globally unique serials nobody has. order.refunded_amountis a deliberate denormalised counter — "never refund more than was paid" is a cross-row sum that a CHECK cannot express, so the counter is maintained by the refund service under the CHECK as backstop.codCollectedAtis the one field distinguishing "committed" from "collected".chk_order_cod_collection— all three ofcodCollectedAt/codCollectedAmount/codCollectedByAdminIdare NULL together or set together; when set,0 < codCollectedAmount <= grandTotalandpaymentMethod = 'cod'. The service layer checks the ceiling itself before reaching the database, because the DTO cannot seegrandTotalto bound it and an unmapped23514reads to an operator as "an error occurred" with no way to tell a typo from a broken screen.fk_order_item_variant_identity (variant_id, variant_public_id)againstproduct_variant (id, public_id)(migration0029) ties the integer and public variant ids so they cannot disagree — the refund path matches lost holds, which are keyed byvariant_id, againstvariant_public_id.
Deploy ordering for migration 0028 is not optional, and here the failure mode is worse than
checkout's. order_item.variant_public_id became NOT NULL in the same migration
(0028_variant_public_id_not_null.sql) as checkout_session_item's. No build before this program
had ever written it — 0023 added the column and backfilled once, and nothing populated it since;
all writers of it arrived together. Migrating the database before deploying the code means
OrderCreationService.insert raises 23502 for an order whose payment has already succeeded,
so the order.create_order job throws and retries. If the attempts exhaust before the new build
lands, a charged customer has no order row at all. Deploy the code, confirm it is serving, then
migrate — never the reverse.
(packages/db/src/schema/order/order-item.ts:99,116-127)
5.3 Relationship Diagram
6. Services and Responsibilities
6.1 The chain — where the order comes from
checkout completion transaction
└── payment attempt marked succeeded
└── order.eligible outbox row ← written INSIDE the transaction
└── CREATE_ORDER job
└── order row (one per checkout, uq_order_checkout_session_id)The outbox row is written inside the checkout-completion transaction, after finalizeHolds and only on the path that actually completed. A row enqueued after the commit can be lost in the gap — and here that gap is a customer who has been charged, whose stock is deducted and whose cart is converted, with no commercial record of any of it and no error anywhere. This is the one cross-module edit (one statement in CheckoutPaymentService.complete), deliberate and reviewed.
6.2 Money — allocateProportionally and valueOfUnits
Per-line discount allocation is computed once at creation with the largest-remainder method and frozen:
allocateProportionally(t, w)— splits a total across lines by weight;sum === tfor every valid input.valueOfUnits(netAmount, quantity, alreadyValued, units)— the per-unit value of a line, offset-aware: successive partial refunds of a line sum to exactly itsnet_amount, never a paisa more or less, whatever order or grouping they arrive in. Four parameters — the offset (alreadyValued) is the whole point.
6.3 Refund lifecycle (human-settled)
Create (lines + quantities — the server computes the money, not the caller) → approve (choose the method: esewa_reversal / bank_transfer / cash) → settle (record a settlement reference — required by constraint; 400 ORDER_REFUND_SETTLEMENT_REFERENCE_REQUIRED without it). Reject is legal from pending only — an approved refund can never be rejected. No gateway refund is integrated; say so directly rather than implying automation. ORDER_REFUND_EXCEEDS_PAID is reachable with no operator fault — two people approving at once — because the ceilings are enforced at write time.
6.4 Return lifecycle
Request (delivered + within window) → decide → receive → inspect (quantities + rejection reason). The budget CHECK refuses an over-return. ORDER_RETURN_QUANTITY_UNAVAILABLE is reachable by a double-tap — not alarming, refresh.
The request itself now notifies. OrderReturnService.request writes an
order.email.order_return_requested outbox row inside its own transaction. It does two jobs from
one row: the customer receives an acknowledgement, and the dispatcher's relay turns the same row
into a live admin notification, because the realtime feed is a side-effect of outbox dispatch —
an event with no job to dispatch cannot reach the stream at all.
The acknowledgement deliberately promises nothing. request() takes the return budget and
decides nothing; copy that read as approval would have a customer post goods back and then be
refused. It carries no refund figure either, because what comes back depends on inspection outcomes
that do not exist yet.
The dedupe key carries the return's public id, not only the order's. An order legitimately has
several returns (uq_order_return_order_sequence), and OutboxService.enqueue is
onConflictDoNothing — a colliding key produces no row, no error and no log, so the second
request's acknowledgement would simply never exist.
Inspection recomputes the order status. Once returns are accepted, deriveOrderStatus reports
returned (every fulfillable unit came back) or partially_returned (some did), checked before
the delivery ladder so the ladder cannot mask them. ORDER_RETURN_ELIGIBLE_STATUSES includes
partially_returned, so an order with units left can be returned against again, and
ORDER_COMPLETABLE_STATUSES lets the completion sweep close both.
6.5 Dispatch and serials
Order owns dispatch because there is no Fulfillment module; the rows are shaped so one can take it over later. Serials are entered at dispatch, unique per (product_id, serial_number); ORDER_SERIAL_ALREADY_RECORDED on a duplicate. ORDER_SERIAL_COUNT_MISMATCH if serials exceed units.
6.6 Cash collection lifecycle — OrderLifecycleService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
recordCodCollection(orderPublicId, adminId, amount?) | OrderCodAdminService.record | order (row lock) | codCollectedAt/codCollectedAmount/codCollectedByAdminId, version | order.cod_cash_collected event | ORDER_NOT_FOUND, ORDER_COD_COLLECTION_NOT_APPLICABLE |
correctCodCollection(orderPublicId, adminId, { amount, expectedCurrentAmount, reason }) | OrderCodAdminService.correct | order (row lock) | codCollectedAmount, version — not codCollectedAt or codCollectedByAdminId | order.cod_cash_collection_corrected event, with reason | ORDER_NOT_FOUND, ORDER_COD_COLLECTION_NOT_RECORDED, ORDER_COD_COLLECTION_STALE, ORDER_COD_COLLECTION_EXCEEDS_TOTAL |
reverseCodCollection(orderPublicId, adminId, reason) | OrderCodAdminService.reverse | order (row lock) | all three cash columns → NULL, version | order.cod_cash_collection_reversed event, with reason | ORDER_NOT_FOUND, ORDER_COD_COLLECTION_NOT_RECORDED |
All three run inside one transaction under access.lockOrder's row lock and write their state change
and audit event together.
Recording is single-fire by construction. The UPDATE carries codCollectedAt IS NULL in its
WHERE clause, not only a service-level check — a replayed request matches no row and is reported as
ORDER_COD_COLLECTION_NOT_APPLICABLE rather than silently overwriting the figure and the operator who
recorded it. effectiveAmount defaults to order.grandTotal, resolved after the row lock so it
reads the same row the ceiling check reads. A defaulted 0 (a fully-discounted pickup order can
legitimately have grandTotal = 0) is refused before it ever reaches the constraint, because the DTO
never saw a value to validate.
Correcting is the inverse guard, not a second recording path. Its UPDATE requires
codCollectedAt IS NOT NULL, and it leaves codCollectedAt and codCollectedByAdminId untouched —
so a correction does not re-enter idx_order_cod_outstanding (the finance team's morning queue) for
cash that has, in fact, already been counted, and does not change who is recorded as having counted
it. expectedCurrentAmount is compared against the current row under the same lock that makes the
audit event's previousAmount accurate — read-then-write on a page rendered before someone else's
correction lands is exactly what ORDER_COD_COLLECTION_STALE exists to catch; the pay path's
expectedGrandTotal guards the identical race for the identical reason.
Reversal moves all three columns in one statement, because the CHECK is evaluated at the end of
the statement and cannot be deferred — clearing the amount first and the timestamp second would be
rejected outright. It exists because, without it, cash recorded against the wrong order number was
permanently invisible to the outstanding-cash queue: the record path refuses a second recording, a
correction never clears the timestamp, and the constraint forbids recording zero. There was no
application path back to the money. previousAmount and previousCollectedByAdminId are carried onto
the event before the columns are nulled, since the row itself no longer names either afterward.
7. Runtime Flows
7.1 Order creation
7.2 Cancel with restock
8. Cache
Order caches nothing. The status endpoint reads recorded state only — repeated calls are cheap and never trigger work.
9. Jobs and Workers
| Queue | Jobs | Notes |
|---|---|---|
ORDER | CREATE_ORDER (+ email jobs) | Consumes the order.eligible outbox row |
The sales receipt, and why its number says RCP
GET /api/mobile/orders/:id/invoice.pdf renders a sales receipt, not an invoice. This platform
is not VAT- or PAN-registered, the PDF carries a boxed disclaimer saying so, and the reference number
is prefixed RCP- to match. It read INV- until migration 0045, which contradicted that disclaimer
in the one field a customer quotes back to support.
The column is still invoice_number and the counter is still invoice_number_seq — renaming either
would touch every consumer for no user-visible gain, and the sequence numbers the counter rather than
the prefix, so numbering continues unbroken across the rename. OrderAdminService normalises a
legacy INV- lookup to the stored form, because a receipt printed before the rename keeps its old
number forever.
Nothing is stored. There is no order_invoice table and no file written to storage; every
request re-renders from the order's own frozen snapshot. That is what makes a template change
retroactive — an order placed last month gets the corrected document on its next download — and it is
also why the receipt can never disagree with the order it describes.
The logo is a compile-time constant, and that is a security property. jspdf is pinned at 2.5.2
by jspdf-invoice-template-nodejs and carries a critical path-traversal advisory patched only in
>=4.0.0. It was assessed unreachable because the renderer called no jsPDF API that resolves a path
or a URL — and drawing a logo means addImage, which is exactly that surface. The wordmark is
therefore inlined as base64 rather than read from disk or fetched from EMAIL_BRAND_LOGO_URL, so
nothing from a request, an order or a config value can reach it. pnpm rules:pdf-image enforces
this; making an image source dynamic requires bumping jspdf first.
Email types, all scheduled through OrderMailQueueService and therefore all written inside the
transaction that caused them: order_confirmed, order_partially_unavailable, order_shipped,
order_ready_for_pickup, order_delivered, order_cancelled, order_return_requested,
order_return_decided, order_refund_settled.
Anything that can happen more than once per order — a shipment, a return, a refund — passes its own
relatedPublicId into the dedupe key. Only genuinely once-per-order events omit it.
Emails: RESEND_API_KEY remains optional — without it every email renders and queues exactly as in production and is logged instead of delivered, so the flow is fully exercisable before a key exists.
A line item names its variant. OrderNotificationService selects order_items.variant_name
alongside the product name and passes both to buildLineMeta from common/email/email-components.ts,
which produces the "256GB · Qty 1" meta line that EmailLineItem had specified all along. Without
it an order holding two configurations of one phone printed the same name twice at two different
prices, and neither the customer nor support could tell from the email which one shipped.
The value is read from the frozen column on order_items, not joined from the live variant: the
variant may since have been renamed or withdrawn, and a confirmation must say what was bought. When
a product has no variants the field is null and the meta line is just "Qty 1" — the formatter
handles that rather than each caller.
buildLineMeta is shared with the POS receipt, which had made the same omission independently. Two
surfaces wanting identical behaviour is the case for one function; a third copy is how they drift.
10. Security and Authorization
- Customer routes:
JwtAuthGuard; ownership-scoped (ORDER_NOT_FOUNDfor another customer's order — no existence oracle). - Admin:
JwtAuthGuard+RoleGuardwith three separate permission modules —Orders_*,Returns_*,Refunds_*— so handing money back is never a side effect of dispatch rights. Runpermissions:syncon deploy; an admin without them sees 403 on every route. - Cash collection sits under
Orders_UPDATE(same module as dispatch/confirm/cancel — it is a fulfilment fact, not money returned) and all three routes carryIpThrottlerGuard+ADMIN_WRITE(10/min).OrderCodAdminModulemust be on the admin Swagger allowlist inconfig/swagger/default.swagger.ts— the allowlist does not recurse into aggregates, so a leaf missing from it is live and invisible in the admin docs. - The receipt is generated on demand and never stale.
ORDER_SERIAL_ALREADY_RECORDEDexists so a duplicate serial is a deliberate operator check, not a silent overwrite.
11. Operational notes
- Deploy ordering: backend first, then
permissions:sync+ grants, then consumers. Orders are created for completed checkouts whether or not any client reads them — which is the correct behaviour, because the alternative is a paid customer with no record. RESEND_API_KEYoptional (§9).- The invoice PDF carries, verbatim and prominently: "This is a sales receipt for your records. It is NOT an official taxable invoice — no VAT or PAN registration is claimed." Never label it "Tax Invoice".
- Courier tracking: the API returns courier name and tracking number as strings; it does not know any courier's URL format.