From 1582202798f45b1665dc1787901002b2f627db41 Mon Sep 17 00:00:00 2001 From: Gowtham M Date: Tue, 21 Jul 2026 09:32:13 +0530 Subject: [PATCH] gwm : go digit --- .env.sample | 19 + app/Config/Acl.php | 6 + app/Config/DigitMotor.php | 45 ++ app/Config/Routes.php | 18 + app/Controllers/DigitMotorController.php | 226 ++++++ app/Database/digit_motor_demo_seed.sql | 125 +++ app/Database/digit_motor_tables.sql | 122 +++ app/Libraries/DigitMotor/DigitApiClient.php | 177 +++++ .../DigitMotor/DigitApiException.php | 48 ++ app/Libraries/DigitMotor/DigitAuthClient.php | 110 +++ .../DigitMotor/DigitExecutorService.php | 743 ++++++++++++++++++ app/Models/MotorApiLogModel.php | 20 + app/Models/MotorKycModel.php | 21 + app/Models/MotorPaymentModel.php | 21 + app/Models/MotorPolicyModel.php | 20 + app/Models/MotorQuoteModel.php | 92 +++ app/Models/MotorTokenModel.php | 41 + app/Models/MotorVehicleModel.php | 22 + app/Views/digit_motor/journey.php | 672 ++++++++++++++++ app/Views/digit_motor/list.php | 103 +++ app/Views/digit_motor/list_table.php | 120 +++ app/Views/layout/header.php | 6 + db.md | 15 + 23 files changed, 2792 insertions(+) create mode 100644 app/Config/DigitMotor.php create mode 100644 app/Controllers/DigitMotorController.php create mode 100644 app/Database/digit_motor_demo_seed.sql create mode 100644 app/Database/digit_motor_tables.sql create mode 100644 app/Libraries/DigitMotor/DigitApiClient.php create mode 100644 app/Libraries/DigitMotor/DigitApiException.php create mode 100644 app/Libraries/DigitMotor/DigitAuthClient.php create mode 100644 app/Libraries/DigitMotor/DigitExecutorService.php create mode 100644 app/Models/MotorApiLogModel.php create mode 100644 app/Models/MotorKycModel.php create mode 100644 app/Models/MotorPaymentModel.php create mode 100644 app/Models/MotorPolicyModel.php create mode 100644 app/Models/MotorQuoteModel.php create mode 100644 app/Models/MotorTokenModel.php create mode 100644 app/Models/MotorVehicleModel.php create mode 100644 app/Views/digit_motor/journey.php create mode 100644 app/Views/digit_motor/list.php create mode 100644 app/Views/digit_motor/list_table.php diff --git a/.env.sample b/.env.sample index 64b3db53..e781e450 100755 --- a/.env.sample +++ b/.env.sample @@ -190,6 +190,25 @@ ICICI_GRANT_TYPE = ICICI_PRIMARY_KEY_CONSTANT = +#-------------------------------------------------------------------- +# Digit Motor (OneAPI) +#-------------------------------------------------------------------- +DIGIT_MOTOR_BASE_URL = https://preprod-oneapi.godigit.com +DIGIT_MOTOR_AUTH_PATH = /OneAPI/digit/generateAuthKey +DIGIT_MOTOR_EXECUTOR_PATH = /OneAPI/v1/executor +DIGIT_MOTOR_USERNAME = +DIGIT_MOTOR_PASSWORD = +DIGIT_MOTOR_ENVIRONMENT = staging +DIGIT_MOTOR_TIMEOUT = 30 +DIGIT_MOTOR_TOKEN_LEEWAY_SEC = 60 +DIGIT_MOTOR_PDF_AUTH_KEY = +DIGIT_MOTOR_IID_QUICK_QUOTE = 29266-0100 +DIGIT_MOTOR_IID_CREATE_QUOTE = 29268-0100 +DIGIT_MOTOR_IID_KYC = 29269-0100 +DIGIT_MOTOR_IID_PAYMENT = 29270-0100 +DIGIT_MOTOR_IID_POLICY_STATUS = 29271-0100 +DIGIT_MOTOR_IID_POLICY_PDF = 29272-0100 + #-------------------------------------------------------------------- # File storage (S3 uploads) #-------------------------------------------------------------------- diff --git a/app/Config/Acl.php b/app/Config/Acl.php index 712d7cdf..e0e7d2b3 100644 --- a/app/Config/Acl.php +++ b/app/Config/Acl.php @@ -127,6 +127,12 @@ class Acl 'teams' => [] ], + // ===================== DIGIT MOTOR ===================== + '#^/digit-motor#' => [ + 'roles' => [ HEAD_ROLE_ID, ADMIN_ROLE_ID, MANAGER_ROLE_ID, ACCOUNT_MANAGER_ROLE_ID, STAFF_ROLE_ID], + 'teams' => [FINANCE_TEAM_ID, POS_TEAM_ID, BUSINESS_TEAM_ID, MANAGEMENT_TEAM_ID] + ], + // ===================== POLICY TRANSACTION / BDS ===================== '#^/policy_tranction#' => [ 'roles' => [ HEAD_ROLE_ID,ADMIN_ROLE_ID, MANAGER_ROLE_ID, ACCOUNT_MANAGER_ROLE_ID,STAFF_ROLE_ID], diff --git a/app/Config/DigitMotor.php b/app/Config/DigitMotor.php new file mode 100644 index 00000000..1c39594a --- /dev/null +++ b/app/Config/DigitMotor.php @@ -0,0 +1,45 @@ +baseUrl = rtrim((string) env('DIGIT_MOTOR_BASE_URL', 'https://preprod-oneapi.godigit.com'), '/'); + $this->authPath = (string) env('DIGIT_MOTOR_AUTH_PATH', '/OneAPI/digit/generateAuthKey'); + $this->executorPath = (string) env('DIGIT_MOTOR_EXECUTOR_PATH', '/OneAPI/v1/executor'); + $this->username = (string) env('DIGIT_MOTOR_USERNAME', ''); + $this->password = (string) env('DIGIT_MOTOR_PASSWORD', ''); + $this->environment = (string) env('DIGIT_MOTOR_ENVIRONMENT', 'staging'); + $this->timeout = (int) env('DIGIT_MOTOR_TIMEOUT', 30); + $this->tokenLeewaySec = (int) env('DIGIT_MOTOR_TOKEN_LEEWAY_SEC', 60); + $this->pdfAuthKey = (string) env('DIGIT_MOTOR_PDF_AUTH_KEY', ''); + + // Integration IDs from NHANCE Digit API kit (CURL / Bruno export) + $this->integrationIds = [ + 'quickQuote' => (string) env('DIGIT_MOTOR_IID_QUICK_QUOTE', '29266-0100'), + 'createQuote' => (string) env('DIGIT_MOTOR_IID_CREATE_QUOTE', '29268-0100'), + 'kycStatus' => (string) env('DIGIT_MOTOR_IID_KYC', '29269-0100'), + 'payment' => (string) env('DIGIT_MOTOR_IID_PAYMENT', '29270-0100'), + 'policyStatus' => (string) env('DIGIT_MOTOR_IID_POLICY_STATUS', '29271-0100'), + 'policyPdf' => (string) env('DIGIT_MOTOR_IID_POLICY_PDF', '29272-0100'), + ]; + } +} diff --git a/app/Config/Routes.php b/app/Config/Routes.php index a8be516c..3daa030c 100755 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -1201,6 +1201,24 @@ $routes->group('expense', ["filter" => "authMVC", 'namespace' => 'App\Controller $routes->get('client-policies', 'ExpenseController::clientPolicies'); }); +// Digit Motor Module +$routes->group('digit-motor', ['filter' => 'authMVC', 'namespace' => 'App\Controllers'], static function ($routes) { + $routes->match(['get', 'post'], 'list', 'DigitMotorController::list'); + $routes->get('journey', 'DigitMotorController::journey'); + $routes->get('journey/(:num)', 'DigitMotorController::journey/$1'); + $routes->get('quotes/(:num)', 'DigitMotorController::detail/$1'); + + $routes->post('quotes/quick', 'DigitMotorController::quickQuote'); + $routes->post('quotes/create', 'DigitMotorController::createQuote'); + $routes->post('quotes/(:num)/create', 'DigitMotorController::createQuote/$1'); + $routes->get('kyc/(:num)', 'DigitMotorController::kycStatus/$1'); + $routes->post('payments/(:num)/link', 'DigitMotorController::paymentLink/$1'); + $routes->get('policies/(:num)/status', 'DigitMotorController::policyStatus/$1'); + $routes->get('policies/(:num)/pdf', 'DigitMotorController::policyPdf/$1'); + + $routes->get('payment/callback/(:segment)/(:num)', 'DigitMotorController::paymentCallback/$1/$2'); +}); + $routes->get('docs', 'Docs\DocsController::index'); $routes->get('docs/(:segment)', 'Docs\DocsController::page/$1'); diff --git a/app/Controllers/DigitMotorController.php b/app/Controllers/DigitMotorController.php new file mode 100644 index 00000000..54a7e164 --- /dev/null +++ b/app/Controllers/DigitMotorController.php @@ -0,0 +1,226 @@ +quoteModel = new MotorQuoteModel(); + $this->executor = new DigitExecutorService(); + } + + // ===================== LIST ===================== + + public function list() + { + if (strtolower($this->request->getMethod()) === 'post') { + $filters = [ + 'status' => $this->request->getPost('status'), + 'enquiry_id' => $this->request->getPost('enquiry_id'), + 'quote_number' => $this->request->getPost('quote_number'), + 'license_plate' => $this->request->getPost('license_plate'), + ]; + $data['quotes'] = $this->quoteModel->getListWithVehicle($filters); + return view('digit_motor/list_table', $data); + } + + $data['quotes'] = $this->quoteModel->getListWithVehicle(); + $data['statuses'] = [ + 'DRAFT' => 'Draft', + 'QUOTED' => 'Quoted', + 'CREATED' => 'Created', + 'KYC_DONE' => 'KYC Done', + 'PAID' => 'Paid', + 'EFFECTIVE' => 'Effective', + 'FAILED' => 'Failed', + ]; + $data['title'] = 'Digit Motor Quotes'; + return $this->loadLayout('digit_motor/list', $data); + } + + // ===================== JOURNEY UI ===================== + + public function journey($quoteId = null) + { + $data['title'] = 'Digit Motor Journey'; + $data['quote'] = null; + $data['quote_id'] = null; + + if ($quoteId) { + $data['quote'] = $this->quoteModel->getDetail((int) $quoteId); + $data['quote_id'] = (int) $quoteId; + if (!$data['quote']) { + return redirect()->to(base_url('digit-motor/list'))->with('error', 'Quote not found.'); + } + } + + return $this->loadLayout('digit_motor/journey', $data); + } + + public function detail($quoteId) + { + $detail = $this->quoteModel->getDetail((int) $quoteId); + if (!$detail) { + return $this->respond(['status' => false, 'message' => 'Quote not found.'], 404); + } + return $this->respond(['status' => true, 'data' => $detail]); + } + + // ===================== API ACTIONS ===================== + + public function quickQuote() + { + return $this->runAction(function () { + $input = $this->request->getJSON(true) ?: $this->request->getPost(); + $rules = [ + 'license_plate_number' => 'required|min_length[4]', + 'vehicle_maincode' => 'required', + 'registration_date' => 'required', + 'manufacture_date' => 'required', + 'pincode' => 'required|exact_length[6]', + ]; + if (!$this->validateDigitInput($input, $rules)) { + return $this->respond([ + 'status' => false, + 'message' => 'Validation failed.', + 'errors' => $this->validator->getErrors(), + ], 422); + } + + $result = $this->executor->quickQuote($input); + return $this->respond([ + 'status' => true, + 'message' => 'Quick quote generated.', + 'data' => $result, + ]); + }); + } + + public function createQuote($quoteId = null) + { + return $this->runAction(function () use ($quoteId) { + $input = $this->request->getJSON(true) ?: $this->request->getPost(); + $quoteId = (int) ($quoteId ?: ($input['quote_id'] ?? 0)); + if ($quoteId <= 0) { + return $this->respond(['status' => false, 'message' => 'quote_id is required.'], 422); + } + + $result = $this->executor->createQuote($quoteId, $input); + return $this->respond([ + 'status' => true, + 'message' => 'Quote created.', + 'data' => $result, + ]); + }); + } + + public function kycStatus($quoteId) + { + return $this->runAction(function () use ($quoteId) { + $result = $this->executor->kycStatus((int) $quoteId); + return $this->respond([ + 'status' => true, + 'message' => 'KYC status fetched.', + 'data' => $result, + ]); + }); + } + + public function paymentLink($quoteId) + { + return $this->runAction(function () use ($quoteId) { + $input = $this->request->getJSON(true) ?: $this->request->getPost(); + $result = $this->executor->paymentLink((int) $quoteId, $input ?: []); + return $this->respond([ + 'status' => true, + 'message' => 'Payment link generated.', + 'data' => $result, + ]); + }); + } + + public function policyStatus($quoteId) + { + return $this->runAction(function () use ($quoteId) { + $result = $this->executor->policyStatus((int) $quoteId); + return $this->respond([ + 'status' => true, + 'message' => 'Policy status fetched.', + 'data' => $result, + ]); + }); + } + + public function policyPdf($quoteId) + { + return $this->runAction(function () use ($quoteId) { + $result = $this->executor->policyPdf((int) $quoteId); + return $this->respond([ + 'status' => true, + 'message' => 'Policy PDF fetched.', + 'data' => $result, + ]); + }); + } + + public function paymentCallback($outcome, $quoteId) + { + $quoteId = (int) $quoteId; + if ($outcome === 'success' && $quoteId > 0) { + try { + $this->executor->policyStatus($quoteId); + } catch (\Throwable $e) { + log_message('error', 'DigitMotor payment callback policyStatus: ' . $e->getMessage()); + } + } + + return redirect()->to(base_url('digit-motor/journey/' . $quoteId)) + ->with($outcome === 'success' ? 'success' : 'error', $outcome === 'success' + ? 'Payment return received. Refreshing policy status.' + : 'Payment was cancelled or failed.'); + } + + protected function runAction(callable $fn) + { + try { + return $fn(); + } catch (DigitApiException $e) { + log_message('error', 'DigitMotor API error: ' . $e->getMessage() . ' code=' . $e->getDigitCode()); + $http = $e->getHttpStatus() >= 400 ? $e->getHttpStatus() : 502; + if ($e->isInfraError()) { + $http = 502; + } + return $this->respond([ + 'status' => false, + 'message' => $e->getMessage(), + 'digit_code' => $e->getDigitCode(), + 'infra' => $e->isInfraError(), + ], min($http, 599)); + } catch (\Throwable $e) { + log_message('error', 'DigitMotor unexpected error: ' . $e->getMessage()); + return $this->respond([ + 'status' => false, + 'message' => 'Something went wrong. Please try again.', + ], 500); + } + } + + protected function validateDigitInput(array $data, array $rules): bool + { + $this->validator = \Config\Services::validation(); + $this->validator->setRules($rules); + return $this->validator->run($data); + } +} diff --git a/app/Database/digit_motor_demo_seed.sql b/app/Database/digit_motor_demo_seed.sql new file mode 100644 index 00000000..45e0de09 --- /dev/null +++ b/app/Database/digit_motor_demo_seed.sql @@ -0,0 +1,125 @@ +-- Dummy Digit Motor full-flow seed (matches current live schema column lengths) + +DELETE k FROM motor_kyc k +INNER JOIN motor_quote q ON q.id = k.quote_id +WHERE q.enquiry_id = 'DEMO_FULL_FLOW_01'; + +DELETE p FROM motor_payment p +INNER JOIN motor_quote q ON q.id = p.quote_id +WHERE q.enquiry_id = 'DEMO_FULL_FLOW_01'; + +DELETE pol FROM motor_policy pol +INNER JOIN motor_quote q ON q.id = pol.quote_id +WHERE q.enquiry_id = 'DEMO_FULL_FLOW_01'; + +DELETE v FROM motor_vehicle v +INNER JOIN motor_quote q ON q.id = v.quote_id +WHERE q.enquiry_id = 'DEMO_FULL_FLOW_01'; + +DELETE FROM motor_quote WHERE enquiry_id = 'DEMO_FULL_FLOW_01'; + +INSERT INTO motor_quote ( + enquiry_id, quote_number, application_id, policy_holder_type, + insurance_product_code, sub_insurance_product_code, + previous_insurer_code, previous_policy_expiry_date, external_policy_number, + is_ncb_transfer, start_date, end_date, pincode, + coverage_details, premium, idv, status, created_at, updated_at +) VALUES ( + 'DEMO_FULL_FLOW_01', + 'D700690360', + 'V2F7789D887C44F1E8D87C1A7D50874F61F25EA9335D73AC40FBE994E1FFB696', + 'INDIVIDUAL', + '20101', + 'PB', + 190, + '2026-05-04', + 'PREV190DEMO01', + 0, + '2026-04-17', + '2027-04-16', + '560068', + JSON_OBJECT( + 'personal_accident', true, + 'zero_dep', true, + 'engine_protect', false, + 'rsa', true, + 'consumables', true, + 'key_protect', true, + 'tppd', true + ), + 13301.21, + 284500.00, + 'EFFECTIVE', + NOW(), + NOW() +); + +SET @qid = LAST_INSERT_ID(); + +INSERT INTO motor_vehicle ( + quote_id, is_vehicle_new, vehicle_maincode, license_plate_number, + vehicle_identification_number, engine_number, manufacture_date, registration_date, + idv, default_idv, minimum_idv, maximum_idv +) VALUES ( + @qid, 0, '1113811407', 'GJ04DA8726', + 'MAKGM651CJ4306951', 'L15Z15337915', '2014-01-10', '2014-01-10', + 284500.00, 280000.00, 255000.00, 310000.00 +); + +INSERT INTO motor_kyc ( + quote_id, kyc_id, kyc_verification_status, reference_id, link, + mismatch_type, id_verification_doc_type, address_verification_doc_type, + mode, checked_at +) VALUES ( + @qid, + 'KYC-DEMO-10042', + 'DONE', + 'RF29268010042', + 'https://example.com/kyc/ovd/demo', + NULL, + 'Aadhaar', + 'Aadhaar', + 'O', + NOW() +); + +INSERT INTO motor_payment ( + quote_id, application_id, digit_payment_id, request_reference, + payment_mode, cancel_return_url, success_return_url, dispatcher_response, + premium, payment_status, created_at +) VALUES ( + @qid, + 'V2F7789D887C44F1E8D87C1A7D50874F61F25EA9335D73AC40FBE994E1FFB696', + 'DPY29268010042', + 'REQ-DEMO-10042', + 'EB', + 'http://localhost/nhance/digit-motor/payment/callback/cancel/0', + 'http://localhost/nhance/digit-motor/payment/callback/success/0', + 'https://example.com/digit/payment/demo-link', + 13301.21, + 'PAID', + NOW() +); + +UPDATE motor_payment +SET + cancel_return_url = CONCAT('http://localhost/nhance/digit-motor/payment/callback/cancel/', @qid), + success_return_url = CONCAT('http://localhost/nhance/digit-motor/payment/callback/success/', @qid) +WHERE quote_id = @qid; + +INSERT INTO motor_policy ( + quote_id, policy_number, policy_status, schedule_path, proposal_path, + response_code, response_message, updated_at +) VALUES ( + @qid, + 'D700690360', + 'EFFECTIVE', + 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf', + 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf', + '0', + 'Policy is effective', + NOW() +); + +SELECT @qid AS demo_quote_id, enquiry_id, quote_number, status +FROM motor_quote WHERE id = @qid; diff --git a/app/Database/digit_motor_tables.sql b/app/Database/digit_motor_tables.sql new file mode 100644 index 00000000..30b6aadd --- /dev/null +++ b/app/Database/digit_motor_tables.sql @@ -0,0 +1,122 @@ +-- Digit Motor Module tables +-- Run once against the NHANCE database. + +CREATE TABLE IF NOT EXISTS motor_token ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + environment VARCHAR(20) NOT NULL DEFAULT 'staging', + access_token TEXT NOT NULL, + refresh_token TEXT, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_motor_token_env (environment) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS motor_quote ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + enquiry_id VARCHAR(64) NOT NULL, + quote_number VARCHAR(32) DEFAULT NULL, + application_id VARCHAR(128) DEFAULT NULL, + policy_holder_type VARCHAR(20) NOT NULL DEFAULT 'INDIVIDUAL', + insurance_product_code VARCHAR(10) NOT NULL, + sub_insurance_product_code VARCHAR(10) NOT NULL DEFAULT 'PB', + previous_insurer_code SMALLINT DEFAULT NULL, + previous_policy_expiry_date DATE DEFAULT NULL, + external_policy_number VARCHAR(32) DEFAULT NULL, + is_ncb_transfer TINYINT(1) DEFAULT 0, + start_date DATE DEFAULT NULL, + end_date DATE DEFAULT NULL, + pincode VARCHAR(6) NOT NULL, + coverage_details JSON DEFAULT NULL, + premium DECIMAL(12,2) DEFAULT NULL, + idv DECIMAL(12,2) DEFAULT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + -- DRAFT -> QUOTED -> CREATED -> KYC_DONE -> PAID -> EFFECTIVE / FAILED + created_by INT DEFAULT NULL, + updated_by INT DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_motor_quote_enquiry (enquiry_id), + KEY idx_motor_quote_status (status), + KEY idx_motor_quote_quote_number (quote_number) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS motor_vehicle ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + quote_id BIGINT NOT NULL, + is_vehicle_new TINYINT(1) NOT NULL DEFAULT 0, + vehicle_maincode VARCHAR(30) NOT NULL, + license_plate_number VARCHAR(12) NOT NULL, + vehicle_identification_number VARCHAR(30) DEFAULT NULL, + engine_number VARCHAR(30) DEFAULT NULL, + manufacture_date DATE NOT NULL, + registration_date DATE NOT NULL, + registration_authority VARCHAR(10) DEFAULT NULL, + idv DECIMAL(12,2) DEFAULT NULL, + default_idv DECIMAL(12,2) DEFAULT NULL, + minimum_idv DECIMAL(12,2) DEFAULT NULL, + maximum_idv DECIMAL(12,2) DEFAULT NULL, + UNIQUE KEY uq_motor_vehicle_quote (quote_id), + CONSTRAINT fk_motor_vehicle_quote FOREIGN KEY (quote_id) REFERENCES motor_quote(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS motor_kyc ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + quote_id BIGINT NOT NULL, + kyc_id VARCHAR(64) DEFAULT NULL, + kyc_verification_status VARCHAR(20) DEFAULT NULL, + reference_id VARCHAR(64) DEFAULT NULL, + link VARCHAR(512) DEFAULT NULL, + mismatch_type VARCHAR(40) DEFAULT NULL, + id_verification_doc_type VARCHAR(40) DEFAULT NULL, + address_verification_doc_type VARCHAR(40) DEFAULT NULL, + mode CHAR(1) DEFAULT 'O', + checked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_motor_kyc_quote (quote_id), + CONSTRAINT fk_motor_kyc_quote FOREIGN KEY (quote_id) REFERENCES motor_quote(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS motor_payment ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + quote_id BIGINT NOT NULL, + application_id VARCHAR(128) NOT NULL, + digit_payment_id VARCHAR(64) DEFAULT NULL, + request_reference VARCHAR(64) DEFAULT NULL, + payment_mode VARCHAR(5) DEFAULT 'EB', + cancel_return_url VARCHAR(512) DEFAULT NULL, + success_return_url VARCHAR(512) DEFAULT NULL, + dispatcher_response VARCHAR(512) DEFAULT NULL, + premium DECIMAL(12,2) DEFAULT NULL, + payment_status VARCHAR(20) DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_motor_payment_quote (quote_id), + CONSTRAINT fk_motor_payment_quote FOREIGN KEY (quote_id) REFERENCES motor_quote(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS motor_policy ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + quote_id BIGINT NOT NULL, + policy_number VARCHAR(32) DEFAULT NULL, + policy_status VARCHAR(20) DEFAULT NULL, + schedule_path VARCHAR(512) DEFAULT NULL, + proposal_path VARCHAR(512) DEFAULT NULL, + response_code VARCHAR(10) DEFAULT NULL, + response_message VARCHAR(255) DEFAULT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_motor_policy_quote (quote_id), + CONSTRAINT fk_motor_policy_quote FOREIGN KEY (quote_id) REFERENCES motor_quote(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS motor_api_log ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + quote_id BIGINT DEFAULT NULL, + integration_id VARCHAR(20) DEFAULT NULL, + endpoint VARCHAR(120) DEFAULT NULL, + request_body JSON DEFAULT NULL, + response_body JSON DEFAULT NULL, + http_status SMALLINT DEFAULT NULL, + error_code VARCHAR(10) DEFAULT NULL, + duration_ms INT DEFAULT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_motor_api_log_quote (quote_id), + KEY idx_motor_api_log_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/app/Libraries/DigitMotor/DigitApiClient.php b/app/Libraries/DigitMotor/DigitApiClient.php new file mode 100644 index 00000000..9cba0f99 --- /dev/null +++ b/app/Libraries/DigitMotor/DigitApiClient.php @@ -0,0 +1,177 @@ +config = $config ?? config('DigitMotor'); + $this->authClient = $authClient ?? new DigitAuthClient($this->config); + $this->logModel = new MotorApiLogModel(); + helper('api'); + } + + /** + * POST to Digit executor (or arbitrary path under baseUrl). + * + * @throws DigitApiException + */ + public function post(string $path, array $payload, ?string $integrationId = null, ?int $quoteId = null): array + { + return $this->request('POST', $path, $payload, $integrationId, $quoteId, false); + } + + /** + * @throws DigitApiException + */ + protected function request( + string $method, + string $path, + array $payload, + ?string $integrationId, + ?int $quoteId, + bool $isRetry + ): array { + $token = $this->authClient->getToken($isRetry); + $url = $this->config->baseUrl . $path; + + $headers = [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $token, + ]; + if ($integrationId) { + $headers[] = 'integrationId: ' . $integrationId; + } + + $started = microtime(true); + $response = call_third_party_api($url, $method, $headers, $payload); + $durationMs = (int) round((microtime(true) - $started) * 1000); + + $httpCode = (int) ($response['code'] ?? 0); + $data = $response['data'] ?? []; + if (is_string($data)) { + $decoded = json_decode($data, true); + $data = is_array($decoded) ? $decoded : ['raw' => $data]; + } + + $digitCode = $this->extractDigitCode($data, $httpCode); + + $this->writeLog($quoteId, $integrationId, $path, $payload, $data, $httpCode, $digitCode, $durationMs); + + // Stale token — refresh once and retry + if (!$isRetry && ($httpCode === 401 || (string) $digitCode === '401')) { + $this->authClient->refreshToken(); + return $this->request($method, $path, $payload, $integrationId, $quoteId, true); + } + + if ($this->isHardFail($httpCode, $digitCode, $response['status'] ?? false, $data)) { + $message = $this->extractMessage($data, $httpCode); + throw new DigitApiException($message, $digitCode, $httpCode, $data); + } + + return is_array($data) ? $data : []; + } + + protected function isHardFail(int $httpCode, $digitCode, bool $httpOk, $data): bool + { + $code = (string) $digitCode; + if (in_array($code, ['403', '999', '400', '500'], true)) { + return true; + } + if ($httpCode >= 400) { + return true; + } + if (!$httpOk) { + return true; + } + // Digit sometimes returns 200 with error object + if (is_array($data) && isset($data['error']) && !isset($data['grossPremium']) && !isset($data['premium']) && !isset($data['quoteNumber'])) { + $errCode = $data['error']['code'] ?? $data['code'] ?? null; + if ($errCode !== null && (string) $errCode !== '0' && strtoupper((string) $errCode) !== 'SUCCESS') { + return true; + } + } + return false; + } + + protected function extractDigitCode($data, int $httpCode) + { + if (!is_array($data)) { + return $httpCode ?: null; + } + return $data['error']['code'] + ?? $data['code'] + ?? $data['errorCode'] + ?? $data['responseCode'] + ?? ($httpCode >= 400 ? (string) $httpCode : null); + } + + protected function extractMessage($data, int $httpCode): string + { + if (!is_array($data)) { + return 'Digit API request failed (HTTP ' . $httpCode . ').'; + } + $msg = $data['error']['message'] + ?? $data['message'] + ?? $data['errorMessage'] + ?? $data['responseMessage'] + ?? null; + if (is_array($msg)) { + $msg = json_encode($msg); + } + return $msg ?: 'Digit API request failed (HTTP ' . $httpCode . ').'; + } + + protected function writeLog( + ?int $quoteId, + ?string $integrationId, + string $endpoint, + array $requestBody, + $responseBody, + int $httpStatus, + $errorCode, + int $durationMs + ): void { + try { + $this->logModel->insert([ + 'quote_id' => $quoteId, + 'integration_id' => $integrationId, + 'endpoint' => $endpoint, + 'request_body' => json_encode($this->redact($requestBody)), + 'response_body' => json_encode($this->redact(is_array($responseBody) ? $responseBody : ['raw' => $responseBody])), + 'http_status' => $httpStatus, + 'error_code' => $errorCode !== null ? (string) $errorCode : null, + 'duration_ms' => $durationMs, + ]); + } catch (\Throwable $e) { + log_message('error', 'DigitMotor API log write failed: ' . $e->getMessage()); + } + } + + protected function redact(array $data): array + { + $sensitive = ['password', 'access_token', 'refresh_token', 'Authorization', 'authorization']; + $out = []; + foreach ($data as $key => $value) { + if (in_array((string) $key, $sensitive, true)) { + $out[$key] = '***REDACTED***'; + } elseif (is_array($value)) { + $out[$key] = $this->redact($value); + } else { + $out[$key] = $value; + } + } + return $out; + } +} diff --git a/app/Libraries/DigitMotor/DigitApiException.php b/app/Libraries/DigitMotor/DigitApiException.php new file mode 100644 index 00000000..7bb67eef --- /dev/null +++ b/app/Libraries/DigitMotor/DigitApiException.php @@ -0,0 +1,48 @@ +digitCode = $digitCode; + $this->digitMessage = $message; + $this->httpStatus = $httpStatus; + $this->responseBody = $responseBody; + } + + public function getDigitCode() + { + return $this->digitCode; + } + + public function getHttpStatus(): int + { + return $this->httpStatus; + } + + public function getResponseBody() + { + return $this->responseBody; + } + + public function isInfraError(): bool + { + $code = (string) $this->digitCode; + return in_array($code, ['403', '999'], true) || in_array($this->httpStatus, [403], true); + } +} diff --git a/app/Libraries/DigitMotor/DigitAuthClient.php b/app/Libraries/DigitMotor/DigitAuthClient.php new file mode 100644 index 00000000..cb632afe --- /dev/null +++ b/app/Libraries/DigitMotor/DigitAuthClient.php @@ -0,0 +1,110 @@ +config = $config ?? config('DigitMotor'); + $this->tokenModel = new MotorTokenModel(); + helper('api'); + } + + /** + * Return a valid Bearer access token, refreshing from Digit when needed. + * + * @throws DigitApiException + */ + public function getToken(bool $forceRefresh = false): string + { + if (!$forceRefresh) { + $cached = $this->tokenModel->getByEnvironment($this->config->environment); + if ($cached && !empty($cached['access_token']) && !empty($cached['expires_at'])) { + $expiresAt = strtotime($cached['expires_at']); + if ($expiresAt !== false && ($expiresAt - $this->config->tokenLeewaySec) > time()) { + return $cached['access_token']; + } + } + } + + return $this->fetchAndStoreToken(); + } + + /** + * Force refresh (e.g. after 401). + * + * @throws DigitApiException + */ + public function refreshToken(): string + { + return $this->getToken(true); + } + + /** + * @throws DigitApiException + */ + protected function fetchAndStoreToken(): string + { + if ($this->config->username === '' || $this->config->password === '') { + throw new DigitApiException( + 'Digit Motor credentials are not configured. Set DIGIT_MOTOR_USERNAME and DIGIT_MOTOR_PASSWORD in .env.', + 'CONFIG', + 0 + ); + } + + $url = $this->config->baseUrl . $this->config->authPath; + $headers = ['Content-Type: application/json']; + $body = [ + 'username' => $this->config->username, + 'password' => $this->config->password, + ]; + + $response = call_third_party_api($url, 'POST', $headers, $body); + $httpCode = (int) ($response['code'] ?? 0); + $data = $response['data'] ?? []; + + if (is_string($data)) { + $decoded = json_decode($data, true); + $data = is_array($decoded) ? $decoded : []; + } + + $accessToken = $data['access_token'] + ?? $data['accessToken'] + ?? $data['token'] + ?? null; + + if (empty($accessToken) || empty($response['status'])) { + $msg = $data['message'] ?? $data['error'] ?? 'Digit token generation failed.'; + throw new DigitApiException( + is_string($msg) ? $msg : 'Digit token generation failed.', + $data['code'] ?? (string) $httpCode, + $httpCode, + $data + ); + } + + $expiresIn = (int) ($data['expires_in'] ?? $data['expiresIn'] ?? 900); + $refreshToken = $data['refresh_token'] ?? $data['refreshToken'] ?? null; + $expiresAt = date('Y-m-d H:i:s', time() + max(60, $expiresIn)); + + $this->tokenModel->upsertToken( + $this->config->environment, + $accessToken, + $refreshToken, + $expiresAt + ); + + return $accessToken; + } +} diff --git a/app/Libraries/DigitMotor/DigitExecutorService.php b/app/Libraries/DigitMotor/DigitExecutorService.php new file mode 100644 index 00000000..43620b1d --- /dev/null +++ b/app/Libraries/DigitMotor/DigitExecutorService.php @@ -0,0 +1,743 @@ +config = $config ?? config('DigitMotor'); + $this->api = $api ?? new DigitApiClient($this->config); + $this->quoteModel = new MotorQuoteModel(); + $this->vehicleModel = new MotorVehicleModel(); + $this->kycModel = new MotorKycModel(); + $this->paymentModel = new MotorPaymentModel(); + $this->policyModel = new MotorPolicyModel(); + } + + /** + * Create/update local quote + call motorQuickQuote. + * + * @throws DigitApiException + */ + public function quickQuote(array $input): array + { + $enquiryId = $input['enquiry_id'] ?? ('NH' . date('ymdHis') . random_int(100, 999)); + $payload = $this->buildQuickQuotePayload($input, $enquiryId); + + $quoteId = $this->persistQuoteShell($input, $enquiryId, 'DRAFT'); + + $response = $this->api->post( + $this->config->executorPath, + $payload, + $this->config->integrationIds['quickQuote'], + $quoteId + ); + + $premium = $this->pickNumber($response, ['grossPremium', 'premium', 'netPremium', 'totalPremium']); + $idv = $this->pickNumber($response, ['idv', 'vehicleIDV', 'insuredDeclaredValue']); + if ($idv === null && isset($response['vehicle']['vehicleIDV']['idv'])) { + $idv = (float) $response['vehicle']['vehicleIDV']['idv']; + } + + $vehicleIdv = $response['vehicle']['vehicleIDV'] ?? $response['vehicleIDV'] ?? []; + + $this->quoteModel->update($quoteId, [ + 'premium' => $premium, + 'idv' => $idv, + 'status' => 'QUOTED', + 'coverage_details' => json_encode($input['coverages'] ?? $payload['contract']['coverages'] ?? []), + ]); + + $vehicle = $this->vehicleModel->where('quote_id', $quoteId)->first(); + if ($vehicle) { + $this->vehicleModel->update($vehicle['id'], [ + 'idv' => $idv ?? ($vehicleIdv['idv'] ?? null), + 'default_idv' => $vehicleIdv['defaultIdv'] ?? $vehicleIdv['defaultIDV'] ?? null, + 'minimum_idv' => $vehicleIdv['minimumIdv'] ?? $vehicleIdv['minIdv'] ?? null, + 'maximum_idv' => $vehicleIdv['maximumIdv'] ?? $vehicleIdv['maxIdv'] ?? null, + ]); + } + + return [ + 'quote_id' => $quoteId, + 'enquiry_id' => $enquiryId, + 'premium' => $premium, + 'idv' => $idv, + 'status' => 'QUOTED', + 'response' => $response, + ]; + } + + /** + * Call motorCreateQuote for an existing quoted row. + * + * @throws DigitApiException + */ + public function createQuote(int $quoteId, array $input = []): array + { + $detail = $this->quoteModel->getDetail($quoteId); + if (!$detail) { + throw new DigitApiException('Quote not found.', '404', 404); + } + + $payload = $this->buildCreateQuotePayload($detail, $input); + + $response = $this->api->post( + $this->config->executorPath, + $payload, + $this->config->integrationIds['createQuote'], + $quoteId + ); + + $quoteNumber = $response['quoteNumber'] ?? $response['policyNumber'] ?? ($response['contract']['quoteNumber'] ?? null); + $applicationId = $response['applicationId'] ?? $response['policyId'] ?? ($response['contract']['applicationId'] ?? null); + $premium = $this->pickNumber($response, ['grossPremium', 'premium', 'netPremium', 'totalPremium']) ?? $detail['premium']; + + $this->quoteModel->update($quoteId, [ + 'quote_number' => $quoteNumber, + 'application_id' => $applicationId, + 'premium' => $premium, + 'start_date' => $input['start_date'] ?? $detail['start_date'], + 'end_date' => $input['end_date'] ?? $detail['end_date'], + 'status' => 'CREATED', + ]); + + return [ + 'quote_id' => $quoteId, + 'quote_number' => $quoteNumber, + 'application_id' => $applicationId, + 'premium' => $premium, + 'status' => 'CREATED', + 'response' => $response, + ]; + } + + /** + * Poll KYC status and upsert motor_kyc. + * + * @throws DigitApiException + */ + public function kycStatus(int $quoteId): array + { + $detail = $this->quoteModel->getDetail($quoteId); + if (!$detail || empty($detail['quote_number'])) { + throw new DigitApiException('Quote number is required for KYC status. Create quote first.', '400', 400); + } + + $payload = [ + 'queryParam' => [ + 'policyNumber' => $detail['quote_number'], + ], + ]; + + $response = $this->api->post( + $this->config->executorPath, + $payload, + $this->config->integrationIds['kycStatus'], + $quoteId + ); + + $status = $response['kycVerificationStatus'] + ?? $response['kycStatus'] + ?? ($response['kyc']['kycVerificationStatus'] ?? null); + + $row = [ + 'quote_id' => $quoteId, + 'kyc_id' => $response['kycId'] ?? ($response['kyc']['kycId'] ?? null), + 'kyc_verification_status' => $status, + 'reference_id' => $response['referenceId'] ?? ($response['kyc']['referenceId'] ?? null), + 'link' => $response['link'] ?? ($response['kyc']['link'] ?? null), + 'mismatch_type' => $response['mismatchType'] ?? null, + 'id_verification_doc_type' => $response['idVerificationDocType'] ?? null, + 'address_verification_doc_type' => $response['addressVerificationDocType'] ?? null, + 'mode' => $response['mode'] ?? 'O', + 'checked_at' => date('Y-m-d H:i:s'), + ]; + + $existing = $this->kycModel->where('quote_id', $quoteId)->orderBy('id', 'DESC')->first(); + if ($existing) { + $this->kycModel->update($existing['id'], $row); + $row['id'] = $existing['id']; + } else { + $row['id'] = $this->kycModel->insert($row); + } + + $normalized = strtoupper((string) $status); + if (in_array($normalized, ['DONE', 'VERIFIED', 'SUCCESS', 'COMPLETED'], true)) { + $this->quoteModel->update($quoteId, ['status' => 'KYC_DONE']); + } + + return [ + 'quote_id' => $quoteId, + 'kyc' => $row, + 'status' => $status, + 'response' => $response, + ]; + } + + /** + * Generate payment link. + * + * @throws DigitApiException + */ + public function paymentLink(int $quoteId, array $input = []): array + { + $detail = $this->quoteModel->getDetail($quoteId); + if (!$detail || empty($detail['application_id'])) { + throw new DigitApiException('Application ID is required for payment. Create quote first.', '400', 400); + } + + $premiumAmount = $input['premium_amount'] + ?? ('INR ' . number_format((float) ($detail['premium'] ?? 0), 2, '.', '')); + + $successUrl = $input['success_return_url'] + ?? base_url('digit-motor/payment/callback/success/' . $quoteId); + $cancelUrl = $input['cancel_return_url'] + ?? base_url('digit-motor/payment/callback/cancel/' . $quoteId); + + $payload = [ + 'premiumAmount' => $premiumAmount, + 'paymentMode' => $input['payment_mode'] ?? 'EB', + 'successReturnUrl' => $successUrl, + 'cancelReturnUrl' => $cancelUrl, + 'applicationId' => $detail['application_id'], + ]; + + $response = $this->api->post( + $this->config->executorPath, + $payload, + $this->config->integrationIds['payment'], + $quoteId + ); + + $dispatcher = $response['dispatcherResponse'] + ?? $response['paymentLink'] + ?? $response['redirectUrl'] + ?? null; + + $row = [ + 'quote_id' => $quoteId, + 'application_id' => $detail['application_id'], + 'digit_payment_id' => $response['digitPaymentId'] ?? $response['paymentId'] ?? null, + 'request_reference' => $response['requestReference'] ?? null, + 'payment_mode' => $payload['paymentMode'], + 'cancel_return_url' => $cancelUrl, + 'success_return_url' => $successUrl, + 'dispatcher_response' => $dispatcher, + 'premium' => $detail['premium'], + 'payment_status' => 'LINK_GENERATED', + 'created_at' => date('Y-m-d H:i:s'), + ]; + + $row['id'] = $this->paymentModel->insert($row); + + return [ + 'quote_id' => $quoteId, + 'payment' => $row, + 'dispatcher_response'=> $dispatcher, + 'response' => $response, + ]; + } + + /** + * Poll policy status. + * + * @throws DigitApiException + */ + public function policyStatus(int $quoteId): array + { + $detail = $this->quoteModel->getDetail($quoteId); + if (!$detail || empty($detail['quote_number'])) { + throw new DigitApiException('Quote/policy number is required for policy status.', '400', 400); + } + + $payload = [ + 'queryParam' => [ + 'policyNumber' => $detail['quote_number'], + ], + ]; + + $response = $this->api->post( + $this->config->executorPath, + $payload, + $this->config->integrationIds['policyStatus'], + $quoteId + ); + + $policyStatus = $response['policyStatus'] + ?? $response['status'] + ?? ($response['policy']['policyStatus'] ?? null); + $policyNumber = $response['policyNumber'] ?? $detail['quote_number']; + + $row = [ + 'quote_id' => $quoteId, + 'policy_number' => $policyNumber, + 'policy_status' => $policyStatus, + 'response_code' => isset($response['responseCode']) ? (string) $response['responseCode'] : null, + 'response_message' => $response['responseMessage'] ?? null, + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + $existing = $this->policyModel->where('quote_id', $quoteId)->first(); + if ($existing) { + $this->policyModel->update($existing['id'], $row); + $row['id'] = $existing['id']; + $row['schedule_path'] = $existing['schedule_path']; + $row['proposal_path'] = $existing['proposal_path']; + } else { + $row['id'] = $this->policyModel->insert($row); + } + + $normalized = strtoupper((string) $policyStatus); + if (in_array($normalized, ['EFFECTIVE', 'COMPLETE', 'COMPLETED', 'ACTIVE'], true)) { + $this->quoteModel->update($quoteId, ['status' => 'EFFECTIVE']); + if (!empty($detail['payment'])) { + $this->paymentModel->update($detail['payment']['id'], ['payment_status' => 'PAID']); + } + } + + return [ + 'quote_id' => $quoteId, + 'policy' => $row, + 'status' => $policyStatus, + 'response' => $response, + ]; + } + + /** + * Fetch policy PDF paths (only after policy is effective-ish). + * + * @throws DigitApiException + */ + public function policyPdf(int $quoteId): array + { + $detail = $this->quoteModel->getDetail($quoteId); + if (!$detail) { + throw new DigitApiException('Quote not found.', '404', 404); + } + + $policyId = $detail['application_id'] + ?? ($detail['policy']['policy_number'] ?? null); + if (empty($policyId)) { + throw new DigitApiException('Application/policy ID is required for PDF.', '400', 400); + } + + $policyStatus = strtoupper((string) ($detail['policy']['policy_status'] ?? $detail['status'] ?? '')); + if (!in_array($policyStatus, ['EFFECTIVE', 'COMPLETE', 'COMPLETED', 'ACTIVE', 'PAID'], true) + && !in_array(strtoupper((string) $detail['status']), ['EFFECTIVE', 'PAID'], true)) { + throw new DigitApiException( + 'Policy PDF is available only after the policy is effective. Current status: ' . ($detail['status'] ?? 'unknown'), + '400', + 400 + ); + } + + $payload = [ + 'policyId' => $policyId, + 'headerParam' => [ + 'Authorization' => $this->config->pdfAuthKey, + ], + ]; + + $response = $this->api->post( + $this->config->executorPath, + $payload, + $this->config->integrationIds['policyPdf'], + $quoteId + ); + + $schedulePath = $response['schedulePath'] ?? $response['policySchedulePath'] ?? ($response['schedule'] ?? null); + $proposalPath = $response['proposalPath'] ?? $response['proposalFormPath'] ?? ($response['proposal'] ?? null); + + $row = [ + 'quote_id' => $quoteId, + 'policy_number' => $detail['quote_number'], + 'policy_status' => $detail['policy']['policy_status'] ?? $detail['status'], + 'schedule_path' => $schedulePath, + 'proposal_path' => $proposalPath, + 'response_code' => isset($response['responseCode']) ? (string) $response['responseCode'] : null, + 'response_message' => $response['responseMessage'] ?? null, + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + $existing = $this->policyModel->where('quote_id', $quoteId)->first(); + if ($existing) { + $this->policyModel->update($existing['id'], $row); + $row['id'] = $existing['id']; + } else { + $row['id'] = $this->policyModel->insert($row); + } + + return [ + 'quote_id' => $quoteId, + 'policy' => $row, + 'response' => $response, + ]; + } + + protected function persistQuoteShell(array $input, string $enquiryId, string $status): int + { + $existing = $this->quoteModel->where('enquiry_id', $enquiryId)->first(); + + $quoteData = [ + 'enquiry_id' => $enquiryId, + 'policy_holder_type' => $input['policy_holder_type'] ?? 'INDIVIDUAL', + 'insurance_product_code' => (string) ($input['insurance_product_code'] ?? '20101'), + 'sub_insurance_product_code' => (string) ($input['sub_insurance_product_code'] ?? 'PB'), + 'previous_insurer_code' => $input['previous_insurer_code'] ?? null, + 'previous_policy_expiry_date'=> $input['previous_policy_expiry_date'] ?? null, + 'external_policy_number' => $input['previous_policy_number'] ?? null, + 'is_ncb_transfer' => !empty($input['is_ncb_transfer']) ? 1 : 0, + 'start_date' => $input['start_date'] ?? null, + 'end_date' => $input['end_date'] ?? null, + 'pincode' => (string) ($input['pincode'] ?? ''), + 'coverage_details' => json_encode($input['coverages'] ?? []), + 'status' => $status, + ]; + + if ($existing) { + $quoteId = (int) $existing['id']; + $this->quoteModel->update($quoteId, $quoteData); + $vehicle = $this->vehicleModel->where('quote_id', $quoteId)->first(); + $vehicleData = $this->vehicleRowFromInput($input, $quoteId); + if ($vehicle) { + $this->vehicleModel->update($vehicle['id'], $vehicleData); + } else { + $this->vehicleModel->insert($vehicleData); + } + return $quoteId; + } + + $quoteId = (int) $this->quoteModel->insert($quoteData); + $this->vehicleModel->insert($this->vehicleRowFromInput($input, $quoteId)); + return $quoteId; + } + + protected function vehicleRowFromInput(array $input, int $quoteId): array + { + $plate = strtoupper(preg_replace('/\s+/', '', (string) ($input['license_plate_number'] ?? ''))); + $authority = $input['registration_authority'] ?? null; + if (!$authority && strlen($plate) >= 4) { + $authority = substr($plate, 0, 4); + } + + return [ + 'quote_id' => $quoteId, + 'is_vehicle_new' => !empty($input['is_vehicle_new']) ? 1 : 0, + 'vehicle_maincode' => (string) ($input['vehicle_maincode'] ?? ''), + 'license_plate_number' => $plate, + 'vehicle_identification_number' => $input['vehicle_identification_number'] ?? null, + 'engine_number' => $input['engine_number'] ?? null, + 'manufacture_date' => $input['manufacture_date'] ?? date('Y-m-d'), + 'registration_date' => $input['registration_date'] ?? date('Y-m-d'), + 'registration_authority' => $authority, + 'idv' => $input['idv'] ?? 0, + ]; + } + + protected function buildQuickQuotePayload(array $input, string $enquiryId): array + { + $plate = strtoupper(preg_replace('/\s+/', '', (string) ($input['license_plate_number'] ?? ''))); + $authority = $input['registration_authority'] ?? (strlen($plate) >= 4 ? substr($plate, 0, 4) : ''); + $coverages = $this->defaultCoverages($input['coverages'] ?? []); + + return [ + 'vehicle' => [ + 'isVehicleNew' => !empty($input['is_vehicle_new']), + 'vehicleIDV' => [ + 'idv' => (string) ($input['idv'] ?? '0'), + ], + 'vehicleMaincode' => (string) ($input['vehicle_maincode'] ?? ''), + 'licensePlateNumber' => $plate, + 'registrationAuthority' => $authority, + 'vehicleIdentificationNumber' => $input['vehicle_identification_number'] ?? '', + 'engineNumber' => $input['engine_number'] ?? '', + 'manufactureDate' => $input['manufacture_date'] ?? null, + 'registrationDate' => $input['registration_date'] ?? null, + 'fastTagNumber' => null, + 'ownershipSerialNumber' => 0, + ], + 'previousInsurer' => [ + 'isPreviousInsurerKnown' => !empty($input['previous_insurer_code']), + 'previousInsurerCode' => isset($input['previous_insurer_code']) ? (string) $input['previous_insurer_code'] : '', + 'previousPolicyExpiryDate' => $input['previous_policy_expiry_date'] ?? null, + 'previousPolicyNumber' => $input['previous_policy_number'] ?? '', + 'isClaimInLastYear' => !empty($input['is_claim_in_last_year']), + 'previousNoClaimBonus' => $input['previous_ncb'] ?? 'ZERO', + 'previousPolicyType' => $input['previous_policy_type'] ?? '', + ], + 'contract' => [ + 'insuranceProductCode' => (int) ($input['insurance_product_code'] ?? 20102), + 'startDate' => $input['start_date'] ?? null, + 'endDate' => $input['end_date'] ?? null, + 'policyTerm' => (int) ($input['policy_term'] ?? 1), + 'coverages' => $coverages, + 'subInsuranceProductCode' => $input['sub_insurance_product_code'] ?? 'PB', + 'policyHolderType' => $input['policy_holder_type'] ?? 'INDIVIDUAL', + 'externalPolicyNumber' => $input['external_policy_number'] ?? '', + ], + 'pinCode' => (int) ($input['pincode'] ?? 0), + 'enquiryId' => $enquiryId, + ]; + } + + protected function buildCreateQuotePayload(array $detail, array $input): array + { + $vehicle = $detail['vehicle'] ?? []; + $coverages = $input['coverages'] + ?? (is_array($detail['coverage_details']) ? $detail['coverage_details'] : $this->defaultCoverages([])); + + $plate = $vehicle['license_plate_number'] ?? ''; + $authority = $vehicle['registration_authority'] ?? (strlen($plate) >= 4 ? substr($plate, 0, 4) : ''); + + $startDate = $input['start_date'] ?? $detail['start_date']; + $endDate = $input['end_date'] ?? $detail['end_date']; + + $payload = [ + 'enquiryId' => $detail['enquiry_id'], + 'contract' => [ + 'insuranceProductCode' => (string) ($detail['insurance_product_code'] ?? '20101'), + 'subInsuranceProductCode' => (string) ($detail['sub_insurance_product_code'] ?? 'PB'), + 'startDate' => $startDate, + 'endDate' => $endDate, + 'policyHolderType' => $detail['policy_holder_type'] ?? 'INDIVIDUAL', + 'externalPolicyNumber' => $detail['external_policy_number'], + 'isNCBTransfer' => !empty($detail['is_ncb_transfer']) ? true : null, + 'coverages' => $coverages, + ], + 'vehicle' => [ + 'isVehicleNew' => !empty($vehicle['is_vehicle_new']) ? 'false' : 'false', + 'vehicleMaincode' => $vehicle['vehicle_maincode'] ?? '', + 'licensePlateNumber' => $plate, + 'vehicleIdentificationNumber' => $vehicle['vehicle_identification_number'] ?? '', + 'registrationAuthority' => $authority, + 'engineNumber' => $vehicle['engine_number'] ?? '', + 'manufactureDate' => $vehicle['manufacture_date'] ?? null, + 'registrationDate' => $vehicle['registration_date'] ?? null, + 'vehicleIDV' => [ + 'idv' => (float) ($vehicle['idv'] ?? $detail['idv'] ?? 0), + ], + 'usageType' => null, + 'permitType' => null, + 'motorType' => null, + ], + 'hypothecation' => [ + 'isHypothecation' => false, + 'hypothecationAgency' => '', + 'hypothecationCIty' => '', + ], + 'previousInsurer' => [ + 'isPreviousInsurerKnown' => !empty($detail['previous_insurer_code']), + 'previousInsurerCode' => isset($detail['previous_insurer_code']) ? (string) $detail['previous_insurer_code'] : '', + 'previousPolicyNumber' => $detail['external_policy_number'] ?? '', + 'previousPolicyExpiryDate' => $detail['previous_policy_expiry_date'] ?? null, + 'isClaimInLastYear' => $input['is_claim_in_last_year'] ?? 'false', + 'originalPreviousPolicyType' => $input['previous_policy_type'] ?? '1OD_1TP', + 'previousPolicyType' => $input['previous_policy_type'] ?? '1OD_1TP', + 'previousNoClaimBonus' => $input['previous_ncb'] ?? 'ZERO', + 'currentThirdPartyPolicy' => null, + ], + 'pospInfo' => [ + 'isPOSP' => 'false', + 'pospName' => null, + 'pospUniqueNumber' => null, + 'pospLocation' => '', + 'pospPanNumber' => null, + 'pospAadhaarNumber' => null, + 'pospContactNumber' => null, + ], + 'persons' => $input['persons'] ?? [$this->defaultPerson($input, $detail)], + 'dealer' => [ + 'dealerName' => '', + 'city' => '', + 'deliveryDate' => null, + ], + 'motorQuestions' => [ + 'furtherAgreement' => '', + 'selfInspection' => false, + 'financer' => '', + ], + 'motorBreakIn' => [ + 'isBreakin' => false, + 'breakinExcess' => null, + 'breakinComments' => null, + 'isPreInspectionWaived' => false, + 'isPreInspectionCompleted' => null, + 'isDocumentUploaded' => null, + ], + 'kyc' => $input['kyc'] ?? [ + 'isKYCDone' => !empty($input['kyc_id']), + 'ckycReferenceDocId' => $input['ckyc_doc_id'] ?? 'D07', + 'ckycReferenceNumber' => $input['ckyc_reference'] ?? ($input['pan'] ?? ''), + 'dateOfBirth' => $input['dob'] ?? null, + 'photo' => '', + ], + 'nominee' => $input['nominee'] ?? [ + 'firstName' => $input['nominee_first_name'] ?? 'NOMINEE', + 'middleName' => '', + 'lastName' => $input['nominee_last_name'] ?? '', + 'dateOfBirth' => $input['nominee_dob'] ?? '1990-01-01', + 'relation' => $input['nominee_relation'] ?? 'OTHER', + 'personType' => 'INDIVIDUAL', + ], + ]; + + // Fix isVehicleNew flag properly + $payload['vehicle']['isVehicleNew'] = !empty($vehicle['is_vehicle_new']) ? 'true' : 'false'; + + return $payload; + } + + protected function defaultPerson(array $input, array $detail): array + { + $pincode = $detail['pincode'] ?? ($input['pincode'] ?? ''); + return [ + 'personType' => 'INDIVIDUAL', + 'addresses' => [[ + 'addressType' => 'PRIMARY_RESIDENCE', + 'flatNumber' => null, + 'streetNumber' => null, + 'street' => $input['address'] ?? '', + 'district' => '', + 'state' => (string) ($input['state_code'] ?? ''), + 'city' => $input['city'] ?? '', + 'country' => 'IN', + 'pincode' => (string) $pincode, + ]], + 'communications' => [ + [ + 'communicationType' => 'MOBILE', + 'communicationId' => $input['mobile'] ?? '9999999999', + 'isPrefferedCommunication' => true, + ], + [ + 'communicationType' => 'EMAIL', + 'communicationId' => $input['email'] ?? 'noreply@nhance.local', + 'isPrefferedCommunication' => true, + ], + ], + 'identificationDocuments' => [ + [ + 'issuingPlace' => 'IN', + 'documentType' => 'PAN_CARD', + 'documentId' => $input['pan'] ?? '', + ], + ], + 'isPolicyHolder' => true, + 'isVehicleOwner' => true, + 'firstName' => $input['first_name'] ?? 'CUSTOMER', + 'middleName' => null, + 'lastName' => $input['last_name'] ?? '', + 'dateOfBirth' => $input['dob'] ?? '1990-01-01', + 'gender' => $input['gender'] ?? 'MALE', + 'isDriver' => true, + 'isInsuredPerson' => true, + ]; + } + + protected function defaultCoverages(array $overrides): array + { + $base = [ + 'isIMT23' => false, + 'personalAccident' => [ + 'selection' => !empty($overrides['personal_accident'] ?? true), + 'insuredAmount' => 1500000, + 'coverTerm' => 1, + ], + 'addons' => [ + 'personalBelonging' => ['selection' => !empty($overrides['personal_belonging'])], + 'returnToInvoice' => ['selection' => !empty($overrides['return_to_invoice'])], + 'rimProtection' => ['selection' => !empty($overrides['rim_protection'])], + 'consumables' => ['selection' => !empty($overrides['consumables'])], + 'partsDepreciation' => [ + 'selection' => !empty($overrides['zero_dep'] ?? $overrides['parts_depreciation']), + 'claimsCovered' => $overrides['claims_covered'] ?? 'TWO', + ], + 'engineProtection' => ['selection' => !empty($overrides['engine_protect'])], + 'tyreProtection' => ['selection' => !empty($overrides['tyre_protect'])], + 'roadSideAssistance' => ['selection' => !empty($overrides['rsa'] ?? $overrides['roadside_assistance'])], + 'keyAndLockProtect' => ['selection' => !empty($overrides['key_protect'])], + ], + 'accessories' => [ + 'electrical' => ['selection' => false, 'insuredAmount' => 0], + 'nonElectrical' => ['selection' => false, 'insuredAmount' => 0], + 'cng' => ['selection' => false, 'insuredAmount' => 0], + ], + 'voluntaryDeductible' => $overrides['voluntary_deductible'] ?? 'ZERO', + 'isGeoExt' => null, + 'legalLiability' => [ + 'nonFarePaxLL' => ['selection' => false, 'insuredCount' => 1], + 'unnamedPaxLL' => ['selection' => false, 'insuredCount' => 0], + 'workersCompensationLL' => ['selection' => false, 'insuredCount' => 1], + 'paidDriverLL' => ['selection' => true, 'insuredCount' => 1], + 'employeesLL' => ['selection' => false, 'insuredCount' => 1], + 'cleanersLL' => ['selection' => false, 'insuredCount' => 1], + ], + 'thirdPartyLiability' => [ + 'isTPPD' => array_key_exists('tppd', $overrides) ? (bool) $overrides['tppd'] : true, + ], + 'unnamedPA' => [ + 'unnamedPaidDriver' => ['selection' => false, 'insuredAmount' => 100000, 'insuredCount' => 1], + 'unnamedPax' => ['selection' => false, 'insuredAmount' => 0, 'insuredCount' => 4], + 'unnamedConductor' => ['selection' => false, 'insuredAmount' => 100000, 'insuredCount' => 1], + 'unnamedHirer' => ['selection' => false, 'insuredAmount' => 100000, 'insuredCount' => 1], + 'unnamedPillionRider'=> ['selection' => false, 'insuredAmount' => 100000, 'insuredCount' => 1], + 'unnamedCleaner' => ['selection' => false, 'insuredAmount' => 100000, 'insuredCount' => 1], + ], + 'isOverturningExclusionIMT47' => false, + 'isTheftAndConversionRiskIMT43' => false, + 'ownDamage' => [ + 'discount' => [ + 'userSpecialDiscountPercent' => 0, + 'discounts' => [], + ], + 'surcharge' => [ + 'loadings' => [], + ], + ], + ]; + + // If caller passed a Digit-shaped coverages object already, prefer it + if (isset($overrides['addons']) || isset($overrides['thirdPartyLiability'])) { + return array_replace_recursive($base, $overrides); + } + + return $base; + } + + protected function pickNumber(array $response, array $keys): ?float + { + foreach ($keys as $key) { + if (isset($response[$key]) && is_numeric($response[$key])) { + return (float) $response[$key]; + } + } + if (isset($response['premiumBreakUp']) && is_array($response['premiumBreakUp'])) { + foreach (['grossPremium', 'totalPremium', 'netPremium'] as $k) { + if (isset($response['premiumBreakUp'][$k]) && is_numeric($response['premiumBreakUp'][$k])) { + return (float) $response['premiumBreakUp'][$k]; + } + } + } + return null; + } +} diff --git a/app/Models/MotorApiLogModel.php b/app/Models/MotorApiLogModel.php new file mode 100644 index 00000000..634ca949 --- /dev/null +++ b/app/Models/MotorApiLogModel.php @@ -0,0 +1,20 @@ +db->table('motor_quote q') + ->select('q.*, v.license_plate_number, v.vehicle_maincode, v.registration_date') + ->join('motor_vehicle v', 'v.quote_id = q.id', 'left') + ->orderBy('q.id', 'DESC'); + + if (!empty($filters['status'])) { + $builder->where('q.status', $filters['status']); + } + if (!empty($filters['enquiry_id'])) { + $builder->like('q.enquiry_id', $filters['enquiry_id']); + } + if (!empty($filters['quote_number'])) { + $builder->like('q.quote_number', $filters['quote_number']); + } + if (!empty($filters['license_plate'])) { + $builder->like('v.license_plate_number', $filters['license_plate']); + } + + return $builder->get()->getResultArray(); + } + + public function getDetail(int $quoteId): ?array + { + $quote = $this->find($quoteId); + if (!$quote) { + return null; + } + + $vehicleModel = new MotorVehicleModel(); + $kycModel = new MotorKycModel(); + $paymentModel = new MotorPaymentModel(); + $policyModel = new MotorPolicyModel(); + + $quote['vehicle'] = $vehicleModel->where('quote_id', $quoteId)->first(); + $quote['kyc'] = $kycModel->where('quote_id', $quoteId)->orderBy('id', 'DESC')->first(); + $quote['payment'] = $paymentModel->where('quote_id', $quoteId)->orderBy('id', 'DESC')->first(); + $quote['policy'] = $policyModel->where('quote_id', $quoteId)->first(); + + if (!empty($quote['coverage_details']) && is_string($quote['coverage_details'])) { + $quote['coverage_details'] = json_decode($quote['coverage_details'], true); + } + + return $quote; + } +} diff --git a/app/Models/MotorTokenModel.php b/app/Models/MotorTokenModel.php new file mode 100644 index 00000000..cce44699 --- /dev/null +++ b/app/Models/MotorTokenModel.php @@ -0,0 +1,41 @@ +where('environment', $environment)->first(); + } + + public function upsertToken(string $environment, string $accessToken, ?string $refreshToken, string $expiresAt): void + { + $existing = $this->getByEnvironment($environment); + $row = [ + 'environment' => $environment, + 'access_token' => $accessToken, + 'refresh_token' => $refreshToken, + 'expires_at' => $expiresAt, + ]; + + if ($existing) { + $this->update($existing['id'], $row); + } else { + $this->insert($row); + } + } +} diff --git a/app/Models/MotorVehicleModel.php b/app/Models/MotorVehicleModel.php new file mode 100644 index 00000000..542c93b2 --- /dev/null +++ b/app/Models/MotorVehicleModel.php @@ -0,0 +1,22 @@ + 0, + 'QUOTED' => 1, + 'CREATED' => 2, + 'KYC_DONE' => 3, + 'PAID' => 4, + 'EFFECTIVE' => 4, +]; +$initialStep = $statusToStep[$status] ?? 0; +$coverage = is_array($quote['coverage_details'] ?? null) ? $quote['coverage_details'] : []; +?> + + +
+
+ + Back to list + +
Enquiry ID
+
+ +
+
+
+
+
+
+
+ +
+
+ + +
+
+
+
Step 1 of 5 · quick quote
+
Vehicle and coverage details
+
Enter the vehicle to fetch an indicative premium before creating the policy quote.
+
+
01
+
+ +
Vehicle
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
Previous policy
+
+
+
+
+
+
Yes
+
No
+
+
+
+ +
Coverage add-ons
+
+
Personal accident
+
Zero depreciation
+
Engine protect
+
Roadside assistance
+
Consumables
+
Key & lock
+
+ +
+ POST · motorQuickQuote + +
+
+ + +
+
+
+
Step 2 of 5 · create quote
+
Confirm premium plan
+
Review the quick-quote premium, add policyholder details, then create the Digit quote.
+
+
02
+
+ +
+
+
+
Quoted premium
+
+
IDV ₹
+
+
+ +
Policyholder
+
+
+
+
+
+
+
+
+
+ +
+ + +
+
+ + +
+
+
+
Step 3 of 5 · KYC status
+
Identity verification
+
Poll KYC status before the payment link can be issued.
+
+
03
+
+ +
+
+
+
+
Click refresh to fetch latest KYC status from Digit.
+
+
+
+ + + + + + +
Quote number
Application ID
KYC reference
OVD link
+ +
+ +
+ + +
+
+
+ + +
+
+
+
Step 4 of 5 · payment
+
Collect premium
+
Generates a Digit UI payment link for the customer to complete the transaction.
+
+
04
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+ + Open link + + + +
+
+ +
+ +
+ + +
+
+
+ + +
+
+
+
Step 5 of 5 · policy
+
Policy status & documents
+
Confirm payment outcome, then download schedule and proposal PDFs.
+
+
05
+
+ +
+
+ +
+
+
+ +
+
Refresh status after customer payment, then fetch PDFs.
+
+
+
+ +
Documents
+
+
Policy schedule
+ >Download +
+
+
Proposal form
+ >Download +
+ +
+ +
+ + +
+
+
+
+
+ + diff --git a/app/Views/digit_motor/list.php b/app/Views/digit_motor/list.php new file mode 100644 index 00000000..43c46c49 --- /dev/null +++ b/app/Views/digit_motor/list.php @@ -0,0 +1,103 @@ + + +
+
+
+
+

