Skip to content

Marketplace payments (Razorpay Route) ​

Model B. A consumer pays a merchant through us; Razorpay splits the money at capture; the merchant is settled directly by Razorpay and we keep a commission. This is the Ganapati money loop and it is the path that has been proven end to end on live Dev.

End to end ​

The three rules that shape this function ​

place-public-order is separate from manage-order and must stay separate. manage-order's entire posture is "the caller is a member of this workspace"; here the caller is the general public and the workspace comes from a public slug. Merging them would put an anonymous code path inside a function whose guards all assume membership.

  1. The client supplies intent, the server supplies money. No price, no line total and no order total is accepted from the request. A field that is not read cannot be tampered with.
  2. The order is created before the payment link, and the link carries the order's uuid as reference_id (36 chars, inside Razorpay's 40-char limit). A webhook can therefore always map money back to an order, including a payment that arrives while the response is still in flight.
  3. Pay is optional and its absence is normal. A stall taking cash is the common case, so an order with no payment link is complete rather than degraded. ⚠ A payment-link failure must not lose the order: if Razorpay throws, the order is already committed and is returned without a url, with payment_unavailable_reason telling the caller why. A festival vendor losing a real order because a provider had a bad minute is the worst available failure.

Payment readiness: a reason, not a boolean ​

resolve_workspace_payment_readiness(workspace_id) returns { can_take_payments, reason }. The reason matters because five different situations produce "no", and they need different UI and different operator action.

⚠ THIS TABLE LISTED FIVE REASON CODES THAT THE FUNCTION DOES NOT RETURN (corrected 2026-08-17)

It documented ok · feature_not_entitled · feature_not_available · no_payout_account · payout_not_activated. The shipped function returns ready · not_entitled · no_payout_account · not_available · unknown_workspace. Only one string overlapped, and it means something narrower than documented. A client branching on the old strings would have matched no branch — the failure would have surfaced as a Pay button that renders for nobody, or for everybody, with no error anywhere. Written from the design intent instead of the deployed body, which is the "asserted where an enumeration was owed" defect this repo keeps paying for.

reasonMeaning
readyentitled, a workspace-scope availability grant exists, and an activated payout account exists
not_entitledthe workspace's resolved plan does not include payments. Reported FIRST, deliberately — telling someone with no plan entitlement to "finish your banking" sends them to fix the wrong thing
no_payout_accountno payout_accounts row with activation_status = 'activated'. ⚠ This single code covers both "no row at all" and "a row that is not activated yet" — the EXISTS clause tests activation inline, so the two situations this table used to separate are indistinguishable to a caller. If they ever need different UI, that is a change to the function, not to a client
not_availableentitled and banked, but the workspace-scope availability grant is absent — i.e. the kill switch is still holding. Reported LAST of the three
unknown_workspacethe workspace id is null, or resolve_workspace_plan returned null. Also what the slug-keyed wrapper returns for a slug that is not published, so a draft card is indistinguishable from a nonexistent one

⚠ The public card and this function must ask the same question. If the card renders a Pay button using different logic, the buyer taps Pay and is refused.

⚠ This page previously ended that paragraph "Both call this one RPC." That was ASPIRATIONAL and read as fact — and the same claim is written in the present tense in place-public-order/index.ts ("the same one the public card asked when it decided whether to render a Pay button"). The card called it from nowhere, because the function is keyed on workspace_id and no anon caller could obtain one. resolve_setu_card_payment_readiness(text) (CR-26.0.1-51) is the slug-keyed door that makes the sentence true; the CTA that calls it is still being built. Do not read a comment in the present tense as evidence that a caller exists.

