diff --git a/app/Commands/AbdmHiuRetry.php b/app/Commands/AbdmHiuRetry.php
new file mode 100644
index 0000000..a36add2
--- /dev/null
+++ b/app/Commands/AbdmHiuRetry.php
@@ -0,0 +1,36 @@
+ 'Maximum failed rows to retry in this run.',
+ ];
+
+ public function run(array $params)
+ {
+ $limit = (int) (CLI::getOption('limit') ?? 20);
+ if ($limit <= 0) {
+ $limit = 20;
+ }
+
+ $service = new M3HiuWorkflowService();
+ $summary = $service->retryDue($limit);
+
+ CLI::write('ABDM M3 HIU Retry Summary', 'yellow');
+ CLI::write('Processed: ' . (int) ($summary['processed'] ?? 0));
+ CLI::write('Success: ' . (int) ($summary['success'] ?? 0), 'green');
+ CLI::write('Failed: ' . (int) ($summary['failed'] ?? 0), ((int) ($summary['failed'] ?? 0) > 0 ? 'red' : 'green'));
+ CLI::write('Skipped: ' . (int) ($summary['skipped'] ?? 0));
+ }
+}
diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index 833abd0..48425d1 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -624,6 +624,18 @@ $routes->post('AbdmGateway/consent_request', 'AbdmGateway::consentRequest', ['fi
$routes->post('AbdmGateway/consent_callback', 'AbdmGateway::consentCallback');
$routes->post('AbdmGateway/consent_revoked_callback', 'AbdmGateway::consentRevokedCallback');
$routes->post('AbdmGateway/record_linked_callback', 'AbdmGateway::recordLinkedCallback');
+// ABDM M3 HIU (HMS -> Gateway)
+$routes->get('AbdmHiu', 'AbdmHiu::index', ['filter' => $abdmPermFilter]);
+$routes->get('AbdmHiu/patient_lookup', 'AbdmHiu::patientLookup', ['filter' => $abdmPermFilter]);
+$routes->get('AbdmHiu/timeline', 'AbdmHiu::timeline', ['filter' => $abdmPermFilter]);
+$routes->post('AbdmHiu/consent_request', 'AbdmHiu::consentRequest', ['filter' => $abdmPermFilter]);
+$routes->post('AbdmHiu/consent_request_status', 'AbdmHiu::consentRequestStatus', ['filter' => $abdmPermFilter]);
+$routes->post('AbdmHiu/consent_request_fetch', 'AbdmHiu::consentRequestFetch', ['filter' => $abdmPermFilter]);
+$routes->post('AbdmHiu/health_information_request', 'AbdmHiu::healthInformationRequest', ['filter' => $abdmPermFilter]);
+$routes->post('api/v3/hiu/consent/request', 'AbdmHiu::consentRequest', ['filter' => $abdmPermFilter]);
+$routes->post('api/v3/hiu/consent/request/status', 'AbdmHiu::consentRequestStatus', ['filter' => $abdmPermFilter]);
+$routes->post('api/v3/hiu/consent/request/fetch', 'AbdmHiu::consentRequestFetch', ['filter' => $abdmPermFilter]);
+$routes->post('api/v3/hiu/health-information/request', 'AbdmHiu::healthInformationRequest', ['filter' => $abdmPermFilter]);
// HMS M2 adapter endpoints (Gateway -> HMS) - keep lowercase v1 path
$routes->get('api/v1/abdm/gateway/health', 'AbdmGateway::m2GatewayHealth');
$routes->post('api/v1/abdm/gateway/discovery/care-contexts', 'AbdmGateway::m2DiscoveryCareContexts');
diff --git a/app/Controllers/AbdmHiu.php b/app/Controllers/AbdmHiu.php
new file mode 100644
index 0000000..c04210e
--- /dev/null
+++ b/app/Controllers/AbdmHiu.php
@@ -0,0 +1,265 @@
+db = \Config\Database::connect();
+ $this->service = new M3HiuWorkflowService();
+ }
+
+ public function index()
+ {
+ return view('abdm/hiu_m3');
+ }
+
+ public function consentRequest()
+ {
+ return $this->handleOperation('consent_request', 'REQUESTED');
+ }
+
+ public function consentRequestStatus()
+ {
+ return $this->handleOperation('consent_status', 'STATUS_CHECKED');
+ }
+
+ public function consentRequestFetch()
+ {
+ return $this->handleOperation('consent_fetch', 'CONSENT_FETCHED');
+ }
+
+ public function healthInformationRequest()
+ {
+ return $this->handleOperation('hi_request', 'DATA_REQUESTED');
+ }
+
+ public function timeline()
+ {
+ $filters = [
+ 'hfr_id' => trim((string) ($this->request->getGet('hfr_id') ?? '')),
+ 'consent_id' => trim((string) ($this->request->getGet('consent_id') ?? '')),
+ 'transaction_id' => trim((string) ($this->request->getGet('transaction_id') ?? '')),
+ 'abha_address' => trim((string) ($this->request->getGet('abha_address') ?? '')),
+ 'date_from' => trim((string) ($this->request->getGet('date_from') ?? '')),
+ 'date_to' => trim((string) ($this->request->getGet('date_to') ?? '')),
+ ];
+
+ $rows = $this->service->listTimeline($filters, 300);
+ return $this->response->setJSON([
+ 'ok' => 1,
+ 'count' => count($rows),
+ 'items' => $rows,
+ ]);
+ }
+
+ public function patientLookup()
+ {
+ if (! $this->db->tableExists('patient_master')) {
+ return $this->response->setStatusCode(500)->setJSON([
+ 'ok' => 0,
+ 'error' => 'patient_master table not found',
+ ]);
+ }
+
+ $q = trim((string) ($this->request->getGet('q') ?? ''));
+ if ($q !== '' && mb_strlen($q) < 2 && ! ctype_digit($q)) {
+ return $this->response->setJSON([
+ 'ok' => 1,
+ 'count' => 0,
+ 'items' => [],
+ 'note' => 'Type at least 2 characters to search.',
+ ]);
+ }
+
+ $fields = $this->db->getFieldNames('patient_master') ?? [];
+ $idCol = $this->resolveExistingColumn($fields, ['id']);
+ $uhidCol = $this->resolveExistingColumn($fields, ['p_code', 'uhid', 'uhid_no', 'patient_code', 'patient_id']);
+ $nameCol = $this->resolveExistingColumn($fields, ['p_fname', 'patient_name', 'name']);
+ $mobileCol = $this->resolveExistingColumn($fields, ['p_mobile', 'mobile', 'phone', 'contact_no']);
+ $abhaNumberCol = $this->resolveExistingColumn($fields, ['abha_id', 'abha_no', 'abha_number', 'abha']);
+ $abhaAddressCol = $this->resolveExistingColumn($fields, ['abha_address', 'abha_addr']);
+
+ if ($idCol === null) {
+ return $this->response->setStatusCode(500)->setJSON([
+ 'ok' => 0,
+ 'error' => 'patient_master.id column not found',
+ ]);
+ }
+
+ $builder = $this->db->table('patient_master p')
+ ->select('p.' . $idCol . ' AS patient_id', false)
+ ->orderBy('p.' . $idCol, 'DESC')
+ ->limit(30);
+
+ if ($uhidCol !== null) {
+ $builder->select('p.' . $uhidCol . ' AS patient_code', false);
+ } else {
+ $builder->select("'' AS patient_code", false);
+ }
+ if ($nameCol !== null) {
+ $builder->select('p.' . $nameCol . ' AS patient_name', false);
+ } else {
+ $builder->select("'' AS patient_name", false);
+ }
+ if ($mobileCol !== null) {
+ $builder->select('p.' . $mobileCol . ' AS mobile', false);
+ } else {
+ $builder->select("'' AS mobile", false);
+ }
+ if ($abhaNumberCol !== null) {
+ $builder->select('p.' . $abhaNumberCol . ' AS abha_number', false);
+ } else {
+ $builder->select("'' AS abha_number", false);
+ }
+ if ($abhaAddressCol !== null) {
+ $builder->select('p.' . $abhaAddressCol . ' AS abha_address', false);
+ } else {
+ $builder->select("'' AS abha_address", false);
+ }
+ if (in_array('log', $fields, true)) {
+ $builder->select('p.log AS patient_log', false);
+ } else {
+ $builder->select("'' AS patient_log", false);
+ }
+
+ if ($q !== '') {
+ $builder->groupStart();
+ if ($nameCol !== null) {
+ $builder->like('p.' . $nameCol, $q);
+ }
+ if ($uhidCol !== null) {
+ $builder->orLike('p.' . $uhidCol, $q);
+ }
+ if ($mobileCol !== null) {
+ $builder->orLike('p.' . $mobileCol, $q);
+ }
+ if ($abhaNumberCol !== null) {
+ $builder->orLike('p.' . $abhaNumberCol, $q);
+ }
+ if ($abhaAddressCol !== null) {
+ $builder->orLike('p.' . $abhaAddressCol, $q);
+ }
+ if (ctype_digit($q)) {
+ $builder->orWhere('p.' . $idCol, (int) $q);
+ }
+ $builder->groupEnd();
+ }
+
+ $rows = $builder->get()->getResultArray();
+ $items = [];
+ foreach ($rows as $row) {
+ $abhaNumber = trim((string) ($row['abha_number'] ?? ''));
+ $abhaAddress = trim((string) ($row['abha_address'] ?? ''));
+ if ($abhaAddress === '') {
+ $abhaAddress = $this->extractAbhaAddressFromLog((string) ($row['patient_log'] ?? ''));
+ }
+ $latest = $this->findLatestConsentByAbhaAddress($abhaAddress);
+
+ $items[] = [
+ 'patient_id' => (int) ($row['patient_id'] ?? 0),
+ 'patient_code' => trim((string) ($row['patient_code'] ?? '')),
+ 'patient_name' => trim((string) ($row['patient_name'] ?? '')),
+ 'mobile' => trim((string) ($row['mobile'] ?? '')),
+ 'abha_number' => $abhaNumber,
+ 'abha_address' => $abhaAddress,
+ 'has_abha_number' => preg_match('/^\d{14}$/', $abhaNumber) === 1 ? 1 : 0,
+ 'has_abha_address' => $abhaAddress !== '' ? 1 : 0,
+ 'latest_consent' => $latest,
+ ];
+ }
+
+ return $this->response->setJSON([
+ 'ok' => 1,
+ 'count' => count($items),
+ 'items' => $items,
+ ]);
+ }
+
+ private function handleOperation(string $operation, string $successState)
+ {
+ $payload = $this->request->getJSON(true);
+ if (! is_array($payload)) {
+ $payload = $this->request->getPost() ?? [];
+ }
+
+ $result = $this->service->runOperation($operation, $payload);
+ $ok = (int) ($result['ok'] ?? 0) === 1;
+ $httpCode = (int) ($result['http_code'] ?? ($ok ? 200 : 400));
+ if ($httpCode < 100 || $httpCode > 599) {
+ $httpCode = $ok ? 200 : 400;
+ }
+
+ $requestId = (string) ($result['request_id'] ?? $result['requestId'] ?? $payload['request_id'] ?? $payload['requestId'] ?? '');
+ if ($ok) {
+ return $this->response->setStatusCode($httpCode)->setJSON([
+ 'ok' => 1,
+ 'request_id' => $requestId,
+ 'http_code' => $httpCode,
+ 'data' => $result,
+ 'workflow_state' => $successState,
+ 'duplicate' => (int) ($result['duplicate'] ?? 0),
+ ]);
+ }
+
+ return $this->response->setStatusCode($httpCode)->setJSON([
+ 'ok' => 0,
+ 'error' => (string) ($result['error_text'] ?? $result['message'] ?? 'HIU operation failed'),
+ 'request_id' => $requestId,
+ 'http_code' => $httpCode,
+ 'retryable' => (int) ($result['retryable'] ?? 0),
+ 'workflow_state' => 'FAILED',
+ 'data' => $result,
+ ]);
+ }
+
+ private function findLatestConsentByAbhaAddress(string $abhaAddress): ?array
+ {
+ if ($abhaAddress === '' || ! $this->db->tableExists('abdm_hiu_workflows')) {
+ return null;
+ }
+
+ $row = $this->db->table('abdm_hiu_workflows')
+ ->select('id, operation, workflow_state, status, consent_id, request_id, transaction_id, created_at, updated_at')
+ ->where('abha_address', $abhaAddress)
+ ->whereIn('operation', ['consent_request', 'consent_status', 'consent_fetch'])
+ ->orderBy('id', 'DESC')
+ ->get(1)
+ ->getRowArray();
+
+ return ! empty($row) ? $row : null;
+ }
+
+ private function resolveExistingColumn(array $fields, array $candidates): ?string
+ {
+ foreach ($candidates as $candidate) {
+ if (in_array($candidate, $fields, true)) {
+ return $candidate;
+ }
+ }
+
+ return null;
+ }
+
+ private function extractAbhaAddressFromLog(string $logText): string
+ {
+ if ($logText === '') {
+ return '';
+ }
+
+ if (preg_match('/abha_address\s*:\s*([A-Za-z0-9._-]+@[A-Za-z0-9.-]+)/i', $logText, $m) === 1) {
+ return trim((string) ($m[1] ?? ''));
+ }
+
+ return '';
+ }
+}
diff --git a/app/Controllers/Patient.php b/app/Controllers/Patient.php
index a29e9d9..9df1f65 100644
--- a/app/Controllers/Patient.php
+++ b/app/Controllers/Patient.php
@@ -2063,7 +2063,7 @@ class Patient extends BaseController
}
}
- if (in_array('abha_address', $fields, true)) {
+ if (in_array('abha_address', $fields, true) && $abhaAddress !== '') {
$data['abha_address'] = $abhaAddress;
}
}
diff --git a/app/Database/Migrations/2026-07-01-000701_CreateAbdmM3HiuWorkflows.php b/app/Database/Migrations/2026-07-01-000701_CreateAbdmM3HiuWorkflows.php
new file mode 100644
index 0000000..55845ab
--- /dev/null
+++ b/app/Database/Migrations/2026-07-01-000701_CreateAbdmM3HiuWorkflows.php
@@ -0,0 +1,135 @@
+db->tableExists('abdm_hiu_workflows')) {
+ return;
+ }
+
+ $this->forge->addField([
+ 'id' => [
+ 'type' => 'BIGINT',
+ 'constraint' => 20,
+ 'unsigned' => true,
+ 'auto_increment' => true,
+ ],
+ 'hospital_id' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 30,
+ 'null' => true,
+ ],
+ 'hfr_id' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 30,
+ 'null' => false,
+ ],
+ 'abha_address' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 120,
+ 'null' => true,
+ ],
+ 'consent_id' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 160,
+ 'null' => true,
+ ],
+ 'request_id' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 160,
+ 'null' => true,
+ ],
+ 'transaction_id' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 160,
+ 'null' => true,
+ ],
+ 'operation' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 40,
+ 'null' => false,
+ 'comment' => 'consent_request|consent_status|consent_fetch|hi_request',
+ ],
+ 'workflow_state' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 40,
+ 'default' => 'CREATED',
+ ],
+ 'status' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 20,
+ 'default' => 'created',
+ ],
+ 'http_code' => [
+ 'type' => 'INT',
+ 'constraint' => 11,
+ 'null' => true,
+ ],
+ 'is_retryable' => [
+ 'type' => 'TINYINT',
+ 'constraint' => 1,
+ 'default' => 0,
+ ],
+ 'retry_count' => [
+ 'type' => 'INT',
+ 'constraint' => 11,
+ 'default' => 0,
+ ],
+ 'next_retry_at' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ ],
+ 'last_error' => [
+ 'type' => 'TEXT',
+ 'null' => true,
+ ],
+ 'request_json' => [
+ 'type' => 'LONGTEXT',
+ 'null' => true,
+ ],
+ 'response_json' => [
+ 'type' => 'LONGTEXT',
+ 'null' => true,
+ ],
+ 'created_at' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ ],
+ 'updated_at' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ ],
+ 'completed_at' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ ],
+ 'expired_at' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ ],
+ 'revoked_at' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ ],
+ ]);
+
+ $this->forge->addKey('id', true);
+ $this->forge->addUniqueKey(['request_id', 'operation'], 'uniq_abdm_hiu_req_op');
+ $this->forge->addUniqueKey(['transaction_id', 'operation'], 'uniq_abdm_hiu_txn_op');
+ $this->forge->addKey(['hfr_id', 'created_at'], false, false, 'idx_abdm_hiu_hfr_created');
+ $this->forge->addKey(['consent_id', 'created_at'], false, false, 'idx_abdm_hiu_consent_created');
+ $this->forge->addKey(['status', 'next_retry_at'], false, false, 'idx_abdm_hiu_retry');
+ $this->forge->addKey(['workflow_state', 'created_at'], false, false, 'idx_abdm_hiu_state_created');
+ $this->forge->createTable('abdm_hiu_workflows', true);
+ }
+
+ public function down(): void
+ {
+ $this->forge->dropTable('abdm_hiu_workflows', true);
+ }
+}
diff --git a/app/Libraries/Abdm/M3HiuGatewayClient.php b/app/Libraries/Abdm/M3HiuGatewayClient.php
new file mode 100644
index 0000000..b821567
--- /dev/null
+++ b/app/Libraries/Abdm/M3HiuGatewayClient.php
@@ -0,0 +1,214 @@
+db = \Config\Database::connect();
+ $this->baseUrl = rtrim((string) (getenv('EATRIA_BRIDGE_URL') ?: 'https://abdm-bridge.e-atria.in/api'), '/');
+ }
+
+ public function consentRequest(array $payload): array
+ {
+ return $this->call('/v3/hiu/consent/request', $payload, 'consent.request');
+ }
+
+ public function consentRequestStatus(array $payload): array
+ {
+ return $this->call('/v3/hiu/consent/request/status', $payload, 'consent.request.status');
+ }
+
+ public function consentRequestFetch(array $payload): array
+ {
+ return $this->call('/v3/hiu/consent/request/fetch', $payload, 'consent.request.fetch');
+ }
+
+ public function healthInformationRequest(array $payload): array
+ {
+ return $this->call('/v3/hiu/health-information/request', $payload, 'health-information.request');
+ }
+
+ private function call(string $path, array $payload, string $eventKey): array
+ {
+ $settings = $this->resolveRuntimeSettings();
+ if (($settings['ok'] ?? 0) !== 1) {
+ return $settings;
+ }
+
+ $token = (string) ($settings['token'] ?? '');
+ $hfrId = (string) ($settings['hfr_id'] ?? '');
+ $hospitalId = (string) ($settings['hospital_id'] ?? '');
+
+ $payloadHfrId = trim((string) ($payload['hfr_id'] ?? ''));
+ if ($payloadHfrId !== '' && strcasecmp($payloadHfrId, $hfrId) !== 0) {
+ return [
+ 'ok' => 0,
+ 'http_code' => 400,
+ 'error_text' => 'hfr_id mismatch: payload hfr_id does not match hospital_setting.ABDM_HFR_ID',
+ 'retryable' => 0,
+ ];
+ }
+ $payload['hfr_id'] = $hfrId;
+
+ $url = $this->baseUrl . $path;
+ $headers = [
+ 'Content-Type: application/json',
+ 'Accept: application/json',
+ 'Authorization: Bearer ' . $token,
+ ];
+ if ($hospitalId !== '') {
+ $headers[] = 'X-Hospital-Id: ' . $hospitalId;
+ }
+
+ $ch = curl_init();
+ curl_setopt_array($ch, [
+ CURLOPT_URL => $url,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TIMEOUT => $this->timeoutSec,
+ CURLOPT_SSL_VERIFYPEER => true,
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_CUSTOMREQUEST => 'POST',
+ CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+ ]);
+
+ $raw = curl_exec($ch);
+ $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $curlErr = curl_error($ch);
+ curl_close($ch);
+
+ $decoded = json_decode((string) $raw, true);
+ $isJson = is_array($decoded);
+ $ok = $isJson ? (int) ($decoded['ok'] ?? ($httpCode >= 200 && $httpCode < 300 ? 1 : 0)) : (int) ($httpCode >= 200 && $httpCode < 300);
+
+ $errorText = '';
+ if ($curlErr !== '') {
+ $errorText = $curlErr;
+ } elseif ($ok !== 1) {
+ $errorText = $isJson
+ ? (string) ($decoded['message'] ?? $decoded['error_text'] ?? $decoded['error'] ?? ('HTTP ' . $httpCode))
+ : ('HTTP ' . $httpCode . ' non-JSON response');
+ }
+
+ $retryable = 0;
+ if ($curlErr !== '' || $httpCode >= 500 || $httpCode === 0 || str_contains(strtolower($errorText), 'timed out')) {
+ $retryable = 1;
+ }
+
+ $response = $isJson ? $decoded : ['raw' => (string) $raw];
+ $response['ok'] = $ok;
+ $response['http_code'] = $httpCode;
+ $response['error_text'] = $errorText;
+ $response['retryable'] = $retryable;
+ if (! isset($response['request_id'])) {
+ $response['request_id'] = (string) ($response['requestId'] ?? $payload['requestId'] ?? $payload['request_id'] ?? '');
+ }
+ if (! isset($response['transaction_id'])) {
+ $response['transaction_id'] = (string) ($response['transactionId'] ?? $payload['transactionId'] ?? $payload['transaction_id'] ?? '');
+ }
+ if (! isset($response['consent_id'])) {
+ $response['consent_id'] = (string) ($response['consentId'] ?? $response['consentRequestId'] ?? $payload['consentId'] ?? $payload['consentRequestId'] ?? $payload['consent_id'] ?? '');
+ }
+
+ $this->writeApiLog($eventKey, $path, $payload, $response, $httpCode, $ok, $errorText, $hospitalId);
+
+ return $response;
+ }
+
+ private function resolveRuntimeSettings(): array
+ {
+ if (! $this->db->tableExists('hospital_setting')) {
+ return ['ok' => 0, 'http_code' => 500, 'error_text' => 'hospital_setting table not found', 'retryable' => 0];
+ }
+
+ $rows = $this->db->table('hospital_setting')
+ ->select('s_name, s_value')
+ ->whereIn('s_name', ['EATRIA_BRIDGE_TOKEN', 'ABDM_HFR_ID', 'ABDM_HOSPITAL_ID'])
+ ->get()
+ ->getResultArray();
+
+ $kv = [];
+ foreach ($rows as $row) {
+ $key = trim((string) ($row['s_name'] ?? ''));
+ if ($key === '') {
+ continue;
+ }
+ $kv[$key] = trim((string) ($row['s_value'] ?? ''));
+ }
+
+ $token = (string) ($kv['EATRIA_BRIDGE_TOKEN'] ?? '');
+ $hfrId = (string) ($kv['ABDM_HFR_ID'] ?? '');
+ $hospitalId = (string) ($kv['ABDM_HOSPITAL_ID'] ?? '');
+
+ if ($token === '') {
+ return [
+ 'ok' => 0,
+ 'http_code' => 401,
+ 'error_text' => 'Missing token: set hospital_setting.EATRIA_BRIDGE_TOKEN',
+ 'retryable' => 0,
+ ];
+ }
+ if ($hfrId === '') {
+ return [
+ 'ok' => 0,
+ 'http_code' => 400,
+ 'error_text' => 'Missing hfr_id: set hospital_setting.ABDM_HFR_ID',
+ 'retryable' => 0,
+ ];
+ }
+
+ return [
+ 'ok' => 1,
+ 'token' => $token,
+ 'hfr_id' => $hfrId,
+ 'hospital_id' => $hospitalId,
+ ];
+ }
+
+ private function writeApiLog(
+ string $eventKey,
+ string $path,
+ array $request,
+ array $response,
+ int $httpCode,
+ int $ok,
+ string $errorText,
+ string $hospitalId
+ ): void {
+ if (! $this->db->tableExists('abdm_api_logs')) {
+ return;
+ }
+
+ $status = $ok === 1 ? 'success' : 'error';
+ $entityId = trim((string) (
+ $request['consent_id']
+ ?? $request['consentId']
+ ?? $request['consentRequestId']
+ ?? $request['request_id']
+ ?? $request['requestId']
+ ?? $request['transaction_id']
+ ?? $request['transactionId']
+ ?? ''
+ ));
+
+ $this->db->table('abdm_api_logs')->insert([
+ 'channel' => 'eatria_bridge',
+ 'event_type' => 'abdm.m3.hiu.' . $eventKey,
+ 'endpoint' => $this->baseUrl . $path,
+ 'http_method' => 'POST',
+ 'entity_type' => 'abdm_hiu',
+ 'entity_id' => $entityId,
+ 'request_json' => (string) json_encode($request, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+ 'response_code' => $httpCode,
+ 'response_json' => (string) json_encode($response, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+ 'status' => $status,
+ 'error_message' => $errorText !== '' ? mb_substr($errorText, 0, 1000) : null,
+ 'created_at' => date('Y-m-d H:i:s'),
+ ]);
+ }
+}
diff --git a/app/Libraries/Abdm/M3HiuWorkflowService.php b/app/Libraries/Abdm/M3HiuWorkflowService.php
new file mode 100644
index 0000000..f935034
--- /dev/null
+++ b/app/Libraries/Abdm/M3HiuWorkflowService.php
@@ -0,0 +1,332 @@
+db = \Config\Database::connect();
+ $this->client = new M3HiuGatewayClient();
+ }
+
+ public function runOperation(string $operation, array $payload): array
+ {
+ $hasWorkflowTable = $this->db->tableExists('abdm_hiu_workflows');
+
+ $normalized = $this->normalizePayload($payload);
+ if ($hasWorkflowTable && $operation !== 'consent_status') {
+ $duplicate = $this->findDuplicate($operation, $normalized);
+ if ($duplicate !== null) {
+ $dupResponse = json_decode((string) ($duplicate['response_json'] ?? ''), true);
+ if (is_array($dupResponse) && ! empty($dupResponse)) {
+ $dupResponse['duplicate'] = 1;
+ $dupResponse['workflow_state'] = (string) ($duplicate['workflow_state'] ?? '');
+ return $dupResponse;
+ }
+ }
+ }
+
+ $result = $this->dispatch($operation, $normalized);
+ $httpCode = (int) ($result['http_code'] ?? 0);
+ $ok = (int) ($result['ok'] ?? 0) === 1;
+
+ $state = $this->resolveState($operation, $result);
+ $status = $ok ? 'success' : 'failed';
+ $retryable = (int) ($result['retryable'] ?? 0) === 1;
+
+ if ($hasWorkflowTable) {
+ $this->upsertWorkflow($operation, $normalized, $result, $state, $status, $httpCode, $retryable);
+ $result['workflow_persisted'] = 1;
+ } else {
+ $result['workflow_persisted'] = 0;
+ $result['workflow_warning'] = 'abdm_hiu_workflows table not found; operation executed without timeline persistence';
+ }
+
+ return $result;
+ }
+
+ public function retryDue(int $limit = 20): array
+ {
+ if (! $this->db->tableExists('abdm_hiu_workflows')) {
+ return ['processed' => 0, 'success' => 0, 'failed' => 0, 'skipped' => 0];
+ }
+
+ $rows = $this->db->table('abdm_hiu_workflows')
+ ->select('*')
+ ->where('status', 'failed')
+ ->where('is_retryable', 1)
+ ->where('retry_count <', 3)
+ ->groupStart()
+ ->where('next_retry_at IS NULL', null, false)
+ ->orWhere('next_retry_at <=', Time::now('Asia/Kolkata')->toDateTimeString())
+ ->groupEnd()
+ ->orderBy('id', 'ASC')
+ ->get($limit)
+ ->getResultArray();
+
+ $summary = ['processed' => 0, 'success' => 0, 'failed' => 0, 'skipped' => 0];
+
+ foreach ($rows as $row) {
+ $summary['processed']++;
+ $operation = trim((string) ($row['operation'] ?? ''));
+ $payload = json_decode((string) ($row['request_json'] ?? ''), true);
+ if (! is_array($payload) || $operation === '') {
+ $summary['skipped']++;
+ continue;
+ }
+
+ $result = $this->dispatch($operation, $payload);
+ $ok = (int) ($result['ok'] ?? 0) === 1;
+ $state = $this->resolveState($operation, $result);
+ $status = $ok ? 'success' : 'failed';
+ $retryable = (int) ($result['retryable'] ?? 0) === 1;
+
+ $this->upsertWorkflow($operation, $payload, $result, $state, $status, (int) ($result['http_code'] ?? 0), $retryable, (int) ($row['id'] ?? 0));
+
+ if ($ok) {
+ $summary['success']++;
+ } else {
+ $summary['failed']++;
+ }
+ }
+
+ return $summary;
+ }
+
+ public function listTimeline(array $filters, int $limit = 200): array
+ {
+ if (! $this->db->tableExists('abdm_hiu_workflows')) {
+ return [];
+ }
+
+ $builder = $this->db->table('abdm_hiu_workflows')->select('*')->orderBy('id', 'DESC');
+
+ $hfrId = trim((string) ($filters['hfr_id'] ?? ''));
+ if ($hfrId !== '') {
+ $builder->where('hfr_id', $hfrId);
+ }
+ $consentId = trim((string) ($filters['consent_id'] ?? ''));
+ if ($consentId !== '') {
+ $builder->where('consent_id', $consentId);
+ }
+ $transactionId = trim((string) ($filters['transaction_id'] ?? ''));
+ if ($transactionId !== '') {
+ $builder->where('transaction_id', $transactionId);
+ }
+ $abhaAddress = trim((string) ($filters['abha_address'] ?? ''));
+ if ($abhaAddress !== '') {
+ $builder->where('abha_address', $abhaAddress);
+ }
+ $from = trim((string) ($filters['date_from'] ?? ''));
+ if ($from !== '') {
+ $builder->where('created_at >=', $from . ' 00:00:00');
+ }
+ $to = trim((string) ($filters['date_to'] ?? ''));
+ if ($to !== '') {
+ $builder->where('created_at <=', $to . ' 23:59:59');
+ }
+
+ return $builder->get($limit)->getResultArray();
+ }
+
+ private function dispatch(string $operation, array $payload): array
+ {
+ return match ($operation) {
+ 'consent_request' => $this->client->consentRequest($payload),
+ 'consent_status' => $this->client->consentRequestStatus($payload),
+ 'consent_fetch' => $this->client->consentRequestFetch($payload),
+ 'hi_request' => $this->client->healthInformationRequest($payload),
+ default => ['ok' => 0, 'http_code' => 400, 'error_text' => 'Unsupported operation: ' . $operation, 'retryable' => 0],
+ };
+ }
+
+ private function normalizePayload(array $payload): array
+ {
+ $normalized = $payload;
+
+ // Accept both legacy snake_case and guide-defined camelCase keys.
+ if (! isset($normalized['request_id']) && isset($normalized['requestId'])) {
+ $normalized['request_id'] = (string) $normalized['requestId'];
+ }
+ if (! isset($normalized['transaction_id']) && isset($normalized['transactionId'])) {
+ $normalized['transaction_id'] = (string) $normalized['transactionId'];
+ }
+ if (! isset($normalized['consent_id']) && isset($normalized['consentId'])) {
+ $normalized['consent_id'] = (string) $normalized['consentId'];
+ }
+ if (! isset($normalized['consent_id']) && isset($normalized['consentRequestId'])) {
+ $normalized['consent_id'] = (string) $normalized['consentRequestId'];
+ }
+
+ if (isset($normalized['abha_id']) && ! isset($normalized['abha_address'])) {
+ $normalized['abha_address'] = (string) $normalized['abha_id'];
+ }
+
+ foreach (['request_id', 'transaction_id', 'consent_id', 'abha_address', 'hfr_id'] as $k) {
+ if (! isset($normalized[$k])) {
+ $normalized[$k] = '';
+ }
+ $normalized[$k] = trim((string) $normalized[$k]);
+ }
+
+ if ($normalized['request_id'] === '') {
+ $normalized['request_id'] = 'REQ-HIU-' . date('YmdHis') . '-' . strtoupper(bin2hex(random_bytes(3)));
+ }
+
+ if (! isset($normalized['requestId']) || trim((string) $normalized['requestId']) === '') {
+ $normalized['requestId'] = $normalized['request_id'];
+ }
+ if (! isset($normalized['timestamp']) || trim((string) $normalized['timestamp']) === '') {
+ $normalized['timestamp'] = gmdate('Y-m-d\TH:i:s.000\Z');
+ }
+ if ($normalized['transaction_id'] !== '' && (! isset($normalized['transactionId']) || trim((string) $normalized['transactionId']) === '')) {
+ $normalized['transactionId'] = $normalized['transaction_id'];
+ }
+ if ($normalized['consent_id'] !== '' && (! isset($normalized['consentId']) || trim((string) $normalized['consentId']) === '')) {
+ $normalized['consentId'] = $normalized['consent_id'];
+ }
+
+ return $normalized;
+ }
+
+ private function resolveState(string $operation, array $result): string
+ {
+ $ok = (int) ($result['ok'] ?? 0) === 1;
+ if (! $ok) {
+ return 'FAILED';
+ }
+
+ $statusStr = strtolower(trim((string) ($result['status'] ?? $result['consent_status'] ?? '')));
+ if (in_array($statusStr, ['revoked', 'denied'], true)) {
+ return 'REVOKED';
+ }
+ if (in_array($statusStr, ['expired'], true)) {
+ return 'EXPIRED';
+ }
+
+ return match ($operation) {
+ 'consent_request' => 'REQUESTED',
+ 'consent_status' => 'STATUS_CHECKED',
+ 'consent_fetch' => 'CONSENT_FETCHED',
+ 'hi_request' => ($statusStr === 'completed' ? 'COMPLETED' : 'DATA_REQUESTED'),
+ default => 'COMPLETED',
+ };
+ }
+
+ private function findDuplicate(string $operation, array $payload): ?array
+ {
+ $requestId = trim((string) ($payload['request_id'] ?? ''));
+ $transactionId = trim((string) ($payload['transaction_id'] ?? ''));
+
+ $builder = $this->db->table('abdm_hiu_workflows')->select('*')->where('operation', $operation);
+ if ($requestId !== '') {
+ $row = $builder->where('request_id', $requestId)->orderBy('id', 'DESC')->get(1)->getRowArray();
+ if (! empty($row)) {
+ return $row;
+ }
+ }
+
+ if ($transactionId !== '') {
+ $row = $this->db->table('abdm_hiu_workflows')
+ ->select('*')
+ ->where('operation', $operation)
+ ->where('transaction_id', $transactionId)
+ ->orderBy('id', 'DESC')
+ ->get(1)
+ ->getRowArray();
+ if (! empty($row)) {
+ return $row;
+ }
+ }
+
+ return null;
+ }
+
+ private function upsertWorkflow(
+ string $operation,
+ array $payload,
+ array $result,
+ string $state,
+ string $status,
+ int $httpCode,
+ bool $retryable,
+ int $forceId = 0
+ ): void {
+ $now = Time::now('Asia/Kolkata')->toDateTimeString();
+ $requestId = trim((string) ($payload['request_id'] ?? $result['request_id'] ?? ''));
+ $transactionId = trim((string) ($payload['transaction_id'] ?? $result['transaction_id'] ?? ''));
+ $consentId = trim((string) ($payload['consent_id'] ?? $result['consent_id'] ?? ''));
+ $abhaAddress = trim((string) ($payload['abha_address'] ?? $payload['abha_id'] ?? $result['abha_address'] ?? ''));
+ $hfrId = trim((string) ($payload['hfr_id'] ?? $result['hfr_id'] ?? ''));
+ $hospitalId = trim((string) ($payload['hospital_id'] ?? $result['hospital_id'] ?? ''));
+ $errorText = trim((string) ($result['error_text'] ?? $result['message'] ?? ''));
+
+ $retryCount = 0;
+ $id = $forceId;
+ if ($id <= 0) {
+ $existing = $this->findDuplicate($operation, ['request_id' => $requestId, 'transaction_id' => $transactionId]);
+ if (! empty($existing)) {
+ $id = (int) ($existing['id'] ?? 0);
+ $retryCount = (int) ($existing['retry_count'] ?? 0);
+ }
+ } else {
+ $existing = $this->db->table('abdm_hiu_workflows')->where('id', $id)->get(1)->getRowArray();
+ $retryCount = (int) ($existing['retry_count'] ?? 0);
+ }
+
+ if ($status === 'failed' && $retryable) {
+ $retryCount++;
+ }
+
+ $nextRetryAt = null;
+ if ($status === 'failed' && $retryable && $retryCount < 4) {
+ $backoffMinutes = min(30, 1 * (2 ** max(0, $retryCount - 1)));
+ $nextRetryAt = date('Y-m-d H:i:s', strtotime('+' . $backoffMinutes . ' minutes'));
+ }
+
+ $row = [
+ 'hospital_id' => $hospitalId !== '' ? $hospitalId : null,
+ 'hfr_id' => $hfrId !== '' ? $hfrId : 'UNKNOWN',
+ 'abha_address' => $abhaAddress !== '' ? $abhaAddress : null,
+ 'consent_id' => $consentId !== '' ? $consentId : null,
+ 'request_id' => $requestId !== '' ? $requestId : null,
+ 'transaction_id' => $transactionId !== '' ? $transactionId : null,
+ 'operation' => $operation,
+ 'workflow_state' => $state,
+ 'status' => $status,
+ 'http_code' => $httpCode > 0 ? $httpCode : null,
+ 'is_retryable' => $retryable ? 1 : 0,
+ 'retry_count' => $retryCount,
+ 'next_retry_at' => $nextRetryAt,
+ 'last_error' => $errorText !== '' ? mb_substr($errorText, 0, 2000) : null,
+ 'request_json' => (string) json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+ 'response_json' => (string) json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
+ 'updated_at' => $now,
+ ];
+
+ if (in_array($state, ['COMPLETED', 'CONSENT_FETCHED'], true)) {
+ $row['completed_at'] = $now;
+ }
+ if ($state === 'EXPIRED') {
+ $row['expired_at'] = $now;
+ }
+ if ($state === 'REVOKED') {
+ $row['revoked_at'] = $now;
+ }
+
+ if ($id > 0) {
+ $this->db->table('abdm_hiu_workflows')->where('id', $id)->update($row);
+ return;
+ }
+
+ $row['created_at'] = $now;
+ $this->db->table('abdm_hiu_workflows')->insert($row);
+ }
+}
diff --git a/app/Views/abdm/hiu_m3.php b/app/Views/abdm/hiu_m3.php
new file mode 100644
index 0000000..eec48c6
--- /dev/null
+++ b/app/Views/abdm/hiu_m3.php
@@ -0,0 +1,400 @@
+
+
+
+
+
+
+
+ Search patient first. Consent Request is enabled only when ABHA Number and ABHA Address are both present.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Action |
+ Patient |
+ UHID |
+ ABHA Number |
+ ABHA Address |
+ Last Consent State |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ Time |
+ Operation |
+ State |
+ Status |
+ HFR |
+ ABHA Address |
+ Consent |
+ Txn |
+ Request |
+ Error |
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/billing/Person_profile_V.php b/app/Views/billing/Person_profile_V.php
index f93a7bb..46b9943 100644
--- a/app/Views/billing/Person_profile_V.php
+++ b/app/Views/billing/Person_profile_V.php
@@ -16,6 +16,9 @@
abha_id ?? $data[0]->abha_no ?? $data[0]->abha ?? $data[0]->abha_address ?? '');
$abhaAddress = trim((string) ($data[0]->abha_address ?? ''));
+ if ($abhaAddress === '' && preg_match('/abha_address\s*:\s*([A-Za-z0-9._-]+@[A-Za-z0-9.-]+)/i', (string) ($data[0]->log ?? ''), $abhaLogMatch) === 1) {
+ $abhaAddress = trim((string) ($abhaLogMatch[1] ?? ''));
+ }
$abhaVerifiedStatus = trim((string) ($data[0]->abha_verified_status ?? ''));
$abhaVerificationType = trim((string) ($data[0]->abha_verification_type ?? ''));
$abhaKycVerified = (int) ($data[0]->abha_kyc_verified ?? 0) === 1;
diff --git a/app/Views/partials/sidebar.php b/app/Views/partials/sidebar.php
index 022460f..9ad389e 100644
--- a/app/Views/partials/sidebar.php
+++ b/app/Views/partials/sidebar.php
@@ -408,6 +408,12 @@ if (! $canChargesSettings && $user && method_exists($user, 'inGroup')) {
ABDM Task Board
+
+
+
+ ABDM HIU M3
+
+
diff --git a/docs/ABDM_M3_HIU_QA_REPORT.md b/docs/ABDM_M3_HIU_QA_REPORT.md
new file mode 100644
index 0000000..76a736b
--- /dev/null
+++ b/docs/ABDM_M3_HIU_QA_REPORT.md
@@ -0,0 +1,81 @@
+# ABDM M3 HIU QA Report
+
+## Build Info
+- Date:
+- Branch/Commit:
+- Tester:
+
+## Test Matrix
+
+### 1. Happy path
+- request -> status -> fetch -> health-information request
+- Expected: workflow reaches COMPLETED
+- Result:
+- Evidence:
+
+### 2. Missing token
+- Remove/blank EATRIA_BRIDGE_TOKEN
+- Expected: unauthorized/fail-fast error, no gateway call
+- Result:
+- Evidence:
+
+### 3. Missing hfr_id
+- Remove/blank ABDM_HFR_ID
+- Expected: validation fail-fast
+- Result:
+- Evidence:
+
+### 4. Wrong hfr_id/token mismatch
+- Send payload hfr_id that mismatches configured ABDM_HFR_ID
+- Expected: blocked with clear actionable message
+- Result:
+- Evidence:
+
+### 5. Duplicate request_id replay
+- Re-send same request_id + operation
+- Expected: idempotent response, no duplicate row
+- Result:
+- Evidence:
+
+### 6. Duplicate transaction_id replay
+- Re-send same transaction_id + operation
+- Expected: idempotent response, no duplicate row
+- Result:
+- Evidence:
+
+### 7. Timeout/5xx retry behavior
+- Simulate transient gateway failure
+- Expected: retryable=1; queued for retry without duplicate workflow rows
+- Result:
+- Evidence:
+
+### 8. Consent revoked flow
+- status returns revoked
+- Expected: workflow state REVOKED
+- Result:
+- Evidence:
+
+### 9. Consent expired flow
+- status returns expired
+- Expected: workflow state EXPIRED
+- Result:
+- Evidence:
+
+### 10. Audit completeness
+- Verify request/response/status/error/hospital context persisted
+- Result:
+- Evidence:
+
+## Defects
+| ID | Severity | Scenario | Description | Status |
+|----|----------|----------|-------------|--------|
+
+## Final Signoff
+- [ ] All 4 APIs integrated
+- [ ] Full audit trail verified
+- [ ] Hospital isolation by hfr_id verified
+- [ ] All QA scenarios passed
+- [ ] Runbook shared with support team
+
+Signoff By:
+Date:
diff --git a/docs/ABDM_M3_HIU_RUNBOOK.md b/docs/ABDM_M3_HIU_RUNBOOK.md
new file mode 100644
index 0000000..7e810ed
--- /dev/null
+++ b/docs/ABDM_M3_HIU_RUNBOOK.md
@@ -0,0 +1,93 @@
+# ABDM M3 HIU Runbook
+
+## Scope
+This runbook covers HMS-side M3 HIU integration to gateway endpoints:
+- POST /api/v3/hiu/consent/request
+- POST /api/v3/hiu/consent/request/status
+- POST /api/v3/hiu/consent/request/fetch
+- POST /api/v3/hiu/health-information/request
+
+## Prerequisites
+1. Hospital settings must include:
+- EATRIA_BRIDGE_TOKEN
+- ABDM_HFR_ID
+- ABDM_HOSPITAL_ID (optional but recommended)
+2. Permissions required:
+- abdm.access OR abdm.taskboard.access OR abdm.gateway.use
+
+## Deploy Steps
+1. Pull latest code.
+2. Run migration:
+```bash
+php spark migrate --namespace App
+```
+3. Verify new table exists:
+- abdm_hiu_workflows
+4. Open HIU UI:
+- /AbdmHiu
+
+## Operational Flow
+1. Create Consent Request -> state REQUESTED
+2. Check Consent Status -> state STATUS_CHECKED
+3. Fetch Consent Artifact -> state CONSENT_FETCHED
+4. Request Health Information -> state DATA_REQUESTED / COMPLETED
+
+## Workflow States
+- CREATED
+- REQUESTED
+- STATUS_CHECKED
+- CONSENT_FETCHED
+- DATA_REQUESTED
+- COMPLETED
+- FAILED
+- REVOKED
+- EXPIRED
+
+## Idempotency
+- request_id + operation: unique
+- transaction_id + operation: unique
+- Duplicate calls return previous result with duplicate=1
+
+## Retry Policy
+Only transient errors are retried:
+- network/curl errors
+- timeout
+- HTTP 5xx
+
+Command:
+```bash
+php spark abdm:hiu-retry --limit 20
+```
+
+## Audit Trail
+All calls are logged in abdm_api_logs with:
+- request_json
+- response_json
+- response_code
+- status
+- error_message
+- endpoint
+- event_type prefixed with abdm.m3.hiu.*
+
+Workflow rows are stored in abdm_hiu_workflows including:
+- request_id
+- transaction_id
+- consent_id
+- abha_address
+- hfr_id
+- state/status timestamps
+
+## Failure Handling
+1. Missing token:
+- Error: Missing token: set hospital_setting.EATRIA_BRIDGE_TOKEN
+2. Missing hfr_id:
+- Error: Missing hfr_id: set hospital_setting.ABDM_HFR_ID
+3. Token/HFR mismatch:
+- Error: hfr_id mismatch with configured ABDM_HFR_ID
+
+## Support Checklist
+1. Confirm token and hfr_id in hospital_setting.
+2. Reproduce in /AbdmHiu.
+3. Check timeline filters by hfr_id/consent_id/transaction_id/date.
+4. Check abdm_api_logs for request_id and endpoint error.
+5. Retry transient failures using spark command.