Marketplace payments — multi-processor architecture
Purpose: How the vendor marketplace payment flow works end-to-end with per-vendor regional gateway routing. Schemas:
billing.*+identity.vendors.country+identity.vendors.payment_account_*Issue: #1311 (umbrella) + #1373 (frontend track) Plan:docs/plans/2026-05-07-feat-vendor-marketplace-multi-processor-plan.mdDecisions:docs/decisions/2026-05-05-marketplace-gateway-deferred.mdStatus: Backend track complete. Frontend track in progress.
Track PR State Backend foundation + Razorpay Route #1364 Merged Backend Stripe Connect Express #1369 Merged Frontend vendor onboarding UI (Razorpay) #1376 Merged Frontend Stripe return + refresh pages #1377 In review Patient app marketplace checkout #1378 In review Ops gates before V1 launch end-to-end:
- Razorpay Marketplace / Route enablement on the business account (#1374 item 3)
- Razorpay international payment acceptance (#1374 item 4) — required only for cross-region bookings
- Stripe Connect Express enablement on the platform account (#1374 item 5) — required to light up the US-vendor + Stripe-checkout paths
Why multi-processor
Each vendor uses the payment processor native to their country. Indian vendors → Razorpay Route. US vendors → Stripe Connect Express. Future EU/UK → local processor. The decision is locked at #1322; the rationale is in the plan.
Cross-region bookings (US patient pays Indian vendor in INR) work via the regional processor's international card acceptance. Razorpay handles FX on the patient's issuing bank; Stripe does the same for US-bound cards. Settlement is always in the vendor's currency — Kulcare carries no multi-currency books.
Components
┌─────────────────────────────────────────────────────────────────┐
│ Patient app (patient/) │
│ /vendors → /vendors/:slug → /packages/:id │
│ MarketplaceCheckoutButton → POST /billing/marketplace/orders │
│ Razorpay handler → POST /orders/:id/verify │
│ → /marketplace/orders/:order_id/success │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Billing::Marketplace::OrdersController │
│ - Strong params: vendor_id, service_package_id (NO amount) │
│ - Server derives amount_minor = package.effective_price × 100 │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Billing::Marketplace::InitiateOrderService │
│ - Freezes commission_bps from Marketplace::Config │
│ - GatewayFactory.for_vendor(@package.owner) → concrete gateway │
│ - Persists MarketplaceOrderIntent (charge_state/payout_state) │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ GatewayInterface (abstract) │
│ ├── RazorpayGateway (concrete, India) │
│ ├── StripeGateway (PR 2, US) │
│ └── NullGateway (fail-loud default) │
└─────────────────────────────────────────────────────────────────┘
↓ (gateway-specific API call)
External provider
↓ (webhook)
┌─────────────────────────────────────────────────────────────────┐
│ POST /billing/marketplace/webhooks/razorpay │
│ └─ RazorpayWebhooksController │
│ 1. Rate-limited (600/min/IP) │
│ 2. Verify HMAC against request.raw_post │
│ 3. Reject replay (>5min past or >1min future skew) │
│ 4. INSERT marketplace_webhook_events (UNIQUE on │
│ (provider, external_event_id) — DB-level idempotency) │
│ 5. Enqueue ProcessWebhookEventJob │
│ 6. Return 200 │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Billing::Marketplace::ProcessWebhookEventJob (Sidekiq) │
│ 1. gateway.parse_webhook_event(payload) → Events::* │
│ 2. ApplyEventService.call(event:) │
│ 3. mark_processed!(outcome: 'applied' | ...) │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Billing::Marketplace::ApplyEventService │
│ case-on-class dispatch: │
│ - LinkedAccountStatusChanged → Identity::Vendor.update! │
│ - OrderPaid → MarketplaceOrderIntent. │
│ charge_state = 'succeeded' │
│ - OrderPaymentFailed → charge_state = 'failed' │
│ - TransferProcessed → payout_state = 'succeeded' │
│ - RefundProcessed → log + observability noop (V1) │
│ - UnhandledEvent → noop, processed_outcome set │
└─────────────────────────────────────────────────────────────────┘Per-vendor gateway routing
Billing::Marketplace::GatewayFactory has two entry points:
# Outbound (checkout, refund, linked-account create)
gateway = Billing::Marketplace::GatewayFactory.for_vendor(vendor)
# Inbound (webhook controllers, no vendor in scope)
gateway = Billing::Marketplace::GatewayFactory.for_provider('razorpay')for_vendor reads vendor.payment_account_provider (set during onboarding); falls back to vendor.expected_provider (derived from country: IN → razorpay, US → stripe).
Adding a third provider is a one-PR change:
- New concrete
Billing::Marketplace::AdyenGateway < GatewayInterface - Register in
Marketplace::Config::PROVIDER_REGISTRY - Add to
Identity::Vendor::SUPPORTED_COUNTRIESandCOUNTRY_TO_PROVIDER - New webhook controller at
domains/billing/controllers/marketplace/adyen_webhooks_controller.rb - Route at
POST /billing/marketplace/webhooks/adyen
The interface, idempotency table, ApplyEventService dispatch, and MarketplaceOrderIntent are reused as-is.
Data model
identity.vendors
| Column | Purpose |
|---|---|
country | ISO-3166-1 alpha-2 (IN, US). NOT NULL, defaults 'IN'. Locked once payment_account_id is set to prevent KYC orphaning. |
payment_account_provider | razorpay, stripe (or none if not yet onboarded) |
payment_account_id | Provider's account ID (acc_xxx for Razorpay, acct_xxx for Stripe) |
payment_account_status | Unified enum: none, pending, under_review, active, suspended, rejected |
payment_account_activated_at | Timestamp of webhook-driven activation |
KYC fields are never persisted in Kulcare DB. PAN, GSTIN, bank account number, IFSC (and US SSN/EIN in PR 2) flow through Onboarding::RazorpayPayloadBuilder to the provider's API. Only the provider-issued account ID lands on the vendor row. This is verified by create_service_spec.rb.
billing.marketplace_order_intents
| Column | Purpose |
|---|---|
order_id | Gateway-issued (UNIQUE) |
user_id / service_package_id | Buyer + package binding |
amount_minor | Server-derived; BIGINT, paise/cents only |
commission_bps | Frozen at order creation. Refunds, disputes, audits read this historical value, not current config. |
currency | ISO-4217 |
charge_state | pending → succeeded/failed/refunded/disputed |
payout_state | pending → processing/succeeded/failed/reversed |
status | Legacy pending/verified/cancelled |
metadata | Includes gateway name and receipt for reconciliation |
charge_state and payout_state are independent because Razorpay's split-payment model can have a successful charge with a delayed/failed transfer to the vendor.
billing.marketplace_webhook_events
| Column | Purpose |
|---|---|
provider | razorpay or stripe |
external_event_id | X-Razorpay-Event-Id header (Razorpay), evt_xxx (Stripe) |
event_type | The provider's event name |
payload | Full JSON for replay/debug. Capped at 256KB at controller level to prevent JSONB bloat. |
processed_at / processed_outcome | applied (the apply changed state), noop_<reason> (the apply changed nothing: noop_unhandled, noop_already_terminal, noop_already_succeeded, noop_not_a_marketplace_order and the other reasons of ApplyEventService), noop_duplicate (the translator returned no event), failed_<reason> |
The UNIQUE (provider, external_event_id) constraint is the source of truth for idempotency. Application-level exists? checks race; the DB constraint is what makes concurrent webhook deliveries safe.
Security guarantees
| Threat | Defense | Where |
|---|---|---|
Client-supplied amount overrides price | Service has no amount parameter; server reads package.effective_price | InitiateOrderService |
| Webhook signature forgery | HMAC-SHA256 against request.raw_post (not parsed JSON); rejection at controller | RazorpayWebhooksController#receive |
| Webhook replay | One-sided window: 5min back, 1min clock-skew forward; fail-closed on missing timestamp | outside_replay_window? |
| Concurrent webhook double-credit | DB UNIQUE constraint; INSERT ON CONFLICT DO NOTHING via Rails create + RecordNotUnique rescue | DB + controller |
| Webhook DDoS via HMAC CPU saturation | rate_limit :receive, limit: 600, period: 1.minute, by: :ip (RateLimitable) | Controller |
| KYC PII (PAN/GSTIN/IFSC) in production logs | filter_parameters adds these keys + nested :bank | config/initializers/filter_parameter_logging.rb |
| KYC PII persisted to Kulcare DB | Pass-through to gateway only; spec asserts row contains no KYC fields | Onboarding::CreateService |
| Refund initiation by non-admin | OrderPolicy#refund? = platform_admin?, hits 403 | RefundsController + OrderPolicy |
| Vendor changing country mid-flight (orphan KYC) | Model validator refuses country change once payment_account_id is set | Identity::Vendor |
commission_bps rate change corrupts historical orders | Model validator freezes commission_bps after persist | MarketplaceOrderIntent |
| Webhook secret rotation drops events | Dual-secret 24h overlap window — webhook_secret_current AND webhook_secret_previous both checked | RazorpayGateway#verify_webhook_signature |
Razorpay.setup global thread race | RAZORPAY_AUTH_MUTEX serializes all marketplace API calls in a process | RazorpayGateway#with_marketplace_credentials |
Vendor onboarding flow
The vendor's payment account is created in two phases — a Kulcare-side wizard that captures their profile + (for IN) Razorpay KYC, then an out-of-band provider review that flips payment_account_status to active via webhook.
HTTP surface
| Endpoint | Use |
|---|---|
POST /billing/marketplace/vendors/:vendor_id/onboarding | Submit KYC, create the linked account on the gateway. Idempotency: 409 if payment_account_id already set + not rejected, with refresh_endpoint hint pointing at /refresh for Stripe vendors. |
GET /billing/marketplace/vendors/:vendor_id/onboarding | Read persisted payment_account_id, provider, status, country. Used by the polling page on Stripe redirect-back. |
POST /billing/marketplace/vendors/:vendor_id/onboarding/refresh | Stripe-only. Re-mints a fresh Account Link for a vendor whose original expired. Razorpay → 422 (no concept of refresh; KYC happens on the merchant dashboard). |
All three endpoints are gated by Identity::VendorPolicy#onboard? (vendor owner or partner-tenant admin). The controller inherits from Shared::BaseController rather than Billing::BaseController because vendors are tenantless — require_tenant! would 422 every legitimate self-serve call.
Frontend pieces (apps/connect)
| Page | Behavior |
|---|---|
/connect/payments | Status-driven UX. status: 'none' shows the Razorpay KYC form for IN vendors or a "coming soon" card for US vendors (until the Stripe path is ops-enabled). pending / under_review / active / rejected / suspended each render their own banner. Form has a double-submit guard (Enter-key races the disabled-button commit) and a Redirecting… splash for the Stripe handoff. |
/connect/vendor/onboarding/return | Stripe redirects here after Stripe-hosted onboarding. Polls GET /onboarding every 3s with a function-form refetchInterval that drops to false at terminal status. 2-minute "still waiting?" notice if no terminal verdict. |
/connect/vendor/onboarding/refresh | Stripe redirects here when an Account Link expires. Single-shot POST /refresh on mount, redirects to the new activation_url. |
Both Stripe-redirect pages cross-check the URL's ?vendor_id= against the authenticated user's vendor as a second layer over the backend Pundit gate.
Status enum mapping
The unified payment_account_status collapses provider-specific lifecycle states into a five-state machine the rest of the codebase consumes:
| Unified | Razorpay | Stripe |
|---|---|---|
none | — | — |
pending | created | account exists, KYC handoff outstanding |
under_review | under_review, needs_clarification | derived from requirements.disabled_reason ∈ ALLOWED |
active | activated | charges_enabled && payouts_enabled |
suspended | suspended | account.application.deauthorized |
rejected | rejected | requirements.disabled_reason indicates rejection |
charges_enabled: true && payouts_enabled: false (vendor can collect, can't withdraw) maps to active for legibility, with the underlying Stripe payload surfaced via LinkedAccountStatusChanged.details so the onboarding UI can warn appropriately.
Patient checkout flow
Vendors with payment_account_status: 'active' are visible on the marketplace browse surface. The patient app at patient/ (Vite + React) handles the buy flow.
Browse → buy
- Browse at
/vendors(paginated, filterable byservice_category) — backed byGET /connect/public/profilesviaConnect::PublicProfilesController. Unauthenticated. - Vendor detail at
/vendors/:slug— backed byGET /connect/public/profiles/:slug. Unauthenticated. - Package detail at
/packages/:id— backed byGET /connect/public/packages/:id. The page readspkg.vendor.can_receive_paymentsfrom the public blueprint to decide whether to render the active checkout button or the "finishing payment setup" placeholder. Unauthenticated browse; auth required to actually buy.
MarketplaceCheckoutButton
Click "Buy"
↓
useInitiateCheckout → POST /billing/marketplace/orders
↓
Branch on response.gateway:
├── 'razorpay' → loadRazorpayScript() → openRazorpayCheckout({...})
│ ↓
│ handler({ razorpay_payment_id, razorpay_signature })
│ ↓
│ useVerifyCheckout → POST /orders/:id/verify
│ ↓
│ navigate('/marketplace/orders/:order_id/success', { state: { subscription } })
│
└── 'stripe' → toast('Stripe checkout coming soon')
(V1.5 — Stripe Elements integration)Critical states:
- Auth gate: visitors get a "Sign in to buy" CTA that returns to
/packages/:id. - Double-submit: the button stays disabled while the user is in the Razorpay modal (the mutation alone doesn't cover that window).
- Payment-captured-but-verify-failed: navigates to success with
verificationPending: true. Webhook reconciles server-side — the user's purchase isn't lost, only the immediate UI confirmation is.
PublicVendorBlueprint exposes can_receive_payments
Boolean only, no provider names or account IDs. Lives on the :normal view of the public vendor blueprint so the embedded shape inside PublicProfileBlueprint and PublicServicePackageBlueprint carries it without a separate request. PII (user_id, payment_account_id, payment_account_status) stays out of the public blueprints by construction; spec asserts both directions (presence of can_receive_payments, absence of PII).
Configuration
Rails credentials
razorpay_marketplace:
key_id: rzp_live_xxx # production
key_secret: xxx
webhook_secret_current: whsec_xxx
webhook_secret_previous: whsec_yyy # optional, 24h rotation overlapEnvironment variables
| Var | Default | Purpose |
|---|---|---|
BILLING_MARKETPLACE_PROVIDER | none | Default provider for GatewayFactory.current (testing/admin). Production should leave none so per-vendor routing always applies. |
BILLING_MARKETPLACE_COMMISSION_RATE | 0.05 | Float 0.0–0.5. Frozen onto each new order at creation. |
Razorpay Marketplace contract
Razorpay Route (linked accounts + transfers) requires explicit enablement on the Razorpay business account. Apply via Razorpay business dashboard before going live. International payment acceptance is a separate enablement (also application-based) — required only for cross-region bookings (US patient → Indian vendor).
Common operations
Adding a webhook event type
- Define a new value object in
domains/billing/services/marketplace/events/extendingEvents::Base. - Map the provider's event name to it inside
RazorpayGateway#parse_webhook_event(andStripeGatewayonce PR 2 lands). - Add an
apply_*private method inApplyEventServiceand awhenarm in the dispatch. - Subscribe to the event in the Razorpay dashboard.
Adding a country / provider
Already covered above (one-PR change). The plan is the source of truth for the file map.
Refund flow (V1, admin-only)
# Backend
result = Billing::Marketplace::RefundService.call(
order_intent: intent,
payment_id: 'pay_xxx',
amount_minor: 50_000, # nil for full refund
actor: current_user, # must have platform_admin
reason: 'patient_canceled'
)The service:
- Gates via
OrderPolicy#refund?(admin only) - Routes via
GatewayFactory.for_vendor(vendor) - Calls
gateway.create_refund(reverse_transfer: true)— claws back vendor share automatically (Razorpay) or via explicit param (Stripe, PR 2) - Transitions
charge_statetorefundedinside a transaction
Refund webhook (refund.processed) arrives later and is observability-only in V1 — V1.5 will add the proportional accounting for partial refunds.
Inspecting webhook event lineage
-- Recent activity by provider
SELECT provider, event_type, processed_outcome, count(*)
FROM billing.marketplace_webhook_events
WHERE received_at > now() - interval '1 day'
GROUP BY 1, 2, 3 ORDER BY 1, 2;
-- Stuck events (received but never processed — should be empty)
SELECT * FROM billing.marketplace_webhook_events
WHERE processed_at IS NULL AND received_at < now() - interval '5 minutes';
-- Trace a specific order's lifecycle
SELECT received_at, event_type, processed_outcome, payload->>'event' AS evt
FROM billing.marketplace_webhook_events
WHERE payload::text LIKE '%order_xxx%'
ORDER BY received_at;Gotchas
- Don't read
Marketplace::Config.commission_ratefor refund/audit math — read the order's frozencommission_bps. Config drift mid-history corrupts the books. - Don't rebuild the JSON body before signature verification — Rails' default
request.bodyre-serializes after parsing, breaking the HMAC. Userequest.raw_post. - Model-level country/commission locks don't cover direct SQL ops fixes — for compliance-critical changes, also add a DB trigger (deferred follow-up).
Razorpay.setupis process-global — theRAZORPAY_AUTH_MUTEXserializes API calls. When upgrading to gem 4.x with per-instance auth, drop the mutex.MarketplaceOrderIntent.statusis the legacy state column — thecharge_state/payout_statemachines are the source of truth for new code. The backfill migration (20260507000004) populated the new states from the legacy status; new orders set both at creation.
Out of V1 scope (deferred)
- Stripe Elements checkout in patient app — patient
MarketplaceCheckoutButtonships the Stripe branch as a "coming soon" toast. Lights up alongside #1374 item 5. - Vendor self-serve refunds — admin-only in V1; tracked at #1371
Audit::FinancialEventtable — outbound action ledger; currently relies on Sidekiq + structured logs (#1370 + #1338 territory)- Partial-refund proportional
reverse_transfermath — full-refund case is correct - DB-level triggers for country/commission_bps freeze — model-level only today (#1374 item 2)
- Provider-event translator extraction —
parse_webhook_eventlives on the gateways for now (#1370) - Order-history page in patient app — the success page ships, the index page doesn't yet
References
- Backend PRs (merged): #1364, #1369
- Frontend PRs: #1376 (merged), #1377, #1378
- Issue: #1311 (umbrella) + #1373 (frontend track) + #1374 (ops)
- Plan:
docs/plans/2026-05-07-feat-vendor-marketplace-multi-processor-plan.md - Decision:
docs/decisions/2026-05-05-marketplace-gateway-deferred.md - QA / smoke-test guide:
docs/guides/testing/marketplace-multi-processor-qa.md - Adjacent: Connect vendor as first-class citizen plan, Vendor self-serve V1 plan