Supercommerce API Docs
Full Module Docs

Cart Module

HTTP surface for the shopping cart — storefront read/mutate (guest + logged-in), customer-side coupon and free-gift application, guest→customer merge on login, checkout…

HTTP surface for the shopping cart — storefront read/mutate (guest + logged-in), customer-side coupon and free-gift application, guest→customer merge on login, checkout reservation handoff, admin browse/inspection, vendor-scoped browse, and cart funnel analytics.

Source: api-modules/cart (registered via CartModule.forRoot() in apps/api/src/app.module.ts).

The module is pluggable: it depends on DISCOUNT_PORT and FREE_GIFT_PORT (resolved via Nest DI from DiscountCoreModule / FreeGiftCoreModule). It exposes its own CART_PORT for the future Order module to consume — Order does not import CartService directly.


Conventions

Authentication

The storefront endpoints under /store/cart/** use BetterAuthGuard with @OptionalAuth() — the bearer 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 session.

Admin endpoints under /admin/** and /admin/cart-analytics/** require both a Better-Auth session and the relevant cart permission. Vendor endpoints under /vendor/** require a session with an active vendor (resolved via resolveActiveVendorId(session) — missing vendor returns 400 ACTIVE_VENDOR_REQUIRED).

Endpoint groupAuthPermission
GET/POST/PATCH/DELETE /store/cart/**optional (guest or customer)
POST /store/cart/syncrequired (customer)
GET /admin/carts, GET /admin/carts/:idrequiredcart: view
DELETE /admin/carts/:id, POST /admin/carts/:id/release-reservationsrequiredcart: manage
GET /admin/cart-analytics/**requiredcart: view
GET /vendor/carts/**, GET /vendor/cart-analytics/**requiredactive vendor in session

Headers

All /store/cart/** endpoints accept and emit two headers:

HeaderDirectionNotes
x-cart-tokenrequest (optional) + response (always)Opaque cart handle. Mint on first request by sending no header — the response sets it. Echo back on subsequent calls. Lost token = lost guest cart.
x-platformrequest (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 only matters when offering a guest token for adoption (see GET /store/cart semantics) or for /sync.

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 */ }
}
statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR, BELOW_MIN_QUANTITY_PER_CART, ABOVE_MAX_QUANTITY_PER_CART, ACTIVE_VENDOR_REQUIRED
401UNAUTHORIZED
403FORBIDDEN
404NOT_FOUND, COUPON_NOT_APPLIED, GUEST_CART_NOT_FOUND
409CONFLICT, 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
500INTERNAL_SERVER_ERROR, DATABASE_ERROR

Lifecycle

A cart row goes through these statuses; once it leaves active, the storefront mutation endpoints will not accept further writes against it.

StatusSet byEditableNotes
activecreated on first /store/cart hityesthe only state in which lines, coupons, gifts can change
abandonedCartAbandonmentService.sweep (cron) when last_activity_at is older than CART_ABANDON_AFTER_MINUTESnohidden from storefront resolution; visible to admin/vendor; reactivated only by future re-engagement code (not yet)
convertedCART_PORT.markConverted(cartId, orderId) (future Order module)nofrozen post-checkout; bag totals are reportable but no longer mutable
discardedDELETE /admin/carts/:id, or guest cart claimed by /sync (CAS flip), or CartPurgeService after retentionnocandidate for hard-delete after CART_PURGE_DISCARDED_AFTER_DAYS

/sync flips a guest cart active → discarded atomically (CAS) before copying its contents into the customer's cart — this gates retry/concurrency and is why a re-issued sync request never doubles quantities.

Currency

All monetary fields (unitPrice, unitPriceAtAdd, subtotal, discountTotal, total, allocations[].amount, vendorSubtotal, estimatedDiscountAmount, etc.) are integer subunits (paise / cents / eurocents — single currency per tenant). Clients should send and receive integers; never decimal currency strings.

Example: "subtotal": 125000 is ₹1,250.00 in an INR tenant.


Domain types

Used in payloads and responses below.

Enums

type Platform     = "APP" | "WEB" | "BOTH";
type CartStatus   = "active" | "abandoned" | "converted" | "discarded";
type CartLineType = "PRODUCT" | "GIFT";
type Granularity  = "hourly" | "daily";

CartLineResponse

