Order Module — Storefront
HTTP surface for the customer-side order lifecycle — payment provider discovery, place-order, list/detail, customer-initiated cancel, and the customer-side return flow…
HTTP surface for the customer-side order lifecycle — payment provider discovery, place-order, list/detail, customer-initiated cancel, and the customer-side return flow (eligibility, photo upload, request, list, detail, cancel).
Source:
api-modules/order/src/controllers/store-orders.controller.ts,store-returns.controller.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. Webhooks (payment, courier) live in their own provider modules.
Conventions
Authentication
| Endpoint group | Auth |
|---|---|
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) |
/store/orders/:id/returns/**, /store/returns/photos | required (customer) |
Customer order detail and return detail enforce a no-leak rule: ids that belong to a different customer return 404 Not Found, never 403.
Headers
POST /store/checkout/place-order requires:
| 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
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR, PAYMENT_PROVIDER_NOT_ENABLED, PAYMENT_METHOD_INVALID |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN (payment provider not enabled, or cart not yours) |
| 404 | NOT_FOUND |
| 409 | CONFLICT, INVALID_TRANSITION, PARENT_NOT_CANCELLABLE |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Currency
All money fields (subtotal, discountTotal, shippingTotal, taxTotal, grandTotal, unitPrice, lineSubtotal, lineTotal, discountAllocated, netAmount, refundAmount, tax components) are integer subunits (paise / cents / eurocents).
Lifecycle (customer-visible)
Parent — order.status
| Status | Notes |
|---|---|
pending_payment | SDK-driven providers (Razorpay etc.); inventory reserved but not committed |
confirmed | Synchronous payment (COD/manual) at place-order, or payment webhook |
cancelled | Customer/admin cancel, or all sub-orders cancelled. Terminal. |
Parent payment — order.payment_status
| Status | Notes |
|---|---|
pending | Initial value |
paid | Provider success, webhook, admin mark-paid, or last COD sub-order delivered |
failed | Webhook failure — order stays pending_payment for retry |
refunded | Admin bookkeeping flag |
Sub-order — order_vendor.fulfillment_status
pending → fulfilled → delivered, with cancelled reachable from pending or fulfilled. Customers don't drive these — they only observe them via vendorBreakdowns[].fulfillmentStatus.
Domain types
OrderResponse
type OrderStatus = "pending_payment" | "confirmed" | "cancelled";
type PaymentStatus = "pending" | "paid" | "failed" | "refunded";
type Platform = "APP" | "WEB" | "BOTH";
type OrderLineType = "PRODUCT" | "GIFT";
type AddressBlock = {
firstName: string;
lastName: string;
fullAddress: string;
city: string;
pincode: string;
state: string;
phone: string;
country: string;
};
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[]; // tail of audit-log rows (most recent first)
/** Bootstrap data the storefront/SDK uses to complete an SDK-driven
* payment flow (Razorpay, Stripe, etc.). Absent for synchronous
* providers like manual COD. */
pendingClientAction: {
provider: string;
payload: Record<string, unknown>;
} | null;
placedAt: string; // ISO
confirmedAt: string | null;
paidAt: string | null;
cancelledAt: string | null;
cancellationReason: string | null;
};OrderVendorResponse
type OrderVendorResponse = {
id: string;
vendorId: string;
vendorNameAtOrder: string; // snapshot
fulfillmentStatus: "pending" | "fulfilled" | "delivered" | "cancelled";
subtotal: number;
discountAllocated: number;
shippingCost: number;
taxAmount: number;
total: number;
shippingProviderId: string | null;
shippingMethod: string | null;
trackingCode: string | null;
awbNumber: string | null;
taxBreakdown: TaxComponent[]; // aggregated by tax type across this vendor's lines + shipping
shippingNetAmount: number | null;
shippingTaxBreakdown: TaxComponent[];
fulfilledAt: string | null;
deliveredAt: string | null;
cancelledAt: string | null;
cancellationReason: string | null;
lines: OrderLineResponse[];
};OrderLineResponse
type OrderLineResponse = {
id: string;
vendorId: string;
variantId: string | null;
productId: string | null;
sku: string;
productNameAtOrder: string; // snapshot
variantNameAtOrder: string | null;
imageAtOrder: string | null;
hsnCodeAtOrder: string | null; // GST classification snapshot from variant
type: OrderLineType;
quantity: number;
unitPrice: number;
lineSubtotal: number; // tax-inclusive amount displayed
discountAllocated: number;
lineTotal: number;
netAmount: number | null; // pre-tax portion
taxBreakdown: TaxComponent[];
};OrderEventResponse
type OrderEventResponse = {
id: string;
orderVendorId: string | null;
eventType: string;
actorType: "user" | "vendor" | "admin" | "system" | "webhook";
actorId: string | null;
source: string;
changes: Record<string, unknown>;
metadata: Record<string, unknown>;
createdAt: string;
};ReturnResponse
type ReturnResponse = {
id: string;
returnNumber: string;
orderId: string;
orderVendorId: string;
customerId: string | null;
vendorId: string;
type: string;
status: string; // e.g. requested / approved / picked_up / received / qc_passed / qc_failed / refunded / rejected / cancelled
reasonCode: string | null;
reasonNotes: string | null;
refundAmount: number; // subunits
refundedAmount: number;
externalRefundReference: string | null;
shippingProvider: string | null;
awbNumber: string | null;
trackingCode: string | null;
rejectionReason: string | null;
qcFailureReason: string | null;
requestedAt: string;
approvedAt: string | null;
rejectedAt: string | null;
pickedUpAt: string | null;
receivedAt: string | null;
qcPassedAt: string | null;
qcFailedAt: string | null;
refundedAt: string | null;
cancelledAt: string | null;
lines: ReturnLineResponse[];
photos: ReturnPhotoResponse[];
};
type ReturnLineResponse = {
id: string;
orderLineId: string;
variantId: string | null;
quantity: number;
unitPrice: number;
taxPortion: number;
lineRefundAmount: number;
reasonCode: string | null;
reasonNotes: string | null;
restocked: boolean;
};Checkout
GET /store/checkout/payment-providers — Enabled payment providers
Returns the providers/methods enabled by admin for the caller's platform. Use to render the payment selector. Defaults to WEB when x-platform is missing.
Headers
| Header | Notes |
|---|---|
x-platform | WEB or APP |
Response 200 — provider list (shape per EnabledPaymentProvidersService.list).
POST /store/checkout/place-order — Convert active cart into an order
Resolves the cart via x-cart-token (and the session's customerId), validates the chosen provider+method against the platform's enabled list, then drives OrderService.createFromCart. For SDK-driven providers (Razorpay), the response carries pendingClientAction with bootstrap data the storefront SDK uses to complete payment. For synchronous providers (COD, manual) the order is confirmed and paymentStatus may already be paid on return.
Headers
| Header | Required | Notes |
|---|---|---|
x-cart-token | yes | Cart handle |
x-platform | no | WEB / APP, default WEB |
Body
{
"paymentProvider": "razorpay", // trimmed, min 1
"paymentMethod": "upi", // trimmed, min 1
"billingAddress": { // optional — when omitted, billing copies shipping
"firstName": "Ada",
"lastName": "Lovelace",
"fullAddress": "221B Baker Street",
"city": "London",
"pincode": "110001",
"state": "Delhi",
"phone": "+919876543210",
"country": "IN"
}
}billingAddress field constraints reuse the address-book validators (Indian pincode regex /^[1-9]\d{5}$/, phone /^(\+91)?[6-9]\d{9}$/).
Response 201 — OrderResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | x-cart-token header missing |
| 400 | PAYMENT_PROVIDER_NOT_ENABLED / PAYMENT_METHOD_INVALID | Provider/method rejected by registry |
| 403 | FORBIDDEN | Cart belongs to another customer |
| 404 | NOT_FOUND | Cart cannot be resolved |
| 409 | CART_EMPTY, INSUFFICIENT_INVENTORY | Cart no longer placeable |
Side effects — emits order.placed; for synchronous-paid providers also order.paid. The cart transitions to converted.
Orders
GET /store/orders — Paginated list of my orders
Most recent first.
Query
| Name | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | (module default) | 1..MAX_ORDER_PAGE_SIZE |
status | OrderStatus? | — | Filter by pending_payment / confirmed / cancelled |
startDateTime | ISO-8601? | — | Inclusive lower bound on order placed-at |
endDateTime | ISO-8601? | — | Inclusive upper bound on order placed-at; must be >= startDateTime |
Response 200 — paginated OrderResponse[].
GET /store/orders/:id — Order detail
Response 200 — full OrderResponse with embedded vendorBreakdowns and recent events.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Order does not exist or belongs to another customer |
POST /store/orders/:id/cancel — Cancel my order
Allowed only when no sub-order has yet been fulfilled or delivered. The cancel cascades to all sub-orders, releases their reservations, and emits order.cancelled.
Body
{ "reason": "Changed my mind" } // optional, 1..500 charsResponse 200 — cancelled OrderResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Order not yours / does not exist |
| 409 | PARENT_NOT_CANCELLABLE | At least one sub-order is past pending |
Returns
Base path: /store/orders/:id/returns (with the photo-upload helper at /store/returns/photos).
GET /store/orders/:id/returns/eligibility — Per-sub-order eligibility
For each sub-order of the given order, returns whether it's currently returnable, the window expiry, the eligible reason codes, and the vendor's return policy text. Use to gate the "Request return" CTA.
Response 200
{
"data": {
"vendors": [
{
"orderVendorId": "01J9...",
"vendorId": "01J9...",
"returnable": true,
"reason": null,
"windowExpiresAt": "2026-05-20T00:00:00.000Z",
"eligibleReasons": ["DAMAGED", "WRONG_ITEM", "NOT_AS_DESCRIBED"],
"policyText": "Returns within 7 days of delivery..."
}
]
}
}When returnable is false, reason carries a stable code (e.g. WINDOW_EXPIRED, ALREADY_RETURNED, NOT_DELIVERED) and windowExpiresAt may still be populated.
POST /store/returns/photos — Presigned upload URL for return evidence
Returns a presigned PUT URL the client uses to upload evidence (one photo per call). Unused keys age out via S3 lifecycle. Pass the returned storageKey in photoKeys[] of the create-return body.
Body
{
"contentType": "image/jpeg",
"fileSizeBytes": 524288 // max 20 MiB (20 * 1024 * 1024)
}Response 200
{
"data": {
"storageKey": "returns/2026-05/abc.jpg",
"uploadUrl": "https://s3.../signed-put-url",
"expiresAt": "2026-05-13T11:45:00.000Z"
}
}POST /store/orders/:id/returns — Create a return request
Open a return against one sub-order. Service enforces:
- caller owns the order;
orderVendorIdbelongs to that order;- per-line
quantity <= delivered quantity; - return is within the vendor's window and reason is in
eligibleReasons.
Body
{
"orderVendorId": "01J9...", // sub-order being returned
"reasonCode": "DAMAGED", // 1..64 chars
"reasonNotes": "Box arrived crushed", // optional, max 2000 chars
"lines": [
{
"orderLineId": "01J9...",
"quantity": 1, // integer >= 1
"reasonCode": "DAMAGED", // optional per-line override
"reasonNotes": "Top half dented" // optional, max 2000 chars
}
],
"photoKeys": ["returns/2026-05/abc.jpg"] // optional, max 20 keys, from POST /store/returns/photos
}lines must be 1..100 entries. photoKeys must be 0..20 keys, each 1..500 chars.
Response 201 — ReturnResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod |
| 404 | NOT_FOUND | Order or sub-order not visible to caller |
| 409 | CONFLICT | Sub-order not currently returnable, or per-line quantity exceeds delivered |
GET /store/orders/:id/returns — List returns on an order
Query
| Name | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | (module default) | 1..MAX_ORDER_PAGE_SIZE |
status | string? | — | 1..32 chars; filter by return lifecycle status |
Response 200 — paginated ReturnResponse[].
GET /store/orders/:id/returns/:returnId — Return detail
Response 200 — ReturnResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Return not yours / does not exist |
POST /store/orders/:id/returns/:returnId/cancel — Withdraw a return
Allowed only before the courier confirms pickup (i.e. while the return is still in the customer's hands). Once pickedUpAt is stamped the customer can't withdraw — vendor/admin paths handle reversals after that.
Response 200 — cancelled ReturnResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Return not yours / does not exist |
| 409 | CONFLICT | Already past requested/approved (courier picked up) |
Related modules
cart—prepare-checkoutruns before place-order to reserve inventory. Seecart.md.payment-razorpay/payment-manual— concrete providers behindpaymentProvider/paymentMethod. Seepayment-razorpay.mdfor the storefront verify endpoint.shipping— vendor-side shipping provider assignment; customer-side tracking is inshipping.md.storage— backs the presigned upload for return photos.customer—billingAddressshape mirrors the address-book validators. Seecustomer.md.
Notifications Module — Storefront
HTTP surface for the customer mobile app to register and unregister its FCM device token for push notifications. Customer devices are tagged appKind: "customer" (distinct from the…
Payment Razorpay Module — Storefront
HTTP surface for the customer-side payment-verify callback used by the Razorpay Checkout SDK after the user completes payment. Verifies the HMAC signature, pins to the place-time…