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 cart | POST /api/v1/cart/{cartId}/smart-collect/order |
| Collect part of a booking balance | POST /api/v1/bookings/{bookingId}/smart-collect/order — body { "amount": 50000.00 } |
| Confirm | the existing /api/v1/shared-cart/{token}/payment/verify (cart) and /api/v1/pay-booking/{bookingId}/verify (balance) |
| Feature key | SMART_COLLECT_PAYMENT — unseeded, 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 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.
| Column | Value |
|---|---|
code | RAZORPAY_IDFC |
gateway_type | RAZORPAY |
active | true |
priority | 500 |
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
| Route | Trigger |
|---|---|
| Primary — the agent returns from Checkout to the existing verify/callback endpoint | the agent's browser |
Safety net — order.paid / payment.captured on the webhook above | Razorpay |
| Backstop — the per-cart reconciliation timer re-drives the verify | scheduler |
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
| Symptom | Cause | Fix |
|---|---|---|
| 403 on either endpoint | SMART_COLLECT_PAYMENT not granted | add a feature-access rule |
| 403 "not in your team scope" on the booking endpoint | the booking's agent is outside the caller's reporting subtree | the 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 agent | the 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 on | share the cart, then retry |
| 400 "not assigned to an agent group" | the agent's users.agent_group_id is null | assign the agent |
| 400 "IDFC Razorpay is not configured" | razorpay.idfc.* blank | set the properties |
| 404 "Unknown payment gateway account: RAZORPAY_IDFC" | the row was never seeded | step 1 above |
SMART COLLECT CREDENTIAL DRIFT in the startup log | the row and the properties name different Razorpay accounts | point both at the same account |
A different ERROR in the startup log naming the RAZORPAY_IDFC row as unresolvable | either 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 fired | seed the row (step 1), or set payment.crypto.master-key |
| Payments arrive but no booking | check the webhook URL, then payment_order for the order ref | the reconciliation timer is the backstop |