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}/*.controller.ts | Routes, permissions |
| Services | customer + admin services, return/refund services, workers | Transitions, money, restock |
| 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 28 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 11 order 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/
admin/
order/order-admin.controller.ts + service
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 |
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, 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".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.
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. - 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.