Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e8da3cc2c | |||
| 384ae8b719 | |||
| 654453222f | |||
| a18e8b92a6 |
@@ -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('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('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']);
|
||||
|
||||
@@ -341,6 +341,859 @@ public function financialReport()
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -637,7 +1490,7 @@ public function financialReport()
|
||||
|
||||
$rows = [
|
||||
'Total Charges' => $data['totalCharges'],
|
||||
'Total Extra Charges' => $data['totalExtraCharges'] ?? 0,
|
||||
'Prior-Year Tuition Carryover' => $data['totalExtraCharges'] ?? 0,
|
||||
'Total Discounts' => $data['totalDiscounts'],
|
||||
'Total Refunds' => $data['totalRefunds'],
|
||||
'Total Expenses' => $data['totalExpenses'],
|
||||
@@ -769,7 +1622,10 @@ public function financialReport()
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
// === 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;
|
||||
$invoiceDateTo = $dateTo ?: $derivedDateTo;
|
||||
if (!empty($invoiceDateFrom)) {
|
||||
@@ -787,12 +1643,16 @@ public function financialReport()
|
||||
|
||||
// === Additional Charges ===
|
||||
$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')
|
||||
->select($sumExpr, false)
|
||||
->where('ac.school_year', $schoolYear)
|
||||
->where('ac.status !=', 'void');
|
||||
->whereNotIn('ac.status', ['void', 'voided']);
|
||||
if ($hasExplicitDates) {
|
||||
if (!empty($dateFrom)) $acAllBuilder->where('DATE(ac.due_date) >=', $dateFrom);
|
||||
if (!empty($dateTo)) $acAllBuilder->where('DATE(ac.due_date) <=', $dateTo);
|
||||
@@ -806,7 +1666,7 @@ public function financialReport()
|
||||
$acExtraBuilder = $db->table('additional_charges ac')
|
||||
->select('ac.parent_id, ' . $sumExpr, false)
|
||||
->where('ac.school_year', $schoolYear)
|
||||
->where('ac.status !=', 'void')
|
||||
->whereNotIn('ac.status', ['void', 'voided'])
|
||||
->groupStart()
|
||||
->where('ac.invoice_id IS NULL', null, false)
|
||||
->orWhere('ac.invoice_id', 0)
|
||||
@@ -1091,9 +1951,10 @@ public function financialReport()
|
||||
$discountResult = $discountBuilder->selectSum('discount_amount')->get()->getRowArray();
|
||||
$totalDiscounts = isset($discountResult['discount_amount']) ? (float) $discountResult['discount_amount'] : 0.00;
|
||||
|
||||
// === Net & Unpaid ===
|
||||
// Sum positive per-parent balances (per-invoice balance + unapplied extra charges)
|
||||
$parentBalances = [];
|
||||
// === Net, Outstanding & Overpayments ===
|
||||
$overpaymentDetails = [];
|
||||
$totalUnpaid = 0.0;
|
||||
$totalOverpaid = 0.0;
|
||||
foreach ($invoices as $inv) {
|
||||
$iid = (int)($inv['id'] ?? 0);
|
||||
$pid = (int)($inv['parent_id'] ?? 0);
|
||||
@@ -1102,26 +1963,62 @@ public function financialReport()
|
||||
$paid = (float)($paidByInvoice[$iid] ?? 0);
|
||||
$disc = (float)($discountByInvoice[$iid] ?? 0);
|
||||
$ref = (float)($refundByInvoice[$iid] ?? 0);
|
||||
$bal = max(0.0, round($total - $disc - $paid - $ref, 2));
|
||||
if (!isset($parentBalances[$pid])) $parentBalances[$pid] = 0.0;
|
||||
$parentBalances[$pid] += $bal;
|
||||
$rawBal = round($total - $disc - $paid - $ref, 2);
|
||||
if ($rawBal > 0.00001) {
|
||||
$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) {
|
||||
if (!isset($parentBalances[$pid])) $parentBalances[$pid] = 0.0;
|
||||
$parentBalances[$pid] += (float)$extra;
|
||||
}
|
||||
$totalUnpaid = 0.0;
|
||||
foreach ($parentBalances as $bal) {
|
||||
if ($bal > 0.00001) $totalUnpaid += $bal;
|
||||
$extra = round((float)$extra, 2);
|
||||
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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
$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);
|
||||
$tuitionCharges = round($grossCharges - (float)$totalEventFees - (float)$totalExtraCharges, 2);
|
||||
$netReceivable = max(0.0, round((float)$totalUnpaid - (float)$totalOverpaid, 2));
|
||||
return [
|
||||
'schoolYear' => $schoolYear,
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
'grossCharges' => $grossCharges,
|
||||
'tuitionCharges' => $tuitionCharges,
|
||||
'totalCharges' => $totalCharges,
|
||||
'totalExtraCharges' => $totalExtraCharges,
|
||||
'totalDiscounts' => $totalDiscounts,
|
||||
@@ -1132,6 +2029,9 @@ public function financialReport()
|
||||
'totalPaid' => $totalPaid,
|
||||
'amountCollected' => $amountCollected,
|
||||
'totalUnpaid' => $totalUnpaid,
|
||||
'totalOverpaid' => $totalOverpaid,
|
||||
'overpaymentDetails' => $overpaymentDetails,
|
||||
'netReceivable' => $netReceivable,
|
||||
'netAmount' => $netAmount,
|
||||
'totalEventFees' => $totalEventFees,
|
||||
];
|
||||
|
||||
@@ -74,7 +74,7 @@ class InvoiceController extends ResourceController
|
||||
$this->dueDate = $this->configModel->getConfig('due_date');
|
||||
$this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 350);
|
||||
$this->secondStudentFee = (float) ($this->configModel->getConfig('second_student_fee') ?? 200);
|
||||
$this->youthFee = (float) ($this->configModel->getConfig('youth_fee') ?? 180);
|
||||
$this->youthFee = (float) ($this->configModel->getConfig('youth_fee') ?? 200);
|
||||
$this->refundDeadline = date('Y-m-d', strtotime($this->configModel->getConfig('refund_deadline')));
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->request = \Config\Services::request();
|
||||
|
||||
@@ -601,153 +601,219 @@ class ReimbursementController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateBatchAssignment()
|
||||
{
|
||||
if (strtolower($this->request->getMethod()) !== 'post') {
|
||||
return $this->response->setStatusCode(405)->setJSON([
|
||||
'success' => false,
|
||||
'error' => 'Method not allowed',
|
||||
]);
|
||||
}
|
||||
public function updateBatchAssignment()
|
||||
{
|
||||
if (strtolower($this->request->getMethod()) !== 'post') {
|
||||
return $this->response->setStatusCode(405)->setJSON([
|
||||
'success' => false,
|
||||
'error' => 'Method not allowed',
|
||||
]);
|
||||
}
|
||||
|
||||
$expenseId = (int) $this->request->getPost('expense_id');
|
||||
$batchIdRaw = $this->request->getPost('batch_id');
|
||||
$batchId = (int) $batchIdRaw;
|
||||
$fallbackBatchNumber = $this->request->getPost('batch_number');
|
||||
if ($batchId <= 0 && $fallbackBatchNumber !== null && trim((string) $fallbackBatchNumber) !== '') {
|
||||
$batchId = (int) $fallbackBatchNumber;
|
||||
}
|
||||
$adminIdRaw = $this->request->getPost('admin_id');
|
||||
$adminId = ($adminIdRaw === '' || $adminIdRaw === null) ? null : (int) $adminIdRaw;
|
||||
$reimbursementId = (int) $this->request->getPost('reimbursement_id') ?: null;
|
||||
$expenseId = (int) $this->request->getPost('expense_id');
|
||||
|
||||
if ($expenseId <= 0) {
|
||||
return $this->response->setStatusCode(422)->setJSON([
|
||||
'success' => false,
|
||||
'error' => 'Invalid expense id.',
|
||||
]);
|
||||
}
|
||||
$batchIdRaw = $this->request->getPost('batch_id');
|
||||
$batchId = (int) $batchIdRaw;
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$fallbackBatchNumber = $this->request->getPost('batch_number');
|
||||
if ($batchId <= 0 && $fallbackBatchNumber !== null && trim((string) $fallbackBatchNumber) !== '') {
|
||||
$batchId = (int) $fallbackBatchNumber;
|
||||
}
|
||||
|
||||
$adminIdRaw = $this->request->getPost('admin_id');
|
||||
$adminId = ($adminIdRaw === '' || $adminIdRaw === null) ? null : (int) $adminIdRaw;
|
||||
|
||||
$reimbursementId = (int) $this->request->getPost('reimbursement_id') ?: null;
|
||||
|
||||
if ($expenseId <= 0) {
|
||||
return $this->response->setStatusCode(422)->setJSON([
|
||||
'success' => false,
|
||||
'error' => 'Invalid expense id.',
|
||||
]);
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$newHashResponse = function (array $payload = []) {
|
||||
$newHash = function_exists('csrf_hash') ? csrf_hash() : null;
|
||||
|
||||
return $this->response
|
||||
->setHeader('X-CSRF-HASH', (string) $newHash)
|
||||
->setJSON(array_merge($payload, [
|
||||
'csrf_hash' => $newHash,
|
||||
]));
|
||||
};
|
||||
|
||||
$this->db->transBegin();
|
||||
|
||||
try {
|
||||
$activeItem = $this->batchItemModel
|
||||
->where('expense_id', $expenseId)
|
||||
->where('unassigned_at IS NULL', null, false)
|
||||
->first();
|
||||
|
||||
/**
|
||||
* If no batch is provided, remove the item from active batch processing.
|
||||
* This means "unassigned from batch", not "unassigned admin".
|
||||
*/
|
||||
if ($batchId <= 0) {
|
||||
if ($activeItem) {
|
||||
$this->batchItemModel->update((int) $activeItem['id'], ['unassigned_at' => $now]);
|
||||
$this->batchItemModel->update((int) $activeItem['id'], [
|
||||
'unassigned_at' => $now,
|
||||
]);
|
||||
|
||||
if (!$reimbursementId && !empty($activeItem['reimbursement_id'])) {
|
||||
$reimbursementId = (int) $activeItem['reimbursement_id'];
|
||||
}
|
||||
}
|
||||
|
||||
$newHash = function_exists('csrf_hash') ? csrf_hash() : null;
|
||||
return $this->response
|
||||
->setHeader('X-CSRF-HASH', (string) $newHash)
|
||||
->setJSON([
|
||||
'success' => true,
|
||||
'batch_id' => 0,
|
||||
'admin_id' => null,
|
||||
'reimbursement_id' => $reimbursementId,
|
||||
'csrf_hash' => $newHash,
|
||||
]);
|
||||
if (!$this->db->transStatus()) {
|
||||
throw new \RuntimeException('Transaction failed while unassigning batch item.');
|
||||
}
|
||||
|
||||
$this->db->transCommit();
|
||||
|
||||
return $newHashResponse([
|
||||
'success' => true,
|
||||
'batch_id' => 0,
|
||||
'admin_id' => null,
|
||||
'reimbursement_id' => $reimbursementId,
|
||||
]);
|
||||
}
|
||||
|
||||
$batch = $this->batchModel->find($batchId);
|
||||
if (!$batch || strtolower((string) ($batch['status'] ?? 'open')) !== 'open') {
|
||||
$this->db->transRollback();
|
||||
|
||||
return $this->response->setStatusCode(404)->setJSON([
|
||||
'success' => false,
|
||||
'error' => 'Batch not found or already closed.',
|
||||
]);
|
||||
}
|
||||
|
||||
$currentAdmin = $activeItem ? (int) ($activeItem['admin_id'] ?? 0) : null;
|
||||
$activeSameBatch = $activeItem && (int) ($activeItem['batch_id'] ?? 0) === $batchId;
|
||||
if ($activeSameBatch && ($currentAdmin === ($adminId ?? 0))) {
|
||||
$newHash = function_exists('csrf_hash') ? csrf_hash() : null;
|
||||
return $this->response
|
||||
->setHeader('X-CSRF-HASH', (string) $newHash)
|
||||
->setJSON([
|
||||
'success' => true,
|
||||
'batch_id' => $batchId,
|
||||
'admin_id' => $adminId,
|
||||
'reimbursement_id' => $activeItem['reimbursement_id'] ?? null,
|
||||
'csrf_hash' => $newHash,
|
||||
]);
|
||||
if (!$reimbursementId) {
|
||||
if ($activeItem && !empty($activeItem['reimbursement_id'])) {
|
||||
$reimbursementId = (int) $activeItem['reimbursement_id'];
|
||||
} else {
|
||||
$reimbursementId = $this->lookupReimbursementId($expenseId);
|
||||
}
|
||||
}
|
||||
|
||||
if ($activeSameBatch) {
|
||||
if (!$reimbursementId) {
|
||||
$reimbursementId = $activeItem['reimbursement_id'] ? (int) $activeItem['reimbursement_id'] : $this->lookupReimbursementId($expenseId);
|
||||
$activeSameBatch = $activeItem && (int) ($activeItem['batch_id'] ?? 0) === $batchId;
|
||||
$currentAdmin = $activeItem ? (int) ($activeItem['admin_id'] ?? 0) : null;
|
||||
$requestedAdmin = $adminId ?? 0;
|
||||
|
||||
/**
|
||||
* If the item is already active in the same batch/admin slot, just return success.
|
||||
*/
|
||||
if ($activeSameBatch && $currentAdmin === $requestedAdmin) {
|
||||
if (!$this->db->transStatus()) {
|
||||
throw new \RuntimeException('Transaction failed while checking existing assignment.');
|
||||
}
|
||||
$updatePayload = [
|
||||
|
||||
$this->db->transCommit();
|
||||
|
||||
return $newHashResponse([
|
||||
'success' => true,
|
||||
'batch_id' => $batchId,
|
||||
'admin_id' => $adminId,
|
||||
'reimbursement_id' => $activeItem['reimbursement_id'] ?? $reimbursementId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* If same batch but different admin, update the existing active row.
|
||||
*
|
||||
* Important:
|
||||
* admin_id = null means active in the batch but not assigned to an admin.
|
||||
* unassigned_at != null means removed from active batch processing.
|
||||
*/
|
||||
if ($activeSameBatch) {
|
||||
$this->batchItemModel->update((int) $activeItem['id'], [
|
||||
'admin_id' => $adminId,
|
||||
'assigned_at' => $now,
|
||||
'reimbursement_id' => $reimbursementId,
|
||||
'unassigned_at' => $adminId === null ? $now : null,
|
||||
];
|
||||
$this->batchItemModel->update((int) $activeItem['id'], $updatePayload);
|
||||
$newHash = function_exists('csrf_hash') ? csrf_hash() : null;
|
||||
return $this->response
|
||||
->setHeader('X-CSRF-HASH', (string) $newHash)
|
||||
->setJSON([
|
||||
'success' => true,
|
||||
'batch_id' => $batchId,
|
||||
'admin_id' => $adminId,
|
||||
'reimbursement_id' => $reimbursementId,
|
||||
'csrf_hash' => $newHash,
|
||||
]);
|
||||
}
|
||||
'unassigned_at' => null,
|
||||
]);
|
||||
|
||||
if ($activeItem) {
|
||||
$this->batchItemModel->update((int) $activeItem['id'], ['unassigned_at' => $now]);
|
||||
if (!$reimbursementId && !empty($activeItem['reimbursement_id'])) {
|
||||
$reimbursementId = (int) $activeItem['reimbursement_id'];
|
||||
if (!$this->db->transStatus()) {
|
||||
throw new \RuntimeException('Transaction failed while updating existing assignment.');
|
||||
}
|
||||
|
||||
$this->db->transCommit();
|
||||
|
||||
return $newHashResponse([
|
||||
'success' => true,
|
||||
'batch_id' => $batchId,
|
||||
'admin_id' => $adminId,
|
||||
'reimbursement_id' => $reimbursementId,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$reimbursementId) {
|
||||
$reimbursementId = $this->lookupReimbursementId($expenseId);
|
||||
/**
|
||||
* If the item is active in another batch, soft-unassign that row first.
|
||||
*/
|
||||
if ($activeItem) {
|
||||
$this->batchItemModel->update((int) $activeItem['id'], [
|
||||
'unassigned_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$insertData = [
|
||||
/**
|
||||
* Critical fix:
|
||||
* Check whether this expense was previously assigned to this batch.
|
||||
* Because uq_batch_expense(batch_id, expense_id) blocks duplicate rows,
|
||||
* we must reactivate/update the old row instead of inserting again.
|
||||
*/
|
||||
$existingBatchItem = $this->batchItemModel
|
||||
->where('batch_id', $batchId)
|
||||
->where('expense_id', $expenseId)
|
||||
->first();
|
||||
|
||||
$assignmentData = [
|
||||
'batch_id' => $batchId,
|
||||
'expense_id' => $expenseId,
|
||||
'reimbursement_id' => $reimbursementId,
|
||||
'admin_id' => $adminId,
|
||||
'assigned_at' => $now,
|
||||
'unassigned_at' => null,
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
];
|
||||
|
||||
try {
|
||||
$this->batchItemModel->insert($insertData);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to assign expense #{expense} to batch #{batch}: {msg}', [
|
||||
'expense' => $expenseId,
|
||||
'batch' => $batchId,
|
||||
'msg' => $e->getMessage(),
|
||||
]);
|
||||
return $this->response->setStatusCode(500)->setJSON([
|
||||
'success' => false,
|
||||
'error' => 'Unable to update batch assignment right now.',
|
||||
]);
|
||||
if ($existingBatchItem) {
|
||||
$this->batchItemModel->update((int) $existingBatchItem['id'], $assignmentData);
|
||||
} else {
|
||||
$this->batchItemModel->insert($assignmentData);
|
||||
}
|
||||
|
||||
$newHash = function_exists('csrf_hash') ? csrf_hash() : null;
|
||||
if (!$this->db->transStatus()) {
|
||||
throw new \RuntimeException('Transaction failed while saving assignment.');
|
||||
}
|
||||
|
||||
return $this->response
|
||||
->setHeader('X-CSRF-HASH', (string) $newHash)
|
||||
->setJSON([
|
||||
'success' => true,
|
||||
'batch_id' => $batchId,
|
||||
'admin_id' => $adminId,
|
||||
'reimbursement_id' => $reimbursementId,
|
||||
'csrf_hash' => $newHash,
|
||||
]);
|
||||
$this->db->transCommit();
|
||||
|
||||
return $newHashResponse([
|
||||
'success' => true,
|
||||
'batch_id' => $batchId,
|
||||
'admin_id' => $adminId,
|
||||
'reimbursement_id' => $reimbursementId,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
|
||||
log_message('error', 'Failed to update batch assignment for expense #{expense}, batch #{batch}: {msg}', [
|
||||
'expense' => $expenseId,
|
||||
'batch' => $batchId,
|
||||
'msg' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return $this->response->setStatusCode(500)->setJSON([
|
||||
'success' => false,
|
||||
'error' => 'Unable to update batch assignment right now.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function lockBatch()
|
||||
{
|
||||
|
||||
@@ -71,12 +71,13 @@ class TuitionForecastController extends BaseController
|
||||
fputcsv($handle, ['Calculator Mode', $result['calculator_mode']]);
|
||||
fputcsv($handle, []);
|
||||
fputcsv($handle, ['Summary']);
|
||||
fputcsv($handle, ['Families', 'Students', 'Billable Students', 'Unit Price', 'Old Projected Income', 'New Projected Income', 'Difference']);
|
||||
fputcsv($handle, ['Families', 'Students', 'Billable Students', 'Grade Unit Price', 'Youth Unit Price', 'Old Projected Income', 'New Projected Income', 'Difference']);
|
||||
fputcsv($handle, [
|
||||
$result['summary']['family_count'],
|
||||
$result['summary']['student_count'],
|
||||
$result['summary']['billable_student_count'],
|
||||
$result['summary']['unit_price'],
|
||||
$result['summary']['youth_unit_price'],
|
||||
$result['summary']['old_projected_income'] ?? $result['summary']['old_projected_tuition'],
|
||||
$result['summary']['new_projected_income'] ?? $result['summary']['new_projected_tuition'],
|
||||
$result['summary']['difference'],
|
||||
@@ -132,6 +133,7 @@ class TuitionForecastController extends BaseController
|
||||
'include_event_only' => $this->request->{$source}('include_event_only') ?? '0',
|
||||
'include_paid_invoices' => $this->request->{$source}('include_paid_invoices') ?? '0',
|
||||
'unit_price' => $this->request->{$source}('unit_price') ?? '',
|
||||
'youth_unit_price' => $this->request->{$source}('youth_unit_price') ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,9 @@ class FinancialSystemLedgerCleanup extends Migration
|
||||
|
||||
$defaults = [
|
||||
'tuition_calculator_version' => 'old',
|
||||
'youth_fee' => '200.00',
|
||||
'new_tuition_full_amount' => '370.00',
|
||||
'new_tuition_youth_amount' => '200.00',
|
||||
'new_tuition_second_student_discount' => '50.00',
|
||||
'new_tuition_third_student_discount' => '50.00',
|
||||
'new_tuition_fourth_plus_discount' => '100.00',
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class UpdateYouthTuitionDefaults extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if (!$this->db->tableExists('configuration')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->upsertConfig('youth_fee', '200.00', ['180', '180.00']);
|
||||
$this->upsertConfig('new_tuition_youth_amount', '200.00', ['180', '180.00']);
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
if (!$this->db->tableExists('configuration')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->rollbackConfig('youth_fee', '180.00');
|
||||
$this->rollbackConfig('new_tuition_youth_amount', '180.00');
|
||||
}
|
||||
|
||||
protected function upsertConfig(string $key, string $value, array $legacyValues = []): void
|
||||
{
|
||||
$row = $this->db->table('configuration')
|
||||
->select('id, config_value')
|
||||
->where('config_key', $key)
|
||||
->orderBy('id', 'ASC')
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if (!$row) {
|
||||
$this->db->table('configuration')->insert([
|
||||
'config_key' => $key,
|
||||
'config_value' => $value,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$current = trim((string) ($row['config_value'] ?? ''));
|
||||
if ($current === '' || in_array($current, $legacyValues, true)) {
|
||||
$this->db->table('configuration')
|
||||
->where('config_key', $key)
|
||||
->update(['config_value' => $value]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function rollbackConfig(string $key, string $value): void
|
||||
{
|
||||
$this->db->table('configuration')
|
||||
->where('config_key', $key)
|
||||
->where('config_value', '200.00')
|
||||
->update(['config_value' => $value]);
|
||||
}
|
||||
}
|
||||
@@ -307,6 +307,7 @@ class InvoiceLedgerService
|
||||
'second_student_fee' => $this->configurationModel->getConfig('second_student_fee'),
|
||||
'youth_fee' => $this->configurationModel->getConfig('youth_fee'),
|
||||
'new_tuition_full_amount' => $this->configurationModel->getConfig('new_tuition_full_amount'),
|
||||
'new_tuition_youth_amount' => $this->configurationModel->getConfig('new_tuition_youth_amount'),
|
||||
'new_tuition_second_student_discount' => $this->configurationModel->getConfig('new_tuition_second_student_discount'),
|
||||
'new_tuition_third_student_discount' => $this->configurationModel->getConfig('new_tuition_third_student_discount'),
|
||||
'new_tuition_fourth_plus_discount' => $this->configurationModel->getConfig('new_tuition_fourth_plus_discount'),
|
||||
|
||||
@@ -10,6 +10,7 @@ final class NewTuitionCalculatorService implements TuitionCalculatorInterface
|
||||
{
|
||||
$gradeFee = (int) ($config['grade_fee'] ?? 9);
|
||||
$fullAmountCents = $this->toCents($config['new_tuition_full_amount'] ?? 370);
|
||||
$youthAmountCents = $this->toCents($config['new_tuition_youth_amount'] ?? $config['youth_fee'] ?? 200);
|
||||
$secondDiscountCents = $this->toCents($config['new_tuition_second_student_discount'] ?? 50);
|
||||
$thirdDiscountCents = $this->toCents($config['new_tuition_third_student_discount'] ?? 50);
|
||||
$fourthPlusDiscountCents = $this->toCents($config['new_tuition_fourth_plus_discount'] ?? 100);
|
||||
@@ -22,32 +23,45 @@ final class NewTuitionCalculatorService implements TuitionCalculatorInterface
|
||||
});
|
||||
|
||||
$details = [];
|
||||
$regularPosition = 0;
|
||||
|
||||
foreach (array_values($students) as $index => $student) {
|
||||
$position = $index + 1;
|
||||
foreach (array_values($students) as $student) {
|
||||
$level = GradeLevelParser::parse($student['grade_level'] ?? null, $gradeFee);
|
||||
|
||||
if ($position === 1) {
|
||||
if ($level > $gradeFee) {
|
||||
$position = null;
|
||||
$discountCents = 0;
|
||||
$rule = 'new_first_student_full_amount';
|
||||
} elseif ($position === 2) {
|
||||
$discountCents = $secondDiscountCents;
|
||||
$rule = 'new_second_student_discount';
|
||||
} elseif ($position === 3) {
|
||||
$discountCents = $thirdDiscountCents;
|
||||
$rule = 'new_third_student_discount';
|
||||
$rule = 'new_youth_unit_price';
|
||||
$baseAmountCents = $youthAmountCents;
|
||||
} else {
|
||||
$discountCents = $fourthPlusDiscountCents;
|
||||
$rule = 'new_fourth_plus_student_discount';
|
||||
$regularPosition++;
|
||||
$position = $regularPosition;
|
||||
|
||||
if ($position === 1) {
|
||||
$discountCents = 0;
|
||||
$rule = 'new_first_student_full_amount';
|
||||
} elseif ($position === 2) {
|
||||
$discountCents = $secondDiscountCents;
|
||||
$rule = 'new_second_student_discount';
|
||||
} elseif ($position === 3) {
|
||||
$discountCents = $thirdDiscountCents;
|
||||
$rule = 'new_third_student_discount';
|
||||
} else {
|
||||
$discountCents = $fourthPlusDiscountCents;
|
||||
$rule = 'new_fourth_plus_student_discount';
|
||||
}
|
||||
|
||||
$baseAmountCents = $fullAmountCents;
|
||||
}
|
||||
|
||||
$amountCents = max(0, $fullAmountCents - $discountCents);
|
||||
$amountCents = max(0, $baseAmountCents - $discountCents);
|
||||
|
||||
$details[] = [
|
||||
'student_id' => (int) ($student['student_id'] ?? 0),
|
||||
'student_name' => (string) ($student['student_name'] ?? ''),
|
||||
'grade_level' => $student['grade_level'] ?? null,
|
||||
'family_position' => $position,
|
||||
'full_amount' => $this->fromCents($fullAmountCents),
|
||||
'full_amount' => $this->fromCents($baseAmountCents),
|
||||
'discount' => $this->fromCents($discountCents),
|
||||
'rule' => $rule,
|
||||
'amount' => $this->fromCents($amountCents),
|
||||
|
||||
@@ -11,7 +11,7 @@ final class OldTuitionCalculatorService implements TuitionCalculatorInterface
|
||||
$gradeFee = (int) ($config['grade_fee'] ?? 9);
|
||||
$firstStudentFee = $this->toCents($config['first_student_fee'] ?? 370);
|
||||
$secondStudentFee = $this->toCents($config['second_student_fee'] ?? 200);
|
||||
$youthFee = $this->toCents($config['youth_fee'] ?? 180);
|
||||
$youthFee = $this->toCents($config['youth_fee'] ?? 200);
|
||||
|
||||
usort($students, function (array $left, array $right) use ($gradeFee): int {
|
||||
$leftLevel = GradeLevelParser::parse($left['grade_level'] ?? null, $gradeFee);
|
||||
|
||||
@@ -43,6 +43,7 @@ class TuitionForecastService
|
||||
$mode = $this->normalizeMode($mode);
|
||||
$options = $this->normalizeOptions($options);
|
||||
$this->unitPriceOverride = $options['unit_price'];
|
||||
$this->youthUnitPriceOverride = $options['youth_unit_price'];
|
||||
$tuitionConfig = $this->getTuitionConfig();
|
||||
$familyRows = [];
|
||||
$summary = [
|
||||
@@ -57,6 +58,7 @@ class TuitionForecastService
|
||||
'projected_tuition' => '0.00',
|
||||
'projected_income' => '0.00',
|
||||
'unit_price' => '0.00',
|
||||
'youth_unit_price' => '0.00',
|
||||
];
|
||||
|
||||
$oldProjectedCents = 0;
|
||||
@@ -117,6 +119,7 @@ class TuitionForecastService
|
||||
$summary['projected_tuition'] = $mode === 'old' ? $summary['old_projected_tuition'] : $summary['new_projected_tuition'];
|
||||
$summary['projected_income'] = $mode === 'old' ? $summary['old_projected_income'] : $summary['new_projected_income'];
|
||||
$summary['unit_price'] = number_format((float) ($tuitionConfig['new_tuition_full_amount'] ?? 0), 2, '.', '');
|
||||
$summary['youth_unit_price'] = number_format((float) ($tuitionConfig['new_tuition_youth_amount'] ?? 0), 2, '.', '');
|
||||
|
||||
return [
|
||||
'school_year' => $schoolYear,
|
||||
@@ -232,6 +235,7 @@ class TuitionForecastService
|
||||
'include_event_only' => $this->toBool($options['include_event_only'] ?? false),
|
||||
'include_paid_invoices' => $this->toBool($options['include_paid_invoices'] ?? true),
|
||||
'unit_price' => $this->normalizeMoney($options['unit_price'] ?? null),
|
||||
'youth_unit_price' => $this->normalizeMoney($options['youth_unit_price'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -541,8 +545,9 @@ class TuitionForecastService
|
||||
'grade_fee' => $this->configurationModel->getConfig('grade_fee'),
|
||||
'first_student_fee' => $this->configurationModel->getConfig('first_student_fee'),
|
||||
'second_student_fee' => $this->configurationModel->getConfig('second_student_fee'),
|
||||
'youth_fee' => $this->configurationModel->getConfig('youth_fee'),
|
||||
'youth_fee' => $this->configurationModel->getConfig('youth_fee') ?? '200.00',
|
||||
'new_tuition_full_amount' => $this->normalizedUnitPriceOverride(),
|
||||
'new_tuition_youth_amount' => $this->normalizedYouthUnitPriceOverride(),
|
||||
'new_tuition_second_student_discount' => $this->configurationModel->getConfig('new_tuition_second_student_discount'),
|
||||
'new_tuition_third_student_discount' => $this->configurationModel->getConfig('new_tuition_third_student_discount'),
|
||||
'new_tuition_fourth_plus_discount' => $this->configurationModel->getConfig('new_tuition_fourth_plus_discount'),
|
||||
@@ -550,6 +555,7 @@ class TuitionForecastService
|
||||
}
|
||||
|
||||
protected ?string $unitPriceOverride = null;
|
||||
protected ?string $youthUnitPriceOverride = null;
|
||||
|
||||
protected function normalizedUnitPriceOverride(): string
|
||||
{
|
||||
@@ -557,6 +563,16 @@ class TuitionForecastService
|
||||
?? (string) ($this->configurationModel->getConfig('new_tuition_full_amount') ?? '0.00');
|
||||
}
|
||||
|
||||
protected function normalizedYouthUnitPriceOverride(): string
|
||||
{
|
||||
return $this->youthUnitPriceOverride
|
||||
?? (string) (
|
||||
$this->configurationModel->getConfig('new_tuition_youth_amount')
|
||||
?? $this->configurationModel->getConfig('youth_fee')
|
||||
?? '200.00'
|
||||
);
|
||||
}
|
||||
|
||||
protected function normalizeMoney($value): ?string
|
||||
{
|
||||
if ($value === null || trim((string) $value) === '') {
|
||||
|
||||
@@ -61,7 +61,7 @@ class FeeCalculationService
|
||||
// Retrieve fee configs
|
||||
$firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 350);
|
||||
$secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 200);
|
||||
$youthFee = (float) ($configModel->getConfig('youth_fee') ?? 180);
|
||||
$youthFee = (float) ($configModel->getConfig('youth_fee') ?? 200);
|
||||
|
||||
// Assign tuition_fee to all students (before filtering refunds)
|
||||
$regularCount = 0;
|
||||
@@ -148,7 +148,7 @@ class FeeCalculationService
|
||||
|
||||
$firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 350);
|
||||
$secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 200);
|
||||
$youthFee = (float) ($configModel->getConfig('youth_fee') ?? 180);
|
||||
$youthFee = (float) ($configModel->getConfig('youth_fee') ?? 200);
|
||||
|
||||
// ✅ Pre-fetch and assign grade/class section names before sorting
|
||||
foreach ($students as &$student) {
|
||||
|
||||
@@ -30,6 +30,7 @@ $warningText = static function (array $warnings): string {
|
||||
'include_event_only' => !empty($filters['include_event_only']) ? '1' : '0',
|
||||
'include_paid_invoices' => !empty($filters['include_paid_invoices']) ? '1' : '0',
|
||||
'unit_price' => $filters['unit_price'] ?? '',
|
||||
'youth_unit_price' => $filters['youth_unit_price'] ?? '',
|
||||
]);
|
||||
?>
|
||||
<a href="<?= site_url('administrator/tuition-forecast/export?' . $exportQuery) ?>" class="btn btn-success">
|
||||
@@ -79,7 +80,7 @@ $warningText = static function (array $warnings): string {
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="unit_price" class="form-label">Unit Price</label>
|
||||
<label for="unit_price" class="form-label">Grades Unit Price</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
@@ -88,7 +89,19 @@ $warningText = static function (array $warnings): string {
|
||||
name="unit_price"
|
||||
class="form-control"
|
||||
value="<?= esc((string) ($filters['unit_price'] ?? ($summary['unit_price'] ?? ''))) ?>"
|
||||
placeholder="New tuition unit price">
|
||||
placeholder="New tuition unit price for grades">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="youth_unit_price" class="form-label">Youth Unit Price</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
id="youth_unit_price"
|
||||
name="youth_unit_price"
|
||||
class="form-control"
|
||||
value="<?= esc((string) ($filters['youth_unit_price'] ?? ($summary['youth_unit_price'] ?? ''))) ?>"
|
||||
placeholder="New tuition unit price for youth">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="form-check">
|
||||
@@ -109,7 +122,7 @@ $warningText = static function (array $warnings): string {
|
||||
|
||||
<?php if ($result): ?>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-2">
|
||||
<div class="col-xl col-lg-3 col-md-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Total Parents</div>
|
||||
@@ -117,7 +130,7 @@ $warningText = static function (array $warnings): string {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="col-xl col-lg-3 col-md-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Total Students</div>
|
||||
@@ -126,7 +139,7 @@ $warningText = static function (array $warnings): string {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="col-xl col-lg-3 col-md-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Old Projected Income</div>
|
||||
@@ -134,7 +147,7 @@ $warningText = static function (array $warnings): string {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="col-xl col-lg-3 col-md-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">New Projected Income</div>
|
||||
@@ -142,15 +155,23 @@ $warningText = static function (array $warnings): string {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="col-xl col-lg-3 col-md-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Unit Price</div>
|
||||
<div class="text-muted small">Grades Unit Price</div>
|
||||
<div class="fs-5 fw-semibold"><?= esc($fmtMoney($summary['unit_price'] ?? 0)) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="col-xl col-lg-3 col-md-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Youth Unit Price</div>
|
||||
<div class="fs-5 fw-semibold"><?= esc($fmtMoney($summary['youth_unit_price'] ?? 0)) ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl col-lg-3 col-md-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Difference</div>
|
||||
|
||||
@@ -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;">
|
||||
<!-- 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;">
|
||||
<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>
|
||||
<select name="school_year" class="form-select form-select-sm rounded-pill px-3" style="min-width: 180px;">
|
||||
<?php foreach (($schoolYears ?? []) as $sy): ?>
|
||||
@@ -16,7 +18,8 @@
|
||||
</form>
|
||||
<!-- Action buttons -->
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,21 +34,31 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="summaryBody">
|
||||
<tr><td>Total Charges</td><td class="text-right" id="sumCharges">$0.00</td></tr>
|
||||
<tr><td>Event Fees</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>Tuition Charges</td><td class="text-right" id="sumTuitionCharges">$0.00</td></tr>
|
||||
<tr><td>Event Fee Charges</td><td class="text-right" id="sumEventFees">$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 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 Reimbursements</td><td class="text-right" id="sumReimb">$0.00</td></tr>
|
||||
<tr><td>Donation to School (Masjid & Donation)</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><td>Donation to School (included in expenses)</td><td class="text-right" id="sumDonationToSchool">$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-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>
|
||||
</table>
|
||||
</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">
|
||||
<h3>Summary Graph</h3>
|
||||
<canvas id="summaryChart" style="max-height:300px;"></canvas>
|
||||
@@ -55,13 +68,13 @@
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-6">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,6 +92,10 @@
|
||||
function fmt(n){ return '$' + Number(n||0).toFixed(2); }
|
||||
const initialSummaryData = {
|
||||
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)) ?>,
|
||||
totalEventFees: <?= json_encode((float)($totalEventFees ?? 0)) ?>,
|
||||
totalExtraCharges: <?= json_encode((float)($totalExtraCharges ?? 0)) ?>,
|
||||
@@ -90,18 +107,47 @@ const initialSummaryData = {
|
||||
totalPaid: <?= json_encode((float)($totalPaid ?? 0)) ?>,
|
||||
amountCollected: <?= json_encode((float)($amountCollected ?? 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)) ?>
|
||||
};
|
||||
|
||||
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) {
|
||||
document.getElementById('summaryPeriod').textContent = 'Report for School Year: ' + (d.schoolYear || '');
|
||||
document.getElementById('sumCharges').textContent = fmt(d.totalCharges);
|
||||
document.getElementById('summaryPeriod').textContent = buildSummaryPeriod(d);
|
||||
document.getElementById('sumTuitionCharges').textContent = fmt(d.tuitionCharges || 0);
|
||||
if (document.getElementById('sumEventFees')) {
|
||||
document.getElementById('sumEventFees').textContent = fmt(d.totalEventFees || 0);
|
||||
}
|
||||
if (document.getElementById('sumExtraCharges')) {
|
||||
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('sumRefunds').textContent = fmt(d.totalRefunds);
|
||||
document.getElementById('sumExpenses').textContent = fmt(d.totalExpenses);
|
||||
@@ -111,14 +157,43 @@ function applySummaryData(d) {
|
||||
}
|
||||
document.getElementById('sumNet').textContent = fmt(d.netAmount);
|
||||
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('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
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
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);
|
||||
updateSummaryActionLinks();
|
||||
renderCharts(initialSummaryData);
|
||||
loadSummary();
|
||||
});
|
||||
@@ -126,9 +201,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
let __sumSeq = 0;
|
||||
function loadSummary(){
|
||||
const mySeq = ++__sumSeq;
|
||||
const sel = document.querySelector('#summaryYearFilter select[name="school_year"]');
|
||||
const params = new URLSearchParams();
|
||||
if (sel && sel.value) params.append('school_year', sel.value);
|
||||
const params = buildSummaryParams();
|
||||
const baseUrl = '<?= site_url('financial-report/financialReportSummary') ?>';
|
||||
const url = baseUrl + (params.toString() ? ('?' + params.toString() + '&format=json') : '?format=json');
|
||||
fetch(url, { headers: { 'Accept': 'application/json' }})
|
||||
@@ -148,13 +221,39 @@ function renderCharts(d){
|
||||
if (window._summaryChart) { window._summaryChart.destroy(); }
|
||||
if (window._collectedChart) { window._collectedChart.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 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, {
|
||||
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:[
|
||||
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
|
||||
], backgroundColor: ['#007bff','#6610f2','#28a745','#ffc107','#17a2b8','#ffc107','#dc3545','#6f42c1','#20c997']}] },
|
||||
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: summaryBarColors}] },
|
||||
options: { responsive:true, scales:{ y:{ beginAtZero:true }}}
|
||||
});
|
||||
|
||||
@@ -162,10 +261,10 @@ function renderCharts(d){
|
||||
window._collectedChart = new Chart(collectedCtx, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: ['Amount Collected (Paid)', 'Amount Outstanding (Unpaid)'],
|
||||
labels: ['Amount Collected (Paid)', 'Amount Unpaid (Net of Credits)', 'Overpayment Credits'],
|
||||
datasets: [{
|
||||
data: [ d.amountCollected || d.totalPaid || 0, d.totalUnpaid || 0 ],
|
||||
backgroundColor: ['#28a745', '#ffc107'],
|
||||
data: [ d.amountCollected || d.totalPaid || 0, d.netReceivable || 0, d.totalOverpaid || 0 ],
|
||||
backgroundColor: [chartColors.collected, chartColors.outstanding, chartColors.overpaymentCredits],
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
@@ -190,7 +289,7 @@ function renderCharts(d){
|
||||
labels: netExpenseLabels,
|
||||
datasets: [{
|
||||
data: netExpenseValues,
|
||||
backgroundColor: ['#20c997', '#dc3545'],
|
||||
backgroundColor: [chartColors.expenses, chartColors.netCharges],
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
@@ -202,7 +301,7 @@ function renderCharts(d){
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
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 + '%)';
|
||||
}
|
||||
|
||||
@@ -373,8 +373,94 @@
|
||||
let tempBatchCounter = -1;
|
||||
|
||||
let csrfValue = cfg.csrfHash || '';
|
||||
const csrfCookieName = <?= json_encode(config('Security')->csrfCookieName ?? (config('Security')->cookieName ?? 'csrf_cookie_name')) ?>;
|
||||
let csrfRequestQueue = Promise.resolve();
|
||||
let dragPayload = null;
|
||||
|
||||
function getCookie(name) {
|
||||
const parts = document.cookie.split(';');
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
const part = parts[i].trim();
|
||||
if (part.startsWith(`${name}=`)) {
|
||||
return decodeURIComponent(part.slice(name.length + 1));
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function syncCsrfFromCookie() {
|
||||
if (!csrfCookieName) {
|
||||
return csrfValue;
|
||||
}
|
||||
const cookieValue = getCookie(csrfCookieName);
|
||||
if (cookieValue) {
|
||||
csrfValue = cookieValue;
|
||||
}
|
||||
return csrfValue;
|
||||
}
|
||||
|
||||
function updateCsrfToken(response, data = null) {
|
||||
const headerValue = response?.headers?.get?.('X-CSRF-HASH') || '';
|
||||
if (headerValue) {
|
||||
csrfValue = headerValue;
|
||||
return;
|
||||
}
|
||||
const bodyValue = data && typeof data === 'object' ? (data.csrf_hash || data.csrfHash || '') : '';
|
||||
if (bodyValue) {
|
||||
csrfValue = bodyValue;
|
||||
return;
|
||||
}
|
||||
syncCsrfFromCookie();
|
||||
}
|
||||
|
||||
function withSerializedCsrfRequest(task) {
|
||||
const run = csrfRequestQueue
|
||||
.catch(() => undefined)
|
||||
.then(() => task());
|
||||
csrfRequestQueue = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
async function postJson(url, form, fallbackError) {
|
||||
return withSerializedCsrfRequest(async () => {
|
||||
syncCsrfFromCookie();
|
||||
if (cfg.csrfToken) {
|
||||
form.set(cfg.csrfToken, csrfValue);
|
||||
}
|
||||
|
||||
const headers = { 'X-Requested-With': 'XMLHttpRequest' };
|
||||
if (csrfValue) {
|
||||
headers['X-CSRF-TOKEN'] = csrfValue;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
body: form,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch (_) {
|
||||
data = {
|
||||
success: false,
|
||||
error: text || fallbackError || 'Unexpected response',
|
||||
};
|
||||
}
|
||||
|
||||
updateCsrfToken(response, data);
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || fallbackError || 'Request failed.');
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
function openReceiptModal(url) {
|
||||
if (!url) {
|
||||
return;
|
||||
@@ -470,23 +556,8 @@
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append('expense_id', itemId);
|
||||
if (cfg.csrfToken) {
|
||||
form.append(cfg.csrfToken, csrfValue);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(cfg.markDonationUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: form,
|
||||
});
|
||||
const data = await response.json().catch(() => ({ success: false, error: 'Unexpected response' }));
|
||||
const newHash = response.headers.get('X-CSRF-HASH') || data.csrf_hash;
|
||||
if (newHash) {
|
||||
csrfValue = newHash;
|
||||
}
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Unable to mark donation right now.');
|
||||
}
|
||||
await postJson(cfg.markDonationUrl, form, 'Unable to mark donation right now.');
|
||||
removeItemFromUI(itemId);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -737,23 +808,7 @@
|
||||
} else {
|
||||
form.append('admin_id', '');
|
||||
}
|
||||
if (cfg.csrfToken) {
|
||||
form.append(cfg.csrfToken, csrfValue);
|
||||
}
|
||||
|
||||
fetch(cfg.updateUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: form,
|
||||
}).then(async (response) => {
|
||||
const data = await response.json().catch(() => ({ success: false, error: 'Unexpected response' }));
|
||||
const newHash = response.headers.get('X-CSRF-HASH') || data.csrf_hash;
|
||||
if (newHash) {
|
||||
csrfValue = newHash;
|
||||
}
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Unable to update batch assignment.');
|
||||
}
|
||||
postJson(cfg.updateUrl, form, 'Unable to update batch assignment.').then((data) => {
|
||||
entry.batch_number = batchId;
|
||||
entry.batchId = batchId;
|
||||
entry.admin_id = (adminId || adminId === 0) ? adminId : null;
|
||||
@@ -791,25 +846,7 @@
|
||||
throw new Error('Batch creation endpoint is not configured.');
|
||||
}
|
||||
const form = new FormData();
|
||||
if (cfg.csrfToken) {
|
||||
form.append(cfg.csrfToken, csrfValue);
|
||||
}
|
||||
|
||||
const response = await fetch(cfg.createBatchUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: form,
|
||||
});
|
||||
const data = await response.json().catch(() => ({ success: false, error: 'Unexpected response when creating batch.' }));
|
||||
const newHash = response.headers.get('X-CSRF-HASH') || data.csrf_hash;
|
||||
if (newHash) {
|
||||
csrfValue = newHash;
|
||||
}
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Unable to create a new batch right now.');
|
||||
}
|
||||
|
||||
return data;
|
||||
return postJson(cfg.createBatchUrl, form, 'Unable to create a new batch right now.');
|
||||
}
|
||||
|
||||
async function uploadAdminCheckFile(batchId, adminId, input, statusEl, linkEl) {
|
||||
@@ -827,28 +864,13 @@
|
||||
form.append('batch_id', batchId);
|
||||
form.append('admin_id', adminId ?? 0);
|
||||
form.append('check_file', file);
|
||||
if (cfg.csrfToken) {
|
||||
form.append(cfg.csrfToken, csrfValue);
|
||||
}
|
||||
|
||||
input.disabled = true;
|
||||
const previous = statusEl.textContent;
|
||||
statusEl.textContent = 'Uploading...';
|
||||
|
||||
try {
|
||||
const response = await fetch(cfg.checkUploadUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: form,
|
||||
});
|
||||
const data = await response.json().catch(() => ({ success: false, error: 'Unexpected response' }));
|
||||
const newHash = response.headers.get('X-CSRF-HASH') || data.csrf_hash;
|
||||
if (newHash) {
|
||||
csrfValue = newHash;
|
||||
}
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Unable to upload check file right now.');
|
||||
}
|
||||
const data = await postJson(cfg.checkUploadUrl, form, 'Unable to upload check file right now.');
|
||||
const label = data.original_filename || 'View check';
|
||||
statusEl.textContent = `Uploaded: ${data.original_filename || 'file'}`;
|
||||
if (linkEl && data.url) {
|
||||
@@ -979,34 +1001,19 @@
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append('batch_id', batchId);
|
||||
if (cfg.csrfToken) {
|
||||
form.append(cfg.csrfToken, csrfValue);
|
||||
}
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.textContent = 'Locking...';
|
||||
}
|
||||
try {
|
||||
const response = await fetch(cfg.lockBatchUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
body: form,
|
||||
});
|
||||
const data = await response.json().catch(() => ({ success: false, error: 'Unexpected response' }));
|
||||
const newHash = response.headers.get('X-CSRF-HASH') || data.csrf_hash;
|
||||
if (newHash) {
|
||||
csrfValue = newHash;
|
||||
}
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || 'Unable to lock batch at the moment.');
|
||||
}
|
||||
await postJson(cfg.lockBatchUrl, form, 'Unable to lock batch at the moment.');
|
||||
markBatchCardLocked(card);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert(err.message || 'Unable to lock the batch. Please try again.');
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.textContent = 'Lock Batch';
|
||||
button.textContent = 'Submit Batch';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"advisories": {
|
||||
"phpoffice/math": [
|
||||
{
|
||||
"advisoryId": "PKSA-jw72-bn8m-h7rc",
|
||||
"packageName": "phpoffice/math",
|
||||
"affectedVersions": "<=0.2.0",
|
||||
"title": "PHPOffice Math allows XXE when processing an XML file in the MathML format ",
|
||||
"cve": "CVE-2025-48882",
|
||||
"link": "https://github.com/advisories/GHSA-42hm-pq2f-3r7m",
|
||||
"reportedAt": "2025-05-29T17:27:39+00:00",
|
||||
"sources": [
|
||||
{
|
||||
"name": "GitHub",
|
||||
"remoteId": "GHSA-42hm-pq2f-3r7m"
|
||||
}
|
||||
],
|
||||
"severity": "high"
|
||||
}
|
||||
],
|
||||
"phpunit/phpunit": [
|
||||
{
|
||||
"advisoryId": "PKSA-z3gr-8qht-p93v",
|
||||
"packageName": "phpunit/phpunit",
|
||||
"affectedVersions": ">=0,<8.5.52|>=9.0.0,<9.6.33|>=10.0.0,<10.5.62|>=11.0.0,<11.5.50|>=12.0.0,<12.5.8",
|
||||
"title": "Unsafe Deserialization in PHPT Code Coverage Handling",
|
||||
"cve": "CVE-2026-24765",
|
||||
"link": "https://github.com/sebastianbergmann/phpunit/security/advisories/GHSA-vvj3-c3rp-c85p",
|
||||
"reportedAt": "2026-01-27T05:21:14+00:00",
|
||||
"sources": [
|
||||
{
|
||||
"name": "GitHub",
|
||||
"remoteId": "GHSA-vvj3-c3rp-c85p"
|
||||
},
|
||||
{
|
||||
"name": "FriendsOfPHP/security-advisories",
|
||||
"remoteId": "phpunit/phpunit/CVE-2026-24765.yaml"
|
||||
}
|
||||
],
|
||||
"severity": "high"
|
||||
}
|
||||
]
|
||||
},
|
||||
"abandoned": {
|
||||
"paypal/rest-api-sdk-php": "paypal/paypal-server-sdk"
|
||||
},
|
||||
"filter": []
|
||||
}
|
||||
@@ -65,6 +65,7 @@ class FakeForecastService extends TuitionForecastService
|
||||
'include_event_only' => $options['include_event_only'] ?? '0',
|
||||
'include_paid_invoices' => $options['include_paid_invoices'] ?? '0',
|
||||
'unit_price' => $options['unit_price'] ?? '370.00',
|
||||
'youth_unit_price' => $options['youth_unit_price'] ?? '200.00',
|
||||
],
|
||||
'summary' => [
|
||||
'family_count' => 1,
|
||||
@@ -75,6 +76,7 @@ class FakeForecastService extends TuitionForecastService
|
||||
'old_projected_income' => '550.00',
|
||||
'new_projected_income' => '650.00',
|
||||
'unit_price' => '370.00',
|
||||
'youth_unit_price' => '200.00',
|
||||
'difference' => '100.00',
|
||||
],
|
||||
'families' => [[
|
||||
@@ -160,6 +162,7 @@ class TuitionForecastControllerTest extends CIUnitTestCase
|
||||
'include_event_only' => '1',
|
||||
'include_paid_invoices' => '1',
|
||||
'unit_price' => '395.00',
|
||||
'youth_unit_price' => '200.00',
|
||||
]);
|
||||
|
||||
$result = $this->controller->setRequestObject($request)->index();
|
||||
@@ -170,6 +173,7 @@ class TuitionForecastControllerTest extends CIUnitTestCase
|
||||
$this->assertSame('new', $result['data']['filters']['calculator_mode']);
|
||||
$this->assertSame('1', $result['data']['filters']['include_payment_pending']);
|
||||
$this->assertSame('395.00', $result['data']['filters']['unit_price']);
|
||||
$this->assertSame('200.00', $result['data']['filters']['youth_unit_price']);
|
||||
$this->assertSame('always', $this->service->calls[0]['options']['include_withdrawn_mode']);
|
||||
}
|
||||
|
||||
@@ -184,6 +188,7 @@ class TuitionForecastControllerTest extends CIUnitTestCase
|
||||
'include_event_only' => '0',
|
||||
'include_paid_invoices' => '1',
|
||||
'unit_price' => '390.00',
|
||||
'youth_unit_price' => '210.00',
|
||||
]);
|
||||
|
||||
$response = $this->controller->setRequestObject($request)->calculate();
|
||||
@@ -193,6 +198,7 @@ class TuitionForecastControllerTest extends CIUnitTestCase
|
||||
$this->assertSame('Fall', $payload['semester']);
|
||||
$this->assertSame('compare', $payload['calculator_mode']);
|
||||
$this->assertSame('390.00', $payload['options']['unit_price']);
|
||||
$this->assertSame('210.00', $payload['options']['youth_unit_price']);
|
||||
}
|
||||
|
||||
public function testExportCsvBuildsDownloadWithSummaryAndFamilyRows(): void
|
||||
@@ -210,6 +216,7 @@ class TuitionForecastControllerTest extends CIUnitTestCase
|
||||
$this->assertStringContainsString('Summary', $body);
|
||||
$this->assertStringContainsString('All Year', $body);
|
||||
$this->assertStringContainsString('Old Projected Income', $body);
|
||||
$this->assertStringContainsString('Youth Unit Price', $body);
|
||||
$this->assertStringContainsString('Layla Yusuf', $body);
|
||||
$this->assertStringContainsString('Student One', $body);
|
||||
}
|
||||
|
||||
@@ -51,4 +51,29 @@ class NewTuitionCalculatorServiceTest extends CIUnitTestCase
|
||||
$this->assertSame('0.00', $result['details'][3]['amount']);
|
||||
$this->assertSame('0.00', $result['details'][4]['amount']);
|
||||
}
|
||||
|
||||
public function testYouthStudentsUseSeparateUnitPriceWithoutAffectingRegularDiscountOrder(): void
|
||||
{
|
||||
$service = new NewTuitionCalculatorService();
|
||||
$result = $service->calculateFamilyTuition([
|
||||
['student_id' => 3, 'student_name' => 'Youth', 'grade_level' => 'Youth 1'],
|
||||
['student_id' => 1, 'student_name' => 'Regular One', 'grade_level' => '2'],
|
||||
['student_id' => 2, 'student_name' => 'Regular Two', 'grade_level' => '5'],
|
||||
], [
|
||||
'grade_fee' => 9,
|
||||
'new_tuition_full_amount' => '370.00',
|
||||
'new_tuition_youth_amount' => '200.00',
|
||||
'new_tuition_second_student_discount' => '150.00',
|
||||
'new_tuition_third_student_discount' => '150.00',
|
||||
'new_tuition_fourth_plus_discount' => '150.00',
|
||||
]);
|
||||
|
||||
$this->assertSame('790.00', $result['total']);
|
||||
$this->assertSame('new_first_student_full_amount', $result['details'][0]['rule']);
|
||||
$this->assertSame('370.00', $result['details'][0]['amount']);
|
||||
$this->assertSame('new_second_student_discount', $result['details'][1]['rule']);
|
||||
$this->assertSame('220.00', $result['details'][1]['amount']);
|
||||
$this->assertSame('new_youth_unit_price', $result['details'][2]['rule']);
|
||||
$this->assertSame('200.00', $result['details'][2]['amount']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,10 +38,12 @@ class TuitionForecastServiceTest extends CIUnitTestCase
|
||||
'students' => [
|
||||
['student_id' => 1, 'student_name' => 'Mariam', 'grade_level' => '1'],
|
||||
['student_id' => 2, 'student_name' => 'Yahya', 'grade_level' => '2'],
|
||||
['student_id' => 4, 'student_name' => 'Youth Student', 'grade_level' => 'Youth 1'],
|
||||
],
|
||||
'all_students' => [
|
||||
['student_id' => 1, 'student_name' => 'Mariam', 'grade_level' => '1', 'billable' => true, 'excluded_reason' => null],
|
||||
['student_id' => 2, 'student_name' => 'Yahya', 'grade_level' => '2', 'billable' => true, 'excluded_reason' => null],
|
||||
['student_id' => 4, 'student_name' => 'Youth Student', 'grade_level' => 'Youth 1', 'billable' => true, 'excluded_reason' => null],
|
||||
['student_id' => 3, 'student_name' => 'Event Only', 'grade_level' => '3', 'billable' => false, 'excluded_reason' => 'event_only'],
|
||||
],
|
||||
'warnings' => ['1 event-only student(s) excluded from tuition.'],
|
||||
@@ -54,8 +56,9 @@ class TuitionForecastServiceTest extends CIUnitTestCase
|
||||
'grade_fee' => 9,
|
||||
'first_student_fee' => '350.00',
|
||||
'second_student_fee' => '200.00',
|
||||
'youth_fee' => '180.00',
|
||||
'youth_fee' => '200.00',
|
||||
'new_tuition_full_amount' => $this->unitPriceOverride ?? '350.00',
|
||||
'new_tuition_youth_amount' => $this->youthUnitPriceOverride ?? '200.00',
|
||||
'new_tuition_second_student_discount' => '50.00',
|
||||
'new_tuition_third_student_discount' => '50.00',
|
||||
'new_tuition_fourth_plus_discount' => '100.00',
|
||||
@@ -67,16 +70,18 @@ class TuitionForecastServiceTest extends CIUnitTestCase
|
||||
'include_payment_pending' => true,
|
||||
'include_withdrawn_mode' => 'refund_deadline',
|
||||
'unit_price' => '400.00',
|
||||
'youth_unit_price' => '200.00',
|
||||
]);
|
||||
|
||||
$this->assertSame(1, $result['summary']['family_count']);
|
||||
$this->assertSame(3, $result['summary']['student_count']);
|
||||
$this->assertSame(2, $result['summary']['billable_student_count']);
|
||||
$this->assertSame('550.00', $result['summary']['old_projected_tuition']);
|
||||
$this->assertSame('750.00', $result['summary']['new_projected_tuition']);
|
||||
$this->assertSame('550.00', $result['summary']['old_projected_income']);
|
||||
$this->assertSame('750.00', $result['summary']['new_projected_income']);
|
||||
$this->assertSame(4, $result['summary']['student_count']);
|
||||
$this->assertSame(3, $result['summary']['billable_student_count']);
|
||||
$this->assertSame('750.00', $result['summary']['old_projected_tuition']);
|
||||
$this->assertSame('950.00', $result['summary']['new_projected_tuition']);
|
||||
$this->assertSame('750.00', $result['summary']['old_projected_income']);
|
||||
$this->assertSame('950.00', $result['summary']['new_projected_income']);
|
||||
$this->assertSame('400.00', $result['summary']['unit_price']);
|
||||
$this->assertSame('event_only', $result['families'][0]['student_details'][2]['excluded_reason']);
|
||||
$this->assertSame('200.00', $result['summary']['youth_unit_price']);
|
||||
$this->assertSame('event_only', $result['families'][0]['student_details'][3]['excluded_reason']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user