Skip to main content

Cart API

Agent-built named quote drafts. A sales agent creates a cart, adds listing items with dates/guests, layers on per-date meal and VAS selections, and ends up with a priced quote they can share with a customer. Carts persist across sessions and re-price live on every read.

This is an agent tool, not a customer-facing cart. Every endpoint requires a JWT and only returns carts owned by the JWT subject.


Concepts

Identity

Every cart belongs to the agent identified by the JWT sub claim. An agent only sees and edits carts they created. There is no anonymous / bev cart, no shared cart, and no "checkout to a customer's user."

Name

A cart's name is required and unique per agent for non-archived carts. Archive a cart to free its name. Names are how agents find drafts in the list view.

Hybrid pricing — snapshot + drift

Every priced row (cart_item, cart_item_meal, cart_item_vas) stores a price snapshot at the time it's written. On every read the server recomputes today's price and compares; lines whose snapshot has drifted carry a priceDrift block:

"priceDrift": {
"snapshot": 16800,
"current": 17500,
"delta": 700,
"snapshotAt": "2026-05-12T10:14:00Z"
}

The cart's totals are always the current prices. The UI can render snapshot next to current to flag drift, and the agent can call POST /cart/{id}/refresh-prices to accept the new prices and clear all drift flags before sharing the cart with a customer.

"Unsure of dates"

Meal and VAS selections can attach to specific dates (the chips in the UI) or apply across all nights of the stay. To apply across all nights, pass unsureOfDates: true (or omit dates):

{ "mealId": "BREAKFAST", "unsureOfDates": true, "adults": 1, "children": 2 }

Internally this writes a single row with meal_date = NULL; pricing fans it out across every night of the item's checkIn..checkOut window.

Guests — primary + secondary

Separate from the per-item guest counts (adults / children / infants, which size the room rate), a cart can carry guest identities: one primary (lead) guest and any number of secondary guests.

These are stored as a snapshot on the cart — the canonical guest directory is never modified during quoting. Each saved guest also keeps a nullable guestId linking back to the matched directory record (when one was found), which a later booking step uses to reconcile (update vs. create).

The typical flow:

  1. The agent types a phone or email. The UI calls GET /api/v1/cart/guest-lookup (read-only) to check the guest directory.
  2. On a hit, the form prefills from the returned details (and carries the guestId through). On a miss (found: false), the agent captures a new guest from scratch.
  3. The agent's edits are saved on the cart via the primary / secondary guest endpoints — not written back to the directory.

Phone takes precedence over email when both are supplied. Re-setting the primary guest overwrites it (1:1); secondary guests accumulate (1:N) and are returned in position order.

Contact validation

When a guest's phone or email is saved (set primary, add secondary, or patch secondary):

  • Phone must be a bare 10-digit local number — no country code, spaces, or punctuation (e.g. 9876543210). The guest directory doesn't store a country code yet (a planned migration), so the cart keeps phones country-code-free; the country code is captured separately in its own field. A non-conforming value is rejected with a clear message. Email is stored lower-cased.
  • Primary guest — directory uniqueness. The primary guest's phone/email are checked against the guest directory (DB). An agent can freely override them, but not to a value already registered to a different guest — that's rejected (This phone number is already registered to another guest…). Re-saving a guest's own phone/email is fine: the match is anchored by the guestId carried through from guest-lookup.
  • Secondary guests — cart-scoped only. A secondary guest is a companion captured on the cart, not a directory-linked record, so it has no guestId and is never checked against the directory. They're checked only within the cart: a secondary guest can't be the same person as the primary guest, and duplicate secondary guests are rejected. Identity is matched on phone, then email — so two people with no contact details at all are always allowed. Setting the primary guest is likewise rejected if that person is already a secondary guest on the cart.

Amount modification — final-total override

