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
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 current variant.price differs from unit_price_at_add
  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.

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.

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[];
  pendingGifts: PendingGiftCandidate[];
  lastActivityAt: string;             // ISO
  createdAt: string;                  // ISO
};

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": [],
    "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,
        "showOnCart": true,
        "estimatedDiscountAmount": 12500     // subunits the coupon would shave off the current cart
      }
    ],
    "ineligible": [
      {
        "code": "FESTIVE25",
        "name": "Q4 Festive",
        "discountId": "01J9...",
        "discountType": "FIXED",
        "value": 25000,
        "freeShipping": true,
        "individualUse": false,
        "showOnCart": true,
        "estimatedDiscountAmount": 0,
        "reason": "BELOW_MIN_ORDER"
      }
    ]
  },
  "message": "Success",
  "statusCode": 200
}

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-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 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 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": [ /* ... */ ],
    "pendingGifts": [],
    "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 (CartCheckoutPreparedEvent).

Errors

StatusCodeWhen
409CART_EMPTYCart has no bags
409CART_NO_PRODUCT_LINESCart has only GIFT lines (nothing to reserve)
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 typesEnumsCartLineResponseCartVendorBagCartAppliedCouponSnapshotPendingGiftCandidateCartResponseCartListItem (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 couponsGET /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