type CartLineResponse = {
  id: string;
  vendorId: string;
  productId: string;
  variantId: string;
  quantity: number;
  type: CartLineType;
  unitPrice: number;                  // effective price now (subunits); 0 for GIFT lines
  unitPriceAtAdd: number | null;      // snapshot at add-time
  specialPriceAtAdd: number | null;   // snapshot at add-time (if special-price window was active)
  priceDrifted: boolean;              // true when the effective price now differs from the effective price at add-time
  priceUnavailable: boolean;          // true when no price can be determined at all; unitPrice reads 0 but the item is NOT free
  allocatedDiscount: number;          // post-pro-rata coupon allocation against THIS line (subunits)
  freeGiftRuleId: string | null;      // populated only for GIFT lines
  sourceLineId: string | null;        // for BuyXGetY: the qualifying buy line
};

CartVendorBag

Per-vendor sub-bag — every cart-level response groups lines by vendor.

type CartVendorSummary = {
  name: string;
  slug: string;
  logo: string | null;
};

type CartVendorBag = {
  vendorId: string;
  vendor: CartVendorSummary | null;
  lines: CartLineResponse[];
  subtotal: number;                   // PRODUCT lines only
  discountAllocated: number;          // sum of allocatedDiscount across this bag's lines
  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 at add time (specialPriceAtAdd when a special-price window was open, otherwise unitPriceAtAdd). A sale window opening or closing therefore flips it even though variant.price never moved. It stays false for GIFT lines, for lines with no add-time snapshot, and when the variant no longer exists.

priceUnavailable marks a line whose price cannot be resolved at all — the variant carries no list price, no live special price, and the line has no add-time snapshot to fall back on (typically a variant sold only at a special price whose window has since closed). unitPrice reads 0 for such a line, but the item is not free: POST /store/checkout/prepare and place-order both refuse the cart with CART_LINE_PRICE_UNAVAILABLE until the line is removed or the variant is repriced.

CartAppliedCouponSnapshot

type CartAppliedCouponSnapshot = {
  code: string;                       // uppercase
  discountId: string;
  individualUse: boolean;
  freeShipping: boolean;
  allocations: Array<{                // per-vendor pro-rata of the coupon's discountAmount
    vendorId: string;
    amount: number;                   // subunits; sums to coupon.discountAmount
  }>;
};

Allocation is pro-rata by each vendor's eligible-line subtotal. Rounding residual goes to the largest bag so per-line allocatedDiscount sums always reconcile to the coupon's discountAmount.

CartRemovedCoupon

An applied coupon that stopped validating and was dropped while building the view.

type CartRemovedCoupon = {
  code: string;                       // uppercase
  reason: string;                     // discount-port code, e.g. DISCOUNT_EXPIRED, BELOW_MIN_AMOUNT, USAGE_LIMIT_REACHED
};

PendingGiftCandidate

A free-gift rule that fired in picker mode and is awaiting the customer's choice.

type PendingGiftCandidate = {
  ruleId: string;
  slotCount: number;                  // how many variants the customer must pick
  alreadySelectedVariantIds: string[];
  optionVariantIds: string[];         // pool of variants the customer may pick from
};

CartResponse

The full storefront cart shape. Returned by every /store/cart/** mutator and by GET /store/cart.

type CartResponse = {
  cartId: string;
  cartToken: string;                  // also sent in x-cart-token response header
  customerId: string | null;
  status: CartStatus;
  platform: Platform;
  version: number;                    // optimistic-version counter (cache key)
  bags: CartVendorBag[];
  cartTotals: {
    subtotal: number;                 // sum across bags (PRODUCT only)
    discountTotal: number;            // sum of applied coupon discountAmount
    total: number;                    // max(0, subtotal - discountTotal)
  };
  appliedCoupons: CartAppliedCouponSnapshot[];
  removedCoupons: CartRemovedCoupon[]; // auto-dropped during this build; [] when none
  pendingGifts: PendingGiftCandidate[];
  lastActivityAt: string;             // ISO
  createdAt: string;                  // ISO
};

removedCoupons reports the coupons this build dropped because they stopped validating. They are already out of appliedCoupons and out of the totals; surface them so the discount does not silently vanish from the shopper's view. Each removal also emits cart.coupon.auto_removed.

The notice is sticky for the cart version it was raised at, not for a single response: the build caches its result under the version the removal bumped the cart to, so repeat reads keep reporting it. It clears on the next cart mutation — including re-applying the same coupon, which bumps the version again and rebuilds with an empty removedCoupons.

Shipping/tax are not present yet — SHIPPING_PORT / TAX_PORT exist as Symbol tokens awaiting implementations.

CartListItem (admin) and CartVendorListItem (vendor)

type CartListItem = {                 // admin listfull visibility
  cartId: string;
  cartToken: string;
  customerId: string | null;
  status: CartStatus;
  platform: Platform;
  lineCount: number;
  vendorIds: string[];
  lastActivityAt: string;
  createdAt: string;
};

type CartVendorListItem = {           // vendor listvendor-scoped, no PII
  cartId: string;
  status: CartStatus;
  platform: Platform;
  vendorLineCount: number;            // ONLY this vendor's lines
  vendorSubtotal: number;             // ONLY this vendor's lines (subunits)
  lastActivityAt: string;
  createdAt: string;
};

CartVendorView (vendor detail)

Per-vendor projection of CartResponse — strips cartToken, customerId, other vendors' bags, the global cartTotals, and per-vendor coupon allocations are restricted to this vendor only.

type CartVendorView = {
  cartId: string;
  status: CartStatus;
  platform: Platform;
  vendorBag: {
    vendorId: string;
    lines: CartLineResponse[];
    subtotal: number;
    discountAllocated: number;
    totalBeforeShippingAndTax: number;
  };
  appliedCouponAllocations: Array<{
    code: string;
    amount: number;                   // allocation against THIS vendor (subunits)
  }>;
  lastActivityAt: string;
  createdAt: string;
};

Storefront endpoints

Base path: /store/cart. All endpoints accept x-cart-token and x-platform headers as described above.

Every endpoint always emits the active cart's token in the response x-cart-token header — clients should overwrite their stored token from the response on each call.

GET /store/cart — Resolve current cart

If the request has a valid x-cart-token for an active cart, returns it. 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 instead.

Response 200CartResponse.

{
  "data": {
    "cartId": "01J9...",
    "cartToken": "ct_01HX...",
    "customerId": null,
    "status": "active",
    "platform": "WEB",
    "version": 0,
    "bags": [],
    "cartTotals": { "subtotal": 0, "discountTotal": 0, "total": 0 },
    "appliedCoupons": [],
    "removedCoupons": [],
    "pendingGifts": [],
    "lastActivityAt": "2026-05-07T10:00:00.000Z",
    "createdAt":      "2026-05-07T10:00:00.000Z"
  },
  "message": "Success",
  "statusCode": 200
}

Side effects — emits cart.created (CartCreatedEvent) when a cart is freshly minted.


POST /store/cart/lines — Add a variant to the cart

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); if not, a new PRODUCT line is created.

Body

{
  "variantId": "01J9...",   // required
  "quantity": 2             // optional, default 1, integer >= 1
}

Response 201CartResponse.

Cross-field validation

RuleError code
quantity >= variant.minQuantityPerCart (when set)BELOW_MIN_QUANTITY_PER_CART
quantity <= variant.maxQuantityPerCart (when set)ABOVE_MAX_QUANTITY_PER_CART
Inventory soft check passesINSUFFICIENT_INVENTORY (409)

Side effects — emits cart.item.added (CartItemAddedEvent) for new lines, cart.item.quantity.changed (CartItemQuantityChangedEvent) when summing into an existing line. last_activity_at is touched.

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails zod validation, or per-cart bounds violated
404NOT_FOUNDVariant does not exist (or is soft-deleted)
409INSUFFICIENT_INVENTORYStock 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 managed by the gift reconciler.

Path params

NameType
lineIdstring (UUID)

Body

{ "quantity": 5 }   // integer >= 1

Response 200CartResponse.

Side effects — emits cart.item.quantity.changed.

Errors

StatusCodeWhen
400VALIDATION_ERROR, per-cart boundsBody fails validation
404NOT_FOUNDLine does not exist or does not belong to this cart
409CONFLICTLine is GIFT (not manually editable)
409INSUFFICIENT_INVENTORYStock 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 200CartResponse.

Side effects — emits cart.item.removed.

Errors

StatusCodeWhen
404NOT_FOUNDLine does not exist or does not belong to this cart
409CONFLICTLine is GIFT (not manually removable)

DELETE /store/cart — Clear all PRODUCT lines

Soft-deletes every PRODUCT line on the cart. Coupons remain applied; gifts will be reconciled (and disappear) on the next read since the qualifying lines are gone.

Response 200CartResponse.


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: applying a code that's already applied returns the existing snapshot.

Body

{ "code": "WELCOME10" }   // 1..64 chars; trimmed; matched case-insensitively, stored uppercase

Response 200CartResponse (with the new coupon under appliedCoupons and recomputed allocations).

Stacking rules — incoming individualUse cannot stack with anything; an existing individualUse coupon blocks any new coupon. The check reads discount.individualUsageOnly directly so a temporarily-ineligible existing individual-use coupon (e.g. cart subtotal dropped below its min) still blocks new coupons.

Side effects — emits cart.coupon.applied (CartCouponAppliedEvent).

Errors

StatusCodeWhen
404NOT_FOUNDCart does not exist
409DISCOUNT_NOT_VALID (or specific reason from the discount port)Coupon ineligible against the current cart
409COUPON_INDIVIDUAL_USE_CONFLICTStacking blocked (carries couponCode, optionally conflictingCode)

DELETE /store/cart/coupons/:code — Remove an applied coupon

Path params

NameType
codestring (matched case-insensitively)

Response 200CartResponse.

Side effects — emits cart.coupon.removed (CartCouponRemovedEvent).

Errors

StatusCodeWhen
404COUPON_NOT_APPLIEDCoupon 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 fires cart.coupon.auto.removed (CartCouponAutoRemovedEvent) with a reason, 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 (DiscountService.batchValidateForShowOnCart), so request cost is constant regardless of the number of showOnCart rules in the platform.

Response 200EligibleCouponsResponse.

{
  "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,    // subunits the coupon would shave off the current cart
        "applied": false                     // already on the cart
      }
    ],
    "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             // subunits still needed to clear minAmount
      }
    ]
  },
  "message": "Success",
  "statusCode": 200
}

Each entry is a coupon card (couponCardSchema, owned by @sc/discount) plus the cart-relative fields. GET /store/discounts/applicable returns the same card for a product page, so both surfaces share one renderer.

Orderingeligible by estimatedDiscountAmount descending; ineligible by amountToQualify ascending with unknown shortfalls last. Both break ties on code, so the order is stable across reads (the candidate scan itself has no ORDER BY).

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 non-null only for reason: "BELOW_MIN_AMOUNT" with criteriaScope: "CART_SUBTOTAL" — every other scope measures its threshold against a total this endpoint does not compute, and a wrong number is worse than none.

Stacking is pre-appliedCartEligibleCouponsService replays the individual-use rules from CartCouponService.applyCoupon, so a coupon blocked by the cart's current coupons lands in ineligible with reason: "COUPON_INDIVIDUAL_USE_CONFLICT" rather than 409-ing on Apply. An applied individual-use coupon stays in eligible with applied: true.


Coupon auto-apply

CartAutoCouponService.maybeAutoApply runs after each cart-line mutation on StoreCartController (add / update quantity / remove), gated on the storeproduct_cartauto_apply_coupon setting (default off).

It applies the highest-saving eligible showOnCart coupon and stamps autoCouponApplied: true into cart.metadata. That stamp is the consent model: one attempt per cart, so removing the coupon is respected for the rest of the cart's life. A cart that never qualified stays unstamped and can still earn one later.

It declines to act when: the setting is off, the cart is already stamped, the cart already carries any coupon, or the best candidate would save 0 (a FREE_GIFT code, or eligible lines that net out to nothing). Every failure path — including an apply that throws — is caught and logged; a courtesy coupon must never fail the mutation that triggered it.

Buy-now carts (StoreCartBuyNowController) are not hooked up.


GET /store/cart/gifts/pending — Free-gift picker candidates

Re-evaluates free-gift rules and returns rules that fired in picker mode — i.e., rules whose qualifying conditions are met but require the customer to pick a variant. Rules that auto-attach are not in this list (their gifts are already in bags[].lines with type: "GIFT").

Response 200

{
  "data": {
    "pendingGifts": [
      {
        "ruleId": "01J9...",
        "slotCount": 1,
        "alreadySelectedVariantIds": [],
        "optionVariantIds": ["01J9...", "01J9..."]
      }
    ]
  },
  "message": "Success",
  "statusCode": 200
}

Calling this endpoint runs the gift reconciler under an advisory lock and may attach/detach GIFT lines as a side effect.


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 200CartResponse.

Side effects — emits cart.gift.selected (CartGiftSelectedEvent); reconciliation may also emit cart.gift.attached for the resulting GIFT line.

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails validation
404NOT_FOUNDCart does not exist
409GIFT_RULE_NOT_IN_PICKERRule is not currently in picker mode (auto rule, or already satisfied)
409GIFT_VARIANT_NOT_IN_POOLVariant is not one of the rule's options
409GIFT_SLOTS_FULLCustomer 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 200CartResponse.

Errors

StatusCodeWhen
404NOT_FOUNDSelection 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 gift 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 200CartResponse 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. A retried request, or a parallel sync against the same guest token, returns alreadyMerged: true semantics (no duplicate quantities).

Side effects — emits cart.merged (CartMergedEvent) with the merged line count and surviving coupon codes.

Errors

StatusCodeWhen
401UNAUTHORIZEDNo customer session
404GUEST_CART_NOT_FOUNDguestCartToken doesn't resolve to a cart
404NOT_FOUNDThe customer-side cart context cannot be resolved
409GUEST_CART_OWNED_BY_OTHER_CUSTOMERGuest 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.

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 — see below): 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.

Every stock-bearing line is reserved, GIFT as well as PRODUCT. A free gift consumes inventory exactly like a purchased item — it is reserved here and decremented when the payment commits. Two consequences:

  • A vendor contributing only a gift gets its own batch, so passing the CART_NO_PRODUCT_LINES guard no longer implies every batch holds a purchased line. That guard is unchanged: a cart of nothing but gifts is still refused.
  • A variant that is both bought and gifted in the same cart is reserved as a single summed item, not two — the reservation layer collapses duplicates before writing.

Gift lines can change without cart.version moving (reconciliation does not touch it, and an operator editing a rule bumps nothing at all), so the gift set is folded into the idempotency key. Without it, a second attempt carrying a different gift set would collide with the first attempt's still-live batch and be rejected as a payload mismatch, stranding the cart until that batch expired.

An unavailable gift is dropped, never a checkout failure. If a gift's stock cannot be reserved, the gift line is removed from the cart and listed in removedGifts; checkout proceeds with the rest of the basket. A free item must not be able to block a paid one, and because gifts are always zero-priced and excluded from every subtotal, discount and tax allocation, removing one leaves the quoted totals untouched. removedGifts is advisory — nothing about it blocks placing the order. Removing a line invalidates the cart's cached priced views rather than bumping cart.version.

The re-price here always bypasses the priced-cart cache (CartViewService.build(cartId, { fresh: true })). That cache is keyed on cart.version, which does not move when an operator edits a price or a coupon passes its endsAt, so an ordinary cart read can serve a total up to 30 minutes stale. Checkout cannot: this snapshot is what the order freezes onto. Clients should expect the totals here to differ from the last GET /store/cart when a price changed in between, and to see removedCoupons populated when a coupon expired between the two calls.

A key is only ever matched against a live (active) batch. Once a batch is committed, released or expired, the same key reserves afresh — otherwise a cart whose reservation died (payment failed, TTL lapsed) could never check out again, since its key is derived from state that has not changed.

Response 200PrepareCheckoutResponse (= CartResponse plus reservation fields).

{
  "data": {
    "cartId": "01J9...",
    "cartToken": "ct_01HX...",
    "customerId": "01J9...",
    "status": "active",
    "platform": "WEB",
    "version": 7,
    "bags": [ /* ... */ ],
    "cartTotals": { "subtotal": 250000, "discountTotal": 25000, "total": 225000 },
    "appliedCoupons": [ /* ... */ ],
    "removedCoupons": [{ "code": "SUMMER20", "reason": "DISCOUNT_EXPIRED" }],
    "pendingGifts": [],
    "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 (CartCheckoutPreparedEvent).