An agent can request a change to the cart's final (post-GST) total — the "Modify Amount Request" box. They enter the grand total the customer should pay (targetTotal), a reason, and optionally an attachment link.

  • Decrease (targetTotal below the current total) → a discount. It's recorded as a PENDING request and needs approval; the cart total is unchanged until an approver approves it.
  • Increase (targetTotal above the current total) → a surcharge. It's applied immediately (no approval); the increase amount is recorded for visibility.

Because the target is post-GST but the override behaves like a coupon (a pre-GST line), the system converts the requested change to a pre-GST adjustment = delta / 1.18 (GST is 18%). Applying that to the taxable base re-runs GST so the recomputed grand total lands exactly on the target. The adjustment shows as a MOD-xxxx line (summary.modificationAdjustment — negative for a discount, positive for a surcharge).

At most one modification is effective (applied/approved) on a cart at a time; a new request supersedes the prior. At most one request can be PENDING. The current modification (pending if any, else the effective one) is surfaced on the cart's amountModification block.

Approval is gated by the cart_approver table: only a user mapped as an approver for the cart's channel can approve/reject, and the discount must be within their max_discount_percentage (else the approval is rejected with a clear message). Approve/reject is the approver's action; the requesting agent can also withdraw a still-pending request.

Inventory hold ("Hold without pay")

An agent can place a temporary inventory hold on a cart — locking its selected rooms for the stay nights without payment, so no one else can take them while the customer decides. The hold auto-releases at a daily cutoff if not confirmed.

  • Placing a hold (POST /{cartId}/hold) snapshots the cart's selected property-units for every stay night and locks them. Availability everywhere subtracts units held by other live holds, so the same room can't be double-held or sold.
  • Combined units cascade: holding a property also reserves its combined sub-units and its parent combination units (× block_quantity) for the same nights — the whole combine group is blocked.
  • Per-agent cap: each agent may hold at most N carts at once (cart.hold.max-active-per-agent, default 5, overridable per agent via agent_hold_cap). The cart response carries holdUsage → "You're using active of max active holds". Exceeding it is rejected.
  • Auto-release is driven by a Temporal durable timer to the cutoff (cart.hold.release-time, default 23:00 IST). A hold past its cutoff stops locking inventory immediately (lazy expiry) even before the timer flips it to EXPIRED. Releasing or confirming early cancels the timer.
  • Channel-manager block: on hold, a FAKE "Confirm" reservation is pushed to Staah for each held property (blocking the rooms at the channel manager); on release/expiry a "Cancel" is pushed to unblock. A confirmed hold keeps its block (it converts to a booking). The agent (and stakeholders) are reminded on placement/resolution.
  • States: ACTIVE → RELEASED | EXPIRED | CONFIRMED. At most one ACTIVE hold per cart. A hold needs the cart to have selected rooms (no property breakdown → nothing to lock).

The cart's live hold (status, expiresAt, locked units) is surfaced on the cart's hold block.


Endpoint summary

