From a5447cb83adeaed5dc8c49cfc8fd7f51bce9fe7e Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Thu, 13 Aug 2026 10:36:20 +0530 Subject: [PATCH] Complete ABHA account verification flow --- app/Config/Routes.php | 2 + app/Controllers/Abha.php | 322 +++++++++++++++++- app/Controllers/Medical.php | 7 +- app/Controllers/Patient.php | 90 +---- app/Libraries/Abdm/AbdmConnectorInterface.php | 16 + app/Libraries/Abdm/DirectAbdmConnector.php | 10 + app/Libraries/Abdm/DreamsoftConnector.php | 10 + app/Libraries/Abdm/EAtriaBridgeConnector.php | 135 +++++++- app/Views/billing/Patient_V.php | 26 +- .../partials/abha_patient_match_modal.php | 33 +- app/Views/partials/abha_verify_modal.php | 287 ++++++++++++++++ .../ABHA_IDENTIFIER_LOGIN_BRIDGE_CONTRACT.md | 198 +++++++++++ 12 files changed, 1019 insertions(+), 117 deletions(-) create mode 100644 app/Views/partials/abha_verify_modal.php create mode 100644 docs/abdm/ABHA_IDENTIFIER_LOGIN_BRIDGE_CONTRACT.md diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 2f33878..67730d9 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -775,6 +775,8 @@ $routes->post('abha/create/confirm_patient', 'Abha::confirmPatient', ['filter' = $routes->post('abha/create/address', 'Abha::address', ['filter' => 'permission:abdm.abha.create']); $routes->get('abha/card/(:segment)', 'Abha::card/$1', ['filter' => 'permission:abdm.abha.create']); $routes->post('abha/register/validate', 'Abha::validateAbha', ['filter' => 'permission:abdm.abha.create']); +$routes->post('abha/register/login/request-otp', 'Abha::loginRequestOtp', ['filter' => 'permission:abdm.abha.create']); +$routes->post('abha/register/login/verify-otp', 'Abha::loginVerifyOtp', ['filter' => 'permission:abdm.abha.create']); $routes->get('abha/register/facility_qr', 'Abha::facilityQr', ['filter' => 'permission:abdm.abha.create']); $routes->get('AbdmTaskBoard', 'AbdmTaskBoard::index', ['filter' => $abdmPermFilter]); $routes->get('AbdmTaskBoard/list', 'AbdmTaskBoard::list', ['filter' => $abdmPermFilter]); diff --git a/app/Controllers/Abha.php b/app/Controllers/Abha.php index 6ec9911..e924401 100644 --- a/app/Controllers/Abha.php +++ b/app/Controllers/Abha.php @@ -731,16 +731,35 @@ class Abha extends BaseController */ private function extractBridgeErrorText(array $result, string $fallback): string { + $fieldErrors = []; + if (is_array($result['data'] ?? null)) { + foreach ($result['data'] as $field => $message) { + if ($field === 'timestamp' || ! is_string($message) || trim($message) === '') { + continue; + } + $fieldErrors[] = $field . ': ' . trim($message); + } + } + if ($fieldErrors !== []) { + return implode('; ', $fieldErrors); + } + $candidates = [ $result['error_text'] ?? null, $result['message'] ?? null, $result['error']['message'] ?? null, $result['error']['description'] ?? null, $result['error']['code'] ?? null, + $result['error']['error'] ?? null, + $result['error']['error_description'] ?? null, $result['data']['message'] ?? null, $result['data']['error']['message'] ?? null, $result['data']['error']['description'] ?? null, $result['data']['error']['code'] ?? null, + $result['data']['error']['error'] ?? null, + $result['data']['error']['error_description'] ?? null, + $result['details']['message'] ?? null, + $result['details']['error'] ?? null, $result['description'] ?? null, $result['data']['description'] ?? null, ]; @@ -766,6 +785,7 @@ class Abha extends BaseController } $action = trim((string) ($this->request->getPost('action') ?? '')); + $updateMode = trim((string) ($this->request->getPost('update_mode') ?? 'update')); $patientId = (int) ($this->request->getPost('patient_id') ?? 0); $abhaNum = (string) ($this->request->getPost('abha_number') ?? ''); $name = trim((string) ($this->request->getPost('name') ?? '')); @@ -776,6 +796,9 @@ class Abha extends BaseController if (! in_array($action, ['new', 'existing'], true)) { return $this->response->setJSON(['ok' => 0, 'error_text' => 'Invalid action. Must be "new" or "existing".']); } + if (! in_array($updateMode, ['keep', 'update'], true)) { + return $this->response->setJSON(['ok' => 0, 'error_text' => 'Invalid patient update mode.']); + } $abhaMeta = [ 'abha_address' => (string) ($this->request->getPost('abha_address') ?? ''), @@ -841,7 +864,20 @@ class Abha extends BaseController ]); } - $patientInfo = $this->linkAbhaToPatient($db, $patientId, $name, $mobile, $gender, $dob, $abhaMeta, $abhaNumClean, $abhaAddress, $fields, $abhaField); + $patientInfo = $this->linkAbhaToPatient( + $db, + $patientId, + $updateMode === 'update' ? $name : '', + $updateMode === 'update' ? $mobile : '', + $updateMode === 'update' ? $gender : '', + $updateMode === 'update' ? $dob : '', + $updateMode === 'update' ? $abhaMeta : array_diff_key($abhaMeta, array_flip(['address', 'district', 'state', 'zip', 'email'])), + $abhaNumClean, + $abhaAddress, + $fields, + $abhaField, + $updateMode === 'update' + ); } else { $patientInfo = $this->createPatientFromAbha($db, $name, $mobile, $gender, $dob, $abhaMeta, $abhaNumClean, $abhaAddress, $fields, $abhaField); } @@ -987,7 +1023,11 @@ class Abha extends BaseController return []; } - $select = 'id,p_code,p_fname,p_lname,gender,dob,age,mphone1,udai'; + $selectFields = array_values(array_intersect( + ['id', 'p_code', 'p_fname', 'p_lname', 'gender', 'dob', 'age', 'mphone1', 'udai', 'add1', 'district', 'state', 'zip'], + $fields + )); + $select = implode(',', $selectFields); if ($abhaField !== null && ! in_array($abhaField, ['id', 'p_code', 'p_fname', 'p_lname', 'gender', 'dob', 'age', 'mphone1', 'udai'], true)) { $select .= ',' . $abhaField; } @@ -1060,6 +1100,12 @@ class Abha extends BaseController 'dob' => $rowDob, 'age' => $rowAgeYears, 'mobile' => (string) ($row['mphone1'] ?? ''), + 'address' => trim(implode(', ', array_filter([ + (string) ($row['add1'] ?? ''), + (string) ($row['district'] ?? ''), + (string) ($row['state'] ?? ''), + (string) ($row['zip'] ?? ''), + ]))), 'aadhaar' => (string) ($row['udai'] ?? ''), 'abha' => $rowAbha, 'abha_conflict' => $abhaConflict, @@ -1099,21 +1145,22 @@ class Abha extends BaseController string $abhaNumClean, string $abhaAddress, array $fields, - ?string $abhaField + ?string $abhaField, + bool $updateDetails = false ): array { $existing = $db->table('patient_master')->where('id', $patientId)->get()->getRowArray() ?? []; $pCode = (string) ($existing['p_code'] ?? ''); $existingUpdates = []; $existingName = strtoupper(trim((string) ($existing['p_fname'] ?? ''))); - if ($name !== '' && ($existingName === '' || $existingName === 'ABHA PATIENT')) { + if ($name !== '' && ($updateDetails || $existingName === '' || $existingName === 'ABHA PATIENT')) { $existingUpdates['p_fname'] = strtoupper($name); } // Backfill DOB/gender/mobile from ABHA profile only if not already recorded, // so we never clobber a manually-corrected value. $existingDob = trim((string) ($existing['dob'] ?? '')); - if (($existingDob === '' || $existingDob === '0000-00-00') && $dob !== '') { + if (($updateDetails || $existingDob === '' || $existingDob === '0000-00-00') && $dob !== '') { $dobDb = $this->normalizeDobToDb($dob); if ($dobDb !== '') { $existingUpdates['dob'] = $dobDb; @@ -1123,10 +1170,10 @@ class Abha extends BaseController } } $existingGenderRaw = (int) ($existing['gender'] ?? 0); - if ($existingGenderRaw <= 0 && $gender !== '') { + if (($updateDetails || $existingGenderRaw <= 0) && $gender !== '') { $existingUpdates['gender'] = $this->toPatientGenderValue($gender); } - if ($mobile !== '' && empty($existing['mphone1'])) { + if ($mobile !== '' && ($updateDetails || empty($existing['mphone1']))) { $existingUpdates['mphone1'] = $mobile; } @@ -1440,11 +1487,26 @@ class Abha extends BaseController */ private function pickGatewayAbhaProfile(array $payload): array { + $account = is_array($payload['account'] ?? null) ? $payload['account'] : []; + $loginAccount = is_array($payload['accounts'][0] ?? null) ? $payload['accounts'][0] : []; + $mergedAccount = $loginAccount; + foreach ($account as $key => $value) { + if (! is_scalar($value) || trim((string) $value) === '') { + continue; + } + if (in_array(strtoupper(trim((string) $value)), ['PATIENT', 'PATIENT NAME', 'N/A'], true)) { + continue; + } + $mergedAccount[$key] = $value; + } + $candidates = [ $payload['ABHAProfile'] ?? null, $payload['gateway_abha_profile'] ?? null, $payload['profile'] ?? null, - $payload['accounts'][0] ?? null, + $mergedAccount, + $account, + $loginAccount, $payload['gateway_patient'] ?? null, $payload, ]; @@ -1464,7 +1526,7 @@ class Abha extends BaseController */ private function extractAbhaProfileName(array $profile, array $payload): string { - foreach ([$profile, $payload, $payload['gateway_abha_profile'] ?? [], $payload['gateway_patient'] ?? []] as $source) { + foreach ([$profile, $payload, $payload['account'] ?? [], $payload['gateway_abha_profile'] ?? [], $payload['gateway_patient'] ?? []] as $source) { if (! is_array($source)) { continue; } @@ -1541,11 +1603,17 @@ class Abha extends BaseController $result['error'] ?? null, $result['error']['message'] ?? null, $result['error']['description'] ?? null, + $result['error']['error'] ?? null, + $result['error']['error_description'] ?? null, $result['data']['message'] ?? null, $result['data']['description'] ?? null, $result['data']['error'] ?? null, $result['data']['error']['message'] ?? null, $result['data']['error']['description'] ?? null, + $result['data']['error']['error'] ?? null, + $result['data']['error']['error_description'] ?? null, + $result['details']['message'] ?? null, + $result['details']['error'] ?? null, $result['data']['raw_response'] ?? null, ] as $candidate) { if (is_string($candidate) && trim($candidate) !== '') { @@ -1603,10 +1671,246 @@ class Abha extends BaseController $validStates = ['VALID', 'ACTIVE', 'VERIFIED', 'EXISTS', 'FOUND', 'SUCCESS']; $status = in_array($statusRaw, $validStates, true) || $statusRaw === '' ? 'VALID' : $statusRaw; + $data = is_array($result['data'] ?? null) ? $result['data'] : []; + $accounts = is_array($data['accounts'] ?? null) ? $data['accounts'] : []; + $account = isset($accounts[0]) && is_array($accounts[0]) + ? $accounts[0] + : (is_array($data['account'] ?? null) ? $data['account'] : []); + $txnId = trim((string) ( + $result['txn_id'] + ?? $result['txnId'] + ?? $data['txn_id'] + ?? $data['txnId'] + ?? $data['transaction_id'] + ?? $data['transactionId'] + ?? $account['txn_id'] + ?? $account['txnId'] + ?? '' + )); + $authMethods = is_array($result['auth_methods'] ?? null) + ? $result['auth_methods'] + : (is_array($result['authMethods'] ?? null) + ? $result['authMethods'] + : (is_array($data['authMethods'] ?? null) ? $data['authMethods'] : ($data['auth_methods'] ?? []))); + $blockedMethods = $data['blockedAuthMethods'] ?? $data['blocked_auth_methods'] ?? $account['blockedAuthMethods'] ?? $account['blocked_auth_methods'] ?? []; + $maskedMobile = $this->maskAbhaMobile((string) ( + $data['mobile'] + ?? $data['mobileNumber'] + ?? $data['maskedMobile'] + ?? $data['masked_mobile'] + ?? $data['phone'] + ?? $account['masked_mobile'] + ?? $account['mobile'] + ?? $account['mobileNumber'] + ?? $account['maskedMobile'] + ?? $account['phone'] + ?? '' + )); return $this->response->setJSON([ 'ok' => 1, 'status' => $status, 'raw_status' => $statusRaw, + 'txn_id' => $txnId, + 'auth_methods' => array_values(array_unique(array_map(static fn($method): string => strtoupper(trim((string) $method)), $authMethods))), + 'blocked_auth_methods' => is_array($blockedMethods) ? array_values($blockedMethods) : [], + 'masked_mobile' => $maskedMobile, + 'account' => [ + 'name' => trim((string) ($account['name'] ?? $account['fullName'] ?? $account['full_name'] ?? $data['name'] ?? $data['fullName'] ?? $data['full_name'] ?? '')), + 'abha_number' => trim((string) ($account['ABHANumber'] ?? $account['abhaNumber'] ?? $account['abha_id'] ?? $account['healthIdNumber'] ?? $data['ABHANumber'] ?? $data['healthIdNumber'] ?? $data['abha_id'] ?? '')), + 'abha_address' => trim((string) ($account['abhaAddress'] ?? $account['preferredAddress'] ?? $account['preferredAbhaAddress'] ?? $account['abha_address'] ?? $data['abhaAddress'] ?? $data['preferredAddress'] ?? $data['preferredAbhaAddress'] ?? $data['abha_address'] ?? '')), + 'masked_mobile' => $maskedMobile, + ], ]); } + + private function maskAbhaMobile(string $value): string + { + $value = trim($value); + if ($value === '' || preg_match('/\*/', $value) === 1) { + return $value; + } + + $digits = preg_replace('/\D/', '', $value); + if (strlen($digits) === 10) { + return substr($digits, 0, 2) . '*****' . substr($digits, -3); + } + + return $value; + } + + public function loginRequestOtp() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + $txnId = trim((string) ($this->request->getPost('txn_id') ?? '')); + $authMethod = strtoupper(trim((string) ($this->request->getPost('auth_method') ?? ''))); + if ($txnId === '' || ! in_array($authMethod, ['MOBILE_OTP', 'AADHAAR_OTP'], true)) { + return $this->response->setStatusCode(422)->setJSON(['ok' => 0, 'error_text' => 'Valid transaction and OTP method are required.']); + } + + try { + $result = AbdmConnectorFactory::make()->abhaLoginRequestOtp([ + 'txn_id' => $txnId, + 'auth_method' => $authMethod, + 'abha_id' => trim((string) ($this->request->getPost('abha_id') ?? '')), + 'abha_address' => trim((string) ($this->request->getPost('abha_address') ?? '')), + 'scope' => trim((string) ($this->request->getPost('scope') ?? 'abha-login')), + ]); + } catch (\Throwable $e) { + return $this->response->setStatusCode(502)->setJSON(['ok' => 0, 'error_text' => $e->getMessage()]); + } + + if (empty($result['ok']) || (int) $result['ok'] !== 1) { + $httpCode = (int) ($result['http_code'] ?? 0); + if ($httpCode < 400 || $httpCode > 599) { + $httpCode = 502; + } + return $this->response->setStatusCode($httpCode)->setJSON([ + 'ok' => 0, + 'error_text' => $this->extractBridgeErrorText($result, 'The ABDM Bridge could not send the OTP.'), + 'request_id' => (string) ($result['request_id'] ?? ''), + ]); + } + + $data = is_array($result['data'] ?? null) ? $result['data'] : []; + $maskedMobile = trim((string) ($data['masked_mobile'] ?? $data['maskedMobile'] ?? $data['mobile'] ?? '')); + $deliveryMessage = trim((string) ($result['message'] ?? $data['message'] ?? '')); + if ($maskedMobile === '' && preg_match('/(\d{4})(?!.*\d)/', $deliveryMessage, $mobileEnding) === 1) { + $maskedMobile = '******' . $mobileEnding[1]; + } + return $this->response->setJSON([ + 'ok' => 1, + 'txn_id' => (string) ($result['txn_id'] ?? $result['txnId'] ?? $data['txn_id'] ?? $data['txnId'] ?? $txnId), + 'auth_method' => $authMethod, + 'masked_mobile' => $this->maskAbhaMobile($maskedMobile), + 'resend_after' => max(60, (int) ($data['resend_after'] ?? $data['resendAfter'] ?? 60)), + ]); + } + + public function loginVerifyOtp() + { + if (! $this->request->isAJAX()) { + return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']); + } + + $txnId = trim((string) ($this->request->getPost('txn_id') ?? '')); + $authMethod = strtoupper(trim((string) ($this->request->getPost('auth_method') ?? ''))); + $otp = preg_replace('/\D/', '', trim((string) ($this->request->getPost('otp') ?? ''))); + if ($txnId === '' || ! in_array($authMethod, ['MOBILE_OTP', 'AADHAAR_OTP'], true) || strlen($otp) !== 6) { + return $this->response->setStatusCode(422)->setJSON(['ok' => 0, 'error_text' => 'Transaction, OTP method, and 6-digit OTP are required.']); + } + + try { + $result = AbdmConnectorFactory::make()->abhaLoginVerifyOtp([ + 'txn_id' => $txnId, + 'auth_method' => $authMethod, + 'otp' => $otp, + ]); + } catch (\Throwable $e) { + return $this->response->setStatusCode(502)->setJSON(['ok' => 0, 'error_text' => $e->getMessage()]); + } + + if (empty($result['ok']) || (int) $result['ok'] !== 1) { + $httpCode = (int) ($result['http_code'] ?? 0); + if ($httpCode < 400 || $httpCode > 599) { + $httpCode = 502; + } + return $this->response->setStatusCode($httpCode)->setJSON([ + 'ok' => 0, + 'error_text' => $this->extractBridgeErrorText($result, 'OTP verification failed.'), + 'request_id' => (string) ($result['request_id'] ?? ''), + ]); + } + + $payload = is_array($result['data'] ?? null) + ? array_merge($result, $result['data']) + : $result; + $profile = $this->pickGatewayAbhaProfile($payload); + $firstNonEmpty = static function (array $values): string { + foreach ($values as $value) { + if (is_scalar($value) && trim((string) $value) !== '') { + return trim((string) $value); + } + } + return ''; + }; + $abhaNumber = preg_replace('/\D/', '', $firstNonEmpty([$profile['ABHANumber'] ?? null, $profile['abhaNumber'] ?? null, $profile['abha_number'] ?? null, $profile['abha_id'] ?? null, $payload['ABHANumber'] ?? null, $payload['abhaNumber'] ?? null, $payload['abha_id'] ?? null])); + $abhaAddress = $firstNonEmpty([$profile['preferredAddress'] ?? null, $profile['preferredAbhaAddress'] ?? null, $profile['abhaAddress'] ?? null, $profile['abha_address'] ?? null, $payload['preferredAddress'] ?? null, $payload['abhaAddress'] ?? null, $payload['abha_address'] ?? null]); + $name = $this->extractAbhaProfileName($profile, $payload); + $mobile = $firstNonEmpty([$profile['mobile'] ?? null, $profile['mobileNumber'] ?? null, $profile['mobile_number'] ?? null, $payload['mobile'] ?? null, $payload['mobileNumber'] ?? null, $payload['mobile_number'] ?? null]); + $gender = $firstNonEmpty([$profile['gender'] ?? null, $payload['gender'] ?? null]); + $dob = $firstNonEmpty([$profile['dob'] ?? null, $profile['dateOfBirth'] ?? null, $profile['date_of_birth'] ?? null, $payload['dob'] ?? null, $payload['dateOfBirth'] ?? null, $payload['date_of_birth'] ?? null]); + if ($dob === '') { + $day = $firstNonEmpty([$profile['dayOfBirth'] ?? null, $profile['day_of_birth'] ?? null]); + $month = $firstNonEmpty([$profile['monthOfBirth'] ?? null, $profile['month_of_birth'] ?? null]); + $year = $firstNonEmpty([$profile['yearOfBirth'] ?? null, $profile['year_of_birth'] ?? null]); + if ($day !== '' && $month !== '' && $year !== '') { + $dob = sprintf('%04d-%02d-%02d', (int) $year, (int) $month, (int) $day); + } + } + $address = $firstNonEmpty([$profile['address'] ?? null, $profile['addressLine'] ?? null, $profile['address_line'] ?? null, $payload['address'] ?? null, $payload['addressLine'] ?? null, $payload['address_line'] ?? null]); + $district = $firstNonEmpty([$profile['districtName'] ?? null, $profile['district_name'] ?? null, $profile['district'] ?? null, $payload['districtName'] ?? null, $payload['district_name'] ?? null, $payload['district'] ?? null]); + $state = $firstNonEmpty([$profile['stateName'] ?? null, $profile['state_name'] ?? null, $profile['state'] ?? null, $payload['stateName'] ?? null, $payload['state_name'] ?? null, $payload['state'] ?? null]); + $zip = $firstNonEmpty([$profile['pinCode'] ?? null, $profile['pincode'] ?? null, $profile['pin'] ?? null, $payload['pinCode'] ?? null, $payload['pincode'] ?? null, $payload['pin'] ?? null]); + $photo = $firstNonEmpty([$profile['profilePhoto'] ?? null, $profile['profile_photo'] ?? null, $profile['photo'] ?? null, $payload['profilePhoto'] ?? null, $payload['profile_photo'] ?? null, $payload['photo'] ?? null]); + $cardData = $this->extractAbhaCardData($payload); + + $db = \Config\Database::connect(); + $fields = $db->getFieldNames('patient_master') ?? []; + $abhaField = $this->resolveAbhaFieldName($fields); + $candidates = $this->findMatchingCandidates($db, $fields, $name, $mobile, $gender, $dob, '', $abhaField, $abhaNumber); + + return $this->response->setJSON([ + 'ok' => 1, + 'need_confirmation' => true, + 'abha_number' => $abhaNumber, + 'abha_address' => $abhaAddress, + 'name' => $name, + 'mobile' => $mobile, + 'gender' => $gender, + 'dob' => $dob, + 'address' => $address, + 'district' => $district, + 'state' => $state, + 'zip' => $zip, + 'photo' => $photo, + 'verified_status' => 'VERIFIED', + 'verification_type' => $authMethod, + 'mobile_verified' => $authMethod === 'MOBILE_OTP' ? 1 : null, + 'card_base64' => $cardData, + 'card_content_type' => $this->resolveAbhaCardContentType($payload), + 'candidates' => $candidates, + ]); + } + + private function extractAbhaCardData(array $payload): string + { + $sources = [$payload, $payload['account'] ?? [], $payload['profile'] ?? []]; + foreach ($sources as $source) { + if (! is_array($source)) { continue; } + foreach (['card_base64', 'card_data', 'abhaCard', 'abha_card', 'official_card', 'cardData', 'card'] as $key) { + $value = $source[$key] ?? ''; + if (is_string($value) && trim($value) !== '') { return trim($value); } + if (is_array($value)) { + foreach (['base64', 'data', 'card_base64', 'card_data'] as $nestedKey) { + if (is_string($value[$nestedKey] ?? null) && trim($value[$nestedKey]) !== '') { + return trim($value[$nestedKey]); + } + } + } + } + } + return ''; + } + + private function resolveAbhaCardContentType(array $payload): string + { + $format = strtolower(trim((string) ($payload['card_content_type'] ?? $payload['cardContentType'] ?? $payload['card_format'] ?? ($payload['account']['card_format'] ?? '')))); + if ($format === 'png') { return 'image/png'; } + if ($format === 'jpg' || $format === 'jpeg') { return 'image/jpeg'; } + if ($format === 'pdf') { return 'application/pdf'; } + return $format !== '' && str_contains($format, '/') ? $format : 'image/png'; + } } diff --git a/app/Controllers/Medical.php b/app/Controllers/Medical.php index f057358..8241554 100644 --- a/app/Controllers/Medical.php +++ b/app/Controllers/Medical.php @@ -12530,7 +12530,7 @@ class Medical extends BaseController : ''; $whereSql = implode(' AND ', $conditions); - $sql = "SELECT p.item_name, p.genericname, + $sql = "SELECT p.item_name, p.formulation, p.genericname, IFNULL(s.packing,p.packing) AS packing, s.batch_no, s.expiry_date, @@ -12546,7 +12546,7 @@ class Medical extends BaseController JOIN purchase_invoice_item s ON p.id=s.item_code JOIN purchase_invoice m ON s.purchase_id=m.id WHERE {$whereSql} - GROUP BY p.item_name, p.genericname, IFNULL(s.packing,p.packing), s.batch_no, s.expiry_date, s.mrp, s.purchase_unit_rate, s.CGST_per + GROUP BY p.item_name, p.formulation, p.genericname, IFNULL(s.packing,p.packing), s.batch_no, s.expiry_date, s.mrp, s.purchase_unit_rate, s.CGST_per {$havingSql} ORDER BY p.item_name, s.batch_no"; @@ -12562,9 +12562,10 @@ class Medical extends BaseController foreach ($stockList as $row) { $stockCost = (float) ($row->stock_cost ?? 0); $totalStockValue += $stockCost; + $medicineName = trim((string) ($row->formulation ?? '') . ' ' . (string) ($row->item_name ?? '')); $content .= '' - . '' . esc((string) ($row->item_name ?? '')) . '' + . '' . esc($medicineName) . '' . '' . esc((string) ($row->genericname ?? '')) . '' . '' . esc(number_format((float) ($row->mrp ?? 0), 2, '.', '')) . '' . '' . esc((string) ($row->batch_no ?? '')) . '' diff --git a/app/Controllers/Patient.php b/app/Controllers/Patient.php index 1f3537a..5762f1f 100644 --- a/app/Controllers/Patient.php +++ b/app/Controllers/Patient.php @@ -3186,89 +3186,7 @@ class Patient extends BaseController $patient = $this->findPatientForAbhaProfile($requestedPid, $abhaDigits, $gatewayMobile); $patientId = (int) ($patient['id'] ?? 0); $before = $patient !== null ? $this->buildPatientSnapshot($patient) : null; - - $photoMeta = [ - 'saved' => false, - 'path' => '', - 'error' => '', - ]; - - if ($patientId > 0 && $this->db->tableExists('patient_master')) { - $updates = []; - - if ($abhaDigits !== '') { - $abhaField = $this->resolvePatientAbhaIdField(); - if ($abhaField !== null) { - $updates[$abhaField] = $abhaDigits; - } - } - - $pmFields = $this->db->getFieldNames('patient_master') ?? []; - if ($gatewayMobile !== '' && in_array('mphone1', $pmFields, true)) { - $updates['mphone1'] = preg_replace('/\D/', '', $gatewayMobile); - } - $dobDb = $this->normalizeGatewayDobToDb($gatewayDob); - if ($dobDb !== '' && in_array('dob', $pmFields, true)) { - $updates['dob'] = $dobDb; - if (in_array('estimate_dob', $pmFields, true)) { - $updates['estimate_dob'] = 0; - } - } - $genderDb = $this->toPatientGenderDbValue($gatewayGender); - if ($genderDb !== null && in_array('gender', $pmFields, true)) { - $updates['gender'] = $genderDb; - } - if ($gatewayAddress !== '' && in_array('add1', $pmFields, true)) { - $updates['add1'] = $gatewayAddress; - } - if ($gatewayCity !== '' && in_array('city', $pmFields, true)) { - $updates['city'] = $gatewayCity; - } - if ($gatewayDistrictName !== '' && in_array('district', $pmFields, true)) { - $updates['district'] = $gatewayDistrictName; - } - if ($gatewayStateName !== '' && in_array('state', $pmFields, true)) { - $updates['state'] = $gatewayStateName; - } - if ($gatewayZip !== '' && in_array('zip', $pmFields, true)) { - $updates['zip'] = $gatewayZip; - } - if ($gatewayEmail !== '' && in_array('email1', $pmFields, true)) { - $updates['email1'] = $gatewayEmail; - } - if ($verifiedStatus !== '' && in_array('abha_verified_status', $pmFields, true)) { - $updates['abha_verified_status'] = $verifiedStatus; - } - if ($verificationType !== '' && in_array('abha_verification_type', $pmFields, true)) { - $updates['abha_verification_type'] = $verificationType; - } - if ($kycVerified !== null && in_array('abha_kyc_verified', $pmFields, true)) { - $updates['abha_kyc_verified'] = (int) ((string) $kycVerified === '1' || (string) $kycVerified === 'true' || (string) $kycVerified === 'yes'); - } - if ($mobileVerified !== null && in_array('abha_mobile_verified', $pmFields, true)) { - $updates['abha_mobile_verified'] = (int) ((string) $mobileVerified === '1' || (string) $mobileVerified === 'true' || (string) $mobileVerified === 'yes'); - } - if ($abhaAddress !== '' && in_array('abha_address', $pmFields, true)) { - $updates['abha_address'] = $abhaAddress; - } - if (in_array('abdm_linked_at', $pmFields, true) && ($abhaDigits !== '' || $abhaAddress !== '')) { - $updates['abdm_linked_at'] = date('Y-m-d H:i:s'); - } - - if ($updates !== []) { - $this->db->table('patient_master')->where('id', $patientId)->update($updates); - } - - if ($gatewayPhoto !== '') { - $photoMeta = $this->saveGatewayProfilePhotoToPatient($patientId, $gatewayPhoto); - } - } - - $afterPatient = null; - if ($patientId > 0 && $this->db->tableExists('patient_master')) { - $afterPatient = $this->db->table('patient_master')->where('id', $patientId)->get()->getRowArray(); - } - $after = $afterPatient !== null ? $this->buildPatientSnapshot($afterPatient) : $before; + $after = $before; $result['gateway_user'] = [ 'name' => $gatewayName, @@ -3324,7 +3242,11 @@ class Patient extends BaseController ? $this->normalizeGenderForCompare((string) ($after['gender'] ?? '')) === $this->normalizeGenderForCompare($gatewayGender) : null, ], - 'photo_update' => $photoMeta, + 'photo_update' => [ + 'saved' => false, + 'path' => '', + 'error' => '', + ], ]; return $result; diff --git a/app/Libraries/Abdm/AbdmConnectorInterface.php b/app/Libraries/Abdm/AbdmConnectorInterface.php index 89a6089..171269e 100644 --- a/app/Libraries/Abdm/AbdmConnectorInterface.php +++ b/app/Libraries/Abdm/AbdmConnectorInterface.php @@ -37,6 +37,22 @@ interface AbdmConnectorInterface */ public function validateAbha(string $abhaId, array $fullPayload = []): array; + /** + * Request an OTP for an ABHA account returned by validateAbha(). + * + * @param array $payload Must contain the search txn_id and + * selected MOBILE_OTP or AADHAAR_OTP method. + */ + public function abhaLoginRequestOtp(array $payload): array; + + /** + * Verify an account-bound ABHA login OTP and return the normalized profile + * plus the official ABHA card payload when available. + * + * @param array $payload Must contain txn_id, auth_method, and otp. + */ + public function abhaLoginVerifyOtp(array $payload): array; + // ------------------------------------------------------------------------- // Consent // ------------------------------------------------------------------------- diff --git a/app/Libraries/Abdm/DirectAbdmConnector.php b/app/Libraries/Abdm/DirectAbdmConnector.php index dd67b50..a5ac55e 100644 --- a/app/Libraries/Abdm/DirectAbdmConnector.php +++ b/app/Libraries/Abdm/DirectAbdmConnector.php @@ -55,6 +55,16 @@ class DirectAbdmConnector implements AbdmConnectorInterface ); } + public function abhaLoginRequestOtp(array $payload): array + { + throw new \RuntimeException('DirectAbdmConnector::abhaLoginRequestOtp() not implemented.'); + } + + public function abhaLoginVerifyOtp(array $payload): array + { + throw new \RuntimeException('DirectAbdmConnector::abhaLoginVerifyOtp() not implemented.'); + } + // ------------------------------------------------------------------------- // Consent // ------------------------------------------------------------------------- diff --git a/app/Libraries/Abdm/DreamsoftConnector.php b/app/Libraries/Abdm/DreamsoftConnector.php index 03057c9..9c07dbb 100644 --- a/app/Libraries/Abdm/DreamsoftConnector.php +++ b/app/Libraries/Abdm/DreamsoftConnector.php @@ -41,6 +41,16 @@ class DreamsoftConnector implements AbdmConnectorInterface return ['ok' => 1, 'queue_id' => $queueId, 'status' => 'queued']; } + public function abhaLoginRequestOtp(array $payload): array + { + throw new \RuntimeException('ABHA login OTP requires the synchronous e-Atria Bridge connector.'); + } + + public function abhaLoginVerifyOtp(array $payload): array + { + throw new \RuntimeException('ABHA login OTP verification requires the synchronous e-Atria Bridge connector.'); + } + // ------------------------------------------------------------------------- // Consent // ------------------------------------------------------------------------- diff --git a/app/Libraries/Abdm/EAtriaBridgeConnector.php b/app/Libraries/Abdm/EAtriaBridgeConnector.php index 7031200..4b98fdf 100644 --- a/app/Libraries/Abdm/EAtriaBridgeConnector.php +++ b/app/Libraries/Abdm/EAtriaBridgeConnector.php @@ -220,7 +220,10 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface } $lowerKey = strtolower($key); - if (in_array($lowerKey, ['token', 'refresh_token', 'refreshtoken', 'authorization', 'auth', 'otp'], true)) { + if (in_array($lowerKey, [ + 'token', 'refresh_token', 'refreshtoken', 'authorization', 'auth', 'otp', + 'loginid', 'login_id', 'mobile', 'mobilenumber', 'mobile_number', 'phone', 'phone_number', + ], true)) { return '[REDACTED]'; } @@ -544,6 +547,11 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface 'type' => $inputType, 'value' => $inputValue, ]; + if ($inputType === 'abha-address') { + $body['abha_address'] = $inputValue; + } else { + $body['abha_id'] = $inputValue; + } if ($this->hfrId !== '') { $body['hfr_id'] = $this->hfrId; } @@ -569,8 +577,20 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface } $data = is_array($result['data'] ?? null) ? $result['data'] : []; + $nestedResult = is_array($result['result'] ?? null) ? $result['result'] : []; $accounts = is_array($data['accounts'] ?? null) ? $data['accounts'] : []; - $firstAccount = isset($accounts[0]) && is_array($accounts[0]) ? $accounts[0] : []; + $firstAccount = isset($accounts[0]) && is_array($accounts[0]) + ? $accounts[0] + : (is_array($data['account'] ?? null) ? $data['account'] : []); + + $txnId = trim((string) ( + $result['txn_id'] ?? $result['txnId'] + ?? $data['txn_id'] ?? $data['txnId'] + ?? $data['transaction_id'] ?? $data['transactionId'] + ?? $nestedResult['txn_id'] ?? $nestedResult['txnId'] + ?? $nestedResult['transaction_id'] ?? $nestedResult['transactionId'] + ?? $firstAccount['txn_id'] ?? $firstAccount['txnId'] ?? '' + )); $status = ''; foreach ([ @@ -592,9 +612,14 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface $authMethods = []; foreach ([ + $result['authMethods'] ?? null, + $result['auth_methods'] ?? null, $data['authMethods'] ?? null, $data['auth_methods'] ?? null, $data['availableAuthMethods'] ?? null, + $nestedResult['authMethods'] ?? null, + $nestedResult['auth_methods'] ?? null, + $nestedResult['availableAuthMethods'] ?? null, $firstAccount['authMethods'] ?? null, $firstAccount['auth_methods'] ?? null, $firstAccount['availableAuthMethods'] ?? null, @@ -613,11 +638,117 @@ class EAtriaBridgeConnector implements AbdmConnectorInterface $result['auth_methods'] = $authMethods; } + if ($txnId !== '') { + $result['txn_id'] = $txnId; + } + + $maskedMobile = trim((string) ( + $result['masked_mobile'] ?? $result['maskedMobile'] + ?? $data['masked_mobile'] ?? $data['maskedMobile'] ?? $data['mobile'] + ?? $data['mobile_number'] ?? $data['phone'] ?? $data['phone_number'] + ?? $firstAccount['masked_mobile'] ?? $firstAccount['maskedMobile'] ?? $firstAccount['mobile'] + ?? $firstAccount['mobile_number'] ?? $firstAccount['phone'] ?? $firstAccount['phone_number'] ?? '' + )); + if ($maskedMobile !== '') { + $result['masked_mobile'] = $maskedMobile; + } + // HMS callers expect a status-like field; treat successful search as VALID when absent. $result['status'] = $status !== '' ? $status : 'VALID'; return $result; } + public function abhaLoginRequestOtp(array $payload): array + { + $authMethod = strtoupper((string) ($payload['auth_method'] ?? $payload['authMethod'] ?? '')); + $body = [ + 'txn_id' => (string) ($payload['txn_id'] ?? $payload['txnId'] ?? ''), + 'auth_method' => $authMethod, + ]; + if (($payload['abha_id'] ?? '') !== '') { $body['abha_id'] = (string) $payload['abha_id']; } + if (($payload['abha_address'] ?? '') !== '') { $body['abha_address'] = (string) $payload['abha_address']; } + if ($this->hfrId !== '') { + $body['hfr_id'] = $this->hfrId; + } + + return $this->post('/v3/abha/login/request-otp', $body); + } + + public function abhaLoginVerifyOtp(array $payload): array + { + $authMethod = strtoupper((string) ($payload['auth_method'] ?? $payload['authMethod'] ?? '')); + $body = [ + 'txn_id' => (string) ($payload['txn_id'] ?? $payload['txnId'] ?? ''), + 'txnId' => (string) ($payload['txn_id'] ?? $payload['txnId'] ?? ''), + 'auth_method' => $authMethod, + 'otp' => (string) ($payload['otp'] ?? ''), + 'scope' => $authMethod === 'AADHAAR_OTP' + ? ['abha-login', 'aadhaar-verify'] + : ['abha-login', 'mobile-verify'], + ]; + if (isset($payload['authData']) && is_array($payload['authData'])) { + $body['authData'] = $payload['authData']; + } + if ($this->hfrId !== '') { + $body['hfr_id'] = $this->hfrId; + } + + $result = $this->post('/v3/abha/login/verify-otp', $body); + if (empty($result['ok']) || (int) $result['ok'] !== 1) { + return $result; + } + + $data = is_array($result['data'] ?? null) ? $result['data'] : []; + $account = is_array($result['account'] ?? null) + ? $result['account'] + : (is_array($data['account'] ?? null) ? $data['account'] : []); + $abhaNumber = trim((string) ($account['ABHANumber'] ?? $account['abhaNumber'] ?? $account['abha_id'] ?? $data['ABHANumber'] ?? $data['abhaNumber'] ?? $data['abha_id'] ?? '')); + $abhaAddress = trim((string) ($account['abhaAddress'] ?? $account['preferredAddress'] ?? $account['abha_address'] ?? $data['abhaAddress'] ?? $data['preferredAddress'] ?? $data['abha_address'] ?? '')); + $extractCard = static function (array $source): string { + foreach (['card_base64', 'card_data', 'abhaCard', 'abha_card', 'official_card', 'cardData', 'card'] as $key) { + $value = $source[$key] ?? ''; + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + if (is_array($value)) { + foreach (['base64', 'data', 'card_base64', 'card_data'] as $nestedKey) { + if (is_string($value[$nestedKey] ?? null) && trim($value[$nestedKey]) !== '') { + return trim($value[$nestedKey]); + } + } + } + } + return ''; + }; + $card = $extractCard($result) ?: $extractCard($data) ?: $extractCard($account); + + if ($card === '' && ($abhaNumber !== '' || $abhaAddress !== '')) { + $cardResult = $this->get('/v3/abha/card', array_filter([ + 'abha_number' => $abhaNumber, + 'abha_address' => $abhaAddress, + ], static fn($value): bool => trim((string) $value) !== '')); + if (! empty($cardResult['ok']) && (int) $cardResult['ok'] === 1) { + $cardData = is_array($cardResult['data'] ?? null) ? $cardResult['data'] : $cardResult; + $card = $extractCard($cardData); + if ($card !== '') { + $result['card_base64'] = $card; + $result['card_content_type'] = $this->resolveCardContentType($cardData); + } + } + } + + return $result; + } + + private function resolveCardContentType(array $payload): string + { + $format = strtolower(trim((string) ($payload['card_content_type'] ?? $payload['content_type'] ?? $payload['card_format'] ?? ''))); + if ($format === 'png') { return 'image/png'; } + if ($format === 'jpg' || $format === 'jpeg') { return 'image/jpeg'; } + if ($format === 'pdf') { return 'application/pdf'; } + return $format !== '' && str_contains($format, '/') ? $format : 'image/png'; + } + // ------------------------------------------------------------------------- // M1 ABHA OTP Flows // ------------------------------------------------------------------------- diff --git a/app/Views/billing/Patient_V.php b/app/Views/billing/Patient_V.php index 0283217..20bad1b 100644 --- a/app/Views/billing/Patient_V.php +++ b/app/Views/billing/Patient_V.php @@ -41,6 +41,7 @@ .abha-panel { border-left:3px solid #007bff; padding-left:16px; } +
@@ -1362,7 +1363,7 @@ } var methodMeta = { - number: { icon: 'πŸ†”', title: 'By ABHA Number/Address', sub: 'Validate then OTP verify' }, + number: { icon: 'πŸ†”', title: 'By ABHA Number/Address', sub: 'Secure modal verification' }, qr: { icon: 'πŸ“·', title: 'Scan Patient ABHA QR', sub: 'Camera β†’ ABHA extracted β†’ OTP verify' }, facility: { icon: 'πŸ₯', title: 'Scan Facility QR', sub: 'Patient scans via ABHA app' }, mobile: { icon: 'πŸ“±', title: 'By Mobile OTP', sub: 'OTP to ABHA-linked mobile' } @@ -1380,11 +1381,26 @@ $('.abhareg-panel').addClass('d-none'); $('#abhareg_panel_' + method).removeClass('d-none'); $('#abhareg_result').addClass('d-none'); - if (method === 'qr') { initQrScanner(); } + if (method === 'number') { + $('#abhareg_panel_number').addClass('d-none'); + openAbhaAccountVerification('', null); + } else if (method === 'qr') { initQrScanner(); } else { stopQrScanner(); } if (method === 'facility') { loadFacilityQr(false); } }; + function openAbhaAccountVerification(identifier, lookupResponse) { + if (!window.AbhaVerifyModal) { + regAlert('abhareg_num_stepA_alert', 'danger', 'ABHA verification modal is unavailable. Reload the page and try again.'); + return; + } + window.AbhaVerifyModal.open(identifier || '', lookupResponse || null, function(profile) { + window.AbhaPatientMatchModal.open(profile, profile.candidates || [], function(finalResponse) { + showRegResult(finalResponse); + }); + }); + } + window.abhaRegReset = function() { regTxnId = null; stopQrScanner(); @@ -1577,9 +1593,7 @@ var numMatch = abha.match(/\d{14}/); if (numMatch) { abha = numMatch[0]; } $('#abhareg_qr_result').html('
QR scanned: ' + abha + '
'); - abhaRegSelectMethod('number'); - $('#abhareg_abha_input').val(abha); - $('#abhareg_validate_btn').trigger('click'); + openAbhaAccountVerification(abha, null); }, function() {} ).then(function() { @@ -1658,7 +1672,7 @@ $('#abhareg_validate_btn').prop('disabled', false); if (resp && resp.ok == 1 && resp.status === 'VALID') { $('#abhareg_abha_status').html('VALID'); - $('#abhareg_goto_stepB').removeClass('d-none'); + openAbhaAccountVerification(abha, resp); } else { var msg = pickApiMessage(resp, (resp && resp.status ? 'Status: ' + resp.status : 'ABHA not found or invalid.')); regAlert('abhareg_num_stepA_alert', 'danger', msg); diff --git a/app/Views/partials/abha_patient_match_modal.php b/app/Views/partials/abha_patient_match_modal.php index ae320ec..0603f79 100644 --- a/app/Views/partials/abha_patient_match_modal.php +++ b/app/Views/partials/abha_patient_match_modal.php @@ -23,7 +23,7 @@ @@ -70,9 +71,10 @@ - +
+ + +
@@ -164,6 +166,7 @@ window.AbhaPatientMatchModal = (function () { + badge(m.aadhaar, 'Aadhaar') + ' ' + '
DOB: ' + esc(c.dob || 'β€”') + ' | Mobile: ' + esc(maskMobile(c.mobile) || 'β€”') + '
' + + '
Address: ' + esc(c.address || 'β€”') + '
' + conflictNote + ' ' + '
' @@ -181,18 +184,19 @@ window.AbhaPatientMatchModal = (function () { $('.abhaMatch-candidate').removeClass('border-success bg-success-subtle'); $('.abhaMatch-candidate[data-id="' + id + '"]').addClass('border-success bg-success-subtle'); $('.abhaMatch-radio[value="' + id + '"]').prop('checked', true); - $('#abhaMatch_update_existing_btn').prop('disabled', !id); + $('#abhaMatch_keep_existing_btn,#abhaMatch_update_existing_btn').prop('disabled', !id); } - function submitConfirm(action, patientId, $btn) { + function submitConfirm(action, patientId, updateMode, $btn) { $('#abhaMatch_alert').empty(); var origHtml = $btn.html(); $btn.prop('disabled', true).html('Saving…'); - $('#abhaMatch_create_new_btn,#abhaMatch_update_existing_btn').prop('disabled', true); + $('#abhaMatch_create_new_btn,#abhaMatch_keep_existing_btn,#abhaMatch_update_existing_btn').prop('disabled', true); var c = csrfPair(); var payload = { action: action, + update_mode: updateMode || 'update', patient_id: patientId || 0, abha_number: _profile.abha_number || '', name: _profile.name || '', @@ -216,9 +220,8 @@ window.AbhaPatientMatchModal = (function () { $.post('', payload, function (resp) { updateCsrf(resp); $btn.prop('disabled', false).html(origHtml); - $('#abhaMatch_create_new_btn,#abhaMatch_update_existing_btn').prop('disabled', !_selectedId && $btn.attr('id') === 'abhaMatch_update_existing_btn'); $('#abhaMatch_create_new_btn').prop('disabled', false); - $('#abhaMatch_update_existing_btn').prop('disabled', !_selectedId); + $('#abhaMatch_keep_existing_btn,#abhaMatch_update_existing_btn').prop('disabled', !_selectedId); if (!resp || resp.ok != 1) { $('#abhaMatch_alert').html(alertHtml('danger', esc((resp && resp.error_text) || 'Failed to save patient link.'))); @@ -231,7 +234,7 @@ window.AbhaPatientMatchModal = (function () { }, 'json').fail(function () { $btn.prop('disabled', false).html(origHtml); $('#abhaMatch_create_new_btn').prop('disabled', false); - $('#abhaMatch_update_existing_btn').prop('disabled', !_selectedId); + $('#abhaMatch_keep_existing_btn,#abhaMatch_update_existing_btn').prop('disabled', !_selectedId); $('#abhaMatch_alert').html(alertHtml('danger', 'Server error while saving. Please try again.')); }); } @@ -246,11 +249,15 @@ window.AbhaPatientMatchModal = (function () { }); $('#abhaMatch_create_new_btn').on('click', function () { - submitConfirm('new', 0, $(this)); + submitConfirm('new', 0, 'update', $(this)); + }); + $('#abhaMatch_keep_existing_btn').on('click', function () { + if (!_selectedId) { return; } + submitConfirm('existing', _selectedId, 'keep', $(this)); }); $('#abhaMatch_update_existing_btn').on('click', function () { if (!_selectedId) { return; } - submitConfirm('existing', _selectedId, $(this)); + submitConfirm('existing', _selectedId, 'update', $(this)); }); }); @@ -260,7 +267,7 @@ window.AbhaPatientMatchModal = (function () { _selectedId = 0; _onResolved = onResolved; $('#abhaMatch_alert').empty(); - $('#abhaMatch_update_existing_btn').prop('disabled', true); + $('#abhaMatch_keep_existing_btn,#abhaMatch_update_existing_btn').prop('disabled', true); renderProfile(_profile); renderCandidates(candidates || []); if (_bsModal) { _bsModal.show(); } else { $modalEl.modal('show'); } diff --git a/app/Views/partials/abha_verify_modal.php b/app/Views/partials/abha_verify_modal.php new file mode 100644 index 0000000..f1f7c7e --- /dev/null +++ b/app/Views/partials/abha_verify_modal.php @@ -0,0 +1,287 @@ + + + + + \ No newline at end of file diff --git a/docs/abdm/ABHA_IDENTIFIER_LOGIN_BRIDGE_CONTRACT.md b/docs/abdm/ABHA_IDENTIFIER_LOGIN_BRIDGE_CONTRACT.md new file mode 100644 index 0000000..2b7b12f --- /dev/null +++ b/docs/abdm/ABHA_IDENTIFIER_LOGIN_BRIDGE_CONTRACT.md @@ -0,0 +1,198 @@ +# ABHA Identifier Login Bridge Contract + +Version: 1.0 + +This contract supports the HMS `/billing/patient` modal flow: + +1. Enter ABHA number or ABHA address. +2. Choose an authentication method returned by ABDM. +3. Send and verify OTP with a 60-second resend cooldown. +4. Review the verified ABHA profile and official ABHA card. +5. Compare with an existing HMS patient before linking or updating. + +Every request requires: + +- `Authorization: Bearer {hospital-token}` +- `Content-Type: application/json` +- `hfr_id` in the request body + +The Bridge must never return an ABDM session or X-token to the browser-facing HMS response. + +## 1. Search ABHA Login Account + +Existing endpoint: + +`POST /api/v3/abha/login/search` + +Request: + +```json +{ + "type": "abha-address", + "value": "patient@sbx", + "hfr_id": "IN0510000828" +} +``` + +`type` must be `abha-address` or `abha-number`. + +Required success response: + +```json +{ + "ok": 1, + "data": { + "txnId": "search-transaction-id", + "status": "ACTIVE", + "authMethods": ["MOBILE_OTP", "AADHAAR_OTP"], + "blockedAuthMethods": [], + "maskedMobile": "******1369", + "accounts": [ + { + "name": "Ashish Soni", + "ABHANumber": "91201531584567", + "abhaAddress": "ashishtest6@sbx", + "status": "ACTIVE" + } + ] + }, + "request_id": "REQ-..." +} +``` + +The search transaction must remain valid for the next OTP request. Do not replace it with a new mobile-search transaction. + +## 2. Request Account-Bound OTP + +New endpoint: + +`POST /api/v3/abha/login/request-otp` + +Request: + +```json +{ + "txn_id": "search-transaction-id", + "auth_method": "MOBILE_OTP", + "abha_id": "91201531584567", + "abha_address": "ashishtest6@sbx", + "hfr_id": "IN0510000828" +} +``` + +Rules: + +- `auth_method` must have been returned by the search response. +- Supported HMS methods are `MOBILE_OTP` and `AADHAAR_OTP`. +- `MOBILE_OTP` means the mobile registered with the ABHA account. +- `AADHAAR_OTP` means the mobile linked with Aadhaar. +- The operator must not enter a mobile number. +- Reject blocked or unavailable methods. +- Apply Bridge-side rate limiting in addition to the HMS timer. + +Required success response: + +```json +{ + "ok": 1, + "data": { + "txnId": "otp-transaction-id", + "authMethod": "MOBILE_OTP", + "maskedMobile": "******1369", + "resendAfter": 60 + }, + "request_id": "REQ-..." +} +``` + +For rate limiting, return HTTP `429` with `retry_after` when appropriate. + +## 3. Verify Account-Bound OTP + +New endpoint: + +`POST /api/v3/abha/login/verify-otp` + +Request: + +```json +{ + "txn_id": "otp-transaction-id", + "auth_method": "MOBILE_OTP", + "otp": "123456", + "hfr_id": "IN0510000828" +} +``` + +Bridge responsibilities after successful ABDM OTP verification: + +1. Use the server-side ABDM session/X-token to fetch the official profile. +2. Call the ABDM profile endpoint equivalent to `GET /profile/account`. +3. Call the official card endpoint equivalent to `GET /profile/account/abha-card`. +4. Normalize the profile and card into one response. +5. Persist only required audit/profile fields according to Bridge policy. +6. Do not return session tokens, access tokens, Aadhaar numbers, or unneeded raw ABDM payloads. + +Required success response: + +```json +{ + "ok": 1, + "data": { + "profile": { + "ABHANumber": "91201531584567", + "preferredAddress": "ashishtest6@sbx", + "name": "Ashish Soni", + "gender": "M", + "dob": "2001-02-19", + "mobile": "9098221369", + "address": "Rathkhana School Road, Gwalior", + "districtName": "Gwalior", + "stateName": "Madhya Pradesh", + "pinCode": "474001", + "profilePhoto": "base64-jpeg-without-data-prefix" + }, + "card_base64": "base64-png-or-pdf-without-data-prefix", + "card_content_type": "image/png" + }, + "request_id": "REQ-..." +} +``` + +The response may also use `gateway_patient` for profile data because HMS already normalizes that shape. + +## 4. Standard Errors + +Use the Bridge standard shape: + +```json +{ + "ok": 0, + "error_code": "OTP_RATE_LIMITED", + "message": "Wait before requesting another OTP.", + "details": { + "retry_after": 42 + }, + "request_id": "REQ-..." +} +``` + +Recommended codes: + +- `ABHA_NOT_FOUND` (`404`) +- `AUTH_METHOD_UNAVAILABLE` (`422`) +- `AUTH_METHOD_BLOCKED` (`423`) +- `OTP_INVALID` (`422`) +- `OTP_EXPIRED` (`410`) +- `OTP_RATE_LIMITED` (`429`) +- `PROFILE_FETCH_FAILED` (`502`) +- `CARD_FETCH_FAILED` (`502`) only when card failure should block; otherwise return profile with an empty card and a warning + +## 5. Security and Audit + +- Redact OTP, Aadhaar, tokens, and profile/card base64 from logs. +- Bind OTP transactions to the hospital, HFR ID, ABHA account, and selected auth method. +- Expire transactions according to ABDM rules. +- Enforce request throttling per hospital, ABHA identity, and transaction. +- Return a `request_id` for all failures. +- Keep the full ABDM session and card fetch server-side. \ No newline at end of file