diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 912ebd8..db62ad3 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -251,44 +251,59 @@ $routes->get('Finance', 'Finance::index'); $routes->get('Finance/index', 'Finance::index'); $routes->get('finance', 'Finance::index'); $routes->get('finance/index', 'Finance::index'); -$routes->get('Finance/phase2', 'Finance::phase2'); -$routes->get('Finance/section/vendor_master', 'Finance::sectionVendorMaster'); -$routes->get('Finance/section/purchase_order', 'Finance::sectionPurchaseOrder'); -$routes->get('Finance/section/grn_entry', 'Finance::sectionGrnEntry'); -$routes->get('Finance/section/vendor_invoice', 'Finance::sectionVendorInvoice'); -$routes->get('Finance/vendors_table', 'Finance::vendorsTable'); -$routes->get('Finance/po_table', 'Finance::poTable'); -$routes->get('Finance/po_documents/(:num)', 'Finance::poDocuments/$1'); -$routes->get('Finance/grn_table', 'Finance::grnTable'); -$routes->get('Finance/grn_print/(:num)', 'Finance::grnPrint/$1'); -$routes->get('Finance/invoice_table', 'Finance::invoiceTable'); -$routes->get('Finance/cashbook', 'Finance::cashbook'); +$routes->get('Finance/cashbook', 'Finance::cashbookAccounts'); +$routes->get('Finance/cashbook/accounts', 'Finance::cashbookAccounts'); +$routes->get('billing/cash-submission', 'Finance::cashbookBilling'); +$routes->get('billing/cash-submission/create', 'Finance::cashbookBilling'); $routes->get('Finance/cash_transactions_table', 'Finance::cashTransactionsTable'); $routes->get('Finance/scroll_table', 'Finance::scrollTable'); -$routes->get('Finance/doctor_payout', 'Finance::doctorPayout'); -$routes->get('Finance/doctor_agreements_table', 'Finance::doctorAgreementsTable'); -$routes->get('Finance/doctor_payouts_table', 'Finance::doctorPayoutsTable'); +$routes->get('Finance/payment_selection_table', 'Finance::paymentSelectionTable'); +$routes->get('Finance/scroll_items_table', 'Finance::scrollItemsTable'); $routes->get('Finance/bank_deposits', 'Finance::bankDeposits'); $routes->get('Finance/bank_deposits_table', 'Finance::bankDepositsTable'); -$routes->get('Finance/compliance_report', 'Finance::complianceReport'); -$routes->post('Finance/vendor_create', 'Finance::vendorCreate'); -$routes->post('Finance/po_create', 'Finance::poCreate'); -$routes->post('Finance/po_update', 'Finance::poUpdate'); -$routes->post('Finance/grn_create', 'Finance::grnCreate'); -$routes->post('Finance/invoice_create', 'Finance::invoiceCreate'); +$routes->get('Finance/bank_audit', 'Finance::bankAudit'); +$routes->get('Finance/bank_audit_direct_payments_table', 'Finance::bankAuditDirectPaymentsTable'); +$routes->get('Finance/bank_statement_entries_table', 'Finance::bankStatementEntriesTable'); +$routes->get('Finance/bank_settlement_entries_table', 'Finance::bankSettlementEntriesTable'); +$routes->get('Finance/bank_settlement_linked_payments_table', 'Finance::bankSettlementLinkedPaymentsTable'); +$routes->get('Finance/bank_settlement_linked_payments_export', 'Finance::bankSettlementLinkedPaymentsExport'); +$routes->get('Finance/bank_pos_settlements_table', 'Finance::bankPosSettlementsTable'); $routes->post('Finance/cash_transaction_create', 'Finance::cashTransactionCreate'); +$routes->post('Finance/fetch_payments_for_period', 'Finance::fetchPaymentsForPeriod'); $routes->post('Finance/scroll_create', 'Finance::scrollCreate'); -$routes->post('Finance/doctor_agreement_create', 'Finance::doctorAgreementCreate'); -$routes->post('Finance/doctor_payout_create', 'Finance::doctorPayoutCreate'); -$routes->post('Finance/doctor_payout_approve', 'Finance::doctorPayoutApprove'); +$routes->post('Finance/scroll_accept', 'Finance::scrollAccept'); +$routes->post('Finance/scroll_verify', 'Finance::scrollVerify'); $routes->post('Finance/bank_deposit_create', 'Finance::bankDepositCreate'); -$routes->get('Finance/section/pharmacy_bills', 'Finance::sectionPharmacyBills'); -$routes->get('Finance/pharmacy_bills_table', 'Finance::pharmacyBillsTable'); -$routes->post('Finance/pharmacy_bill_create', 'Finance::pharmacyBillCreate'); -$routes->post('Finance/pharmacy_bill_settle', 'Finance::pharmacyBillSettle'); +$routes->post('Finance/bank_deposit_status_update', 'Finance::bankDepositStatusUpdate'); +$routes->post('Finance/bank_statement_entry_create', 'Finance::bankStatementEntryCreate'); +$routes->post('Finance/bank_reconcile_match', 'Finance::bankReconcileMatch'); +$routes->post('Finance/bank_reconcile_batch_match', 'Finance::bankReconcileBatchMatch'); +$routes->post('Finance/bank_settlement_create', 'Finance::bankSettlementCreate'); +$routes->post('Finance/bank_settlement_match_statement', 'Finance::bankSettlementMatchStatement'); +$routes->post('Finance/bank_reconcile_unmatch', 'Finance::bankReconcileUnmatch'); +$routes->post('Finance/bank_pos_settlement_create', 'Finance::bankPosSettlementCreate'); +$routes->post('Finance/bank_pos_settlement_accept', 'Finance::bankPosSettlementAccept'); service('auth')->routes($routes); +// Hospital Stock Management (Storestock) +$routes->get('Storestock', 'Storestock::index'); +$routes->get('Storestock/Indent_List', 'Storestock::indent_list'); +$routes->match(['get', 'post'], 'Storestock/getIndentTable', 'Storestock::getIndentTable'); +$routes->get('Storestock/new_indent', 'Storestock::new_indent'); +$routes->post('Storestock/Indent_create/(:num)', 'Storestock::indent_create/$1'); +$routes->get('Storestock/Indent_show/(:num)', 'Storestock::indent_show/$1'); +$routes->get('Storestock/Indent_Items_list/(:num)', 'Storestock::indent_items_list/$1'); +$routes->post('Storestock/add_item/(:num)', 'Storestock::add_item/$1'); +$routes->get('Storestock/get_drug', 'Storestock::get_drug'); +$routes->get('Storestock/final_invoice/(:num)', 'Storestock::final_invoice/$1'); +$routes->get('Storestock/print_single_indent/(:num)', 'Storestock::print_single_indent/$1'); +$routes->get('Storestock/store_stock', 'Storestock::store_stock'); +$routes->post('Storestock/store_Stock_result', 'Storestock::store_stock_result'); +$routes->match(['get', 'post'], 'Storestock/get_product_stock/(:num)', 'Storestock::get_product_stock/$1'); +$routes->get('Storestock/Report_2', 'Storestock::report_2'); +$routes->get('Storestock/main_store', 'Storestock::main_store'); + // Legacy compatibility: some IPD charge screens still refresh via old IpdNew URL. $routes->get('IpdNew/show_ipd_items/(:num)', 'Billing\\Ipd::panelTab/$1/ipd-charges'); $routes->get('IpdNew/show_ipd_items/(:num)/(:num)', 'Billing\\Ipd::panelTab/$1/ipd-charges'); diff --git a/app/Controllers/Finance.php b/app/Controllers/Finance.php index 9fc8ff1..4f7bc0e 100644 --- a/app/Controllers/Finance.php +++ b/app/Controllers/Finance.php @@ -10,6 +10,11 @@ use App\Models\FinanceDoctorPayoutModel; use App\Models\FinancePolicySettingModel; use App\Models\FinancePoDocumentModel; use App\Models\FinancePurchaseOrderModel; +use App\Models\FinanceBankPosSettlementModel; +use App\Models\FinanceBankReconciliationAuditModel; +use App\Models\FinanceBankSettlementEntryModel; +use App\Models\FinanceBankStatementEntryModel; +use App\Models\FinanceScrollSubmissionItemModel; use App\Models\FinanceScrollSubmissionModel; use App\Models\FinanceVendorInvoiceModel; use App\Models\FinancePharmacyBillModel; @@ -24,8 +29,13 @@ class Finance extends BaseController private FinanceVendorInvoiceModel $invoiceModel; private FinanceCashTransactionModel $cashTxnModel; private FinanceBankDepositModel $bankDepositModel; + private FinanceBankStatementEntryModel $bankStatementEntryModel; + private FinanceBankPosSettlementModel $bankPosSettlementModel; + private FinanceBankReconciliationAuditModel $bankReconcileAuditModel; + private FinanceBankSettlementEntryModel $bankSettlementEntryModel; private FinancePolicySettingModel $policySettingModel; private FinanceScrollSubmissionModel $scrollModel; + private FinanceScrollSubmissionItemModel $scrollItemModel; private FinanceDoctorAgreementModel $doctorAgreementModel; private FinanceDoctorPayoutModel $doctorPayoutModel; private FinancePharmacyBillModel $pharmBillModel; @@ -39,8 +49,13 @@ class Finance extends BaseController $this->invoiceModel = new FinanceVendorInvoiceModel(); $this->cashTxnModel = new FinanceCashTransactionModel(); $this->bankDepositModel = new FinanceBankDepositModel(); + $this->bankStatementEntryModel = new FinanceBankStatementEntryModel(); + $this->bankPosSettlementModel = new FinanceBankPosSettlementModel(); + $this->bankReconcileAuditModel = new FinanceBankReconciliationAuditModel(); + $this->bankSettlementEntryModel = new FinanceBankSettlementEntryModel(); $this->policySettingModel = new FinancePolicySettingModel(); $this->scrollModel = new FinanceScrollSubmissionModel(); + $this->scrollItemModel = new FinanceScrollSubmissionItemModel(); $this->doctorAgreementModel = new FinanceDoctorAgreementModel(); $this->doctorPayoutModel = new FinanceDoctorPayoutModel(); $this->pharmBillModel = new FinancePharmacyBillModel(); @@ -48,7 +63,7 @@ class Finance extends BaseController public function bankDeposits() { - if (! $this->canFinance('finance.bank_deposit.manage')) { + if (! $this->canFinanceAny(['finance.bank.deposit.create', 'finance.bank.audit', 'finance.bank.statement.update'])) { return $this->response->setStatusCode(403)->setBody('Access denied'); } @@ -61,7 +76,7 @@ class Finance extends BaseController public function bankDepositsTable() { - if (! $this->canFinance('finance.bank_deposit.manage')) { + if (! $this->canFinanceAny(['finance.bank.deposit.create', 'finance.bank.audit', 'finance.bank.statement.update'])) { return $this->response->setStatusCode(403)->setBody('Access denied'); } @@ -72,7 +87,7 @@ class Finance extends BaseController public function bankDepositCreate() { - if (! $this->canFinance('finance.bank_deposit.manage')) { + if (! $this->canFinance('finance.bank.deposit.create')) { return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']); } @@ -425,7 +440,34 @@ class Finance extends BaseController public function cashbook() { - if (! $this->canFinance('finance.cash.manage')) { + if (! $this->canFinanceAny(['finance.cash.accounts.accept', 'finance.cash.accounts.verify'])) { + return $this->response->setStatusCode(403)->setBody('Access denied'); + } + + return $this->renderCashbook(false, true); + } + + public function cashbookBilling() + { + if (! $this->canFinance('finance.cash.billing.submit')) { + return $this->response->setStatusCode(403)->setBody('Access denied'); + } + + return $this->renderCashbook(true, false); + } + + public function cashbookAccounts() + { + if (! $this->canFinanceAny(['finance.cash.accounts.accept', 'finance.cash.accounts.verify'])) { + return $this->response->setStatusCode(403)->setBody('Access denied'); + } + + return $this->renderCashbook(false, true); + } + + private function renderCashbook(bool $showBillingPanel, bool $showAccountsPanel) + { + if (! $this->canFinanceAny(['finance.cash.billing.submit', 'finance.cash.accounts.accept', 'finance.cash.accounts.verify'])) { return $this->response->setStatusCode(403)->setBody('Access denied'); } @@ -433,12 +475,16 @@ class Finance extends BaseController 'transactions' => $this->fetchCashTransactions(), 'scrolls' => $this->fetchScrolls(), 'summary' => $this->buildCashSummary(), + 'queue_summary' => $this->buildScrollQueueSummary(), + 'collector_options' => $this->fetchCollectorOptions(), + 'show_billing_panel' => $showBillingPanel, + 'show_accounts_panel' => $showAccountsPanel, ]); } public function cashTransactionsTable() { - if (! $this->canFinance('finance.cash.manage')) { + if (! $this->canFinanceAny(['finance.cash.billing.submit', 'finance.cash.accounts.accept', 'finance.cash.accounts.verify'])) { return $this->response->setStatusCode(403)->setBody('Access denied'); } @@ -449,7 +495,7 @@ class Finance extends BaseController public function scrollTable() { - if (! $this->canFinance('finance.cash.manage')) { + if (! $this->canFinanceAny(['finance.cash.billing.submit', 'finance.cash.accounts.accept', 'finance.cash.accounts.verify'])) { return $this->response->setStatusCode(403)->setBody('Access denied'); } @@ -458,9 +504,106 @@ class Finance extends BaseController ]); } + public function fetchPaymentsForPeriod() + { + if (! $this->canFinanceAny(['finance.cash.billing.submit', 'finance.cash.accounts.accept', 'finance.cash.accounts.verify'])) { + return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']); + } + + $startDateTime = trim((string) ($this->request->getPost('start_datetime') ?? $this->request->getGet('start_datetime') ?? '')); + $endDateTime = trim((string) ($this->request->getPost('end_datetime') ?? $this->request->getGet('end_datetime') ?? '')); + $collectedBy = trim((string) ($this->request->getPost('collected_by') ?? '')); + $paymentMode = 'cash'; + + if ($startDateTime === '' || $endDateTime === '') { + return $this->response->setStatusCode(422)->setJSON([ + 'status' => 0, + 'message' => 'Start and end date/time are required.', + ]); + } + + $startTs = strtotime($startDateTime); + $endTs = strtotime($endDateTime); + if ($startTs === false || $endTs === false || $startTs > $endTs) { + return $this->response->setStatusCode(422)->setJSON([ + 'status' => 0, + 'message' => 'Invalid collection period.', + ]); + } + + $startAt = date('Y-m-d H:i:s', $startTs); + $endAt = date('Y-m-d H:i:s', $endTs); + $rows = $this->fetchPaymentHistoryRows($startAt, $endAt, $collectedBy, $paymentMode); + $totals = $this->summarizePaymentHistoryRows($rows); + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'Payments loaded for selected period.', + 'start_datetime' => $startAt, + 'end_datetime' => $endAt, + 'total_receipts' => (float) ($totals['total_receipts'] ?? 0), + 'payment_count' => (int) ($totals['payment_count'] ?? 0), + ]); + } + + public function paymentSelectionTable() + { + if (! $this->canFinanceAny(['finance.cash.billing.submit', 'finance.cash.accounts.accept', 'finance.cash.accounts.verify'])) { + return $this->response->setStatusCode(403)->setBody('Access denied'); + } + + $startDateTime = trim((string) ($this->request->getGet('start_datetime') ?? '')); + $endDateTime = trim((string) ($this->request->getGet('end_datetime') ?? '')); + $collectedBy = trim((string) ($this->request->getGet('collected_by') ?? '')); + $paymentMode = 'cash'; + + $rows = []; + $totals = ['total_receipts' => 0.0, 'payment_count' => 0]; + if ($startDateTime !== '' && $endDateTime !== '') { + $startTs = strtotime($startDateTime); + $endTs = strtotime($endDateTime); + if ($startTs !== false && $endTs !== false && $startTs <= $endTs) { + $rows = $this->fetchPaymentHistoryRows( + date('Y-m-d H:i:s', $startTs), + date('Y-m-d H:i:s', $endTs), + $collectedBy, + $paymentMode + ); + $totals = $this->summarizePaymentHistoryRows($rows); + } + } + + return view('finance/partials/payment_selection_table', [ + 'rows' => $rows, + 'totals' => $totals, + ]); + } + + public function scrollItemsTable() + { + if (! $this->canFinanceAny(['finance.cash.billing.submit', 'finance.cash.accounts.accept', 'finance.cash.accounts.verify'])) { + return $this->response->setStatusCode(403)->setBody('Access denied'); + } + + $scrollId = (int) ($this->request->getGet('scroll_id') ?? 0); + if ($scrollId <= 0) { + return $this->response->setStatusCode(422)->setBody('Invalid statement id.'); + } + + $rows = $this->scrollItemModel + ->where('scroll_submission_id', $scrollId) + ->orderBy('id', 'ASC') + ->findAll(1000); + + return view('finance/partials/scroll_items_table', [ + 'rows' => $rows, + 'scroll_id' => $scrollId, + ]); + } + public function cashTransactionCreate() { - if (! $this->canFinance('finance.cash.manage')) { + if (! $this->canFinance('finance.cash.billing.submit')) { return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']); } @@ -539,33 +682,73 @@ class Finance extends BaseController public function scrollCreate() { - if (! $this->canFinance('finance.cash.manage')) { + if (! $this->canFinance('finance.cash.billing.submit')) { return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']); } - $scrollDate = trim((string) ($this->request->getPost('scroll_date') ?? '')); + $startDateTime = trim((string) ($this->request->getPost('start_datetime') ?? '')); + $endDateTime = trim((string) ($this->request->getPost('end_datetime') ?? '')); $department = trim((string) ($this->request->getPost('department') ?? '')); + $collectedBy = trim((string) ($this->request->getPost('collected_by') ?? '')); + $paymentMode = 'cash'; $submittedAmount = (float) ($this->request->getPost('submitted_amount') ?? 0); + $selectedPaymentIds = $this->request->getPost('payment_ids'); + $paymentIds = []; - if ($scrollDate === '' || $department === '' || $submittedAmount < 0) { + if (is_array($selectedPaymentIds)) { + foreach ($selectedPaymentIds as $pid) { + $id = (int) $pid; + if ($id > 0) { + $paymentIds[] = $id; + } + } + $paymentIds = array_values(array_unique($paymentIds)); + } + + if (empty($paymentIds)) { return $this->response->setStatusCode(422)->setJSON([ 'status' => 0, - 'message' => 'Scroll date, department and submitted amount are required.', + 'message' => 'Select at least one payment to submit.', ]); } - $row = $this->cashTxnModel - ->select('COALESCE(SUM(amount),0) AS total') - ->where('txn_date', $scrollDate) - ->where('txn_type', 'receipt') - ->where('department', $department) - ->first(); + if ($startDateTime === '' || $endDateTime === '') { + return $this->response->setStatusCode(422)->setJSON([ + 'status' => 0, + 'message' => 'Start and end date/time are required.', + ]); + } - $totalReceipts = (float) ($row['total'] ?? 0); + $startTs = strtotime($startDateTime); + $endTs = strtotime($endDateTime); + if ($startTs === false || $endTs === false || $startTs > $endTs) { + return $this->response->setStatusCode(422)->setJSON([ + 'status' => 0, + 'message' => 'Invalid collection period.', + ]); + } + + $startAt = date('Y-m-d H:i:s', $startTs); + $endAt = date('Y-m-d H:i:s', $endTs); + $scrollDate = date('Y-m-d', $startTs); + $department = $department !== '' ? $department : 'Billing'; + $selectedRows = $this->fetchPaymentHistoryRows($startAt, $endAt, $collectedBy, $paymentMode, $paymentIds); + if (empty($selectedRows)) { + return $this->response->setStatusCode(422)->setJSON([ + 'status' => 0, + 'message' => 'Selected payments are not valid for the chosen filter.', + ]); + } + + $totals = $this->summarizePaymentHistoryRows($selectedRows); + + $totalReceipts = (float) ($totals['total_receipts'] ?? 0); + $paymentCount = (int) ($totals['payment_count'] ?? 0); + $submittedAmount = $totalReceipts; $variance = round($submittedAmount - $totalReceipts, 2); - $status = abs($variance) <= 0.01 ? 'matched' : 'pending'; + $status = 'pending'; - $this->scrollModel->insert([ + $insertData = [ 'scroll_date' => $scrollDate, 'department' => $department, 'total_receipts' => $totalReceipts, @@ -574,21 +757,1047 @@ class Finance extends BaseController 'reconciliation_status' => $status, 'submitted_by' => trim((string) ($this->request->getPost('submitted_by') ?? $this->currentUserName())), 'remarks' => trim((string) ($this->request->getPost('remarks') ?? '')), + ]; + + if ($this->tableHasField('finance_scroll_submissions', 'start_datetime')) { + $insertData['start_datetime'] = $startAt; + } + if ($this->tableHasField('finance_scroll_submissions', 'end_datetime')) { + $insertData['end_datetime'] = $endAt; + } + if ($this->tableHasField('finance_scroll_submissions', 'collected_by')) { + $insertData['collected_by'] = $collectedBy; + } + if ($this->tableHasField('finance_scroll_submissions', 'payment_count')) { + $insertData['payment_count'] = $paymentCount; + } + + $this->db->transStart(); + $this->scrollModel->insert($insertData); + $scrollId = (int) $this->scrollModel->getInsertID(); + + foreach ($selectedRows as $paymentRow) { + $this->scrollItemModel->insert([ + 'scroll_submission_id' => $scrollId, + 'payment_history_id' => (int) ($paymentRow['id'] ?? 0), + 'payment_date' => (string) ($paymentRow['payment_date'] ?? ''), + 'amount' => (float) ($paymentRow['amount'] ?? 0), + 'payment_mode' => (int) ($paymentRow['payment_mode'] ?? 0), + 'payof_type' => (int) ($paymentRow['payof_type'] ?? 0), + 'payof_id' => (int) ($paymentRow['payof_id'] ?? 0), + 'payof_code' => (string) ($paymentRow['payof_code'] ?? ''), + 'update_by_id' => (int) ($paymentRow['update_by_id'] ?? 0), + 'update_by' => (string) ($paymentRow['update_by'] ?? ''), + 'snapshot_json' => json_encode($paymentRow), + ]); + } + + if ($this->tableHasField('payment_history', 'cash_submission_status')) { + $paymentBuilder = $this->db->table('payment_history'); + $paymentBuilder->whereIn('id', $paymentIds)->update([ + 'cash_submission_status' => 'submitted', + ]); + + if ($this->tableHasField('payment_history', 'cash_submission_scroll_id')) { + $this->db->table('payment_history')->whereIn('id', $paymentIds)->update([ + 'cash_submission_scroll_id' => $scrollId, + ]); + } + + if ($this->tableHasField('payment_history', 'cash_submission_updated_at')) { + $this->db->table('payment_history')->whereIn('id', $paymentIds)->update([ + 'cash_submission_updated_at' => date('Y-m-d H:i:s'), + ]); + } + } + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + return $this->response->setStatusCode(500)->setJSON([ + 'status' => 0, + 'message' => 'Unable to save cash submission.', + ]); + } + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'Cash submission sent to Accounts with ' . $paymentCount . ' payments selected.', + ]); + } + + public function scrollAccept() + { + if (! $this->canFinance('finance.cash.accounts.accept')) { + return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']); + } + + $scrollId = (int) ($this->request->getPost('scroll_id') ?? 0); + if ($scrollId <= 0) { + return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Invalid statement id.']); + } + + $row = $this->scrollModel->find($scrollId); + if (! $row) { + return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Statement not found.']); + } + + $currentStatus = (string) ($row['reconciliation_status'] ?? 'pending'); + if (! in_array($currentStatus, ['submitted', 'pending'], true)) { + return $this->response->setStatusCode(422)->setJSON([ + 'status' => 0, + 'message' => 'Only submitted statements can be accepted. Current status: ' . $currentStatus . '.', + ]); + } + + $remarks = $this->appendAuditRemark((string) ($row['remarks'] ?? ''), 'Accepted by Accounts: ' . $this->currentUserName()); + + $this->scrollModel->update($scrollId, [ + 'reconciliation_status' => 'received', + 'remarks' => $remarks, ]); return $this->response->setJSON([ 'status' => 1, - 'message' => 'Scroll submitted. Reconciliation status: ' . $status . '.', + 'message' => 'Cash submission marked as Received by Accounts.', ]); } + public function scrollVerify() + { + if (! $this->canFinance('finance.cash.accounts.verify')) { + return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']); + } + + $scrollId = (int) ($this->request->getPost('scroll_id') ?? 0); + if ($scrollId <= 0) { + return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Invalid statement id.']); + } + + $row = $this->scrollModel->find($scrollId); + if (! $row) { + return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Statement not found.']); + } + + $currentStatus = (string) ($row['reconciliation_status'] ?? 'pending'); + if (! in_array($currentStatus, ['accepted', 'received', 'matched'], true)) { + return $this->response->setStatusCode(422)->setJSON([ + 'status' => 0, + 'message' => 'Only accepted statements can be verified. Current status: ' . $currentStatus . '.', + ]); + } + + $remarks = $this->appendAuditRemark((string) ($row['remarks'] ?? ''), 'Verified by Accounts: ' . $this->currentUserName()); + + $this->db->transStart(); + $this->scrollModel->update($scrollId, [ + 'reconciliation_status' => 'deposited', + 'remarks' => $remarks, + ]); + + if ($this->tableHasField('payment_history', 'cash_submission_status')) { + $itemRows = $this->scrollItemModel + ->select('payment_history_id') + ->where('scroll_submission_id', $scrollId) + ->findAll(); + + $paymentIds = []; + foreach ($itemRows as $item) { + $pid = (int) ($item['payment_history_id'] ?? 0); + if ($pid > 0) { + $paymentIds[] = $pid; + } + } + + $paymentIds = array_values(array_unique($paymentIds)); + if (! empty($paymentIds)) { + $this->db->table('payment_history')->whereIn('id', $paymentIds)->update([ + 'cash_submission_status' => 'deposited', + ]); + + if ($this->tableHasField('payment_history', 'cash_submission_updated_at')) { + $this->db->table('payment_history')->whereIn('id', $paymentIds)->update([ + 'cash_submission_updated_at' => date('Y-m-d H:i:s'), + ]); + } + } + } + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + return $this->response->setStatusCode(500)->setJSON([ + 'status' => 0, + 'message' => 'Unable to mark submission as deposited.', + ]); + } + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'Cash submission marked as Deposited.', + ]); + } + + public function bankDepositStatusUpdate() + { + $depositId = (int) ($this->request->getPost('deposit_id') ?? 0); + $action = trim((string) ($this->request->getPost('action') ?? '')); + + if ($depositId <= 0 || ! in_array($action, ['audit', 'statement_update'], true)) { + return $this->response->setStatusCode(422)->setJSON([ + 'status' => 0, + 'message' => 'Invalid request for bank status update.', + ]); + } + + if ($action === 'audit' && ! $this->canFinance('finance.bank.audit')) { + return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']); + } + + if ($action === 'statement_update' && ! $this->canFinance('finance.bank.statement.update')) { + return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']); + } + + $row = $this->bankDepositModel->find($depositId); + if (! $row) { + return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Bank deposit not found.']); + } + + $nextStatus = $action === 'audit' ? 'audited' : 'statement_updated'; + $note = $action === 'audit' + ? 'Bank transaction audited by Accounts: ' . $this->currentUserName() + : 'Updated in bank statement by Accounts: ' . $this->currentUserName(); + + $remarks = $this->appendAuditRemark((string) ($row['remarks'] ?? ''), $note); + + $this->bankDepositModel->update($depositId, [ + 'reconciliation_status' => $nextStatus, + 'remarks' => $remarks, + ]); + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'Bank deposit status updated to ' . $nextStatus . '.', + ]); + } + + // ─── Bank Audit: Main page ───────────────────────────────────────────────── + + public function bankAudit() + { + if (! $this->canFinanceAny(['finance.bank.audit', 'finance.bank.statement.update'])) { + return $this->response->setStatusCode(403)->setBody('Access denied'); + } + + $db = \Config\Database::connect(); + + // Summary cards (direct bank payments + settlement entries) + $directUnmatched = $db->table('payment_history') + ->where('payment_mode', 2) + ->where('credit_debit', 0) + ->where("(bank_reconcile_status IS NULL OR bank_reconcile_status = '')") + ->countAllResults(); + + $directMatched = $db->table('payment_history') + ->where('payment_mode', 2) + ->where('credit_debit', 0) + ->where('bank_reconcile_status', 'matched') + ->countAllResults(); + + $settlementUnmatched = $db->table('finance_bank_settlement_entries') + ->where('reconciliation_status', 'unmatched') + ->countAllResults(); + + $settlementMatched = $db->table('finance_bank_settlement_entries') + ->where('reconciliation_status', 'matched') + ->countAllResults(); + + // Use same bank source master as billing invoice screen. + $bankSourceOptions = $db->table('hospital_bank_payment_source s') + ->select('s.id, s.pay_type, m.bank_name') + ->join('hospital_bank m', 'm.id = s.bank_id', 'left') + ->orderBy('m.bank_name', 'ASC') + ->orderBy('s.pay_type', 'ASC') + ->get()->getResultArray(); + + $bankOptions = $db->table('hospital_bank') + ->select('id, bank_name') + ->orderBy('bank_name', 'ASC') + ->get()->getResultArray(); + + // Distinct users by ID (avoids duplicate timestamped update_by strings). + $acceptedByUsers = $db->table('payment_history p') + ->select('p.update_by_id AS user_id, COALESCE(MAX(u.username), MAX(p.update_by)) AS user_name', false) + ->join('users u', 'u.id = p.update_by_id', 'left') + ->where('p.payment_mode', 2) + ->where('p.credit_debit', 0) + ->where('p.update_by_id >', 0) + ->groupBy('p.update_by_id') + ->orderBy('user_name', 'ASC') + ->get()->getResultArray(); + + return view('finance/bank_audit', [ + 'direct_unmatched' => $directUnmatched, + 'direct_matched' => $directMatched, + 'sett_unmatched' => $settlementUnmatched, + 'sett_matched' => $settlementMatched, + 'bank_options' => $bankOptions, + 'bank_source_options'=> $bankSourceOptions, + 'accepted_by_users' => $acceptedByUsers, + ]); + } + + // ─── Bank Audit: Direct reconciliation tables ────────────────────────────── + + public function bankAuditDirectPaymentsTable() + { + if (! $this->canFinanceAny(['finance.bank.audit', 'finance.bank.statement.update'])) { + return $this->response->setStatusCode(403)->setBody('Access denied'); + } + + $fromDate = $this->normalizeDateInput((string) ($this->request->getGet('from_date') ?? '')); + $toDate = $this->normalizeDateInput((string) ($this->request->getGet('to_date') ?? '')); + $status = trim((string) ($this->request->getGet('status') ?? 'unmatched')); + $bankId = (int) ($this->request->getGet('bank_id') ?? $this->request->getGet('bank_name') ?? 0); + $acceptedBy = (int) ($this->request->getGet('accepted_by') ?? 0); + + $db = \Config\Database::connect(); + $q = $db->table('payment_history ph') + ->select("ph.id, ph.payment_date, ph.amount, ph.payment_mode, ph.card_tran_id, ph.card_remark, ph.cust_card, ph.remark, ph.update_by, ph.update_by_id, ph.bank_reconcile_status, ph.bank_statement_entry_id, ph.bank_reconcile_batch_ref, ph.bankcard_machine, ph.insert_code, ph.payof_type, ph.payof_id, ph.payof_code, + COALESCE(MAX(u.username), MAX(ph.update_by)) AS accepted_by_name, + MAX(CONCAT(COALESCE(s.pay_type,''), ' [', COALESCE(m.bank_name,''), ']')) AS bank_source_label, + MAX(se.id) AS bank_settlement_entry_id, + MAX(se.settlement_ref) AS settlement_ref, + (SELECT bra.action_by FROM finance_bank_reconciliation_audit bra + WHERE bra.payment_history_id = ph.id + AND bra.action_type IN ('single_match','batch_match','settlement_match') + ORDER BY bra.id DESC LIMIT 1) AS matched_by, + (SELECT bra.action_at FROM finance_bank_reconciliation_audit bra + WHERE bra.payment_history_id = ph.id + AND bra.action_type IN ('single_match','batch_match','settlement_match') + ORDER BY bra.id DESC LIMIT 1) AS matched_at", false) + ->join('users u', 'u.id = ph.update_by_id', 'left') + ->join('hospital_bank_payment_source s', 'CAST(s.id AS CHAR) = ph.insert_code', 'left') + ->join('hospital_bank m', 'm.id = s.bank_id', 'left') + ->join('finance_bank_settlement_entries se', 'se.id = ph.bank_settlement_entry_id', 'left') + ->where('ph.payment_mode', 2) + ->where('ph.credit_debit', 0) + ->groupBy('ph.id'); + + if ($status === 'unmatched') { + $q->where("(ph.bank_reconcile_status IS NULL OR ph.bank_reconcile_status = '')"); + } elseif ($status === 'matched') { + $q->where('ph.bank_reconcile_status', 'matched'); + } + + if ($fromDate !== '') { + $q->where('DATE(ph.payment_date) >=', $fromDate); + } + if ($toDate !== '') { + $q->where('DATE(ph.payment_date) <=', $toDate); + } + if ($bankId > 0) { + $q->where('m.id', $bankId); + } + if ($acceptedBy > 0) { + $q->where('ph.update_by_id', $acceptedBy); + } + + $rows = $q->orderBy('ph.payment_date', 'DESC')->limit(200)->get()->getResultArray(); + + return view('finance/partials/bank_audit_direct_table', ['rows' => $rows, 'status_filter' => $status]); + } + + public function bankStatementEntriesTable() + { + if (! $this->canFinanceAny(['finance.bank.audit', 'finance.bank.statement.update'])) { + return $this->response->setStatusCode(403)->setBody('Access denied'); + } + + $fromDate = $this->normalizeDateInput((string) ($this->request->getGet('from_date') ?? '')); + $toDate = $this->normalizeDateInput((string) ($this->request->getGet('to_date') ?? '')); + $status = trim((string) ($this->request->getGet('status') ?? 'unmatched')); + + $q = $this->bankStatementEntryModel->orderBy('entry_date', 'DESC'); + + if ($status !== 'all') { + $q->where('reconciliation_status', $status); + } + if ($fromDate !== '') { + $q->where('entry_date >=', $fromDate); + } + if ($toDate !== '') { + $q->where('entry_date <=', $toDate); + } + + $entries = $q->limit(200)->findAll(); + + return view('finance/partials/bank_statement_entries_table', ['entries' => $entries]); + } + + public function bankStatementEntryCreate() + { + if (! $this->canFinance('finance.bank.audit')) { + return $this->response->setStatusCode(403)->setJSON([ + 'status' => 0, 'message' => 'Access denied', + ]); + } + + $entryDate = trim((string) ($this->request->getPost('entry_date') ?? '')); + $amount = (float) ($this->request->getPost('amount') ?? 0); + $refNo = trim((string) ($this->request->getPost('reference_no') ?? '')); + $narration = trim((string) ($this->request->getPost('narration') ?? '')); + $txnType = trim((string) ($this->request->getPost('transaction_type') ?? 'credit')); + $remarks = trim((string) ($this->request->getPost('remarks') ?? '')); + + if ($entryDate === '' || $amount <= 0) { + return $this->response->setStatusCode(422)->setJSON([ + 'status' => 0, 'message' => 'Entry date and amount are required.', + ]); + } + + if (! in_array($txnType, ['credit', 'debit'], true)) { + $txnType = 'credit'; + } + + $this->bankStatementEntryModel->insert([ + 'entry_date' => $entryDate, + 'reference_no' => $refNo !== '' ? $refNo : null, + 'narration' => $narration !== '' ? $narration : null, + 'amount' => $amount, + 'transaction_type' => $txnType, + 'reconciliation_status'=> 'unmatched', + 'remarks' => $remarks !== '' ? $remarks : null, + 'created_by' => $this->currentUserName(), + ]); + + return $this->response->setJSON(['status' => 1, 'message' => 'Bank statement entry saved.']); + } + + public function bankReconcileMatch() + { + if (! $this->canFinance('finance.bank.audit')) { + return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']); + } + + $paymentId = (int) ($this->request->getPost('payment_id') ?? 0); + $remarks = trim((string) ($this->request->getPost('remarks') ?? '')); + + if ($paymentId <= 0) { + return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Payment ID required.']); + } + + $db = \Config\Database::connect(); + $payment = $db->table('payment_history') + ->where('id', $paymentId) + ->where('payment_mode', 2) + ->where('credit_debit', 0) + ->get()->getRowArray(); + if (! $payment) { + return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Payment not found.']); + } + + $oldStatus = (string) ($payment['bank_reconcile_status'] ?? ''); + if ($oldStatus === 'matched') { + return $this->response->setStatusCode(409)->setJSON(['status' => 0, 'message' => 'Payment already matched.']); + } + + $db->table('payment_history')->where('id', $paymentId)->update([ + 'bank_reconcile_status' => 'matched', + 'bank_statement_entry_id' => null, + 'bank_reconcile_batch_ref'=> null, + 'bank_settlement_entry_id'=> null, + ]); + + $this->logBankReconcileAudit( + $paymentId, + 'single_match', + $oldStatus, + 'matched', + null, + $remarks + ); + + return $this->response->setJSON(['status' => 1, 'message' => 'Payment marked as matched (bank received).']); + } + + public function bankReconcileBatchMatch() + { + if (! $this->canFinance('finance.bank.audit')) { + return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']); + } + + $paymentIds = $this->request->getPost('payment_ids'); + $remarks = trim((string) ($this->request->getPost('remarks') ?? '')); + + if (! is_array($paymentIds)) { + $paymentIds = []; + } + + $ids = []; + foreach ($paymentIds as $id) { + $v = (int) $id; + if ($v > 0) { + $ids[$v] = $v; + } + } + $ids = array_values($ids); + + if ($ids === []) { + return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Select at least one payment.']); + } + + $db = \Config\Database::connect(); + $rows = $db->table('payment_history') + ->select('id, bank_reconcile_status') + ->whereIn('id', $ids) + ->where('payment_mode', 2) + ->where('credit_debit', 0) + ->get()->getResultArray(); + + if ($rows === []) { + return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'No eligible bank payments found.']); + } + + $eligibleIds = []; + foreach ($rows as $row) { + $eligibleIds[] = (int) ($row['id'] ?? 0); + } + + $batchRef = 'BR-' . date('YmdHis') . '-' . random_int(1000, 9999); + $matchedCount = 0; + + $db->transStart(); + foreach ($rows as $row) { + $pid = (int) ($row['id'] ?? 0); + $oldStatus = (string) ($row['bank_reconcile_status'] ?? ''); + if ($oldStatus === 'matched') { + continue; + } + + $db->table('payment_history')->where('id', $pid)->update([ + 'bank_reconcile_status' => 'matched', + 'bank_statement_entry_id' => null, + 'bank_reconcile_batch_ref' => $batchRef, + 'bank_settlement_entry_id' => null, + ]); + + $this->logBankReconcileAudit( + $pid, + 'batch_match', + $oldStatus, + 'matched', + $batchRef, + $remarks + ); + + $matchedCount++; + } + $db->transComplete(); + + if (! $db->transStatus()) { + return $this->response->setStatusCode(500)->setJSON(['status' => 0, 'message' => 'Failed to update selected payments.']); + } + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'Batch reconciliation completed for ' . $matchedCount . ' payment(s).', + 'batch_ref' => $batchRef, + 'updated_count' => $matchedCount, + 'selected_count' => count($eligibleIds), + ]); + } + + public function bankSettlementCreate() + { + if (! $this->canFinance('finance.bank.audit')) { + return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']); + } + + $paymentIds = $this->request->getPost('payment_ids'); + $remarks = trim((string) ($this->request->getPost('remarks') ?? '')); + + if (! is_array($paymentIds)) { + $paymentIds = []; + } + + $ids = []; + foreach ($paymentIds as $id) { + $v = (int) $id; + if ($v > 0) { + $ids[$v] = $v; + } + } + $ids = array_values($ids); + + if ($ids === []) { + return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Select at least one payment.']); + } + + $db = \Config\Database::connect(); + $rows = $db->table('payment_history') + ->select('id, amount, bank_reconcile_status') + ->whereIn('id', $ids) + ->where('payment_mode', 2) + ->where('credit_debit', 0) + ->get()->getResultArray(); + + if ($rows === []) { + return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'No eligible bank payments found.']); + } + + $settlementRef = 'SE-' . date('YmdHis') . '-' . random_int(1000, 9999); + $totalAmount = 0.0; + $matchedCount = 0; + + foreach ($rows as $row) { + if ((string) ($row['bank_reconcile_status'] ?? '') === 'matched') { + continue; + } + $totalAmount += (float) ($row['amount'] ?? 0); + $matchedCount++; + } + + if ($matchedCount === 0) { + return $this->response->setStatusCode(409)->setJSON(['status' => 0, 'message' => 'All selected payments are already matched.']); + } + + $userName = $this->currentUserName(); + $settlementId = 0; + + $db->transStart(); + + $this->bankSettlementEntryModel->insert([ + 'settlement_ref' => $settlementRef, + 'settlement_date' => date('Y-m-d'), + 'payment_count' => $matchedCount, + 'total_amount' => round($totalAmount, 2), + 'reconciliation_status' => 'unmatched', + 'remarks' => $remarks !== '' ? $remarks : null, + 'created_by' => $userName, + 'created_at' => date('Y-m-d H:i:s'), + ]); + $settlementId = (int) ($this->bankSettlementEntryModel->getInsertID() ?? 0); + + foreach ($rows as $row) { + $pid = (int) ($row['id'] ?? 0); + $oldStatus = (string) ($row['bank_reconcile_status'] ?? ''); + if ($oldStatus === 'matched') { + continue; + } + + $db->table('payment_history')->where('id', $pid)->update([ + 'bank_reconcile_status' => 'matched', + 'bank_statement_entry_id' => null, + 'bank_reconcile_batch_ref' => $settlementRef, + 'bank_settlement_entry_id' => $settlementId, + ]); + + $this->logBankReconcileAudit( + $pid, + 'settlement_match', + $oldStatus, + 'matched', + $settlementRef, + $remarks + ); + } + + $db->transComplete(); + + if (! $db->transStatus() || $settlementId <= 0) { + return $this->response->setStatusCode(500)->setJSON(['status' => 0, 'message' => 'Failed to create settlement entry.']); + } + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'Settlement entry created and linked to ' . $matchedCount . ' payment(s).', + 'settlement_ref' => $settlementRef, + 'settlement_id' => $settlementId, + 'updated_count' => $matchedCount, + ]); + } + + public function bankSettlementEntriesTable() + { + if (! $this->canFinanceAny(['finance.bank.audit', 'finance.bank.statement.update'])) { + return $this->response->setStatusCode(403)->setBody('Access denied'); + } + + $fromDate = $this->normalizeDateInput((string) ($this->request->getGet('from_date') ?? '')); + $toDate = $this->normalizeDateInput((string) ($this->request->getGet('to_date') ?? '')); + $status = trim((string) ($this->request->getGet('status') ?? 'all')); + $bankId = (int) ($this->request->getGet('bank_id') ?? 0); + + $db = \Config\Database::connect(); + $q = $db->table('finance_bank_settlement_entries se') + ->select("se.*, GROUP_CONCAT(DISTINCT m.bank_name ORDER BY m.bank_name SEPARATOR ', ') AS bank_names", false) + ->join('payment_history ph', 'ph.bank_settlement_entry_id = se.id', 'left') + ->join('hospital_bank_payment_source s', 'CAST(s.id AS CHAR) = ph.insert_code', 'left') + ->join('hospital_bank m', 'm.id = s.bank_id', 'left') + ->groupBy('se.id'); + if ($fromDate !== '') { + $q->where('se.settlement_date >=', $fromDate); + } + if ($toDate !== '') { + $q->where('se.settlement_date <=', $toDate); + } + if ($status === 'matched' || $status === 'unmatched') { + $q->where('se.reconciliation_status', $status); + } + if ($bankId > 0) { + $q->where('m.id', $bankId); + } + + $entries = $q->orderBy('se.settlement_date', 'DESC') + ->orderBy('se.id', 'DESC') + ->limit(200) + ->get() + ->getResultArray(); + + return view('finance/partials/bank_settlement_entries_table', ['entries' => $entries]); + } + + public function bankSettlementMatchStatement() + { + if (! $this->canFinanceAny(['finance.bank.audit', 'finance.bank.statement.update'])) { + return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']); + } + + $settlementId = (int) ($this->request->getPost('settlement_id') ?? 0); + $remarks = trim((string) ($this->request->getPost('remarks') ?? '')); + + if ($settlementId <= 0) { + return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Settlement ID required.']); + } + + $entry = $this->bankSettlementEntryModel->find($settlementId); + if (! is_array($entry)) { + return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Settlement entry not found.']); + } + + if ((string) ($entry['reconciliation_status'] ?? '') === 'matched') { + return $this->response->setStatusCode(409)->setJSON(['status' => 0, 'message' => 'Settlement already matched with bank statement.']); + } + + $this->bankSettlementEntryModel->update($settlementId, [ + 'reconciliation_status' => 'matched', + 'statement_matched_by' => $this->currentUserName(), + 'statement_matched_at' => date('Y-m-d H:i:s'), + 'statement_match_remarks' => $remarks !== '' ? $remarks : null, + ]); + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'Settlement matched with bank statement.', + ]); + } + + public function bankSettlementLinkedPaymentsTable() + { + if (! $this->canFinanceAny(['finance.bank.audit', 'finance.bank.statement.update'])) { + return $this->response->setStatusCode(403)->setBody('Access denied'); + } + + $settlementId = (int) ($this->request->getGet('settlement_id') ?? 0); + if ($settlementId <= 0) { + return $this->response->setStatusCode(422)->setBody('Invalid settlement id.'); + } + + $db = \Config\Database::connect(); + $rows = $db->table('payment_history ph') + ->select("ph.id, ph.payment_date, ph.amount, ph.card_tran_id, ph.bank_reconcile_status, ph.bank_reconcile_batch_ref, ph.bankcard_machine, + COALESCE(MAX(u.username), MAX(ph.update_by)) AS accepted_by_name, + MAX(CONCAT(COALESCE(s.pay_type,''), ' [', COALESCE(m.bank_name,''), ']')) AS bank_source_label") + ->join('users u', 'u.id = ph.update_by_id', 'left') + ->join('hospital_bank_payment_source s', 'CAST(s.id AS CHAR) = ph.insert_code', 'left') + ->join('hospital_bank m', 'm.id = s.bank_id', 'left') + ->where('ph.payment_mode', 2) + ->where('ph.credit_debit', 0) + ->where('ph.bank_settlement_entry_id', $settlementId) + ->groupBy('ph.id') + ->orderBy('ph.payment_date', 'DESC') + ->get()->getResultArray(); + + $entry = $this->bankSettlementEntryModel->find($settlementId); + + return view('finance/partials/bank_settlement_linked_payments_table', [ + 'rows' => $rows, + 'entry' => $entry, + 'settlement_id' => $settlementId, + ]); + } + + public function bankSettlementLinkedPaymentsExport() + { + if (! $this->canFinanceAny(['finance.bank.audit', 'finance.bank.statement.update'])) { + return $this->response->setStatusCode(403)->setBody('Access denied'); + } + + $settlementId = (int) ($this->request->getGet('settlement_id') ?? 0); + if ($settlementId <= 0) { + return $this->response->setStatusCode(422)->setBody('Invalid settlement id.'); + } + + $entry = $this->bankSettlementEntryModel->find($settlementId); + if (! is_array($entry)) { + return $this->response->setStatusCode(404)->setBody('Settlement entry not found.'); + } + + $db = \Config\Database::connect(); + $rows = $db->table('payment_history ph') + ->select("ph.id, ph.payment_date, ph.amount, ph.card_tran_id, ph.bank_reconcile_status, ph.bank_reconcile_batch_ref, ph.bankcard_machine, + COALESCE(MAX(u.username), MAX(ph.update_by)) AS accepted_by_name, + MAX(CONCAT(COALESCE(s.pay_type,''), ' [', COALESCE(m.bank_name,''), ']')) AS bank_source_label") + ->join('users u', 'u.id = ph.update_by_id', 'left') + ->join('hospital_bank_payment_source s', 'CAST(s.id AS CHAR) = ph.insert_code', 'left') + ->join('hospital_bank m', 'm.id = s.bank_id', 'left') + ->where('ph.payment_mode', 2) + ->where('ph.credit_debit', 0) + ->where('ph.bank_settlement_entry_id', $settlementId) + ->groupBy('ph.id') + ->orderBy('ph.payment_date', 'DESC') + ->get()->getResultArray(); + + $lines = []; + $lines[] = $this->csvLine(['Settlement Ref', (string) ($entry['settlement_ref'] ?? '')]); + $lines[] = $this->csvLine(['Settlement Date', (string) ($entry['settlement_date'] ?? '')]); + $lines[] = $this->csvLine(['Payment Count', (string) ($entry['payment_count'] ?? '0')]); + $lines[] = $this->csvLine(['Total Amount', (string) ($entry['total_amount'] ?? '0.00')]); + $lines[] = $this->csvLine(['Remarks', (string) ($entry['remarks'] ?? '')]); + $lines[] = ''; + $lines[] = $this->csvLine(['HMS Ref', 'Payment Date', 'Amount', 'Txn Ref / UTR', 'Channel', 'Accepted By', 'Status', 'Batch / Settlement Ref']); + + foreach ($rows as $row) { + $sourceLabel = trim((string) ($row['bank_source_label'] ?? '')); + if ($sourceLabel === '[]') { + $sourceLabel = ''; + } + $isPos = stripos($sourceLabel, 'machine') !== false || stripos((string) ($row['bankcard_machine'] ?? ''), 'machine') !== false; + $channel = $isPos ? 'POS' : 'UPI/Direct'; + if ($sourceLabel !== '') { + $channel .= ' - ' . $sourceLabel; + } + + $lines[] = $this->csvLine([ + 'PH-' . (int) ($row['id'] ?? 0), + (string) ($row['payment_date'] ?? ''), + number_format((float) ($row['amount'] ?? 0), 2, '.', ''), + (string) ($row['card_tran_id'] ?? ''), + $channel, + (string) ($row['accepted_by_name'] ?? ''), + (string) ($row['bank_reconcile_status'] ?? ''), + (string) ($row['bank_reconcile_batch_ref'] ?? ''), + ]); + } + + $filename = 'settlement-linked-payments-' . $settlementId . '-' . date('Ymd_His') . '.csv'; + $csv = implode("\r\n", $lines) . "\r\n"; + + return $this->response + ->setHeader('Content-Type', 'text/csv; charset=utf-8') + ->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"') + ->setBody($csv); + } + + public function bankReconcileUnmatch() + { + if (! $this->canFinance('finance.bank.audit')) { + return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']); + } + + $paymentId = (int) ($this->request->getPost('payment_id') ?? 0); + $remarks = trim((string) ($this->request->getPost('remarks') ?? '')); + + if ($paymentId <= 0) { + return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Payment ID required.']); + } + + $db = \Config\Database::connect(); + $payment = $db->table('payment_history')->where('id', $paymentId)->get()->getRowArray(); + if (! $payment) { + return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Payment not found.']); + } + + $oldStatus = (string) ($payment['bank_reconcile_status'] ?? ''); + $entryId = (int) ($payment['bank_statement_entry_id'] ?? 0); + + $db->table('payment_history')->where('id', $paymentId)->update([ + 'bank_reconcile_status' => null, + 'bank_statement_entry_id' => null, + 'bank_reconcile_batch_ref' => null, + 'bank_settlement_entry_id' => null, + ]); + + if ($entryId > 0) { + $this->bankStatementEntryModel->update($entryId, [ + 'reconciliation_status' => 'unmatched', + 'matched_payment_id' => null, + 'matched_by' => null, + 'matched_at' => null, + ]); + } + + $this->logBankReconcileAudit( + $paymentId, + 'unmatch', + $oldStatus, + 'unmatched', + null, + $remarks + ); + + return $this->response->setJSON(['status' => 1, 'message' => 'Match reversed.']); + } + + // ─── Bank Audit: POS Settlement ──────────────────────────────────────────── + + public function bankPosSettlementsTable() + { + if (! $this->canFinanceAny(['finance.bank.audit', 'finance.bank.statement.update'])) { + return $this->response->setStatusCode(403)->setBody('Access denied'); + } + + $status = trim((string) ($this->request->getGet('status') ?? 'all')); + + $q = $this->bankPosSettlementModel->orderBy('settlement_date', 'DESC')->orderBy('id', 'DESC'); + if ($status !== 'all') { + $q->where('status', $status); + } + + $rows = $q->limit(100)->findAll(); + + return view('finance/partials/bank_pos_settlements_table', ['rows' => $rows]); + } + + public function bankPosSettlementCreate() + { + if (! $this->canFinance('finance.bank.audit')) { + return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']); + } + + $settlementDate = trim((string) ($this->request->getPost('settlement_date') ?? '')); + $terminalId = trim((string) ($this->request->getPost('terminal_id') ?? '')); + $terminalName = trim((string) ($this->request->getPost('terminal_name') ?? '')); + $settlAmount = (float) ($this->request->getPost('settlement_amount') ?? 0); + $remarks = trim((string) ($this->request->getPost('remarks') ?? '')); + + if ($settlementDate === '' || $terminalId === '' || $settlAmount <= 0) { + return $this->response->setStatusCode(422)->setJSON([ + 'status' => 0, 'message' => 'Settlement date, terminal ID, and amount are required.', + ]); + } + + $db = \Config\Database::connect(); + + // Prevent duplicate settlement for same date + terminal + $existing = $this->bankPosSettlementModel + ->where('settlement_date', $settlementDate) + ->where('terminal_id', $terminalId) + ->first(); + if ($existing) { + return $this->response->setStatusCode(409)->setJSON([ + 'status' => 0, 'message' => 'A settlement for this terminal and date already exists.', + ]); + } + + // Sum all POS transactions for this terminal and date from payment_history + $row = $db->table('payment_history') + ->select('COALESCE(SUM(amount),0) AS total, COUNT(*) AS cnt') + ->where('payment_mode', 2) + ->where('credit_debit', 0) + ->where('bankcard_machine', $terminalId) + ->where('DATE(payment_date)', $settlementDate) + ->get()->getRowArray(); + + $systemTotal = (float) ($row['total'] ?? 0); + $paymentCount = (int) ($row['cnt'] ?? 0); + $variance = round($settlAmount - $systemTotal, 2); + + $status = 'pending'; + if ($paymentCount === 0) { + $status = 'pending'; // no transactions found yet + } elseif (abs($variance) <= 0.01) { + $status = 'matched'; + } else { + $status = 'variance'; + } + + $this->bankPosSettlementModel->insert([ + 'settlement_date' => $settlementDate, + 'terminal_id' => $terminalId, + 'terminal_name' => $terminalName !== '' ? $terminalName : null, + 'settlement_amount' => $settlAmount, + 'system_total' => $systemTotal, + 'variance' => $variance, + 'payment_count' => $paymentCount, + 'status' => $status, + 'created_by' => $this->currentUserName(), + 'remarks' => $remarks !== '' ? $remarks : null, + ]); + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'POS settlement recorded. Status: ' . $status . '.', + 'data' => [ + 'system_total' => $systemTotal, + 'payment_count' => $paymentCount, + 'variance' => $variance, + 'status' => $status, + ], + ]); + } + + public function bankPosSettlementAccept() + { + if (! $this->canFinance('finance.bank.audit')) { + return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']); + } + + $id = (int) ($this->request->getPost('settlement_id') ?? 0); + $remarks = trim((string) ($this->request->getPost('remarks') ?? '')); + + if ($id <= 0) { + return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Settlement ID required.']); + } + + $row = $this->bankPosSettlementModel->find($id); + if (! $row) { + return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Settlement record not found.']); + } + + if ((string) ($row['status'] ?? '') === 'accepted') { + return $this->response->setStatusCode(409)->setJSON(['status' => 0, 'message' => 'Already accepted.']); + } + + $existingRemarks = (string) ($row['remarks'] ?? ''); + if ($remarks !== '') { + $existingRemarks = $existingRemarks !== '' ? $existingRemarks . ' | ' . $remarks : $remarks; + } + + $this->bankPosSettlementModel->update($id, [ + 'status' => 'accepted', + 'reconciled_by' => $this->currentUserName(), + 'reconciled_at' => date('Y-m-d H:i:s'), + 'remarks' => $existingRemarks !== '' ? $existingRemarks : null, + ]); + + return $this->response->setJSON(['status' => 1, 'message' => 'POS settlement accepted.']); + } + public function index() { - if (! $this->canFinance('finance.access')) { + if (! $this->canFinanceAny([ + 'finance.workflow.view', + 'finance.cash.billing.submit', + 'finance.cash.accounts.accept', + 'finance.cash.accounts.verify', + 'finance.bank.deposit.create', + 'finance.bank.audit', + 'finance.bank.statement.update', + ])) { return $this->response->setStatusCode(403)->setBody('Access denied'); } - return view('finance/index', $this->buildDashboardData()); + return view('finance/index'); } public function phase2() @@ -1922,10 +3131,126 @@ class Finance extends BaseController 'today_receipts' => (float) ($receiptRow['total'] ?? 0), 'today_disbursements' => (float) ($disbursementRow['total'] ?? 0), 'hold_count' => $this->cashTxnModel->where('is_compliance_hold', 1)->countAllResults(), - 'pending_scroll' => $this->scrollModel->where('reconciliation_status', 'pending')->countAllResults(), + 'pending_scroll' => $this->scrollModel->whereIn('reconciliation_status', ['pending', 'submitted', 'accepted', 'received'])->countAllResults(), ]; } + private function buildScrollQueueSummary(): array + { + return [ + 'all' => $this->scrollModel->countAllResults(), + 'pending' => $this->scrollModel->whereIn('reconciliation_status', ['pending', 'submitted'])->countAllResults(), + 'received' => $this->scrollModel->whereIn('reconciliation_status', ['received', 'accepted'])->countAllResults(), + 'deposited' => $this->scrollModel->whereIn('reconciliation_status', ['deposited', 'verified', 'matched'])->countAllResults(), + ]; + } + + private function fetchPaymentHistoryRows(string $startAt, string $endAt, string $collectedBy = '', string $paymentMode = 'all', array $paymentIds = []): array + { + if (! method_exists($this->db, 'tableExists') || ! $this->db->tableExists('payment_history')) { + return []; + } + + $builder = $this->db->table('payment_history p'); + $builder->select('p.id, p.payment_date, p.amount, p.payment_mode, p.payof_type, p.payof_id, p.payof_code, p.update_by_id, p.update_by') + ->where('p.payment_date >=', $startAt) + ->where('p.payment_date <=', $endAt) + ->where('p.credit_debit', 0) + ->orderBy('p.payment_date', 'ASC') + ->orderBy('p.id', 'ASC'); + + // Cash submission module is cash-only. + if ($this->tableHasField('payment_history', 'payment_mode')) { + $builder->where('p.payment_mode', 1); + } + + // Only open/unsubmitted payments are selectable for new submissions. + if ($this->tableHasField('payment_history', 'cash_submission_status')) { + $builder->groupStart() + ->where('p.cash_submission_status', null) + ->orWhere('p.cash_submission_status', '') + ->orWhere('p.cash_submission_status', 'open') + ->groupEnd(); + } + + if (! empty($paymentIds)) { + $builder->whereIn('p.id', $paymentIds); + } + + if ($collectedBy !== '') { + if (ctype_digit($collectedBy) && $this->tableHasField('payment_history', 'update_by_id')) { + $builder->where('p.update_by_id', (int) $collectedBy); + } elseif ($this->tableHasField('payment_history', 'update_by')) { + $builder->where('p.update_by', $collectedBy); + } + } + + return $builder->get()->getResultArray(); + } + + private function summarizePaymentHistoryRows(array $rows): array + { + $total = 0.0; + foreach ($rows as $row) { + $total += (float) ($row['amount'] ?? 0); + } + + return [ + 'total_receipts' => round($total, 2), + 'payment_count' => count($rows), + ]; + } + + private function fetchCollectorOptions(): array + { + if (! method_exists($this->db, 'tableExists') || ! $this->db->tableExists('payment_history')) { + return []; + } + + if ($this->tableHasField('payment_history', 'update_by_id')) { + $rows = $this->db->table('payment_history p') + ->select('p.update_by_id AS user_id, COALESCE(MAX(u.username), MAX(p.update_by)) AS user_name', false) + ->join('users u', 'u.id = p.update_by_id', 'left') + ->where('p.credit_debit', 0) + ->groupBy('p.update_by_id') + ->orderBy('user_name', 'ASC') + ->get() + ->getResultArray(); + + return array_values(array_filter($rows, static function (array $row): bool { + return (int) ($row['user_id'] ?? 0) > 0; + })); + } + + if ($this->tableHasField('payment_history', 'update_by')) { + $rows = $this->db->table('payment_history') + ->select('update_by AS user_name') + ->where('credit_debit', 0) + ->where('update_by !=', '') + ->groupBy('update_by') + ->orderBy('update_by', 'ASC') + ->get() + ->getResultArray(); + + return array_map(static function (array $row): array { + return [ + 'user_id' => 0, + 'user_name' => (string) ($row['user_name'] ?? ''), + ]; + }, $rows); + } + + return []; + } + + private function appendAuditRemark(string $existing, string $entry): string + { + $existing = trim($existing); + $prefix = $existing === '' ? '' : $existing . ' | '; + + return $prefix . date('Y-m-d H:i') . ' - ' . $entry; + } + private function fetchDoctorAgreements(): array { return $this->doctorAgreementModel->orderBy('id', 'DESC')->findAll(12); @@ -2003,6 +3328,26 @@ class Finance extends BaseController return implode(',', $escaped); } + private function logBankReconcileAudit( + int $paymentId, + string $actionType, + ?string $oldStatus, + ?string $newStatus, + ?string $batchRef, + ?string $remarks + ): void { + $this->bankReconcileAuditModel->insert([ + 'payment_history_id' => $paymentId, + 'action_type' => $actionType, + 'old_status' => $oldStatus !== '' ? $oldStatus : null, + 'new_status' => $newStatus !== '' ? $newStatus : null, + 'batch_ref' => $batchRef, + 'remarks' => $remarks !== '' ? $remarks : null, + 'action_by' => $this->currentUserName(), + 'action_at' => date('Y-m-d H:i:s'), + ]); + } + private function canFinance(string $permission): bool { $user = function_exists('auth') ? auth()->user() : null; @@ -2010,11 +3355,18 @@ class Finance extends BaseController return false; } - $inDefaultFinanceGroup = method_exists($user, 'inGroup') - ? $user->inGroup('superadmin', 'admin', 'developer') - : false; + return $user->can($permission) || $user->can('finance.*'); + } - return $user->can($permission) || $user->can('finance.*') || $inDefaultFinanceGroup; + private function canFinanceAny(array $permissions): bool + { + foreach ($permissions as $permission) { + if ($this->canFinance((string) $permission)) { + return true; + } + } + + return false; } private function evaluateInvoiceMatch(int $vendorId, ?int $poId, ?int $grnId, float $invoiceAmount): array diff --git a/app/Database/Migrations/2026-04-21-000050_AddBankSettlementStatementMatchFields.php b/app/Database/Migrations/2026-04-21-000050_AddBankSettlementStatementMatchFields.php new file mode 100644 index 0000000..edc41f1 --- /dev/null +++ b/app/Database/Migrations/2026-04-21-000050_AddBankSettlementStatementMatchFields.php @@ -0,0 +1,105 @@ +hasTable('finance_bank_settlement_entries')) { + return; + } + + if (! $this->hasField('finance_bank_settlement_entries', 'reconciliation_status')) { + $this->forge->addColumn('finance_bank_settlement_entries', [ + 'reconciliation_status' => [ + 'type' => 'VARCHAR', + 'constraint' => 20, + 'null' => false, + 'default' => 'unmatched', + 'after' => 'total_amount', + ], + ]); + } + + if (! $this->hasField('finance_bank_settlement_entries', 'statement_matched_by')) { + $this->forge->addColumn('finance_bank_settlement_entries', [ + 'statement_matched_by' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + 'null' => true, + 'after' => 'reconciliation_status', + ], + ]); + } + + if (! $this->hasField('finance_bank_settlement_entries', 'statement_matched_at')) { + $this->forge->addColumn('finance_bank_settlement_entries', [ + 'statement_matched_at' => [ + 'type' => 'DATETIME', + 'null' => true, + 'after' => 'statement_matched_by', + ], + ]); + } + + if (! $this->hasField('finance_bank_settlement_entries', 'statement_match_remarks')) { + $this->forge->addColumn('finance_bank_settlement_entries', [ + 'statement_match_remarks' => [ + 'type' => 'TEXT', + 'null' => true, + 'after' => 'statement_matched_at', + ], + ]); + } + + $this->db->query("UPDATE finance_bank_settlement_entries SET reconciliation_status = 'unmatched' WHERE reconciliation_status IS NULL OR reconciliation_status = ''"); + } + + public function down(): void + { + if (! $this->hasTable('finance_bank_settlement_entries')) { + return; + } + + if ($this->hasField('finance_bank_settlement_entries', 'statement_match_remarks')) { + $this->forge->dropColumn('finance_bank_settlement_entries', 'statement_match_remarks'); + } + if ($this->hasField('finance_bank_settlement_entries', 'statement_matched_at')) { + $this->forge->dropColumn('finance_bank_settlement_entries', 'statement_matched_at'); + } + if ($this->hasField('finance_bank_settlement_entries', 'statement_matched_by')) { + $this->forge->dropColumn('finance_bank_settlement_entries', 'statement_matched_by'); + } + if ($this->hasField('finance_bank_settlement_entries', 'reconciliation_status')) { + $this->forge->dropColumn('finance_bank_settlement_entries', 'reconciliation_status'); + } + } + + private function hasTable(string $table): bool + { + try { + $rows = $this->db->query('SHOW TABLES LIKE ' . $this->db->escape($table))->getResultArray(); + return ! empty($rows); + } catch (\Throwable $e) { + return false; + } + } + + private function hasField(string $table, string $field): bool + { + if (! $this->hasTable($table)) { + return false; + } + + try { + $sql = 'SHOW COLUMNS FROM `' . str_replace('`', '``', $table) . '` LIKE ' . $this->db->escape($field); + $rows = $this->db->query($sql)->getResultArray(); + return ! empty($rows); + } catch (\Throwable $e) { + return false; + } + } +} diff --git a/app/Models/FinanceBankSettlementEntryModel.php b/app/Models/FinanceBankSettlementEntryModel.php new file mode 100644 index 0000000..8063e4e --- /dev/null +++ b/app/Models/FinanceBankSettlementEntryModel.php @@ -0,0 +1,32 @@ + +
+