Errors

StatusCodeWhen
409CART_EMPTYCart has no bags
409CART_NO_PRODUCT_LINESCart has only GIFT lines (nothing to reserve)
409CART_LINE_PRICE_UNAVAILABLEA line has no determinable price; variantIds lists the offenders
409INSUFFICIENT_INVENTORYInventory layer rejects the reservation

Admin endpoints

Base path: /admin/carts.

GET /admin/carts — List carts

Paginated list with full visibility (cart token + customer id + cross-vendor data).

Query

NameTypeDefaultNotes
statusCartStatus?Lifecycle filter
customerIdstring?Exact match
vendorIdstring?Limits to carts containing a line owned by this vendor
activeSinceISO datetime?Inclusive lower bound on last_activity_at
limitint501..500
offsetint0≥ 0

Soft-deleted (deletedAt IS NOT NULL) carts are always excluded.

Response 200 — paginated CartListItem list.

{
  "data": [
    {
      "cartId": "01J9...",
      "cartToken": "ct_01HX...",
      "customerId": "01J9...",
      "status": "active",
      "platform": "WEB",
      "lineCount": 3,
      "vendorIds": ["01J9...", "01J9..."],
      "lastActivityAt": "2026-05-07T10:00:00.000Z",
      "createdAt":      "2026-05-07T09:00:00.000Z"
    }
  ],
  "metadata": { "total": 42, "limit": 50, "offset": 0, "hasMore": false }
}

