Kulcare Docs
Abdm

M1 — Link Token Generation & ABHA-Address Flow: Test Cases

Scope: Every path that generates an ABDM HIP link token (care-context linking, M1→M2 bridge), and how the patient's ABHA address is selected / carried for each booking entry point (UHI, public appointment, CMS add/edit patient, scan & share).

Why this doc exists: link-token generation is fan-out across 7 call sites, and the ABHA address that "goes with" a booking is sourced differently in each flow. This doc maps every case so nothing silently drops the ABHA or fails to link.

Last verified: 2026-07-31 against feature/abdm_integration_port.


1. Architecture — where link tokens are generated (7 entry points)

#TriggerService / JobSync or AsyncFile
L1CMS ABHA verification (verify_otp / select_account)LinkTokenService.call via perform_auto_link_tokenSyncdomains/integration/controllers/abdm/abha/verifications_controller.rb
L2Patient create (CMS add, patient-app order, public booking)FindOrCreateService#enqueue_link_token_if_abha_linked → EnqueueLinkTokenJobAsyncdomains/clinical/services/patients/find_or_create_service.rb:594
L3Patient edit (CMS patients tab update)RegisterPatientService#enqueue_link_token_job → EnqueueLinkTokenJobAsyncdomains/clinical/services/patients/register_patient_service.rb:98
L4Patient self-registration (public sign-up)SelfRegistrationService → EnqueueLinkTokenJobAsyncdomains/clinical/services/patients/self_registration_service.rb:145
L5UHI appointment bookingUhiClinicalSyncService#enqueue_link_token → EnqueueLinkTokenJobAsyncdomains/uhi/services/uhi_clinical_sync_service.rb:561
L6HIP link webhook (profile share / token callbacks)HipLinkHandler → EnqueueLinkTokenJobAsyncdomains/integration/services/abdm/webhooks/hip_link_handler.rb:423
L7Care-context linking (HI/M2 explicit)CareContextLinkingService → LinkTokenService.callSyncdomains/integration/services/abdm/hip/care_context_linking_service.rb:114

No double-enqueue. L2 (create) and L3 (update) live in mutually-exclusive branches of RegisterPatientService#call (return update_patient if @patient_id.present? else create_patient). A single request hits exactly one. Do not "dedupe" them — removing L3 would break ABHA linking on patient edits.

The async token-delivery model

LinkTokenService calls ABDM generate-token, which returns 202 Accepted (empty body). The actual link token arrives seconds later via the webhook POST /integration/abdm/webhooks/hip/on_token_generate → HipLinkHandler#on_token_generate → stored on integration.abdm_link_requests. Sidekiq must be running for the job, and the webhook endpoint must be reachable, for tokens to land.


2. LinkTokenService guards (HIP / token state)

From domains/integration/services/abdm/hip/link_token_service.rb:

ConditionBehavior
Facility not registered as HIP (hip_id nil, line 42)Returns failure: ['Facility is not registered as HIP']. Bypasses ABDM entirely. Non-blocking — job logs a warning and moves on.
Valid, non-expired token already exists (link_request.token_valid?)Fast-path reuse — no ABDM call. Returns status: 'token_already_exists'.
ABDM-1092 (duplicate)Finds prior valid token for same facility + abha_address and reuses it.
No prior tokenGenerates request_id, calls ABDM, returns status: 'pending_webhook'. Token delivered by on_token_generate.

Idempotency key: (patient + facility + abha_address) via find_or_create_link_request (for_abha_address scope). Re-runs are safe.


3. How the ABHA address is selected per booking path

3a. UHI appointment booking ✅ logged-in ABHA flows

  1. FE: patient/src/features/uhi/hooks/useUhiBooking.ts:152 → abhaAddress = user?.abha_address ?? ''
  2. Order payload: customer.id = abhaAddress (line 268 / 399)
  3. BE: booking.abha_address = order.dig('customer', 'id') (hspa/confirm_controller.rb:63, eua/on_confirm_controller.rb:85)
  4. UhiClinicalSyncService line 62: enqueue_link_token(...) if booking.abha_address.present?
  5. If patient has no profile yet, backfill_abha_from_booking creates one from booking.abha_address.

Source of user.abha_address: session.patient.abha_address returned by GenerateKulcareSessionService (generate_kulcare_session_service.rb:280, @abha_address.presence || patient.abha_address). Set on every ABHA login path.

