Kulcare Docs
DeveloperConnect

Onboarding and facility setup

Audience: engineers touching signup, Connect, or the identity backend; product/sales who need to know which paths are supported.

TL;DR: a practitioner gets a facility in one of three ways. The Facility track of Connect onboarding ends with POST /identity/my/facility_setup, which makes a new practice or adds a facility to the user's own practice. Sales can also provision a practice by hand from a product lead. An owner or admin can add a facility from the dashboard. Virtual OPD is service_level: 'opd_only' + is_virtual_only: true, not a service_level enum value.

Supported paths

PathWhoOutcome
Self-serve practice (Connect onboarding, Facility track)A user who may start a practice (owner, admin or practitioner of any practice, or a new Facility Owner)A new active practice, its first facility, a trial on the picked plan, and the team invites (section 5)
Self-serve facility addOwner or admin of an active practice, with manage_facilitiesA new facility under that practice. The onboarding add-facility track uses POST /identity/my/facility_setup with existing_tenant (section 5). The dashboard form uses POST /identity/facilities (section 6).
Sales-ledA practitioner who leaves a product leadSales provisions the practice by hand after the call back (section 4)

The ProductInterestForm captures interest, and sales follows up out-of-band.

Full journey

Stage-by-stage

1. Signup

  • Endpoint: POST /identity/registrations, then POST /identity/registrations/verify with the OTP.
  • Frontend: frontend/apps/shell/src/pages/SignUpPage.tsx
  • Backend: backend/domains/identity/forms/user_sign_up_form.rb line 190 lists the allowed signed_up_as values: practitioner | facility | patient | staff | vendor.
  • After the OTP check, VerificationWithProfileService calls ProfileOrchestrationService, which acts on signed_up_as:
    • practitioner: it makes the Identity::Practitioner record, and turns each pending practitioner invite into a practitioner role at the invited facility. It makes no practice.
    • facility: a Facility Owner signup makes the user only. No tenant, organization or facility is made at signup (plan 2026-10-07, R1). The practice is made at the end of the Facility track (section 5). A user who signs up from a Facility Admin invite (a pending staff invite with staff_role: 'admin') goes the staff way instead and lands at the invited facility.
    • staff: it processes the pending staff invites. vendor: it makes the vendor record. patient: it makes the patient record.

2. Connect onboarding tracks

The old shell OnboardingModal (3 steps) is gone. #2613 removed it. Connect onboarding now runs as full-page steps, one track for each role. Connect onboarding by role has every step.

  • Where the user lands. Sign-in ends on next when it is a safe path, else on /connect (signInTarget, frontend/apps/shell/src/services/identity/postAuth.ts). /connect (ConnectIndexRoute, frontend/apps/connect/src/pages/onboarding/OnboardingPages.tsx) sends a user who has not finished to their next open step, and a finished user to the dashboard.
  • The role. onboardingRoleFor(user.signed_up_as) (frontend/packages/shared-services/src/domains/connect/onboarding.types.ts:308): practitioner or doctor is Practitioner, facility or owner is Owner, staff or facility_staff is Staff, vendor or lamaze is Vendor. Any other value opens Welcome (/connect/welcome), where the user picks a role.
  • The answers. They go to one onboarding document on the user (user.onboarding_data.step_data.connect, GET and POST /identity/users/:id/onboarding) and save as the user types. Only a few steps also write to a real record (see the guide by role).
  • The Facility track (Owner). About, Facility, Practice and Verify (only when the owner sees patients), Address (not for a virtual-only facility), Team, Operations, Plan and Review. Nothing is made while the owner walks the steps. Review's "Create my practice" calls POST /identity/my/facility_setup with new_tenant (frontend/packages/shared-services/src/domains/connect/facilitySetup.ts), which makes the practice, its facility, the owner's role, the plan's trial and the team invites (section 5).
  • The add-facility track. Facility, Address, Team, Operations, Plan and Review. Review's button is "Add my facility" for existing_tenant (a user who owns or administers an active practice), else "Create my practice" (new_tenant).
  • "Not now" on Review makes nothing.
  • Finished. The user's own flow is finished when each of its steps is completed or skipped. The first save that sees this records onboarding.completed_at.