GET /admin/carts/:id — Get cart detail

Returns the full CartResponse for any cart (active or not). Triggers a live re-price + revalidate even for non-active carts.

Response 200CartResponse.

Errors

StatusCodeWhen
404NOT_FOUNDCart does not exist

DELETE /admin/carts/:id — Force-discard

Soft-transitions the cart to discarded. Lines/coupons/gifts are not deleted (still reportable); the cart is just removed from the active set.

Response 204 — empty.

Errors

StatusCodeWhen
404NOT_FOUNDCart does not exist

POST /admin/carts/:id/release-reservations — Ops escape hatch

Releases every active inventory reservation batch held by this cart. Used to unstick a customer waiting on already-allocated stock.

Response 200

{ "data": { "releasedBatches": 1 } }

Errors

StatusCodeWhen
404NOT_FOUNDCart does not exist

Admin analytics endpoints

Base path: /admin/cart-analytics. All endpoints accept the same range query unless noted.

Common query — AnalyticsRangeQuery

NameTypeDefaultNotes
fromISO datetime?30 days agoInclusive lower bound
toISO datetime?nowInclusive upper bound
granularity"hourly" | "daily""daily"Bucket size

top-abandoned-variants additionally takes limit (1..100, default 10).

GET /admin/cart-analytics/funnel — Funnel rollup

