From 4ca463cc1e19d043e8f06cb4429c280f87d93c54 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Mon, 10 Aug 2026 14:48:33 +0530 Subject: [PATCH] feat: add payment editing and fix discharge templates --- app/Config/AuthGroups.php | 2 + app/Config/Routes.php | 10 + app/Controllers/Ipd_discharge.php | 5 +- app/Controllers/Payment.php | 264 ++++++++++++++++++ ...000006_AddFinancePaymentEditPermission.php | 58 ++++ ...ckfillFinancePaymentEditUserPermission.php | 58 ++++ app/Models/PaymentModel.php | 60 +++- .../Setting/Admin/UserManagement/index.php | 4 +- app/Views/partials/sidebar.php | 11 + app/Views/payment/payment_edit.php | 101 +++++++ app/Views/payment/payment_search.php | 124 ++++++++ 11 files changed, 690 insertions(+), 7 deletions(-) create mode 100644 app/Controllers/Payment.php create mode 100644 app/Database/Migrations/2026-08-10-000006_AddFinancePaymentEditPermission.php create mode 100644 app/Database/Migrations/2026-08-10-000007_BackfillFinancePaymentEditUserPermission.php create mode 100644 app/Views/payment/payment_edit.php create mode 100644 app/Views/payment/payment_search.php diff --git a/app/Config/AuthGroups.php b/app/Config/AuthGroups.php index 8dc4703..9e6deb8 100644 --- a/app/Config/AuthGroups.php +++ b/app/Config/AuthGroups.php @@ -157,6 +157,7 @@ class AuthGroups extends ShieldAuthGroups 'finance.bank.deposit.create' => 'Can create bank deposit register entries', 'finance.bank.audit' => 'Can mark bank transaction audit status', 'finance.bank.statement.update' => 'Can mark updated in bank statement status', + 'finance.payment.edit' => 'Can correct payment mode, amount, or recorded user', 'finance.compliance.view' => 'Can view finance compliance reports', 'finance.doctor_payout.manage' => 'Can manage doctor payouts and agreements', 'finance.vendor.manage' => 'Can manage finance vendors', @@ -378,6 +379,7 @@ class AuthGroups extends ShieldAuthGroups 'finance.cash.accounts.verify', 'finance.bank.audit', 'finance.bank.statement.update', + 'finance.payment.edit', ], 'user' => [], 'beta' => [ diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 0cc1b2a..d507627 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -292,6 +292,16 @@ $routes->post('Payment_Medical/change_to_cash', 'Payment_Medical::change_to_cash $routes->post('Payment_Medical/update_amount', 'Payment_Medical::update_amount'); $routes->post('Payment_Medical/change_user', 'Payment_Medical::change_user'); +$paymentEditFilter = ['filter' => 'permission:finance.payment.edit']; +$routes->get('Payment', 'Payment::index', $paymentEditFilter); +$routes->post('Payment/payment_record', 'Payment::paymentRecord', $paymentEditFilter); +$routes->post('Payment/change_to_bank', 'Payment::changeToBank', $paymentEditFilter); +$routes->post('Payment/change_to_cash', 'Payment::changeToCash', $paymentEditFilter); +$routes->post('Payment/change_user', 'Payment::changeUser', $paymentEditFilter); +$routes->post('Payment/change_amount', 'Payment::changeAmount', $paymentEditFilter); +$routes->post('Payment/update_amount', 'Payment::changeAmount', $paymentEditFilter); +$routes->get('finance/payment-edit', 'Payment::index', $paymentEditFilter); + // Finance & Accounting SOP - Phase 1 $routes->get('Finance', 'Finance::index'); $routes->get('Finance/index', 'Finance::index'); diff --git a/app/Controllers/Ipd_discharge.php b/app/Controllers/Ipd_discharge.php index c51ee0c..3eaf598 100644 --- a/app/Controllers/Ipd_discharge.php +++ b/app/Controllers/Ipd_discharge.php @@ -498,9 +498,6 @@ class Ipd_discharge extends BaseController private function buildDischargeSectionTokenVars(string $content, array $panelData): array { $full = trim($content); - if ($full === '') { - return []; - } $ipd = $panelData['ipd_info'] ?? null; $dischargeStatus = $this->getDischargeStatusText($ipd); @@ -1114,7 +1111,7 @@ class Ipd_discharge extends BaseController $patientCode = html_entity_decode((string) ($tokenVars['UHID'] ?? ''), ENT_QUOTES, 'UTF-8'); $ipdCode = html_entity_decode((string) ($tokenVars['IPD_CODE'] ?? ''), ENT_QUOTES, 'UTF-8'); - if ($this->shouldUseContentOnlyTemplate($content, $templateHtml)) { + if ($requestedTemplateId === null && $this->shouldUseContentOnlyTemplate($content, $templateHtml)) { $templateHtml = '{{CONTENT}}'; } diff --git a/app/Controllers/Payment.php b/app/Controllers/Payment.php new file mode 100644 index 0000000..02fcd8a --- /dev/null +++ b/app/Controllers/Payment.php @@ -0,0 +1,264 @@ +paymentModel = new PaymentModel(); + } + + public function index(): string + { + return view('payment/payment_search'); + } + + public function paymentRecord() + { + $paymentId = (int) preg_replace('/\D+/', '', (string) $this->request->getPost('txtsearch')); + if ($paymentId <= 0) { + return $this->searchResponse('
Enter a valid Payment ID.
', 422); + } + + $payment = $this->paymentModel->findPayment($paymentId); + if ($payment === null) { + return $this->searchResponse('
Payment record not found.
', 404); + } + + return $this->searchResponse($this->renderPayment($payment)); + } + + public function changeToBank() + { + $paymentId = (int) $this->request->getPost('pay_id'); + $sourceId = (int) $this->request->getPost('cbo_pay_type'); + $transactionId = trim((string) $this->request->getPost('input_card_tran')); + $source = $this->findBankSource($sourceId); + + if ($source === null || $transactionId === '') { + return $this->error('Bank/online source and transaction reference are required.', 422); + } + + $label = (string) $source['label']; + return $this->applyCorrection($paymentId, [ + 'payment_mode' => 2, + 'pay_bank_id' => $sourceId, + 'insert_code' => (string) $sourceId, + 'card_bank' => $label, + 'card_tran_id' => $transactionId, + 'update_remark' => 'Changed to Bank/Online', + ], 2, 'Payment mode changed to BANK [' . $label . '], reference ' . $transactionId); + } + + public function changeToCash() + { + $paymentId = (int) $this->request->getPost('pay_id'); + + return $this->applyCorrection($paymentId, [ + 'payment_mode' => 1, + 'pay_bank_id' => 0, + 'card_bank' => '', + 'card_remark' => '', + 'cust_card' => '', + 'card_tran_id' => '', + 'bankcard_machine' => '', + 'insert_code' => '', + 'update_remark' => 'Changed to Cash', + ], 1, 'Payment mode changed to CASH'); + } + + public function changeUser() + { + $paymentId = (int) $this->request->getPost('pay_id'); + $userId = (int) $this->request->getPost('user_list'); + $user = $this->db->table('users')->select('id,username')->where('id', $userId)->where('active', 1)->get(1)->getRowArray(); + if ($user === null) { + return $this->error('Select a valid active user.', 422); + } + + $payment = $this->paymentModel->findPayment($paymentId); + $oldUser = trim((string) ($payment['update_by'] ?? '-')); + $newUser = (string) $user['username'] . '[' . $userId . ']'; + + return $this->applyCorrection($paymentId, [ + 'update_by_id' => $userId, + 'update_by' => $newUser, + 'update_remark' => 'Payment user corrected', + ], 3, 'Payment user changed from ' . $oldUser . ' to ' . $newUser); + } + + public function changeAmount() + { + $paymentId = (int) $this->request->getPost('pay_id'); + $rawAmount = trim((string) $this->request->getPost('change_value')); + if (! is_numeric($rawAmount) || (float) $rawAmount <= 0) { + return $this->error('Enter a valid amount greater than zero.', 422); + } + + $payment = $this->paymentModel->findPayment($paymentId); + $oldAmount = number_format((float) ($payment['amount'] ?? 0), 2, '.', ''); + $newAmount = number_format((float) $rawAmount, 2, '.', ''); + + return $this->applyCorrection($paymentId, [ + 'amount' => $newAmount, + 'update_remark' => 'Payment amount corrected', + ], 4, 'Payment amount changed from ' . $oldAmount . ' to ' . $newAmount); + } + + private function applyCorrection(int $paymentId, array $changes, int $updateType, string $remark) + { + if ($paymentId <= 0) { + return $this->error('Payment record not found.', 404); + } + + $reason = trim((string) $this->request->getPost('correction_reason')); + if ($reason === '') { + return $this->error('Correction reason is required.', 422); + } + + try { + $payment = $this->paymentModel->correctPayment($paymentId, $changes, $updateType, $this->actorName(), $remark . '. Reason: ' . $reason); + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'Payment updated successfully.', + 'html' => $this->renderPayment($payment), + 'csrf_hash' => csrf_hash(), + ]); + } catch (RuntimeException $e) { + $status = str_contains(strtolower($e->getMessage()), 'not found') ? 404 : 409; + return $this->error($e->getMessage(), $status); + } catch (Throwable $e) { + log_message('error', 'Payment correction failed: {message}', ['message' => $e->getMessage()]); + return $this->error('Unable to update the payment record.', 500); + } + } + + private function renderPayment(array $payment): string + { + $context = $this->paymentContext($payment); + + return view('payment/payment_edit', [ + 'payment' => $payment, + 'context' => $context, + 'users' => $this->db->table('users')->select('id,username')->where('active', 1)->orderBy('username')->get()->getResultArray(), + 'bank_sources' => $this->bankSources(), + ]); + } + + private function paymentContext(array $payment): array + { + $type = (int) ($payment['payof_type'] ?? 0); + $referenceId = (int) ($payment['payof_id'] ?? 0); + $context = [ + 'type' => [1 => 'OPD', 2 => 'OPD Charge', 3 => 'Organization Charge', 4 => 'IPD Payment'][$type] ?? 'Payment', + 'code' => (string) ($payment['payof_code'] ?? ''), + 'patient' => '', + ]; + + $tableMap = [ + 1 => ['opd_master', 'opd_id', ['opd_code'], ['p_id'], ['p_name', 'P_name']], + 2 => ['invoice_master', 'id', ['invoice_code'], ['attach_id'], ['inv_name']], + 3 => ['organization_case_master', 'id', ['case_id_code'], ['p_id'], ['p_name', 'insurance_company_name']], + 4 => ['ipd_master', 'id', ['ipd_code'], ['p_id'], ['P_name', 'p_name']], + ]; + + if (! isset($tableMap[$type])) { + return $context; + } + + [$table, $key, $codeColumns, $patientColumns, $nameColumns] = $tableMap[$type]; + if (! $this->db->tableExists($table)) { + return $context; + } + + $row = $this->db->table($table)->where($key, $referenceId)->get(1)->getRowArray(); + if ($row === null) { + return $context; + } + + $context['code'] = $this->firstValue($row, $codeColumns, $context['code']); + $context['patient'] = $this->firstValue($row, $nameColumns); + $patientId = (int) $this->firstValue($row, $patientColumns, '0'); + if ($context['patient'] === '' && $patientId > 0) { + foreach (['patient_master', 'patient_master_exten'] as $patientTable) { + if (! $this->db->tableExists($patientTable)) { + continue; + } + $patient = $this->db->table($patientTable)->where('id', $patientId)->get(1)->getRowArray(); + if ($patient !== null) { + $context['patient'] = trim((string) ($patient['p_fname'] ?? '') . ' ' . (string) ($patient['p_lname'] ?? '')); + break; + } + } + } + + return $context; + } + + private function bankSources(): array + { + if (! $this->db->tableExists('hospital_bank_payment_source') || ! $this->db->tableExists('hospital_bank')) { + return []; + } + + return $this->db->table('hospital_bank_payment_source s') + ->select("s.id, CONCAT(COALESCE(s.pay_type, ''), ' [', COALESCE(b.bank_name, ''), ']') AS label", false) + ->join('hospital_bank b', 'b.id = s.bank_id', 'left') + ->orderBy('s.pay_type') + ->get() + ->getResultArray(); + } + + private function findBankSource(int $sourceId): ?array + { + foreach ($this->bankSources() as $source) { + if ((int) $source['id'] === $sourceId) { + return $source; + } + } + + return null; + } + + private function firstValue(array $row, array $columns, string $default = ''): string + { + foreach ($columns as $column) { + if (isset($row[$column]) && trim((string) $row[$column]) !== '') { + return trim((string) $row[$column]); + } + } + + return $default; + } + + private function actorName(): string + { + $user = auth()->user(); + return $user ? ((string) ($user->username ?? 'user') . '[' . (int) $user->id . ']') : 'system'; + } + + private function searchResponse(string $body, int $status = 200) + { + return $this->response + ->setStatusCode($status) + ->setHeader('X-CSRF-TOKEN', csrf_hash()) + ->setBody($body); + } + + private function error(string $message, int $status) + { + return $this->response->setStatusCode($status)->setJSON([ + 'status' => 0, + 'message' => $message, + 'csrf_hash' => csrf_hash(), + ]); + } +} \ No newline at end of file diff --git a/app/Database/Migrations/2026-08-10-000006_AddFinancePaymentEditPermission.php b/app/Database/Migrations/2026-08-10-000006_AddFinancePaymentEditPermission.php new file mode 100644 index 0000000..4dddb9e --- /dev/null +++ b/app/Database/Migrations/2026-08-10-000006_AddFinancePaymentEditPermission.php @@ -0,0 +1,58 @@ +tableExists('auth_roles') || ! $this->tableExists('auth_role_permissions')) { + return; + } + + $now = date('Y-m-d H:i:s'); + foreach (['superadmin', 'admin', 'developer', 'accounts_officer'] as $alias) { + $role = $this->db->table('auth_roles')->select('id')->where('alias', $alias)->get(1)->getRowArray(); + if ($role === null) { + continue; + } + + $exists = $this->db->table('auth_role_permissions') + ->where('role_id', (int) $role['id']) + ->where('permission', self::PERMISSION) + ->countAllResults(); + if ($exists === 0) { + $this->db->table('auth_role_permissions')->insert([ + 'role_id' => (int) $role['id'], + 'permission' => self::PERMISSION, + 'created_at' => $now, + ]); + } + } + + (new RoleRegistry($this->db))->publish(); + } + + public function down() + { + if ($this->tableExists('auth_role_permissions')) { + $this->db->table('auth_role_permissions')->where('permission', self::PERMISSION)->delete(); + } + if ($this->tableExists('auth_permissions_users')) { + $this->db->table('auth_permissions_users')->where('permission', self::PERMISSION)->delete(); + } + if ($this->tableExists('auth_roles')) { + (new RoleRegistry($this->db))->publish(); + } + } + + private function tableExists(string $table): bool + { + return $this->db->query('SHOW TABLES LIKE ' . $this->db->escape($table))->getRowArray() !== null; + } +} \ No newline at end of file diff --git a/app/Database/Migrations/2026-08-10-000007_BackfillFinancePaymentEditUserPermission.php b/app/Database/Migrations/2026-08-10-000007_BackfillFinancePaymentEditUserPermission.php new file mode 100644 index 0000000..19d8856 --- /dev/null +++ b/app/Database/Migrations/2026-08-10-000007_BackfillFinancePaymentEditUserPermission.php @@ -0,0 +1,58 @@ +tableExists('auth_permissions_users')) { + return; + } + + $eligiblePermissions = [ + 'finance.cash.accounts.accept', + 'finance.cash.accounts.verify', + 'finance.bank.audit', + 'users.manage-admins', + ]; + $userIds = array_unique(array_column( + $this->db->table('auth_permissions_users') + ->select('user_id') + ->whereIn('permission', $eligiblePermissions) + ->get() + ->getResultArray(), + 'user_id' + )); + + foreach ($userIds as $userId) { + $exists = $this->db->table('auth_permissions_users') + ->where('user_id', (int) $userId) + ->where('permission', self::PERMISSION) + ->countAllResults(); + if ($exists === 0) { + $this->db->table('auth_permissions_users')->insert([ + 'user_id' => (int) $userId, + 'permission' => self::PERMISSION, + 'created_at' => date('Y-m-d H:i:s'), + ]); + } + } + } + + public function down() + { + if ($this->tableExists('auth_permissions_users')) { + $this->db->table('auth_permissions_users')->where('permission', self::PERMISSION)->delete(); + } + } + + private function tableExists(string $table): bool + { + return $this->db->query('SHOW TABLES LIKE ' . $this->db->escape($table))->getRowArray() !== null; + } +} \ No newline at end of file diff --git a/app/Models/PaymentModel.php b/app/Models/PaymentModel.php index ceeff06..489f728 100644 --- a/app/Models/PaymentModel.php +++ b/app/Models/PaymentModel.php @@ -3,14 +3,15 @@ namespace App\Models; use CodeIgniter\Database\BaseConnection; +use RuntimeException; class PaymentModel { private BaseConnection $db; - public function __construct() + public function __construct(?BaseConnection $db = null) { - $this->db = db_connect(); + $this->db = $db ?? db_connect(); } public function insertPayment(array $data): int @@ -24,4 +25,59 @@ class PaymentModel { return (bool) $this->db->table('payment_history')->where('id', $id)->update($data); } + + public function findPayment(int $id): ?array + { + return $this->db->table('payment_history')->where('id', $id)->get(1)->getRowArray(); + } + + public function assertEditable(array $payment): void + { + $cashStatus = strtolower(trim((string) ($payment['cash_submission_status'] ?? 'open'))); + if ($cashStatus !== '' && $cashStatus !== 'open') { + throw new RuntimeException('This payment is already included in a cash submission and cannot be edited.'); + } + if ((int) ($payment['cash_submission_scroll_id'] ?? 0) > 0) { + throw new RuntimeException('This payment is linked to a cash submission and cannot be edited.'); + } + + $bankStatus = strtolower(trim((string) ($payment['bank_reconcile_status'] ?? ''))); + if ($bankStatus !== '' || (int) ($payment['bank_statement_entry_id'] ?? 0) > 0 || (int) ($payment['bank_settlement_entry_id'] ?? 0) > 0) { + throw new RuntimeException('This payment is already reconciled with the bank and cannot be edited.'); + } + } + + public function correctPayment(int $id, array $changes, int $updateType, string $actor, string $remark): array + { + $payment = $this->findPayment($id); + if ($payment === null) { + throw new RuntimeException('Payment record not found.'); + } + + $this->assertEditable($payment); + $fields = $this->db->getFieldNames('payment_history') ?? []; + $changes = array_intersect_key($changes, array_flip($fields)); + if ($changes === []) { + throw new RuntimeException('No supported payment fields were provided.'); + } + if (! $this->db->tableExists('payment_history_log')) { + throw new RuntimeException('Payment audit log table is unavailable.'); + } + + $this->db->transStart(); + $this->db->table('payment_history')->where('id', $id)->update($changes); + $this->db->table('payment_history_log')->insert([ + 'pay_id' => $id, + 'update_type' => $updateType, + 'update_by' => mb_substr($actor, 0, 50), + 'update_remark' => mb_substr($remark, 0, 200), + ]); + $this->db->transComplete(); + + if (! $this->db->transStatus()) { + throw new RuntimeException('Unable to update the payment record.'); + } + + return $this->findPayment($id) ?? $payment; + } } diff --git a/app/Views/Setting/Admin/UserManagement/index.php b/app/Views/Setting/Admin/UserManagement/index.php index 431e912..ed76c89 100644 --- a/app/Views/Setting/Admin/UserManagement/index.php +++ b/app/Views/Setting/Admin/UserManagement/index.php @@ -1,3 +1,4 @@ +

Users

@@ -167,4 +168,5 @@ }); }); })(); - \ No newline at end of file + +
\ No newline at end of file diff --git a/app/Views/partials/sidebar.php b/app/Views/partials/sidebar.php index 60555b9..689603c 100644 --- a/app/Views/partials/sidebar.php +++ b/app/Views/partials/sidebar.php @@ -92,16 +92,19 @@ if ($user && method_exists($user, 'can')) { || $user->can('finance.bank.deposit.create') || $user->can('finance.bank.audit') || $user->can('finance.bank.statement.update') + || $user->can('finance.payment.edit') || $user->can('finance.*'); } $canFinanceBilling = false; $canFinanceAccounts = false; $canFinanceBank = false; +$canFinancePaymentEdit = false; if ($user && method_exists($user, 'can')) { $canFinanceBilling = $user->can('finance.cash.billing.submit') || $user->can('finance.*'); $canFinanceAccounts = $user->can('finance.cash.accounts.accept') || $user->can('finance.cash.accounts.verify') || $user->can('finance.*'); $canFinanceBank = $user->can('finance.bank.deposit.create') || $user->can('finance.bank.audit') || $user->can('finance.bank.statement.update') || $user->can('finance.*'); + $canFinancePaymentEdit = $user->can('finance.payment.edit') || $user->can('finance.*'); } $canDoctorWork = false; @@ -294,6 +297,14 @@ if (! $canChargesSettings && $user && method_exists($user, 'inGroup')) { + + +