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. The token rotates when a guest cart is adopted at login, so always overwrite the stored token from the response header. |
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, CART_LINE_PRICE_UNAVAILABLE, CART_CHECKOUT_IN_PROGRESS |
| 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" | "buy_now";
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; // effective price now != effective price at add-time
priceUnavailable: boolean; // no price determinable; unitPrice reads 0 but the item is NOT free
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, rating, 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. Read a variant's availability from
isOrderable rather than inventoryQuantity > 0 — a backorderable or untracked variant is
orderable at zero — and note inventoryQuantity already has safety stock deducted, so it is
the ceiling checkout will actually honour.
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.
priceDrifted compares the price the customer would pay now against what the line effectively cost when it was added — a special-price window opening or closing flips it even though variant.price never moved.
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.
CartRemovedCoupon
type CartRemovedCoupon = {
code: string;
reason: string; // e.g. DISCOUNT_EXPIRED, BELOW_MIN_AMOUNT, USAGE_LIMIT_REACHED
};PendingGiftCandidate
type PendingGiftOption = {
variantId: string;
productId: string;
title: string;
thumbnail: string | null;
price: number | null; // integer subunits (paise)
specialPrice: number | null; // integer subunits (paise)
};
type PendingGiftCandidate = {
ruleId: string;
slotCount: number; // how many variants the customer must pick
alreadySelectedVariantIds: string[];
optionVariantIds: string[]; // pool the customer may pick from
optionVariants: PendingGiftOption[]; // display data for optionVariantIds (same pool, enriched)
};
type GiftTierLockedReason =
| "BELOW_MIN_AMOUNT"
| "ABOVE_MAX_AMOUNT"
| "REQUIRES_LOGIN"
| "NO_GIFT_VARIANTS"
| "GIFTS_OUT_OF_STOCK";
type GiftProgressTier = {
ruleId: string;
name: string;
description: string | null;
threshold: number; // integer subunits (paise)
maxAmount: number | null; // integer subunits (paise)
achieved: boolean;
amountAway: number; // 0 whenever the threshold is already met
giftQuantity: number; // gifts this rung grants
gifts: PendingGiftOption[]; // same display shape as the picker's options
lockedReason: GiftTierLockedReason | null;
};
type GiftProgressResponse = {
enabled: boolean;
currentAmount: number; // integer subunits (paise)
tiers: GiftProgressTier[]; // sorted by threshold ascending
nextTier: GiftProgressTier | null; // first rung with amountAway > 0
amountToNextTier: number | null; // nextTier.amountAway; always > 0 when set
highestAchievedTier: GiftProgressTier | null;
};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[];
removedCoupons: CartRemovedCoupon[]; // auto-dropped during this build; [] when none
appliedRedemption: CartAppliedRedemption | null; // rewards plugin; null when no points apply
redemptionRejection: RewardRedemptionRejectReason | null; // why they did not apply
pendingGifts: PendingGiftCandidate[];
deliveryAddressId: string | null;
deliveryAddress: AddressResponse | null; // fetched fresh on every read (not cached)
serviceability: CartServiceability | null; // delivery verdict for that address
lastActivityAt: string; // ISO
createdAt: string; // ISO
};
type CartServiceability = {
pincode: string;
status: "allowed" | "inform" | "block";
deliverable: boolean; // true for allowed and inform
message: string | null; // operator's copy; null when allowed
};removedCoupons reports the coupons this build dropped because they stopped validating (expired, cart fell below the minimum, usage limit reached). They are already gone from appliedCoupons and from the totals — surface them to the shopper so the discount does not just silently vanish.
appliedRedemption and redemptionRejection come from the rewards plugin and are documented with the redemption surface. The pair behaves like removedCoupons: when points that were applying stop applying — the cart fell under the redemption minimum, the operator tightened a cap — appliedRedemption goes null and redemptionRejection says why, so the discount does not just silently vanish.
deliveryAddress is re-read on every cart fetch so edits in the customer's address book are reflected immediately.
serviceability is derived from that address on every read (never cached) and is null when no address is picked or when the serviceability plugin is not installed. It is advisory — a cart read never fails on an undeliverable pincode, so the shopper can still see and edit the bag. block means place-order will reject with PINCODE_NOT_SERVICEABLE; inform warns but places fine. For the pre-address pincode box, use GET /store/serviceability/check.
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, active and not yet bound to a customer. 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.
Adoption rotates the cart token: the pre-login token is a bearer credential handed to an anonymous visitor, so once the cart carries a customer id (with their coupons, redemption intent and delivery address) it stops resolving. The adopting response carries the new token in x-cart-token. A request without a session that presents a customer-bound token gets a fresh empty guest cart, never the bound one.
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,
"requireCustomerLogin": false,
"platform": "BOTH",
"criteriaScope": "CART_SUBTOTAL",
"minAmount": null,
"maxAmount": null,
"minQuantity": null,
"minProductCount": null,
"startsAt": null,
"endsAt": null,
"showOnCart": true,
"estimatedDiscountAmount": 12500,
"applied": false
}
],
"ineligible": [
{
"code": "FESTIVE25",
"name": "Q4 Festive",
"discountId": "01J9...",
"discountType": "FIXED",
"value": 25000,
"freeShipping": true,
"individualUse": false,
"requireCustomerLogin": false,
"platform": "BOTH",
"criteriaScope": "CART_SUBTOTAL",
"minAmount": 150000,
"maxAmount": null,
"minQuantity": null,
"minProductCount": null,
"startsAt": null,
"endsAt": "2026-09-30T00:00:00.000Z",
"showOnCart": true,
"estimatedDiscountAmount": 0,
"applied": false,
"reason": "BELOW_MIN_AMOUNT",
"amountToQualify": 12000
}
]
}
}Every entry carries the full coupon card — the offer plus its qualifying thresholds and validity window — so a tile can render "₹250 off on orders above ₹1,500, ends 30 Sep" without a second round trip. The same card shape is returned by GET /store/discounts/applicable, so one component renders both surfaces. All amounts are integer subunits.
Ordering — eligible is sorted by estimatedDiscountAmount descending (ties broken on code), so the biggest saving leads. ineligible is sorted by amountToQualify ascending with unknown shortfalls last, so the coupon closest to unlocking leads. Both orders are stable across reads.
applied marks a coupon that is already on the cart — render it as applied rather than as a fresh offer.
reason is a stable code (e.g. BELOW_MIN_AMOUNT, CUSTOMER_NOT_ELIGIBLE, DISCOUNT_PLATFORM_MISMATCH) so the UI can localize.
Coupons outside their validity window are excluded from both lists rather than reported as DISCOUNT_EXPIRED — an expired coupon is noise the shopper can never act on, unlike a missed minimum.
amountToQualify is the subunits still needed to clear minAmount, for an "add ₹120 more" nudge. It is non-null only when reason is BELOW_MIN_AMOUNT and criteriaScope is CART_SUBTOTAL; every other scope measures the threshold against a total this endpoint does not compute, so it reports null rather than a number that would not actually unlock the coupon.
Stacking is pre-applied. The same individual-use rules that POST /store/cart/coupons enforces run here, so a coupon blocked by what is already on the cart lands in ineligible with reason: "COUPON_INDIVIDUAL_USE_CONFLICT" instead of sitting behind an Apply button that throws 409. An individual-use coupon that is itself applied stays in eligible (flagged applied: true).
discountType is PERCENTAGE, FIXED, or FREE_GIFT. A FREE_GIFT coupon always reports value: 0 and estimatedDiscountAmount: 0 — it takes nothing off the cart and instead unlocks a COUPON_BASED free-gift rule, so render it as a gift rather than a saving.
Coupon auto-apply
When the operator turns on the product_cart.auto_apply_coupon store setting, adding, re-quantifying, or removing a cart line applies the highest-saving eligible showOnCart coupon on the customer's behalf. The applied coupon is already reflected in the CartResponse those endpoints return — there is no separate call to make.
The guarantees the storefront can rely on:
- Once per cart. Success stamps
autoCouponAppliedinto cart metadata and no later line change re-runs it, so a customer who removes the coupon is not fought with. A cart that never qualified stays unstamped, so a later line change can still earn one. - Never overrides a choice. A cart that already carries any coupon is skipped entirely.
- Only real savings. A coupon whose
estimatedDiscountAmountis0(aFREE_GIFTcode, or one whose eligible lines net out to nothing) is not worth the cart's single shot and is passed over. - Never fails the mutation. An apply that throws is logged and swallowed; the add-to-cart still succeeds.
Buy-now carts are excluded — a one-shot single-item checkout is not the surface for a courtesy coupon.
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..."],
"optionVariants": [
{
"variantId": "01J9...",
"productId": "01J8...",
"title": "Travel Pouch",
"thumbnail": "https://cdn.example.com/pouch.jpg",
"price": 49900,
"specialPrice": null
}
]
}
]
}
}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 |
GET /store/cart/gifts/progress — Spend-tier gift ladder
Returns the ordered ladder of spend milestones the storefront renders as a progress bar — "Shop for ₹999 / 1 Free gift" — including rungs the cart has not reached, with the exact gap left to close. This is the free-gift mirror of GET /store/cart/coupons/eligible; it is a pure read and never attaches or detaches a GIFT line.
A rung is one free-gift rule with showOnCart: true, type: AUTOMATIC, criteriaScope in {CART_SUBTOTAL, ORDER_TOTAL} and a minAmount — the only rules a linear bar can honestly position. BUYXGETY and COUPON_BASED rules never appear here; they surface through pendingGifts instead.
All amounts are integer subunits (paise) —
₹999is99900. Divide by 100 before formatting.
Response 200
{
"data": {
"enabled": true,
"currentAmount": 120000,
"tiers": [
{
"ruleId": "01J9...",
"name": "Gift 999",
"description": null,
"threshold": 99900,
"maxAmount": null,
"achieved": true,
"amountAway": 0,
"giftQuantity": 1,
"gifts": [
{
"variantId": "01J9...",
"productId": "01J8...",
"title": "Travel Pouch",
"thumbnail": "https://cdn.example.com/pouch.jpg",
"price": 49900,
"specialPrice": null
}
],
"lockedReason": null
},
{
"ruleId": "01JA...",
"name": "Gift 2499",
"description": null,
"threshold": 249900,
"maxAmount": null,
"achieved": false,
"amountAway": 129900,
"giftQuantity": 1,
"gifts": [],
"lockedReason": "BELOW_MIN_AMOUNT"
}
],
"nextTier": { "ruleId": "01JA...", "threshold": 249900, "amountAway": 129900 },
"amountToNextTier": 129900,
"highestAchievedTier": { "ruleId": "01J9...", "threshold": 99900 }
}
}tiers is sorted by threshold ascending, and is the full ladder — render the bar from it.
nextTier is the next rung the shopper can unlock by adding more to the cart: the first rung with amountAway > 0. A rung that is locked for some other reason (REQUIRES_LOGIN, NO_GIFT_VARIANTS, GIFTS_OUT_OF_STOCK, ABOVE_MAX_AMOUNT) is never nextTier, because telling the shopper to spend 0 more would be wrong — it still appears in tiers with its lockedReason so you can prompt for the real blocker. amountToNextTier mirrors nextTier.amountAway and is therefore always > 0 when present; both are null when no rung is reachable by spending more.
highestAchievedTier is the last cleared rung, or null when none is cleared. gifts carries the same option shape as pendingGifts[].optionVariants — a variant whose product was deleted is omitted.
enabled is false when the operator turns off the product_cart.gift_progress_bar_enabled store setting. tiers is then always empty — but the underlying rules keep firing, so gifts still attach as GIFT lines and pendingGifts still populates. Hide the bar, not the gifts.
enabled: true with an empty tiers is the ordinary "nothing to show" case: no rule currently qualifies as a rung.
lockedReason codes — stable, so the UI can localize.
| Code | Meaning |
|---|---|
BELOW_MIN_AMOUNT | Cart has not reached threshold yet — amountAway is the gap |
ABOVE_MAX_AMOUNT | Cart overshot the rule's maxAmount window |
REQUIRES_LOGIN | Rule sets requireCustomerLogin and the caller is a guest — prompt sign-in |
NO_GIFT_VARIANTS | Misconfigured rule — no gift variants are configured on it at all |
GIFTS_OUT_OF_STOCK | Rule has a gift pool, but every variant in it is currently unavailable |
null means the rung is unlocked (achieved: true).
Rungs stack: a ₹2,499 cart clears both the ₹999 and ₹2,499 rules and receives both gifts. Operators wanting mutually exclusive rungs set
maxAmounton the lower ones — there is no "best tier wins" logic. Rules scoped to other customers (customerScope) are omitted entirely rather than shown locked.
The same ladder is available for a Buy Now cart — see GET /store/cart/buy-now/gifts/progress.
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 handles. Wrapped in an advisory lock so parallel attempts on the same cart serialize.
The re-price here always bypasses the priced-cart cache. That cache is keyed on cart.version, which does not move when an operator edits a price or a coupon passes its end date, so a cart read can serve a price up to 30 minutes stale — checkout cannot, because this snapshot is what the order freezes onto. Expect the totals returned here to differ from the last GET /store/cart when a price changed in the meantime, and expect removedCoupons to be populated when a coupon expired between the two calls.
Reservation batches are per-vendor, so a cart spanning multiple vendors produces one batch per vendor; reservationBatchIds lists them and all share a single reservationExpiresAt. The reservation idempotency key is cart:<id>:v<version>:<vendorId>, or cart:<id>:v<version>:g<digest>:<vendorId> when the cart holds free gifts (digest is a short hash of the gift set): a retry after a network blip resolves to the same per-vendor key and returns the existing reservation rather than double-holding stock. cart.version is deliberately not bumped inside this endpoint — except when the hold has already lapsed on an unchanged cart: the key would only return the expired batch, so the endpoint releases any still-active sibling batches, bumps the version once, and reserves again under the new key.
The hold lasts admin.payment.checkout_hold_minutes (default 15, range 5–120) from this call, and reservationExpiresAt reports when it ends. Placing the order extends it to admin.payment.reservation_hold_hours.
Free gifts are reserved too. A GIFT line consumes inventory exactly like a purchased one, so a vendor contributing only a gift gets its own batch, and a variant that is both bought and gifted is reserved as a single summed item. If a gift's stock cannot be reserved it is dropped from the cart and reported in removedGifts rather than failing the checkout — a free item never blocks a paid basket, and since gifts are zero-priced the quoted totals do not change. removedGifts is advisory; it blocks nothing.
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": [ /* ... */ ],
"removedCoupons": [{ "code": "SUMMER20", "reason": "DISCOUNT_EXPIRED" }],
"pendingGifts": [],
"deliveryAddressId": "01J9...",
"deliveryAddress": { /* AddressResponse */ },
"serviceability": { "pincode": "110001", "status": "allowed", "deliverable": true, "message": null },
"lastActivityAt": "2026-05-07T10:30:00.000Z",
"createdAt": "2026-05-07T10:00:00.000Z",
"reservationBatchIds": ["01J9...", "01JA..."],
"reservationExpiresAt": "2026-05-07T10:45:00.000Z",
"removedGifts": [
{ "ruleId": "01J9...", "variantId": "01JB...", "reason": "OUT_OF_STOCK" }
]
}
}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 | CART_LINE_PRICE_UNAVAILABLE | A line has no determinable price; variantIds lists the offenders |
| 409 | INSUFFICIENT_INVENTORY | Inventory rejects the reservation |
Buy Now
An isolated, single-item checkout that never touches the customer's real cart — no cart badge change, invisible to GET /store/cart. Every /store/cart/buy-now/** endpoint resolves its cart strictly by the x-cart-token it's given; a buy-now token must never be mixed with the regular cart's token, and vice versa. Each POST /store/cart/buy-now call mints a brand-new cart (status: "buy_now") — no reuse across clicks.
POST /store/cart/buy-now — Start a Buy Now checkout
Mints a fresh buy-now cart, adds the line, and reserves inventory in one call. Any incoming x-cart-token is ignored — a new cart is always created.
Body
{ "variantId": "01J9...", "quantity": 1 }Response 201 — PrepareCheckoutResponse, same shape as POST /store/cart/prepare-checkout. x-cart-token in the response header is this buy-now cart's handle — store it separately from the regular cart's token.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | variantId doesn't resolve to a product variant |
| 409 | INSUFFICIENT_INVENTORY | Inventory rejects the reservation |
GET /store/cart/buy-now — Fetch the current Buy Now cart
Response 200 — CartResponse. 404s (NOT_FOUND) if the token doesn't resolve to a buy_now cart owned by the caller.
PATCH /store/cart/buy-now/address — Set or clear the delivery address
Same body/behavior as PATCH /store/cart/address, scoped to the buy-now cart.
POST /store/cart/buy-now/coupons — Apply a coupon
Same body/behavior as POST /store/cart/coupons, scoped to the buy-now cart.
DELETE /store/cart/buy-now/coupons/:code — Remove an applied coupon
Same behavior as DELETE /store/cart/coupons/:code, scoped to the buy-now cart.
GET /store/cart/buy-now/coupons/eligible — Browse showable coupons
Same behavior as GET /store/cart/coupons/eligible, scoped to the buy-now cart.
GET /store/cart/buy-now/gifts/progress — Buy-now gift ladder
Same GiftProgressResponse as GET /store/cart/gifts/progress, scoped to the buy-now cart. Unlike the persistent cart, the buy-now token is required — there is no singleton to fall back on.
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | x-cart-token header missing |
| 404 | NOT_FOUND | Buy-now cart not found |
POST /store/checkout/buy-now/place-order — Place the order
Documented in order.md. Same body as the regular place-order; resolves the cart strictly by the buy-now x-cart-token.
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),…