MethodPathPurpose
POST/api/v1/cartCreate a named cart
GET/api/v1/cartList my carts (paginated, filter by status)
GET/api/v1/cart/{cartId}Fetch one cart, re-priced, with drift flags
PATCH/api/v1/cart/{cartId}Rename / relabel / change channel / change status
DELETE/api/v1/cart/{cartId}Archive (soft-delete)
POST/api/v1/cart/{cartId}/duplicateClone with a new name; prices re-snapshotted
POST/api/v1/cart/{cartId}/refresh-pricesAccept current prices; clears all drift flags
POST/api/v1/cart/{cartId}/itemsAdd a listing item
PATCH/api/v1/cart/{cartId}/items/{itemId}Update dates / guests / quantity / position
DELETE/api/v1/cart/{cartId}/items/{itemId}Remove an item
POST/api/v1/cart/{cartId}/items/{itemId}/mealsUpsert a meal selection
DELETE/api/v1/cart/{cartId}/items/{itemId}/meals/{mealId}Drop a meal selection
POST/api/v1/cart/{cartId}/items/{itemId}/vasUpsert a VAS selection
DELETE/api/v1/cart/{cartId}/items/{itemId}/vas/{vasId}Drop a VAS selection
GET/api/v1/cart/guest-lookupLook up a directory guest by phone/email (prefill)
POST/api/v1/cart/{cartId}/primary-guestSet / replace the cart's primary (lead) guest
DELETE/api/v1/cart/{cartId}/primary-guestRemove the primary guest
POST/api/v1/cart/{cartId}/secondary-guestsAdd a secondary guest
PATCH/api/v1/cart/{cartId}/secondary-guests/{guestId}Update a secondary guest
DELETE/api/v1/cart/{cartId}/secondary-guests/{guestId}Remove a secondary guest
POST/api/v1/cart/{cartId}/modificationRequest a final-total override (decrease=approval, increase=immediate)
DELETE/api/v1/cart/{cartId}/modificationWithdraw the pending modification request
POST/api/v1/cart/{cartId}/modification/{modId}/approveApprover: approve a pending decrease
POST/api/v1/cart/{cartId}/modification/{modId}/rejectApprover: reject a pending request
POST/api/v1/cart/{cartId}/holdPlace an inventory hold ("Hold without pay")
DELETE/api/v1/cart/{cartId}/holdRelease the hold (free the units)
POST/api/v1/cart/{cartId}/hold/confirmConfirm the hold (stops auto-release)

Every mutating endpoint returns the full re-priced cart, so the UI never needs a follow-up GET. (guest-lookup is the one read-only exception — it returns prefill details, not a cart.)


Create + populate flow

# 1. Create the cart
curl -X POST /api/v1/cart \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Sharma family — Goa Mar 2026",
"customerLabel": "Anita Sharma, +91 9XXXXXX",
"channelId": "B2C"
}'

# 2. Add a listing item (dedup: re-adding same listing+dates sums unit_quantity)
# `selectedProperties` is the per-property breakdown the listing-search result returned,
# passed through unchanged so the cart remembers which rooms inside the villa were picked.
curl -X POST /api/v1/cart/$CART_ID/items \
-H "Authorization: Bearer $TOKEN" \
-d '{
"listingId": "lst_nimishka_villa",
"checkIn": "2026-03-18",
"checkOut": "2026-03-20",
"adults": 1, "children": 2, "unitQuantity": 1,
"selectedProperties": [
{ "propertyId": "prop_deluxe", "quantity": 2 },
{ "propertyId": "prop_suite", "quantity": 1 }
]
}'

# 3. Add breakfast across all nights ("Unsure of dates")
curl -X POST /api/v1/cart/$CART_ID/items/$ITEM_ID/meals \
-H "Authorization: Bearer $TOKEN" \
-d '{
"mealId": "BREAKFAST",
"unsureOfDates": true,
"adults": 1, "children": 2
}'

# 4. Add lunch only on specific dates
curl -X POST /api/v1/cart/$CART_ID/items/$ITEM_ID/meals \
-H "Authorization: Bearer $TOKEN" \
-d '{
"mealId": "LUNCH",
"dates": ["2026-03-18", "2026-03-19"],
"adults": 1, "children": 2
}'

# 5. Add a VAS — BBQ Non Veg, priced per session
curl -X POST /api/v1/cart/$CART_ID/items/$ITEM_ID/vas \
-H "Authorization: Bearer $TOKEN" \
-d '{ "vasId": "BBQ_NV", "dates": ["2026-03-19"], "quantity": 1 }'

# 6. When ready to share with the customer — accept current prices
curl -X POST /api/v1/cart/$CART_ID/refresh-prices -H "Authorization: Bearer $TOKEN"

Adding guests

# 1. Look up the lead guest by phone (or email) to prefill the form.
# Returns { "found": false } when nobody matches — capture a new guest then.
curl -G /api/v1/cart/guest-lookup \
-H "Authorization: Bearer $TOKEN" \
--data-urlencode "phone=9XXXXXXXXX"
# → { "found": true, "guestId": "guest_abc123", "salutation": "Ms",
# "firstName": "Anita", "lastName": "Sharma", "email": "[email protected]",
# "phone": "9XXXXXXXXX", "city": "Mumbai" }