Digit Motor Quotes

+
+
+
+ +
+ $quotes ?? []]) ?> +
+
+ +
+
+

Filter

+ × +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + diff --git a/app/Views/digit_motor/list_table.php b/app/Views/digit_motor/list_table.php new file mode 100644 index 00000000..8ae22cf0 --- /dev/null +++ b/app/Views/digit_motor/list_table.php @@ -0,0 +1,120 @@ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Enquiry ID Reg. No. Quote No. Premium IDV Status Created Action 
+ + + + +
+
+
+
+
+ + diff --git a/app/Views/layout/header.php b/app/Views/layout/header.php index 2eed7f7f..ebc63494 100755 --- a/app/Views/layout/header.php +++ b/app/Views/layout/header.php @@ -2350,6 +2350,12 @@ body[data-sidebar-size="condensed"] .footer { Motor Policy Bulk Upload +
  • + + + Digit Motor + +
  • diff --git a/db.md b/db.md index a889580d..d471b18a 100644 --- a/db.md +++ b/db.md @@ -339,3 +339,18 @@ END$$ DELIMITER ; ``` + +--- + +# Digit Motor Module - Database Tables + +Apply: `app/Database/digit_motor_tables.sql` + +Tables: +- `motor_token` — cached Digit OneAPI bearer tokens +- `motor_quote` — quote header (quick + create quote) +- `motor_vehicle` — vehicle 1:1 with quote +- `motor_kyc` — KYC poll results +- `motor_payment` — payment link records +- `motor_policy` — policy status + PDF paths +- `motor_api_log` — redacted request/response audit log