Skip to main content

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 CHARGED counts. For Juspay, params carries { "order_id": "..." }.

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:

  1. Reserves on Livbnb up front for Livbnb-ingested properties (its id becomes the booking id).
  2. Writes the booking + all detail records (properties, units, meals, VAS, secondary guests) in one transaction, copying the cart snapshot.
  3. Reserves inventory (atomic decrement per property-night).
  4. 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:

  1. payment_event UNIQUE(gateway, payment_ref) — atomic single-winner per payment; a duplicate reads back the winner's booking.
  2. cart.booking_id — one booking per cart (a second payment returns the existing booking).
  3. Temporal workflow id booking-cart-<cartId> — concurrent starts collapse; the loser awaits the running workflow's result.
  4. The booking write is one transaction and re-checks existence on retry.

Status reference

The booking-creation response (/payment/callback, /payment/verify) returns:

statusMeaning
CREATEDBooking created on this call
ALREADY_EXISTSThis cart was already converted — returns the existing bookingId (idempotent)
PROCESSINGA concurrent confirm is still running

The poll response (/payment/status):

stateMeaning
PENDINGNo payment received yet
PROCESSINGPaid; booking in progress
BOOKEDDone — carries bookingId
FAILEDLast 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:

  1. POST /api/v1/cart/{cartId}/offline-payment/upload-url → a presigned S3 URL to upload the proof image to (the private proof bucket).
  2. POST /api/v1/cart/{cartId}/offline-payment → creates the booking with paymentStatus = 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).