# 2. Set the primary guest (carry guestId through from the lookup, if there was a hit).
# Re-POSTing overwrites the existing primary guest.
curl -X POST /api/v1/cart/$CART_ID/primary-guest \
-H "Authorization: Bearer $TOKEN" \
-d '{
"guestId": "guest_abc123",
"salutation": "Ms", "firstName": "Anita", "lastName": "Sharma",
"email": "[email protected]", "phone": "9XXXXXXXXX",
"countryCode": "+91", "city": "Mumbai"
}'

# 3. Add secondary guests (one call each; many allowed).
curl -X POST /api/v1/cart/$CART_ID/secondary-guests \
-H "Authorization: Bearer $TOKEN" \
-d '{ "firstName": "Raj", "lastName": "Sharma", "position": 0 }'

# 4. Patch a secondary guest (only the fields you send change).
curl -X PATCH /api/v1/cart/$CART_ID/secondary-guests/$GUEST_ID \
-H "Authorization: Bearer $TOKEN" \
-d '{ "phone": "9YYYYYYYYY" }'

# 5. Remove a secondary guest.
curl -X DELETE /api/v1/cart/$CART_ID/secondary-guests/$GUEST_ID -H "Authorization: Bearer $TOKEN"

Guest details are stored on the cart only — they never modify the canonical guest directory. The guestId is preserved so a later booking step can reconcile against the matched directory record.


Modifying the final amount

# Current grand total is, say, ₹39,648 (post-GST).

# DECREASE → discount, needs approval. The cart total does NOT change yet.
curl -X POST /api/v1/cart/$CART_ID/modification \
-H "Authorization: Bearer $TOKEN" \
-d '{ "targetTotal": 30000, "reason": "Loyalty goodwill", "attachmentUrl": "https://files/approval.pdf" }'
# → cart.amountModification = { type: "DECREASE", status: "PENDING", delta: 9648, ... }
# summary.grandTotal still 39648.

# Approver (must be in cart_approver for the cart's channel, within their max_discount_percentage):
curl -X POST /api/v1/cart/$CART_ID/modification/$MOD_ID/approve -H "Authorization: Bearer $APPROVER_TOKEN"
# → status APPROVED, summary.modificationAdjustment = -8176.27, summary.grandTotal = 30000.

# …or reject:
curl -X POST /api/v1/cart/$CART_ID/modification/$MOD_ID/reject \
-H "Authorization: Bearer $APPROVER_TOKEN" -d '{ "note": "Discount too steep" }'

# INCREASE → surcharge, applied immediately (no approval).
curl -X POST /api/v1/cart/$CART_ID/modification \
-H "Authorization: Bearer $TOKEN" \
-d '{ "targetTotal": 50000, "reason": "Premium add-ons" }'
# → status APPLIED, summary.modificationAdjustment = +8772.88, summary.grandTotal = 50000.

# Agent withdraws a still-pending request:
curl -X DELETE /api/v1/cart/$CART_ID/modification -H "Authorization: Bearer $TOKEN"

Holding inventory ("Hold without pay")

# Place a hold — locks the cart's selected rooms (and their combine group) for the stay nights.
# Rejects if the cart is already held, the agent is at their hold cap, no rooms are selected,
# or the units are no longer available.
curl -X POST /api/v1/cart/$CART_ID/hold -H "Authorization: Bearer $TOKEN"
# → cart.hold = { status: "ACTIVE", expiresAt: "…T17:30:00Z", units: [...] }
# cart.holdUsage = { active: 2, max: 5 }
# A fake "Confirm" reservation is pushed to Staah to block the rooms; the agent is reminded.
# The hold auto-releases at the daily cutoff (Temporal timer) unless confirmed.

# Confirm the hold (e.g. payment received) — stops auto-release; the Staah block is kept.
curl -X POST /api/v1/cart/$CART_ID/hold/confirm -H "Authorization: Bearer $TOKEN"

