Kulcare Docs
DeveloperMarketplace

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.md Decisions: docs/decisions/2026-05-05-marketplace-gateway-deferred.md Status: Backend track complete. Frontend track in progress.

TrackPRState
Backend foundation + Razorpay Route#1364Merged
Backend Stripe Connect Express#1369Merged
Frontend vendor onboarding UI (Razorpay)#1376Merged
Frontend Stripe return + refresh pages#1377In review
Patient app marketplace checkout#1378In 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:

  1. New concrete Billing::Marketplace::AdyenGateway < GatewayInterface
  2. Register in Marketplace::Config::PROVIDER_REGISTRY
  3. Add to Identity::Vendor::SUPPORTED_COUNTRIES and COUNTRY_TO_PROVIDER
  4. New webhook controller at domains/billing/controllers/marketplace/adyen_webhooks_controller.rb
  5. Route at POST /billing/marketplace/webhooks/adyen

The interface, idempotency table, ApplyEventService dispatch, and MarketplaceOrderIntent are reused as-is.

Data model

identity.vendors

ColumnPurpose
countryISO-3166-1 alpha-2 (IN, US). NOT NULL, defaults 'IN'. Locked once payment_account_id is set to prevent KYC orphaning.
payment_account_providerrazorpay, stripe (or none if not yet onboarded)
payment_account_idProvider's account ID (acc_xxx for Razorpay, acct_xxx for Stripe)
payment_account_statusUnified enum: none, pending, under_review, active, suspended, rejected
payment_account_activated_atTimestamp 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

ColumnPurpose
order_idGateway-issued (UNIQUE)
user_id / service_package_idBuyer + package binding
amount_minorServer-derived; BIGINT, paise/cents only
commission_bpsFrozen at order creation. Refunds, disputes, audits read this historical value, not current config.
currencyISO-4217
charge_statepending → succeeded/failed/refunded/disputed
payout_statepending → processing/succeeded/failed/reversed
statusLegacy pending/verified/cancelled
metadataIncludes 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

ColumnPurpose
providerrazorpay or stripe
external_event_idX-Razorpay-Event-Id header (Razorpay), evt_xxx (Stripe)
event_typeThe provider's event name
payloadFull JSON for replay/debug. Capped at 256KB at controller level to prevent JSONB bloat.
processed_at / processed_outcomeapplied (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

ThreatDefenseWhere
Client-supplied amount overrides priceService has no amount parameter; server reads package.effective_priceInitiateOrderService
Webhook signature forgeryHMAC-SHA256 against request.raw_post (not parsed JSON); rejection at controllerRazorpayWebhooksController#receive
Webhook replayOne-sided window: 5min back, 1min clock-skew forward; fail-closed on missing timestampoutside_replay_window?
Concurrent webhook double-creditDB UNIQUE constraint; INSERT ON CONFLICT DO NOTHING via Rails create + RecordNotUnique rescueDB + controller
Webhook DDoS via HMAC CPU saturationrate_limit :receive, limit: 600, period: 1.minute, by: :ip (RateLimitable)Controller
KYC PII (PAN/GSTIN/IFSC) in production logsfilter_parameters adds these keys + nested :bankconfig/initializers/filter_parameter_logging.rb
KYC PII persisted to Kulcare DBPass-through to gateway only; spec asserts row contains no KYC fieldsOnboarding::CreateService
Refund initiation by non-adminOrderPolicy#refund? = platform_admin?, hits 403RefundsController + OrderPolicy
Vendor changing country mid-flight (orphan KYC)Model validator refuses country change once payment_account_id is setIdentity::Vendor
commission_bps rate change corrupts historical ordersModel validator freezes commission_bps after persistMarketplaceOrderIntent
Webhook secret rotation drops eventsDual-secret 24h overlap window — webhook_secret_current AND webhook_secret_previous both checkedRazorpayGateway#verify_webhook_signature
Razorpay.setup global thread raceRAZORPAY_AUTH_MUTEX serializes all marketplace API calls in a processRazorpayGateway#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

EndpointUse
POST /billing/marketplace/vendors/:vendor_id/onboardingSubmit 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/onboardingRead persisted payment_account_id, provider, status, country. Used by the polling page on Stripe redirect-back.
POST /billing/marketplace/vendors/:vendor_id/onboarding/refreshStripe-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)

PageBehavior
/connect/paymentsStatus-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/returnStripe 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/refreshStripe 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:

UnifiedRazorpayStripe
none——
pendingcreatedaccount exists, KYC handoff outstanding
under_reviewunder_review, needs_clarificationderived from requirements.disabled_reason ∈ ALLOWED
activeactivatedcharges_enabled && payouts_enabled
suspendedsuspendedaccount.application.deauthorized
rejectedrejectedrequirements.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

  1. Browse at /vendors (paginated, filterable by service_category) — backed by GET /connect/public/profiles via Connect::PublicProfilesController. Unauthenticated.
  2. Vendor detail at /vendors/:slug — backed by GET /connect/public/profiles/:slug. Unauthenticated.
  3. Package detail at /packages/:id — backed by GET /connect/public/packages/:id. The page reads pkg.vendor.can_receive_payments from 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 overlap

Environment variables

VarDefaultPurpose
BILLING_MARKETPLACE_PROVIDERnoneDefault provider for GatewayFactory.current (testing/admin). Production should leave none so per-vendor routing always applies.
BILLING_MARKETPLACE_COMMISSION_RATE0.05Float 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

  1. Define a new value object in domains/billing/services/marketplace/events/ extending Events::Base.
  2. Map the provider's event name to it inside RazorpayGateway#parse_webhook_event (and StripeGateway once PR 2 lands).
  3. Add an apply_* private method in ApplyEventService and a when arm in the dispatch.
  4. 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:

  1. Gates via OrderPolicy#refund? (admin only)
  2. Routes via GatewayFactory.for_vendor(vendor)
  3. Calls gateway.create_refund(reverse_transfer: true) — claws back vendor share automatically (Razorpay) or via explicit param (Stripe, PR 2)
  4. Transitions charge_state to refunded inside 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

  1. Don't read Marketplace::Config.commission_rate for refund/audit math — read the order's frozen commission_bps. Config drift mid-history corrupts the books.
  2. Don't rebuild the JSON body before signature verification — Rails' default request.body re-serializes after parsing, breaking the HMAC. Use request.raw_post.
  3. Model-level country/commission locks don't cover direct SQL ops fixes — for compliance-critical changes, also add a DB trigger (deferred follow-up).
  4. Razorpay.setup is process-global — the RAZORPAY_AUTH_MUTEX serializes API calls. When upgrading to gem 4.x with per-instance auth, drop the mutex.
  5. MarketplaceOrderIntent.status is the legacy state column — the charge_state/payout_state machines 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 MarketplaceCheckoutButton ships 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::FinancialEvent table — outbound action ledger; currently relies on Sidekiq + structured logs (#1370 + #1338 territory)
  • Partial-refund proportional reverse_transfer math — 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_event lives 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

On this page