⚠ AND THE TWO RESOLVERS CAN LEGITIMATELY DISAGREE, which is not a bug but must not surprise you.resolve_workspace_payment_readiness hardcodes scope_kind = 'workspace' for availability and ignores effective_from/effective_until, precedence-deny, depends_on and min_app_build. resolve_features — behind the merchant's own useFeature('payments') — applies all of them. So a plan-scope availability grant would light payments in the merchant app and not on the buyer path, and an expired grant would light the buyer path and not the merchant app. The "one function, both callers" property holds between the card and the order Edge Function, which is the pair that matters for "the button and the write path agree". It does not hold between the card and the merchant's own view of their features.

⚠ payments availability may only ever be granted at WORKSPACE scope. ADR-0021 resolves effective = applicability AND entitlement AND availability, and a platform-scope availability grant on payments would enable collection for every workspace on the platform, including ones with no linked account. That is a payout incident, not a feature flag. The platform-scope DENY seeded in feature_grants is what makes per-merchant enablement the only path.

The commission split ​

amountMinor = the order subtotal, in paise
rateBp      = resolveCommissionBp(client, workspaceId)   // 500 by default
commissionMinor = floor(amountMinor * rateBp / 10000)
vendorMinor     = amountMinor - commissionMinor          // the REMAINDER, never re-rounded

Computing both independently and rounding each would fail payments_split_adds_up on roughly half of all real amounts. Rounding the commission down and subtracting makes the identity hold by construction, and sends any sub-paise fraction to the vendor: a platform that rounds fractions toward itself is a platform explaining that to vendors.

The rate is read through _shared/commission.ts resolveCommissionBp(client, workspaceId), whose body currently reads PLATFORM_COMMISSION_BP and defaults to 500. ⚠ A missing rate must never become a zero rate — that would hand the platform's entire commission away with no error anywhere. A malformed one throws.

The function is async and takes a workspace precisely so the wave-2 per-merchant rate table is a change to one body rather than a sweep across the money path. It is called outside the try that wraps link creation, because when it becomes an RPC a transient failure must not be swallowed as payment_link_failed while a missing term must be a hard 500.

Webhook mechanics ​

razorpay-webhook is the only writer of gateway payment state, and it is the only function in the project with verify_jwt = false, necessarily: Razorpay cannot send a Supabase JWT. Its authentication is the HMAC-SHA256 signature over the raw body, verified before anything is parsed. ⚠ The raw body must never be re-stringified to verify: parsing and re-serialising produces a byte-different string from what Razorpay signed.

Status codes are a control mechanism, not a report ​

A provider treats any non-2xx as "retry", so the policy is inverted from an ordinary API.

SituationCodeWhy
bad or missing signature401never retried, and it was not a real event
unknown or duplicate event200recorded, then a no-op. Retrying forever helps nobody
transient failure (DB down)5xxthe money moved and we did not record it. Failing loudly is correct
permanent business failure200 + process_errora provider retrying an event we can never process buries the ones we can

⚠ A 5xx retry is inert, and this was mis-stated before being traced. Razorpay redelivers, the redelivery hits payment_events' unique (provider, provider_event_id), and the handler returns acknowledged('duplicate') before any processing. So a failed event is dead, not looping. Same severity, different mechanism, and it means a dead letter needs a reconciler, not a retry.

Three properties instead of an ordering guarantee ​

Razorpay emits several events for the same money with no ordering guarantee. Measured on live Dev for one ₹800 payment: payment.captured and order.paid both at 14:32:09.142, payment_link.paid at .372. So the handler is built on properties, not sequence:

  1. Every event is recorded in payment_events, whose unique constraint turns a redelivery into a constraint violation rather than a second payment.
  2. State transitions are monotonic. created(0) < failed(1) < authorized(2) < captured(3) < partly_refunded(4) < refunded(5). ⚠ failed ranks below captured on purpose: Razorpay can deliver a failed first attempt after a successful retry, and ranking by arrival would mark a paid order failed so a merchant refuses to hand over goods that were paid for.
  3. orders.payment_status is always derived from the ledger, never set from the event.

Correlation is a fallback chain ​