⚠️ RISK — empty customer.id: if the patient is logged in via phone only (no ABHA), user.abha_address is empty → order sends customer.id: '' → booking.abha_address nil → no link token fires. This is silent. See TC-LT-06.

3b. Public / direct appointment booking (patient app)

  1. public_appointments/update_service.rb#find_or_create_patient builds patient_params with name, phone, email, gender, age, dob — NO abha field.
  2. Calls FindOrCreateService. enqueue_link_token_if_abha_linked enqueues only if patient.phr_abha_profile_id is set.
  3. FIXED: backfill_abha_if_available now consults the identity patient's existing Phr::AbhaProfile (created at PHR login) — so a patient who logged in via ABHA and books directly gets their profile linked and the token fires, without the booking carrying the ABHA. appointment_orders/create_service.rb passes identity_user: @user, so the same backfill applies there.

A patient who has no ABHA at all (never logged in via ABHA) correctly gets no link — there is nothing to link. See TC-LT-04 / TC-LT-05.

3c. CMS patients tab — add patient (create)

patients_controller#create → RegisterPatientService (create branch) → FindOrCreateService (L2). ABHA sourced from patient_params[:abha_id] / [:health_id] (create_abha_for_new_patient, line 472) or from user.settings['abha'] (backfill). One enqueue, via FindOrCreateService.

3d. CMS patients tab — edit patient (update)

patients_controller#update → RegisterPatientService (update branch, line 98) → enqueue_link_token_job (L3). Does not call FindOrCreateService. ABHA from the edit form (abha_address/abha_number params → create_abha_profile). One enqueue.

3e. Self-registration (public sign-up)

self_registration_service.rb:145 → EnqueueLinkTokenJob (L4).


Yes. Scan & Share is the patient walk-in flow: the patient scans the facility QR in the PHR app (ScanSharePage → share({ token, hip_id, counter_id })) and shares their profile. ABDM delivers it to the HIP:

  1. ABDM calls POST /integration/abdm/webhooks/hip/profile_share
  2. → HipLinkHandler#profile_share (line 225) → find_or_create_shared_patient (passes the shared abha_address in patient_params via patient_params_from_profile)
  3. → FindOrCreateService creates/links the patient + Phr::AbhaProfile
  4. → enqueue_link_token_for_patient (line 423, entry point L6) fires EnqueueLinkTokenJob for the linked ABHA profile
  5. → register_walk_in_and_token issues a visit / queue token
  6. → acknowledges via /on-share (SUCCESS or FAILED) → Ably delivers the token to the PHR app

So Scan & Share produces both a visit/queue token (for the encounter) and a care-context link request (integration.abdm_link_requests row via L6 → on_token_generate webhook). The two are distinct: the visit token is the queue number shown to the patient; the link token is what lets the HIP later add care contexts to this ABHA (M2).

