Kulcare Docs
Feature QA checklists

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_development DB, 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_bytes

For 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 users

1.2 Test users (seeded)

The seed creates accounts representing each signed_up_as value. Phone numbers double as login IDs (OTP flow):

RoleEmailPhonePurpose
State A practitionerstate.a.practitioner@example.com915555556000No tenant; runs through facility wizard
State A vendorvendor@acmelab.example.com915555556001No tenant; runs through vendor wizard
Existing-tenant owner(created via the practitioner wizard)n/aOwner of a Tenant — exercises the Add Clinic + virtual-to-physical paths
Platform adminadmin@kulcare.com919000000000Sales-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
end

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

RoleCards visible (onboarding not complete)
Practitioner, no tenantVirtual practice · Set up clinic · Talk to sales (clinic catalog) · Waiting for invite
Practitioner, has tenant(none — cards hidden, dashboard widgets show)
Vendor, no vendor recordWellness practice · Talk to sales (vendor catalog) · Waiting for invite
Vendor, has vendor recordFinish setting up (payments) · Talk to sales · Waiting for invite
StaffWaiting for invite only. No Talk to sales.
Admin / generalTalk 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

CheckExpected
Finish the last step, or "Open my dashboard" on ConfirmThe URL is /connect/dashboard?welcome=1. The "What just happened" card shows Made, Waiting and Do next.
Owner finishes ReviewMade 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 startedWaiting 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 publicWaiting reads "Your listing is not public yet." It names no rule for when it goes live.
Close the cardThe card goes. ?welcome=1 leaves the URL. A reload does not bring the card back.
Onboarding not complete, or a step skippedOne "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 approvedThe 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 PaymentsOne item only: "Get confirmed by your facility", or "Link your payout account". No second item for the skipped step.
Verification tileHidden 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) tileLive 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

RoleSidebar items
PractitionerDashboard · Inbox · Notifications · My profile · Schedule · Search · Network · Referrals · Invites · Feed
Facility ownerDashboard · Inbox · Notifications · My profile · Facilities · Schedule · Search · Network · Referrals · Invites · Feed
VendorDashboard · Inbox · Notifications · My profile · Classes · Clients · Schedule (Availability) · Payments · Network · Referrals · Invites
StaffDashboard · 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)

RoleFAB visibleCatalog shown when opened
Any signed-in, no active subscriptionYesPractitioner catalog (default)
Vendor (signed_up_as)YesVendor catalog (Packages help / Reach more / Studio / Razorpay help)
Any user with active subscriptionNon/a
Signed-outNon/a

2.4 Add Clinic CTAs

SurfaceWhere the button lands
/account/clinics "Add Clinic" button/connect/facility/new (the wizard, existing-tenant branch)
Direct visit to /account/clinics/addRedirects to /connect/facility/new

3. Practitioner self-serve flows

3.1 Solo virtual practice (State A)

#StepExpected
P1Sign in as State A practitioner; land on /connect4 path-selector cards render (no wellness card). Cards in order: Virtual practice (primary tone), Clinic, Talk to sales, Waiting for invite
P2Click "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
P3Walk through About → Team → Operations → Review → Plan & paymentAll steps render; conditional address step is skipped
P4On Plan step, pick the lowest-priced INR planSubmit button enables only after a plan is picked
P5SubmitToast: "Welcome to {name} — let's go!"; lands on /cms/dashboard; sidebar shows the new tenant in profile switcher
P6Verify backend stateIdentity::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
P7Hit /connect/dashboard againPath-selector cards no longer render (user is now State B)

3.2 Multi-doctor clinic (State A)

#StepExpected
P8Sign in as State A practitioner; click "Set up your clinic"/connect/facility/new (no ?mode); wizard shows full address + multi-doctor sections
P9About: enter name, country, specialty, practice_type = in_personStep validates, advances to Address
P10Address: enter line 1, city, state, postal codeStep validates, advances to Team
P11Team: toggle "Are you a practicing doctor?" ON; fill self-row (name, phone, fee)Self-row validates
P12Operations: pick mon_fri_9_5, has_ipd: false, patients_per_hour: 4Step advances
P13Plan: pick a plan; submitSame 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)