Keys are tried in order and a miss falls through to the next:

provider_payment_id  ->  provider_link_id  ->  provider_order_id  ->  order_id (only if a real uuid)

Three defects lived in the if / else if this replaced. A provider string bound into the uuid column order_id is a 22P02 that throws and 5xxs, so a non-uuid reference is now a skipped key rather than an error. .maybeSingle() errors on 2+ rows while the ledger deliberately allows several payments per order, so the query is a bounded list. And the chain was exclusive, so a link id matching nothing abandoned an event another key would have matched.

Where several rows match, the choice is deterministic, and the first rule is load-bearing: payments has a unique (provider, provider_payment_id), so attaching a capture to the wrong attempt row burns that pay_id on the wrong row and turns the correct row's own event into a permanent 23505 loop.

⚠ payment.captured cannot be correlated on first arrival, measured. Its payload contains only id, fee, tax, vpa, notes, amount, entity, status, captured, currency, order_id, created_at, base_amount, international, amount_refunded. There is no payment_link_id and no reference_id, and when it arrives before payment_link.paid our row has neither provider_payment_id nor provider_order_id written yet. It dead-letters. This is noise, not data loss — payment_link.paid carries the same fee 230ms later and correlates cleanly on the link id. Closing it needs the reconciler to replay unmatched events; correlating it at arrival is impossible with the keys the payload actually has.

Why the stale-transition branch carries the fee ​

All three capture events carry fee and tax in their nested payment entity. ⚠ That is a statement about the payload shape, not about any rate: gateway charges are provider-controlled commercial terms and nothing here may depend on their value (see the governing rule). But order.paid correlates first via order.receipt and advances the row to captured; payment_link.paid then arrives with the fee, matches on the link id, and is refused by the monotonic guard as a stale transition.

⚠ So the event that carries the fee is precisely the one the guard rejects. The stale-transition branch therefore applies provider facts before returning. Extracting the fee without that branch passes every unit test and captures nothing, which is exactly what happened: every captured payment on Dev has provider_fee_minor = NULL.

Refunds ​

refund.processed determines no status by itself. It used to map to refunded unconditionally, which is terminal rank 5, so a second partial refund failed the monotonic guard and its amount was silently dropped: the money left the account and the ledger did not move.

incoming = min(event.amount_refunded, payments.amount_minor)
refunded = max(incoming, payments.refunded_minor)      // monotonic on the AMOUNT axis
status   = refunded >= amount_minor ? 'refunded' : 'partly_refunded'

⚠ Both halves are required. Returning status: null alone is a money-losing regression, because with no status the canTransition guard is skipped entirely and a late-arriving first partial would overwrite the cumulative total downward. Math.max restores the protection on the axis that actually carries the money. payment.amount_refunded is Razorpay's running total; refund.amount is only that refund, so preferring the latter would make a second partial overwrite the first.

Counter payments, which share the tables and not the path ​

A merchant taking cash or a direct UPI transfer at the stall records it through manage-order, not this function. Those payments carry provider = 'cash' | 'upi_manual', zero commission (CHECK enforced), and no Razorpay identifiers. upi_manual is kept distinct from cash for exactly one reason: it has an external reference (a UTR) to reconcile against.

complete_order_with_settlement is the one action whose two halves must be atomic: the status and the money move together or neither does. It locks the order FOR UPDATE, refuses a terminal order, refuses a razorpay settlement (gateway money is the webhook's to write), and re-derives payment_status from the ledger.

⚠ NULL and 0 reach different code. p_amount_minor = NULL means "no settlement to record"; <= 0 raises. A fully-prepaid order has a balance of exactly 0, so a caller sending 0 would fail every prepaid handover with a money-shaped error. Three layers keep that unreachable: advanceIntentFor returns complete rather than settle when nothing is due, CollectionsScreen passes null, and requireMinor refuses a literal zero before the RPC is reached.