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 admin mark-paid for COD cash collection |
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;
customer: { id: string; name: string; email: string } | null; // The customer account that placed the order; null for legacy/guest orders.
status: OrderStatus;
paymentStatus: PaymentStatus;
paymentProvider: string; // e.g. "manual", "razorpay", "phonepe"
paymentMethod: string; // e.g. "cod", "upi", "card"
platform: Platform;
shippingAddress: AddressBlock;
billingAddress: AddressBlock;
customerNote: string | null; // shopper's checkout instruction, snapshotted at placement
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 a client-driven
* payment flow. Absent for synchronous providers like manual COD.
* `payload` is provider- and platform-specific — see below. */
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;
productSlug: string | null; // CURRENT store slug, resolved live for `/product/{slug}` links; null if not store-visible
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;
actor: { id: string; name: string; email: string } | null; // Always null on the store surface (operator-only; staff PII not exposed to customers).
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 — Payment options for this platform
Returns the payment rows to render, for the caller's platform. Defaults to WEB when x-platform is missing.
options is render-ready: the operator's checkout-appearance settings (store.checkout_payment) merged over each gateway's own defaults, ordered as configured, with exactly one entry flagged isDefault. Clients should render these rows and send the chosen one's providerId + method to place-order — a storefront holding its own gateway labels or logos will silently fail to show a newly enabled gateway.
providers is the same enabled set before appearance is applied, kept for clients that dispatch on provider id.
Headers
| Header | Notes |
|---|---|
x-platform | WEB or APP |
Response 200
{
"data": {
"providers": [
{ "id": "manual", "methods": ["cod", "bank-transfer"] },
{ "id": "phonepe", "methods": ["phonepe"] }
],
"options": [
{
"key": "phonepe",
"providerId": "phonepe",
"method": "phonepe",
"label": "PhonePe",
"description": "UPI, cards, net banking and wallets via PhonePe.",
"logoUrl": "https://cdn.example.com/settings/phonepe.png",
"badge": "Fastest",
"isDefault": true
},
{
"key": "manual.cod",
"providerId": "manual",
"method": "cod",
"label": "Cash on Delivery",
"description": "Pay with cash when your order arrives.",
"logoUrl": null,
"badge": null,
"isDefault": false
}
]
},
"message": "Success",
"statusCode": 200
}| Field | Notes |
|---|---|
key | Stable option key. A single-method provider whose method equals its id collapses to the bare id (phonepe); multi-method providers qualify each one (manual.cod). |
label / description | Operator override from provider_display, else the provider's displayName / checkoutDescription, else a prettified key. |
logoUrl | Always absolute. The operator stores either a storage key or a pasted URL; the API resolves both, and it is null when unset. |
badge | Short pill copy, e.g. "Fastest". null when unset. |
isDefault | Preselect this row. The operator sets a primary per platform (default_option.web / .app), since the app and the web storefront rarely want the same one. A platform left empty, or pointed at an option not enabled there, falls back to its own first option — so no platform is ever left with nothing selected. |
Ordering. provider_display row order is the checkout order. Enabled options with no row are appended after the configured ones in registry order rather than dropped, so switching a gateway on is enough to make it appear.
Exclusions. Providers flagged hiddenAtCheckout never appear (razorpay-magic runs its own cart-side flow). Anything the platform can't serve is filtered out too, so an APP call omits WEB-only gateways.
Which gateways are available at all is set in admin — see Payment Gateways.
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 client-driven providers (Razorpay, PhonePe) the response carries pendingClientAction with the bootstrap data needed to complete payment. For synchronous providers (COD, manual) the order is confirmed and paymentStatus may already be paid on return.
The pendingClientAction.payload shape depends on the provider and on x-platform, because a mobile SDK needs different data than a browser:
// provider "razorpay" — Checkout SDK
{ "razorpayOrderId": "order_…", "keyId": "rzp_…", "amount": 125000, "currency": "INR", "prefill": { } }
// provider "phonepe", x-platform: WEB — hosted redirect
{ "phonepeOrderId": "OMO…", "redirectUrl": "https://…", "expireAt": 1703756259307, "amount": 125000 }
// provider "phonepe", x-platform: APP — mobile SDK
{ "phonepeOrderId": "OMO…", "token": "…", "merchantId": "…",
"environment": "SANDBOX", "flowId": "…", "expireAt": 1703756259307, "amount": 125000 }For PhonePe the client must always follow up with POST /store/orders/:id/phonepe/verify — neither its web redirect nor its SDK callback reports whether the payment succeeded. See payment-phonepe.md.
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"
},
"customerNote": "Leave with the security desk" // optional, trimmed, max 500 chars
}billingAddress field constraints reuse the address-book validators (Indian pincode regex /^[1-9]\d{5}$/, phone /^(\+91)?[6-9]\d{9}$/).
customerNote is stored verbatim on the order and never edited afterwards. It is returned on the store, admin, and vendor order reads so the fulfilling side sees the instruction.
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 |
| 400 | PINCODE_NOT_SERVICEABLE | Shipping address is on the delivery deny-list in block mode. message is the operator's own copy; details.pincode carries the pincode. See Serviceability |
| 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.
POST /store/checkout/buy-now/place-order — Convert a Buy Now cart into an order
Same body, response, and side effects as POST /store/checkout/place-order. The only difference is cart resolution: x-cart-token must be a buy-now cart handle from POST /store/cart/buy-now (see cart.md) — it's resolved strictly by token and ownership, never the caller's regular active cart. Returns 404 (NOT_FOUND) if the token doesn't resolve to a buy_now cart owned by the caller.
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-phonepe/payment-manual— concrete providers behindpaymentProvider/paymentMethod. Seepayment-razorpay.mdandpayment-phonepe.mdfor their storefront verify endpoints.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 app: register/unregister FCM device tokens, the in-app notification feed (bell/inbox with read/seen state, SSE live stream, images), the per-user notification preference matrix, and the public token-gated marketing-email unsubscribe. The in-app channel mirrors push.
Payment PhonePe Module — Storefront
The customer-side verify endpoint called after PhonePe returns the shopper, from web and mobile alike. Takes no body — PhonePe's client callback carries no payment status, so the server reads the gateway itself.