Onboarding QA Guide
Scope: End-to-end test plan for every onboarding path on Kulcare — practitioner (virtual + clinic), wellness vendor, sales-led, existing-tenant facility add, and the cross-cutting role gates.
Prerequisites: Backend + frontend dev servers running, fresh
kulcare_developmentDB, at least one Razorpay test merchant available for vendor-side tests (Phase 4 only).Related: Onboarding feature doc · Vendor self-serve V1 plan · Polymorphic professional pattern · CMS referrals feature doc · Referrals: CMS create flow + recipient picker
1. Test environment setup
1.1 Database
cd backend
RAILS_ENV=test bundle exec rails db:drop db:create db:migrate
psql -d kulcare_test -c "CREATE EXTENSION IF NOT EXISTS pgcrypto" # required for User PK gen_random_bytesFor dev (where you'll click through the flows in a browser):
bundle exec rails db:migrate
bundle exec rake dev_seed:generate_accounts # creates the seeded test users1.2 Test users (seeded)
The seed creates accounts representing each signed_up_as value. Phone numbers double as login IDs (OTP flow):
| Role | Phone | Purpose | |
|---|---|---|---|
| State A practitioner | state.a.practitioner@example.com | 915555556000 | No tenant; runs through facility wizard |
| State A vendor | vendor@acmelab.example.com | 915555556001 | No tenant; runs through vendor wizard |
| Existing-tenant owner | (created via the practitioner wizard) | n/a | Owner of a Tenant — exercises the Add Clinic + virtual-to-physical paths |
| Platform admin | admin@kulcare.com | 919000000000 | Sales-side and superadmin views (out of this guide's scope) |
To reset a State A user back to State A after they've onboarded (for re-running the wizard):
# bundle exec rails runner
ActiveRecord::Base.transaction do
user = Identity::User.find_by(phone_country_code: '91', phone_number: '5555556000')
Identity::UserMembership.where(user_id: user.id).destroy_all
Identity::PractitionerRole.where(practitioner_id: user.practitioner&.id).destroy_all if user.practitioner
Identity::Tenant.where(name: 'Acme').destroy_all
endFor the vendor user, also Identity::Vendor.where(user_id: user.id).destroy_all (cascades the Connect profile).
1.3 Auth
Sign in via /signin using the phone number. OTP is logged to log/development.log in dev; grep for the user's phone number to find the verification code.
1.4 What "State A" means
A user is in State A when they have no tenant memberships AND no vendor record. The State A surface (path-selector cards) renders only when both conditions hold. After the wizard completes (either path), the user is in State B and the cards disappear.
2. Role × surface matrix
What each role should see, per surface, on a clean cache after sign-in.
2.1 Path-selector cards on /connect/dashboard
The cards show only while Connect onboarding is not complete. After onboarding, they do not show.
| Role | Cards visible (onboarding not complete) |
|---|---|
| Practitioner, no tenant | Virtual practice · Set up clinic · Talk to sales (clinic catalog) · Waiting for invite |
| Practitioner, has tenant | (none — cards hidden, dashboard widgets show) |
| Vendor, no vendor record | Wellness practice · Talk to sales (vendor catalog) · Waiting for invite |
| Vendor, has vendor record | Finish setting up (payments) · Talk to sales · Waiting for invite |
| Staff | Waiting for invite only. No Talk to sales. |
| Admin / general | Talk to sales · Waiting for invite |
A practitioner who completed onboarding and has no tenant sees one line: "Where do you see patients? Ask the facility admin for an invite, or set up your own." Each part is a link (/connect/invites, /connect/facility/new).
2.1a The dashboard after onboarding
| Check | Expected |
|---|---|
| Finish the last step, or "Open my dashboard" on Confirm | The URL is /connect/dashboard?welcome=1. The "What just happened" card shows Made, Waiting and Do next. |
| Owner finishes Review | Made names the practice, the trial end date and the invites sent. A failed plan start shows "Pick a plan" (/account/subscription). |
| Facility added to an existing practice, plan not started | Waiting reads "This facility is not on your plan yet." Do next shows "Add this facility to your plan", not "Pick a plan". |
| Vendor finishes, listing not public | Waiting reads "Your listing is not public yet." It names no rule for when it goes live. |
| Close the card | The card goes. ?welcome=1 leaves the URL. A reload does not bring the card back. |
| Onboarding not complete, or a step skipped | One "Continue setup" card with the setup percentage and "Continue with ". Each skipped step is a checklist item that opens the step. No second ring. |
| Skipped Verify, then documents sent (pending) or approved | The checklist drops "Upload your documents". The "Continue setup" card does not offer Verify, and goes when nothing else is left. |
| Staff skipped Your facility, or vendor skipped Payments | One item only: "Get confirmed by your facility", or "Link your payout account". No second item for the skipped step. |
| Verification tile | Hidden when verified. "In review" when pending. The reason and "Upload again" when rejected. "Upload documents" when nothing was sent. |
| Finished user opens "Upload documents" or "Change goals" | The step opens. "Save and return" saves and goes back to the dashboard. "Back to dashboard" also keeps the edits. Verify does not open while documents are in review, also when it was skipped. |
| Same, after "Add a facility" (finished or still open) | The user's own Verify or Goals step opens in the same return mode. It does not bounce to the dashboard or into the add-facility wizard. |
Verification check fails on /connect/onboarding/verify | "We could not load your setup" with "Try again". The page does not send the user to the dashboard. |
| My facility (CMS) tile | Live only for an owner or admin membership. With the CMS off, it reads "The CMS is not on for your account yet." and links to the facility profile. A practitioner who only works at a facility sees the "Set up your facility" empty state. |
2.2 Connect sidebar
| Role | Sidebar items |
|---|---|
| Practitioner | Dashboard · Inbox · Notifications · My profile · Schedule · Search · Network · Referrals · Invites · Feed |
| Facility owner | Dashboard · Inbox · Notifications · My profile · Facilities · Schedule · Search · Network · Referrals · Invites · Feed |
| Vendor | Dashboard · Inbox · Notifications · My profile · Classes · Clients · Schedule (Availability) · Payments · Network · Referrals · Invites |
| Staff | Dashboard · Inbox · Notifications · My profile · Your desk (Invites) · Schedule · Search · Network · Referrals · Feed |
Inbox has an inbox icon. Notifications has the bell. A practitioner who ticked "teaches" gets no "Classes" entry: /connect/packages is for vendor accounts.
"My profile" opens /connect/profile for every role. The page it shows depends on the role. A vendor sees the vendor profile. A practitioner, a facility owner and a staff member see the practitioner profile.
2.3 Talk-to-Sales FAB (bottom-right)
| Role | FAB visible | Catalog shown when opened |
|---|---|---|
| Any signed-in, no active subscription | Yes | Practitioner catalog (default) |
| Vendor (signed_up_as) | Yes | Vendor catalog (Packages help / Reach more / Studio / Razorpay help) |
| Any user with active subscription | No | n/a |
| Signed-out | No | n/a |
2.4 Add Clinic CTAs
| Surface | Where the button lands |
|---|---|
/account/clinics "Add Clinic" button | /connect/facility/new (the wizard, existing-tenant branch) |
Direct visit to /account/clinics/add | Redirects to /connect/facility/new |
3. Practitioner self-serve flows
3.1 Solo virtual practice (State A)
| # | Step | Expected |
|---|---|---|
| P1 | Sign in as State A practitioner; land on /connect | 4 path-selector cards render (no wellness card). Cards in order: Virtual practice (primary tone), Clinic, Talk to sales, Waiting for invite |
| P2 | Click "Start your virtual practice" | Lands on /connect/facility/new?mode=personal_virtual; wizard pre-fills name = "Dr. {last_name}'s Practice", practice_type = virtual_only, address fields hidden |
| P3 | Walk through About → Team → Operations → Review → Plan & payment | All steps render; conditional address step is skipped |
| P4 | On Plan step, pick the lowest-priced INR plan | Submit button enables only after a plan is picked |
| P5 | Submit | Toast: "Welcome to {name} — let's go!"; lands on /cms/dashboard; sidebar shows the new tenant in profile switcher |
| P6 | Verify backend state | Identity::Tenant.last is the new tenant; Identity::Facility.last has is_virtual_only: true, service_level: 'opd_only', no Identity::Location row; Identity::UserMembership with role: 'owner' exists |
| P7 | Hit /connect/dashboard again | Path-selector cards no longer render (user is now State B) |
3.2 Multi-doctor clinic (State A)
| # | Step | Expected |
|---|---|---|
| P8 | Sign in as State A practitioner; click "Set up your clinic" | /connect/facility/new (no ?mode); wizard shows full address + multi-doctor sections |
| P9 | About: enter name, country, specialty, practice_type = in_person | Step validates, advances to Address |
| P10 | Address: enter line 1, city, state, postal code | Step validates, advances to Team |
| P11 | Team: toggle "Are you a practicing doctor?" ON; fill self-row (name, phone, fee) | Self-row validates |
| P12 | Operations: pick mon_fri_9_5, has_ipd: false, patients_per_hour: 4 | Step advances |
| P13 | Plan: pick a plan; submit | Same as P5–P7 but with the in-person facility shape: is_virtual_only: false, an Identity::Location row attached, Identity::Practitioner + PractitionerRole rows for the owner |
3.3 Multi-stakeholder (any practitioner sub-flow)
| # | Step | Expected |
|---|---|---|
| P14 | On Team step, toggle "Multiple stakeholders earn from this practice?" ON | Toggle visually selected |
| P15 | Submit the wizard | Tenant created normally AND an Identity::ProductLead row exists with product: 'opd_module', context: { multiple_stakeholders: true, source: 'facility_wizard' } |
| P16 | If ProductLead creation fails | Tenant creation does NOT roll back; the failure is logged but doesn't block the user |
3.4 Add a facility under existing tenant
| # | Step | Expected |
|---|---|---|
| P17 | Sign in as the owner from P5; visit /account/clinics; click "Add Clinic" | Navigates to /connect/facility/new; wizard subtitle reads "Adding a facility under {tenant.name}" |
| P18 | Wizard skips Plan & Payment step (existing tenant) | Only About → Address → Team → Operations → Review steps visible |
| P19 | Submit | Toast: "{facility name} is ready"; JWT switches to new facility; lands on /connect/dashboard |
| P20 | Verify | New Identity::Facility row under existing tenant; Identity::UserMembership count unchanged |
3.5 Owner/admin gate on /connect/facility/new
| # | Step | Expected |
|---|---|---|
| P21 | Sign in as a non-owner, non-admin tenant member; visit /connect/facility/new | Page renders the wizard normally if selectedProfile?.role is owner/admin OR if selectedProfile hasn't hydrated yet (fail-open). Otherwise, denial card renders with "Only the clinic owner or an admin can add a new facility" |
| P22 | Sign in as an owner; clear localStorage; reload | Self-heal effect re-fetches /identity/profiles; wizard loads; no flash of denial |
4. Vendor self-serve flow (wellness)
4.1 Vendor wizard (State A)
| # | Step | Expected |
|---|---|---|
| V1 | Sign in as State A vendor (signed_up_as: 'vendor'); land on /connect | 3 path-selector cards: Wellness practice (primary tone), Talk to sales (vendor catalog), Waiting for invite. Virtual practice + clinic cards must NOT render. |
| V2 | Click "Finish setup" on the vendor card | Lands on /connect/onboarding/review, or on the furthest step not done yet. Review's Finish makes the vendor, its classes and its intro. /connect/vendor/new (the old wizard) is no longer linked from Connect. |
| V3 | About step: name, vendor_type radio (individual/business), service_category radio (5 options) | Step advances |
| V4 | Offerings step: optional headline, offerings list (one per line), specializations (comma-separated) | Step advances; both fields skippable |
| V5 | Review step | Shows all entered fields + parsed offerings/specializations |
| V6 | Submit step: shows commission copy + Razorpay-needed callout | Submit button enabled |
| V7 | Click "Create wellness practice" | Toast: "Welcome to Kulcare — let's get you set up"; lands on /connect/dashboard?vendor_setup=just_completed |
| V8 | Verify backend state | Identity::Vendor.last has tenant_id: nil, user_id = current user, status: 'active'. Connect::ProfessionalProfile.last has professional = the vendor, visibility: 'unlisted', accepting_referrals: false, slug auto-generated from name |
| V9 | Sidebar after wizard | Shows Classes, Clients, Schedule (with Availability under it) and Payments together, below My profile |
4.2 Vendor profile page
| # | Step | Expected |
|---|---|---|
| V10 | Click "My profile" in sidebar | Page loads (NO "associated with facility" placeholder). Shows "Your page is not live." with "Your page goes live when your payment account is active." and a link to Payments. The Visibility panel shows "Not live" and the "Show in search" switch. The preview card shows the /vendors/{slug} URL and no referral line. Identity card shows display name, business name (if any), service category, slug |
| V10a | Activate the vendor's payment account, then reload My profile | Visibility shows "Live". "View public page" in the header opens /vendors/{slug} in the patient app |
| V10b | Turn "Show in search" off, then activate payments | The profile stays private. The page says "You turned off Show in search" |
| V11 | Edit headline + bio + toggle accepting-referrals; save | Toast: "Profile updated"; values persist on reload |
| V12 | Visit /connect/profile as a non-vendor (e.g. practitioner) | The practitioner profile page renders. The vendor page does not render |
| V13 | Visit /connect/profile as a vendor who has not finished setup | "Finish your setup" renders, linking to /connect/onboarding/review. Payments shows the same gate. |
| V14 | Visit /connect/vendor/profile?x=1#y and /connect/practitioner/profile | Each URL changes to /connect/profile. The query string and the hash stay |
4.3 Vendor stub pages (Phases 2 + 4)
| # | Step | Expected |
|---|---|---|
| V14 | Click Classes in sidebar | Lands on /connect/packages, titled "Classes" |
| V15 | Click Payments in sidebar | Stub page with amber Razorpay-needed banner explaining why profile is unlisted |
4.4 Vendor sees the global FAB
| # | Step | Expected |
|---|---|---|
| V16 | Anywhere in Connect, look bottom-right | "Talk to us" FAB visible (no active subscription) |
| V17 | Click FAB | Modal opens with the vendor catalog: Help with packages · Reach more patients · Run a studio or team · Razorpay setup help. Description: "Pick what you'd like help with." |
| V18 | Pick one + add notes + submit | Toast: "Thanks — our team will be in touch within 1 business day"; Identity::ProductLead row exists with the picked product, context.source_route = the route at submit time, context.current_product = 'shell' |
4.5 Backend vendor state
After V8, verify:
vendor = Identity::Vendor.last
vendor.user_id == user.id
vendor.tenant_id.nil?
vendor.connect_can_receive_referrals? == false # accepting_referrals: false initially
profile = vendor.professional_profile
profile.visibility == 'unlisted'
profile.slug.present? # auto-generated, format /\A[a-z0-9\-]+\z/4.6 Vendor packages (V1 Phase 2)
| # | Step | Expected |
|---|---|---|
| V19 | Sign in as a vendor with at least one class; click Classes in the sidebar | Lands on /connect/packages. Empty state shows when no classes exist (CTA: "Add your first class"); list view otherwise. |
| V20 | Click "New class" | Lands on /connect/packages/new. Form fields: name (required), description, price (INR, required), duration value + unit, active toggle. Save button disabled until fields are dirty. |
| V21 | Submit a valid class | Toast "Class created". Returns to /connect/packages with the new row visible. Backend: Identity::ServicePackage.last has owner_type: 'Identity::Vendor', owner_id: vendor.id, tenant_id: NULL, facility_id: NULL, currency: 'INR', package_type: 'wellness_program'. |
| V22 | Submit a package with the same name as an existing one (same vendor) | 422 with "You already have a package with this name". |
| V23 | Submit the same name as a DIFFERENT vendor's package | 201 — uniqueness is per-vendor. |
| V24 | Click Edit on a row | Form pre-populates from server data via react-hook-form's values prop (no useEffect). Save button stays disabled until something actually changes (formState.isDirty). |
| V25 | Toggle "Active" off and save | Row renders with reduced opacity + "Inactive" pill in the list. Patient-app browse should hide it (verify in Phase 3). |
| V26 | Click Delete on a package with no active subscriptions | Inline confirm-in-place buttons appear. Confirm → row removed. |
| V27 | Click Delete on a package WITH active subscriptions | Soft-deactivates (is_active: false) instead of hard-deleting. Toast still shows success. |
| V28 | Sign in as a non-vendor (practitioner); navigate to /connect/packages | Wrong-role gate renders with "Classes not available" + Back to Dashboard CTA. |
| V29 | Sign in as a non-vendor; hit POST /identity/vendor_packages directly | 403 with "must be onboarded as a wellness vendor". |
| V30 | As a tenant owner, confirm CMS-side Identity::ServicePackagesController flows still work (existing tenant packages CRUD) | No regression — the polymorphic refactor preserved tenant-owned package shape. List, create, edit, delete should function as before. |
4.6a Vendor practice: clients, payments, bookings (plan 2026-10-08)
| # | Step | Expected |
|---|---|---|
| V30a | Finish the vendor onboarding with two class rows; press Finish on Review | Toast "Your practice is ready. 2 classes are on your Classes page." Classes lists both rows. Payments and My profile do not ask for a second setup. |
| V30b | Open the dashboard as that vendor | "Your practice" is at the top: Sessions this week, Clients, Earnings this month, Listing. Each opens Schedule, Clients, Payments and My profile. No "Leads and follow-ups" tile. |
| V30c | Open Clients with no bookings or purchases | "Clients appear here when they book or buy a class." |
| V30d | A client books a vendor slot | The booking shows on Schedule and on the Upcoming classes tile with the client's name and "Class booking", not "Unknown patient". The client shows on Clients. |
| V30e | A client buys a class | Payments shows the payment under "Recent payments": client, class, amount, "Paid" and the payout state. |
| V30f | Vendor with no availability and an unlisted profile opens the dashboard | The setup card lists "Set your availability" and "Make your listing public". The listing item opens Payments, because the listing goes public when payments are active. Each ticks itself once done. |
| V30g | Vendor who finished onboarding before Finish made the practice (onboarding complete, no vendor record) presses "Finish setup" on Payments | Review opens and stays open. Finish makes the practice and lands on the dashboard. Payments then shows the payment setup, not the gate. |
| V30h | Vendor opens a class booking on Schedule | The details sheet offers "Manage availability". It does not offer "Book in this facility". |
| V30i | Vendor dashboard with a failed Clients read; press Retry on the Clients tile | The clients read runs again. |
4.7 Polymorphic ServicePackage cross-tenant invariants
| # | Step | Expected |
|---|---|---|
| V31 | Vendor A creates a package; vendor B logs in | B sees only their own packages. The Scope#resolve unions are per-user, not global. |
| V32 | Tenant member queries ServicePackage in CMS | Sees only tenant-owned rows in their tenant. Vendor-owned rows do NOT appear in tenant-scoped CMS queries. |
| V33 | Run Identity::ServicePackage.unscoped_by_tenant.for_vendor(vendor) in a runner | Returns only that vendor's rows. The unscoped_by_tenant is required because the model's default_scope filters by Shared::Current.tenant when set. |
| V34 | Migrate down (db:rollback) on a DB with vendor-owned rows | Migration raises ActiveRecord::IrreversibleMigration with the count of vendor rows. Hard-fails by design — manual cleanup required. |
5. Sales-led flow (any role)
5.1 Path-selector "Talk to sales" card
| # | Step | Expected |
|---|---|---|
| S1 | State A practitioner clicks "Talk to sales" card | Modal: "Talk to our team" / "Pick the products you'd like to discuss"; products = practitioner catalog (OPD Module / OPD+IPD Lite / Hospital / Rev Share Tracking) |
| S2 | State A vendor clicks "Talk to sales" card | Same modal frame; copy: "Pick what you'd like help with"; products = vendor catalog (Packages help / Reach more / Studio / Razorpay) |
| S3 | Submit with no products selected | Submit button stays disabled |
| S4 | Submit with 1+ product + optional notes | Toast: "Thanks — ..."; modal closes; ProductLead row created per selected product (one row per product) |
| S5 | Re-open modal after submitting | Previously-submitted products show with a "Submitted" indicator (previouslySubmitted) |
5.2 Global FAB (cross-surface)
| # | Step | Expected |
|---|---|---|
| S6 | Sign in; navigate anywhere in the shell (e.g. /cms/patients) | FAB visible bottom-right (no active subscription on current facility) |
| S7 | Click FAB | Same modal; context.source_route set to the current route (not /connect/dashboard) |
| S8 | Sign in as a user whose active facility has subscription.status === 'active' | FAB hidden |
| S9 | Switch facility to one without an active subscription | FAB re-appears (audience switches if signed_up_as differs) |
5.3 Backend ProductLead behavior
| # | Step | Expected |
|---|---|---|
| S10 | Submit the same product twice in a row | Single row in the DB (upsert on (user_id, product)); notes updated to the latest, updated_at bumped |
| S11 | Submit with context containing a boolean | Backend persists the boolean as true/false (not stringified — request comes in as JSON) |
| S12 | Submit with context > 4KB serialized | Silently dropped — lead.context is {}. (Documented behavior; no validation error today) |
6. Virtual-to-physical graduation
6.1 The "Add a physical location" card
| # | Step | Expected |
|---|---|---|
| G1 | Sign in as a virtual-only facility owner; visit CMS Settings → General | Card with "Add a physical location" CTA renders (only when currentFacility.is_virtual_only === true) |
| G2 | Click "Add address"; expand the form | Address line 1, line 2 (optional), city, state, postal code |
| G3 | Submit valid address | Toast: "{facility name} now serves patients in person and online"; card disappears |
| G4 | Verify backend | Same facility_id (no new Facility row); is_virtual_only flipped to false; new Identity::Location row attached with is_primary: true. Patient/appointment/prescription rows still attached to the same facility |
| G5 | Reload settings page | Card no longer renders (facility is no longer virtual-only) |
| G6 | As a non-owner / non-admin | Card never renders (controlled by the page's render gate) |
7. Polymorphic referral receiver (vendor as referee)
This is the only "after onboarding" flow worth re-testing here — it's the surface where vendor identity meets the existing clinical referral flow.
The CMS-side create flow (/cms/referrals + /cms/referrals/new) is exercised in §7A below. This section (R1–R7) covers the underlying validation + notification invariants regardless of UI entry point.
| # | Step | Expected |
|---|---|---|
| R1 | As a clinic doctor with manage_referrals permission, create a referral with referee_type: 'Identity::Vendor', referee_id: <vendor.id> | Referral created. Validation passes only when vendor.connect_can_receive_referrals? is true (vendor is status_active, profile visibility_public, accepting_referrals: true) |
| R2 | If vendor has accepting_referrals: false | 422; error: "referee is not currently accepting referrals" |
| R3 | If vendor profile is unlisted | 422 (same reason) |
| R4 | Switch to the vendor user; visit /connect/referrals (or the dashboard recent-referrals widget) | Referral appears with the vendor receiver shape (Vendor display name, vendor type chip) |
| R5 | Vendor accepts the referral | Status flips to accepted; ReferralStatusNotificationJob enqueues; sender receives a push (and email if configured) titled "{vendor.display_name} accepted your referral" |
| R6 | Same for decline + complete transitions | Same notification dispatch with the appropriate verb |
| R7 | No Identity::UserMembership is created at any point | Confirm the vendor has no tenant access — the referral acceptance does NOT grant tenant scope |
7A. CMS referral creation flow (sender side)
/cms/referrals (list) + /cms/referrals/new (create with the unified picker). Backed by GET /clinical/referrals/recipients + POST /clinical/referrals. Shipped in PR #1314. Feature doc: cms-referrals.md. Developer reference: Referrals: CMS create flow.
7A.1 List page
| # | Step | Expected |
|---|---|---|
| RX1 | Sign in as a tenant member with manage_patients / manage_referrals / create_referrals permission and visit /cms/referrals | Page loads. Empty state shows "No referrals yet" + "Create your first referral" CTA |
| RX2 | After at least one referral has been sent, reload /cms/referrals | Row renders with patient name, status badge (color-coded by status), reason snippet, sent date |
| RX3 | Force a backend error (e.g. revoke tenant context for a moment) | Error card renders with the specific error message (not a generic "refresh and try again") and a Retry button. Hitting Retry re-fires the request |
7A.2 Recipient picker
| # | Step | Expected |
|---|---|---|
| RX4 | On /cms/referrals/new, click "Choose a recipient..." | Popover opens with search input + tabs (All / Practitioners / Vendors); list pre-populated (debounced fetch on open) |
| RX5 | Type "anita" slowly (5 keystrokes) | Network tab shows ~1 request per ~250ms (debounced), not one per keystroke |
| RX6 | Switch tab to "Vendors" | List filters to vendor rows only; service_category line shows ("Wellness vendor" etc.) |
| RX7 | Search resolves to a practitioner with primary specialty + facility + city | Row's secondary line reads <specialty> · <facility name> · <city> |
| RX8 | Vendor whose accepting_referrals: false (set via runner) | Row does not appear in any tab |
| RX9 | Vendor whose status: 'suspended' | Row does not appear |
| RX10 | Practitioner profile with visibility: 'unlisted' | Row does not appear (only visibility: 'public' profiles surface) |
| RX11 | Force the picker API to 500 (kill the server, search again) | Error UI renders with red icon + "Couldn't load recipients" + actual error message + Retry button (not the empty-state "No recipients match" text) |
7A.3 Submit + result
| # | Step | Expected |
|---|---|---|
| RX12 | Open /cms/referrals/new?patient_id=<uuid> | Patient ID field prefilled, disabled, helper text reads "Patient prefilled from the previous page." |
| RX13 | Submit without choosing a recipient | Inline error under the picker: "Please choose a recipient." Form does NOT submit |
| RX14 | Open /cms/referrals/new (no ?patient_id=) and submit with the patient id field empty | Inline error under the field: "Patient ID is required." aria-invalid set on the input |
| RX15 | Choose a vendor recipient + valid patient + reason; submit | Toast: "Referral sent to {vendor name}". Navigation: lands on /cms/referrals (no detail page yet — list shows the new row) |
| RX16 | Verify backend | New Clinical::Referral row with referee_type: 'Identity::Vendor', referee_id: <vendor id>, referring_practitioner_id: <user id>, tenant_id: <tenant id>, status: 'pending' |
| RX17 | Submit with a patient id that doesn't exist | Toast surfaces the per-field validation error from Rails (e.g. patient: not found) instead of "Failed to create referral" generic |
| RX18 | Submit with a vendor that just became accepting_referrals: false between picker fetch and submit | Toast surfaces "referee: not eligible to receive referrals" — the model-side referee_must_accept_referrals validator catches it |
7A.4 Authorization
| # | Step | Expected |
|---|---|---|
| RX19 | Sign in as a user with no tenant memberships and GET /clinical/referrals/recipients | 403 forbidden (Pundit Clinical::Referral, :create? gate) |
| RX20 | Sign in as a tenant member whose role lacks manage_patients / manage_referrals / create_referrals and GET /clinical/referrals/recipients | 403 forbidden |
| RX21 | Sign in as an owner / admin / role with manage_patients (or any of the create permissions) | 200 OK with results |
7A.5 Polymorphic invariants in the picker
| # | Step | Expected |
|---|---|---|
| RX22 | A Connect::ProfessionalProfile whose polymorphic FK target was hard-deleted (orphan) | Picker silently skips the row; Rails.logger.warn line written with the profile id + reason. Does NOT 500 the picker |
| RX23 | A vendor in the picker also appears in the existing-referral acceptance flow (Connect inbox) | Picker eligibility (SQL filters) and model-side referee_must_accept_referrals (Ruby check at create) agree on which records are acceptable. If a vendor disappears from the picker, attempting to create a referral with that vendor's id returns 422 |
8. Auth state self-heal
Critical for users who hit /cms/* after creating a tenant in another tab, or after a wizard race.
| # | Step | Expected |
|---|---|---|
| A1 | Sign in; complete the practitioner wizard; navigate to /cms/dashboard | Lands on the CMS dashboard (no bounce to /connect) |
| A2 | Open DevTools → Application → Local Storage; clear the kulcare_* keys | Persisted profile cache wiped |
| A3 | Reload the page (don't sign out) | Shell re-fetches /identity/profiles on mount; populates availableProfiles; selectedProfile resolves; /cms/dashboard renders |
| A4 | If availableProfiles is populated but JWT's tenant_id has no matching row (stale tab) | Same self-heal triggers; cache refreshed; user lands cleanly |
9. Cross-cutting role gates
Things to visually confirm in Chrome DevTools React tab if a flow misbehaves.
| Gate | Lives in | Resolves to |
|---|---|---|
| Path-selector renders | apps/connect/src/pages/DashboardPage.tsx | !isAuthLoading && !hasMemberships && !onboardingCompleted |
| Path-selector card filtering | apps/connect/src/components/identity/PathSelectorCards.tsx | user.signed_up_as === 'vendor' switches the catalog |
Wizard owner/admin guard on /connect/facility/new | apps/connect/src/pages/FacilityNewPage.tsx | hasRole(selectedProfile, ['owner','admin','clinic_owner','hospital_owner']), fail-open on null |
| Sidebar Packages + Payments | apps/connect/src/layouts/ConnectLayout.tsx | authUser?.signed_up_as === 'vendor' |
| FAB visibility | apps/shell/src/components/TalkToSalesFAB.tsx | subscription?.status !== 'active' |
| FAB audience | same | user?.signed_up_as === 'vendor' ? 'vendor' : 'practitioner' |
| Vendor profile page wrong-role redirect | apps/connect/src/pages/VendorProfilePage.tsx | user.signed_up_as !== 'vendor' |
10. Known limitations / out of scope
- Razorpay merchant linking for vendors lands in V1 Phase 4. Today the Payments page is a stub.
- Vendor scheduling lands in V1.5. Today vendors coordinate first sessions manually post-purchase.
- Vendor packages land in V1 Phase 2. Today the Packages page is a stub.
- Patient-facing vendor browse (patient app) lands in V1 Phase 3.
- Cross-vendor referrals (vendor → vendor) are V2. Vendors can only RECEIVE referrals from clinic doctors today.
- Practitioner who is also a vendor:
Connect::BasePolicy#user_profileresolves practitioner first. A practitioner who later signs up as a vendor will silently lose vendor profile access. Tracked for vendor V1 with an?as=vendortoggle. - Subscription gating for unpaid tenants: not enforced. New tenants land in trial state without payment. Phase 2.
- Refund initiation: V1 default is "vendors handle refunds via Razorpay merchant dashboard". Kulcare does not initiate refunds.
11. Common gotchas
- Stale
availableProfilesafter creating a tenant in another tab → self-heal effect fixes on next mount; if it's still broken, sign out + back in. signed_up_as: 'patient'users land in the patient app, not Connect. The Connect path-selector won't render for them.- Test DB lacks pgcrypto after
db:drop db:create db:migrate(structure.sql doesn't capture the extension). Re-runpsql -d kulcare_test -c "CREATE EXTENSION IF NOT EXISTS pgcrypto"whenever you reset. - Modal swallows thrown errors:
TalkToSalesModalkeeps itself open with selections intact whenonSubmitthrows. Caller is responsible for the toast — the modal won't surface the error inline. - Vendor profile starts
unlisted, notpublic. Payment account activation makes itpublic(Connect::PublishVendorListing), unless the vendor turned "Show in search" off or staff delisted them. For vendors that were active before this change, runrake connect:publish_active_vendors(a dry run unlessCONFIRM=1). ServicePackagedefault scope does NOT includeunscoped_by_tenantcalls — the custom default scope on the model already passes through vendor-owned rows (rows withtenant_id IS NULL). UseIdentity::ServicePackage.for_vendor(vendor)for vendor reads;Identity::ServicePackage.for_tenant(tenant)for tenant reads. See polymorphic ServicePackage owner guide.vendor.service_packagesis NOT defined. The polymorphic side names the associationowner. UseIdentity::ServicePackage.for_vendor(vendor)instead.
12. Spec coverage map
Where automated coverage lives today (and what it does NOT cover).
| Flow | Spec | Status |
|---|---|---|
| Wizard service (facility, new tenant) | backend/spec/domains/identity/services/facilities/wizard_setup_service_spec.rb | 12 examples, green |
| Wizard service (vendor) | backend/spec/domains/identity/services/vendors/wizard_setup_service_spec.rb | 10 examples, green |
| Vendor save-package service (V1 P2) | backend/spec/domains/identity/services/vendors/save_package_service_spec.rb | 8 examples, green |
| Vendor request endpoint | backend/domains/identity/spec/requests/vendors_spec.rb | 4 examples, green |
| Vendor policy | backend/domains/identity/spec/policies/vendor_policy_spec.rb | 5 examples, green |
| ProductLead create + context handling | backend/domains/identity/spec/requests/product_leads_spec.rb | 6 examples, green |
| Polymorphic referral receiver | backend/domains/clinical/spec/requests/referrals_spec.rb | 5 examples, green |
| Referral recipient picker (CMS create) | backend/domains/clinical/spec/requests/referral_recipients_spec.rb | 10 examples, green |
| Referral status notification | backend/domains/clinical/spec/jobs/referral_status_notification_job_spec.rb | 7 examples, green |
| Polymorphic Connect profile | backend/domains/connect/spec/models/professional_profile_spec.rb | 11 examples, green |
No automated coverage for: the frontend wizard / vendor packages pages (manual only), the path-selector role filtering, the global FAB visibility logic, the auth-state self-heal, or the virtual-to-physical graduation card. The polymorphic ServicePackage model + policy + controller specs are also pending (the existing model spec has pre-existing factory failures unrelated to the polymorphic refactor; needs a fresh-rewrite pass). All worth adding as Vitest + Playwright tests in a follow-up.
Status: living document; update when a new flow ships Last Updated: 2026-05-05