# Release the hold early — frees the units and pushes a "Cancel" to Staah.
curl -X DELETE /api/v1/cart/$CART_ID/hold -H "Authorization: Bearer $TOKEN"

Cart response shape

{
"id": "9e1f…",
"name": "Sharma family — Goa Mar 2026",
"customerLabel": "Anita Sharma, +91 9XXXXXX",
"createdByUserId": "auth0|abc123",
"channelId": "B2C",
"currency": "INR",
"status": "DRAFT",
"primaryGuest": {
"guestId": "guest_abc123",
"salutation": "Ms",
"firstName": "Anita",
"lastName": "Sharma",
"email": "[email protected]",
"phone": "9XXXXXXXXX",
"countryCode": "+91",
"city": "Mumbai",
"dob": "1990-04-12",
"anniversary": "2015-11-30"
},
"secondaryGuests": [
{ "id": "f3a1…", "position": 0, "firstName": "Raj", "lastName": "Sharma" },
{ "id": "b7c2…", "position": 1, "firstName": "Priya", "lastName": "Sharma" }
],
"amountModification": {
"id": "9c2e…",
"type": "DECREASE",
"status": "PENDING",
"originalTotal": 39648,
"targetTotal": 30000,
"delta": 9648,
"preGstAdjustment": 8176.27,
"adjustmentCode": "MOD-7A3F",
"reason": "Loyalty goodwill",
"requestedBy": "agent-1"
},
"hold": {
"id": "h_…",
"status": "ACTIVE",
"heldAt": "2026-06-18T06:00:00Z",
"expiresAt": "2026-06-18T17:30:00Z",
"units": [
{ "propertyId": "prop_deluxe", "date": "2026-03-18", "quantity": 2 },
{ "propertyId": "prop_deluxe", "date": "2026-03-19", "quantity": 2 }
]
},
"holdUsage": { "active": 2, "max": 5 },
"items": [
{
"id": "ci_…",
"listingId": "lst_nimishka_villa",
"listingTitle": "Nimishka Villa",
"listingCity": "Kasauli",
"checkIn": "2026-03-18",
"checkOut": "2026-03-20",
"nights": 2,
"adults": 1, "children": 2, "unitQuantity": 1,
"selectedProperties": [
{ "propertyId": "prop_deluxe", "selectedQuantity": 2 },
{ "propertyId": "prop_suite", "selectedQuantity": 1 }
],
"meta": { "image": "https://img.example/prop_deluxe.jpg" },
"base": {
"snapshot": 16800, "current": 16800,
"lineTotal": 33600,
"priceDrift": null,
"nights": [
{
"date": "2026-03-18",
"subtotal": 16800,
"properties": [
{ "propertyId": "prop_deluxe", "quantity": 2 }
]
},
{
"date": "2026-03-19",
"subtotal": 16800,
"properties": [
{ "propertyId": "prop_deluxe", "quantity": 2 }
]
}
]
},
"meals": [
{
"mealId": "BREAKFAST",
"dates": ["2026-03-18", "2026-03-19"],
"unsureOfDates": true,
"adults": 1, "children": 2,
"perAdultSnapshot": 600, "perAdultCurrent": 600,
"perChildSnapshot": 300, "perChildCurrent": 300,
"lineTotal": 2400,
"priceDrift": null,
"occurrences": [
{ "date": "2026-03-18", "adults": 1, "perAdultPrice": 600, "children": 2, "perChildPrice": 300, "subtotal": 1200 },
{ "date": "2026-03-19", "adults": 1, "perAdultPrice": 600, "children": 2, "perChildPrice": 300, "subtotal": 1200 }
]
}
],
"vas": [
{
"vasId": "BBQ_NV",
"dates": ["2026-03-19"],
"unsureOfDates": false,
"quantity": 1,
"pricingType": "TIERED",
"tierNote": "Min 6 guests required. You have 3, you'll still be charged for 6.",
"unitSnapshot": 49500, "unitCurrent": 49500,
"lineTotal": 49500,
"priceDrift": null,
"occurrences": [
{ "date": "2026-03-19", "quantity": 1, "unitPrice": 49500, "subtotal": 49500 }
]
}
],
"itemSubtotal": 85500
}
],
"summary": {
"baseSubtotal": 33600,
"mealsSubtotal": 2400,
"vasSubtotal": 49500,
"offersDiscount": 0,
"modificationAdjustment": null,
"taxableSubtotal": 85500,
"gst": 15390,
"grandTotal": 100890,
"driftedLineCount": 0
}
}

