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 viaCartModule.forRoot()inapps/api/src/app.module.ts).The module is pluggable: it depends on
DISCOUNT_PORTandFREE_GIFT_PORT(resolved via Nest DI fromDiscountCoreModule/FreeGiftCoreModule). It exposes its ownCART_PORTfor the future Order module to consume — Order does not importCartServicedirectly.
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 group | Auth | Permission |
|---|---|---|
GET/POST/PATCH/DELETE /store/cart/** | optional (guest or customer) | — |
POST /store/cart/sync | required (customer) | — |
GET /admin/carts, GET /admin/carts/:id | required | cart: view |
DELETE /admin/carts/:id, POST /admin/carts/:id/release-reservations | required | cart: manage |
GET /admin/cart-analytics/** | required | cart: view |
GET /vendor/carts/**, GET /vendor/cart-analytics/** | required | active vendor in session |
Headers
All /store/cart/** endpoints accept and emit two headers:
| Header | Direction | Notes |
|---|---|---|
x-cart-token | request (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-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 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 */ }
}statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR, BELOW_MIN_QUANTITY_PER_CART, ABOVE_MAX_QUANTITY_PER_CART, ACTIVE_VENDOR_REQUIRED |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND, COUPON_NOT_APPLIED, GUEST_CART_NOT_FOUND |
| 409 | CONFLICT, INSUFFICIENT_INVENTORY, COUPON_INDIVIDUAL_USE_CONFLICT, GIFT_VARIANT_NOT_IN_POOL, GIFT_RULE_NOT_IN_PICKER, GIFT_SLOTS_FULL, GUEST_CART_OWNED_BY_OTHER_CUSTOMER, CART_EMPTY, CART_NO_PRODUCT_LINES |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Lifecycle
A cart row goes through these statuses; once it leaves active, the storefront mutation endpoints will not accept further writes against it.
| Status | Set by | Editable | Notes |
|---|---|---|---|
active | created on first /store/cart hit | yes | the only state in which lines, coupons, gifts can change |
abandoned | CartAbandonmentService.sweep (cron) when last_activity_at is older than CART_ABANDON_AFTER_MINUTES | no | hidden from storefront resolution; visible to admin/vendor; reactivated only by future re-engagement code (not yet) |
converted | CART_PORT.markConverted(cartId, orderId) (future Order module) | no | frozen post-checkout; bag totals are reportable but no longer mutable |
discarded | DELETE /admin/carts/:id, or guest cart claimed by /sync (CAS flip), or CartPurgeService after retention | no | candidate 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 list — full visibility
cartId: string;
cartToken: string;
customerId: string | null;
status: CartStatus;
platform: Platform;
lineCount: number;
vendorIds: string[];
lastActivityAt: string;
createdAt: string;
};
type CartVendorListItem = { // vendor list — vendor-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-tokenheader — 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 200 — CartResponse.
{
"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 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 (CartItemAddedEvent) for new lines, cart.item.quantity.changed (CartItemQuantityChangedEvent) when summing into an existing line. last_activity_at is touched.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod validation, or per-cart bounds violated |
| 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 managed by the gift reconciler.
Path params
| Name | Type |
|---|---|
lineId | string (UUID) |
Body
{ "quantity": 5 } // integer >= 1Response 200 — CartResponse.
Side effects — emits cart.item.quantity.changed.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR, per-cart bounds | Body fails validation |
| 404 | NOT_FOUND | Line does not exist or does not belong to this 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.
Side effects — emits cart.item.removed.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Line does not exist or does not belong to this 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 will be reconciled (and disappear) on the next read since the qualifying lines are gone.
Response 200 — CartResponse.
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 uppercaseResponse 200 — CartResponse (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
| 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
Path params
| Name | Type |
|---|---|
code | string (matched case-insensitively) |
Response 200 — CartResponse.
Side effects — emits cart.coupon.removed (CartCouponRemovedEvent).
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.removed(CartCouponAutoRemovedEvent) with 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 (DiscountService.batchValidateForShowOnCart), so request cost is constant regardless of the number of showOnCart rules in the platform.
Response 200 — EligibleCouponsResponse.
{
"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
GIFTlines 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 200 — CartResponse.
Side effects — emits cart.gift.selected (CartGiftSelectedEvent); reconciliation may also emit cart.gift.attached for the resulting GIFT line.
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 not currently in picker mode (auto rule, or already satisfied) |
| 409 | GIFT_VARIANT_NOT_IN_POOL | Variant is not one of the rule's options |
| 409 | GIFT_SLOTS_FULL | Customer already picked slotCount variants for this rule |
DELETE /store/cart/gifts/select/:ruleId/:variantId — Revoke a selection
Removes the prior pick and re-runs reconciliation (which detaches the corresponding GIFT line).
Response 200 — CartResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Selection did not exist |
POST /store/cart/sync — Merge a guest cart into the customer cart
Authenticated. Atomically claims the guest cart (CAS: active → discarded, only when its customer_id IS NULL) before copying its PRODUCT lines into the customer's active cart, summing overlapping variant quantities (capped at current stock). GIFT lines on the customer side are dropped — the 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 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. 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
| Status | Code | When |
|---|---|---|
| 401 | UNAUTHORIZED | No customer session |
| 404 | GUEST_CART_NOT_FOUND | guestCartToken doesn't resolve to a cart |
| 404 | NOT_FOUND | The customer-side cart context cannot be resolved |
| 409 | GUEST_CART_OWNED_BY_OTHER_CUSTOMER | Guest cart is already bound to a different customer |
POST /store/cart/prepare-checkout — Reserve inventory and snapshot
Re-prices the cart (revalidates coupons, reconciles gifts), then reserves stock via InventoryService.reserve, returning the priced view plus the reservation batch handle. Wrapped in an advisory lock so parallel attempts on the same cart serialize.
The reservation idempotency key is cart:<id>:v<version>: a retry after a network blip resolves to the same key and returns the existing reservation rather than double-holding stock. cart.version is deliberately not bumped inside this endpoint.
Response 200 — PrepareCheckoutResponse (= CartResponse plus reservation fields).
{
"data": {
"cartId": "01J9...",
"cartToken": "ct_01HX...",
"customerId": "01J9...",
"status": "active",
"platform": "WEB",
"version": 7,
"bags": [ /* ... */ ],
"cartTotals": { "subtotal": 250000, "discountTotal": 25000, "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
| Status | Code | When |
|---|---|---|
| 409 | CART_EMPTY | Cart has no bags |
| 409 | CART_NO_PRODUCT_LINES | Cart has only GIFT lines (nothing to reserve) |
| 409 | INSUFFICIENT_INVENTORY | Inventory 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
| Name | Type | Default | Notes |
|---|---|---|---|
status | CartStatus? | — | Lifecycle filter |
customerId | string? | — | Exact match |
vendorId | string? | — | Limits to carts containing a line owned by this vendor |
activeSince | ISO datetime? | — | Inclusive lower bound on last_activity_at |
limit | int | 50 | 1..500 |
offset | int | 0 | ≥ 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 200 — CartResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Cart 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
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Cart 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
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Cart does not exist |
Admin analytics endpoints
Base path: /admin/cart-analytics. All endpoints accept the same range query unless noted.
Common query — AnalyticsRangeQuery
| Name | Type | Default | Notes |
|---|---|---|---|
from | ISO datetime? | 30 days ago | Inclusive lower bound |
to | ISO datetime? | now | Inclusive 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: CartPortinterface 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.
| Event | Constant | Class | Fired when |
|---|---|---|---|
cart.created | CART_CREATED | CartCreatedEvent | A cart is freshly minted |
cart.item.added | CART_ITEM_ADDED | CartItemAddedEvent | A new PRODUCT line is created |
cart.item.quantity.changed | CART_ITEM_QUANTITY_CHANGED | CartItemQuantityChangedEvent | An existing line's quantity changes (add-to-existing or PATCH) |
cart.item.removed | CART_ITEM_REMOVED | CartItemRemovedEvent | A PRODUCT line is removed |
cart.coupon.applied | CART_COUPON_APPLIED | CartCouponAppliedEvent | A coupon is applied successfully |
cart.coupon.removed | CART_COUPON_REMOVED | CartCouponRemovedEvent | A customer-initiated removal |
cart.coupon.auto.removed | CART_COUPON_AUTO_REMOVED | CartCouponAutoRemovedEvent | Revalidation drops a coupon that lost eligibility |
cart.gift.selected | CART_GIFT_SELECTED | CartGiftSelectedEvent | Customer picked a variant for a picker rule |
cart.gift.attached | CART_GIFT_ATTACHED | CartGiftAttachedEvent | A GIFT line is added by the reconciler |
cart.gift.removed | CART_GIFT_REMOVED | CartGiftRemovedEvent | A GIFT line is removed by the reconciler |
cart.merged | CART_MERGED | CartMergedEvent | A guest cart is merged into a customer cart via /sync |
cart.checkout.prepared | CART_CHECKOUT_PREPARED | CartCheckoutPreparedEvent | Inventory reserved, priced snapshot returned |
cart.abandoned | CART_ABANDONED | CartAbandonedEvent | Sweep 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 viaInventoryService.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).
| Job | Cadence | Purpose |
|---|---|---|
cart-abandon-sweep | every 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-discarded | weekly Sunday 03:00 UTC (CART_PURGE_DISCARDED_CRON) | Hard-delete carts that have been discarded longer than CART_PURGE_DISCARDED_AFTER_DAYS. |
cart-analytics-record | per-event | Persists each emitted cart event into the partitioned analytics table. |
cart-analytics-hourly-rollup | 5 * * * * | Aggregates the prior hour's events into hourly metrics. |
cart-analytics-daily-rollup | 30 0 * * * | Aggregates the prior day's events into daily metrics. |
cart-analytics-partition-maint | 0 2 * * * | Idempotent partition maintenance — creates upcoming partitions, drops those past CART_ANALYTICS_RAW_RETENTION_MONTHS. |
Configuration
| Env var | Default | Effect |
|---|---|---|
CART_ABANDON_AFTER_MINUTES | 1440 (24h) | Inactivity threshold before a cart is marked abandoned. |
CART_PURGE_DISCARDED_AFTER_DAYS | 90 | Retention for discarded carts before hard-delete. |
CART_ANALYTICS_RAW_RETENTION_MONTHS | 3 | Retention 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.
Banner Module
HTTP surface for promotional banners attached to catalog entities (categories, brands, tags, ingredients) — admin CRUD + unauthenticated storefront read by entity slug.
Catalog Module
HTTP surface for the product catalog — vendor-facing CRUD over products, variants, options, tabs; vendor-side "request a new taxonomy entry" workflow; admin CRUD + approval over…