Cart Payment & Booking Journey
This is the end-to-end guest journey from a shared cart to a confirmed booking: initiating payment with a gateway (Razorpay or Juspay), polling for status, and the durable, idempotent booking creation that follows. All guest-facing endpoints are public (resolved by the cart's opaque share token); the backfill is authenticated.
Overview
Agent builds cart ──► shares (token) ──► Guest opens shared cart
│
GET /shared-cart/{token} (view the deal)
│
POST /shared-cart/{token}/payment/order (initiate payment)
│
Guest pays at Razorpay / Juspay
│
┌──────────────────────────┼──────────────────────────┬─────────────────────────┐
POST .../payment/callback POST .../payment/verify POST /payments/{gateway}/webhook
(frontend, signature) (backend asks gateway) (gateway, source of truth)
└──────────────────────────┴──────────────────────────┴─────────────────────────┘
│ verified with the gateway (never the client)
▼
Booking creation workflow (Temporal saga)
│
GET /shared-cart/{token}/payment/status (poll until BOOKED)
A single payment can only ever create one booking — concurrent callback + webhook (or retries) collapse to the same booking (see Idempotency).
1. View the shared cart
GET /api/v1/shared-cart/{token}
Returns the guest-facing deal with a state (VALID / EXPIRED / INVALID) and a payment block
containing grandTotal, depositPercentage, payNow, and remaining. The amount the guest pays now is
payNow (the deposit split) or grandTotal when there is no split.
2. Initiate payment
POST /api/v1/shared-cart/{token}/payment/order
Content-Type: application/json
{ "gateway": "RAZORPAY" }
The server computes the pay-now amount, creates the gateway order, and records an order→cart mapping (so the webhook can resolve the cart later). Response:
{ "gateway": "RAZORPAY", "orderId": "order_Nk...", "publicKey": "rzp_live_...", "amount": 11800.00, "currency": "INR" }
The frontend launches the gateway checkout with orderId + publicKey.
What is passed to the gateway (mirrors the PMS): the cart's coupon / bank-offer codes and refs travel
as Razorpay notes / Juspay metadata. The gateway offer is resolved server-side from the cart's
bank-offer code (bank_offers.pg_offer_id) and passed as Razorpay offer_id / Juspay
offer_details.offer_code — the client never supplies it. The charged amount is always the
server-computed pay-now (a client-supplied amount is never trusted).
3. Confirm the payment
After checkout completes, the frontend posts the gateway's verification fields:
POST /api/v1/shared-cart/{token}/payment/callback
Content-Type: application/json
{
"gateway": "RAZORPAY",
"params": {
"razorpay_order_id": "order_Nk...",
"razorpay_payment_id": "pay_Nk...",
"razorpay_signature": "<hex hmac>"
}
}
- Razorpay is verified by HMAC-SHA256 of
order_id|payment_id(callback) / the raw body (webhook). - Juspay is verified by re-fetching the order status from Juspay — only
CHARGEDcounts. For Juspay,paramscarries{ "order_id": "..." }.
Or: verify with the gateway (recommended after a redirect)
Instead of posting gateway fields, the frontend can ask the backend to confirm with the gateway — the client asserts nothing about whether money arrived:
POST /api/v1/shared-cart/{token}/payment/verify
The server looks up the cart's latest order, queries the gateway's API for its authoritative status
(Razorpay orders/{id} ⇒ paid + the captured payment; Juspay orders/{id} ⇒ CHARGED), and creates the
booking only if the gateway confirms payment — otherwise it returns { "status": "PENDING" }. This is the
preferred confirmation path (the older client-asserted payment-confirmed endpoint has been removed).
On success the response is the booking:
{ "bookingId": "…", "code": "ELV-…", "status": "CREATED", "grandTotal": 11800.00, "paidAmount": 11800.00, "outstandingAmount": 0.00 }
status is CREATED on first success, ALREADY_EXISTS if the cart was already booked, or PROCESSING
if a concurrent confirm is still running.
4. Webhook (source of truth)
POST /api/v1/payments/{gateway}/webhook
Gateways call this independently of the callback. It is signature-verified (Razorpay) or status-verified
(Juspay), resolves the cart from the order mapping, and drives the same idempotent booking creation. It
always returns 200 so the gateway does not retry-storm.
5. Poll for status
GET /api/v1/shared-cart/{token}/payment/status
{ "state": "BOOKED", "bookingId": "…", "gateway": "RAZORPAY" }
state is PENDING (no payment yet), PROCESSING (paid, booking in progress), BOOKED (done, with
bookingId), or FAILED. The frontend polls this after checkout/redirect — particularly for the Juspay
redirect flow where the booking may complete via the webhook.
Booking creation (Temporal saga)
Confirmation starts a durable workflow (id booking-cart-<cartId>) that:
- Reserves on Livbnb up front for Livbnb-ingested properties (its id becomes the booking id).
- Writes the booking + all detail records (properties, units, meals, VAS, secondary guests) in one transaction, copying the cart snapshot.
- Reserves inventory (atomic decrement per property-night).
- Pushes to Staah (and onward to OTAs), expanding combination properties into rooms.
It is a saga: any failure compensates in reverse — cancel the Staah reservation (re-pushing the
same payload with status Cancel), release the reserved inventory, and cancel the booking
(status CANCELLED, cart unlinked for retry) — then the workflow fails. Physical units are
auto-allocated for non-Livbnb properties (Livbnb owns its own inventory).
There is no auto-refund: because the guest's payment is already captured, any post-payment failure
emails the configured recipients (booking.alert.recipients, via AWS SES) so the team reconciles or
refunds manually.
Pricing & GST
The booking copies the cart's priced amounts, so cart and booking always agree:
- GST is per-unit, per-night, date-aware (India GST 2.0): a room tariff ≤ ₹7,499/night is 5% (stays on/after 22 Sep 2025; 12% before), and 18% above ₹7,499. Meals & VAS are 18%.
- Tax is on the gross amount; discounts apply after tax — a coupon/bank-offer no longer reduces GST.
- A channel price-change multiplier is applied to the per-night unit rate (price increase at unit level).
- A platform fee (2.04%) on stay + meals is recorded on the booking/properties and deducted from the unit-level owner payout.
The booking stores the stay amount in the net_amount_* columns; meals (meal_cost) and VAS
(net_vas_amount_before_tax) are separate. paid_amount / outstanding_amount reflect the full grand
total the guest pays.
Idempotency
One payment ⇒ at most one booking, enforced by four layers:
payment_eventUNIQUE(gateway, payment_ref)— atomic single-winner per payment; a duplicate reads back the winner's booking.cart.booking_id— one booking per cart (a second payment returns the existing booking).- Temporal workflow id
booking-cart-<cartId>— concurrent starts collapse; the loser awaits the running workflow's result. - The booking write is one transaction and re-checks existence on retry.
Status reference
The booking-creation response (/payment/callback, /payment/verify) returns:
status | Meaning |
|---|---|
CREATED | Booking created on this call |
ALREADY_EXISTS | This cart was already converted — returns the existing bookingId (idempotent) |
PROCESSING | A concurrent confirm is still running |
The poll response (/payment/status):
state | Meaning |
|---|---|
PENDING | No payment received yet |
PROCESSING | Paid; booking in progress |
BOOKED | Done — carries bookingId |
FAILED | Last payment attempt failed |
Failure handling
There is no auto-refund. If a booking fails after the payment was captured, an alert email is sent to
the configured recipients (booking.alert.recipients, via AWS SES) so the team can reconcile / refund
manually. The booking saga rolls back cleanly: the Staah reservation is cancelled (same payload, status
Cancel), reserved inventory is released, and the booking is cancelled — leaving status PENDING.
Offline payments
When a guest pays outside the gateway (UPI/cash/bank transfer), the agent records it from the cart instead of the online flow above:
POST /api/v1/cart/{cartId}/offline-payment/upload-url→ a presigned S3 URL to upload the proof image to (the private proof bucket).POST /api/v1/cart/{cartId}/offline-payment→ creates the booking withpaymentStatus = PENDING(nothing paid yet), lays out the installment schedule for the chosen split (FULL / HALF / CUSTOM — the amount for FULL/HALF is derived server-side from the grand total), and records a PENDING payment for the finance team. These endpoints are agent-authenticated (JWT), not public.
A finance manager then reviews the proof and approves/rejects it via the
Payment Approval API (/api/v1/admin/payment-approvals, role CRS_FINANCE_MANAGER); on
approval the booking's paid_amount / outstanding_amount / payment_status are settled.
Admin: backfill legacy bookings
POST /api/v1/admin/bookings/backfill?bookingId={optional} (JWT required)
Idempotent and re-runnable — populates the booking detail tables from existing bookings, skipping rows already present.
API reference
The full request/response schemas are in the generated CRS API reference (the
Shared cart (guest) and Payment webhooks tags).