3. Connect Dashboard — state branching

  • File: frontend/apps/connect/src/pages/DashboardPage.tsx
  • Gate (lines 49 + 77-79):
    const hasMemberships = availableProfiles.length > 0
    // ...
    {!isAuthLoading && !hasMemberships && user.is_practitioner && (
      <ProductInterestForm />
    )}
  • If hasMemberships, the standard dashboard renders (stats grid + upcoming appointments + recent referrals).

4. State A: product lead capture

5. The end of the Facility track: POST /identity/my/facility_setup

Two modes:

ModeWhoWhat it makes
new_tenantA user who may start a practice (PracticeStart) and owns no active practiceAn active tenant, organization, facility and owner membership, through SetupService.call(draft: false). plan_id is required. It must be a plan for sale (SubscriptionPlan.for_sale) offered in the facility's country.
existing_tenantOwner or admin of the active practice tenant_id, with the manage_facilities permissionA facility under that practice. The facility country must be the practice's country.

In one transaction, under an advisory lock on the user:

  1. The records, with the answers mapped onto the facility and its primary location.
  2. A physician role for an owner who sees patients (CreateOrLinkWithRoleService, safe to call twice). The owner sees patients when answers.owner_sees_patients is true, or when the team has their own row (is_self: true) with the practitioner role. A practitioner who adds a facility of their own is not asked; the Facility steps put them on the team instead.
  3. The result, stored against idempotency_key in users.settings and marked incomplete.

After the commit:

  1. Billing. new_tenant starts the plan's trial (Billing::Subscriptions::CreateService, no payment). An owner who owns another practice that lapsed for non-payment gets no trial (TrialPolicy.no_new_trial?): no subscription is made, and subscription_error says to pay for the plan from billing. Checkout on the new practice then starts it with no trial. The rule applies to a new practice only, one with no live subscription in its history (TrialPolicy.owner_lapsed_elsewhere?). It reads the owners of the practice and the user who pays. A practice with a history keeps its own record, and a co-owner's lapse elsewhere does not change it. existing_tenant puts the facility on the practice's subscription through PreviewService and ChangeService, the path the subscription screens use. It does this only when the membership has manage_subscription and the quote's new monthly total equals expected_new_recurring. The client gets that number, in minor units, from POST /billing/subscriptions/changes/preview. Connect's Plan step sends the target { kind: 'facility', facilityTypeId }, which prices the same as the target B6 builds after it makes the facility. The step keeps the total as new_recurring in the plan answers, and Review sends it. The step asks only when the session is in that practice and the member has manage_subscription. Otherwise the facility stays off the plan, and someone with the permission adds it from billing.
  2. Invites. Each team row, less the owner's own row, gets a practitioner or staff invite in the new practice's context.
  3. The final result replaces the stored one and is marked complete.

A billing or invite failure goes into the response and does not undo the practice. A billing failure is also logged and reported to error tracking.

Response:

{ "data": { "tenant_id": "…", "facility_id": "…", "status": "active",
  "subscription": { "status": "trial", "trial_end_date": "…" },
  "subscription_error": null,
  "subscription_error_code": null,
  "invites": { "sent": 2, "failed": [] } } }

subscription_error_code is no_trial when the practice gets no free trial (TrialPolicy), and null for every other error. Connect's Review shows the server's subscription_error line as it is only for no_trial. It branches on the code, never on the wording.

Repeats:

  • Same key, complete result: 200 with the stored result. Nothing is made or sent.
  • Same key, incomplete result (the process stopped after the commit): the billing and the invites run again, and the result is completed. A trial or plan item that exists is kept, and an invite that went out is counted as sent.
  • A name race: two requests that pick the same name at the same moment are retried once. A second clash gets 409 FACILITY_SETUP_CONFLICT.

