financial fix

This commit is contained in:
root
2026-06-06 23:30:41 -04:00
parent a18e8b92a6
commit 654453222f
3 changed files with 1042 additions and 41 deletions
+2
View File
@@ -645,6 +645,8 @@ $routes->post('admin/broadcast-email/upload-image', 'View\BroadcastEmailControll
$routes->get('payment/financial_report', 'View\FinancialController::financialReport', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']); $routes->get('payment/financial_report', 'View\FinancialController::financialReport', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
$routes->get('financial-report/financialReportSummary', 'View\FinancialController::financialReportSummary', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']); $routes->get('financial-report/financialReportSummary', 'View\FinancialController::financialReportSummary', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
$routes->get('financial-report/downloadSummaryCsv', 'View\FinancialController::downloadSummaryCsv', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
$routes->get('financial-report/downloadSummaryAllDetailsCsv', 'View\FinancialController::downloadSummaryAllDetailsCsv', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
$routes->get('payment/download_csv', 'View\FinancialController::downloadCsv', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']); $routes->get('payment/download_csv', 'View\FinancialController::downloadCsv', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
$routes->get('administrator/tuition-forecast', 'View\TuitionForecastController::index', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']); $routes->get('administrator/tuition-forecast', 'View\TuitionForecastController::index', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
$routes->post('administrator/tuition-forecast/calculate', 'View\TuitionForecastController::calculate', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']); $routes->post('administrator/tuition-forecast/calculate', 'View\TuitionForecastController::calculate', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
+917 -17
View File
@@ -341,6 +341,859 @@ public function financialReport()
return $this->financialReportSummary(); return $this->financialReportSummary();
} }
public function downloadSummaryCsv()
{
$dateFrom = $this->request->getGet('date_from');
$dateTo = $this->request->getGet('date_to');
$schoolYear = $this->request->getGet('school_year');
$data = $this->getFinancialSummary($dateFrom, $dateTo, $schoolYear);
$safeYear = preg_replace('/[^A-Za-z0-9_-]+/', '_', (string)($data['schoolYear'] ?? 'summary'));
$filename = 'financial_report_summary_' . trim($safeYear, '_') . '_' . date('Ymd_His') . '.csv';
header('Content-Type: text/csv');
header('Content-Disposition: attachment; filename="' . $filename . '"');
$out = fopen('php://output', 'w');
fputcsv($out, ['Financial Report Summary']);
fputcsv($out, ['School Year', (string)($data['schoolYear'] ?? '')]);
fputcsv($out, ['Date From', (string)($data['dateFrom'] ?? '')]);
fputcsv($out, ['Date To', (string)($data['dateTo'] ?? '')]);
fputcsv($out, []);
fputcsv($out, ['Description', 'Amount']);
$rows = [
['Tuition Charges', (float)($data['tuitionCharges'] ?? 0)],
['Event Fees', (float)($data['totalEventFees'] ?? 0)],
['Prior-Year Tuition Carryover', (float)($data['totalExtraCharges'] ?? 0)],
['Gross Charges', (float)($data['grossCharges'] ?? $data['totalCharges'] ?? 0)],
['Total Discounts', (float)($data['totalDiscounts'] ?? 0)],
['Total Refunds', (float)($data['totalRefunds'] ?? 0)],
['Total Expenses', (float)($data['totalExpenses'] ?? 0)],
['Total Reimbursements', (float)($data['totalReimbursements'] ?? 0)],
['Donation to School (included in expenses)', (float)($data['donationToSchool'] ?? 0)],
['Net Charges After Discounts/Refunds', (float)($data['netAmount'] ?? 0)],
['Amount Collected (Paid)', (float)($data['amountCollected'] ?? 0)],
['Overpayment Credits', (float)($data['totalOverpaid'] ?? 0)],
['Gross Outstanding Before Credits', (float)($data['totalUnpaid'] ?? 0)],
['Amount Unpaid (Net of Credits)', (float)($data['netReceivable'] ?? 0)],
];
foreach ($rows as $row) {
fputcsv($out, $row);
}
fputcsv($out, []);
fputcsv($out, ['Reconciliation']);
fputcsv($out, [
'Collected + Outstanding - Overpayment Credits = Net Charges After Discounts/Refunds',
round(
(float)($data['amountCollected'] ?? 0)
+ (float)($data['totalUnpaid'] ?? 0)
- (float)($data['totalOverpaid'] ?? 0),
2
),
]);
if (!empty($data['overpaymentDetails']) && is_array($data['overpaymentDetails'])) {
fputcsv($out, []);
fputcsv($out, ['Overpayment Credit Details']);
fputcsv($out, ['Parent', 'Invoice #', 'Gross Charges', 'Discounts', 'Refunds', 'Paid', 'Credit Amount', 'Note']);
foreach ($data['overpaymentDetails'] as $detail) {
fputcsv($out, [
(string)($detail['parent_name'] ?? ''),
(string)($detail['invoice_number'] ?? ''),
(float)($detail['gross_amount'] ?? 0),
(float)($detail['discount_amount'] ?? 0),
(float)($detail['refund_amount'] ?? 0),
(float)($detail['paid_amount'] ?? 0),
(float)($detail['credit_amount'] ?? 0),
(string)($detail['note'] ?? ''),
]);
}
}
fclose($out);
exit;
}
public function downloadSummaryAllDetailsCsv()
{
$dateFrom = $this->request->getGet('date_from');
$dateTo = $this->request->getGet('date_to');
$schoolYear = $this->request->getGet('school_year');
$summary = $this->getFinancialSummary($dateFrom, $dateTo, $schoolYear);
$sections = $this->getFinancialSummaryDetailSections($dateFrom, $dateTo, $schoolYear);
$safeYear = preg_replace('/[^A-Za-z0-9_-]+/', '_', (string)($summary['schoolYear'] ?? 'summary'));
$filename = 'financial_report_all_details_' . trim($safeYear, '_') . '_' . date('Ymd_His') . '.csv';
header('Content-Type: text/csv');
header('Content-Disposition: attachment; filename="' . $filename . '"');
$out = fopen('php://output', 'w');
fputcsv($out, ['Financial Report All Details']);
fputcsv($out, ['School Year', (string)($summary['schoolYear'] ?? '')]);
fputcsv($out, ['Date From', (string)($summary['dateFrom'] ?? '')]);
fputcsv($out, ['Date To', (string)($summary['dateTo'] ?? '')]);
$overview = [
['Tuition Charges' => (float)($summary['tuitionCharges'] ?? 0)],
['Event Fee Charges' => (float)($summary['totalEventFees'] ?? 0)],
['Prior-Year Tuition Carryover' => (float)($summary['totalExtraCharges'] ?? 0)],
['Gross Charges' => (float)($summary['grossCharges'] ?? $summary['totalCharges'] ?? 0)],
['Total Discounts' => (float)($summary['totalDiscounts'] ?? 0)],
['Total Refunds' => (float)($summary['totalRefunds'] ?? 0)],
['Total Expenses' => (float)($summary['totalExpenses'] ?? 0)],
['Total Reimbursements' => (float)($summary['totalReimbursements'] ?? 0)],
['Donation to School (included in expenses)' => (float)($summary['donationToSchool'] ?? 0)],
['Net Charges After Discounts/Refunds' => (float)($summary['netAmount'] ?? 0)],
['Amount Collected (Paid)' => (float)($summary['amountCollected'] ?? 0)],
['Overpayment Credits' => (float)($summary['totalOverpaid'] ?? 0)],
['Gross Outstanding Before Credits' => (float)($summary['totalUnpaid'] ?? 0)],
['Amount Unpaid (Net of Credits)' => (float)($summary['netReceivable'] ?? 0)],
];
fputcsv($out, []);
fputcsv($out, ['Summary Overview']);
fputcsv($out, ['Description', 'Amount']);
foreach ($overview as $row) {
$label = array_key_first($row);
fputcsv($out, [$label, $row[$label]]);
}
fputcsv($out, []);
fputcsv($out, ['Reconciliation']);
fputcsv($out, [
'Collected + Gross Outstanding - Overpayment Credits = Net Charges After Discounts/Refunds',
round(
(float)($summary['amountCollected'] ?? 0)
+ (float)($summary['totalUnpaid'] ?? 0)
- (float)($summary['totalOverpaid'] ?? 0),
2
),
]);
$orderedSections = [
'Invoices' => $sections['invoices'] ?? [],
'Discount Details' => $sections['discounts'] ?? [],
'Payment Details' => $sections['payments'] ?? [],
'Refund Details' => $sections['refunds'] ?? [],
'Event Fee Charge Details' => $sections['eventCharges'] ?? [],
'Prior-Year Tuition Carryover Details' => $sections['carryovers'] ?? [],
'Parents With Outstanding Balance Details' => $sections['unpaidParents'] ?? [],
'Expense Details' => $sections['expenses'] ?? [],
'Reimbursement Details' => $sections['reimbursements'] ?? [],
'Donation Expense Details' => $sections['donationExpenses'] ?? [],
'Donation Reimbursement Details' => $sections['donationReimbursements'] ?? [],
'Reimbursement Batch Fallback Details' => $sections['reimbursementBatchFallbacks'] ?? [],
'Overpayment Credit Details' => $summary['overpaymentDetails'] ?? [],
];
foreach ($orderedSections as $title => $rows) {
$this->writeCsvSection($out, $title, $rows);
}
fclose($out);
exit;
}
private function writeCsvSection($out, string $title, array $rows): void
{
fputcsv($out, []);
fputcsv($out, [$title]);
if (empty($rows)) {
fputcsv($out, ['No rows found']);
return;
}
$headers = array_keys((array)reset($rows));
fputcsv($out, $headers);
foreach ($rows as $row) {
$line = [];
foreach ($headers as $header) {
$line[] = $row[$header] ?? '';
}
fputcsv($out, $line);
}
}
private function getFinancialSummaryDetailSections(?string $dateFrom = null, ?string $dateTo = null, ?string $schoolYear = null): array
{
$configModel = new \App\Models\ConfigurationModel();
$schoolYear = $schoolYear ?: $configModel->getConfig('school_year');
$derivedDateFrom = null;
$derivedDateTo = null;
if (!empty($schoolYear) && empty($dateFrom) && empty($dateTo)) {
if (preg_match('/^(\\d{4})[^\\d]?(\\d{4})$/', $schoolYear, $m)) {
$derivedDateFrom = $m[1] . '-01-01';
$derivedDateTo = $m[2] . '-12-31';
}
}
$invoiceDateFrom = $dateFrom ?: $derivedDateFrom;
$invoiceDateTo = $dateTo ?: $derivedDateTo;
$db = \Config\Database::connect();
$normalizeYear = static function (?string $year): string {
return preg_replace('/[^0-9]/', '', (string)$year);
};
$normalizedYear = $normalizeYear($schoolYear);
$paymentExclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
$hasEventPaid = $db->fieldExists('event_paid', 'event_charges');
$hasEventPaymentId = $db->fieldExists('event_payment_id', 'event_charges');
$specialRecipientIds = array_map('intval', array_keys(ReimbursementController::SPECIAL_RECIPIENTS));
$invoiceRows = $db->table('invoices')
->select("invoices.id, invoices.invoice_number, invoices.parent_id, CONCAT(COALESCE(users.firstname, ''), ' ', COALESCE(users.lastname, '')) AS parent_name, invoices.issue_date, invoices.due_date, invoices.total_amount, invoices.status")
->join('users', 'users.id = invoices.parent_id', 'left')
->where('invoices.school_year', $schoolYear);
if (!empty($invoiceDateFrom)) {
$invoiceRows->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) >=', $invoiceDateFrom);
}
if (!empty($invoiceDateTo)) {
$invoiceRows->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $invoiceDateTo);
}
$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);
}
}
$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);
$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),
'Discounts' => $disc,
'Refunds' => $refund,
'Paid' => $paid,
'Balance' => $balance,
'Status' => (string)($row['status'] ?? ''),
];
}
$discountDetailsBuilder = $db->table('discount_usages du')
->select("du.id, du.invoice_id, du.voucher_id, du.discount_amount, du.used_at, du.created_at, i.invoice_number, CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.lastname, '')) AS parent_name")
->join('invoices i', 'i.id = du.invoice_id')
->join('users u', 'u.id = i.parent_id', 'left')
->where('i.school_year', $schoolYear);
if (!empty($dateFrom)) {
$discountDetailsBuilder->where('DATE(COALESCE(du.used_at, du.created_at)) >=', $dateFrom);
}
if (!empty($dateTo)) {
$discountDetailsBuilder->where('DATE(COALESCE(du.used_at, du.created_at)) <=', $dateTo);
}
$discounts = array_map(static function ($row) {
return [
'Parent' => trim((string)($row['parent_name'] ?? '')),
'Invoice #' => (string)($row['invoice_number'] ?? ''),
'Voucher ID' => (string)($row['voucher_id'] ?? ''),
'Discount Amount' => (float)($row['discount_amount'] ?? 0),
'Used At' => (string)($row['used_at'] ?? ''),
'Created At' => (string)($row['created_at'] ?? ''),
];
}, $discountDetailsBuilder->orderBy('du.id', 'ASC')->get()->getResultArray());
$paymentDetailsBuilder = $db->table('payments p')
->select("p.id, p.invoice_id, p.parent_id, p.paid_amount, p.payment_method, p.payment_date, p.status, p.transaction_id, p.check_number, i.invoice_number, CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.lastname, '')) AS parent_name")
->join('invoices i', 'i.id = p.invoice_id', 'left')
->join('users u', 'u.id = p.parent_id', 'left')
->where('p.school_year', $schoolYear)
->groupStart()
->whereNotIn('p.status', $paymentExclude)
->orWhere('p.status IS NULL', null, false)
->groupEnd();
if (!empty($invoiceDateFrom)) {
$paymentDetailsBuilder->where('DATE(p.payment_date) >=', $invoiceDateFrom);
}
if (!empty($invoiceDateTo)) {
$paymentDetailsBuilder->where('DATE(p.payment_date) <=', $invoiceDateTo);
}
$payments = array_map(static function ($row) {
return [
'Parent' => trim((string)($row['parent_name'] ?? '')),
'Invoice #' => (string)($row['invoice_number'] ?? ''),
'Paid Amount' => (float)($row['paid_amount'] ?? 0),
'Payment Method' => (string)($row['payment_method'] ?? ''),
'Payment Date' => (string)($row['payment_date'] ?? ''),
'Status' => (string)($row['status'] ?? ''),
'Transaction ID' => (string)($row['transaction_id'] ?? ''),
'Check Number' => (string)($row['check_number'] ?? ''),
];
}, $paymentDetailsBuilder->orderBy('p.id', 'ASC')->get()->getResultArray());
$refundDetailsBuilder = $db->table('refunds r')
->select("r.id, r.invoice_id, r.parent_id, r.refund_paid_amount, r.status, r.request, r.reason, r.refunded_at, r.created_at, i.invoice_number, CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.lastname, '')) AS parent_name")
->join('invoices i', 'i.id = r.invoice_id', 'left')
->join('users u', 'u.id = r.parent_id', 'left')
->where('r.school_year', $schoolYear)
->whereIn('r.status', ['Partial', 'Paid']);
if (!empty($dateFrom)) {
$refundDetailsBuilder->where('DATE(COALESCE(r.refunded_at, r.created_at)) >=', $dateFrom);
}
if (!empty($dateTo)) {
$refundDetailsBuilder->where('DATE(COALESCE(r.refunded_at, r.created_at)) <=', $dateTo);
}
$refunds = array_map(static function ($row) {
return [
'Parent' => trim((string)($row['parent_name'] ?? '')),
'Invoice #' => (string)($row['invoice_number'] ?? ''),
'Refund Amount' => (float)($row['refund_paid_amount'] ?? 0),
'Status' => (string)($row['status'] ?? ''),
'Request Type' => (string)($row['request'] ?? ''),
'Reason' => (string)($row['reason'] ?? ''),
'Refunded At' => (string)($row['refunded_at'] ?? ''),
'Created At' => (string)($row['created_at'] ?? ''),
];
}, $refundDetailsBuilder->orderBy('r.id', 'ASC')->get()->getResultArray());
$eventSelect = "ec.id, ec.parent_id, ec.student_id, ec.charged, ec.participation, ec.created_at, e.event_name, CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.lastname, '')) AS parent_name, CONCAT(COALESCE(s.firstname, ''), ' ', COALESCE(s.lastname, '')) AS student_name";
if ($hasEventPaid) {
$eventSelect .= ", ec.event_paid";
}
if ($hasEventPaymentId) {
$eventSelect .= ", ec.event_payment_id";
}
$eventBuilder = $db->table('event_charges ec')
->select($eventSelect, false)
->join('events e', 'e.id = ec.event_id', 'left')
->join('users u', 'u.id = ec.parent_id', 'left')
->join('students s', 's.id = ec.student_id', 'left')
->where('ec.school_year', $schoolYear);
if (!empty($invoiceDateFrom)) {
$eventBuilder->where('DATE(ec.created_at) >=', $invoiceDateFrom);
}
if (!empty($invoiceDateTo)) {
$eventBuilder->where('DATE(ec.created_at) <=', $invoiceDateTo);
}
$eventCharges = array_map(static function ($row) use ($hasEventPaid, $hasEventPaymentId) {
return [
'Parent' => trim((string)($row['parent_name'] ?? '')),
'Student' => trim((string)($row['student_name'] ?? '')),
'Event' => (string)($row['event_name'] ?? ''),
'Charged Amount' => (float)($row['charged'] ?? 0),
'Participation' => (string)($row['participation'] ?? ''),
'Event Paid' => $hasEventPaid ? (string)($row['event_paid'] ?? '') : '',
'Event Payment ID' => $hasEventPaymentId ? (string)($row['event_payment_id'] ?? '') : '',
'Created At' => (string)($row['created_at'] ?? ''),
];
}, $eventBuilder->orderBy('ec.id', 'ASC')->get()->getResultArray());
$carryoverBuilder = $db->table('additional_charges ac')
->select("ac.id, ac.parent_id, ac.invoice_id, ac.title, ac.description, ac.amount, ac.status, ac.charge_type, ac.due_date, ac.created_at, i.invoice_number, CONCAT(COALESCE(u.firstname, ''), ' ', COALESCE(u.lastname, '')) AS parent_name")
->join('users u', 'u.id = ac.parent_id', 'left')
->join('invoices i', 'i.id = ac.invoice_id', 'left')
->where('ac.school_year', $schoolYear)
->whereNotIn('ac.status', ['void', 'voided']);
if (!empty($dateFrom)) {
$carryoverBuilder->where('DATE(ac.due_date) >=', $dateFrom);
}
if (!empty($dateTo)) {
$carryoverBuilder->where('DATE(ac.due_date) <=', $dateTo);
}
$carryovers = array_map(static function ($row) {
return [
'Parent' => trim((string)($row['parent_name'] ?? '')),
'Invoice #' => (string)($row['invoice_number'] ?? ''),
'Title' => (string)($row['title'] ?? ''),
'Description' => (string)($row['description'] ?? ''),
'Amount' => (float)($row['amount'] ?? 0),
'Charge Type' => (string)($row['charge_type'] ?? ''),
'Status' => (string)($row['status'] ?? ''),
'Due Date' => (string)($row['due_date'] ?? ''),
'Created At' => (string)($row['created_at'] ?? ''),
];
}, $carryoverBuilder->orderBy('ac.id', 'ASC')->get()->getResultArray());
$expenseBuilder = $db->table('expenses e')
->select('e.id, e.category, e.amount, e.description, e.retailor, e.date_of_purchase, e.status, e.school_year, e.semester, e.created_at')
->where('e.school_year', $schoolYear);
if (!empty($invoiceDateFrom)) {
$expenseBuilder->where('DATE(e.created_at) >=', $invoiceDateFrom);
}
if (!empty($invoiceDateTo)) {
$expenseBuilder->where('DATE(e.created_at) <=', $invoiceDateTo);
}
$expenseRows = $expenseBuilder->orderBy('e.id', 'ASC')->get()->getResultArray();
$expenses = array_map(static function ($row) {
return [
'Expense ID' => (string)($row['id'] ?? ''),
'Category' => (string)($row['category'] ?? ''),
'Amount' => (float)($row['amount'] ?? 0),
'Description' => (string)($row['description'] ?? ''),
'Vendor' => (string)($row['retailor'] ?? ''),
'Date of Purchase' => (string)($row['date_of_purchase'] ?? ''),
'Status' => (string)($row['status'] ?? ''),
'Created At' => (string)($row['created_at'] ?? ''),
];
}, $expenseRows);
$reimbursementBuilder = $db->table('reimbursements r')
->select("r.id, r.expense_id, r.amount, r.status, r.description, r.reimbursement_method, r.check_number, r.batch_number, r.created_at, e.category AS expense_category, e.amount AS expense_amount, e.description AS expense_description, CONCAT(COALESCE(rec.firstname, ''), ' ', COALESCE(rec.lastname, '')) AS reimbursed_to_name")
->join('expenses e', 'e.id = r.expense_id', 'left')
->join('users rec', 'rec.id = r.reimbursed_to', 'left');
if (!empty($schoolYear)) {
$reimbursementBuilder
->groupStart()
->groupStart()
->where('r.school_year', $schoolYear)
->orWhere(
"REPLACE(REPLACE(REPLACE(REPLACE(r.school_year, ' ', ''), '-', ''), '/', ''), '\\\\', '')",
$normalizedYear
)
->groupEnd()
->orGroupStart()
->where('r.school_year IS NULL', null, false)
->groupStart()
->where('e.school_year', $schoolYear)
->orWhere(
"REPLACE(REPLACE(REPLACE(REPLACE(e.school_year, ' ', ''), '-', ''), '/', ''), '\\\\', '')",
$normalizedYear
)
->groupEnd()
->groupEnd()
->groupEnd();
}
if (!empty($invoiceDateFrom)) {
$reimbursementBuilder->where('DATE(COALESCE(r.created_at, e.created_at)) >=', $invoiceDateFrom);
}
if (!empty($invoiceDateTo)) {
$reimbursementBuilder->where('DATE(COALESCE(r.created_at, e.created_at)) <=', $invoiceDateTo);
}
$reimbursementRows = $reimbursementBuilder->orderBy('r.id', 'ASC')->get()->getResultArray();
$reimbursements = array_map(static function ($row) {
return [
'Reimbursement ID' => (string)($row['id'] ?? ''),
'Expense ID' => (string)($row['expense_id'] ?? ''),
'Expense Category' => (string)($row['expense_category'] ?? ''),
'Expense Amount' => (float)($row['expense_amount'] ?? 0),
'Reimbursed Amount' => (float)($row['amount'] ?? 0),
'Reimbursed To' => trim((string)($row['reimbursed_to_name'] ?? '')),
'Status' => (string)($row['status'] ?? ''),
'Method' => (string)($row['reimbursement_method'] ?? ''),
'Check Number' => (string)($row['check_number'] ?? ''),
'Batch Number' => (string)($row['batch_number'] ?? ''),
'Description' => (string)($row['description'] ?? ''),
'Expense Description' => (string)($row['expense_description'] ?? ''),
'Created At' => (string)($row['created_at'] ?? ''),
];
}, $reimbursementRows);
$donationExpenses = array_values(array_filter($expenses, static function ($row) {
return strtolower(trim((string)($row['Category'] ?? ''))) === 'donation';
}));
$donationReimbursements = [];
if (!empty($specialRecipientIds)) {
$donationBuilder = $db->table('reimbursements r')
->select("r.id, r.expense_id, r.amount, r.created_at, r.description, CONCAT(COALESCE(rec.firstname, ''), ' ', COALESCE(rec.lastname, '')) AS reimbursed_to_name, e.category AS expense_category, e.description AS expense_description")
->join('expenses e', 'e.id = r.expense_id', 'left')
->join('users rec', 'rec.id = r.reimbursed_to', 'left')
->whereIn('r.reimbursed_to', $specialRecipientIds);
if (!empty($schoolYear)) {
$donationBuilder
->groupStart()
->groupStart()
->where('r.school_year', $schoolYear)
->orWhere(
"REPLACE(REPLACE(REPLACE(REPLACE(r.school_year, ' ', ''), '-', ''), '/', ''), '\\\\', '')",
$normalizedYear
)
->groupEnd()
->orGroupStart()
->where('r.school_year IS NULL', null, false)
->groupStart()
->where('e.school_year', $schoolYear)
->orWhere(
"REPLACE(REPLACE(REPLACE(REPLACE(e.school_year, ' ', ''), '-', ''), '/', ''), '\\\\', '')",
$normalizedYear
)
->groupEnd()
->groupEnd()
->groupEnd();
}
if (!empty($invoiceDateFrom)) {
$donationBuilder->where('DATE(COALESCE(r.created_at, e.created_at)) >=', $invoiceDateFrom);
}
if (!empty($invoiceDateTo)) {
$donationBuilder->where('DATE(COALESCE(r.created_at, e.created_at)) <=', $invoiceDateTo);
}
$donationReimbursements = array_map(static function ($row) {
return [
'Reimbursement ID' => (string)($row['id'] ?? ''),
'Expense ID' => (string)($row['expense_id'] ?? ''),
'Reimbursed To' => trim((string)($row['reimbursed_to_name'] ?? '')),
'Amount' => (float)($row['amount'] ?? 0),
'Expense Category' => (string)($row['expense_category'] ?? ''),
'Expense Description' => (string)($row['expense_description'] ?? ''),
'Description' => (string)($row['description'] ?? ''),
'Created At' => (string)($row['created_at'] ?? ''),
];
}, $donationBuilder->orderBy('r.id', 'ASC')->get()->getResultArray());
}
$batchFallbackBuilder = $db->table('reimbursement_batch_items bi')
->select("bi.batch_id, b.title AS batch_title, b.yearly_batch_number, e.id AS expense_id, e.category, e.amount, e.description, b.closed_at")
->join('reimbursement_batches b', 'b.id = bi.batch_id', 'inner')
->join('expenses e', 'e.id = bi.expense_id', 'inner')
->join('reimbursements r', 'r.expense_id = e.id', 'left')
->where('b.status', 'closed')
->where('bi.unassigned_at IS NULL', null, false)
->where('r.id IS NULL');
if (!empty($schoolYear)) {
$batchFallbackBuilder
->groupStart()
->where('e.school_year', $schoolYear)
->orWhere(
"REPLACE(REPLACE(REPLACE(REPLACE(e.school_year, ' ', ''), '-', ''), '/', ''), '\\\\', '')",
$normalizedYear
)
->groupEnd();
}
if (!empty($invoiceDateFrom)) {
$batchFallbackBuilder->where('DATE(COALESCE(e.created_at, b.closed_at)) >=', $invoiceDateFrom);
}
if (!empty($invoiceDateTo)) {
$batchFallbackBuilder->where('DATE(COALESCE(e.created_at, b.closed_at)) <=', $invoiceDateTo);
}
$reimbursementBatchFallbacks = array_map(static function ($row) {
$batchNumber = (string)($row['yearly_batch_number'] ?? '');
if ($batchNumber === '') {
$batchNumber = (string)($row['batch_title'] ?? '');
}
return [
'Batch ID' => (string)($row['batch_id'] ?? ''),
'Batch Number' => $batchNumber,
'Expense ID' => (string)($row['expense_id'] ?? ''),
'Category' => (string)($row['category'] ?? ''),
'Amount' => (float)($row['amount'] ?? 0),
'Description' => (string)($row['description'] ?? ''),
'Closed At' => (string)($row['closed_at'] ?? ''),
];
}, $batchFallbackBuilder->orderBy('bi.batch_id', 'ASC')->get()->getResultArray());
return [
'invoices' => $invoices,
'discounts' => $discounts,
'payments' => $payments,
'refunds' => $refunds,
'eventCharges' => $eventCharges,
'carryovers' => $carryovers,
'unpaidParents' => $this->getUnpaidParentsDetails($schoolYear),
'expenses' => $expenses,
'reimbursements' => $reimbursements,
'donationExpenses' => $donationExpenses,
'donationReimbursements' => $donationReimbursements,
'reimbursementBatchFallbacks' => $reimbursementBatchFallbacks,
];
}
private function getUnpaidParentsDetails(string $schoolYear): array
{
if ($schoolYear === '') {
return [];
}
$db = \Config\Database::connect();
$invRows = $db->table('invoices i')
->select('i.id, i.parent_id, i.total_amount, u.firstname, u.lastname, u.email')
->join('users u', 'u.id = i.parent_id', 'inner')
->where('i.school_year', $schoolYear)
->orderBy('i.parent_id', 'ASC')
->orderBy('i.id', 'ASC')
->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);
if ($iid <= 0 || $pid <= 0) {
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));
if (!isset($byParent[$pid])) {
$byParent[$pid] = [
'parent_id' => $pid,
'firstname' => (string)($r['firstname'] ?? ''),
'lastname' => (string)($r['lastname'] ?? ''),
'email' => (string)($r['email'] ?? ''),
'total_invoice' => 0.0,
'total_balance' => 0.0,
'total_paid' => 0.0,
'total_discount' => 0.0,
];
}
$byParent[$pid]['total_invoice'] += $total;
$byParent[$pid]['total_balance'] += $balance;
$byParent[$pid]['total_paid'] += $paidSum;
$byParent[$pid]['total_discount']+= $discSum;
}
$extraRows = $db->table('additional_charges ac')
->select(
"ac.parent_id,
u.firstname,
u.lastname,
u.email,
COALESCE(SUM(ac.amount), 0) AS total_extra",
false
)
->join('users u', 'u.id = ac.parent_id', 'left')
->where('ac.school_year', $schoolYear)
->where('ac.status !=', 'void')
->groupStart()
->where('ac.invoice_id IS NULL', null, false)
->orWhere('ac.invoice_id', 0)
->orWhere('ac.status !=', 'applied')
->groupEnd()
->groupBy('ac.parent_id')
->get()
->getResultArray();
foreach ($extraRows as $er) {
$pid = (int)($er['parent_id'] ?? 0);
if ($pid <= 0) {
continue;
}
$extra = (float)($er['total_extra'] ?? 0);
if (abs($extra) < 0.00001) {
continue;
}
if (!isset($byParent[$pid])) {
$byParent[$pid] = [
'parent_id' => $pid,
'firstname' => (string)($er['firstname'] ?? ''),
'lastname' => (string)($er['lastname'] ?? ''),
'email' => (string)($er['email'] ?? ''),
'total_invoice' => 0.0,
'total_balance' => 0.0,
'total_paid' => 0.0,
'total_discount' => 0.0,
];
}
$byParent[$pid]['total_invoice'] += $extra;
$byParent[$pid]['total_balance'] += $extra;
}
$eventFeesPerParent = [];
try {
$eventFeesRows = $db->table('event_charges ec')
->select('ec.parent_id, COALESCE(SUM(ec.charged),0) AS event_fees', false)
->where('ec.school_year', $schoolYear)
->groupBy('ec.parent_id')
->get()
->getResultArray();
foreach ($eventFeesRows as $row) {
$pid = (int)($row['parent_id'] ?? 0);
if ($pid <= 0) {
continue;
}
$eventFeesPerParent[$pid] = (float)($row['event_fees'] ?? 0);
}
} catch (\Throwable $e) {
}
$installmentEndRaw = (string) (new \App\Models\ConfigurationModel())->getConfig('installment_date');
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$tzNY = new \DateTimeZone($tzName);
$todayNY = new \DateTimeImmutable('today', $tzNY);
$endDate = null;
if ($installmentEndRaw) {
try {
$endDate = new \DateTimeImmutable($installmentEndRaw, $tzNY);
} catch (\Throwable $e) {
$endDate = null;
}
}
$monthsUntil = function (?\DateTimeImmutable $end) use ($todayNY): int {
if (!$end) {
return 0;
}
$y = (int)$end->format('Y') - (int)$todayNY->format('Y');
$m = (int)$end->format('n') - (int)$todayNY->format('n');
$months = $y * 12 + $m;
if ((int)$end->format('j') > (int)$todayNY->format('j')) {
$months += 1;
}
return max(0, $months);
};
$rows = [];
foreach ($byParent as $pid => $agg) {
$balance = (float)($agg['total_balance'] ?? 0);
if ($balance > 0.00001) {
$remMonths = $monthsUntil($endDate);
$remainingInstallments = max(1, $remMonths);
$instAmount = round($balance / $remainingInstallments, 2);
$rows[] = [
'parent_id' => $pid,
'firstname' => $agg['firstname'],
'lastname' => $agg['lastname'],
'email' => $agg['email'],
'total_invoice' => (float)$agg['total_invoice'],
'total_balance' => $balance,
'total_discount' => (float)($agg['total_discount'] ?? 0),
'remaining_installments' => $remainingInstallments,
'installment_amount' => $instAmount,
];
}
}
usort($rows, static function ($a, $b) {
return ($b['total_balance'] <=> $a['total_balance']);
});
$tz = new \DateTimeZone($tzName);
$now = new \DateTime('now', $tz);
$nextInstallmentDt = (clone $now)->modify('first day of next month');
$nextInstallmentYmd = $nextInstallmentDt->format('Y-m-d');
$parentIds = array_values(array_unique(array_map(static fn($r) => (int)($r['parent_id'] ?? 0), $rows)));
$hasPayments = [];
$paidTotals = [];
$paymentCounts = [];
if (!empty($parentIds)) {
try {
$pRows = $db->table('payments')
->select('parent_id, SUM(paid_amount) AS total_paid, COUNT(*) AS payment_count')
->whereIn('parent_id', $parentIds)
->where('school_year', $schoolYear)
->groupBy('parent_id')
->get()->getResultArray();
foreach ($pRows as $pr) {
$pid = (int)($pr['parent_id'] ?? 0);
if ($pid > 0) {
$hasPayments[$pid] = true;
$paidTotals[$pid] = (float)($pr['total_paid'] ?? 0);
$paymentCounts[$pid] = (int)($pr['payment_count'] ?? 0);
}
}
} catch (\Throwable $e) {
}
}
return array_map(function(array $r) use ($hasPayments, $paidTotals, $paymentCounts, $nextInstallmentYmd, $eventFeesPerParent) {
$pid = (int)($r['parent_id'] ?? 0);
$name = trim((string)($r['firstname'] ?? '') . ' ' . (string)($r['lastname'] ?? ''));
return [
'Parent' => $name,
'Parent ID' => $pid,
'Email' => (string)($r['email'] ?? ''),
'Type' => isset($hasPayments[$pid]) ? 'installment' : 'no_payment',
'Event Fees' => (float)($eventFeesPerParent[$pid] ?? 0),
'Invoice Amount' => (float)($r['total_invoice'] ?? 0),
'Applied Discount' => (float)($r['total_discount'] ?? 0),
'Paid Amount' => isset($paidTotals[$pid]) ? (float)$paidTotals[$pid] : 0.0,
'Nbr of Installments' => isset($paymentCounts[$pid]) ? (int)$paymentCounts[$pid] : 0,
'Remaining Installments' => (int)($r['remaining_installments'] ?? 0),
'Installment Amount' => (float)($r['installment_amount'] ?? 0),
'Has Installment' => isset($hasPayments[$pid]) ? 1 : 0,
'Next Installment' => $nextInstallmentYmd,
'Outstanding Balance' => (float)($r['total_balance'] ?? 0),
];
}, $rows);
}
public function downloadCsv() public function downloadCsv()
{ {
@@ -637,7 +1490,7 @@ public function financialReport()
$rows = [ $rows = [
'Total Charges' => $data['totalCharges'], 'Total Charges' => $data['totalCharges'],
'Total Extra Charges' => $data['totalExtraCharges'] ?? 0, 'Prior-Year Tuition Carryover' => $data['totalExtraCharges'] ?? 0,
'Total Discounts' => $data['totalDiscounts'], 'Total Discounts' => $data['totalDiscounts'],
'Total Refunds' => $data['totalRefunds'], 'Total Refunds' => $data['totalRefunds'],
'Total Expenses' => $data['totalExpenses'], 'Total Expenses' => $data['totalExpenses'],
@@ -769,7 +1622,10 @@ public function financialReport()
$db = \Config\Database::connect(); $db = \Config\Database::connect();
// === Invoices: Total Charges === // === Invoices: Total Charges ===
$invoiceBuilder = $invoiceModel->where('school_year', $schoolYear); $invoiceBuilder = $invoiceModel
->select("invoices.*, CONCAT(COALESCE(users.firstname, ''), ' ', COALESCE(users.lastname, '')) AS parent_name")
->join('users', 'users.id = invoices.parent_id', 'left')
->where('invoices.school_year', $schoolYear);
$invoiceDateFrom = $dateFrom ?: $derivedDateFrom; $invoiceDateFrom = $dateFrom ?: $derivedDateFrom;
$invoiceDateTo = $dateTo ?: $derivedDateTo; $invoiceDateTo = $dateTo ?: $derivedDateTo;
if (!empty($invoiceDateFrom)) { if (!empty($invoiceDateFrom)) {
@@ -787,12 +1643,16 @@ public function financialReport()
// === Additional Charges === // === Additional Charges ===
$hasExplicitDates = !empty($dateFrom) || !empty($dateTo); $hasExplicitDates = !empty($dateFrom) || !empty($dateTo);
$sumExpr = "COALESCE(SUM(ac.amount), 0) AS amount"; $signedAdditionalChargeExpr = "CASE
WHEN LOWER(COALESCE(ac.charge_type, '')) = 'deduct' THEN -ABS(ac.amount)
ELSE ABS(ac.amount)
END";
$sumExpr = "COALESCE(SUM({$signedAdditionalChargeExpr}), 0) AS amount";
$acAllBuilder = $db->table('additional_charges ac') $acAllBuilder = $db->table('additional_charges ac')
->select($sumExpr, false) ->select($sumExpr, false)
->where('ac.school_year', $schoolYear) ->where('ac.school_year', $schoolYear)
->where('ac.status !=', 'void'); ->whereNotIn('ac.status', ['void', 'voided']);
if ($hasExplicitDates) { if ($hasExplicitDates) {
if (!empty($dateFrom)) $acAllBuilder->where('DATE(ac.due_date) >=', $dateFrom); if (!empty($dateFrom)) $acAllBuilder->where('DATE(ac.due_date) >=', $dateFrom);
if (!empty($dateTo)) $acAllBuilder->where('DATE(ac.due_date) <=', $dateTo); if (!empty($dateTo)) $acAllBuilder->where('DATE(ac.due_date) <=', $dateTo);
@@ -806,7 +1666,7 @@ public function financialReport()
$acExtraBuilder = $db->table('additional_charges ac') $acExtraBuilder = $db->table('additional_charges ac')
->select('ac.parent_id, ' . $sumExpr, false) ->select('ac.parent_id, ' . $sumExpr, false)
->where('ac.school_year', $schoolYear) ->where('ac.school_year', $schoolYear)
->where('ac.status !=', 'void') ->whereNotIn('ac.status', ['void', 'voided'])
->groupStart() ->groupStart()
->where('ac.invoice_id IS NULL', null, false) ->where('ac.invoice_id IS NULL', null, false)
->orWhere('ac.invoice_id', 0) ->orWhere('ac.invoice_id', 0)
@@ -1091,9 +1951,10 @@ public function financialReport()
$discountResult = $discountBuilder->selectSum('discount_amount')->get()->getRowArray(); $discountResult = $discountBuilder->selectSum('discount_amount')->get()->getRowArray();
$totalDiscounts = isset($discountResult['discount_amount']) ? (float) $discountResult['discount_amount'] : 0.00; $totalDiscounts = isset($discountResult['discount_amount']) ? (float) $discountResult['discount_amount'] : 0.00;
// === Net & Unpaid === // === Net, Outstanding & Overpayments ===
// Sum positive per-parent balances (per-invoice balance + unapplied extra charges) $overpaymentDetails = [];
$parentBalances = []; $totalUnpaid = 0.0;
$totalOverpaid = 0.0;
foreach ($invoices as $inv) { foreach ($invoices as $inv) {
$iid = (int)($inv['id'] ?? 0); $iid = (int)($inv['id'] ?? 0);
$pid = (int)($inv['parent_id'] ?? 0); $pid = (int)($inv['parent_id'] ?? 0);
@@ -1102,26 +1963,62 @@ public function financialReport()
$paid = (float)($paidByInvoice[$iid] ?? 0); $paid = (float)($paidByInvoice[$iid] ?? 0);
$disc = (float)($discountByInvoice[$iid] ?? 0); $disc = (float)($discountByInvoice[$iid] ?? 0);
$ref = (float)($refundByInvoice[$iid] ?? 0); $ref = (float)($refundByInvoice[$iid] ?? 0);
$bal = max(0.0, round($total - $disc - $paid - $ref, 2)); $rawBal = round($total - $disc - $paid - $ref, 2);
if (!isset($parentBalances[$pid])) $parentBalances[$pid] = 0.0; if ($rawBal > 0.00001) {
$parentBalances[$pid] += $bal; $totalUnpaid += $rawBal;
} elseif ($rawBal < -0.00001) {
$credit = abs($rawBal);
$totalOverpaid += $credit;
$overpaymentDetails[] = [
'type' => 'invoice',
'parent_id' => $pid,
'parent_name' => trim((string)($inv['parent_name'] ?? '')),
'invoice_id' => $iid,
'invoice_number' => (string)($inv['invoice_number'] ?? ''),
'credit_amount' => $credit,
'gross_amount' => $total,
'discount_amount' => $disc,
'refund_amount' => $ref,
'paid_amount' => $paid,
'note' => 'Invoice payments/discounts exceed net invoice charges.',
];
}
} }
foreach ($extraByParent as $pid => $extra) { foreach ($extraByParent as $pid => $extra) {
if (!isset($parentBalances[$pid])) $parentBalances[$pid] = 0.0; $extra = round((float)$extra, 2);
$parentBalances[$pid] += (float)$extra; if ($extra > 0.00001) {
$totalUnpaid += $extra;
} elseif ($extra < -0.00001) {
$credit = abs($extra);
$totalOverpaid += $credit;
$overpaymentDetails[] = [
'type' => 'adjustment',
'parent_id' => (int)$pid,
'parent_name' => '',
'invoice_id' => null,
'invoice_number' => '',
'credit_amount' => $credit,
'gross_amount' => 0.0,
'discount_amount' => 0.0,
'refund_amount' => 0.0,
'paid_amount' => 0.0,
'note' => 'Negative unapplied carryover adjustment.',
];
} }
$totalUnpaid = 0.0;
foreach ($parentBalances as $bal) {
if ($bal > 0.00001) $totalUnpaid += $bal;
} }
$amountCollected = $totalPaid; $amountCollected = $totalPaid;
$netAmount = ($totalCharges - $totalDiscounts - $totalRefunds); $grossCharges = round((float)$totalCharges, 2);
$netAmount = round($grossCharges - (float)$totalDiscounts - (float)$totalRefunds, 2);
$totalEventFees = $this->getEventFeesTotal($schoolYear, $invoiceDateFrom, $invoiceDateTo); $totalEventFees = $this->getEventFeesTotal($schoolYear, $invoiceDateFrom, $invoiceDateTo);
$tuitionCharges = round($grossCharges - (float)$totalEventFees - (float)$totalExtraCharges, 2);
$netReceivable = max(0.0, round((float)$totalUnpaid - (float)$totalOverpaid, 2));
return [ return [
'schoolYear' => $schoolYear, 'schoolYear' => $schoolYear,
'dateFrom' => $dateFrom, 'dateFrom' => $dateFrom,
'dateTo' => $dateTo, 'dateTo' => $dateTo,
'grossCharges' => $grossCharges,
'tuitionCharges' => $tuitionCharges,
'totalCharges' => $totalCharges, 'totalCharges' => $totalCharges,
'totalExtraCharges' => $totalExtraCharges, 'totalExtraCharges' => $totalExtraCharges,
'totalDiscounts' => $totalDiscounts, 'totalDiscounts' => $totalDiscounts,
@@ -1132,6 +2029,9 @@ public function financialReport()
'totalPaid' => $totalPaid, 'totalPaid' => $totalPaid,
'amountCollected' => $amountCollected, 'amountCollected' => $amountCollected,
'totalUnpaid' => $totalUnpaid, 'totalUnpaid' => $totalUnpaid,
'totalOverpaid' => $totalOverpaid,
'overpaymentDetails' => $overpaymentDetails,
'netReceivable' => $netReceivable,
'netAmount' => $netAmount, 'netAmount' => $netAmount,
'totalEventFees' => $totalEventFees, 'totalEventFees' => $totalEventFees,
]; ];
+122 -23
View File
@@ -7,6 +7,8 @@
<div class="d-flex align-items-center gap-2 my-3 flex-nowrap w-100 overflow-auto" style="white-space: nowrap;"> <div class="d-flex align-items-center gap-2 my-3 flex-nowrap w-100 overflow-auto" style="white-space: nowrap;">
<!-- School year filter --> <!-- School year filter -->
<form id="summaryYearFilter" class="d-flex align-items-center gap-2 flex-nowrap me-auto" method="get" action="<?= site_url('financial-report/financialReportSummary') ?>" style="white-space: nowrap;"> <form id="summaryYearFilter" class="d-flex align-items-center gap-2 flex-nowrap me-auto" method="get" action="<?= site_url('financial-report/financialReportSummary') ?>" style="white-space: nowrap;">
<input type="hidden" name="date_from" value="<?= esc((string)($dateFrom ?? '')) ?>">
<input type="hidden" name="date_to" value="<?= esc((string)($dateTo ?? '')) ?>">
<label class="form-label mb-0 me-2">School year</label> <label class="form-label mb-0 me-2">School year</label>
<select name="school_year" class="form-select form-select-sm rounded-pill px-3" style="min-width: 180px;"> <select name="school_year" class="form-select form-select-sm rounded-pill px-3" style="min-width: 180px;">
<?php foreach (($schoolYears ?? []) as $sy): ?> <?php foreach (($schoolYears ?? []) as $sy): ?>
@@ -16,7 +18,8 @@
</form> </form>
<!-- Action buttons --> <!-- Action buttons -->
<div class="d-flex align-items-center gap-2 flex-nowrap flex-shrink-0" style="white-space: nowrap;"> <div class="d-flex align-items-center gap-2 flex-nowrap flex-shrink-0" style="white-space: nowrap;">
<a href="<?= base_url('reports/downloadFinancialReport') ?>" class="btn btn-primary">Download PDF</a> <a id="downloadSummaryCsvLink" href="<?= base_url('financial-report/downloadSummaryCsv') ?>" class="btn btn-success">Download CSV</a>
<a id="downloadSummaryAllDetailsCsvLink" href="<?= base_url('financial-report/downloadSummaryAllDetailsCsv') ?>" class="btn btn-primary">Download All Details CSV</a>
<a href="<?= base_url('/payment/financial_report') ?>" class="btn btn-secondary">Back To Detailed Report</a> <a href="<?= base_url('/payment/financial_report') ?>" class="btn btn-secondary">Back To Detailed Report</a>
</div> </div>
</div> </div>
@@ -31,21 +34,31 @@
</tr> </tr>
</thead> </thead>
<tbody id="summaryBody"> <tbody id="summaryBody">
<tr><td>Total Charges</td><td class="text-right" id="sumCharges">$0.00</td></tr> <tr><td>Tuition Charges</td><td class="text-right" id="sumTuitionCharges">$0.00</td></tr>
<tr><td>Event Fees</td><td class="text-right" id="sumEventFees">$0.00</td></tr> <tr><td>Event Fee Charges</td><td class="text-right" id="sumEventFees">$0.00</td></tr>
<tr><td>Total Extra Charges</td><td class="text-right" id="sumExtraCharges">$0.00</td></tr> <tr><td>Prior-Year Tuition Carryover</td><td class="text-right" id="sumExtraCharges">$0.00</td></tr>
<tr><td>Gross Charges</td><td class="text-right" id="sumGrossCharges">$0.00</td></tr>
<tr><td>Total Discounts</td><td class="text-right" id="sumDiscounts">$0.00</td></tr> <tr><td>Total Discounts</td><td class="text-right" id="sumDiscounts">$0.00</td></tr>
<tr><td>Total Refunds</td><td class="text-right" id="sumRefunds">$0.00</td></tr> <tr><td>Total Refunds</td><td class="text-right" id="sumRefunds">$0.00</td></tr>
<tr><td>Total Expenses</td><td class="text-right" id="sumExpenses">$0.00</td></tr> <tr><td>Total Expenses</td><td class="text-right" id="sumExpenses">$0.00</td></tr>
<tr><td>Total Reimbursements</td><td class="text-right" id="sumReimb">$0.00</td></tr> <tr><td>Total Reimbursements</td><td class="text-right" id="sumReimb">$0.00</td></tr>
<tr><td>Donation to School (Masjid &amp; Donation)</td><td class="text-right" id="sumDonationToSchool">$0.00</td></tr> <tr><td>Donation to School (included in expenses)</td><td class="text-right" id="sumDonationToSchool">$0.00</td></tr>
<tr class="table-success"><td>Net Amount (Earned Income)</td><td class="text-right font-weight-bold" id="sumNet">$0.00</td></tr> <tr class="table-success"><td>Net Charges After Discounts/Refunds</td><td class="text-right font-weight-bold" id="sumNet">$0.00</td></tr>
<tr class="table-info"><td>Amount Collected (Paid)</td><td class="text-right font-weight-bold" id="sumCollected">$0.00</td></tr> <tr class="table-info"><td>Amount Collected (Paid)</td><td class="text-right font-weight-bold" id="sumCollected">$0.00</td></tr>
<tr class="table-warning"><td>Amount Unpaid (Outstanding)</td><td class="text-right font-weight-bold" id="sumUnpaid">$0.00</td></tr> <tr><td>Overpayment Credits</td><td class="text-right" id="sumOverpaid">$0.00</td></tr>
<tr><td>Gross Outstanding Before Credits</td><td class="text-right" id="sumUnpaid">$0.00</td></tr>
<tr class="table-warning"><td>Amount Unpaid (Net of Credits)</td><td class="text-right font-weight-bold" id="sumNetReceivable">$0.00</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<p class="small text-muted mb-3" id="summaryNetFormula"></p>
<div class="alert alert-light border my-3" id="summaryReconciliationBox">
<div class="fw-semibold">Reconciliation</div>
<div id="summaryReconciliationText" class="small text-muted"></div>
</div>
<div class="my-4"> <div class="my-4">
<h3>Summary Graph</h3> <h3>Summary Graph</h3>
<canvas id="summaryChart" style="max-height:300px;"></canvas> <canvas id="summaryChart" style="max-height:300px;"></canvas>
@@ -55,13 +68,13 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<div class="h-100 p-3 border rounded"> <div class="h-100 p-3 border rounded">
<h3 class="h5">Collected vs Outstanding</h3> <h3 class="h5">Collected vs Net Outstanding</h3>
<canvas id="collectedChart" style="max-height:320px; width:100%;"></canvas> <canvas id="collectedChart" style="max-height:320px; width:100%;"></canvas>
</div> </div>
</div> </div>
<div class="col-12 col-lg-6"> <div class="col-12 col-lg-6">
<div class="h-100 p-3 border rounded"> <div class="h-100 p-3 border rounded">
<h3 class="h5">Expenses out of Net Amount</h3> <h3 class="h5">Expenses out of Net Charges</h3>
<canvas id="netExpenseChart" style="max-height:320px; width:100%;"></canvas> <canvas id="netExpenseChart" style="max-height:320px; width:100%;"></canvas>
</div> </div>
</div> </div>
@@ -79,6 +92,10 @@
function fmt(n){ return '$' + Number(n||0).toFixed(2); } function fmt(n){ return '$' + Number(n||0).toFixed(2); }
const initialSummaryData = { const initialSummaryData = {
schoolYear: <?= json_encode((string)($schoolYear ?? '')) ?>, schoolYear: <?= json_encode((string)($schoolYear ?? '')) ?>,
dateFrom: <?= json_encode((string)($dateFrom ?? '')) ?>,
dateTo: <?= json_encode((string)($dateTo ?? '')) ?>,
grossCharges: <?= json_encode((float)($grossCharges ?? $totalCharges ?? 0)) ?>,
tuitionCharges: <?= json_encode((float)($tuitionCharges ?? 0)) ?>,
totalCharges: <?= json_encode((float)($totalCharges ?? 0)) ?>, totalCharges: <?= json_encode((float)($totalCharges ?? 0)) ?>,
totalEventFees: <?= json_encode((float)($totalEventFees ?? 0)) ?>, totalEventFees: <?= json_encode((float)($totalEventFees ?? 0)) ?>,
totalExtraCharges: <?= json_encode((float)($totalExtraCharges ?? 0)) ?>, totalExtraCharges: <?= json_encode((float)($totalExtraCharges ?? 0)) ?>,
@@ -90,18 +107,47 @@ const initialSummaryData = {
totalPaid: <?= json_encode((float)($totalPaid ?? 0)) ?>, totalPaid: <?= json_encode((float)($totalPaid ?? 0)) ?>,
amountCollected: <?= json_encode((float)($amountCollected ?? 0)) ?>, amountCollected: <?= json_encode((float)($amountCollected ?? 0)) ?>,
totalUnpaid: <?= json_encode((float)($totalUnpaid ?? 0)) ?>, totalUnpaid: <?= json_encode((float)($totalUnpaid ?? 0)) ?>,
totalOverpaid: <?= json_encode((float)($totalOverpaid ?? 0)) ?>,
overpaymentDetails: <?= json_encode($overpaymentDetails ?? []) ?>,
netReceivable: <?= json_encode((float)($netReceivable ?? 0)) ?>,
netAmount: <?= json_encode((float)($netAmount ?? 0)) ?> netAmount: <?= json_encode((float)($netAmount ?? 0)) ?>
}; };
function buildSummaryPeriod(d) {
const parts = ['Report for School Year: ' + (d.schoolYear || '')];
if (d.dateFrom) parts.push('Date From: ' + d.dateFrom);
if (d.dateTo) parts.push('Date To: ' + d.dateTo);
return parts.join(' | ');
}
function buildReconciliationText(d) {
const collected = Number(d.amountCollected || d.totalPaid || 0);
const unpaid = Number(d.totalUnpaid || 0);
const overpaid = Number(d.totalOverpaid || 0);
const net = Number(d.netAmount || 0);
return fmt(collected) + ' + ' + fmt(unpaid) + ' - ' + fmt(overpaid) + ' = ' + fmt(net)
+ ' (Collected + Gross Outstanding - Overpayment Credits = Net Charges After Discounts/Refunds)';
}
function buildNetFormulaText(d) {
const gross = Number(d.grossCharges || d.totalCharges || 0);
const discounts = Number(d.totalDiscounts || 0);
const refunds = Number(d.totalRefunds || 0);
const net = Number(d.netAmount || 0);
return 'Net Charges After Discounts/Refunds = Gross Charges - Total Discounts - Total Refunds = '
+ fmt(gross) + ' - ' + fmt(discounts) + ' - ' + fmt(refunds) + ' = ' + fmt(net);
}
function applySummaryData(d) { function applySummaryData(d) {
document.getElementById('summaryPeriod').textContent = 'Report for School Year: ' + (d.schoolYear || ''); document.getElementById('summaryPeriod').textContent = buildSummaryPeriod(d);
document.getElementById('sumCharges').textContent = fmt(d.totalCharges); document.getElementById('sumTuitionCharges').textContent = fmt(d.tuitionCharges || 0);
if (document.getElementById('sumEventFees')) { if (document.getElementById('sumEventFees')) {
document.getElementById('sumEventFees').textContent = fmt(d.totalEventFees || 0); document.getElementById('sumEventFees').textContent = fmt(d.totalEventFees || 0);
} }
if (document.getElementById('sumExtraCharges')) { if (document.getElementById('sumExtraCharges')) {
document.getElementById('sumExtraCharges').textContent = fmt(d.totalExtraCharges || 0); document.getElementById('sumExtraCharges').textContent = fmt(d.totalExtraCharges || 0);
} }
document.getElementById('sumGrossCharges').textContent = fmt(d.grossCharges || d.totalCharges || 0);
document.getElementById('sumDiscounts').textContent = fmt(d.totalDiscounts); document.getElementById('sumDiscounts').textContent = fmt(d.totalDiscounts);
document.getElementById('sumRefunds').textContent = fmt(d.totalRefunds); document.getElementById('sumRefunds').textContent = fmt(d.totalRefunds);
document.getElementById('sumExpenses').textContent = fmt(d.totalExpenses); document.getElementById('sumExpenses').textContent = fmt(d.totalExpenses);
@@ -111,14 +157,43 @@ function applySummaryData(d) {
} }
document.getElementById('sumNet').textContent = fmt(d.netAmount); document.getElementById('sumNet').textContent = fmt(d.netAmount);
document.getElementById('sumCollected').textContent = fmt(d.amountCollected || d.totalPaid || 0); document.getElementById('sumCollected').textContent = fmt(d.amountCollected || d.totalPaid || 0);
document.getElementById('sumOverpaid').textContent = fmt(d.totalOverpaid || 0);
document.getElementById('sumUnpaid').textContent = fmt(d.totalUnpaid); document.getElementById('sumUnpaid').textContent = fmt(d.totalUnpaid);
document.getElementById('sumNetReceivable').textContent = fmt(d.netReceivable || 0);
document.getElementById('summaryNetFormula').textContent = buildNetFormulaText(d);
document.getElementById('summaryReconciliationText').textContent = buildReconciliationText(d);
}
function buildSummaryParams() {
const params = new URLSearchParams();
const form = document.getElementById('summaryYearFilter');
if (form) {
new FormData(form).forEach((value, key) => {
params.set(key, value);
});
}
return params;
}
function updateSummaryActionLinks() {
const params = buildSummaryParams().toString();
const summaryHref = '<?= base_url('financial-report/downloadSummaryCsv') ?>' + (params ? ('?' + params) : '');
const allDetailsHref = '<?= base_url('financial-report/downloadSummaryAllDetailsCsv') ?>' + (params ? ('?' + params) : '');
const summaryLink = document.getElementById('downloadSummaryCsvLink');
const allDetailsLink = document.getElementById('downloadSummaryAllDetailsCsvLink');
if (summaryLink) summaryLink.href = summaryHref;
if (allDetailsLink) allDetailsLink.href = allDetailsHref;
} }
// Auto-submit school year on change and hydrate via API // Auto-submit school year on change and hydrate via API
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const sel = document.querySelector('#summaryYearFilter select[name="school_year"]'); const sel = document.querySelector('#summaryYearFilter select[name="school_year"]');
if (sel) sel.addEventListener('change', function(){ loadSummary(); }); if (sel) sel.addEventListener('change', function(){
updateSummaryActionLinks();
loadSummary();
});
applySummaryData(initialSummaryData); applySummaryData(initialSummaryData);
updateSummaryActionLinks();
renderCharts(initialSummaryData); renderCharts(initialSummaryData);
loadSummary(); loadSummary();
}); });
@@ -126,9 +201,7 @@ document.addEventListener('DOMContentLoaded', function() {
let __sumSeq = 0; let __sumSeq = 0;
function loadSummary(){ function loadSummary(){
const mySeq = ++__sumSeq; const mySeq = ++__sumSeq;
const sel = document.querySelector('#summaryYearFilter select[name="school_year"]'); const params = buildSummaryParams();
const params = new URLSearchParams();
if (sel && sel.value) params.append('school_year', sel.value);
const baseUrl = '<?= site_url('financial-report/financialReportSummary') ?>'; const baseUrl = '<?= site_url('financial-report/financialReportSummary') ?>';
const url = baseUrl + (params.toString() ? ('?' + params.toString() + '&format=json') : '?format=json'); const url = baseUrl + (params.toString() ? ('?' + params.toString() + '&format=json') : '?format=json');
fetch(url, { headers: { 'Accept': 'application/json' }}) fetch(url, { headers: { 'Accept': 'application/json' }})
@@ -148,13 +221,39 @@ function renderCharts(d){
if (window._summaryChart) { window._summaryChart.destroy(); } if (window._summaryChart) { window._summaryChart.destroy(); }
if (window._collectedChart) { window._collectedChart.destroy(); } if (window._collectedChart) { window._collectedChart.destroy(); }
if (window._netExpenseChart) { window._netExpenseChart.destroy(); } if (window._netExpenseChart) { window._netExpenseChart.destroy(); }
const chartColors = {
tuition: '#1f77b4',
eventFees: '#ff7f0e',
carryover: '#2ca02c',
grossCharges: '#d62728',
discounts: '#9467bd',
expenses: '#20c997',
netCharges: '#e377c2',
collected: '#28a745',
overpaymentCredits: '#dc3545',
outstanding: '#ffc107',
netReceivable: '#f4a261'
};
const summaryCtx = document.getElementById('summaryChart').getContext('2d'); const summaryCtx = document.getElementById('summaryChart').getContext('2d');
const summaryBarColors = [
chartColors.tuition,
chartColors.eventFees,
chartColors.carryover,
chartColors.grossCharges,
chartColors.discounts,
chartColors.expenses,
chartColors.netCharges,
chartColors.collected,
chartColors.overpaymentCredits,
chartColors.outstanding,
chartColors.netReceivable
];
window._summaryChart = new Chart(summaryCtx, { window._summaryChart = new Chart(summaryCtx, {
type: 'bar', type: 'bar',
data: { labels: ['Charges','Event Fees','Paid','Unpaid','Discounts','Refunds','Expenses','Reimbursements','Net'], data: { labels: ['Tuition','Event Fees','Prior-Year Carryover','Gross Charges','Discounts','Expenses','Net Charges','Collected','Overpayment Credits','Outstanding','Net Receivable'],
datasets: [{ label:'Amount (USD)', data:[ datasets: [{ label:'Amount (USD)', data:[
d.totalCharges||0, d.totalEventFees||0, d.totalPaid||0, d.totalUnpaid||0, d.totalDiscounts||0, d.totalRefunds||0, d.totalExpenses||0, d.totalReimbursements||0, d.netAmount||0 d.tuitionCharges||0, d.totalEventFees||0, d.totalExtraCharges||0, d.grossCharges||d.totalCharges||0, d.totalDiscounts||0, d.totalExpenses||0, d.netAmount||0, d.amountCollected||d.totalPaid||0, d.totalOverpaid||0, d.totalUnpaid||0, d.netReceivable||0
], backgroundColor: ['#007bff','#6610f2','#28a745','#ffc107','#17a2b8','#ffc107','#dc3545','#6f42c1','#20c997']}] }, ], backgroundColor: summaryBarColors}] },
options: { responsive:true, scales:{ y:{ beginAtZero:true }}} options: { responsive:true, scales:{ y:{ beginAtZero:true }}}
}); });
@@ -162,10 +261,10 @@ function renderCharts(d){
window._collectedChart = new Chart(collectedCtx, { window._collectedChart = new Chart(collectedCtx, {
type: 'pie', type: 'pie',
data: { data: {
labels: ['Amount Collected (Paid)', 'Amount Outstanding (Unpaid)'], labels: ['Amount Collected (Paid)', 'Amount Unpaid (Net of Credits)', 'Overpayment Credits'],
datasets: [{ datasets: [{
data: [ d.amountCollected || d.totalPaid || 0, d.totalUnpaid || 0 ], data: [ d.amountCollected || d.totalPaid || 0, d.netReceivable || 0, d.totalOverpaid || 0 ],
backgroundColor: ['#28a745', '#ffc107'], backgroundColor: [chartColors.collected, chartColors.outstanding, chartColors.overpaymentCredits],
borderWidth: 1 borderWidth: 1
}] }]
}, },
@@ -190,7 +289,7 @@ function renderCharts(d){
labels: netExpenseLabels, labels: netExpenseLabels,
datasets: [{ datasets: [{
data: netExpenseValues, data: netExpenseValues,
backgroundColor: ['#20c997', '#dc3545'], backgroundColor: [chartColors.expenses, chartColors.netCharges],
borderWidth: 1 borderWidth: 1
}] }]
}, },
@@ -202,7 +301,7 @@ function renderCharts(d){
callbacks: { callbacks: {
label: function(context) { label: function(context) {
if (context.dataIndex === 0) { if (context.dataIndex === 0) {
return 'Expenses: ' + fmt(totalExpenses) + ' (' + expensePercent + '% of net amount)'; return 'Expenses: ' + fmt(totalExpenses) + ' (' + expensePercent + '% of net charges)';
} }
return 'Remaining Net: ' + fmt(remainingNet) + ' (' + remainingPercent + '%)'; return 'Remaining Net: ' + fmt(remainingNet) + ' (' + remainingPercent + '%)';
} }