Cart Module — Storefront
HTTP surface for the storefront shopping cart — guest and logged-in reads/mutates, address attach, coupon apply/remove + browse, free-gift picker, guest→customer merge on login,…
HTTP surface for the storefront shopping cart — guest and logged-in reads/mutates, address attach, coupon apply/remove + browse, free-gift picker, guest→customer merge on login, and the checkout inventory-reservation handoff.
Source:
api-modules/cart/src/controllers/store-cart.controller.ts,store-cart-address.controller.ts,store-cart-checkout.controller.ts,store-cart-coupon.controller.ts,store-cart-gift.controller.ts,store-cart-sync.controller.ts.The cart module is pluggable — it depends on
DISCOUNT_PORTandFREE_GIFT_PORTfor coupon/free-gift evaluation. Order does not importCartServicedirectly; it consumes the cart in-process via theCART_PORTtoken. Admin and vendor surfaces live in sibling docs.
Conventions
Authentication
The storefront cart endpoints use BetterAuthGuard with @OptionalAuth() — a session is not required. A guest cart is identified by an opaque x-cart-token header (issued by the server on first request); a logged-in cart is identified by the customer's session and binds automatically. The exception is /store/cart/sync, which requires a customer session.
| Endpoint group | Auth |
|---|---|
GET/POST/PATCH/DELETE /store/cart/** | optional (guest or customer) |
POST /store/cart/sync | required (customer) |
Headers
All /store/cart/** endpoints accept and emit two headers:
| Header | Direction | Notes |
|---|---|---|
x-cart-token | request (optional) + response (always) | Opaque cart handle. Mint by omitting the header — the server's response sets it. Echo back on subsequent calls. Lost token = lost guest cart. |
x-platform | request (optional) | WEB or APP (case-insensitive). Used for platform-scoped coupon and free-gift rules. Defaults to WEB. |
For a logged-in customer the cart is resolved by their customer id (the active cart), so x-cart-token mainly matters for offering a guest token for adoption or as the payload of /sync.
Response envelope
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR, BELOW_MIN_QUANTITY_PER_CART, ABOVE_MAX_QUANTITY_PER_CART |
| 401 | UNAUTHORIZED (sync without session) |
| 403 | FORBIDDEN (guest cart cannot attach an address) |
| 404 | NOT_FOUND, COUPON_NOT_APPLIED, GUEST_CART_NOT_FOUND |
| 409 | CONFLICT, INSUFFICIENT_INVENTORY, COUPON_INDIVIDUAL_USE_CONFLICT, GIFT_VARIANT_NOT_IN_POOL, GIFT_RULE_NOT_IN_PICKER, GIFT_SLOTS_FULL, GUEST_CART_OWNED_BY_OTHER_CUSTOMER, CART_EMPTY, CART_NO_PRODUCT_LINES |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Currency
All monetary fields (unitPrice, subtotal, discountTotal, total, allocations[].amount, etc.) are integer subunits (paise / cents / eurocents). Single currency per tenant — clients send and receive integers, never decimal strings. Example: "subtotal": 125000 is ₹1,250.00 in an INR tenant.
Domain types
Enums
type Platform = "APP" | "WEB" | "BOTH";
type CartStatus = "active" | "abandoned" | "converted" | "discarded";
type CartLineType = "PRODUCT" | "GIFT";CartLineResponse
type CartLineResponse = {
id: string;
vendorId: string;
productId: string;
variantId: string;
quantity: number;
type: CartLineType;
unitPrice: number; // effective price now (subunits); 0 for GIFT
unitPriceAtAdd: number | null; // snapshot at add-time
specialPriceAtAdd: number | null;
priceDrifted: boolean; // current variant.price != unit_price_at_add
allocatedDiscount: number; // coupon allocation against THIS line (subunits)
freeGiftRuleId: string | null; // populated only for GIFT lines
sourceLineId: string | null; // for BuyXGetY: the qualifying buy line
product: ProductCard | null; // canonical product card; null if the product was deleted
};Each line embeds product, the canonical product card — the exact same shape the
storefront search endpoint returns for each result (see the Product type in search.md:
id, title, subtitle, description, slug, thumbnail, images, priceStart, priceEnd, brand, inStock, hasActiveSpecial, variants[]). This lets the frontend render one product-card
component everywhere (search grid and cart alike). It is built from Postgres (authoritative
price/stock), reflects all sibling variants, and is null only when the line's product no
longer exists. Money fields are integer subunits.
CartVendorBag
type CartVendorBag = {
vendorId: string;
vendor: { name: string; slug: string; logo: string | null } | null;
lines: CartLineResponse[];
subtotal: number; // PRODUCT lines only (subunits)
discountAllocated: number; // sum of allocatedDiscount across this bag
totalBeforeShippingAndTax: number; // max(0, subtotal - discountAllocated)
};Bags are sorted by subtotal descending, then by vendorId for stability.
CartAppliedCouponSnapshot
type CartAppliedCouponSnapshot = {
code: string;
discountId: string;
individualUse: boolean;
freeShipping: boolean;
allocations: Array<{ vendorId: string; amount: number }>;
};Allocation is pro-rata by each vendor's eligible-line subtotal. The rounding residual goes to the largest bag so per-line allocatedDiscount always reconciles to the coupon's discount amount.
PendingGiftCandidate
type PendingGiftCandidate = {
ruleId: string;
slotCount: number; // how many variants the customer must pick
alreadySelectedVariantIds: string[];
optionVariantIds: string[]; // pool the customer may pick from
};CartResponse
type CartResponse = {
cartId: string;
cartToken: string; // also sent in the x-cart-token response header
customerId: string | null;
status: CartStatus;
platform: Platform;
version: number; // optimistic-version counter (cache key)
bags: CartVendorBag[];
cartTotals: {
subtotal: number;
discountTotal: number;
shippingTotal: number;
total: number;
};
appliedCoupons: CartAppliedCouponSnapshot[];
pendingGifts: PendingGiftCandidate[];
deliveryAddressId: string | null;
deliveryAddress: AddressResponse | null; // fetched fresh on every read (not cached)
lastActivityAt: string; // ISO
createdAt: string; // ISO
};deliveryAddress is re-read on every cart fetch so edits in the customer's address book are reflected immediately.
Endpoints
Base path: /store/cart. Every endpoint emits x-cart-token in the response — clients should overwrite their stored token on each call.
Every successful mutator returns the full
CartResponseso the client can re-render after a single round-trip.
GET /store/cart — Resolve current cart
Returns the cart matching the request's x-cart-token if it's valid and active. Otherwise mints a fresh cart and emits its token. For a logged-in customer with an existing active cart, that cart is preferred — but if the request also presents an unbound guest token, the guest cart is adopted (customer bound to it). To merge a guest cart into an existing customer cart, use POST /store/cart/sync.
Response 200 — CartResponse.
Side effects — emits cart.created when a cart is freshly minted.
POST /store/cart/lines — Add a variant
Idempotently adds quantity for the given variant. If the variant is already in the cart, the new quantity is summed with the existing one (existing.quantity + body.quantity); otherwise a new PRODUCT line is created.
Body
{
"variantId": "01J9...", // required, min 1 char
"quantity": 2 // optional, integer >= 1, default 1
}Response 201 — CartResponse.
Cross-field validation
| Rule | Error code |
|---|---|
quantity >= variant.minQuantityPerCart (when set) | BELOW_MIN_QUANTITY_PER_CART |
quantity <= variant.maxQuantityPerCart (when set) | ABOVE_MAX_QUANTITY_PER_CART |
| Inventory soft check passes | INSUFFICIENT_INVENTORY (409) |
Side effects — emits cart.item.added for new lines or cart.item.quantity.changed when summing.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod or per-cart bounds |
| 404 | NOT_FOUND | Variant does not exist or is soft-deleted |
| 409 | INSUFFICIENT_INVENTORY | Stock cannot cover the requested quantity |
PATCH /store/cart/lines/:lineId — Update line quantity
Replaces the line's quantity (does not sum). Only PRODUCT lines are mutable here — GIFT lines are owned by the reconciler.
Body
{ "quantity": 5 } // integer >= 1Response 200 — CartResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR, per-cart bounds | Body fails validation |
| 404 | NOT_FOUND | Line does not exist or belongs to a different cart |
| 409 | CONFLICT | Line is GIFT (not manually editable) |
| 409 | INSUFFICIENT_INVENTORY | Stock cannot cover the new quantity |
DELETE /store/cart/lines/:lineId — Remove a line
Soft-deletes the line. Only PRODUCT lines may be removed manually.
Response 200 — CartResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Line does not exist or belongs to a different cart |
| 409 | CONFLICT | Line is GIFT (not manually removable) |
DELETE /store/cart — Clear all PRODUCT lines
Soft-deletes every PRODUCT line on the cart. Coupons remain applied; gifts disappear on the next read since the qualifying lines are gone.
Response 200 — CartResponse.
PATCH /store/cart/address — Set or clear the delivery address
Pin the customer-side delivery address that drives the shipping rate. Pass deliveryAddressId: <id> to set, or null to clear. Requires the cart to be bound to a customer — guest carts can't attach an address. The address must belong to the same customer; cross-customer ids return 404 (existence is not leaked).
Body
{ "deliveryAddressId": "01J9..." } // or nullResponse 200 — CartResponse (with deliveryAddress populated).
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod (missing field, empty string) |
| 403 | FORBIDDEN | Cart is a guest cart — sign in first |
| 404 | NOT_FOUND | Address does not exist or belongs to a different customer |
POST /store/cart/coupons — Apply a coupon
Validates the coupon against the cart via DISCOUNT_PORT.validateCoupon, enforces individual-use stacking, and persists the application. Idempotent — re-applying an already-applied code returns the existing snapshot.
Body
{ "code": "WELCOME10" } // 1..64 chars; trimmed; matched case-insensitively, stored uppercaseResponse 200 — CartResponse (with the new coupon under appliedCoupons and recomputed allocations).
Stacking rules — an incoming individualUse coupon cannot stack with anything; an existing individualUse coupon blocks any new coupon. The check reads discount.individualUsageOnly directly, so a currently-ineligible existing individual-use coupon (e.g. cart subtotal dropped below its min) still blocks new applications.
Side effects — emits cart.coupon.applied.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Cart does not exist |
| 409 | DISCOUNT_NOT_VALID (or specific reason from the discount port) | Coupon ineligible against the current cart |
| 409 | COUPON_INDIVIDUAL_USE_CONFLICT | Stacking blocked (carries couponCode, optionally conflictingCode) |
DELETE /store/cart/coupons/:code — Remove an applied coupon
code is matched case-insensitively.
Response 200 — CartResponse.
Side effects — emits cart.coupon.removed.
Errors
| Status | Code | When |
|---|---|---|
| 404 | COUPON_NOT_APPLIED | Coupon was not applied to this cart |
A coupon may also be auto-removed during a cart read when it loses eligibility (e.g. items were removed and the cart subtotal fell below the coupon's
minOrderAmount). That firescart.coupon.auto.removedwith areason, but is invisible to the HTTP request that triggered the recompute.
GET /store/cart/coupons/eligible — Browse showable coupons
Returns all showOnCart discounts split into eligible vs ineligible. Uses a single batched validation pass, so request cost is constant regardless of how many showOnCart rules exist platform-wide.
Response 200
{
"data": {
"eligible": [
{
"code": "WELCOME10",
"name": "Welcome 10",
"discountId": "01J9...",
"discountType": "PERCENTAGE",
"value": 10,
"freeShipping": false,
"individualUse": false,
"showOnCart": true,
"estimatedDiscountAmount": 12500
}
],
"ineligible": [
{
"code": "FESTIVE25",
"name": "Q4 Festive",
"discountId": "01J9...",
"discountType": "FIXED",
"value": 25000,
"freeShipping": true,
"individualUse": false,
"showOnCart": true,
"estimatedDiscountAmount": 0,
"reason": "BELOW_MIN_ORDER"
}
]
}
}reason is a stable code (e.g. BELOW_MIN_ORDER, EXCLUDES_CUSTOMER) so the UI can localize.
GET /store/cart/gifts/pending — Free-gift picker candidates
Re-runs the gift reconciler under an advisory lock and returns rules currently in picker mode — i.e., rules whose qualifying conditions are met but require the customer to pick a variant. Auto-attach rules don't appear here; their gifts are already in bags[].lines with type: "GIFT".
Calling this endpoint may attach/detach
GIFTlines as a side effect of reconciliation.
Response 200
{
"data": {
"pendingGifts": [
{
"ruleId": "01J9...",
"slotCount": 1,
"alreadySelectedVariantIds": [],
"optionVariantIds": ["01J9...", "01J9..."]
}
]
}
}POST /store/cart/gifts/select — Pick a variant for a picker rule
Records the customer's choice and re-runs reconciliation, which inserts the chosen GIFT line.
Body
{
"ruleId": "01J9...",
"variantId": "01J9..."
}Response 200 — CartResponse.
Side effects — emits cart.gift.selected; reconciliation may also emit cart.gift.attached.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails validation |
| 404 | NOT_FOUND | Cart does not exist |
| 409 | GIFT_RULE_NOT_IN_PICKER | Rule is auto-attach or already satisfied |
| 409 | GIFT_VARIANT_NOT_IN_POOL | Variant is not one of the rule's options |
| 409 | GIFT_SLOTS_FULL | Customer already picked slotCount variants for this rule |
DELETE /store/cart/gifts/select/:ruleId/:variantId — Revoke a selection
Removes the prior pick and re-runs reconciliation (which detaches the corresponding GIFT line).
Response 200 — CartResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Selection did not exist |
POST /store/cart/sync — Merge a guest cart into the customer cart
Authenticated. Atomically claims the guest cart (CAS: active → discarded, only when its customer_id IS NULL) before copying its PRODUCT lines into the customer's active cart, summing overlapping variant quantities (capped at current stock). GIFT lines on the customer side are dropped — the reconciler rebuilds them. Guest coupons are re-applied via the normal apply path; ones that fail eligibility or stacking are silently skipped.
Body
{ "guestCartToken": "ct_01HX..." }Response 200 — CartResponse of the customer's now-merged cart.
Concurrency safety — wrapped in an advisory lock keyed on the customer cart plus the CAS gate on the guest cart. Retries and parallel syncs against the same guest token return the post-merge cart without doubling quantities.
Side effects — emits cart.merged with the merged line count and surviving coupon codes.
Errors
| Status | Code | When |
|---|---|---|
| 401 | UNAUTHORIZED | No customer session |
| 404 | GUEST_CART_NOT_FOUND | guestCartToken doesn't resolve to a cart |
| 404 | NOT_FOUND | The customer-side cart context cannot be resolved |
| 409 | GUEST_CART_OWNED_BY_OTHER_CUSTOMER | Guest cart is already bound to a different customer |
POST /store/cart/prepare-checkout — Reserve inventory and snapshot
Re-prices the cart (revalidates coupons, reconciles gifts), then reserves stock via InventoryService.reserve, returning the priced view plus the reservation batch handle. Wrapped in an advisory lock so parallel attempts on the same cart serialize.
The reservation idempotency key is cart:<id>:v<version>: a retry after a network blip resolves to the same key and returns the existing reservation rather than double-holding stock. cart.version is deliberately not bumped inside this endpoint.
Response 200 — PrepareCheckoutResponse (= CartResponse plus reservation fields).
{
"data": {
"cartId": "01J9...",
"cartToken": "ct_01HX...",
"customerId": "01J9...",
"status": "active",
"platform": "WEB",
"version": 7,
"bags": [ /* ... */ ],
"cartTotals": { "subtotal": 250000, "discountTotal": 25000, "shippingTotal": 0, "total": 225000 },
"appliedCoupons": [ /* ... */ ],
"pendingGifts": [],
"deliveryAddressId": "01J9...",
"deliveryAddress": { /* AddressResponse */ },
"lastActivityAt": "2026-05-07T10:30:00.000Z",
"createdAt": "2026-05-07T10:00:00.000Z",
"reservationBatchId": "01J9...",
"reservationExpiresAt": "2026-05-07T10:45:00.000Z"
}
}Side effects — emits cart.checkout.prepared.
Errors
| Status | Code | When |
|---|---|---|
| 409 | CART_EMPTY | Cart has no bags |
| 409 | CART_NO_PRODUCT_LINES | Cart has only GIFT lines (nothing to reserve) |
| 409 | INSUFFICIENT_INVENTORY | Inventory rejects the reservation |
Related modules
customer— owns the address book;PATCH /store/cart/addressreferences itsaddressId. Seecustomer.md.catalog— variants referenced byPOST /store/cart/linescome from catalog. Seecatalog.md.discount— backsDISCOUNT_PORTfor coupon evaluation.free-gift— backsFREE_GIFT_PORTfor picker/auto rules.inventory— backs the reservation and stock checks in prepare-checkout.order— consumesCART_PORTto materialize the order from a prepared cart.
Banner Module — Storefront
HTTP surface for storefront banner reads. The customer-facing app fetches active, platform-targeted promotional banners attached to a catalog entity (category / brand / tag /…
Catalog Module — Storefront
HTTP surface for unauthenticated catalog reads. The storefront uses these endpoints to render the navigation tree, brand / tag / ingredient pages, the product detail page (PDP),…