Refusals: 403 AUTHZ_INSUFFICIENT_ROLE for a user who may not start a practice, or who is not an owner or admin with manage_facilities of the active practice. 422 PRACTICE_ALREADY_OWNED for a new_tenant from a user who already owns an active practice, unless the body sets allow_second_practice: true. No screen sends that flag. 422 with field errors for a missing name or type, a missing, foreign or not-for-sale plan, a bad team row, or a bad expected_new_recurring. 422 FACILITY_SETUP_MODE_REQUIRED for an unknown mode.

The result is stored under a row lock on the user, and the write sets only the facility_setup_results key of users.settings (jsonb_set). A write to another key of users.settings is kept.

Known limit: other writers of users.settings (reauth codes, identifier changes, ABHA) still read and rewrite the whole column without the row lock. One of them can drop facility_setup_results. A retry with that idempotency key then gets 422 PRACTICE_ALREADY_OWNED, not the stored result.

POST /billing/subscriptions and POST /billing/subscriptions/initiate also take only a plan for sale. A legacy plan that live subscriptions keep active, such as a free one, is 404.

An owner who already owns a practice

A Facility Owner from before #2613 owns an active practice and has never done Connect onboarding. The owner flow does not offer them a second practice:

  • The onboarding repository reads the active practices the user owns from the profile list (activeOwnedPracticeIds). On every read it marks such an owner on Facility details (owned_practice_id, withOwnedPractice). A save stores the mark.
  • stepsFor then drops Facility details, Address, Team, Operations and Plan. The flow is About, Practice and Verify when the owner says on About that they see patients, and Review. Until the owner answers on About, an answer they gave earlier on Facility details stands (ownerSeesPatientsIn).
  • Review makes nothing (makesFacility is false). The owner adds facilities from the dashboard.
  • Practice and Verify make only the practitioner record. They do not give the owner a physician role at their existing facility, so patients cannot book them there yet. That is pending work.

The rest of the Connect side

  • "Add a facility" (/connect/facility/new) does not reset the user's own flow while it is not complete, for any role. It opens the furthest step of that flow. The reset would write add_facility over their role, and nothing brings that role back. An owner who already owns a practice is the exception: their own flow has no Facility steps, so the reset runs.
  • onboarding.completed_at is recorded only from the user's own flow, never from the add_facility flow. A finished add_facility flow also does not make the user complete (onboardingCompleted). Only completed_at does.
  • A changed facility type on Facility details clears plan.new_recurring and the Plan step's status, so the owner confirms the price again before Review.

Sales-led provisioning from a product lead is still manual. Sales provisions those practices by hand, not through this endpoint.

6. Self-serve facility addition (post-onboarding)

The onboarding add-facility track uses POST /identity/my/facility_setup with existing_tenant (section 5). The dashboard form below uses POST /identity/facilities. Both need the manage_facilities permission.

  • File: frontend/apps/connect/src/pages/FacilityNewPage.tsx
  • Gate (line 80): selectedProfile?.role === 'owner' || selectedProfile?.role === 'admin'. Other roles see an access-denied guidance card.
  • Practice type radio (form field practice_type, default 'in_person'):
    • in_person: collects address_line_1, city (required), state, postal_code, etc.
    • virtual_only: hides the address section entirely.
  • Submit payload for both branches:
    {
      "facility": {
        "name": "...",
        "display_name": "...",
        "slug": "auto-derived-from-name-and-city",
        "service_level": "opd_only",
        "is_virtual_only": true,
        "locations_attributes": [/* only for in_person */]
      }
    }
  • Backend: backend/domains/identity/controllers/facilities_controller.rb — create strips client-supplied organization_id and derives it from current_tenant.organizations.order(:created_at).first (single-org tenants today; multi-org gets the oldest org with a Rails.logger warning).
  • Post-create flow:
    1. GET /identity/profiles → useAuthStore.setAvailableProfiles(fresh) — refreshes the store before the JWT swap so selectedProfile resolves cleanly.
    2. queryClient.invalidateQueries({ queryKey: identityKeys.facilities.all() }).
    3. identityAuthService.switchFacility({ tenantId, facilityId }) — flips the bearer.
    4. Toast "{facility name} is ready." → navigate to /connect/dashboard.

