Compare commits

...

7 Commits

Author SHA1 Message Date
root ec9fca8c45 fix db tables to have school year 2026-07-12 01:02:04 -04:00
root ed11cccecc fix payments 2026-07-08 23:30:16 -04:00
root 6e8da3cc2c fix batch issue 2026-06-08 23:20:06 -04:00
root 384ae8b719 fix the forecast tuition 2026-06-07 00:27:27 -04:00
root 654453222f financial fix 2026-06-06 23:30:41 -04:00
root a18e8b92a6 fix batch 2026-06-04 23:09:58 -04:00
root 6444b61416 fix financial issues 2026-06-01 02:08:27 -04:00
111 changed files with 7628 additions and 2360 deletions
BIN
View File
Binary file not shown.
+171
View File
@@ -0,0 +1,171 @@
<?php
namespace App\Commands;
use App\Libraries\InvoiceLedgerService;
use App\Models\InvoiceModel;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
class RecalculateInvoices extends BaseCommand
{
protected $group = 'Financial';
protected $name = 'financial:recalculate-invoices';
protected $description = 'Audits invoice totals against the centralized ledger and optionally writes the corrected values.';
protected $usage = 'financial:recalculate-invoices [--commit] [--invoice-id=123] [--parent-id=456] [--school-year=2025-2026] [--semester=Fall]';
protected $options = [
'--commit' => 'Persist recalculated totals. Without this flag the command runs in dry-run mode.',
'--invoice-id' => 'Only process a single invoice ID.',
'--parent-id' => 'Only process invoices for one parent.',
'--school-year' => 'Only process invoices for the specified school year.',
'--semester' => 'Only process invoices for the specified semester.',
];
public function run(array $params)
{
$options = $this->parseOptions($params);
$invoiceModel = new InvoiceModel();
$ledgerService = new InvoiceLedgerService();
$invoiceIds = $this->loadInvoiceIds($invoiceModel, $options);
if ($invoiceIds === []) {
CLI::write('No invoices matched the provided filters.', 'yellow');
return;
}
$commit = !empty($options['commit']);
CLI::write(($commit ? 'Applying' : 'Dry-run auditing') . ' ' . count($invoiceIds) . ' invoice(s)...', 'yellow');
$changed = 0;
$unchanged = 0;
$errors = 0;
foreach ($invoiceIds as $invoiceId) {
$invoice = $invoiceModel->find($invoiceId);
if (!$invoice) {
continue;
}
try {
$calculation = $ledgerService->calculateInvoice($invoiceId);
$diffs = $this->diffInvoice($invoice, $calculation);
if ($diffs === []) {
$unchanged++;
CLI::write('Invoice #' . $invoiceId . ' unchanged.', 'green');
continue;
}
$changed++;
CLI::write('Invoice #' . $invoiceId . ' requires recalculation:', 'light_yellow');
foreach ($diffs as $label => $values) {
CLI::write(sprintf(
' %s: %s -> %s',
$label,
(string) $values['from'],
(string) $values['to']
));
}
if ($commit) {
$ledgerService->recalculateInvoice($invoiceId);
CLI::write(' saved', 'blue');
}
} catch (\Throwable $e) {
$errors++;
CLI::error('Invoice #' . $invoiceId . ' failed: ' . $e->getMessage());
}
}
CLI::newLine();
CLI::write('Processed: ' . count($invoiceIds), 'white');
CLI::write('Changed: ' . $changed, $changed > 0 ? 'yellow' : 'white');
CLI::write('Unchanged: ' . $unchanged, 'green');
CLI::write('Errors: ' . $errors, $errors > 0 ? 'red' : 'white');
if (!$commit) {
CLI::write('Dry-run complete. Re-run with --commit to persist corrections.', 'light_blue');
}
}
protected function parseOptions(array $params): array
{
$options = [
'commit' => false,
'invoice-id' => null,
'parent-id' => null,
'school-year' => null,
'semester' => null,
];
foreach ($params as $param) {
$value = trim((string) $param);
if ($value === '--commit') {
$options['commit'] = true;
continue;
}
if (!str_starts_with($value, '--') || !str_contains($value, '=')) {
continue;
}
[$key, $raw] = explode('=', substr($value, 2), 2);
if (array_key_exists($key, $options)) {
$options[$key] = $raw;
}
}
return $options;
}
protected function loadInvoiceIds(InvoiceModel $invoiceModel, array $options): array
{
$builder = $invoiceModel->select('id')->orderBy('id', 'ASC');
if (!empty($options['invoice-id'])) {
$builder->where('id', (int) $options['invoice-id']);
}
if (!empty($options['parent-id'])) {
$builder->where('parent_id', (int) $options['parent-id']);
}
if (!empty($options['school-year'])) {
$builder->where('school_year', (string) $options['school-year']);
}
if (!empty($options['semester'])) {
$builder->where('semester', (string) $options['semester']);
}
return array_map(
static fn (array $row): int => (int) ($row['id'] ?? 0),
$builder->findAll()
);
}
protected function diffInvoice(array $invoice, array $calculation): array
{
$fields = [
'total_amount' => 'total_amount',
'paid_amount' => 'paid_amount',
'balance' => 'balance',
'status' => 'status',
'has_discount' => 'has_discount',
];
$diffs = [];
foreach ($fields as $invoiceKey => $calcKey) {
$current = (string) ($invoice[$invoiceKey] ?? '');
$recalculated = (string) ($calculation[$calcKey] ?? '');
if ($current !== $recalculated) {
$diffs[$invoiceKey] = [
'from' => $current,
'to' => $recalculated,
];
}
}
return $diffs;
}
}
-232
View File
@@ -1,232 +0,0 @@
<?php
namespace App\Commands;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use App\Models\PayPalPaymentModel;
use App\Models\PaymentModel;
use App\Models\UserModel;
use App\Models\ConfigurationModel;
use App\Models\InvoiceModel;
use App\Models\StudentModel;
use App\Models\EnrollmentModel;
class SyncPaypalPayments extends BaseCommand
{
protected $group = 'Payments';
protected $name = 'payments:sync-paypal';
protected $description = 'Sync PayPal payments to internal payments table from paypal_payments';
protected $configModel;
protected $semester;
protected $schoolYear;
protected $paypalModel;
protected $paymentModel;
protected $userModel;
protected $invoiceModel;
protected $studentModel;
protected $enrollmentModel;
public function __construct()
{
$this->configModel = new ConfigurationModel();
$this->paypalModel = new PayPalPaymentModel();
$this->paymentModel = new PaymentModel();
$this->userModel = new UserModel();
$this->invoiceModel = new InvoiceModel();
$this->studentModel = new StudentModel();
$this->enrollmentModel = new EnrollmentModel();
}
public function run(array $params)
{
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
$dryRun = CLI::getOption('dry-run');
$reportOnly = CLI::getOption('report-only');
$mode = $reportOnly ? 'REPORT-ONLY' : ($dryRun ? 'DRY-RUN' : 'LIVE');
$paypalEntries = $this->paypalModel
->where('status', 'COMPLETED')
->where('synced', 0)
->where('sync_attempts <', 3)
->where('transaction_id IS NOT NULL')
->findAll();
$syncedCount = 0;
$failed = [];
foreach ($paypalEntries as $entry) {
$parentId = null;
$invoiceId = 0;
$users = $this->userModel->getUsersBySchoolId($entry['parent_school_id']);
$user = $users[0] ?? null;
// Always increment sync_attempts unless report-only
if (!$reportOnly) {
$this->paypalModel->update($entry['id'], [
'sync_attempts' => $entry['sync_attempts'] + 1
]);
}
if ($user) {
$parentId = $user['id'];
$invoice = $this->invoiceModel->getInvoicesByParentId($parentId, $this->schoolYear);
if (!$reportOnly && !$dryRun) {
if ($invoice) {
$invoiceId = $invoice['id'];
$success = $this->processPayment(
$invoiceId,
$entry['amount'],
'PayPal',
null,
$entry['transaction_id'],
date('Y-m-d', strtotime($entry['created_at'])),
$this->schoolYear,
$this->semester
);
if (!$success) {
$failed[] = $entry['transaction_id'];
continue;
}
} else {
$this->paymentModel->insert([
'parent_id' => $parentId,
'invoice_id' => 0,
'total_amount' => $entry['amount'],
'paid_amount' => $entry['amount'],
'balance' => 0.00,
'number_of_installments' => 1,
'transaction_id' => $entry['transaction_id'],
'payment_method' => 'PayPal',
'payment_date' => date('Y-m-d', strtotime($entry['created_at'])),
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'status' => 'Completed',
'updated_by' => null,
]);
}
// Mark as synced only in LIVE mode
$this->paypalModel->update($entry['id'], ['synced' => 1]);
}
$syncedCount++;
} else {
log_message('error', "[PAYPAL SYNC FAILED] No user found for parent_school_id: {$entry['parent_school_id']}");
$failed[] = $entry['transaction_id'];
}
}
// === Logging ===
log_message('info', "[$mode] PAYPAL SYNC: $syncedCount processed.");
if (!empty($failed)) {
log_message('error', "[$mode] PAYPAL SYNC Failed: " . implode(', ', $failed));
}
// === CLI Output ===
CLI::write("[$mode] $syncedCount PayPal payments processed.", 'green');
if (!empty($failed)) {
CLI::error("[$mode] Failed transactions: " . implode(', ', $failed));
}
// === Email Report: Only if there's any update ===
if ($syncedCount > 0 || !empty($failed)) {
helper('email');
$email = \Config\Services::email();
$email->setTo('support@alrahmaisgl.org');
$email->setFrom('no-parentsreply@alrahmaisgl.org', 'PayPal Sync Report');
$email->setSubject("[$mode] PayPal Sync Report - " . date('Y-m-d H:i'));
$body = "PayPal Sync Mode: $mode\n\n";
$body .= "$syncedCount PayPal payments processed.\n\n";
if (!empty($failed)) {
$body .= count($failed) . " failed transactions:\n";
$body .= implode("\n", $failed);
} else {
$body .= "No failed transactions.\n";
}
$email->setMessage(nl2br($body));
if ($email->send()) {
CLI::write("[$mode] Email report sent successfully.", 'yellow');
} else {
CLI::error("[$mode] Failed to send email report.");
log_message('error', 'Email send error: ' . $email->printDebugger(['headers']));
}
} else {
log_message('info', "[$mode] No PayPal sync updates. Email not sent.");
CLI::write("[$mode] No changes to report. Email not sent.", 'blue');
}
}
private function processPayment($invoiceId, $amount, $paymentMethod, $checkFile = null, $transactionId = null, $paymentDate = null, $schoolYear = null, $semester = null)
{
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) {
return false;
}
$transactionId = $transactionId ?? 'INV-' . $invoiceId . '-' . time();
$paymentDate = $paymentDate ?? date('Y-m-d');
$newPaid = $invoice['paid_amount'] + $amount;
$newBalance = $invoice['balance'] - $amount;
$invoiceUpdateData = [
'paid_amount' => $newPaid,
'balance' => $newBalance,
'status' => ($newBalance <= 0) ? 'Paid' : $invoice['status'],
];
if (!$this->invoiceModel->update($invoiceId, $invoiceUpdateData)) {
return false;
}
$this->paymentModel->insert([
'parent_id' => $invoice['parent_id'],
'invoice_id' => $invoiceId,
'total_amount' => $invoice['total_amount'],
'paid_amount' => $amount,
'balance' => $newBalance,
'number_of_installments' => 1,
'transaction_id' => $transactionId,
'payment_method' => $paymentMethod,
'payment_date' => $paymentDate,
'status' => ($newBalance <= 0) ? 'Full' : 'Partial',
'check_file' => $checkFile,
'updated_by' => null, // Avoid using session()->get() in CLI
'school_year' => $schoolYear,
'semester' => $semester
]);
$this->updateEnrollmentStatusIfPaid($invoiceId, $schoolYear);
return true;
}
private function updateEnrollmentStatusIfPaid($invoiceId, $schoolYear)
{
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice || $invoice['balance'] > 0) {
return;
}
$students = $this->studentModel->where('parent_id', $invoice['parent_id'])
->where('school_year', $schoolYear)
->findAll();
foreach ($students as $student) {
$this->enrollmentModel->set(['enrollment_status' => 'enrolled'])
->where('student_id', $student['id'])
->update();
}
}
}
+2 -2
View File
@@ -16,11 +16,11 @@ class Commands extends BaseService
\App\Commands\CleanupPasswordResets::class,
\App\Commands\ConfigUpdate::class,
\App\Commands\DeleteInactiveUsers::class,
\App\Commands\RecalculateInvoices::class,
\App\Commands\SendAbsenteesSummary::class,
\App\Commands\SendLatesSummary::class,
\App\Commands\SendMonthlyPaymentNotifications::class,
\App\Commands\SendTestPaymentNotification::class,
\App\Commands\SyncPaypalPayments::class,
\App\Commands\RecalculateAttendance::class,
];
@@ -30,4 +30,4 @@ class Commands extends BaseService
}
}
}
}
}
-4
View File
@@ -47,10 +47,6 @@ class Filters extends BaseConfig
'sanitizeinput',
'invalidchars',
'csrf' => ['except' => [
// Webhooks / integrations
'api/paypal-webhook',
'index.php/api/paypal-webhook',
// WhatsApp membership management (legacy allowances retained)
'whatsapp/update-membership',
'index.php/whatsapp/update-membership',
-13
View File
@@ -1,13 +0,0 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class PaypalConfig extends BaseConfig
{
// PayPal API Credentials
public $paypalClientId = 'YOUR_PAYPAL_CLIENT_ID';
public $paypalSecret = 'YOUR_PAYPAL_SECRET';
public $paypalMode = 'sandbox'; // Change to 'live' when moving to production
}
+59 -77
View File
@@ -257,15 +257,17 @@ $routes->post('administrator/sections/auto-distribute', 'View\StudentController:
$routes->get('administrator/sections/promotion-totals', 'View\StudentController::promotionTotalsApi');
$routes->get('refunds/list', 'View\RefundController::listRefunds');
$routes->post('refunds/request', 'View\RefundController::requestRefund');
$routes->post('refunds/approve/(:num)', 'View\RefundController::approveRefund/$1');
$routes->post('refunds/pay/(:num)', 'View\RefundController::payRefund/$1');
$routes->post('refunds/updateStatus/(:num)', 'View\RefundController::updateStatus/$1');
$routes->post('refunds/processRefunds', 'View\RefundController::processRefunds');
$routes->post('refunds/updateStatus', 'View\RefundController::updateStatus');
$routes->post('refunds/updatePayment', 'View\RefundController::updatePayment');
$routes->post('refunds/recalculateOverpayments', 'View\RefundController::recalculateOverpayments');
$routes->get('refunds/list', 'View\RefundController::listRefunds', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']);
$routes->post('refunds/request', 'View\RefundController::requestRefund', ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
$routes->post('refunds/approve/(:num)', 'View\RefundController::approveRefund/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('refunds/pay/(:num)', 'View\RefundController::payRefund/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('refunds/updateStatus/(:num)', 'View\RefundController::updateStatus/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('refunds/processRefunds', 'View\RefundController::processRefunds', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('refunds/updateStatus', 'View\RefundController::updateStatus', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('refunds/updatePayment', 'View\RefundController::updatePayment', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('refunds/recalculateOverpayments', 'View\RefundController::recalculateOverpayments', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->get('refunds/file/(:num)', 'View\RefundController::serveRefundFile/$1', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']);
$routes->get('refunds/file/(:num)/(:segment)', 'View\RefundController::serveRefundFile/$1/$2', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']);
@@ -586,19 +588,19 @@ $routes->post('invoices/updateStatus/(:num)', 'View\InvoiceController::updateSta
// app/Config/Routes.php
$routes->group('payment', ['filter' => 'auth'], static function ($routes) {
// Read
$routes->get('manual_pay', 'View\PaymentController::manualPaySearch', ['filter' => 'auth:read']);
$routes->get('manual_pay_suggest', 'View\PaymentController::manualPaySuggest', ['filter' => 'auth:read']);
$routes->get('manual_pay', 'View\PaymentController::manualPaySearch', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
$routes->get('manual_pay_suggest', 'View\PaymentController::manualPaySuggest', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
// Create
$routes->post('manual_pay', 'View\PaymentController::manualPayUpdate', ['filter' => 'auth:create']);
$routes->post('manual_pay', 'View\PaymentController::manualPayUpdate', ['filter' => 'auth:create_payment|create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
// Update
$routes->post('manual_pay_edit', 'View\PaymentController::manualPayEdit', ['filter' => 'auth:update']);
$routes->post('manual_pay_update', 'View\PaymentController::manualPayUpdate', ['filter' => 'auth:update']);
$routes->post('manual_pay_edit', 'View\PaymentController::manualPayEdit', ['filter' => 'auth:update_payment|update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('manual_pay_update', 'View\PaymentController::manualPayUpdate', ['filter' => 'auth:create_payment|create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
// Read (serve files)
$routes->get('serveCheckFile/(:any)/(:any)', 'View\PaymentController::serveCheckFile/$1/$2', ['filter' => 'auth:read']);
$routes->get('serveCheckFile/(:any)', 'View\PaymentController::serveCheckFile/$1', ['filter' => 'auth:read']);
$routes->get('file/(:num)/(:segment)', 'View\PaymentController::servePaymentFile/$1/$2', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
$routes->get('file/(:num)', 'View\PaymentController::servePaymentFile/$1', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
});
@@ -641,9 +643,14 @@ $routes->group('admin', ['filter' => 'auth'], static function ($routes) {
$routes->post('admin/broadcast-email/upload-image', 'View\BroadcastEmailController::uploadImage');
$routes->get('payment/financial_report', 'View\FinancialController::financialReport');
$routes->get('financial-report/financialReportSummary', 'View\FinancialController::financialReportSummary');
$routes->get('payment/download_csv', 'View\FinancialController::downloadCsv');
$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']);
$routes->get('administrator/tuition-forecast/export', 'View\TuitionForecastController::exportCsv', ['filter' => 'auth:view_financial_reports|view_invoice|administrator|administrative staff|principal']);
$routes->get('reports/downloadFinancialReport', 'View\FinancialController::downloadFinancialReport');
// Financial APIs (JSON)
$routes->get('api/financial/report', 'View\FinancialController::financialReportData', ['filter' => 'auth']);
@@ -654,27 +661,27 @@ $routes->get('payment/unpaid-parents', 'View\FinancialController::unpaidParents'
$routes->get('api/financial/unpaid-parents', 'View\FinancialController::unpaidParents', ['filter' => 'auth:view_invoice']);
$routes->get('expenses/index', 'View\ExpenseController::index');
$routes->get('expenses/create', 'View\ExpenseController::create');
$routes->post('expenses/store', 'View\ExpenseController::store');
$routes->post('expenses/updateStatus', 'View\ExpenseController::updateStatus');
$routes->get('expenses/index', 'View\ExpenseController::index', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->get('expenses/create', 'View\ExpenseController::create', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->post('expenses/store', 'View\ExpenseController::store', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,create']);
$routes->post('expenses/updateStatus', 'View\ExpenseController::updateStatus', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
$routes->get('reimbursements/index', 'View\ReimbursementController::index');
$routes->get('reimbursements/create', 'View\ReimbursementController::create');
$routes->post('reimbursements/store', 'View\ReimbursementController::store');
$routes->get('reimbursements/under-processing', 'View\ReimbursementController::underProcessing');
$routes->post('reimbursements/mark-donation', 'View\ReimbursementController::markDonation');
$routes->post('reimbursements/batch/create', 'View\ReimbursementController::createBatch');
$routes->post('reimbursements/batch/update', 'View\ReimbursementController::updateBatchAssignment');
$routes->post('reimbursements/batch/lock', 'View\ReimbursementController::lockBatch');
$routes->post('reimbursements/batch/admin-file/upload', 'View\ReimbursementController::uploadBatchAdminFile');
$routes->get('reimbursements/batch/admin-file/(:segment)', 'View\ReimbursementController::serveAdminCheckFile/$1');
$routes->get('reimbursements/batch/admin-file/(:segment)/(:segment)', 'View\ReimbursementController::serveAdminCheckFile/$1/$2');
$routes->post('reimbursements/batch/send', 'View\ReimbursementController::sendBatchEmail');
$routes->get('reimbursements/batch/export', 'View\ReimbursementController::exportBatch');
$routes->post('reimbursements/process', 'View\ReimbursementController::process');
$routes->get('reimbursements/export', 'View\ReimbursementController::export');
$routes->get('reimbursements', 'View\ReimbursementController::index');
$routes->get('reimbursements/index', 'View\ReimbursementController::index', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->get('reimbursements/create', 'View\ReimbursementController::create', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->post('reimbursements/store', 'View\ReimbursementController::store', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,create']);
$routes->get('reimbursements/under-processing', 'View\ReimbursementController::underProcessing', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->post('reimbursements/mark-donation', 'View\ReimbursementController::markDonation', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('reimbursements/batch/create', 'View\ReimbursementController::createBatch', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,create']);
$routes->post('reimbursements/batch/update', 'View\ReimbursementController::updateBatchAssignment', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('reimbursements/batch/lock', 'View\ReimbursementController::lockBatch', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('reimbursements/batch/admin-file/upload', 'View\ReimbursementController::uploadBatchAdminFile', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
$routes->get('reimbursements/batch/admin-file/(:segment)', 'View\ReimbursementController::serveAdminCheckFile/$1', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->get('reimbursements/batch/admin-file/(:segment)/(:segment)', 'View\ReimbursementController::serveAdminCheckFile/$1/$2', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->post('reimbursements/batch/send', 'View\ReimbursementController::sendBatchEmail', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
$routes->get('reimbursements/batch/export', 'View\ReimbursementController::exportBatch', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->post('reimbursements/process', 'View\ReimbursementController::process', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
$routes->get('reimbursements/export', 'View\ReimbursementController::export', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->get('reimbursements', 'View\ReimbursementController::index', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
// Health check (upload dirs + DB timezone columns)
$routes->get('admin/health', 'View\HealthController::index');
@@ -736,19 +743,9 @@ $routes->get('payments/getByParent/(:num)', 'View\PaymentController::getByParent
$routes->get('payments/create', 'View\PaymentController::create');
$routes->post('payments/updateBalance/(:num)', 'View\PaymentController::updateBalance/$1');
// Web View Routes for Payment Transactions
$routes->get('payment_transactions/getByPayment/(:num)', 'View\PaymentTransactionController::getByPayment/$1');
$routes->get('payment_transactions/create', 'View\PaymentTransactionController::create');
$routes->post('payment_transactions/updateStatus/(:num)', 'View\PaymentTransactionController::updateStatus/$1');
// Routes for PayPal integration
$routes->get('payments/createPaypalPayment/(:num)', 'View\PaymentController::createPaypalPayment/$1');
$routes->get('payments/executePaypalPayment', 'View\PaymentController::executePaypalPayment');
$routes->get('payments/cancelPaypalPayment', 'View\PaymentController::cancelPaypalPayment');
// Routes for payment pages
$routes->get('payments/view/(:num)', 'View\PaymentController::viewPayment/$1');
$routes->post('payments/createPaypalPayment/(:num)', 'View\PaymentController::createPaypalPayment/$1');
$routes->get('payment/get_enrolled_students/(:num)', 'View\PaymentController::getEnrolledStudents/$1');
$routes->get('payment/get_enrolled_students/(:num)', 'View\PaymentController::getEnrolledStudents/$1', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
// Payment Notification Management
$routes->get('payment/notification_management', 'View\PaymentNotificationController::index', ['filter' => 'auth:view_invoice']);
@@ -968,15 +965,6 @@ $routes->group('inventory', ['filter' => 'csrf'], static function ($routes) {
$routes->get('admin/enrollment/new-students', 'View\AdministratorController::showNewStudents', ['filter' => 'auth:view_new_students']);
//Paypal transactions
$routes->post('api/paypal-webhook', 'Api\PaypalWebhook::handle');
$routes->get('administrator/paypal_transactions', 'View\PaypalTransactionsController::index');
$routes->get('administrator/paypal_transactions/export', 'View\PaypalTransactionsController::exportCsv');
$routes->get('admin/paypal-transactions', 'View\PaypalTransactionsController::index');
$routes->get('admin/paypal-transactions/export', 'View\PaypalTransactionsController::exportCsv');
//Emergency Contact
$routes->get('administrator/emergency_contact', 'View\EmergencyContactController::index');
$routes->get('administrator/emergency_contact/edit/(:num)', 'View\EmergencyContactController::edit/$1');
@@ -1055,16 +1043,16 @@ $routes->get('family', 'View\FamilyAdminController::index');
//////////////////////////////////////////////////////////
//upload files
$routes->get('receipts/(:any)', 'View\FilesController::receipt/$1');
$routes->get('reimbreceipts/(:any)', 'View\FilesController::reimb/$1'); // serves from writable/uploads/reimbursements
$routes->get('receipts/(:any)', 'View\FilesController::receipt/$1', ['filter' => 'auth']);
$routes->get('reimbreceipts/(:any)', 'View\FilesController::reimb/$1', ['filter' => 'auth']); // serves from writable/uploads/reimbursements
$routes->get('early-dismissal-signatures/(:any)', 'View\FilesController::earlyDismissalSignature/$1');
// Expenses
$routes->get('expenses/edit/(:num)', 'View\ExpenseController::edit/$1');
$routes->post('expenses/update/(:num)', 'View\ExpenseController::update/$1');
$routes->get('expenses/edit/(:num)', 'View\ExpenseController::edit/$1', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->post('expenses/update/(:num)', 'View\ExpenseController::update/$1', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
// Reimbursements
$routes->get('reimbursements/edit/(:num)', 'View\ReimbursementController::edit/$1');
$routes->post('reimbursements/update/(:num)', 'View\ReimbursementController::update/$1');
$routes->get('reimbursements/edit/(:num)', 'View\ReimbursementController::edit/$1', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->post('reimbursements/update/(:num)', 'View\ReimbursementController::update/$1', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
@@ -1209,16 +1197,15 @@ $routes->get('/terms_of_service', 'View\PageController::termsOfService');
$routes->get('/help_center', 'View\PageController::helpCenter');
//payment
$routes->get('/payment', 'View\PaymentController::redirectPage');
$routes->get('/payment/paypal', 'View\PaymentController::paypal');
$routes->get('/payment', 'View\PaymentController::redirectPage', ['filter' => 'auth:parent']);
$routes->get('/payment/manual', 'View\PaymentController::manual');
$routes->post('/payment/manual', 'View\PaymentController::manual');
// Voucher management
$routes->get('discounts/list', 'View\DiscountController::listVouchers');
$routes->match(['get', 'post'], 'discount/create', 'View\DiscountController::createVoucher');
$routes->match(['get', 'post'], 'discount/editVoucher/(:num)', 'View\DiscountController::editVoucher/$1');
$routes->match(['get', 'post'], 'discount/apply', 'View\DiscountController::applyVoucher');
$routes->get('discounts/list', 'View\DiscountController::listVouchers', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']);
$routes->match(['get', 'post'], 'discount/create', 'View\DiscountController::createVoucher', ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
$routes->match(['get', 'post'], 'discount/editVoucher/(:num)', 'View\DiscountController::editVoucher/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->match(['get', 'post'], 'discount/apply', 'View\DiscountController::applyVoucher', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
@@ -1562,11 +1549,6 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
$routes->get('class-preparation/(:num)', 'View\SupportController::show/$1');
$routes->post('class-preparation/(:num)/mark-printed', 'View\SupportController::markPrinted/$1');
// PayPal Transactions
$routes->get('paypal-transactions', 'View\UserController::index');
$routes->get('paypal-transactions/(:num)', 'View\SupportController::show/$1');
$routes->get('paypal-transactions/transaction/(:segment)', 'View\SupportController::getByTransactionId/$1');
// Stats
$routes->get('stats', 'View\UserController::index');
+13 -225
View File
@@ -2,6 +2,7 @@
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Libraries\InvoiceLedgerService;
use App\Models\DiscountVoucherModel;
use App\Models\DiscountUsageModel;
use App\Models\InvoiceModel;
@@ -28,6 +29,7 @@ class DiscountController extends BaseController
protected $eventChargesModel;
protected $additionalChargeModel;
protected $classSectionModel;
protected $invoiceLedgerService;
public function __construct()
{
@@ -41,6 +43,7 @@ class DiscountController extends BaseController
$this->eventChargesModel = new EventChargesModel();
$this->additionalChargeModel = new AdditionalChargeModel();
$this->classSectionModel = new ClassSectionModel();
$this->invoiceLedgerService = new InvoiceLedgerService();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
@@ -121,6 +124,8 @@ class DiscountController extends BaseController
foreach ($invoices as $invoice) {
if ($remainingUses <= 0) break 2; // out of parentIds loop too
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $invoice['id']]);
// Snapshot current balance BEFORE applying
$initialPreBalance = (float) $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
if ($initialPreBalance <= 0) {
@@ -201,22 +206,9 @@ class DiscountController extends BaseController
'updated_at' => $now,
]);
// Update invoice balance based on pre-discount snapshot (supports multiple discounts)
$newBalance = max(0.0, round($initialPreBalance - $discount, 2));
$this->db->table('invoices')
->where('id', $invoice['id'])
->update([
'balance' => $newBalance,
'has_discount' => 1,
'updated_at' => $now,
]);
// Compute post-balance based on pre-snapshot (more stable than $invoice['balance'])
$postBalance = round($initialPreBalance - $discount, 2);
if ($postBalance < 0) $postBalance = 0.0;
// (Optional) current balance re-check (in case of concurrent writes)
$currentBalance = (float) $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
$ledger = $this->invoiceLedgerService->recalculateInvoice((int) $invoice['id']);
$postBalance = (float) ($ledger['balance'] ?? 0.0);
$currentBalance = $postBalance;
// Increment voucher usage
$this->db->table('discount_vouchers')
@@ -596,53 +588,11 @@ class DiscountController extends BaseController
*/
private function getCurrentInvoiceBalance($invoiceId, $schoolYear)
{
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) return 0.0;
// Payments (exclude void/refund/failed, honor year)
$qb = $this->paymentModel
->select('COALESCE(SUM(paid_amount),0) AS total_paid')
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear);
$table = $this->paymentModel->table;
$hasStatus = $this->db->fieldExists('status', $table);
$hasVoid = $this->db->fieldExists('is_void', $table);
if ($hasStatus) {
$qb->groupStart()
->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'])
->orWhere('status IS NULL', null, false)
->groupEnd();
try {
return (float) ($this->invoiceLedgerService->calculateInvoice((int) $invoiceId)['balance'] ?? 0.0);
} catch (\Throwable $e) {
return 0.0;
}
if ($hasVoid) {
$qb->groupStart()
->where('is_void', 0)
->orWhere('is_void IS NULL', null, false)
->groupEnd();
}
$rowPaid = $qb->first();
$totalPaid = (float)($rowPaid['total_paid'] ?? 0);
// Discounts for this invoice in this year
$rowDisc = $this->db->table('discount_usages du')
->select('COALESCE(SUM(du.discount_amount),0) AS total_disc')
->join('invoices i', 'i.id = du.invoice_id')
->where('du.invoice_id', $invoiceId)
->where('i.school_year', $schoolYear)
->get()->getRowArray();
$totalDisc = (float)($rowDisc['total_disc'] ?? 0);
// Refunds PAID for this invoice in this year
$rowRefund = $this->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear)
->whereIn('status', ['Partial', 'Paid'])
->get()->getRowArray();
$totalRefundPaid = (float)($rowRefund['total_refund_paid'] ?? 0);
$total = (float)($invoice['total_amount'] ?? 0);
return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2));
}
/**
@@ -650,169 +600,7 @@ class DiscountController extends BaseController
*/
private function recalculateInvoice($invoiceId, $schoolYear): void
{
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) return;
$parentId = (int)($invoice['parent_id'] ?? 0);
if ($parentId <= 0) return;
// ---- Tuition (recompute from enrollments) ----
$enrollments = $this->enrollmentModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->findAll();
$registered = [];
$withdrawn = [];
foreach ($enrollments as $e) {
$row = [
'student_id' => (int)($e['student_id'] ?? 0),
'class_section_id' => $e['class_section_id'] ?? null,
'enrollment_status'=> (string)($e['enrollment_status'] ?? ''),
];
if (in_array($row['enrollment_status'], ['enrolled','payment pending'], true)) {
$registered[] = $row;
} elseif (in_array($row['enrollment_status'], ['withdrawn','refund pending','withdraw under review'], true)) {
$withdrawn[] = $row;
}
}
// Refund window check if after deadline, withdrawn still billed
$refundDeadline = (string)($this->configModel->getConfig('refund_deadline') ?? '');
$refundAllowed = true;
try {
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$tz = new \DateTimeZone($tzName);
$today = new \DateTimeImmutable('today', $tz);
$deadline = new \DateTimeImmutable($refundDeadline, $tz);
$refundAllowed = $today <= $deadline;
} catch (\Throwable $e) {
$refundAllowed = true;
}
$tuitionStudents = $registered;
if (!$refundAllowed) {
$tuitionStudents = array_merge($tuitionStudents, $withdrawn);
}
// Grade threshold and fees
$gradeFee = (int)($this->configModel->getConfig('grade_fee') ?? 9);
$firstStudentFee = (float)($this->configModel->getConfig('first_student_fee') ?? 350);
$secondStudentFee = (float)($this->configModel->getConfig('second_student_fee') ?? 200);
$youthFee = (float)($this->configModel->getConfig('youth_fee') ?? 180);
// Normalize grades for tuition students
foreach ($tuitionStudents as &$s) {
$name = null;
if (!empty($s['class_section_id'])) {
$name = $this->classSectionModel->getClassSectionNameBySectionId($s['class_section_id']);
}
$s['grade_name'] = is_string($name) ? strtoupper(trim($name)) : 'N/A';
}
unset($s);
// Count regular vs youth and compute tuition
$regularCount = 0;
$youthCount = 0;
foreach ($tuitionStudents as $s) {
$lvl = $this->parseGradeLevel($s['grade_name']);
if ($lvl > $gradeFee) $youthCount++; else $regularCount++;
}
$tuitionSubtotal = 0.0;
$tuitionSubtotal += $youthCount * $youthFee;
if ($regularCount >= 2) {
$tuitionSubtotal += $firstStudentFee + ($regularCount - 1) * $secondStudentFee;
} elseif ($regularCount === 1) {
$tuitionSubtotal += $firstStudentFee;
}
// ---- Event charges (parent-year) ----
$eventSubtotal = 0.0;
try {
$events = $this->eventChargesModel->getChargesWithEventInfo($parentId, $schoolYear) ?? [];
foreach ($events as $ev) { $eventSubtotal += (float)($ev['charged'] ?? 0.0); }
} catch (\Throwable $e) {}
// ---- Additional charges (per-invoice) ----
$additionalSubtotal = 0.0;
try {
$rows = $this->additionalChargeModel
->select('charge_type, amount')
->where('invoice_id', $invoiceId)
->where('status', 'applied')
->findAll();
foreach ($rows as $r) {
$amt = (float)($r['amount'] ?? 0);
$typ = strtolower((string)($r['charge_type'] ?? 'add'));
if ($typ === 'deduct') $amt = -abs($amt); else $amt = abs($amt);
$additionalSubtotal += $amt;
}
} catch (\Throwable $e) {}
$discountableTotal = $tuitionSubtotal + $additionalSubtotal;
$nonDiscountableTotal = $eventSubtotal;
$newTotal = round($discountableTotal + $nonDiscountableTotal, 2);
// ---- Payments / Discounts / Refunds ----
$db = $this->db;
$table = $this->paymentModel->table;
$hasStatus = $db->fieldExists('status', $table);
$hasVoid = $db->fieldExists('is_void', $table);
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
$qb = $this->paymentModel
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear);
if ($hasStatus) {
$qb->groupStart()
->whereNotIn('status', $exclude)
->orWhere('status IS NULL', null, false)
->groupEnd();
}
if ($hasVoid) {
$qb->groupStart()
->where('is_void', 0)
->orWhere('is_void IS NULL', null, false)
->groupEnd();
}
$payments = $qb->findAll();
$totalPaid = 0.0;
foreach ($payments as $p) { $totalPaid += (float)($p['paid_amount'] ?? 0); }
$discRow = $this->db->table('discount_usages')
->select('COALESCE(SUM(discount_amount),0) AS total_disc')
->where('invoice_id', $invoiceId)
->get()->getRowArray();
$totalDisc = (float)($discRow['total_disc'] ?? 0);
$refundRow = $this->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
->where('invoice_id', $invoiceId)
->whereIn('status', ['Partial','Paid'])
->get()->getRowArray();
$totalRefundPaid = (float)($refundRow['total_refund_paid'] ?? 0);
$appliedDiscount = min($totalDisc, $discountableTotal);
$newBalance = max(0.0, $newTotal - $appliedDiscount - $totalPaid - $totalRefundPaid);
$newStatus = ($newBalance <= 0.00001) ? 'Paid' : (($totalPaid > 0) ? 'Partially Paid' : 'Unpaid');
$updateData = [
'total_amount' => $newTotal,
'paid_amount' => $totalPaid,
'balance' => $newBalance,
'status' => $newStatus,
'has_discount' => ($totalDisc > 0.0) ? 1 : 0,
];
if ($this->db->fieldExists('discount', $this->invoiceModel->table)) {
$updateData['discount'] = $totalDisc;
}
$this->invoiceModel->update($invoiceId, $updateData);
$this->invoiceLedgerService->recalculateInvoice((int) $invoiceId);
}
/**
+20 -43
View File
@@ -3,6 +3,8 @@
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Libraries\FinancialStatus;
use App\Libraries\InvoiceLedgerService;
use App\Models\AdditionalChargeModel;
use CodeIgniter\Controller;
use App\Models\UserModel;
@@ -28,6 +30,7 @@ class ExtraChargesController extends BaseController
protected $studentClassModel;
protected $enableAttendance;
protected $attendanceDayModel;
protected $invoiceLedgerService;
public function __construct()
{
@@ -39,6 +42,7 @@ class ExtraChargesController extends BaseController
$this->invoiceModel = new InvoiceModel();
$this->semester = $this->configModel->getConfig('semester');
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->invoiceLedgerService = new InvoiceLedgerService();
}
public function index()
@@ -247,13 +251,9 @@ class ExtraChargesController extends BaseController
// keep status as-is
]);
// If its already applied on an invoice and the amount changed, reflect the delta
if ($row['status'] === 'applied' && !empty($row['invoice_id']) && abs($delta) > 0.00001) {
if ($delta > 0) {
$this->invoiceModel->applyAdditionalCharge((int)$row['invoice_id'], $delta);
} else {
$this->invoiceModel->reverseAdditionalCharge((int)$row['invoice_id'], -$delta);
}
if (($row['status'] ?? '') === FinancialStatus::ADDITIONAL_CHARGE_APPLIED && !empty($row['invoice_id'])) {
$db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $row['invoice_id']]);
$this->invoiceLedgerService->recalculateInvoice((int) $row['invoice_id']);
}
$db->transComplete();
@@ -343,7 +343,7 @@ class ExtraChargesController extends BaseController
'description' => trim($data['description'] ?? ''),
'amount' => $signedAmount,
'due_date' => !empty($data['due_date']) ? $data['due_date'] : null,
'status' => $invoiceId ? 'applied' : 'pending',
'status' => $invoiceId ? FinancialStatus::ADDITIONAL_CHARGE_APPLIED : FinancialStatus::ADDITIONAL_CHARGE_PENDING,
'created_by' => (int)(session()->get('user_id') ?? 0),
'created_at' => \CodeIgniter\I18n\Time::now('UTC')->toDateTimeString(), // store UTC
];
@@ -357,17 +357,9 @@ class ExtraChargesController extends BaseController
$this->additionalChargeModel->insert($payload);
$chargeId = (int)$this->additionalChargeModel->getInsertID();
// Apply to invoice if present
if ($invoiceId) {
try {
if ($chargeType === 'add') {
$this->invoiceModel->applyAdditionalCharge($invoiceId, $amountAbs);
} else {
$this->invoiceModel->deductAdditionalCharge($invoiceId, $amountAbs);
}
} catch (\Throwable $e) {
log_message('error', 'apply/deductAdditionalCharge failed: ' . $e->getMessage());
}
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId]);
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
}
// AFTER
@@ -470,23 +462,15 @@ class ExtraChargesController extends BaseController
$this->db->transStart();
if ($status === 'applied' && $invoiceId > 0 && $amountAbs > 0) {
try {
if ($chargeType === 'add') {
$this->invoiceModel->reverseAdditionalCharge($invoiceId, $amountAbs);
} else {
// voiding a deduction -> add back
$this->invoiceModel->applyAdditionalCharge($invoiceId, $amountAbs);
}
} catch (\Throwable $e) {
log_message('error', 'void(): invoice adjust failed: ' . $e->getMessage());
}
}
$this->additionalChargeModel->update((int)$id, [
'status' => 'void',
'status' => FinancialStatus::ADDITIONAL_CHARGE_VOIDED,
]);
if ($status === FinancialStatus::ADDITIONAL_CHARGE_APPLIED && $invoiceId > 0) {
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId]);
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
}
$this->db->transComplete();
if (!$this->db->transStatus()) {
@@ -514,26 +498,19 @@ class ExtraChargesController extends BaseController
$chargeType = (string)($row['charge_type'] ?? 'add');
$status = (string)($row['status'] ?? 'pending');
if ($status !== 'applied' || $invoiceId <= 0 || $amountAbs <= 0) {
if ($status !== FinancialStatus::ADDITIONAL_CHARGE_APPLIED || $invoiceId <= 0 || $amountAbs <= 0) {
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Nothing to reverse']);
return redirect()->back()->with('error', 'Nothing to reverse.');
}
$this->db->transStart();
try {
if ($chargeType === 'add') {
$this->invoiceModel->reverseAdditionalCharge($invoiceId, $amountAbs);
} else {
$this->invoiceModel->applyAdditionalCharge($invoiceId, $amountAbs);
}
} catch (\Throwable $e) {
log_message('error', 'reverse(): invoice adjust failed: ' . $e->getMessage());
}
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId]);
$this->additionalChargeModel->update((int)$id, [
'status' => 'pending',
'status' => FinancialStatus::ADDITIONAL_CHARGE_PENDING,
'invoice_id' => null,
]);
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
$this->db->transComplete();
+12 -8
View File
@@ -150,10 +150,12 @@ class FamilyAdminController extends BaseController
}
// Recent payments (limit 10)
$payRows = $db->table('payments')
->select('id, parent_id, invoice_id, paid_amount, balance, payment_method, payment_date, status')
->whereIn('parent_id', $parentIds)
->orderBy('payment_date', 'DESC')
$payRows = $db->table('payments p')
->select('p.id, p.parent_id, p.invoice_id, p.paid_amount, p.payment_method, p.payment_date, p.status AS payment_status, p.installment_seq, p.number_of_installments, i.invoice_number, i.balance AS invoice_current_balance, i.status AS invoice_status, i.school_year')
->join('invoices i', 'i.id = p.invoice_id', 'inner')
->whereIn('p.parent_id', $parentIds)
->orderBy('p.payment_date', 'DESC')
->orderBy('p.id', 'DESC')
->limit(10)
->get()->getResultArray();
$fam['payments'] = $payRows;
@@ -395,10 +397,12 @@ class FamilyAdminController extends BaseController
}
// Payments
$payRows = $db->table('payments')
->select('id, parent_id, invoice_id, paid_amount, balance, payment_method, payment_date, status')
->whereIn('parent_id', $parentIds)
->orderBy('payment_date', 'DESC')
$payRows = $db->table('payments p')
->select('p.id, p.parent_id, p.invoice_id, p.paid_amount, p.payment_method, p.payment_date, p.status AS payment_status, p.installment_seq, p.number_of_installments, i.invoice_number, i.balance AS invoice_current_balance, i.status AS invoice_status, i.school_year')
->join('invoices i', 'i.id = p.invoice_id', 'inner')
->whereIn('p.parent_id', $parentIds)
->orderBy('p.payment_date', 'DESC')
->orderBy('p.id', 'DESC')
->limit(10)
->get()->getResultArray();
$family['payments'] = $payRows;
+75 -3
View File
@@ -4,7 +4,6 @@ namespace App\Controllers\View;
use CodeIgniter\Controller;
use CodeIgniter\Exceptions\PageNotFoundException;
use Config\Database;
class FilesController extends Controller
{
@@ -22,7 +21,11 @@ class FilesController extends Controller
throw PageNotFoundException::forPageNotFound();
}
// 3) Build path under writable
$expense = $this->expenseRecordForFile($name);
if ($expense === null || !$this->canViewExpenseFile($expense)) {
return $this->response->setStatusCode(403, 'You are not allowed to access this file.');
}
$path = WRITEPATH . 'uploads/receipts/' . $name;
if (!is_file($path)) {
throw PageNotFoundException::forPageNotFound();
@@ -79,7 +82,11 @@ class FilesController extends Controller
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
// 3) Build path under writable (REIMBURSEMENTS)
$reimbursement = $this->reimbursementRecordForFile($name);
if ($reimbursement === null || !$this->canViewReimbursementFile($reimbursement)) {
return $this->response->setStatusCode(403, 'You are not allowed to access this file.');
}
$path = WRITEPATH . 'uploads/reimbursements/' . $name;
if (!is_file($path)) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
@@ -425,4 +432,69 @@ class FilesController extends Controller
return $draftSemester === '' || $currentSemester === '' || $draftSemester === $currentSemester;
}
private function expenseRecordForFile(string $name): ?array
{
return \Config\Database::connect()
->table('expenses')
->where('receipt_path', $name)
->get()
->getRowArray();
}
private function reimbursementRecordForFile(string $name): ?array
{
return \Config\Database::connect()
->table('reimbursements')
->where('receipt_path', $name)
->get()
->getRowArray();
}
private function canViewExpenseFile(array $expense): bool
{
if ($this->hasFinancialStaffAccess()) {
return true;
}
$userId = (int) (session()->get('user_id') ?? 0);
return $userId > 0 && in_array($userId, [
(int) ($expense['purchased_by'] ?? 0),
(int) ($expense['added_by'] ?? 0),
(int) ($expense['approved_by'] ?? 0),
], true);
}
private function canViewReimbursementFile(array $reimbursement): bool
{
if ($this->hasFinancialStaffAccess()) {
return true;
}
$userId = (int) (session()->get('user_id') ?? 0);
return $userId > 0 && in_array($userId, [
(int) ($reimbursement['reimbursed_to'] ?? 0),
(int) ($reimbursement['approved_by'] ?? 0),
(int) ($reimbursement['added_by'] ?? 0),
], true);
}
private function hasFinancialStaffAccess(): bool
{
$roles = array_map('strtolower', (array) (session()->get('roles') ?? []));
$activeRole = strtolower((string) (session()->get('role') ?? ''));
if ($activeRole !== '' && !in_array($activeRole, $roles, true)) {
$roles[] = $activeRole;
}
foreach (['administrator', 'administrative staff', 'principal', 'teacher', 'teacher_assistant'] as $role) {
if (in_array($role, $roles, true)) {
return true;
}
}
return false;
}
}
File diff suppressed because it is too large Load Diff
+148
View File
@@ -2357,6 +2357,7 @@ public function belowSixty()
's.id AS student_id',
's.firstname',
's.lastname',
's.age',
's.school_id',
'cs.class_section_name',
'LOWER(TRIM(ss.semester)) AS sem_key',
@@ -2389,6 +2390,7 @@ public function belowSixty()
'school_id' => $row['school_id'] ?? '',
'firstname' => $row['firstname'] ?? '',
'lastname' => $row['lastname'] ?? '',
'age' => $row['age'] ?? null,
'class_section_name' => $row['class_section_name'] ?? '',
'fall_score' => null,
'spring_score' => null,
@@ -3397,6 +3399,8 @@ public function allDecisions()
's.school_id',
's.firstname',
's.lastname',
's.gender',
'ss.class_section_id',
'cs.class_section_name',
'LOWER(TRIM(ss.semester)) AS sem_key',
'ss.semester_score',
@@ -3428,6 +3432,8 @@ public function allDecisions()
'school_id' => $sr['school_id'] ?? '',
'firstname' => $sr['firstname'] ?? '',
'lastname' => $sr['lastname'] ?? '',
'gender' => $sr['gender'] ?? '',
'class_section_id' => (int)($sr['class_section_id'] ?? 0),
'class_section_name' => $sr['class_section_name'] ?? '',
'fall_score' => null,
'spring_score' => null,
@@ -3513,6 +3519,8 @@ public function allDecisions()
'school_id' => $info['school_id'],
'firstname' => $info['firstname'],
'lastname' => $info['lastname'],
'gender' => $info['gender'] ?? '',
'class_section_id' => (int)($info['class_section_id'] ?? 0),
'class_section_name' => $info['class_section_name'],
'fall_score' => $fall,
'spring_score' => $spring,
@@ -3521,9 +3529,47 @@ public function allDecisions()
'source' => $source,
'notes' => $notes,
'saved' => isset($savedMap[$sid]),
'is_trophy' => false,
];
}
$rowsByClass = [];
foreach ($rows as $index => $row) {
$classSectionId = (int)($row['class_section_id'] ?? 0);
if ($classSectionId <= 0) {
continue;
}
$rowsByClass[$classSectionId][] = $index;
}
foreach ($rowsByClass as $classIndexes) {
$scores = [];
foreach ($classIndexes as $rowIndex) {
$yearScore = $rows[$rowIndex]['year_score'] ?? null;
if (is_numeric($yearScore)) {
$scores[] = (float)$yearScore;
}
}
$thresholdInfo = $this->calculateTrophyThreshold($scores, 75.0);
$threshold = $thresholdInfo['threshold'];
if ($threshold === null) {
continue;
}
foreach ($classIndexes as $rowIndex) {
$yearScore = $rows[$rowIndex]['year_score'] ?? null;
$rows[$rowIndex]['is_trophy'] = is_numeric($yearScore) && (float)$yearScore >= $threshold;
}
}
$generated = !empty($saved);
return view('grading/all_decisions', [
@@ -3696,6 +3742,108 @@ public function generateAllDecisions()
->with('status', "Decisions generated for {$savedCount} students.");
}
private function calculateTrophyThreshold(array $scores, float $percentile = 75.0): array
{
$scores = array_values(array_filter(
$scores,
static fn ($value): bool => is_numeric($value) && $value !== null
));
$scores = array_map('floatval', $scores);
sort($scores);
$count = count($scores);
if ($count === 0) {
return ['threshold' => null, 'winners' => 0, 'method' => 'empty'];
}
$minWinners = 3;
$maxWinners = max($minWinners, (int) floor($count * (1 - $percentile / 100)));
$threshold = $this->empiricalTrophyPercentile($scores, $percentile);
$winners = $this->countScoresAtOrAbove($scores, $threshold);
if ($winners < $minWinners) {
$target = min($minWinners, $count);
$descending = array_reverse($scores);
$threshold = $descending[$target - 1];
$winners = $this->countScoresAtOrAbove($scores, $threshold);
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_reduced'];
}
if ($winners <= $maxWinners) {
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'empirical_percentile'];
}
$result = $this->capTrophyThresholdByRank($scores, $maxWinners);
if ($result['winners'] < $minWinners) {
$target = min($minWinners, $count);
$descending = array_reverse($scores);
$threshold = $descending[$target - 1];
$winners = $this->countScoresAtOrAbove($scores, $threshold);
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_after_cap'];
}
return $result;
}
private function capTrophyThresholdByRank(array $sortedScores, int $max): array
{
$descending = array_reverse($sortedScores);
$threshold = $descending[$max - 1];
$winners = $this->countScoresAtOrAbove($sortedScores, $threshold);
if ($winners <= $max) {
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'capped_25pct'];
}
$uniqueHigherScores = array_values(array_unique(array_filter(
$sortedScores,
static fn ($score): bool => $score > $threshold
)));
sort($uniqueHigherScores);
foreach ($uniqueHigherScores as $candidate) {
$winnerCount = $this->countScoresAtOrAbove($sortedScores, $candidate);
if ($winnerCount <= $max) {
return ['threshold' => $candidate, 'winners' => $winnerCount, 'method' => 'capped_25pct'];
}
}
return ['threshold' => $sortedScores[0], 'winners' => count($sortedScores), 'method' => 'all_equal'];
}
private function empiricalTrophyPercentile(array $sortedScores, float $percentile): float
{
$count = count($sortedScores);
if ($count === 0) {
return 0.0;
}
$index = ($percentile / 100.0) * ($count - 1);
$lower = (int) floor($index);
$upper = (int) ceil($index);
if ($lower === $upper) {
return $sortedScores[$lower];
}
return $sortedScores[$lower] + ($index - $lower) * ($sortedScores[$upper] - $sortedScores[$lower]);
}
private function countScoresAtOrAbove(array $scores, float $threshold): int
{
return count(array_filter(
$scores,
static fn ($score): bool => $score >= $threshold
));
}
public function getScoreComment()
{
// Get all students for the current semester and school year
+1 -1
View File
@@ -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();
+171 -558
View File
@@ -2,6 +2,9 @@
namespace App\Controllers\View;
use App\Libraries\FinancialAttachmentService;
use App\Libraries\FinancialStatus;
use App\Libraries\InvoiceLedgerService;
use App\Models\PaymentModel;
use App\Models\AdditionalChargeModel;
use App\Models\ClassSectionModel;
@@ -16,23 +19,14 @@ use App\Models\PaymentErrorModel;
use App\Models\InvoiceModel;
use App\Models\TeacherClassModel;
use App\Models\DiscountUsageModel;
use Config\PaypalConfig;
use PayPal\Api\Amount;
use PayPal\Api\Payment;
use PayPal\Api\PaymentExecution;
use PayPal\Api\Payer;
use PayPal\Api\Transaction;
use PayPal\Rest\ApiContext;
use PayPal\Auth\OAuthTokenCredential;
use CodeIgniter\RESTful\ResourceController;
use CodeIgniter\Events\Events;
use CodeIgniter\Exceptions\PageNotFoundException;
class PaymentController extends ResourceController
{
protected $paymentModel;
protected $paypalConfig;
protected $apiContext;
protected $request;
protected $db;
protected $invoiceModel;
@@ -51,6 +45,8 @@ class PaymentController extends ResourceController
protected $discountUsageModel;
protected $additionalChargeModel;
protected $classSectionModel;
protected $invoiceLedgerService;
protected $financialAttachmentService;
public function __construct()
{
@@ -60,7 +56,6 @@ class PaymentController extends ResourceController
$this->eventChargesModel = new EventChargesModel();
$this->manualPaymentModel = new ManualPaymentModel();
$this->paymentModel = new PaymentModel();
$this->paypalConfig = new PaypalConfig();
$this->request = \Config\Services::request();
$this->db = \Config\Database::connect();
$this->invoiceModel = new InvoiceModel();
@@ -73,20 +68,9 @@ class PaymentController extends ResourceController
$this->installmentDate = $this->configModel->getConfig('installment_date');
$this->discountUsageModel = new DiscountUsageModel();
$this->additionalChargeModel = new AdditionalChargeModel();
$this->classSectionModel = new ClassSectionModel();
// Set up PayPal API context
$this->apiContext = new ApiContext(
new OAuthTokenCredential(
$this->paypalConfig->paypalClientId,
$this->paypalConfig->paypalSecret
)
);
$this->apiContext->setConfig([
'mode' => $this->paypalConfig->paypalMode, // 'sandbox' or 'live'
'http.headers' => ['Connection' => 'Close']
]);
$this->classSectionModel = new ClassSectionModel();
$this->invoiceLedgerService = new InvoiceLedgerService();
$this->financialAttachmentService = new FinancialAttachmentService();
}
// API: Create a new payment plan
@@ -101,7 +85,6 @@ class PaymentController extends ResourceController
'payment_date' => $this->request->getPost('payment_date'),
'payment_type' => $this->request->getPost('payment_type'),
'status' => 'Pending',
'semester' => $this->request->getPost('semester'),
'school_year' => $this->request->getPost('school_year'),
];
@@ -115,7 +98,8 @@ class PaymentController extends ResourceController
// API: Get payments by parent ID
public function getByParentAPI($parentId)
{
$payments = $this->paymentModel->getPaymentsByParentId($parentId);
$selectedYear = $this->getSelectedPaymentHistoryYear();
$payments = $this->paymentModel->getPaymentsByParentId((int) $parentId, $selectedYear);
if ($payments) {
return $this->respond($payments);
} else {
@@ -126,13 +110,8 @@ class PaymentController extends ResourceController
// View: Get payments by parent ID (for web views)
public function getByParent($parentId)
{
// Fetch payments with invoice number (if available)
$payments = $this->paymentModel
->select('payments.*, invoices.invoice_number')
->join('invoices', 'invoices.id = payments.invoice_id', 'left')
->where('payments.parent_id', (int)$parentId)
->orderBy('payments.payment_date', 'DESC')
->findAll();
$selectedYear = $this->getSelectedPaymentHistoryYear();
$payments = $this->paymentModel->getPaymentsByParentId((int) $parentId, $selectedYear);
// Parent display name
$parent = $this->userModel->select('firstname, lastname')->find((int)$parentId) ?: [];
@@ -156,6 +135,13 @@ class PaymentController extends ResourceController
]);
}
private function getSelectedPaymentHistoryYear(): ?string
{
$selectedYear = $this->request->getGet('school_year') ?? $this->schoolYear;
return $selectedYear !== '' ? $selectedYear : null;
}
// View: Create a new payment plan
public function create()
{
@@ -185,135 +171,11 @@ class PaymentController extends ResourceController
}
// Create a PayPal payment
public function createPaypalPayment($paymentId)
{
// Fetch the payment details from the database
$payment = $this->paymentModel->find($paymentId);
// Create the payer object (who is making the payment)
$payer = new Payer();
$payer->setPaymentMethod('paypal');
// Set up the payment amount
$amount = new Amount();
$amount->setCurrency('USD')
->setTotal($payment['balance_amount']); // The balance amount to be paid
// Set up the transaction details
$transaction = new Transaction();
$transaction->setAmount($amount)
->setDescription('Payment for school fees')
->setInvoiceNumber(uniqid());
// Create the payment and set the redirect URLs
$payment = new Payment();
$payment->setIntent('sale')
->setPayer($payer)
->setTransactions([$transaction]);
// Set the approval URL for the payment
$redirectUrls = new \PayPal\Api\RedirectUrls();
$redirectUrls->setReturnUrl(base_url('payments/executePaypalPayment')) // Set this to where the user will be redirected after approval
->setCancelUrl(base_url('payments/cancelPaypalPayment'));
$payment->setRedirectUrls($redirectUrls);
// Create the payment and get the approval URL
try {
$payment->create($this->apiContext);
// Store the payment ID in the session to retrieve it later
session()->set('paypalPaymentId', $payment->getId());
session()->set('paymentId', $paymentId);
$approvalUrl = $payment->getApprovalLink();
return redirect()->to($approvalUrl); // Redirect the user to PayPal's approval page
} catch (\PayPal\Exception\PayPalConnectionException $ex) {
// Handle errors
echo $ex->getData();
exit;
}
}
// Execute the PayPal payment after user approval
public function executePaypalPayment()
{
// Get the payment ID and Payer ID from the request
$paymentId = session()->get('paypalPaymentId');
$payerId = $this->request->getGet('PayerID');
// Get the payment object using the payment ID
$payment = Payment::get($paymentId, $this->apiContext);
// Create an execution object to execute the payment
$execution = new PaymentExecution();
$execution->setPayerId($payerId);
// Execute the payment
try {
$result = $payment->execute($execution, $this->apiContext);
// Payment successful, update the payment status in the database
$paymentId = session()->get('paymentId');
$this->paymentModel->update($paymentId, ['status' => 'Completed']);
return redirect()->to('/payments'); // Redirect to the payments page after success
} catch (\PayPal\Exception\PayPalConnectionException $ex) {
// Handle payment execution failure
echo $ex->getData();
exit;
}
}
// Cancel the PayPal payment
public function cancelPaypalPayment()
{
// Payment was canceled by the user
return redirect()->to('/payments')->with('error', 'Payment was canceled.');
}
public function redirectPage()
{
$modeCheck = $this->configModel->getConfig('paypal_mode');
$parentId = session()->get('user_id'); // assuming this is the logged-in parent
// Get parent name and school ID
$parent = $this->db->table('users')
->select('firstname, lastname, school_id')
->where('id', $parentId)
->get()
->getRowArray();
$parentName = isset($parent['firstname'], $parent['lastname'])
? $parent['firstname'] . ' ' . $parent['lastname']
: 'Parent';
$schoolId = $parent['school_id'] ?? null;
// Get latest invoice total_amount
$latestInvoice = $this->invoiceModel->where('parent_id', $parentId)
->orderBy('created_at', 'DESC')
->select('total_amount')
->get()
->getRowArray();
$totalAmount = $latestInvoice['total_amount'] ?? 0;
return view('payment/payment_redirect', [
'parentName' => $parentName,
'totalAmount' => $totalAmount,
'schoolId' => $schoolId,
'modeCheck' => $modeCheck,
]);
}
public function paypal()
{
// Redirect to PayPal API or show instructions
return redirect()->to('https://www.paypal.com/ncp/payment/87FJL3EV8C7NE');
return redirect()
->to(site_url('parent/invoice_payment'))
->with('info', 'Online payment is currently unavailable. Please contact the school office to complete payment.');
}
public function manual()
@@ -329,11 +191,7 @@ class PaymentController extends ResourceController
// Handle file upload if present
$proof = $this->request->getFile('proof');
if ($proof && $proof->isValid() && !$proof->hasMoved()) {
$filename = $proof->getRandomName();
$proof->move(WRITEPATH . 'uploads/payments/', $filename);
$data['proof_path'] = $filename;
}
$data['proof_path'] = $this->financialAttachmentService->saveUploadedFile($proof, 'payments');
$this->manualPaymentModel->insert($data);
@@ -490,12 +348,12 @@ class PaymentController extends ResourceController
->where('parent_id', $parentId)
->findAll();
// Payments (paginated)
$payments = $this->paymentModel
->where('parent_id', $parentId)
->orderBy('payment_date', 'DESC')
->paginate(10);
$pager = $this->paymentModel->pager;
// Payments (paginated). Join invoices so history is filtered by invoice term
// and displays current invoice state instead of stale payment snapshots.
$selectedYear = $this->getSelectedPaymentHistoryYear();
$paymentHistory = $this->paymentModel->parentPaymentHistoryQuery($parentId, $selectedYear);
$payments = $paymentHistory->paginate(10);
$pager = $paymentHistory->pager;
// Invoices
$rawInvoices = $this->invoiceModel
@@ -1057,26 +915,13 @@ class PaymentController extends ResourceController
// Optional receipt upload
$checkFile = null;
$paymentFile = $this->request->getFile('payment_file');
if ($paymentFile && $paymentFile->isValid() && !$paymentFile->hasMoved()) {
$okTypes = ['image/jpeg', 'image/png', 'application/pdf'];
$okExts = ['jpg', 'jpeg', 'png', 'pdf'];
$mime = $paymentFile->getMimeType();
$ext = strtolower($paymentFile->getClientExtension());
if (!in_array($mime, $okTypes, true) || !in_array($ext, $okExts, true)) {
return redirect()->back()->withInput()->with('error', 'Unsupported file type. Use JPG, PNG, or PDF.');
}
if ($paymentFile->getSize() > 5 * 1024 * 1024) {
return redirect()->back()->withInput()->with('error', 'File too large. Max 5MB.');
}
$fileName = $paymentFile->getRandomName();
$subdir = ($paymentMethod === 'check') ? 'checks' : (($paymentMethod === 'card') ? 'cards' : 'misc');
$targetDir = WRITEPATH . 'uploads/' . $subdir . '/';
if (!is_dir($targetDir)) @mkdir($targetDir, 0775, true);
$paymentFile->move($targetDir, $fileName);
$checkFile = $fileName;
try {
$checkFile = $this->financialAttachmentService->saveUploadedFile(
$paymentFile,
$paymentMethod === 'check' ? 'checks' : ($paymentMethod === 'card' ? 'cards' : 'misc')
);
} catch (\RuntimeException $e) {
return redirect()->back()->withInput()->with('error', $e->getMessage());
}
$this->db->transBegin();
@@ -1096,10 +941,7 @@ class PaymentController extends ResourceController
$invYear = (string)($row['school_year'] ?? $this->schoolYear);
// Recompute invoice totals from tuition + events + additional charges
$this->recalculateInvoice($invoiceId, $invYear);
// Authoritative balance
$currentBalance = $this->getCurrentInvoiceBalance($invoiceId);
$currentBalance = (float) $this->invoiceLedgerService->recalculateInvoice($invoiceId)['balance'];
if ($amount > $currentBalance + 0.00001) {
$this->db->transRollback();
@@ -1132,10 +974,11 @@ class PaymentController extends ResourceController
$checkFile,
$transactionId,
$paymentDate,
$this->schoolYear,
$this->semester,
$invYear,
$checkNumber,
$installmentSeq
$installmentSeq,
(array) $this->invoiceModel->find($invoiceId),
$currentBalance
);
if (!$ok) {
@@ -1144,11 +987,10 @@ class PaymentController extends ResourceController
}
// Ensure invoice totals/balance reflect discounts and this payment
$this->recalculateInvoice($invoiceId, $this->schoolYear);
$ledger = $this->invoiceLedgerService->recalculateInvoice($invoiceId);
// Post-payment balance from snapshot
$postBalance = (float)round($initialPreBalance - $amount, 2);
if ($postBalance < 0) $postBalance = 0.0;
$postBalance = (float) ($ledger['balance'] ?? max(0.0, round($initialPreBalance - $amount, 2)));
// Optional enrollment update
$enrollmentupdated = $this->updateEnrollmentStatusIfPaid($invoiceId);
@@ -1174,7 +1016,7 @@ class PaymentController extends ResourceController
$rowParentDisc = $this->db->table('discount_usages')
->selectSum('discount_amount', 'sum_disc')
->where('parent_id', $parentId)
->where('school_year', $this->schoolYear)
->where('school_year', $invYear)
->get()->getRowArray();
if ($rowParentDisc && isset($rowParentDisc['sum_disc'])) {
$parentYearDiscountTotal = (float)$rowParentDisc['sum_disc'];
@@ -1235,20 +1077,21 @@ class PaymentController extends ResourceController
$checkFile = $payment['check_file']; // default keep old file
// Handle optional check or card payment receipt upload
if (strtolower($paymentMethod) === 'check') {
$paymentFile = $this->request->getFile('payment_file'); // ✅ Updated to payment_file
if ($paymentFile && $paymentFile->isValid() && !$paymentFile->hasMoved()) {
$fileName = $paymentFile->getRandomName();
$paymentFile->move(WRITEPATH . 'uploads/checks/', $fileName);
$checkFile = $fileName;
}
} elseif (strtolower($paymentMethod) === 'card') {
$paymentFile = $this->request->getFile('payment_file'); // ✅ Updated to payment_file
if ($paymentFile && $paymentFile->isValid() && !$paymentFile->hasMoved()) {
$fileName = $paymentFile->getRandomName();
$paymentFile->move(WRITEPATH . 'uploads/cards/', $fileName);
$checkFile = $fileName; // reuse for compatibility
try {
$paymentFile = $this->request->getFile('payment_file');
if (strtolower($paymentMethod) === 'check') {
$uploaded = $this->financialAttachmentService->saveUploadedFile($paymentFile, 'checks');
if ($uploaded !== null) {
$checkFile = $uploaded;
}
} elseif (strtolower($paymentMethod) === 'card') {
$uploaded = $this->financialAttachmentService->saveUploadedFile($paymentFile, 'cards');
if ($uploaded !== null) {
$checkFile = $uploaded;
}
}
} catch (\RuntimeException $e) {
return redirect()->back()->withInput()->with('error', $e->getMessage());
}
// ❌ Validate amount - negative or zero
@@ -1259,41 +1102,51 @@ class PaymentController extends ResourceController
);
}
// 🔄 Recalculate invoice first to ensure totals reflect tuition + events + additional
$this->recalculateInvoice($payment['invoice_id'], $this->schoolYear);
$this->db->transBegin();
// 🔄 Get current balance based on actual payments (with school_year filter)
// We need to calculate what the balance would be WITHOUT the current payment being edited
$currentBalance = $this->getCurrentInvoiceBalanceExcludingPayment($payment['invoice_id'], $paymentId);
try {
$invoiceId = (int) ($payment['invoice_id'] ?? 0);
$lockedInvoice = $this->db->query(
'SELECT id FROM invoices WHERE id = ? FOR UPDATE',
[$invoiceId]
)->getRowArray();
if ($paidAmount > $currentBalance) {
return redirect()->back()->with(
'error',
'Entered amount (' . number_format($paidAmount, 2) . ') exceeds remaining balance (' . number_format($currentBalance, 2) . ').'
);
if (!$lockedInvoice) {
$this->db->transRollback();
return redirect()->back()->with('error', 'Linked invoice not found.');
}
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
$currentBalance = $this->getCurrentInvoiceBalanceExcludingPayment($invoiceId, $paymentId);
if ($paidAmount > $currentBalance + 0.00001) {
$this->db->transRollback();
return redirect()->back()->with(
'error',
'Entered amount (' . number_format($paidAmount, 2) . ') exceeds remaining balance (' . number_format($currentBalance, 2) . ').'
);
}
$updateData = [
'paid_amount' => $paidAmount,
'payment_method' => strtolower($paymentMethod),
'check_file' => $checkFile,
'check_number' => strtolower($paymentMethod) === 'check' ? $checkNumber : null,
'balance' => max(0.0, round($currentBalance - $paidAmount, 2)),
'status' => FinancialStatus::PAYMENT_RECORDED,
'updated_by' => session()->get('user_id'),
];
$this->paymentModel->update($paymentId, $updateData);
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
$this->db->transCommit();
return redirect()->back()->with('success', 'Payment updated successfully.');
} catch (\Throwable $e) {
$this->db->transRollback();
log_message('error', '[manualPayEdit] ' . $e->getMessage());
return redirect()->back()->withInput()->with('error', 'Unexpected error while updating payment.');
}
// ✅ Update edited payment with check_number
$updateData = [
'paid_amount' => $paidAmount,
'payment_method' => strtolower($paymentMethod),
'check_file' => $checkFile,
'updated_by' => session()->get('user_id')
];
// ✅ Only add check_number if payment method is check
if (strtolower($paymentMethod) === 'check') {
$updateData['check_number'] = $checkNumber;
} else {
$updateData['check_number'] = null; // Clear check number for non-check payments
}
$this->paymentModel->update($paymentId, $updateData);
// 🔄 Always recalc invoice after change
$this->recalculateInvoice($payment['invoice_id'], $this->schoolYear);
return redirect()->back()->with('success', 'Payment updated successfully.');
}
@@ -1302,164 +1155,7 @@ class PaymentController extends ResourceController
*/
private function recalculateInvoice($invoiceId, $schoolYear)
{
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) return;
$parentId = (int)($invoice['parent_id'] ?? 0);
if ($parentId <= 0) return;
// ---- Tuition (recompute from enrollments) ----
$enrollments = $this->enrollmentModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->findAll();
$registered = [];
$withdrawn = [];
foreach ($enrollments as $e) {
$row = [
'student_id' => (int)($e['student_id'] ?? 0),
'class_section_id' => $e['class_section_id'] ?? null,
'enrollment_status'=> (string)($e['enrollment_status'] ?? ''),
];
if (in_array($row['enrollment_status'], ['enrolled','payment pending'], true)) {
$registered[] = $row;
} elseif (in_array($row['enrollment_status'], ['withdrawn','refund pending','withdraw under review'], true)) {
$withdrawn[] = $row;
}
}
// Refund window check if after deadline, withdrawn still billed
$refundDeadline = (string)($this->configModel->getConfig('refund_deadline') ?? '');
$refundAllowed = true;
try {
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$tz = new \DateTimeZone($tzName);
$today = new \DateTimeImmutable('today', $tz);
$deadline = new \DateTimeImmutable($refundDeadline, $tz);
$refundAllowed = $today <= $deadline;
} catch (\Throwable $e) {
$refundAllowed = true;
}
$tuitionStudents = $registered;
if (!$refundAllowed) {
$tuitionStudents = array_merge($tuitionStudents, $withdrawn);
}
$tuitionStudents = array_values(array_filter($tuitionStudents, function ($student) use ($schoolYear) {
$sid = (int)($student['student_id'] ?? 0);
return $sid > 0 && $this->studentClassModel->hasNonEventAssignment($sid, $schoolYear);
}));
// Grade threshold and fees
$gradeFee = (int)($this->configModel->getConfig('grade_fee') ?? 9);
$firstStudentFee = (float)($this->configModel->getConfig('first_student_fee') ?? 350);
$secondStudentFee = (float)($this->configModel->getConfig('second_student_fee') ?? 200);
$youthFee = (float)($this->configModel->getConfig('youth_fee') ?? 180);
// Normalize grades for tuition students
foreach ($tuitionStudents as &$s) {
$name = null;
if (!empty($s['class_section_id'])) {
$name = $this->classSectionModel->getClassSectionNameBySectionId($s['class_section_id']);
}
$s['grade_name'] = is_string($name) ? strtoupper(trim($name)) : 'N/A';
}
unset($s);
// Count regular vs youth and compute tuition
$regularCount = 0;
$youthCount = 0;
foreach ($tuitionStudents as $s) {
$lvl = $this->parseGradeLevel($s['grade_name']);
if ($lvl > $gradeFee) $youthCount++; else $regularCount++;
}
$tuitionSubtotal = 0.0;
$tuitionSubtotal += $youthCount * $youthFee;
if ($regularCount >= 2) {
$tuitionSubtotal += $firstStudentFee + ($regularCount - 1) * $secondStudentFee;
} elseif ($regularCount === 1) {
$tuitionSubtotal += $firstStudentFee;
}
// ---- Event charges (parent-year) ----
$eventSubtotal = 0.0;
try {
$events = $this->eventChargesModel->getChargesWithEventInfo($parentId, $schoolYear) ?? [];
foreach ($events as $ev) { $eventSubtotal += (float)($ev['charged'] ?? 0.0); }
} catch (\Throwable $e) {}
// ---- Additional charges (per-invoice) ----
$additionalSubtotal = 0.0;
try {
$rows = $this->additionalChargeModel
->select('charge_type, amount')
->where('invoice_id', $invoiceId)
->where('status', 'applied')
->findAll();
foreach ($rows as $r) {
$amt = (float)($r['amount'] ?? 0);
$typ = strtolower((string)($r['charge_type'] ?? 'add'));
if ($typ === 'deduct') $amt = -abs($amt); else $amt = abs($amt);
$additionalSubtotal += $amt;
}
} catch (\Throwable $e) {}
$newTotal = round($tuitionSubtotal + $eventSubtotal + $additionalSubtotal, 2);
// ---- Payments / Discounts / Refunds ----
$db = $this->db;
$table = $this->paymentModel->table;
$hasStatus = $db->fieldExists('status', $table);
$hasVoid = $db->fieldExists('is_void', $table);
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
$qb = $this->paymentModel
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear);
if ($hasStatus) {
$qb->groupStart()
->whereNotIn('status', $exclude)
->orWhere('status IS NULL', null, false)
->groupEnd();
}
if ($hasVoid) {
$qb->groupStart()
->where('is_void', 0)
->orWhere('is_void IS NULL', null, false)
->groupEnd();
}
$payments = $qb->findAll();
$totalPaid = 0.0;
foreach ($payments as $p) { $totalPaid += (float)($p['paid_amount'] ?? 0); }
$discRow = $this->db->table('discount_usages')
->select('COALESCE(SUM(discount_amount),0) AS total_disc')
->where('invoice_id', $invoiceId)
->get()->getRowArray();
$totalDisc = (float)($discRow['total_disc'] ?? 0);
$refundRow = $this->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
->where('invoice_id', $invoiceId)
->whereIn('status', ['Partial','Paid'])
->get()->getRowArray();
$totalRefundPaid = (float)($refundRow['total_refund_paid'] ?? 0);
$newBalance = max(0.0, $newTotal - $totalDisc - $totalPaid - $totalRefundPaid);
$newStatus = ($newBalance <= 0.00001) ? 'Paid' : (($totalPaid > 0) ? 'Partially Paid' : 'Unpaid');
$this->invoiceModel->update($invoiceId, [
'total_amount' => $newTotal,
'paid_amount' => $totalPaid,
'balance' => $newBalance,
'status' => $newStatus,
]);
$this->invoiceLedgerService->recalculateInvoice((int) $invoiceId);
}
/**
@@ -1521,106 +1217,28 @@ class PaymentController extends ResourceController
/** Get current invoice balance = total - payments - discounts - refundsPaid (no school_year filter). */
private function getCurrentInvoiceBalance(int $invoiceId): float
{
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) return 0.0;
$db = $this->db;
$table = $this->paymentModel->table; // usually 'payments'
$hasStatus = $db->fieldExists('status', $table);
$hasVoid = $db->fieldExists('is_void', $table);
$qb = $this->paymentModel
->select('COALESCE(SUM(paid_amount),0) AS total_paid')
->where('invoice_id', $invoiceId);
if ($hasStatus) {
$qb->groupStart()
->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'])
->orWhere('status IS NULL', null, false)
->groupEnd();
try {
return (float) ($this->invoiceLedgerService->calculateInvoice($invoiceId)['balance'] ?? 0.0);
} catch (\Throwable $e) {
return 0.0;
}
if ($hasVoid) {
$qb->groupStart()
->where('is_void', 0)
->orWhere('is_void IS NULL', null, false)
->groupEnd();
}
$row = $qb->first();
$totalPaid = (float)($row['total_paid'] ?? 0);
// Discount sum on this invoice
$discRow = $this->db->table('discount_usages')
->select('COALESCE(SUM(discount_amount),0) AS total_disc')
->where('invoice_id', $invoiceId)
->get()->getRowArray();
$totalDisc = (float)($discRow['total_disc'] ?? 0);
// Refunds paid
$refundRow = $this->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
->where('invoice_id', $invoiceId)
->whereIn('status', ['Partial','Paid'])
->get()->getRowArray();
$totalRefundPaid = (float)($refundRow['total_refund_paid'] ?? 0);
$total = (float)($invoice['total_amount'] ?? 0);
return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2));
}
/** Balance excluding a specific payment (for edit scenarios) and subtracting discounts/refunds. */
private function getCurrentInvoiceBalanceExcludingPayment(int $invoiceId, int $excludePaymentId): float
{
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) return 0.0;
$db = $this->db;
$table = $this->paymentModel->table;
$hasStatus = $db->fieldExists('status', $table);
$hasVoid = $db->fieldExists('is_void', $table);
$qb = $this->paymentModel
->select('COALESCE(SUM(paid_amount),0) AS total_paid')
->where('invoice_id', $invoiceId)
->where('id !=', $excludePaymentId);
if ($hasStatus) {
$qb->groupStart()
->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'])
->orWhere('status IS NULL', null, false)
->groupEnd();
$payment = $this->paymentModel->find($excludePaymentId);
if (!$payment) {
return $this->getCurrentInvoiceBalance($invoiceId);
}
if ($hasVoid) {
$qb->groupStart()
->where('is_void', 0)
->orWhere('is_void IS NULL', null, false)
->groupEnd();
$currentBalance = $this->getCurrentInvoiceBalance($invoiceId);
$status = FinancialStatus::normalizePaymentStatus($payment['status'] ?? null);
if (in_array($status, FinancialStatus::EXCLUDED_PAYMENT_STATUSES, true)) {
return $currentBalance;
}
$row = $qb->first();
$totalPaid = (float)($row['total_paid'] ?? 0);
// Discount sum on this invoice
$discRow = $this->db->table('discount_usages')
->select('COALESCE(SUM(discount_amount),0) AS total_disc')
->where('invoice_id', $invoiceId)
->get()->getRowArray();
$totalDisc = (float)($discRow['total_disc'] ?? 0);
// Refunds paid
$refundRow = $this->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
->where('invoice_id', $invoiceId)
->whereIn('status', ['Partial','Paid'])
->get()->getRowArray();
$totalRefundPaid = (float)($refundRow['total_refund_paid'] ?? 0);
$total = (float)($invoice['total_amount'] ?? 0);
return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2));
return max(0.0, round($currentBalance + (float) ($payment['paid_amount'] ?? 0), 2));
}
@@ -1653,11 +1271,12 @@ class PaymentController extends ResourceController
$transactionId = null,
$paymentDate = null,
$schoolYear = null,
$semester = null,
$checkNumber = null,
?int $installmentSeq = null // <-- NOW: the installment sequence (1,2,3,...) for this invoice
?int $installmentSeq = null,
?array $invoice = null,
?float $currentBalance = null
) {
$invoice = $this->invoiceModel->find($invoiceId);
$invoice = $invoice ?? $this->invoiceModel->find($invoiceId);
if (!$invoice) {
return false;
}
@@ -1678,49 +1297,25 @@ class PaymentController extends ResourceController
}
// Compute new totals
$newPaid = (float) $invoice['paid_amount'] + (float) $amount;
$newBalance = (float) $invoice['balance'] - (float) $amount;
if ($newBalance == $invoice['total_amount']) {
$paymentStatus = 'Unpaid';
} elseif ($newBalance > 0 && $newBalance < $invoice['total_amount']) {
$paymentStatus = 'Partially Paid';
} elseif ($newBalance <= 0.00001) {
$paymentStatus = 'Paid';
} else {
$paymentStatus = $invoice['status']; // fallback
}
$preBalance = $currentBalance ?? $this->getCurrentInvoiceBalance((int) $invoiceId);
$newBalance = max(0.0, round($preBalance - (float) $amount, 2));
// Update invoice
$invoiceUpdateData = [
'paid_amount' => $newPaid,
'balance' => $newBalance,
'status' => $paymentStatus,
'updated_by' => session()->get('user_id'),
];
if (!$this->invoiceModel->update($invoiceId, $invoiceUpdateData)) {
return false;
}
// Store the *sequence* in number_of_installments (kept for schema compatibility)
// Consider renaming the column to `installment_seq` in a future migration.
$paymentData = [
'parent_id' => $invoice['parent_id'],
'invoice_id' => $invoiceId,
'total_amount' => $invoice['total_amount'],
'paid_amount' => $amount,
'balance' => $newBalance,
'parent_id' => $invoice['parent_id'],
'invoice_id' => $invoiceId,
'total_amount' => $invoice['total_amount'],
'paid_amount' => $amount,
'balance' => $newBalance,
'number_of_installments' => $installmentSeq, // <-- installment sequence (1,2,3,...)
'transaction_id' => $transactionId,
'payment_method' => strtolower($paymentMethod),
'payment_date' => $paymentDate,
'status' => $paymentStatus,
'check_file' => $checkFile,
'check_number' => (strtolower($paymentMethod) === 'check') ? $checkNumber : null,
'updated_by' => session()->get('user_id'),
'school_year' => $schoolYear ?? $this->schoolYear,
'semester' => $semester ?? $this->semester,
// 'installment_index' => $installmentSeq, // use if you add a dedicated column later
'installment_seq' => $installmentSeq,
'transaction_id' => $transactionId,
'payment_method' => strtolower($paymentMethod),
'payment_date' => $paymentDate,
'status' => FinancialStatus::PAYMENT_RECORDED,
'check_file' => $checkFile,
'check_number' => (strtolower($paymentMethod) === 'check') ? $checkNumber : null,
'updated_by' => session()->get('user_id'),
'school_year' => $schoolYear ?? ($invoice['school_year'] ?? $this->schoolYear),
];
if (!$this->paymentModel->insert($paymentData)) {
@@ -1741,35 +1336,53 @@ class PaymentController extends ResourceController
->countAllResults();
}
public function serveCheckFile($filename, $mode = 'download')
public function servePaymentFile(int $paymentId, string $mode = 'download')
{
$filename = basename($filename);
$roots = [
WRITEPATH . 'uploads/checks/' . $filename,
WRITEPATH . 'uploads/cards/' . $filename,
WRITEPATH . 'uploads/misc/' . $filename,
WRITEPATH . 'uploads/' . $filename, // final fallback
];
$path = null;
foreach ($roots as $candidate) {
if (is_file($candidate)) {
$path = $candidate;
break;
}
$payment = $this->paymentModel->find($paymentId);
if (!$payment || empty($payment['check_file'])) {
throw PageNotFoundException::forPageNotFound('Payment file not found.');
}
if (!$path) {
throw new \CodeIgniter\Exceptions\PageNotFoundException('Payment file not found: ' . esc($filename));
if (!$this->canViewPayment($payment)) {
return $this->response->setStatusCode(403);
}
$subdir = match (strtolower((string) ($payment['payment_method'] ?? ''))) {
'check' => 'checks',
'card', 'debit/credit card' => 'cards',
default => 'misc',
};
$path = $this->financialAttachmentService->resolvePath($subdir, (string) $payment['check_file']);
if ($path === null) {
throw PageNotFoundException::forPageNotFound('Payment file not found.');
}
if ($mode === 'inline') {
return $this->response
->setHeader('Content-Type', mime_content_type($path))
->setHeader('Content-Disposition', 'inline; filename="' . $filename . '"')
->setHeader('Content-Type', $this->financialAttachmentService->detectMime($path))
->setHeader('Content-Disposition', 'inline; filename="' . basename($path) . '"')
->setBody(file_get_contents($path));
}
return $this->response->download($path, null);
}
private function canViewPayment(array $payment): bool
{
$roles = array_map('strtolower', (array) (session()->get('roles') ?? []));
$activeRole = strtolower((string) (session()->get('role') ?? ''));
if ($activeRole !== '' && !in_array($activeRole, $roles, true)) {
$roles[] = $activeRole;
}
$staffRoles = ['administrator', 'administrative staff', 'principal', 'teacher', 'teacher_assistant'];
foreach ($staffRoles as $role) {
if (in_array($role, $roles, true)) {
return true;
}
}
return in_array('parent', $roles, true) && (int) ($payment['parent_id'] ?? 0) === (int) session()->get('user_id');
}
}
@@ -1,99 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\PayPalPaymentModel;
use App\Models\PaymentModel;
use App\Models\UserModel;
use App\Models\ConfigurationModel;
use App\Models\InvoiceModel;
class PaypalTransactionsController extends BaseController
{
public function index()
{
$model = new PayPalPaymentModel();
$keyword = $this->request->getGet('q');
$perPage = 10;
if ($keyword) {
$transactions = $model
->groupStart()
->like('transaction_id', $keyword)
->orLike('payer_email', $keyword)
->orLike('event_type', $keyword)
->orLike('order_id', $keyword)
->orLike('parent_school_id', $keyword)
->groupEnd()
->orderBy('created_at', 'DESC')
->paginate($perPage);
} else {
$transactions = $model
->orderBy('created_at', 'DESC')
->paginate($perPage);
}
return view('administrator/paypal_transactions', [
'transactions' => $transactions,
'pager' => $model->pager,
'keyword' => $keyword
]);
}
public function exportCsv()
{
$model = new PayPalPaymentModel();
$keyword = $this->request->getGet('q');
if ($keyword) {
$transactions = $model
->groupStart()
->like('transaction_id', $keyword)
->orLike('payer_email', $keyword)
->orLike('event_type', $keyword)
->orLike('order_id', $keyword)
->orLike('parent_school_id', $keyword)
->groupEnd()
->orderBy('created_at', 'DESC')
->findAll();
} else {
$transactions = $model->orderBy('created_at', 'DESC')->findAll();
}
$filename = 'paypal_transactions_' . date('Ymd_His') . '.csv';
header('Content-Type: text/csv');
header("Content-Disposition: attachment; filename=\"$filename\"");
$output = fopen('php://output', 'w');
// CSV headers
fputcsv($output, [
'ID', 'Transaction ID', 'Order ID', 'Parent School ID',
'Email', 'Amount', 'Net Amount', 'Currency',
'Status', 'Event Type', 'Created At'
]);
foreach ($transactions as $t) {
fputcsv($output, [
$t['id'],
$t['transaction_id'],
$t['order_id'],
$t['parent_school_id'],
$t['payer_email'],
$t['amount'],
$t['net_amount'],
$t['currency'],
$t['status'],
$t['event_type'],
$t['created_at'],
]);
}
fclose($output);
exit;
}
}
+176 -44
View File
@@ -3,12 +3,15 @@
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Libraries\FinancialAttachmentService;
use App\Libraries\InvoiceLedgerService;
use App\Models\RefundModel;
use App\Models\UserModel;
use App\Models\PaymentModel;
use App\Models\ConfigurationModel;
use App\Models\InvoiceModel;
use App\Models\EnrollmentModel;
use CodeIgniter\Exceptions\PageNotFoundException;
class RefundController extends BaseController
{
@@ -18,6 +21,8 @@ class RefundController extends BaseController
protected ConfigurationModel $configModel;
protected InvoiceModel $invoiceModel;
protected EnrollmentModel $enrollmentModel;
protected InvoiceLedgerService $invoiceLedgerService;
protected FinancialAttachmentService $financialAttachmentService;
protected $db;
// Allowed request types (mapped to your `refunds.request` column)
@@ -33,6 +38,8 @@ class RefundController extends BaseController
$this->configModel = new ConfigurationModel();
$this->invoiceModel = new InvoiceModel();
$this->enrollmentModel = new EnrollmentModel();
$this->invoiceLedgerService = new InvoiceLedgerService();
$this->financialAttachmentService = new FinancialAttachmentService();
$this->db = \Config\Database::connect();
}
@@ -445,6 +452,14 @@ class RefundController extends BaseController
return $this->response->setJSON(['error' => 'Failed to create refund request.']);
}
if (!empty($invoiceId)) {
try {
$this->invoiceLedgerService->recalculateInvoice((int) $invoiceId);
} catch (\Throwable $e) {
log_message('error', 'requestRefund recalc failed: ' . $e->getMessage());
}
}
// Fire refundPending notification/event for newly created refunds
try {
$user = $this->userModel->select('id, email, firstname, lastname')->find($parentId) ?: [];
@@ -467,13 +482,36 @@ class RefundController extends BaseController
// Approve refund (no money movement)
public function approveRefund(int $refundId)
{
$ok = $this->refundModel->update($refundId, [
'status' => 'Approved',
'approved_at' => utc_now(),
'approved_by' => session()->get('user_id'),
'updated_at' => utc_now(),
'updated_by' => session()->get('user_id'),
]);
$refund = $this->refundModel->find($refundId);
if (!$refund) {
return $this->response->setJSON(['error' => 'Refund not found']);
}
$this->db->transBegin();
try {
if (!empty($refund['invoice_id'])) {
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $refund['invoice_id']]);
}
$ok = $this->refundModel->update($refundId, [
'status' => 'Approved',
'approved_at' => utc_now(),
'approved_by' => session()->get('user_id'),
'updated_at' => utc_now(),
'updated_by' => session()->get('user_id'),
]);
if (!empty($refund['invoice_id'])) {
$this->invoiceLedgerService->recalculateInvoice((int) $refund['invoice_id']);
}
$this->db->transCommit();
} catch (\Throwable $e) {
$this->db->transRollback();
log_message('error', 'approveRefund failed: ' . $e->getMessage());
$ok = false;
}
return $this->response->setJSON($ok ? ['success' => 'Refund approved'] : ['error' => 'Approve failed']);
}
@@ -481,13 +519,36 @@ class RefundController extends BaseController
// Reject refund
public function rejectRefund(int $refundId)
{
$ok = $this->refundModel->update($refundId, [
'status' => 'Rejected',
'approved_at' => utc_now(),
'approved_by' => session()->get('user_id'),
'updated_at' => utc_now(),
'updated_by' => session()->get('user_id'),
]);
$refund = $this->refundModel->find($refundId);
if (!$refund) {
return $this->response->setJSON(['error' => 'Refund not found']);
}
$this->db->transBegin();
try {
if (!empty($refund['invoice_id'])) {
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $refund['invoice_id']]);
}
$ok = $this->refundModel->update($refundId, [
'status' => 'Rejected',
'approved_at' => utc_now(),
'approved_by' => session()->get('user_id'),
'updated_at' => utc_now(),
'updated_by' => session()->get('user_id'),
]);
if (!empty($refund['invoice_id'])) {
$this->invoiceLedgerService->recalculateInvoice((int) $refund['invoice_id']);
}
$this->db->transCommit();
} catch (\Throwable $e) {
$this->db->transRollback();
log_message('error', 'rejectRefund failed: ' . $e->getMessage());
$ok = false;
}
return $this->response->setJSON($ok ? ['success' => 'Refund rejected'] : ['error' => 'Reject failed']);
}
@@ -526,17 +587,21 @@ class RefundController extends BaseController
// Optional file upload for Check
$checkFileName = $refund['check_file'] ?? null;
if ($refundMethod === 'Check') {
$checkFile = $this->request->getFile('check_file');
if ($checkFile && $checkFile->isValid() && !$checkFile->hasMoved()) {
$checkFileName = $checkFile->getRandomName();
$checkFile->move(WRITEPATH . 'uploads/checks/', $checkFileName);
try {
$checkFile = $this->request->getFile('check_file');
$uploaded = $this->financialAttachmentService->saveUploadedFile($checkFile, 'checks');
if ($uploaded !== null) {
$checkFileName = $uploaded;
}
} catch (\RuntimeException $e) {
return $this->response->setJSON(['error' => $e->getMessage()]);
}
}
$newStatus = ($total < $target) ? 'Partial' : 'Paid';
$db = db_connect();
$db->transStart();
$db->transBegin();
// 0) If this refund is not tied to an invoice yet, assign it to the most overpaid invoice
$assignInvoiceId = null;
@@ -595,26 +660,32 @@ class RefundController extends BaseController
}
}
// 1) Update refund row
$this->refundModel->update($refundId, [
'refund_paid_amount' => $total,
'status' => $newStatus,
'refunded_at' => utc_now(),
'updated_at' => utc_now(),
'updated_by' => session()->get('user_id'),
'refund_method' => $refundMethod,
'check_nbr' => $checkNbr,
'check_file' => $checkFileName,
// tie to invoice if determined
'invoice_id' => $assignInvoiceId ?? $refund['invoice_id'],
]);
try {
$affectedInvoiceId = (int) ($assignInvoiceId ?? $refund['invoice_id'] ?? 0);
if ($affectedInvoiceId > 0) {
$db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$affectedInvoiceId]);
}
// 2) Optional: If you want an accounting journal entry for payouts, write to a dedicated table.
// We no longer insert a negative row into payments to avoid schema/validation conflicts.
$this->refundModel->update($refundId, [
'refund_paid_amount' => $total,
'status' => $newStatus,
'refunded_at' => utc_now(),
'updated_at' => utc_now(),
'updated_by' => session()->get('user_id'),
'refund_method' => $refundMethod,
'check_nbr' => $checkNbr,
'check_file' => $checkFileName,
'invoice_id' => $affectedInvoiceId ?: null,
]);
$db->transComplete();
if ($affectedInvoiceId > 0) {
$this->invoiceLedgerService->recalculateInvoice($affectedInvoiceId);
}
if ($db->transStatus() === false) {
$db->transCommit();
} catch (\Throwable $e) {
$db->transRollback();
log_message('error', 'updatePayment failed: ' . $e->getMessage());
return $this->response->setJSON(['error' => 'Failed to update payment.']);
}
@@ -770,16 +841,77 @@ class RefundController extends BaseController
return $this->response->setJSON(['error' => 'Refund not found.']);
}
$ok = $this->refundModel->update($refundId, [
'status' => $status,
'reason' => $reason,
'approved_at' => utc_now(),
'approved_by' => session()->get('user_id'),
'updated_at' => utc_now(),
'updated_by' => session()->get('user_id'),
]);
$this->db->transBegin();
try {
if (!empty($refund['invoice_id'])) {
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $refund['invoice_id']]);
}
$ok = $this->refundModel->update($refundId, [
'status' => $status,
'reason' => $reason,
'approved_at' => utc_now(),
'approved_by' => session()->get('user_id'),
'updated_at' => utc_now(),
'updated_by' => session()->get('user_id'),
]);
if (!empty($refund['invoice_id'])) {
$this->invoiceLedgerService->recalculateInvoice((int) $refund['invoice_id']);
}
$this->db->transCommit();
} catch (\Throwable $e) {
$this->db->transRollback();
log_message('error', 'updateStatus failed: ' . $e->getMessage());
$ok = false;
}
return $this->response->setJSON($ok ? ['success' => 'Refund status updated successfully.']
: ['error' => 'Failed to update refund status.']);
}
public function serveRefundFile(int $refundId, string $mode = 'download')
{
$refund = $this->refundModel->find($refundId);
if (!$refund || empty($refund['check_file'])) {
throw PageNotFoundException::forPageNotFound('Refund file not found.');
}
if (!$this->canViewRefund($refund)) {
return $this->response->setStatusCode(403);
}
$path = $this->financialAttachmentService->resolvePath('checks', (string) $refund['check_file']);
if ($path === null) {
throw PageNotFoundException::forPageNotFound('Refund file not found.');
}
if ($mode === 'inline') {
return $this->response
->setHeader('Content-Type', $this->financialAttachmentService->detectMime($path))
->setHeader('Content-Disposition', 'inline; filename="' . basename($path) . '"')
->setBody(file_get_contents($path));
}
return $this->response->download($path, null);
}
private function canViewRefund(array $refund): bool
{
$roles = array_map('strtolower', (array) (session()->get('roles') ?? []));
$activeRole = strtolower((string) (session()->get('role') ?? ''));
if ($activeRole !== '' && !in_array($activeRole, $roles, true)) {
$roles[] = $activeRole;
}
foreach (['administrator', 'administrative staff', 'principal', 'teacher', 'teacher_assistant'] as $role) {
if (in_array($role, $roles, true)) {
return true;
}
}
return in_array('parent', $roles, true) && (int) ($refund['parent_id'] ?? 0) === (int) session()->get('user_id');
}
}
+162 -96
View File
@@ -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()
{
+35 -9
View File
@@ -1849,15 +1849,42 @@ private function calculateTermRanking(
return null;
}
/*
* Rank must be limited to the actual selected class roster.
* Do NOT rank by every semester_scores row that happens to share class_section_id,
* because old/mismatched score rows can inflate the denominator (for example 18 students
* in 3-A showing as "out of 32"). The roster is the source of truth for class size.
*/
$rosterRows = $this->fetchStudentsByClass($sectionCode, $schoolYear);
if (empty($rosterRows) && $sectionId && $sectionId !== $sectionCode) {
$rosterRows = $this->fetchStudentsByClass($sectionId, $schoolYear);
}
$rosterByStudent = [];
foreach ($rosterRows as $row) {
$sid = (int)($row['id'] ?? 0);
if ($sid <= 0) {
continue;
}
$rosterByStudent[$sid] = [
'firstname' => trim((string)($row['firstname'] ?? '')),
'lastname' => trim((string)($row['lastname'] ?? '')),
];
}
if (empty($rosterByStudent) || !isset($rosterByStudent[$studentId])) {
return null;
}
$rosterStudentIds = array_keys($rosterByStudent);
$semesterForRank = trim((string)$semester);
$rankByFinalScore = $this->normalizeSemester($semesterForRank) === 'spring';
$builder = $this->db->table('semester_scores ss')
->select('ss.student_id, ss.semester, ss.semester_score, ss.updated_at, ss.id, s.firstname, s.lastname')
->join('students s', 's.id = ss.student_id', 'inner')
->where('s.is_active', 1)
->select('ss.student_id, ss.semester, ss.semester_score, ss.updated_at, ss.id')
->where('ss.school_year', $schoolYear)
->whereIn('ss.class_section_id', $sectionIds)
->whereIn('ss.student_id', $rosterStudentIds)
->orderBy('ss.updated_at', 'DESC')
->orderBy('ss.id', 'DESC');
@@ -1876,7 +1903,7 @@ private function calculateTermRanking(
foreach ($rows as $row) {
$sid = (int)($row['student_id'] ?? 0);
if ($sid <= 0 || isset($scoresByStudent[$sid])) {
if ($sid <= 0 || !isset($rosterByStudent[$sid]) || isset($scoresByStudent[$sid])) {
continue;
}
@@ -1892,8 +1919,8 @@ private function calculateTermRanking(
'student_id' => $sid,
'score' => $rawScore,
'rank_score' => round($rawScore, 1),
'firstname' => trim((string)($row['firstname'] ?? '')),
'lastname' => trim((string)($row['lastname'] ?? '')),
'firstname' => $rosterByStudent[$sid]['firstname'],
'lastname' => $rosterByStudent[$sid]['lastname'],
];
}
@@ -1910,7 +1937,6 @@ private function calculateTermRanking(
->select('ss.student_id, ss.semester, ss.semester_score, ss.updated_at, ss.id')
->where('ss.school_year', $schoolYear)
->whereIn('ss.student_id', $studentIds)
->whereIn('ss.class_section_id', $sectionIds)
->orderBy('ss.updated_at', 'DESC')
->orderBy('ss.id', 'DESC');
@@ -1925,7 +1951,7 @@ private function calculateTermRanking(
foreach ($firstRows as $row) {
$sid = (int)($row['student_id'] ?? 0);
if ($sid <= 0 || isset($firstScoresByStudent[$sid])) {
if ($sid <= 0 || !isset($rosterByStudent[$sid]) || isset($firstScoresByStudent[$sid])) {
continue;
}
@@ -0,0 +1,139 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Libraries\Tuition\TuitionForecastService;
class TuitionForecastController extends BaseController
{
protected TuitionForecastService $forecastService;
public function __construct()
{
$this->forecastService = new TuitionForecastService();
helper(['url']);
}
public function index()
{
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? ''));
$semester = trim((string) ($this->request->getGet('semester') ?? ''));
$mode = (string) ($this->request->getGet('calculator_mode') ?? 'compare');
$filters = $this->buildOptionsFromRequest('get');
$result = $this->forecastService->calculate($schoolYear, $semester, $mode, $filters);
return view('administrator/tuition_forecast', [
'title' => 'Tuition Collection Forecast',
'schoolYears' => $this->forecastService->getAvailableSchoolYears(),
'semesters' => $this->forecastService->getAvailableSemesters(),
'filters' => [
'school_year' => $result['school_year'],
'semester' => $result['semester'],
'calculator_mode' => $result['calculator_mode'],
] + $result['options'],
'result' => $result,
]);
}
public function calculate()
{
$schoolYear = trim((string) ($this->request->getPost('school_year') ?? ''));
$semester = trim((string) ($this->request->getPost('semester') ?? ''));
$mode = (string) ($this->request->getPost('calculator_mode') ?? 'compare');
return $this->response->setJSON(
$this->forecastService->calculate($schoolYear, $semester, $mode, $this->buildOptionsFromRequest('post'))
);
}
public function exportCsv()
{
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? ''));
$semester = trim((string) ($this->request->getGet('semester') ?? ''));
$mode = (string) ($this->request->getGet('calculator_mode') ?? 'compare');
$result = $this->forecastService->calculate($schoolYear, $semester, $mode, $this->buildOptionsFromRequest('get'));
$filename = sprintf(
'tuition_forecast_%s_%s_%s.csv',
preg_replace('/[^A-Za-z0-9_-]+/', '-', $result['school_year']) ?: 'school-year',
preg_replace('/[^A-Za-z0-9_-]+/', '-', $result['semester']) ?: 'all-year',
$result['calculator_mode']
);
$handle = fopen('php://temp', 'w+');
if ($handle === false) {
throw new \RuntimeException('Unable to create CSV export.');
}
fputcsv($handle, ['School Year', $result['school_year']]);
fputcsv($handle, ['Semester', $result['semester'] !== '' ? $result['semester'] : 'All Year']);
fputcsv($handle, ['Calculator Mode', $result['calculator_mode']]);
fputcsv($handle, []);
fputcsv($handle, ['Summary']);
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'],
]);
fputcsv($handle, []);
fputcsv($handle, ['Families']);
fputcsv($handle, ['Parent/Family', 'Student Count', 'Billable Student Count', 'Old Tuition Total', 'New Tuition Total', 'Difference', 'Warnings']);
foreach ($result['families'] as $family) {
fputcsv($handle, [
$family['parent_name'] ?? '',
$family['student_count'] ?? 0,
$family['billable_student_count'] ?? 0,
$family['old_total'] ?? '0.00',
$family['new_total'] ?? '0.00',
$family['difference'] ?? '0.00',
implode(' | ', $family['warnings'] ?? []),
]);
fputcsv($handle, ['Student Name', 'Grade', 'Billable', 'Excluded Reason', 'Old Rule', 'Old Amount', 'New Rule', 'New Amount']);
foreach ($family['student_details'] as $detail) {
fputcsv($handle, [
$detail['student_name'] ?? '',
$detail['grade_level'] ?? '',
!empty($detail['billable']) ? 'Yes' : 'No',
$detail['excluded_reason'] ?? '',
$detail['old_rule'] ?? '',
$detail['old_amount'] ?? '0.00',
$detail['new_rule'] ?? '',
$detail['new_amount'] ?? '0.00',
]);
}
fputcsv($handle, []);
}
rewind($handle);
$csv = stream_get_contents($handle) ?: '';
fclose($handle);
return $this->response
->setHeader('Content-Type', 'text/csv; charset=UTF-8')
->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"')
->setBody($csv);
}
protected function buildOptionsFromRequest(string $method): array
{
$source = $method === 'post' ? 'getPost' : 'getGet';
return [
'include_withdrawn_mode' => (string) ($this->request->{$source}('include_withdrawn_mode') ?? 'refund_deadline'),
'include_payment_pending' => $this->request->{$source}('include_payment_pending') ?? '0',
'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') ?? '',
];
}
}
+4 -19
View File
@@ -106,14 +106,12 @@ class WhatsappController extends BaseController
$existing = $this->linkModel->where([
'class_section_id' => $sectionId,
'school_year' => $this->schoolYear,
'semester' => $this->semester,
])->first();
$payload = [
'class_section_id' => $sectionId,
'class_section_name' => $sectionName,
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'invite_link' => trim($inviteLink),
'active' => $active,
];
@@ -434,8 +432,6 @@ class WhatsappController extends BaseController
sp.secondparent_lastname AS sp_lastname,
sp.secondparent_email AS sp_email,
sp.secondparent_phone AS sp_phone,
sp.school_year AS sp_school_year,
sp.semester AS sp_semester,
u.id AS u_id,
u.firstname AS u_firstname,
@@ -448,14 +444,6 @@ class WhatsappController extends BaseController
$b->join('users u', 'u.id = sp.firstparent_id', 'left');
// Scope by term using parents table (authoritative for the pairing)
if ($schoolYear !== '') {
$b->where('sp.school_year', $schoolYear);
}
if ($semester !== '') {
$b->where('sp.semester', $semester);
}
$rows = $b->get()->getResultArray();
// Flatten into a single contacts list (primary + second parent as separate rows)
@@ -606,11 +594,10 @@ class WhatsappController extends BaseController
$pb->join('students s', 's.id = sc.student_id', 'inner'); // adjust if your table is named `student` (singular)
$pb->join('users u', 'u.id = s.parent_id', 'inner'); // primary parent lives on students.parent_id
// Second parent row for the same term (allow NULL/'' semester if you sometimes omit it)
// Second parent row for the primary parent.
$pb->join(
'parents sp',
"sp.firstparent_id = u.id
AND sp.school_year = sc.school_year",
'sp.firstparent_id = u.id',
'left'
);
@@ -1202,7 +1189,7 @@ class WhatsappController extends BaseController
/**
* Class mode:
* - Given classSectionId, find students (student_class) for the term
* - Given classSectionId, find students (student_class) for the year
* - Map to primaries (users) and second-parents (parents)
* - Return one bundle **per primary parent** (so each parent gets their own email)
*/
@@ -1215,7 +1202,6 @@ class WhatsappController extends BaseController
$stuRows = $this->db->table('student_class')
->select('student_id')
->where('school_year', $this->schoolYear)
->where('semester', $this->semester)
->where('class_section_id', $classSectionId)
->get()->getResultArray();
if (empty($stuRows)) return [];
@@ -1302,7 +1288,7 @@ class WhatsappController extends BaseController
/**
* All mode:
* - Iterate all distinct class_section_id in student_class (for the term)
* - Iterate all distinct class_section_id in student_class (for the year)
* - Reuse class bundles per section and flatten
*/
private function bundlesForAllParentsAllClasses(array $linkBySection): array
@@ -1310,7 +1296,6 @@ class WhatsappController extends BaseController
$secRows = $this->db->table('student_class')
->select('DISTINCT class_section_id', false)
->where('school_year', $this->schoolYear)
->where('semester', $this->semester)
->orderBy('class_section_id', 'ASC')
->get()->getResultArray();
@@ -0,0 +1,349 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class FinancialSystemLedgerCleanup extends Migration
{
public function up()
{
$this->addInstallmentSequenceColumn();
$this->backfillInstallmentSequence();
$this->ensurePaymentDateHasTime();
$this->ensureIndexes();
$this->repairPaymentInvoiceTerms();
$this->normalizePaymentStatuses();
$this->ensureConfigurationDefaults();
$this->archivePaypalTables();
$this->refreshFinancialNavItems();
}
public function down()
{
$this->restorePaypalTables();
if ($this->db->tableExists('payments') && $this->db->fieldExists('installment_seq', 'payments')) {
$this->forge->dropColumn('payments', 'installment_seq');
}
$this->dropIndexIfExists('payments', 'idx_payments_invoice_id');
$this->dropIndexIfExists('payments', 'idx_payments_parent_year');
$this->dropIndexIfExists('payments', 'idx_payments_parent_year_semester');
$this->dropIndexIfExists('payments', 'uniq_payments_transaction_id');
$this->dropIndexIfExists('invoices', 'idx_invoices_parent_year_semester');
$this->dropIndexIfExists('discount_usages', 'idx_discount_usages_invoice_id');
$this->dropIndexIfExists('discount_usages', 'idx_discount_usages_parent_year_semester');
$this->dropIndexIfExists('refunds', 'idx_refunds_invoice_id');
$this->dropIndexIfExists('refunds', 'idx_refunds_parent_year_semester_status');
$this->dropIndexIfExists('additional_charges', 'idx_additional_charges_invoice_id');
$this->dropIndexIfExists('additional_charges', 'idx_additional_charges_parent_year_semester_status');
$this->dropIndexIfExists('invoice_event', 'idx_invoice_event_invoice_id');
if ($this->db->tableExists('nav_items')) {
$forecastRow = $this->db->table('nav_items')
->select('id')
->where('url', 'administrator/tuition-forecast')
->get()
->getRowArray();
if ($forecastRow && $this->db->tableExists('role_nav_items')) {
$this->db->table('role_nav_items')
->where('nav_item_id', (int) $forecastRow['id'])
->delete();
}
$this->db->table('nav_items')
->where('url', 'administrator/tuition-forecast')
->delete();
}
}
protected function addInstallmentSequenceColumn(): void
{
if ($this->db->tableExists('payments') && !$this->db->fieldExists('installment_seq', 'payments')) {
$this->forge->addColumn('payments', [
'installment_seq' => [
'type' => 'INT',
'constraint' => 11,
'null' => true,
'after' => 'number_of_installments',
],
]);
}
}
protected function backfillInstallmentSequence(): void
{
if (!$this->db->tableExists('payments') || !$this->db->fieldExists('installment_seq', 'payments')) {
return;
}
$this->db->query(
'UPDATE `payments`
SET `installment_seq` = `number_of_installments`
WHERE `installment_seq` IS NULL'
);
}
protected function ensurePaymentDateHasTime(): void
{
if (!$this->db->tableExists('payments') || !$this->db->fieldExists('payment_date', 'payments')) {
return;
}
$this->db->query('ALTER TABLE `payments` MODIFY `payment_date` DATETIME NOT NULL');
}
protected function ensureIndexes(): void
{
$this->addIndexIfMissing('payments', 'idx_payments_invoice_id', ['invoice_id']);
$this->dropIndexIfExists('payments', 'idx_payments_parent_year_semester');
$this->addIndexIfMissing('payments', 'idx_payments_parent_year', ['parent_id', 'school_year']);
$this->addIndexIfMissing('invoices', 'idx_invoices_parent_year_semester', ['parent_id', 'school_year', 'semester']);
$this->addIndexIfMissing('discount_usages', 'idx_discount_usages_invoice_id', ['invoice_id']);
$this->addIndexIfMissing('discount_usages', 'idx_discount_usages_parent_year_semester', ['parent_id', 'school_year', 'semester']);
$this->addIndexIfMissing('refunds', 'idx_refunds_invoice_id', ['invoice_id']);
$this->addIndexIfMissing('refunds', 'idx_refunds_parent_year_semester_status', ['parent_id', 'school_year', 'semester', 'status']);
$this->addIndexIfMissing('additional_charges', 'idx_additional_charges_invoice_id', ['invoice_id']);
$this->addIndexIfMissing('additional_charges', 'idx_additional_charges_parent_year_semester_status', ['parent_id', 'school_year', 'semester', 'status']);
$this->addIndexIfMissing('invoice_event', 'idx_invoice_event_invoice_id', ['invoice_id']);
if ($this->db->tableExists('payments') && $this->db->fieldExists('transaction_id', 'payments') && !$this->hasDuplicateTransactionIds()) {
$this->addIndexIfMissing('payments', 'uniq_payments_transaction_id', ['transaction_id'], true);
}
}
protected function repairPaymentInvoiceTerms(): void
{
if (!$this->db->tableExists('payments') || !$this->db->tableExists('invoices')) {
return;
}
$this->db->query(
'UPDATE `payments` p
JOIN `invoices` i ON i.`id` = p.`invoice_id`
SET p.`school_year` = i.`school_year`
WHERE COALESCE(p.`school_year`, \'\') <> COALESCE(i.`school_year`, \'\')'
);
}
protected function normalizePaymentStatuses(): void
{
if (!$this->db->tableExists('payments') || !$this->db->fieldExists('status', 'payments')) {
return;
}
$this->db->query(
"UPDATE `payments`
SET `status` = 'recorded'
WHERE LOWER(TRIM(`status`)) IN ('paid', 'partially paid', 'payment recorded', 'full', 'completed')"
);
}
protected function ensureConfigurationDefaults(): void
{
if (!$this->db->tableExists('configuration')) {
return;
}
$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',
];
foreach ($defaults as $key => $value) {
$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,
]);
continue;
}
if (trim((string) ($row['config_value'] ?? '')) === '') {
$this->db->table('configuration')
->where('config_key', $key)
->update(['config_value' => $value]);
}
}
}
protected function archivePaypalTables(): void
{
$this->renameTableIfPresent('paypal_payments', 'archived_paypal_payments');
$this->renameTableIfPresent('paypal_transactions', 'archived_paypal_transactions');
}
protected function restorePaypalTables(): void
{
$this->renameTableIfPresent('archived_paypal_payments', 'paypal_payments');
$this->renameTableIfPresent('archived_paypal_transactions', 'paypal_transactions');
}
protected function refreshFinancialNavItems(): void
{
if (!$this->db->tableExists('nav_items')) {
return;
}
$parentColumn = null;
if ($this->db->fieldExists('menu_parent_id', 'nav_items')) {
$parentColumn = 'menu_parent_id';
} elseif ($this->db->fieldExists('parent_id', 'nav_items')) {
$parentColumn = 'parent_id';
}
if ($parentColumn === null) {
return;
}
$this->db->table('nav_items')
->groupStart()
->where('url', 'administrator/paypal_transactions')
->orWhere('url', 'admin/paypal-transactions')
->orWhere('label', 'PaypalTransactions')
->orWhere('label', 'PayPal Transactions')
->groupEnd()
->delete();
$financialRow = $this->db->table('nav_items')
->select('id')
->where('label', 'Financial')
->where($parentColumn, null)
->get()
->getRowArray();
if (!$financialRow) {
return;
}
$forecastUrl = 'administrator/tuition-forecast';
$existing = $this->db->table('nav_items')
->select('id')
->where('url', $forecastUrl)
->get()
->getRowArray();
if ($existing) {
$forecastId = (int) $existing['id'];
} else {
$this->db->table('nav_items')->insert([
$parentColumn => (int) $financialRow['id'],
'label' => 'Tuition Forecast',
'url' => $forecastUrl,
'sort_order' => 4,
'is_enabled' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
$forecastId = (int) $this->db->insertID();
}
if ($forecastId <= 0 || !$this->db->tableExists('role_nav_items') || !$this->db->tableExists('roles')) {
return;
}
$roles = $this->db->table('roles')
->select('id')
->whereIn('name', ['administrator', 'administrative staff', 'principal', 'vice_principal', 'head of department (finance)'])
->get()
->getResultArray();
foreach ($roles as $role) {
$roleId = (int) ($role['id'] ?? 0);
if ($roleId <= 0) {
continue;
}
$exists = $this->db->table('role_nav_items')
->where('role_id', $roleId)
->where('nav_item_id', $forecastId)
->get()
->getRowArray();
if ($exists) {
continue;
}
$this->db->table('role_nav_items')->insert([
'role_id' => $roleId,
'nav_item_id' => $forecastId,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
}
protected function renameTableIfPresent(string $from, string $to): void
{
if (!$this->db->tableExists($from) || $this->db->tableExists($to)) {
return;
}
$this->db->query(sprintf('RENAME TABLE `%s` TO `%s`', $from, $to));
}
protected function addIndexIfMissing(string $table, string $indexName, array $columns, bool $unique = false): void
{
if (!$this->db->tableExists($table) || $this->indexExists($table, $indexName)) {
return;
}
$quotedColumns = implode(', ', array_map(static fn (string $column): string => '`' . $column . '`', $columns));
$type = $unique ? 'UNIQUE INDEX' : 'INDEX';
$sql = sprintf('ALTER TABLE `%s` ADD %s `%s` (%s)', $table, $type, $indexName, $quotedColumns);
$this->db->query($sql);
}
protected function dropIndexIfExists(string $table, string $indexName): void
{
if (!$this->db->tableExists($table) || !$this->indexExists($table, $indexName)) {
return;
}
$this->db->query(sprintf('ALTER TABLE `%s` DROP INDEX `%s`', $table, $indexName));
}
protected function indexExists(string $table, string $indexName): bool
{
if (!$this->db->tableExists($table)) {
return false;
}
$rows = $this->db->query(sprintf('SHOW INDEX FROM `%s`', $table))->getResultArray();
foreach ($rows as $row) {
if (($row['Key_name'] ?? null) === $indexName) {
return true;
}
}
return false;
}
protected function hasDuplicateTransactionIds(): bool
{
$row = $this->db->table('payments')
->select('transaction_id')
->where('transaction_id IS NOT NULL', null, false)
->where('transaction_id !=', '')
->groupBy('transaction_id')
->having('COUNT(*) >', 1, false)
->get(1)
->getRowArray();
return $row !== null;
}
}
@@ -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]);
}
}
@@ -0,0 +1,372 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class FixSchoolYearColumns extends Migration
{
/**
* Tables that directly own an academic year.
*
* school_year stays as text by design. Valid values use YYYY-YYYY and
* the second year must be exactly the first year + 1.
*/
private array $yearOwnerTables = [
'additional_charges',
'archived_paypal_transactions',
'attendance_data',
'attendance_day',
'attendance_record',
'attendance_tracking',
'badge_print_logs',
'below_sixty_decisions',
'calendar_events',
'certificate_records',
'classSection',
'class_progress_reports',
'competitions',
'current_flag',
'discount_vouchers',
'early_dismissal_signatures',
'enrollments',
'events',
'exams',
'exam_drafts',
'expenses',
'final_exam',
'final_score',
'flag',
'grading_locks',
'homework',
'inventory_movements',
'invoices',
'late_slip_logs',
'manual_payments',
'midterm_exam',
'missing_score_overrides',
'parent_attendance_reports',
'parent_meeting_schedules',
'parent_notifications',
'participation',
'payments',
'placement_batches',
'print_requests',
'project',
'quiz',
'refunds',
'reimbursements',
'reimbursement_batches',
'report_card_acknowledgements',
'scan_log',
'score_comments',
'semester_scores',
'staff_attendance',
'student_class',
'student_decisions',
'teacher_attendance_data',
'teacher_class',
'teacher_submission_notification_history',
'whatsapp_group_links',
'whatsapp_group_memberships',
];
/** Tables where school_year is redundant or conceptually incorrect. */
private array $tablesWithoutDirectYear = [
'chapters',
'classes',
'class_preparation_log',
'class_prep_adjustments',
'contactus',
'emergency_contacts',
'inventory_items',
'invoice_students_list',
'ip_attempts',
'login_activity',
'messages',
'notifications',
'notification_recipients',
'parents',
'paypal_transactions',
'placement_levels',
'preferences',
'staff',
'students',
'support_requests',
'users',
'user_notifications',
];
/** Tables where semester is redundant or conceptually incorrect. */
private array $tablesWithoutDirectSemester = [
'badge_print_logs',
'classes',
'contactus',
'emergency_contacts',
'inventory_items',
'invoice_students_list',
'ip_attempts',
'notification_recipients',
'notifications',
'parents',
'support_requests',
'user_notifications',
'whatsapp_group_links',
];
public function up(): void
{
if ($this->db->DBDriver !== 'MySQLi') {
throw new \RuntimeException(
'This migration targets MySQL 8 because it uses enforced CHECK constraints.'
);
}
// These annual entities lacked a school_year column in the audited schema.
foreach (['class_progress_reports', 'exams', 'print_requests'] as $table) {
if ($this->db->tableExists($table) && ! $this->db->fieldExists('school_year', $table)) {
// Nullable avoids inventing a year for existing rows.
// Backfill it, then make it NOT NULL in a later migration.
$this->forge->addColumn($table, [
'school_year' => [
'type' => 'VARCHAR',
'constraint' => 9,
'null' => true,
],
]);
}
}
// Validate and normalize every direct owner to VARCHAR(9).
foreach ($this->yearOwnerTables as $table) {
if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) {
continue;
}
$this->assertValidSchoolYears($table, 'school_year');
$this->normalizeYearColumn($table, 'school_year');
$this->ensureYearCheckConstraint($table, 'school_year');
$this->ensureYearIndex($table, 'school_year');
}
// Promotion is a transition and legitimately owns both source and target years.
if ($this->db->tableExists('promotion_queue')) {
foreach (['school_year_from', 'school_year_to'] as $column) {
if (! $this->db->fieldExists($column, 'promotion_queue')) {
continue;
}
$this->assertValidSchoolYears('promotion_queue', $column);
$this->normalizeYearColumn('promotion_queue', $column);
$this->ensureYearCheckConstraint('promotion_queue', $column);
$this->ensureYearIndex('promotion_queue', $column);
}
}
// Remove redundant columns. Dependent non-primary indexes are removed first.
foreach ($this->tablesWithoutDirectYear as $table) {
if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) {
continue;
}
$this->dropIndexesContainingColumn($table, 'school_year');
$this->dropChecksContainingColumn($table, 'school_year');
$this->forge->dropColumn($table, 'school_year');
}
foreach ($this->tablesWithoutDirectSemester as $table) {
if (! $this->db->tableExists($table) || ! $this->db->fieldExists('semester', $table)) {
continue;
}
$this->dropIndexesContainingColumn($table, 'semester');
$this->dropChecksContainingColumn($table, 'semester');
$this->forge->dropColumn($table, 'semester');
}
foreach ($this->yearOwnerTables as $table) {
if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) {
continue;
}
$this->ensureYearIndex($table, 'school_year');
}
}
public function down(): void
{
throw new \RuntimeException(
'This migration is intentionally irreversible because dropping school_year columns destroys data. Restore from backup instead of pretending rollback can resurrect it.'
);
}
private function assertValidSchoolYears(string $table, string $column): void
{
$tableName = $this->quoteIdentifier($table);
$columnName = $this->quoteIdentifier($column);
$invalid = $this->db->query(
"SELECT COUNT(*) AS aggregate
FROM {$tableName}
WHERE {$columnName} IS NOT NULL
AND (
{$columnName} NOT REGEXP '^[0-9]{4}-[0-9]{4}$'
OR CAST(RIGHT({$columnName}, 4) AS UNSIGNED)
<> CAST(LEFT({$columnName}, 4) AS UNSIGNED) + 1
)"
)->getRow();
if ((int) ($invalid->aggregate ?? 0) > 0) {
throw new \RuntimeException(
"Cannot migrate {$table}.{$column}: found {$invalid->aggregate} invalid school-year value(s). Expected YYYY-YYYY with consecutive years, for example 2025-2026."
);
}
}
private function normalizeYearColumn(string $table, string $column): void
{
$metadata = $this->db->query(
'SELECT IS_NULLABLE, COLLATION_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?',
[$table, $column]
)->getRow();
if ($metadata === null) {
return;
}
$nullable = $metadata->IS_NULLABLE === 'YES' ? 'NULL' : 'NOT NULL';
$collation = $metadata->COLLATION_NAME && preg_match('/^[A-Za-z0-9_]+$/', $metadata->COLLATION_NAME)
? ' COLLATE ' . $metadata->COLLATION_NAME
: '';
$this->db->query(sprintf(
'ALTER TABLE %s MODIFY COLUMN %s VARCHAR(9)%s %s',
$this->quoteIdentifier($table),
$this->quoteIdentifier($column),
$collation,
$nullable
));
}
private function ensureYearCheckConstraint(string $table, string $column): void
{
$constraint = $this->objectName('chk_sy', $table, $column);
$exists = $this->db->query(
'SELECT COUNT(*) AS aggregate
FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND CONSTRAINT_NAME = ?
AND CONSTRAINT_TYPE = \'CHECK\'',
[$table, $constraint]
)->getRow();
if ((int) ($exists->aggregate ?? 0) > 0) {
return;
}
$columnName = $this->quoteIdentifier($column);
$this->db->query(sprintf(
"ALTER TABLE %s ADD CONSTRAINT %s CHECK (
%s IS NULL OR (
%s REGEXP '^[0-9]{4}-[0-9]{4}$'
AND CAST(RIGHT(%s, 4) AS UNSIGNED)
= CAST(LEFT(%s, 4) AS UNSIGNED) + 1
)
)",
$this->quoteIdentifier($table),
$this->quoteIdentifier($constraint),
$columnName,
$columnName,
$columnName,
$columnName
));
}
private function ensureYearIndex(string $table, string $column): void
{
$indexed = $this->db->query(
'SELECT COUNT(*) AS aggregate
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?',
[$table, $column]
)->getRow();
if ((int) ($indexed->aggregate ?? 0) > 0) {
return;
}
$index = $this->objectName('idx_sy', $table, $column);
$this->db->query(sprintf(
'ALTER TABLE %s ADD INDEX %s (%s)',
$this->quoteIdentifier($table),
$this->quoteIdentifier($index),
$this->quoteIdentifier($column)
));
}
private function dropIndexesContainingColumn(string $table, string $column): void
{
$indexes = $this->db->query(
'SELECT DISTINCT INDEX_NAME
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?
AND INDEX_NAME <> \'PRIMARY\'',
[$table, $column]
)->getResult();
foreach ($indexes as $index) {
$this->db->query(sprintf(
'ALTER TABLE %s DROP INDEX %s',
$this->quoteIdentifier($table),
$this->quoteIdentifier($index->INDEX_NAME)
));
}
}
private function dropChecksContainingColumn(string $table, string $column): void
{
$checks = $this->db->query(
'SELECT tc.CONSTRAINT_NAME
FROM information_schema.TABLE_CONSTRAINTS tc
INNER JOIN information_schema.CHECK_CONSTRAINTS cc
ON cc.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA
AND cc.CONSTRAINT_NAME = tc.CONSTRAINT_NAME
WHERE tc.CONSTRAINT_SCHEMA = DATABASE()
AND tc.TABLE_NAME = ?
AND tc.CONSTRAINT_TYPE = \'CHECK\'
AND cc.CHECK_CLAUSE LIKE ?',
[$table, '%' . $column . '%']
)->getResult();
foreach ($checks as $check) {
$this->db->query(sprintf(
'ALTER TABLE %s DROP CHECK %s',
$this->quoteIdentifier($table),
$this->quoteIdentifier($check->CONSTRAINT_NAME)
));
}
}
private function objectName(string $prefix, string $table, string $column): string
{
// MySQL identifiers are limited to 64 characters.
return substr($prefix . '_' . $table . '_' . $column . '_' . substr(sha1($table . '.' . $column), 0, 8), 0, 64);
}
private function quoteIdentifier(string $identifier): string
{
return '`' . str_replace('`', '``', $identifier) . '`';
}
}
@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
use RuntimeException;
final class EnsureSchoolYearOnFinancialTables extends Migration
{
/** @var array<string, bool> table => nullable */
private array $tables = [
'discount_usages' => false,
'event_charges' => true,
'invoice_event' => true,
'payment_error' => true,
'payment_notification_logs' => true,
'payment_transactions' => true,
'reimbursement_batch_items' => true,
];
public function up(): void
{
if ($this->db->DBDriver !== 'MySQLi') {
throw new RuntimeException('This migration requires MySQL/MariaDB through the MySQLi driver.');
}
foreach ($this->tables as $table => $nullable) {
if (! $this->db->tableExists($table)) {
continue;
}
if (! $this->db->fieldExists('school_year', $table)) {
$this->forge->addColumn($table, [
'school_year' => [
'type' => 'VARCHAR',
'constraint' => 9,
'null' => $nullable,
'after' => $this->afterColumn($table),
],
]);
}
$this->backfillSchoolYear($table);
$this->assertValidYears($table);
$nullSql = $nullable ? 'NULL' : 'NOT NULL';
$this->db->query(
sprintf(
'ALTER TABLE `%s` MODIFY `school_year` VARCHAR(9) %s',
str_replace('`', '``', $table),
$nullSql
)
);
$this->ensureIndex($table);
}
}
public function down(): void
{
// These columns are part of the business model and must not be dropped on rollback.
}
private function assertValidYears(string $table): void
{
$sql = sprintf(
"SELECT COUNT(*) AS invalid_count
FROM `%s`
WHERE `school_year` IS NOT NULL
AND (
`school_year` NOT REGEXP '^[0-9]{4}-[0-9]{4}$'
OR CAST(RIGHT(`school_year`, 4) AS UNSIGNED)
<> CAST(LEFT(`school_year`, 4) AS UNSIGNED) + 1
)",
str_replace('`', '``', $table)
);
$row = $this->db->query($sql)->getRowArray();
$invalid = (int) ($row['invalid_count'] ?? 0);
if ($invalid > 0) {
throw new RuntimeException(
"Cannot normalize {$table}.school_year: {$invalid} invalid value(s). Expected YYYY-YYYY, for example 2025-2026."
);
}
}
private function backfillSchoolYear(string $table): void
{
if ($table === 'discount_usages' && $this->db->tableExists('invoices')) {
$this->db->query(
"UPDATE `discount_usages` du
INNER JOIN `invoices` i ON i.`id` = du.`invoice_id`
SET du.`school_year` = i.`school_year`
WHERE (du.`school_year` IS NULL OR TRIM(du.`school_year`) = '')
AND i.`school_year` REGEXP '^[0-9]{4}-[0-9]{4}$'
AND CAST(RIGHT(i.`school_year`, 4) AS UNSIGNED)
= CAST(LEFT(i.`school_year`, 4) AS UNSIGNED) + 1"
);
}
}
private function ensureIndex(string $table): void
{
$indexName = 'idx_' . $table . '_school_year';
if (strlen($indexName) > 64) {
$indexName = 'idx_' . substr(hash('sha256', $table . '_school_year'), 0, 24);
}
$row = $this->db->query(
'SELECT COUNT(*) AS index_count
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?',
[$table, 'school_year']
)->getRowArray();
if ((int) ($row['index_count'] ?? 0) === 0) {
$this->db->query(sprintf(
'CREATE INDEX `%s` ON `%s` (`school_year`)',
str_replace('`', '``', $indexName),
str_replace('`', '``', $table)
));
}
}
private function afterColumn(string $table): string
{
$preferred = ['semester', 'invoice_id', 'payment_id', 'batch_id'];
foreach ($preferred as $column) {
if ($this->db->fieldExists($column, $table)) {
return $column;
}
}
return 'id';
}
}
@@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
use RuntimeException;
final class ApplySchoolYearSemesterAuditCorrections extends Migration
{
private array $schoolYearIndexTables = [
'archived_paypal_transactions',
'class_progress_reports',
'exams',
'missing_score_overrides',
'payments',
'placement_batches',
'print_requests',
'report_card_acknowledgements',
'semester_scores',
'staff_attendance',
'teacher_attendance_data',
'whatsapp_group_links',
];
private array $semesterDropTables = [
'badge_print_logs',
'classes',
'contactus',
'emergency_contacts',
'inventory_items',
'invoice_students_list',
'ip_attempts',
'notification_recipients',
'notifications',
'parents',
'support_requests',
'user_notifications',
'whatsapp_group_links',
];
public function up(): void
{
if ($this->db->DBDriver !== 'MySQLi') {
throw new RuntimeException('This migration requires MySQL/MariaDB through the MySQLi driver.');
}
if ($this->db->tableExists('archived_paypal_transactions')
&& $this->db->fieldExists('school_year', 'archived_paypal_transactions')) {
$this->assertValidSchoolYears('archived_paypal_transactions', 'school_year');
$this->normalizeYearColumn('archived_paypal_transactions', 'school_year');
$this->ensureYearCheckConstraint('archived_paypal_transactions', 'school_year');
}
foreach ($this->schoolYearIndexTables as $table) {
if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) {
continue;
}
$this->ensureIndex($table, 'school_year');
}
foreach ($this->semesterDropTables as $table) {
if (! $this->db->tableExists($table) || ! $this->db->fieldExists('semester', $table)) {
continue;
}
$this->dropIndexesContainingColumn($table, 'semester');
$this->dropChecksContainingColumn($table, 'semester');
$this->forge->dropColumn($table, 'semester');
}
foreach ($this->schoolYearIndexTables as $table) {
if (! $this->db->tableExists($table) || ! $this->db->fieldExists('school_year', $table)) {
continue;
}
$this->ensureIndex($table, 'school_year');
}
}
public function down(): void
{
throw new RuntimeException(
'This migration is intentionally irreversible because dropping semester columns destroys data.'
);
}
private function assertValidSchoolYears(string $table, string $column): void
{
$tableName = $this->quoteIdentifier($table);
$columnName = $this->quoteIdentifier($column);
$invalid = $this->db->query(
"SELECT COUNT(*) AS aggregate
FROM {$tableName}
WHERE {$columnName} IS NOT NULL
AND (
{$columnName} NOT REGEXP '^[0-9]{4}-[0-9]{4}$'
OR CAST(RIGHT({$columnName}, 4) AS UNSIGNED)
<> CAST(LEFT({$columnName}, 4) AS UNSIGNED) + 1
)"
)->getRow();
if ((int) ($invalid->aggregate ?? 0) > 0) {
throw new RuntimeException(
"Cannot migrate {$table}.{$column}: found {$invalid->aggregate} invalid school-year value(s)."
);
}
}
private function normalizeYearColumn(string $table, string $column): void
{
$metadata = $this->db->query(
'SELECT IS_NULLABLE, COLLATION_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?',
[$table, $column]
)->getRow();
if ($metadata === null) {
return;
}
$nullable = $metadata->IS_NULLABLE === 'YES' ? 'NULL' : 'NOT NULL';
$collation = $metadata->COLLATION_NAME && preg_match('/^[A-Za-z0-9_]+$/', $metadata->COLLATION_NAME)
? ' COLLATE ' . $metadata->COLLATION_NAME
: '';
$this->db->query(sprintf(
'ALTER TABLE %s MODIFY COLUMN %s VARCHAR(9)%s %s',
$this->quoteIdentifier($table),
$this->quoteIdentifier($column),
$collation,
$nullable
));
}
private function ensureYearCheckConstraint(string $table, string $column): void
{
$constraint = $this->objectName('chk_sy', $table, $column);
$exists = $this->db->query(
'SELECT COUNT(*) AS aggregate
FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND CONSTRAINT_NAME = ?
AND CONSTRAINT_TYPE = \'CHECK\'',
[$table, $constraint]
)->getRow();
if ((int) ($exists->aggregate ?? 0) > 0) {
return;
}
$columnName = $this->quoteIdentifier($column);
$this->db->query(sprintf(
"ALTER TABLE %s ADD CONSTRAINT %s CHECK (
%s IS NULL OR (
%s REGEXP '^[0-9]{4}-[0-9]{4}$'
AND CAST(RIGHT(%s, 4) AS UNSIGNED)
= CAST(LEFT(%s, 4) AS UNSIGNED) + 1
)
)",
$this->quoteIdentifier($table),
$this->quoteIdentifier($constraint),
$columnName,
$columnName,
$columnName,
$columnName
));
}
private function ensureIndex(string $table, string $column): void
{
$indexed = $this->db->query(
'SELECT COUNT(*) AS aggregate
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?',
[$table, $column]
)->getRow();
if ((int) ($indexed->aggregate ?? 0) > 0) {
return;
}
$this->db->query(sprintf(
'ALTER TABLE %s ADD INDEX %s (%s)',
$this->quoteIdentifier($table),
$this->quoteIdentifier($this->objectName('idx_sy', $table, $column)),
$this->quoteIdentifier($column)
));
}
private function dropIndexesContainingColumn(string $table, string $column): void
{
$indexes = $this->db->query(
'SELECT DISTINCT INDEX_NAME
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?
AND INDEX_NAME <> \'PRIMARY\'',
[$table, $column]
)->getResult();
foreach ($indexes as $index) {
$this->db->query(sprintf(
'ALTER TABLE %s DROP INDEX %s',
$this->quoteIdentifier($table),
$this->quoteIdentifier($index->INDEX_NAME)
));
}
}
private function dropChecksContainingColumn(string $table, string $column): void
{
$checks = $this->db->query(
'SELECT tc.CONSTRAINT_NAME
FROM information_schema.TABLE_CONSTRAINTS tc
INNER JOIN information_schema.CHECK_CONSTRAINTS cc
ON cc.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA
AND cc.CONSTRAINT_NAME = tc.CONSTRAINT_NAME
WHERE tc.CONSTRAINT_SCHEMA = DATABASE()
AND tc.TABLE_NAME = ?
AND tc.CONSTRAINT_TYPE = \'CHECK\'
AND cc.CHECK_CLAUSE LIKE ?',
[$table, '%' . $column . '%']
)->getResult();
foreach ($checks as $check) {
$this->db->query(sprintf(
'ALTER TABLE %s DROP CHECK %s',
$this->quoteIdentifier($table),
$this->quoteIdentifier($check->CONSTRAINT_NAME)
));
}
}
private function objectName(string $prefix, string $table, string $column): string
{
return substr($prefix . '_' . $table . '_' . $column . '_' . substr(sha1($table . '.' . $column), 0, 8), 0, 64);
}
private function quoteIdentifier(string $identifier): string
{
return '`' . str_replace('`', '``', $identifier) . '`';
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
use RuntimeException;
final class EnsureWhatsappGroupLinksSchoolYearIndex extends Migration
{
public function up(): void
{
if ($this->db->DBDriver !== 'MySQLi') {
throw new RuntimeException('This migration requires MySQL/MariaDB through the MySQLi driver.');
}
if (! $this->db->tableExists('whatsapp_group_links')
|| ! $this->db->fieldExists('school_year', 'whatsapp_group_links')) {
return;
}
$indexed = $this->db->query(
'SELECT COUNT(*) AS aggregate
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?',
['whatsapp_group_links', 'school_year']
)->getRow();
if ((int) ($indexed->aggregate ?? 0) > 0) {
return;
}
$this->db->query(
'ALTER TABLE `whatsapp_group_links` ADD INDEX `idx_sy_whatsapp_group_links_school_year` (`school_year`)'
);
}
public function down(): void
{
if ($this->db->tableExists('whatsapp_group_links')) {
$this->db->query('ALTER TABLE `whatsapp_group_links` DROP INDEX `idx_sy_whatsapp_group_links_school_year`');
}
}
}
+3 -3
View File
@@ -86,9 +86,9 @@ class NavSeeder extends Seeder
['parent'=>'Financial','label'=>'Discount Management','url'=>'discounts/list','sort_order'=>1],
['parent'=>'Financial','label'=>'Expenses Management','url'=>'expenses/index','sort_order'=>2],
['parent'=>'Financial','label'=>'Financial Report','url'=>'payment/financial_report','sort_order'=>3],
['parent'=>'Financial','label'=>'Invoices Management','url'=>'invoice_payment/invoice_management','sort_order'=>4],
['parent'=>'Financial','label'=>'Manual Payment','url'=>'payment/manual_pay','sort_order'=>5],
['parent'=>'Financial','label'=>'PaypalTransactions','url'=>'administrator/paypal_transactions','sort_order'=>6],
['parent'=>'Financial','label'=>'Tuition Forecast','url'=>'administrator/tuition-forecast','sort_order'=>4],
['parent'=>'Financial','label'=>'Invoices Management','url'=>'invoice_payment/invoice_management','sort_order'=>5],
['parent'=>'Financial','label'=>'Manual Payment','url'=>'payment/manual_pay','sort_order'=>6],
['parent'=>'Financial','label'=>'Refund Management','url'=>'refunds/list','sort_order'=>7],
['parent'=>'Financial','label'=>'Reimbursement Management','url'=>'reimbursements/index','sort_order'=>8],
+79 -26
View File
@@ -35,7 +35,7 @@ class AuthFilter implements FilterInterface
public function before(RequestInterface $request, $arguments = null)
{
$session = session();
$userRoles = $session->get('roles'); // not used here, but keep if other code expects it
$userRoles = $session->get('roles');
$userId = $session->get('user_id');
$loginTime = (int) $session->get('login_time');
@@ -66,35 +66,16 @@ class AuthFilter implements FilterInterface
return $this->deny($request, "You don't have permission to use this feature.");
}
// Route arguments: ['filter' => 'auth:permission_name|alt_permission,update']
// Route arguments patterns supported:
// auth -> default 'read'
// auth:read -> CRUD only
// auth:create -> CRUD only
// auth:update -> CRUD only
// auth:delete -> CRUD only
// auth:permA|permB,read -> permission + CRUD
$requiredPermission = null;
$crudAction = 'read';
[$requirements, $crudAction] = $this->parseRequirements($arguments);
if (!empty($arguments)) {
$arg0 = strtolower((string) $arguments[0]);
// If only one argument and it's a CRUD keyword, treat as CRUD-only
if (count($arguments) === 1 && in_array($arg0, ['create', 'read', 'update', 'delete'], true)) {
$crudAction = $arg0;
} else {
// Otherwise, first arg is permission(s); optional second is CRUD
$requiredPermission = $arguments[0]; // e.g., 'edit_student|manage_students'
$crudAction = isset($arguments[1]) ? strtolower((string) $arguments[1]) : 'read';
if (!in_array($crudAction, ['create', 'read', 'update', 'delete'], true)) {
$crudAction = 'read';
}
if (!empty($requirements)) {
if ($this->matchesAnyRequirement($requirements, $crudAction, $roleIds, (array) $userRoles)) {
return;
}
return $this->deny($request, "You don't have permission to use this feature.");
}
// No explicit route permission: fall back to menu rules
if ($this->isAllowedByMenu($request, $roleIds)) {
return; // ✅ allowed
}
@@ -126,6 +107,78 @@ class AuthFilter implements FilterInterface
->with('error', 'Your session has expired. Please log in again.');
}
private function parseRequirements(?array $arguments): array
{
if (empty($arguments)) {
return [[], 'read'];
}
$tokens = array_values(array_filter(array_map(static fn($value) => trim((string) $value), $arguments), static fn($value) => $value !== ''));
if (empty($tokens)) {
return [[], 'read'];
}
$crudAction = 'read';
$last = strtolower((string) end($tokens));
if (in_array($last, ['create', 'read', 'update', 'delete'], true)) {
$crudAction = array_pop($tokens);
}
if (count($tokens) === 1 && in_array(strtolower($tokens[0]), ['create', 'read', 'update', 'delete'], true)) {
return [[], strtolower($tokens[0])];
}
return [$tokens, strtolower((string) $crudAction)];
}
private function matchesAnyRequirement(array $requirements, string $crudAction, array $roleIds, array $sessionRoles): bool
{
$normalizedRoles = array_map(static fn($role) => strtolower(trim((string) $role)), $sessionRoles);
foreach ($requirements as $requirement) {
$alternatives = array_values(array_filter(array_map(static fn($value) => trim((string) $value), explode('|', (string) $requirement))));
if (empty($alternatives)) {
continue;
}
foreach ($alternatives as $alternative) {
$candidate = strtolower($alternative);
if (in_array($candidate, $normalizedRoles, true)) {
return true;
}
if ($this->userHasNamedPermission($roleIds, $alternative, $crudAction)) {
return true;
}
}
}
return false;
}
private function userHasNamedPermission(array $roleIds, string $permissionName, string $crudAction): bool
{
if (empty($roleIds)) {
return false;
}
$rows = $this->db->table('role_permissions rp')
->join('permissions p', 'p.id = rp.permission_id')
->select('rp.*')
->whereIn('rp.role_id', $roleIds)
->where('LOWER(p.name)', strtolower($permissionName))
->get()
->getResultArray();
foreach ($rows as $row) {
if ($this->hasPermission($row, $crudAction)) {
return true;
}
}
return false;
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
// No-op
+35 -1
View File
@@ -47,6 +47,28 @@ if (!function_exists('attendance_comment_template_for_score')) {
}
}
return attendance_comment_template_match($templates, $score);
}
}
if (!function_exists('attendance_comment_template_match')) {
function attendance_comment_template_match(array $templates, float $score): ?array
{
if ($templates === []) {
return null;
}
// Attendance scores are percentage values; clamp to the expected domain
// so minor calculation drift above 100 or below 0 does not skip a template.
$score = max(0.0, min(100.0, $score));
usort($templates, static function (array $a, array $b): int {
$aMin = isset($a['min_score']) ? (float) $a['min_score'] : 0.0;
$bMin = isset($b['min_score']) ? (float) $b['min_score'] : 0.0;
return $aMin <=> $bMin;
});
foreach ($templates as $template) {
$min = isset($template['min_score']) ? (float) $template['min_score'] : 0.0;
$max = isset($template['max_score']) ? (float) $template['max_score'] : 100.0;
@@ -55,6 +77,18 @@ if (!function_exists('attendance_comment_template_for_score')) {
}
}
return null;
// Some configured bands use integer boundaries like 60-69 and 70-79,
// while attendance scores contain decimals like 69.23. When the score
// lands in that fractional gap, fall back to the nearest lower band.
$candidate = null;
foreach ($templates as $template) {
$min = isset($template['min_score']) ? (float) $template['min_score'] : 0.0;
if ($score < $min) {
break;
}
$candidate = $template;
}
return $candidate;
}
}
@@ -0,0 +1,8 @@
<?php
namespace App\Interfaces;
interface TuitionCalculatorInterface
{
public function calculateFamilyTuition(array $students, array $config): array;
}
@@ -0,0 +1,100 @@
<?php
namespace App\Libraries;
use CodeIgniter\Files\File;
use CodeIgniter\HTTP\Files\UploadedFile;
final class FinancialAttachmentService
{
private const MAX_BYTES = 5242880;
private const ALLOWED_MIMES = [
'application/pdf',
'image/jpeg',
'image/png',
];
private const ALLOWED_EXTENSIONS = [
'jpg',
'jpeg',
'pdf',
'png',
];
public function saveUploadedFile($file, string $subdir): ?string
{
if (!$file instanceof UploadedFile) {
return null;
}
if (!$file->isValid() || $file->hasMoved()) {
return null;
}
$size = (int) ($file->getSize() ?? 0);
if ($size <= 0) {
return null;
}
$mime = strtolower((string) $file->getMimeType());
$ext = strtolower((string) $file->getClientExtension());
if (!in_array($mime, self::ALLOWED_MIMES, true) || !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
throw new \RuntimeException('Unsupported file type. Use PDF, JPG, or PNG.');
}
if ($size > self::MAX_BYTES) {
throw new \RuntimeException('File too large. Maximum size is 5 MB.');
}
$dir = $this->ensureSubdir($subdir);
$name = $file->getRandomName();
$file->move($dir, $name);
return $name;
}
public function resolvePath(string $subdir, string $filename): ?string
{
$safeName = basename($filename);
if ($safeName === '') {
return null;
}
$path = $this->ensureSubdir($subdir) . DIRECTORY_SEPARATOR . $safeName;
return is_file($path) ? $path : null;
}
public function ensureSubdir(string $subdir): string
{
$path = WRITEPATH . 'uploads' . DIRECTORY_SEPARATOR . trim($subdir, '/');
if (!is_dir($path) && !mkdir($path, 0775, true) && !is_dir($path)) {
throw new \RuntimeException('Unable to prepare upload directory.');
}
return $path;
}
public function detectMime(string $path): string
{
if (function_exists('finfo_open')) {
$handle = finfo_open(FILEINFO_MIME_TYPE);
if ($handle) {
$mime = finfo_file($handle, $path);
finfo_close($handle);
if (is_string($mime) && $mime !== '') {
return $mime;
}
}
}
if (function_exists('mime_content_type')) {
$mime = mime_content_type($path);
if (is_string($mime) && $mime !== '') {
return $mime;
}
}
return 'application/octet-stream';
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace App\Libraries;
final class FinancialStatus
{
public const INVOICE_UNPAID = 'unpaid';
public const INVOICE_PARTIALLY_PAID = 'partially_paid';
public const INVOICE_PAID = 'paid';
public const INVOICE_OVERPAID = 'overpaid';
public const INVOICE_CANCELLED = 'cancelled';
public const PAYMENT_RECORDED = 'recorded';
public const PAYMENT_VOIDED = 'voided';
public const PAYMENT_FAILED = 'failed';
public const PAYMENT_REFUNDED = 'refunded';
public const PAYMENT_CHARGEBACK = 'chargeback';
public const PAYMENT_DECLINED = 'declined';
public const PAYMENT_REVERSED = 'reversed';
public const PAYMENT_CANCELED = 'canceled';
public const PAYMENT_CANCELLED = 'cancelled';
public const PAYMENT_TRANSACTION_STATUSES = [
self::PAYMENT_RECORDED,
self::PAYMENT_VOIDED,
self::PAYMENT_REFUNDED,
self::PAYMENT_FAILED,
self::PAYMENT_REVERSED,
self::PAYMENT_CHARGEBACK,
];
public const INVOICE_STATUSES = [
self::INVOICE_UNPAID,
self::INVOICE_PARTIALLY_PAID,
self::INVOICE_PAID,
self::INVOICE_OVERPAID,
self::INVOICE_CANCELLED,
];
public const REFUND_PENDING = 'pending';
public const REFUND_APPROVED = 'approved';
public const REFUND_REJECTED = 'rejected';
public const REFUND_PARTIALLY_PAID = 'partially_paid';
public const REFUND_PAID = 'paid';
public const REFUND_VOIDED = 'voided';
public const ADDITIONAL_CHARGE_PENDING = 'pending';
public const ADDITIONAL_CHARGE_APPLIED = 'applied';
public const ADDITIONAL_CHARGE_VOIDED = 'voided';
public const EXCLUDED_PAYMENT_STATUSES = [
self::PAYMENT_VOIDED,
self::PAYMENT_FAILED,
self::PAYMENT_REFUNDED,
self::PAYMENT_CHARGEBACK,
self::PAYMENT_DECLINED,
self::PAYMENT_REVERSED,
self::PAYMENT_CANCELED,
self::PAYMENT_CANCELLED,
'void',
];
public const REFUND_REDUCES_INVOICE_STATUSES = [
self::REFUND_PARTIALLY_PAID,
self::REFUND_PAID,
'partial',
'paid',
'Partial',
'Paid',
];
public static function normalizeInvoiceStatus(?string $status): string
{
return match (self::normalize($status)) {
'paid', 'full' => self::INVOICE_PAID,
'partially paid', 'partially_paid', 'partial' => self::INVOICE_PARTIALLY_PAID,
'overpaid' => self::INVOICE_OVERPAID,
'cancelled', 'canceled', 'cancelled invoice', 'canceled invoice' => self::INVOICE_CANCELLED,
default => self::INVOICE_UNPAID,
};
}
public static function normalizePaymentStatus(?string $status): string
{
return match (self::normalize($status)) {
'completed', 'paid', 'full', 'recorded' => self::PAYMENT_RECORDED,
'void', 'voided' => self::PAYMENT_VOIDED,
'failed' => self::PAYMENT_FAILED,
'refunded' => self::PAYMENT_REFUNDED,
'chargeback' => self::PAYMENT_CHARGEBACK,
'declined' => self::PAYMENT_DECLINED,
'reversed' => self::PAYMENT_REVERSED,
'cancelled' => self::PAYMENT_CANCELLED,
'canceled' => self::PAYMENT_CANCELED,
default => self::normalize($status),
};
}
public static function normalizeRefundStatus(?string $status): string
{
return match (self::normalize($status)) {
'approved' => self::REFUND_APPROVED,
'rejected' => self::REFUND_REJECTED,
'partial', 'partially paid', 'partially_paid' => self::REFUND_PARTIALLY_PAID,
'paid', 'full' => self::REFUND_PAID,
'void', 'voided' => self::REFUND_VOIDED,
default => self::REFUND_PENDING,
};
}
public static function normalize(?string $status): string
{
$value = strtolower(trim((string) $status));
$value = str_replace('_', ' ', $value);
$value = preg_replace('/\s+/', ' ', $value) ?? $value;
return $value;
}
}
+343
View File
@@ -0,0 +1,343 @@
<?php
namespace App\Libraries;
use App\Interfaces\TuitionCalculatorInterface;
use App\Libraries\Tuition\NewTuitionCalculatorService;
use App\Libraries\Tuition\OldTuitionCalculatorService;
use App\Models\AdditionalChargeModel;
use App\Models\ClassSectionModel;
use App\Models\ConfigurationModel;
use App\Models\DiscountUsageModel;
use App\Models\EnrollmentModel;
use App\Models\EventChargesModel;
use App\Models\InvoiceEventModel;
use App\Models\InvoiceModel;
use App\Models\PaymentModel;
use App\Models\RefundModel;
use App\Models\StudentClassModel;
use App\Models\StudentModel;
class InvoiceLedgerService
{
protected InvoiceModel $invoiceModel;
protected PaymentModel $paymentModel;
protected RefundModel $refundModel;
protected DiscountUsageModel $discountUsageModel;
protected AdditionalChargeModel $additionalChargeModel;
protected ConfigurationModel $configurationModel;
protected EnrollmentModel $enrollmentModel;
protected StudentClassModel $studentClassModel;
protected ClassSectionModel $classSectionModel;
protected EventChargesModel $eventChargesModel;
protected InvoiceEventModel $invoiceEventModel;
protected StudentModel $studentModel;
protected TuitionCalculatorInterface $oldCalculator;
protected TuitionCalculatorInterface $newCalculator;
public function __construct()
{
$this->invoiceModel = new InvoiceModel();
$this->paymentModel = new PaymentModel();
$this->refundModel = new RefundModel();
$this->discountUsageModel = new DiscountUsageModel();
$this->additionalChargeModel = new AdditionalChargeModel();
$this->configurationModel = new ConfigurationModel();
$this->enrollmentModel = new EnrollmentModel();
$this->studentClassModel = new StudentClassModel();
$this->classSectionModel = new ClassSectionModel();
$this->eventChargesModel = new EventChargesModel();
$this->invoiceEventModel = new InvoiceEventModel();
$this->studentModel = new StudentModel();
$this->oldCalculator = new OldTuitionCalculatorService();
$this->newCalculator = new NewTuitionCalculatorService();
}
public function calculateInvoice(int $invoiceId): array
{
$invoice = $this->loadInvoice($invoiceId);
if ($invoice === null) {
throw new \RuntimeException('Invoice not found.');
}
$tuitionTotal = $this->calculateTuitionTotal($invoice);
$eventTotal = $this->calculateEventTotal($invoice);
$additionalTotal = $this->calculateAdditionalCharges($invoiceId);
$discountRawTotal = $this->calculateDiscounts($invoiceId);
$paidTotal = $this->calculateValidPayments($invoiceId);
$refundPaidTotal = $this->calculatePaidRefunds($invoiceId);
$tuitionCents = $this->toCents($tuitionTotal);
$eventCents = $this->toCents($eventTotal);
$additionalCents = $this->toCents($additionalTotal);
$discountRawCents = $this->toCents($discountRawTotal);
$paidCents = $this->toCents($paidTotal);
$refundPaidCents = $this->toCents($refundPaidTotal);
$discountBaseCents = max(0, $tuitionCents + $additionalCents);
$discountCents = min($discountRawCents, $discountBaseCents);
$totalAmountCents = $tuitionCents + $eventCents + $additionalCents;
$balanceCents = max(0, $totalAmountCents - $discountCents - $paidCents - $refundPaidCents);
if ($balanceCents === 0) {
$status = FinancialStatus::INVOICE_PAID;
} elseif ($paidCents > 0 || $discountCents > 0 || $refundPaidCents > 0) {
$status = FinancialStatus::INVOICE_PARTIALLY_PAID;
} else {
$status = FinancialStatus::INVOICE_UNPAID;
}
return [
'invoice_id' => $invoiceId,
'tuition_total' => $this->fromCents($tuitionCents),
'event_total' => $this->fromCents($eventCents),
'additional_total' => $this->fromCents($additionalCents),
'discount_total' => $this->fromCents($discountCents),
'discount_raw_total' => $this->fromCents($discountRawCents),
'paid_amount' => $this->fromCents($paidCents),
'refund_paid_total' => $this->fromCents($refundPaidCents),
'total_amount' => $this->fromCents($totalAmountCents),
'balance' => $this->fromCents($balanceCents),
'status' => $status,
'has_discount' => $discountCents > 0 ? 1 : 0,
];
}
public function recalculateInvoice(int $invoiceId): array
{
$calculation = $this->calculateInvoice($invoiceId);
$payload = [
'total_amount' => $calculation['total_amount'],
'paid_amount' => $calculation['paid_amount'],
'balance' => $calculation['balance'],
'status' => $calculation['status'],
'has_discount' => $calculation['has_discount'],
'updated_at' => utc_now(),
];
if ($this->invoiceModel->db->fieldExists('discount', $this->invoiceModel->table)) {
$payload['discount'] = $calculation['discount_total'];
}
$this->invoiceModel->update($invoiceId, $payload);
return $calculation;
}
protected function loadInvoice(int $invoiceId): ?array
{
return $this->invoiceModel->find($invoiceId);
}
protected function calculateTuitionTotal(array $invoice): float
{
$parentId = (int) ($invoice['parent_id'] ?? 0);
$schoolYear = (string) ($invoice['school_year'] ?? '');
if ($parentId <= 0 || $schoolYear === '') {
return 0.0;
}
$students = $this->loadTuitionStudents($parentId, $schoolYear);
$config = $this->getTuitionConfig();
$calculator = $this->resolveActiveCalculator();
return (float) ($calculator->calculateFamilyTuition($students, $config)['total'] ?? 0.0);
}
protected function calculateEventTotal(array $invoice): float
{
$invoiceId = (int) ($invoice['id'] ?? 0);
$invoiceEventRows = $this->invoiceEventModel
->select('COALESCE(SUM(amount),0) AS total_amount')
->where('invoice_id', $invoiceId)
->findAll();
if (!empty($invoiceEventRows)) {
$total = (float) ($invoiceEventRows[0]['total_amount'] ?? 0);
if ($total > 0) {
return $total;
}
}
$rows = $this->eventChargesModel
->select('COALESCE(SUM(charged),0) AS total_amount')
->where('parent_id', (int) ($invoice['parent_id'] ?? 0))
->where('school_year', (string) ($invoice['school_year'] ?? ''))
->where('semester', (string) ($invoice['semester'] ?? ''))
->findAll();
return (float) ($rows[0]['total_amount'] ?? 0);
}
protected function calculateAdditionalCharges(int $invoiceId): float
{
$rows = $this->additionalChargeModel
->select('COALESCE(SUM(amount),0) AS total_amount')
->where('invoice_id', $invoiceId)
->whereNotIn('status', ['void', FinancialStatus::ADDITIONAL_CHARGE_VOIDED, 'cancelled', 'canceled'])
->findAll();
return (float) ($rows[0]['total_amount'] ?? 0);
}
protected function calculateDiscounts(int $invoiceId): float
{
$row = $this->discountUsageModel
->select('COALESCE(SUM(discount_amount),0) AS total_amount')
->where('invoice_id', $invoiceId)
->first();
return (float) ($row['total_amount'] ?? 0);
}
protected function calculateValidPayments(int $invoiceId): float
{
$query = $this->paymentModel
->select('COALESCE(SUM(paid_amount),0) AS total_amount')
->where('invoice_id', $invoiceId);
if ($this->paymentModel->db->fieldExists('status', $this->paymentModel->table)) {
$query->groupStart()
->whereNotIn('status', FinancialStatus::EXCLUDED_PAYMENT_STATUSES)
->orWhere('status IS NULL', null, false)
->groupEnd();
}
if ($this->paymentModel->db->fieldExists('is_void', $this->paymentModel->table)) {
$query->groupStart()
->where('is_void', 0)
->orWhere('is_void IS NULL', null, false)
->groupEnd();
}
$row = $query->first();
return (float) ($row['total_amount'] ?? 0);
}
protected function calculatePaidRefunds(int $invoiceId): float
{
$row = $this->refundModel
->select('COALESCE(SUM(refund_paid_amount),0) AS total_amount')
->where('invoice_id', $invoiceId)
->whereIn('status', FinancialStatus::REFUND_REDUCES_INVOICE_STATUSES)
->first();
return (float) ($row['total_amount'] ?? 0);
}
protected function loadTuitionStudents(int $parentId, string $schoolYear): array
{
$enrollments = $this->enrollmentModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->findAll();
if (empty($enrollments)) {
return [];
}
$refundDeadline = (string) ($this->configurationModel->getConfig('refund_deadline') ?? '');
$includeWithdrawn = !$this->isWithinRefundWindow($refundDeadline);
$eligibleStatuses = ['enrolled', 'payment pending'];
if ($includeWithdrawn) {
array_push($eligibleStatuses, 'withdrawn', 'refund pending', 'withdraw under review');
}
$students = [];
foreach ($enrollments as $enrollment) {
$status = strtolower(trim((string) ($enrollment['enrollment_status'] ?? '')));
$studentId = (int) ($enrollment['student_id'] ?? 0);
if ($studentId <= 0 || !in_array($status, $eligibleStatuses, true)) {
continue;
}
if (!$this->studentClassModel->hasNonEventAssignment($studentId, $schoolYear)) {
continue;
}
$gradeName = $this->resolveGradeName($studentId, $schoolYear, $enrollment['class_section_id'] ?? null);
$student = $this->studentModel->find($studentId) ?? [];
$students[] = [
'student_id' => $studentId,
'student_name' => trim(((string) ($student['firstname'] ?? '')) . ' ' . ((string) ($student['lastname'] ?? ''))),
'grade_level' => $gradeName,
];
}
return $students;
}
protected function resolveGradeName(int $studentId, string $schoolYear, $classSectionId = null): string
{
$sectionId = $classSectionId;
if (empty($sectionId)) {
$row = $this->studentClassModel
->select('class_section_id')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->orderBy('updated_at', 'DESC')
->first();
$sectionId = $row['class_section_id'] ?? null;
}
if (empty($sectionId)) {
return 'N/A';
}
$name = $this->classSectionModel->getClassSectionNameBySectionId($sectionId);
return is_string($name) && $name !== '' ? strtoupper(trim($name)) : 'N/A';
}
protected function resolveActiveCalculator(): TuitionCalculatorInterface
{
$version = strtolower(trim((string) ($this->configurationModel->getConfig('tuition_calculator_version') ?? 'old')));
return $version === 'new' ? $this->newCalculator : $this->oldCalculator;
}
protected function getTuitionConfig(): array
{
return [
'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'),
'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'),
];
}
protected function isWithinRefundWindow(string $refundDeadline): bool
{
if ($refundDeadline === '') {
return true;
}
try {
$timeZone = new \DateTimeZone((string) (config('School')->attendance['timezone'] ?? user_timezone()));
$today = new \DateTimeImmutable('today', $timeZone);
$deadline = new \DateTimeImmutable($refundDeadline, $timeZone);
return $today <= $deadline;
} catch (\Throwable $e) {
return true;
}
}
protected function toCents($amount): int
{
return (int) round(((float) $amount) * 100);
}
protected function fromCents(int $cents): string
{
return number_format($cents / 100, 2, '.', '');
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Libraries\Tuition;
final class GradeLevelParser
{
public static function parse($grade, int $gradeFee = 9): int
{
if (is_numeric($grade)) {
return (int) $grade;
}
if (!is_string($grade)) {
return 999;
}
$value = strtoupper(trim($grade));
$value = preg_replace('/\s+/', ' ', $value) ?? $value;
$value = str_replace(['.', '_', '-'], ['', '', ' '], $value);
if (in_array($value, ['K', 'KG', 'K G', 'KINDER', 'KINDERGARTEN'], true)) {
return 1;
}
if (in_array($value, ['PK', 'P K', 'PREK', 'PRE K', 'PRE KINDER', 'PREKINDER'], true)) {
return -1;
}
if (preg_match('/^Y(?:OUTH)?\s*(\d+)?$/', $value, $matches)) {
$offset = isset($matches[1]) && $matches[1] !== '' ? max(1, (int) $matches[1]) : 1;
return $gradeFee + $offset;
}
if (preg_match('/^(?:GR?ADE\s*)?(\d{1,2})\s*([A-Z]*)$/', $value, $matches)) {
return (int) $matches[1];
}
return 999;
}
}
@@ -0,0 +1,89 @@
<?php
namespace App\Libraries\Tuition;
use App\Interfaces\TuitionCalculatorInterface;
final class NewTuitionCalculatorService implements TuitionCalculatorInterface
{
public function calculateFamilyTuition(array $students, array $config): array
{
$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);
usort($students, function (array $left, array $right) use ($gradeFee): int {
$leftLevel = GradeLevelParser::parse($left['grade_level'] ?? null, $gradeFee);
$rightLevel = GradeLevelParser::parse($right['grade_level'] ?? null, $gradeFee);
return [$leftLevel, (int) ($left['student_id'] ?? 0)] <=> [$rightLevel, (int) ($right['student_id'] ?? 0)];
});
$details = [];
$regularPosition = 0;
foreach (array_values($students) as $student) {
$level = GradeLevelParser::parse($student['grade_level'] ?? null, $gradeFee);
if ($level > $gradeFee) {
$position = null;
$discountCents = 0;
$rule = 'new_youth_unit_price';
$baseAmountCents = $youthAmountCents;
} else {
$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, $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($baseAmountCents),
'discount' => $this->fromCents($discountCents),
'rule' => $rule,
'amount' => $this->fromCents($amountCents),
];
}
$total = array_sum(array_map(fn (array $detail): int => $this->toCents($detail['amount']), $details));
return [
'calculator' => 'new',
'total' => $this->fromCents($total),
'details' => $details,
];
}
private function toCents($amount): int
{
return (int) round(((float) $amount) * 100);
}
private function fromCents(int $cents): string
{
return number_format($cents / 100, 2, '.', '');
}
}
@@ -0,0 +1,65 @@
<?php
namespace App\Libraries\Tuition;
use App\Interfaces\TuitionCalculatorInterface;
final class OldTuitionCalculatorService implements TuitionCalculatorInterface
{
public function calculateFamilyTuition(array $students, array $config): array
{
$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'] ?? 200);
usort($students, function (array $left, array $right) use ($gradeFee): int {
$leftLevel = GradeLevelParser::parse($left['grade_level'] ?? null, $gradeFee);
$rightLevel = GradeLevelParser::parse($right['grade_level'] ?? null, $gradeFee);
return [$leftLevel, (int) ($left['student_id'] ?? 0)] <=> [$rightLevel, (int) ($right['student_id'] ?? 0)];
});
$regularCount = 0;
$details = [];
foreach ($students as $student) {
$level = GradeLevelParser::parse($student['grade_level'] ?? null, $gradeFee);
if ($level > $gradeFee) {
$amountCents = $youthFee;
$rule = 'old_youth_fee';
} else {
$regularCount++;
$amountCents = $regularCount === 1 ? $firstStudentFee : $secondStudentFee;
$rule = $regularCount === 1 ? 'old_first_student_fee' : 'old_second_student_fee';
}
$details[] = [
'student_id' => (int) ($student['student_id'] ?? 0),
'student_name' => (string) ($student['student_name'] ?? ''),
'grade_level' => $student['grade_level'] ?? null,
'rule' => $rule,
'amount' => $this->fromCents($amountCents),
];
}
$total = array_sum(array_map(fn (array $detail): int => $this->toCents($detail['amount']), $details));
return [
'calculator' => 'old',
'total' => $this->fromCents($total),
'details' => $details,
];
}
private function toCents($amount): int
{
return (int) round(((float) $amount) * 100);
}
private function fromCents(int $cents): string
{
return number_format($cents / 100, 2, '.', '');
}
}
@@ -0,0 +1,663 @@
<?php
namespace App\Libraries\Tuition;
use App\Libraries\FinancialStatus;
use App\Models\ClassSectionModel;
use App\Models\ConfigurationModel;
use App\Models\EnrollmentModel;
use App\Models\PaymentModel;
use App\Models\RefundModel;
use App\Models\StudentClassModel;
use App\Models\UserModel;
class TuitionForecastService
{
protected ConfigurationModel $configurationModel;
protected EnrollmentModel $enrollmentModel;
protected StudentClassModel $studentClassModel;
protected ClassSectionModel $classSectionModel;
protected PaymentModel $paymentModel;
protected RefundModel $refundModel;
protected UserModel $userModel;
protected OldTuitionCalculatorService $oldCalculator;
protected NewTuitionCalculatorService $newCalculator;
public function __construct()
{
$this->configurationModel = new ConfigurationModel();
$this->enrollmentModel = new EnrollmentModel();
$this->studentClassModel = new StudentClassModel();
$this->classSectionModel = new ClassSectionModel();
$this->paymentModel = new PaymentModel();
$this->refundModel = new RefundModel();
$this->userModel = new UserModel();
$this->oldCalculator = new OldTuitionCalculatorService();
$this->newCalculator = new NewTuitionCalculatorService();
}
public function calculate(string $schoolYear, string $semester, string $mode = 'compare', array $options = []): array
{
$schoolYear = trim($schoolYear) !== '' ? trim($schoolYear) : $this->getDefaultSchoolYear();
$semester = trim($semester) !== '' ? trim($semester) : $this->getDefaultSemester();
$mode = $this->normalizeMode($mode);
$options = $this->normalizeOptions($options);
$this->unitPriceOverride = $options['unit_price'];
$this->youthUnitPriceOverride = $options['youth_unit_price'];
$tuitionConfig = $this->getTuitionConfig();
$familyRows = [];
$summary = [
'family_count' => 0,
'student_count' => 0,
'billable_student_count' => 0,
'old_projected_tuition' => '0.00',
'new_projected_tuition' => '0.00',
'old_projected_income' => '0.00',
'new_projected_income' => '0.00',
'difference' => '0.00',
'projected_tuition' => '0.00',
'projected_income' => '0.00',
'unit_price' => '0.00',
'youth_unit_price' => '0.00',
];
$oldProjectedCents = 0;
$newProjectedCents = 0;
$studentCount = 0;
$billableStudentCount = 0;
foreach ($this->loadFamilies($schoolYear, $semester) as $family) {
$parentId = (int) ($family['parent_id'] ?? 0);
if ($parentId <= 0) {
continue;
}
$studentContext = $this->loadFamilyStudents($parentId, $schoolYear, $semester, $options);
$students = $studentContext['students'];
$allStudents = $studentContext['all_students'];
$warnings = $studentContext['warnings'];
if (empty($allStudents)) {
continue;
}
$oldResult = $this->oldCalculator->calculateFamilyTuition($students, $tuitionConfig);
$newResult = $this->newCalculator->calculateFamilyTuition($students, $tuitionConfig);
$oldTotalCents = $this->toCents($oldResult['total'] ?? 0);
$newTotalCents = $this->toCents($newResult['total'] ?? 0);
$mergedDetails = $this->mergeStudentDetails($allStudents, $oldResult['details'] ?? [], $newResult['details'] ?? []);
$studentCount += count($allStudents);
$billableStudentCount += count($students);
$oldProjectedCents += $oldTotalCents;
$newProjectedCents += $newTotalCents;
$familyRows[] = [
'parent_id' => $parentId,
'parent_name' => $family['parent_name'] ?? ('Parent #' . $parentId),
'student_count' => count($allStudents),
'billable_student_count' => count($students),
'old_total' => $this->fromCents($oldTotalCents),
'new_total' => $this->fromCents($newTotalCents),
'difference' => $this->fromCents($newTotalCents - $oldTotalCents),
'warnings' => array_values(array_unique($warnings)),
'student_details' => $mergedDetails,
];
}
usort($familyRows, static fn (array $left, array $right): int => strcmp((string) ($left['parent_name'] ?? ''), (string) ($right['parent_name'] ?? '')));
$summary['family_count'] = count($familyRows);
$summary['student_count'] = $studentCount;
$summary['billable_student_count'] = $billableStudentCount;
$summary['old_projected_tuition'] = $this->fromCents($oldProjectedCents);
$summary['new_projected_tuition'] = $this->fromCents($newProjectedCents);
$summary['old_projected_income'] = $summary['old_projected_tuition'];
$summary['new_projected_income'] = $summary['new_projected_tuition'];
$summary['difference'] = $this->fromCents($newProjectedCents - $oldProjectedCents);
$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,
'semester' => $semester,
'calculator_mode' => $mode,
'options' => $options,
'summary' => $summary,
'families' => $familyRows,
];
}
public function getAvailableSchoolYears(): array
{
$values = [];
if ($this->enrollmentModel->db->tableExists('enrollments')) {
$rows = $this->enrollmentModel->db->table('enrollments')
->select('school_year')
->where('school_year IS NOT NULL', null, false)
->where('school_year !=', '')
->groupBy('school_year')
->orderBy('school_year', 'DESC')
->get()
->getResultArray();
foreach ($rows as $row) {
$value = trim((string) ($row['school_year'] ?? ''));
if ($value !== '') {
$values[$value] = $value;
}
}
}
if ($this->paymentModel->db->tableExists('invoices')) {
$rows = $this->paymentModel->db->table('invoices')
->select('school_year')
->where('school_year IS NOT NULL', null, false)
->where('school_year !=', '')
->groupBy('school_year')
->orderBy('school_year', 'DESC')
->get()
->getResultArray();
foreach ($rows as $row) {
$value = trim((string) ($row['school_year'] ?? ''));
if ($value !== '') {
$values[$value] = $value;
}
}
}
if ($values === []) {
$default = $this->getDefaultSchoolYear();
if ($default !== '') {
$values[$default] = $default;
}
}
krsort($values);
return array_values($values);
}
public function getAvailableSemesters(): array
{
$values = [];
if ($this->enrollmentModel->db->tableExists('enrollments')) {
$rows = $this->enrollmentModel->db->table('enrollments')
->select('semester')
->where('semester IS NOT NULL', null, false)
->where('semester !=', '')
->groupBy('semester')
->orderBy('semester', 'ASC')
->get()
->getResultArray();
foreach ($rows as $row) {
$value = trim((string) ($row['semester'] ?? ''));
if ($value !== '') {
$values[$value] = $value;
}
}
}
if ($values === []) {
$default = $this->getDefaultSemester();
if ($default !== '') {
$values[$default] = $default;
}
}
return array_values($values);
}
protected function normalizeMode(string $mode): string
{
$value = strtolower(trim($mode));
return in_array($value, ['old', 'new'], true) ? $value : 'compare';
}
protected function normalizeOptions(array $options): array
{
$includeWithdrawnMode = strtolower(trim((string) ($options['include_withdrawn_mode'] ?? 'refund_deadline')));
if (!in_array($includeWithdrawnMode, ['refund_deadline', 'include', 'exclude'], true)) {
$includeWithdrawnMode = 'refund_deadline';
}
return [
'include_withdrawn_mode' => $includeWithdrawnMode,
'include_payment_pending' => $this->toBool($options['include_payment_pending'] ?? true),
'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),
];
}
protected function loadFamilies(string $schoolYear, string $semester): array
{
$builder = $this->enrollmentModel->db->table('enrollments e')
->select("e.parent_id, CONCAT(COALESCE(u.lastname, ''), ', ', COALESCE(u.firstname, '')) AS parent_name", false)
->join('users u', 'u.id = e.parent_id', 'left')
->where('e.school_year', $schoolYear)
->groupBy('e.parent_id')
->orderBy('parent_name', 'ASC');
$this->applyEnrollmentSemesterFilter($builder, 'e', $semester);
return $builder->get()->getResultArray();
}
protected function loadFamilyStudents(int $parentId, string $schoolYear, string $semester, array $options): array
{
$rows = $this->enrollmentModel->db->table('enrollments e')
->select('e.*, s.firstname, s.lastname, s.is_active')
->join('students s', 's.id = e.student_id', 'left')
->where('e.parent_id', $parentId)
->where('e.school_year', $schoolYear)
->orderBy('e.updated_at', 'DESC')
->orderBy('e.id', 'DESC')
->get()
->getResultArray();
$latestByStudent = [];
foreach ($rows as $row) {
$studentId = (int) ($row['student_id'] ?? 0);
if ($studentId <= 0 || isset($latestByStudent[$studentId])) {
continue;
}
if (!$this->matchesEnrollmentSemester($row, $semester)) {
continue;
}
$latestByStudent[$studentId] = $row;
}
ksort($latestByStudent);
$withinRefundWindow = $this->isWithinRefundWindow((string) ($this->configurationModel->getConfig('refund_deadline') ?? ''));
$students = [];
$allStudents = [];
$warnings = [];
$eventOnlyCount = 0;
$missingClassCount = 0;
$inactiveCount = 0;
$pendingSkippedCount = 0;
foreach ($latestByStudent as $row) {
$studentId = (int) ($row['student_id'] ?? 0);
$studentName = trim(((string) ($row['firstname'] ?? '')) . ' ' . ((string) ($row['lastname'] ?? '')));
$studentLabel = $studentName !== '' ? $studentName : ('Student #' . $studentId);
if ((int) ($row['is_active'] ?? 1) === 0) {
$inactiveCount++;
$allStudents[] = [
'student_id' => $studentId,
'student_name' => $studentLabel,
'grade_level' => 'N/A',
'billable' => false,
'excluded_reason' => 'inactive',
];
continue;
}
$inclusion = $this->resolveEnrollmentInclusion($row, $options, $withinRefundWindow);
if (!$inclusion['include']) {
if (($inclusion['reason'] ?? '') === 'payment_pending_excluded') {
$pendingSkippedCount++;
}
continue;
}
$classFlags = $this->resolveStudentClassFlags($studentId, $schoolYear);
$gradeName = $this->resolveGradeName($studentId, $schoolYear, $row['class_section_id'] ?? null);
$studentBase = [
'student_id' => $studentId,
'student_name' => $studentLabel,
'grade_level' => $gradeName,
'billable' => false,
'excluded_reason' => null,
];
if (!$classFlags['has_any_assignment']) {
$missingClassCount++;
$studentBase['excluded_reason'] = 'missing_class_assignment';
$allStudents[] = $studentBase;
continue;
}
if (!$classFlags['has_non_event_assignment']) {
$eventOnlyCount++;
$studentBase['excluded_reason'] = 'event_only';
$allStudents[] = $studentBase;
if ($options['include_event_only']) {
$warnings[] = $studentLabel . ' is event-only and excluded from tuition billing.';
}
continue;
}
if ($gradeName === 'N/A') {
$warnings[] = $studentLabel . ' has no resolved class section grade.';
}
$studentBase['billable'] = true;
$allStudents[] = $studentBase;
$students[] = [
'student_id' => $studentId,
'student_name' => $studentLabel,
'grade_level' => $gradeName,
];
}
if ($eventOnlyCount > 0) {
$warnings[] = $eventOnlyCount . ' event-only student(s) excluded from tuition.';
}
if ($missingClassCount > 0) {
$warnings[] = $missingClassCount . ' student(s) missing class assignments.';
}
if ($inactiveCount > 0) {
$warnings[] = $inactiveCount . ' inactive student(s) skipped.';
}
if ($pendingSkippedCount > 0) {
$warnings[] = $pendingSkippedCount . ' payment-pending student(s) skipped by filter.';
}
if ($this->toCents($this->configurationModel->getConfig('new_tuition_full_amount') ?? 0) <= 0) {
$warnings[] = 'New tuition full amount is missing or zero.';
}
return [
'students' => $students,
'all_students' => $allStudents,
'warnings' => array_values(array_unique($warnings)),
];
}
protected function resolveEnrollmentInclusion(array $row, array $options, bool $withinRefundWindow): array
{
$admissionStatus = strtolower(trim((string) ($row['admission_status'] ?? '')));
$enrollmentStatus = strtolower(trim((string) ($row['enrollment_status'] ?? '')));
if ($admissionStatus === 'denied' || $enrollmentStatus === 'admission under review') {
return ['include' => false, 'reason' => 'not_admitted'];
}
if ($enrollmentStatus === 'payment pending') {
return ['include' => $options['include_payment_pending'], 'reason' => 'payment_pending_excluded'];
}
if ($enrollmentStatus === 'enrolled') {
return ['include' => true, 'reason' => null];
}
if (in_array($enrollmentStatus, ['withdrawn', 'refund pending', 'withdraw under review'], true)) {
if ($options['include_withdrawn_mode'] === 'include') {
return ['include' => true, 'reason' => null];
}
if ($options['include_withdrawn_mode'] === 'exclude') {
return ['include' => false, 'reason' => 'withdrawn_excluded'];
}
return ['include' => !$withinRefundWindow, 'reason' => 'refund_deadline_excluded'];
}
return ['include' => $admissionStatus === 'accepted', 'reason' => 'accepted_only'];
}
protected function resolveStudentClassFlags(int $studentId, string $schoolYear): array
{
$rows = $this->studentClassModel->where('student_id', $studentId)
->where('school_year', $schoolYear)
->findAll();
$hasAnyAssignment = !empty($rows);
$hasNonEventAssignment = false;
$hasEventOnlyAssignment = false;
foreach ($rows as $row) {
$isEventOnly = (int) ($row['is_event_only'] ?? 0) === 1;
$hasEventOnlyAssignment = $hasEventOnlyAssignment || $isEventOnly;
$hasNonEventAssignment = $hasNonEventAssignment || !$isEventOnly;
}
return [
'has_any_assignment' => $hasAnyAssignment,
'has_non_event_assignment' => $hasNonEventAssignment,
'has_event_only_assignment' => $hasEventOnlyAssignment,
];
}
protected function resolveGradeName(int $studentId, string $schoolYear, $classSectionId = null): string
{
$sectionId = $classSectionId;
if (empty($sectionId)) {
$row = $this->studentClassModel
->select('class_section_id')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('is_event_only', 0)
->orderBy('updated_at', 'DESC')
->first();
$sectionId = $row['class_section_id'] ?? null;
}
if (empty($sectionId)) {
return 'N/A';
}
$name = $this->classSectionModel->getClassSectionNameBySectionId($sectionId);
return is_string($name) && trim($name) !== '' ? strtoupper(trim($name)) : 'N/A';
}
protected function calculateAlreadyCollected(int $parentId, string $schoolYear, string $semester): int
{
$paymentBuilder = $this->paymentModel->db->table('payments')
->select('COALESCE(SUM(paid_amount),0) AS total_amount')
->where('parent_id', $parentId)
->where('school_year', $schoolYear);
if ($semester !== '') {
$paymentBuilder->where('semester', $semester);
}
if ($this->paymentModel->db->fieldExists('status', 'payments')) {
$paymentBuilder->groupStart()
->whereNotIn('status', FinancialStatus::EXCLUDED_PAYMENT_STATUSES)
->orWhere('status IS NULL', null, false)
->groupEnd();
}
if ($this->paymentModel->db->fieldExists('is_void', 'payments')) {
$paymentBuilder->groupStart()
->where('is_void', 0)
->orWhere('is_void IS NULL', null, false)
->groupEnd();
}
$paymentRow = $paymentBuilder->get()->getRowArray();
$paidCents = $this->toCents($paymentRow['total_amount'] ?? 0);
$refundBuilder = $this->refundModel->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS total_amount')
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->whereIn('status', FinancialStatus::REFUND_REDUCES_INVOICE_STATUSES);
if ($semester !== '' && $this->refundModel->db->fieldExists('semester', 'refunds')) {
$refundBuilder->where('semester', $semester);
}
$refundRow = $refundBuilder->get()->getRowArray();
$refundCents = $this->toCents($refundRow['total_amount'] ?? 0);
return max(0, $paidCents - $refundCents);
}
protected function mergeStudentDetails(array $allStudents, array $oldDetails, array $newDetails): array
{
$oldMap = [];
foreach ($oldDetails as $detail) {
$oldMap[(int) ($detail['student_id'] ?? 0)] = $detail;
}
$newMap = [];
foreach ($newDetails as $detail) {
$newMap[(int) ($detail['student_id'] ?? 0)] = $detail;
}
$rows = [];
foreach ($allStudents as $student) {
$studentId = (int) ($student['student_id'] ?? 0);
$old = $oldMap[$studentId] ?? null;
$new = $newMap[$studentId] ?? null;
$rows[] = [
'student_id' => $studentId,
'student_name' => $student['student_name'] ?? '',
'grade_level' => $student['grade_level'] ?? 'N/A',
'billable' => (bool) ($student['billable'] ?? false),
'excluded_reason' => $student['excluded_reason'] ?? null,
'old_rule' => $old['rule'] ?? null,
'old_amount' => $old['amount'] ?? '0.00',
'new_rule' => $new['rule'] ?? null,
'new_amount' => $new['amount'] ?? '0.00',
];
}
return $rows;
}
protected function getTuitionConfig(): array
{
return [
'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') ?? '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'),
];
}
protected ?string $unitPriceOverride = null;
protected ?string $youthUnitPriceOverride = null;
protected function normalizedUnitPriceOverride(): string
{
return $this->unitPriceOverride
?? (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) === '') {
return null;
}
$normalized = preg_replace('/[^0-9.]+/', '', (string) $value);
if ($normalized === '' || !is_numeric($normalized)) {
return null;
}
$amount = (float) $normalized;
return $amount > 0 ? number_format($amount, 2, '.', '') : null;
}
protected function isWithinRefundWindow(string $refundDeadline): bool
{
if ($refundDeadline === '') {
return true;
}
try {
$timeZone = new \DateTimeZone((string) (config('School')->attendance['timezone'] ?? user_timezone()));
$today = new \DateTimeImmutable('today', $timeZone);
$deadline = new \DateTimeImmutable($refundDeadline, $timeZone);
return $today <= $deadline;
} catch (\Throwable $e) {
return true;
}
}
protected function getDefaultSchoolYear(): string
{
return (string) ($this->configurationModel->getConfig('school_year') ?? '');
}
protected function getDefaultSemester(): string
{
return '';
}
protected function applyEnrollmentSemesterFilter($builder, string $alias, string $semester): void
{
if ($semester === '') {
return;
}
$builder->groupStart()
->where($alias . '.semester', $semester)
->orWhere($alias . '.semester', '')
->orWhere($alias . '.semester IS NULL', null, false)
->groupEnd();
}
protected function matchesEnrollmentSemester(array $row, string $semester): bool
{
if ($semester === '') {
return true;
}
$value = trim((string) ($row['semester'] ?? ''));
return $value === '' || strcasecmp($value, $semester) === 0;
}
protected function toBool($value): bool
{
if (is_bool($value)) {
return $value;
}
$normalized = strtolower(trim((string) $value));
return in_array($normalized, ['1', 'true', 'yes', 'on'], true);
}
protected function toCents($amount): int
{
return (int) round(((float) $amount) * 100);
}
protected function fromCents(int $cents): string
{
return number_format($cents / 100, 2, '.', '');
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ class AdditionalChargeModel extends Model
'description' => 'permit_empty|string',
'amount' => 'required|decimal',
'due_date' => 'permit_empty|valid_date',
'status' => 'required|in_list[pending,applied]',
'status' => 'required|in_list[pending,applied,voided]',
'created_by' => 'permit_empty|integer',
];
-1
View File
@@ -18,7 +18,6 @@ class AttendanceDataModel extends Model
'date',
'status',
'reason',
'reported',
'is_reported',
'is_notified',
'semester',
+2 -2
View File
@@ -7,6 +7,6 @@ class ClassPrepAdjustmentModel extends Model
{
protected $table = 'class_prep_adjustments';
protected $allowedFields = [
'class_section_id', 'item_name', 'adjustment', 'school_year', 'created_at'
'class_section_id', 'item_name', 'adjustment', 'adjustable', 'created_at'
];
}
}
-1
View File
@@ -11,7 +11,6 @@ class ClassPreparationLogModel extends Model
protected $allowedFields = [
'class_section_id',
'class_section',
'school_year',
'prep_data',
'created_at',
];
+2 -9
View File
@@ -19,8 +19,6 @@ class ContactUsModel extends Model
'reciever_id',
'subject',
'message',
'semester',
'school_year',
'created_at',
'updated_at'
];
@@ -35,8 +33,6 @@ class ContactUsModel extends Model
'reciever_id' => 'required|integer',
'subject' => 'required|string|max_length[255]',
'message' => 'required|string',
'semester' => 'required|string|max_length[255]',
'school_year' => 'permit_empty|string|max_length[9]'
];
protected $validationMessages = [];
@@ -51,10 +47,7 @@ class ContactUsModel extends Model
*/
public function getMessagesBySemesterAndYear($semester, $school_year)
{
return $this->where([
'semester' => $semester,
'school_year' => $school_year
])->findAll();
return $this->findAll();
}
/**
@@ -92,4 +85,4 @@ class ContactUsModel extends Model
{
return $this->update($id, $data);
}
}
}
+10 -3
View File
@@ -6,12 +6,19 @@ use CodeIgniter\Model;
class EmailTemplateModel extends Model {
protected $table = 'email_templates';
protected $primaryKey = 'id';
protected $allowedFields = ['template_key','name','subject','body','is_active'];
protected $allowedFields = ['code', 'variant', 'subject', 'body_html', 'is_active', 'updated_by', 'updated_at'];
public function getActiveTemplates(): array {
return $this->where('is_active', 1)->orderBy('template_key','asc')->findAll();
return $this->select('email_templates.*, code AS template_key, code AS name, body_html AS body')
->where('is_active', 1)
->orderBy('code', 'asc')
->findAll();
}
public function findByKey(string $key): ?array {
return $this->where('template_key', $key)->where('is_active', 1)->first();
return $this->select('email_templates.*, code AS template_key, code AS name, body_html AS body')
->where('code', $key)
->where('is_active', 1)
->first();
}
}
-2
View File
@@ -16,8 +16,6 @@ class EmergencyContactModel extends Model
'cellphone',
'email',
'relation',
'semester',
'school_year',
'created_at',
'updated_at'
];
+2 -2
View File
@@ -86,7 +86,7 @@ class EnrollmentModel extends Model
/**
* Get all enrolled students (full student info) for a given parent.
*/
public function getEnrolledStudents(int $parentId, string $schoolYear = null, string $semester = null): array
public function getEnrolledStudents(int $parentId, ?string $schoolYear = null, ?string $semester = null): array
{
$builder = $this->select('students.*')
->join('students', 'students.id = enrollments.student_id')
@@ -106,7 +106,7 @@ class EnrollmentModel extends Model
/**
* Get student basic info (ID, name, grade) for a given parent where status is enrolled or Payment pending.
*/
public function getenrolledStudentDetails(int $parentId, string $schoolYear = null): array
public function getenrolledStudentDetails(int $parentId, ?string $schoolYear = null): array
{
$builder = $this->db->table('enrollments')
->select('students.id, students.firstname, students.lastname, students.grade_level')
-1
View File
@@ -28,7 +28,6 @@ class EventChargesModel extends Model
'external_parent_email',
'semester',
'school_year',
'created_by',
'updated_by',
'created_at',
'updated_at'
-9
View File
@@ -32,7 +32,6 @@ class ExamDraftModel extends Model
];
protected $allowedFields = [
'teacher_id',
'author_id',
'class_section_id',
'semester',
@@ -40,20 +39,14 @@ class ExamDraftModel extends Model
'exam_type',
'draft_title',
'author_comment',
'description',
'teacher_file',
'teacher_filename',
'author_file',
'author_filename',
'status',
'acceptance_type',
'review_revision',
'reviewer_id',
'admin_id',
'is_legacy',
'reviewer_comment',
'reviewer_comments',
'admin_comments',
'reviewed_at',
'final_file',
'final_filename',
@@ -87,11 +80,9 @@ class ExamDraftModel extends Model
* @var array<string, string>
*/
protected array $casts = [
'teacher_id' => 'int',
'author_id' => 'int',
'class_section_id' => 'int',
'reviewer_id' => '?int',
'admin_id' => '?int',
'review_revision' => 'int',
'version' => 'int',
'previous_draft_id' => '?int',
+3 -2
View File
@@ -12,9 +12,10 @@ class ExamModel extends Model
protected $allowedFields = [
'student_id',
'school_id',
'student_school_id',
'class_section_id',
'exam_name',
'school_year',
'created_at'
];
@@ -24,4 +25,4 @@ class ExamModel extends Model
protected $updatedField = ''; // Set this if you later add an `updated_at` column
protected $returnType = 'array'; // You can change this to 'object' if preferred
}
}
-1
View File
@@ -18,7 +18,6 @@ class FinalExamModel extends Model
'class_section_id',
'updated_by',
'score',
'comment',
'semester',
'school_year',
'created_at',
+2 -2
View File
@@ -14,7 +14,7 @@ class FinalScoreModel extends Model
'student_id',
'school_id',
'class_section_id',
'teacher_id',
'updated_by',
'score',
'score_letter',
'comment',
@@ -37,4 +37,4 @@ class FinalScoreModel extends Model
// Return the score, or null if no result is found
return $result ? $result['score'] : null;
}
}
}
-6
View File
@@ -30,10 +30,6 @@ protected $table = 'inventory_items';
'sku',
'notes',
// academic tags
'semester',
'school_year',
// audit
'updated_by',
@@ -50,7 +46,5 @@ protected $table = 'inventory_items';
'name' => 'required|min_length[2]',
'quantity' => 'permit_empty|integer',
'unit_price' => 'permit_empty|decimal',
'semester' => 'permit_empty|in_list[Spring,Fall]',
'school_year' => 'permit_empty|max_length[16]', // e.g. 2025-2026
];
}
-2
View File
@@ -20,8 +20,6 @@ class InvoiceStudentListModel extends Model
'student_lastname',
'school_id',
'enrolled',
'school_year',
'semester',
'created_at',
'updated_at'
];
+1 -3
View File
@@ -13,8 +13,6 @@ class IpAttemptModel extends Model
'ip_address',
'attempts',
'last_attempt_at',
'semester',
'school_year',
'blocked_until'
];
@@ -48,4 +46,4 @@ class IpAttemptModel extends Model
{
return $this->insert($data);
}
}
}
+1 -2
View File
@@ -15,7 +15,6 @@ class LoginActivityModel extends Model
'logout_time',
'ip_address',
'user_agent',
'school_year',
'semester',
'created_at',
'updated_at'
@@ -35,4 +34,4 @@ class LoginActivityModel extends Model
}
?>
?>
+5 -8
View File
@@ -21,8 +21,7 @@ class MessageModel extends Model
'priority',
'attachment',
'status',
'semester', // Added field
'school_year' // Added field
'semester'
];
protected $useTimestamps = false; // Since you're manually handling date fields
@@ -150,10 +149,8 @@ class MessageModel extends Model
*/
public function getMessagesBySemesterAndYear($semester, $school_year = null)
{
$builder = $this->where('semester', $semester);
if ($school_year) {
$builder->where('school_year', $school_year);
}
return $builder->orderBy('sent_datetime', 'DESC')->findAll();
return $this->where('semester', $semester)
->orderBy('sent_datetime', 'DESC')
->findAll();
}
}
}
+2 -2
View File
@@ -17,8 +17,8 @@ class NotificationModel extends Model
'status',
'action_url',
'attachment_path',
'semester',
'school_year',
'expires_at',
'sent_at',
'scheduled_at'
];
protected $useTimestamps = true;
-2
View File
@@ -15,7 +15,5 @@ class ParentModel extends Model
'secondparent_email',
'secondparent_phone',
'firstparent_id',
'semester',
'school_year',
];
}
-38
View File
@@ -1,38 +0,0 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class PayPalPaymentModel extends Model
{
protected $table = 'paypal_payments';
protected $primaryKey = 'id';
protected $allowedFields = [
'webhook_id',
'parent_school_id',
'order_id',
'transaction_id',
'status',
'amount',
'currency',
'paypal_fee',
'net_amount',
'payer_email',
'merchant_id',
'event_type',
'summary',
'raw_payload',
'synced',
'semester',
'school_year',
'sync_attempts'
];
// Enable timestamps
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = ''; // not used; leave empty if not needed
}
+284 -65
View File
@@ -2,7 +2,9 @@
namespace App\Models;
use CodeIgniter\Database\ConnectionInterface;
use CodeIgniter\Model;
use CodeIgniter\Validation\ValidationInterface;
class PaymentModel extends Model
{
@@ -10,39 +12,68 @@ class PaymentModel extends Model
protected $primaryKey = 'id';
protected $returnType = 'array';
/**
* Keep this list broad, then filter it in __construct() against the real DB schema.
* This prevents CI from trying to insert/update columns that do not exist, such as `balance`.
*/
protected $allowedFields = [
'parent_id',
'invoice_id',
// Legacy invoice snapshot fields. Do not use these as source of truth.
'total_amount',
// Actual payment fields.
'paid_amount',
'balance',
'balance_amount',
'balance_after_payment',
'number_of_installments',
'installment_seq',
'transaction_id',
'check_file',
'check_number',
'payment_method',
'payment_date',
'school_year',
'semester',
'status',
'updated_by'
'updated_by',
];
protected $useTimestamps = false;
// ❗ Your DB handles created_at / updated_at with default CURRENT_TIMESTAMP
// Remove CI4 auto timestamp management unless you modify your DB schema
/**
* Let DB defaults handle created_at / updated_at.
* Keep validation strict for actual payment data, but do not require legacy snapshot columns.
*/
protected $cleanValidationRules = true;
protected $validationRules = [
'parent_id' => 'required|integer',
'invoice_id' => 'required|integer',
'total_amount' => 'required|decimal',
'paid_amount' => 'required|decimal',
'balance' => 'required|decimal',
'number_of_installments' => 'required|integer',
'payment_method' => 'required|max_length[50]', // ✅ Removed 'string'
'payment_date' => 'required|valid_date[Y-m-d H:i:s]',
'status' => 'required|max_length[50]', // ✅ Removed 'string'
// Legacy snapshot. Optional because invoice total belongs to invoices table.
'total_amount' => 'permit_empty|decimal',
'paid_amount' => 'required|decimal|greater_than[0]',
// These are optional because not every live schema has a balance snapshot column.
'balance' => 'permit_empty|decimal|greater_than_equal_to[0]',
'balance_amount' => 'permit_empty|decimal|greater_than_equal_to[0]',
'balance_after_payment' => 'permit_empty|decimal|greater_than_equal_to[0]',
// Legacy/current sequence columns.
'number_of_installments' => 'permit_empty|integer|greater_than[0]',
'installment_seq' => 'permit_empty|integer|greater_than[0]',
'transaction_id' => 'permit_empty|max_length[100]',
'payment_method' => 'required|in_list[cash,check,card]',
'payment_date' => 'required|valid_date',
'school_year' => 'permit_empty|max_length[20]',
'status' => 'required|max_length[50]',
'check_file' => 'permit_empty|max_length[255]',
'check_number' => 'permit_empty|max_length[100]',
'updated_by' => 'permit_empty|integer',
];
protected $validationMessages = [
@@ -55,111 +86,225 @@ class PaymentModel extends Model
'integer' => 'Invoice ID must be an integer.',
],
'total_amount' => [
'required' => 'Total amount is required.',
'decimal' => 'Total amount must be a valid decimal.',
],
'paid_amount' => [
'required' => 'Paid amount is required.',
'decimal' => 'Paid amount must be a valid decimal.',
'greater_than' => 'Paid amount must be greater than zero.',
],
'balance' => [
'required' => 'Balance is required.',
'decimal' => 'Balance must be a valid decimal.',
'greater_than_equal_to' => 'Balance must not be negative.',
],
'balance_amount' => [
'decimal' => 'Balance amount must be a valid decimal.',
'greater_than_equal_to' => 'Balance amount must not be negative.',
],
'balance_after_payment' => [
'decimal' => 'Balance after payment must be a valid decimal.',
'greater_than_equal_to' => 'Balance after payment must not be negative.',
],
'payment_date' => [
'required' => 'Payment date is required.',
'valid_date' => 'Payment date must be a valid date (Y-m-d H:i:s).',
'valid_date' => 'Payment date must be a valid date.',
],
'payment_method' => [
'required' => 'Payment method is required.',
'max_length' => 'Payment method must not exceed 50 characters.',
'in_list' => 'Payment method must be cash, check, or card.',
],
'status' => [
'required' => 'Status is required.',
'max_length' => 'Status must not exceed 50 characters.',
],
'number_of_installments' => [
'required' => 'Number of installments is required.',
'integer' => 'Number of installments must be an integer.',
'greater_than' => 'Number of installments must be greater than zero.',
],
'installment_seq' => [
'integer' => 'Installment sequence must be an integer.',
'greater_than' => 'Installment sequence must be greater than zero.',
],
'check_number' => [
'max_length' => 'Check number must not exceed 100 characters.',
]
],
];
/**
* Get payments by parent ID.
*
* @param int $parentId
* @return array
*/
public function getPaymentsByParentId(int $parentId): array
private array $paymentColumns = [];
private array $excludedPaymentStatuses = [
'void',
'voided',
'refunded',
'failed',
'chargeback',
'declined',
'reversed',
'canceled',
'cancelled',
];
public function __construct(?ConnectionInterface $db = null, ?ValidationInterface $validation = null)
{
return $this->where('parent_id', $parentId)
->orderBy('payment_date', 'DESC')
->findAll();
parent::__construct($db, $validation);
$this->paymentColumns = $this->getTableColumns($this->table);
// Remove fields that do not exist in the live DB.
// This prevents errors like "Unknown column balance".
$this->allowedFields = array_values(array_filter(
$this->allowedFields,
fn (string $field): bool => in_array($field, $this->paymentColumns, true)
));
// Remove validation rules for columns that do not exist.
foreach (array_keys($this->validationRules) as $field) {
if (!in_array($field, $this->allowedFields, true)) {
unset($this->validationRules[$field]);
unset($this->validationMessages[$field]);
}
}
}
/**
* Calculate the total paid amount for a specific parent.
* Get payments by parent ID with invoice context.
*
* @param int $parentId
* @return float
* The source of truth for invoice total/status/balance is invoices, not payments.
*/
public function getPaymentsByParentId(int $parentId, ?string $schoolYear = null): array
{
return $this->parentPaymentHistoryQuery($parentId, $schoolYear)->findAll();
}
/**
* Build parent payment history query with invoice context.
*
* Kept public so controllers can paginate without duplicating select/join logic.
*/
public function parentPaymentHistoryQuery(int $parentId, ?string $schoolYear = null): self
{
$paymentBalanceSelect = $this->getPaymentBalanceSelectExpression('payments');
$invoiceBalanceSelect = $this->columnExists('invoices', 'balance')
? 'invoices.balance AS invoice_current_balance'
: '0.00 AS invoice_current_balance';
$select = [
'payments.id',
'payments.invoice_id',
'invoices.invoice_number',
'payments.transaction_id',
'payments.paid_amount',
'payments.payment_method',
'payments.check_number',
'payments.check_file',
'payments.payment_date',
'payments.installment_seq',
'payments.number_of_installments',
$paymentBalanceSelect,
'payments.status AS payment_status',
'invoices.total_amount AS invoice_total',
$invoiceBalanceSelect,
'invoices.status AS invoice_status',
'invoices.school_year',
];
$model = new self($this->db);
$builder = $model->select(implode(', ', $select), false)
->join('invoices', 'invoices.id = payments.invoice_id', 'inner')
->where('payments.parent_id', $parentId);
if ($schoolYear !== null && $schoolYear !== '') {
$builder->where('invoices.school_year', $schoolYear);
}
return $builder
->orderBy('payments.payment_date', 'DESC')
->orderBy('payments.id', 'DESC');
}
/**
* Calculate the total paid amount for a parent in a school year.
*
* Uses invoices.school_year so old poisoned payment term data does not corrupt totals.
*/
public function getTotalPaidByParentId(int $parentId, string $schoolYear): float
{
$db = \Config\Database::connect();
$result = $db->table('payments')
->selectSum('paid_amount', 'total_paid')
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
$result = $this->db->table('payments p')
->selectSum('p.paid_amount', 'total_paid')
->join('invoices i', 'i.id = p.invoice_id', 'inner')
->where('p.parent_id', $parentId)
->where('i.school_year', $schoolYear)
->where($this->successfulPaymentWhereSql('p.status'), null, false)
->get()
->getRowArray();
return $result && isset($result['total_paid']) ? (float) $result['total_paid'] : 0.00;
return $result && isset($result['total_paid'])
? (float) $result['total_paid']
: 0.00;
}
/**
* Update balance for a specific payment.
* Legacy method.
*
* @param int $paymentId
* @param float $amountPaid
* @return bool
* This should not be used for new payment recording.
* New payments should be inserted as immutable payment rows and invoices recalculated by InvoiceLedgerService.
*/
public function updateBalance(int $paymentId, float $amountPaid): bool
{
if ($amountPaid <= 0) {
return false;
}
$payment = $this->find($paymentId);
if (!$payment) {
return false;
}
$newBalance = $payment['balance'] - $amountPaid;
$updateData = [
'paid_amount' => round((float) ($payment['paid_amount'] ?? 0) + $amountPaid, 2),
];
return $this->update($paymentId, [
'paid_amount' => $payment['paid_amount'] + $amountPaid,
'balance' => $newBalance
]);
$balanceField = $this->getPaymentBalanceField();
if ($balanceField !== null && array_key_exists($balanceField, $payment)) {
$updateData[$balanceField] = max(
0.00,
round((float) ($payment[$balanceField] ?? 0) - $amountPaid, 2)
);
}
return $this->update($paymentId, $updateData);
}
/**
* Get payments by school year.
*
* @param string $schoolYear
* @return array
* Uses invoice school year when possible because payment.school_year may have old bad data.
*/
public function getPaymentsByYear(string $schoolYear): array
{
return $this->where('school_year', $schoolYear)
->orderBy('payment_date', 'DESC')
$paymentBalanceSelect = $this->getPaymentBalanceSelectExpression('payments');
$select = [
'payments.*',
'invoices.invoice_number',
'invoices.total_amount AS invoice_total',
'invoices.status AS invoice_status',
$paymentBalanceSelect,
];
return $this->select(implode(', ', $select), false)
->join('invoices', 'invoices.id = payments.invoice_id', 'inner')
->where('invoices.school_year', $schoolYear)
->orderBy('payments.payment_date', 'DESC')
->orderBy('payments.id', 'DESC')
->findAll();
}
/**
* Generate a new transaction ID.
* Generate a transaction ID.
*
* @return string
* This is okay for legacy/manual use, but a DB UNIQUE KEY on transaction_id is still required.
*/
public function generateNewTransactionId(): string
{
@@ -171,41 +316,115 @@ class PaymentModel extends Model
->first();
if ($latest && isset($latest['transaction_id'])) {
$lastNumber = (int) str_replace($prefix, '', $latest['transaction_id']);
$lastNumber = (int) str_replace($prefix, '', (string) $latest['transaction_id']);
$newNumber = $lastNumber + 1;
} else {
$newNumber = 1;
}
return $prefix . str_pad($newNumber, 6, '0', STR_PAD_LEFT);
return $prefix . str_pad((string) $newNumber, 6, '0', STR_PAD_LEFT);
}
/**
* Get latest successful payment per invoice.
*
* Uses latest payment ID instead of latest date only.
* Date-only matching can return duplicates when several payments happen on the same day.
*/
public function getPaymentsByInvoice($invoiceIds): array
{
// Normalize input
if (!is_array($invoiceIds)) {
$invoiceIds = [$invoiceIds];
}
$invoiceIds = array_values(array_filter(array_map('intval', $invoiceIds)));
if (empty($invoiceIds)) {
return [];
}
// Subquery: latest payment_date per invoice (Full/Partial only)
$latestSub = $this->db->table('payments')
->select('invoice_id, MAX(payment_date) AS max_date')
->whereIn('status', ['Full', 'Paid', 'Partially Paid'])
->select('invoice_id, MAX(id) AS max_id')
->whereIn('invoice_id', $invoiceIds)
->where('paid_amount >', 0)
->where($this->successfulPaymentWhereSql('status'), null, false)
->groupBy('invoice_id')
->getCompiledSelect();
// Main query: join payments to the latest per invoice
$rows = $this->db->table('payments p')
->select('p.invoice_id, p.paid_amount AS last_paid_amount, p.payment_date AS last_payment_date, p.status AS last_payment_status')
->join("($latestSub) latest", 'latest.invoice_id = p.invoice_id AND latest.max_date = p.payment_date', 'inner', false) // <-- escape=false
return $this->db->table('payments p')
->select([
'p.invoice_id',
'p.id AS payment_id',
'p.paid_amount AS last_paid_amount',
'p.payment_date AS last_payment_date',
'p.status AS last_payment_status',
'p.transaction_id',
'p.payment_method',
'p.check_number',
])
->join(
"($latestSub) latest",
'latest.invoice_id = p.invoice_id AND latest.max_id = p.id',
'inner',
false
)
->whereIn('p.invoice_id', $invoiceIds)
->whereIn('p.status', ['Full', 'Paid', 'Partially Paid'])
->get()
->getResultArray();
}
return $rows;
private function getPaymentBalanceField(): ?string
{
foreach (['balance_after_payment', 'balance', 'balance_amount'] as $field) {
if (in_array($field, $this->paymentColumns, true)) {
return $field;
}
}
return null;
}
private function getPaymentBalanceSelectExpression(string $alias = 'payments'): string
{
$field = $this->getPaymentBalanceField();
if ($field === null) {
return '0.00 AS balance_after_payment';
}
return "{$alias}.{$field} AS balance_after_payment";
}
private function successfulPaymentWhereSql(string $column): string
{
$escapedStatuses = array_map(
fn (string $status): string => $this->db->escape($status),
$this->excludedPaymentStatuses
);
return 'LOWER(COALESCE(' . $column . ', \'\')) NOT IN (' . implode(', ', $escapedStatuses) . ')';
}
private function getTableColumns(string $table): array
{
try {
return $this->db->getFieldNames($table);
} catch (\Throwable $e) {
log_message('error', '[PaymentModel] Could not read table columns for {table}: {error}', [
'table' => $table,
'error' => $e->getMessage(),
]);
return [];
}
}
private function columnExists(string $table, string $column): bool
{
try {
return $this->db->fieldExists($column, $table);
} catch (\Throwable $e) {
return false;
}
}
}
+10 -20
View File
@@ -18,18 +18,11 @@ class PaymentTransactionModel extends Model
'payment_method',
'payment_status',
'transaction_fee',
'payment_reference',
'semester',
'school_year',
'is_full_payment', // Flag to track full payment status
'created_at',
'updated_at'
'school_year'
];
// Set automatic timestamps
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $useTimestamps = false;
// Validation rules
protected $validationRules = [
@@ -38,8 +31,8 @@ class PaymentTransactionModel extends Model
'amount' => 'required|decimal',
'payment_method' => 'required|string|max_length[50]',
'payment_status' => 'required|string|max_length[50]',
'payment_reference' => 'permit_empty|string|max_length[255]',
'is_full_payment' => 'required|in_list[0,1]', // 0 for installment, 1 for full payment
'semester' => 'permit_empty|string|max_length[30]',
'school_year' => 'permit_empty|string|max_length[9]',
];
// Custom error messages
@@ -64,13 +57,6 @@ class PaymentTransactionModel extends Model
'required' => 'The payment status is required.',
'max_length' => 'The payment status must not exceed 50 characters.',
],
'payment_reference' => [
'max_length' => 'The payment reference must not exceed 255 characters.',
],
'is_full_payment' => [
'required' => 'The full payment flag is required.',
'in_list' => 'The full payment flag must be either 0 (installment) or 1 (full payment).',
],
];
/**
@@ -106,7 +92,9 @@ class PaymentTransactionModel extends Model
*/
public function updateTransactionStatus($transactionId, $status)
{
return $this->update($transactionId, ['payment_status' => $status]);
return $this->where('transaction_id', $transactionId)
->set(['payment_status' => $status])
->update();
}
/**
@@ -130,7 +118,9 @@ class PaymentTransactionModel extends Model
*/
public function updateTransactionFee($transactionId, $transactionFee)
{
return $this->update($transactionId, ['transaction_fee' => $transactionFee]);
return $this->where('transaction_id', $transactionId)
->set(['transaction_fee' => $transactionFee])
->update();
}
/**
-30
View File
@@ -1,30 +0,0 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class PaypalTransactionModel extends Model
{
protected $table = 'paypal_transactions';
protected $primaryKey = 'id';
protected $allowedFields = [
'transaction_id',
'payment_id',
'firstname',
'lastname',
'event_type',
'payer_email',
'amount',
'currency',
'status',
'payment_method',
'transaction_fee',
'semester',
'school_year',
'raw_data'
];
protected $useTimestamps = true;
}
-1
View File
@@ -12,7 +12,6 @@ class PlacementLevelModel extends Model
protected $allowedFields = [
'student_id',
'level',
'school_year',
'created_by',
'updated_by',
'created_at',
+2 -7
View File
@@ -12,8 +12,8 @@ class PreferencesModel extends Model
// Allowed fields to enable mass assignment
protected $allowedFields = [
'user_id', // Foreign key linking preferences to a user
'receive_email_notifications', // Email notifications preference
'receive_sms_notifications', // SMS notifications preference
'notification_email', // Email notifications preference
'notification_sms', // SMS notifications preference
'theme', // Theme preference (e.g., light or dark)
'language', // Language preference
'timezone', // Timezone preference
@@ -22,11 +22,6 @@ class PreferencesModel extends Model
'menu_custom_bg', // Custom menu background color
'menu_custom_text', // Custom menu text color
'menu_custom_mode', // Custom menu mode: light|dark
'receive_push_notifications', // Push notifications preference
'daily_summary_email', // Daily summary email preference
'privacy_mode', // Privacy mode setting
'marketing_emails', // Marketing emails preference
'account_activity_alerts', // Account activity alerts preference
'created_at', // Timestamp of when the record was created
'updated_at' // Timestamp of when the record was last updated
];
+1 -2
View File
@@ -12,7 +12,6 @@ class SectionModel extends Model
protected $allowedFields = [
'section_name',
'description',
'created_at',
'updated_at',
'updated_by'
];
@@ -55,4 +54,4 @@ class SectionModel extends Model
{
return $this->delete($sectionId);
}
}
}
+3 -24
View File
@@ -21,8 +21,6 @@ class StaffModel extends Model
'phone',
'role_name',
'active_role',
'status',
'school_year',
'created_at',
'updated_at'
];
@@ -44,7 +42,7 @@ class StaffModel extends Model
}
/**
* Insert or update a staff row keyed by (user_id, school_year).
* Insert or update a staff row keyed by user_id.
* Ensures created_at is only set on insert and updated_at can be provided by caller.
*/
public function upsert(array $data): bool
@@ -53,27 +51,9 @@ class StaffModel extends Model
return false;
}
// If school_year not provided, try to keep existing or fall back to current year string
$schoolYear = $data['school_year'] ?? null;
unset($data['school_year'], $data['status']);
if ($schoolYear === null) {
// Try to find any existing row by user_id to reuse its school_year
$existingAny = $this->where('user_id', $data['user_id'])->orderBy('id', 'DESC')->first();
if ($existingAny && isset($existingAny['school_year'])) {
$schoolYear = $existingAny['school_year'];
$data['school_year'] = $schoolYear;
}
}
$existing = null;
if ($schoolYear !== null) {
$existing = $this->where('user_id', $data['user_id'])
->where('school_year', $schoolYear)
->first();
} else {
// If no school_year, treat (user_id) as key
$existing = $this->where('user_id', $data['user_id'])->first();
}
$existing = $this->where('user_id', $data['user_id'])->first();
$now = utc_now();
@@ -100,4 +80,3 @@ class StaffModel extends Model
return $this->insert($data) !== false;
}
}
-1
View File
@@ -11,7 +11,6 @@ class StudentClassModel extends Model
protected $allowedFields = [
'student_id',
'school_id',
'class_section_id',
'school_year',
'is_event_only',
+7 -5
View File
@@ -19,7 +19,6 @@ class StudentModel extends Model
'photo_consent',
'is_new',
'parent_id',
'school_year',
'registration_date',
'tuition_paid',
'year_of_registration',
@@ -122,7 +121,9 @@ class StudentModel extends Model
// school_year filter (skip if null or "all")
if ($schoolYear !== null && strtolower($schoolYear) !== 'all') {
$builder->where('students.school_year', $schoolYear);
$builder
->join('student_class sc_filter', 'sc_filter.student_id = students.id', 'inner')
->where('sc_filter.school_year', $schoolYear);
}
return $builder
@@ -171,12 +172,13 @@ class StudentModel extends Model
users.cellphone as phone,
students.registration_grade,
classSection.class_section_name as current_class,
"' . $schoolYear . '" as school_year,
student_class.school_year,
"' . $semester . '" as semester
')
->join('users', 'users.id = students.parent_id', 'left')
->join('student_class', 'student_class.student_id = students.id', 'left')
->join('class_section', 'student_class.class_section_id = classSection.id', 'left')
->join('classSection', 'student_class.class_section_id = classSection.class_section_id', 'left')
->where('student_class.school_year', $schoolYear)
->get()
->getResultArray();
}
@@ -199,7 +201,7 @@ class StudentModel extends Model
')
->join('student_class', 'students.id = student_class.student_id', 'left')
->join('teacher_class', 'student_class.class_section_id = teacher_class.class_section_id', 'left')
->join('class_section', 'student_class.class_section_id = classSection.id', 'left')
->join('classSection', 'student_class.class_section_id = classSection.class_section_id', 'left')
->where('teacher_class.teacher_id', $teacherId)
->get()
->getResultArray();
+30 -6
View File
@@ -28,7 +28,6 @@ class UserModel extends Model
'failed_attempts',
'last_failed_at',
'semester',
'school_year',
'status',
'is_suspended',
'is_verified',
@@ -237,13 +236,23 @@ class UserModel extends Model
public function getUsersByRoleAndSchoolYear(string $roleName, string $schoolYear): array
{
return $this->select('users.*')
$builder = $this->select('users.*')
->join('user_roles', 'user_roles.user_id = users.id')
->join('roles', 'roles.id = user_roles.role_id')
->where('roles.name', $roleName)
->where('users.school_year', $schoolYear)
->where('user_roles.deleted_at', null)
->findAll();
->where('user_roles.deleted_at', null);
if ($this->userRolesHaveSchoolYear()) {
$builder->where('user_roles.school_year', $schoolYear);
} elseif ($this->roleUsesTeacherAssignments($roleName)) {
$builder->join(
'teacher_class tc_year',
'tc_year.teacher_id = users.id AND tc_year.school_year = ' . $this->db->escape($schoolYear),
'inner'
);
}
return $builder->groupBy('users.id')->findAll();
}
public function countAdminsBySchoolYear(string $schoolYear): int
@@ -260,8 +269,8 @@ class UserModel extends Model
)
->join('user_roles ur', 'ur.user_id = u.id', 'inner')
->join('roles r', 'r.id = ur.role_id', 'inner')
->where('u.school_year', $schoolYear)
->where('r.is_active', 1)
->where('LOWER(r.name) NOT IN ("guest","teacher","teacher_assistant","parent")', null, false)
->groupBy('u.id')
->having('is_admin', 1);
@@ -407,4 +416,19 @@ class UserModel extends Model
->orderBy($sort === 'roles' ? 'roles' : $sort, $order)
->findAll();
}
private function userRolesHaveSchoolYear(): bool
{
try {
return in_array('school_year', $this->db->getFieldNames('user_roles'), true);
} catch (\Throwable $e) {
return false;
}
}
private function roleUsesTeacherAssignments(string $roleName): bool
{
$role = strtolower(trim($roleName));
return $role === 'ta' || str_contains($role, 'teacher');
}
}
+1 -3
View File
@@ -13,8 +13,6 @@ class UserRoleModel extends Model
protected $allowedFields = [
'user_id',
'role_id',
'semester',
'school_year',
'created_at',
'updated_at',
'updated_by',
@@ -99,4 +97,4 @@ public function getRolesByUserId(int $userId): array
'updated_by' => session()->get('user_id')
]);
}
}
}
+3 -28
View File
@@ -11,7 +11,6 @@ class WhatsappGroupLinkModel extends Model
'class_section_id',
'class_section_name',
'school_year',
'semester',
'invite_link',
'active',
];
@@ -22,9 +21,9 @@ class WhatsappGroupLinkModel extends Model
*
* @param int $sectionId Class/section code (not PK).
* @param string $year School year, e.g. "2025-2026".
* @param string $sem Semester, e.g. "Fall".
* @param string $sem Deprecated/ignored; links are scoped by school year.
* @param bool $onlyActive If true, require active=1.
* @param bool $allowNullSemester If true, accept rows with semester IS NULL as well.
* @param bool $allowNullSemester Deprecated/ignored.
*/
public function getLinkForSection(
int $sectionId,
@@ -40,17 +39,6 @@ class WhatsappGroupLinkModel extends Model
->where('class_section_id', $sectionId)
->where('school_year', $year);
if ($sem !== '') {
if ($allowNullSemester) {
$b = $b->groupStart()
->where('semester', $sem)
->orWhere('semester IS NULL', null, false)
->groupEnd();
} else {
$b = $b->where('semester', $sem);
}
}
if ($onlyActive) {
$b = $b->where('active', 1);
}
@@ -70,7 +58,7 @@ class WhatsappGroupLinkModel extends Model
* @param string $year
* @param string $sem
* @param bool|null $onlyActive true: active only, false: inactive only, null: both
* @param bool $allowNullSemester If true, include rows with semester IS NULL.
* @param bool $allowNullSemester Deprecated/ignored.
*/
public function getAllForTerm(
string $year,
@@ -84,17 +72,6 @@ class WhatsappGroupLinkModel extends Model
$b = $this->asArray()
->where('school_year', $year);
if ($sem !== '') {
if ($allowNullSemester) {
$b = $b->groupStart()
->where('semester', $sem)
->orWhere('semester IS NULL', null, false)
->groupEnd();
} else {
$b = $b->where('semester', $sem);
}
}
if ($onlyActive === true) {
$b = $b->where('active', 1);
} elseif ($onlyActive === false) {
@@ -122,7 +99,6 @@ class WhatsappGroupLinkModel extends Model
'class_section_id' => $sectionId,
'class_section_name' => $sectionName,
'school_year' => trim($year),
'semester' => trim($sem),
'invite_link' => trim($inviteLink),
'active' => $active ? 1 : 0,
];
@@ -131,7 +107,6 @@ class WhatsappGroupLinkModel extends Model
$existing = $this->asArray()
->where('class_section_id', $sectionId)
->where('school_year', $payload['school_year'])
->where('semester', $payload['semester'])
->first();
if ($existing) {
+2 -2
View File
@@ -42,7 +42,7 @@ class WhatsappInviteLogModel extends Model
/**
* Log a successful send.
*/
public function logSuccess(int $parentId, string $email, int $classSectionId = null, int $linkId = null): bool
public function logSuccess(int $parentId, string $email, ?int $classSectionId = null, ?int $linkId = null): bool
{
return (bool) $this->insert([
'parent_id' => $parentId,
@@ -58,7 +58,7 @@ class WhatsappInviteLogModel extends Model
/**
* Log a failure with error message.
*/
public function logFailure(int $parentId, string $email, string $error, int $classSectionId = null, int $linkId = null): bool
public function logFailure(int $parentId, string $email, string $error, ?int $classSectionId = null, ?int $linkId = null): bool
{
return (bool) $this->insert([
'parent_id' => $parentId,
+2 -2
View File
@@ -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) {
@@ -1,84 +0,0 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container-fluid">
<div class="wrapper">
<div class="content"></div>
<h2 class="text-center mt-4 mb-3">PayPal Transactions</h2>
<?= $this->include('partials/academic_filter') ?>
<!-- Export Button -->
<div class="d-flex gap-2 mb-3 justify-content-end">
<a href="<?= base_url('admin/paypal-transactions/export') . ($keyword ? '?q=' . urlencode($keyword) : '') ?>"
class="btn btn-success">
Export CSV
</a>
</div>
<!-- Search Form -->
<!--
<form method="get" action="<?= base_url('admin/paypal-transactions') ?>" class="mb-3">
<?= csrf_field(); ?>
<div class="input-group">
<input type="text" name="q" value="<?= esc($keyword) ?>" class="form-control"
placeholder="Search by transaction ID, email, order ID, or event type">
<button type="submit" class="btn btn-primary">Search</button>
<?php if ($keyword): ?>
<a href="<?= base_url('admin/paypal-transactions') ?>" class="btn btn-secondary">Clear</a>
<?php endif; ?>
</div>
</form>
-->
<!-- Transactions Table -->
<table id="myTable" class="display table table-striped">
<thead>
<tr>
<th>ID</th>
<th>Transaction ID</th>
<th>Order ID</th>
<th>Parent School ID</th>
<th>Email</th>
<th>Amount</th>
<th>Net Amount</th>
<th>Currency</th>
<th>Status</th>
<th>Event Type</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<?php foreach ($transactions as $t): ?>
<tr>
<td><?= esc($t['id']) ?></td>
<td><?= esc($t['transaction_id']) ?></td>
<td><?= esc($t['order_id']) ?></td>
<td><?= esc($t['parent_school_id']) ?></td>
<td><?= esc($t['payer_email']) ?></td>
<td>$<?= esc(number_format($t['amount'], 2)) ?></td>
<td>$<?= esc(number_format($t['net_amount'], 2)) ?></td>
<td><?= esc($t['currency']) ?></td>
<td><?= esc($t['status']) ?></td>
<td><?= esc($t['event_type']) ?></td>
<td><?= esc(local_datetime($t['created_at'], 'm-d-Y H:i')) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<!-- Pagination -->
<div class="d-flex justify-content-center">
<?= $pager->links() ?>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
$(document).ready(function() {
$('#myTable').DataTable({
"order": [
[0, "desc"]
],
"pageLength": 10
});
});
</script>
<?= $this->endSection() ?>
+149 -46
View File
@@ -15,23 +15,65 @@ $totalSurprise = array_sum(array_column($classResults, 'surprises'));
$totalMissed = array_sum(array_column($classResults, 'missed'));
$overallAccuracy = $totalPredicted > 0 ? round($totalConfirmed / $totalPredicted * 100) : ($totalActual === 0 ? 100 : 0);
$pct = static function (int $count, int $total): string {
return $total > 0 ? number_format(($count / $total) * 100, 1) . '%' : '0.0%';
};
$genderKey = static function (?string $gender): string {
$value = strtolower(trim((string)$gender));
return match (true) {
in_array($value, ['male', 'm', 'boy', 'boys'], true) => 'boys',
in_array($value, ['female', 'f', 'girl', 'girls'], true) => 'girls',
default => 'other',
};
};
$genderStats = static function (array $items) use ($genderKey): array {
$stats = ['boys' => 0, 'girls' => 0, 'other' => 0];
foreach ($items as $item) {
$stats[$genderKey($item['gender'] ?? '')]++;
}
return $stats;
};
// Winner gender breakdown.
// "Winner" means actual year-end trophy winner.
$totalWinnerBoys = 0;
$totalWinnerGirls = 0;
$totalWinnerOther = 0;
$allStudentsFlat = [];
$passStudents = [];
$trophyStudents = [];
// Sticker names.
// 2 columns x 10 rows = 20 stickers per page.
// Stickers print NAME ONLY.
$winnerStickerNames = [];
// 2 columns x 7 rows = 14 stickers per page.
// Stickers print name and class section.
$stickerColumns = 2;
$stickerRows = 7;
$stickerWidthInches = 4.0;
$stickerHeightInches = 1.33;
$stickerPrintablePageWidthInches = 8.0;
$stickerPrintablePageHeightInches = 10.5;
$stickersPerPage = $stickerColumns * $stickerRows;
$winnerStickers = [];
foreach ($classResults as $cls) {
foreach (($cls['students'] ?? []) as $s) {
$allStudentsFlat[] = $s;
if (isset($s['year_score']) && is_numeric($s['year_score']) && (float)$s['year_score'] >= 60) {
$passStudents[] = $s;
}
if (empty($s['actual'])) {
continue;
}
$trophyStudents[] = $s;
$gender = strtolower(trim((string)($s['gender'] ?? '')));
if (in_array($gender, ['male', 'm', 'boy', 'boys'], true)) {
@@ -43,9 +85,13 @@ foreach ($classResults as $cls) {
}
$name = trim((string)($s['name'] ?? ''));
$sectionName = trim((string)($cls['section_name'] ?? ''));
if ($name !== '') {
$winnerStickerNames[] = $name;
$winnerStickers[] = [
'name' => $name,
'section' => $sectionName,
];
}
}
}
@@ -55,6 +101,10 @@ $totalWinners = $totalWinnerBoys + $totalWinnerGirls + $totalWinnerOther;
$winnerBoysPct = $totalWinners > 0 ? round(($totalWinnerBoys / $totalWinners) * 100, 1) : 0;
$winnerGirlsPct = $totalWinners > 0 ? round(($totalWinnerGirls / $totalWinners) * 100, 1) : 0;
$winnerOtherPct = $totalWinners > 0 ? round(($totalWinnerOther / $totalWinners) * 100, 1) : 0;
$allGenderStats = $genderStats($allStudentsFlat);
$passGenderStats = $genderStats($passStudents);
$trophyGenderStats = $genderStats($trophyStudents);
$totalPass = count($passStudents);
?>
<style>
@@ -68,7 +118,7 @@ $winnerOtherPct = $totalWinners > 0 ? round(($totalWinnerOther / $totalWinners)
@page {
size: Letter portrait;
margin: 0.35in;
margin: 0.25in;
}
@media print {
@@ -162,11 +212,18 @@ $winnerOtherPct = $totalWinners > 0 ? round(($totalWinnerOther / $totalWinners)
body.print-stickers-mode .sticker-page {
display: grid !important;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(10, 1fr);
grid-template-columns: repeat(<?= $stickerColumns ?>, <?= rtrim(rtrim(number_format($stickerWidthInches, 2, '.', ''), '0'), '.') ?>in);
grid-template-rows: repeat(<?= $stickerRows ?>, <?= rtrim(rtrim(number_format($stickerHeightInches, 2, '.', ''), '0'), '.') ?>in);
gap: 0;
width: 100%;
height: 10.3in;
width: <?= rtrim(rtrim(number_format($stickerPrintablePageWidthInches, 2, '.', ''), '0'), '.') ?>in;
height: <?= rtrim(rtrim(number_format($stickerPrintablePageHeightInches, 2, '.', ''), '0'), '.') ?>in;
margin: 0 auto;
padding: 0;
box-sizing: border-box;
align-content: start;
justify-content: center;
page-break-inside: avoid;
break-inside: avoid-page;
page-break-after: always;
break-after: page;
}
@@ -186,16 +243,36 @@ $winnerOtherPct = $totalWinners > 0 ? round(($totalWinnerOther / $totalWinners)
overflow: hidden;
min-height: 0;
box-sizing: border-box;
width: <?= rtrim(rtrim(number_format($stickerWidthInches, 2, '.', ''), '0'), '.') ?>in;
height: <?= rtrim(rtrim(number_format($stickerHeightInches, 2, '.', ''), '0'), '.') ?>in;
}
body.print-stickers-mode .sticker-name {
display: block !important;
font-size: 20pt;
font-size: 18pt;
font-weight: 700;
line-height: 1.1;
color: #000 !important;
}
body.print-stickers-mode .sticker-content {
display: flex !important;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
transform: translateY(0.22in);
}
body.print-stickers-mode .sticker-section {
display: block !important;
margin-top: 0.08in;
font-size: 10pt;
font-weight: 600;
line-height: 1.1;
color: #000 !important;
}
body.print-stickers-mode .sticker-empty {
visibility: hidden !important;
}
@@ -219,7 +296,7 @@ $winnerOtherPct = $totalWinners > 0 ? round(($totalWinnerOther / $totalWinners)
<div class="d-flex gap-2 flex-wrap">
<button onclick="printWithCharts()" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-printer-fill me-1"></i>Print
<i class="bi bi-printer-fill me-1"></i>Print Stats
</button>
<button onclick="printWinnerStickers()" class="btn btn-warning btn-sm">
@@ -548,13 +625,6 @@ $winnerOtherPct = $totalWinners > 0 ? round(($totalWinnerOther / $totalWinners)
</div>
</div>
<div class="col-12 col-lg-3">
<div class="border rounded p-3 text-center h-100">
<div class="text-muted small mb-1">Unknown / Other</div>
<div class="display-6 fw-bold text-secondary"><?= (int)$totalWinnerOther ?></div>
<div class="fw-semibold"><?= number_format($winnerOtherPct, 1) ?>%</div>
</div>
</div>
</div>
<div class="mt-3">
@@ -577,17 +647,6 @@ $winnerOtherPct = $totalWinners > 0 ? round(($totalWinnerOther / $totalWinners)
aria-valuemax="100">
Girls <?= number_format($winnerGirlsPct, 1) ?>%
</div>
<?php if ($totalWinnerOther > 0): ?>
<div class="progress-bar bg-secondary"
role="progressbar"
style="width: <?= $winnerOtherPct ?>%;"
aria-valuenow="<?= $winnerOtherPct ?>"
aria-valuemin="0"
aria-valuemax="100">
Other <?= number_format($winnerOtherPct, 1) ?>%
</div>
<?php endif; ?>
<?php else: ?>
<div class="progress-bar bg-secondary"
role="progressbar"
@@ -619,6 +678,50 @@ $winnerOtherPct = $totalWinners > 0 ? round(($totalWinnerOther / $totalWinners)
</p>
</div>
<h3 style="margin-bottom:6px;">Print Stats</h3>
<table data-no-mgmt-sticky style="margin-bottom:12px;">
<thead>
<tr>
<th>Metric</th>
<th style="text-align:center;">Count</th>
<th style="text-align:center;">Percent</th>
<th style="text-align:center;">Boys</th>
<th style="text-align:center;">Boys %</th>
<th style="text-align:center;">Girls</th>
<th style="text-align:center;">Girls %</th>
</tr>
</thead>
<tbody>
<tr>
<td>Total Students</td>
<td style="text-align:center;"><?= $totalStudents ?></td>
<td style="text-align:center;">100.0%</td>
<td style="text-align:center;"><?= $allGenderStats['boys'] ?></td>
<td style="text-align:center;"><?= $pct($allGenderStats['boys'], $totalStudents) ?></td>
<td style="text-align:center;"><?= $allGenderStats['girls'] ?></td>
<td style="text-align:center;"><?= $pct($allGenderStats['girls'], $totalStudents) ?></td>
</tr>
<tr>
<td>Pass</td>
<td style="text-align:center;"><?= $totalPass ?></td>
<td style="text-align:center;"><?= $pct($totalPass, $totalStudents) ?></td>
<td style="text-align:center;"><?= $passGenderStats['boys'] ?></td>
<td style="text-align:center;"><?= $pct($passGenderStats['boys'], $totalPass) ?></td>
<td style="text-align:center;"><?= $passGenderStats['girls'] ?></td>
<td style="text-align:center;"><?= $pct($passGenderStats['girls'], $totalPass) ?></td>
</tr>
<tr>
<td>Trophies</td>
<td style="text-align:center;"><?= $totalWinners ?></td>
<td style="text-align:center;"><?= $pct($totalWinners, $totalStudents) ?></td>
<td style="text-align:center;"><?= $trophyGenderStats['boys'] ?></td>
<td style="text-align:center;"><?= $pct($trophyGenderStats['boys'], $totalWinners) ?></td>
<td style="text-align:center;"><?= $trophyGenderStats['girls'] ?></td>
<td style="text-align:center;"><?= $pct($trophyGenderStats['girls'], $totalWinners) ?></td>
</tr>
</tbody>
</table>
<h3 style="margin-bottom:4px;">Student Detail</h3>
<table data-no-mgmt-sticky>
<thead>
@@ -638,11 +741,15 @@ $winnerOtherPct = $totalWinners > 0 ? round(($totalWinnerOther / $totalWinners)
<tbody>
<?php
$rank = 0;
foreach ($classResults as $cls):
foreach ($cls['students'] as $s):
if (!in_array($s['status'], ['confirmed', 'surprise'], true)) continue;
$rank++;
$classPrintRows = array_values(array_filter(
$cls['students'],
static fn (array $student): bool => in_array($student['status'], ['confirmed', 'surprise'], true)
));
$classPrintRows = array_reverse($classPrintRows);
foreach ($classPrintRows as $idx => $s):
$rank = count($classPrintRows) - $idx;
$genderNorm = strtolower(trim((string)($s['gender'] ?? '')));
$isMale = in_array($genderNorm, ['male', 'm', 'boy'], true);
$statusLabel = match ($s['status']) {
@@ -761,13 +868,6 @@ $winnerOtherPct = $totalWinners > 0 ? round(($totalWinnerOther / $totalWinners)
<td style="text-align:center;"><?= (int)$totalWinnerGirls ?></td>
<td style="text-align:center;"><?= number_format($winnerGirlsPct, 1) ?>%</td>
</tr>
<?php if ($totalWinnerOther > 0): ?>
<tr>
<td>Unknown / Other</td>
<td style="text-align:center;"><?= (int)$totalWinnerOther ?></td>
<td style="text-align:center;"><?= number_format($winnerOtherPct, 1) ?>%</td>
</tr>
<?php endif; ?>
</tbody>
<tfoot>
@@ -784,16 +884,19 @@ $winnerOtherPct = $totalWinners > 0 ? round(($totalWinnerOther / $totalWinners)
<!-- Winner sticker print area: names only, no header, no score, no class -->
<div id="winnerStickerPrintArea" class="winner-sticker-print-area">
<?php if (!empty($winnerStickerNames)): ?>
<?php foreach (array_chunk($winnerStickerNames, 20) as $chunk): ?>
<?php if (!empty($winnerStickers)): ?>
<?php foreach (array_chunk($winnerStickers, $stickersPerPage) as $chunk): ?>
<div class="sticker-page">
<?php foreach ($chunk as $winnerName): ?>
<?php foreach ($chunk as $winnerSticker): ?>
<div class="sticker-cell">
<div class="sticker-name"><?= esc($winnerName) ?></div>
<div class="sticker-content">
<div class="sticker-name"><?= esc($winnerSticker['name']) ?></div>
<div class="sticker-section"><?= esc($winnerSticker['section']) ?></div>
</div>
</div>
<?php endforeach; ?>
<?php for ($i = 0, $remaining = 20 - count($chunk); $i < $remaining; $i++): ?>
<?php for ($i = 0, $remaining = $stickersPerPage - count($chunk); $i < $remaining; $i++): ?>
<div class="sticker-cell sticker-empty"></div>
<?php endfor; ?>
</div>
@@ -971,4 +1074,4 @@ window.addEventListener('beforeprint', function () {
</script>
<?= $this->endSection() ?>
<?= $this->endSection() ?>
<?= $this->endSection() ?>
@@ -0,0 +1,265 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php
$filters = $filters ?? [];
$result = $result ?? null;
$summary = $result['summary'] ?? [];
$mode = $filters['calculator_mode'] ?? 'compare';
$fmtMoney = static fn($value): string => '$' . number_format((float) $value, 2);
$warningText = static function (array $warnings): string {
$warnings = array_values(array_filter(array_map('trim', $warnings), 'strlen'));
return $warnings === [] ? 'None' : implode(' | ', $warnings);
};
?>
<div class="container-fluid mt-4">
<div class="d-flex flex-wrap justify-content-between align-items-end gap-3 mb-4">
<div>
<h2 class="mt-4 mb-1">Tuition Collection Forecast</h2>
<p class="text-muted mb-0">Uses actual enrollment families from the selected school year to compare old and new projected tuition income.</p>
</div>
<?php if ($result): ?>
<?php
$exportQuery = http_build_query([
'school_year' => $result['school_year'] ?? '',
'semester' => $result['semester'] ?? '',
'calculator_mode' => $result['calculator_mode'] ?? 'compare',
'include_withdrawn_mode' => $filters['include_withdrawn_mode'] ?? 'refund_deadline',
'include_payment_pending' => !empty($filters['include_payment_pending']) ? '1' : '0',
'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">
Export CSV
</a>
<?php endif; ?>
</div>
<div class="card shadow-sm mb-4">
<div class="card-body">
<form method="get" action="<?= site_url('administrator/tuition-forecast') ?>" class="row g-3 align-items-end">
<div class="col-md-3">
<label for="school_year" class="form-label">School Year</label>
<select id="school_year" name="school_year" class="form-select">
<?php foreach (($schoolYears ?? []) as $schoolYear): ?>
<option value="<?= esc($schoolYear) ?>" <?= ($filters['school_year'] ?? '') === $schoolYear ? 'selected' : '' ?>>
<?= esc($schoolYear) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-2">
<label for="semester" class="form-label">Semester Filter</label>
<select id="semester" name="semester" class="form-select">
<option value="" <?= ($filters['semester'] ?? '') === '' ? 'selected' : '' ?>>All Year</option>
<?php foreach (($semesters ?? []) as $semester): ?>
<option value="<?= esc($semester) ?>" <?= ($filters['semester'] ?? '') === $semester ? 'selected' : '' ?>>
<?= esc($semester) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-2">
<label for="calculator_mode" class="form-label">Calculator Mode</label>
<select id="calculator_mode" name="calculator_mode" class="form-select">
<option value="compare" <?= $mode === 'compare' ? 'selected' : '' ?>>Compare Both</option>
<option value="old" <?= $mode === 'old' ? 'selected' : '' ?>>Old Only</option>
<option value="new" <?= $mode === 'new' ? 'selected' : '' ?>>New Only</option>
</select>
</div>
<div class="col-md-2">
<label for="include_withdrawn_mode" class="form-label">Withdrawn Students</label>
<select id="include_withdrawn_mode" name="include_withdrawn_mode" class="form-select">
<option value="refund_deadline" <?= ($filters['include_withdrawn_mode'] ?? 'refund_deadline') === 'refund_deadline' ? 'selected' : '' ?>>Use Refund Deadline Rule</option>
<option value="include" <?= ($filters['include_withdrawn_mode'] ?? '') === 'include' ? 'selected' : '' ?>>Always Include</option>
<option value="exclude" <?= ($filters['include_withdrawn_mode'] ?? '') === 'exclude' ? 'selected' : '' ?>>Always Exclude</option>
</select>
</div>
<div class="col-md-3">
<label for="unit_price" class="form-label">Grades Unit Price</label>
<input
type="number"
min="0"
step="0.01"
id="unit_price"
name="unit_price"
class="form-control"
value="<?= esc((string) ($filters['unit_price'] ?? ($summary['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">
<input class="form-check-input" type="checkbox" id="include_payment_pending" name="include_payment_pending" value="1" <?= !empty($filters['include_payment_pending']) ? 'checked' : '' ?>>
<label class="form-check-label" for="include_payment_pending">Include payment-pending students</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="include_event_only" name="include_event_only" value="1" <?= !empty($filters['include_event_only']) ? 'checked' : '' ?>>
<label class="form-check-label" for="include_event_only">Show event-only students in warnings</label>
</div>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">Run Forecast</button>
</div>
</form>
</div>
</div>
<?php if ($result): ?>
<div class="row g-3 mb-4">
<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>
<div class="fs-4 fw-semibold"><?= esc((string) ($summary['family_count'] ?? 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">Total Students</div>
<div class="fs-4 fw-semibold"><?= esc((string) ($summary['student_count'] ?? 0)) ?></div>
<div class="small text-muted">Billable: <?= esc((string) ($summary['billable_student_count'] ?? 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">Old Projected Income</div>
<div class="fs-5 fw-semibold"><?= esc($fmtMoney($summary['old_projected_income'] ?? ($summary['old_projected_tuition'] ?? 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">New Projected Income</div>
<div class="fs-5 fw-semibold"><?= esc($fmtMoney($summary['new_projected_income'] ?? ($summary['new_projected_tuition'] ?? 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">Grades Unit Price</div>
<div class="fs-5 fw-semibold"><?= esc($fmtMoney($summary['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">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>
<div class="fs-5 fw-semibold <?= ((float) ($summary['difference'] ?? 0)) >= 0 ? 'text-success' : 'text-danger' ?>">
<?= esc($fmtMoney($summary['difference'] ?? 0)) ?>
</div>
</div>
</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered align-middle">
<thead class="table-light">
<tr>
<th>Parent / Family</th>
<th>Student Count</th>
<th>Billable Student Count</th>
<th>Old Tuition Total</th>
<th>New Tuition Total</th>
<th>Difference</th>
<th>Warnings</th>
<th>Details</th>
</tr>
</thead>
<tbody>
<?php if (!empty($result['families'])): ?>
<?php foreach ($result['families'] as $family): ?>
<tr>
<td><?= esc($family['parent_name'] ?? '') ?></td>
<td><?= esc((string) ($family['student_count'] ?? 0)) ?></td>
<td><?= esc((string) ($family['billable_student_count'] ?? 0)) ?></td>
<td><?= esc($fmtMoney($family['old_total'] ?? 0)) ?></td>
<td><?= esc($fmtMoney($family['new_total'] ?? 0)) ?></td>
<td class="<?= ((float) ($family['difference'] ?? 0)) >= 0 ? 'text-success' : 'text-danger' ?>">
<?= esc($fmtMoney($family['difference'] ?? 0)) ?>
</td>
<td class="small"><?= esc($warningText($family['warnings'] ?? [])) ?></td>
<td>
<details>
<summary>Students</summary>
<div class="table-responsive mt-2">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th>Student</th>
<th>Grade</th>
<th>Billable</th>
<th>Excluded Reason</th>
<th>Old Rule</th>
<th>Old Amount</th>
<th>New Rule</th>
<th>New Amount</th>
</tr>
</thead>
<tbody>
<?php foreach (($family['student_details'] ?? []) as $detail): ?>
<tr>
<td><?= esc($detail['student_name'] ?? '') ?></td>
<td><?= esc($detail['grade_level'] ?? '') ?></td>
<td><?= !empty($detail['billable']) ? 'Yes' : 'No' ?></td>
<td><?= esc((string) ($detail['excluded_reason'] ?? '')) ?></td>
<td><?= esc((string) ($detail['old_rule'] ?? '')) ?></td>
<td><?= esc($fmtMoney($detail['old_amount'] ?? 0)) ?></td>
<td><?= esc((string) ($detail['new_rule'] ?? '')) ?></td>
<td><?= esc($fmtMoney($detail['new_amount'] ?? 0)) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</details>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="8" class="text-center text-muted">No forecastable families matched the selected filters.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<?php endif; ?>
</div>
<?= $this->endSection() ?>
+2 -2
View File
@@ -262,9 +262,9 @@ $show_actions = false; // notified page hides actions by design
// Initialize Bootstrap tooltips (for "View full" note)
document.addEventListener('DOMContentLoaded', function () {
if (window.bootstrap) {
if (window.bootstrap && bootstrap.Tooltip) {
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.forEach(function (el) { new bootstrap.Tooltip(el); });
tooltipTriggerList.forEach(function (el) { bootstrap.Tooltip.getOrCreateInstance(el); });
}
});
+5 -4
View File
@@ -350,19 +350,20 @@ if ($returnUrl === '') {
<table class="table table-sm table-striped table-hover align-middle fc-table-stack">
<thead class="table-light">
<tr>
<th>Invoice</th><th>Amount</th><th>Balance</th><th>Method</th><th>Date</th><th>Status</th>
<th>Invoice</th><th>Amount</th><th>Invoice Balance</th><th>Method</th><th>Date</th><th>Payment Status</th><th>Invoice Status</th>
</tr>
</thead>
<tbody>
<?php $imap = $f['invoice_map'] ?? []; ?>
<?php foreach ($f['payments'] as $p): ?>
<tr>
<td data-label="Invoice"><?php $iid = (int)($p['invoice_id'] ?? 0); echo esc($imap[$iid] ?? ('#'.$iid)); ?></td>
<td data-label="Invoice"><?php $iid = (int)($p['invoice_id'] ?? 0); echo esc($p['invoice_number'] ?? $imap[$iid] ?? ('#'.$iid)); ?></td>
<td data-label="Amount">$<?= number_format((float)($p['paid_amount'] ?? 0), 2) ?></td>
<td data-label="Balance">$<?= number_format((float)($p['balance'] ?? 0), 2) ?></td>
<td data-label="Invoice Balance">$<?= number_format((float)($p['invoice_current_balance'] ?? 0), 2) ?></td>
<td data-label="Method"><?= esc($p['payment_method'] ?? '') ?></td>
<td data-label="Date"><?= esc(!empty($p['payment_date']) ? local_date($p['payment_date'], 'm-d-Y') : '') ?></td>
<td data-label="Status"><?= esc($p['status'] ?? '') ?></td>
<td data-label="Payment Status"><?= esc($p['payment_status'] ?? '') ?></td>
<td data-label="Invoice Status"><?= esc($p['invoice_status'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
+5 -4
View File
@@ -263,19 +263,20 @@
<table class="table table-sm table-striped table-hover align-middle">
<thead class="table-light">
<tr>
<th>Invoice</th><th>Amount</th><th>Balance</th><th>Method</th><th>Date</th><th>Status</th>
<th>Invoice</th><th>Amount</th><th>Invoice Balance</th><th>Method</th><th>Date</th><th>Payment Status</th><th>Invoice Status</th>
</tr>
</thead>
<tbody>
<?php $imap = $f['invoice_map'] ?? []; ?>
<?php foreach ($f['payments'] as $p): ?>
<tr>
<td><?php $iid = (int)($p['invoice_id'] ?? 0); echo esc($imap[$iid] ?? ('#'.$iid)); ?></td>
<td><?php $iid = (int)($p['invoice_id'] ?? 0); echo esc($p['invoice_number'] ?? $imap[$iid] ?? ('#'.$iid)); ?></td>
<td>$<?= number_format((float)($p['paid_amount'] ?? 0), 2) ?></td>
<td>$<?= number_format((float)($p['balance'] ?? 0), 2) ?></td>
<td>$<?= number_format((float)($p['invoice_current_balance'] ?? 0), 2) ?></td>
<td><?= esc($p['payment_method'] ?? '') ?></td>
<td><?= esc(!empty($p['payment_date']) ? local_date($p['payment_date'], 'm-d-Y') : '') ?></td>
<td><?= esc($p['status'] ?? '') ?></td>
<td><?= esc($p['payment_status'] ?? '') ?></td>
<td><?= esc($p['invoice_status'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
+115 -6
View File
@@ -6,7 +6,7 @@
<h2 class="text-center mt-4 mb-4">Student Decisions All Students</h2>
<!-- School Year filter only -->
<form method="get" class="row g-2 align-items-center justify-content-center mb-3">
<form method="get" class="row g-2 align-items-center justify-content-center mb-3 no-print">
<div class="col-auto"><label class="form-label mb-0">School year</label></div>
<div class="col-auto">
<select name="school_year" class="form-select form-select-sm" style="min-width: 180px;">
@@ -35,7 +35,10 @@
<span class="badge bg-warning text-dark ms-2">Not yet generated</span>
<?php endif; ?>
</div>
<div class="d-flex gap-2 flex-wrap">
<div class="d-flex gap-2 flex-wrap no-print">
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="window.print()">
Print Stats
</button>
<a class="btn btn-outline-secondary btn-sm" href="<?= base_url('grading') ?>">
Grading
</a>
@@ -56,7 +59,7 @@
<?php endif; ?>
<!-- Generate / Regenerate button -->
<form method="post" action="<?= site_url('grading/decisions/generate') ?>" class="mb-3">
<form method="post" action="<?= site_url('grading/decisions/generate') ?>" class="mb-3 no-print">
<?= csrf_field() ?>
<input type="hidden" name="school_year" value="<?= esc($schoolYear ?? '') ?>">
<button type="submit" class="btn btn-primary">
@@ -93,10 +96,42 @@
elseif ($r['decision'] === '' || $r['source'] === 'pending') $stats['Pending']++;
else $stats['Other']++;
}
$pct = static function (int $count, int $total): string {
return $total > 0 ? number_format(($count / $total) * 100, 1) . '%' : '0.0%';
};
$genderKey = static function (?string $gender): string {
$value = strtolower(trim((string)$gender));
return match (true) {
in_array($value, ['male', 'm', 'boy', 'boys'], true) => 'boys',
in_array($value, ['female', 'f', 'girl', 'girls'], true) => 'girls',
default => 'other',
};
};
$genderStats = static function (array $items) use ($genderKey): array {
$stats = ['boys' => 0, 'girls' => 0, 'other' => 0];
foreach ($items as $item) {
$stats[$genderKey($item['gender'] ?? '')]++;
}
return $stats;
};
$totalStudents = count($rows);
$passRows = array_values(array_filter($rows, static fn (array $row): bool => (string)($row['decision'] ?? '') === 'Pass'));
$trophyRows = array_values(array_filter($rows, static fn (array $row): bool => !empty($row['is_trophy'])));
$allGenderStats = $genderStats($rows);
$passGenderStats = $genderStats($passRows);
$trophyGenderStats = $genderStats($trophyRows);
?>
<!-- Summary cards -->
<div class="d-flex gap-3 mb-3 flex-wrap">
<div class="d-flex gap-3 mb-3 flex-wrap no-print">
<div class="card text-center px-4 py-2 border-success">
<div class="fs-4 fw-bold text-success"><?= $stats['Pass'] ?></div>
<div class="text-muted small">Pass</div>
@@ -115,7 +150,60 @@
</div>
</div>
<div class="table-responsive">
<div class="print-only" style="margin-bottom:14px;">
<div style="text-align:center;margin-bottom:10px;border-bottom:2px solid #333;padding-bottom:6px;">
<h2 style="margin:0;">Student Decisions Summary <?= esc($schoolYear ?? '') ?></h2>
<p style="margin:3px 0 0;font-size:10px;color:#555;">
Trophy counts use the same year-score rule as the trophy final page.
</p>
</div>
<h3 style="margin-bottom:6px;">Print Stats</h3>
<table data-no-mgmt-sticky style="margin-bottom:12px;">
<thead>
<tr>
<th>Metric</th>
<th style="text-align:center;">Count</th>
<th style="text-align:center;">Percent</th>
<th style="text-align:center;">Boys</th>
<th style="text-align:center;">Boys %</th>
<th style="text-align:center;">Girls</th>
<th style="text-align:center;">Girls %</th>
</tr>
</thead>
<tbody>
<tr>
<td>Total Students</td>
<td style="text-align:center;"><?= $totalStudents ?></td>
<td style="text-align:center;">100.0%</td>
<td style="text-align:center;"><?= $allGenderStats['boys'] ?></td>
<td style="text-align:center;"><?= $pct($allGenderStats['boys'], $totalStudents) ?></td>
<td style="text-align:center;"><?= $allGenderStats['girls'] ?></td>
<td style="text-align:center;"><?= $pct($allGenderStats['girls'], $totalStudents) ?></td>
</tr>
<tr>
<td>Pass</td>
<td style="text-align:center;"><?= count($passRows) ?></td>
<td style="text-align:center;"><?= $pct(count($passRows), $totalStudents) ?></td>
<td style="text-align:center;"><?= $passGenderStats['boys'] ?></td>
<td style="text-align:center;"><?= $pct($passGenderStats['boys'], count($passRows)) ?></td>
<td style="text-align:center;"><?= $passGenderStats['girls'] ?></td>
<td style="text-align:center;"><?= $pct($passGenderStats['girls'], count($passRows)) ?></td>
</tr>
<tr>
<td>Trophies</td>
<td style="text-align:center;"><?= count($trophyRows) ?></td>
<td style="text-align:center;"><?= $pct(count($trophyRows), $totalStudents) ?></td>
<td style="text-align:center;"><?= $trophyGenderStats['boys'] ?></td>
<td style="text-align:center;"><?= $pct($trophyGenderStats['boys'], count($trophyRows)) ?></td>
<td style="text-align:center;"><?= $trophyGenderStats['girls'] ?></td>
<td style="text-align:center;"><?= $pct($trophyGenderStats['girls'], count($trophyRows)) ?></td>
</tr>
</tbody>
</table>
</div>
<div class="table-responsive no-print">
<table class="table table-bordered table-striped align-middle w-100 all-decisions-dt"
data-no-mgmt-sticky data-no-dt-fixedheader>
<thead class="table-light">
@@ -174,7 +262,7 @@
</div>
<!-- Legend -->
<div class="mt-2 mb-4 d-flex gap-2 flex-wrap align-items-center">
<div class="mt-2 mb-4 d-flex gap-2 flex-wrap align-items-center no-print">
<?php foreach ($decisionBadge as $label => $color): ?>
<span class="badge bg-<?= esc($color) ?> px-2 py-1"><?= esc($label) ?></span>
<?php endforeach; ?>
@@ -189,8 +277,29 @@
<?= $this->section('scripts') ?>
<style>
.print-only { display: none !important; }
.grade-orange td { background: #fff3cd !important; color: #8a5d00; }
.grade-red td { background: #f8d7da !important; color: #842029; }
@media print {
.no-print { display: none !important; }
.print-only { display: block !important; }
body { font-size: 11px; }
.container-fluid { padding: 0 !important; }
table { width: 100%; border-collapse: collapse; font-size: 10px; }
table th, table td { border: 1px solid #bbb; padding: 3px 5px; }
table thead {
background: #333 !important;
color: #fff !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
table thead th {
position: static !important;
top: auto !important;
box-shadow: none !important;
}
}
</style>
<script>
(function () {
+6 -3
View File
@@ -147,6 +147,7 @@ if (empty($schoolYears) && $schoolYear !== '') {
<thead class="table-light">
<tr>
<th style="min-width:160px">Student Name</th>
<th class="text-center">Age</th>
<th>Class-Section</th>
<th class="text-center">Year Score</th>
<th style="min-width:300px">Comments / Rationale &amp; Decision</th>
@@ -177,9 +178,11 @@ if (empty($schoolYears) && $schoolYear !== '') {
<tr class="<?= esc($rowClass) ?>">
<td><?= esc($studentLabel) ?></td>
<td class="text-center"><?= esc((string)($row['age'] ?? '—')) ?></td>
<td><?= esc($row['class_section_name'] ?? '—') ?></td>
<td class="text-center">
<td class="text-center" data-order="<?= esc($scoreVal !== null ? (string)$scoreVal : '-1') ?>">
<div class="fw-semibold"><?= $displayScore($scoreRaw) ?></div>
<button type="button"
@@ -400,7 +403,7 @@ if (empty($schoolYears) && $schoolYear !== '') {
pageLength: 100,
lengthMenu: [10, 25, 50, 100, 200],
columnDefs: [
{ orderable: false, targets: [3] }
{ orderable: false, targets: [4] }
]
});
} catch (_) {}
@@ -695,4 +698,4 @@ if (empty($schoolYears) && $schoolYear !== '') {
});
})();
</script>
<?= $this->endSection() ?>
<?= $this->endSection() ?>
-2
View File
@@ -41,8 +41,6 @@
<!-- Bootstrap Bundle JS (for modal, tooltips, etc.) -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- Optional: Your other custom scripts -->
<script src="<?= base_url('assets/js/bootstrap.min.js') ?>"></script>
<!-- Your validation logic (depends on jQuery & Select2) -->
<script type="module" src="/public/assets/js/validate_student.js"></script>
<script src="<?= base_url('assets/js/modal_validation.js') ?>"></script>
+5 -4
View File
@@ -250,18 +250,19 @@ html, body { overflow-x: hidden; }
</div>
</div>
<!-- Bootstrap Bundle (with Popper) -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (!window.bootstrap || !bootstrap.Tooltip) return;
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.forEach(function(tooltipTriggerEl) {
new bootstrap.Tooltip(tooltipTriggerEl);
bootstrap.Tooltip.getOrCreateInstance(tooltipTriggerEl);
});
});
</script>
<!-- Bootstrap Bundle (with Popper) -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- Additional JS Libraries -->
<script src="https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js"></script>
<script src="<?= base_url('lib/wow/wow.min.js') ?>"></script>
+1 -2
View File
@@ -36,7 +36,6 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/OwlCarousel2/2.3.4/owl.carousel.min.js"></script>
<!-- Custom JS -->
<script src="<?= base_url('assets/js/bootstrap.min.js') ?>"></script>
<script src="<?= base_url('assets/js/modal_validation.js') ?>"></script>
<script src="<?= base_url('assets/js/validation.js') ?>" defer></script>
@@ -45,4 +44,4 @@
<?= $this->renderSection('scripts') ?>
</body>
</html>
</html>
+1 -6
View File
@@ -118,11 +118,6 @@
</div>
<br>
<?php include(__DIR__ . '/../partials/footer.php'); ?>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<!-- JavaScript for Phone and Email Validation -->
<script>
document.addEventListener('DOMContentLoaded', function() {
@@ -283,4 +278,4 @@
});
</script>
<?= $this->endSection() ?>
<?= $this->endSection() ?>
+1 -6
View File
@@ -40,12 +40,7 @@
<div class="col-md-6">
<h4>Payment Options</h4>
<form method="post" action="<?= base_url('payments/createPaypalPayment/' . $payment['id']) ?>">
<?= csrf_field(); ?>
<button type="submit" class="btn btn-primary btn-block">
Pay via PayPal
</button>
</form>
<p class="text-muted mb-0">Online payment is currently unavailable. Please contact the school office to complete payment.</p>
</div>
</div>
</div>
+3 -3
View File
@@ -133,9 +133,9 @@ $role = strtolower(session()->get('role') ?? 'guest');
<a class="dropdown-item" href="/discounts/list">Discount Management</a>
<a class="dropdown-item" href="/expenses/index">Expenses Management</a>
<a class="dropdown-item" href="/payment/financial_report">Financial Report</a>
<a class="dropdown-item" href="/administrator/tuition-forecast">Tuition Forecast</a>
<a class="dropdown-item" href="/invoice_payment/invoice_management">Invoices Management</a>
<a class="dropdown-item" href="/payment/manual_pay">Manual Payment</a>
<a class="dropdown-item" href="/administrator/paypal_transactions">PaypalTransactions</a>
<a class="dropdown-item" href="/refunds/list">Refund Management</a>
<a class="dropdown-item" href="/reimbursements/index">Reimbursement Management</a>
<a class="dropdown-item" href="/payment/notification_management">Payment Notification Management</a>
@@ -233,9 +233,9 @@ $role = strtolower(session()->get('role') ?? 'guest');
<a class="dropdown-item" href="/discounts/list">Discount Management</a>
<a class="dropdown-item" href="/expenses/index">Expenses Management</a>
<a class="dropdown-item" href="/payment/financial_report">Financial Report</a>
<a class="dropdown-item" href="/administrator/tuition-forecast">Tuition Forecast</a>
<a class="dropdown-item" href="/invoice_payment/invoice_management">Invoices Management</a>
<a class="dropdown-item" href="/payment/manual_pay">Manual Payment</a>
<a class="dropdown-item" href="/administrator/paypal_transactions">PaypalTransactions</a>
<a class="dropdown-item" href="/refunds/list">Refund Management</a>
<a class="dropdown-item" href="/reimbursements/index">Reimbursement Management</a>
</div>
@@ -323,9 +323,9 @@ $role = strtolower(session()->get('role') ?? 'guest');
<div class="dropdown-menu">
<a class="dropdown-item" href="/expenses/index">Expenses Management</a>
<a class="dropdown-item" href="/payment/financial_report">Financial Report</a>
<a class="dropdown-item" href="/administrator/tuition-forecast">Tuition Forecast</a>
<a class="dropdown-item" href="/invoice_payment/invoice_management">Invoices Management</a>
<a class="dropdown-item" href="/payment/manual_pay">Manual Payment</a>
<a class="dropdown-item" href="/administrator/paypal_transactions">PaypalTransactions</a>
<a class="dropdown-item" href="/refunds/list">Refund Management</a>
<a class="dropdown-item" href="/reimbursements/index">Reimbursement Management</a>
</div>
+9 -5
View File
@@ -21,15 +21,17 @@
<th>Date</th>
<th>Invoice</th>
<th class="text-end">Paid</th>
<th class="text-end">Balance</th>
<th class="text-end">Invoice Balance</th>
<th>Method</th>
<th>Status</th>
<th>Payment Status</th>
<th>Invoice Status</th>
<th>School Year</th>
</tr>
</thead>
<tbody>
<?php if (empty($payments)): ?>
<tr>
<td colspan="6" class="text-center text-muted py-4">No payment</td>
<td colspan="8" class="text-center text-muted py-4">No payment</td>
</tr>
<?php else: ?>
<?php foreach ($payments as $p): ?>
@@ -40,9 +42,11 @@
<?= $inv !== '' ? esc($inv) : ('#' . (int)($p['invoice_id'] ?? 0)) ?>
</td>
<td class="text-end">$<?= number_format((float)($p['paid_amount'] ?? 0), 2) ?></td>
<td class="text-end">$<?= number_format((float)($p['balance'] ?? 0), 2) ?></td>
<td class="text-end">$<?= number_format((float)($p['invoice_current_balance'] ?? 0), 2) ?></td>
<td><?= esc($p['payment_method'] ?? '') ?></td>
<td><?= esc($p['status'] ?? '') ?></td>
<td><?= esc($p['payment_status'] ?? '') ?></td>
<td><?= esc($p['invoice_status'] ?? '') ?></td>
<td><?= esc($p['school_year'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
+197 -123
View File
@@ -7,6 +7,8 @@
<div class="d-flex align-items-center gap-2 my-3 flex-nowrap w-100 overflow-auto" style="white-space: nowrap;">
<!-- 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 &amp; 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,14 +68,14 @@
<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">Expense Breakdown</h3>
<canvas id="expenseChart" style="max-height:320px; width:100%;"></canvas>
<h3 class="h5">Expenses out of Net Charges</h3>
<canvas id="netExpenseChart" style="max-height:320px; width:100%;"></canvas>
</div>
</div>
</div>
@@ -77,19 +90,118 @@
<script>
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)) ?>,
totalDiscounts: <?= json_encode((float)($totalDiscounts ?? 0)) ?>,
totalRefunds: <?= json_encode((float)($totalRefunds ?? 0)) ?>,
totalExpenses: <?= json_encode((float)($totalExpenses ?? 0)) ?>,
totalReimbursements: <?= json_encode((float)($totalReimbursements ?? 0)) ?>,
donationToSchool: <?= json_encode((float)($donationToSchool ?? 0)) ?>,
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 = 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);
document.getElementById('sumReimb').textContent = fmt(d.totalReimbursements);
if (document.getElementById('sumDonationToSchool')) {
document.getElementById('sumDonationToSchool').textContent = fmt(d.donationToSchool);
}
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();
});
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' }})
@@ -97,26 +209,7 @@ function loadSummary(){
.then(d => {
if (mySeq !== __sumSeq) return; // stale response
if (!d || d.ok !== true) return;
document.getElementById('summaryPeriod').textContent = 'Report for School Year: ' + (d.schoolYear||'');
document.getElementById('sumCharges').textContent = fmt(d.totalCharges);
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('sumDiscounts').textContent = fmt(d.totalDiscounts);
document.getElementById('sumRefunds').textContent = fmt(d.totalRefunds);
document.getElementById('sumExpenses').textContent = fmt(d.totalExpenses);
document.getElementById('sumReimb').textContent = fmt(d.totalReimbursements);
if (document.getElementById('sumDonationToSchool')) {
document.getElementById('sumDonationToSchool').textContent = fmt(d.donationToSchool);
}
document.getElementById('sumNet').textContent = fmt(d.netAmount);
document.getElementById('sumCollected').textContent = fmt(d.amountCollected);
document.getElementById('sumUnpaid').textContent = fmt(d.totalUnpaid);
// Refresh charts with new data
applySummaryData(d);
renderCharts(d);
})
.catch(()=>{});
@@ -127,14 +220,40 @@ function renderCharts(d){
// Destroy existing if present
if (window._summaryChart) { window._summaryChart.destroy(); }
if (window._collectedChart) { window._collectedChart.destroy(); }
if (window._expenseChart) { window._expenseChart.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 }}}
});
@@ -142,21 +261,54 @@ 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
}]
},
options: { responsive:true, plugins:{ legend:{ position:'bottom' } } }
});
const expenseCtx = document.getElementById('expenseChart').getContext('2d');
window._expenseChart = new Chart(expenseCtx, {
const netExpenseCtx = document.getElementById('netExpenseChart').getContext('2d');
const netAmount = Number(d.netAmount || 0);
const totalExpenses = Number(d.totalExpenses || 0);
const expensePortion = Math.max(0, Math.min(totalExpenses, netAmount));
const remainingNet = Math.max(0, netAmount - expensePortion);
const expensePercent = netAmount > 0 ? ((totalExpenses / netAmount) * 100).toFixed(1) : '0.0';
const remainingPercent = netAmount > 0 ? ((remainingNet / netAmount) * 100).toFixed(1) : '0.0';
const netExpenseValues = [expensePortion, remainingNet];
const netExpenseLabels = [
'Expenses (' + expensePercent + '%)',
'Remaining Net (' + remainingPercent + '%)'
];
window._netExpenseChart = new Chart(netExpenseCtx, {
type: 'pie',
data: { labels: ['Expenses','Reimbursements'], datasets: [{ data:[ d.totalExpenses||0, d.totalReimbursements||0 ], backgroundColor: ['#dc3545','#6f42c1']}]},
options: { responsive:true }
data: {
labels: netExpenseLabels,
datasets: [{
data: netExpenseValues,
backgroundColor: [chartColors.expenses, chartColors.netCharges],
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom' },
tooltip: {
callbacks: {
label: function(context) {
if (context.dataIndex === 0) {
return 'Expenses: ' + fmt(totalExpenses) + ' (' + expensePercent + '% of net charges)';
}
return 'Remaining Net: ' + fmt(remainingNet) + ' (' + remainingPercent + '%)';
}
}
}
}
}
});
}
</script>
@@ -174,82 +326,4 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
</script>
<script>
document.addEventListener("DOMContentLoaded", function() {
// Bar chart summary
const summaryCtx = document.getElementById('summaryChart').getContext('2d');
window._summaryChart = new Chart(summaryCtx, {
type: 'bar',
data: {
labels: ['Charges', 'Event Fees', 'Paid', 'Unpaid', 'Discounts', 'Refunds', 'Expenses', 'Reimbursements', 'Net'],
datasets: [{
label: 'Amount (USD)',
data: [
<?= (float)$totalCharges ?>,
<?= (float)($totalEventFees ?? 0) ?>,
<?= (float)$totalPaid ?>,
<?= (float)$totalUnpaid ?>,
<?= (float)$totalDiscounts ?>,
<?= (float)$totalRefunds ?>,
<?= (float)$totalExpenses ?>,
<?= (float)$totalReimbursements ?>,
<?= (float)$netAmount ?>
],
backgroundColor: [
'#007bff', '#6610f2', '#28a745', '#ffc107', '#17a2b8', '#ffc107', '#dc3545', '#6f42c1', '#20c997'
]
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true
}
}
}
});
// Collected vs Outstanding pie
const collectedCtx = document.getElementById('collectedChart').getContext('2d');
window._collectedChart = new Chart(collectedCtx, {
type: 'pie',
data: {
labels: ['Amount Collected (Paid)', 'Amount Outstanding (Unpaid)'],
datasets: [{
data: [
<?= (float)$amountCollected ?>,
<?= (float)$totalUnpaid ?>
],
backgroundColor: ['#28a745', '#ffc107']
}]
},
options: {
responsive: true,
plugins: { legend: { position: 'bottom' } }
}
});
// Pie chart expenses
const expenseCtx = document.getElementById('expenseChart').getContext('2d');
window._expenseChart = new Chart(expenseCtx, {
type: 'pie',
data: {
labels: ['Expenses', 'Reimbursements'],
datasets: [{
data: [
<?= (float)$totalExpenses ?>,
<?= (float)$totalReimbursements ?>
],
backgroundColor: ['#dc3545', '#6f42c1']
}]
},
options: {
responsive: true
}
});
});
</script>
<?= $this->endSection() ?>
+9 -7
View File
@@ -219,11 +219,12 @@
<tr>
<th>Paid Date</th>
<th>Paid Amount</th>
<th>Balance</th>
<th>Invoice Balance</th>
<th>Payment Method</th>
<th>Check Number</th>
<th>Installments</th>
<th>Status</th>
<th>Payment Status</th>
<th>Invoice Status</th>
<th>Check/Card File</th>
<th>Action</th>
</tr>
@@ -242,7 +243,7 @@
?>
<td><?= esc($paidAt) ?></td>
<td>$<?= number_format((float)($payment['paid_amount'] ?? 0), 2) ?></td>
<td>$<?= number_format((float)($payment['balance'] ?? 0), 2) ?></td>
<td>$<?= number_format((float)($payment['invoice_current_balance'] ?? 0), 2) ?></td>
<td>
<?php if ($method === 'cash'): ?>
<span class="badge" style="background-color:#28a745;color:#fff;">Cash</span>
@@ -255,12 +256,13 @@
<?php endif; ?>
</td>
<td><?= !empty($payment['check_number']) ? esc($payment['check_number']) : '-' ?></td>
<td><?= esc($payment['number_of_installments'] ?? '') ?></td>
<td><?= esc($payment['status'] ?? '') ?></td>
<td><?= esc($payment['installment_seq'] ?? $payment['number_of_installments'] ?? '') ?></td>
<td><?= esc($payment['payment_status'] ?? '') ?></td>
<td><?= esc($payment['invoice_status'] ?? '') ?></td>
<td>
<?php if (!empty($payment['check_file'])): ?>
<a href="#" data-bs-toggle="modal" data-bs-target="#checkModal<?= (int)$payment['id'] ?>">View</a> /
<a href="<?= base_url('payment/serveCheckFile/' . esc($payment['check_file']) . '/download') ?>">Download</a>
<a href="<?= base_url('payment/file/' . (int) $payment['id'] . '/download') ?>">Download</a>
<!-- File Preview Modal -->
<div class="modal fade" id="checkModal<?= (int)$payment['id'] ?>" tabindex="-1" aria-hidden="true">
@@ -271,7 +273,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center">
<iframe src="<?= base_url('payment/serveCheckFile/' . esc($payment['check_file']) . '/inline') ?>" width="100%" height="600" style="border:none;"></iframe>
<iframe src="<?= base_url('payment/file/' . (int) $payment['id'] . '/inline') ?>" width="100%" height="600" style="border:none;"></iframe>
</div>
</div>
</div>
+2 -3
View File
@@ -91,10 +91,9 @@
<td><?= esc($payment['status']) ?></td>
<td>
<?php if (!empty($payment['check_file'])): ?>
<?php $file = rawurlencode((string)$payment['check_file']); ?>
<!-- Trigger Modal -->
<a href="#" data-bs-toggle="modal" data-bs-target="#checkModal<?= (int)$payment['id'] ?>">View</a> /
<a href="<?= base_url('payment/serveCheckFile/' . $file . '/download') ?>">Download</a>
<a href="<?= base_url('payment/file/' . (int) $payment['id'] . '/download') ?>">Download</a>
<!-- Modal -->
<div class="modal fade" id="checkModal<?= (int)$payment['id'] ?>" tabindex="-1" aria-hidden="true">
@@ -105,7 +104,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-center">
<iframe src="<?= base_url('payment/serveCheckFile/' . $file . '/inline') ?>"
<iframe src="<?= base_url('payment/file/' . (int) $payment['id'] . '/inline') ?>"
width="100%" height="600px" style="border:none;"></iframe>
</div>
</div>
-81
View File
@@ -1,81 +0,0 @@
<!-- This snippet supports rendering separate standalone buttons
in different parts of your page with unique payment methods.
Test EMAIL: sb-cbmre43313068@business.example.com
Test PASSWORD: 9JC?].qM
-->
<?= $this->extend('layout/register_layout') ?>
<?= $this->section('content') ?>
<div class="container d-flex justify-content-center align-items-start" style="min-height: 100vh; padding-top: 80px;">
<div class="registration-form text-center p-4 rounded-4 shadow"
style="background-color: white; max-width: 600px; width: 300%;">
<!-- Logo -->
<div class="mb-4">
<a href="<?= base_url('/parent_dashboard') ?>">
<img src="<?= base_url('assets/images/alrahma_logo.png') ?>" alt="Alrahma Logo"
style="width: 120px; height: 120px; border-radius: 50%; object-fit: cover;">
</a>
</div>
<!-- Page Title -->
<h3 class="text-success" style="font-family: Arial, sans-serif;">Payment</h3>
<br>
<!-- PayPal Button -->
<div class="d-flex justify-content-center">
<div id="paypal-button-container"></div>
</div>
<p class="text-success">
<br><strong>Your payment information, including credit card and bank account details, is securely stored only by PayPal and Venmo. </strong>
</p>
<p class="text-danger">
We do not collect or save any financial data on our website.
</p>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script src="https://www.paypal.com/sdk/js?client-id=AYsiOekZUvrjgx9C5c554ZeSQ4W6yd0ZX-OHE93_D0fWoa4YXrMmroEeLiAjjdCKkELH8EVZR_yGMLPS&currency=USD&components=buttons,funding-eligibility&enable-funding=venmo&disable-funding=card,paylater">
</script>
<script>
const parentName = <?= json_encode($parentName) ?>;
const totalAmount = <?= json_encode(number_format($totalAmount, 2, '.', '')) ?>;
const schoolId = <?= json_encode($schoolId) ?>;
paypal.Buttons({
style: {
layout: 'vertical',
color: 'blue',
shape: 'rect',
label: 'paypal'
},
createOrder: function(data, actions) {
return actions.order.create({
purchase_units: [{
amount: {
value: totalAmount
},
custom_id: `${schoolId}`,
description: `Payment by ${parentName}`
}]
});
},
onApprove: function(data, actions) {
return actions.order.capture().then(function(details) {
alert('Transaction completed by ' + details.payer.name.given_name + ' for <?= esc($parentName) ?>');
});
},
onError: function(err) {
console.error('An error occurred during the transaction', err);
}
}).render('#paypal-button-container');
</script>
<?= $this->endSection() ?>
+10 -6
View File
@@ -19,25 +19,29 @@
<th>Date</th>
<th>Invoice #</th>
<th class="text-end">Paid Amount</th>
<th class="text-end">Balance</th>
<th class="text-end">Invoice Balance</th>
<th>Method</th>
<th>Status</th>
<th>Payment Status</th>
<th>Invoice Status</th>
<th>School Year</th>
</tr>
</thead>
<tbody>
<?php if (empty($payments)): ?>
<tr>
<td colspan="6" class="text-center text-muted py-4">No payment</td>
<td colspan="8" class="text-center text-muted py-4">No payment</td>
</tr>
<?php else: ?>
<?php foreach ($payments as $p): ?>
<tr>
<td><?= esc(!empty($p['payment_date']) ? local_date($p['payment_date'], 'm-d-Y') : '') ?></td>
<td><?= esc($p['invoice_id'] ?? '') ?></td>
<td><?= esc($p['invoice_number'] ?? ('#' . (int)($p['invoice_id'] ?? 0))) ?></td>
<td class="text-end">$<?= number_format((float)($p['paid_amount'] ?? 0), 2) ?></td>
<td class="text-end">$<?= number_format((float)($p['balance'] ?? 0), 2) ?></td>
<td class="text-end">$<?= number_format((float)($p['invoice_current_balance'] ?? 0), 2) ?></td>
<td><?= esc($p['payment_method'] ?? '') ?></td>
<td><?= esc($p['status'] ?? '') ?></td>
<td><?= esc($p['payment_status'] ?? '') ?></td>
<td><?= esc($p['invoice_status'] ?? '') ?></td>
<td><?= esc($p['school_year'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
+2 -2
View File
@@ -113,7 +113,7 @@
<td><?= esc($r['check_nbr'] ?? '-') ?></td>
<td>
<?php if (!empty($r['check_file'])): ?>
<a href="<?= base_url('uploads/checks/' . $r['check_file']) ?>" target="_blank">View</a>
<a href="<?= base_url('refunds/file/' . (int) $r['id'] . '/inline') ?>" target="_blank">View</a>
<?php else: ?>
-
<?php endif; ?>
@@ -200,7 +200,7 @@
</div>
<div class="mb-3">
<label for="checkFile" class="form-label">Upload Check Image</label>
<input type="file" class="form-control" name="check_file" id="checkFile" accept="image/*">
<input type="file" class="form-control" name="check_file" id="checkFile" accept=".jpg,.jpeg,.png,.pdf">
</div>
</div>
</div>
+92 -85
View File
@@ -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';
}
}
}
+2 -1
View File
@@ -101,9 +101,10 @@
<!-- Tooltip activation script (include once near bottom of page) -->
<script>
document.addEventListener('DOMContentLoaded', function() {
if (!window.bootstrap || !bootstrap.Tooltip) return;
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.forEach(function(tooltipTriggerEl) {
new bootstrap.Tooltip(tooltipTriggerEl);
bootstrap.Tooltip.getOrCreateInstance(tooltipTriggerEl);
});
});
</script>
-1
View File
@@ -18,7 +18,6 @@
"fpdf/fpdf": "^1.86",
"kint-php/kint": "^6.0",
"mike42/escpos-php": "^4.0",
"paypal/rest-api-sdk-php": "^1.6",
"phpmailer/phpmailer": "^6.9",
"phpoffice/phpword": "^1.3",
"tecnickcom/tcpdf": "^6.7"
Generated
+2 -55
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "1d9a05bb735e750eee1a58bae71f7cda",
"content-hash": "4ed9702ae876705936ba1463bc717a32",
"packages": [
{
"name": "bacon/bacon-qr-code",
@@ -1109,59 +1109,6 @@
},
"time": "2019-10-05T02:44:33+00:00"
},
{
"name": "paypal/rest-api-sdk-php",
"version": "v1.6.4",
"source": {
"type": "git",
"url": "https://github.com/paypal/PayPal-PHP-SDK.git",
"reference": "06837d290c4906578cfd92786412dff330a1429c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paypal/PayPal-PHP-SDK/zipball/06837d290c4906578cfd92786412dff330a1429c",
"reference": "06837d290c4906578cfd92786412dff330a1429c",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "3.7.*"
},
"type": "library",
"autoload": {
"psr-0": {
"PayPal": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache2"
],
"authors": [
{
"name": "PayPal",
"homepage": "https://github.com/paypal/rest-api-sdk-php/contributors"
}
],
"description": "PayPal's PHP SDK for REST APIs",
"homepage": "http://paypal.github.io/PayPal-PHP-SDK/",
"keywords": [
"payments",
"paypal",
"rest",
"sdk"
],
"support": {
"issues": "https://github.com/paypal/PayPal-PHP-SDK/issues",
"source": "https://github.com/paypal/PayPal-PHP-SDK/tree/stable"
},
"abandoned": "paypal/paypal-server-sdk",
"time": "2016-01-20T17:45:52+00:00"
},
{
"name": "phpmailer/phpmailer",
"version": "v6.10.0",
@@ -4305,5 +4252,5 @@
"php": "^8.1"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"
"plugin-api-version": "2.9.0"
}
+492
View File
@@ -0,0 +1,492 @@
# Payment Data Repair Plan
## Scope
This plan fixes the payment table, invoice/payment display logic, and manual payment write flow.
Reviewed inputs:
- `invoices(2).sql`
- `payments(1).sql`
- `PaymentController(2).php`
- `PaymentTransactionController(1).php`
## Current findings
| Check | Result |
|---|---:|
| Invoice rows | 126 |
| Payment rows | 321 |
| Invoice `paid_amount` vs `SUM(payments.paid_amount)` mismatches | 0 |
| Payment rows where `payments.total_amount` differs from linked invoice total | 106 |
| Invoices affected by stale `payments.total_amount` | 66 |
| Payment rows with negative `payments.balance` | 5 |
| Payment rows whose `semester` / `school_year` differs from linked invoice | 26 |
| Invoice IDs with no payment rows | 46, 76, 77, 108, 112, 122 |
Main conclusion: payment rows are not missing. The system is reading and storing payment context inconsistently. The payment table is being treated as both a transaction ledger and a cached invoice snapshot, which is why the UI looks wrong.
---
## Priority 1 — Protect production before repair
- [ ] Take a database backup.
- [ ] Export `invoices`, `payments`, `discount_usages`, `refunds`, `balance_transfers`, and any `payment_transactions` table.
- [ ] Run all reconciliation SQL in read-only mode first.
- [ ] Apply data repair only after confirming the result count of each query.
Recommended backup command:
```bash
mysqldump -u <user> -p <database> invoices payments discount_usages refunds balance_transfers payment_transactions > payment_repair_backup.sql
```
---
## Priority 2 — Add missing schema support
The PHP controller writes `installment_seq`, but the uploaded `payments` schema only has `number_of_installments`.
Add a dedicated installment sequence column:
```sql
ALTER TABLE payments
ADD COLUMN installment_seq INT NULL AFTER number_of_installments;
UPDATE payments
SET installment_seq = number_of_installments
WHERE installment_seq IS NULL;
```
Change `payment_date` from `DATE` to `DATETIME` so multiple same-day payments can be sorted correctly:
```sql
ALTER TABLE payments
MODIFY payment_date DATETIME NOT NULL;
```
Add indexes:
```sql
CREATE INDEX idx_payments_invoice_id ON payments(invoice_id);
CREATE INDEX idx_payments_parent_year ON payments(parent_id, school_year, semester);
CREATE UNIQUE INDEX uq_payments_transaction_id ON payments(transaction_id);
CREATE INDEX idx_invoices_parent_year ON invoices(parent_id, school_year, semester);
```
---
## Priority 3 — Repair existing payment school year and semester
Payments should inherit their term from the linked invoice. The current data has payments marked `Spring` while their invoice is `Fall`.
Preview first:
```sql
SELECT
p.id AS payment_id,
p.invoice_id,
p.parent_id,
p.school_year AS payment_school_year,
p.semester AS payment_semester,
i.school_year AS invoice_school_year,
i.semester AS invoice_semester
FROM payments p
JOIN invoices i ON i.id = p.invoice_id
WHERE p.school_year <> i.school_year
OR p.semester <> i.semester
ORDER BY p.id;
```
Repair:
```sql
UPDATE payments p
JOIN invoices i ON i.id = p.invoice_id
SET
p.school_year = i.school_year,
p.semester = i.semester
WHERE
p.school_year <> i.school_year
OR p.semester <> i.semester;
```
---
## Priority 4 — Stop trusting stale copied fields
Do not use these fields as authoritative in the UI:
- `payments.total_amount`
- `payments.balance`
- `payments.status` as invoice status
Use:
- `payments.paid_amount` as the actual transaction amount.
- `invoices.total_amount` as the invoice total.
- `InvoiceLedgerService` for current invoice balance.
- Invoice status from recalculation, not from the latest payment row.
The payment history table should display:
```text
invoice_number
payment_date
paid_amount
payment_method
check_number
transaction_id
installment_seq
balance_after_payment
invoice_total
invoice_current_balance
invoice_status
school_year
semester
```
---
## Priority 5 — Fix `manualPayUpdate()` term assignment
Current bug: the controller locks the invoice and reads the invoice school year, but then passes `$this->schoolYear` and `$this->semester` into `processPayment()`. That stores payment rows under the active config term instead of the invoice term.
Change the invoice lock query:
```php
$row = $this->db->query(
'SELECT id, parent_id, total_amount, school_year, semester FROM invoices WHERE id = ? FOR UPDATE',
[$invoiceId]
)->getRowArray();
$parentId = (int) ($row['parent_id'] ?? 0);
$invYear = (string) ($row['school_year'] ?? $this->schoolYear);
$invSemester = (string) ($row['semester'] ?? $this->semester);
```
Then pass the invoice term into `processPayment()`:
```php
$ok = $this->processPayment(
$invoiceId,
$amount,
$paymentMethod,
$checkFile,
$transactionId,
$paymentDate,
$invYear,
$invSemester,
$checkNumber,
$installmentSeq,
(array) $this->invoiceModel->find($invoiceId),
$currentBalance
);
```
Do not use the current configuration term when recording a payment for an existing invoice. The invoice owns the term.
---
## Priority 6 — Replace weak parent payment queries
Current parent payment lookup is too broad and does not include enough invoice context.
Replace simple payment-only queries with invoice-joined queries.
Example:
```php
$selectedYear = $this->request->getGet('school_year') ?? $this->schoolYear;
$selectedSemester = $this->request->getGet('semester') ?? null;
$paymentsQuery = $this->paymentModel
->select([
'payments.id',
'payments.invoice_id',
'invoices.invoice_number',
'payments.transaction_id',
'payments.paid_amount',
'payments.payment_method',
'payments.check_number',
'payments.check_file',
'payments.payment_date',
'payments.installment_seq',
'payments.number_of_installments',
'payments.balance AS balance_after_payment',
'payments.status AS payment_status',
'invoices.total_amount AS invoice_total',
'invoices.balance AS invoice_current_balance',
'invoices.status AS invoice_status',
'invoices.school_year',
'invoices.semester',
])
->join('invoices', 'invoices.id = payments.invoice_id', 'inner')
->where('payments.parent_id', $parentId)
->where('invoices.school_year', $selectedYear);
if ($selectedSemester !== null && $selectedSemester !== '') {
$paymentsQuery->where('invoices.semester', $selectedSemester);
}
$payments = $paymentsQuery
->orderBy('payments.payment_date', 'DESC')
->orderBy('payments.id', 'DESC')
->paginate(10);
```
Apply this to:
- `getByParent()`
- `manualPaySearch()`
- Any dashboard or parent profile payment history endpoint
- Any admin finance page reading payment history
---
## Priority 7 — Normalize payment status
Payment status and invoice status must not mean the same thing.
Payment statuses should be transaction-level:
```text
Recorded
Voided
Refunded
Failed
Reversed
Chargeback
```
Invoice statuses should be computed:
```text
Unpaid
Partially Paid
Paid
Overpaid
Cancelled
```
After the UI stops depending on `payments.status` as invoice status, normalize existing successful payment rows:
```sql
UPDATE payments
SET status = 'Recorded'
WHERE LOWER(status) IN ('paid', 'partially paid', 'payment recorded');
```
Update `FinancialStatus` to include transaction statuses separately from invoice statuses.
---
## Priority 8 — Decide what to do with `payment_transactions`
`PaymentTransactionController` writes to a separate transaction model, but manual payments are recorded directly into `payments`.
Pick one architecture:
### Preferred
Use `payments` as the single payment transaction ledger.
Then:
- [ ] Retire payment transaction pages/routes if unused.
- [ ] Do not build UI reports from `payment_transactions`.
- [ ] Keep gateway attempts in a separate `payment_attempts` or `online_payment_transactions` table only if needed.
### Alternative
If `payment_transactions` must stay:
- [ ] Create a transaction row every time a payment row is inserted.
- [ ] Do it inside the same DB transaction.
- [ ] Never show `payment_transactions` without joining back to `payments`.
Preferred option is cleaner. Two ledgers become two lies unless aggressively synchronized.
---
## Priority 9 — Recalculate invoice ledger
After schema and controller fixes, recalculate every invoice through `InvoiceLedgerService`.
Required behavior:
- [ ] Sum successful payments from `payments`.
- [ ] Subtract discounts from `discount_usages`.
- [ ] Apply refunds/credits consistently.
- [ ] Include additional/event charges if they belong in invoice total.
- [ ] Update cached `invoices.paid_amount`, `invoices.balance`, and `invoices.status`.
- [ ] Never calculate invoice balance directly inside controllers.
Suggested maintenance command:
```php
foreach ($invoiceModel->findAll() as $invoice) {
$invoiceLedgerService->recalculateInvoice((int) $invoice['id']);
}
```
Run this from a CLI command, not from a browser request.
---
## Priority 10 — Reconciliation SQL
Run these checks after repair.
### Stale payment totals
```sql
SELECT
p.id AS payment_id,
p.invoice_id,
p.parent_id,
p.total_amount AS payment_total_amount,
i.total_amount AS invoice_total_amount,
p.paid_amount,
p.balance,
p.payment_date
FROM payments p
JOIN invoices i ON i.id = p.invoice_id
WHERE p.total_amount <> i.total_amount
ORDER BY p.invoice_id, p.id;
```
Expected after UI fix: rows may still exist historically, but the UI must not rely on `payments.total_amount`.
### Term mismatch
```sql
SELECT
p.id AS payment_id,
p.invoice_id,
p.parent_id,
p.school_year AS payment_school_year,
p.semester AS payment_semester,
i.school_year AS invoice_school_year,
i.semester AS invoice_semester
FROM payments p
JOIN invoices i ON i.id = p.invoice_id
WHERE p.school_year <> i.school_year
OR p.semester <> i.semester
ORDER BY p.id;
```
Expected: zero rows.
### Negative payment balance snapshots
```sql
SELECT
id,
parent_id,
invoice_id,
total_amount,
paid_amount,
balance,
status,
payment_date
FROM payments
WHERE balance < 0
ORDER BY invoice_id, id;
```
Expected: zero rows after balance snapshot repair, or ignored if `payments.balance` is deprecated.
### Invoices with no payment rows
```sql
SELECT
i.id,
i.parent_id,
i.invoice_number,
i.total_amount,
i.paid_amount,
i.balance,
i.has_discount,
i.status
FROM invoices i
LEFT JOIN payments p ON p.invoice_id = i.id
WHERE p.id IS NULL
ORDER BY i.id;
```
Expected: only invoices fully covered by discounts, credits, cancellations, or legitimate zero-payment states.
### Invoice paid amount consistency
```sql
SELECT
i.id AS invoice_id,
i.parent_id,
i.invoice_number,
i.paid_amount AS invoice_paid_amount,
COALESCE(SUM(p.paid_amount), 0) AS payment_sum
FROM invoices i
LEFT JOIN payments p ON p.invoice_id = i.id
GROUP BY i.id, i.parent_id, i.invoice_number, i.paid_amount
HAVING ROUND(i.paid_amount, 2) <> ROUND(payment_sum, 2)
ORDER BY i.id;
```
Expected: zero rows, unless invoice paid amount intentionally includes imported legacy payments that are not in `payments`.
---
## Priority 11 — Tests to add
### Unit tests
- [ ] Payment creation uses invoice `school_year` and `semester`, not config term.
- [ ] Payment amount cannot exceed current ledger balance.
- [ ] Card payment must equal full current balance.
- [ ] Editing a payment recalculates invoice balance.
- [ ] `installment_seq` increments only successful payments.
- [ ] Voided/refunded/failed payments are excluded from balance.
- [ ] `payments.total_amount` is never used as current invoice total in DTOs.
### Integration tests
- [ ] Parent payment history returns payments joined to invoice number.
- [ ] Parent payment history filters by invoice school year.
- [ ] Parent payment history filters by invoice semester.
- [ ] Admin finance table shows transaction amount and current invoice balance correctly.
- [ ] Manual payment insert, invoice recalculation, and notification event are atomic.
### Data repair tests
- [ ] Term mismatch query returns zero rows after repair.
- [ ] Negative payment balance query returns zero rows or deprecated field is hidden.
- [ ] Invoice paid amount consistency query returns zero rows.
- [ ] Invoices with no payments are manually classified as discount-only, cancelled, imported, or broken.
---
## Acceptance criteria
The fix is complete only when:
- [ ] Existing payments are visible in parent and admin payment history.
- [ ] Payment history shows transaction amount, not stale invoice total.
- [ ] Payment rows inherit school year and semester from invoice.
- [ ] No payment row has mismatched invoice term.
- [ ] No new payment insert depends on global config term for existing invoices.
- [ ] `installment_seq` exists and is populated.
- [ ] Invoice balances are recalculated by `InvoiceLedgerService`.
- [ ] `payment_transactions` is either retired or explicitly separated as gateway-attempt history.
- [ ] Reconciliation SQL has been run and saved as evidence.
---
## Do not do this
- Do not manually edit invoice balances row by row.
- Do not use `payments.total_amount` as invoice total.
- Do not use `payments.status` as invoice status.
- Do not filter payment history only by `payments.school_year` if invoice term exists.
- Do not keep two independent payment ledgers unless one is clearly marked as gateway attempts.
- Do not trust the latest payment row balance as the current invoice balance.
+68
View File
@@ -0,0 +1,68 @@
# School Year and Semester Schema Audit
Source: `scool_view2.sql`
Tables found: 73
## Result
The schema is not fully fixed.
## Required financial tables
Confirmed with `school_year`:
- payment_notification_logs
- payment_transactions
- reimbursement_batch_items
Absent from this dump and therefore not verifiable:
- discount_usages
- event_charges
- invoice_event
- payment_error
## School-year defects
- `archived_paypal_transactions.school_year` is `VARCHAR(20)` instead of `VARCHAR(9)`.
- Tables with `school_year` but no direct school-year index:
- archived_paypal_transactions
- class_progress_reports
- exams
- missing_score_overrides
- payments
- placement_batches
- print_requests
- report_card_acknowledgements
- semester_scores
- staff_attendance
- teacher_attendance_data
- whatsapp_group_links
## Tables where `semester` should be removed
- badge_print_logs
- classes
- contactus
- emergency_contacts
- inventory_items
- invoice_students_list
- ip_attempts
- notification_recipients
- notifications
- parents
- support_requests
- user_notifications
- whatsapp_group_links
## Tables requiring business-rule confirmation before removing `semester`
- archived_paypal_transactions
- discount_vouchers
- payment_transactions
- reimbursement_batch_items
- scan_log
## Notes
`semester` should remain on semester-owned academic and operational records such as exams, quizzes, grades, class sections, attendance, semester reports, and semester-specific charges or payments.
Child tables should not duplicate `semester` when it can be obtained reliably through their parent record, unless immutable historical snapshots are an explicit requirement.

Some files were not shown because too many files have changed in this diff Show More