Order Module
HTTP surface for the order lifecycle — storefront place-order/list/detail/cancel, vendor sub-order fulfillment and delivery, and admin oversight (cancel, mark-paid, mark-refunded).
HTTP surface for the order lifecycle — storefront place-order/list/detail/cancel, vendor sub-order fulfillment and delivery, and admin oversight (cancel, mark-paid, mark-refunded).
Source:
api-modules/order(registered viaOrderModule.forRoot()inapps/api/src/app.module.ts).The module orchestrates the cart → order handoff: it consumes
CartService(cart resolution + checkout commit),PaymentRegistry(provider/method dispatch),InventoryService(reservation lifecycle), and the shipping registry (provider assignment at the pending → fulfilled transition). Webhooks (payment, courier) live in their own provider modules and call back intoOrderService—OrderModuleis registered asglobal: trueso those modules can injectOrderServicewithout re-importing.
Conventions
Authentication
| Endpoint group | Auth | Permission |
|---|---|---|
GET /store/checkout/payment-providers | required (customer) | — |
POST /store/checkout/place-order | required (customer) | — |
GET /store/orders, GET /store/orders/:id, POST /store/orders/:id/cancel | required (customer) | — |
GET /vendor/orders, GET /vendor/orders/:id | required (vendor session) | active vendor in session |
POST /vendor/orders/:id/cancel, POST /vendor/orders/:id/fulfilled, POST /vendor/orders/:id/delivered, POST /vendor/orders/bulk-fulfill | required (vendor session) | active vendor in session |
GET /admin/orders, GET /admin/orders/:id | required | order: view |
POST /admin/orders/:id/cancel | required | order: cancel |
POST /admin/orders/:id/mark-paid, POST /admin/orders/:id/mark-refunded | required | order: update |
Vendor endpoints resolve the active vendor via resolveActiveVendorId(session) — sessions missing an active vendor are rejected with 403 Forbidden (FORBIDDEN). Vendor sub-order ids that belong to a different vendor return 404 Not Found, not 403 (no leak of foreign ids). Customer order detail follows the same no-leak rule.
Headers
POST /store/checkout/place-order requires both:
| Header | Required | Notes |
|---|---|---|
x-cart-token | yes | Cart handle issued by the cart endpoints. Identifies the active cart even for a logged-in customer (handles the guest→customer adoption window) |
x-platform | no | WEB or APP (case-insensitive). Defaults to WEB. Used to pick a platform-specific enabled payment provider list |
GET /store/checkout/payment-providers accepts x-platform only.
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Error envelope
Failures are normalized by HttpExceptionFilter:
{
"data": null,
"message": "Human-readable summary",
"statusCode": 400,
"errorCode": "BAD_REQUEST",
"errors": [ /* zod issues, when validation fails */ ],
"debug": { /* dev-only: error + stack */ }
}statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR, PAYMENT_PROVIDER_NOT_ENABLED, PAYMENT_METHOD_INVALID |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 409 | CONFLICT, INVALID_TRANSITION, PARENT_NOT_CANCELLABLE, SUB_ORDER_NOT_CANCELLABLE, ORDER_ALREADY_PAID, ORDER_ALREADY_REFUNDED |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Currency
All monetary fields (subtotal, discountTotal, shippingTotal, taxTotal, grandTotal, unitPrice, lineSubtotal, lineTotal, discountAllocated, netAmount, vendor breakdowns, tax components) are integer subunits (paise / cents / eurocents — single currency per tenant). Clients should send and receive integers; never decimal currency strings.
Lifecycle
Parent order — order.status
| Status | Set by | Editable | Notes |
|---|---|---|---|
pending_payment | OrderService.createFromCart for SDK-driven providers (Razorpay etc.) | — | inventory reserved but not committed; awaiting client-side action / webhook confirmation |
confirmed | Synchronous COD/manual at place-order, or payment webhook / mark-paid | — | inventory committed; sub-orders are now actionable by vendors |
cancelled | cancelByCustomer, cancelByAdmin, or all sub-orders cancelled | no | terminal |
Parent payment — order.payment_status
| Status | Set by | Notes |
|---|---|---|
pending | created with the order | initial value for all providers |
paid | synchronous provider success, payment webhook, mark-paid, or last COD sub-order delivered | stamps paid_at |
failed | payment webhook | parent stays pending_payment for retry until reservation TTL expires (or it auto-cancels) |
refunded | admin mark-refunded | gateway-side refund is out-of-band; this flag is bookkeeping only |
Sub-order — order_vendor.fulfillment_status
Each vendor on the order has its own row with its own lifecycle.
| Status | Set by | Editable | Notes |
|---|---|---|---|
pending | created at place-order | yes | awaiting vendor to assign a shipping provider |
fulfilled | POST /vendor/orders/:id/fulfilled (or bulk-fulfill) | partial | provider/method/tracking persisted; stamps fulfilled_at. cancel still allowed (courier rejected, RTO) |
delivered | POST /vendor/orders/:id/delivered (or courier webhook) | no | stamps delivered_at. For COD, the last delivered sub-order auto-flips parent payment_status → paid |
cancelled | POST /vendor/orders/:id/cancel, parent cancel cascade | no | releases this vendor's reservation; if all siblings cancelled, parent auto-cancels |
Allowed transitions are enforced server-side — invalid moves (e.g. cancelling a delivered sub-order, fulfilling a cancelled one) return 409 INVALID_TRANSITION.
Audit log
Every status mutation writes one or more rows to order_events (one per affected order/sub-order, in the same transaction as the change). The row carries actor_type (user / vendor / admin / system / webhook), actor_id, source, structured changes, and optional metadata. The most recent 50 rows ship inline on order detail (events[]); older rows stay in the table.
Domain events
Emitted via EventEmitter2 after a successful write — consumed by the notifications module (email + push) and the search reindexer.
| Event | Fired when | Payload (high-level) |
|---|---|---|
order.placed | OrderService.createFromCart commits | order id, customer id, vendor ids, totals, platform |
order.paid | parent payment_status → paid | order id, payment provider, external ref |
order.refunded | parent payment_status → refunded | order id, actor, reason |
order.cancelled | parent status → cancelled (any actor) | order id, actor, reason |
order.vendor.fulfilled | sub-order pending → fulfilled | order vendor id, provider id, method, tracking |
order.vendor.delivered | sub-order fulfilled → delivered | order vendor id, delivered at |
order.vendor.cancelled | sub-order * → cancelled | order vendor id, actor, reason |
Domain types
OrderResponse
Customer + admin response. Embeds vendor sub-orders, their lines, and recent events so the detail page is one round trip.
type OrderStatus = "pending_payment" | "confirmed" | "cancelled";
type PaymentStatus = "pending" | "paid" | "failed" | "refunded";
type Platform = "APP" | "WEB" | "BOTH";
type OrderResponse = {
id: string;
orderNumber: string;
status: OrderStatus;
paymentStatus: PaymentStatus;
paymentProvider: string; // e.g. "manual", "razorpay"
paymentMethod: string; // e.g. "cod", "upi", "card"
platform: Platform;
shippingAddress: AddressBlock;
billingAddress: AddressBlock;
subtotal: number; // subunits
discountTotal: number;
shippingTotal: number;
taxTotal: number;
grandTotal: number;
vendorBreakdowns: OrderVendorResponse[];
events: OrderEventResponse[]; // most recent 50
pendingClientAction: {
provider: string; // e.g. "razorpay"
payload: Record<string, unknown>; // SDK bootstrap data
} | null;
placedAt: string; // ISO
confirmedAt: string | null;
paidAt: string | null;
cancelledAt: string | null;
cancellationReason: string | null;
};
type AddressBlock = {
firstName: string; lastName: string;
fullAddress: string; city: string; pincode: string;
state: string; phone: string; country: string;
};OrderVendorResponse
type OrderVendorStatus = "pending" | "fulfilled" | "delivered" | "cancelled";
type OrderVendorResponse = {
id: string;
vendorId: string;
vendorNameAtOrder: string; // snapshot
fulfillmentStatus: OrderVendorStatus;
subtotal: number;
discountAllocated: number;
shippingCost: number;
taxAmount: number;
total: number;
shippingProviderId: string | null; // set at pending → fulfilled
shippingMethod: string | null;
trackingCode: string | null;
awbNumber: string | null;
// Tax breakdown aggregated across this vendor's lines + their shipping.
// Empty when the vendor has no taxes configured.
taxBreakdown: TaxComponent[];
shippingNetAmount: number | null;
shippingTaxBreakdown: TaxComponent[];
fulfilledAt: string | null;
deliveredAt: string | null;
cancelledAt: string | null;
cancellationReason: string | null;
lines: OrderLineResponse[];
};OrderLineResponse
type OrderLineType = "PRODUCT" | "GIFT";
type OrderLineResponse = {
id: string;
vendorId: string;
variantId: string | null;
productId: string | null;
sku: string;
productNameAtOrder: string; // snapshot
variantNameAtOrder: string | null; // snapshot
imageAtOrder: string | null; // snapshot
hsnCodeAtOrder: string | null; // India GST classification, snapshot from variant
type: OrderLineType;
quantity: number;
unitPrice: number;
lineSubtotal: number; // tax-inclusive
discountAllocated: number;
lineTotal: number;
netAmount: number | null; // pre-tax portion; null when no taxes
taxBreakdown: TaxComponent[];
};OrderEventResponse
type OrderEventActor = "user" | "vendor" | "admin" | "system" | "webhook";
type OrderEventResponse = {
id: string;
orderVendorId: string | null; // null = parent-order event
eventType: string; // e.g. "order.placed", "vendor.fulfilled"
actorType: OrderEventActor;
actorId: string | null;
source: string; // e.g. "storefront", "vendor-panel", "razorpay-webhook"
changes: Record<string, unknown>; // before → after fields
metadata: Record<string, unknown>;
createdAt: string;
};Storefront
Base prefix: /store. All endpoints require a Better-Auth session.
GET /store/checkout/payment-providers — List enabled providers
Returns the admin-enabled payment providers for the caller's platform (x-platform), each with its supported methods. The storefront uses this to render the payment selector at checkout.
Headers
| Name | Required | Notes |
|---|---|---|
x-platform | no | WEB or APP; defaults to WEB |
Response 200
{
"data": [
{
"provider": "manual",
"label": "Cash on Delivery",
"methods": [{ "id": "cod", "label": "Cash on Delivery" }]
},
{
"provider": "razorpay",
"label": "Razorpay",
"methods": [
{ "id": "upi", "label": "UPI" },
{ "id": "card", "label": "Card" },
{ "id": "netbanking", "label": "Net Banking" }
]
}
],
"message": "Success",
"statusCode": 200
}POST /store/checkout/place-order — Place an order
Converts the caller's active cart into an order. Body picks the registered payment provider; for SDK-driven providers (Razorpay etc.) the response carries pendingClientAction with the bootstrap data the client SDK needs.
Body
{
"paymentProvider": "razorpay",
"paymentMethod": "upi",
"billingAddress": { // optional — defaults to shipping snapshot
"firstName": "Ada",
"lastName": "Lovelace",
"fullAddress": "221B Baker Street",
"city": "London",
"pincode": "NW1 6XE",
"state": "Greater London",
"phone": "+44-20-7224-3688",
"country": "GB" // optional
}
}| Field | Type | Constraints |
|---|---|---|
paymentProvider | string | Required. Must be registered AND enabled for the cart's platform |
paymentMethod | string | Required. Must belong to paymentProvider's method list |
billingAddress | object? | Validated against the same schemas as the address book. When omitted the order's billing block copies the shipping block |
Response 201 — OrderResponse
For synchronous providers (manual COD, manual bank-transfer) the order returns with status="confirmed" and paymentStatus reflecting the provider behavior (COD stays pending until delivery; manual-paid flips to paid). pendingClientAction is null.
For SDK-driven providers (Razorpay) the order returns with status="pending_payment", paymentStatus="pending", and pendingClientAction populated:
{
"pendingClientAction": {
"provider": "razorpay",
"payload": {
"razorpayOrderId": "order_NWxYz...",
"razorpayKeyId": "rzp_test_...",
"amount": 125000,
"currency": "INR"
}
}
}The client SDK completes the payment, then either calls POST /store/payments/razorpay/verify (see payment-razorpay.md) or the Razorpay webhook flips the order. Inventory is reserved (not committed) for the entire pending-payment window; reservation TTL expiry auto-cancels the order if payment never lands.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod; missing x-cart-token |
| 403 | FORBIDDEN | Cart does not belong to caller |
| 403 | PAYMENT_PROVIDER_NOT_ENABLED | Provider not in the enabled list for the platform |
| 400 | PAYMENT_METHOD_INVALID | Method not registered under the chosen provider |
| 404 | NOT_FOUND | Cart not found |
| 409 | CART_EMPTY | Active cart has no product lines |
| 409 | INSUFFICIENT_INVENTORY | Reservation step couldn't hold stock for one or more lines |
Side effects
- Reserves inventory for every product line (committed at
pending_payment → confirmed). - Marks the cart
converted(no further mutations). - Writes
order.placedtoorder_eventsand emits the domain event.
GET /store/orders — List my orders
Paginated, newest first. Customer scope is taken from the session.
Query
| Name | Type | Default | Constraints |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 20 | 1..100 |
status | OrderStatus | — | optional filter |
startDateTime | ISO-8601 | — | optional; inclusive lower bound on order placed-at |
endDateTime | ISO-8601 | — | optional; inclusive upper bound on order placed-at (>= startDateTime) |
Response 200 — paginated envelope of OrderResponse.
GET /store/orders/:id — Order detail
Returns the full OrderResponse (including vendor breakdowns, lines, and recent events).
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Order does not exist or belongs to another customer (no leak) |
POST /store/orders/:id/cancel — Cancel my order
Customer-initiated cancel. Allowed only when no sub-order has been fulfilled or delivered yet. Cascades sub-orders to cancelled, releases per-vendor inventory reservations (no-op if already committed), and writes audit rows for the parent + each affected sub-order.
Body
{ "reason": "Changed my mind" }| Field | Type | Constraints |
|---|---|---|
reason | string? | 1..500 chars |
Response 200 — updated OrderResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Order not owned by caller |
| 409 | PARENT_NOT_CANCELLABLE | At least one sub-order is fulfilled or delivered |
Vendor
Base path: /vendor/orders. All endpoints require a Better-Auth session with an active vendor. :id everywhere is order_vendor.id (a sub-order). Cross-vendor ids return 404 Not Found.
GET /vendor/orders — List my sub-orders
Paginated, newest first.
Query
| Name | Type | Default | Constraints |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 20 | 1..100 |
status | OrderVendorStatus | — | optional filter |
Response 200 — paginated envelope of OrderVendorResponse.
GET /vendor/orders/:id — Sub-order detail
Returns a OrderVendorResponse for the active vendor's sub-order.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Sub-order does not belong to active vendor |
POST /vendor/orders/:id/cancel — Cancel sub-order
Allowed from pending (declining fulfillment) or fulfilled (courier rejected, package returned, etc.). delivered and cancelled reject. For fulfilled cancels a non-empty reason is required.
Body
{ "reason": "Out of stock at warehouse" }Response 200 — updated OrderVendorResponse.
Side effects
- Releases this vendor's inventory reservation (no-op when already committed at place-order).
- Fires the shipping registry's
cancelShipment()hook (best-effort courier void). - Writes an audit row + emits
order.vendor.cancelled. - If every sibling sub-order is now cancelled, the parent order auto-cancels (cascading audit +
order.cancelled).
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Sub-order not owned by vendor |
| 409 | SUB_ORDER_NOT_CANCELLABLE | Status is delivered or already cancelled |
| 400 | VALIDATION_ERROR | Cancelling a fulfilled sub-order without a reason |
POST /vendor/orders/:id/fulfilled — Mark fulfilled
Assigns a shipping provider, dispatches provider.createShipment(), and transitions pending → fulfilled. Only allowed when the sub-order is pending.
Body
{
"providerId": "clickpost",
"method": "express",
"trackingCode": "CP123456", // optional
"awbNumber": "AWB987654" // optional
}| Field | Type | Constraints |
|---|---|---|
providerId | string | Required. Must be enabled for the active vendor |
method | string | Required. Must belong to that provider |
trackingCode | string? | 1..200 chars. Some providers fill this asynchronously via webhook |
awbNumber | string? | 1..200 chars |
Response 200 — updated OrderVendorResponse with fulfillmentStatus="fulfilled", fulfilledAt stamped, provider fields populated.
Side effects
- Validates
(providerId, method)against the vendor's enabled providers. - Dispatches
provider.createShipment()— provider failure rolls back the transition (sub-order stayspending). - Writes audit row + emits
order.vendor.fulfilled.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Sub-order not owned by vendor |
| 409 | INVALID_TRANSITION | Sub-order is not pending |
| 400 | VALIDATION_ERROR | Provider/method invalid for this vendor |
POST /vendor/orders/bulk-fulfill — Bulk fulfill
Apply the same provider/method/(tracking)/(awb) across up to 200 sub-orders in one call. Each sub-order runs in its own transaction so one failure does not roll back the rest. Vendors needing per-order tracking codes should call the single-order endpoint.
Body
{
"orderVendorIds": ["01J9...", "01J9..."],
"providerId": "clickpost",
"method": "express",
"trackingCode": "CP123456", // optional, applies to every id
"awbNumber": "AWB987654" // optional, applies to every id
}| Field | Constraints |
|---|---|
orderVendorIds | 1..200 ids |
Response 200
{
"data": {
"successful": ["01J9...", "01J9..."],
"errors": [
{ "orderVendorId": "01J9...", "reason": "Sub-order not in pending status" }
]
},
"message": "Success",
"statusCode": 200
}POST /vendor/orders/:id/delivered — Mark delivered
Allowed only when the sub-order is fulfilled. Stamps deliveredAt and transitions to delivered.
Body — empty.
Response 200 — updated OrderVendorResponse.
Side effects
- Audit row +
order.vendor.deliveredevent. - COD auto-paid: when every non-cancelled sub-order on the parent is delivered, the parent's
payment_statusflips topaidandpaid_atis stamped — the customer has paid every vendor on delivery. Anorder.paidevent fires.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Sub-order not owned by vendor |
| 409 | INVALID_TRANSITION | Sub-order is not fulfilled |
Admin
Base path: /admin/orders. Requires order:* permissions (view / cancel / update).
GET /admin/orders — List all orders
Same query shape as the storefront list, but unscoped (every customer, every vendor).
Required permission: order:view.
GET /admin/orders/:id — Order detail
Same shape as GET /store/orders/:id, no customer-scope filter.
Required permission: order:view.
POST /admin/orders/:id/cancel — Cancel on behalf of customer
Same semantics as the customer cancel, but bypasses the customer-ownership check. Still blocked when any sub-order is delivered.
Required permission: order:cancel.
Body
{ "reason": "Customer requested via support" }Errors — same as customer cancel, plus 403 FORBIDDEN when caller lacks order:cancel.
POST /admin/orders/:id/mark-paid — Manually mark as paid
For bank-transfer settlements, stuck COD edge cases (vendor confirmed delivery offline), and support overrides.
Required permission: order:update.
Body
{
"externalReference": "BANK-TXN-2026-04-1234", // optional, ≤200 chars
"reason": "Bank transfer settled on 2026-04-12" // optional, ≤500 chars
}Response 200 — updated OrderResponse.
Side effects
- If the order was at
pending_payment, transitions it toconfirmedand commits the inventory reservation. - Stamps
paid_at; emitsorder.paid.
Errors
| Status | Code | When |
|---|---|---|
| 409 | ORDER_ALREADY_PAID | payment_status is already paid |
| 409 | INVALID_TRANSITION | Order is cancelled |
| 404 | NOT_FOUND | Order not found |
POST /admin/orders/:id/mark-refunded — Mark as refunded
v1 refund flow is admin-driven: the admin issues the refund out-of-band in the gateway's dashboard, then calls this to flip our payment_status to refunded. Order status (confirmed/cancelled) is unchanged — refund is a money-only operation.
Required permission: order:update.
Body
{
"externalReference": "rfnd_NX...", // optional gateway refund id
"reason": "Customer return processed"
}Response 200 — updated OrderResponse.
Side effects — emits order.refunded.
Errors
| Status | Code | When |
|---|---|---|
| 409 | ORDER_ALREADY_REFUNDED | payment_status is already refunded |
| 409 | CONFLICT | Order has not been paid yet |
| 404 | NOT_FOUND | Order not found |
Configuration
| Env var | Default | Purpose |
|---|---|---|
ORDER_REQUEST_RESERVATION_TTL_MINUTES | inherited from inventory | Window between place-order and payment confirmation; expiry auto-cancels the order |
Related modules
cart— providesCartService.resolveand the cart→order checkout commit consumed byOrderService.createFromCart. Seecart.md.payment-razorpay— webhook + verify endpoints that flip parentpayment_status. Seepayment-razorpay.md.shipping+shipping-clickpost— registry that powers vendor provider assignment and courier webhooks that flip sub-orders todelivered. Seeshipping.md.inventory— reservation lifecycle behind every order transition. Seeinventory.md.
Notifications Module
HTTP surface for the event-driven notification system — customer + vendor mobile-device registration for FCM push, admin broadcasts (one-off email or push to a defined audience),…
Payment — Razorpay Provider
Razorpay payment provider that plugs into the platform-neutral payment module. Exposes the SDK-bootstrap flow at place-order (via the PaymentProvider port), the customer-side…