#StepExpected
P14On Team step, toggle "Multiple stakeholders earn from this practice?" ONToggle visually selected
P15Submit the wizardTenant created normally AND an Identity::ProductLead row exists with product: 'opd_module', context: { multiple_stakeholders: true, source: 'facility_wizard' }
P16If ProductLead creation failsTenant creation does NOT roll back; the failure is logged but doesn't block the user

3.4 Add a facility under existing tenant

#StepExpected
P17Sign 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}"
P18Wizard skips Plan & Payment step (existing tenant)Only About → Address → Team → Operations → Review steps visible
P19SubmitToast: "{facility name} is ready"; JWT switches to new facility; lands on /connect/dashboard
P20VerifyNew Identity::Facility row under existing tenant; Identity::UserMembership count unchanged

3.5 Owner/admin gate on /connect/facility/new

#StepExpected
P21Sign in as a non-owner, non-admin tenant member; visit /connect/facility/newPage 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"
P22Sign in as an owner; clear localStorage; reloadSelf-heal effect re-fetches /identity/profiles; wizard loads; no flash of denial

4. Vendor self-serve flow (wellness)

4.1 Vendor wizard (State A)

#StepExpected
V1Sign in as State A vendor (signed_up_as: 'vendor'); land on /connect3 path-selector cards: Wellness practice (primary tone), Talk to sales (vendor catalog), Waiting for invite. Virtual practice + clinic cards must NOT render.
V2Click "Finish setup" on the vendor cardLands 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.
V3About step: name, vendor_type radio (individual/business), service_category radio (5 options)Step advances
V4Offerings step: optional headline, offerings list (one per line), specializations (comma-separated)Step advances; both fields skippable
V5Review stepShows all entered fields + parsed offerings/specializations
V6Submit step: shows commission copy + Razorpay-needed calloutSubmit button enabled
V7Click "Create wellness practice"Toast: "Welcome to Kulcare — let's get you set up"; lands on /connect/dashboard?vendor_setup=just_completed
V8Verify backend stateIdentity::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
V9Sidebar after wizardShows Classes, Clients, Schedule (with Availability under it) and Payments together, below My profile

4.2 Vendor profile page

#StepExpected
V10Click "My profile" in sidebarPage 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
V10aActivate the vendor's payment account, then reload My profileVisibility shows "Live". "View public page" in the header opens /vendors/{slug} in the patient app
V10bTurn "Show in search" off, then activate paymentsThe profile stays private. The page says "You turned off Show in search"
V11Edit headline + bio + toggle accepting-referrals; saveToast: "Profile updated"; values persist on reload
V12Visit /connect/profile as a non-vendor (e.g. practitioner)The practitioner profile page renders. The vendor page does not render
V13Visit /connect/profile as a vendor who has not finished setup"Finish your setup" renders, linking to /connect/onboarding/review. Payments shows the same gate.
V14Visit /connect/vendor/profile?x=1#y and /connect/practitioner/profileEach URL changes to /connect/profile. The query string and the hash stay

4.3 Vendor stub pages (Phases 2 + 4)

#StepExpected
V14Click Classes in sidebarLands on /connect/packages, titled "Classes"
V15Click Payments in sidebarStub page with amber Razorpay-needed banner explaining why profile is unlisted

4.4 Vendor sees the global FAB

#StepExpected
V16Anywhere in Connect, look bottom-right"Talk to us" FAB visible (no active subscription)
V17Click FABModal 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."
V18Pick one + add notes + submitToast: "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)

