Cart Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for the Cart module.
Use this page for the cart domain: what it does for customers and admins, and how each flow behaves from start to finish.
| Source Type | Files or Docs | What Was Extracted |
|---|
| API | apps/api/src/modules/cart/customer/cart-customer.controller.ts, admin/cart-admin.controller.ts | Routes, status codes, idempotency scopes |
| Backend | cart-write.service.ts, cart-bulk.service.ts, cart-query.service.ts, cart-response.builder.ts | One-statement UPDATE, line validation, pricing |
| Schema | packages/db/src/schema/cart/{cart,cart-item,enums}.ts | Partial unique, caps, status enum |
| Inventory seam | InventoryAvailabilityService.checkQuantities | The stock question goes here |
| Error registry | apps/api/src/common/types/error-codes.ts (// CART) | CART_* codes |
| Field | Value |
|---|
| Module | cart |
| Submodule | N/A |
| Primary user value | A customer assembles what they intend to buy, with live prices and stock, a safe debounced stepper, and a strict pre-checkout validation pass |
| Actors | Customer (signed in), admin (read-only) |
| Main entry points | /api/mobile/cart (8 routes), /api/admin/carts (2 routes) |
| Main outputs | Whole-cart responses, badge counters, checkout validation reports |
| Related docs | API, Backend |
| Actor | Can Do | Cannot Do | Auth Requirement | Notes |
|---|
| Customer | Read cart, badge counters, checkout validation; add, set quantity, remove, clear, bulk-edit | Add a product that is not published/unlisted (404), exceed 50 distinct products (409) or 99 per line (409), mutate a locked cart (409) | JWT | CUSTOMER_READ 60/min, CUSTOMER_CART_MUTATION 60/min — account-keyed |
| Admin | List carts (always paginated), read one cart with lines | Mutate any cart, see the storefront product contract or the customer record | Admin JWT + Cart_READ | Read-only by design; no admin mutation planned |
| Checkout (future) | Lock the cart (checkout_locked), convert it | Forget the unlock on payment failure/cancellation/expiry | Internal | The obligation in §0 |
| Capability | Surface | Actor | Route/Trigger | State Read | State Written | Linked API Section |
|---|
| Read cart | Customer | Customer | GET /api/mobile/cart | cart, items, live products | — | API §4 |
| Badge counters | Customer | Customer | GET /api/mobile/cart/summary | cart, items | — (30s cache) | API |
| Checkout validation | Customer | Customer | GET /api/mobile/cart/checkout-validation | everything, fresh | — | API |
| Add item | Customer | Customer | POST /api/mobile/cart/items | cart, product | cart_item | API |
| Bulk edit | Customer | Customer | POST /api/mobile/cart/items/bulk | cart, products | many cart_item rows | API |
| Set quantity | Customer | Customer | PUT /items/:variantPublicId | cart, product | cart_item | API |
| Remove line | Customer | Customer | DELETE /items/:variantPublicId | cart | cart_item | API |
| Empty cart | Customer | Customer | DELETE /items | cart | cart_item rows | API |
| Admin list | Admin | Admin | GET /api/admin/carts | carts + aggregates | — | API |
| Admin detail | Admin | Admin | GET /api/admin/carts/:cartId | cart + lines | — | API |
The backend cannot debounce; the client does. What the backend guarantees: a coalesced flush is safe, and a retried flush cannot double-apply. The stepper uses PUT with an absolute quantity; retrying sets the same number twice, which is the same number.
| Branch | Condition | Behavior | Error/Result |
|---|
| Retry | Same absolute quantity | No double-apply | 200 |
| Product not in cart | First set | Upsert adds it | 200 |
quantity: 0 | Stepper at bottom | Line removed — even if the product was archived | 200 |
| Stale version | Another device wrote | 409 CART_VERSION_CONFLICT + current cart | Refetch and reapply |
| Version omitted | Product-card add | Last-write-wins | 200 |
| Resulting quantity > 99 | Accumulation | 409 CART_QUANTITY_LIMIT_EXCEEDED | Clamp at 99 |
POST /items is a delta — the only non-idempotent verb, because a product card cannot know the absolute target (the customer may already hold three in a cart the page never loaded). It is the only route taking an Idempotency-Key (optional): retry with the same key replays the stored response instead of adding again. Adding an out-of-stock product succeeds — stock is reported on the line and blocks checkout, never the add.
GET /checkout-validation re-reads everything and trusts nothing previously loaded — that re-read is the point. Product existence/purchasability, inventory shortfalls and price changes are re-checked; blockingReasons ⊆ empty, cart_locked, items_unavailable, insufficient_stock. Shipping, promotions and address validation are deliberately absent — the cart does not know the delivery address, and GET /mobile/shipping/quote already answers serviceability.
Invalid lines stay in the cart — the cart never silently removes anything — and are excluded from pricing.subtotal, with excludedItemCount saying how many.
Read-only: list (always paginated) and detail. The admin list aggregates over the full cart ⋈ cart_item ⋈ product join; totals are computed at live prices over every line, including ones the customer surface excludes as unbuyable — an operator looks at what is in the cart, not at what would be charged. Lines carry a three-field product reference, never the storefront contract; the customer reference is a public id and a name.
| From | Event/Action | To | Guard Condition | Side Effects |
|---|
active | checkout begins (future) | checkout_locked | Written by checkout — nothing in cart writes it | Holds the live-cart slot; mutations refuse |
checkout_locked | payment succeeds (future) | converted | — | Frees the slot (partial index excludes it) |
checkout_locked | payment fails / cancels / expiry (future) | active | Checkout must own this edge | Unlock; otherwise absorbing |
Empty and ready-for-checkout are derived, not stored; expired/abandoned was rejected because marking an untouched cart abandoned means the customer who returns in three weeks finds it gone — last_activity_at carries that signal instead.
| Flow | DB Writes | Cache Effects | Jobs | Realtime | Analytics | Notifications |
|---|
| Add/set/remove/clear/bulk | cart_item rows (+ cart bump) | summary cache cleared | — | — | — | — |
| Read cart | — | — | — | — | — | — |
| Summary | — | summary read (30s) | — | — | — | — |
| Checkout validation | — | — | — | — | — | — |
| Scenario | Trigger | User/System Experience | Recovery | Source |
|---|
| Locked cart | Checkout in flight | 409 CART_LOCKED_FOR_CHECKOUT | Go to the in-flight checkout | status guard |
| Version conflict | Concurrent device | 409 + current cart | Refetch and reapply | version check |
| Line cap | 50 distinct products | 409 CART_ITEM_LIMIT_REACHED | Remove something first | locked count |
| Quantity cap | Line > 99 by accumulation | 409 CART_QUANTITY_LIMIT_EXCEEDED | Clamp at 99 | locked count |
| > 99 in one request | Malformed request | 400 validation | Client fix | DTO |
| Bulk duplicate product | Same product twice | 400 CART_BULK_DUPLICATE_PRODUCT | Coalesce before sending | DTO |
| Add of unsaveable product | draft/archived/deleted/unknown | 404 — same code for all | Refresh the product | anti-enumeration |
- Actor capability diagram — §3/§4.
- Sequence diagram per major flow — §5.1/§5.4.
- State machine diagram — §7.1.
- Data side-effect diagram — §9.
- Error branch diagram — §10.
| Feature | Minor Behavior | Actor | Trigger | User/System Result | Backend Side Effect | Source |
|---|
| Stepper | Absolute PUT | Customer | Coalesced flush | Safe retry | upsert | service |
| Stepper | Zero removes | Customer | Bottom of stepper | Line gone, even archived | — | |
| Add | Idempotency header | Customer | Retried tap | No double add | interceptor | controller |
| Add | Out-of-stock allowed | Customer | Sold-out product | Line with reason | — | |
| Bulk | One version bump | Customer | Multi-line flush | No self-conflict | single bump | |
| Bulk | Cap after all ops | Customer | Remove 2 + add 2 at cap | Succeeds | evaluated once | |
| Read | Never creates cart | Customer | First visit | Empty cart, null cart | no insert | |
| Read | totalQuantity counts every line | Customer | Badge | Never drops on stock-out | — | |
| Price change | Acknowledge | Customer | Re-send quantity | Banner clears | snapshot refresh | |
| Summary | No version | Customer | Badge | No self-conflict | deliberate | |
| Admin list | No pagination=false | Admin | Badge | 400 | deliberate | |
| Admin list | Date-only createdTo | Admin | End of day | Inclusive whole day | deliberate | |
| Rule | Business Reason | Actor Impact | Enforced In | API Impact | Backend Impact | Tests |
|---|
| One statement = lock + status + version + bump | Nothing forgettable | Safe concurrent writes | SQL UPDATE | version semantics | WHERE clause | spec |
| Exact 50-line cap | Locked count | 12 concurrent adds admit one | locked count read | 409 | vs wishlist's approximate cap | concurrency spec |
last_known_unit_price not a snapshot | "Changed since you chose" signal | Banner | write-on-mutation | priceChange | no totals from it | spec |
| PUT upsert + zero removes | Stepper one path | Archived lines removable | upsert | 200 | — | |
| Add allows out-of-stock | Stock changes minute to minute | Line with reason | lifecycle gate only | — | blocks checkout not add | |
| No new stock utility | Reuse the seam | Consistent answers | checkQuantities | — | cart = 3rd consumer | reviewed |
| Oversell not invalid | available is 0 for oversell | Checkout not blocked | seam predicate | — | naive < would block forever | spec |
| Locked refuses all mutations | Checkout integrity | 409 | status guard | — | absorbing until checkout owns unlock | spec |
A cart line is (cart, variant), never (cart, product) | The 256GB and the 512GB of one phone are two independent, independently priced lines | Set-quantity and remove are addressed by variantPublicId; variant.name is null only when the variant IS the product | uq_cart_item_cart_id_variant_id | variant block on every item; path param is :variantPublicId | variant_id column, product_id retained denormalised | spec |
Removal that removes nothing does not bump version | A stale product-id in the removal path must not invalidate every other device's token for a no-op | 200, cart unchanged | CartWriteService.runRemoval splits lock+version-assert from the bump | — | bumpVersion called only when apply returns true | spec |
| Product Decision | User Benefit | Engineering Benefit | Alternative | Tradeoff | Risk |
|---|
| Absolute PUT for stepper | Retry-safe debounce | One code path | Delta PUT | Client must know quantity | Documented |
| One non-idempotent POST | Product card works blind | Single interceptor path | All-delta | Retry needs header | Optional key |
| Live data on read | Never stale | No snapshot sync | Snapshot on add | Read cost | Batched seam |
| Three stored states | Derived emptiness/readiness | Minimal writes | Five states | Abandoned not stored | last_activity_at |
| Locked absorbing | Frozen contract | Checkout owns return | Auto-unlock timer | No recovery until checkout | Prominent obligation |
| Admin read-only | No cart tampering | No audit burden | Admin edits | Operators can't fix | Accepted |
| Flow | Edge Case | Trigger | Expected Behavior | User/System Feedback | Source |
|---|
| Add | Retry without header | Double tap | Doubles (documented) | 200 | non-idempotent verb |
| Add | Retry with header | Same key | Replays stored response | 200 | interceptor |
| Bulk | Same LINE (resolved variant) twice | Bad client, or mixing "always send variant" with "never send it" in one batch | 400 | BULK_DUPLICATE_PRODUCT | two DIFFERENT variants of one product in one batch is legal |
| Bulk | Cap reached mid-batch | Remove+add at cap | Succeeds | — | evaluated once |
| Set | Product archived | Old line | Still removable | 200 | zero-removes exemption |
| Remove | Never saved | Any id, or a product id sent where a variant id belongs (both are uuid7) | 200 no-op, cart version not bumped | — | no CART_ITEM_NOT_FOUND, anti-enumeration |
| Read | No cart | First visit | Null cart, zeroed counters | — | GET never creates |
| Summary | Cached | Other device | Up to 30s stale | badge lag | TTL |
| Checkout validation | Price changed | Admin reprice | anyChanged true | — | never blocks |
| Untracked product | Available null | Line | Always valid | availableQuantity: null | seam |
| Flow | Reads | Writes | Cache | Jobs/Events | Response Fields |
|---|
| Read cart | cart, items, products, inventory | — | — | — | cart, customer, summary, items, pricing, validation |
| Summary | cart, items | — | 30s | — | totalItems, totalQuantity |
| Add/set/remove | cart, product | cart_item, cart bump | clear summary | — | whole cart |
| Bulk | cart, products | many cart_item | clear summary | — | whole cart |
| Validation | everything fresh | — | — | — | readyForCheckout + groups |
| Admin list | carts + aggregates | — | — | — | rows + pagination |