devsofttech_hms_ci4/app/Controllers/Abha.php

4182 lines
189 KiB
PHP

<?php
namespace App\Controllers;
use App\Libraries\Abdm\AbdmConnectorFactory;
/**
* Abha Controller
*
* Handles ABHA number creation wizard endpoints called from billing/patient ABHA Create tab.
* All methods proxy calls to the ABDM gateway via AbdmConnectorFactory.
*/
class Abha extends BaseController
{
// -------------------------------------------------------------------------
// Step 1 — Initiate: send Aadhaar OTP
// POST abha/create/initiate
// -------------------------------------------------------------------------
public function initiate()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
$aadhaar = preg_replace('/\D/', '', trim((string) ($this->request->getPost('aadhaar') ?? '')));
$authType = trim((string) ($this->request->getPost('auth_type') ?? 'aadhaar_otp'));
$consent = $this->request->getPost('consent');
$beneficiaryName = trim((string) ($this->request->getPost('beneficiary_name') ?? ''));
if (strlen($aadhaar) !== 12 || ! $this->validateVerhoeff($aadhaar)) {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Aadhaar Number is not valid. Valid 12-digit Aadhaar number is required.']);
}
if ($consent !== null && ! in_array($consent, ['1', 1, 'true', true], true)) {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Consent is mandatory. Please agree to the consent declarations before sending the OTP.']);
}
try {
$result = AbdmConnectorFactory::make()->abhaAadhaarGenerateOtp(['aadhaar' => $aadhaar]);
} catch (\Throwable $e) {
return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => $e->getMessage()]);
}
// Normalise for the wizard: expose txn_id at top level
if (! empty($result['ok']) && $result['ok'] == 1) {
$txnId = $result['txn_id'] ?? $result['data']['txnId'] ?? $result['data']['txn_id'] ?? null;
// Cache the Aadhaar number against this txn_id so verifyOtp() can use it
// later for local-patient name/age/gender/aadhaar matching (never sent to browser).
if ($txnId) {
session()->set('abha_aadhaar_txn_' . $txnId, $aadhaar);
if ($beneficiaryName !== '') {
session()->set('abha_beneficiary_' . $txnId, $beneficiaryName);
}
}
return $this->response->setJSON([
'ok' => 1,
'txn_id' => $txnId,
'message' => (string) ($result['message'] ?? $result['data']['message'] ?? 'OTP sent to Aadhaar-linked mobile.'),
'masked_mobile' => (string) ($result['masked_mobile'] ?? $result['data']['masked_mobile'] ?? $result['data']['maskedMobile'] ?? ''),
'request_id' => (string) ($result['request_id'] ?? ''),
]);
}
$errorMsg = $this->extractBridgeErrorText($result, 'Failed to send OTP');
if (stripos($errorMsg, 'loginId') !== false || stripos($errorMsg, 'Invalid LoginId') !== false) {
$errorMsg = 'Aadhaar Number is not valid. Valid 12-digit Aadhaar number is required.';
}
return $this->response->setJSON([
'ok' => 0,
'error_text' => $errorMsg,
'request_id' => (string) ($result['request_id'] ?? ''),
]);
}
// -------------------------------------------------------------------------
// Step 2 — Verify Aadhaar OTP
// POST abha/create/verify_otp
// Response includes skip_mobile=true and abha_number if ABHA already linked.
// -------------------------------------------------------------------------
public function verifyOtp()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
$txnId = trim((string) ($this->request->getPost('txn_id') ?? $this->request->getPost('txnId') ?? ''));
$otp = trim((string) ($this->request->getPost('otp') ?? ''));
$requestMobile = trim((string) ($this->request->getPost('mobile') ?? ''));
if ($txnId === '' || $otp === '') {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'txn_id and otp are required']);
}
try {
$result = AbdmConnectorFactory::make()->abhaAadhaarVerifyOtp(['txnId' => $txnId, 'otp' => $otp, 'mobile' => $requestMobile]);
} catch (\Throwable $e) {
return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => $e->getMessage()]);
}
$authResult = strtolower(trim((string) ($result['authResult'] ?? $result['data']['authResult'] ?? $result['auth_result'] ?? $result['data']['auth_result'] ?? '')));
$status = strtolower(trim((string) ($result['status'] ?? $result['data']['status'] ?? '')));
if ($authResult === 'failed' || in_array($status, ['failed', 'failure', 'error'], true)) {
$result['ok'] = 0;
}
if (empty($result['ok']) || $result['ok'] != 1) {
$requestId = trim((string) ($result['request_id'] ?? ''));
$errorText = $this->extractBridgeErrorText($result, 'Incorrect OTP');
return $this->response->setJSON([
'ok' => 0,
'error_text' => $errorText,
'request_id' => $requestId,
]);
}
// Keep top-level keys (gateway_patient, official_card) that also appear under data.
$payload = $result;
if (is_array($result['data'] ?? null)) {
foreach ($result['data'] as $key => $value) {
if (! array_key_exists($key, $payload)) {
$payload[$key] = $value;
}
}
}
$newTxnId = $payload['txnId'] ?? $payload['txn_id'] ?? $txnId;
$profile = $this->pickGatewayAbhaProfile($payload);
$abhaNum = (string) ($profile['ABHANumber'] ?? $profile['abha_id'] ?? $payload['ABHANumber'] ?? $payload['abha_id'] ?? '');
$aadhaarVerifiedAbha = preg_replace('/\D/', '', $abhaNum);
// ABDM v3 creates/returns the ABHA inside Aadhaar verify-otp when an alternate
// mobile is supplied, so no further mobile OTP step may run for this person.
$abhaCreated = strlen($aadhaarVerifiedAbha) === 14;
if ($newTxnId && $abhaCreated) {
session()->set('abha_enrol_identity_' . $newTxnId, $aadhaarVerifiedAbha);
}
$abhaAddress = (string) (
($profile['preferredAddress'] ?? '')
?: ($profile['preferredAbhaAddress'] ?? '')
?: ($profile['abha_address'] ?? '')
?: ($payload['preferredAddress'] ?? '')
?: ($payload['preferredAbhaAddress'] ?? '')
?: ($payload['abha_address'] ?? '')
?: ''
);
$name = $this->extractAbhaProfileName($profile, $payload);
$photo = $this->extractAbhaProfilePhoto($profile, $payload);
$mobile = (string) ($profile['mobile'] ?? $payload['mobile'] ?? $payload['mobileNumber'] ?? '');
$mobileSource = $mobile === '' ? '' : 'abdm';
$enrolMobile = preg_replace('/\D/', '', $requestMobile);
// ABDM accepted this number while creating the ABHA but does not always echo it back.
if ($mobile === '' && $abhaCreated && strlen($enrolMobile) === 10) {
$mobile = $enrolMobile;
$mobileSource = 'enrolment';
}
$profileGender = (string) ($profile['gender'] ?? $payload['gender'] ?? '');
$profileDob = (string) ($profile['dob'] ?? $profile['date_of_birth'] ?? $payload['dob'] ?? $payload['date_of_birth'] ?? '');
$verifiedStatus = (string) (($payload['gateway_abha_profile']['status'] ?? '') ?: ($profile['verifiedStatus'] ?? '') ?: ($profile['status'] ?? '') ?: ($payload['verifiedStatus'] ?? '') ?: ($payload['status'] ?? ''));
$verificationType = (string) (($payload['gateway_abha_profile']['abha_type'] ?? '') ?: ($profile['verificationType'] ?? '') ?: ($profile['abhaType'] ?? '') ?: ($payload['verificationType'] ?? '') ?: ($payload['abhaType'] ?? ''));
$kycVerified = $profile['kycVerified'] ?? $payload['kycVerified'] ?? null;
$mobileVerified = $profile['mobileVerified'] ?? $payload['mobileVerified'] ?? null;
$address = (string) (($profile['address'] ?? '') ?: ($profile['address_line'] ?? '') ?: ($payload['address'] ?? '') ?: ($payload['address_line'] ?? '') ?: ($payload['gateway_abha_profile']['address'] ?? '') ?: ($payload['gateway_patient']['address_line'] ?? ''));
$zip = (string) (($profile['pinCode'] ?? '') ?: ($profile['pin_code'] ?? '') ?: ($payload['pinCode'] ?? '') ?: ($payload['pin_code'] ?? '') ?: ($payload['gateway_abha_profile']['pin_code'] ?? '') ?: ($payload['gateway_patient']['pincode'] ?? ''));
$stateName = (string) (($profile['stateName'] ?? '') ?: ($profile['state_name'] ?? '') ?: ($payload['stateName'] ?? '') ?: ($payload['state_name'] ?? '') ?: ($payload['gateway_abha_profile']['state_name'] ?? '') ?: ($payload['gateway_patient']['state_name'] ?? ''));
$districtName = (string) (($profile['districtName'] ?? '') ?: ($profile['district_name'] ?? '') ?: ($payload['districtName'] ?? '') ?: ($payload['district_name'] ?? '') ?: ($payload['gateway_abha_profile']['district_name'] ?? '') ?: ($payload['gateway_patient']['district'] ?? ''));
$email = (string) (($profile['email'] ?? '') ?: ($payload['email'] ?? '') ?: ($payload['gateway_abha_profile']['email'] ?? '') ?: ($payload['gateway_patient']['email'] ?? ''));
$abhaMeta = [
'abha_address' => $abhaAddress,
'profile_photo' => $photo,
'verified_status' => $verifiedStatus,
'verification_type' => $verificationType,
'kyc_verified' => $kycVerified,
'mobile_verified' => $mobileVerified,
'address' => $address,
'district' => $districtName,
'state' => $stateName,
'zip' => $zip,
'email' => $email,
];
// Aadhaar cached against this txn_id in initiate(), used only for local
// duplicate-patient matching below (never persisted/echoed to the browser).
$aadhaarForMatch = trim((string) (session()->get('abha_aadhaar_txn_' . $txnId) ?? ''));
session()->remove('abha_aadhaar_txn_' . $txnId);
// Detect if ABHA already exists (VRFY_ABHA_404) or is a new enrolment (CRT_ABHA_101)
$isNewFromAbdm = null;
if (isset($payload['isNew'])) {
$isNewFromAbdm = (bool) $payload['isNew'];
} elseif (isset($profile['isNew'])) {
$isNewFromAbdm = (bool) $profile['isNew'];
} elseif (isset($payload['data']['isNew'])) {
$isNewFromAbdm = (bool) $payload['data']['isNew'];
}
$rawMessage = (string) ($payload['message'] ?? $result['message'] ?? '');
$hasAlreadyExistMsg = stripos($rawMessage, 'already exist') !== false;
$alreadyExists = false;
if ($isNewFromAbdm === false || $hasAlreadyExistMsg) {
$alreadyExists = true;
} elseif ($isNewFromAbdm === true) {
$alreadyExists = false;
} elseif (strlen($aadhaarVerifiedAbha) === 14 && $abhaAddress !== '') {
$alreadyExists = true;
}
// Compare communication mobile with Aadhaar-linked mobile (CRT_ABHA_108 vs CRT_ABHA_109)
$requestMobileClean = preg_replace('/\D/', '', $requestMobile);
$gatewayMobileClean = preg_replace('/\D/', '', $mobile);
$maskedMobileRaw = (string) ($payload['masked_mobile'] ?? $payload['maskedMobile'] ?? $result['masked_mobile'] ?? $result['data']['masked_mobile'] ?? '');
$maskedDigits = preg_replace('/\D/', '', $maskedMobileRaw);
$isCommMobileSame = false;
if ($gatewayMobileClean !== '' && strlen($gatewayMobileClean) === 10 && strlen($requestMobileClean) === 10) {
$isCommMobileSame = ($gatewayMobileClean === $requestMobileClean);
} elseif (strlen($requestMobileClean) === 10 && strlen($gatewayMobileClean) >= 4) {
$isCommMobileSame = (substr($requestMobileClean, -4) === substr($gatewayMobileClean, -4));
} elseif (strlen($requestMobileClean) === 10 && strlen($maskedDigits) >= 4) {
$isCommMobileSame = (substr($requestMobileClean, -4) === substr($maskedDigits, -4));
}
if ($newTxnId) {
session()->set('abha_already_exists_' . $newTxnId, $alreadyExists);
session()->set('abha_existing_number_' . $newTxnId, $abhaNum);
session()->set('abha_comm_mobile_' . $newTxnId, $requestMobileClean);
}
$patientInfo = $this->tryAutoLinkByDirectMatch($abhaNum, $name, $mobile, $profileGender, $profileDob, $abhaMeta);
$responseBase = [
'ok' => 1,
'txn_id' => $newTxnId,
'skip_mobile' => $isCommMobileSame,
'abha_created' => $abhaCreated,
'already_exists' => $alreadyExists,
'is_new_abha' => ! $alreadyExists,
'is_comm_mobile_same' => $isCommMobileSame,
'comm_mobile' => $requestMobileClean,
'aadhaar_mobile_masked' => $maskedMobileRaw ?: ($maskedDigits ? ('******' . $maskedDigits) : ''),
'card_base64' => $this->extractAbhaCardData($result),
'card_content_type' => $this->resolveAbhaCardContentType($result),
'card_source' => $this->resolveAbhaCardSource($result),
'card_message' => $this->resolveAbhaCardMessage($result),
'abha_number' => $abhaNum,
'name' => $name,
'photo' => $photo,
'mobile' => $mobile,
'mobile_source' => $mobileSource,
'gender' => $profileGender,
'dob' => $profileDob,
'abha_address' => $abhaAddress,
'verified_status' => $verifiedStatus,
'verification_type' => $verificationType,
'kyc_verified' => $kycVerified,
'mobile_verified' => $mobileVerified,
'address' => $address,
'district' => $districtName,
'state' => $stateName,
'zip' => $zip,
'email' => $email,
];
if ($patientInfo !== null) {
return $this->response->setJSON($responseBase + [
'need_confirmation' => false,
'patient_id' => $patientInfo['patient_id'],
'p_code' => $patientInfo['p_code'],
'is_new_patient' => $patientInfo['is_new'],
]);
}
$db = \Config\Database::connect();
$fields = $db->getFieldNames('patient_master') ?? [];
$abhaField = $this->resolveAbhaFieldName($fields);
$abhaNumClean = preg_replace('/\D/', '', $abhaNum);
$candidates = $this->findMatchingCandidates($db, $fields, $name, $mobile, $profileGender, $profileDob, $aadhaarForMatch, $abhaField, $abhaNumClean);
return $this->response->setJSON($responseBase + [
'need_confirmation' => true,
'patient_id' => 0,
'p_code' => '',
'is_new_patient' => null,
'candidates' => $candidates,
]);
}
// -------------------------------------------------------------------------
// Step 3a — Send mobile OTP
// POST abha/create/communication
// -------------------------------------------------------------------------
public function communication()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
$mobile = preg_replace('/\D/', '', trim((string) ($this->request->getPost('mobile') ?? '')));
$txnId = trim((string) ($this->request->getPost('txn_id') ?? $this->request->getPost('txnId') ?? ''));
$isMobileDiscovery = (string) $this->request->getPost('find_abha') === '1';
if (strlen($mobile) !== 10) {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Valid 10-digit mobile number is required']);
}
if (! $isMobileDiscovery && $txnId === '') {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Aadhaar enrolment txn_id is required']);
}
try {
$result = $isMobileDiscovery
? AbdmConnectorFactory::make()->abhaMobileGenerateOtp(['mobile' => $mobile])
: AbdmConnectorFactory::make()->abhaEnrolMobileRequestOtp(['mobile' => $mobile, 'txnId' => $txnId]);
} catch (\Throwable $e) {
return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => $e->getMessage()]);
}
if (! empty($result['ok']) && $result['ok'] == 1) {
$newTxnId = $result['txn_id'] ?? $result['data']['txnId'] ?? $result['data']['txn_id'] ?? $txnId;
$expectedAbha = (string) (session()->get('abha_enrol_identity_' . $txnId) ?? '');
if ($expectedAbha !== '') {
session()->set('abha_enrol_identity_' . $newTxnId, $expectedAbha);
}
$existingFlag = session()->get('abha_already_exists_' . $txnId);
if ($existingFlag !== null) {
session()->set('abha_already_exists_' . $newTxnId, (bool) $existingFlag);
}
$existingNum = session()->get('abha_existing_number_' . $txnId);
if ($existingNum !== null) {
session()->set('abha_existing_number_' . $newTxnId, $existingNum);
}
session()->set('abha_comm_mobile_' . $newTxnId, $mobile);
return $this->response->setJSON([
'ok' => 1,
'txn_id' => $newTxnId,
'message' => (string) ($result['message'] ?? $result['data']['message'] ?? 'OTP sent to alternate mobile.'),
'masked_mobile' => (string) ($result['masked_mobile'] ?? $result['data']['masked_mobile'] ?? ('******' . substr($mobile, -4))),
'request_id' => (string) ($result['request_id'] ?? ''),
]);
}
return $this->response->setJSON([
'ok' => 0,
'error_text' => $this->extractBridgeErrorText($result, 'Failed to send mobile OTP'),
'request_id' => (string) ($result['request_id'] ?? ''),
]);
}
// -------------------------------------------------------------------------
// Step 3b — Verify mobile OTP
// POST abha/create/verify_comm_otp
// -------------------------------------------------------------------------
public function verifyCommOtp()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
$txnId = trim((string) ($this->request->getPost('txn_id') ?? $this->request->getPost('txnId') ?? ''));
$otp = trim((string) ($this->request->getPost('otp') ?? ''));
$isMobileDiscovery = (string) $this->request->getPost('find_abha') === '1';
// The mobile itself isn't part of the OTP-verify payload (it's tied to txn_id
// on the bridge side), but the caller may resend it so we can persist it even
// if the gateway response doesn't echo it back.
$requestMobile = trim((string) ($this->request->getPost('mobile') ?? ''));
if ($requestMobile === '') {
$requestMobile = (string) (session()->get('abha_comm_mobile_' . $txnId) ?? '');
}
if ($txnId === '' || $otp === '') {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'txn_id and otp are required']);
}
try {
$result = $isMobileDiscovery
? AbdmConnectorFactory::make()->abhaMobileVerifyOtp(['txnId' => $txnId, 'otp' => $otp, 'mobile' => $requestMobile])
: AbdmConnectorFactory::make()->abhaEnrolMobileVerifyOtp(['txnId' => $txnId, 'otp' => $otp, 'mobile' => $requestMobile]);
} catch (\Throwable $e) {
return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => $e->getMessage()]);
}
if (empty($result['ok']) || $result['ok'] != 1) {
return $this->response->setJSON([
'ok' => 0,
'error_text' => $this->extractBridgeErrorText($result, 'Mobile OTP verification failed'),
'request_id' => (string) ($result['request_id'] ?? ''),
]);
}
// Keep top-level keys (gateway_patient, official_card) that also appear under data.
$payload = $result;
if (is_array($result['data'] ?? null)) {
foreach ($result['data'] as $key => $value) {
if (! array_key_exists($key, $payload)) {
$payload[$key] = $value;
}
}
}
$profile = $this->pickGatewayAbhaProfile($payload);
$abhaNum = (string) ($profile['ABHANumber'] ?? $profile['abha_id'] ?? $payload['ABHANumber'] ?? $payload['abha_id'] ?? '');
$expectedAbha = preg_replace('/\D/', '', (string) (session()->get('abha_enrol_identity_' . $txnId) ?? ''));
$returnedAbha = preg_replace('/\D/', '', $abhaNum);
if (! $isMobileDiscovery && $expectedAbha !== '' && (strlen($expectedAbha) !== 14 || ($returnedAbha !== '' && $returnedAbha !== $expectedAbha))) {
log_message('error', '[ABHA] Enrolment identity mismatch blocked for txn ' . $txnId);
return $this->response->setStatusCode(409)->setJSON([
'ok' => 0,
'error_text' => 'ABHA identity mismatch. The alternate-mobile response did not match the Aadhaar-verified person and was rejected.',
'request_id' => (string) ($result['request_id'] ?? ''),
]);
}
if (! $isMobileDiscovery) {
session()->remove('abha_enrol_identity_' . $txnId);
}
$abhaAddress = (string) (
($profile['preferredAddress'] ?? '')
?: ($profile['preferredAbhaAddress'] ?? '')
?: ($profile['abha_address'] ?? '')
?: ($payload['preferredAddress'] ?? '')
?: ($payload['preferredAbhaAddress'] ?? '')
?: ($payload['abha_address'] ?? '')
?: ''
);
$name = $this->extractAbhaProfileName($profile, $payload);
$photo = $this->extractAbhaProfilePhoto($profile, $payload);
$gender = (string) ($profile['gender'] ?? $payload['gender'] ?? '');
$dob = (string) ($profile['dob'] ?? $profile['date_of_birth'] ?? $payload['dob'] ?? $payload['date_of_birth'] ?? '');
// Bridge/gateway verify-otp responses don't always echo the mobile back;
// fall back to the mobile the user actually typed and OTP-verified.
$mobile = (string) ($profile['mobile'] ?? $payload['mobile'] ?? $payload['mobileNumber'] ?? $requestMobile ?? '');
$verifiedStatus = (string) (($payload['gateway_abha_profile']['status'] ?? '') ?: ($profile['verifiedStatus'] ?? '') ?: ($profile['status'] ?? '') ?: ($payload['verifiedStatus'] ?? '') ?: ($payload['status'] ?? ''));
$verificationType = (string) (($payload['gateway_abha_profile']['abha_type'] ?? '') ?: ($profile['verificationType'] ?? '') ?: ($profile['abhaType'] ?? '') ?: ($payload['verificationType'] ?? '') ?: ($payload['abhaType'] ?? ''));
$kycVerified = $profile['kycVerified'] ?? $payload['kycVerified'] ?? null;
$mobileVerified = $profile['mobileVerified'] ?? $payload['mobileVerified'] ?? null;
$address = (string) (($profile['address'] ?? '') ?: ($profile['address_line'] ?? '') ?: ($payload['address'] ?? '') ?: ($payload['address_line'] ?? '') ?: ($payload['gateway_abha_profile']['address'] ?? '') ?: ($payload['gateway_patient']['address_line'] ?? ''));
$zip = (string) (($profile['pinCode'] ?? '') ?: ($profile['pin_code'] ?? '') ?: ($payload['pinCode'] ?? '') ?: ($payload['pin_code'] ?? '') ?: ($payload['gateway_abha_profile']['pin_code'] ?? '') ?: ($payload['gateway_patient']['pincode'] ?? ''));
$stateName = (string) (($profile['stateName'] ?? '') ?: ($profile['state_name'] ?? '') ?: ($payload['stateName'] ?? '') ?: ($payload['state_name'] ?? '') ?: ($payload['gateway_abha_profile']['state_name'] ?? '') ?: ($payload['gateway_patient']['state_name'] ?? ''));
$districtName = (string) (($profile['districtName'] ?? '') ?: ($profile['district_name'] ?? '') ?: ($payload['districtName'] ?? '') ?: ($payload['district_name'] ?? '') ?: ($payload['gateway_abha_profile']['district_name'] ?? '') ?: ($payload['gateway_patient']['district'] ?? ''));
$email = (string) (($profile['email'] ?? '') ?: ($payload['email'] ?? '') ?: ($payload['gateway_abha_profile']['email'] ?? '') ?: ($payload['gateway_patient']['email'] ?? ''));
$abhaMeta = [
'abha_address' => $abhaAddress,
'profile_photo' => $photo,
'verified_status' => $verifiedStatus,
'verification_type' => $verificationType,
'kyc_verified' => $kycVerified,
'mobile_verified' => $mobileVerified,
'address' => $address,
'district' => $districtName,
'state' => $stateName,
'zip' => $zip,
'email' => $email,
];
$rawAccounts = is_array($payload['accounts'] ?? null)
? $payload['accounts']
: (is_array($result['data']['accounts'] ?? null) ? $result['data']['accounts'] : []);
$txnIdOut = (string) ($payload['txnId'] ?? $payload['txn_id'] ?? $result['txn_id'] ?? $result['data']['txnId'] ?? $txnId);
$tokenOut = (string) ($payload['token'] ?? $result['data']['token'] ?? $result['token'] ?? '');
$savedExistingNum = (string) (session()->get('abha_existing_number_' . $txnId) ?? '');
if ($abhaNum === '' && $savedExistingNum !== '') {
$abhaNum = $savedExistingNum;
}
$alreadyExists = (bool) (session()->get('abha_already_exists_' . $txnId) ?? false);
if (! $alreadyExists && strlen(preg_replace('/\D/', '', $abhaNum)) === 14 && $abhaAddress !== '') {
$alreadyExists = true;
}
$patientInfo = $this->tryAutoLinkByDirectMatch($abhaNum, $name, $mobile, $gender, $dob, $abhaMeta);
$responseBase = [
'ok' => 1,
'txn_id' => $txnIdOut,
'token' => $tokenOut,
'accounts' => $rawAccounts,
'already_exists' => $alreadyExists,
'is_new_abha' => ! $alreadyExists,
'abha_created' => (strlen(preg_replace('/\D/', '', $abhaNum)) === 14),
'card_base64' => $this->extractAbhaCardData($result),
'card_content_type' => $this->resolveAbhaCardContentType($result),
'card_source' => $this->resolveAbhaCardSource($result),
'card_message' => $this->resolveAbhaCardMessage($result),
'abha_number' => $abhaNum,
'name' => $name,
'photo' => $photo,
'mobile' => $mobile,
'mobile_source' => 'communication_verified',
'comm_mobile' => $mobile,
'gender' => $gender,
'dob' => $dob,
'abha_address' => $abhaAddress,
'verified_status' => $verifiedStatus,
'verification_type' => $verificationType,
'kyc_verified' => $kycVerified,
'mobile_verified' => $mobileVerified,
'address' => $address,
'district' => $districtName,
'state' => $stateName,
'zip' => $zip,
'email' => $email,
];
if ($patientInfo !== null) {
return $this->response->setJSON($responseBase + [
'need_confirmation' => false,
'patient_id' => $patientInfo['patient_id'],
'p_code' => $patientInfo['p_code'],
'is_new_patient' => $patientInfo['is_new'],
]);
}
$db = \Config\Database::connect();
$fields = $db->getFieldNames('patient_master') ?? [];
$abhaField = $this->resolveAbhaFieldName($fields);
$abhaNumClean = preg_replace('/\D/', '', $abhaNum);
// No Aadhaar available on the pure mobile-OTP path.
$candidates = $this->findMatchingCandidates($db, $fields, $name, $mobile, $gender, $dob, '', $abhaField, $abhaNumClean);
return $this->response->setJSON($responseBase + [
'need_confirmation' => true,
'patient_id' => 0,
'p_code' => '',
'is_new_patient' => null,
'candidates' => $candidates,
]);
}
// -------------------------------------------------------------------------
// Multi-Account Selection for Mobile OTP (ABDM M1)
// POST abha/find/mobile/select-account
// -------------------------------------------------------------------------
public function selectAccount()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
$txnId = trim((string) ($this->request->getPost('txn_id') ?? $this->request->getPost('txnId') ?? ''));
$token = trim((string) ($this->request->getPost('token') ?? ''));
$abhaNumber = trim((string) ($this->request->getPost('abha_number') ?? $this->request->getPost('abhaNumber') ?? ''));
$abhaAddress = trim((string) ($this->request->getPost('abha_address') ?? $this->request->getPost('abhaAddress') ?? ''));
$mobile = trim((string) ($this->request->getPost('mobile') ?? ''));
$accountRaw = $this->request->getPost('account');
$clientAccount = [];
if (is_string($accountRaw) && trim($accountRaw) !== '') {
$decoded = json_decode(trim($accountRaw), true);
if (is_array($decoded)) {
$clientAccount = $decoded;
}
} elseif (is_array($accountRaw)) {
$clientAccount = $accountRaw;
}
if ($abhaNumber === '' && ! empty($clientAccount)) {
$abhaNumber = (string) ($clientAccount['ABHANumber'] ?? $clientAccount['abhaNumber'] ?? $clientAccount['abha_id'] ?? '');
}
if ($abhaAddress === '' && ! empty($clientAccount)) {
$abhaAddress = (string) ($clientAccount['preferredAbhaAddress'] ?? $clientAccount['preferredAddress'] ?? $clientAccount['abhaAddress'] ?? $clientAccount['abha_address'] ?? '');
}
$result = [];
try {
$result = AbdmConnectorFactory::make()->abhaLoginSelectAccount([
'txnId' => $txnId,
'token' => $token,
'abha_number' => $abhaNumber,
'abha_address' => $abhaAddress,
]);
} catch (\Throwable $e) {
log_message('warning', '[ABHA] Bridge selectAccount exception: ' . $e->getMessage());
}
$payload = [];
if (! empty($result['ok']) && (int) $result['ok'] === 1) {
$payload = $result;
if (is_array($result['data'] ?? null)) {
foreach ($result['data'] as $key => $value) {
if (! array_key_exists($key, $payload)) {
$payload[$key] = $value;
}
}
}
} elseif (! empty($clientAccount) || $abhaNumber !== '') {
// Graceful fallback to the already OTP-verified account profile from accounts list
log_message('notice', '[ABHA] Using verified account data fallback for ABHA ' . $abhaNumber);
$payload = [
'ok' => 1,
'ABHANumber' => $clientAccount['ABHANumber'] ?? $clientAccount['abhaNumber'] ?? $clientAccount['abha_id'] ?? $abhaNumber,
'preferredAbhaAddress' => $clientAccount['preferredAbhaAddress'] ?? $clientAccount['preferredAddress'] ?? $clientAccount['abhaAddress'] ?? $clientAccount['abha_address'] ?? $abhaAddress,
'name' => $clientAccount['name'] ?? '',
'gender' => $clientAccount['gender'] ?? '',
'dob' => $clientAccount['dob'] ?? $clientAccount['date_of_birth'] ?? '',
'profilePhoto' => $clientAccount['profilePhoto'] ?? $clientAccount['photo'] ?? '',
'mobile' => $mobile,
'status' => $clientAccount['status'] ?? 'ACTIVE',
];
} else {
return $this->response->setJSON([
'ok' => 0,
'error_text' => $this->extractBridgeErrorText($result, 'Account selection failed'),
'request_id' => (string) ($result['request_id'] ?? ''),
]);
}
$profile = $this->pickGatewayAbhaProfile($payload);
$abhaNum = (string) ($profile['ABHANumber'] ?? $profile['abha_id'] ?? $payload['ABHANumber'] ?? $payload['abha_id'] ?? $abhaNumber);
$resAbhaAddress = (string) (
($profile['preferredAddress'] ?? '')
?: ($profile['preferredAbhaAddress'] ?? '')
?: ($profile['abha_address'] ?? '')
?: ($payload['preferredAddress'] ?? '')
?: ($payload['preferredAbhaAddress'] ?? '')
?: ($payload['abha_address'] ?? '')
?: $abhaAddress
);
$name = $this->extractAbhaProfileName($profile, $payload);
$photo = $this->extractAbhaProfilePhoto($profile, $payload);
$gender = (string) ($profile['gender'] ?? $payload['gender'] ?? '');
$dob = (string) ($profile['dob'] ?? $profile['date_of_birth'] ?? $payload['dob'] ?? $payload['date_of_birth'] ?? '');
$resMobile = (string) ($profile['mobile'] ?? $payload['mobile'] ?? $payload['mobileNumber'] ?? $mobile);
$verifiedStatus = (string) (($payload['gateway_abha_profile']['status'] ?? '') ?: ($profile['verifiedStatus'] ?? '') ?: ($profile['status'] ?? '') ?: ($payload['verifiedStatus'] ?? '') ?: ($payload['status'] ?? ''));
$verificationType = (string) (($payload['gateway_abha_profile']['abha_type'] ?? '') ?: ($profile['verificationType'] ?? '') ?: ($profile['abhaType'] ?? '') ?: ($payload['verificationType'] ?? '') ?: ($payload['abhaType'] ?? ''));
$kycVerified = $profile['kycVerified'] ?? $payload['kycVerified'] ?? null;
$mobileVerified = $profile['mobileVerified'] ?? $payload['mobileVerified'] ?? null;
$address = (string) (($profile['address'] ?? '') ?: ($profile['address_line'] ?? '') ?: ($payload['address'] ?? '') ?: ($payload['address_line'] ?? '') ?: ($payload['gateway_abha_profile']['address'] ?? '') ?: ($payload['gateway_patient']['address_line'] ?? ''));
$zip = (string) (($profile['pinCode'] ?? '') ?: ($profile['pin_code'] ?? '') ?: ($payload['pinCode'] ?? '') ?: ($payload['pin_code'] ?? '') ?: ($payload['gateway_abha_profile']['pin_code'] ?? '') ?: ($payload['gateway_patient']['pincode'] ?? ''));
$stateName = (string) (($profile['stateName'] ?? '') ?: ($profile['state_name'] ?? '') ?: ($payload['stateName'] ?? '') ?: ($payload['state_name'] ?? '') ?: ($payload['gateway_abha_profile']['state_name'] ?? '') ?: ($payload['gateway_patient']['state_name'] ?? ''));
$districtName = (string) (($profile['districtName'] ?? '') ?: ($profile['district_name'] ?? '') ?: ($payload['districtName'] ?? '') ?: ($payload['district_name'] ?? '') ?: ($payload['gateway_abha_profile']['district_name'] ?? '') ?: ($payload['gateway_patient']['district'] ?? ''));
$email = (string) (($profile['email'] ?? '') ?: ($payload['email'] ?? '') ?: ($payload['gateway_abha_profile']['email'] ?? '') ?: ($payload['gateway_patient']['email'] ?? ''));
$abhaMeta = [
'abha_address' => $resAbhaAddress,
'profile_photo' => $photo,
'verified_status' => $verifiedStatus,
'verification_type' => $verificationType,
'kyc_verified' => $kycVerified,
'mobile_verified' => $mobileVerified,
'address' => $address,
'district' => $districtName,
'state' => $stateName,
'zip' => $zip,
'email' => $email,
];
$patientInfo = $this->tryAutoLinkByDirectMatch($abhaNum, $name, $resMobile, $gender, $dob, $abhaMeta);
$responseBase = [
'ok' => 1,
'card_base64' => $this->extractAbhaCardData($result),
'card_content_type' => $this->resolveAbhaCardContentType($result),
'card_source' => $this->resolveAbhaCardSource($result),
'card_message' => $this->resolveAbhaCardMessage($result),
'abha_number' => $abhaNum,
'name' => $name,
'photo' => $photo,
'mobile' => $resMobile,
'gender' => $gender,
'dob' => $dob,
'abha_address' => $resAbhaAddress,
'verified_status' => $verifiedStatus,
'verification_type' => $verificationType,
'kyc_verified' => $kycVerified,
'mobile_verified' => $mobileVerified,
'address' => $address,
'district' => $districtName,
'state' => $stateName,
'zip' => $zip,
'email' => $email,
];
if ($patientInfo !== null) {
return $this->response->setJSON($responseBase + [
'need_confirmation' => false,
'patient_id' => $patientInfo['patient_id'],
'p_code' => $patientInfo['p_code'],
'is_new_patient' => $patientInfo['is_new'],
]);
}
$db = \Config\Database::connect();
$fields = $db->getFieldNames('patient_master') ?? [];
$abhaField = $this->resolveAbhaFieldName($fields);
$abhaNumClean = preg_replace('/\D/', '', $abhaNum);
$candidates = $this->findMatchingCandidates($db, $fields, $name, $resMobile, $gender, $dob, '', $abhaField, $abhaNumClean);
return $this->response->setJSON($responseBase + [
'need_confirmation' => true,
'patient_id' => 0,
'p_code' => '',
'is_new_patient' => null,
'candidates' => $candidates,
]);
}
// -------------------------------------------------------------------------
// ABDM M1 Find ABHA via Mobile Flow
// FLOW: MOBILE NUMBER -> PROFILES -> OTP -> COMPLETE PROFILE FETCHED
// -------------------------------------------------------------------------
/**
* Step 1: Search ABHA profiles by 10-digit mobile number
* POST abha/find/mobile/search
*/
public function findMobileSearch()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
$mobile = preg_replace('/\D/', '', trim((string) ($this->request->getPost('mobile') ?? '')));
if (strlen($mobile) !== 10) {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Valid 10-digit mobile number is required']);
}
try {
$db = \Config\Database::connect();
// 1. Fetch matching local patients from patient_master
$localPatients = $db->table('patient_master')
->groupStart()
->where('mphone1', $mobile)
->orWhere('mphone2', $mobile)
->groupEnd()
->get()->getResultArray();
// 2. Fetch all known ABHA addresses from abdm_link_transactions for these patients
$patientIds = array_filter(array_column($localPatients, 'id'));
$patientCodes = array_filter(array_column($localPatients, 'p_code'));
$linkedAddressesByPatient = [];
if (! empty($patientIds) || ! empty($patientCodes)) {
$builder = $db->table('abdm_link_transactions');
$builder->groupStart();
if (! empty($patientIds)) {
$builder->whereIn('patient_id', $patientIds);
}
if (! empty($patientCodes)) {
$builder->orWhereIn('patient_ref', $patientCodes);
}
$builder->groupEnd();
$txns = $builder->select('patient_id, patient_ref, abha_address')->get()->getResultArray();
foreach ($txns as $t) {
$addr = trim((string)($t['abha_address'] ?? ''));
if ($addr !== '') {
if (! empty($t['patient_id'])) {
$linkedAddressesByPatient['id_' . $t['patient_id']][$addr] = true;
}
if (! empty($t['patient_ref'])) {
$linkedAddressesByPatient['ref_' . $t['patient_ref']][$addr] = true;
}
}
}
}
// 3. Query ABDM V3 Direct Helper
$helper = new \App\Libraries\Abdm\AbdmV3DirectHelper();
$result = $helper->searchByMobile($mobile);
if (! empty($result['ok']) && ! empty($result['profiles'])) {
// Enrich ABDM profiles with known local data (UHID, DOB, and associated ABHA addresses)
$enrichedProfiles = [];
foreach ($result['profiles'] as $prof) {
$pIndex = (string)($prof['index'] ?? '');
$pName = trim((string)($prof['name'] ?? ''));
$pAbhaNum = trim((string)($prof['abha_number'] ?? ''));
$last4 = preg_replace('/\D/', '', substr($pAbhaNum, -4));
$pGender = strtoupper(substr(trim((string)($prof['gender'] ?? 'M')), 0, 1));
// Attempt match with local patient_master:
// Priority 1: Match by last 4 digits of abha_id
// Priority 2: Match by exact or partial name
$matchedPatient = null;
if ($last4 !== '') {
foreach ($localPatients as $lp) {
$lpAbha = preg_replace('/\D/', '', (string)($lp['abha_id'] ?? ''));
if ($lpAbha !== '' && str_ends_with($lpAbha, $last4)) {
$matchedPatient = $lp;
break;
}
}
}
if (! $matchedPatient) {
foreach ($localPatients as $lp) {
$lpFName = trim((string)($lp['p_fname'] ?? ''));
if ($lpFName !== '' && (stripos($lpFName, $pName) !== false || stripos($pName, $lpFName) !== false)) {
$matchedPatient = $lp;
break;
}
}
}
$allAddresses = [];
$primaryAddress = '';
$pCode = '';
$pId = 0;
$pDob = (string)($prof['dob'] ?? '');
if ($matchedPatient) {
$pCode = (string)($matchedPatient['p_code'] ?? '');
$pId = (int)($matchedPatient['id'] ?? 0);
if ($pDob === '' && ! empty($matchedPatient['dob'])) {
$pDob = (string)$matchedPatient['dob'];
}
if (! empty($matchedPatient['abha_address'])) {
$addr = trim((string)$matchedPatient['abha_address']);
$allAddresses[$addr] = true;
$primaryAddress = $addr;
}
if (isset($linkedAddressesByPatient['id_' . $pId])) {
foreach (array_keys($linkedAddressesByPatient['id_' . $pId]) as $a) {
$allAddresses[$a] = true;
}
}
if (isset($linkedAddressesByPatient['ref_' . $pCode])) {
foreach (array_keys($linkedAddressesByPatient['ref_' . $pCode]) as $a) {
$allAddresses[$a] = true;
}
}
}
$prof['abha_address'] = $primaryAddress !== '' ? $primaryAddress : (array_keys($allAddresses)[0] ?? '');
$prof['all_abha_addresses'] = array_keys($allAddresses);
$prof['p_code'] = $pCode;
$prof['patient_id'] = $pId;
$prof['dob'] = $pDob;
$enrichedProfiles[] = $prof;
}
return $this->response->setJSON([
'ok' => 1,
'txn_id' => $result['txn_id'] ?? '',
'profiles' => $enrichedProfiles,
'mobile' => $mobile,
]);
}
// 4. Fallback: If ABDM Sandbox returns no external profiles, check if HMS patient_master has records with this mobile
if (! empty($localPatients)) {
$profiles = [];
$idx = 1;
foreach ($localPatients as $p) {
$pName = trim((string)($p['p_fname'] ?? ''));
if ($pName === '') {
$pName = 'Patient ' . ($p['p_code'] ?? '');
}
$pAbha = trim((string)($p['abha_id'] ?? ''));
$maskedAbha = $pAbha !== ''
? preg_replace('/^(\d{2})(\d{4})(\d{4})(\d{4})$/', 'xx-xxxx-xxxx-$4', preg_replace('/\D/', '', $pAbha))
: 'xx-xxxx-xxxx-XXXX';
$pGender = 'M';
$rawGen = (string)($p['gender'] ?? '1');
if ($rawGen === '2' || strtoupper($rawGen) === 'F' || strtoupper($rawGen) === 'FEMALE') {
$pGender = 'F';
}
$allAddresses = [];
if (! empty($p['abha_address'])) {
$allAddresses[trim((string)$p['abha_address'])] = true;
}
$pId = (int)($p['id'] ?? 0);
$pCode = (string)($p['p_code'] ?? '');
if (isset($linkedAddressesByPatient['id_' . $pId])) {
foreach (array_keys($linkedAddressesByPatient['id_' . $pId]) as $a) {
$allAddresses[$a] = true;
}
}
if (isset($linkedAddressesByPatient['ref_' . $pCode])) {
foreach (array_keys($linkedAddressesByPatient['ref_' . $pCode]) as $a) {
$allAddresses[$a] = true;
}
}
$profiles[] = [
'index' => (string) $idx++,
'abha_number' => $maskedAbha,
'name' => $pName,
'gender' => $pGender,
'dob' => (string)($p['dob'] ?? ''),
'kyc_verified' => true,
'auth_methods' => ['MOBILE_OTP', 'AADHAAR_OTP'],
'profile_photo' => '',
'local_patient' => true,
'p_code' => $pCode,
'patient_id' => $pId,
'abha_address' => (string)($p['abha_address'] ?? (array_keys($allAddresses)[0] ?? '')),
'all_abha_addresses' => array_keys($allAddresses),
];
}
return $this->response->setJSON([
'ok' => 1,
'txn_id' => 'LOCAL-' . uniqid(),
'profiles' => $profiles,
'mobile' => $mobile,
]);
}
return $this->response->setJSON([
'ok' => 0,
'error_text' => $result['error_text'] ?? 'No ABHA profiles found linked with mobile number ' . $mobile,
]);
} catch (\Throwable $e) {
log_message('error', '[ABHA] findMobileSearch exception: ' . $e->getMessage());
return $this->response->setStatusCode(500)->setJSON([
'ok' => 0,
'error_text' => $e->getMessage(),
]);
}
}
/**
* Step 2: Request OTP for the chosen ABHA profile
* POST abha/find/mobile/request-otp
*/
public function findMobileRequestOtp()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
$txnId = trim((string) ($this->request->getPost('txn_id') ?? $this->request->getPost('txnId') ?? ''));
$index = trim((string) ($this->request->getPost('index') ?? '1'));
$authMethod = strtoupper(trim((string) ($this->request->getPost('auth_method') ?? 'MOBILE_OTP')));
$mobile = preg_replace('/\D/', '', trim((string) ($this->request->getPost('mobile') ?? '')));
if ($txnId === '') {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Search transaction ID (txn_id) is required']);
}
try {
$helper = new \App\Libraries\Abdm\AbdmV3DirectHelper();
$result = $helper->requestProfileOtp($txnId, $index, $authMethod);
if (! empty($result['ok']) && (int) $result['ok'] === 1) {
return $this->response->setJSON([
'ok' => 1,
'txn_id' => $result['txn_id'] ?? $txnId,
'message' => $result['message'] ?? 'OTP sent to mobile ending with ' . substr($mobile, -4),
'auth_method' => $authMethod,
]);
}
return $this->response->setJSON([
'ok' => 0,
'error_text' => $result['error_text'] ?? 'Failed to send OTP to registered mobile/Aadhaar.',
]);
} catch (\Throwable $e) {
log_message('error', '[ABHA] findMobileRequestOtp exception: ' . $e->getMessage());
return $this->response->setStatusCode(500)->setJSON([
'ok' => 0,
'error_text' => $e->getMessage(),
]);
}
}
/**
* Step 3: Verify OTP and fetch complete ABHA profile + ABHA card
* POST abha/find/mobile/verify-otp
*/
public function findMobileVerifyOtp()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
$txnId = trim((string) ($this->request->getPost('txn_id') ?? $this->request->getPost('txnId') ?? ''));
$otp = trim((string) ($this->request->getPost('otp') ?? ''));
$authMethod = strtoupper(trim((string) ($this->request->getPost('auth_method') ?? 'MOBILE_OTP')));
$mobile = preg_replace('/\D/', '', trim((string) ($this->request->getPost('mobile') ?? '')));
if ($txnId === '' || $otp === '') {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Transaction ID and 6-digit OTP are required']);
}
try {
$helper = new \App\Libraries\Abdm\AbdmV3DirectHelper();
$result = $helper->verifyProfileOtp($txnId, $otp, $authMethod);
if (empty($result['ok']) || (int) $result['ok'] !== 1) {
return $this->response->setJSON([
'ok' => 0,
'error_text' => $result['error_text'] ?? 'OTP verification failed.',
]);
}
$fullProfile = is_array($result['full_profile'] ?? null) ? $result['full_profile'] : [];
$account = is_array($result['account'] ?? null) ? $result['account'] : [];
$userToken = (string) ($result['token'] ?? '');
$firstNonEmpty = static function (array $values): string {
foreach ($values as $value) {
if (is_scalar($value) && trim((string) $value) !== '') {
return trim((string) $value);
}
}
return '';
};
$abhaNum = $firstNonEmpty([
$account['ABHANumber'] ?? null,
$account['abhaNumber'] ?? null,
$account['abha_number'] ?? null,
$account['abha_id'] ?? null,
$fullProfile['ABHANumber'] ?? null,
$fullProfile['abhaNumber'] ?? null,
$fullProfile['abha_id'] ?? null,
]);
$abhaAddress = $firstNonEmpty([
$account['preferredAbhaAddress'] ?? null,
$account['preferredAddress'] ?? null,
$account['abhaAddress'] ?? null,
$account['abha_address'] ?? null,
$fullProfile['preferredAbhaAddress'] ?? null,
$fullProfile['preferredAddress'] ?? null,
$fullProfile['abhaAddress'] ?? null,
$fullProfile['abha_address'] ?? null,
]);
$name = $firstNonEmpty([
$account['name'] ?? null,
$account['fullName'] ?? null,
$account['full_name'] ?? null,
$fullProfile['name'] ?? null,
trim(($fullProfile['firstName'] ?? '') . ' ' . ($fullProfile['middleName'] ?? '') . ' ' . ($fullProfile['lastName'] ?? '')),
]);
$genderRaw = $firstNonEmpty([
$account['gender'] ?? null,
$fullProfile['gender'] ?? null,
]);
$genderUpper = strtoupper($genderRaw);
if ($genderUpper === 'M' || $genderUpper === '1' || $genderUpper === 'MALE') {
$gender = 'Male';
} elseif ($genderUpper === 'F' || $genderUpper === '2' || $genderUpper === 'FEMALE') {
$gender = 'Female';
} elseif ($genderUpper === 'O' || $genderUpper === '3' || $genderUpper === 'OTHER') {
$gender = 'Other';
} else {
$gender = $genderRaw;
}
$dob = $firstNonEmpty([
$account['dob'] ?? null,
$account['dateOfBirth'] ?? null,
$account['date_of_birth'] ?? null,
$fullProfile['dob'] ?? null,
$fullProfile['dateOfBirth'] ?? null,
$fullProfile['date_of_birth'] ?? null,
]);
if ($dob === '') {
$day = $firstNonEmpty([$account['dayOfBirth'] ?? null, $account['day_of_birth'] ?? null, $fullProfile['dayOfBirth'] ?? null, $fullProfile['day_of_birth'] ?? null]);
$month = $firstNonEmpty([$account['monthOfBirth'] ?? null, $account['month_of_birth'] ?? null, $fullProfile['monthOfBirth'] ?? null, $fullProfile['month_of_birth'] ?? null]);
$year = $firstNonEmpty([$account['yearOfBirth'] ?? null, $account['year_of_birth'] ?? null, $fullProfile['yearOfBirth'] ?? null, $fullProfile['year_of_birth'] ?? null]);
if ($year !== '' && $month !== '' && $day !== '') {
$dob = sprintf('%04d-%02d-%02d', (int) $year, (int) $month, (int) $day);
} elseif ($year !== '') {
$dob = $year;
}
}
$profileMobile = $firstNonEmpty([
$account['mobile'] ?? null,
$account['mobileNumber'] ?? null,
$account['mobile_number'] ?? null,
$fullProfile['mobile'] ?? null,
$fullProfile['mobileNumber'] ?? null,
$mobile,
]);
$photo = $firstNonEmpty([
$account['profilePhoto'] ?? null,
$account['profile_photo'] ?? null,
$fullProfile['profilePhoto'] ?? null,
$fullProfile['profile_photo'] ?? null,
$fullProfile['photo'] ?? null,
$fullProfile['kycPhoto'] ?? null,
]);
$address = $firstNonEmpty([
$fullProfile['address'] ?? null,
$fullProfile['addressLine'] ?? null,
$fullProfile['address_line'] ?? null,
$account['address'] ?? null,
$account['addressLine'] ?? null,
]);
$districtName = $firstNonEmpty([
$fullProfile['district'] ?? null,
$fullProfile['districtName'] ?? null,
$fullProfile['district_name'] ?? null,
$account['district'] ?? null,
$account['districtName'] ?? null,
]);
$stateName = $firstNonEmpty([
$fullProfile['state'] ?? null,
$fullProfile['stateName'] ?? null,
$fullProfile['state_name'] ?? null,
$account['state'] ?? null,
$account['stateName'] ?? null,
]);
$zip = $firstNonEmpty([
$fullProfile['pinCode'] ?? null,
$fullProfile['pincode'] ?? null,
$fullProfile['pin'] ?? null,
$account['pinCode'] ?? null,
$account['pincode'] ?? null,
]);
$email = $firstNonEmpty([
$fullProfile['email'] ?? null,
$account['email'] ?? null,
]);
$cardBase64 = (string) ($result['card_base64'] ?? '');
// Fallback to Bridge card endpoint if not retrieved directly
if ($cardBase64 === '' && ($abhaNum !== '' || $abhaAddress !== '')) {
try {
$bridge = \App\Libraries\Abdm\AbdmConnectorFactory::make();
$cardResult = $bridge->fetchOfficialAbhaCard([
'abha_number' => $abhaNum,
'abha_address' => $abhaAddress,
'token' => $userToken,
'x_token' => $userToken,
]);
if (! empty($cardResult['ok']) && ! empty($cardResult['card_base64'])) {
$cardBase64 = (string) $cardResult['card_base64'];
}
} catch (\Throwable $e) {
log_message('warning', '[ABHA] Bridge card fetch fallback failed: ' . $e->getMessage());
}
}
$abhaMeta = [
'abha_address' => $abhaAddress,
'profile_photo' => $photo,
'verified_status' => 'ACTIVE',
'verification_type' => 'VERIFIED',
'kyc_verified' => true,
'mobile_verified' => true,
'address' => $address,
'district' => $districtName,
'state' => $stateName,
'zip' => $zip,
'email' => $email,
];
$patientInfo = $this->tryAutoLinkByDirectMatch($abhaNum, $name, $profileMobile, $gender, $dob, $abhaMeta);
$allAbhaAddresses = [];
if ($abhaAddress !== '' && str_contains($abhaAddress, '@')) {
$allAbhaAddresses[$abhaAddress] = true;
}
if (! empty($result['accounts']) && is_array($result['accounts'])) {
foreach ($result['accounts'] as $acc) {
if (is_array($acc)) {
foreach (['preferredAbhaAddress', 'preferredAddress', 'abhaAddress', 'abha_address'] as $ak) {
if (! empty($acc[$ak]) && is_string($acc[$ak]) && str_contains($acc[$ak], '@')) {
$allAbhaAddresses[trim($acc[$ak])] = true;
}
}
}
}
}
$db = \Config\Database::connect();
$patientIdForAddrs = $patientInfo !== null ? (int)($patientInfo['patient_id'] ?? 0) : 0;
$cleanAbhaDigits = preg_replace('/\D/', '', $abhaNum);
$pBuilder = $db->table('patient_master');
$pBuilder->groupStart();
if ($patientIdForAddrs > 0) {
$pBuilder->where('id', $patientIdForAddrs);
}
if ($cleanAbhaDigits !== '') {
$pBuilder->orWhere('abha_id', $cleanAbhaDigits);
}
if ($profileMobile !== '') {
$pBuilder->orWhere('mphone1', $profileMobile)->orWhere('mphone2', $profileMobile);
}
$pBuilder->groupEnd();
$pRows = $pBuilder->select('id, abha_address')->get()->getResultArray();
$matchedPids = [];
foreach ($pRows as $pr) {
$matchedPids[] = (int)$pr['id'];
$pa = trim((string)($pr['abha_address'] ?? ''));
if ($pa !== '' && str_contains($pa, '@')) {
$allAbhaAddresses[$pa] = true;
}
}
if (! empty($matchedPids)) {
$txns = $db->table('abdm_link_transactions')
->select('abha_address')
->whereIn('patient_id', array_unique($matchedPids))
->get()->getResultArray();
foreach ($txns as $t) {
$ta = trim((string)($t['abha_address'] ?? ''));
if ($ta !== '' && str_contains($ta, '@')) {
$allAbhaAddresses[$ta] = true;
}
}
}
$responseBase = [
'ok' => 1,
'txn_id' => $result['txn_id'] ?? $txnId,
'token' => $result['token'] ?? '',
'accounts' => $result['accounts'] ?? [],
'card_base64' => $cardBase64,
'card_content_type' => 'image/png',
'card_source' => $cardBase64 !== '' ? 'abdm' : 'generated',
'card_message' => $cardBase64 !== '' ? 'Official ABHA Card from ABDM' : '',
'abha_number' => $abhaNum,
'name' => $name,
'photo' => $photo,
'mobile' => $profileMobile,
'gender' => $gender,
'dob' => $dob,
'abha_address' => $abhaAddress !== '' ? $abhaAddress : (array_keys($allAbhaAddresses)[0] ?? ''),
'all_abha_addresses'=> array_keys($allAbhaAddresses),
'verified_status' => 'ACTIVE',
'verification_type' => 'VERIFIED',
'kyc_verified' => true,
'mobile_verified' => true,
'address' => $address,
'district' => $districtName,
'state' => $stateName,
'zip' => $zip,
'email' => $email,
];
if ($patientInfo !== null) {
return $this->response->setJSON($responseBase + [
'need_confirmation' => false,
'patient_id' => $patientInfo['patient_id'],
'p_code' => $patientInfo['p_code'],
'is_new_patient' => $patientInfo['is_new'],
]);
}
$db = \Config\Database::connect();
$fields = $db->getFieldNames('patient_master') ?? [];
$abhaField = $this->resolveAbhaFieldName($fields);
$abhaNumClean = preg_replace('/\D/', '', $abhaNum);
$candidates = $this->findMatchingCandidates($db, $fields, $name, $profileMobile, $gender, $dob, '', $abhaField, $abhaNumClean);
return $this->response->setJSON($responseBase + [
'need_confirmation' => true,
'patient_id' => 0,
'p_code' => '',
'is_new_patient' => null,
'candidates' => $candidates,
]);
} catch (\Throwable $e) {
log_message('error', '[ABHA] findMobileVerifyOtp exception: ' . $e->getMessage());
return $this->response->setStatusCode(500)->setJSON([
'ok' => 0,
'error_text' => $e->getMessage(),
]);
}
}
// -------------------------------------------------------------------------
// ABDM M1 Find ABHA via Aadhaar Flow
// TEST POINT: 12.1 VRFY_ABHA_401 to VRFY_ABHA_405
// FLOW: AADHAAR NUMBER -> OTP -> COMPLETE PROFILE FETCHED / CREATE ABHA
// -------------------------------------------------------------------------
/**
* VRFY_ABHA_401: Request OTP using 12-digit Aadhaar Number
* POST abha/find/aadhaar/request-otp
*/
public function findAadhaarRequestOtp()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
$aadhaar = preg_replace('/\D/', '', trim((string) ($this->request->getPost('aadhaar') ?? '')));
if (strlen($aadhaar) !== 12 || ! $this->validateVerhoeff($aadhaar)) {
return $this->response->setJSON([
'ok' => 0,
'error_text' => 'Aadhaar Number is not valid. Please enter a valid 12-digit Aadhaar Number.',
]);
}
try {
$connector = AbdmConnectorFactory::make();
$result = $connector->abhaAadhaarGenerateOtp(['aadhaar' => $aadhaar, 'purpose' => 'search', 'flow' => 'search']);
if (! empty($result['ok']) && (int) $result['ok'] === 1) {
$txnId = (string) ($result['txn_id'] ?? $result['data']['txnId'] ?? $result['data']['txn_id'] ?? '');
$msg = (string) ($result['message'] ?? $result['data']['message'] ?? 'OTP sent to Aadhaar linked mobile number.');
$maskedMobile = (string) ($result['masked_mobile'] ?? $result['data']['maskedMobile'] ?? $result['data']['masked_mobile'] ?? '');
return $this->response->setJSON([
'ok' => 1,
'txn_id' => $txnId,
'message' => $msg,
'masked_mobile' => $maskedMobile,
'resend_after' => 60,
]);
}
// Fallback to direct V3 client if bridge call did not succeed
try {
$helper = new \App\Libraries\Abdm\AbdmV3DirectHelper();
$directResult = $helper->requestLoginOtpByAadhaar($aadhaar);
if (! empty($directResult['ok']) && (int) $directResult['ok'] === 1) {
return $this->response->setJSON([
'ok' => 1,
'txn_id' => $directResult['txn_id'] ?? '',
'message' => $directResult['message'] ?? 'OTP sent to Aadhaar linked mobile number.',
'resend_after' => $directResult['resend_after'] ?? 60,
]);
}
} catch (\Throwable $e) {}
$errMsg = $this->extractBridgeErrorText($result, 'Failed to send OTP to Aadhaar linked mobile.');
if (stripos($errMsg, 'invalid') !== false && stripos($errMsg, 'aadhaar') !== false) {
$errMsg = 'Aadhaar Number is not valid. Please enter a valid 12-digit Aadhaar Number.';
}
return $this->response->setJSON([
'ok' => 0,
'error_text' => $errMsg,
]);
} catch (\Throwable $e) {
log_message('error', '[ABHA] findAadhaarRequestOtp exception: ' . $e->getMessage());
return $this->response->setStatusCode(500)->setJSON([
'ok' => 0,
'error_text' => $e->getMessage(),
]);
}
}
/**
* VRFY_ABHA_402, 403, 404, 405: Verify Aadhaar OTP and fetch complete ABHA profile + card
* POST abha/find/aadhaar/verify-otp
*/
public function findAadhaarVerifyOtp()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
$txnId = trim((string) ($this->request->getPost('txn_id') ?? $this->request->getPost('txnId') ?? ''));
$otp = preg_replace('/\D/', '', trim((string) ($this->request->getPost('otp') ?? '')));
if ($txnId === '' || strlen($otp) !== 6) {
return $this->response->setJSON([
'ok' => 0,
'error_text' => 'Transaction ID and 6-digit OTP are required.',
]);
}
try {
$connector = AbdmConnectorFactory::make();
$result = $connector->abhaAadhaarVerifyOtp(['txnId' => $txnId, 'otp' => $otp, 'purpose' => 'search', 'flow' => 'search']);
$authResult = strtolower(trim((string) ($result['authResult'] ?? $result['data']['authResult'] ?? $result['auth_result'] ?? $result['data']['auth_result'] ?? '')));
$status = strtolower(trim((string) ($result['status'] ?? $result['data']['status'] ?? '')));
if ($authResult === 'failed' || in_array($status, ['failed', 'failure', 'error'], true)) {
$result['ok'] = 0;
}
if (empty($result['ok']) || (int) $result['ok'] !== 1) {
$err = $this->extractBridgeErrorText($result, 'Please enter a valid OTP. Entered OTP is either expired or incorrect.');
if (stripos($err, 'no abha') !== false || stripos($err, 'not registered') !== false || stripos($err, 'not exist') !== false || stripos($err, 'not found') !== false) {
return $this->response->setJSON([
'ok' => 0,
'no_abha_found' => true,
'error_text' => 'NO ABHA user registered with this Aadhaar Number',
]);
}
return $this->response->setJSON([
'ok' => 0,
'error_text' => $err,
]);
}
$payload = $result;
if (is_array($result['data'] ?? null)) {
foreach ($result['data'] as $k => $v) {
if (! array_key_exists($k, $payload)) {
$payload[$k] = $v;
}
}
}
$profile = $this->pickGatewayAbhaProfile($payload);
$abhaNum = (string) (
$profile['ABHANumber']
?? $profile['abha_id']
?? $profile['abha_number']
?? $payload['ABHANumber']
?? $payload['abha_id']
?? $payload['abha_number']
?? ''
);
$cleanAbha = preg_replace('/\D/', '', $abhaNum);
$abhaAddress = (string) (
($profile['preferredAddress'] ?? '')
?: ($profile['preferredAbhaAddress'] ?? '')
?: ($profile['abha_address'] ?? '')
?: ($payload['preferredAddress'] ?? '')
?: ($payload['preferredAbhaAddress'] ?? '')
?: ($payload['abha_address'] ?? '')
?: ''
);
// VRFY_ABHA_403: No ABHA user registered with this Aadhaar Number
$isNew = ! empty($payload['is_new']) || ! empty($payload['new_user']) || ! empty($result['new_user'])
|| (isset($payload['isNew']) && ($payload['isNew'] === true || $payload['isNew'] === 'true'))
|| (isset($payload['new']) && ($payload['new'] === true || $payload['new'] === 'true'));
if ((strlen($cleanAbha) !== 14 && $abhaAddress === '') || $isNew) {
return $this->response->setJSON([
'ok' => 0,
'no_abha_found' => true,
'error_text' => 'NO ABHA user registered with this Aadhaar Number',
]);
}
$name = $this->extractAbhaProfileName($profile, $payload);
$photo = $this->extractAbhaProfilePhoto($profile, $payload);
$profileMobile = $this->extractAbhaProfileMobile($profile, $payload);
$gender = $this->extractAbhaProfileGender($profile, $payload);
$profileDob = $this->extractAbhaProfileDob($profile, $payload);
$addrInfo = $this->extractAbhaProfileAddress($profile, $payload);
$address = $addrInfo['address'];
$districtName = $addrInfo['district'];
$stateName = $addrInfo['state'];
$zip = $addrInfo['zip'];
$email = (string) (($profile['email'] ?? '') ?: ($payload['email'] ?? '') ?: ($payload['gateway_patient']['email'] ?? ''));
$cardBase64 = (string) (
$result['official_card']['card_base64']
?? $payload['official_card']['card_base64']
?? $result['card_base64']
?? $payload['card_base64']
?? ''
);
$abhaMeta = [
'abha_address' => $abhaAddress,
'profile_photo' => $photo,
'verified_status' => 'ACTIVE',
'verification_type' => 'VERIFIED',
'kyc_verified' => true,
'mobile_verified' => true,
'address' => $address,
'district' => $districtName,
'state' => $stateName,
'zip' => $zip,
'email' => $email,
];
$patientInfo = $this->tryAutoLinkByDirectMatch($cleanAbha, $name, $profileMobile, $gender, $profileDob, $abhaMeta);
$responseBase = [
'ok' => 1,
'txn_id' => (string) ($payload['txnId'] ?? $payload['txn_id'] ?? $txnId),
'card_base64' => $cardBase64,
'card_content_type' => 'image/png',
'card_source' => $cardBase64 !== '' ? 'abdm' : 'generated',
'card_message' => $cardBase64 !== '' ? 'Official ABHA Card from ABDM' : '',
'abha_number' => $cleanAbha,
'name' => $name,
'photo' => $photo,
'mobile' => $profileMobile,
'gender' => $gender,
'dob' => $profileDob,
'abha_address' => $abhaAddress,
'verified_status' => 'ACTIVE',
'verification_type' => 'VERIFIED',
'kyc_verified' => true,
'mobile_verified' => true,
'address' => $address,
'district' => $districtName,
'state' => $stateName,
'zip' => $zip,
'email' => $email,
];
if ($patientInfo !== null) {
return $this->response->setJSON($responseBase + [
'need_confirmation' => false,
'patient_id' => $patientInfo['patient_id'],
'p_code' => $patientInfo['p_code'],
'is_new_patient' => $patientInfo['is_new'],
]);
}
$db = \Config\Database::connect();
$fields = $db->getFieldNames('patient_master') ?? [];
$abhaField = $this->resolveAbhaFieldName($fields);
$candidates = $this->findMatchingCandidates($db, $fields, $name, $profileMobile, $gender, $profileDob, '', $abhaField, $cleanAbha);
return $this->response->setJSON($responseBase + [
'need_confirmation' => true,
'patient_id' => 0,
'p_code' => '',
'is_new_patient' => null,
'candidates' => $candidates,
]);
} catch (\Throwable $e) {
log_message('error', '[ABHA] findAadhaarVerifyOtp exception: ' . $e->getMessage());
return $this->response->setStatusCode(500)->setJSON([
'ok' => 0,
'error_text' => $e->getMessage(),
]);
}
}
// -------------------------------------------------------------------------
// Step 4 — ABHA address suggestions / assignment
// POST abha/create/address_suggestions, POST abha/create/address
// -------------------------------------------------------------------------
public function addressSuggestions()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
$txnId = trim((string) ($this->request->getPost('txn_id') ?? $this->request->getPost('txnId') ?? ''));
if ($txnId === '') {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'txn_id is required']);
}
try {
$result = AbdmConnectorFactory::make()->abhaAddressSuggestions(['txn_id' => $txnId]);
} catch (\Throwable $e) {
return $this->response->setJSON(['ok' => 0, 'supported' => false, 'error_text' => $e->getMessage()]);
}
if (empty($result['ok']) || (int) $result['ok'] !== 1) {
// A missing Bridge endpoint must not block ABHA creation — the modal
// keeps the ABDM-assigned address and skips the selection step.
$unsupported = in_array((int) ($result['http_code'] ?? 0), [404, 405], true);
$bridgeErr = $this->extractBridgeErrorText($result, 'Unable to load ABHA address suggestions.');
if (stripos($bridgeErr, 'Internal Server Error') !== false || (int) ($result['http_code'] ?? 0) >= 500) {
$bridgeErr = 'Suggested ABHA addresses are temporarily unavailable from ABDM.';
}
return $this->response->setJSON([
'ok' => 0,
'supported' => ! $unsupported,
'error_text' => $unsupported
? 'The ABDM Bridge does not expose ABHA address suggestions yet.'
: $bridgeErr,
'request_id' => (string) ($result['request_id'] ?? ''),
]);
}
return $this->response->setJSON([
'ok' => 1,
'supported' => true,
'txn_id' => $txnId,
'suggestions' => array_values((array) ($result['suggestions'] ?? [])),
'bridge_mock' => ! empty($result['bridge_mock']),
'error_text' => ! empty($result['bridge_mock'])
? 'The ABDM Bridge is running in test mode and returned a mock response, so no ABHA address suggestions were provided.'
: '',
]);
}
public function address()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
$txnId = trim((string) ($this->request->getPost('txn_id') ?? $this->request->getPost('txnId') ?? ''));
$abhaAddress = trim((string) ($this->request->getPost('abha_address') ?? ''));
if ($abhaAddress === '') {
return $this->response->setJSON(['ok' => 1, 'message' => 'ABHA created successfully.']);
}
$handle = explode('@', $abhaAddress)[0];
$len = strlen($handle);
$dotCount = substr_count($handle, '.');
$underCount = substr_count($handle, '_');
if ($len < 8 || $len > 18) {
return $this->response->setJSON([
'ok' => 0,
'error_text' => 'ABHA address must be between 8 and 18 characters.',
]);
}
if (! preg_match('/^[a-zA-Z0-9]/', $handle) || ! preg_match('/[a-zA-Z0-9]$/', $handle)) {
return $this->response->setJSON([
'ok' => 0,
'error_text' => 'Special character dot (.) and underscore (_) can only be in between, not at the beginning or end.',
]);
}
if ($dotCount > 1 || $underCount > 1 || ! preg_match('/^[a-zA-Z0-9._]+$/', $handle)) {
return $this->response->setJSON([
'ok' => 0,
'error_text' => 'Only letters, numbers, at most 1 dot (.) and/or 1 underscore (_) are allowed.',
]);
}
try {
$result = AbdmConnectorFactory::make()->abhaSetAddress([
'txn_id' => $txnId,
'abha_address' => $abhaAddress,
]);
} catch (\Throwable $e) {
return $this->response->setJSON(['ok' => 0, 'error_text' => $e->getMessage()]);
}
if (empty($result['ok']) || (int) $result['ok'] !== 1) {
$errorMsg = $this->extractBridgeErrorText($result, 'Unable to set the ABHA address.');
if (stripos($errorMsg, 'already exist') !== false || stripos($errorMsg, 'already in use') !== false || stripos($errorMsg, 'taken') !== false) {
$errorMsg = 'ABHA Address is already exist';
}
return $this->response->setJSON([
'ok' => 0,
'error_text' => $errorMsg,
'request_id' => (string) ($result['request_id'] ?? ''),
]);
}
$data = is_array($result['data'] ?? null) ? $result['data'] : [];
if (! empty($result['bridge_mock'])) {
return $this->response->setJSON([
'ok' => 0,
'error_text' => 'The ABDM Bridge is running in test mode and did not apply the ABHA address. Ask the Bridge team to enable live mode for /v3/abha/set-address.',
'request_id' => (string) ($result['request_id'] ?? ''),
]);
}
return $this->response->setJSON([
'ok' => 1,
'abha_address' => (string) ($result['abha_address'] ?? $data['abhaAddress'] ?? $data['abha_address'] ?? $abhaAddress),
'message' => 'ABHA address updated successfully.',
]);
}
// -------------------------------------------------------------------------
// Facility Scan & Share QR — GET abha/register/facility_qr
// -------------------------------------------------------------------------
public function facilityQr()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
try {
/** @var \App\Libraries\Abdm\AbdmConnectorInterface $connector */
$connector = AbdmConnectorFactory::make();
$result = $connector->facilityQrCode();
} catch (\Throwable $e) {
return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => $e->getMessage()]);
}
if (empty($result['ok']) || (int) $result['ok'] !== 1) {
return $this->response->setStatusCode((int) ($result['http_code'] ?? 502) ?: 502)->setJSON([
'ok' => 0,
'error_text' => $result['error_text'] ?? $result['message'] ?? $result['error'] ?? 'Unable to fetch facility QR code',
'bridge_http_code' => (int) ($result['http_code'] ?? 0),
]);
}
return $this->response->setJSON([
'ok' => 1,
'hospital_id' => $result['hospital_id'] ?? $result['data']['hospital_id'] ?? '',
'hospital_name' => $result['hospital_name'] ?? $result['data']['hospital_name'] ?? '',
'hfr_id' => $result['hfr_id'] ?? $result['data']['hfr_id'] ?? '',
'qr_data' => $result['qr_data'] ?? $result['data']['qr_data'] ?? '',
'request_id' => $result['request_id'] ?? '',
]);
}
// -------------------------------------------------------------------------
// Fetch official ABHA card — POST abha/register/card
// -------------------------------------------------------------------------
public function fetchOfficialCard()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
$abhaNumber = trim((string) ($this->request->getPost('abha_number') ?? ''));
$abhaAddress = trim((string) ($this->request->getPost('abha_address') ?? ''));
if ($abhaNumber === '' && $abhaAddress === '') {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'ABHA number or ABHA address is required']);
}
try {
$result = AbdmConnectorFactory::make()->fetchOfficialAbhaCard([
'abha_number' => $abhaNumber,
'abha_address' => $abhaAddress,
]);
} catch (\Throwable $e) {
log_message('error', '[ABHA] Official card fetch failed: ' . $e->getMessage());
return $this->response->setStatusCode(502)->setJSON([
'ok' => 0,
'error_text' => 'The official ABHA card could not be downloaded from the Bridge.',
]);
}
if (empty($result['ok']) || (int) $result['ok'] !== 1) {
return $this->response->setStatusCode(502)->setJSON([
'ok' => 0,
'error_text' => $this->extractBridgeErrorText($result, 'The official ABHA card was not returned by the Bridge.'),
]);
}
return $this->response->setJSON($result);
}
// -------------------------------------------------------------------------
// Official ABHA Card — GET abha/card/{abha_number}
// Renders only the official card image returned by ABDM/NHA.
// -------------------------------------------------------------------------
public function card(string $abhaNumber = '')
{
$abhaNumClean = preg_replace('/\D/', '', $abhaNumber);
if (strlen($abhaNumClean) !== 14) {
return $this->response->setStatusCode(400)
->setBody('<h3 style="font-family:sans-serif;color:red;">Invalid ABHA number.</h3>');
}
$db = \Config\Database::connect();
$fields = $db->getFieldNames('patient_master') ?? [];
$abhaField = null;
foreach (['abha_id', 'abha_no', 'abha'] as $f) {
if (in_array($f, $fields, true)) { $abhaField = $f; break; }
}
if ($abhaField === null && in_array('abha_address', $fields, true)) {
$abhaField = 'abha_address';
}
$patient = null;
if ($abhaField) {
$patient = $db->table('patient_master')
->where($abhaField, $abhaNumClean)->get()->getRowArray();
if (! $patient) {
$patient = $db->table('patient_master')
->where($abhaField, $abhaNumber)->get()->getRowArray();
}
}
if (! $patient) {
return $this->response->setStatusCode(404)
->setBody('<h3 style="font-family:sans-serif;color:red;">Patient with ABHA number ' . esc($abhaNumber) . ' not found.</h3>');
}
$storedCard = trim((string) ($patient['abha_card_base64'] ?? ''));
if ($storedCard === '') {
return $this->response->setStatusCode(404)
->setBody('<h3 style="font-family:sans-serif;color:red;">Official ABHA card is not available for this patient.</h3>');
}
return view('abha/official_card', [
'patient' => $patient,
'stored_abha_card' => $storedCard,
]);
}
// Hospital Card — GET abha/hospital-card/{patient_id}
public function hospitalCard(int $patientId = 0)
{
if ($patientId <= 0) {
return $this->response->setStatusCode(400)
->setBody('<h3 style="font-family:sans-serif;color:red;">Invalid patient.</h3>');
}
$db = \Config\Database::connect();
$patient = $db->table('patient_master')->where('id', $patientId)->get()->getRowArray();
if (! $patient) {
return $this->response->setStatusCode(404)
->setBody('<h3 style="font-family:sans-serif;color:red;">Patient not found.</h3>');
}
$abhaNumClean = '';
foreach (['abha_id', 'abha_no', 'abha'] as $field) {
$candidate = preg_replace('/\D/', '', (string) ($patient[$field] ?? ''));
if (strlen($candidate) === 14) {
$abhaNumClean = $candidate;
break;
}
}
if ($abhaNumClean === '') {
return $this->response->setStatusCode(404)
->setBody('<h3 style="font-family:sans-serif;color:red;">ABHA is not linked with this patient.</h3>');
}
$abhaDisp = preg_replace('/^(\d{2})(\d{4})(\d{4})(\d{4})$/', '$1-$2-$3-$4', $abhaNumClean) ?: $abhaNumClean;
$genderRaw = (string) ($patient['gender'] ?? '');
$genderLabel = $genderRaw === '1' ? 'Male' : ($genderRaw === '2' ? 'Female' : $genderRaw);
$dobRaw = (string) ($patient['dob'] ?? '');
$dobLabel = '';
if ($dobRaw && $dobRaw !== '0000-00-00') {
try { $dobLabel = (new \DateTime($dobRaw))->format('d M Y'); } catch (\Exception $e) { $dobLabel = $dobRaw; }
}
$hospitalBrand = $this->loadHospitalBranding();
$hmsId = trim((string) ($patient['p_code'] ?? ''));
if ($hmsId === '') {
$hmsId = 'PID-' . (string) ($patient['id'] ?? '');
}
$profilePhotoUrl = $this->resolvePatientPhotoUrl($patient);
$mobileNo = trim((string) ($patient['mphone1'] ?? ''));
$mobileVerified = (int) ($patient['abha_mobile_verified'] ?? 0) === 1;
$patientQrPayload = json_encode([
'hidn' => $abhaNumClean,
'hid' => trim((string) ($patient['abha_address'] ?? '')),
'name' => trim((string) ($patient['p_fname'] ?? '')),
'gender' => $genderLabel,
'dob' => $dobRaw !== '0000-00-00' ? $dobRaw : '',
'mobile' => $mobileNo,
'address' => trim((string) ($patient['add1'] ?? '')),
'districtName' => trim((string) ($patient['district'] ?? '')),
'stateName' => trim((string) ($patient['state'] ?? '')),
'pincode' => trim((string) ($patient['zip'] ?? '')),
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$patientQrImage = $this->buildQrCodeDataUri(is_string($patientQrPayload) ? $patientQrPayload : $abhaNumClean);
$barcodeSvg = $this->buildCode39Svg($hmsId);
$barcodeImage = $this->buildCode39ImageDataUri($hmsId, $barcodeSvg);
return view('abha/card', [
'patient' => $patient,
'abha_num' => $abhaDisp,
'abha_raw' => $abhaNumClean,
'patient_id' => (int) ($patient['id'] ?? 0),
'abha_address' => (string) ($patient['abha_address'] ?? ''),
'gender' => $genderLabel,
'dob' => $dobLabel,
'hospital_name' => (string) ($hospitalBrand['hospital_name'] ?? 'E-Atria Hospital'),
'hospital_logo_url' => (string) ($hospitalBrand['hospital_logo_url'] ?? base_url('assets/img/logo.png')),
'brand_name' => (string) ($hospitalBrand['brand_name'] ?? 'HMS'),
'hms_id' => $hmsId,
'hms_barcode_svg' => $barcodeSvg,
'hms_barcode_image' => $barcodeImage,
'profile_photo_url' => $profilePhotoUrl,
'patient_qr_image' => $patientQrImage,
'patient_mobile' => $mobileNo,
'mobile_verified' => $mobileVerified,
]);
}
private function buildQrCodeDataUri(string $payload): string
{
try {
$qrCode = new \Mpdf\QrCode\QrCode($payload, 'M');
$svg = (new \Mpdf\QrCode\Output\Svg())->output($qrCode, 220);
return 'data:image/svg+xml;base64,' . base64_encode($svg);
} catch (\Throwable $e) {
log_message('error', '[ABHA] Hospital card QR generation failed: ' . $e->getMessage());
return '';
}
}
/**
* @return array<string, string>
*/
private function loadHospitalBranding(): array
{
$branding = [
'hospital_name' => '',
'hospital_logo_url' => base_url('assets/img/logo.png'),
'brand_name' => 'HMS',
];
try {
$db = \Config\Database::connect();
if (! $db->tableExists('hospital_setting')) {
return $branding;
}
$rows = $db->table('hospital_setting')
->select('s_name, s_value')
->whereIn('s_name', [
'H_Name',
'H_logo',
'HOSPITAL_NAME',
'HOSPITAL_DISPLAY_NAME',
'HOSPITAL_TITLE',
])
->orderBy('id', 'DESC')
->get()
->getResultArray();
$settings = [];
foreach ($rows as $row) {
$key = trim((string) ($row['s_name'] ?? ''));
if ($key === '' || array_key_exists($key, $settings)) {
continue;
}
$settings[$key] = trim((string) ($row['s_value'] ?? ''));
}
foreach (['H_Name', 'HOSPITAL_NAME', 'HOSPITAL_DISPLAY_NAME', 'HOSPITAL_TITLE'] as $nameKey) {
if (! empty($settings[$nameKey])) {
$branding['hospital_name'] = (string) $settings[$nameKey];
break;
}
}
$logoName = trim((string) ($settings['H_logo'] ?? ''));
if ($logoName !== '') {
$branding['hospital_logo_url'] = base_url('assets/images/' . rawurlencode($logoName));
}
if ($branding['hospital_name'] !== '') {
$branding['brand_name'] = $branding['hospital_name'];
}
} catch (\Throwable $e) {
// Keep default branding if settings lookup fails.
}
if ($branding['hospital_name'] === '') {
$branding['hospital_name'] = 'E-Atria Hospital';
}
return $branding;
}
/**
* @param array<string,mixed> $patient
*/
private function resolvePatientPhotoUrl(array $patient): string
{
$photoPath = '/assets/images/no_image.svg';
$profileFileId = (int) ($patient['profile_file_id'] ?? 0);
if ($profileFileId > 0) {
try {
$db = \Config\Database::connect();
if ($db->tableExists('file_upload_data')) {
$fileRow = $db->table('file_upload_data')
->select('full_path')
->where('id', $profileFileId)
->get()
->getRowArray();
$fullPath = trim((string) ($fileRow['full_path'] ?? ''));
if ($fullPath !== '') {
$pos = strpos($fullPath, '/uploads/', 1);
if ($pos !== false) {
return substr($fullPath, $pos);
}
return $fullPath;
}
}
} catch (\Throwable $e) {
// Fall through to next source.
}
}
$profilePicture = trim((string) ($patient['profile_picture'] ?? ''));
if ($profilePicture !== '') {
$pos = strpos($profilePicture, '/uploads/', 1);
if ($pos !== false) {
return substr($profilePicture, $pos);
}
return $profilePicture;
}
$abhaPhotoBase64 = trim((string) ($patient['abha_profile_photo_base64'] ?? ''));
if ($abhaPhotoBase64 !== '') {
return str_starts_with($abhaPhotoBase64, 'data:image')
? $abhaPhotoBase64
: 'data:image/jpeg;base64,' . $abhaPhotoBase64;
}
return $photoPath;
}
private function buildCode39Svg(string $value): string
{
$layout = $this->buildCode39Layout($value);
if ($layout === null) {
return '';
}
$rects = [];
foreach ($layout['bars'] as $bar) {
$rects[] = '<rect x="' . $bar['x'] . '" y="0" width="' . $bar['w'] . '" height="' . $layout['height'] . '" fill="#111" />';
}
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' . $layout['width'] . ' ' . $layout['height'] . '" role="img" aria-label="HMS Barcode">'
. implode('', $rects)
. '</svg>';
}
private function buildCode39ImageDataUri(string $value, string $svgFallback = ''): string
{
$layout = $this->buildCode39Layout($value);
if ($layout === null) {
return '';
}
if (! function_exists('imagecreatetruecolor') || ! function_exists('imagepng')) {
$svg = $svgFallback !== '' ? $svgFallback : $this->buildCode39Svg($value);
return $svg !== '' ? 'data:image/svg+xml;base64,' . base64_encode($svg) : '';
}
$img = imagecreatetruecolor($layout['width'], $layout['height']);
if ($img === false) {
$svg = $svgFallback !== '' ? $svgFallback : $this->buildCode39Svg($value);
return $svg !== '' ? 'data:image/svg+xml;base64,' . base64_encode($svg) : '';
}
$white = imagecolorallocate($img, 255, 255, 255);
$black = imagecolorallocate($img, 17, 17, 17);
imagefilledrectangle($img, 0, 0, $layout['width'], $layout['height'], $white);
foreach ($layout['bars'] as $bar) {
imagefilledrectangle(
$img,
$bar['x'],
0,
$bar['x'] + $bar['w'] - 1,
$layout['height'],
$black
);
}
ob_start();
imagepng($img);
$pngBinary = ob_get_clean();
imagedestroy($img);
if (! is_string($pngBinary) || $pngBinary === '') {
$svg = $svgFallback !== '' ? $svgFallback : $this->buildCode39Svg($value);
return $svg !== '' ? 'data:image/svg+xml;base64,' . base64_encode($svg) : '';
}
return 'data:image/png;base64,' . base64_encode($pngBinary);
}
/**
* @return array{bars: list<array{x:int,w:int}>, width:int, height:int}|null
*/
private function buildCode39Layout(string $value): ?array
{
$normalized = strtoupper(trim($value));
if ($normalized === '') {
return null;
}
$supported = [
'0' => 'nnnwwnwnn', '1' => 'wnnwnnnnw', '2' => 'nnwwnnnnw', '3' => 'wnwwnnnnn',
'4' => 'nnnwwnnnw', '5' => 'wnnwwnnnn', '6' => 'nnwwwnnnn', '7' => 'nnnwnnwnw',
'8' => 'wnnwnnwnn', '9' => 'nnwwnnwnn', 'A' => 'wnnnnwnnw', 'B' => 'nnwnnwnnw',
'C' => 'wnwnnwnnn', 'D' => 'nnnnwwnnw', 'E' => 'wnnnwwnnn', 'F' => 'nnwnwwnnn',
'G' => 'nnnnnwwnw', 'H' => 'wnnnnwwnn', 'I' => 'nnwnnwwnn', 'J' => 'nnnnwwwnn',
'K' => 'wnnnnnnww', 'L' => 'nnwnnnnww', 'M' => 'wnwnnnnwn', 'N' => 'nnnnwnnww',
'O' => 'wnnnwnnwn', 'P' => 'nnwnwnnwn', 'Q' => 'nnnnnnwww', 'R' => 'wnnnnnwwn',
'S' => 'nnwnnnwwn', 'T' => 'nnnnwnwwn', 'U' => 'wwnnnnnnw', 'V' => 'nwwnnnnnw',
'W' => 'wwwnnnnnn', 'X' => 'nwnnwnnnw', 'Y' => 'wwnnwnnnn', 'Z' => 'nwwnwnnnn',
'-' => 'nwnnnnwnw', '.' => 'wwnnnnwnn', ' ' => 'nwwnnnwnn', '$' => 'nwnwnwnnn',
'/' => 'nwnwnnnwn', '+' => 'nwnnnwnwn', '%' => 'nnnwnwnwn', '*' => 'nwnnwnwnn',
];
$payload = '*' . $normalized . '*';
$clean = '';
$len = strlen($payload);
for ($i = 0; $i < $len; $i++) {
$ch = $payload[$i];
if (! isset($supported[$ch])) {
continue;
}
$clean .= $ch;
}
if ($clean === '') {
return null;
}
$narrow = 2;
$wide = 5;
$height = 56;
$quiet = 10;
$gap = 2;
$x = $quiet;
$bars = [];
$cleanLen = strlen($clean);
for ($i = 0; $i < $cleanLen; $i++) {
$pattern = $supported[$clean[$i]];
for ($j = 0; $j < 9; $j++) {
$w = ($pattern[$j] === 'w') ? $wide : $narrow;
if ($j % 2 === 0) {
$bars[] = ['x' => $x, 'w' => $w];
}
$x += $w;
}
if ($i < ($cleanLen - 1)) {
$x += $gap;
}
}
return [
'bars' => $bars,
'width' => $x + $quiet,
'height' => $height,
];
}
/**
* Normalize nested bridge error payloads into a single readable message.
*
* @param array<string, mixed> $result
*/
private function extractBridgeErrorText(array $result, string $fallback): string
{
$candidates = [
$result['error_text'] ?? null,
is_string($result['error'] ?? null) ? $result['error'] : 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,
is_string($result['data']['error'] ?? null) ? $result['data']['error'] : 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,
];
$genericPhrases = [
'abdm gateway returned an error.',
'abdm_upstream_error',
'gateway error',
'upstream error',
'bad request',
'an error occurred.',
];
$firstGeneric = null;
foreach ($candidates as $candidate) {
if (is_string($candidate) && trim($candidate) !== '') {
$trimmed = trim($candidate);
if (stripos($trimmed, 'preferredAbhaAddress') !== false ||
(stripos($trimmed, 'Unknown column') !== false && stripos($trimmed, 'where clause') !== false) ||
stripos($trimmed, 'Please make a valid request') !== false) {
$isNumberLookup = (($result['type'] ?? '') === 'abha-number')
|| (isset($result['abha_id']) && !isset($result['abha_address']))
|| (isset($result['value']) && !str_contains((string) $result['value'], '@') && ctype_digit(str_replace(['-', ' '], '', (string) $result['value'])));
return ($isNumberLookup ? 'ABHA Number' : 'ABHA Address') . ' does not exist or was not found in the ABDM registry.';
}
if (stripos($trimmed, 'loginHint') !== false && stripos($trimmed, 'invalid') !== false) {
return 'ABDM gateway rejected login hint: OTP verification by ABHA Address is not supported directly by ABDM. Please verify using the 14-digit ABHA Number or Find ABHA via Mobile.';
}
if (stripos($trimmed, 'loginId') !== false && stripos($trimmed, 'invalid') !== false) {
return 'Aadhaar Number is not valid. Valid 12-digit Aadhaar number is required.';
}
// Normalize ABDM/UIDAI OTP errors: replace technical error codes with clear user-friendly messages (ABDM M1)
if (preg_match('/(?:UIDAI Error code\s*:\s*403|attempts for OTP match is exceeded|max(?:imum)? number of attempts.*exceeded)/i', $trimmed)) {
return 'Incorrect OTP. Maximum number of attempts exceeded. Please generate a fresh OTP and try again.';
}
if (preg_match('/(?:UIDAI Error code\s*:\s*400|Invalid (?:Aadhaar )?OTP value|Invalid OTP|Incorrect OTP|OTP mismatch|OTP.*not valid|OTP.*either expired or incorrect)/i', $trimmed)) {
return 'Incorrect OTP';
}
// Normalize OTP cooldown / rate-limit message to 60 seconds (ABDM M1 test compliance)
if (preg_match('/(?:after\s*30\s*seconds|request\s*(?:for\s*)?new\s*otp\s*after\s*30\s*seconds)/i', $trimmed)) {
return 'You can request for new OTP after 60 seconds';
}
// Strip raw technical error code prefixes (e.g. ABDM-1204 : or UIDAI Error code : 400 :)
$cleaned = preg_replace('/^(?:ABDM-\d+\s*:\s*)?(?:UIDAI Error code\s*:\s*\d+\s*:\s*)?/i', '', $trimmed);
if (preg_match('/(?:invalid.*otp|incorrect.*otp|otp.*invalid|otp.*incorrect|otp.*match)/i', $cleaned)) {
if (preg_match('/(?:attempts|exceeded|403)/i', $cleaned)) {
return 'Incorrect OTP. Maximum number of attempts exceeded. Please generate a fresh OTP and try again.';
}
return 'Incorrect OTP';
}
if (preg_match('/30\s*seconds/i', $cleaned)) {
$cleaned = preg_replace('/30\s*seconds/i', '60 seconds', $cleaned);
}
if (in_array(strtolower($cleaned), $genericPhrases, true)) {
$firstGeneric ??= $cleaned;
continue;
}
return $cleaned;
}
}
$ignoreFields = [
'timestamp', 'txnid', 'txn_id', 'authresult', 'auth_result',
'authmethod', 'auth_method', 'status', 'token', 'tokens',
'refreshtoken', 'refresh_token', 'expiresin', 'expires_in',
'refreshexpiresin', 'refresh_expires_in', 'request_id', 'requestid',
'scope', 'accounts', 'message', 'error', 'error_text', 'code',
];
$fieldErrors = [];
if (is_array($result['data'] ?? null)) {
foreach ($result['data'] as $field => $message) {
$lowerField = strtolower(trim((string) $field));
if (in_array($lowerField, $ignoreFields, true) || ! is_string($message) || trim($message) === '') {
continue;
}
$fieldMsg = trim($message);
if (strcasecmp($field, 'loginHint') === 0 && stripos($fieldMsg, 'invalid') !== false) {
$fieldErrors[] = 'ABDM gateway rejected login hint: OTP verification by ABHA Address is not supported directly by ABDM. Please verify using the 14-digit ABHA Number or Find ABHA via Mobile.';
} elseif (strcasecmp($field, 'loginId') === 0 && stripos($fieldMsg, 'invalid') !== false) {
$fieldErrors[] = 'Aadhaar Number is not valid. Valid 12-digit Aadhaar number is required.';
} elseif (preg_match('/(?:attempts.*exceeded|403)/i', $fieldMsg)) {
$fieldErrors[] = 'Incorrect OTP. Maximum number of attempts exceeded. Please generate a fresh OTP and try again.';
} elseif (preg_match('/(?:invalid.*otp|incorrect.*otp|otp.*match)/i', $fieldMsg)) {
$fieldErrors[] = 'Incorrect OTP';
} elseif (preg_match('/30\s*seconds/i', $fieldMsg)) {
$fieldErrors[] = preg_replace('/30\s*seconds/i', '60 seconds', $fieldMsg);
} else {
$fieldErrors[] = $field . ': ' . $fieldMsg;
}
}
}
if ($fieldErrors !== []) {
return implode('; ', $fieldErrors);
}
if ($firstGeneric !== null) {
return preg_replace('/30\s*seconds/i', '60 seconds', $firstGeneric);
}
if (preg_match('/(?:invalid.*otp|incorrect.*otp|otp.*expired)/i', $fallback)) {
return 'Incorrect OTP';
}
return preg_replace('/30\s*seconds/i', '60 seconds', $fallback);
}
/**
* UIDAI Verhoeff algorithm check for 12-digit Aadhaar number.
*/
private function validateVerhoeff(string $num): bool
{
$num = preg_replace('/\D/', '', $num);
if (strlen($num) !== 12 || $num[0] === '0' || $num[0] === '1') {
return false;
}
$d = [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
[1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
[2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
[3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
[4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
[5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
[6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
[7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
[8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
];
$p = [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
[1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
[5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
[8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
[9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
[4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
[2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
[7, 0, 4, 6, 9, 1, 3, 2, 5, 8]
];
$c = 0;
$invertedArray = array_reverse(str_split($num));
foreach ($invertedArray as $i => $digit) {
$c = $d[$c][$p[$i % 8][(int)$digit]];
}
return ($c === 0);
}
// -------------------------------------------------------------------------
// Step 5 — Confirm patient link/create after a "matching patients found"
// review by the operator (see findMatchingCandidates()).
// POST abha/create/confirm_patient
// -------------------------------------------------------------------------
public function confirmPatient()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
$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') ?? ''));
$mobile = preg_replace('/\D/', '', (string) ($this->request->getPost('mobile') ?? ''));
$gender = (string) ($this->request->getPost('gender') ?? '');
$dob = (string) ($this->request->getPost('dob') ?? '');
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') ?? ''),
'profile_photo' => (string) ($this->request->getPost('photo') ?? ''),
'card_base64' => (string) ($this->request->getPost('card_base64') ?? ''),
'verified_status' => (string) ($this->request->getPost('verified_status') ?? ''),
'verification_type' => (string) ($this->request->getPost('verification_type') ?? ''),
'kyc_verified' => $this->request->getPost('kyc_verified'),
'mobile_verified' => $this->request->getPost('mobile_verified'),
'address' => (string) ($this->request->getPost('address') ?? ''),
'district' => (string) ($this->request->getPost('district') ?? ''),
'state' => (string) ($this->request->getPost('state') ?? ''),
'zip' => (string) ($this->request->getPost('zip') ?? ''),
'email' => (string) ($this->request->getPost('email') ?? ''),
];
$db = \Config\Database::connect();
$fields = $db->getFieldNames('patient_master') ?? [];
$abhaField = $this->resolveAbhaFieldName($fields);
$abhaNumClean = preg_replace('/\D/', '', $abhaNum);
$abhaAddress = trim((string) ($abhaMeta['abha_address'] ?? ''));
if ($abhaNumClean === '' && $abhaAddress === '') {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Missing ABHA identifier.']);
}
// ABHA ID and Address must be unique across patient_master — never let two
// patient rows carry the same ABHA number or address.
if ($abhaNumClean !== '' || $abhaAddress !== '') {
$conflict = \App\Libraries\Abdm\AbhaDemographicValidator::checkUniqueness(
$abhaNumClean,
$abhaAddress,
$action === 'existing' ? $patientId : null,
$db
);
if ($conflict !== null) {
return $this->response->setJSON([
'ok' => 0,
'error_text' => $conflict['error_text'],
'conflict_patient_id' => $conflict['conflict_patient_id'],
'conflict_p_code' => $conflict['p_code'],
]);
}
}
if ($action === 'existing') {
if ($patientId <= 0) {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'patient_id is required to link an existing patient.']);
}
$row = $db->table('patient_master')->where('id', $patientId)->get()->getRowArray();
if (! $row) {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Selected patient not found.']);
}
// Guard: this patient must not already carry a *different* ABHA number.
if ($abhaField && ! empty($row[$abhaField]) && $abhaNumClean !== ''
&& preg_replace('/\D/', '', (string) $row[$abhaField]) !== $abhaNumClean) {
return $this->response->setJSON([
'ok' => 0,
'error_text' => 'Selected patient already has a different ABHA linked (' . $row[$abhaField] . '). Cannot link a second ABHA to the same patient.',
]);
}
// Enforce demographic validation: Name, Age/DOB, and Gender must match!
$demoError = \App\Libraries\Abdm\AbhaDemographicValidator::validateDemographicsMatch(
$row,
[
'name' => $name,
'gender' => $gender,
'dob' => $dob,
'mobile' => $mobile,
]
);
if ($demoError !== null) {
return $this->response->setJSON([
'ok' => 0,
'error_text' => $demoError,
]);
}
$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);
}
return $this->response->setJSON([
'ok' => 1,
'patient_id' => $patientInfo['patient_id'],
'p_code' => $patientInfo['p_code'],
'is_new_patient' => $patientInfo['is_new'],
]);
}
// -------------------------------------------------------------------------
// Private helper — resolve the column name used to store the ABHA number
// in patient_master (varies by installation/migration history).
// -------------------------------------------------------------------------
private function resolveAbhaFieldName(array $fields): ?string
{
foreach (['abha_id', 'abha_no', 'abha'] as $f) {
if (in_array($f, $fields, true)) {
return $f;
}
}
if (in_array('abha_address', $fields, true)) {
return 'abha_address';
}
return null;
}
/**
* Try to safely auto-resolve the local patient WITHOUT operator confirmation.
* Only used when there's no real ambiguity:
* - exact ABHA-number match (this exact digital identity was already linked here before), or
* - an unambiguous orphan "ABHA PATIENT" placeholder row (HMS's own incomplete record, not a
* different real patient).
* Returns null when neither applies — caller should then run findMatchingCandidates()
* and ask the operator to confirm "Create New" vs "Update Existing".
*
* @return array{patient_id:int,p_code:string,is_new:bool}|null
*/
private function tryAutoLinkByDirectMatch(
string $abhaNum,
string $name,
string $mobile,
string $gender,
string $dob,
array $abhaMeta = []
): ?array {
$db = \Config\Database::connect();
$fields = $db->getFieldNames('patient_master') ?? [];
$abhaField = $this->resolveAbhaFieldName($fields);
$abhaNumClean = preg_replace('/\D/', '', $abhaNum);
$abhaAddress = trim((string) ($abhaMeta['abha_address'] ?? ''));
$existing = null;
if ($abhaField && $abhaNumClean !== '') {
$existing = $db->table('patient_master')
->where($abhaField, $abhaNumClean)->get()->getRowArray();
if (! $existing && $abhaNum !== $abhaNumClean) {
$existing = $db->table('patient_master')
->where($abhaField, $abhaNum)->get()->getRowArray();
}
}
if ($existing) {
$patientId = (int) ($existing['id'] ?? 0);
$demoError = \App\Libraries\Abdm\AbhaDemographicValidator::validateDemographicsMatch($existing, [
'name' => $name,
'gender' => $gender,
'dob' => $dob,
'mobile' => $mobile,
]);
if ($demoError === null) {
return $this->linkAbhaToPatient($db, $patientId, $name, $mobile, $gender, $dob, $abhaMeta, $abhaNumClean, $abhaAddress, $fields, $abhaField);
}
log_message('warning', '[tryAutoLinkByDirectMatch] Skipped auto-link due to demographic mismatch: {msg}', ['msg' => $demoError]);
}
// Safe repair path for orphan placeholder rows created during prior failed mapping.
// Reuse only when there is exactly one unambiguous candidate with no clinical linkage.
$placeholder = $this->findRepairablePlaceholderPatient($db, $fields);
if ($placeholder !== null) {
$patientId = (int) ($placeholder['id'] ?? 0);
if ($patientId > 0) {
return $this->repairPlaceholderPatient($db, $patientId, $name, $mobile, $gender, $dob, $abhaMeta, $abhaNumClean, $abhaAddress, $fields, $abhaField);
}
}
return null;
}
/**
* ABHA returns DOB as DD-MM-YYYY while patient_master stores YYYY-MM-DD.
*/
private function extractBirthYear(string $dob): string
{
$dob = trim($dob);
if ($dob === '') {
return '';
}
$normalized = $this->normalizeDobToDb($dob);
if ($normalized !== '' && preg_match('/^(\d{4})/', $normalized, $match) === 1) {
return $match[1];
}
return preg_match('/(?<!\d)(\d{4})(?!\d)/', $dob, $match) === 1 ? $match[1] : '';
}
/**
* Compute whole-years age from a dob string ('DD-MM-YYYY' or 'YYYY-MM-DD').
*/
private function computeAgeYears(string $dob): ?int
{
$dobDb = $this->normalizeDobToDb($dob);
if ($dobDb === '') {
return null;
}
try {
return (int) (new \DateTime($dobDb))->diff(new \DateTime())->y;
} catch (\Throwable $e) {
return null;
}
}
private function genderLabel(int $g): string
{
if ($g === 2) {
return 'Female';
}
if ($g === 3) {
return 'Other';
}
if ($g === 1) {
return 'Male';
}
return '';
}
/**
* Search patient_master for possible existing local records matching the
* ABHA profile by name/age/gender (and mobile/aadhaar when available), so
* the operator can confirm "this is the same person" before linking, or
* choose to register as a brand-new patient. HMS never auto-creates a new
* patient record on its own once this search has run — the operator always
* makes the final call.
*
* @return array<int, array<string, mixed>>
*/
private function findMatchingCandidates(
$db,
array $fields,
string $name,
string $mobile,
string $genderRaw,
string $dob,
string $aadhaar,
?string $abhaField,
string $abhaNumClean
): array {
$genderDb = $this->toPatientGenderValue($genderRaw);
// ABHA sends DOB as DD-MM-YYYY, patient_master stores YYYY-MM-DD.
$birthYear = $this->extractBirthYear($dob);
$nameUp = strtoupper(trim($name));
$nameTokens = array_values(array_filter(preg_split('/\s+/', $nameUp) ?: [], fn ($t) => strlen($t) >= 3));
$aadhaar = preg_replace('/\D/', '', $aadhaar);
if ($nameTokens === [] && $mobile === '' && $aadhaar === '' && $abhaNumClean === '' && $birthYear === '') {
return [];
}
$candidateAbhaCols = array_values(array_intersect(['abha_id', 'abha_no', 'abha', 'abha_address'], $fields));
$selectFields = array_values(array_intersect(
['id', 'p_code', 'p_fname', 'gender', 'dob', 'age', 'mphone1', 'udai', 'add1', 'district', 'state', 'zip', 'profile_file_id', 'profile_picture', 'abha_profile_photo_base64'],
$fields
));
foreach ($candidateAbhaCols as $cCol) {
if (! in_array($cCol, $selectFields, true)) {
$selectFields[] = $cCol;
}
}
$select = implode(',', $selectFields);
// Two targeted passes: exact identifiers can never be crowded out by a
// common name, and gender alone is a filter (see scoring) not a selector.
$rows = [];
$collect = static function (callable $applyConditions) use ($db, $select, &$rows): void {
$builder = $db->table('patient_master')->select($select);
$builder->groupStart();
$applyConditions($builder);
$builder->groupEnd();
foreach ($builder->orderBy('insert_date', 'DESC')->limit(200)->get()->getResultArray() as $row) {
$rows[(int) ($row['id'] ?? 0)] = $row;
}
};
$formattedAbha = (strlen($abhaNumClean) === 14)
? substr($abhaNumClean, 0, 2) . '-' . substr($abhaNumClean, 2, 4) . '-' . substr($abhaNumClean, 6, 4) . '-' . substr($abhaNumClean, 10, 4)
: '';
$abhaVals = array_values(array_filter([$abhaNumClean, $formattedAbha]));
$collect(static function ($builder) use ($candidateAbhaCols, $abhaVals, $aadhaar, $mobile, $fields): void {
$hasAny = false;
if (! empty($abhaVals) && ! empty($candidateAbhaCols)) {
foreach ($candidateAbhaCols as $cCol) {
if (! $hasAny) {
$builder->whereIn($cCol, $abhaVals);
$hasAny = true;
} else {
$builder->orWhereIn($cCol, $abhaVals);
}
}
}
if ($aadhaar !== '' && in_array('udai', $fields, true)) {
$formattedAadhaar = substr($aadhaar, 0, 4) . ' ' . substr($aadhaar, 4, 4) . ' ' . substr($aadhaar, 8, 4);
if (! $hasAny) {
$builder->whereIn('udai', [$aadhaar, $formattedAadhaar]);
$hasAny = true;
} else {
$builder->orWhereIn('udai', [$aadhaar, $formattedAadhaar]);
}
}
if ($aadhaar !== '' && in_array('udai_hash', $fields, true)) {
$vault = new \App\Libraries\AadhaarVaultService();
$hash = $vault->hash($aadhaar);
if ($hash !== '') {
if (! $hasAny) {
$builder->where('udai_hash', $hash);
$hasAny = true;
} else {
$builder->orWhere('udai_hash', $hash);
}
}
}
if ($mobile !== '' && in_array('mphone1', $fields, true)) {
if (! $hasAny) {
$builder->where('mphone1', $mobile);
$hasAny = true;
} else {
$builder->orWhere('mphone1', $mobile);
}
}
});
if ($nameTokens !== []) {
$collect(static function ($builder) use ($nameTokens): void {
foreach ($nameTokens as $token) {
$builder->orLike('p_fname', $token);
}
});
}
if ($rows === []) {
return [];
}
$candidates = [];
foreach ($rows as $row) {
$rowName = strtoupper(trim((string) ($row['p_fname'] ?? '')));
$rowTokens = array_values(array_filter(preg_split('/\s+/', $rowName) ?: [], fn ($t) => strlen($t) >= 3));
$nameOverlap = $nameTokens !== [] && $rowTokens !== [] && $nameTokens[0] === $rowTokens[0];
$rowAgeYears = null;
$rowDob = trim((string) ($row['dob'] ?? ''));
if ($rowDob !== '' && $rowDob !== '0000-00-00') {
try {
$rowAgeYears = (int) (new \DateTime($rowDob))->diff(new \DateTime())->y;
} catch (\Throwable $e) {
$rowAgeYears = null;
}
}
if ($rowAgeYears === null && (int) ($row['age'] ?? 0) > 0) {
$rowAgeYears = (int) $row['age'];
}
$rowBirthYear = $this->extractBirthYear($rowDob);
$birthYearMatch = $birthYear !== '' && $rowBirthYear !== '' && $rowBirthYear === $birthYear;
$ageMatch = $birthYearMatch;
$genderMatch = $genderDb > 0 && (int) ($row['gender'] ?? 0) === $genderDb;
$mobileMatch = $mobile !== '' && preg_replace('/\D/', '', (string) ($row['mphone1'] ?? '')) === $mobile;
$aadhaarMatch = $aadhaar !== '' && preg_replace('/\D/', '', (string) ($row['udai'] ?? '')) === $aadhaar;
$rowAbha = '';
$abhaMatch = false;
foreach ($candidateAbhaCols as $cCol) {
$val = trim((string) ($row[$cCol] ?? ''));
if ($val !== '') {
if ($rowAbha === '') {
$rowAbha = $val;
}
$digitsVal = preg_replace('/\D/', '', $val);
if ($abhaNumClean !== '' && $digitsVal === $abhaNumClean) {
$abhaMatch = true;
$rowAbha = $val;
break;
}
}
}
$abhaConflict = $rowAbha !== '' && preg_replace('/\D/', '', $rowAbha) !== $abhaNumClean;
$minimumDemographicMatch = $nameOverlap && $birthYearMatch && $genderMatch;
if (! $abhaMatch && ! $aadhaarMatch && ! $minimumDemographicMatch) {
continue;
}
// Top score to exact ABHA (1000) and Aadhaar (500) matches
$score = ($abhaMatch ? 1000 : 0) + ($aadhaarMatch ? 500 : 0)
+ ($minimumDemographicMatch ? 5 : 0) + ($nameOverlap ? 2 : 0)
+ ($birthYearMatch ? 2 : 0) + ($genderMatch ? 1 : 0) + ($mobileMatch ? 4 : 0);
$candidates[] = [
'id' => (int) ($row['id'] ?? 0),
'p_code' => (string) ($row['p_code'] ?? ''),
'name' => trim((string) ($row['p_fname'] ?? '')),
'gender' => (int) ($row['gender'] ?? 0),
'gender_label' => $this->genderLabel((int) ($row['gender'] ?? 0)),
'dob' => $rowDob,
'age' => $rowAgeYears,
'mobile' => (string) ($row['mphone1'] ?? ''),
'photo_url' => $this->resolvePatientPhotoUrl($row),
'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,
'match' => [
'name' => $nameOverlap,
'age' => $ageMatch,
'gender' => $genderMatch,
'mobile' => $mobileMatch,
'aadhaar' => $aadhaarMatch,
'abha' => $abhaMatch,
],
'score' => $score,
];
}
usort($candidates, fn ($a, $b) => $b['score'] <=> $a['score']);
return array_slice($candidates, 0, 8);
}
/**
* Link an ABHA profile to an already-identified existing patient row
* (either auto-matched by exact ABHA number, or explicitly chosen by the
* operator from the "matching patients" confirmation modal). Only
* backfills fields that are currently blank so verified/manually-entered
* HMS data is never clobbered.
*
* @return array{patient_id:int,p_code:string,is_new:bool}
*/
private function linkAbhaToPatient(
$db,
int $patientId,
string $name,
string $mobile,
string $gender,
string $dob,
array $abhaMeta,
string $abhaNumClean,
string $abhaAddress,
array $fields,
?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 !== '' && ($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 (($updateDetails || $existingDob === '' || $existingDob === '0000-00-00') && $dob !== '') {
$dobDb = $this->normalizeDobToDb($dob);
if ($dobDb !== '') {
$existingUpdates['dob'] = $dobDb;
if (in_array('estimate_dob', $fields, true)) {
$existingUpdates['estimate_dob'] = 0;
}
}
}
$existingGenderRaw = (int) ($existing['gender'] ?? 0);
if (($updateDetails || $existingGenderRaw <= 0) && $gender !== '') {
$existingUpdates['gender'] = $this->toPatientGenderValue($gender);
}
if ($mobile !== '' && ($updateDetails || empty($existing['mphone1']))) {
$existingUpdates['mphone1'] = $mobile;
}
// Backfill ABHA field if it was empty
if ($abhaField && empty($existing[$abhaField]) && $abhaNumClean !== '') {
$existingUpdates[$abhaField] = $abhaNumClean;
}
if ($existingUpdates !== []) {
$db->table('patient_master')->where('id', $patientId)->update($existingUpdates);
}
$this->syncAbhaMetaToPatient($patientId, $abhaMeta, $abhaNumClean, $abhaAddress, $fields);
return ['patient_id' => $patientId, 'p_code' => $pCode, 'is_new' => false];
}
/**
* Repair a single unambiguous orphan "ABHA PATIENT" placeholder row created
* during a prior failed mapping. Not a "new patient" from the operator's
* perspective — this is HMS fixing its own incomplete record.
*
* @return array{patient_id:int,p_code:string,is_new:bool}
*/
private function repairPlaceholderPatient(
$db,
int $patientId,
string $name,
string $mobile,
string $gender,
string $dob,
array $abhaMeta,
string $abhaNumClean,
string $abhaAddress,
array $fields,
?string $abhaField
): array {
$repairData = [
'p_fname' => strtoupper($name !== '' ? $name : 'ABHA PATIENT'),
'mphone1' => $mobile,
'gender' => $this->toPatientGenderValue($gender),
'estimate_dob' => 1,
];
$dobDb = $this->normalizeDobToDb($dob);
if ($dobDb !== '') {
$repairData['dob'] = $dobDb;
$repairData['estimate_dob'] = 0;
}
if ($abhaField && $abhaNumClean !== '') {
$repairData[$abhaField] = $abhaNumClean;
}
if ($abhaAddress !== '' && in_array('abha_address', $fields, true)) {
$repairData['abha_address'] = $abhaAddress;
}
$this->applyAbhaMetaColumns($repairData, $abhaMeta, $fields);
if (($abhaNumClean !== '' || $abhaAddress !== '') && in_array('abdm_linked_at', $fields, true)) {
$repairData['abdm_linked_at'] = date('Y-m-d H:i:s');
}
$db->table('patient_master')->where('id', $patientId)->update($repairData);
$row = $db->table('patient_master')->select('p_code')->where('id', $patientId)->get()->getRowArray();
$pCode = (string) ($row['p_code'] ?? '');
$this->syncAbhaMetaToPatient($patientId, $abhaMeta, $abhaNumClean, $abhaAddress, $fields);
return ['patient_id' => $patientId, 'p_code' => $pCode, 'is_new' => false];
}
/**
* Create a brand-new patient_master row from an ABHA profile. Only ever
* called when the operator has explicitly chosen "Create New" from the
* matching-patients confirmation modal (or there was nothing to match).
*
* @return array{patient_id:int,p_code:string,is_new:bool}
*/
private function createPatientFromAbha(
$db,
string $name,
string $mobile,
string $gender,
string $dob,
array $abhaMeta,
string $abhaNumClean,
string $abhaAddress,
array $fields,
?string $abhaField
): array {
$genderDb = $this->toPatientGenderValue($gender);
$dobDb = $this->normalizeDobToDb($dob);
$insertData = [
'p_fname' => strtoupper($name !== '' ? $name : 'ABHA PATIENT'),
'mphone1' => $mobile,
'gender' => $genderDb,
'blood_group' => 'Not Define',
'estimate_dob' => $dobDb !== '' ? 0 : 1,
];
if ($dobDb !== '') {
$insertData['dob'] = $dobDb;
} else {
$insertData['age'] = 0;
$insertData['age_in_month'] = 0;
}
if ($abhaField && $abhaNumClean !== '') {
$insertData[$abhaField] = $abhaNumClean;
}
if ($abhaAddress !== '' && in_array('abha_address', $fields, true)) {
$insertData['abha_address'] = $abhaAddress;
}
$this->applyAbhaMetaColumns($insertData, $abhaMeta, $fields);
if (($abhaNumClean !== '' || $abhaAddress !== '') && in_array('abdm_linked_at', $fields, true)) {
$insertData['abdm_linked_at'] = date('Y-m-d H:i:s');
}
$patientModel = new \App\Models\PatientModel();
$patientId = $patientModel->insertPatient($insertData);
$pCode = '';
if ($patientId > 0) {
$row = $db->table('patient_master')->select('p_code')->where('id', $patientId)->get()->getRowArray();
$pCode = (string) ($row['p_code'] ?? '');
}
if ($patientId > 0) {
$this->syncAbhaMetaToPatient($patientId, $abhaMeta, $abhaNumClean, $abhaAddress, $fields);
}
return ['patient_id' => $patientId, 'p_code' => $pCode, 'is_new' => true];
}
private function syncAbhaMetaToPatient(int $patientId, array $abhaMeta, string $abhaNumClean, string $abhaAddress, array $fields): void
{
if ($patientId <= 0) {
return;
}
$db = \Config\Database::connect();
$updates = [];
if ($abhaAddress !== '' && in_array('abha_address', $fields, true)) {
$updates['abha_address'] = $abhaAddress;
}
$this->applyAbhaMetaColumns($updates, $abhaMeta, $fields);
if (($abhaNumClean !== '' || $abhaAddress !== '') && in_array('abdm_linked_at', $fields, true)) {
$updates['abdm_linked_at'] = date('Y-m-d H:i:s');
}
if ($updates !== []) {
$db->table('patient_master')->where('id', $patientId)->update($updates);
}
}
private function applyAbhaMetaColumns(array &$target, array $abhaMeta, array $fields): void
{
$verifiedStatus = trim((string) ($abhaMeta['verified_status'] ?? ''));
if ($verifiedStatus !== '' && in_array('abha_verified_status', $fields, true)) {
$target['abha_verified_status'] = strtoupper($verifiedStatus);
}
$verificationType = trim((string) ($abhaMeta['verification_type'] ?? ''));
if ($verificationType !== '' && in_array('abha_verification_type', $fields, true)) {
$target['abha_verification_type'] = strtoupper($verificationType);
}
if (array_key_exists('kyc_verified', $abhaMeta) && in_array('abha_kyc_verified', $fields, true)) {
$target['abha_kyc_verified'] = $this->toDbBool($abhaMeta['kyc_verified']);
}
if (array_key_exists('mobile_verified', $abhaMeta) && in_array('abha_mobile_verified', $fields, true)) {
$target['abha_mobile_verified'] = $this->toDbBool($abhaMeta['mobile_verified']);
}
$address = trim((string) ($abhaMeta['address'] ?? ''));
if ($address !== '' && in_array('add1', $fields, true)) {
$target['add1'] = $address;
}
$district = trim((string) ($abhaMeta['district'] ?? ''));
if ($district !== '' && in_array('district', $fields, true)) {
$target['district'] = strtoupper($district);
}
$state = trim((string) ($abhaMeta['state'] ?? ''));
if ($state !== '' && in_array('state', $fields, true)) {
$target['state'] = strtoupper($state);
}
$zip = trim((string) ($abhaMeta['zip'] ?? ''));
if ($zip !== '' && in_array('zip', $fields, true)) {
$target['zip'] = $zip;
}
$email = trim((string) ($abhaMeta['email'] ?? ''));
if ($email !== '' && in_array('email1', $fields, true)) {
$target['email1'] = $email;
}
$profilePhoto = trim((string) ($abhaMeta['profile_photo'] ?? ''));
if ($profilePhoto !== '' && in_array('abha_profile_photo_base64', $fields, true)) {
$target['abha_profile_photo_base64'] = $profilePhoto;
}
$cardBase64 = trim((string) ($abhaMeta['card_base64'] ?? ''));
if ($cardBase64 !== '' && in_array('abha_card_base64', $fields, true)) {
$target['abha_card_base64'] = $cardBase64;
}
}
private function toDbBool($value): int
{
if (is_bool($value)) {
return $value ? 1 : 0;
}
$v = strtolower(trim((string) $value));
if ($v === '1' || $v === 'true' || $v === 'yes' || $v === 'y') {
return 1;
}
return 0;
}
private function toPatientGenderValue(string $gender): int
{
$genderAbs = strtoupper(trim($gender));
if ($genderAbs === 'F' || $genderAbs === '2' || $genderAbs === 'FEMALE') {
return 2;
}
if ($genderAbs === 'O' || $genderAbs === '3' || $genderAbs === 'OTHER') {
return 3;
}
return 1;
}
private function normalizeDobToDb(string $dob): string
{
$dob = trim($dob);
if ($dob === '') {
return '';
}
if (preg_match('/^(\d{2})-(\d{2})-(\d{4})$/', $dob, $m)) {
return $m[3] . '-' . $m[2] . '-' . $m[1];
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $dob)) {
return $dob;
}
return '';
}
/**
* Find one safe placeholder candidate to repair.
* Conditions:
* - name is ABHA PATIENT
* - ABHA id and address are empty
* - no OPD/invoice linkage
* - inserted recently (last 24h)
* - exactly one candidate (to avoid accidental overwrite)
*
* @param array<int, string> $fields
* @return array<string, mixed>|null
*/
private function findRepairablePlaceholderPatient($db, array $fields): ?array
{
if (! $db->tableExists('patient_master')) {
return null;
}
$abhaField = null;
foreach (['abha_id', 'abha_no', 'abha'] as $f) {
if (in_array($f, $fields, true)) {
$abhaField = $f;
break;
}
}
$builder = $db->table('patient_master p')
->select('p.id,p.p_code,p.insert_date')
->where('UPPER(TRIM(COALESCE(p.p_fname, "")))', 'ABHA PATIENT')
->where('(p.mphone1 IS NULL OR TRIM(p.mphone1) = "")', null, false)
->where('p.insert_date >= DATE_SUB(NOW(), INTERVAL 24 HOUR)', null, false);
if ($abhaField !== null) {
$builder->where('(p.' . $abhaField . ' IS NULL OR TRIM(p.' . $abhaField . ') = "")', null, false);
}
if (in_array('abha_address', $fields, true)) {
$builder->where('(p.abha_address IS NULL OR TRIM(p.abha_address) = "")', null, false);
}
if ($db->tableExists('opd_master')) {
$builder->where('NOT EXISTS (SELECT 1 FROM opd_master o WHERE o.p_id = p.id)', null, false);
}
if ($db->tableExists('invoice_master')) {
$builder->where('NOT EXISTS (SELECT 1 FROM invoice_master i WHERE i.attach_type = 0 AND i.attach_id = p.id)', null, false);
}
$rows = $builder->orderBy('p.id', 'DESC')->limit(2)->get()->getResultArray();
if (count($rows) !== 1) {
return null;
}
return $rows[0];
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function pickGatewayAbhaProfile(array $payload): array
{
$account = is_array($payload['account'] ?? null) ? $payload['account'] : [];
$dataAccount = is_array($payload['data']['account'] ?? null) ? $payload['data']['account'] : [];
$loginAccount = is_array($payload['accounts'][0] ?? null) ? $payload['accounts'][0] : [];
$mergedAccount = $loginAccount;
foreach ([$dataAccount, $account] as $accountSource) {
foreach ($accountSource 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['data']['ABHAProfile'] ?? null,
$payload['gateway_abha_profile'] ?? null,
$payload['data']['gateway_abha_profile'] ?? null,
$payload['gateway_patient'] ?? null,
$payload['data']['gateway_patient'] ?? null,
$payload['profile'] ?? null,
$mergedAccount,
$account,
$loginAccount,
$payload['data']['account'] ?? null,
$payload,
];
foreach ($candidates as $candidate) {
if (is_array($candidate) && $this->looksLikeGatewayAbhaProfile($candidate)) {
return $candidate;
}
}
return [];
}
/**
* @param array<string, mixed> $profile
* @param array<string, mixed> $payload
*/
private function extractAbhaProfileName(array $profile, array $payload): string
{
foreach ([$profile, $payload, $payload['account'] ?? [], $payload['data']['account'] ?? [], $payload['gateway_abha_profile'] ?? [], $payload['gateway_patient'] ?? [], $payload['data']['gateway_patient'] ?? []] as $source) {
if (! is_array($source)) {
continue;
}
foreach (['name', 'fullName', 'full_name', 'patient_name'] as $key) {
$name = trim((string) ($source[$key] ?? ''));
if ($name !== '') {
return $name;
}
}
$parts = [];
foreach (['firstName', 'first_name', 'middleName', 'middle_name', 'lastName', 'last_name'] as $key) {
$part = trim((string) ($source[$key] ?? ''));
if ($part !== '') {
$parts[] = $part;
}
}
if ($parts !== []) {
return implode(' ', $parts);
}
}
return '';
}
/**
* @param array<string, mixed> $profile
* @param array<string, mixed> $payload
*/
private function extractAbhaProfilePhoto(array $profile, array $payload): string
{
$sources = [
$profile,
$payload,
$payload['ABHAProfile'] ?? [],
$payload['data']['ABHAProfile'] ?? [],
$payload['gateway_abha_profile'] ?? [],
$payload['data']['gateway_abha_profile'] ?? [],
$payload['gateway_patient'] ?? [],
$payload['data']['gateway_patient'] ?? [],
$payload['account'] ?? [],
$payload['data']['account'] ?? [],
];
foreach ($sources as $source) {
if (! is_array($source)) {
continue;
}
foreach (['profilePhoto', 'profile_photo', 'patient_photo', 'photo'] as $key) {
$photo = trim((string) ($source[$key] ?? ''));
if ($photo !== '') {
return $photo;
}
}
}
return '';
}
/**
* @param array<string, mixed> $profile
* @param array<string, mixed> $payload
*/
private function extractAbhaProfileMobile(array $profile, array $payload): string
{
$sources = [
$profile,
$payload,
$payload['gateway_patient'] ?? [],
$payload['data']['gateway_patient'] ?? [],
$payload['patient_profile'] ?? [],
$payload['data']['patient_profile'] ?? [],
$payload['ABHAProfile'] ?? [],
$payload['data']['ABHAProfile'] ?? [],
$payload['profile'] ?? [],
$payload['data']['profile'] ?? [],
$payload['account'] ?? [],
$payload['data']['account'] ?? [],
$payload['accounts'][0] ?? [],
$payload['data']['accounts'][0] ?? [],
$payload['gateway_abha_profile'] ?? [],
$payload['data']['gateway_abha_profile'] ?? [],
];
foreach ($sources as $source) {
if (! is_array($source)) {
continue;
}
foreach (['mobile', 'mobileNumber', 'mobile_number', 'phone', 'contact_no', 'mobile_no'] as $key) {
$raw = trim((string) ($source[$key] ?? ''));
$digits = preg_replace('/\D/', '', $raw);
if (strlen($digits) === 12 && str_starts_with($digits, '91')) {
$digits = substr($digits, 2);
}
if (strlen($digits) === 10 && preg_match('/^[6-9]\d{9}$/', $digits)) {
return $digits;
}
}
}
// Try extracting from JWT token claims
foreach ([$payload['token'] ?? null, $payload['data']['token'] ?? null, $payload['tokens']['token'] ?? null, $payload['data']['tokens']['token'] ?? null] as $candToken) {
if (is_string($candToken) && trim($candToken) !== '') {
$parts = explode('.', trim($candToken));
if (count($parts) >= 2) {
$claims = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true);
if (is_array($claims)) {
foreach (['mobile', 'phone', 'mobileNumber', 'mobile_number'] as $key) {
$raw = trim((string) ($claims[$key] ?? ''));
$digits = preg_replace('/\D/', '', $raw);
if (strlen($digits) === 12 && str_starts_with($digits, '91')) {
$digits = substr($digits, 2);
}
if (strlen($digits) === 10) {
return $digits;
}
}
}
}
}
}
return '';
}
/**
* @param array<string, mixed> $profile
* @param array<string, mixed> $payload
*/
private function extractAbhaProfileGender(array $profile, array $payload): string
{
$sources = [
$profile,
$payload,
$payload['gateway_patient'] ?? [],
$payload['data']['gateway_patient'] ?? [],
$payload['patient_profile'] ?? [],
$payload['data']['patient_profile'] ?? [],
$payload['ABHAProfile'] ?? [],
$payload['data']['ABHAProfile'] ?? [],
$payload['profile'] ?? [],
$payload['data']['profile'] ?? [],
$payload['account'] ?? [],
$payload['data']['account'] ?? [],
$payload['accounts'][0] ?? [],
$payload['data']['accounts'][0] ?? [],
];
foreach ($sources as $source) {
if (! is_array($source)) {
continue;
}
foreach (['gender', 'gender_code', 'sex'] as $key) {
$val = strtoupper(trim((string) ($source[$key] ?? '')));
if ($val === '') {
continue;
}
if ($val === 'M' || $val === '1' || str_starts_with($val, 'MALE')) {
return 'Male';
}
if ($val === 'F' || $val === '2' || str_starts_with($val, 'FEMALE')) {
return 'Female';
}
if ($val === 'O' || $val === '3' || str_starts_with($val, 'OTHER')) {
return 'Other';
}
return ucfirst(strtolower($val));
}
}
return '';
}
/**
* @param array<string, mixed> $profile
* @param array<string, mixed> $payload
*/
private function extractAbhaProfileDob(array $profile, array $payload): string
{
$sources = [
$profile,
$payload,
$payload['gateway_patient'] ?? [],
$payload['data']['gateway_patient'] ?? [],
$payload['patient_profile'] ?? [],
$payload['data']['patient_profile'] ?? [],
$payload['ABHAProfile'] ?? [],
$payload['data']['ABHAProfile'] ?? [],
$payload['profile'] ?? [],
$payload['data']['profile'] ?? [],
$payload['account'] ?? [],
$payload['data']['account'] ?? [],
$payload['accounts'][0] ?? [],
$payload['data']['accounts'][0] ?? [],
];
foreach ($sources as $source) {
if (! is_array($source)) {
continue;
}
foreach (['dob', 'date_of_birth', 'dateOfBirth', 'birthDate', 'birthdate'] as $key) {
$dob = trim((string) ($source[$key] ?? ''));
if ($dob !== '') {
if (preg_match('/^(\d{4})[-\/](\d{1,2})[-\/](\d{1,2})$/', $dob, $m)) {
return sprintf('%02d-%02d-%04d', (int) $m[3], (int) $m[2], (int) $m[1]);
}
if (preg_match('/^(\d{1,2})[-\/](\d{1,2})[-\/](\d{4})$/', $dob, $m)) {
return sprintf('%02d-%02d-%04d', (int) $m[1], (int) $m[2], (int) $m[3]);
}
return $dob;
}
}
$y = $source['yearOfBirth'] ?? $source['year_of_birth'] ?? null;
$m = $source['monthOfBirth'] ?? $source['month_of_birth'] ?? null;
$d = $source['dayOfBirth'] ?? $source['day_of_birth'] ?? null;
if ($y !== null && (int) $y > 1900) {
if ($m !== null && $d !== null && (int) $m > 0 && (int) $d > 0) {
return sprintf('%02d-%02d-%04d', (int) $d, (int) $m, (int) $y);
}
return (string) $y;
}
}
return '';
}
/**
* @param array<string, mixed> $profile
* @param array<string, mixed> $payload
* @return array{address: string, district: string, state: string, zip: string}
*/
private function extractAbhaProfileAddress(array $profile, array $payload): array
{
$sources = [
$profile,
$payload,
$payload['gateway_patient'] ?? [],
$payload['data']['gateway_patient'] ?? [],
$payload['patient_profile'] ?? [],
$payload['data']['patient_profile'] ?? [],
$payload['ABHAProfile'] ?? [],
$payload['data']['ABHAProfile'] ?? [],
$payload['profile'] ?? [],
$payload['data']['profile'] ?? [],
$payload['account'] ?? [],
$payload['data']['account'] ?? [],
];
$addr = '';
$dist = '';
$state = '';
$zip = '';
foreach ($sources as $source) {
if (! is_array($source)) {
continue;
}
if ($addr === '') {
foreach (['address', 'address_line', 'addressLine', 'villageTownCity', 'subDistrictName', 'line'] as $k) {
$v = trim((string) ($source[$k] ?? ''));
if ($v !== '') {
$addr = $v;
break;
}
}
}
if ($dist === '') {
foreach (['districtName', 'district', 'district_name'] as $k) {
$v = trim((string) ($source[$k] ?? ''));
if ($v !== '') {
$dist = $v;
break;
}
}
}
if ($state === '') {
foreach (['stateName', 'state', 'state_name'] as $k) {
$v = trim((string) ($source[$k] ?? ''));
if ($v !== '') {
$state = $v;
break;
}
}
}
if ($zip === '') {
foreach (['pincode', 'pinCode', 'pin_code', 'postalCode', 'zip'] as $k) {
$v = trim((string) ($source[$k] ?? ''));
if ($v !== '') {
$zip = $v;
break;
}
}
}
}
return [
'address' => $addr,
'district' => $dist,
'state' => $state,
'zip' => $zip,
];
}
/**
* @param array<string, mixed> $profile
*/
private function looksLikeGatewayAbhaProfile(array $profile): bool
{
foreach (['ABHANumber', 'abha_id', 'preferredAddress', 'preferredAbhaAddress', 'abha_address', 'name', 'fullName', 'full_name', 'patient_name', 'firstName', 'first_name', 'lastName', 'last_name', 'gender', 'dob', 'date_of_birth', 'profilePhoto', 'profile_photo'] as $key) {
if (array_key_exists($key, $profile) && trim((string) $profile[$key]) !== '') {
return true;
}
}
return false;
}
// -------------------------------------------------------------------------
// Validate ABHA number or address
// POST abha/register/validate
// -------------------------------------------------------------------------
public function validateAbha()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
$lookupType = strtolower(trim((string) ($this->request->getPost('lookup_type') ?? '')));
$postedNumber = trim((string) ($this->request->getPost('abha_id') ?? ''));
$postedAddress = trim((string) ($this->request->getPost('abha_address') ?? ''));
if (! in_array($lookupType, ['number', 'address'], true)) {
$lookupType = $postedAddress !== '' || str_contains($postedNumber, '@') ? 'address' : 'number';
}
$input = $lookupType === 'address' ? $postedAddress : $postedNumber;
if ($input === '') {
return $this->response->setJSON(['ok' => 0, 'error_text' => $lookupType === 'address' ? 'ABHA Address is required' : 'ABHA Number or ID is required']);
}
$isAddress = $lookupType === 'address';
$payload = $isAddress
? ['abha_address' => $input]
: ['abha_id' => preg_replace('/\D/', '', $input)];
try {
$result = AbdmConnectorFactory::make()->validateAbha('', $payload);
} catch (\Throwable $e) {
return $this->response->setStatusCode(500)->setJSON(['ok' => 0, 'error_text' => $e->getMessage()]);
}
if (empty($result['ok']) || $result['ok'] != 1) {
$errorText = '';
foreach ([
$result['error_text'] ?? null,
$result['message'] ?? null,
$result['description'] ?? null,
$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) !== '') {
$errorText = trim($candidate);
break;
}
}
$requestId = '';
foreach ([
$result['request_id'] ?? null,
$result['data']['request_id'] ?? null,
$result['result']['request_id'] ?? null,
] as $candidate) {
if (is_string($candidate) && trim($candidate) !== '') {
$requestId = trim($candidate);
break;
}
}
$entityName = $isAddress ? 'ABHA Address' : 'ABHA Number';
if (stripos($errorText, 'Unknown column') !== false ||
stripos($errorText, 'preferredAbhaAddress') !== false ||
stripos($errorText, 'not found') !== false ||
stripos($errorText, 'does not exist') !== false ||
stripos($errorText, 'ABDM gateway returned an error') !== false ||
stripos($errorText, 'Please make a valid request') !== false ||
stripos($errorText, 'Invalid Login Hint') !== false ||
stripos($errorText, 'rejected login hint') !== false ||
stripos($errorText, 'abdm_upstream_error') !== false) {
$errorText = $entityName . ' does not exist or was not found in the ABDM registry.';
} else {
if ($errorText === '') {
$errorText = 'ABHA validation failed';
}
if ($requestId !== '' && stripos($errorText, $requestId) === false) {
$errorText .= ' (Ref: ' . $requestId . ')';
}
}
return $this->response->setJSON([
'ok' => 0,
'error_text' => $errorText,
]);
}
$statusRaw = '';
foreach ([
$result['status'] ?? null,
$result['validation_status'] ?? null,
$result['data']['status'] ?? null,
$result['data']['validation_status'] ?? null,
$result['result']['status'] ?? null,
$result['data']['result']['status'] ?? null,
] as $candidate) {
if (is_string($candidate) && trim($candidate) !== '') {
$statusRaw = strtoupper(trim($candidate));
break;
}
}
// Bridge adapters may return ACTIVE/VERIFIED/EXISTS for successful ABHA checks.
$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']
?? ''
));
$aadhaarMaskedMobile = $this->maskAbhaMobile((string) (
$data['aadhaar_masked_mobile']
?? $data['aadhaarMaskedMobile']
?? $account['aadhaar_masked_mobile']
?? $account['aadhaarMaskedMobile']
?? ''
));
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,
'aadhaar_masked_mobile' => $aadhaarMaskedMobile,
'account' => (function () use ($account, $data, $result, $maskedMobile, $aadhaarMaskedMobile): array {
$resolvedAbhaNumber = '';
foreach ([
$data['healthIdNumber'] ?? null,
$data['ABHANumber'] ?? null,
$data['abhaNumber'] ?? null,
$data['abha_number'] ?? null,
$data['abha_id'] ?? null,
$account['healthIdNumber'] ?? null,
$account['ABHANumber'] ?? null,
$account['abhaNumber'] ?? null,
$account['abha_number'] ?? null,
$account['abha_id'] ?? null,
$result['healthIdNumber'] ?? null,
$result['ABHANumber'] ?? null,
$result['abhaNumber'] ?? null,
$result['abha_number'] ?? null,
$result['abha_id'] ?? null,
] as $candidateNum) {
if (is_string($candidateNum) && trim($candidateNum) !== '') {
$resolvedAbhaNumber = trim($candidateNum);
break;
}
}
$resolvedName = '';
foreach ([
$data['fullName'] ?? null,
$data['full_name'] ?? null,
$data['name'] ?? null,
$account['fullName'] ?? null,
$account['full_name'] ?? null,
$account['name'] ?? null,
$result['fullName'] ?? null,
$result['full_name'] ?? null,
$result['name'] ?? null,
] as $candidateName) {
if (is_string($candidateName) && trim($candidateName) !== '' && strcasecmp(trim($candidateName), 'ABHA User') !== 0) {
$resolvedName = trim($candidateName);
break;
}
}
if ($resolvedName === '') {
$resolvedName = trim((string) ($account['name'] ?? $data['name'] ?? $result['name'] ?? 'ABHA User'));
}
$resolvedAddress = '';
foreach ([
$data['abhaAddress'] ?? null,
$data['preferredAddress'] ?? null,
$data['preferredAbhaAddress'] ?? null,
$data['abha_address'] ?? null,
$account['abhaAddress'] ?? null,
$account['preferredAddress'] ?? null,
$account['preferredAbhaAddress'] ?? null,
$account['abha_address'] ?? null,
$result['abhaAddress'] ?? null,
$result['preferredAddress'] ?? null,
$result['preferredAbhaAddress'] ?? null,
$result['abha_address'] ?? null,
] as $candidateAddr) {
if (is_string($candidateAddr) && trim($candidateAddr) !== '') {
$resolvedAddress = trim($candidateAddr);
break;
}
}
return [
'name' => $resolvedName,
'abha_number' => $resolvedAbhaNumber,
'abha_address' => $resolvedAddress,
'masked_mobile' => $maskedMobile,
'aadhaar_masked_mobile' => $aadhaarMaskedMobile,
];
})(),
]);
}
private function maskAbhaMobile(string $value): string
{
$value = trim($value);
if ($value === '' || preg_match('/\d/', $value) !== 1) {
return '';
}
if (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) (
$result['masked_mobile']
?? $result['maskedMobile']
?? $data['masked_mobile']
?? $data['maskedMobile']
?? $data['mobile']
?? $data['mobile_number']
?? ''
));
$deliveryMessage = trim((string) (
$result['message']
?? $data['message']
?? $result['details']['message']
?? $data['details']['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()]);
}
$authResult = strtolower(trim((string) ($result['authResult'] ?? $result['data']['authResult'] ?? $result['auth_result'] ?? $result['data']['auth_result'] ?? '')));
$status = strtolower(trim((string) ($result['status'] ?? $result['data']['status'] ?? '')));
if ($authResult === 'failed' || in_array($status, ['failed', 'failure', 'error'], true)) {
$result['ok'] = 0;
}
if (empty($result['ok']) || (int) $result['ok'] !== 1) {
$httpCode = (int) ($result['http_code'] ?? 0);
if ($httpCode < 400 || $httpCode > 599) {
$httpCode = 422;
}
return $this->response->setStatusCode($httpCode)->setJSON([
'ok' => 0,
'error_text' => $this->extractBridgeErrorText($result, 'Please enter a valid OTP. Entered OTP is either expired or incorrect.'),
'request_id' => (string) ($result['request_id'] ?? ''),
]);
}
$payload = $result;
if (is_array($result['data'] ?? null)) {
foreach ($result['data'] as $key => $value) {
if (! array_key_exists($key, $payload)) {
$payload[$key] = $value;
}
}
}
$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]);
$userToken = trim((string) ($payload['token'] ?? $payload['tokens']['token'] ?? $payload['auth_token'] ?? $payload['user_token'] ?? ''));
if ($abhaNumber === '' && $abhaAddress === '' && $userToken === '') {
return $this->response->setStatusCode(422)->setJSON([
'ok' => 0,
'error_text' => $this->extractBridgeErrorText($result, 'Please enter a valid OTP. Entered OTP is either expired or incorrect.'),
'request_id' => (string) ($result['request_id'] ?? ''),
]);
}
$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['patient_photo'] ?? null, $payload['profilePhoto'] ?? null, $payload['profile_photo'] ?? null, $payload['photo'] ?? null, $payload['data']['profilePhoto'] ?? null, $payload['data']['patient_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);
$hasExactAbhaMatch = false;
$conflictPatient = null;
foreach ($candidates as $c) {
if (! empty($c['match']['abha'])) {
$hasExactAbhaMatch = true;
$conflictPatient = [
'id' => (int) $c['id'],
'p_code' => (string) ($c['p_code'] ?? ''),
'p_fname' => (string) ($c['name'] ?? ''),
'name' => (string) ($c['name'] ?? ''),
];
break;
}
}
// Direct fallback safety check via findPatientByAbha
if (! $hasExactAbhaMatch && ($abhaNumber !== '' || $abhaAddress !== '')) {
$patientController = new Patient();
$direct = $patientController->findPatientByAbha($abhaNumber !== '' ? $abhaNumber : $abhaAddress);
if ($direct !== null) {
$hasExactAbhaMatch = true;
$conflictPatient = [
'id' => (int) $direct['id'],
'p_code' => (string) ($direct['p_code'] ?? ''),
'p_fname' => (string) ($direct['p_fname'] ?? ''),
'name' => (string) ($direct['p_fname'] ?? ''),
];
array_unshift($candidates, [
'id' => (int) $direct['id'],
'p_code' => (string) ($direct['p_code'] ?? ''),
'name' => (string) ($direct['p_fname'] ?? ''),
'gender' => 0,
'gender_label' => '',
'dob' => '',
'age' => null,
'mobile' => (string) ($direct['mphone1'] ?? ''),
'photo_url' => '',
'address' => '',
'aadhaar' => (string) ($direct['udai'] ?? ''),
'abha' => (string) ($direct['abha_id'] ?? ''),
'abha_conflict' => false,
'match' => [
'name' => false,
'age' => false,
'gender' => false,
'mobile' => false,
'aadhaar' => false,
'abha' => true,
],
'score' => 1000,
]);
}
}
return $this->response->setJSON([
'ok' => 1,
'need_confirmation' => true,
'already_registered' => $hasExactAbhaMatch,
'conflict_patient' => $conflictPatient,
'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),
'card_source' => $this->resolveAbhaCardSource($payload),
'card_message' => $this->resolveAbhaCardMessage($payload),
'candidates' => $candidates,
]);
}
// -------------------------------------------------------------------------
// ABHA QR scan — POST abha/register/qr_scan
// Accepts the JSON payload printed on an ABHA card / PHR app QR code and
// returns the demographics plus possible existing HMS patients. The QR is a
// demographic capture, not an OTP authentication, so it is never marked VERIFIED.
// -------------------------------------------------------------------------
public function qrScan()
{
if (! $this->request->isAJAX()) {
return $this->response->setStatusCode(400)->setJSON(['ok' => 0, 'error_text' => 'Invalid request']);
}
$raw = trim((string) ($this->request->getPost('qr_data') ?? ''));
if ($raw === '') {
return $this->response->setJSON(['ok' => 0, 'error_text' => 'Scan or paste the ABHA QR content first.']);
}
$decoded = json_decode($raw, true);
if (! is_array($decoded)) {
// Some cards/apps encode only the ABHA number or address.
$digits = preg_replace('/\D/', '', $raw);
if (strlen($digits) === 14) {
$decoded = ['hidn' => $digits];
} elseif (str_contains($raw, '@')) {
$decoded = ['hid' => $raw];
} else {
return $this->response->setJSON([
'ok' => 0,
'error_text' => 'This QR code is not a recognised ABHA card. Scan the QR printed on the ABHA card or shown in the ABHA/PHR app.',
]);
}
}
// ABHA QR keys vary by issuer ("state name", "stateName", "state_name").
$normalized = [];
foreach ($decoded as $key => $value) {
$flatKey = strtolower(preg_replace('/[^a-z0-9]/i', '', (string) $key));
if ($flatKey !== '' && is_scalar($value)) {
$normalized[$flatKey] = trim((string) $value);
}
}
$pick = static function (array $keys) use ($normalized): string {
foreach ($keys as $key) {
if (trim((string) ($normalized[$key] ?? '')) !== '') {
return trim((string) $normalized[$key]);
}
}
return '';
};
$abhaNumber = preg_replace('/\D/', '', $pick(['hidn', 'abhanumber', 'healthidnumber', 'abhano', 'abhaid']));
$abhaAddress = $pick(['hid', 'phr', 'phraddress', 'abhaaddress', 'healthid', 'preferredabhaaddress']);
$name = $pick(['name', 'fullname', 'patientname']);
$gender = $pick(['gender', 'sex']);
$dob = $pick(['dob', 'dateofbirth', 'birthdate']);
$mobile = preg_replace('/\D/', '', $pick(['mobile', 'mobilenumber', 'phone', 'contact']));
$address = $pick(['address', 'addressline']);
$district = $pick(['districtname', 'district', 'distname']);
$state = $pick(['statename', 'state']);
$zip = $pick(['pincode', 'pin', 'postalcode', 'zip']);
if ($abhaNumber === '' && $abhaAddress === '') {
return $this->response->setJSON([
'ok' => 0,
'error_text' => 'The QR code did not contain an ABHA number or ABHA address.',
]);
}
if ($abhaNumber === '' && str_contains($abhaAddress, '@')) {
$candidateDigits = preg_replace('/\D/', '', explode('@', $abhaAddress)[0]);
if (strlen($candidateDigits) === 14) {
$abhaNumber = $candidateDigits;
}
}
$db = \Config\Database::connect();
$fields = $db->getFieldNames('patient_master') ?? [];
$abhaField = $this->resolveAbhaFieldName($fields);
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,
'verification_type' => 'QR_SCAN',
'candidates' => $this->findMatchingCandidates($db, $fields, $name, $mobile, $gender, $dob, '', $abhaField, $abhaNumber),
]);
}
private function extractAbhaCardData(array $payload): string
{
$data = is_array($payload['data'] ?? null) ? $payload['data'] : [];
$sources = [
$payload,
$data,
$payload['account'] ?? [],
$data['account'] ?? [],
$payload['profile'] ?? [],
$data['profile'] ?? [],
$payload['ABHAProfile'] ?? [],
$data['ABHAProfile'] ?? [],
$payload['gateway_patient'] ?? [],
$data['gateway_patient'] ?? [],
];
foreach ($sources as $source) {
if (! is_array($source)) { continue; }
// card_base64 first: the connector puts the resolved official card there,
// while official_card can still hold a Bridge-generated image.
foreach (['card_base64', 'official_card', 'card_data_uri', 'card_data', 'abhaCard', 'abha_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 resolveAbhaCardMessage(array $payload): string
{
$data = is_array($payload['data'] ?? null) ? $payload['data'] : [];
foreach ([
$payload['card_message'] ?? null,
$payload['message'] ?? null,
$data['message'] ?? null,
] as $candidate) {
$value = trim((string) $candidate);
if ($value !== '') {
return $value;
}
}
return '';
}
private function resolveAbhaCardSource(array $payload): string
{
$data = is_array($payload['data'] ?? null) ? $payload['data'] : [];
foreach ([
$payload['card_source'] ?? null,
$payload['source'] ?? null,
$data['card_source'] ?? null,
$data['source'] ?? null,
] as $candidate) {
$value = strtolower(trim((string) $candidate));
if ($value === '') {
continue;
}
if (str_contains($value, 'abdm')) {
return 'abdm';
}
// "none" means ABDM issued no card for this session, not a local render.
return $value === 'none' ? 'none' : 'generated';
}
return '';
}
private function resolveAbhaCardContentType(array $payload): string
{
$data = is_array($payload['data'] ?? null) ? $payload['data'] : [];
$format = strtolower(trim((string) ($payload['card_content_type'] ?? $payload['cardContentType'] ?? $payload['card_format'] ?? ($payload['account']['card_format'] ?? '') ?: ($data['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';
}
}