From 6444b6141657c0ad36ce84f35142fc3da5654f5e Mon Sep 17 00:00:00 2001 From: root Date: Mon, 1 Jun 2026 02:08:27 -0400 Subject: [PATCH] fix financial issues --- app/Commands/RecalculateInvoices.php | 171 +++++ app/Commands/SyncPaypalPayments.php | 232 ------ app/Config/Commands.php | 4 +- app/Config/Filters.php | 4 - app/Config/PaypalConfig.php | 13 - app/Config/Routes.php | 129 ++-- app/Controllers/View/DiscountController.php | 238 +----- .../View/ExtraChargesController.php | 63 +- app/Controllers/View/FilesController.php | 78 +- app/Controllers/View/FinancialController.php | 25 +- app/Controllers/View/GradingController.php | 148 ++++ app/Controllers/View/PaymentController.php | 692 ++++-------------- .../View/PaypalTransactionsController.php | 99 --- app/Controllers/View/RefundController.php | 220 ++++-- .../View/ReportCardsController.php | 44 +- .../View/TuitionForecastController.php | 137 ++++ ...30-000001_FinancialSystemLedgerCleanup.php | 292 ++++++++ app/Database/Seeds/NavSeeder.php | 6 +- app/Filters/AuthFilter.php | 105 ++- app/Helpers/attendance_comment_helper.php | 36 +- app/Interfaces/TuitionCalculatorInterface.php | 8 + app/Libraries/FinancialAttachmentService.php | 100 +++ app/Libraries/FinancialStatus.php | 98 +++ app/Libraries/InvoiceLedgerService.php | 342 +++++++++ app/Libraries/Tuition/GradeLevelParser.php | 40 + .../Tuition/NewTuitionCalculatorService.php | 75 ++ .../Tuition/OldTuitionCalculatorService.php | 65 ++ .../Tuition/TuitionForecastService.php | 647 ++++++++++++++++ app/Models/AdditionalChargeModel.php | 2 +- app/Models/PayPalPaymentModel.php | 38 - app/Models/PaymentModel.php | 2 + app/Models/PaypalTransactionModel.php | 30 - .../administrator/paypal_transactions.php | 84 --- app/Views/administrator/trophy_final.php | 195 +++-- app/Views/administrator/tuition_forecast.php | 244 ++++++ app/Views/grading/all_decisions.php | 121 ++- app/Views/grading/below_sixty_decisions.php | 9 +- app/Views/parent/payment_view.php | 7 +- app/Views/partials/navbar_back.php | 6 +- .../payment/financial_report_summary.php | 185 ++--- app/Views/payment/manual_pay.php | 4 +- app/Views/payment/manual_payment.php | 5 +- app/Views/payment/payment_redirect.php | 81 -- app/Views/refunds/list.php | 4 +- composer.json | 1 - composer.lock | 57 +- .../View/PaymentControllerRegressionTest.php | 138 ++++ .../View/TuitionForecastControllerTest.php | 217 ++++++ .../FinancialSystemLedgerCleanupTest.php | 103 +++ .../Helpers/AttendanceCommentHelperTest.php | 53 ++ .../FinancialAttachmentServiceTest.php | 99 +++ .../NewTuitionCalculatorServiceTest.php | 54 ++ .../OldTuitionCalculatorServiceTest.php | 29 + .../Libraries/TuitionForecastServiceTest.php | 82 +++ tests/app/Models/PaymentModelMetadataTest.php | 19 + .../app/Models/PaypalTransactionModelTest.php | 40 - 56 files changed, 4196 insertions(+), 1824 deletions(-) create mode 100644 app/Commands/RecalculateInvoices.php delete mode 100644 app/Commands/SyncPaypalPayments.php delete mode 100644 app/Config/PaypalConfig.php delete mode 100644 app/Controllers/View/PaypalTransactionsController.php create mode 100644 app/Controllers/View/TuitionForecastController.php create mode 100644 app/Database/Migrations/2026-05-30-000001_FinancialSystemLedgerCleanup.php create mode 100644 app/Interfaces/TuitionCalculatorInterface.php create mode 100644 app/Libraries/FinancialAttachmentService.php create mode 100644 app/Libraries/FinancialStatus.php create mode 100644 app/Libraries/InvoiceLedgerService.php create mode 100644 app/Libraries/Tuition/GradeLevelParser.php create mode 100644 app/Libraries/Tuition/NewTuitionCalculatorService.php create mode 100644 app/Libraries/Tuition/OldTuitionCalculatorService.php create mode 100644 app/Libraries/Tuition/TuitionForecastService.php delete mode 100644 app/Models/PayPalPaymentModel.php delete mode 100644 app/Models/PaypalTransactionModel.php delete mode 100644 app/Views/administrator/paypal_transactions.php create mode 100644 app/Views/administrator/tuition_forecast.php delete mode 100644 app/Views/payment/payment_redirect.php create mode 100644 tests/app/Controllers/View/PaymentControllerRegressionTest.php create mode 100644 tests/app/Controllers/View/TuitionForecastControllerTest.php create mode 100644 tests/app/Database/Migrations/FinancialSystemLedgerCleanupTest.php create mode 100644 tests/app/Helpers/AttendanceCommentHelperTest.php create mode 100644 tests/app/Libraries/FinancialAttachmentServiceTest.php create mode 100644 tests/app/Libraries/NewTuitionCalculatorServiceTest.php create mode 100644 tests/app/Libraries/OldTuitionCalculatorServiceTest.php create mode 100644 tests/app/Libraries/TuitionForecastServiceTest.php create mode 100644 tests/app/Models/PaymentModelMetadataTest.php delete mode 100644 tests/app/Models/PaypalTransactionModelTest.php diff --git a/app/Commands/RecalculateInvoices.php b/app/Commands/RecalculateInvoices.php new file mode 100644 index 0000000..6da84c8 --- /dev/null +++ b/app/Commands/RecalculateInvoices.php @@ -0,0 +1,171 @@ + '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; + } +} diff --git a/app/Commands/SyncPaypalPayments.php b/app/Commands/SyncPaypalPayments.php deleted file mode 100644 index ef23972..0000000 --- a/app/Commands/SyncPaypalPayments.php +++ /dev/null @@ -1,232 +0,0 @@ -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(); - } - } -} diff --git a/app/Config/Commands.php b/app/Config/Commands.php index 40de4da..3308643 100644 --- a/app/Config/Commands.php +++ b/app/Config/Commands.php @@ -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 } } } -} \ No newline at end of file +} diff --git a/app/Config/Filters.php b/app/Config/Filters.php index c1cd695..e674f95 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -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', diff --git a/app/Config/PaypalConfig.php b/app/Config/PaypalConfig.php deleted file mode 100644 index 744403b..0000000 --- a/app/Config/PaypalConfig.php +++ /dev/null @@ -1,13 +0,0 @@ -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,12 @@ $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('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 +659,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'); @@ -741,14 +746,9 @@ $routes->get('payment_transactions/getByPayment/(:num)', 'View\PaymentTransactio $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 +968,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 +1046,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 +1200,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 +1552,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'); diff --git a/app/Controllers/View/DiscountController.php b/app/Controllers/View/DiscountController.php index e87c073..65f9f82 100644 --- a/app/Controllers/View/DiscountController.php +++ b/app/Controllers/View/DiscountController.php @@ -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); } /** diff --git a/app/Controllers/View/ExtraChargesController.php b/app/Controllers/View/ExtraChargesController.php index 37cd2bf..433531a 100644 --- a/app/Controllers/View/ExtraChargesController.php +++ b/app/Controllers/View/ExtraChargesController.php @@ -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 it’s 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(); diff --git a/app/Controllers/View/FilesController.php b/app/Controllers/View/FilesController.php index 386d2cb..2cb291b 100644 --- a/app/Controllers/View/FilesController.php +++ b/app/Controllers/View/FilesController.php @@ -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; + } } diff --git a/app/Controllers/View/FinancialController.php b/app/Controllers/View/FinancialController.php index 43c2939..8391cef 100644 --- a/app/Controllers/View/FinancialController.php +++ b/app/Controllers/View/FinancialController.php @@ -701,8 +701,7 @@ public function financialReport() ] ]; - $url = 'https://quickchart.io/chart?c=' . urlencode(json_encode($chartData)); - file_put_contents(WRITEPATH . 'reports/bar_chart.png', file_get_contents($url)); + $this->writeChartImage('bar_chart.png', $chartData); } private function generatePieChart(array $data) @@ -721,8 +720,28 @@ public function financialReport() ] ]; + $this->writeChartImage('pie_chart.png', $chartData); + } + + private function writeChartImage(string $filename, array $chartData): void + { + $reportDir = WRITEPATH . 'reports'; + if (!is_dir($reportDir) && !mkdir($reportDir, 0775, true) && !is_dir($reportDir)) { + log_message('error', 'Unable to create financial report chart directory: {dir}', ['dir' => $reportDir]); + return; + } + $url = 'https://quickchart.io/chart?c=' . urlencode(json_encode($chartData)); - file_put_contents(WRITEPATH . 'reports/pie_chart.png', file_get_contents($url)); + $image = @file_get_contents($url); + if ($image === false || $image === '') { + log_message('warning', 'Unable to download financial report chart from QuickChart.'); + return; + } + + $path = $reportDir . DIRECTORY_SEPARATOR . $filename; + if (@file_put_contents($path, $image) === false) { + log_message('error', 'Unable to write financial report chart image: {path}', ['path' => $path]); + } } private function getFinancialSummary(?string $dateFrom = null, ?string $dateTo = null, ?string $schoolYear = null): array diff --git a/app/Controllers/View/GradingController.php b/app/Controllers/View/GradingController.php index b54462b..e753c3a 100644 --- a/app/Controllers/View/GradingController.php +++ b/app/Controllers/View/GradingController.php @@ -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 diff --git a/app/Controllers/View/PaymentController.php b/app/Controllers/View/PaymentController.php index 029c2d3..25cfd3d 100644 --- a/app/Controllers/View/PaymentController.php +++ b/app/Controllers/View/PaymentController.php @@ -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 @@ -185,135 +169,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 +189,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); @@ -1057,26 +913,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 +939,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(); @@ -1135,7 +975,9 @@ class PaymentController extends ResourceController $this->schoolYear, $this->semester, $checkNumber, - $installmentSeq + $installmentSeq, + (array) $this->invoiceModel->find($invoiceId), + $currentBalance ); if (!$ok) { @@ -1144,11 +986,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); @@ -1235,20 +1076,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 +1101,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 +1154,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 +1216,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)); } @@ -1655,9 +1272,11 @@ class PaymentController extends ResourceController $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,26 @@ 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 ?? $this->schoolYear, + 'semester' => $semester ?? $this->semester, ]; if (!$this->paymentModel->insert($paymentData)) { @@ -1741,35 +1337,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'); + } } diff --git a/app/Controllers/View/PaypalTransactionsController.php b/app/Controllers/View/PaypalTransactionsController.php deleted file mode 100644 index 370d991..0000000 --- a/app/Controllers/View/PaypalTransactionsController.php +++ /dev/null @@ -1,99 +0,0 @@ -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; - } - -} diff --git a/app/Controllers/View/RefundController.php b/app/Controllers/View/RefundController.php index fd89b9a..decef2d 100644 --- a/app/Controllers/View/RefundController.php +++ b/app/Controllers/View/RefundController.php @@ -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'); + } } diff --git a/app/Controllers/View/ReportCardsController.php b/app/Controllers/View/ReportCardsController.php index b3b128f..953788a 100644 --- a/app/Controllers/View/ReportCardsController.php +++ b/app/Controllers/View/ReportCardsController.php @@ -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; } diff --git a/app/Controllers/View/TuitionForecastController.php b/app/Controllers/View/TuitionForecastController.php new file mode 100644 index 0000000..a2060a2 --- /dev/null +++ b/app/Controllers/View/TuitionForecastController.php @@ -0,0 +1,137 @@ +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', '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']['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') ?? '', + ]; + } +} diff --git a/app/Database/Migrations/2026-05-30-000001_FinancialSystemLedgerCleanup.php b/app/Database/Migrations/2026-05-30-000001_FinancialSystemLedgerCleanup.php new file mode 100644 index 0000000..e919be0 --- /dev/null +++ b/app/Database/Migrations/2026-05-30-000001_FinancialSystemLedgerCleanup.php @@ -0,0 +1,292 @@ +addInstallmentSequenceColumn(); + $this->ensureIndexes(); + $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_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 ensureIndexes(): void + { + $this->addIndexIfMissing('payments', 'idx_payments_invoice_id', ['invoice_id']); + $this->addIndexIfMissing('payments', 'idx_payments_parent_year_semester', ['parent_id', 'school_year', 'semester']); + $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 ensureConfigurationDefaults(): void + { + if (!$this->db->tableExists('configuration')) { + return; + } + + $defaults = [ + 'tuition_calculator_version' => 'old', + 'new_tuition_full_amount' => '370.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; + } +} diff --git a/app/Database/Seeds/NavSeeder.php b/app/Database/Seeds/NavSeeder.php index 5569878..cee3a47 100644 --- a/app/Database/Seeds/NavSeeder.php +++ b/app/Database/Seeds/NavSeeder.php @@ -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], diff --git a/app/Filters/AuthFilter.php b/app/Filters/AuthFilter.php index 0bc85f3..d69386f 100644 --- a/app/Filters/AuthFilter.php +++ b/app/Filters/AuthFilter.php @@ -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 diff --git a/app/Helpers/attendance_comment_helper.php b/app/Helpers/attendance_comment_helper.php index 5ea47e7..c04b8ea 100644 --- a/app/Helpers/attendance_comment_helper.php +++ b/app/Helpers/attendance_comment_helper.php @@ -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; } } diff --git a/app/Interfaces/TuitionCalculatorInterface.php b/app/Interfaces/TuitionCalculatorInterface.php new file mode 100644 index 0000000..ad09391 --- /dev/null +++ b/app/Interfaces/TuitionCalculatorInterface.php @@ -0,0 +1,8 @@ +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'; + } +} diff --git a/app/Libraries/FinancialStatus.php b/app/Libraries/FinancialStatus.php new file mode 100644 index 0000000..4d1f8ec --- /dev/null +++ b/app/Libraries/FinancialStatus.php @@ -0,0 +1,98 @@ + self::INVOICE_PAID, + 'partially paid', 'partially_paid', 'partial' => self::INVOICE_PARTIALLY_PAID, + 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; + } +} diff --git a/app/Libraries/InvoiceLedgerService.php b/app/Libraries/InvoiceLedgerService.php new file mode 100644 index 0000000..e5f19d9 --- /dev/null +++ b/app/Libraries/InvoiceLedgerService.php @@ -0,0 +1,342 @@ +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_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, '.', ''); + } +} diff --git a/app/Libraries/Tuition/GradeLevelParser.php b/app/Libraries/Tuition/GradeLevelParser.php new file mode 100644 index 0000000..086006f --- /dev/null +++ b/app/Libraries/Tuition/GradeLevelParser.php @@ -0,0 +1,40 @@ +toCents($config['new_tuition_full_amount'] ?? 370); + $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 = []; + + foreach (array_values($students) as $index => $student) { + $position = $index + 1; + + 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'; + } + + $amountCents = max(0, $fullAmountCents - $discountCents); + + $details[] = [ + 'student_id' => (int) ($student['student_id'] ?? 0), + 'student_name' => (string) ($student['student_name'] ?? ''), + 'grade_level' => $student['grade_level'] ?? null, + 'family_position' => $position, + 'full_amount' => $this->fromCents($fullAmountCents), + '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, '.', ''); + } +} diff --git a/app/Libraries/Tuition/OldTuitionCalculatorService.php b/app/Libraries/Tuition/OldTuitionCalculatorService.php new file mode 100644 index 0000000..7136f45 --- /dev/null +++ b/app/Libraries/Tuition/OldTuitionCalculatorService.php @@ -0,0 +1,65 @@ +toCents($config['first_student_fee'] ?? 370); + $secondStudentFee = $this->toCents($config['second_student_fee'] ?? 200); + $youthFee = $this->toCents($config['youth_fee'] ?? 180); + + 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, '.', ''); + } +} diff --git a/app/Libraries/Tuition/TuitionForecastService.php b/app/Libraries/Tuition/TuitionForecastService.php new file mode 100644 index 0000000..46b381c --- /dev/null +++ b/app/Libraries/Tuition/TuitionForecastService.php @@ -0,0 +1,647 @@ +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']; + $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', + ]; + + $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, '.', ''); + + 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), + ]; + } + + 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'), + 'new_tuition_full_amount' => $this->normalizedUnitPriceOverride(), + '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 function normalizedUnitPriceOverride(): string + { + return $this->unitPriceOverride + ?? (string) ($this->configurationModel->getConfig('new_tuition_full_amount') ?? '0.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, '.', ''); + } +} diff --git a/app/Models/AdditionalChargeModel.php b/app/Models/AdditionalChargeModel.php index 14dd055..dc73398 100644 --- a/app/Models/AdditionalChargeModel.php +++ b/app/Models/AdditionalChargeModel.php @@ -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', ]; diff --git a/app/Models/PayPalPaymentModel.php b/app/Models/PayPalPaymentModel.php deleted file mode 100644 index 302ec8d..0000000 --- a/app/Models/PayPalPaymentModel.php +++ /dev/null @@ -1,38 +0,0 @@ -selectSum('paid_amount', 'total_paid') ->where('parent_id', $parentId) ->where('school_year', $schoolYear) + ->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled']) ->get() ->getRowArray(); diff --git a/app/Models/PaypalTransactionModel.php b/app/Models/PaypalTransactionModel.php deleted file mode 100644 index ac109c8..0000000 --- a/app/Models/PaypalTransactionModel.php +++ /dev/null @@ -1,30 +0,0 @@ -extend('layout/management_layout') ?> -section('content') ?> -
-
-
-

PayPal Transactions

- include('partials/academic_filter') ?> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
IDTransaction IDOrder IDParent School IDEmailAmountNet AmountCurrencyStatusEvent TypeDate
$$
- -
- links() ?> -
-
-
-endSection() ?> - -section('scripts') ?> - -endSection() ?> diff --git a/app/Views/administrator/trophy_final.php b/app/Views/administrator/trophy_final.php index 9f76843..7a1295b 100644 --- a/app/Views/administrator/trophy_final.php +++ b/app/Views/administrator/trophy_final.php @@ -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); ?> -endSection() ?> \ No newline at end of file +endSection() ?> diff --git a/app/Views/parent/payment_view.php b/app/Views/parent/payment_view.php index c8406db..6a82b9f 100644 --- a/app/Views/parent/payment_view.php +++ b/app/Views/parent/payment_view.php @@ -40,12 +40,7 @@

Payment Options

-
- - -
+

Online payment is currently unavailable. Please contact the school office to complete payment.

diff --git a/app/Views/partials/navbar_back.php b/app/Views/partials/navbar_back.php index 8fbb2ad..a55c10d 100644 --- a/app/Views/partials/navbar_back.php +++ b/app/Views/partials/navbar_back.php @@ -133,9 +133,9 @@ $role = strtolower(session()->get('role') ?? 'guest'); Discount Management Expenses Management Financial Report + Tuition Forecast Invoices Management Manual Payment - PaypalTransactions Refund Management Reimbursement Management Payment Notification Management @@ -233,9 +233,9 @@ $role = strtolower(session()->get('role') ?? 'guest'); Discount Management Expenses Management Financial Report + Tuition Forecast Invoices Management Manual Payment - PaypalTransactions Refund Management Reimbursement Management @@ -323,9 +323,9 @@ $role = strtolower(session()->get('role') ?? 'guest'); diff --git a/app/Views/payment/financial_report_summary.php b/app/Views/payment/financial_report_summary.php index 405315f..6ff0f2b 100644 --- a/app/Views/payment/financial_report_summary.php +++ b/app/Views/payment/financial_report_summary.php @@ -61,8 +61,8 @@
-

Expense Breakdown

- +

Expenses out of Net Amount

+
@@ -77,10 +77,49 @@ @@ -174,82 +227,4 @@ document.addEventListener('DOMContentLoaded', function() { } }); - - - endSection() ?> diff --git a/app/Views/payment/manual_pay.php b/app/Views/payment/manual_pay.php index d864c32..a913e45 100644 --- a/app/Views/payment/manual_pay.php +++ b/app/Views/payment/manual_pay.php @@ -260,7 +260,7 @@ View / - Download + Download diff --git a/app/Views/payment/manual_payment.php b/app/Views/payment/manual_payment.php index 638b351..08c1b66 100644 --- a/app/Views/payment/manual_payment.php +++ b/app/Views/payment/manual_payment.php @@ -91,10 +91,9 @@ - View / - Download + Download diff --git a/app/Views/payment/payment_redirect.php b/app/Views/payment/payment_redirect.php deleted file mode 100644 index 7b8de4a..0000000 --- a/app/Views/payment/payment_redirect.php +++ /dev/null @@ -1,81 +0,0 @@ - - -extend('layout/register_layout') ?> -section('content') ?> - -
-
- - -
- - Alrahma Logo - -
- - -

Payment

-
- - -
-
-
-

-
Your payment information, including credit card and bank account details, is securely stored only by PayPal and Venmo. -

-

- We do not collect or save any financial data on our website. -

-
-
- -endSection() ?> - - - -section('scripts') ?> - - - -endSection() ?> \ No newline at end of file diff --git a/app/Views/refunds/list.php b/app/Views/refunds/list.php index 2c2bba8..41e40ee 100644 --- a/app/Views/refunds/list.php +++ b/app/Views/refunds/list.php @@ -113,7 +113,7 @@ - View + View - @@ -200,7 +200,7 @@
- +
diff --git a/composer.json b/composer.json index 96b1948..203fea4 100644 --- a/composer.json +++ b/composer.json @@ -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" diff --git a/composer.lock b/composer.lock index 05d190d..74f336c 100644 --- a/composer.lock +++ b/composer.lock @@ -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" } diff --git a/tests/app/Controllers/View/PaymentControllerRegressionTest.php b/tests/app/Controllers/View/PaymentControllerRegressionTest.php new file mode 100644 index 0000000..e33228e --- /dev/null +++ b/tests/app/Controllers/View/PaymentControllerRegressionTest.php @@ -0,0 +1,138 @@ + $name, 'data' => $data, 'options' => $options]; + } + } +} + +namespace Tests\App\Controllers\View { + +use App\Controllers\View\PaymentController; +use CodeIgniter\HTTP\RedirectResponse; +use CodeIgniter\Test\CIUnitTestCase; + +class PaymentRequestStub +{ + public function __construct( + private string $method = 'get', + private array $post = [], + private $file = null + ) { + } + + public function getMethod(): string + { + return $this->method; + } + + public function getPost(?string $key = null) + { + if ($key === null) { + return $this->post; + } + + return $this->post[$key] ?? null; + } + + public function getFile(string $name) + { + return $name === 'proof' ? $this->file : null; + } +} + +class ManualPaymentModelSpy +{ + public array $inserted = []; + + public function insert(array $data) + { + $this->inserted[] = $data; + return true; + } +} + +class AttachmentServiceStub +{ + public function __construct(private ?string $path = null) + { + } + + public function saveUploadedFile($file, string $subdir): ?string + { + return $this->path; + } +} + +class TestablePaymentController extends PaymentController +{ + public function __construct() + { + } + + public function setRequestObject($request): self + { + $this->request = $request; + return $this; + } + + public function setManualPaymentModel($model): self + { + $this->manualPaymentModel = $model; + return $this; + } + + public function setAttachmentService($service): self + { + $this->financialAttachmentService = $service; + return $this; + } +} + +class PaymentControllerRegressionTest extends CIUnitTestCase +{ + public function testRedirectPageSendsUsersBackToInvoicePaymentWithInfoMessage(): void + { + $controller = new TestablePaymentController(); + + $response = $controller->redirectPage(); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertStringContainsString('parent/invoice_payment', $response->getHeaderLine('Location')); + } + + public function testManualGetReturnsManualPaymentView(): void + { + $controller = (new TestablePaymentController()) + ->setRequestObject(new PaymentRequestStub('get')); + + $result = $controller->manual(); + + $this->assertSame('payment/manual_payment', $result['view']); + } + + public function testManualPostStoresProofPathFromAttachmentService(): void + { + $model = new ManualPaymentModelSpy(); + $controller = (new TestablePaymentController()) + ->setManualPaymentModel($model) + ->setAttachmentService(new AttachmentServiceStub('proofs/check-123.pdf')) + ->setRequestObject(new PaymentRequestStub('post', [ + 'invoice_number' => 'INV-001', + 'amount' => '125.00', + 'payment_method' => 'check', + 'reference' => 'CHK123', + ], new \stdClass())); + + $response = $controller->manual(); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertCount(1, $model->inserted); + $this->assertSame('proofs/check-123.pdf', $model->inserted[0]['proof_path']); + $this->assertSame('INV-001', $model->inserted[0]['invoice_number']); + } +} +} diff --git a/tests/app/Controllers/View/TuitionForecastControllerTest.php b/tests/app/Controllers/View/TuitionForecastControllerTest.php new file mode 100644 index 0000000..0cb09f7 --- /dev/null +++ b/tests/app/Controllers/View/TuitionForecastControllerTest.php @@ -0,0 +1,217 @@ + $name, 'data' => $data, 'options' => $options]; + } + } +} + +namespace Tests\App\Controllers\View { + +use App\Controllers\View\TuitionForecastController; +use App\Libraries\Tuition\TuitionForecastService; +use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\Test\CIUnitTestCase; + +class ForecastRequestStub +{ + public function __construct( + private array $get = [], + private array $post = [] + ) { + } + + public function getGet(?string $key = null) + { + if ($key === null) { + return $this->get; + } + + return $this->get[$key] ?? null; + } + + public function getPost(?string $key = null) + { + if ($key === null) { + return $this->post; + } + + return $this->post[$key] ?? null; + } +} + +class FakeForecastService extends TuitionForecastService +{ + public array $calls = []; + + public function __construct() + { + } + + public function calculate(string $schoolYear = '', string $semester = '', string $mode = 'compare', array $options = []): array + { + $this->calls[] = compact('schoolYear', 'semester', 'mode', 'options'); + + return [ + 'school_year' => $schoolYear !== '' ? $schoolYear : '2025-2026', + 'semester' => $semester, + 'calculator_mode' => $mode, + 'options' => [ + 'include_withdrawn_mode' => $options['include_withdrawn_mode'] ?? 'refund_deadline', + 'include_payment_pending' => $options['include_payment_pending'] ?? '0', + 'include_event_only' => $options['include_event_only'] ?? '0', + 'include_paid_invoices' => $options['include_paid_invoices'] ?? '0', + 'unit_price' => $options['unit_price'] ?? '370.00', + ], + 'summary' => [ + 'family_count' => 1, + 'student_count' => 2, + 'billable_student_count' => 2, + 'old_projected_tuition' => '550.00', + 'new_projected_tuition' => '650.00', + 'old_projected_income' => '550.00', + 'new_projected_income' => '650.00', + 'unit_price' => '370.00', + 'difference' => '100.00', + ], + 'families' => [[ + 'parent_name' => 'Layla Yusuf', + 'student_count' => 2, + 'billable_student_count' => 2, + 'old_total' => '550.00', + 'new_total' => '650.00', + 'difference' => '100.00', + 'warnings' => ['warning'], + 'student_details' => [[ + 'student_name' => 'Student One', + 'grade_level' => '1', + 'billable' => true, + 'excluded_reason' => '', + 'old_rule' => 'full', + 'old_amount' => '350.00', + 'new_rule' => 'full', + 'new_amount' => '370.00', + ]], + ]], + ]; + } + + public function getAvailableSchoolYears(): array + { + return ['2025-2026']; + } + + public function getAvailableSemesters(): array + { + return ['Fall', 'Spring']; + } +} + +class TestableTuitionForecastController extends TuitionForecastController +{ + public function __construct() + { + } + + public function setForecastService(TuitionForecastService $service): self + { + $this->forecastService = $service; + return $this; + } + + public function setRequestObject($request): self + { + $this->request = $request; + return $this; + } + + public function setResponseObject(ResponseInterface $response): self + { + $this->response = $response; + return $this; + } +} + +class TuitionForecastControllerTest extends CIUnitTestCase +{ + private TestableTuitionForecastController $controller; + private FakeForecastService $service; + + protected function setUp(): void + { + parent::setUp(); + $this->service = new FakeForecastService(); + $this->controller = (new TestableTuitionForecastController()) + ->setForecastService($this->service) + ->setResponseObject(service('response')); + } + + public function testIndexBuildsForecastViewDataFromQueryParameters(): void + { + $request = new ForecastRequestStub([ + 'school_year' => '2026-2027', + 'semester' => 'Spring', + 'calculator_mode' => 'new', + 'include_withdrawn_mode' => 'always', + 'include_payment_pending' => '1', + 'include_event_only' => '1', + 'include_paid_invoices' => '1', + 'unit_price' => '395.00', + ]); + + $result = $this->controller->setRequestObject($request)->index(); + + $this->assertSame('administrator/tuition_forecast', $result['view']); + $this->assertSame('2026-2027', $result['data']['filters']['school_year']); + $this->assertSame('Spring', $result['data']['filters']['semester']); + $this->assertSame('new', $result['data']['filters']['calculator_mode']); + $this->assertSame('1', $result['data']['filters']['include_payment_pending']); + $this->assertSame('395.00', $result['data']['filters']['unit_price']); + $this->assertSame('always', $this->service->calls[0]['options']['include_withdrawn_mode']); + } + + public function testCalculateReturnsJsonPayload(): void + { + $request = new ForecastRequestStub([], [ + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'calculator_mode' => 'compare', + 'include_withdrawn_mode' => 'refund_deadline', + 'include_payment_pending' => '1', + 'include_event_only' => '0', + 'include_paid_invoices' => '1', + 'unit_price' => '390.00', + ]); + + $response = $this->controller->setRequestObject($request)->calculate(); + $payload = json_decode((string) $response->getBody(), true); + + $this->assertSame('2025-2026', $payload['school_year']); + $this->assertSame('Fall', $payload['semester']); + $this->assertSame('compare', $payload['calculator_mode']); + $this->assertSame('390.00', $payload['options']['unit_price']); + } + + public function testExportCsvBuildsDownloadWithSummaryAndFamilyRows(): void + { + $request = new ForecastRequestStub([ + 'school_year' => '2025-2026', + 'calculator_mode' => 'compare', + ]); + + $response = $this->controller->setRequestObject($request)->exportCsv(); + $body = (string) $response->getBody(); + + $this->assertStringContainsString('text/csv', $response->getHeaderLine('Content-Type')); + $this->assertStringContainsString('tuition_forecast_2025-2026_all-year_compare.csv', $response->getHeaderLine('Content-Disposition')); + $this->assertStringContainsString('Summary', $body); + $this->assertStringContainsString('All Year', $body); + $this->assertStringContainsString('Old Projected Income', $body); + $this->assertStringContainsString('Layla Yusuf', $body); + $this->assertStringContainsString('Student One', $body); + } +} +} diff --git a/tests/app/Database/Migrations/FinancialSystemLedgerCleanupTest.php b/tests/app/Database/Migrations/FinancialSystemLedgerCleanupTest.php new file mode 100644 index 0000000..68c65a2 --- /dev/null +++ b/tests/app/Database/Migrations/FinancialSystemLedgerCleanupTest.php @@ -0,0 +1,103 @@ +calls[] = 'addInstallmentSequenceColumn'; + } + + protected function ensureIndexes(): void + { + $this->calls[] = 'ensureIndexes'; + } + + protected function ensureConfigurationDefaults(): void + { + $this->calls[] = 'ensureConfigurationDefaults'; + } + + protected function archivePaypalTables(): void + { + $this->calls[] = 'archivePaypalTables'; + } + + protected function refreshFinancialNavItems(): void + { + $this->calls[] = 'refreshFinancialNavItems'; + } + + protected function restorePaypalTables(): void + { + $this->calls[] = 'restorePaypalTables'; + } + + protected function dropIndexIfExists(string $table, string $indexName): void + { + $this->calls[] = "dropIndexIfExists:$table:$indexName"; + } +} + +class FinancialSystemLedgerCleanupTest extends CIUnitTestCase +{ + public function testUpRunsCleanupStepsInExpectedOrder(): void + { + $migration = new TestableFinancialSystemLedgerCleanup(); + + $migration->up(); + + $this->assertSame([ + 'addInstallmentSequenceColumn', + 'ensureIndexes', + 'ensureConfigurationDefaults', + 'archivePaypalTables', + 'refreshFinancialNavItems', + ], $migration->calls); + } + + public function testDownRestoresPaypalAndDropsFinancialIndexes(): void + { + $migration = new TestableFinancialSystemLedgerCleanup(); + $fakeDb = new class () { + public function tableExists(string $name): bool + { + return false; + } + + public function fieldExists(string $field, string $table): bool + { + return false; + } + }; + $fakeForge = new class () { + public array $dropped = []; + + public function dropColumn(string $table, string $column): void + { + $this->dropped[] = [$table, $column]; + } + }; + + self::setPrivateProperty($migration, 'db', $fakeDb); + self::setPrivateProperty($migration, 'forge', $fakeForge); + + $migration->down(); + + $this->assertSame('restorePaypalTables', $migration->calls[0]); + $this->assertContains('dropIndexIfExists:payments:uniq_payments_transaction_id', $migration->calls); + $this->assertContains('dropIndexIfExists:invoice_event:idx_invoice_event_invoice_id', $migration->calls); + } +} diff --git a/tests/app/Helpers/AttendanceCommentHelperTest.php b/tests/app/Helpers/AttendanceCommentHelperTest.php new file mode 100644 index 0000000..f233ba6 --- /dev/null +++ b/tests/app/Helpers/AttendanceCommentHelperTest.php @@ -0,0 +1,53 @@ + 60, 'max_score' => 69, 'template_text' => 'below average'], + ['min_score' => 70, 'max_score' => 79, 'template_text' => 'fair'], + ]; + + $match = attendance_comment_template_match($templates, 70.0); + + $this->assertNotNull($match); + $this->assertSame('fair', $match['template_text']); + } + + public function testTemplateMatchFallsBackToNearestLowerBandForDecimalGap(): void + { + $templates = [ + ['min_score' => 60, 'max_score' => 69, 'template_text' => 'below average'], + ['min_score' => 70, 'max_score' => 79, 'template_text' => 'fair'], + ]; + + $match = attendance_comment_template_match($templates, 69.23); + + $this->assertNotNull($match); + $this->assertSame('below average', $match['template_text']); + } + + public function testCommentUsesResolvedTemplateForDecimalGap(): void + { + $templates = [ + ['min_score' => 60, 'max_score' => 69, 'template_text' => 'attendance has been below average.'], + ['min_score' => 70, 'max_score' => 79, 'template_text' => 'attendance has been fair.'], + ]; + + $match = attendance_comment_template_match($templates, 69.23); + + $this->assertNotNull($match); + $this->assertSame('attendance has been below average.', $match['template_text']); + } +} diff --git a/tests/app/Libraries/FinancialAttachmentServiceTest.php b/tests/app/Libraries/FinancialAttachmentServiceTest.php new file mode 100644 index 0000000..fff9949 --- /dev/null +++ b/tests/app/Libraries/FinancialAttachmentServiceTest.php @@ -0,0 +1,99 @@ +service = new FinancialAttachmentService(); + } + + public function testSaveUploadedFileReturnsNullForInvalidInput(): void + { + $this->assertNull($this->service->saveUploadedFile(null, 'payments')); + + $file = $this->createMock(UploadedFile::class); + $file->method('isValid')->willReturn(false); + + $this->assertNull($this->service->saveUploadedFile($file, 'payments')); + } + + public function testSaveUploadedFileRejectsUnsupportedMimeType(): void + { + $file = $this->createMock(UploadedFile::class); + $file->method('isValid')->willReturn(true); + $file->method('hasMoved')->willReturn(false); + $file->method('getSize')->willReturn(1000); + $file->method('getMimeType')->willReturn('text/plain'); + $file->method('getClientExtension')->willReturn('txt'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Unsupported file type.'); + + $this->service->saveUploadedFile($file, 'payments'); + } + + public function testSaveUploadedFileRejectsOversizedFiles(): void + { + $file = $this->createMock(UploadedFile::class); + $file->method('isValid')->willReturn(true); + $file->method('hasMoved')->willReturn(false); + $file->method('getSize')->willReturn(5242881); + $file->method('getMimeType')->willReturn('application/pdf'); + $file->method('getClientExtension')->willReturn('pdf'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('File too large.'); + + $this->service->saveUploadedFile($file, 'payments'); + } + + public function testSaveUploadedFileMovesValidFilesAndReturnsRandomName(): void + { + $file = $this->createMock(UploadedFile::class); + $file->method('isValid')->willReturn(true); + $file->method('hasMoved')->willReturn(false); + $file->method('getSize')->willReturn(1024); + $file->method('getMimeType')->willReturn('application/pdf'); + $file->method('getClientExtension')->willReturn('pdf'); + $file->method('getRandomName')->willReturn('proof.pdf'); + + $file->expects($this->once()) + ->method('move') + ->with($this->stringContains('writable/uploads/payments'), 'proof.pdf'); + + $this->assertSame('proof.pdf', $this->service->saveUploadedFile($file, 'payments')); + } + + public function testResolvePathUsesBasenameAndReturnsNullForMissingFiles(): void + { + $dir = $this->service->ensureSubdir('receipts'); + $path = $dir . DIRECTORY_SEPARATOR . 'receipt.pdf'; + file_put_contents($path, 'pdf'); + + $this->assertSame($path, $this->service->resolvePath('receipts', '../receipt.pdf')); + $this->assertNull($this->service->resolvePath('receipts', 'missing.pdf')); + } + + public function testDetectMimeReturnsKnownMimeForExistingFile(): void + { + $dir = $this->service->ensureSubdir('mime-tests'); + $path = $dir . DIRECTORY_SEPARATOR . 'image.png'; + file_put_contents( + $path, + base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aap8AAAAASUVORK5CYII=') + ); + + $mime = $this->service->detectMime($path); + + $this->assertStringContainsString('image/', $mime); + } +} diff --git a/tests/app/Libraries/NewTuitionCalculatorServiceTest.php b/tests/app/Libraries/NewTuitionCalculatorServiceTest.php new file mode 100644 index 0000000..415c11d --- /dev/null +++ b/tests/app/Libraries/NewTuitionCalculatorServiceTest.php @@ -0,0 +1,54 @@ +calculateFamilyTuition([ + ['student_id' => 4, 'student_name' => 'Fourth', 'grade_level' => '4'], + ['student_id' => 1, 'student_name' => 'First', 'grade_level' => '1'], + ['student_id' => 3, 'student_name' => 'Third', 'grade_level' => '3'], + ['student_id' => 2, 'student_name' => 'Second', 'grade_level' => '2'], + ], [ + 'grade_fee' => 9, + 'new_tuition_full_amount' => '350.00', + 'new_tuition_second_student_discount' => '50.00', + 'new_tuition_third_student_discount' => '50.00', + 'new_tuition_fourth_plus_discount' => '100.00', + ]); + + $this->assertSame('1200.00', $result['total']); + $this->assertSame('350.00', $result['details'][0]['amount']); + $this->assertSame('300.00', $result['details'][1]['amount']); + $this->assertSame('300.00', $result['details'][2]['amount']); + $this->assertSame('250.00', $result['details'][3]['amount']); + } + + public function testDiscountCannotDriveAmountBelowZero(): void + { + $service = new NewTuitionCalculatorService(); + $result = $service->calculateFamilyTuition([ + ['student_id' => 1, 'student_name' => 'One', 'grade_level' => '1'], + ['student_id' => 2, 'student_name' => 'Two', 'grade_level' => '2'], + ['student_id' => 3, 'student_name' => 'Three', 'grade_level' => '3'], + ['student_id' => 4, 'student_name' => 'Four', 'grade_level' => '4'], + ['student_id' => 5, 'student_name' => 'Five', 'grade_level' => '5'], + ], [ + 'grade_fee' => 9, + 'new_tuition_full_amount' => '75.00', + 'new_tuition_second_student_discount' => '50.00', + 'new_tuition_third_student_discount' => '50.00', + 'new_tuition_fourth_plus_discount' => '100.00', + ]); + + $this->assertSame('125.00', $result['total']); + $this->assertSame('0.00', $result['details'][3]['amount']); + $this->assertSame('0.00', $result['details'][4]['amount']); + } +} diff --git a/tests/app/Libraries/OldTuitionCalculatorServiceTest.php b/tests/app/Libraries/OldTuitionCalculatorServiceTest.php new file mode 100644 index 0000000..cb42c94 --- /dev/null +++ b/tests/app/Libraries/OldTuitionCalculatorServiceTest.php @@ -0,0 +1,29 @@ +calculateFamilyTuition([ + ['student_id' => 3, 'student_name' => 'Older Youth', 'grade_level' => 'Youth 1'], + ['student_id' => 1, 'student_name' => 'First Regular', 'grade_level' => '3'], + ['student_id' => 2, 'student_name' => 'Second Regular', 'grade_level' => '5'], + ], [ + 'grade_fee' => 9, + 'first_student_fee' => '350.00', + 'second_student_fee' => '200.00', + 'youth_fee' => '180.00', + ]); + + $this->assertSame('730.00', $result['total']); + $this->assertSame('old_first_student_fee', $result['details'][0]['rule']); + $this->assertSame('old_second_student_fee', $result['details'][1]['rule']); + $this->assertSame('old_youth_fee', $result['details'][2]['rule']); + } +} diff --git a/tests/app/Libraries/TuitionForecastServiceTest.php b/tests/app/Libraries/TuitionForecastServiceTest.php new file mode 100644 index 0000000..c4505dc --- /dev/null +++ b/tests/app/Libraries/TuitionForecastServiceTest.php @@ -0,0 +1,82 @@ + 10, 'parent_name' => 'Yusuf, Layla'], + ]; + } + + protected function loadFamilyStudents(int $parentId, string $schoolYear, string $semester, array $options): array + { + return [ + 'students' => [ + ['student_id' => 1, 'student_name' => 'Mariam', 'grade_level' => '1'], + ['student_id' => 2, 'student_name' => 'Yahya', 'grade_level' => '2'], + ], + 'all_students' => [ + ['student_id' => 1, 'student_name' => 'Mariam', 'grade_level' => '1', 'billable' => true, 'excluded_reason' => null], + ['student_id' => 2, 'student_name' => 'Yahya', 'grade_level' => '2', 'billable' => true, 'excluded_reason' => null], + ['student_id' => 3, 'student_name' => 'Event Only', 'grade_level' => '3', 'billable' => false, 'excluded_reason' => 'event_only'], + ], + 'warnings' => ['1 event-only student(s) excluded from tuition.'], + ]; + } + + protected function getTuitionConfig(): array + { + return [ + 'grade_fee' => 9, + 'first_student_fee' => '350.00', + 'second_student_fee' => '200.00', + 'youth_fee' => '180.00', + 'new_tuition_full_amount' => $this->unitPriceOverride ?? '350.00', + 'new_tuition_second_student_discount' => '50.00', + 'new_tuition_third_student_discount' => '50.00', + 'new_tuition_fourth_plus_discount' => '100.00', + ]; + } + }; + + $result = $service->calculate('2025-2026', 'Fall', 'compare', [ + 'include_payment_pending' => true, + 'include_withdrawn_mode' => 'refund_deadline', + 'unit_price' => '400.00', + ]); + + $this->assertSame(1, $result['summary']['family_count']); + $this->assertSame(3, $result['summary']['student_count']); + $this->assertSame(2, $result['summary']['billable_student_count']); + $this->assertSame('550.00', $result['summary']['old_projected_tuition']); + $this->assertSame('750.00', $result['summary']['new_projected_tuition']); + $this->assertSame('550.00', $result['summary']['old_projected_income']); + $this->assertSame('750.00', $result['summary']['new_projected_income']); + $this->assertSame('400.00', $result['summary']['unit_price']); + $this->assertSame('event_only', $result['families'][0]['student_details'][2]['excluded_reason']); + } +} diff --git a/tests/app/Models/PaymentModelMetadataTest.php b/tests/app/Models/PaymentModelMetadataTest.php new file mode 100644 index 0000000..19c9d36 --- /dev/null +++ b/tests/app/Models/PaymentModelMetadataTest.php @@ -0,0 +1,19 @@ +assertContains('installment_seq', $fields); + $this->assertContains('transaction_id', $fields); + $this->assertContains('check_file', $fields); + } +} diff --git a/tests/app/Models/PaypalTransactionModelTest.php b/tests/app/Models/PaypalTransactionModelTest.php deleted file mode 100644 index 6900754..0000000 --- a/tests/app/Models/PaypalTransactionModelTest.php +++ /dev/null @@ -1,40 +0,0 @@ -assertNotNull($record->id); - $fetched = (new PaypalTransactionModel())->find($record->id); - $this->assertEquals($record->id, $fetched->id); - } - - public function testCanUpdate() - { - $record = fake(PaypalTransactionModel::class); - $model = new PaypalTransactionModel(); - $model->update($record->id, ['updated_at' => date('Y-m-d H:i:s')]); - $this->assertTrue(true); // simple test to verify no exception thrown - } - - public function testCanDelete() - { - $record = fake(PaypalTransactionModel::class); - $model = new PaypalTransactionModel(); - $model->delete($record->id); - $this->assertNull($model->find($record->id)); - } -}