Marketplace multi-processor — QA / smoke-test guide
Test cases for the vendor marketplace end-to-end flow. Companion to the developer guide.
Scope:
- Backend (#1364 + #1369, both merged): webhook controller (signature, replay, idempotency, payload cap, rate limit), order creation hardening, refund authz, KYC PII pass-through, Razorpay-credential thread safety, Stripe Connect Express dual-secret webhooks.
- Frontend (#1376 merged, #1377 + #1378 in review): vendor onboarding UI (
/connect/payments), Stripe return + refresh pages (/connect/vendor/onboarding/return+/refresh), patient marketplace browse + checkout (patient/).Ops gates (#1374) must be done before E2E QA against real providers — see Section 1.4.
1. Test environment setup
1.1 Prereqs
- Backend running on
http://localhost:4000 - Redis + Sidekiq running (webhook processing job)
- All four 2026-05-07 migrations applied:
cd backend
bundle exec rails db:migrate:status | grep 2026050700000
# Expect 4 lines, all 'up':
# up 20260507000001 Add country to identity vendors
# up 20260507000002 Create billing marketplace webhook events
# up 20260507000003 Extend billing marketplace order intents
# up 20260507000004 Backfill marketplace order intent states1.2 Razorpay credentials
bin/rails credentials:editrazorpay_marketplace:
key_id: rzp_test_xxx
key_secret: xxx
webhook_secret_current: whsec_smoke_test
# webhook_secret_previous: optional, for testing 24h rotation1.2.1 Stripe Connect credentials (US-vendor + Stripe-checkout paths)
stripe_connect:
platform_secret_key: sk_test_xxx
webhook_secret_connect_current: whsec_xxx # /webhooks/stripe/connect endpoint
webhook_secret_account_current: whsec_yyy # /webhooks/stripe/account endpoint
# webhook_secret_*_previous: optional, 24h rotation overlapPin Stripe.api_version = '2026-04-22.dahlia' in config/initializers/stripe.rb.
1.3 Required fixtures
-- A vendor in country=IN with payment_account_id set, status=active
SELECT id, country, payment_account_provider, payment_account_id, payment_account_status
FROM identity.vendors
WHERE country = 'IN' AND payment_account_status = 'active' LIMIT 1;
-- A vendor-owned ServicePackage with effective_price > 0
SELECT id, name, total_price, currency
FROM identity.service_packages
WHERE owner_type = 'Identity::Vendor' AND is_active = true AND total_price > 0
LIMIT 1;
-- A patient user (buyer)
SELECT id, email FROM identity.users WHERE signed_up_as = 'patient' LIMIT 1;
-- An admin user (for refund tests)
SELECT id, email FROM identity.users WHERE platform_admin = true LIMIT 1;If the vendor doesn't exist, create one quickly via the Rails console:
user = Identity::User.find_by(email: 'vendor-smoke@example.com') || Identity::User.create!(
email: 'vendor-smoke@example.com', first_name: 'Smoke', last_name: 'Vendor',
phone_country_code: '91', phone_number: '9876512345', active: true,
signed_up_as: 'vendor', password: 'Password123!', password_confirmation: 'Password123!'
)
vendor = Identity::Vendor.create!(
user_id: user.id, name: 'Smoke Yoga', vendor_type: 'individual',
service_category: 'wellness', status: 'active', country: 'IN',
payment_account_provider: 'razorpay',
payment_account_id: 'acc_smoke_test_001',
payment_account_status: 'active'
)
package = Identity::ServicePackage.create!(
owner: vendor, name: 'Smoke yoga session',
package_type: 'wellness_program', currency: 'INR', total_price: 500, is_active: true
)
puts "VENDOR_ID=#{vendor.id} PACKAGE_ID=#{package.id} BUYER_USER_ID=#{Identity::User.find_by(signed_up_as: 'patient')&.id}"1.3.1 Ops gates before E2E QA
Some sections in this guide work fully against mocks (Sections 2–11 — backend webhook + service hardening). The full E2E flow (Sections 12–13 — frontend onboarding, Stripe redirect-back, patient checkout) requires live provider sandbox credentials.
| Gate | Required for | Tracked in |
|---|---|---|
| Razorpay Marketplace / Route enabled on the business account | All Razorpay-vendor flows | #1374 item 3 |
| Razorpay international payment acceptance | Cross-region (US patient → IN vendor) | #1374 item 4 |
| Stripe Connect Express enabled on platform account | US-vendor onboarding + Stripe checkout | #1374 item 5 |
| Webhook endpoints configured in provider dashboards | All webhook paths | #1374 items 3 + 5 |
If only Razorpay is enabled (the V1 minimum), skip Section 12.4 (US onboarding) and Section 13.2 (Stripe checkout).
1.4 Helpers
Save these in ~/.rc or a local script — they're referenced throughout.
RZP_SECRET="whsec_smoke_test"
# Build a signed Razorpay-shaped webhook body
mk_payload() {
local event="$1" id="${2:-evt_smoke_$(date +%s)}" account="${3:-acc_smoke_test_001}" ts="${4:-$(date +%s)}"
jq -nc --arg event "$event" --arg id "$id" --arg account "$account" --argjson ts "$ts" \
'{id: $id, event: $event, created_at: $ts, payload: {account: {entity: {id: $account}}}}'
}
sign() {
local body="$1"
printf '%s' "$body" | openssl dgst -sha256 -hmac "$RZP_SECRET" -hex | awk '{print $2}'
}
post_webhook() {
local body="$1" sig="$2" event_id="${3:-}"
local extra=""
[ -n "$event_id" ] && extra="-H X-Razorpay-Event-Id:$event_id"
curl -sS -o /tmp/resp.json -w "%{http_code}\n" \
-X POST http://localhost:4000/billing/marketplace/webhooks/razorpay \
-H 'Content-Type: application/json' \
-H "X-Razorpay-Signature: $sig" \
$extra \
--data "$body"
cat /tmp/resp.json | jq . 2>/dev/null
}2. Webhook controller — signature verification
2.1 ✅ Valid signature → 200, row inserted, job enqueued
BODY=$(mk_payload "account.activated" "evt_smoke_valid_1")
SIG=$(sign "$BODY")
post_webhook "$BODY" "$SIG" "evt_smoke_valid_1"| Check | Expected |
|---|---|
| HTTP status | 200 |
| Response body | {"received": true, "event_id": "<uuid>"} |
marketplace_webhook_events row | inserted with provider='razorpay', external_event_id='evt_smoke_valid_1', processed_at populated within seconds (Sidekiq) |
| Sidekiq dashboard | Billing::Marketplace::ProcessWebhookEventJob ran |
SELECT id, provider, external_event_id, event_type, processed_at, processed_outcome
FROM billing.marketplace_webhook_events
WHERE external_event_id = 'evt_smoke_valid_1';2.2 ❌ Invalid signature → 400, no row
BODY=$(mk_payload "account.activated" "evt_smoke_bad_sig")
post_webhook "$BODY" "0000000000000000000000000000000000000000000000000000000000000000"| Check | Expected |
|---|---|
| HTTP status | 400 |
| Response body | {"error": "signature_verification_failed"} |
marketplace_webhook_events row | none (counter unchanged) |
2.3 ❌ Missing signature header → 400
curl -sS -w "\n%{http_code}\n" -X POST http://localhost:4000/billing/marketplace/webhooks/razorpay \
-H 'Content-Type: application/json' \
--data "$(mk_payload "account.activated" "evt_smoke_no_sig")"Expect 400 with signature_verification_failed.
3. Webhook controller — replay rejection
3.1 ❌ Timestamp 6 minutes old → 400
SIX_MIN_AGO=$(($(date +%s) - 360))
BODY=$(mk_payload "account.activated" "evt_smoke_replay_old" "acc_smoke_test_001" "$SIX_MIN_AGO")
SIG=$(sign "$BODY")
post_webhook "$BODY" "$SIG" "evt_smoke_replay_old"Expect 400 timestamp_skew_too_large. No row.
3.2 ❌ Timestamp 2 minutes in the future → 400 (one-sided check)
FUTURE=$(($(date +%s) + 120))
BODY=$(mk_payload "account.activated" "evt_smoke_future" "acc_smoke_test_001" "$FUTURE")
SIG=$(sign "$BODY")
post_webhook "$BODY" "$SIG" "evt_smoke_future"Expect 400 timestamp_skew_too_large. (Pre-fix this would have passed because the window was symmetric .abs.)
3.3 ✅ Timestamp within 1-minute future-skew → 200
FUTURE=$(($(date +%s) + 30))
BODY=$(mk_payload "account.activated" "evt_smoke_skew_ok" "acc_smoke_test_001" "$FUTURE")
SIG=$(sign "$BODY")
post_webhook "$BODY" "$SIG" "evt_smoke_skew_ok"Expect 200. Clock-skew tolerance for legitimate slightly-future timestamps.
3.4 ❌ Missing created_at → 400 (fail-closed)
BODY='{"id":"evt_smoke_no_ts","event":"account.activated","payload":{"account":{"entity":{"id":"acc_smoke_test_001"}}}}'
SIG=$(sign "$BODY")
post_webhook "$BODY" "$SIG" "evt_smoke_no_ts"Expect 400 timestamp_skew_too_large. Pre-fix this passed (fail-open); now fail-closed.
4. Webhook controller — idempotency
4.1 ✅ Duplicate event-id → 200 with duplicate: true, no second row
BODY=$(mk_payload "account.activated" "evt_smoke_dup_1")
SIG=$(sign "$BODY")
post_webhook "$BODY" "$SIG" "evt_smoke_dup_1"
# First call: 200, row inserted
post_webhook "$BODY" "$SIG" "evt_smoke_dup_1"
# Second call: 200, response.duplicate = trueSELECT count(*) FROM billing.marketplace_webhook_events
WHERE external_event_id = 'evt_smoke_dup_1';
-- Expect: 14.2 ✅ Concurrent identical deliveries → exactly one row
BODY=$(mk_payload "account.activated" "evt_smoke_concurrent")
SIG=$(sign "$BODY")
# Fire 5 in parallel
for i in 1 2 3 4 5; do
post_webhook "$BODY" "$SIG" "evt_smoke_concurrent" &
done
waitSELECT count(*) FROM billing.marketplace_webhook_events
WHERE external_event_id = 'evt_smoke_concurrent';
-- Expect: 1 (DB UNIQUE wins the race)5. Webhook controller — payload size cap
5.1 ❌ 300KB body → 413
JUNK=$(printf 'a%.0s' {1..300000}) # 300K chars
BODY="{\"id\":\"evt_huge\",\"event\":\"account.activated\",\"created_at\":$(date +%s),\"junk\":\"$JUNK\",\"payload\":{\"account\":{\"entity\":{\"id\":\"acc_smoke_test_001\"}}}}"
SIG=$(sign "$BODY")
post_webhook "$BODY" "$SIG" "evt_huge"Expect 413 payload_too_large. Body short-circuited before HMAC verification.
6. Webhook controller — rate limiting
6.1 ✅ Burst 700 requests/min from same IP → blocks after 600
BODY=$(mk_payload "account.activated" "evt_smoke_rate_1")
SIG=$(sign "$BODY")
# RateLimitable uses Redis; 600/min/IP. Fire 700 fast.
for i in $(seq 1 700); do
curl -sS -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:4000/billing/marketplace/webhooks/razorpay \
-H 'Content-Type: application/json' \
-H "X-Razorpay-Signature: $SIG" \
-H "X-Razorpay-Event-Id: evt_smoke_rate_$i" \
--data "$BODY" &
[[ $((i % 50)) -eq 0 ]] && wait
done | sort | uniq -c
# Expect: ~600 of 200, ~100 of 429 (Too Many Requests)7. Order creation — server-derived amount
7.1 ✅ Client amount in body is ignored
TOKEN=<patient_jwt>
PACKAGE_ID=<vendor_owned_package_with_total_price=500>
curl -sS -o /tmp/order.json -w "%{http_code}\n" \
-X POST http://localhost:4000/billing/marketplace/orders \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"service_package_id\":\"$PACKAGE_ID\",\"amount\":1,\"receipt\":\"smoke-1\"}"
cat /tmp/order.json | jq .-- Verify the persisted order intent
SELECT order_id, amount_minor, currency, commission_bps
FROM billing.marketplace_order_intents
ORDER BY created_at DESC LIMIT 1;
-- Expect: amount_minor = 50000 (500 INR × 100), NOT 1
-- commission_bps = 500 (5% frozen at create)
-- currency = 'INR'7.2 ✅ commission_bps is frozen after persist
# rails console
intent = Billing::MarketplaceOrderIntent.last
intent.commission_bps = 1000
intent.valid?
# => false
intent.errors[:commission_bps]
# => ["is frozen at order creation and cannot be changed"]8. Refund — admin only
8.1 ❌ Patient initiates refund → 403
PATIENT_TOKEN=<patient_jwt>
ORDER_ID=<order_id_of_a_succeeded_order>
curl -sS -o /tmp/refund.json -w "%{http_code}\n" \
-X POST http://localhost:4000/billing/marketplace/orders/$ORDER_ID/refund \
-H "Authorization: Bearer $PATIENT_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"payment_id":"pay_xxx","amount_minor":50000,"reason":"smoke test"}'
# Expect: 4038.2 ❌ Vendor user → 403
Same as above with vendor's user JWT. Expect 403.
8.3 ✅ Platform admin → 200, gateway refund + state transition
ADMIN_TOKEN=<jwt_for_user_with_platform_admin=true>
curl -sS -o /tmp/refund.json -w "%{http_code}\n" \
-X POST http://localhost:4000/billing/marketplace/orders/$ORDER_ID/refund \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"payment_id":"pay_xxx","reason":"smoke test"}'
cat /tmp/refund.json | jq .SELECT order_id, charge_state FROM billing.marketplace_order_intents WHERE order_id = '<order_id>';
-- Expect: charge_state = 'refunded'9. KYC PII pass-through
9.1 ✅ Onboarding succeeds, vendor row contains no KYC data
The onboarding endpoint isn't shipped on this PR (frontend wizard is a follow-up), so test via Rails console:
# rails console
user = Identity::User.find_by(email: 'vendor-pii@example.com') || Identity::User.create!(
email: 'vendor-pii@example.com', first_name: 'PII', last_name: 'Test',
phone_country_code: '91', phone_number: '9876500001', active: true,
signed_up_as: 'vendor', password: 'Password123!', password_confirmation: 'Password123!'
)
vendor = Identity::Vendor.create!(
user_id: user.id, name: 'PII Test', vendor_type: 'individual',
service_category: 'wellness', status: 'active', country: 'IN'
)
# Stub the gateway so it returns a fake account_id without hitting Razorpay
gateway = Billing::Marketplace::GatewayFactory.for_vendor(vendor)
allow(gateway).to receive(:create_linked_account).and_return(
account_id: 'acc_pii_test', status: 'pending', activation_url: nil, raw: {}
) # use rspec mocks loaded in console — or hit Razorpay test env directly
result = Billing::Marketplace::Onboarding::CreateService.call(
vendor: vendor, actor: user,
kyc_payload: {
legal_business_name: 'PII Test', business_type: 'individual',
contact_name: 'PII Test', email: 'pii@example.com', phone: '+919876500001',
pan: 'ABCDE1234F', gstin: '22ABCDE1234F1Z5',
bank: { account_number: '1234567890', ifsc_code: 'HDFC0000001', beneficiary_name: 'PII Test' }
}
)
vendor.reload
puts vendor.attributes.values.map(&:to_s).join(' ')
# CRITICAL: this string MUST NOT contain ABCDE1234F, 22ABCDE1234F1Z5,
# 1234567890, or HDFC0000001. If it does, KYC PII has leaked into the
# vendor row.9.2 ✅ KYC fields filtered from request logs
# In one terminal: tail the dev log
tail -f backend/log/development.log
# In another: POST something with PAN/GSTIN/bank to any route that logs params
# (the controller path will land in the wizard PR; for now use the rails-console
# spec-style invocation to check the logger filter directly)# rails console
filter = ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters)
filter.filter({ pan: 'ABCDE1234F', gstin: '22ABCDE1234F1Z5', bank: { account_number: '1234567890', ifsc_code: 'HDFC0000001' } })
# Expect: every value replaced with '[FILTERED]'10. ApplyEventService — webhook-driven state transitions
After a successful webhook ingest (test 2.1 above), check that:
-- Vendor's payment_account_status flipped to active
SELECT id, payment_account_id, payment_account_status, payment_account_activated_at
FROM identity.vendors
WHERE payment_account_id = 'acc_smoke_test_001';
-- Expect: payment_account_status = 'active', payment_account_activated_at populatedTest other event types by varying the event field in mk_payload:
| Event | Expected mutation |
|---|---|
account.under_review | vendor payment_account_status = 'under_review' |
account.suspended | vendor payment_account_status = 'suspended' |
account.rejected | vendor payment_account_status = 'rejected' |
payment.captured | order intent charge_state = 'succeeded' |
payment.failed | order intent charge_state = 'failed' |
transfer.processed | order intent payout_state = 'succeeded' |
| Unknown event type | row gets processed_outcome = 'noop_unhandled' (or noop_duplicate when the translator returns no event), warning in logs |
For payment events, build the payload differently:
mk_payment_payload() {
local event="$1" order_id="$2" payment_id="${3:-pay_smoke_$(date +%s)}" ts="${4:-$(date +%s)}"
jq -nc --arg event "$event" --arg id "evt_$payment_id" --arg order "$order_id" --arg pay "$payment_id" --argjson ts "$ts" \
'{id: $id, event: $event, created_at: $ts, payload: {payment: {entity: {id: $pay, order_id: $order, amount: 50000, currency: "INR"}}}}'
}
BODY=$(mk_payment_payload "payment.captured" "<order_id>" "pay_smoke_$(date +%s)")
SIG=$(sign "$BODY")
post_webhook "$BODY" "$SIG"11. Job alerting on permanent failure
11.1 ✅ ApplyEventService failure triggers Rails.error.report
# rails console
event = Billing::MarketplaceWebhookEvent.create!(
provider: 'razorpay',
external_event_id: "evt_alert_#{SecureRandom.hex(4)}",
event_type: 'account.activated',
payload: { 'event' => 'account.activated', 'payload' => { 'account' => { 'entity' => { 'id' => 'acc_does_not_exist' } } } }
)
# Trigger the job manually
Billing::Marketplace::ProcessWebhookEventJob.perform_now(event.id)
# Expect:
# - log line: [Marketplace::ProcessWebhookEventJob] Apply failed id=...
# - Rails.error.report invoked with ApplyFailedError instance + structured context
# - event.reload.processed_outcome == 'failed_apply_failed'If you have Sentry/Honeybadger wired in dev, the error should appear in the dashboard. Without an error reporter, the log line is the only evidence.
12. Thread-safety spot check (optional, hard to repro deterministically)
The with_marketplace_credentials mutex serializes Razorpay API calls inside a Puma worker. Without it, concurrent calls would race Razorpay.setup. To exercise:
# Apache Bench: 5 concurrent connections, 20 total
ab -n 20 -c 5 -p /tmp/onboard.json -T application/json \
-H "Authorization: Bearer <vendor_jwt>" \
http://localhost:4000/identity/vendors/<vendor_id>/onboarding(The onboarding HTTP endpoint isn't shipped in PR 1 — this becomes runnable after the wizard PR lands. Until then, the mutex's correctness is asserted by the unit-spec contract that no two calls observe each other's credentials.)
12. Frontend smoke tests
End-to-end browser tests against apps/connect (vendor side) and patient/ (buyer side). Requires:
- Backend running on
:4000 apps/connectrunning on:5174(pnpm dev --filter=@kulcare/connectfromfrontend/)patient/running on:5175(pnpm devfrompatient/)
12.1 IN vendor: Razorpay onboarding via the Connect UI
Pre: a vendor user signed in to Connect with signed_up_as: 'vendor', country: 'IN', no payment_account_id.
- Navigate to
/connect/payments. Expect the Razorpay KYC form (legal business name, business type, contact, PAN required, bank details). - Submit valid Razorpay test KYC. Expect:
- Browser POST to
/billing/marketplace/vendors/:vendor_id/onboarding - 201 response with
{ account_id, status: 'pending', activation_url: null } - Status banner flips to "Awaiting submission" or "KYC review in progress"
- Browser POST to
- Re-submit (e.g., reload + submit again). Expect 409 with
error.code: 'onboarding_already_started'anddata.refresh_endpoint: null(Razorpay has no refresh). - Trigger an
account.activatedwebhook from Razorpay (or manually via Section 11.3 helpers). Reload. Expect statusactive+ the green "You're live" banner.
12.2 US vendor: Stripe handoff (gated on #1374 item 5)
Pre: a vendor user with country: 'US', no payment_account_id, and Stripe Connect Express enabled in ops.
- Navigate to
/connect/payments. Until ops enablement, expect the "US payments coming soon" card. After enablement (and after the equivalent submit form lands — see #1373 PR 2 follow-up), the form posts to the same endpoint. - Submit. Expect 201 with
activation_urlpointing athttps://connect.stripe.com/setup/.... UI shows "Redirecting…" splash andwindow.location.assignruns. - Complete Stripe-hosted onboarding. Stripe redirects to
/connect/vendor/onboarding/return?vendor_id=.... Expect:- Page polls
GET /onboardingevery 3s (function-formrefetchIntervalstops at terminal) - "Reviewing your details…" Alert with animated clock icon
- Webhook arrives → page flips to green "You're live" within seconds
- Page polls
- Refresh path: abandon Stripe-hosted onboarding mid-flow. Stripe Account Links expire in minutes. Click an expired link → Stripe redirects to
/connect/vendor/onboarding/refresh?vendor_id=.... Expect:- Page calls
POST /onboarding/refreshexactly once (Strict-Mode guard viauseRef) - 200 with
{ activation_url: 'https://connect.stripe.com/setup/...' } - Browser redirects to the new URL
- Page calls
- Cross-vendor mismatch: visit
/connect/vendor/onboarding/return?vendor_id=<some-other-vendor>while signed in as your vendor. Expect the "this link is for a different account" notice — second layer over the backend Pundit gate.
12.3 Patient browse + buy (Razorpay path)
Pre: an active vendor (payment_account_status: 'active') with at least one active package. A signed-in patient.
- Navigate to
patient//vendors. Expect the active vendor in the list. Click through to/vendors/:slugthen/packages/:id. - On
/packages/:id, expect the active "Buy ₹X" button (gated onpkg.vendor.can_receive_payments). For a vendor withcan_receive_payments: false, expect the "finishing payment setup" placeholder. - While signed out, expect "Sign in to buy" CTA returning to
/packages/:idafter login. - Click "Buy". Expect:
- POST to
/billing/marketplace/orders - Razorpay Checkout modal opens
- Complete with a Razorpay test card (
4111 1111 1111 1111, any CVV, any future expiry)
- POST to
- After payment success, expect:
razorpay_payment_id+razorpay_signaturePOSTed to/billing/marketplace/orders/:order_id/verify- Navigate to
/marketplace/orders/:order_id/successwith subscription details - DB:
Identity::PatientPackageSubscriptionrow exists;Billing::MarketplaceOrderIntent.charge_state == 'succeeded'
- Verify-failed-after-capture: stub the
/verifyendpoint to return 422 (or trigger a signature-mismatch). Expect a warning toast + navigation to success page withverificationPending: true. Webhook will reconcile server-side.
12.4 Patient checkout (Stripe path) — placeholder until V1.5
For a vendor with payment_account_provider: 'stripe' and can_receive_payments: true, clicking Buy currently shows "Stripe checkout coming soon" toast. Stripe Elements integration ships in a follow-up. Verify the toast appears (not a 500 / silent failure).
12.5 Reload-safety on success page
After completing 12.3, reload the success page. Expect graceful render with the order_id from the URL — the "verification pending" fallback shows if location state was lost (subscription details unavailable but order reference present).
13. Vendor onboarding — country derivation + dashboard CTA (PR #1644)
Scope: Two of the six bugs from the 2026-06-03 #1547 vendor-workflow re-audit. Verifies that vendor signups route to the right marketplace gateway based on phone country code, and that the post-signup dashboard CTA goes to payments rather than profile edit.
What's NOT in scope: Bug #1 (vendor stuck after signup) is unfixed pending a design call. The vendor-stuck repro is documented in #1547 but isn't a QA path here.
13.1 ✅ Indian phone (+91) → IN vendor → Cashfree routing
- Sign up via the patient app / Connect with phone
+91 9876543210. Pick "I'm a wellness practitioner" / vendor at the signup chooser. - Verify OTP. Profile orchestration runs in the background.
- Confirm via Rails console:
user = Identity::User.find_by(phone_number: '9876543210', phone_country_code: '91')
vendor = Identity::Vendor.find_by(user_id: user.id)
expect(vendor.country).to eq('IN')
expect(vendor.expected_provider).to eq('cashfree') # BILLING_CASHFREE_ENABLED on- Tail Rails log — expect a single info line like
Vendor profile <id> created for user <id> (country=IN, derived_from="91"). - ❌ Fail if
vendor.country == 'IN'butderived_fromshows nothing in the log — the pre-fix silent fallback is back.
13.2 ✅ US phone (+1) → US vendor → Stripe routing
Same flow as 13.1 but with +1 4155551234:
vendor = Identity::Vendor.find_by(user_id: user.id)
expect(vendor.country).to eq('US')
expect(vendor.expected_provider).to eq('stripe') # Independent of Cashfree flag❌ Fail if vendor.country == 'IN' — this is the original #1547 bug #2. Means the country_from_phone_code derivation didn't fire or phone_country_code wasn't '1'/'+1'.
13.3 ✅ Unrecognized phone code (e.g. +44 UK) → IN default + warn log
UK or any non-IN/non-US phone:
- Sign up with
+44 7700900123as a vendor. - Verify OTP.
- Confirm:
vendor = Identity::Vendor.find_by(user_id: user.id)
expect(vendor.country).to eq('IN') # model default- Tail Rails log — expect a
WARNline:[vendor_signup] Unrecognized phone_country_code="44" for user <id>; falling back to model default 'IN'. Marketplace routing may be wrong. - ❌ Fail if no WARN line — the operator visibility for misrouted vendors is broken. Pre-PR #1644 this was a fully silent failure.
13.4 ✅ Legacy user with nil phone_country_code → IN default + NO warn
Some legacy users (pre-signup-validation rows) have phone_country_code: nil. The fix specifically does NOT log a warn for these — blank input is expected state, not an operator signal.
# Manually create a user with nil phone code, or use a legacy row from staging
user = Identity::User.create!(phone_country_code: nil, phone_number: '5550000', signed_up_as: 'vendor', ...)
Identity::Users::ProfileOrchestrationService.call(user: user)Verify:
vendor.country == 'IN'✅- No
[vendor_signup] Unrecognizedwarn line in the log ✅
❌ Fail if a warn fires for blank input — the conditional elsif present? guard regressed and operators will see false alarms for every legacy row.
13.5 ✅ Dashboard "Finish setting up" CTA → /connect/payments
Setup: Use the vendor created in 13.1 or 13.2 BEFORE completing KYC at /connect/payments. The "Finish setting up" card renders only along the no-payment-account branch. To reset a vendor that already linked a payment account, clear it from Rails console: Identity::Vendor.find_by(user_id: user.id).update!(payment_account_id: nil, payment_account_status: :none).
- As a vendor with a created profile but no payment account, navigate to the Connect dashboard (
/connect/dashboard). - The vendor card titled "Finish setting up" should display.
- Confirm:
- Description: "Your wellness practice is created. Set up payments to start receiving patients." (no Razorpay-specific text)
- Button label: "Set up payments" (not "Open my profile")
- Button target:
/connect/payments(verify via DevTools inspect or click and confirm URL)
- Click the button — should land on the VendorPaymentsPage with the gateway-specific KYC flow (Cashfree for IN, Stripe for US).
❌ Fail if any of:
- Button text says "Open my profile"
- Description mentions "Razorpay" specifically
- URL after click is
/connect/profile(or the old/connect/vendor/profile) instead of/connect/payments
13.6 Edge cases — phone code with formatting variants
The country_from_phone_code helper normalizes whitespace + leading '+' before lookup. Quick sanity in Rails console (no signup needed):
Identity::Vendor.country_from_phone_code('+91') # => 'IN'
Identity::Vendor.country_from_phone_code('91') # => 'IN'
Identity::Vendor.country_from_phone_code(' 91 ') # => 'IN' (whitespace tolerated)
Identity::Vendor.country_from_phone_code('+91 ') # => 'IN'
Identity::Vendor.country_from_phone_code('+1') # => 'US'
Identity::Vendor.country_from_phone_code('0091') # => nil (current behavior — leading-zero prefix is not normalized)
Identity::Vendor.country_from_phone_code(nil) # => nil
Identity::Vendor.country_from_phone_code('') # => nilSmoke. Any deviation indicates the normalization in vendor.rb#country_from_phone_code regressed.
13.7 ✅ Bug #1 — wizard accepts auto-created incomplete vendors
Pre-fix repro (the audit's bug #1): vendor signs up + verifies OTP → ProfileOrchestrationService#create_vendor_profile auto-creates an Identity::Vendor row WITHOUT service_category → vendor opens /connect/vendor/new → wizard refused with "You already have a wellness practice" → permanently stuck.
The fix (PR #1646): WizardSetupService treats service_category.present? as the "complete" signal. Incomplete vendors get filled in place; complete vendors continue to reject.
Setup: Fresh vendor signup with +91 8877665544. Verify OTP. The auto-created vendor exists but service_category is nil.
vendor = Identity::Vendor.find_by(user_id: user.id)
expect(vendor.service_category).to be_nil
expect(vendor.professional_profile).to be_nil # auto-create doesn't make a profile13.7.1 ✅ Wizard completes the incomplete vendor + creates the Connect profile
- Navigate to
/connect/vendor/new. - Fill the 4-step wizard (name, vendor_type, service_category, offerings + specializations).
- Submit.
Expected:
- HTTP 200 / 201 success response.
- No duplicate vendor row created — same
vendor.idas the auto-created one. vendor.reload.service_category == 'wellness'(whatever the wizard form selected).vendor.professional_profileis now present,visibility: 'unlisted',accepting_referrals: false.
expect { Identity::Vendors::WizardSetupService.call(user: user, payload: {...}) }
.not_to change(Identity::Vendor, :count)
expect(vendor.reload.service_category).to eq('wellness')
expect(vendor.professional_profile).to be_present❌ Fail if a second vendor row is created OR if professional_profile stays nil after submit.
13.7.2 ✅ Re-running the wizard on a now-completed vendor rejects
After 13.7.1, hit /connect/vendor/new again with the same user. Expected: wizard refuses with "You already have a wellness practice. Visit your vendor profile to edit it." — the second wizard run is idempotent in the "already onboarded" sense.
13.7.3 ✅ Suspended vendor is NOT silently un-suspended
If an admin manually flipped Identity::Vendor.status to :suspended (e.g. fraud review), the wizard re-entry must NOT flip it back to :active. Rails console smoke:
vendor.update!(status: 'suspended')
result = Identity::Vendors::WizardSetupService.call(user: user, payload: {...complete payload...})
expect(result).to be_success
expect(vendor.reload.status).to eq('suspended') # preserved❌ Fail if vendor.status flips to 'active' after the wizard re-runs.
14. Spec sweep
cd backend
bundle exec rspec \
domains/billing/spec/services/marketplace/ \
domains/billing/spec/policies/marketplace/order_policy_spec.rb \
domains/billing/spec/requests/marketplace/ \
domains/billing/spec/models/marketplace_webhook_event_spec.rb \
domains/billing/spec/models/marketplace_order_intent_extensions_spec.rb \
domains/identity/spec/models/vendor_country_spec.rb \
domains/identity/spec/services/users/profile_orchestration_service_spec.rb \
domains/connect/spec/requests/public_packages_spec.rb \
domains/connect/spec/requests/public_profiles_spec.rb
# Expect: ~200 examples, 0 failures (the count grows with each merged PR)PR #1644 added 11 examples covering the country-derivation chain — both
vendor_country_spec.rb (6 new for .country_from_phone_code) and
profile_orchestration_service_spec.rb (4 new contexts: +91, +1, +44 unrecognized, nil).
15. Sign-off checklist
Backend (Sections 2–11) — runs against mocks; no ops gates required:
- Section 2 (signature verification) — all three cases pass
- Section 3 (replay rejection) — all four cases pass
- Section 4 (idempotency) — both cases pass
- Section 5 (payload cap) — 413 returned
- Section 6 (rate limit) — 429s appear after 600 requests
- Section 7 (server-derived amount) —
amount_minorandcommission_bpscorrect - Section 8 (refund authz) — patient/vendor 403, admin 200
- Section 9 (KYC PII) — vendor row contains no PII; logger filter applied
- Section 10 (ApplyEventService) — vendor and order state transitions visible in DB
- Section 11 (job alerting) — error reporter invoked on permanent failure
Frontend (Section 12) — requires ops enablement per Section 1.3.1:
- 12.1 IN vendor onboarding — KYC submit, 409 idempotency, webhook-driven activation
- 12.2 US vendor handoff — gated on Stripe Connect Express (skip if not yet enabled)
- 12.3 Patient Razorpay checkout — buy + verify + success
- 12.4 Patient Stripe checkout — "coming soon" toast (until V1.5)
- 12.5 Success page reload-safety
Vendor country derivation + dashboard CTA (Section 13) — PR #1644:
- 13.1 IN vendor signup → country=IN + expected_provider=cashfree
- 13.2 US vendor signup → country=US + expected_provider=stripe
- 13.3 Unrecognized phone code → IN default + WARN log fires
- 13.4 Nil phone code → IN default + NO warn (legacy path)
- 13.5 Dashboard CTA → /connect/payments (not /connect/profile)
- 13.6 Normalization edge cases (Rails console smoke)
- 13.7.1 Wizard completes auto-created incomplete vendor + creates profile (no duplicate row)
- 13.7.2 Re-running wizard on completed vendor rejects
- 13.7.3 Suspended vendor stays suspended after wizard re-entry
Cross-cutting:
- Section 14 (spec sweep) — full marketplace suite green
References
- Backend PRs: #1364 (merged), #1369 (merged), #1644 (vendor country derivation + dashboard CTA — 2026-06-03)
- Frontend PRs: #1376 (merged), #1377 (review), #1378 (review)
- Issue: #1311 (umbrella) + #1373 (frontend track) + #1374 (ops) + #1547 (audit findings — bugs #2 + #3 fixed in #1644, bug #1 still open)
- Plan:
docs/plans/2026-05-07-feat-vendor-marketplace-multi-processor-plan.md - Developer guide:
docs/guides/developer/marketplace/marketplace-multi-processor.md - Decision:
docs/decisions/2026-05-05-marketplace-gateway-deferred.md