Failure modes: if the shared profile lacks phone_number/country_code, patient registration fails → /on-share FAILED → no link token (patient wasn't created).


5. Test-case matrix

Legend: ✅ expected pass · ⚠️ known gap/risk · ❌ negative test

IDScenarioExpectedRef
TC-LT-01CMS ABHA verify (abha_number), HIP activeSync link request created (L1); on_token_generate webhook stores token ~2-3s later§1 L1
TC-LT-02CMS ABHA verify, then book appointmentAppointment's EnqueueLinkTokenJob (L2) hits idempotency fast-path (token_already_exists)§1 L2
TC-LT-03CMS add patient with abha_address, HIP activeL2 fires once; abdm_link_requests row created§3c
TC-LT-03bCMS edit patient, add abha_addressL3 fires once (NOT L2); abdm_link_requests row created§3d
TC-LT-04Patient-app direct booking, user has prior phr_abha_profile (logged in via ABHA before)L2 fires (backfill links the login-created profile). FIXED — see §3b§3b
TC-LT-05✅ Patient-app direct booking, user logged in via phone onlyIf the user has any ABHA from a prior login, backfill now links it (login-created phr.abha_profiles row). If the user genuinely has no ABHA, correctly no link. FIXED§3b
TC-LT-06⚠️❌ UHI booking, patient logged in via phone only (user.abha_address empty)customer.id: '' → booking.abha_address nil → no link token§3a RISK
TC-LT-07✅ UHI booking, patient logged in via ABHAcustomer.id = logged-in ABHA → booking.abha_address set → L5 fires§3a
TC-LT-08Multi-ABHA patient, UHI bookingOnly the logged-in user.abha_address flows (single value); other ABHAs not linked by this booking§3a
TC-LT-09Self-registration with ABHAL4 fires once§3e

Guard behavior

IDScenarioExpectedRef
TC-LT-10❌ Facility not registered as HIP + ABHA verificationLinkTokenService returns failure 'Facility is not registered as HIP'; no ABDM call; non-blocking (verification still succeeds for the user)§2
TC-LT-11HIP active, valid token already exists (re-book / re-verify)Fast-path reuse, status: token_already_exists; no ABDM generate call§2
TC-LT-12HIP active, prior token expiredNew generate-token → 202 → webhook delivers fresh token§2
TC-LT-13ABDM-1092 (duplicate) on generateReuses prior valid token for same facility + abha_address§2

Scan & share

IDScenarioExpectedRef
TC-LT-14M1 scan & share at HIP counterprofile_share webhook → patient + ABHA profile created + visit token issued + link token enqueued (L6); /on-share SUCCESS. Expect both a queue token AND an abdm_link_requests row§4
TC-LT-15Scan & share where shared profile missing phone/country_codePatient registration fails → /on-share FAILED; no link token (no patient created)§4 validate_profile_share

6. DB inspection queries

-- All link requests for a patient's ABHA address (most useful single query)
SELECT id, patient_id, abha_address, abha_number, status, link_token IS NOT NULL AS has_token,
       valid_to, created_at, updated_at
FROM integration.abdm_link_requests
WHERE abha_address = 'simmi@sbx'
ORDER BY created_at DESC;

-- Is the facility a HIP?
SELECT id, facility_id, service_id, active
FROM integration.abdm_facility_registrations
WHERE facility_id = '<facility-uuid>' AND registration_type = 'HIP';

-- Does the patient have an ABHA profile? (the gate for L2)
SELECT p.id, p.phr_abha_profile_id, ap.abha_address, ap.abha_number
FROM clinical.patients p
LEFT JOIN phr.abha_profiles ap ON ap.id = p.phr_abha_profile_id
WHERE p.id = '<clinical-patient-uuid>';

-- Recent ABDM API logs (generate-token + webhook) for an abha_address
SELECT id, endpoint, method, status_code, created_at
FROM integration.abdm_api_logs
WHERE (request_payload ->> 'abha_address' = 'simmi@sbx'
   OR response_payload ->> 'abha_address' = 'simmi@sbx')
ORDER BY created_at DESC LIMIT 20;

7. Known gaps / risks (open items)

  1. RESOLVED (TC-LT-04/05) — backfill now consults phr.abha_profiles. FindOrCreateService#backfill_abha_if_available was changed to look up the identity patient's existing Phr::AbhaProfile row (created at PHR login) as the primary source — independent of whether the FE wrote user.settings['abha']. The create path now calls backfill too. So a patient who logged in via ABHA and later books a direct/public/appointment-order gets linked and the link token fires. The only remaining "no link" case is a patient who genuinely has no ABHA at all (correct behavior).

  2. UHI customer.id still depends on user.abha_address (TC-LT-06). If the patient is logged in via phone only, the UHI order sends customer.id: '' → no ABHA → no link. This is expected for phone-only users (no ABHA to send); acceptable default.

  3. Multi-ABHA UHI selection. UHI carries only the single user.abha_address. No UI to pick which ABHA a UHI booking should link. Acceptable default, but documented.

  4. RESOLVED — test_fails_if_phone_number_missing drift. Phone is optional by design (UHI guest patients); the test was asserting obsolete "phone required" behavior. Replaced with a test asserting phone is optional (phone_number is optional (UHI guest patients arrive without it)). Full suite green.


8. Quick verification greps (confirm wiring before each pass)

# All EnqueueLinkTokenJob call sites (expect L2-L6)
grep -rn "EnqueueLinkTokenJob.perform_later" backend/domains backend/parts --include="*.rb"

# Sync LinkTokenService.call sites (expect L1, L7)
grep -rn "LinkTokenService.call" backend/domains --include="*.rb"

# UHI customer.id source
grep -n "user?.abha_address" patient/src/features/uhi/hooks/useUhiBooking.ts

# The HIP guard
grep -n "unless hip_id" backend/domains/integration/services/abdm/hip/link_token_service.rb

On this page