@@ -9,12 +9,55 @@ use App\Models\RefundModel;
|
||||
use App\Models\ExpenseModel;
|
||||
use App\Models\ReimbursementModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Libraries\FinancialStatus;
|
||||
use App\Libraries\InvoiceLedgerService;
|
||||
|
||||
require_once APPPATH . 'ThirdParty/fpdf/fpdf.php';
|
||||
use FPDF;
|
||||
|
||||
class FinancialController extends BaseController
|
||||
{
|
||||
private ?InvoiceLedgerService $invoiceLedgerService = null;
|
||||
|
||||
private function invoiceLedger(): InvoiceLedgerService
|
||||
{
|
||||
if ($this->invoiceLedgerService === null) {
|
||||
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||
}
|
||||
|
||||
return $this->invoiceLedgerService;
|
||||
}
|
||||
|
||||
private function ledgerProjectionForInvoice(int $invoiceId): array
|
||||
{
|
||||
try {
|
||||
return $this->invoiceLedger()->calculateInvoice($invoiceId);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Invoice ledger projection failed for invoice ' . $invoiceId . ': ' . $e->getMessage());
|
||||
return [
|
||||
'total_amount' => '0.00',
|
||||
'paid_amount' => '0.00',
|
||||
'discount_total' => '0.00',
|
||||
'refund_paid_total' => '0.00',
|
||||
'balance' => '0.00',
|
||||
'customer_credit' => '0.00',
|
||||
'status' => FinancialStatus::INVOICE_UNPAID,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function ledgerProjectionMap(array $invoiceIds): array
|
||||
{
|
||||
$map = [];
|
||||
foreach (array_values(array_unique(array_map('intval', $invoiceIds))) as $invoiceId) {
|
||||
if ($invoiceId > 0) {
|
||||
$map[$invoiceId] = $this->ledgerProjectionForInvoice($invoiceId);
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function wantsJson(): bool
|
||||
{
|
||||
$accept = strtolower((string)($this->request->getHeaderLine('Accept') ?? ''));
|
||||
@@ -157,6 +200,7 @@ public function financialReport()
|
||||
$id = (int)($inv['id'] ?? 0);
|
||||
return $id > 0 ? $id : null;
|
||||
}, $invoices))));
|
||||
$ledgerByInvoice = $this->ledgerProjectionMap($invoiceIds);
|
||||
|
||||
// Helper to build a fresh, filtered PaymentModel each time (so filters don't get lost between queries)
|
||||
$buildPaymentModel = function () use ($schoolYear, $dateFrom, $dateTo) {
|
||||
@@ -195,18 +239,14 @@ public function financialReport()
|
||||
return $qb;
|
||||
};
|
||||
|
||||
// === Payments aggregated by invoice_id (for the "Paid" column) ===
|
||||
$paymentsQuery = $applyPaymentFilters($buildPaymentModel());
|
||||
if (!empty($invoiceIds)) {
|
||||
$paymentsQuery->whereIn('invoice_id', $invoiceIds);
|
||||
} else {
|
||||
$paymentsQuery->where('invoice_id', -1);
|
||||
// === Ledger-derived invoice totals (for Paid/Refund/Discount/Balance/Status columns) ===
|
||||
$payments = [];
|
||||
foreach ($ledgerByInvoice as $invoiceId => $ledger) {
|
||||
$payments[] = [
|
||||
'invoice_id' => $invoiceId,
|
||||
'paid_amount' => (float) ($ledger['paid_amount'] ?? 0),
|
||||
];
|
||||
}
|
||||
$payments = $paymentsQuery
|
||||
->select('invoice_id, SUM(paid_amount) AS paid_amount')
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->groupBy('invoice_id')
|
||||
->findAll();
|
||||
|
||||
// === Per-invoice breakdown by normalized method (cash/credit/check) ===
|
||||
// Normalization rules:
|
||||
@@ -260,21 +300,44 @@ public function financialReport()
|
||||
'total_credit' => (float)($paymentTotalsRow['total_credit'] ?? 0),
|
||||
];
|
||||
|
||||
// === Refunds (grouped) ===
|
||||
$refunds = $refundModel
|
||||
->select('invoice_id, school_year, SUM(refund_paid_amount) AS total_refunded')
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->groupBy(['invoice_id', 'school_year'])
|
||||
->findAll();
|
||||
// === Ledger-derived refunds and discounts ===
|
||||
$refunds = [];
|
||||
$discounts = [];
|
||||
foreach ($ledgerByInvoice as $invoiceId => $ledger) {
|
||||
$invoiceSchoolYear = '';
|
||||
foreach ($invoices as $invoiceRow) {
|
||||
if ((int)($invoiceRow['id'] ?? 0) === (int)$invoiceId) {
|
||||
$invoiceSchoolYear = (string)($invoiceRow['school_year'] ?? '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
$refunds[] = [
|
||||
'invoice_id' => $invoiceId,
|
||||
'school_year' => $invoiceSchoolYear,
|
||||
'total_refunded' => (float) ($ledger['refund_paid_total'] ?? 0),
|
||||
];
|
||||
$discounts[] = [
|
||||
'invoice_id' => $invoiceId,
|
||||
'school_year' => $invoiceSchoolYear,
|
||||
'discount_amount' => (float) ($ledger['discount_total'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
// === Discounts (grouped) ===
|
||||
$discounts = $discountModel
|
||||
->select('invoice_id, school_year, SUM(discount_amount) AS discount_amount')
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->groupBy(['invoice_id', 'school_year'])
|
||||
->get()
|
||||
->getResultArray();
|
||||
foreach ($invoices as &$invoiceRow) {
|
||||
$invoiceId = (int)($invoiceRow['id'] ?? 0);
|
||||
$ledger = $ledgerByInvoice[$invoiceId] ?? null;
|
||||
if ($ledger === null) {
|
||||
continue;
|
||||
}
|
||||
$invoiceRow['total_amount'] = (float)($ledger['total_amount'] ?? 0);
|
||||
$invoiceRow['paid_amount'] = (float)($ledger['paid_amount'] ?? 0);
|
||||
$invoiceRow['discount'] = (float)($ledger['discount_total'] ?? 0);
|
||||
$invoiceRow['refund_paid'] = (float)($ledger['refund_paid_total'] ?? 0);
|
||||
$invoiceRow['balance'] = (float)($ledger['balance'] ?? 0);
|
||||
$invoiceRow['customer_credit'] = (float)($ledger['customer_credit'] ?? 0);
|
||||
$invoiceRow['status'] = (string)($ledger['status'] ?? ($invoiceRow['status'] ?? ''));
|
||||
}
|
||||
unset($invoiceRow);
|
||||
|
||||
// === Expenses ===
|
||||
$expenses = $expenseModel
|
||||
@@ -583,77 +646,27 @@ public function financialReport()
|
||||
$invoiceRows = $invoiceRows->orderBy('invoices.id', 'ASC')->get()->getResultArray();
|
||||
|
||||
$invoiceIds = array_values(array_filter(array_map(static fn($row) => (int)($row['id'] ?? 0), $invoiceRows)));
|
||||
|
||||
$paidByInvoice = [];
|
||||
$discountByInvoice = [];
|
||||
$refundByInvoice = [];
|
||||
if (!empty($invoiceIds)) {
|
||||
$paidRows = $db->table('payments')
|
||||
->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->groupStart()
|
||||
->whereNotIn('status', $paymentExclude)
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
if (!empty($schoolYear)) {
|
||||
$paidRows->where('school_year', $schoolYear);
|
||||
}
|
||||
if (!empty($invoiceDateFrom)) {
|
||||
$paidRows->where('DATE(payment_date) >=', $invoiceDateFrom);
|
||||
}
|
||||
if (!empty($invoiceDateTo)) {
|
||||
$paidRows->where('DATE(payment_date) <=', $invoiceDateTo);
|
||||
}
|
||||
foreach ($paidRows->groupBy('invoice_id')->get()->getResultArray() as $row) {
|
||||
$paidByInvoice[(int)($row['invoice_id'] ?? 0)] = (float)($row['total_paid'] ?? 0);
|
||||
}
|
||||
|
||||
$discountRows = $db->table('discount_usages')
|
||||
->select('invoice_id, COALESCE(SUM(discount_amount),0) AS total_discount')
|
||||
->whereIn('invoice_id', $invoiceIds);
|
||||
if (!empty($dateFrom)) {
|
||||
$discountRows->where('DATE(COALESCE(used_at, created_at)) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$discountRows->where('DATE(COALESCE(used_at, created_at)) <=', $dateTo);
|
||||
}
|
||||
foreach ($discountRows->groupBy('invoice_id')->get()->getResultArray() as $row) {
|
||||
$discountByInvoice[(int)($row['invoice_id'] ?? 0)] = (float)($row['total_discount'] ?? 0);
|
||||
}
|
||||
|
||||
$refundRows = $db->table('refunds')
|
||||
->select('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_refund')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->whereIn('status', ['Partial', 'Paid']);
|
||||
if (!empty($dateFrom)) {
|
||||
$refundRows->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$refundRows->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo);
|
||||
}
|
||||
foreach ($refundRows->groupBy('invoice_id')->get()->getResultArray() as $row) {
|
||||
$refundByInvoice[(int)($row['invoice_id'] ?? 0)] = (float)($row['total_refund'] ?? 0);
|
||||
}
|
||||
}
|
||||
$ledgerByInvoice = $this->ledgerProjectionMap($invoiceIds);
|
||||
|
||||
$invoices = [];
|
||||
foreach ($invoiceRows as $row) {
|
||||
$iid = (int)($row['id'] ?? 0);
|
||||
$paid = (float)($paidByInvoice[$iid] ?? 0);
|
||||
$disc = (float)($discountByInvoice[$iid] ?? 0);
|
||||
$refund = (float)($refundByInvoice[$iid] ?? 0);
|
||||
$balance = round((float)($row['total_amount'] ?? 0) - $disc - $refund - $paid, 2);
|
||||
$ledger = $ledgerByInvoice[$iid] ?? [];
|
||||
$paid = (float)($ledger['paid_amount'] ?? 0);
|
||||
$disc = (float)($ledger['discount_total'] ?? 0);
|
||||
$refund = (float)($ledger['refund_paid_total'] ?? 0);
|
||||
$balance = (float)($ledger['balance'] ?? 0);
|
||||
$invoices[] = [
|
||||
'Invoice #' => (string)($row['invoice_number'] ?? ''),
|
||||
'Parent' => trim((string)($row['parent_name'] ?? '')),
|
||||
'Issue Date' => (string)($row['issue_date'] ?? ''),
|
||||
'Due Date' => (string)($row['due_date'] ?? ''),
|
||||
'Gross Charges' => (float)($row['total_amount'] ?? 0),
|
||||
'Gross Charges' => (float)($ledger['total_amount'] ?? 0),
|
||||
'Discounts' => $disc,
|
||||
'Refunds' => $refund,
|
||||
'Paid' => $paid,
|
||||
'Balance' => $balance,
|
||||
'Status' => (string)($row['status'] ?? ''),
|
||||
'Status' => (string)($ledger['status'] ?? ($row['status'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -992,10 +1005,6 @@ public function financialReport()
|
||||
->get()->getResultArray();
|
||||
|
||||
$byParent = [];
|
||||
$paymentsTbl = 'payments';
|
||||
$hasStatus = $db->fieldExists('status', $paymentsTbl);
|
||||
$hasVoid = $db->fieldExists('is_void', $paymentsTbl);
|
||||
|
||||
foreach ($invRows as $r) {
|
||||
$iid = (int)($r['id'] ?? 0);
|
||||
$pid = (int)($r['parent_id'] ?? 0);
|
||||
@@ -1003,37 +1012,11 @@ public function financialReport()
|
||||
continue;
|
||||
}
|
||||
|
||||
$qb = $db->table('payments')->select('COALESCE(SUM(paid_amount),0) AS tot')->where('invoice_id', $iid);
|
||||
if ($hasStatus) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($hasVoid) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
$paidRow = $qb->get()->getRowArray();
|
||||
$paidSum = (float)($paidRow['tot'] ?? 0);
|
||||
|
||||
$discRow = $db->table('discount_usages')
|
||||
->select('COALESCE(SUM(discount_amount),0) AS tot')
|
||||
->where('invoice_id', $iid)
|
||||
->get()->getRowArray();
|
||||
$discSum = (float)($discRow['tot'] ?? 0);
|
||||
|
||||
$refRow = $db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
||||
->where('invoice_id', $iid)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->get()->getRowArray();
|
||||
$refSum = (float)($refRow['tot'] ?? 0);
|
||||
|
||||
$total = (float)($r['total_amount'] ?? 0);
|
||||
$balance = max(0.0, round($total - $discSum - $paidSum - $refSum, 2));
|
||||
$ledger = $this->ledgerProjectionForInvoice($iid);
|
||||
$paidSum = (float)($ledger['paid_amount'] ?? 0);
|
||||
$discSum = (float)($ledger['discount_total'] ?? 0);
|
||||
$total = (float)($ledger['total_amount'] ?? 0);
|
||||
$balance = (float)($ledger['balance'] ?? 0);
|
||||
|
||||
if (!isset($byParent[$pid])) {
|
||||
$byParent[$pid] = [
|
||||
@@ -1293,8 +1276,10 @@ public function financialReport()
|
||||
->findAll();
|
||||
|
||||
$paymentsMap = [];
|
||||
foreach ($payments as $payment) {
|
||||
$paymentsMap[(int)($payment['invoice_id'] ?? 0)] = (float)($payment['paid_amount'] ?? 0);
|
||||
$invoiceIdsForExport = array_values(array_filter(array_map(static fn($row) => (int)($row['id'] ?? 0), $invoices)));
|
||||
$ledgerByInvoice = $this->ledgerProjectionMap($invoiceIdsForExport);
|
||||
foreach ($ledgerByInvoice as $invoiceId => $ledger) {
|
||||
$paymentsMap[(int)$invoiceId] = (float)($ledger['paid_amount'] ?? 0);
|
||||
}
|
||||
|
||||
$paymentBreakdownRows = $applyPaymentFilters($buildPaymentModel())
|
||||
@@ -1350,47 +1335,11 @@ public function financialReport()
|
||||
}
|
||||
$reimbursements = $reimbBuilder->groupBy('status')->findAll();
|
||||
|
||||
// Refunds (grouped)
|
||||
if (!empty($schoolYear)) {
|
||||
$refundModel->where('school_year', $schoolYear);
|
||||
}
|
||||
if ($hasFrom) {
|
||||
$refundModel->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom);
|
||||
}
|
||||
if ($hasTo) {
|
||||
$refundModel->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo);
|
||||
}
|
||||
$refundsData = $refundModel
|
||||
->select('invoice_id, SUM(refund_paid_amount) AS total_refunded')
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->groupBy('invoice_id')
|
||||
->findAll();
|
||||
$refunds = [];
|
||||
foreach ($refundsData as $refund) {
|
||||
$refunds[$refund['invoice_id']] = $refund['total_refunded'];
|
||||
}
|
||||
|
||||
$discountBuilder = $discountModel;
|
||||
if (!empty($schoolYear)) {
|
||||
$discountBuilder->where('school_year', $schoolYear);
|
||||
}
|
||||
if ($hasFrom) {
|
||||
$discountBuilder->where('DATE(COALESCE(used_at, created_at)) >=', $dateFrom);
|
||||
}
|
||||
if ($hasTo) {
|
||||
$discountBuilder->where('DATE(COALESCE(used_at, created_at)) <=', $dateTo);
|
||||
}
|
||||
$discountsData = $discountBuilder
|
||||
->select('invoice_id, SUM(discount_amount) AS discount_amount')
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->groupBy('invoice_id')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$discounts = [];
|
||||
foreach ($discountsData as $disc) {
|
||||
$discounts[$disc['invoice_id']] = $disc['discount_amount'];
|
||||
foreach ($ledgerByInvoice as $invoiceId => $ledger) {
|
||||
$refunds[(int)$invoiceId] = (float)($ledger['refund_paid_total'] ?? 0);
|
||||
$discounts[(int)$invoiceId] = (float)($ledger['discount_total'] ?? 0);
|
||||
}
|
||||
|
||||
$filename = 'financial_report_' . date('Ymd_His') . '.csv';
|
||||
@@ -1419,10 +1368,10 @@ public function financialReport()
|
||||
$check = (float)($bd['check'] ?? 0);
|
||||
$refunded = (float)($refunds[$invoiceId] ?? 0);
|
||||
$discount = (float)($discounts[$invoiceId] ?? 0);
|
||||
$total = (float)($inv['total_amount'] ?? 0);
|
||||
$balance = $total - $paid - $discount - $refunded;
|
||||
if ($balance < 0) $balance = 0.0;
|
||||
$status = ($balance === 0.0) ? 'Paid' : 'Unpaid';
|
||||
$ledger = $ledgerByInvoice[$invoiceId] ?? [];
|
||||
$total = (float)($ledger['total_amount'] ?? 0);
|
||||
$balance = (float)($ledger['balance'] ?? 0);
|
||||
$status = (string)($ledger['status'] ?? ($inv['status'] ?? ''));
|
||||
|
||||
fputcsv($out, [
|
||||
$inv['invoice_number'],
|
||||
@@ -1660,11 +1609,21 @@ public function financialReport()
|
||||
$invoiceBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $invoiceDateTo);
|
||||
}
|
||||
$invoices = $invoiceBuilder->findAll();
|
||||
$totalCharges = array_sum(array_column($invoices, 'total_amount'));
|
||||
$invoiceIds = array_values(array_unique(array_filter(array_map(static function ($inv) {
|
||||
$id = (int)($inv['id'] ?? 0);
|
||||
return $id > 0 ? $id : null;
|
||||
}, $invoices))));
|
||||
$ledgerByInvoice = $this->ledgerProjectionMap($invoiceIds);
|
||||
$totalCharges = 0.0;
|
||||
$totalPaid = 0.0;
|
||||
$totalDiscounts = 0.0;
|
||||
$totalRefunds = 0.0;
|
||||
foreach ($ledgerByInvoice as $ledger) {
|
||||
$totalCharges += (float)($ledger['total_amount'] ?? 0);
|
||||
$totalPaid += (float)($ledger['paid_amount'] ?? 0);
|
||||
$totalDiscounts += (float)($ledger['discount_total'] ?? 0);
|
||||
$totalRefunds += (float)($ledger['refund_paid_total'] ?? 0);
|
||||
}
|
||||
|
||||
// === Additional Charges ===
|
||||
$hasExplicitDates = !empty($dateFrom) || !empty($dateTo);
|
||||
@@ -1714,98 +1673,6 @@ public function financialReport()
|
||||
|
||||
$totalCharges += $extraChargesUnapplied;
|
||||
|
||||
// === Payments: Total Paid ===
|
||||
$paymentBuilder = $paymentModel->where('school_year', $schoolYear);
|
||||
if (!empty($invoiceDateFrom)) {
|
||||
$paymentBuilder->where('DATE(payment_date) >=', $invoiceDateFrom);
|
||||
}
|
||||
if (!empty($invoiceDateTo)) {
|
||||
$paymentBuilder->where('DATE(payment_date) <=', $invoiceDateTo);
|
||||
}
|
||||
$payHasStatus = $db->fieldExists('status', 'payments');
|
||||
$payHasVoid = $db->fieldExists('is_void', 'payments');
|
||||
if ($payHasStatus) {
|
||||
$paymentBuilder->groupStart()
|
||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($payHasVoid) {
|
||||
$paymentBuilder->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
$paymentResult = $paymentBuilder->selectSum('paid_amount')->get()->getRowArray();
|
||||
$totalPaid = isset($paymentResult['paid_amount']) ? (float) $paymentResult['paid_amount'] : 0.00;
|
||||
|
||||
// === Per-invoice paid/discount/refund totals for outstanding balance ===
|
||||
$paidByInvoice = [];
|
||||
$discountByInvoice = [];
|
||||
$refundByInvoice = [];
|
||||
if (!empty($invoiceIds)) {
|
||||
$paidRows = $db->table('payments')
|
||||
->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid')
|
||||
->whereIn('invoice_id', $invoiceIds);
|
||||
if (!empty($schoolYear)) {
|
||||
$paidRows->where('school_year', $schoolYear);
|
||||
}
|
||||
if (!empty($invoiceDateFrom)) {
|
||||
$paidRows->where('DATE(payment_date) >=', $invoiceDateFrom);
|
||||
}
|
||||
if (!empty($invoiceDateTo)) {
|
||||
$paidRows->where('DATE(payment_date) <=', $invoiceDateTo);
|
||||
}
|
||||
if ($payHasStatus) {
|
||||
$paidRows->groupStart()
|
||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($payHasVoid) {
|
||||
$paidRows->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
$paidRows = $paidRows->groupBy('invoice_id')->get()->getResultArray();
|
||||
foreach ($paidRows as $r) {
|
||||
$iid = (int)($r['invoice_id'] ?? 0);
|
||||
if ($iid > 0) $paidByInvoice[$iid] = (float)($r['total_paid'] ?? 0);
|
||||
}
|
||||
|
||||
$discRows = $db->table('discount_usages')
|
||||
->select('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc')
|
||||
->whereIn('invoice_id', $invoiceIds);
|
||||
if (!empty($dateFrom)) {
|
||||
$discRows->where('DATE(COALESCE(used_at, created_at)) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$discRows->where('DATE(COALESCE(used_at, created_at)) <=', $dateTo);
|
||||
}
|
||||
$discRows = $discRows->groupBy('invoice_id')->get()->getResultArray();
|
||||
foreach ($discRows as $r) {
|
||||
$iid = (int)($r['invoice_id'] ?? 0);
|
||||
if ($iid > 0) $discountByInvoice[$iid] = (float)($r['total_disc'] ?? 0);
|
||||
}
|
||||
|
||||
$refRows = $db->table('refunds')
|
||||
->select('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_refund')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->whereIn('status', ['Partial', 'Paid']);
|
||||
if (!empty($dateFrom)) {
|
||||
$refRows->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$refRows->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo);
|
||||
}
|
||||
$refRows = $refRows->groupBy('invoice_id')->get()->getResultArray();
|
||||
foreach ($refRows as $r) {
|
||||
$iid = (int)($r['invoice_id'] ?? 0);
|
||||
if ($iid > 0) $refundByInvoice[$iid] = (float)($r['total_refund'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// === Expenses ===
|
||||
$expenseBuilder = $expenseModel->where('school_year', $schoolYear);
|
||||
if (!empty($invoiceDateFrom)) {
|
||||
@@ -1943,39 +1810,6 @@ public function financialReport()
|
||||
$donationToSchool = $donationExpense + $donationReimb;
|
||||
$totalReimbursements = max(0.0, $totalReimbursements - $donationReimb);
|
||||
|
||||
// === Refunds ===
|
||||
$refundBuilder = $refundModel
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->where('refund_paid_amount IS NOT NULL');
|
||||
|
||||
if (!empty($dateFrom)) {
|
||||
$refundBuilder->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$refundBuilder->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo);
|
||||
}
|
||||
|
||||
$refundResult = $refundBuilder
|
||||
->selectSum('refund_paid_amount')
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
$totalRefunds = isset($refundResult['refund_paid_amount']) ? (float) $refundResult['refund_paid_amount'] : 0.00;
|
||||
|
||||
// === Discounts ===
|
||||
$discountBuilder = $discountModel
|
||||
->join('invoices', 'invoices.id = discount_usages.invoice_id')
|
||||
->where('invoices.school_year', $schoolYear);
|
||||
if (!empty($dateFrom)) {
|
||||
$discountBuilder->where('DATE(COALESCE(discount_usages.used_at, discount_usages.created_at)) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$discountBuilder->where('DATE(COALESCE(discount_usages.used_at, discount_usages.created_at)) <=', $dateTo);
|
||||
}
|
||||
$discountResult = $discountBuilder->selectSum('discount_amount')->get()->getRowArray();
|
||||
$totalDiscounts = isset($discountResult['discount_amount']) ? (float) $discountResult['discount_amount'] : 0.00;
|
||||
|
||||
// === Net, Outstanding & Overpayments ===
|
||||
$overpaymentDetails = [];
|
||||
$totalUnpaid = 0.0;
|
||||
@@ -1984,15 +1818,16 @@ public function financialReport()
|
||||
$iid = (int)($inv['id'] ?? 0);
|
||||
$pid = (int)($inv['parent_id'] ?? 0);
|
||||
if ($iid <= 0 || $pid <= 0) continue;
|
||||
$total = (float)($inv['total_amount'] ?? 0);
|
||||
$paid = (float)($paidByInvoice[$iid] ?? 0);
|
||||
$disc = (float)($discountByInvoice[$iid] ?? 0);
|
||||
$ref = (float)($refundByInvoice[$iid] ?? 0);
|
||||
$rawBal = round($total - $disc - $paid - $ref, 2);
|
||||
if ($rawBal > 0.00001) {
|
||||
$totalUnpaid += $rawBal;
|
||||
} elseif ($rawBal < -0.00001) {
|
||||
$credit = abs($rawBal);
|
||||
$ledger = $ledgerByInvoice[$iid] ?? [];
|
||||
$total = (float)($ledger['total_amount'] ?? 0);
|
||||
$paid = (float)($ledger['paid_amount'] ?? 0);
|
||||
$disc = (float)($ledger['discount_total'] ?? 0);
|
||||
$ref = (float)($ledger['refund_paid_total'] ?? 0);
|
||||
$balance = (float)($ledger['balance'] ?? 0);
|
||||
$credit = (float)($ledger['customer_credit'] ?? 0);
|
||||
if ($balance > 0.00001) {
|
||||
$totalUnpaid += $balance;
|
||||
} elseif ($credit > 0.00001) {
|
||||
$totalOverpaid += $credit;
|
||||
$overpaymentDetails[] = [
|
||||
'type' => 'invoice',
|
||||
@@ -2005,7 +1840,7 @@ public function financialReport()
|
||||
'discount_amount' => $disc,
|
||||
'refund_amount' => $ref,
|
||||
'paid_amount' => $paid,
|
||||
'note' => 'Invoice payments/discounts exceed net invoice charges.',
|
||||
'note' => 'Invoice ledger shows customer credit.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2111,9 +1946,7 @@ public function financialReport()
|
||||
$schoolYears[] = (string)$schoolYear;
|
||||
}
|
||||
|
||||
// Aggregate balances by parent for selected school year
|
||||
// IMPORTANT: Compute current balance = total - payments - discounts - refundsPaid
|
||||
// rather than trusting invoices.balance which may become stale.
|
||||
// Aggregate balances by parent for selected school year from the canonical invoice ledger.
|
||||
$db = \Config\Database::connect();
|
||||
$invRows = $db->table('invoices i')
|
||||
->select('i.id, i.parent_id, i.total_amount, u.firstname, u.lastname, u.email')
|
||||
@@ -2123,51 +1956,18 @@ public function financialReport()
|
||||
->orderBy('i.id', 'ASC')
|
||||
->get()->getResultArray();
|
||||
|
||||
// Group by parent and compute balances dynamically
|
||||
$byParent = [];
|
||||
$paymentsTbl = 'payments';
|
||||
$hasStatus = $db->fieldExists('status', $paymentsTbl);
|
||||
$hasVoid = $db->fieldExists('is_void', $paymentsTbl);
|
||||
|
||||
foreach ($invRows as $r) {
|
||||
$iid = (int)($r['id'] ?? 0);
|
||||
$pid = (int)($r['parent_id'] ?? 0);
|
||||
if ($iid <= 0 || $pid <= 0) continue;
|
||||
|
||||
// Sum payments for this invoice (exclude void/failed if such columns exist)
|
||||
$qb = $db->table('payments')->select('COALESCE(SUM(paid_amount),0) AS tot')->where('invoice_id', $iid);
|
||||
if ($hasStatus) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($hasVoid) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
$paidRow = $qb->get()->getRowArray();
|
||||
$paidSum = (float)($paidRow['tot'] ?? 0);
|
||||
|
||||
// Sum discounts for this invoice
|
||||
$discRow = $db->table('discount_usages')
|
||||
->select('COALESCE(SUM(discount_amount),0) AS tot')
|
||||
->where('invoice_id', $iid)
|
||||
->get()->getRowArray();
|
||||
$discSum = (float)($discRow['tot'] ?? 0);
|
||||
|
||||
// Sum refunds PAID for this invoice (Partial/Paid only)
|
||||
$refRow = $db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
||||
->where('invoice_id', $iid)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->get()->getRowArray();
|
||||
$refSum = (float)($refRow['tot'] ?? 0);
|
||||
|
||||
$total = (float)($r['total_amount'] ?? 0);
|
||||
$balance = max(0.0, round($total - $discSum - $paidSum - $refSum, 2));
|
||||
$ledger = $this->ledgerProjectionForInvoice($iid);
|
||||
$paidSum = (float)($ledger['paid_amount'] ?? 0);
|
||||
$discSum = (float)($ledger['discount_total'] ?? 0);
|
||||
$total = (float)($ledger['total_amount'] ?? 0);
|
||||
$balance = (float)($ledger['balance'] ?? 0);
|
||||
|
||||
if (!isset($byParent[$pid])) {
|
||||
$byParent[$pid] = [
|
||||
|
||||
Reference in New Issue
Block a user