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 @@
+
\ 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')) {
+
+
+
+
+ Payment Edit
+
+
+
diff --git a/app/Views/payment/payment_edit.php b/app/Views/payment/payment_edit.php
new file mode 100644
index 0000000..612d752
--- /dev/null
+++ b/app/Views/payment/payment_edit.php
@@ -0,0 +1,101 @@
+ 0
+ || $bankStatus !== ''
+ || (int) ($payment['bank_statement_entry_id'] ?? 0) > 0
+ || (int) ($payment['bank_settlement_entry_id'] ?? 0) > 0;
+?>
+
+
+
+
+
+
Payment ID: = esc((string) $paymentId) ?>
+
= esc((string) ($payment['payment_date'] ?? $payment['insert_time'] ?? '')) ?>
+
+
= $paymentMode === 1 ? 'CASH' : 'BANK / ONLINE' ?>
+
+
+
+
Invoice Type
= esc((string) ($context['type'] ?? '-')) ?>
+
Invoice / Visit No.
= esc((string) ($context['code'] ?? '-')) ?>
+
Patient
= esc((string) ($context['patient'] ?? '-')) ?>
+
Amount
Rs. = esc(number_format((float) ($payment['amount'] ?? 0), 2)) ?>
+
+
+
+
This payment is already submitted or bank-reconciled. Corrections are locked to preserve the finance audit trail.
+
+
+
+
+
+
+
+ required>
+
+
+
+
Payment Mode
+
+
+
+
+
+
+
+
+ >
+
+
+
+
+
+
Source: = esc((string) ($payment['card_bank'] ?? 'Bank/Online')) ?>
Reference: = esc((string) ($payment['card_tran_id'] ?? '-')) ?>
+
+
+
+
+
+
+
Recorded User
+
+
+
+
+
+
+
+
+
+
+
Payment Amount
+
+
+
+ >
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/Views/payment/payment_search.php b/app/Views/payment/payment_search.php
new file mode 100644
index 0000000..a468586
--- /dev/null
+++ b/app/Views/payment/payment_search.php
@@ -0,0 +1,124 @@
+
+
+
+
+
\ No newline at end of file