Happy House - Ecommerce Docs
Developer ResourcesOrder

Order Backend Documentation

Backend architecture, data model, services, and operational behavior for the Order module.

Order - Backend Documentation

1. Documentation Evidence

AreaFiles InspectedVerified Details
Module wiringapps/api/src/modules/order/ module filesCustomer/admin/worker leaves, email
Controllerscustomer/order-customer.controller.ts, admin/{order,return,refund}/*.controller.tsRoutes, permissions
Servicescustomer + admin services, return/refund services, workersTransitions, money, restock
Schemapackages/db/src/schema/order/*.ts12 tables, 9 statuses, CHECKs
Moneyapps/api/src/utils/money/money.util.tsallocateProportionally, valueOfUnits
Cross-moduleapps/api/src/modules/checkout/shared/checkout-payment.service.tsThe order.eligible outbox row
Error registryapps/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.eligible outbox 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

ConcernSource of TruthNotes
Commercial recordOrder + item rows (snapshots from the frozen checkout)
StatusRecomputed from line quantitiesNine values, none returned
Returns/refundsSeparate aggregates with own statuses
MoneyMinor units; frozen at creationBoth refund ceilings are CHECKs

3. Module Composition

ModuleTypePathControllersProvidersExportsResponsibility
OrderModuleAggregateorder.module.tsNoneLeavesComposes customer/admin/worker
OrderCustomerModuleLeafcustomer/OrderCustomerControllercustomer serviceThe 8 customer routes
OrderAdminModuleLeafadmin/order/OrderAdminControlleradmin serviceThe 11 order routes
OrderReturnAdminModuleLeafadmin/return/OrderReturnAdminControllerreturn serviceThe 5 return routes
OrderRefundAdminModuleLeafadmin/refund/create + admin controllersrefund servicesThe 5 refund routes
OrderWorkerModuleLeaforder-worker.module.tsNoneprocessorsCREATE_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.sql

Key files:

FilePurposeKey ExportsNotes
workers/order-create.processor.tsBuild the orderprocessorConsumes order.eligible
admin/refund/ servicesRefund lifecyclecreate/approve/reject/settleSettlement reference required
money.util.tsAllocationallocateProportionally, valueOfUnitsLargest-remainder, frozen

5. Data Model

5.1 Schema Source

packages/db/src/schema/order/   (12 tables + enums)

5.2 Tables

TablePurpose
orderThe commercial record — checkout snapshot, money, status, codCollectedAt, refundedAmount
order_itemLines 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_eventOne 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_noteOperator notes
order_promotionFrozen promotion snapshot
order_shipmentA parcel; courier + tracking strings
order_shipment_itemPer-parcel quantities
order_item_serialDevice serial numbers — unique per (product_id, serial_number), entered at dispatch
order_return / order_return_itemThe return aggregate and its lines
order_refund / order_refund_itemThe refund aggregate and its lines

Key invariants:

  • chk_order_refunded_within_paid (order) and chk_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-001 is meaningful only within one product line; global uniqueness would force globally unique serials nobody has.
  • order.refunded_amount is 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.
  • codCollectedAt is the one field distinguishing "committed" from "collected".
  • fk_order_item_variant_identity (variant_id, variant_public_id) against product_variant (id, public_id) (migration 0029) ties the integer and public variant ids so they cannot disagree — the refund path matches lost holds, which are keyed by variant_id, against variant_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 === t for 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 its net_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

QueueJobsNotes
ORDERCREATE_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_FOUND for another customer's order — no existence oracle).
  • Admin: JwtAuthGuard + RoleGuard with three separate permission modules — Orders_*, Returns_*, Refunds_* — so handing money back is never a side effect of dispatch rights. Run permissions:sync on deploy; an admin without them sees 403 on every route.
  • The receipt is generated on demand and never stale.
  • ORDER_SERIAL_ALREADY_RECORDED exists 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_KEY optional (§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.