Behavior notes

  • Re-adding the same listing + dates to a cart sums the unitQuantity rather than creating a duplicate row. Matches the screenshot's 4 Units counter behavior. selectedProperties follow the same rule — re-adding { "propertyId": "prop_deluxe", "quantity": 1 } twice produces selectedQuantity: 2 for that property line, not two rows.
  • selectedProperties is optional. Omit it and the cart records the listing-level booking only; pass it (typically from the listing-search response) and the cart preserves the property breakdown for display. It's informational — the room rate math runs on listing.price × unit_quantity × nights, not per-property.
  • meta is an extensible per-item metadata snapshot stored as JSON. Today it carries a single representative image — the image of the first selectedProperties entry, looked up server-side at add-to-cart time and frozen on the item so the cart can render a thumbnail without re-fetching. It's absent when no property (or no image) was supplied. New fields can be added to meta over time without a breaking change.
  • Upserting a meal/VAS replaces all prior selections for that mealId/vasId on the item. To make incremental edits, send the full desired state.
  • Guests (primaryGuest / secondaryGuests) are guest identities captured on the cart, distinct from the per-item guest counts. They're absent from the response until set. The primary guest is 1:1 (re-POST overwrites); secondary guests are 1:N and ordered by position. Setting them never mutates the canonical guest directory — guest-lookup reads it, the cart endpoints write only to the cart. secondaryGuests[].id is the row id to pass to the secondary-guest PATCH/DELETE.
  • status transitions the agent can perform via PATCH: DRAFT → SHARED → CHECKED_OUT. Archiving is done via DELETE, not PATCH.
  • refresh-prices is idempotent and only touches snapshots — it does not change any selection, dates, or quantities.
  • duplicate clones the items / meals / VAS into a new cart, but re-snapshots all prices from today — the clone starts with zero drift.
  • GST is fixed at 18%. Offer/coupon resolution is currently a no-op (offersDiscount: 0); a future task wires this into OfferResolutionService.
  • Tier note on a VAS line is surfaced when pricingType = TIERED or BASE_PLUS_OVERAGE and the item's guest count is below the minimum recorded in pricingConfig. This is informational; the full per-tier math is not replicated in the cart engine yet — the unit price is charged per occurrence.

Invoice-ready breakdown

Each priced section carries a fan-out array so an invoice can render line-by-line:

SectionFan-out fieldOne row perSum invariant
basenights[]night of the stayΣ subtotal == base.lineTotal
meals[i]occurrences[]date the meal applies toΣ subtotal == meals[i].lineTotal
vas[i]occurrences[]date the VAS applies toΣ subtotal == vas[i].lineTotal

base.nights[].properties[] echoes the selectedProperties allocation under each night. The per-property unitPrice / subtotal are currently null (the room rate is driven by the listing, not per property) — the field reserves the shape for future per-property pricing without a breaking change.

For "Unsure of dates" meals/VAS, the row's dates[] is the fanned-out list of nights and occurrences[] has one entry per night with identical pricing — the fan-out is what makes the invoice line-itemised even when the agent didn't pick specific dates.


Pre-share checklist

Before sharing a cart with a customer:

  1. GET /cart/{id} — confirm summary.driftedLineCount === 0.
  2. If it isn't, decide whether to refresh-prices (accept the new prices) or call the customer to re-confirm.
  3. PATCH /cart/{id} with status: "SHARED" so the cart is visible on your "shared" tab.