#StepExpected
V19Sign in as a vendor with at least one class; click Classes in the sidebarLands on /connect/packages. Empty state shows when no classes exist (CTA: "Add your first class"); list view otherwise.
V20Click "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.
V21Submit a valid classToast "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'.
V22Submit a package with the same name as an existing one (same vendor)422 with "You already have a package with this name".
V23Submit the same name as a DIFFERENT vendor's package201 — uniqueness is per-vendor.
V24Click Edit on a rowForm pre-populates from server data via react-hook-form's values prop (no useEffect). Save button stays disabled until something actually changes (formState.isDirty).
V25Toggle "Active" off and saveRow renders with reduced opacity + "Inactive" pill in the list. Patient-app browse should hide it (verify in Phase 3).
V26Click Delete on a package with no active subscriptionsInline confirm-in-place buttons appear. Confirm → row removed.
V27Click Delete on a package WITH active subscriptionsSoft-deactivates (is_active: false) instead of hard-deleting. Toast still shows success.
V28Sign in as a non-vendor (practitioner); navigate to /connect/packagesWrong-role gate renders with "Classes not available" + Back to Dashboard CTA.
V29Sign in as a non-vendor; hit POST /identity/vendor_packages directly403 with "must be onboarded as a wellness vendor".
V30As 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)

#StepExpected
V30aFinish the vendor onboarding with two class rows; press Finish on ReviewToast "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.
V30bOpen 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.
V30cOpen Clients with no bookings or purchases"Clients appear here when they book or buy a class."
V30dA client books a vendor slotThe 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.
V30eA client buys a classPayments shows the payment under "Recent payments": client, class, amount, "Paid" and the payout state.
V30fVendor with no availability and an unlisted profile opens the dashboardThe 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.
V30gVendor who finished onboarding before Finish made the practice (onboarding complete, no vendor record) presses "Finish setup" on PaymentsReview opens and stays open. Finish makes the practice and lands on the dashboard. Payments then shows the payment setup, not the gate.
V30hVendor opens a class booking on ScheduleThe details sheet offers "Manage availability". It does not offer "Book in this facility".
V30iVendor dashboard with a failed Clients read; press Retry on the Clients tileThe clients read runs again.

4.7 Polymorphic ServicePackage cross-tenant invariants

#StepExpected
V31Vendor A creates a package; vendor B logs inB sees only their own packages. The Scope#resolve unions are per-user, not global.
V32Tenant member queries ServicePackage in CMSSees only tenant-owned rows in their tenant. Vendor-owned rows do NOT appear in tenant-scoped CMS queries.
V33Run Identity::ServicePackage.unscoped_by_tenant.for_vendor(vendor) in a runnerReturns only that vendor's rows. The unscoped_by_tenant is required because the model's default_scope filters by Shared::Current.tenant when set.
V34Migrate down (db:rollback) on a DB with vendor-owned rowsMigration 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

#StepExpected
S1State A practitioner clicks "Talk to sales" cardModal: "Talk to our team" / "Pick the products you'd like to discuss"; products = practitioner catalog (OPD Module / OPD+IPD Lite / Hospital / Rev Share Tracking)
S2State A vendor clicks "Talk to sales" cardSame modal frame; copy: "Pick what you'd like help with"; products = vendor catalog (Packages help / Reach more / Studio / Razorpay)
S3Submit with no products selectedSubmit button stays disabled
S4Submit with 1+ product + optional notesToast: "Thanks — ..."; modal closes; ProductLead row created per selected product (one row per product)
S5Re-open modal after submittingPreviously-submitted products show with a "Submitted" indicator (previouslySubmitted)

5.2 Global FAB (cross-surface)

#StepExpected
S6Sign in; navigate anywhere in the shell (e.g. /cms/patients)FAB visible bottom-right (no active subscription on current facility)
S7Click FABSame modal; context.source_route set to the current route (not /connect/dashboard)
S8Sign in as a user whose active facility has subscription.status === 'active'FAB hidden
S9Switch facility to one without an active subscriptionFAB re-appears (audience switches if signed_up_as differs)

5.3 Backend ProductLead behavior

#StepExpected
S10Submit the same product twice in a rowSingle row in the DB (upsert on (user_id, product)); notes updated to the latest, updated_at bumped
S11Submit with context containing a booleanBackend persists the boolean as true/false (not stringified — request comes in as JSON)
S12Submit with context > 4KB serializedSilently dropped — lead.context is {}. (Documented behavior; no validation error today)