Bank Payment Audit

+

Reconcile bank payments and track settlement matching against your bank statement.

+
+ +
+ + +
+
+
+
+
System Bank Payments Pending Match
+
+
+
+
+
+
+
+
System Bank Payments Matched
+
+
+
+
+
+
+
+
Settlement Entries Unmatched
+
+
+
+
+
+
+
+
Settlement Entries Matched
+
+
+
+
+
+ + + + +
+ + +
+
+ + +
+
+
+ System Bank Payments +
+ + + + + + +
+
+
+
+
+
+
+
+
+ + + + + Use checkboxes in first column for batch confirmation. +
+
+
+
+ +
+
+ + +
+
+
+
+
+ Settlement Entries +
+ + + + + +
+
+
+
+
+
+
+
+
+
+ + + + + diff --git a/app/Views/finance/partials/bank_settlement_entries_table.php b/app/Views/finance/partials/bank_settlement_entries_table.php new file mode 100644 index 0000000..39bafd6 --- /dev/null +++ b/app/Views/finance/partials/bank_settlement_entries_table.php @@ -0,0 +1,59 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#Settlement DateSettlement RefBank NamePayment CountTotal AmountRemarksStatusCreated ByAction
No settlement entries found.
₹ + + Matched + + Unmatched + + + + + + +
+