devsofttech_hms_ci4/app/Controllers/Finance.php

4603 lines
188 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Controllers;
use App\Models\DoctorModel;
use App\Models\FinanceGrnModel;
use App\Models\FinanceCashTransactionModel;
use App\Models\FinanceBankDepositModel;
use App\Models\FinanceDoctorAgreementModel;
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;
use App\Models\FinancePayoutRequestAuditModel;
use App\Models\FinancePayoutRequestLineModel;
use App\Models\FinancePayoutRequestModel;
use App\Models\FinanceOutgoingPaymentAllocationModel;
use App\Models\FinanceOutgoingPaymentHistoryModel;
use App\Models\FinanceVendorModel;
class Finance extends BaseController
{
private FinanceVendorModel $vendorModel;
private FinancePurchaseOrderModel $poModel;
private FinancePoDocumentModel $poDocumentModel;
private FinanceGrnModel $grnModel;
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;
private FinancePayoutRequestModel $payoutRequestModel;
private FinancePayoutRequestLineModel $payoutRequestLineModel;
private FinanceOutgoingPaymentHistoryModel $outgoingPaymentHistoryModel;
private FinanceOutgoingPaymentAllocationModel $outgoingPaymentAllocationModel;
private FinancePayoutRequestAuditModel $payoutRequestAuditModel;
public function __construct()
{
$this->vendorModel = new FinanceVendorModel();
$this->poModel = new FinancePurchaseOrderModel();
$this->poDocumentModel = new FinancePoDocumentModel();
$this->grnModel = new FinanceGrnModel();
$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();
$this->payoutRequestModel = new FinancePayoutRequestModel();
$this->payoutRequestLineModel = new FinancePayoutRequestLineModel();
$this->outgoingPaymentHistoryModel = new FinanceOutgoingPaymentHistoryModel();
$this->outgoingPaymentAllocationModel = new FinanceOutgoingPaymentAllocationModel();
$this->payoutRequestAuditModel = new FinancePayoutRequestAuditModel();
}
public function bankDeposits()
{
if (! $this->canFinanceAny(['finance.bank.deposit.create', 'finance.bank.audit', 'finance.bank.statement.update'])) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
return view('finance/bank_deposits', [
'deposits' => $this->fetchBankDeposits(),
'scroll_options' => $this->scrollModel->orderBy('id', 'DESC')->findAll(100),
'summary' => $this->buildDepositSummary(),
]);
}
public function bankDepositsTable()
{
if (! $this->canFinanceAny(['finance.bank.deposit.create', 'finance.bank.audit', 'finance.bank.statement.update'])) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
return view('finance/partials/bank_deposits_table', [
'deposits' => $this->fetchBankDeposits(),
]);
}
public function bankDepositCreate()
{
if (! $this->canFinance('finance.bank.deposit.create')) {
return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
}
$depositDate = trim((string) ($this->request->getPost('deposit_date') ?? ''));
$department = trim((string) ($this->request->getPost('department') ?? ''));
$bankName = trim((string) ($this->request->getPost('bank_name') ?? ''));
$amount = (float) ($this->request->getPost('deposited_amount') ?? 0);
$relatedScrollId = (int) ($this->request->getPost('related_scroll_id') ?? 0) ?: null;
if ($depositDate === '' || $department === '' || $bankName === '' || $amount <= 0) {
return $this->response->setStatusCode(422)->setJSON([
'status' => 0,
'message' => 'Date, department, bank name and amount are required.',
]);
}
$status = 'pending';
if ($relatedScrollId !== null) {
$scroll = $this->scrollModel->find($relatedScrollId);
if ($scroll) {
$variance = abs((float) ($scroll['submitted_amount'] ?? 0) - $amount);
$status = $variance <= 0.01 ? 'matched' : 'pending';
}
}
$this->bankDepositModel->insert([
'deposit_date' => $depositDate,
'department' => $department,
'bank_name' => $bankName,
'slip_no' => trim((string) ($this->request->getPost('slip_no') ?? '')),
'deposited_amount' => $amount,
'related_scroll_id' => $relatedScrollId,
'reconciliation_status' => $status,
'remarks' => trim((string) ($this->request->getPost('remarks') ?? '')),
'created_by' => $this->currentUserName(),
]);
return $this->response->setJSON([
'status' => 1,
'message' => 'Bank deposit saved. Reconciliation status: ' . $status . '.',
]);
}
public function complianceReport()
{
if (! $this->canFinance('finance.compliance.view')) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
$from = $this->normalizeDateInput((string) ($this->request->getGet('from') ?? ''));
$to = $this->normalizeDateInput((string) ($this->request->getGet('to') ?? ''));
$export = (string) ($this->request->getGet('export') ?? '') === '1';
$invoiceQuery = $this->invoiceModel
->select('finance_vendor_invoices.*, finance_vendors.vendor_name')
->join('finance_vendors', 'finance_vendors.id = finance_vendor_invoices.vendor_id', 'left')
->where('finance_vendor_invoices.is_compliance_hold', 1);
if ($from !== '') {
$invoiceQuery->where('finance_vendor_invoices.invoice_date >=', $from);
}
if ($to !== '') {
$invoiceQuery->where('finance_vendor_invoices.invoice_date <=', $to);
}
$invoiceExceptions = $invoiceQuery
->orderBy('finance_vendor_invoices.id', 'DESC')
->findAll($export ? 5000 : 20);
$cashQuery = $this->cashTxnModel->where('is_compliance_hold', 1);
if ($from !== '') {
$cashQuery->where('txn_date >=', $from);
}
if ($to !== '') {
$cashQuery->where('txn_date <=', $to);
}
$cashAlerts = $cashQuery
->orderBy('id', 'DESC')
->findAll($export ? 5000 : 20);
$pendingPayoutQuery = $this->doctorPayoutModel->where('status !=', 'paid');
if ($from !== '') {
$pendingPayoutQuery->where('payout_date >=', $from);
}
if ($to !== '') {
$pendingPayoutQuery->where('payout_date <=', $to);
}
$pendingPayouts = $pendingPayoutQuery->countAllResults();
$invoiceHoldCountQuery = $this->invoiceModel->where('is_compliance_hold', 1);
if ($from !== '') {
$invoiceHoldCountQuery->where('invoice_date >=', $from);
}
if ($to !== '') {
$invoiceHoldCountQuery->where('invoice_date <=', $to);
}
$cashHoldCountQuery = $this->cashTxnModel->where('is_compliance_hold', 1);
if ($from !== '') {
$cashHoldCountQuery->where('txn_date >=', $from);
}
if ($to !== '') {
$cashHoldCountQuery->where('txn_date <=', $to);
}
$pendingDepositQuery = $this->bankDepositModel->where('reconciliation_status', 'pending');
if ($from !== '') {
$pendingDepositQuery->where('deposit_date >=', $from);
}
if ($to !== '') {
$pendingDepositQuery->where('deposit_date <=', $to);
}
$summary = [
'invoice_holds' => $invoiceHoldCountQuery->countAllResults(),
'cash_holds' => $cashHoldCountQuery->countAllResults(),
'pending_deposits' => $pendingDepositQuery->countAllResults(),
'pending_payouts' => $pendingPayouts,
];
if ($export) {
$lines = [];
$lines[] = $this->csvLine(['Finance Compliance Report']);
$lines[] = $this->csvLine(['From', $from !== '' ? $from : 'NA', 'To', $to !== '' ? $to : 'NA']);
$lines[] = '';
$lines[] = $this->csvLine(['Summary']);
$lines[] = $this->csvLine(['Invoice Holds', (string) $summary['invoice_holds']]);
$lines[] = $this->csvLine(['Cash Holds', (string) $summary['cash_holds']]);
$lines[] = $this->csvLine(['Pending Deposits', (string) $summary['pending_deposits']]);
$lines[] = $this->csvLine(['Draft/Unpaid Payouts', (string) $summary['pending_payouts']]);
$lines[] = '';
$lines[] = $this->csvLine(['Cash Compliance Alerts']);
$lines[] = $this->csvLine(['Date', 'Type', 'Department', 'Amount', 'Note']);
foreach ($cashAlerts as $row) {
$lines[] = $this->csvLine([
(string) ($row['txn_date'] ?? ''),
(string) ($row['txn_type'] ?? ''),
(string) ($row['department'] ?? ''),
number_format((float) ($row['amount'] ?? 0), 2, '.', ''),
(string) ($row['compliance_note'] ?? ''),
]);
}
$lines[] = '';
$lines[] = $this->csvLine(['Invoice Match Exceptions']);
$lines[] = $this->csvLine(['Date', 'Invoice', 'Vendor', 'Status', 'Variance', 'Note']);
foreach ($invoiceExceptions as $row) {
$lines[] = $this->csvLine([
(string) ($row['invoice_date'] ?? ''),
(string) ($row['invoice_no'] ?? ''),
(string) ($row['vendor_name'] ?? ''),
(string) ($row['match_status'] ?? ''),
number_format((float) ($row['variance_amount'] ?? 0), 2, '.', ''),
(string) ($row['match_note'] ?? ''),
]);
}
$filename = 'finance_compliance_' . date('Ymd_His') . '.csv';
return $this->response
->setHeader('Content-Type', 'text/csv; charset=UTF-8')
->setHeader('Content-Disposition', 'attachment; filename=' . $filename)
->setBody(implode("\n", $lines));
}
return view('finance/compliance_report', [
'summary' => $summary,
'from' => $from,
'to' => $to,
'invoice_exceptions' => $invoiceExceptions,
'cash_alerts' => $cashAlerts,
]);
}
public function doctorPayout()
{
if (! $this->canFinance('finance.doctor_payout.manage')) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
return view('finance/doctor_payout', [
'agreements' => $this->fetchDoctorAgreements(),
'payouts' => $this->fetchDoctorPayouts(),
'doctor_options' => $this->doctorAgreementModel->where('status', 1)->orderBy('doctor_name', 'ASC')->findAll(),
'summary' => $this->buildDoctorPayoutSummary(),
]);
}
public function doctorAgreementsTable()
{
if (! $this->canFinance('finance.doctor_payout.manage')) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
return view('finance/partials/doctor_agreements_table', [
'agreements' => $this->fetchDoctorAgreements(),
]);
}
public function doctorPayoutsTable()
{
if (! $this->canFinance('finance.doctor_payout.manage')) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
return view('finance/partials/doctor_payouts_table', [
'payouts' => $this->fetchDoctorPayouts(),
]);
}
public function doctorAgreementCreate()
{
if (! $this->canFinance('finance.doctor_payout.manage')) {
return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
}
$doctorCode = strtoupper(trim((string) ($this->request->getPost('doctor_code') ?? '')));
$doctorName = trim((string) ($this->request->getPost('doctor_name') ?? ''));
if ($doctorCode === '' || $doctorName === '') {
return $this->response->setStatusCode(422)->setJSON([
'status' => 0,
'message' => 'Doctor code and name are required.',
]);
}
if ($this->doctorAgreementModel->where('doctor_code', $doctorCode)->countAllResults() > 0) {
return $this->response->setStatusCode(422)->setJSON([
'status' => 0,
'message' => 'Doctor code already exists.',
]);
}
$this->doctorAgreementModel->insert([
'doctor_code' => $doctorCode,
'doctor_name' => $doctorName,
'specialization' => trim((string) ($this->request->getPost('specialization') ?? '')),
'consultation_rate' => (float) ($this->request->getPost('consultation_rate') ?? 0),
'surgery_rate' => (float) ($this->request->getPost('surgery_rate') ?? 0),
'agreement_start_date' => trim((string) ($this->request->getPost('agreement_start_date') ?? '')) ?: null,
'agreement_end_date' => trim((string) ($this->request->getPost('agreement_end_date') ?? '')) ?: null,
'status' => 1,
'created_by' => $this->currentUserName(),
]);
return $this->response->setJSON([
'status' => 1,
'message' => 'Doctor agreement saved successfully.',
]);
}
public function doctorPayoutCreate()
{
if (! $this->canFinance('finance.doctor_payout.manage')) {
return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
}
$doctorId = (int) ($this->request->getPost('doctor_id') ?? 0);
$payoutDate = trim((string) ($this->request->getPost('payout_date') ?? ''));
$payoutType = trim((string) ($this->request->getPost('payout_type') ?? ''));
$units = max(1, (int) ($this->request->getPost('units') ?? 1));
if ($doctorId <= 0 || $payoutDate === '' || ! in_array($payoutType, ['consultation', 'surgery'], true)) {
return $this->response->setStatusCode(422)->setJSON([
'status' => 0,
'message' => 'Doctor, payout date and payout type are required.',
]);
}
$agreement = $this->doctorAgreementModel->find($doctorId);
if (! $agreement) {
return $this->response->setStatusCode(422)->setJSON([
'status' => 0,
'message' => 'Doctor agreement not found.',
]);
}
$rate = $payoutType === 'consultation'
? (float) ($agreement['consultation_rate'] ?? 0)
: (float) ($agreement['surgery_rate'] ?? 0);
$calculatedAmount = round($rate * $units, 2);
$this->doctorPayoutModel->insert([
'payout_date' => $payoutDate,
'doctor_id' => $doctorId,
'case_reference' => trim((string) ($this->request->getPost('case_reference') ?? '')),
'payout_type' => $payoutType,
'units' => $units,
'rate' => $rate,
'calculated_amount' => $calculatedAmount,
'approved_amount' => $calculatedAmount,
'status' => 'draft',
'remarks' => trim((string) ($this->request->getPost('remarks') ?? '')),
'hr_submitted_by' => $this->currentUserName(),
]);
return $this->response->setJSON([
'status' => 1,
'message' => 'Payout draft created. Current status: draft.',
]);
}
public function doctorPayoutApprove()
{
if (! $this->canFinance('finance.doctor_payout.manage')) {
return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
}
$payoutId = (int) ($this->request->getPost('payout_id') ?? 0);
$level = trim((string) ($this->request->getPost('level') ?? ''));
$row = $this->doctorPayoutModel->find($payoutId);
if (! $row) {
return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Payout not found']);
}
$status = (string) ($row['status'] ?? 'draft');
$now = date('Y-m-d H:i:s');
$user = $this->currentUserName();
$update = [];
if ($level === 'finance' && $status === 'draft') {
$update = [
'status' => 'finance_approved',
'finance_approved_by' => $user,
'finance_approved_at' => $now,
];
} elseif ($level === 'ceo' && $status === 'finance_approved') {
$update = [
'status' => 'ceo_approved',
'ceo_approved_by' => $user,
'ceo_approved_at' => $now,
];
} elseif ($level === 'paid' && $status === 'ceo_approved') {
$update = [
'status' => 'paid',
'paid_at' => $now,
];
} else {
return $this->response->setStatusCode(422)->setJSON([
'status' => 0,
'message' => 'Invalid approval transition from ' . $status . ' using level ' . $level . '.',
]);
}
$this->doctorPayoutModel->update($payoutId, $update);
return $this->response->setJSON([
'status' => 1,
'message' => 'Payout status updated to ' . $update['status'] . '.',
]);
}
public function cashbook()
{
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');
}
return view('finance/cashbook', [
'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->canFinanceAny(['finance.cash.billing.submit', 'finance.cash.accounts.accept', 'finance.cash.accounts.verify'])) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
return view('finance/partials/cash_transactions_table', [
'transactions' => $this->fetchCashTransactions(),
]);
}
public function scrollTable()
{
if (! $this->canFinanceAny(['finance.cash.billing.submit', 'finance.cash.accounts.accept', 'finance.cash.accounts.verify'])) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
return view('finance/partials/scroll_table', [
'scrolls' => $this->fetchScrolls(),
]);
}
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.billing.submit')) {
return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
}
$txnDate = trim((string) ($this->request->getPost('txn_date') ?? ''));
$txnType = trim((string) ($this->request->getPost('txn_type') ?? ''));
$amount = (float) ($this->request->getPost('amount') ?? 0);
$mode = trim((string) ($this->request->getPost('mode') ?? ''));
if ($txnDate === '' || ! in_array($txnType, ['receipt', 'disbursement'], true) || $amount <= 0 || $mode === '') {
return $this->response->setStatusCode(422)->setJSON([
'status' => 0,
'message' => 'Date, type, amount and mode are required.',
]);
}
$flag269st = 0;
$flag40a3 = 0;
$complianceHold = 0;
$notes = [];
if ($mode === 'cash' && $txnType === 'receipt' && $amount >= 200000) {
$flag269st = 1;
$complianceHold = 1;
$notes[] = 'Potential Sec 269ST threshold breach (>= 200000 cash receipt).';
}
if ($mode === 'cash' && $txnType === 'disbursement' && $amount > 10000) {
$flag40a3 = 1;
$complianceHold = 1;
$notes[] = 'Potential Sec 40A(3) breach (> 10000 cash disbursement).';
}
if ($mode === 'cash' && $txnType === 'disbursement') {
$pettyLimit = $this->getPolicyLimit('petty_cash_daily_limit', 50000);
$todayTotal = $this->cashTxnModel
->select('COALESCE(SUM(amount),0) as total')
->where('txn_date', $txnDate)
->where('txn_type', 'disbursement')
->where('mode', 'cash')
->where('department', trim((string) ($this->request->getPost('department') ?? '')))
->first();
$projected = (float) ($todayTotal['total'] ?? 0) + $amount;
if ($projected > $pettyLimit) {
$complianceHold = 1;
$notes[] = 'Petty cash daily limit exceeded. Limit: ' . number_format($pettyLimit, 2) . ', projected: ' . number_format($projected, 2) . '.';
}
}
$this->cashTxnModel->insert([
'txn_date' => $txnDate,
'txn_type' => $txnType,
'flow_type' => trim((string) ($this->request->getPost('flow_type') ?? 'other')),
'department' => trim((string) ($this->request->getPost('department') ?? '')),
'reference_no' => trim((string) ($this->request->getPost('reference_no') ?? '')),
'amount' => $amount,
'mode' => $mode,
'party_name' => trim((string) ($this->request->getPost('party_name') ?? '')),
'narration' => trim((string) ($this->request->getPost('narration') ?? '')),
'flag_269st' => $flag269st,
'flag_40a3' => $flag40a3,
'is_compliance_hold' => $complianceHold,
'compliance_note' => empty($notes) ? 'Compliant entry' : implode(' ', $notes),
'created_by' => $this->currentUserName(),
]);
$message = empty($notes)
? 'Cash transaction saved.'
: 'Cash transaction saved with compliance alert: ' . implode(' ', $notes);
return $this->response->setJSON([
'status' => 1,
'message' => $message,
]);
}
public function scrollCreate()
{
if (! $this->canFinance('finance.cash.billing.submit')) {
return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
}
$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 (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' => 'Select at least one payment to submit.',
]);
}
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);
$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 = 'pending';
$insertData = [
'scroll_date' => $scrollDate,
'department' => $department,
'total_receipts' => $totalReceipts,
'submitted_amount' => $submittedAmount,
'variance_amount' => $variance,
'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' => '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->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',
'finance.access',
'finance.compliance.view',
'finance.doctor_payout.manage',
'finance.vendor.manage',
'finance.po.manage',
'finance.grn.manage',
'finance.invoice.manage',
])) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
return view('finance/index');
}
public function payoutOpdConsult()
{
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');
}
$doctorModel = new DoctorModel();
$doctorRows = $doctorModel->getDoctors();
usort($doctorRows, static function ($left, $right): int {
$leftName = trim((string) (($left->p_title ?? '') . ' ' . ($left->p_fname ?? '')));
$rightName = trim((string) (($right->p_title ?? '') . ' ' . ($right->p_fname ?? '')));
return strcasecmp($leftName, $rightName);
});
$doctorOptions = [];
foreach ($doctorRows as $row) {
$isActive = property_exists($row, 'active') ? (int) ($row->active ?? 0) : 1;
if ($isActive !== 1) {
continue;
}
$doctorOptions[] = [
'id' => (int) ($row->id ?? 0),
'name' => trim((string) (($row->p_title ?? '') . ' ' . ($row->p_fname ?? ''))),
];
}
return view('finance/payout_opd_consult', [
'doctor_options' => $doctorOptions,
'state_unit_options' => $this->fetchOpdStateUnitOptions(),
]);
}
public function payoutOpdConsultSummary()
{
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');
}
$fromDate = $this->normalizeDateInput((string) ($this->request->getGet('from_date') ?? ''));
$toDate = $this->normalizeDateInput((string) ($this->request->getGet('to_date') ?? ''));
$doctorId = (int) ($this->request->getGet('doctor_id') ?? 0);
$stateUnit = trim((string) ($this->request->getGet('state_unit') ?? ''));
if ($fromDate === '') {
$fromDate = date('Y-m-01');
}
if ($toDate === '') {
$toDate = date('Y-m-d');
}
if ($fromDate > $toDate) {
[$fromDate, $toDate] = [$toDate, $fromDate];
}
return view('finance/partials/payout_opd_consult_summary', $this->buildOpdConsultPayoutSummaryData($fromDate, $toDate, $doctorId, $stateUnit));
}
public function payoutOpdConsultDraftsTable()
{
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',
'finance.doctor_payout.manage',
])) {
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') ?? ''));
$doctorId = (int) ($this->request->getGet('doctor_id') ?? 0);
$stateUnit = trim((string) ($this->request->getGet('state_unit') ?? ''));
if ($fromDate === '') {
$fromDate = date('Y-m-01');
}
if ($toDate === '') {
$toDate = date('Y-m-d');
}
if ($fromDate > $toDate) {
[$fromDate, $toDate] = [$toDate, $fromDate];
}
$builder = $this->doctorPayoutModel
->select('finance_doctor_payouts.*, finance_doctor_agreements.doctor_code, finance_doctor_agreements.doctor_name')
->join('finance_doctor_agreements', 'finance_doctor_agreements.id = finance_doctor_payouts.doctor_id', 'left')
->where('finance_doctor_payouts.payout_type', 'consultation')
->where('finance_doctor_payouts.payout_date >=', $fromDate)
->where('finance_doctor_payouts.payout_date <=', $toDate)
->orderBy('finance_doctor_payouts.id', 'DESC');
if ($doctorId > 0) {
$agreementIds = $this->findDoctorAgreementIdsFromMaster($doctorId);
if (! empty($agreementIds)) {
$builder->whereIn('finance_doctor_payouts.doctor_id', $agreementIds);
} else {
$builder->where('1 = 0', null, false);
}
}
if ($stateUnit !== '') {
$builder->like('finance_doctor_payouts.case_reference', '-U' . $this->normalizeStateUnitToken($stateUnit), 'both');
}
return view('finance/partials/payout_opd_consult_drafts_table', [
'rows' => $builder->findAll(30),
]);
}
public function payoutOpdConsultDraftCreate()
{
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',
'finance.doctor_payout.manage',
])) {
return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
}
if (! method_exists($this->db, 'tableExists') || ! $this->db->tableExists('finance_doctor_payouts')) {
return $this->response->setStatusCode(500)->setJSON(['status' => 0, 'message' => 'Payout table not available.']);
}
$doctorId = (int) ($this->request->getPost('doctor_id') ?? 0);
$fromDate = $this->normalizeDateInput((string) ($this->request->getPost('from_date') ?? ''));
$toDate = $this->normalizeDateInput((string) ($this->request->getPost('to_date') ?? ''));
$stateUnit = trim((string) ($this->request->getPost('state_unit') ?? ''));
$baseAmount = (float) ($this->request->getPost('base_amount') ?? 0);
$doctorShare = (float) ($this->request->getPost('doctor_share') ?? 75);
$hospitalShare = (float) ($this->request->getPost('hospital_share') ?? 25);
$deductions = (float) ($this->request->getPost('deductions') ?? 0);
$adjustments = (float) ($this->request->getPost('adjustments') ?? 0);
if ($doctorId <= 0) {
return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Please select a doctor before creating payout draft.']);
}
if ($fromDate === '') {
$fromDate = date('Y-m-01');
}
if ($toDate === '') {
$toDate = date('Y-m-d');
}
if ($fromDate > $toDate) {
[$fromDate, $toDate] = [$toDate, $fromDate];
}
$summaryData = $this->buildOpdConsultPayoutSummaryData($fromDate, $toDate, $doctorId, $stateUnit);
$summary = (array) ($summaryData['summary'] ?? []);
$eligibleOpdIds = $this->fetchEligibleOpdIdsForPayout($fromDate, $toDate, $doctorId, $stateUnit);
$completedOpd = count($eligibleOpdIds);
if ($completedOpd <= 0) {
return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'No completed OPD found for selected filters.']);
}
if ($baseAmount <= 0) {
$baseAmount = (float) ($summary['total_received'] ?? 0);
}
$doctorGross = round(($baseAmount * $doctorShare) / 100, 2);
$hospitalGross = round(($baseAmount * $hospitalShare) / 100, 2);
$netPayable = round($doctorGross - $deductions + $adjustments, 2);
if ($netPayable < 0) {
$netPayable = 0.0;
}
$agreementId = $this->resolveOrCreateDoctorAgreementIdFromMaster($doctorId);
if ($agreementId <= 0) {
return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Unable to map selected doctor to payout agreement.']);
}
$rate = round($netPayable / max(1, $completedOpd), 2);
$caseReference = 'OPD-' . str_replace('-', '', $fromDate) . '-' . str_replace('-', '', $toDate) . '-D' . $doctorId;
if ($stateUnit !== '') {
$caseReference .= '-U' . $this->normalizeStateUnitToken($stateUnit);
}
$remarks = 'OPD payout draft from OPD Consult Payout | Completed=' . $completedOpd
. ', Base=' . number_format($baseAmount, 2, '.', '')
. ', Doc%=' . number_format($doctorShare, 2, '.', '')
. ', Hosp%=' . number_format($hospitalShare, 2, '.', '')
. ', Deductions=' . number_format($deductions, 2, '.', '')
. ', Adjustments=' . number_format($adjustments, 2, '.', '')
. ', DoctorGross=' . number_format($doctorGross, 2, '.', '')
. ', HospitalShare=' . number_format($hospitalGross, 2, '.', '')
. ', Net=' . number_format($netPayable, 2, '.', '');
$this->db->transStart();
$this->doctorPayoutModel->insert([
'payout_date' => $toDate,
'doctor_id' => $agreementId,
'case_reference' => $caseReference,
'payout_type' => 'consultation',
'units' => $completedOpd,
'rate' => $rate,
'calculated_amount' => $netPayable,
'approved_amount' => $netPayable,
'status' => 'draft',
'remarks' => $remarks,
'hr_submitted_by' => $this->currentUserName(),
]);
$insertId = (int) ($this->doctorPayoutModel->getInsertID() ?? 0);
if ($insertId > 0 && ! empty($eligibleOpdIds) && method_exists($this->db, 'tableExists') && $this->db->tableExists('opd_master')) {
$opdUpdate = [
'payout_draft_id' => $insertId,
'payout_calculated_at' => date('Y-m-d H:i:s'),
'payout_calculated_by' => $this->currentUserName(),
];
if ($this->tableHasField('opd_master', 'updated_at')) {
$opdUpdate['updated_at'] = date('Y-m-d H:i:s');
}
$this->db->table('opd_master')
->whereIn('opd_id', $eligibleOpdIds)
->update($opdUpdate);
}
$this->db->transComplete();
if (! $this->db->transStatus() || $insertId <= 0) {
return $this->response->setStatusCode(500)->setJSON(['status' => 0, 'message' => 'Failed to create payout draft.']);
}
return $this->response->setJSON([
'status' => 1,
'message' => 'Payout draft created successfully.',
'payout_id' => $insertId,
'case_reference' => $caseReference,
'net_payable' => $netPayable,
]);
}
public function payoutOpdConsultDraftUpdate()
{
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',
'finance.doctor_payout.manage',
])) {
return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
}
$payoutId = (int) ($this->request->getPost('payout_id') ?? 0);
$payoutDate = $this->normalizeDateInput((string) ($this->request->getPost('payout_date') ?? ''));
$approvedAmount = (float) ($this->request->getPost('approved_amount') ?? 0);
$remarks = trim((string) ($this->request->getPost('remarks') ?? ''));
if ($payoutId <= 0) {
return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Invalid payout id.']);
}
$row = $this->doctorPayoutModel->find($payoutId);
if (! is_array($row)) {
return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Payout draft not found.']);
}
if ((string) ($row['status'] ?? 'draft') !== 'draft') {
return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Only draft entries can be edited.']);
}
if ($payoutDate === '') {
$payoutDate = (string) ($row['payout_date'] ?? date('Y-m-d'));
}
if ($approvedAmount < 0) {
$approvedAmount = 0;
}
$units = max(1, (int) ($row['units'] ?? 1));
$rate = round($approvedAmount / $units, 2);
$this->doctorPayoutModel->update($payoutId, [
'payout_date' => $payoutDate,
'approved_amount' => round($approvedAmount, 2),
'rate' => $rate,
'remarks' => $remarks !== '' ? $remarks : null,
]);
return $this->response->setJSON([
'status' => 1,
'message' => 'Payout draft updated successfully.',
]);
}
public function payoutOpdConsultDraftDelete()
{
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',
'finance.doctor_payout.manage',
])) {
return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
}
$payoutId = (int) ($this->request->getPost('payout_id') ?? 0);
if ($payoutId <= 0) {
return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Invalid payout id.']);
}
$row = $this->doctorPayoutModel->find($payoutId);
if (! is_array($row)) {
return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Payout draft not found.']);
}
if ((string) ($row['status'] ?? 'draft') !== 'draft') {
return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Only draft entries can be deleted.']);
}
$this->db->transStart();
if (method_exists($this->db, 'tableExists') && $this->db->tableExists('opd_master') && $this->tableHasField('opd_master', 'payout_draft_id')) {
$clearData = [
'payout_draft_id' => null,
];
if ($this->tableHasField('opd_master', 'payout_calculated_at')) {
$clearData['payout_calculated_at'] = null;
}
if ($this->tableHasField('opd_master', 'payout_calculated_by')) {
$clearData['payout_calculated_by'] = null;
}
$this->db->table('opd_master')
->where('payout_draft_id', $payoutId)
->update($clearData);
}
$this->doctorPayoutModel->delete($payoutId);
$this->db->transComplete();
if (! $this->db->transStatus()) {
return $this->response->setStatusCode(500)->setJSON(['status' => 0, 'message' => 'Failed to delete payout draft.']);
}
return $this->response->setJSON([
'status' => 1,
'message' => 'Payout draft deleted and linked OPD records unlocked for recalculation.',
]);
}
public function phase2()
{
if (! $this->canFinance('finance.access')) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
return view('finance/phase2', $this->buildPhase2Data());
}
public function sectionVendorMaster()
{
if (! $this->canFinance('finance.access')) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
return view('finance/sections/vendor_master', [
'vendors' => $this->fetchVendors(),
]);
}
public function sectionPurchaseOrder()
{
if (! $this->canFinance('finance.access')) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
return view('finance/sections/purchase_order', [
'purchase_orders' => $this->fetchPurchaseOrders(),
'vendor_options' => $this->vendorModel->select('id, vendor_name, vendor_code')->orderBy('vendor_name', 'ASC')->findAll(),
]);
}
public function sectionGrnEntry()
{
if (! $this->canFinance('finance.access')) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
return view('finance/sections/grn_entry', [
'grns' => $this->fetchGrns(),
'po_options' => $this->poModel->select('id, po_no')->orderBy('id', 'DESC')->findAll(),
]);
}
public function sectionVendorInvoice()
{
if (! $this->canFinance('finance.access')) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
return view('finance/sections/vendor_invoice', [
'vendor_invoices' => $this->fetchVendorInvoices(),
'vendor_options' => $this->vendorModel->select('id, vendor_name, vendor_code')->orderBy('vendor_name', 'ASC')->findAll(),
'po_options' => $this->poModel->select('id, po_no')->orderBy('id', 'DESC')->findAll(),
'grn_options' => $this->grnModel->select('id, grn_no')->orderBy('id', 'DESC')->findAll(),
]);
}
public function vendorsTable()
{
if (! $this->canFinance('finance.access')) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
return view('finance/partials/vendors_table', [
'vendors' => $this->fetchVendors(),
]);
}
public function poTable()
{
if (! $this->canFinance('finance.access')) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
return view('finance/partials/po_table', [
'purchase_orders' => $this->fetchPurchaseOrders(),
]);
}
public function grnTable()
{
if (! $this->canFinance('finance.access')) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
return view('finance/partials/grn_table', [
'grns' => $this->fetchGrns(),
]);
}
public function invoiceTable()
{
if (! $this->canFinance('finance.access')) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
return view('finance/partials/invoice_table', [
'vendor_invoices' => $this->fetchVendorInvoices(),
]);
}
public function vendorCreate()
{
if (! $this->canFinance('finance.vendor.manage')) {
return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
}
$vendorCode = strtoupper(trim((string) ($this->request->getPost('vendor_code') ?? '')));
$vendorName = trim((string) ($this->request->getPost('vendor_name') ?? ''));
if ($vendorCode === '' || $vendorName === '') {
return $this->response->setStatusCode(422)->setJSON([
'status' => 0,
'message' => 'Vendor code and vendor name are required.',
]);
}
if ($this->vendorModel->where('vendor_code', $vendorCode)->countAllResults() > 0) {
return $this->response->setStatusCode(422)->setJSON([
'status' => 0,
'message' => 'Vendor code already exists.',
]);
}
$this->vendorModel->insert([
'vendor_code' => $vendorCode,
'vendor_name' => $vendorName,
'contact_person' => trim((string) ($this->request->getPost('contact_person') ?? '')),
'phone' => trim((string) ($this->request->getPost('phone') ?? '')),
'email' => trim((string) ($this->request->getPost('email') ?? '')),
'gst_no' => trim((string) ($this->request->getPost('gst_no') ?? '')),
'pan_no' => trim((string) ($this->request->getPost('pan_no') ?? '')),
'address' => trim((string) ($this->request->getPost('address') ?? '')),
'status' => (int) ($this->request->getPost('status') ?? 1) === 1 ? 1 : 0,
'created_by' => $this->currentUserName(),
]);
return $this->response->setJSON([
'status' => 1,
'message' => 'Vendor created successfully.',
]);
}
public function poCreate()
{
if (! $this->canFinance('finance.po.manage')) {
return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
}
$poNo = strtoupper(trim((string) ($this->request->getPost('po_no') ?? '')));
$poDate = trim((string) ($this->request->getPost('po_date') ?? ''));
$vendorId = (int) ($this->request->getPost('vendor_id') ?? 0);
if ($poNo === '' || $poDate === '' || $vendorId <= 0) {
return $this->response->setStatusCode(422)->setJSON([
'status' => 0,
'message' => 'PO no, date and vendor are required.',
]);
}
if ($this->poModel->where('po_no', $poNo)->countAllResults() > 0) {
return $this->response->setStatusCode(422)->setJSON([
'status' => 0,
'message' => 'PO number already exists.',
]);
}
$uploadResult = $this->handlePoDocumentUploads($poNo);
if (! $uploadResult['ok']) {
return $this->response->setStatusCode($uploadResult['code'])->setJSON([
'status' => 0,
'message' => $uploadResult['message'],
]);
}
$uploadedDocs = $uploadResult['documents'];
$insertData = [
'po_no' => $poNo,
'po_date' => $poDate,
'vendor_id' => $vendorId,
'department' => trim((string) ($this->request->getPost('department') ?? '')),
'amount' => (float) ($this->request->getPost('amount') ?? 0),
'approval_status' => trim((string) ($this->request->getPost('approval_status') ?? 'draft')),
'remarks' => trim((string) ($this->request->getPost('remarks') ?? '')),
'created_by' => $this->currentUserName(),
];
$documentPath = '';
$documentName = '';
if (! empty($uploadedDocs)) {
$documentPath = (string) ($uploadedDocs[0]['file_path'] ?? '');
$documentName = (string) ($uploadedDocs[0]['file_name'] ?? '');
}
if ($documentPath !== '' && $this->tableHasField('finance_purchase_orders', 'po_document_path')) {
$insertData['po_document_path'] = $documentPath;
}
if ($documentName !== '' && $this->tableHasField('finance_purchase_orders', 'po_document_name')) {
$insertData['po_document_name'] = $documentName;
}
$this->poModel->insert($insertData);
$poId = (int) $this->poModel->getInsertID();
if ($poId > 0) {
$this->savePoDocuments($poId, $uploadedDocs);
}
return $this->response->setJSON([
'status' => 1,
'message' => $documentPath !== ''
? 'Purchase order created successfully with document upload.'
: 'Purchase order created successfully.',
]);
}
public function poUpdate()
{
if (! $this->canFinance('finance.po.manage')) {
return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
}
$poId = (int) ($this->request->getPost('po_id') ?? 0);
$poNo = strtoupper(trim((string) ($this->request->getPost('po_no') ?? '')));
$poDate = trim((string) ($this->request->getPost('po_date') ?? ''));
$vendorId = (int) ($this->request->getPost('vendor_id') ?? 0);
if ($poId <= 0 || $poNo === '' || $poDate === '' || $vendorId <= 0) {
return $this->response->setStatusCode(422)->setJSON([
'status' => 0,
'message' => 'PO id, no, date and vendor are required.',
]);
}
$existing = $this->poModel->find($poId);
if (! $existing) {
return $this->response->setStatusCode(404)->setJSON([
'status' => 0,
'message' => 'Purchase order not found.',
]);
}
$duplicate = $this->poModel
->where('po_no', $poNo)
->where('id !=', $poId)
->countAllResults();
if ($duplicate > 0) {
return $this->response->setStatusCode(422)->setJSON([
'status' => 0,
'message' => 'PO number already exists.',
]);
}
$uploadResult = $this->handlePoDocumentUploads($poNo);
if (! $uploadResult['ok']) {
return $this->response->setStatusCode($uploadResult['code'])->setJSON([
'status' => 0,
'message' => $uploadResult['message'],
]);
}
$removeDocIds = $this->request->getPost('remove_document_ids');
if (is_array($removeDocIds) && ! empty($removeDocIds)) {
foreach ($removeDocIds as $docId) {
$this->removePoDocument($poId, (int) $docId);
}
}
$uploadedDocs = $uploadResult['documents'];
if (! empty($uploadedDocs)) {
$this->savePoDocuments($poId, $uploadedDocs);
}
$updateData = [
'po_no' => $poNo,
'po_date' => $poDate,
'vendor_id' => $vendorId,
'department' => trim((string) ($this->request->getPost('department') ?? '')),
'amount' => (float) ($this->request->getPost('amount') ?? 0),
'approval_status' => trim((string) ($this->request->getPost('approval_status') ?? 'draft')),
'remarks' => trim((string) ($this->request->getPost('remarks') ?? '')),
];
$latestDoc = $this->fetchLatestPoDocument($poId);
if ($this->tableHasField('finance_purchase_orders', 'po_document_path')) {
$updateData['po_document_path'] = (string) ($latestDoc['file_path'] ?? '');
}
if ($this->tableHasField('finance_purchase_orders', 'po_document_name')) {
$updateData['po_document_name'] = (string) ($latestDoc['file_name'] ?? '');
}
$this->poModel->update($poId, $updateData);
return $this->response->setJSON([
'status' => 1,
'message' => 'Purchase order updated successfully.',
]);
}
public function poDocuments($poId)
{
if (! $this->canFinance('finance.po.manage')) {
return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
}
$poId = (int) $poId;
if ($poId <= 0) {
return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Invalid PO id']);
}
$docs = $this->fetchPoDocuments($poId);
return $this->response->setJSON([
'status' => 1,
'documents' => $docs,
]);
}
private function handlePoDocumentUploads(string $poNo): array
{
$files = $this->request->getFileMultiple('po_documents');
if (! is_array($files) || empty($files)) {
$single = $this->request->getFile('po_document');
if ($single && $single->isValid() && ! $single->hasMoved()) {
$files = [$single];
}
}
if (! is_array($files) || empty($files)) {
return [
'ok' => true,
'code' => 200,
'message' => '',
'documents' => [],
];
}
$canSaveDocument = $this->tableHasField('finance_purchase_orders', 'po_document_path')
&& $this->tableHasField('finance_purchase_orders', 'po_document_name');
if (! $canSaveDocument) {
return [
'ok' => false,
'code' => 500,
'message' => 'PO document columns are missing. Please run latest migrations and retry upload.',
'documents' => [],
];
}
$targetDir = rtrim(FCPATH, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'uploads' . DIRECTORY_SEPARATOR . 'finance_po_docs';
if (! is_dir($targetDir)) {
@mkdir($targetDir, 0755, true);
}
if (! is_dir($targetDir)) {
return [
'ok' => false,
'code' => 500,
'message' => 'Unable to create PO document upload directory.',
'documents' => [],
];
}
$allowedExt = ['pdf', 'jpg', 'jpeg', 'png'];
$maxBytes = 5 * 1024 * 1024;
$safePoNo = preg_replace('/[^A-Z0-9_-]/i', '_', $poNo);
$uploadedDocs = [];
foreach ($files as $file) {
if (! $file || ! $file->isValid() || $file->hasMoved()) {
continue;
}
$ext = strtolower((string) $file->getExtension());
if (! in_array($ext, $allowedExt, true)) {
return [
'ok' => false,
'code' => 422,
'message' => 'Invalid PO document type. Allowed: PDF, JPG, JPEG, PNG.',
'documents' => [],
];
}
if ((int) $file->getSize() > $maxBytes) {
return [
'ok' => false,
'code' => 422,
'message' => 'Each PO document size must be 5 MB or less.',
'documents' => [],
];
}
$newName = $safePoNo . '_' . date('YmdHis') . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
try {
$file->move($targetDir, $newName, true);
} catch (\Throwable $e) {
return [
'ok' => false,
'code' => 500,
'message' => 'Unable to upload PO document.',
'documents' => [],
];
}
$uploadedDocs[] = [
'file_name' => (string) $file->getClientName(),
'file_path' => 'uploads/finance_po_docs/' . $newName,
];
}
return [
'ok' => true,
'code' => 200,
'message' => '',
'documents' => $uploadedDocs,
];
}
private function savePoDocuments(int $poId, array $documents): void
{
if ($poId <= 0 || empty($documents) || ! $this->canUsePoDocumentTable()) {
return;
}
$rows = [];
foreach ($documents as $doc) {
$path = trim((string) ($doc['file_path'] ?? ''));
$name = trim((string) ($doc['file_name'] ?? ''));
if ($path === '' || $name === '') {
continue;
}
$rows[] = [
'po_id' => $poId,
'file_name' => $name,
'file_path' => $path,
'uploaded_by' => $this->currentUserName(),
];
}
if (! empty($rows)) {
$this->poDocumentModel->insertBatch($rows);
}
}
private function removePoDocument(int $poId, int $docId): void
{
if ($poId <= 0 || $docId <= 0 || ! $this->canUsePoDocumentTable()) {
return;
}
$doc = $this->poDocumentModel
->where('id', $docId)
->where('po_id', $poId)
->first();
if (! $doc) {
return;
}
$path = trim((string) ($doc['file_path'] ?? ''));
if ($path !== '') {
$fullPath = rtrim(FCPATH, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path);
if (is_file($fullPath)) {
@unlink($fullPath);
}
}
$this->poDocumentModel->delete((int) ($doc['id'] ?? 0));
}
private function fetchPoDocuments(int $poId): array
{
if ($poId <= 0) {
return [];
}
$docs = [];
if ($this->canUsePoDocumentTable()) {
$rows = $this->poDocumentModel
->where('po_id', $poId)
->orderBy('id', 'DESC')
->findAll();
foreach ($rows as $row) {
$path = trim((string) ($row['file_path'] ?? ''));
if ($path === '') {
continue;
}
$docs[] = [
'id' => (int) ($row['id'] ?? 0),
'file_name' => (string) ($row['file_name'] ?? 'Document'),
'file_path' => $path,
'url' => base_url($path),
'removable' => true,
];
}
}
if (empty($docs)) {
$po = $this->poModel->find($poId);
$legacyPath = trim((string) ($po['po_document_path'] ?? ''));
if ($legacyPath !== '') {
$docs[] = [
'id' => 0,
'file_name' => (string) ($po['po_document_name'] ?? 'Legacy document'),
'file_path' => $legacyPath,
'url' => base_url($legacyPath),
'removable' => false,
];
}
}
return $docs;
}
private function fetchLatestPoDocument(int $poId): array
{
if ($poId <= 0) {
return [];
}
if ($this->canUsePoDocumentTable()) {
$row = $this->poDocumentModel
->where('po_id', $poId)
->orderBy('id', 'DESC')
->first();
if (! empty($row)) {
return [
'file_name' => (string) ($row['file_name'] ?? ''),
'file_path' => (string) ($row['file_path'] ?? ''),
];
}
}
$po = $this->poModel->find($poId);
return [
'file_name' => (string) ($po['po_document_name'] ?? ''),
'file_path' => (string) ($po['po_document_path'] ?? ''),
];
}
private function canUsePoDocumentTable(): bool
{
if (! method_exists($this->db, 'tableExists') || ! $this->db->tableExists('finance_po_documents')) {
return false;
}
return $this->tableHasField('finance_po_documents', 'po_id')
&& $this->tableHasField('finance_po_documents', 'file_name')
&& $this->tableHasField('finance_po_documents', 'file_path');
}
public function grnCreate()
{
if (! $this->canFinance('finance.grn.manage')) {
return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
}
$grnNo = strtoupper(trim((string) ($this->request->getPost('grn_no') ?? '')));
$grnDate = trim((string) ($this->request->getPost('grn_date') ?? ''));
$poId = (int) ($this->request->getPost('po_id') ?? 0);
if ($grnNo === '' || $grnDate === '' || $poId <= 0) {
return $this->response->setStatusCode(422)->setJSON([
'status' => 0,
'message' => 'GRN no, date and PO are required.',
]);
}
if ($this->grnModel->where('grn_no', $grnNo)->countAllResults() > 0) {
return $this->response->setStatusCode(422)->setJSON([
'status' => 0,
'message' => 'GRN number already exists.',
]);
}
$this->grnModel->insert([
'grn_no' => $grnNo,
'grn_date' => $grnDate,
'po_id' => $poId,
'received_amount' => (float) ($this->request->getPost('received_amount') ?? 0),
'received_by' => trim((string) ($this->request->getPost('received_by') ?? '')),
'remarks' => trim((string) ($this->request->getPost('remarks') ?? '')),
]);
return $this->response->setJSON([
'status' => 1,
'message' => 'GRN created successfully.',
]);
}
public function grnPrint($grnId)
{
if (! $this->canFinance('finance.grn.manage')) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
$grnId = (int) $grnId;
if ($grnId <= 0) {
return $this->response->setStatusCode(422)->setBody('Invalid GRN id');
}
$grn = $this->grnModel
->select('finance_grns.*, finance_purchase_orders.po_no, finance_purchase_orders.po_date, finance_vendors.vendor_name, finance_vendors.vendor_code, finance_vendors.phone as vendor_phone, finance_vendors.address as vendor_address')
->join('finance_purchase_orders', 'finance_purchase_orders.id = finance_grns.po_id', 'left')
->join('finance_vendors', 'finance_vendors.id = finance_purchase_orders.vendor_id', 'left')
->where('finance_grns.id', $grnId)
->first();
if (! $grn) {
return $this->response->setStatusCode(404)->setBody('GRN not found');
}
return view('finance/print/grn_vendor_copy', [
'grn' => $grn,
'printed_at' => date('Y-m-d H:i:s'),
]);
}
public function invoiceCreate()
{
if (! $this->canFinance('finance.invoice.manage')) {
return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
}
$invoiceNo = strtoupper(trim((string) ($this->request->getPost('invoice_no') ?? '')));
$invoiceDate = trim((string) ($this->request->getPost('invoice_date') ?? ''));
$vendorId = (int) ($this->request->getPost('vendor_id') ?? 0);
if ($invoiceNo === '' || $invoiceDate === '' || $vendorId <= 0) {
return $this->response->setStatusCode(422)->setJSON([
'status' => 0,
'message' => 'Invoice no, date and vendor are required.',
]);
}
$invoiceAmount = (float) ($this->request->getPost('invoice_amount') ?? 0);
$poId = (int) ($this->request->getPost('po_id') ?? 0) ?: null;
$grnId = (int) ($this->request->getPost('grn_id') ?? 0) ?: null;
$match = $this->evaluateInvoiceMatch($vendorId, $poId, $grnId, $invoiceAmount);
$this->invoiceModel->insert([
'invoice_no' => $invoiceNo,
'invoice_date' => $invoiceDate,
'vendor_id' => $vendorId,
'po_id' => $poId,
'grn_id' => $grnId,
'invoice_amount' => $invoiceAmount,
'payment_status' => trim((string) ($this->request->getPost('payment_status') ?? 'pending')),
'match_status' => $match['match_status'],
'variance_amount' => $match['variance_amount'],
'match_note' => $match['match_note'],
'is_compliance_hold' => $match['is_compliance_hold'],
'match_checked_at' => date('Y-m-d H:i:s'),
'remarks' => trim((string) ($this->request->getPost('remarks') ?? '')),
'created_by' => $this->currentUserName(),
]);
return $this->response->setJSON([
'status' => 1,
'message' => 'Vendor invoice created successfully. Match status: ' . $match['match_status'] . '.',
]);
}
// ── Finance Phase 2 – Pharmacy Bills (Payable / Cr. to Hospital) ─────────────
public function sectionPharmacyBills()
{
if (! $this->canFinance('finance.access')) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
return view('finance/sections/pharmacy_bills', [
'pharmacy_bills' => $this->fetchPharmacyBills(),
]);
}
public function pharmacyBillsTable()
{
if (! $this->canFinance('finance.access')) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
return view('finance/partials/pharmacy_bills_table', [
'pharmacy_bills' => $this->fetchPharmacyBills(),
]);
}
public function pharmacyBillCreate()
{
if (! $this->canFinance('finance.invoice.manage')) {
return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
}
$billNo = strtoupper(trim((string) ($this->request->getPost('bill_no') ?? '')));
$billDate = trim((string) ($this->request->getPost('bill_date') ?? ''));
$pharmName = trim((string) ($this->request->getPost('pharmacy_name') ?? ''));
if ($billNo === '' || $billDate === '' || $pharmName === '') {
return $this->response->setStatusCode(422)->setJSON([
'status' => 0,
'message' => 'Bill number, date and pharmacy name are required.',
]);
}
$billAmount = (float) ($this->request->getPost('bill_amount') ?? 0);
$taxAmount = (float) ($this->request->getPost('tax_amount') ?? 0);
$netAmount = (float) ($this->request->getPost('net_amount') ?? 0);
if ($netAmount <= 0) {
$netAmount = $billAmount + $taxAmount;
}
$this->pharmBillModel->insert([
'bill_no' => $billNo,
'bill_date' => $billDate,
'pharmacy_name' => $pharmName,
'description' => trim((string) ($this->request->getPost('description') ?? '')),
'bill_amount' => $billAmount,
'tax_amount' => $taxAmount,
'net_amount' => $netAmount,
'payment_status' => 'pending',
'paid_amount' => 0.00,
'remarks' => trim((string) ($this->request->getPost('remarks') ?? '')),
'created_by' => $this->currentUserName(),
]);
return $this->response->setJSON([
'status' => 1,
'message' => 'Pharmacy bill registered. Net payable: Rs. ' . number_format($netAmount, 2) . '.',
]);
}
public function pharmacyBillSettle()
{
if (! $this->canFinance('finance.invoice.manage')) {
return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
}
$billId = (int) ($this->request->getPost('bill_id') ?? 0);
$paidAmount = (float) ($this->request->getPost('paid_amount') ?? 0);
$paymentDate = trim((string) ($this->request->getPost('payment_date') ?? ''));
$paymentMode = trim((string) ($this->request->getPost('payment_mode') ?? ''));
$paymentRef = trim((string) ($this->request->getPost('payment_ref') ?? ''));
if ($billId <= 0 || $paidAmount <= 0 || $paymentDate === '') {
return $this->response->setStatusCode(422)->setJSON([
'status' => 0,
'message' => 'Bill, paid amount, and payment date are required.',
]);
}
$bill = $this->pharmBillModel->find($billId);
if (! $bill) {
return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Bill not found.']);
}
$netAmount = (float) ($bill['net_amount'] ?? 0);
$alreadyPaid = (float) ($bill['paid_amount'] ?? 0);
$totalPaid = $alreadyPaid + $paidAmount;
$paymentStatus = $totalPaid >= $netAmount ? 'paid' : ($totalPaid > 0 ? 'part_paid' : 'pending');
$this->pharmBillModel->update($billId, [
'paid_amount' => min($totalPaid, $netAmount),
'payment_status' => $paymentStatus,
'payment_date' => $paymentDate,
'payment_mode' => $paymentMode,
'payment_ref' => $paymentRef,
]);
return $this->response->setJSON([
'status' => 1,
'message' => 'Payment recorded. Status: ' . $paymentStatus . '.',
]);
}
private function fetchPharmacyBills(): array
{
return $this->pharmBillModel
->orderBy('bill_date', 'DESC')
->orderBy('id', 'DESC')
->findAll(200);
}
// ── end Pharmacy Bills ────────────────────────────────────────────────────────
private function buildDashboardData(): array
{
$matchSummary = [
'matched' => 0,
'minor_variance' => 0,
'mismatch' => 0,
'hold' => 0,
];
$summaryRows = $this->invoiceModel
->select('match_status, is_compliance_hold, COUNT(*) as total')
->groupBy('match_status, is_compliance_hold')
->findAll();
foreach ($summaryRows as $row) {
$status = (string) ($row['match_status'] ?? 'not_checked');
$count = (int) ($row['total'] ?? 0);
if ($status === 'matched') {
$matchSummary['matched'] += $count;
}
if ($status === 'minor_variance') {
$matchSummary['minor_variance'] += $count;
}
if ($status === 'mismatch' || $status === 'not_checked') {
$matchSummary['mismatch'] += $count;
}
if ((int) ($row['is_compliance_hold'] ?? 0) === 1) {
$matchSummary['hold'] += $count;
}
}
return [
'vendors' => $this->fetchVendors(),
'purchase_orders' => $this->fetchPurchaseOrders(),
'grns' => $this->fetchGrns(),
'vendor_invoices' => $this->fetchVendorInvoices(),
'match_summary' => $matchSummary,
'vendor_options' => $this->vendorModel->select('id, vendor_name, vendor_code')->orderBy('vendor_name', 'ASC')->findAll(),
'po_options' => $this->poModel->select('id, po_no')->orderBy('id', 'DESC')->findAll(),
'grn_options' => $this->grnModel->select('id, grn_no')->orderBy('id', 'DESC')->findAll(),
];
}
private function buildPhase2Data(): array
{
$opdSummary = $this->buildOpdReceivableSummary();
$ipdSummary = $this->buildIpdReceivableSummary();
$pharmacySummary = $this->buildPharmacyReceivableSummary();
$orgSummary = $this->buildOrganizationExposureSummary();
$refundSummary = $this->buildRefundLifecycleSummary();
$receivableSummary = [
'total_billed' => $opdSummary['billed_total'] + $ipdSummary['billed_total'] + $pharmacySummary['billed_total'],
'total_collected' => $opdSummary['collected_total'] + $ipdSummary['collected_total'] + $pharmacySummary['collected_total'],
'total_outstanding' => $opdSummary['outstanding_total'] + $ipdSummary['outstanding_total'] + $pharmacySummary['outstanding_total'],
'pending_invoice_count' => $opdSummary['pending_count'] + $ipdSummary['pending_count'] + $pharmacySummary['pending_count'],
'streams' => [
'opd' => $opdSummary,
'ipd' => $ipdSummary,
'pharmacy' => $pharmacySummary,
],
];
// Pharmacy Bills payable summary (hospital owes to pharmacy entity)
$pharmBillSummary = ['total_bills' => 0, 'total_net' => 0.0, 'total_paid' => 0.0, 'total_pending' => 0.0, 'pending_count' => 0];
if ($this->db->tableExists('finance_pharmacy_bills')) {
try {
$row = $this->db->table('finance_pharmacy_bills')
->select('COUNT(*) as total_bills', false)
->select('COALESCE(SUM(net_amount),0) as total_net', false)
->select('COALESCE(SUM(paid_amount),0) as total_paid', false)
->select('COALESCE(SUM(CASE WHEN payment_status != \'paid\' THEN net_amount - paid_amount ELSE 0 END),0) as total_pending', false)
->select('COALESCE(SUM(CASE WHEN payment_status != \'paid\' THEN 1 ELSE 0 END),0) as pending_count', false)
->get()->getRowArray();
$pharmBillSummary = [
'total_bills' => (int) ($row['total_bills'] ?? 0),
'total_net' => (float) ($row['total_net'] ?? 0),
'total_paid' => (float) ($row['total_paid'] ?? 0),
'total_pending' => (float) ($row['total_pending'] ?? 0),
'pending_count' => (int) ($row['pending_count'] ?? 0),
];
} catch (\Throwable $e) {
// leave defaults
}
}
return [
'phase_started_at' => '2026-03-17',
'phase_plan' => [
['phase' => 1, 'name' => 'Procurement, cash controls, payouts, compliance', 'status' => 'live'],
['phase' => 2, 'name' => 'AR, refund, pharmacy and org-credit consolidation', 'status' => 'started'],
['phase' => 3, 'name' => 'Ledger hardening, audit trails, and statutory exports', 'status' => 'planned'],
],
'receivable_summary' => $receivableSummary,
'organization_summary' => $orgSummary,
'refund_summary' => $refundSummary,
'pharm_bill_summary' => $pharmBillSummary,
'bridge_metrics' => [
'charge_invoices_total' => $opdSummary['invoice_count'],
'refund_requests_total' => $refundSummary['total_requests'],
'medical_purchase_invoices_total' => $this->countTableRows('purchase_invoice'),
'supplier_ledger_entries_total' => $this->countTableRows('med_supplier_ledger'),
'organization_cases_total' => $orgSummary['case_count'],
],
];
}
private function buildOpdReceivableSummary(): array
{
$summary = $this->defaultReceivableSummary('OPD Billing');
if (! method_exists($this->db, 'tableExists') || ! $this->db->tableExists('invoice_master')) {
return $summary;
}
$netExpr = $this->resolveNetExpression('invoice_master', 'inv', ['correction_net_amount', 'net_amount']);
$outstandingExpr = $this->resolveOutstandingExpression('invoice_master', 'inv', ['payment_part_balance', 'payment_balance'], $netExpr, ['payment_part_received', 'payment_received']);
$collectedExpr = $this->resolveCollectedExpression('invoice_master', 'inv', ['payment_part_received', 'payment_received'], $netExpr, $outstandingExpr);
if ($netExpr === null || $outstandingExpr === null || $collectedExpr === null) {
return $summary;
}
try {
$builder = $this->db->table('invoice_master inv')
->select('COUNT(*) as invoice_count', false)
->select('COALESCE(SUM(' . $netExpr . '),0) as billed_total', false)
->select('COALESCE(SUM(' . $collectedExpr . '),0) as collected_total', false)
->select('COALESCE(SUM(' . $outstandingExpr . '),0) as outstanding_total', false)
->select('COALESCE(SUM(CASE WHEN ' . $outstandingExpr . ' > 0 THEN 1 ELSE 0 END),0) as pending_count', false);
if ($this->tableHasField('invoice_master', 'invoice_status')) {
$builder->where('inv.invoice_status', 1);
}
if ($this->tableHasField('invoice_master', 'ipd_id')) {
$builder->groupStart()->where('inv.ipd_id', 0)->orWhere('inv.ipd_id IS NULL', null, false)->groupEnd();
}
$row = (array) ($builder->get()->getRowArray() ?? []);
$summary['invoice_count'] = (int) ($row['invoice_count'] ?? 0);
$summary['pending_count'] = (int) ($row['pending_count'] ?? 0);
$summary['billed_total'] = (float) ($row['billed_total'] ?? 0);
$summary['collected_total'] = (float) ($row['collected_total'] ?? 0);
$summary['outstanding_total'] = (float) ($row['outstanding_total'] ?? 0);
} catch (\Throwable $e) {
return $summary;
}
return $summary;
}
private function buildIpdReceivableSummary(): array
{
$summary = $this->defaultReceivableSummary('IPD Billing');
if (! method_exists($this->db, 'tableExists') || ! $this->db->tableExists('ipd_master')) {
return $summary;
}
$netExpr = $this->resolveNetExpression('ipd_master', 'ipd', ['net_amount']);
$outstandingExpr = $this->resolveOutstandingExpression('ipd_master', 'ipd', ['balance_amount'], $netExpr, ['total_paid_amount']);
$collectedExpr = null;
if ($this->tableHasField('ipd_master', 'total_paid_amount') && $this->tableHasField('ipd_master', 'org_amount_recived')) {
$collectedExpr = 'GREATEST(IFNULL(ipd.total_paid_amount,0) + IFNULL(ipd.org_amount_recived,0),0)';
}
if ($collectedExpr === null) {
$collectedExpr = $this->resolveCollectedExpression('ipd_master', 'ipd', ['total_paid_amount'], $netExpr, $outstandingExpr);
}
if ($netExpr === null || $outstandingExpr === null || $collectedExpr === null) {
return $summary;
}
try {
$builder = $this->db->table('ipd_master ipd')
->select('COUNT(*) as invoice_count', false)
->select('COALESCE(SUM(' . $netExpr . '),0) as billed_total', false)
->select('COALESCE(SUM(' . $collectedExpr . '),0) as collected_total', false)
->select('COALESCE(SUM(' . $outstandingExpr . '),0) as outstanding_total', false)
->select('COALESCE(SUM(CASE WHEN ' . $outstandingExpr . ' > 0 THEN 1 ELSE 0 END),0) as pending_count', false);
if ($this->tableHasField('ipd_master', 'net_amount')) {
$builder->where('ipd.net_amount >', 0);
}
$row = (array) ($builder->get()->getRowArray() ?? []);
$summary['invoice_count'] = (int) ($row['invoice_count'] ?? 0);
$summary['pending_count'] = (int) ($row['pending_count'] ?? 0);
$summary['billed_total'] = (float) ($row['billed_total'] ?? 0);
$summary['collected_total'] = (float) ($row['collected_total'] ?? 0);
$summary['outstanding_total'] = (float) ($row['outstanding_total'] ?? 0);
} catch (\Throwable $e) {
return $summary;
}
return $summary;
}
private function buildPharmacyReceivableSummary(): array
{
$summary = $this->defaultReceivableSummary('Pharmacy Billing');
if (! method_exists($this->db, 'tableExists') || ! $this->db->tableExists('invoice_med_master')) {
return $summary;
}
$netExpr = $this->resolveNetExpression('invoice_med_master', 'med', ['net_amount']);
$outstandingExpr = $this->resolveOutstandingExpression('invoice_med_master', 'med', ['payment_balance'], $netExpr, ['payment_received']);
$collectedExpr = $this->resolveCollectedExpression('invoice_med_master', 'med', ['payment_received'], $netExpr, $outstandingExpr);
if ($netExpr === null || $outstandingExpr === null || $collectedExpr === null) {
return $summary;
}
try {
$builder = $this->db->table('invoice_med_master med')
->select('COUNT(*) as invoice_count', false)
->select('COALESCE(SUM(' . $netExpr . '),0) as billed_total', false)
->select('COALESCE(SUM(' . $collectedExpr . '),0) as collected_total', false)
->select('COALESCE(SUM(' . $outstandingExpr . '),0) as outstanding_total', false)
->select('COALESCE(SUM(CASE WHEN ' . $outstandingExpr . ' > 0 THEN 1 ELSE 0 END),0) as pending_count', false);
if ($this->tableHasField('invoice_med_master', 'invoice_status')) {
$builder->where('med.invoice_status', 1);
}
if ($this->tableHasField('invoice_med_master', 'sale_return')) {
$builder->where('med.sale_return', 0);
}
$row = (array) ($builder->get()->getRowArray() ?? []);
$summary['invoice_count'] = (int) ($row['invoice_count'] ?? 0);
$summary['pending_count'] = (int) ($row['pending_count'] ?? 0);
$summary['billed_total'] = (float) ($row['billed_total'] ?? 0);
$summary['collected_total'] = (float) ($row['collected_total'] ?? 0);
$summary['outstanding_total'] = (float) ($row['outstanding_total'] ?? 0);
} catch (\Throwable $e) {
return $summary;
}
return $summary;
}
private function buildOrganizationExposureSummary(): array
{
$summary = [
'case_count' => $this->countTableRows('organization_case_master'),
'invoice_outstanding' => 0.0,
'ipd_outstanding' => 0.0,
'total_outstanding' => 0.0,
];
if (method_exists($this->db, 'tableExists') && $this->db->tableExists('invoice_master') && $this->tableHasField('invoice_master', 'insurance_case_id')) {
$outstandingExpr = $this->resolveOutstandingExpression('invoice_master', 'inv', ['payment_part_balance', 'payment_balance'], $this->resolveNetExpression('invoice_master', 'inv', ['correction_net_amount', 'net_amount']), ['payment_part_received', 'payment_received']);
if ($outstandingExpr !== null) {
try {
$row = $this->db->table('invoice_master inv')
->select('COALESCE(SUM(' . $outstandingExpr . '),0) as total', false)
->where('inv.insurance_case_id >', 0)
->get()
->getRowArray();
$summary['invoice_outstanding'] = (float) ($row['total'] ?? 0);
} catch (\Throwable $e) {
$summary['invoice_outstanding'] = 0.0;
}
}
}
if (method_exists($this->db, 'tableExists') && $this->db->tableExists('ipd_master') && $this->tableHasField('ipd_master', 'case_id') && $this->tableHasField('ipd_master', 'balance_amount')) {
try {
$row = $this->db->table('ipd_master ipd')
->select('COALESCE(SUM(GREATEST(IFNULL(ipd.balance_amount,0),0)),0) as total', false)
->where('ipd.case_id >', 0)
->get()
->getRowArray();
$summary['ipd_outstanding'] = (float) ($row['total'] ?? 0);
} catch (\Throwable $e) {
$summary['ipd_outstanding'] = 0.0;
}
}
$summary['total_outstanding'] = $summary['invoice_outstanding'] + $summary['ipd_outstanding'];
return $summary;
}
private function buildRefundLifecycleSummary(): array
{
$summary = [
'total_requests' => 0,
'total_requested_amount' => 0.0,
'pending_count' => 0,
'pending_amount' => 0.0,
'completed_count' => 0,
'completed_amount' => 0.0,
'cancelled_count' => 0,
'cancelled_amount' => 0.0,
'by_type' => [],
];
if (! method_exists($this->db, 'tableExists') || ! $this->db->tableExists('refund_order')) {
return $summary;
}
$hasType = $this->tableHasField('refund_order', 'refund_type');
$hasProcess = $this->tableHasField('refund_order', 'refund_process');
$hasAmount = $this->tableHasField('refund_order', 'refund_amount');
if (! $hasType || ! $hasProcess || ! $hasAmount) {
return $summary;
}
try {
$row = (array) ($this->db->table('refund_order r')
->select('COUNT(*) as total_requests', false)
->select('COALESCE(SUM(IFNULL(r.refund_amount,0)),0) as total_requested_amount', false)
->select('COALESCE(SUM(CASE WHEN IFNULL(r.refund_process,0)=0 THEN 1 ELSE 0 END),0) as pending_count', false)
->select('COALESCE(SUM(CASE WHEN IFNULL(r.refund_process,0)=0 THEN IFNULL(r.refund_amount,0) ELSE 0 END),0) as pending_amount', false)
->select('COALESCE(SUM(CASE WHEN IFNULL(r.refund_process,0)=2 THEN 1 ELSE 0 END),0) as cancelled_count', false)
->select('COALESCE(SUM(CASE WHEN IFNULL(r.refund_process,0)=2 THEN IFNULL(r.refund_amount,0) ELSE 0 END),0) as cancelled_amount', false)
->select('COALESCE(SUM(CASE WHEN IFNULL(r.refund_process,0)>0 AND IFNULL(r.refund_process,0)<>2 THEN 1 ELSE 0 END),0) as completed_count', false)
->select('COALESCE(SUM(CASE WHEN IFNULL(r.refund_process,0)>0 AND IFNULL(r.refund_process,0)<>2 THEN IFNULL(r.refund_amount,0) ELSE 0 END),0) as completed_amount', false)
->get()
->getRowArray() ?? []);
$summary['total_requests'] = (int) ($row['total_requests'] ?? 0);
$summary['total_requested_amount'] = (float) ($row['total_requested_amount'] ?? 0);
$summary['pending_count'] = (int) ($row['pending_count'] ?? 0);
$summary['pending_amount'] = (float) ($row['pending_amount'] ?? 0);
$summary['completed_count'] = (int) ($row['completed_count'] ?? 0);
$summary['completed_amount'] = (float) ($row['completed_amount'] ?? 0);
$summary['cancelled_count'] = (int) ($row['cancelled_count'] ?? 0);
$summary['cancelled_amount'] = (float) ($row['cancelled_amount'] ?? 0);
$typeRows = $this->db->table('refund_order r')
->select('IFNULL(r.refund_type,0) as refund_type', false)
->select('COUNT(*) as total_requests', false)
->select('COALESCE(SUM(IFNULL(r.refund_amount,0)),0) as total_amount', false)
->select('COALESCE(SUM(CASE WHEN IFNULL(r.refund_process,0)=0 THEN 1 ELSE 0 END),0) as pending_count', false)
->select('COALESCE(SUM(CASE WHEN IFNULL(r.refund_process,0)=0 THEN IFNULL(r.refund_amount,0) ELSE 0 END),0) as pending_amount', false)
->select('COALESCE(SUM(CASE WHEN IFNULL(r.refund_process,0)>0 AND IFNULL(r.refund_process,0)<>2 THEN IFNULL(r.refund_amount,0) ELSE 0 END),0) as completed_amount', false)
->groupBy('r.refund_type')
->orderBy('r.refund_type', 'ASC')
->get()
->getResultArray();
foreach ($typeRows as $typeRow) {
$typeCode = (int) ($typeRow['refund_type'] ?? 0);
$summary['by_type'][] = [
'type_code' => $typeCode,
'type_label' => match ($typeCode) {
1 => 'OPD',
2 => 'Charge',
3 => 'IPD',
default => 'Other',
},
'total_requests' => (int) ($typeRow['total_requests'] ?? 0),
'total_amount' => (float) ($typeRow['total_amount'] ?? 0),
'pending_count' => (int) ($typeRow['pending_count'] ?? 0),
'pending_amount' => (float) ($typeRow['pending_amount'] ?? 0),
'completed_amount' => (float) ($typeRow['completed_amount'] ?? 0),
];
}
} catch (\Throwable $e) {
return $summary;
}
return $summary;
}
private function defaultReceivableSummary(string $label): array
{
return [
'label' => $label,
'invoice_count' => 0,
'pending_count' => 0,
'billed_total' => 0.0,
'collected_total' => 0.0,
'outstanding_total' => 0.0,
];
}
private function resolveNetExpression(string $table, string $alias, array $preferredColumns): ?string
{
$column = $this->firstExistingColumn($table, $preferredColumns);
return $column === null ? null : 'GREATEST(IFNULL(' . $alias . '.' . $column . ',0),0)';
}
private function resolveOutstandingExpression(string $table, string $alias, array $balanceCandidates, ?string $netExpr, array $paidCandidates): ?string
{
$balanceColumn = $this->firstExistingColumn($table, $balanceCandidates);
if ($balanceColumn !== null) {
return 'GREATEST(IFNULL(' . $alias . '.' . $balanceColumn . ',0),0)';
}
$paidColumn = $this->firstExistingColumn($table, $paidCandidates);
if ($netExpr !== null && $paidColumn !== null) {
return 'GREATEST((' . $netExpr . ') - GREATEST(IFNULL(' . $alias . '.' . $paidColumn . ',0),0),0)';
}
return $netExpr;
}
private function resolveCollectedExpression(string $table, string $alias, array $paidCandidates, ?string $netExpr, ?string $outstandingExpr): ?string
{
$paidColumn = $this->firstExistingColumn($table, $paidCandidates);
if ($paidColumn !== null) {
return 'GREATEST(IFNULL(' . $alias . '.' . $paidColumn . ',0),0)';
}
if ($netExpr !== null && $outstandingExpr !== null) {
return 'GREATEST((' . $netExpr . ') - (' . $outstandingExpr . '),0)';
}
return null;
}
private function firstExistingColumn(string $table, array $candidates): ?string
{
foreach ($candidates as $column) {
if ($this->tableHasField($table, $column)) {
return $column;
}
}
return null;
}
private function tableHasField(string $table, string $field): bool
{
return method_exists($this->db, 'fieldExists') && $this->db->fieldExists($field, $table);
}
private function countTableRows(string $table): int
{
if (! method_exists($this->db, 'tableExists') || ! $this->db->tableExists($table)) {
return 0;
}
try {
return (int) $this->db->table($table)->countAllResults();
} catch (\Throwable $e) {
return 0;
}
}
private function fetchVendors(): array
{
return $this->vendorModel->orderBy('id', 'DESC')->findAll(12);
}
private function fetchPurchaseOrders(): array
{
$rows = $this->poModel
->select('finance_purchase_orders.*, finance_vendors.vendor_name, finance_vendors.vendor_code')
->join('finance_vendors', 'finance_vendors.id = finance_purchase_orders.vendor_id', 'left')
->orderBy('finance_purchase_orders.id', 'DESC')
->findAll(12);
if (empty($rows)) {
return [];
}
$poIds = array_map(static fn(array $r): int => (int) ($r['id'] ?? 0), $rows);
$docCountMap = [];
if ($this->canUsePoDocumentTable()) {
$countRows = $this->poDocumentModel
->select('po_id, COUNT(*) as cnt')
->whereIn('po_id', $poIds)
->groupBy('po_id')
->findAll();
foreach ($countRows as $countRow) {
$docCountMap[(int) ($countRow['po_id'] ?? 0)] = (int) ($countRow['cnt'] ?? 0);
}
}
foreach ($rows as &$row) {
$id = (int) ($row['id'] ?? 0);
$row['document_count'] = (int) ($docCountMap[$id] ?? 0);
}
unset($row);
return $rows;
}
private function fetchGrns(): array
{
return $this->grnModel
->select('finance_grns.*, finance_purchase_orders.po_no, finance_vendors.vendor_name, finance_vendors.vendor_code')
->join('finance_purchase_orders', 'finance_purchase_orders.id = finance_grns.po_id', 'left')
->join('finance_vendors', 'finance_vendors.id = finance_purchase_orders.vendor_id', 'left')
->orderBy('finance_grns.id', 'DESC')
->findAll(12);
}
private function fetchVendorInvoices(): array
{
return $this->invoiceModel
->select('finance_vendor_invoices.*, finance_vendors.vendor_name, finance_purchase_orders.po_no, finance_grns.grn_no')
->join('finance_vendors', 'finance_vendors.id = finance_vendor_invoices.vendor_id', 'left')
->join('finance_purchase_orders', 'finance_purchase_orders.id = finance_vendor_invoices.po_id', 'left')
->join('finance_grns', 'finance_grns.id = finance_vendor_invoices.grn_id', 'left')
->orderBy('finance_vendor_invoices.id', 'DESC')
->findAll(12);
}
private function currentUserName(): string
{
$user = function_exists('auth') ? auth()->user() : null;
return trim((string) ($user->username ?? 'System'));
}
private function currentUserId(): ?int
{
$user = function_exists('auth') ? auth()->user() : null;
if (! $user || ! isset($user->id)) {
return null;
}
return (int) $user->id;
}
private function fetchCashTransactions(): array
{
return $this->cashTxnModel->orderBy('id', 'DESC')->findAll(15);
}
private function fetchScrolls(): array
{
return $this->scrollModel->orderBy('id', 'DESC')->findAll(15);
}
private function buildCashSummary(): array
{
$today = date('Y-m-d');
$receiptRow = $this->cashTxnModel
->select('COALESCE(SUM(amount),0) AS total')
->where('txn_date', $today)
->where('txn_type', 'receipt')
->first();
$disbursementRow = $this->cashTxnModel
->select('COALESCE(SUM(amount),0) AS total')
->where('txn_date', $today)
->where('txn_type', 'disbursement')
->first();
return [
'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->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),
];
}
public function medicalStoreCreditAccount()
{
if (! $this->canFinanceAny(['finance.workflow.view', 'finance.cash.accounts.accept', 'finance.cash.accounts.verify', 'finance.doctor_payout.manage'])) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
$summary = [
'total_requested' => 0.0,
'total_paid' => 0.0,
'total_pending' => 0.0,
'open_requests' => 0,
];
$rows = [];
if ($this->db->tableExists('finance_payout_requests')) {
$summaryRow = $this->db->table('finance_payout_requests')
->select('COALESCE(SUM(requested_amount),0) AS total_requested, COALESCE(SUM(paid_amount),0) AS total_paid, COALESCE(SUM(pending_amount),0) AS total_pending', false)
->where('request_type', 'medical_store_credit')
->get()->getRowArray();
$summary['total_requested'] = (float) ($summaryRow['total_requested'] ?? 0);
$summary['total_paid'] = (float) ($summaryRow['total_paid'] ?? 0);
$summary['total_pending'] = (float) ($summaryRow['total_pending'] ?? 0);
$summary['open_requests'] = $this->db->table('finance_payout_requests')
->where('request_type', 'medical_store_credit')
->whereIn('status', ['submitted', 'finance_review', 'approved', 'partially_paid'])
->countAllResults();
$rows = $this->payoutRequestModel
->where('request_type', 'medical_store_credit')
->orderBy('id', 'DESC')
->findAll(60);
}
return view('finance/medical_store_credit_account', [
'summary' => $summary,
'rows' => $rows,
]);
}
public function medicalStoreRequestsTable()
{
if (! $this->canFinanceAny(['finance.workflow.view', 'finance.cash.accounts.accept', 'finance.cash.accounts.verify', 'finance.doctor_payout.manage'])) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
$status = trim((string) ($this->request->getGet('status') ?? ''));
$rows = [];
if ($this->db->tableExists('finance_payout_requests')) {
$builder = $this->payoutRequestModel
->where('request_type', 'medical_store_credit')
->orderBy('id', 'DESC');
if ($status !== '') {
$builder->where('status', $status);
}
$rows = $builder->findAll(200);
}
return view('finance/partials/medical_store_requests_table', ['rows' => $rows]);
}
public function medicalStoreRequestLinesTable()
{
if (! $this->canFinanceAny(['finance.workflow.view', 'finance.cash.accounts.accept', 'finance.cash.accounts.verify', 'finance.doctor_payout.manage'])) {
return $this->response->setStatusCode(403)->setBody('Access denied');
}
$requestId = (int) ($this->request->getGet('request_id') ?? 0);
if ($requestId <= 0) {
return view('finance/partials/medical_store_request_lines_table', [
'request' => null,
'lines' => [],
'payments' => [],
]);
}
$request = $this->payoutRequestModel->find($requestId);
$lines = $this->db->tableExists('finance_payout_request_lines')
? $this->payoutRequestLineModel->where('request_id', $requestId)->orderBy('line_order', 'ASC')->orderBy('id', 'ASC')->findAll()
: [];
$payments = $this->db->tableExists('finance_outgoing_payment_history')
? $this->outgoingPaymentHistoryModel->where('request_id', $requestId)->orderBy('id', 'DESC')->findAll()
: [];
return view('finance/partials/medical_store_request_lines_table', [
'request' => $request,
'lines' => $lines,
'payments' => $payments,
]);
}
public function medicalStoreRequestDetail($requestId = 0)
{
if (! $this->canFinanceAny(['finance.workflow.view', 'finance.cash.accounts.accept', 'finance.cash.accounts.verify', 'finance.doctor_payout.manage'])) {
return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
}
$requestId = (int) $requestId;
if ($requestId <= 0) {
return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Request ID required']);
}
$request = $this->payoutRequestModel->find($requestId);
if (! is_array($request)) {
return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Request not found']);
}
$lines = $this->db->tableExists('finance_payout_request_lines')
? $this->payoutRequestLineModel->where('request_id', $requestId)->orderBy('line_order', 'ASC')->orderBy('id', 'ASC')->findAll()
: [];
$payments = $this->db->tableExists('finance_outgoing_payment_history')
? $this->outgoingPaymentHistoryModel->where('request_id', $requestId)->orderBy('id', 'DESC')->findAll()
: [];
return $this->response->setJSON([
'status' => 1,
'request' => $request,
'lines' => $lines,
'payments' => $payments,
]);
}
public function medicalStoreRequestReview()
{
return $this->transitionMedicalStoreRequestStatus('finance_review', ['submitted']);
}
public function medicalStoreRequestApprove()
{
return $this->transitionMedicalStoreRequestStatus('approved', ['finance_review']);
}
public function medicalStoreRequestReject()
{
return $this->transitionMedicalStoreRequestStatus('rejected', ['finance_review', 'submitted']);
}
public function medicalStorePaymentCreate()
{
if (! $this->canFinanceAny(['finance.cash.accounts.accept', 'finance.cash.accounts.verify', 'finance.doctor_payout.manage'])) {
return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
}
$requestId = (int) ($this->request->getPost('request_id') ?? 0);
$amount = round((float) ($this->request->getPost('amount') ?? 0), 2);
$paymentMode = (int) ($this->request->getPost('payment_mode') ?? 1);
$remark = trim((string) ($this->request->getPost('remark') ?? ''));
if ($requestId <= 0 || $amount <= 0) {
return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Request and amount are required.']);
}
$request = $this->payoutRequestModel->find($requestId);
if (! is_array($request)) {
return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Request not found.']);
}
$status = (string) ($request['status'] ?? '');
if (! in_array($status, ['approved', 'partially_paid'], true)) {
return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Only approved or partially paid requests can be paid.']);
}
$pending = round((float) ($request['pending_amount'] ?? 0), 2);
if ($pending <= 0) {
return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Request has no pending amount.']);
}
if ($amount > $pending) {
return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Payment amount exceeds pending amount.']);
}
$userId = $this->currentUserId();
$userName = $this->currentUserName();
$now = date('Y-m-d H:i:s');
$paymentNo = 'MSP-' . date('YmdHis') . '-' . random_int(1000, 9999);
$this->db->transStart();
$this->outgoingPaymentHistoryModel->insert([
'payment_no' => $paymentNo,
'payout_type' => 'medical_store',
'payee_label' => 'Medical Store Credit Settlement',
'request_id' => $requestId,
'payment_date' => $now,
'amount' => $amount,
'payment_mode' => $paymentMode,
'pay_bank_id' => (int) ($this->request->getPost('pay_bank_id') ?? 0),
'card_bank' => trim((string) ($this->request->getPost('card_bank') ?? '')),
'card_remark' => trim((string) ($this->request->getPost('card_remark') ?? '')),
'cust_card' => trim((string) ($this->request->getPost('cust_card') ?? '')),
'card_tran_id' => trim((string) ($this->request->getPost('card_tran_id') ?? '')),
'bankcard_machine' => trim((string) ($this->request->getPost('bankcard_machine') ?? '')),
'insert_code' => trim((string) ($this->request->getPost('insert_code') ?? '')),
'credit_debit' => 0,
'remark' => $remark,
'status' => 'paid',
'created_by' => $userName,
'created_by_id' => $userId,
'created_at' => $now,
'updated_at' => $now,
]);
$paymentId = (int) $this->outgoingPaymentHistoryModel->getInsertID();
$openLines = $this->payoutRequestLineModel
->where('request_id', $requestId)
->where('pending_amount >', 0)
->orderBy('line_order', 'ASC')
->orderBy('id', 'ASC')
->findAll();
$remaining = $amount;
$allocOrder = 1;
foreach ($openLines as $line) {
$lineId = (int) ($line['id'] ?? 0);
$linePending = round((float) ($line['pending_amount'] ?? 0), 2);
if ($lineId <= 0 || $linePending <= 0 || $remaining <= 0) {
continue;
}
$alloc = round(min($linePending, $remaining), 2);
$this->outgoingPaymentAllocationModel->insert([
'payment_id' => $paymentId,
'request_id' => $requestId,
'request_line_id' => $lineId,
'allocated_amount' => $alloc,
'allocation_order' => $allocOrder,
'allocation_note' => 'Auto FIFO allocation',
'created_by' => $userName,
'created_by_id' => $userId,
'created_at' => $now,
]);
$newAllocated = round((float) ($line['allocated_amount'] ?? 0) + $alloc, 2);
$newPending = round(max(0, (float) ($line['line_amount'] ?? 0) - $newAllocated), 2);
$newLineStatus = $newPending <= 0 ? 'closed' : ($newAllocated > 0 ? 'partial' : 'open');
$this->payoutRequestLineModel->update($lineId, [
'allocated_amount' => $newAllocated,
'pending_amount' => $newPending,
'line_status' => $newLineStatus,
'updated_at' => $now,
]);
$remaining = round($remaining - $alloc, 2);
$allocOrder++;
}
if ($remaining > 0) {
$this->db->transRollback();
return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Payment exceeds open line capacity.']);
}
$this->recalculateMedicalStoreRequestTotals($requestId, $userId, $userName);
$this->logPayoutRequestAudit($requestId, 'payment_create', $status, null, 'Payment #' . $paymentNo . ' amount: ' . number_format($amount, 2, '.', ''));
$this->db->transComplete();
if (! $this->db->transStatus()) {
return $this->response->setStatusCode(500)->setJSON(['status' => 0, 'message' => 'Failed to save payout payment.']);
}
return $this->response->setJSON([
'status' => 1,
'message' => 'Payment saved and allocated successfully.',
'payment_id' => $paymentId,
'payment_no' => $paymentNo,
]);
}
public function medicalStoreDashboardCard()
{
if (! $this->canFinanceAny(['finance.workflow.view', 'finance.cash.accounts.accept', 'finance.cash.accounts.verify', 'finance.doctor_payout.manage'])) {
return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
}
if (! $this->db->tableExists('finance_payout_requests')) {
return $this->response->setJSON([
'status' => 1,
'summary' => ['total_requested' => 0, 'total_paid' => 0, 'total_pending' => 0, 'open_requests' => 0],
]);
}
$summaryRow = $this->db->table('finance_payout_requests')
->select('COALESCE(SUM(requested_amount),0) AS total_requested, COALESCE(SUM(paid_amount),0) AS total_paid, COALESCE(SUM(pending_amount),0) AS total_pending', false)
->where('request_type', 'medical_store_credit')
->get()->getRowArray();
$openRequests = $this->db->table('finance_payout_requests')
->where('request_type', 'medical_store_credit')
->whereIn('status', ['submitted', 'finance_review', 'approved', 'partially_paid'])
->countAllResults();
return $this->response->setJSON([
'status' => 1,
'summary' => [
'total_requested' => (float) ($summaryRow['total_requested'] ?? 0),
'total_paid' => (float) ($summaryRow['total_paid'] ?? 0),
'total_pending' => (float) ($summaryRow['total_pending'] ?? 0),
'open_requests' => $openRequests,
],
]);
}
private function transitionMedicalStoreRequestStatus(string $newStatus, array $allowedFrom)
{
if (! $this->canFinanceAny(['finance.workflow.view', 'finance.cash.accounts.accept', 'finance.cash.accounts.verify', 'finance.doctor_payout.manage'])) {
return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
}
$requestId = (int) ($this->request->getPost('request_id') ?? 0);
$remark = trim((string) ($this->request->getPost('remark') ?? ''));
if ($requestId <= 0) {
return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Request ID required.']);
}
$request = $this->payoutRequestModel->find($requestId);
if (! is_array($request)) {
return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Request not found.']);
}
$oldStatus = (string) ($request['status'] ?? '');
if (! in_array($oldStatus, $allowedFrom, true)) {
return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Invalid status transition from ' . $oldStatus . ' to ' . $newStatus . '.']);
}
$now = date('Y-m-d H:i:s');
$update = [
'status' => $newStatus,
'updated_at' => $now,
'updated_by' => $this->currentUserName(),
'updated_by_id' => $this->currentUserId(),
];
if ($newStatus === 'finance_review') {
$update['finance_reviewed_at'] = $now;
}
if ($newStatus === 'approved') {
$update['approved_at'] = $now;
if ((float) ($request['approved_amount'] ?? 0) <= 0) {
$update['approved_amount'] = (float) ($request['requested_amount'] ?? 0);
}
}
$this->payoutRequestModel->update($requestId, $update);
$this->logPayoutRequestAudit($requestId, 'status_change', $oldStatus, $newStatus, $remark);
return $this->response->setJSON(['status' => 1, 'message' => 'Request status updated to ' . $newStatus . '.']);
}
private function recalculateMedicalStoreRequestTotals(int $requestId, ?int $userId = null, ?string $userName = null): void
{
$sumLines = $this->db->table('finance_payout_request_lines')
->select('COALESCE(SUM(line_amount),0) AS line_total, COALESCE(SUM(allocated_amount),0) AS allocated_total, COALESCE(SUM(pending_amount),0) AS pending_total', false)
->where('request_id', $requestId)
->get()->getRowArray();
$requested = round((float) ($sumLines['line_total'] ?? 0), 2);
$paid = round((float) ($sumLines['allocated_total'] ?? 0), 2);
$pending = round((float) ($sumLines['pending_total'] ?? 0), 2);
$newStatus = $paid <= 0 ? 'approved' : ($pending > 0 ? 'partially_paid' : 'paid');
$update = [
'requested_amount' => $requested,
'paid_amount' => $paid,
'pending_amount' => $pending,
'status' => $newStatus,
'updated_at' => date('Y-m-d H:i:s'),
];
if ($userName !== null) {
$update['updated_by'] = $userName;
}
if ($userId !== null) {
$update['updated_by_id'] = $userId;
}
if ($newStatus === 'paid') {
$update['closed_at'] = date('Y-m-d H:i:s');
}
$this->payoutRequestModel->update($requestId, $update);
}
private function logPayoutRequestAudit(int $requestId, string $actionType, ?string $oldStatus, ?string $newStatus, string $note = ''): void
{
if (! $this->db->tableExists('finance_payout_request_audit')) {
return;
}
$this->payoutRequestAuditModel->insert([
'request_id' => $requestId,
'action_type' => $actionType,
'old_status' => $oldStatus,
'new_status' => $newStatus,
'action_note' => $note !== '' ? $note : null,
'action_by' => $this->currentUserName(),
'action_by_id' => $this->currentUserId(),
'action_at' => date('Y-m-d H:i:s'),
]);
}
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);
}
private function fetchDoctorPayouts(): array
{
return $this->doctorPayoutModel
->select('finance_doctor_payouts.*, finance_doctor_agreements.doctor_name')
->join('finance_doctor_agreements', 'finance_doctor_agreements.id = finance_doctor_payouts.doctor_id', 'left')
->orderBy('finance_doctor_payouts.id', 'DESC')
->findAll(20);
}
private function buildDoctorPayoutSummary(): array
{
return [
'draft' => $this->doctorPayoutModel->where('status', 'draft')->countAllResults(),
'finance_approved' => $this->doctorPayoutModel->where('status', 'finance_approved')->countAllResults(),
'ceo_approved' => $this->doctorPayoutModel->where('status', 'ceo_approved')->countAllResults(),
'paid' => $this->doctorPayoutModel->where('status', 'paid')->countAllResults(),
];
}
private function fetchBankDeposits(): array
{
return $this->bankDepositModel->orderBy('id', 'DESC')->findAll(20);
}
private function buildDepositSummary(): array
{
$today = date('Y-m-d');
$todayRow = $this->bankDepositModel
->select('COALESCE(SUM(deposited_amount),0) AS total')
->where('deposit_date', $today)
->first();
return [
'today_deposit' => (float) ($todayRow['total'] ?? 0),
'pending_count' => $this->bankDepositModel->where('reconciliation_status', 'pending')->countAllResults(),
'matched_count' => $this->bankDepositModel->where('reconciliation_status', 'matched')->countAllResults(),
];
}
private function getPolicyLimit(string $key, float $default): float
{
$row = $this->policySettingModel->where('setting_key', $key)->first();
if (! $row) {
return $default;
}
$value = (float) ($row['setting_value'] ?? $default);
return $value > 0 ? $value : $default;
}
private function buildOpdConsultPayoutSummaryData(string $fromDate, string $toDate, int $doctorId = 0, string $stateUnit = ''): array
{
$db = \Config\Database::connect();
$stateUnitColumn = $this->resolveOpdStateUnitColumn();
$runningExpr = "(COALESCE(o.running_opd,0)=1 OR o.opd_fee_type=3 OR UPPER(COALESCE(o.opd_fee_desc,'')) LIKE '%RUNNING%')";
$newExpr = "(o.opd_fee_type=1 OR UPPER(COALESCE(o.opd_fee_desc,'')) LIKE '%NEW%')";
$emergencyExpr = "(UPPER(COALESCE(o.opd_fee_desc,'')) LIKE '%EMERG%')";
$routineExpr = "(NOT {$emergencyExpr})";
$orgCreditExpr = "(COALESCE(o.insurance_case_id,0) > 0 OR COALESCE(o.insurance_id,0) > 0 OR COALESCE(o.payment_status,0) = 3)";
$doctorName = 'All Doctors';
if ($doctorId > 0) {
$doctorRow = $db->table('doctor_master')->select('p_title, p_fname')->where('id', $doctorId)->get()->getRow();
if ($doctorRow) {
$doctorName = trim((string) (($doctorRow->p_title ?? '') . ' ' . ($doctorRow->p_fname ?? '')));
}
}
$opdBuilder = $db->table('opd_master o')
->select('o.doc_id')
->select("COALESCE(NULLIF(TRIM(o.doc_name), ''), CONCAT('Doctor #', o.doc_id)) AS doctor_name", false)
->select('SUM(CASE WHEN o.opd_status IN (1,2) THEN 1 ELSE 0 END) AS completed_opd', false)
->select("SUM(CASE WHEN o.opd_status IN (1,2) AND {$routineExpr} THEN 1 ELSE 0 END) AS routine_opd", false)
->select("SUM(CASE WHEN o.opd_status IN (1,2) AND {$emergencyExpr} THEN 1 ELSE 0 END) AS emergency_opd", false)
->select("SUM(CASE WHEN o.opd_status IN (1,2) AND {$runningExpr} THEN 1 ELSE 0 END) AS running_opd", false)
->select("SUM(CASE WHEN o.opd_status IN (1,2) AND {$newExpr} THEN 1 ELSE 0 END) AS new_opd", false)
->select('SUM(CASE WHEN o.opd_status IN (1,2) THEN COALESCE(o.opd_fee_amount,0) ELSE 0 END) AS gross_amount', false)
->select("SUM(CASE WHEN o.opd_status IN (1,2) AND {$orgCreditExpr} THEN COALESCE(o.opd_fee_amount,0) ELSE 0 END) AS org_credit_amount", false)
->where('o.apointment_date >=', $fromDate)
->where('o.apointment_date <=', $toDate)
->whereIn('o.opd_status', [1, 2]);
if ($this->tableHasField('opd_master', 'payout_draft_id')) {
$opdBuilder->groupStart()
->where('o.payout_draft_id IS NULL', null, false)
->orWhere('o.payout_draft_id', 0)
->groupEnd();
}
if ($doctorId > 0) {
$opdBuilder->where('o.doc_id', $doctorId);
}
if ($stateUnit !== '' && $stateUnitColumn !== null) {
$opdBuilder->where('o.' . $stateUnitColumn, $stateUnit);
}
$doctorBreakdown = $opdBuilder
->groupBy('o.doc_id, o.doc_name')
->orderBy('doctor_name', 'ASC')
->get()
->getResultArray();
$summary = [
'completed_opd' => 0,
'routine_opd' => 0,
'emergency_opd' => 0,
'running_opd' => 0,
'new_opd' => 0,
'calculated_opd' => 0,
'gross_amount' => 0.0,
'org_credit_amount' => 0.0,
'doctor_count' => 0,
'approved_consents' => 0,
'payout_count' => 0,
'payout_amount' => 0.0,
'cash_received' => 0.0,
'bank_received' => 0.0,
'total_received' => 0.0,
'credit_amount' => 0.0,
];
foreach ($doctorBreakdown as &$row) {
$row['completed_opd'] = (int) ($row['completed_opd'] ?? 0);
$row['routine_opd'] = (int) ($row['routine_opd'] ?? 0);
$row['emergency_opd'] = (int) ($row['emergency_opd'] ?? 0);
$row['running_opd'] = (int) ($row['running_opd'] ?? 0);
$row['new_opd'] = (int) ($row['new_opd'] ?? 0);
$row['gross_amount'] = (float) ($row['gross_amount'] ?? 0);
$row['org_credit_amount'] = (float) ($row['org_credit_amount'] ?? 0);
$row['cash_received'] = 0.0;
$row['bank_received'] = 0.0;
$row['credit_amount'] = 0.0;
$row['payout_amount'] = 0.0;
$summary['completed_opd'] += $row['completed_opd'];
$summary['routine_opd'] += $row['routine_opd'];
$summary['emergency_opd'] += $row['emergency_opd'];
$summary['running_opd'] += $row['running_opd'];
$summary['new_opd'] += $row['new_opd'];
$summary['gross_amount'] += $row['gross_amount'];
$summary['org_credit_amount'] += $row['org_credit_amount'];
}
unset($row);
$summary['doctor_count'] = count($doctorBreakdown);
if ($this->tableHasField('opd_master', 'payout_draft_id')) {
$calcBuilder = $db->table('opd_master o')
->select('COUNT(*) AS calculated_opd', false)
->where('o.apointment_date >=', $fromDate)
->where('o.apointment_date <=', $toDate)
->whereIn('o.opd_status', [1, 2])
->where('o.payout_draft_id >', 0);
if ($doctorId > 0) {
$calcBuilder->where('o.doc_id', $doctorId);
}
if ($stateUnit !== '' && $stateUnitColumn !== null) {
$calcBuilder->where('o.' . $stateUnitColumn, $stateUnit);
}
$calcRow = $calcBuilder->get()->getRowArray();
$summary['calculated_opd'] = (int) ($calcRow['calculated_opd'] ?? 0);
}
$collectionBuilder = $db->table('payment_history p')
->select('o.doc_id')
->select('SUM(CASE WHEN p.credit_debit = 0 THEN COALESCE(p.amount,0) ELSE 0 END) AS total_received', false)
->select('SUM(CASE WHEN p.credit_debit = 0 AND p.payment_mode = 1 THEN COALESCE(p.amount,0) ELSE 0 END) AS cash_received', false)
->select('SUM(CASE WHEN p.credit_debit = 0 AND p.payment_mode = 2 THEN COALESCE(p.amount,0) ELSE 0 END) AS bank_received', false)
->join('opd_master o', 'p.payof_type = 1 AND o.opd_id = p.payof_id', 'inner')
->where('DATE(p.payment_date) >=', $fromDate)
->where('DATE(p.payment_date) <=', $toDate);
if ($doctorId > 0) {
$collectionBuilder->where('o.doc_id', $doctorId);
}
if ($stateUnit !== '' && $stateUnitColumn !== null) {
$collectionBuilder->where('o.' . $stateUnitColumn, $stateUnit);
}
$collectionRows = $collectionBuilder->groupBy('o.doc_id')->get()->getResultArray();
$collectionMap = [];
foreach ($collectionRows as $row) {
$docKey = (int) ($row['doc_id'] ?? 0);
$collectionMap[$docKey] = [
'total_received' => (float) ($row['total_received'] ?? 0),
'cash_received' => (float) ($row['cash_received'] ?? 0),
'bank_received' => (float) ($row['bank_received'] ?? 0),
];
}
foreach ($doctorBreakdown as &$row) {
$docKey = (int) ($row['doc_id'] ?? 0);
$received = $collectionMap[$docKey] ?? ['total_received' => 0.0, 'cash_received' => 0.0, 'bank_received' => 0.0];
$row['cash_received'] = $received['cash_received'];
$row['bank_received'] = $received['bank_received'];
$row['total_received'] = $received['total_received'];
$row['credit_amount'] = max(0, round($row['gross_amount'] - $row['total_received'], 2));
$summary['cash_received'] += $row['cash_received'];
$summary['bank_received'] += $row['bank_received'];
}
unset($row);
$summary['total_received'] = $summary['cash_received'] + $summary['bank_received'];
$summary['credit_amount'] = max(0, round($summary['gross_amount'] - $summary['total_received'], 2));
if ($db->tableExists('abdm_consent_records')) {
$consentBuilder = $db->table('abdm_consent_records acr')
->select('COUNT(DISTINCT acr.id) AS approved_consents', false)
->join('opd_master o', 'o.p_id = acr.patient_id', 'inner')
->where('o.apointment_date >=', $fromDate)
->where('o.apointment_date <=', $toDate)
->whereIn('o.opd_status', [1, 2])
->where('acr.consent_status', 'approved');
if ($doctorId > 0) {
$consentBuilder->where('o.doc_id', $doctorId);
}
if ($stateUnit !== '' && $stateUnitColumn !== null) {
$consentBuilder->where('o.' . $stateUnitColumn, $stateUnit);
}
$consentRow = $consentBuilder->get()->getRowArray();
$summary['approved_consents'] = (int) ($consentRow['approved_consents'] ?? 0);
}
if ($db->tableExists('finance_doctor_payouts')) {
$payoutBuilder = $db->table('finance_doctor_payouts p')
->select('COUNT(*) AS payout_count, SUM(COALESCE(p.approved_amount, p.calculated_amount, 0)) AS payout_amount', false)
->where('p.payout_date >=', $fromDate)
->where('p.payout_date <=', $toDate);
if ($doctorId > 0) {
$agreementIds = $this->findDoctorAgreementIdsFromMaster($doctorId);
if (! empty($agreementIds)) {
$payoutBuilder->whereIn('p.doctor_id', $agreementIds);
} else {
$payoutBuilder->where('1 = 0', null, false);
}
}
$payoutRow = $payoutBuilder->get()->getRowArray();
$summary['payout_count'] = (int) ($payoutRow['payout_count'] ?? 0);
$summary['payout_amount'] = (float) ($payoutRow['payout_amount'] ?? 0);
}
foreach ($doctorBreakdown as &$row) {
$row['payout_amount'] = $summary['doctor_count'] === 1 ? $summary['payout_amount'] : 0.0;
}
unset($row);
return [
'summary' => $summary,
'doctor_breakdown' => $doctorBreakdown,
'from_date' => $fromDate,
'to_date' => $toDate,
'doctor_name' => $doctorName,
'state_unit' => $stateUnit,
'state_unit_label' => $stateUnitColumn !== null ? ucwords(str_replace('_', ' ', $stateUnitColumn)) : 'State/Unit',
];
}
private function fetchOpdStateUnitOptions(): array
{
$column = $this->resolveOpdStateUnitColumn();
if ($column === null || ! method_exists($this->db, 'tableExists') || ! $this->db->tableExists('opd_master')) {
return [];
}
try {
$rows = $this->db->table('opd_master o')
->select('TRIM(o.' . $column . ') AS value', false)
->where('o.' . $column . ' IS NOT NULL', null, false)
->where("TRIM(o." . $column . ") != ''", null, false)
->groupBy('o.' . $column)
->orderBy('value', 'ASC')
->get()
->getResultArray();
return array_values(array_filter(array_map(static function (array $row): string {
return trim((string) ($row['value'] ?? ''));
}, $rows), static fn(string $value): bool => $value !== ''));
} catch (\Throwable $e) {
return [];
}
}
private function resolveOpdStateUnitColumn(): ?string
{
$candidates = ['unit_name', 'unit', 'state_name', 'state', 'branch_name', 'branch', 'center_name', 'location', 'city'];
foreach ($candidates as $column) {
if ($this->tableHasField('opd_master', $column)) {
return $column;
}
}
return null;
}
private function normalizeStateUnitToken(string $value): string
{
return preg_replace('/[^A-Za-z0-9]/', '', strtoupper(trim($value)));
}
private function findDoctorAgreementIdsFromMaster(int $doctorId): array
{
if ($doctorId <= 0) {
return [];
}
$ids = [];
$code = 'DM-' . $doctorId;
$row = $this->doctorAgreementModel->select('id')->where('doctor_code', $code)->first();
if (is_array($row) && (int) ($row['id'] ?? 0) > 0) {
$ids[] = (int) ($row['id'] ?? 0);
}
if (empty($ids) && method_exists($this->db, 'tableExists') && $this->db->tableExists('doctor_master')) {
$doctor = $this->db->table('doctor_master')->select('p_title, p_fname')->where('id', $doctorId)->get(1)->getRowArray();
if (is_array($doctor)) {
$doctorName = trim((string) (($doctor['p_title'] ?? '') . ' ' . ($doctor['p_fname'] ?? '')));
if ($doctorName !== '') {
$nameRows = $this->doctorAgreementModel->select('id')->like('doctor_name', $doctorName, 'both')->findAll();
foreach ($nameRows as $item) {
$id = (int) ($item['id'] ?? 0);
if ($id > 0) {
$ids[] = $id;
}
}
}
}
}
return array_values(array_unique(array_filter($ids, static fn(int $id): bool => $id > 0)));
}
private function fetchEligibleOpdIdsForPayout(string $fromDate, string $toDate, int $doctorId, string $stateUnit = ''): array
{
if (! method_exists($this->db, 'tableExists') || ! $this->db->tableExists('opd_master')) {
return [];
}
$builder = $this->db->table('opd_master o')
->select('o.opd_id')
->where('o.apointment_date >=', $fromDate)
->where('o.apointment_date <=', $toDate)
->whereIn('o.opd_status', [1, 2]);
if ($doctorId > 0) {
$builder->where('o.doc_id', $doctorId);
}
$stateUnitColumn = $this->resolveOpdStateUnitColumn();
if ($stateUnit !== '' && $stateUnitColumn !== null) {
$builder->where('o.' . $stateUnitColumn, $stateUnit);
}
if ($this->tableHasField('opd_master', 'payout_draft_id')) {
$builder->groupStart()
->where('o.payout_draft_id IS NULL', null, false)
->orWhere('o.payout_draft_id', 0)
->groupEnd();
}
$rows = $builder->get()->getResultArray();
return array_values(array_filter(array_map(static fn(array $row): int => (int) ($row['opd_id'] ?? 0), $rows), static fn(int $id): bool => $id > 0));
}
private function resolveOrCreateDoctorAgreementIdFromMaster(int $doctorId): int
{
if ($doctorId <= 0 || ! method_exists($this->db, 'tableExists') || ! $this->db->tableExists('doctor_master')) {
return 0;
}
$doctorRow = $this->db->table('doctor_master')
->select('id, p_title, p_fname')
->where('id', $doctorId)
->get(1)
->getRowArray();
if (! is_array($doctorRow)) {
return 0;
}
$doctorName = trim((string) (($doctorRow['p_title'] ?? '') . ' ' . ($doctorRow['p_fname'] ?? '')));
if ($doctorName === '') {
$doctorName = 'Doctor #' . $doctorId;
}
$doctorCode = 'DM-' . $doctorId;
$existing = $this->doctorAgreementModel->where('doctor_code', $doctorCode)->first();
if (is_array($existing) && (int) ($existing['id'] ?? 0) > 0) {
return (int) $existing['id'];
}
$this->doctorAgreementModel->insert([
'doctor_code' => $doctorCode,
'doctor_name' => $doctorName,
'specialization' => null,
'consultation_rate' => 0,
'surgery_rate' => 0,
'agreement_start_date' => null,
'agreement_end_date' => null,
'status' => 1,
'created_by' => $this->currentUserName(),
]);
return (int) ($this->doctorAgreementModel->getInsertID() ?? 0);
}
private function normalizeDateInput(string $date): string
{
$date = trim($date);
if ($date === '') {
return '';
}
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1 ? $date : '';
}
private function csvLine(array $cells): string
{
$escaped = array_map(static function ($value): string {
$text = str_replace('"', '""', (string) $value);
return '"' . $text . '"';
}, $cells);
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;
if (! $user || ! method_exists($user, 'can')) {
return false;
}
return $user->can($permission) || $user->can('finance.*');
}
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
{
$poAmount = null;
$grnAmount = null;
$issues = [];
if ($poId !== null) {
$po = $this->poModel->find($poId);
if (! $po) {
$issues[] = 'PO not found';
} else {
$poAmount = (float) ($po['amount'] ?? 0);
if ((int) ($po['vendor_id'] ?? 0) !== $vendorId) {
$issues[] = 'Vendor mismatch against PO';
}
}
}
if ($grnId !== null) {
$grn = $this->grnModel->find($grnId);
if (! $grn) {
$issues[] = 'GRN not found';
} else {
$grnAmount = (float) ($grn['received_amount'] ?? 0);
if ($poId !== null && (int) ($grn['po_id'] ?? 0) !== $poId) {
$issues[] = 'GRN does not belong to PO';
}
}
}
$referenceAmount = $grnAmount ?? $poAmount;
if ($referenceAmount === null) {
return [
'match_status' => 'not_checked',
'variance_amount' => 0,
'match_note' => 'PO/GRN missing for 3-way check',
'is_compliance_hold' => 1,
];
}
$variance = round($invoiceAmount - $referenceAmount, 2);
$absVariance = abs($variance);
$status = 'matched';
$complianceHold = 0;
if (! empty($issues)) {
$status = 'mismatch';
$complianceHold = 1;
} elseif ($absVariance > 0.01 && $absVariance <= 100) {
$status = 'minor_variance';
} elseif ($absVariance > 100) {
$status = 'mismatch';
$complianceHold = 1;
$issues[] = 'Invoice variance exceeds tolerance';
}
return [
'match_status' => $status,
'variance_amount' => $variance,
'match_note' => empty($issues) ? 'Auto matched against reference amount' : implode('; ', $issues),
'is_compliance_hold' => $complianceHold,
];
}
}