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; }
= view('partials/abha_patient_match_modal') ?>
+= view('partials/abha_verify_modal') ?>
@@ -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 @@
@@ -63,6 +63,7 @@
No matching patient found in HMS by name, age or gender. You can create a new patient record.
+
The verified ABHA identity is linked in both existing-patient actions. Keep Existing preserves HMS demographics; Update Details applies the latest ABHA profile.
@@ -70,9 +71,10 @@
Create New Patient
-
- Update Selected Patient
-
+
+ Keep Existing
+ Update Details
+
@@ -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('= base_url('abha/create/confirm_patient') ?>', 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