Kulcare Docs
Feature QA checklists

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 states

1.2 Razorpay credentials

bin/rails credentials:edit
razorpay_marketplace:
  key_id: rzp_test_xxx
  key_secret: xxx
  webhook_secret_current: whsec_smoke_test
  # webhook_secret_previous: optional, for testing 24h rotation

1.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 overlap

Pin 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.

GateRequired forTracked in
Razorpay Marketplace / Route enabled on the business accountAll Razorpay-vendor flows#1374 item 3
Razorpay international payment acceptanceCross-region (US patient → IN vendor)#1374 item 4
Stripe Connect Express enabled on platform accountUS-vendor onboarding + Stripe checkout#1374 item 5
Webhook endpoints configured in provider dashboardsAll 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"
CheckExpected
HTTP status200
Response body{"received": true, "event_id": "<uuid>"}
marketplace_webhook_events rowinserted with provider='razorpay', external_event_id='evt_smoke_valid_1', processed_at populated within seconds (Sidekiq)
Sidekiq dashboardBilling::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"
CheckExpected
HTTP status400
Response body{"error": "signature_verification_failed"}
marketplace_webhook_events rownone (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 = true
SELECT count(*) FROM billing.marketplace_webhook_events
WHERE external_event_id = 'evt_smoke_dup_1';
-- Expect: 1

4.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
wait
SELECT 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: 403

8.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 populated

Test other event types by varying the event field in mk_payload:

EventExpected mutation
account.under_reviewvendor payment_account_status = 'under_review'
account.suspendedvendor payment_account_status = 'suspended'
account.rejectedvendor payment_account_status = 'rejected'
payment.capturedorder intent charge_state = 'succeeded'
payment.failedorder intent charge_state = 'failed'
transfer.processedorder intent payout_state = 'succeeded'
Unknown event typerow 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/connect running on :5174 (pnpm dev --filter=@kulcare/connect from frontend/)
  • patient/ running on :5175 (pnpm dev from patient/)

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.

  1. Navigate to /connect/payments. Expect the Razorpay KYC form (legal business name, business type, contact, PAN required, bank details).
  2. 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"
  3. Re-submit (e.g., reload + submit again). Expect 409 with error.code: 'onboarding_already_started' and data.refresh_endpoint: null (Razorpay has no refresh).
  4. Trigger an account.activated webhook from Razorpay (or manually via Section 11.3 helpers). Reload. Expect status active + 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.

  1. 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.
  2. Submit. Expect 201 with activation_url pointing at https://connect.stripe.com/setup/.... UI shows "Redirecting…" splash and window.location.assign runs.
  3. Complete Stripe-hosted onboarding. Stripe redirects to /connect/vendor/onboarding/return?vendor_id=.... Expect:
    • Page polls GET /onboarding every 3s (function-form refetchInterval stops at terminal)
    • "Reviewing your details…" Alert with animated clock icon
    • Webhook arrives → page flips to green "You're live" within seconds
  4. 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/refresh exactly once (Strict-Mode guard via useRef)
    • 200 with { activation_url: 'https://connect.stripe.com/setup/...' }
    • Browser redirects to the new URL
  5. 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.

  1. Navigate to patient/ /vendors. Expect the active vendor in the list. Click through to /vendors/:slug then /packages/:id.
  2. On /packages/:id, expect the active "Buy ₹X" button (gated on pkg.vendor.can_receive_payments). For a vendor with can_receive_payments: false, expect the "finishing payment setup" placeholder.
  3. While signed out, expect "Sign in to buy" CTA returning to /packages/:id after login.
  4. 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)
  5. After payment success, expect:
    • razorpay_payment_id + razorpay_signature POSTed to /billing/marketplace/orders/:order_id/verify
    • Navigate to /marketplace/orders/:order_id/success with subscription details
    • DB: Identity::PatientPackageSubscription row exists; Billing::MarketplaceOrderIntent.charge_state == 'succeeded'
  6. Verify-failed-after-capture: stub the /verify endpoint to return 422 (or trigger a signature-mismatch). Expect a warning toast + navigation to success page with verificationPending: 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

  1. Sign up via the patient app / Connect with phone +91 9876543210. Pick "I'm a wellness practitioner" / vendor at the signup chooser.
  2. Verify OTP. Profile orchestration runs in the background.
  3. 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
  1. Tail Rails log — expect a single info line like Vendor profile <id> created for user <id> (country=IN, derived_from="91").
  2. ❌ Fail if vendor.country == 'IN' but derived_from shows 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:

  1. Sign up with +44 7700900123 as a vendor.
  2. Verify OTP.
  3. Confirm:
vendor = Identity::Vendor.find_by(user_id: user.id)
expect(vendor.country).to eq('IN')  # model default
  1. Tail Rails log — expect a WARN line: [vendor_signup] Unrecognized phone_country_code="44" for user <id>; falling back to model default 'IN'. Marketplace routing may be wrong.
  2. ❌ 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] Unrecognized warn 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).

  1. As a vendor with a created profile but no payment account, navigate to the Connect dashboard (/connect/dashboard).
  2. The vendor card titled "Finish setting up" should display.
  3. 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)
  4. 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('')      # => nil

Smoke. 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 profile

13.7.1 ✅ Wizard completes the incomplete vendor + creates the Connect profile

  1. Navigate to /connect/vendor/new.
  2. Fill the 4-step wizard (name, vendor_type, service_category, offerings + specializations).
  3. Submit.

Expected:

  • HTTP 200 / 201 success response.
  • No duplicate vendor row created — same vendor.id as the auto-created one.
  • vendor.reload.service_category == 'wellness' (whatever the wizard form selected).
  • vendor.professional_profile is 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_minor and commission_bps correct
  • 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

On this page

1. Test environment setup1.1 Prereqs1.2 Razorpay credentials1.2.1 Stripe Connect credentials (US-vendor + Stripe-checkout paths)1.3 Required fixtures1.3.1 Ops gates before E2E QA1.4 Helpers2. Webhook controller — signature verification2.1 ✅ Valid signature → 200, row inserted, job enqueued2.2 ❌ Invalid signature → 400, no row2.3 ❌ Missing signature header → 4003. Webhook controller — replay rejection3.1 ❌ Timestamp 6 minutes old → 4003.2 ❌ Timestamp 2 minutes in the future → 400 (one-sided check)3.3 ✅ Timestamp within 1-minute future-skew → 2003.4 ❌ Missing created_at → 400 (fail-closed)4. Webhook controller — idempotency4.1 ✅ Duplicate event-id → 200 with duplicate: true, no second row4.2 ✅ Concurrent identical deliveries → exactly one row5. Webhook controller — payload size cap5.1 ❌ 300KB body → 4136. Webhook controller — rate limiting6.1 ✅ Burst 700 requests/min from same IP → blocks after 6007. Order creation — server-derived amount7.1 ✅ Client amount in body is ignored7.2 ✅ commission_bps is frozen after persist8. Refund — admin only8.1 ❌ Patient initiates refund → 4038.2 ❌ Vendor user → 4038.3 ✅ Platform admin → 200, gateway refund + state transition9. KYC PII pass-through9.1 ✅ Onboarding succeeds, vendor row contains no KYC data9.2 ✅ KYC fields filtered from request logs10. ApplyEventService — webhook-driven state transitions11. Job alerting on permanent failure11.1 ✅ ApplyEventService failure triggers Rails.error.report12. Thread-safety spot check (optional, hard to repro deterministically)12. Frontend smoke tests12.1 IN vendor: Razorpay onboarding via the Connect UI12.2 US vendor: Stripe handoff (gated on #1374 item 5)12.3 Patient browse + buy (Razorpay path)12.4 Patient checkout (Stripe path) — placeholder until V1.512.5 Reload-safety on success page13. Vendor onboarding — country derivation + dashboard CTA (PR #1644)13.1 ✅ Indian phone (+91) → IN vendor → Cashfree routing13.2 ✅ US phone (+1) → US vendor → Stripe routing13.3 ✅ Unrecognized phone code (e.g. +44 UK) → IN default + warn log13.4 ✅ Legacy user with nil phone_country_code → IN default + NO warn13.5 ✅ Dashboard "Finish setting up" CTA → /connect/payments13.6 Edge cases — phone code with formatting variants13.7 ✅ Bug #1 — wizard accepts auto-created incomplete vendors13.7.1 ✅ Wizard completes the incomplete vendor + creates the Connect profile13.7.2 ✅ Re-running the wizard on a now-completed vendor rejects13.7.3 ✅ Suspended vendor is NOT silently un-suspended14. Spec sweep15. Sign-off checklistReferences