Virtual OPD — the explicit answer

What "virtual OPD" means in the schema: service_level: 'opd_only' + is_virtual_only: true.

The DB enum identity.service_level does not include 'virtual_only'. Virtual is signaled by the dedicated boolean column. If you find yourself trying to add 'virtual_only' to the enum union, stop — read this section.

Path A — sales-led (new clinic)

  1. Practitioner signs up → 3-step OnboardingModal → State A on Connect Dashboard.
  2. Selects "OPD Module" in ProductInterestForm, optionally adds notes ("telemedicine-only practice"), submits.
  3. Sales calls back, provisions tenant + facility via the manual SOP. They set is_virtual_only: true if the practitioner asked for telemedicine-only.
  4. Practitioner logs back in to Connect and sees their tenant active in the facility switcher.

Path B — self-serve (existing tenant)

  1. Owner or admin already has a tenant. They navigate to /connect/facility/new (link from the "Add facility" QuickAction on Dashboard, gated on owner/admin role).
  2. Pick "Virtual-only practice" radio. Address fields disappear.
  3. Submit. Backend persists service_level: 'opd_only', is_virtual_only: true.
  4. Frontend refreshes profiles → switches JWT to the new facility → toast → land on Dashboard with the new virtual OPD selected.

Permissions matrix

RoleView Connect DashboardUse ProductInterestFormCreate new facility
Owner✓only if no membership✓
Admin✓only if no membership✓
Staff / nurse / coordinator✓only if no membership✗ (access-denied card on /connect/facility/new)
State A practitioner✓✓ (the only path forward)✗ (no tenant)

Test accounts

Full list in backend/db/seeds/identity/TEST_ACCOUNTS.md. Quick reference for testing this flow:

EmailPasswordTests
state.a.practitioner@example.comStateA123!State A → ProductInterestForm
dr.james.wilson@smith.comWilson123!Owner → /connect/facility/new (in-person + virtual-only)
sarah.johnson@cityhospital.comAdmin123!Admin → /connect/facility/new (TOTP required)
rani.nurse@cityhospital.comNurse123!Staff → access-denied guidance card

To get the State A account, run bin/rails db:drop db:create db:migrate db:seed (the State A user is in 04_users.rb + 06_practitioners.rb with no membership rows).

Test scenarios

  1. State A flow: log in as state.a.practitioner@example.com. Confirm the ProductInterestForm card renders. Submit OPD Module + Hospital. In Rails console: Identity::ProductLead.where(user_id: SeedUUIDs::STATE_A_PRACTITIONER).pluck(:product) should return both. Sidekiq web should show two NotifySalesLeadJob enqueues (or check Rails.logger for two [sales.product_lead] lines).
  2. Self-serve in-person: log in as dr.james.wilson@smith.com. Click "Add facility" → fill name, city, full address. Submit. Confirm: toast appears, facility switcher shows new facility selected, DB row has is_virtual_only: false and service_level: 'opd_only'.
  3. Self-serve virtual-only: same account. Pick "Virtual-only practice". Submit. Confirm: address fields didn't render, DB row has is_virtual_only: true and service_level: 'opd_only'.
  4. Staff access-denied: log in as rani.nurse@cityhospital.com. Navigate directly to /connect/facility/new. Confirm: access-denied guidance card; "Add facility" QuickAction is hidden on Dashboard.

Open documentation debt

  • The sales SOP: how a captured lead becomes a provisioned tenant is not in this repo. Document the manual SOP in an ops-owned doc and link to it from step 4 above, or give sales an admin tool.
  • Identity::Facilities::SetupService.call(draft: false) is what POST /identity/my/facility_setup wraps for a new practice (section 5). An admin tool for sales can wrap it the same way.

Cross-references

On this page