{
  "data": {
    "granularity": "daily",
    "vendorId": null,
    "buckets": [
      {
        "bucketAt": "2026-05-06T00:00:00.000Z",
        "cartsCreated": 412,
        "itemsAdded": 1284,
        "itemsRemoved": 137,
        "couponsApplied": 92,
        "giftsAttached": 64,
        "checkoutsPrepared": 188,
        "cartsAbandoned": 178,
        "uniqueActiveCarts": 401
      }
    ],
    "totals": { /* same shape minus bucketAt */ }
  }
}

GET /admin/cart-analytics/abandonment

{
  "data": {
    "vendorId": null,
    "cartsCreated": 1240,
    "cartsAbandoned": 530,
    "checkoutsPrepared": 612,
    "abandonmentRate": 0.43,
    "checkoutRate": 0.49
  }
}

GET /admin/cart-analytics/top-abandoned-variants

{
  "data": {
    "vendorId": null,
    "items": [
      { "variantId": "01J9...", "vendorId": "01J9...", "removedCount": 47 }
    ]
  }
}

GET /admin/cart-analytics/coupon-usage and /gift-attach-rate

Return scalar counters/rates for the window — exact shape mirrors the underlying CartAnalyticsQueryService methods.

GET /admin/cart-analytics/cohort/:customerId — Per-customer breakdown