6. Virtual-to-physical graduation

6.1 The "Add a physical location" card

#StepExpected
G1Sign in as a virtual-only facility owner; visit CMS Settings → GeneralCard with "Add a physical location" CTA renders (only when currentFacility.is_virtual_only === true)
G2Click "Add address"; expand the formAddress line 1, line 2 (optional), city, state, postal code
G3Submit valid addressToast: "{facility name} now serves patients in person and online"; card disappears
G4Verify backendSame 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
G5Reload settings pageCard no longer renders (facility is no longer virtual-only)
G6As a non-owner / non-adminCard 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.

#StepExpected
R1As 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)
R2If vendor has accepting_referrals: false422; error: "referee is not currently accepting referrals"
R3If vendor profile is unlisted422 (same reason)
R4Switch 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)
R5Vendor accepts the referralStatus flips to accepted; ReferralStatusNotificationJob enqueues; sender receives a push (and email if configured) titled "{vendor.display_name} accepted your referral"
R6Same for decline + complete transitionsSame notification dispatch with the appropriate verb
R7No Identity::UserMembership is created at any pointConfirm 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

#StepExpected
RX1Sign in as a tenant member with manage_patients / manage_referrals / create_referrals permission and visit /cms/referralsPage loads. Empty state shows "No referrals yet" + "Create your first referral" CTA
RX2After at least one referral has been sent, reload /cms/referralsRow renders with patient name, status badge (color-coded by status), reason snippet, sent date
RX3Force 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

#StepExpected
RX4On /cms/referrals/new, click "Choose a recipient..."Popover opens with search input + tabs (All / Practitioners / Vendors); list pre-populated (debounced fetch on open)
RX5Type "anita" slowly (5 keystrokes)Network tab shows ~1 request per ~250ms (debounced), not one per keystroke
RX6Switch tab to "Vendors"List filters to vendor rows only; service_category line shows ("Wellness vendor" etc.)
RX7Search resolves to a practitioner with primary specialty + facility + cityRow's secondary line reads <specialty> · <facility name> · <city>
RX8Vendor whose accepting_referrals: false (set via runner)Row does not appear in any tab
RX9Vendor whose status: 'suspended'Row does not appear
RX10Practitioner profile with visibility: 'unlisted'Row does not appear (only visibility: 'public' profiles surface)
RX11Force 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

#StepExpected
RX12Open /cms/referrals/new?patient_id=<uuid>Patient ID field prefilled, disabled, helper text reads "Patient prefilled from the previous page."
RX13Submit without choosing a recipientInline error under the picker: "Please choose a recipient." Form does NOT submit
RX14Open /cms/referrals/new (no ?patient_id=) and submit with the patient id field emptyInline error under the field: "Patient ID is required." aria-invalid set on the input
RX15Choose a vendor recipient + valid patient + reason; submitToast: "Referral sent to {vendor name}". Navigation: lands on /cms/referrals (no detail page yet — list shows the new row)
RX16Verify backendNew Clinical::Referral row with referee_type: 'Identity::Vendor', referee_id: <vendor id>, referring_practitioner_id: <user id>, tenant_id: <tenant id>, status: 'pending'
RX17Submit with a patient id that doesn't existToast surfaces the per-field validation error from Rails (e.g. patient: not found) instead of "Failed to create referral" generic
RX18Submit with a vendor that just became accepting_referrals: false between picker fetch and submitToast surfaces "referee: not eligible to receive referrals" — the model-side referee_must_accept_referrals validator catches it

7A.4 Authorization

#StepExpected
RX19Sign in as a user with no tenant memberships and GET /clinical/referrals/recipients403 forbidden (Pundit Clinical::Referral, :create? gate)
RX20Sign in as a tenant member whose role lacks manage_patients / manage_referrals / create_referrals and GET /clinical/referrals/recipients403 forbidden
RX21Sign 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

