Skip to main content

Smart Collect booking payments

An agent booking for an agent group can pay with a Razorpay order raised on their group's own Smart Collect customer, on the dedicated IDFC Razorpay account. The booking confirms from Razorpay's own report that the order was paid — not from a human approving a screenshot.

Pay a cartPOST /api/v1/cart/{cartId}/smart-collect/order
Collect part of a booking balancePOST /api/v1/bookings/{bookingId}/smart-collect/order — body { "amount": 50000.00 }
Confirmthe existing /api/v1/shared-cart/{token}/payment/verify (cart) and /api/v1/pay-booking/{bookingId}/verify (balance)
Feature keySMART_COLLECT_PAYMENTunseeded, so the rail is off until an admin grants it

Both endpoints need a JWT and return the order id + public key for Razorpay Checkout, plus the group's virtual account details so the agent can transfer instead.

A transfer into the virtual account does not confirm a booking

A bare virtual_account.credited carries no cart, no booking and no order. Matching one to a pending booking by amount and recency would confirm the wrong booking the first time two agents in one group paid the same figure on the same day. That money is the invoice rail's, and settling a booking from it stays a human decision on the existing approval screen.

One-time setup, per environment

1. Seed the RAZORPAY_IDFC gateway account

Through the payment-gateway admin API — never by a migration. PaymentGatewayAdminService is what encrypts the secret map with payment.crypto.master-key, and a Liquibase changelog has no access to that key; a seeded row would have to carry plaintext credentials.

ColumnValue
codeRAZORPAY_IDFC
gateway_typeRAZORPAY
activetrue
priority500
config{"keyId": "<razorpay.idfc.key-id>", "purpose": "SMART_COLLECT"}
secrets{"keySecret": "<razorpay.idfc.key-secret>", "webhookSecret": "<razorpay.idfc.webhook-secret>"}

The values must be the same Razorpay account as the razorpay.idfc.* properties. A startup check compares the two key ids and logs SMART COLLECT CREDENTIAL DRIFT at ERROR if they differ — grep for it after any credential rotation.

active stays true: byCode must resolve this row for every verify, webhook, status check and refund that follows a payment on it. config.purpose = SMART_COLLECT is what keeps guest checkout, and the legacy-row fallback, from ever picking it — PaymentGatewayResolver excludes it from both selectionOrder() and soleActiveByType().

priority is set high on purpose. The purpose filter is the real guard, but the column defaults to 100 and accounts are ordered priority ASC, code ASC — so a row left at the default would sort ahead of RAZORPAY_MAIN on code alone. Seeding it at 500 means a mistake in the filter is not also a mistake in the ordering.

2. Grant the feature

SMART_COLLECT_PAYMENT has no seeded rule, so it is denied everywhere until an admin adds one from PMS (feature access → user / agent group / channel). Nothing else has to be enabled.

3. Leave the webhook URL alone

The IDFC account's Razorpay webhook already points at /api/v1/payments/idfc-razorpay/webhook, and three consumers now read every delivery from it: the virtual-account collection ledger, the agent-group billing ledger, and the booking leg. Repointing it at the generic /api/v1/payments/{gateway}/webhook would silently stop the virtual-account ledger.

Each consumer runs in its own try block and fails independently, so one leg's failure never costs another its copy of the event. The response code is a deliberate retry instruction, not a blanket success/failure flag: Razorpay gets 503 only when the billing-ledger leg or the booking leg hits an unexpected failure (the case a redelivery could genuinely fix), and 200 for everything else — including a missing RAZORPAY_IDFC row, which is logged with the seeding instruction rather than turned into a retry loop that can never fix itself.

That exclusion is narrow on purpose, and it is drawn by cause, not by step. The account is resolved on its own, before the booking runs, and only a failure config alone explains is acked: no RAZORPAY_IDFC row, an unset payment.crypto.master-key, or a malformed config JSON. A transient failure resolving that same account — the database briefly unreachable — still asks for a redelivery, because on a booking-rail payment the billing-ledger leg returns early without touching the database again, leaving this leg the only one that would have asked. Anything the booking itself throws — an unresolvable cart, a cart with nothing payable — is a captured payment with no booking behind it and asks for a redelivery. The message is logged at ERROR once per process and DEBUG after: a deployment using Smart Collect invoicing with the booking rail off has no RAZORPAY_IDFC row and is not broken, and this endpoint also carries every virtual_account.* event. The row itself is re-read per delivery, so seeding it takes effect without a restart.

What confirms a booking

RouteTrigger
Primary — the agent returns from Checkout to the existing verify/callback endpointthe agent's browser
Safety netorder.paid / payment.captured on the webhook aboveRazorpay
Backstop — the per-cart reconciliation timer re-drives the verifyscheduler

All three end at payment_event UNIQUE(gateway, payment_ref), so a payment announced by all three books exactly once.

Booking money never reaches the billing ledger

A booking order carries agent_group_id in its notes, which is the fallback the agent-group billing ledger's payment ingestion uses when no invoice matches. PaymentIngestionService therefore ignores any payment whose order_id matches a payment_order row of ours: that money settles a booking and is recorded on payment_event. Without the guard the allocation engine would settle an open invoice with money that had already paid for a booking.

When something goes wrong

SymptomCauseFix
403 on either endpointSMART_COLLECT_PAYMENT not grantedadd a feature-access rule
403 "not in your team scope" on the booking endpointthe booking's agent is outside the caller's reporting subtreethe booking's own agent, their manager, or a CRS admin must collect it
400 "This cart belongs to another agent"the cart is not visible to the calling agentthe cart's own agent pays for it
400 "This cart has not been shared yet"the cart was built but never shared, so it has no share_token — and the share token is what the payment order, the reconciliation timer and the booking lookup are all keyed onshare the cart, then retry
400 "not assigned to an agent group"the agent's users.agent_group_id is nullassign the agent
400 "IDFC Razorpay is not configured"razorpay.idfc.* blankset the properties
404 "Unknown payment gateway account: RAZORPAY_IDFC"the row was never seededstep 1 above
SMART COLLECT CREDENTIAL DRIFT in the startup logthe row and the properties name different Razorpay accountspoint both at the same account
A different ERROR in the startup log naming the RAZORPAY_IDFC row as unresolvableeither the row was never seeded, or (most likely if the row exists) payment.crypto.master-key is unset and the row can't be decrypted — the two cases log different messages, so read which one firedseed the row (step 1), or set payment.crypto.master-key
Payments arrive but no bookingcheck the webhook URL, then payment_order for the order refthe reconciliation timer is the backstop