{
  "data": {
    "customerId": "01J9...",
    "byEvent": [
      { "eventType": "cart.item.added", "total": 12 },
      { "eventType": "cart.coupon.applied", "total": 2 }
    ]
  }
}

Vendor endpoints

Base path: /vendor/carts (browse) and /vendor/cart-analytics (analytics).

Vendor-scoped responses never include cart_token (a bearer handle on storefront endpoints) or customer_id (PII). Aggregate fields (vendorLineCount, vendorSubtotal) are scoped to the requesting vendor's own lines so a multi-vendor cart doesn't leak one vendor's volume to another.

GET /vendor/carts — List vendor's carts

Same query shape as admin list. Only carts containing at least one line owned by the active vendor are returned.

Response 200 — paginated CartVendorListItem.

GET /vendor/carts/:id — Vendor-scoped detail

Returns CartVendorView. 404s if the cart has no lines owned by the active vendor (cart's existence is itself confidential to non-participating vendors).

{
  "data": {
    "cartId": "01J9...",
    "status": "active",
    "platform": "WEB",
    "vendorBag": {
      "vendorId": "01J9...",
      "lines": [ /* CartLineResponse[] for THIS vendor only */ ],
      "subtotal": 125000,
      "discountAllocated": 12500,
      "totalBeforeShippingAndTax": 112500
    },
    "appliedCouponAllocations": [
      { "code": "WELCOME10", "amount": 12500 }
    ],
    "lastActivityAt": "2026-05-07T10:00:00.000Z",
    "createdAt":      "2026-05-07T09:00:00.000Z"
  }
}

GET /vendor/cart-analytics/{funnel,abandonment,top-abandoned-variants,coupon-usage,gift-attach-rate}

Same shapes as the admin analytics endpoints, but every metric is filtered to the active vendor (vendorId field in each response carries the vendor's ID instead of null).


Internal API — CART_PORT

Future Order module consumes the cart in-process via the CART_PORT injection token rather than calling CartService directly. Cart and Order are otherwise unrelated modules.

import { CART_PORT, type CartPort } from "@sc/cart";

@Inject(CART_PORT) private readonly cartPort: CartPort
interface CartPort {
  getCart(cartId: string): Promise<CartView | null>;
  prepareCheckout(cartId: string): Promise<PreparedCheckout>;
  markConverted(cartId: string, orderId: string): Promise<void>;
}

CartView and PreparedCheckout mirror CartResponse and PrepareCheckoutResponse (subunit currency throughout). Order calls markConverted once its order row is durable, transitioning the cart to converted.

SHIPPING_PORT and TAX_PORT are exported as Symbol tokens with no concrete implementations yet; future shipping/tax modules will bind to them.


Domain events

Emitted on the @nestjs/event-emitter bus. The CartAnalyticsRecorderListener writes them into the cart_analytics_event partitioned table (raw retention: CART_ANALYTICS_RAW_RETENTION_MONTHS); the rollup services aggregate them into hourly/daily metric tables.

EventConstantClassFired when
cart.createdCART_CREATEDCartCreatedEventA cart is freshly minted
cart.item.addedCART_ITEM_ADDEDCartItemAddedEventA new PRODUCT line is created
cart.item.quantity.changedCART_ITEM_QUANTITY_CHANGEDCartItemQuantityChangedEventAn existing line's quantity changes (add-to-existing or PATCH)
cart.item.removedCART_ITEM_REMOVEDCartItemRemovedEventA PRODUCT line is removed
cart.coupon.appliedCART_COUPON_APPLIEDCartCouponAppliedEventA coupon is applied successfully
cart.coupon.removedCART_COUPON_REMOVEDCartCouponRemovedEventA customer-initiated removal
cart.coupon.auto.removedCART_COUPON_AUTO_REMOVEDCartCouponAutoRemovedEventRevalidation drops a coupon that lost eligibility
cart.gift.selectedCART_GIFT_SELECTEDCartGiftSelectedEventCustomer picked a variant for a picker rule
cart.gift.attachedCART_GIFT_ATTACHEDCartGiftAttachedEventA GIFT line is added by the reconciler
cart.gift.removedCART_GIFT_REMOVEDCartGiftRemovedEventA GIFT line is removed by the reconciler
cart.mergedCART_MERGEDCartMergedEventA guest cart is merged into a customer cart via /sync
cart.checkout.preparedCART_CHECKOUT_PREPAREDCartCheckoutPreparedEventInventory reserved, priced snapshot returned
cart.abandonedCART_ABANDONEDCartAbandonedEventSweep marks a stale cart abandoned

The cart module is itself a listener for upstream events:

  • DiscountRuleChangedListener — observes discount lifecycle events. Cart-side handling is lazy: the next view fetch revalidates applied coupons. Mass invalidation across all carts holding the changed rule is intentionally not done.
  • FreeGiftRuleChangedListener — same pattern for free-gift rules.
  • InventoryStockChangedListener — observation only; subsequent reads pick up stock differences via InventoryService.assertOrderable.

Background jobs

Wired through Bull on the cart queue (CART_QUEUE). All jobs are idempotent and safe to run in parallel across workers (sweepers use FOR UPDATE SKIP LOCKED).

JobCadencePurpose
cart-abandon-sweepevery CART_ABANDON_SWEEP_INTERVAL_MS (15 min)Mark active carts older than CART_ABANDON_AFTER_MINUTES as abandoned. Per-tick ceiling: 50k carts (50 batches × 1000).
cart-purge-discardedweekly Sunday 03:00 UTC (CART_PURGE_DISCARDED_CRON)Hard-delete carts that have been discarded longer than CART_PURGE_DISCARDED_AFTER_DAYS.
cart-analytics-recordper-eventPersists each emitted cart event into the partitioned analytics table.
cart-analytics-hourly-rollup5 * * * *Aggregates the prior hour's events into hourly metrics.
cart-analytics-daily-rollup30 0 * * *Aggregates the prior day's events into daily metrics.
cart-analytics-partition-maint0 2 * * *Idempotent partition maintenance — creates upcoming partitions, drops those past CART_ANALYTICS_RAW_RETENTION_MONTHS.

Configuration

Env varDefaultEffect
CART_ABANDON_AFTER_MINUTES1440 (24h)Inactivity threshold before a cart is marked abandoned.
CART_PURGE_DISCARDED_AFTER_DAYS90Retention for discarded carts before hard-delete.
CART_ANALYTICS_RAW_RETENTION_MONTHS3Retention for cart_analytics_event raw partitions. Aggregated rollup tables retain history independently.

The module also depends on DATABASE_URL (consumed by @sc/db), the Bull/Redis connection (consumed by @sc/queue), and the Better-Auth session set up by @sc/auth. Pricing assumes a single currency per tenant — see subunit-pricing semantics; all amounts are integer subunits.

On this page

ConventionsAuthenticationHeadersResponse envelopeError envelopeLifecycleCurrencyDomain typesEnumsCartLineResponseCartVendorBagCartAppliedCouponSnapshotCartRemovedCouponPendingGiftCandidateCartResponseCartListItem (admin) and CartVendorListItem (vendor)CartVendorView (vendor detail)Storefront endpointsGET /store/cart — Resolve current cartPOST /store/cart/lines — Add a variant to the cartPATCH /store/cart/lines/:lineId — Update line quantityDELETE /store/cart/lines/:lineId — Remove a lineDELETE /store/cart — Clear all PRODUCT linesPOST /store/cart/coupons — Apply a couponDELETE /store/cart/coupons/:code — Remove an applied couponGET /store/cart/coupons/eligible — Browse showable couponsCoupon auto-applyGET /store/cart/gifts/pending — Free-gift picker candidatesPOST /store/cart/gifts/select — Pick a variant for a picker ruleDELETE /store/cart/gifts/select/:ruleId/:variantId — Revoke a selectionPOST /store/cart/sync — Merge a guest cart into the customer cartPOST /store/cart/prepare-checkout — Reserve inventory and snapshotAdmin endpointsGET /admin/carts — List cartsGET /admin/carts/:id — Get cart detailDELETE /admin/carts/:id — Force-discardPOST /admin/carts/:id/release-reservations — Ops escape hatchAdmin analytics endpointsCommon query — AnalyticsRangeQueryGET /admin/cart-analytics/funnel — Funnel rollupGET /admin/cart-analytics/abandonmentGET /admin/cart-analytics/top-abandoned-variantsGET /admin/cart-analytics/coupon-usage and /gift-attach-rateGET /admin/cart-analytics/cohort/:customerId — Per-customer breakdownVendor endpointsGET /vendor/carts — List vendor's cartsGET /vendor/carts/:id — Vendor-scoped detailGET /vendor/cart-analytics/{funnel,abandonment,top-abandoned-variants,coupon-usage,gift-attach-rate}Internal API — CART_PORTDomain eventsBackground jobsConfiguration