#StepExpected
RX22A 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
RX23A 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.

#StepExpected
A1Sign in; complete the practitioner wizard; navigate to /cms/dashboardLands on the CMS dashboard (no bounce to /connect)
A2Open DevTools → Application → Local Storage; clear the kulcare_* keysPersisted profile cache wiped
A3Reload the page (don't sign out)Shell re-fetches /identity/profiles on mount; populates availableProfiles; selectedProfile resolves; /cms/dashboard renders
A4If 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.

GateLives inResolves to
Path-selector rendersapps/connect/src/pages/DashboardPage.tsx!isAuthLoading && !hasMemberships && !onboardingCompleted
Path-selector card filteringapps/connect/src/components/identity/PathSelectorCards.tsxuser.signed_up_as === 'vendor' switches the catalog
Wizard owner/admin guard on /connect/facility/newapps/connect/src/pages/FacilityNewPage.tsxhasRole(selectedProfile, ['owner','admin','clinic_owner','hospital_owner']), fail-open on null
Sidebar Packages + Paymentsapps/connect/src/layouts/ConnectLayout.tsxauthUser?.signed_up_as === 'vendor'
FAB visibilityapps/shell/src/components/TalkToSalesFAB.tsxsubscription?.status !== 'active'
FAB audiencesameuser?.signed_up_as === 'vendor' ? 'vendor' : 'practitioner'
Vendor profile page wrong-role redirectapps/connect/src/pages/VendorProfilePage.tsxuser.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_profile resolves practitioner first. A practitioner who later signs up as a vendor will silently lose vendor profile access. Tracked for vendor V1 with an ?as=vendor toggle.
  • 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 availableProfiles after 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-run psql -d kulcare_test -c "CREATE EXTENSION IF NOT EXISTS pgcrypto" whenever you reset.
  • Modal swallows thrown errors: TalkToSalesModal keeps itself open with selections intact when onSubmit throws. Caller is responsible for the toast — the modal won't surface the error inline.
  • Vendor profile starts unlisted, not public. Payment account activation makes it public (Connect::PublishVendorListing), unless the vendor turned "Show in search" off or staff delisted them. For vendors that were active before this change, run rake connect:publish_active_vendors (a dry run unless CONFIRM=1).
  • ServicePackage default scope does NOT include unscoped_by_tenant calls — the custom default scope on the model already passes through vendor-owned rows (rows with tenant_id IS NULL). Use Identity::ServicePackage.for_vendor(vendor) for vendor reads; Identity::ServicePackage.for_tenant(tenant) for tenant reads. See polymorphic ServicePackage owner guide.
  • vendor.service_packages is NOT defined. The polymorphic side names the association owner. Use Identity::ServicePackage.for_vendor(vendor) instead.

12. Spec coverage map

Where automated coverage lives today (and what it does NOT cover).

FlowSpecStatus
Wizard service (facility, new tenant)backend/spec/domains/identity/services/facilities/wizard_setup_service_spec.rb12 examples, green
Wizard service (vendor)backend/spec/domains/identity/services/vendors/wizard_setup_service_spec.rb10 examples, green
Vendor save-package service (V1 P2)backend/spec/domains/identity/services/vendors/save_package_service_spec.rb8 examples, green
Vendor request endpointbackend/domains/identity/spec/requests/vendors_spec.rb4 examples, green
Vendor policybackend/domains/identity/spec/policies/vendor_policy_spec.rb5 examples, green
ProductLead create + context handlingbackend/domains/identity/spec/requests/product_leads_spec.rb6 examples, green
Polymorphic referral receiverbackend/domains/clinical/spec/requests/referrals_spec.rb5 examples, green
Referral recipient picker (CMS create)backend/domains/clinical/spec/requests/referral_recipients_spec.rb10 examples, green
Referral status notificationbackend/domains/clinical/spec/jobs/referral_status_notification_job_spec.rb7 examples, green
Polymorphic Connect profilebackend/domains/connect/spec/models/professional_profile_spec.rb11 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

On this page