diff --git a/app/Commands/ConfigUpdate.php b/app/Commands/ConfigUpdate.php index deb5598..f1d3531 100644 --- a/app/Commands/ConfigUpdate.php +++ b/app/Commands/ConfigUpdate.php @@ -98,10 +98,9 @@ class ConfigUpdate extends BaseCommand return true; // no-op is success } - CLI::write("Set semester = Spring" . ($dry ? ' [DRY]' : ''), 'light_gray'); - if ($dry) return true; + CLI::write('Semester is derived from fall_semester_start and spring_semester_start; no config row is updated.', 'yellow'); - return (bool) $this->configModel->setConfigValueByKey('semester', 'Spring'); + return true; } protected function taskSetSemesterFall(bool $dry, DateTimeZone $tz): bool @@ -118,10 +117,9 @@ class ConfigUpdate extends BaseCommand return true; } - CLI::write("Set semester = Fall" . ($dry ? ' [DRY]' : ''), 'light_gray'); - if ($dry) return true; + CLI::write('Semester is derived from fall_semester_start and spring_semester_start; no config row is updated.', 'yellow'); - return (bool) $this->configModel->setConfigValueByKey('semester', 'Fall'); + return true; } public function run(array $params) diff --git a/app/Commands/SendExamDraftDeadlineReminders.php b/app/Commands/SendExamDraftDeadlineReminders.php index 0f86744..b956072 100644 --- a/app/Commands/SendExamDraftDeadlineReminders.php +++ b/app/Commands/SendExamDraftDeadlineReminders.php @@ -23,7 +23,7 @@ class SendExamDraftDeadlineReminders extends BaseCommand $configModel = new ConfigurationModel(); $schoolYear = (string) ($configModel->getConfig('school_year') ?? ''); - $semester = (string) ($configModel->getConfig('semester') ?? ''); + $semester = (string) (getSemester() ?? ''); $deadlineValue = trim((string) ($configModel->getConfig('exam_draft_deadline') ?? '')); if ($deadlineValue === '') { CLI::write('exam_draft_deadline is not configured.', 'yellow'); diff --git a/app/Config/Autoload.php b/app/Config/Autoload.php index 23013fc..e4376d5 100644 --- a/app/Config/Autoload.php +++ b/app/Config/Autoload.php @@ -98,6 +98,6 @@ class Autoload extends AutoloadConfig * * @var list */ - public $helpers = ['url', 'form', 'pbkdf2', 'document', 'time', 'api']; + public $helpers = ['url', 'form', 'pbkdf2', 'document', 'time', 'api', 'global_config']; } diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 4ebbef4..c456e56 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -1235,6 +1235,7 @@ $routes->post('discount/create', 'View\DiscountController::createVoucher', ['fil $routes->get('discount/editVoucher/(:num)', 'View\DiscountController::editVoucher/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal']); $routes->post('discount/editVoucher/(:num)', 'View\DiscountController::editVoucher/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']); $routes->get('discount/voucher-form', 'View\DiscountController::applyVoucher', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal']); +$routes->post('discount/voucher-form', 'View\DiscountController::applyVoucher', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']); $routes->post('discount/apply', 'View\DiscountController::applyVoucher', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']); diff --git a/app/Controllers/Admin/CompetitionWinnersController.php b/app/Controllers/Admin/CompetitionWinnersController.php index cf55022..b791e19 100644 --- a/app/Controllers/Admin/CompetitionWinnersController.php +++ b/app/Controllers/Admin/CompetitionWinnersController.php @@ -87,7 +87,7 @@ class CompetitionWinnersController extends BaseController 'errors' => session('errors'), 'classSections' => $classSections, 'classRows' => $classRows, - 'defaultSemester' => $this->configModel->getConfig('semester'), + 'defaultSemester' => getSemester(), 'defaultSchoolYear'=> $schoolYear, ]); } @@ -119,7 +119,7 @@ class CompetitionWinnersController extends BaseController 'errors' => session('errors'), 'classSections' => $classSections, 'classRows' => $classRows, - 'defaultSemester' => $this->configModel->getConfig('semester'), + 'defaultSemester' => getSemester(), 'defaultSchoolYear'=> $schoolYear, ]); } @@ -391,7 +391,7 @@ class CompetitionWinnersController extends BaseController $semester = session('semester'); $schoolYear = session('school_year'); if ($semester === null || $semester === '') { - $semester = $this->configModel->getConfig('semester'); + $semester = getSemester(); } if ($schoolYear === null || $schoolYear === '') { $schoolYear = $this->configModel->getConfig('school_year'); diff --git a/app/Controllers/AdminProgressController.php b/app/Controllers/AdminProgressController.php index cb7f4af..f77223f 100644 --- a/app/Controllers/AdminProgressController.php +++ b/app/Controllers/AdminProgressController.php @@ -451,7 +451,7 @@ class AdminProgressController extends BaseController $schoolYear = $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? '')); } - $semester = (string) ($this->configModel->getConfig('semester') ?? ''); + $semester = (string) (getSemester() ?? ''); $schoolYearForRange = $schoolYear !== '' ? $schoolYear : $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? '')); [$rangeStart, $rangeEnd] = $this->semesterRangeService->getSchoolYearRange($schoolYearForRange); $semesterNorm = $this->semesterRangeService->normalizeSemester($semester); diff --git a/app/Controllers/Administrator/FinancialAidController.php b/app/Controllers/Administrator/FinancialAidController.php index c912634..fc4a926 100644 --- a/app/Controllers/Administrator/FinancialAidController.php +++ b/app/Controllers/Administrator/FinancialAidController.php @@ -68,7 +68,7 @@ class FinancialAidController extends BaseController return redirect()->back()->with('error', 'Only open requests can be approved.'); } - $amount = (float) $this->request->getPost('admin_amount'); + $amount = $this->approvalAmount($request); $note = trim((string) $this->request->getPost('admin_note')); service('financialAid')->applyApprovedAmount($request, $amount, (int) session()->get('user_id'), $note); @@ -103,4 +103,14 @@ class FinancialAidController extends BaseController return redirect()->to('/administrator/financial-aid')->with('success', 'Financial aid request was denied.'); } + + private function approvalAmount(array $request): float + { + $postedAmount = trim((string) $this->request->getPost('admin_amount')); + if ($postedAmount !== '') { + return (float) $postedAmount; + } + + return (float) ($request['requested_amount'] ?? 0); + } } diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index 5e16642..4b88eaf 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -38,7 +38,7 @@ class AuthController extends BaseController $this->loginActivityModel = new LoginActivityModel(); $this->preferencesModel = new PreferencesModel(); $this->schoolYear = $this->configModel->getConfig('school_year'); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); } @@ -332,7 +332,7 @@ class AuthController extends BaseController 'state' => $requestData['state'] ?? null, 'zip' => $requestData['zip'] ?? null, 'school_year' => $this->configModel->getConfig('school_year'), - 'semester' => $this->configModel->getConfig('semester'), + 'semester' => getSemester(), 'status' => 'active', 'is_verified' => 0, // Require email verification ]; diff --git a/app/Controllers/ClassProgressController.php b/app/Controllers/ClassProgressController.php index 0fdec5c..7b7697c 100644 --- a/app/Controllers/ClassProgressController.php +++ b/app/Controllers/ClassProgressController.php @@ -905,7 +905,7 @@ class ClassProgressController extends BaseController } $semesterResolver = new SemesterRangeService($this->configModel); - $semester = $semesterResolver->normalizeSemester((string) ($this->configModel->getConfig('semester') ?? '')); + $semester = $semesterResolver->normalizeSemester((string) (getSemester() ?? '')); if ($semester === '') { $semester = $semesterResolver->getSemesterForDate(); } @@ -968,7 +968,7 @@ class ClassProgressController extends BaseController protected function resolveCurrentTerm(): array { $schoolYear = $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? '')); - $semester = (string) ($this->configModel->getConfig('semester') ?? ''); + $semester = (string) (getSemester() ?? ''); return [$semester, $schoolYear]; } diff --git a/app/Controllers/ParentReportCardController.php b/app/Controllers/ParentReportCardController.php index 8cae5c7..1e779f6 100644 --- a/app/Controllers/ParentReportCardController.php +++ b/app/Controllers/ParentReportCardController.php @@ -34,7 +34,7 @@ class ParentReportCardController extends BaseController $schoolYearContext = $this->resolveSchoolYearContext(); $schoolYear = trim($schoolYearContext->yearName()); - $semester = trim((string) ($this->request->getGet('semester') ?? $this->configModel->getConfig('semester') ?? '')); + $semester = trim((string) ($this->request->getGet('semester') ?? getSemester() ?? '')); $builder = $this->db->table('students s') ->select('s.id, s.firstname, s.lastname, cs.class_section_name') @@ -89,7 +89,7 @@ class ParentReportCardController extends BaseController $schoolYearContext = $this->resolveSchoolYearContext(); $schoolYear = trim($schoolYearContext->yearName()); - $semester = trim((string) ($this->request->getGet('semester') ?? $this->configModel->getConfig('semester') ?? '')); + $semester = trim((string) ($this->request->getGet('semester') ?? getSemester() ?? '')); if (! $this->reportExists((int) $studentId, $schoolYear, $semester)) { return redirect()->to(site_url('parent/report-cards')) @@ -142,7 +142,7 @@ class ParentReportCardController extends BaseController } $schoolYear = trim($schoolYearContext->yearName()); - $semester = trim((string) ($this->configModel->getConfig('semester') ?? '')); + $semester = trim((string) (getSemester() ?? '')); if (! $this->reportExists((int) $studentId, $schoolYear, $semester)) { return redirect()->to(site_url('parent/report-cards')) diff --git a/app/Controllers/PrintRequests.php b/app/Controllers/PrintRequests.php index cf2bc15..64575d3 100644 --- a/app/Controllers/PrintRequests.php +++ b/app/Controllers/PrintRequests.php @@ -51,6 +51,7 @@ class PrintRequests extends BaseController $schoolYear = $context->yearName(); $printRequestsQuery = $this->printRequestModel + ->distinct() ->select('print_requests.*, admins.firstname as admin_firstname, admins.lastname as admin_lastname') ->join('users as admins', 'admins.id = print_requests.admin_id', 'left') ->join('classSection cs', 'cs.class_section_id = print_requests.class_id', 'left') @@ -67,6 +68,8 @@ class PrintRequests extends BaseController $data['sundays'] = $dateOptions['sundays']; $data['times'] = $dateOptions['times']; $data['isSchoolYearReadonly'] = $context->isReadonly(); + $data['printRequestToken'] = $this->issuePrintRequestToken('print'); + $data['copyRequestToken'] = $this->issuePrintRequestToken('copy'); return view('print_requests/teacher_index', $data); } @@ -77,6 +80,7 @@ class PrintRequests extends BaseController $schoolYear = $context->yearName(); $printRequestsQuery = $this->printRequestModel + ->distinct() ->select(' print_requests.*, u.firstname, @@ -125,6 +129,10 @@ class PrintRequests extends BaseController return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); } + if (! $this->consumePrintRequestToken('print')) { + return redirect()->to('teacher/print-requests')->with('error', 'This print request was already submitted. Please refresh the page before submitting another request.'); + } + $teacher_id = session()->get('user_id'); $file = $this->request->getFile('file'); @@ -346,6 +354,10 @@ class PrintRequests extends BaseController return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); } + if (! $this->consumePrintRequestToken('copy')) { + return redirect()->to('teacher/print-requests')->with('error', 'This copy request was already submitted. Please refresh the page before submitting another request.'); + } + $teacher_id = session()->get('user_id'); $data = [ @@ -402,6 +414,54 @@ class PrintRequests extends BaseController ]; } + private function issuePrintRequestToken(string $type): string + { + $key = $this->printRequestTokenSessionKey($type); + $tokens = session()->get($key); + + if (! is_array($tokens)) { + $tokens = []; + } + + $token = bin2hex(random_bytes(16)); + $tokens[$token] = time(); + + if (count($tokens) > 20) { + asort($tokens); + $tokens = array_slice($tokens, -20, null, true); + } + + session()->set($key, $tokens); + + return $token; + } + + private function consumePrintRequestToken(string $type): bool + { + $token = (string) $this->request->getPost('request_token'); + + if ($token === '') { + return false; + } + + $key = $this->printRequestTokenSessionKey($type); + $tokens = session()->get($key); + + if (! is_array($tokens) || ! array_key_exists($token, $tokens)) { + return false; + } + + unset($tokens[$token]); + session()->set($key, $tokens); + + return true; + } + + private function printRequestTokenSessionKey(string $type): string + { + return 'print_request_' . $type . '_tokens'; + } + private function applyPrintRequestSchoolYearScope($query, string $schoolYear): void { if ($schoolYear === '') { @@ -620,7 +680,7 @@ class PrintRequests extends BaseController 'action_url' => $isCopyRequest ? '' : $actionUrl, 'scheduled_at' => utc_now(), 'school_year' => $this->configModel->getConfig('school_year'), - 'semester' => $this->configModel->getConfig('semester'), + 'semester' => getSemester(), ]; $notificationFields = $db->getFieldNames('notifications'); diff --git a/app/Controllers/View/AdministratorController.php b/app/Controllers/View/AdministratorController.php index 2f95675..75420e9 100644 --- a/app/Controllers/View/AdministratorController.php +++ b/app/Controllers/View/AdministratorController.php @@ -76,7 +76,7 @@ class AdministratorController extends BaseController $this->classSectionModel = new ClassSectionModel(); $this->adminNotificationSubjectModel = new AdminNotificationSubjectModel(); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->schoolYear = $this->configModel->getConfig('school_year'); $this->studentClassModel = new StudentClassModel(); $this->staffAttendanceModel = new StaffAttendanceModel(); @@ -701,7 +701,7 @@ class AdministratorController extends BaseController public function teacherSubmissionsReport() { - $semester = (string)($this->configModel->getConfig('semester') ?? $this->semester ?? ''); + $semester = (string)(getSemester() ?? $this->semester ?? ''); $schoolYear = trim((string) ($this->request->getGet('school_year') ?? '')); if ($schoolYear === '') { $schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); @@ -1106,7 +1106,7 @@ class AdministratorController extends BaseController $semesterResolver = new SemesterRangeService($this->configModel); $schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); - $semester = (string)($this->configModel->getConfig('semester') ?? ''); + $semester = (string)(getSemester() ?? ''); $schoolYearForRange = $schoolYear !== '' ? $schoolYear : $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); [$rangeStart, $rangeEnd] = $semesterResolver->getSchoolYearRange($schoolYearForRange); $semesterNorm = $semesterResolver->normalizeSemester($semester); @@ -1224,7 +1224,7 @@ class AdministratorController extends BaseController if (!is_array($notify)) { return redirect()->back()->with('info', 'Select at least one teacher to notify.'); } - $semester = (string)($this->configModel->getConfig('semester') ?? $this->semester ?? ''); + $semester = (string)(getSemester() ?? $this->semester ?? ''); $missingItemsPayload = $this->request->getPost('missing_items') ?? []; $homeworkNotifyAll = (bool) $this->request->getPost('homework_notify_all'); $examTerm = $this->resolveExamTermLabel($semester); @@ -3208,7 +3208,7 @@ class AdministratorController extends BaseController 'currency' => 'USD', 'refund_paid_amount' => 0.0, 'status' => 'Pending', - 'source_type' => 'invoice_overpayment', + 'source_type' => 'tuition_withdrawal', 'source_id' => (int)$invoice['id'], 'requested_at' => utc_now(), 'updated_by' => session()->get('user_id') ?? null, diff --git a/app/Controllers/View/AssignmentController.php b/app/Controllers/View/AssignmentController.php index 757f450..27af370 100644 --- a/app/Controllers/View/AssignmentController.php +++ b/app/Controllers/View/AssignmentController.php @@ -33,7 +33,7 @@ class AssignmentController extends BaseController $this->teacherClassModel = new TeacherClassModel(); $this->studentClassModel = new StudentClassModel(); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->schoolYear = $this->configModel->getConfig('school_year'); } diff --git a/app/Controllers/View/AttendanceController.php b/app/Controllers/View/AttendanceController.php index 61e1886..fb77a39 100644 --- a/app/Controllers/View/AttendanceController.php +++ b/app/Controllers/View/AttendanceController.php @@ -63,7 +63,7 @@ class AttendanceController extends Controller $this->calendarModel = model(CalendarModel::class); $this->userRoleModel = new UserRoleModel(); $this->semesterScoreService = service('semesterScoreService'); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->schoolYear = $this->currentSchoolYearName(); $this->enableAttendance = $this->configModel->getConfig('enable_attendance'); $this->semesterRangeService = new SemesterRangeService($this->configModel); @@ -534,19 +534,19 @@ public function showUpdateAttendanceForm() // If there are no assignments yet for the requested semester (e.g., Spring roster not populated), // fall back to the school year only so the roster still renders and can be used for the new term. $classSections = $this->classSectionModel - ->select('classSection.id, classSection.class_id, classSection.class_section_id, classSection.class_section_name') + ->select('MIN(classSection.id) AS id, classSection.class_id, classSection.class_section_id, classSection.class_section_name', false) ->join('student_class sc', 'sc.class_section_id = classSection.class_section_id', 'inner') ->where('sc.school_year', $termYear) - ->groupBy(['classSection.id', 'classSection.class_id', 'classSection.class_section_id', 'classSection.class_section_name']) + ->groupBy(['classSection.class_id', 'classSection.class_section_id', 'classSection.class_section_name']) ->findAll(); $useRosterFallback = false; if (empty($classSections) && $termYear !== '') { $classSections = $this->classSectionModel - ->select('classSection.id, classSection.class_id, classSection.class_section_id, classSection.class_section_name') + ->select('MIN(classSection.id) AS id, classSection.class_id, classSection.class_section_id, classSection.class_section_name', false) ->join('student_class sc', 'sc.class_section_id = classSection.class_section_id', 'inner') ->where('sc.school_year', $termYear) - ->groupBy(['classSection.id', 'classSection.class_id', 'classSection.class_section_id', 'classSection.class_section_name']) + ->groupBy(['classSection.class_id', 'classSection.class_section_id', 'classSection.class_section_name']) ->findAll(); $useRosterFallback = !empty($classSections); } @@ -588,9 +588,15 @@ public function showUpdateAttendanceForm() } $hasRoster = false; + $seenStudentIds = []; foreach ($students as $sc) { $studentId = (int)$sc['student_id']; + if ($studentId <= 0 || isset($seenStudentIds[$studentId])) { + continue; + } + $seenStudentIds[$studentId] = true; + $student = $this->studentModel ->select('id, firstname, lastname, school_id') ->where('id', $studentId) diff --git a/app/Controllers/View/AttendanceTrackingController.php b/app/Controllers/View/AttendanceTrackingController.php index 13b279f..1101818 100644 --- a/app/Controllers/View/AttendanceTrackingController.php +++ b/app/Controllers/View/AttendanceTrackingController.php @@ -40,7 +40,7 @@ class AttendanceTrackingController extends BaseController $this->db = \Config\Database::connect(); $this->notificationModel = new ParentNotificationModel(); $this->attendanceEmailTemplateModel = new AttendanceEmailTemplateModel(); - $this->semester = (string) $this->configModel->getConfig('semester'); + $this->semester = (string) getSemester(); $this->schoolYear = (string) $this->configModel->getConfig('school_year'); } @@ -49,7 +49,8 @@ class AttendanceTrackingController extends BaseController $syParam = $this->request->getGet('school_year'); $semParam = $this->request->getGet('semester'); $schoolYear = (is_string($syParam) && $syParam !== '') ? (string)$syParam : (string)$this->schoolYear; - $semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : null; // semester filter disabled + $semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester; + $semesterWasRequested = is_string($semParam) && $semParam !== ''; $debugInfo = [ 'school_year_param' => $schoolYear, @@ -325,7 +326,8 @@ class AttendanceTrackingController extends BaseController 'semester' => $semester, 'student_ids' => $studentIds, ]); - // Second chance: detect the latest attendance_data term for these students + // Second chance: detect the latest attendance_data term only when the user has not + // requested a semester and the date-based current semester is unavailable. $latestAttendanceTerm = $this->db->table('attendance_data') ->select('school_year, semester, date') ->whereIn('student_id', $studentIds) @@ -334,7 +336,7 @@ class AttendanceTrackingController extends BaseController ->get() ->getRowArray(); - if ($latestAttendanceTerm) { + if ($latestAttendanceTerm && !$semesterWasRequested && trim($semester) === '') { $schoolYear = !empty($latestAttendanceTerm['school_year']) ? (string)$latestAttendanceTerm['school_year'] : $schoolYear; @@ -2140,7 +2142,7 @@ class AttendanceTrackingController extends BaseController $wrapped = $this->renderWithEmailLayout($subject, $safeHtmlBody); // Resolve term (prefer controller properties if set) - $semester = $this->semester ?? (new \App\Models\ConfigurationModel())->getConfig('semester'); + $semester = $this->semester ?? getSemester(); $schoolYear = $this->schoolYear ?? (new \App\Models\ConfigurationModel())->getConfig('school_year'); try { diff --git a/app/Controllers/View/CertificateController.php b/app/Controllers/View/CertificateController.php index 62cbbcc..89ca100 100644 --- a/app/Controllers/View/CertificateController.php +++ b/app/Controllers/View/CertificateController.php @@ -37,6 +37,7 @@ class CertificateController extends BaseController ->join('classSection cs', 'cs.class_section_id = sc.class_section_id') ->where('s.is_active', 1) ->where('sc.school_year', $schoolYear) + ->groupBy('s.id, s.firstname, s.lastname, sc.class_section_id, cs.class_section_name') ->orderBy('s.firstname', 'ASC') ->orderBy('s.lastname', 'ASC') ->get() @@ -105,10 +106,18 @@ class CertificateController extends BaseController // ── Build per-class buckets and stats ────────────────────────────────── $studentsByClass = []; $statsPerClass = []; + $seenEnrollments = []; foreach ($allEnrolled as $row) { $sid = (int)$row['student_id']; $csid = (int)$row['class_section_id']; + $enrollmentKey = $csid . ':' . $sid; + + if (isset($seenEnrollments[$enrollmentKey])) { + continue; + } + + $seenEnrollments[$enrollmentKey] = true; if (!isset($statsPerClass[$csid])) { $statsPerClass[$csid] = [ @@ -293,7 +302,7 @@ class CertificateController extends BaseController ->with('error', 'Please select at least one student.'); } - $studentIds = array_filter(array_map('intval', $studentIds)); + $studentIds = array_values(array_unique(array_filter(array_map('intval', $studentIds)))); $certDate = preg_replace('/[^0-9\/\-]/', '', $certDate); if (empty($studentIds)) { @@ -661,4 +670,4 @@ class CertificateController extends BaseController $pdf->SetTextColor(0, 0, 0); $pdf->Text($x, $y, $text); } -} \ No newline at end of file +} diff --git a/app/Controllers/View/ClassController.php b/app/Controllers/View/ClassController.php index 192de23..9d62ef0 100644 --- a/app/Controllers/View/ClassController.php +++ b/app/Controllers/View/ClassController.php @@ -29,7 +29,7 @@ class ClassController extends BaseController $this->configModel = new ConfigurationModel(); // Get the semester from the configuration table - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->schoolYear = $this->configModel->getConfig('school_year'); } diff --git a/app/Controllers/View/ClassPreparationController.php b/app/Controllers/View/ClassPreparationController.php index c8eba8a..48124b9 100644 --- a/app/Controllers/View/ClassPreparationController.php +++ b/app/Controllers/View/ClassPreparationController.php @@ -50,7 +50,7 @@ class ClassPreparationController extends BaseController $this->db = \Config\Database::connect(); $this->schoolYear = $this->configModel->getConfig('school_year'); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); } public function index() diff --git a/app/Controllers/View/CompetitionScoresController.php b/app/Controllers/View/CompetitionScoresController.php index 29fd114..4a99a40 100644 --- a/app/Controllers/View/CompetitionScoresController.php +++ b/app/Controllers/View/CompetitionScoresController.php @@ -244,7 +244,7 @@ class CompetitionScoresController extends BaseController { $userId = (int) (session()->get('user_id') ?? 0); $schoolYear = (string) ($this->configModel->getConfig('school_year') ?? ''); - $semester = (string) ($this->configModel->getConfig('semester') ?? ''); + $semester = (string) (getSemester() ?? ''); $assignments = $this->teacherClassModel->getClassAssignmentsByUserId( $userId, $schoolYear, diff --git a/app/Controllers/View/DiscountController.php b/app/Controllers/View/DiscountController.php index 7332f79..8a1c3cc 100644 --- a/app/Controllers/View/DiscountController.php +++ b/app/Controllers/View/DiscountController.php @@ -45,12 +45,14 @@ class DiscountController extends BaseController $this->classSectionModel = new ClassSectionModel(); $this->invoiceLedgerService = new InvoiceLedgerService(); - $this->schoolYear = $this->configModel->getConfig('school_year'); - $this->semester = $this->configModel->getConfig('semester'); + $this->schoolYear = $this->activeSchoolYearName(); + $this->semester = getSemester(); } public function applyVoucher() { + $this->schoolYear = $this->activeSchoolYearName(); + if (strtolower($this->request->getMethod()) === 'post') { $voucherId = $this->request->getPost('voucher_id'); $parentIds = $this->request->getPost('parent_ids') ?? []; @@ -402,7 +404,10 @@ class DiscountController extends BaseController unset($parent); return view('discounts/apply_voucher', [ - 'vouchers' => $this->voucherModel->where('is_active', 1)->findAll(), + 'vouchers' => $this->voucherModel + ->where('is_active', 1) + ->where('school_year', $this->schoolYear) + ->findAll(), 'parents' => $parents, ]); } @@ -464,7 +469,7 @@ class DiscountController extends BaseController ->orWhere('enrollment_status', 'Payment pending') ->orWhere('enrollment_status', 'Payment Pending') ->orWhere('enrollment_status', 'PAYMENT PENDING') - ->orWhere("LOWER(TRIM(REPLACE(enrollment_status, CHAR(160), ' '))) = 'payment pending'", null, false) + ->orWhere("LOWER(TRIM(REPLACE(enrollment_status, CONVERT(0xC2A0 USING utf8mb4), ' '))) = 'payment pending'", null, false) ->groupEnd(); if (!empty($paidEnrollmentIds)) { @@ -524,13 +529,20 @@ class DiscountController extends BaseController public function listVouchers() { + $this->schoolYear = $this->activeSchoolYearName(); + + $vouchers = $this->voucherModel + ->where('school_year', $this->schoolYear) + ->orderBy('code', 'ASC') + ->findAll(); - $vouchers = $this->voucherModel->findAll(); return view('discounts/list', ['vouchers' => $vouchers]); } public function createVoucher() { + $this->schoolYear = $this->activeSchoolYearName(); + if (strtolower($this->request->getMethod()) === 'post') { // -------- Gather & normalize inputs -------- @@ -606,6 +618,7 @@ class DiscountController extends BaseController 'valid_until' => $validUntil, 'is_active' => $isActive, 'description' => $description, // <-- NEW + 'school_year' => $this->schoolYear, ]; if ($this->voucherModel->save($data)) { @@ -624,7 +637,11 @@ class DiscountController extends BaseController public function editVoucher($id) { - $voucher = $this->voucherModel->find($id); + $this->schoolYear = $this->activeSchoolYearName(); + + $voucher = $this->voucherModel + ->where('school_year', $this->schoolYear) + ->find($id); if (!$voucher) { return redirect()->to('discounts/list')->with('error', 'Voucher not found'); @@ -640,6 +657,7 @@ class DiscountController extends BaseController 'valid_from' => $this->request->getPost('valid_from') ?: null, 'valid_until' => $this->request->getPost('valid_until') ?: null, 'is_active' => $this->request->getPost('is_active') ? 1 : 0, + 'school_year' => $this->schoolYear, ]; $this->voucherModel->save($data); @@ -649,6 +667,15 @@ class DiscountController extends BaseController return view('discounts/edit', ['voucher' => $voucher]); } + private function activeSchoolYearName(): string + { + try { + return service('schoolYearContext')->active()->yearName(); + } catch (\Throwable) { + return (string) ($this->configModel->getConfig('school_year') ?? ''); + } + } + /** * 🔄 Helper: Current invoice balance (school-year scoped) = total - payments - discounts - refundsPaid */ diff --git a/app/Controllers/View/EventController.php b/app/Controllers/View/EventController.php index 23feb68..855e992 100644 --- a/app/Controllers/View/EventController.php +++ b/app/Controllers/View/EventController.php @@ -67,7 +67,7 @@ class EventController extends ResourceController $this->invoiceLedgerService = new InvoiceLedgerService(); $this->schoolYear = $this->currentSchoolYearName(); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->categories = [ 'workshops', 'orientations', diff --git a/app/Controllers/View/ExamDraftController.php b/app/Controllers/View/ExamDraftController.php index c0ecf44..827b285 100644 --- a/app/Controllers/View/ExamDraftController.php +++ b/app/Controllers/View/ExamDraftController.php @@ -65,7 +65,7 @@ class ExamDraftController extends BaseController $this->db = Database::connect(); $this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? ''); - $this->semester = (string) ($this->configModel->getConfig('semester') ?? ''); + $this->semester = (string) (getSemester() ?? ''); $this->hasFinalPdfColumn = $this->schemaHasColumn('exam_drafts', 'final_pdf_file'); $this->hasIsLegacyColumn = $this->schemaHasColumn('exam_drafts', 'is_legacy'); $this->hasAcceptanceTypeColumn = $this->schemaHasColumn('exam_drafts', 'acceptance_type'); @@ -882,7 +882,7 @@ class ExamDraftController extends BaseController private function syncAcademicContext(): void { $this->schoolYear = $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? '')); - $this->semester = (string) ($this->configModel->getConfig('semester') ?? ''); + $this->semester = (string) (getSemester() ?? ''); } private function applyExamDraftYearScope($query): void diff --git a/app/Controllers/View/ExpenseController.php b/app/Controllers/View/ExpenseController.php index d94b862..f16fa4d 100644 --- a/app/Controllers/View/ExpenseController.php +++ b/app/Controllers/View/ExpenseController.php @@ -26,7 +26,7 @@ class ExpenseController extends BaseController $this->financialAttachmentService = new FinancialAttachmentService(); $this->schoolYear = $this->configModel->getConfig('school_year'); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); // Default list of common retailors; adjust as needed $this->retailors = [ @@ -96,6 +96,8 @@ class ExpenseController extends BaseController public function index() { + $schoolYear = (string) ($this->configModel->getConfig('school_year') ?? ''); + $expenses = $this->expenseModel ->select(" expenses.*, @@ -104,6 +106,7 @@ class ExpenseController extends BaseController ") ->join('users u', 'u.id = expenses.purchased_by', 'left') ->join('users approver', 'approver.id = expenses.approved_by', 'left') + ->where('expenses.school_year', $schoolYear) ->orderBy('expenses.created_at', 'DESC') ->findAll(); @@ -115,7 +118,10 @@ class ExpenseController extends BaseController return $row; }, $expenses); - return view('expenses/index', ['expenses' => $expenses]); + return view('expenses/index', [ + 'expenses' => $expenses, + 'schoolYear' => $schoolYear, + ]); } public function create() diff --git a/app/Controllers/View/ExtraChargesController.php b/app/Controllers/View/ExtraChargesController.php index 6c3cb96..a1a8a5c 100644 --- a/app/Controllers/View/ExtraChargesController.php +++ b/app/Controllers/View/ExtraChargesController.php @@ -42,7 +42,7 @@ class ExtraChargesController extends BaseController $this->additionalChargeModel = new AdditionalChargeModel(); $this->userModel = new UserModel(); $this->invoiceModel = new InvoiceModel(); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->schoolYear = $this->configModel->getConfig('school_year'); $this->invoiceLedgerService = new InvoiceLedgerService(); $this->invoiceAdjustmentService = new InvoiceAdjustmentService($this->db); diff --git a/app/Controllers/View/FinalController.php b/app/Controllers/View/FinalController.php index c028396..c96d094 100644 --- a/app/Controllers/View/FinalController.php +++ b/app/Controllers/View/FinalController.php @@ -27,7 +27,7 @@ class FinalController extends BaseController $this->studentModel = new StudentModel(); $this->teacherClassModel = new TeacherClassModel(); $this->schoolYear = $this->configModel->getConfig('school_year'); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->gradingLockModel = new GradingLockModel(); $this->missingScoreOverrideModel = new MissingScoreOverrideModel(); } diff --git a/app/Controllers/View/FlagController.php b/app/Controllers/View/FlagController.php index d1ef50d..75561d4 100644 --- a/app/Controllers/View/FlagController.php +++ b/app/Controllers/View/FlagController.php @@ -367,7 +367,7 @@ class FlagController extends Controller $userId = session()->get('user_id'); // Get the semester and school year from configuration - $semester = $configModel->getConfig('semester'); + $semester = getSemester(); $schoolYear = $configModel->getConfig('school_year'); // Set the current system date and time for flag_datetime diff --git a/app/Controllers/View/GradingController.php b/app/Controllers/View/GradingController.php index ffa8860..bc66a27 100644 --- a/app/Controllers/View/GradingController.php +++ b/app/Controllers/View/GradingController.php @@ -67,7 +67,7 @@ class GradingController extends BaseController $this->configModel = new ConfigurationModel(); $this->db = \Config\Database::connect(); $this->schoolYear = $this->configModel->getConfig('school_year'); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->classSection = new ClassSectionModel(); $this->attendanceCalculator = new AttendanceCalculator( new AttendanceRecordModel(), @@ -93,7 +93,7 @@ class GradingController extends BaseController $configModel = new ConfigurationModel(); $schoolYear = $configModel->getConfig('school_year'); - $semester = $configModel->getConfig('semester'); + $semester = getSemester(); $student = $studentModel->find($studentId); $scores = $scoreModel->where([ @@ -140,7 +140,7 @@ class GradingController extends BaseController $studentModel = new StudentModel(); $schoolYear = $configModel->getConfig('school_year'); - $semester = $configModel->getConfig('semester'); + $semester = getSemester(); $model = $this->getModelByType($type); $classSectionIdInt = (int) ($classSectionId ?? 0); @@ -433,6 +433,7 @@ class GradingController extends BaseController // Build structures keyed by BUSINESS section id $grades = []; // class_id => [ ['class_section_id','class_section_name'], ... ] $studentsBySection = []; // section_id => [ students... ] + $seenStudentsBySection = []; foreach ($rows as $r) { $sectionId = (int) ($r['section_id'] ?? 0); // BUSINESS id @@ -457,6 +458,8 @@ class GradingController extends BaseController $sid = (int) ($r['student_id'] ?? 0); if ($sid <= 0) continue; + if (isset($seenStudentsBySection[$sectionId][$sid])) continue; + $seenStudentsBySection[$sectionId][$sid] = true; $ptapScore = $r['ss_ptap_score'] ?? null; $semesterScore = $r['ss_semester_score'] ?? null; diff --git a/app/Controllers/View/HomeworkController.php b/app/Controllers/View/HomeworkController.php index 7ea2104..3a4da13 100644 --- a/app/Controllers/View/HomeworkController.php +++ b/app/Controllers/View/HomeworkController.php @@ -42,7 +42,7 @@ class HomeworkController extends BaseController $this->configModel = new ConfigurationModel(); $this->schoolYear = $this->configModel->getConfig('school_year'); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); // Log the service initialization log_message('debug', 'Initializing SemesterScoreService'); diff --git a/app/Controllers/View/HomeworkTrackingController.php b/app/Controllers/View/HomeworkTrackingController.php index 812d8ce..dd0b463 100644 --- a/app/Controllers/View/HomeworkTrackingController.php +++ b/app/Controllers/View/HomeworkTrackingController.php @@ -26,7 +26,7 @@ class HomeworkTrackingController extends BaseController $this->homeworkModel = new HomeworkModel(); $this->db = \Config\Database::connect(); - $this->semester = (string) ($this->configModel->getConfig('semester') ?? ''); + $this->semester = (string) (getSemester() ?? ''); $this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? ''); } diff --git a/app/Controllers/View/InventoryController.php b/app/Controllers/View/InventoryController.php index 8d41607..a69418a 100644 --- a/app/Controllers/View/InventoryController.php +++ b/app/Controllers/View/InventoryController.php @@ -44,7 +44,7 @@ class InventoryController extends BaseController $this->teacherClassModel = new TeacherClassModel(); $this->studentClassModel = new StudentClassModel(); $this->schoolYear = $this->configModel->getConfig('school_year'); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->classSectionModel = new ClassSectionModel(); $this->db = \Config\Database::connect(); $this->teacherModel = new TeacherModel(); diff --git a/app/Controllers/View/InvoiceController.php b/app/Controllers/View/InvoiceController.php index 0656579..2e52e39 100644 --- a/app/Controllers/View/InvoiceController.php +++ b/app/Controllers/View/InvoiceController.php @@ -80,7 +80,7 @@ class InvoiceController extends ResourceController $this->gradeFee = $this->configModel->getConfig('grade_fee'); $this->schoolYear = $this->currentSchoolYearName(); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->dueDate = $this->configModel->getConfig('first_day_of_school') ?: $this->configModel->getConfig('due_date'); $this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 380); @@ -136,16 +136,9 @@ class InvoiceController extends ResourceController : ($invoice['updated_at'] ?? null); $parentData['invoice_id'] = $invoice['id']; - // ✅ Fetch refund amount actually PAID this year (Partial/Paid) - $refund = $this->db->table('refunds') - ->select('COALESCE(SUM(refund_paid_amount),0) AS refund_paid_amount') - ->where('parent_id', $parent['id']) - ->where('school_year', $schoolYear) - ->whereIn('status', ['Partial','Paid']) - ->get() - ->getRowArray(); - - $parentData['refund_amount'] = (float)($refund['refund_paid_amount'] ?? 0.0); + $refundSummary = $this->paidRefundSummaryForParentYear((int) $parent['id'], (string) $schoolYear); + $parentData['refund_amount'] = $refundSummary['amount']; + $parentData['refund_details'] = $refundSummary['details']; log_message('info', "Latest invoice for parent {$parent['firstname']} {$parent['lastname']} in school year $schoolYear: Amount = {$invoice['total_amount']}, Updated at = {$invoice['updated_at']}"); } else { @@ -230,6 +223,120 @@ class InvoiceController extends ResourceController } } + private function paidRefundSummaryForParentYear(int $parentId, string $schoolYear): array + { + $refund = $this->db->table('refunds') + ->select('COALESCE(SUM(refund_paid_amount),0) AS refund_paid_amount') + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->whereIn('status', ['Partial', 'Paid']) + ->get() + ->getRowArray(); + + $details = []; + if ($this->db->tableExists('refund_payouts')) { + $details = $this->db->table('refund_payouts rp') + ->select('rp.amount_cents, rp.payment_method, rp.check_number, rp.processed_at, rp.created_at') + ->join('refunds r', 'r.id = rp.refund_id', 'inner') + ->where('r.parent_id', $parentId) + ->where('r.school_year', $schoolYear) + ->where('rp.payout_type', 'cash_out') + ->whereIn('rp.status', ['completed', 'processing']) + ->orderBy('COALESCE(rp.processed_at, rp.created_at)', 'DESC', false) + ->get() + ->getResultArray(); + + $details = array_map(static function (array $row): array { + return [ + 'amount' => ((int) ($row['amount_cents'] ?? 0)) / 100, + 'date' => $row['processed_at'] ?? $row['created_at'] ?? null, + 'method' => $row['payment_method'] ?? '', + 'check_number' => $row['check_number'] ?? '', + ]; + }, $details); + } + + if (empty($details)) { + $legacyRows = $this->db->table('refunds') + ->select('refund_paid_amount, refund_method, check_nbr, refunded_at, updated_at') + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->whereIn('status', ['Partial', 'Paid']) + ->where('refund_paid_amount >', 0) + ->orderBy('COALESCE(refunded_at, updated_at)', 'DESC', false) + ->get() + ->getResultArray(); + + $details = array_map(static function (array $row): array { + return [ + 'amount' => (float) ($row['refund_paid_amount'] ?? 0), + 'date' => $row['refunded_at'] ?? $row['updated_at'] ?? null, + 'method' => $row['refund_method'] ?? '', + 'check_number' => $row['check_nbr'] ?? '', + ]; + }, $legacyRows); + } + + return [ + 'amount' => (float) ($refund['refund_paid_amount'] ?? 0.0), + 'details' => $details, + ]; + } + + private function paidRefundDetailsForInvoice(int $invoiceId): array + { + $details = []; + if ($this->db->tableExists('refund_payouts')) { + $rows = $this->db->table('refund_payouts rp') + ->select('rp.amount_cents, rp.payment_method, rp.check_number, rp.processed_at, rp.created_at, rp.payout_type') + ->join('refunds r', 'r.id = rp.refund_id', 'inner') + ->where('r.invoice_id', $invoiceId) + ->whereIn('rp.payout_type', ['cash_out', 'reversal']) + ->where('rp.status', 'completed') + ->orderBy('COALESCE(rp.processed_at, rp.created_at)', 'ASC', false) + ->get() + ->getResultArray(); + + foreach ($rows as $row) { + $amount = ((int) ($row['amount_cents'] ?? 0)) / 100; + if (($row['payout_type'] ?? '') === 'reversal') { + $amount *= -1; + } + + $details[] = [ + 'amount' => $amount, + 'date' => $row['processed_at'] ?? $row['created_at'] ?? null, + 'method' => $row['payment_method'] ?? '', + 'check_number' => $row['check_number'] ?? '', + 'type' => $row['payout_type'] ?? 'cash_out', + ]; + } + } + + if (empty($details)) { + $rows = $this->db->table('refunds') + ->select('refund_paid_amount, refund_method, check_nbr, refunded_at, updated_at') + ->where('invoice_id', $invoiceId) + ->whereIn('status', ['Partial', 'Paid']) + ->where('refund_paid_amount >', 0) + ->orderBy('COALESCE(refunded_at, updated_at)', 'ASC', false) + ->get() + ->getResultArray(); + + foreach ($rows as $row) { + $details[] = [ + 'amount' => (float) ($row['refund_paid_amount'] ?? 0), + 'date' => $row['refunded_at'] ?? $row['updated_at'] ?? null, + 'method' => $row['refund_method'] ?? '', + 'check_number' => $row['check_nbr'] ?? '', + 'type' => 'cash_out', + ]; + } + } + + return $details; + } + private function assertSchoolYearNameWritable(string $schoolYear): void { service('schoolYearWriteGuard')->assertWritable( @@ -297,14 +404,9 @@ class InvoiceController extends ResourceController } $parentData['invoice_id'] = $invoice['id'] ?? null; - // Refund total paid for parent/year (Partial/Paid) - $refund = $this->db->table('refunds') - ->select('COALESCE(SUM(refund_paid_amount),0) AS refund_paid_amount') - ->where('parent_id', $parent['id']) - ->where('school_year', $schoolYear) - ->whereIn('status', ['Partial','Paid']) - ->get()->getRowArray(); - $parentData['refund_amount'] = (float)($refund['refund_paid_amount'] ?? 0.0); + $refundSummary = $this->paidRefundSummaryForParentYear((int) $parent['id'], (string) $schoolYear); + $parentData['refund_amount'] = $refundSummary['amount']; + $parentData['refund_details'] = $refundSummary['details']; break; // only most recent as before } } @@ -503,15 +605,6 @@ class InvoiceController extends ResourceController log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}."); $updated = true; } else { - // Generate invoice number - $schoolId = $this->userModel->getSchoolIdByUserId($parentId); - if (!empty($schoolId)) { - $invoiceNumber = 'INV-' . $schoolId . '-' . uniqid(); - } else { - log_message('warning', "No school ID found for parent_id {$parentId}, generating fallback invoice number."); - $invoiceNumber = uniqid('INV-'); - } - $issueUtc = (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s'); // Due date: interpret the date in configured/user local TZ, @@ -527,7 +620,7 @@ class InvoiceController extends ResourceController try { $issueResult = $this->invoiceIssuanceService->issueInvoice(new IssueInvoiceCommand([ 'parent_id' => $parentId, - 'invoice_number' => $invoiceNumber, + 'invoice_number' => $this->invoiceIssuanceService->generateInvoiceNumber($schoolYear, (int)$parentId), 'total_amount' => $totalAmount, 'paid_amount' => 0, 'balance' => $totalAmount, @@ -894,6 +987,7 @@ class InvoiceController extends ResourceController ->findAll(); $refundsPaidTotal = (float) ($ledger['refund_paid_total'] ?? 0.0); + $refundDetails = $this->paidRefundDetailsForInvoice((int) $invoiceId); /* ============================================================ * ADDITIONAL CHARGES (itemized) for this invoice @@ -964,6 +1058,7 @@ class InvoiceController extends ResourceController 'additionalChargeLines' => $additionalChargeLines, 'invoiceLines' => $invoiceLines, 'refundsPaidTotal' => $refundsPaidTotal, + 'refundDetails' => $refundDetails, 'ledger' => $ledger, ]; } @@ -1265,6 +1360,28 @@ class InvoiceController extends ResourceController $push($dt, $desc, -1 * $amt, 'discount'); } + // --- Refund payouts (positive) integrated into the timeline + foreach (($refundDetails ?? []) as $refund) { + $amount = (float)($refund['amount'] ?? 0.0); + if (abs($amount) < 0.00001) { + continue; + } + + $dt = $toLocal($refund['date'] ?? null, true); + $method = trim((string)($refund['method'] ?? '')); + $checkNumber = trim((string)($refund['check_number'] ?? '')); + $isReversal = (string)($refund['type'] ?? 'cash_out') === 'reversal'; + $desc = $isReversal ? 'Refund reversal' : 'Refund paid'; + if ($method !== '') { + $desc .= ' (' . $method . ')'; + } + if ($checkNumber !== '') { + $desc .= ' - Check #' . $checkNumber; + } + + $push($dt, $desc, $amount, 'refund'); + } + // --- Sort by exact timestamp, then by insertion sequence for stability usort($transactions, function ($a, $b) { // Different days: keep chronological by timestamp diff --git a/app/Controllers/View/LandingPageController.php b/app/Controllers/View/LandingPageController.php index dd0966f..f7bcfc9 100644 --- a/app/Controllers/View/LandingPageController.php +++ b/app/Controllers/View/LandingPageController.php @@ -56,7 +56,7 @@ class LandingPageController extends BaseController // Fetch Enrollment and Refund Deadlines from Configuration $this->schoolYear = $this->configModel->getConfig('school_year'); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->schoolYear = $this->selectedSchoolYearName((string) ($this->schoolYear ?? '')); $this->lastDayOfRegistration = $this->configModel->getConfig('enrollment_deadline') ?? 'Not set'; $this->refundDeadline = $this->configModel->getConfig('refund_deadline') ?? 'Not set'; diff --git a/app/Controllers/View/LateSlipLogsController.php b/app/Controllers/View/LateSlipLogsController.php index 1428813..23f0ecb 100644 --- a/app/Controllers/View/LateSlipLogsController.php +++ b/app/Controllers/View/LateSlipLogsController.php @@ -22,7 +22,7 @@ class LateSlipLogsController extends BaseController $req = $this->request; $defaultYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); - $defaultSem = (string) ($this->configModel->getConfig('semester') ?? ''); + $defaultSem = (string) (getSemester() ?? ''); $schoolYear = trim((string) ($req->getGet('school_year') ?? $defaultYear)); $semester = trim((string) ($req->getGet('semester') ?? $defaultSem)); $q = trim((string) ($req->getGet('q') ?? '')); diff --git a/app/Controllers/View/MidtermController.php b/app/Controllers/View/MidtermController.php index 9d35f8a..448f187 100644 --- a/app/Controllers/View/MidtermController.php +++ b/app/Controllers/View/MidtermController.php @@ -36,7 +36,7 @@ class MidtermController extends BaseController $this->teacherClassModel = new TeacherClassModel(); $this->semesterScoreService = service('semesterScoreService'); $this->schoolYear = $this->configModel->getConfig('school_year'); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->gradingLockModel = new GradingLockModel(); $this->missingScoreOverrideModel = new MissingScoreOverrideModel(); } diff --git a/app/Controllers/View/ParentAttendanceReportController.php b/app/Controllers/View/ParentAttendanceReportController.php index 4fab84e..fe78fe8 100644 --- a/app/Controllers/View/ParentAttendanceReportController.php +++ b/app/Controllers/View/ParentAttendanceReportController.php @@ -247,7 +247,7 @@ class ParentAttendanceReportController extends BaseController // Upper bound already enforced by $validDates building - $semester = (string) $this->configModel->getConfig('semester'); + $semester = (string) getSemester(); $semesterResolver = new SemesterRangeService($this->configModel); $inserted = 0; @@ -996,7 +996,7 @@ class ParentAttendanceReportController extends BaseController : (string)$this->configModel->getConfig('school_year'); $semester = (is_string($semesterParam) && $semesterParam !== '') ? (string)$semesterParam - : (string)$this->configModel->getConfig('semester'); + : (string) getSemester(); $rows = $this->reportModel->listForDateRange($start, $end ?: null, $schoolYear, $semester); @@ -1306,7 +1306,7 @@ class ParentAttendanceReportController extends BaseController } $schoolYear = (string)$this->configModel->getConfig('school_year'); - $semester = (string)$this->configModel->getConfig('semester'); + $semester = (string) getSemester(); // Fetch students with their current-year class section (if any) try { @@ -1362,7 +1362,7 @@ class ParentAttendanceReportController extends BaseController } $schoolYear = (string)$this->configModel->getConfig('school_year'); - $semester = (string)$this->configModel->getConfig('semester'); + $semester = (string) getSemester(); // Resolve parent_id of the student $stu = $this->studentModel->select('id, parent_id, firstname, lastname')->find($studentId); diff --git a/app/Controllers/View/ParentController.php b/app/Controllers/View/ParentController.php index 08d4458..d4d4c50 100644 --- a/app/Controllers/View/ParentController.php +++ b/app/Controllers/View/ParentController.php @@ -86,7 +86,7 @@ class ParentController extends BaseController $this->schoolStartDate = $this->configModel->getConfig('fall_semester_start'); $this->withdrawalDeadline = $this->configModel->getConfig('refund_deadline'); $this->schoolYear = $this->configModel->getConfig('school_year'); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->dateAgeReference = $this->configModel->getConfig('date_age_reference'); $this->maxChilds = (int) $this->configModel->getConfig('max_kids') ?? 0; $this->maxEmergency = (int) $this->configModel->getConfig('max_emergency') ?? 0; @@ -548,7 +548,7 @@ class ParentController extends BaseController if ($existingEnrollment['is_withdrawn'] == 1) { // Reactivate the enrollment if the student was previously withdrawn - $this->enrollmentModel->where('id', $existingEnrollment['id'])->update($update); + $this->enrollmentModel->update((int) $existingEnrollment['id'], $update); log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) has been re-enrolled in enrollment ID {$existingEnrollment['id']}."); // Apply promotion-based class placement for the upcoming year $this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment); @@ -557,7 +557,7 @@ class ParentController extends BaseController if ($currentStatus === 'enrolled') { $update['admission_status'] = 'accepted'; } - $this->enrollmentModel->where('id', $existingEnrollment['id'])->update($update); + $this->enrollmentModel->update((int) $existingEnrollment['id'], $update); log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) is already actively enrolled."); $this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment); } @@ -642,7 +642,7 @@ class ParentController extends BaseController if ($enrollment !== null) { // Update enrollment as withdrawn - $this->enrollmentModel->where('id', $enrollment['id'])->update([ + $this->enrollmentModel->update((int) $enrollment['id'], [ 'withdrawal_date' => local_date(utc_now(), 'Y-m-d'), 'enrollment_status' => 'withdraw under review', // Withdrawal needs review 'updated_at' => utc_now() @@ -660,6 +660,12 @@ class ParentController extends BaseController if ($invoice !== null) { $invoiceId = $invoice['id']; + $studentsForRefund = $this->enrollmentModel + ->where('parent_id', $parentId) + ->where('school_year', $this->schoolYear) + ->findAll(); + $refundAmount = $refundService->calculateRefund($studentsForRefund, (int) $parentId); + $refundCents = max(0, (int) round($refundAmount * 100)); $refundTable = $this->db->table('refunds'); @@ -667,21 +673,29 @@ class ParentController extends BaseController ->where('parent_id', $parentId) ->where('invoice_id', $invoiceId) ->where('school_year', $this->schoolYear) + ->whereIn('status', ['Pending', 'Approved', 'Partial', 'pending', 'requested', 'approved', 'partial', 'partially_paid']) ->get() ->getRow(); if ($existingRefund) { // Only update the fields that should change $updateData = [ + 'refund_amount' => $refundAmount, + 'requested_amount_cents' => $refundCents, + 'currency' => 'USD', 'reason' => 'Withdrawal under review for student ID ' . $studentId, 'note' => null, + 'request' => 'tuition', + 'source_type' => 'tuition_withdrawal', + 'source_id' => (int) $invoiceId, + 'status' => 'Pending', 'updated_by' => session()->get('user_id'), // optionally track updates // Add other fields if *and only if* they must be changed ]; $refundTable ->where('id', $existingRefund->id) - ->update($updateData); + ->update($this->filterPayloadByTableColumns($updateData, 'refunds')); log_message('info', "Refund record updated for invoice ID {$invoiceId}, student ID {$studentId}."); } else { @@ -689,16 +703,22 @@ class ParentController extends BaseController $insertData = [ 'parent_id' => $parentId, 'invoice_id' => $invoiceId, + 'refund_amount' => $refundAmount, + 'requested_amount_cents' => $refundCents, + 'approved_amount_cents' => null, + 'currency' => 'USD', 'requested_at' => utc_now(), 'school_year' => $this->schoolYear, - 'status' => 'Pending review', + 'status' => 'Pending', 'reason' => 'Withdrawal under review for student ID ' . $studentId, - 'request' => 'new', + 'request' => 'tuition', + 'source_type' => 'tuition_withdrawal', + 'source_id' => (int) $invoiceId, 'semester' => $this->semester, 'refund_paid_amount' => 0.0, ]; - $refundTable->insert($insertData); + $refundTable->insert($this->filterPayloadByTableColumns($insertData, 'refunds')); log_message('info', "Refund record created for invoice ID {$invoiceId}, student ID {$studentId}."); } @@ -893,9 +913,14 @@ class ParentController extends BaseController } private function filterEnrollmentPayloadByColumns(array $payload): array + { + return $this->filterPayloadByTableColumns($payload, 'enrollments'); + } + + private function filterPayloadByTableColumns(array $payload, string $table): array { foreach (array_keys($payload) as $column) { - if (! $this->db->fieldExists($column, 'enrollments')) { + if (! $this->db->fieldExists($column, $table)) { unset($payload[$column]); } } diff --git a/app/Controllers/View/ParticipationController.php b/app/Controllers/View/ParticipationController.php index 5b0e52c..784b82e 100644 --- a/app/Controllers/View/ParticipationController.php +++ b/app/Controllers/View/ParticipationController.php @@ -37,7 +37,7 @@ class ParticipationController extends BaseController $this->semesterScoreService = service('semesterScoreService'); $this->schoolYear = $this->configModel->getConfig('school_year'); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->gradingLockModel = new GradingLockModel(); $this->missingScoreOverrideModel = new MissingScoreOverrideModel(); } diff --git a/app/Controllers/View/PaymentController.php b/app/Controllers/View/PaymentController.php index 41ac88d..1a00ff4 100644 --- a/app/Controllers/View/PaymentController.php +++ b/app/Controllers/View/PaymentController.php @@ -64,7 +64,7 @@ class PaymentController extends ResourceController $this->studentModel = new StudentModel(); $this->studentClassModel = new StudentClassModel(); $this->schoolYear = $this->currentSchoolYearName(); - $this->semester = $this->configModel->getConfig('semester'); //installment_date + $this->semester = getSemester(); //installment_date $this->installmentDate = $this->configModel->getConfig('installment_date'); $this->discountUsageModel = new DiscountUsageModel(); $this->additionalChargeModel = new AdditionalChargeModel(); @@ -151,6 +151,15 @@ class PaymentController extends ResourceController } } + private function activeSchoolYearName(): string + { + try { + return service('schoolYearContext')->active()->yearName(); + } catch (\Throwable $e) { + return (string) ($this->configModel->getConfig('school_year') ?? ''); + } + } + private function assertSchoolYearNameWritable(string $schoolYear): void { service('schoolYearWriteGuard')->assertWritable( @@ -323,6 +332,7 @@ class PaymentController extends ResourceController // Read the search term (email or phone) $searchTerm = trim((string) $this->request->getGet('search_term')); + $manualPaySchoolYear = $this->activeSchoolYearName(); // --- Installment end date comes ONLY from config --- $installmentDateRaw = (string) ($this->installmentDate ?? ''); @@ -338,29 +348,36 @@ class PaymentController extends ResourceController $searchClean = preg_replace('/\D/', '', $searchTerm); $searchFormatted = $this->formatPhone($searchClean); - $builder = $this->db->table('users'); - $builder->select('*'); + $builder = $this->db->table('users u'); + $builder->distinct(); + $builder->select('u.*'); + $builder->join('user_roles ur', 'ur.user_id = u.id', 'inner'); + $builder->join('roles r', 'r.id = ur.role_id', 'inner'); + $builder->where('LOWER(r.name)', 'parent'); // Build search conditions $builder->groupStart(); - $builder->where('email', $searchTerm); + $builder->where('u.email', $searchTerm); + $builder->orLike("CONCAT_WS(' ', u.firstname, u.lastname)", $searchTerm, 'both', null, true); + $builder->orLike('u.firstname', $searchTerm); + $builder->orLike('u.lastname', $searchTerm); if (!empty($searchClean)) { if (strlen($searchClean) === 10) { $formattedPhone = substr($searchClean, 0, 3) . '-' . substr($searchClean, 3, 3) . '-' . substr($searchClean, 6, 4); - $builder->orWhere('cellphone', $formattedPhone); + $builder->orWhere('u.cellphone', $formattedPhone); } // compare stripped formatting $builder->orWhere( - "REPLACE(REPLACE(REPLACE(REPLACE(cellphone, '(', ''), ')', ''), '-', ''), ' ', '') =", + "REPLACE(REPLACE(REPLACE(REPLACE(u.cellphone, '(', ''), ')', ''), '-', ''), ' ', '') =", $this->db->escapeString($searchClean), false ); } if (!empty($searchFormatted)) { - $builder->orWhere('cellphone', $searchFormatted); + $builder->orWhere('u.cellphone', $searchFormatted); } $builder->groupEnd(); @@ -374,7 +391,7 @@ class PaymentController extends ResourceController if ($parent && !empty($parent['id'])) { $parentData = $parent; $parentId = (int) $parent['id']; - $carryForwardPaymentRequired = $this->parentHasActiveCarryForwardBalance($parentId, $this->schoolYear); + $carryForwardPaymentRequired = $this->parentHasActiveCarryForwardBalance($parentId, $manualPaySchoolYear); if ($carryForwardPaymentRequired) { $carryForwardPaymentMessage = 'This parent has a balance carried over from a previous school year. Manual payments must be paid in full; installments are not allowed.'; } @@ -386,14 +403,14 @@ class PaymentController extends ResourceController // Payments (paginated). Join invoices so history is filtered by invoice term // and displays current invoice state instead of stale payment snapshots. - $selectedYear = $this->getSelectedPaymentHistoryYear(); - $paymentHistory = $this->paymentModel->parentPaymentHistoryQuery($parentId, $selectedYear); + $paymentHistory = $this->paymentModel->parentPaymentHistoryQuery($parentId, $manualPaySchoolYear); $payments = $paymentHistory->paginate(10); $pager = $paymentHistory->pager; // Invoices $rawInvoices = $this->invoiceModel - ->where('parent_id', $parentId) // <- fixed (removed "value:") + ->where('parent_id', $parentId) + ->where('school_year', $manualPaySchoolYear) ->orderBy('issue_date', 'DESC') ->findAll(); @@ -460,21 +477,23 @@ class PaymentController extends ResourceController $qs = '%' . $db->escapeLikeString($q) . '%'; $digits = preg_replace('/\D/', '', $q); - $sql = "SELECT id, firstname, lastname, email, cellphone - FROM users + $sql = "SELECT DISTINCT u.id, u.firstname, u.lastname, u.email, u.cellphone + FROM users u + JOIN user_roles ur ON ur.user_id = u.id + JOIN roles r ON r.id = ur.role_id AND LOWER(r.name) = 'parent' WHERE ( - CONCAT_WS(' ', firstname, lastname) LIKE ? - OR email LIKE ? - OR cellphone LIKE ?"; + CONCAT_WS(' ', u.firstname, u.lastname) LIKE ? + OR u.email LIKE ? + OR u.cellphone LIKE ?"; $params = [$qs, $qs, $qs]; if ($digits !== '') { - $sql .= " OR REPLACE(REPLACE(REPLACE(REPLACE(cellphone, '(', ''), ')', ''), '-', ''), ' ', '') LIKE ?"; + $sql .= " OR REPLACE(REPLACE(REPLACE(REPLACE(u.cellphone, '(', ''), ')', ''), '-', ''), ' ', '') LIKE ?"; $params[] = '%' . $digits . '%'; } $sql .= ") - ORDER BY lastname, firstname + ORDER BY u.lastname, u.firstname LIMIT 20"; $rows = $db->query($sql, $params)->getResultArray(); @@ -562,7 +581,7 @@ class PaymentController extends ResourceController ->orWhere('enrollment_status', 'Payment pending') ->orWhere('enrollment_status', 'Payment Pending') ->orWhere('enrollment_status', 'PAYMENT PENDING') - ->orWhere("LOWER(TRIM(REPLACE(enrollment_status, CHAR(160), ' '))) = 'payment pending'", null, false) + ->orWhere("LOWER(TRIM(REPLACE(enrollment_status, CONVERT(0xC2A0 USING utf8mb4), ' '))) = 'payment pending'", null, false) ->groupEnd(); if ($semester !== null) { @@ -1457,12 +1476,15 @@ class PaymentController extends ResourceController if ($amount > $preBalance + 0.00001) { return false; } + $postBalance = max(0.0, round($preBalance - $amount, 2)); $paymentData = [ 'parent_id' => (int) $invoice['parent_id'], 'invoice_id' => $invoiceId, 'total_amount' => $invoice['total_amount'], 'paid_amount' => $amount, - 'balance' => null, + 'balance' => $postBalance, + 'balance_amount' => $postBalance, + 'balance_after_payment' => $postBalance, 'number_of_installments' => $installmentSeq, // <-- installment sequence (1,2,3,...) 'installment_seq' => $installmentSeq, 'transaction_id' => $transactionId, @@ -1480,6 +1502,7 @@ class PaymentController extends ResourceController $paymentId = $this->paymentModel->insert($paymentData); if (!$paymentId) { + log_message('error', '[processPayment] Failed to insert payment: ' . json_encode($this->paymentModel->errors())); return false; } diff --git a/app/Controllers/View/PrintablesBaseController.php b/app/Controllers/View/PrintablesBaseController.php index 521e4ec..7c11c2e 100644 --- a/app/Controllers/View/PrintablesBaseController.php +++ b/app/Controllers/View/PrintablesBaseController.php @@ -58,7 +58,7 @@ class PrintablesBaseController extends BaseController $this->attendanceRecordModel = new AttendanceRecordModel(); $this->schoolYear = $this->configModel->getConfig('school_year'); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->stickerWidth = $this->configModel->getConfig('stickerWidth'); $this->stickerHeight = $this->configModel->getConfig('stickerHeight'); $this->pageW = $this->configModel->getConfig('pageWidth'); diff --git a/app/Controllers/View/QuizController.php b/app/Controllers/View/QuizController.php index 602b59f..e8b75ce 100644 --- a/app/Controllers/View/QuizController.php +++ b/app/Controllers/View/QuizController.php @@ -45,7 +45,7 @@ class QuizController extends BaseController $this->quizModel = new QuizModel(); $this->semesterScoreService = service('semesterScoreService'); $this->schoolYear = $this->configModel->getConfig('school_year'); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->gradingLockModel = new GradingLockModel(); $this->missingScoreOverrideModel = new MissingScoreOverrideModel(); } diff --git a/app/Controllers/View/RefundController.php b/app/Controllers/View/RefundController.php index 2f28175..d910158 100644 --- a/app/Controllers/View/RefundController.php +++ b/app/Controllers/View/RefundController.php @@ -16,6 +16,7 @@ use App\Models\PaymentModel; use App\Models\ConfigurationModel; use App\Models\InvoiceModel; use App\Models\EnrollmentModel; +use App\Services\FeeCalculationService; use CodeIgniter\Exceptions\PageNotFoundException; class RefundController extends BaseController @@ -31,6 +32,7 @@ class RefundController extends BaseController protected ParentLedgerService $parentLedgerService; protected RefundEligibilityService $refundEligibilityService; protected FinancialAttachmentService $financialAttachmentService; + protected FeeCalculationService $feeCalculationService; protected $db; // Allowed request types (mapped to your `refunds.request` column) @@ -52,6 +54,7 @@ class RefundController extends BaseController $this->parentLedgerService = new ParentLedgerService(); $this->refundEligibilityService = new RefundEligibilityService(); $this->financialAttachmentService = new FinancialAttachmentService(); + $this->feeCalculationService = new FeeCalculationService(); $this->db = \Config\Database::connect(); } @@ -70,6 +73,22 @@ class RefundController extends BaseController } } + private function refundStatusForStorage(string $status): string + { + return match (FinancialStatus::normalizeRefundStatus($status)) { + FinancialStatus::REFUND_APPROVED => 'Approved', + FinancialStatus::REFUND_REJECTED => 'Rejected', + FinancialStatus::REFUND_PARTIALLY_PAID => 'Partial', + FinancialStatus::REFUND_PAID => 'Paid', + default => 'Pending', + }; + } + + private function refundOpenStatuses(): array + { + return ['Pending', 'Approved', 'Partial', 'pending', 'requested', 'approved', 'partial', 'partially_paid']; + } + private function refundFailureResponse( string $publicCode, string $publicMessage, @@ -89,12 +108,12 @@ class RefundController extends BaseController ]); } - /** Get current term (school_year, semester) from configuration */ + /** Get current school year from configuration and derive the current semester from calendar dates. */ private function getCurrentTerm(): array { $rows = $this->configModel ->select('config_key, config_value') - ->whereIn('config_key', ['school_year','semester']) + ->whereIn('config_key', ['school_year']) ->findAll(); $map = []; @@ -103,7 +122,7 @@ class RefundController extends BaseController } return [ 'school_year' => $map['school_year'] ?? date('Y') . '-' . (date('Y') + 1), - 'semester' => $map['semester'] ?? 'Fall', + 'semester' => getSemester(), ]; } @@ -140,7 +159,7 @@ class RefundController extends BaseController ->where('invoice_id', $iid) ->where('source_type', 'invoice_overpayment') ->where('source_id', $iid) - ->whereIn('status', ['Pending','Approved','Partial']) + ->whereIn('status', $this->refundOpenStatuses()) ->orderBy('id', 'DESC') ->first(); if ($openRow) { @@ -161,7 +180,7 @@ class RefundController extends BaseController ->where('invoice_id', $iid) ->where('source_type', 'invoice_overpayment') ->where('source_id', $iid) - ->whereIn('status', ['Pending','Approved','Partial','Paid']) + ->whereIn('status', array_merge($this->refundOpenStatuses(), ['Paid', 'paid'])) ->orderBy('id', 'DESC') ->first(); @@ -176,7 +195,7 @@ class RefundController extends BaseController 'approved_amount_cents' => null, 'currency' => 'USD', 'refund_paid_amount' => 0.00, - 'status' => FinancialStatus::REFUND_REQUESTED, + 'status' => $this->refundStatusForStorage(FinancialStatus::REFUND_REQUESTED), 'request' => 'overpayment', 'source_type' => 'invoice_overpayment', 'source_id' => $iid, @@ -393,7 +412,7 @@ class RefundController extends BaseController 'approved_amount_cents' => null, 'currency' => 'USD', 'refund_paid_amount' => 0.00, // IMPORTANT: your column is NOT NULL - 'status' => FinancialStatus::REFUND_REQUESTED, + 'status' => $this->refundStatusForStorage(FinancialStatus::REFUND_REQUESTED), 'reason' => $reason, 'request' => $requestType, // <- store the source/type here 'source_type' => $sourceType, @@ -470,7 +489,7 @@ class RefundController extends BaseController $this->refundEligibilityService->validateRequestedAmount($eligibility, $requestedCents); $ok = $this->refundModel->update($refundId, [ - 'status' => FinancialStatus::REFUND_APPROVED, + 'status' => $this->refundStatusForStorage(FinancialStatus::REFUND_APPROVED), 'approved_amount_cents' => $requestedCents, 'approved_at' => utc_now(), 'approved_by' => session()->get('user_id'), @@ -513,7 +532,7 @@ class RefundController extends BaseController } $ok = $this->refundModel->update($refundId, [ - 'status' => FinancialStatus::REFUND_REJECTED, + 'status' => $this->refundStatusForStorage(FinancialStatus::REFUND_REJECTED), 'reason' => $this->request->getPost('reason') ?: ($refund['reason'] ?? null), 'approved_at' => utc_now(), 'approved_by' => session()->get('user_id'), @@ -697,7 +716,7 @@ class RefundController extends BaseController ]; if (!$isOnline) { $refundProjection['refund_paid_amount'] = $total; - $refundProjection['status'] = $newStatus; + $refundProjection['status'] = $this->refundStatusForStorage($newStatus); $refundProjection['refunded_at'] = utc_now(); } if (!$this->refundModel->update($refundId, $refundProjection)) { @@ -865,7 +884,7 @@ class RefundController extends BaseController if (!$this->refundModel->update($refundId, [ 'refund_paid_amount' => $netPaidCents / 100, - 'status' => $newStatus, + 'status' => $this->refundStatusForStorage($newStatus), 'refunded_at' => $netPaidCents > 0 ? ($refund['refunded_at'] ?? utc_now()) : null, 'updated_at' => utc_now(), 'updated_by' => session()->get('user_id'), @@ -912,15 +931,17 @@ class RefundController extends BaseController /** Keep your listing; added extra fields for clarity */ public function listRefunds() { - // NOTE: We no longer auto-create/adjust refunds on page load to avoid duplicate lines - // when staff are recording payouts. Use the "Recalculate" buttons to run detection on demand. + // Repair legacy withdrawal placeholders only; overpayment recalculation remains explicit. + $this->repairPendingWithdrawalRefunds(); // 2) List refunds with joins $refunds = $this->refundModel ->select('refunds.*, + i.invoice_number, u.firstname, u.lastname, u.school_id, a.firstname AS approved_by_firstname, a.lastname AS approved_by_lastname') + ->join('invoices i', 'refunds.invoice_id = i.id', 'left') ->join('users u', 'refunds.parent_id = u.id') ->join('users a', 'refunds.approved_by = a.id', 'left') ->orderBy('refunds.created_at', 'DESC') @@ -970,6 +991,59 @@ class RefundController extends BaseController ]); } + private function repairPendingWithdrawalRefunds(): void + { + try { + $rows = $this->refundModel + ->groupStart() + ->where('refund_amount <=', 0) + ->orWhere('source_type IS NULL', null, false) + ->orWhere("(request = 'tuition' AND source_type = 'invoice_overpayment')", null, false) + ->orWhere('source_id IS NULL', null, false) + ->groupEnd() + ->whereIn('status', ['Pending', 'pending', 'requested']) + ->like('reason', 'Withdrawal under review') + ->findAll(); + + foreach ($rows as $refund) { + $parentId = (int)($refund['parent_id'] ?? 0); + $schoolYear = (string)($refund['school_year'] ?? ''); + if ($parentId <= 0 || $schoolYear === '') { + continue; + } + + $invoice = $this->invoiceModel + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->orderBy('created_at', 'DESC') + ->first(); + if (!$invoice) { + continue; + } + + $students = $this->enrollmentModel + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->findAll(); + $refundAmount = $this->feeCalculationService->calculateRefund($students, $parentId); + $refundCents = max(0, (int)round($refundAmount * 100)); + + $this->refundModel->update((int)$refund['id'], [ + 'invoice_id' => (int)$invoice['id'], + 'refund_amount' => $refundAmount, + 'requested_amount_cents' => $refundCents, + 'currency' => 'USD', + 'request' => 'tuition', + 'source_type' => 'tuition_withdrawal', + 'source_id' => (int)$invoice['id'], + 'updated_at' => utc_now(), + ]); + } + } catch (\Throwable $e) { + log_message('error', 'Pending withdrawal refund repair failed: ' . $e->getMessage()); + } + } + /** Manual endpoint to recalculate/sync overpayments and optionally notify newly created entries. */ public function recalculateOverpayments() { @@ -991,7 +1065,7 @@ class RefundController extends BaseController ->where('invoice_id', $iid) ->where('source_type', 'invoice_overpayment') ->where('source_id', $iid) - ->whereIn('status', ['Pending','Approved','Partial']) + ->whereIn('status', $this->refundOpenStatuses()) ->orderBy('id', 'DESC') ->first(); if ($openRow) { @@ -1028,7 +1102,7 @@ class RefundController extends BaseController 'approved_amount_cents' => null, 'currency' => 'USD', 'refund_paid_amount' => 0.00, - 'status' => FinancialStatus::REFUND_REQUESTED, + 'status' => $this->refundStatusForStorage(FinancialStatus::REFUND_REQUESTED), 'request' => 'overpayment', 'source_type' => 'invoice_overpayment', 'source_id' => $iid, @@ -1113,7 +1187,7 @@ class RefundController extends BaseController } $ok = $this->refundModel->update($refundId, [ - 'status' => $status, + 'status' => $this->refundStatusForStorage($status), 'reason' => $reason, 'approved_amount_cents' => $approvedCents, 'approved_at' => utc_now(), @@ -1188,7 +1262,7 @@ class RefundController extends BaseController private function lockRefundSourceForUpdate(string $sourceType, int $sourceId, ?int $invoiceId): void { - if ($sourceType === 'invoice_overpayment') { + if (in_array($sourceType, ['invoice_overpayment', 'tuition_withdrawal'], true)) { $this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId ?: $sourceId])->getRowArray(); } elseif (in_array($sourceType, ['payment_duplicate', 'payment_correction'], true)) { $payment = $this->db->query('SELECT * FROM payments WHERE id = ? FOR UPDATE', [$sourceId])->getRowArray(); diff --git a/app/Controllers/View/RegisterController.php b/app/Controllers/View/RegisterController.php index 6c129e1..cd6822d 100644 --- a/app/Controllers/View/RegisterController.php +++ b/app/Controllers/View/RegisterController.php @@ -44,7 +44,7 @@ class RegisterController extends Controller $this->parentModel = new ParentModel(); $this->policyAcceptanceModel = new ParentPolicyAcceptanceModel(); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->schoolYear = $this->configModel->getConfig('school_year'); } diff --git a/app/Controllers/View/ReimbursementController.php b/app/Controllers/View/ReimbursementController.php index 686dbde..6f0b884 100644 --- a/app/Controllers/View/ReimbursementController.php +++ b/app/Controllers/View/ReimbursementController.php @@ -50,7 +50,7 @@ class ReimbursementController extends BaseController $this->batchItemModel = new ReimbursementBatchItemModel(); $this->batchAdminFileModel = new ReimbursementBatchAdminFileModel(); - $this->semester = $this->configModel->getConfig('semester') ?? 'Fall'; + $this->semester = getSemester() ?? 'Fall'; $this->schoolYear = $this->configModel->getConfig('school_year') ?? date('Y'); } diff --git a/app/Controllers/View/RolePermissionController.php b/app/Controllers/View/RolePermissionController.php index 7132385..02f15df 100644 --- a/app/Controllers/View/RolePermissionController.php +++ b/app/Controllers/View/RolePermissionController.php @@ -41,7 +41,7 @@ class RolePermissionController extends Controller $this->request = \Config\Services::request(); $this->db = \Config\Database::connect(); $this->schoolYear = $this->configModel->getConfig('school_year'); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); } public function index() diff --git a/app/Controllers/View/RoleSwitcherController.php b/app/Controllers/View/RoleSwitcherController.php index 2351d6e..2e34b77 100644 --- a/app/Controllers/View/RoleSwitcherController.php +++ b/app/Controllers/View/RoleSwitcherController.php @@ -28,7 +28,7 @@ class RoleSwitcherController extends BaseController } $this->configModel = new ConfigurationModel(); $this->userRoleModel = new UserRoleModel(); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->schoolYear = $this->configModel->getConfig('school_year'); } diff --git a/app/Controllers/View/SchoolCalendarController.php b/app/Controllers/View/SchoolCalendarController.php index 84f6d9d..5960b3a 100644 --- a/app/Controllers/View/SchoolCalendarController.php +++ b/app/Controllers/View/SchoolCalendarController.php @@ -45,7 +45,7 @@ class SchoolCalendarController extends BaseController $this->meetingModel = new ParentMeetingScheduleModel(); $this->db = \Config\Database::connect(); $this->schoolYear = $this->configModel->getConfig('school_year'); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); // Load helpers helper(['form', 'url']); diff --git a/app/Controllers/View/ScoreCommentController.php b/app/Controllers/View/ScoreCommentController.php index 3c8a259..92def89 100644 --- a/app/Controllers/View/ScoreCommentController.php +++ b/app/Controllers/View/ScoreCommentController.php @@ -39,7 +39,7 @@ class ScoreCommentController extends BaseController $this->semesterScoreModel = new SemesterScoreModel(); $this->gradingLockModel = new GradingLockModel(); $this->missingScoreOverrideModel = new MissingScoreOverrideModel(); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->schoolYear = $this->configModel->getConfig('school_year'); } diff --git a/app/Controllers/View/ScoreController.php b/app/Controllers/View/ScoreController.php index f4a89d8..dd926ca 100644 --- a/app/Controllers/View/ScoreController.php +++ b/app/Controllers/View/ScoreController.php @@ -77,7 +77,7 @@ class ScoreController extends BaseController $this->gradingLockModel = new GradingLockModel(); $this->missingScoreOverrideModel = new MissingScoreOverrideModel(); // Retrieve the configuration values - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->schoolYear = $this->configModel->getConfig('school_year'); $this->targetHigh = $this->configModel->getConfig('trophy_score'); $this->targetLow = $this->configModel->getConfig('pass_score'); @@ -1030,7 +1030,7 @@ class ScoreController extends BaseController $fallStartCfg = (string)($this->configModel->getConfig('fall_semester_start') ?? ''); $fallEndCfg = (string)($this->configModel->getConfig('fall_end_date') ?? ''); // e.g., 'YYYY-01-15' $springStartCfg = (string)($this->configModel->getConfig('spring_semester_start') ?? ''); // e.g., 'YYYY-01-16' - $springEndCfg = (string)($this->configModel->getConfig('last_school_day') ?? ''); + $springEndCfg = (string)($this->configModel->getConfig('last_day_of_school') ?? ''); if ($norm === 'fall') { $start = ($fallStartCfg !== '') ? date($y1 . '-m-d', strtotime($fallStartCfg)) : "{$y1}-09-01"; diff --git a/app/Controllers/View/ScorePredictor.php b/app/Controllers/View/ScorePredictor.php index c2afb3e..108ed23 100644 --- a/app/Controllers/View/ScorePredictor.php +++ b/app/Controllers/View/ScorePredictor.php @@ -36,7 +36,7 @@ class ScorePredictor extends BaseController $this->classSectionModel = new ClassSectionModel(); // Retrieve the configuration values - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->schoolYear = $this->configModel->getConfig('school_year'); $this->targetTrophy = $this->configModel->getConfig('trophy_score'); $this->targetLow = $this->configModel->getConfig('pass_score'); diff --git a/app/Controllers/View/SlipPrinterController.php b/app/Controllers/View/SlipPrinterController.php index 52e9b64..dd1e383 100644 --- a/app/Controllers/View/SlipPrinterController.php +++ b/app/Controllers/View/SlipPrinterController.php @@ -28,7 +28,7 @@ class SlipPrinterController extends BaseController { helper(['form', 'url']); $this->configModel = new ConfigurationModel(); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->schoolYear = $this->configModel->getConfig('school_year'); } /** @@ -66,7 +66,7 @@ class SlipPrinterController extends BaseController try { $printedBy = (int) (session()->get('user_id') ?? 0) ?: null; $cfg = new ConfigurationModel(); - $semester = (string)($cfg->getConfig('semester') ?? ''); + $semester = (string)(getSemester() ?? ''); $logData = [ 'school_year' => $data['school_year'], 'semester' => $semester, @@ -325,7 +325,7 @@ class SlipPrinterController extends BaseController try { $printedBy = (int) (session()->get('user_id') ?? 0) ?: null; $cfg = new ConfigurationModel(); - $semester = (string)($cfg->getConfig('semester') ?? ''); + $semester = (string)(getSemester() ?? ''); $logData = [ 'school_year' => $data['school_year'], 'semester' => $semester, diff --git a/app/Controllers/View/StaffController.php b/app/Controllers/View/StaffController.php index 7ac26ba..95c02d9 100644 --- a/app/Controllers/View/StaffController.php +++ b/app/Controllers/View/StaffController.php @@ -29,7 +29,7 @@ class StaffController extends BaseController $this->staffDirectorySync = service('staffDirectorySync'); // Retrieve the configuration values - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->schoolYear = $this->configModel->getConfig('school_year'); } diff --git a/app/Controllers/View/StudentController.php b/app/Controllers/View/StudentController.php index b5cb809..dd4c056 100644 --- a/app/Controllers/View/StudentController.php +++ b/app/Controllers/View/StudentController.php @@ -58,7 +58,7 @@ class StudentController extends BaseController $this->classSectionModel = new ClassSectionModel(); $this->emergencyContact = new EmergencyContactModel(); $this->enrollmentModel = new EnrollmentModel(); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->schoolYear = $this->configModel->getConfig('school_year'); helper(['url', 'form']); } diff --git a/app/Controllers/View/TeacherController.php b/app/Controllers/View/TeacherController.php index f2c87bf..2bce733 100644 --- a/app/Controllers/View/TeacherController.php +++ b/app/Controllers/View/TeacherController.php @@ -55,7 +55,7 @@ class TeacherController extends BaseController $this->staffAttendanceModel = new StaffAttendanceModel(); // Retrieve the configuration values - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->schoolYear = $this->configModel->getConfig('school_year'); $this->schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); } diff --git a/app/Controllers/View/WhatsappController.php b/app/Controllers/View/WhatsappController.php index cab4455..92ed54a 100644 --- a/app/Controllers/View/WhatsappController.php +++ b/app/Controllers/View/WhatsappController.php @@ -51,7 +51,7 @@ class WhatsappController extends BaseController $this->membershipModel = new WhatsappGroupMembershipModel(); $this->configModel = new ConfigurationModel(); - $this->semester = $this->configModel->getConfig('semester'); + $this->semester = getSemester(); $this->schoolYear = $this->configModel->getConfig('school_year'); } diff --git a/app/Database/Migrations/2026-08-16-000100_RemoveDuplicateCalendarConfigurationKeys.php b/app/Database/Migrations/2026-08-16-000100_RemoveDuplicateCalendarConfigurationKeys.php new file mode 100644 index 0000000..1324a4a --- /dev/null +++ b/app/Database/Migrations/2026-08-16-000100_RemoveDuplicateCalendarConfigurationKeys.php @@ -0,0 +1,31 @@ +db->tableExists('configuration')) { + return; + } + + $this->db->table('configuration') + ->whereIn('config_key', self::DUPLICATE_KEYS) + ->delete(); + } + + public function down(): void + { + // Obsolete duplicate keys should not be recreated. + } +} diff --git a/app/Database/Migrations/2026-08-16-000200_RemoveAdditionalDuplicateCalendarConfigurationKeys.php b/app/Database/Migrations/2026-08-16-000200_RemoveAdditionalDuplicateCalendarConfigurationKeys.php new file mode 100644 index 0000000..f8b4dd7 --- /dev/null +++ b/app/Database/Migrations/2026-08-16-000200_RemoveAdditionalDuplicateCalendarConfigurationKeys.php @@ -0,0 +1,30 @@ +db->tableExists('configuration')) { + return; + } + + $this->db->table('configuration') + ->whereIn('config_key', self::DUPLICATE_KEYS) + ->delete(); + } + + public function down(): void + { + // Obsolete duplicate keys should not be recreated. + } +} diff --git a/app/Database/Migrations/2026-08-16-000300_AlignFallSemesterStartWithSchoolYearStart.php b/app/Database/Migrations/2026-08-16-000300_AlignFallSemesterStartWithSchoolYearStart.php new file mode 100644 index 0000000..39615dd --- /dev/null +++ b/app/Database/Migrations/2026-08-16-000300_AlignFallSemesterStartWithSchoolYearStart.php @@ -0,0 +1,59 @@ +db->tableExists('configuration')) { + return; + } + + $schoolYearStart = $this->configValue('school_year_start_date'); + if ($schoolYearStart === null || $schoolYearStart === '') { + return; + } + + $this->upsertConfigValue('fall_semester_start', $schoolYearStart); + } + + public function down(): void + { + } + + private function configValue(string $key): ?string + { + $row = $this->db->table('configuration') + ->select('config_value') + ->where('config_key', $key) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + + return $row ? (string) $row['config_value'] : null; + } + + private function upsertConfigValue(string $key, string $value): void + { + $exists = $this->db->table('configuration') + ->select('id') + ->where('config_key', $key) + ->get(1) + ->getRowArray(); + + if ($exists) { + $this->db->table('configuration') + ->where('config_key', $key) + ->update(['config_value' => $value]); + return; + } + + $this->db->table('configuration')->insert([ + 'config_key' => $key, + 'config_value' => $value, + ]); + } +} diff --git a/app/Helpers/GlobalConfigHelper.php b/app/Helpers/GlobalConfigHelper.php index e537d1a..4f40564 100644 --- a/app/Helpers/GlobalConfigHelper.php +++ b/app/Helpers/GlobalConfigHelper.php @@ -1,12 +1,15 @@ getConfig('semester'); + $semester = (new SemesterRangeService($configModel))->getSemesterForDate(); + + return $semester !== '' ? $semester : 'Fall'; } } diff --git a/app/Helpers/global_config_helper.php b/app/Helpers/global_config_helper.php new file mode 100644 index 0000000..25d208b --- /dev/null +++ b/app/Helpers/global_config_helper.php @@ -0,0 +1,3 @@ +getConfig('semester') ?? '')); + $configSemester = trim((string) ((new SemesterRangeService(new ConfigurationModel()))->getSemesterForDate() ?: 'Fall')); if ($configSemester !== '') { return ucfirst(strtolower($configSemester)); } diff --git a/app/Libraries/InvoiceIssuanceService.php b/app/Libraries/InvoiceIssuanceService.php index b4b273b..37849ca 100644 --- a/app/Libraries/InvoiceIssuanceService.php +++ b/app/Libraries/InvoiceIssuanceService.php @@ -27,6 +27,12 @@ class InvoiceIssuanceService public function issueInvoice(IssueInvoiceCommand $command): InvoiceLedgerResult { $invoiceData = $command->invoiceData; + if (trim((string)($invoiceData['invoice_number'] ?? '')) === '') { + $invoiceData['invoice_number'] = $this->generateInvoiceNumber( + (string)($invoiceData['school_year'] ?? ''), + (int)($invoiceData['parent_id'] ?? 0) + ); + } $invoiceData['status'] = FinancialStatus::INVOICE_DRAFT; $invoiceData['total_amount'] = $invoiceData['total_amount'] ?? number_format($command->tuitionAmount + $command->eventAmount, 2, '.', ''); $invoiceData['balance'] = $invoiceData['balance'] ?? $invoiceData['total_amount']; @@ -78,6 +84,54 @@ class InvoiceIssuanceService } } + public function generateInvoiceNumber(string $schoolYear, ?int $parentId = null): string + { + $prefix = $this->invoiceNumberPrefix($schoolYear, $parentId); + do { + $invoiceNumber = $prefix . '-' . uniqid(); + $exists = $this->db->table('invoices') + ->where('invoice_number', $invoiceNumber) + ->countAllResults() > 0; + } while ($exists); + + return $invoiceNumber; + } + + private function invoiceNumberPrefix(string $schoolYear, ?int $parentId = null): string + { + $year = date('y'); + if (preg_match('/^(\d{4})-\d{4}$/', $schoolYear, $matches) === 1) { + $year = substr($matches[1], -2); + } + + $parentSuffix = $this->parentSchoolIdSuffix((int)($parentId ?? 0)); + if ($parentSuffix !== '') { + return 'INV-' . $year . $parentSuffix; + } + + return 'INV-' . $year . str_pad((string) max(0, (int)($parentId ?? 0)), 5, '0', STR_PAD_LEFT); + } + + private function parentSchoolIdSuffix(int $parentId): string + { + if ($parentId <= 0) { + return ''; + } + + $row = $this->db->table('users') + ->select('school_id') + ->where('id', $parentId) + ->get() + ->getRowArray(); + + $schoolId = trim((string)($row['school_id'] ?? '')); + if (preg_match('/^\d{2}(\d{5})$/', $schoolId, $matches) === 1) { + return $matches[1]; + } + + return ''; + } + private function requireWrite($result, string $code, $model = null): void { if ($result === false || $result === null || $result === 0) { diff --git a/app/Libraries/InvoiceLedgerService.php b/app/Libraries/InvoiceLedgerService.php index 7370afa..c0e13a7 100644 --- a/app/Libraries/InvoiceLedgerService.php +++ b/app/Libraries/InvoiceLedgerService.php @@ -103,8 +103,10 @@ class InvoiceLedgerService $totalAmountCents = $tuitionCents + $eventCents + $additionalCents; } - $rawBalanceCents = $totalAmountCents - $discountCents - $paidCents + $refundPaidCents; + $netChargeCents = $totalAmountCents - $discountCents; + $rawBalanceCents = $netChargeCents - $paidCents - $refundPaidCents; $balanceCents = max(0, $rawBalanceCents); + $customerCreditCents = max(0, $paidCents - $refundPaidCents - $netChargeCents); if ($balanceCents === 0) { $status = FinancialStatus::INVOICE_PAID; @@ -120,14 +122,14 @@ class InvoiceLedgerService 'discount_eligible_base_cents' => $discountBaseCents, 'requested_discount_cents' => $discountRawCents, 'applied_discount_cents' => $discountCents, - 'net_charge_cents' => $totalAmountCents - $discountCents, + 'net_charge_cents' => $netChargeCents, 'totalAmountCents' => $totalAmountCents, 'discountCents' => $discountCents, 'paidCents' => $paidCents, 'completedRefundCents' => $refundPaidCents, 'rawBalanceCents' => $rawBalanceCents, 'balanceDueCents' => $balanceCents, - 'customerCreditCents' => max(0, -$rawBalanceCents), + 'customerCreditCents' => $customerCreditCents, 'tuition_total' => $this->fromCents($tuitionCents), 'event_total' => $this->fromCents($eventCents), 'additional_total' => $this->fromCents($additionalCents), @@ -136,7 +138,7 @@ class InvoiceLedgerService 'paid_amount' => $this->fromCents($paidCents), 'refund_paid_total' => $this->fromCents($refundPaidCents), 'total_amount' => $this->fromCents($totalAmountCents), - 'customer_credit' => $this->fromCents(max(0, -$rawBalanceCents)), + 'customer_credit' => $this->fromCents($customerCreditCents), 'balance' => $this->fromCents($balanceCents), 'status' => $status, 'has_discount' => $discountCents > 0 ? 1 : 0, diff --git a/app/Libraries/RefundEligibilityService.php b/app/Libraries/RefundEligibilityService.php index 2d73708..38e9f7f 100644 --- a/app/Libraries/RefundEligibilityService.php +++ b/app/Libraries/RefundEligibilityService.php @@ -164,6 +164,7 @@ class RefundEligibilityService { return match ($sourceType) { 'invoice_overpayment' => $this->invoiceCreditCents($parentId, $invoiceId ?: $sourceId), + 'tuition_withdrawal' => $this->invoicePaidCents($parentId, $invoiceId ?: $sourceId), 'payment_duplicate', 'payment_correction' => $this->paymentCreditCents($parentId, $invoiceId, $sourceId), 'credit_memo', 'administrative_credit' => 0, default => 0, @@ -175,6 +176,22 @@ class RefundEligibilityService return $sourceType !== 'invoice_overpayment'; } + protected function invoicePaidCents(int $parentId, int $invoiceId): int + { + if ($invoiceId <= 0) { + return 0; + } + + $invoice = $this->invoiceModel->find($invoiceId); + if (!$invoice || (int)($invoice['parent_id'] ?? 0) !== $parentId) { + return 0; + } + + $ledger = $this->invoiceLedgerService->calculateInvoice($invoiceId); + + return max(0, (int)($ledger['paidCents'] ?? 0)); + } + protected function invoiceCreditCents(int $parentId, int $invoiceId): int { if ($invoiceId <= 0) { diff --git a/app/Models/AttendanceTrackingModel.php b/app/Models/AttendanceTrackingModel.php index 3648975..9ff32fc 100644 --- a/app/Models/AttendanceTrackingModel.php +++ b/app/Models/AttendanceTrackingModel.php @@ -435,7 +435,7 @@ class AttendanceTrackingModel extends Model ?string $semester = null, ?string $schoolYear = null ): bool { - $semester = $semester ?? (new \App\Models\ConfigurationModel())->getConfig('semester'); + $semester = $semester ?? getSemester(); $schoolYear = $schoolYear ?? (new \App\Models\ConfigurationModel())->getConfig('school_year'); $day = substr($ymd, 0, 10); diff --git a/app/Models/ConfigurationModel.php b/app/Models/ConfigurationModel.php index 101e594..95dbdde 100644 --- a/app/Models/ConfigurationModel.php +++ b/app/Models/ConfigurationModel.php @@ -86,12 +86,9 @@ class ConfigurationModel extends Model if ($key === 'semester') { try { - $semester = (new \App\Services\SemesterRangeService($this))->getSemesterForDate(); - if ($semester !== '') { - return $semester; - } + return (new \App\Services\SemesterRangeService($this))->getSemesterForDate() ?: 'Fall'; } catch (\Throwable $e) { - // ignore and fall back + return 'Fall'; } } diff --git a/app/Models/ManualPaymentModel.php b/app/Models/ManualPaymentModel.php index e5d43e9..a0f2588 100644 --- a/app/Models/ManualPaymentModel.php +++ b/app/Models/ManualPaymentModel.php @@ -4,6 +4,8 @@ namespace App\Models; use CodeIgniter\Model; use App\Models\Concerns\SchoolYearAutoFillTrait; +use CodeIgniter\Database\ConnectionInterface; +use CodeIgniter\Validation\ValidationInterface; class ManualPaymentModel extends Model { @@ -23,6 +25,38 @@ class ManualPaymentModel extends Model 'school_year' => 'required|string|max_length[9]', ]; - protected $useTimestamps = false; + + private array $manualPaymentColumns = []; + + public function __construct(?ConnectionInterface $db = null, ?ValidationInterface $validation = null) + { + parent::__construct($db, $validation); + + $this->manualPaymentColumns = $this->getTableColumns($this->table); + $this->allowedFields = array_values(array_filter( + $this->allowedFields, + fn (string $field): bool => in_array($field, $this->manualPaymentColumns, true) + )); + + foreach (array_keys($this->validationRules) as $field) { + if (!in_array($field, $this->allowedFields, true)) { + unset($this->validationRules[$field]); + } + } + } + + private function getTableColumns(string $table): array + { + try { + return $this->db->getFieldNames($table); + } catch (\Throwable $e) { + log_message('error', '[ManualPaymentModel] Could not read table columns for {table}: {error}', [ + 'table' => $table, + 'error' => $e->getMessage(), + ]); + + return []; + } + } } diff --git a/app/Models/RefundModel.php b/app/Models/RefundModel.php index 27a8c5b..b0c921b 100644 --- a/app/Models/RefundModel.php +++ b/app/Models/RefundModel.php @@ -2,8 +2,10 @@ namespace App\Models; +use CodeIgniter\Database\ConnectionInterface; use CodeIgniter\Model; use App\Models\Concerns\SchoolYearAutoFillTrait; +use CodeIgniter\Validation\ValidationInterface; class RefundModel extends Model { @@ -50,6 +52,39 @@ class RefundModel extends Model protected $createdField = 'created_at'; protected $updatedField = 'updated_at'; + private array $refundColumns = []; + + public function __construct(?ConnectionInterface $db = null, ?ValidationInterface $validation = null) + { + parent::__construct($db, $validation); + + $this->refundColumns = $this->getTableColumns($this->table); + $this->allowedFields = array_values(array_filter( + $this->allowedFields, + fn (string $field): bool => in_array($field, $this->refundColumns, true) + )); + + foreach (array_keys($this->validationRules) as $field) { + if (!in_array($field, $this->allowedFields, true)) { + unset($this->validationRules[$field]); + } + } + } + + private function getTableColumns(string $table): array + { + try { + return $this->db->getFieldNames($table); + } catch (\Throwable $e) { + log_message('error', '[RefundModel] Could not read table columns for {table}: {error}', [ + 'table' => $table, + 'error' => $e->getMessage(), + ]); + + return []; + } + } + /** * Get total approved refund for a parent in a specific school year. * diff --git a/app/Models/RefundPayoutModel.php b/app/Models/RefundPayoutModel.php index 978c065..a08141c 100644 --- a/app/Models/RefundPayoutModel.php +++ b/app/Models/RefundPayoutModel.php @@ -2,8 +2,10 @@ namespace App\Models; +use CodeIgniter\Database\ConnectionInterface; use CodeIgniter\Model; use App\Models\Concerns\SchoolYearAutoFillTrait; +use CodeIgniter\Validation\ValidationInterface; class RefundPayoutModel extends Model { @@ -49,4 +51,37 @@ class RefundPayoutModel extends Model 'operation_type' => 'permit_empty|max_length[50]', 'request_fingerprint_hash' => 'permit_empty|exact_length[64]', ]; + + private array $refundPayoutColumns = []; + + public function __construct(?ConnectionInterface $db = null, ?ValidationInterface $validation = null) + { + parent::__construct($db, $validation); + + $this->refundPayoutColumns = $this->getTableColumns($this->table); + $this->allowedFields = array_values(array_filter( + $this->allowedFields, + fn (string $field): bool => in_array($field, $this->refundPayoutColumns, true) + )); + + foreach (array_keys($this->validationRules) as $field) { + if (!in_array($field, $this->allowedFields, true)) { + unset($this->validationRules[$field]); + } + } + } + + private function getTableColumns(string $table): array + { + try { + return $this->db->getFieldNames($table); + } catch (\Throwable $e) { + log_message('error', '[RefundPayoutModel] Could not read table columns for {table}: {error}', [ + 'table' => $table, + 'error' => $e->getMessage(), + ]); + + return []; + } + } } diff --git a/app/Models/StudentClassModel.php b/app/Models/StudentClassModel.php index 4c01289..17e7bbf 100644 --- a/app/Models/StudentClassModel.php +++ b/app/Models/StudentClassModel.php @@ -157,11 +157,22 @@ class StudentClassModel extends Model ); } - return $builder + $rows = $builder ->orderBy('students.lastname', 'ASC') ->orderBy('students.firstname', 'ASC') ->get() ->getResultArray(); + + $unique = []; + foreach ($rows as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + if ($studentId <= 0 || isset($unique[$studentId])) { + continue; + } + $unique[$studentId] = $row; + } + + return array_values($unique); } /** @@ -530,4 +541,4 @@ class StudentClassModel extends Model return $counts; } -} \ No newline at end of file +} diff --git a/app/Models/StudentModel.php b/app/Models/StudentModel.php index 4cabd76..9b2054c 100644 --- a/app/Models/StudentModel.php +++ b/app/Models/StudentModel.php @@ -150,17 +150,14 @@ class StudentModel extends Model */ public function getStudentsWithAssignments() { - // Retrieve school year and semester from the configuration table + // Retrieve school year from configuration and derive the current semester from calendar dates. $configTable = $this->db->table('configuration'); $schoolYear = $configTable->select('config_value') ->where('config_key', 'school_year') ->get() ->getRowArray()['config_value']; - $semester = $configTable->select('config_value') - ->where('config_key', 'semester') - ->get() - ->getRowArray()['config_value']; + $semester = getSemester(); return $this->db->table('students') ->select(' diff --git a/app/Services/Calculators/AttendanceCalculator.php b/app/Services/Calculators/AttendanceCalculator.php index e07b600..9c0f032 100644 --- a/app/Services/Calculators/AttendanceCalculator.php +++ b/app/Services/Calculators/AttendanceCalculator.php @@ -103,7 +103,7 @@ class AttendanceCalculator implements ScoreCalculatorInterface $fallStartCfg = (string)($this->configModel->getConfig('fall_semester_start') ?? ''); $fallEndCfg = (string)($this->configModel->getConfig('fall_end_date') ?? ''); $springStartCfg = (string)($this->configModel->getConfig('spring_semester_start') ?? ''); - $springEndCfg = (string)($this->configModel->getConfig('last_school_day') ?? ''); + $springEndCfg = (string)($this->configModel->getConfig('last_day_of_school') ?? ''); if ($norm === 'fall') { $start = ($fallStartCfg !== '') ? sprintf('%04d-%s', $y1, date('m-d', strtotime($fallStartCfg))) : "{$y1}-09-01"; diff --git a/app/Services/FeeCalculationService.php b/app/Services/FeeCalculationService.php index 935d2fc..0fd04cc 100644 --- a/app/Services/FeeCalculationService.php +++ b/app/Services/FeeCalculationService.php @@ -23,7 +23,7 @@ class FeeCalculationService $schoolYear = $configModel->getConfig('school_year'); $refundDeadline = date('Y-m-d', strtotime($configModel->getConfig('refund_deadline'))); $weekOfStudy = (float) ($configModel->getConfig('weeks_study') ?? 8); - $schoolEndDate = date('Y-m-d', strtotime($configModel->getConfig('last_school_day'))); + $schoolEndDate = date('Y-m-d', strtotime($configModel->getConfig('last_day_of_school'))); $totalPaid = $paymentModel->getTotalPaidByParentId($parentId, $schoolYear); if ($totalPaid <= 0) { @@ -55,7 +55,18 @@ class FeeCalculationService return 0; } - // Combine all students for proper fee tiering + usort($withdrawnStudents, function ($a, $b) { + $leftDate = strtotime((string)($a['withdrawal_date'] ?? '')) ?: PHP_INT_MAX; + $rightDate = strtotime((string)($b['withdrawal_date'] ?? '')) ?: PHP_INT_MAX; + + if ($leftDate !== $rightDate) { + return $leftDate <=> $rightDate; + } + + return $this->compareGrades($a['grade'], $b['grade']); + }); + + // Combine all students for proper fee tiering before withdrawal. $allStudents = array_merge($registeredStudents, $withdrawnStudents); // Sort all students by grade for correct tiering @@ -67,17 +78,16 @@ class FeeCalculationService $firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 380); $secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 280); - // Assign tuition_fee to all students (before filtering refunds) - $studentCount = 0; - foreach ($allStudents as &$student) { - $studentFee = ($studentCount === 0) ? $firstStudentFee : $secondStudentFee; - $studentCount++; - $student['tuition_fee'] = $studentFee; - } - unset($student); + $refundFeeStack = $this->reverseTuitionRefundFeeStack( + count($allStudents), + count($registeredStudents), + $firstStudentFee, + $secondStudentFee + ); // Calculate refund for withdrawn students $refundAmount = 0; + $withdrawnRefundIndex = 0; foreach ($withdrawnStudents as $student) { if (empty($student['withdrawal_date'])) { @@ -96,7 +106,8 @@ class FeeCalculationService $daysRemaining = $withdrawDateObj->diff($schoolEndDateObj)->days; $weeksRemaining = min($weekOfStudy, max(0, ceil($daysRemaining / 7))); - $studentFee = (float) ($student['tuition_fee'] ?? 0); + $studentFee = (float) ($refundFeeStack[$withdrawnRefundIndex] ?? 0); + $withdrawnRefundIndex++; $proportionalRefund = ($studentFee / $weekOfStudy) * $weeksRemaining; $refundAmount += $proportionalRefund; @@ -112,6 +123,34 @@ class FeeCalculationService return $refundAmount; } + /** + * Refunds reverse the family tuition stack. + * + * Example: three students are charged [first, additional, additional]. + * If one student withdraws, refund the last/additional fee first, not the + * withdrawn student's sorted family position. + * + * @return array + */ + private function reverseTuitionRefundFeeStack( + int $originalStudentCount, + int $remainingStudentCount, + float $firstStudentFee, + float $additionalStudentFee + ): array { + $withdrawnCount = max(0, $originalStudentCount - $remainingStudentCount); + if ($withdrawnCount === 0) { + return []; + } + + $fees = []; + for ($position = $originalStudentCount; $position > $remainingStudentCount; $position--) { + $fees[] = $position === 1 ? $firstStudentFee : $additionalStudentFee; + } + + return $fees; + } + private function compareGrades($gradeA, $gradeB) { diff --git a/app/Services/FinancialAidService.php b/app/Services/FinancialAidService.php index b7ba2c2..b3ce448 100644 --- a/app/Services/FinancialAidService.php +++ b/app/Services/FinancialAidService.php @@ -147,7 +147,6 @@ final class FinancialAidService $classSectionModel = $this->classSectionModel ?? new ClassSectionModel(); $eventChargesModel = $this->eventChargesModel ?? new EventChargesModel(); $configurationModel = $this->configurationModel ?? new ConfigurationModel(); - $userModel = $this->userModel ?? new UserModel(); $invoiceIssuanceService = $this->invoiceIssuanceService ?? new InvoiceIssuanceService( $this->requestModel->db, $this->invoiceModel, @@ -155,7 +154,7 @@ final class FinancialAidService $this->invoiceLedgerService ); - $semester = (string) ($configurationModel->getConfig('semester') ?: ''); + $semester = (string) (getSemester() ?: ''); $enrollments = $enrollmentModel ->where('parent_id', $parentId) ->where('school_year', $schoolYear) @@ -199,16 +198,12 @@ final class FinancialAidService throw new RuntimeException('Invoice could not be created because this parent has no billable tuition or event charges.'); } - $schoolId = $userModel->getSchoolIdByUserId($parentId); - $invoiceNumber = !empty($schoolId) - ? 'INV-' . $schoolId . '-' . uniqid() - : uniqid('INV-'); $issueUtc = (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s'); $dueUtc = $this->invoiceDueUtc($configurationModel); $result = $invoiceIssuanceService->issueInvoice(new IssueInvoiceCommand([ 'parent_id' => $parentId, - 'invoice_number' => $invoiceNumber, + 'invoice_number' => $invoiceIssuanceService->generateInvoiceNumber($schoolYear, $parentId), 'total_amount' => $totalAmount, 'paid_amount' => 0, 'balance' => $totalAmount, diff --git a/app/Services/SchoolYearManagementService.php b/app/Services/SchoolYearManagementService.php index 6c4305d..0f2b233 100644 --- a/app/Services/SchoolYearManagementService.php +++ b/app/Services/SchoolYearManagementService.php @@ -379,27 +379,20 @@ final class SchoolYearManagementService 'school_year' => $name, 'date_age_reference' => $ageReferenceDate, 'refund_deadline' => $ageReferenceDate, - 'year_start_date' => $yearStart, - 'year_end_date' => $yearEnd, 'school_year_start_date' => $yearStart, 'school_year_end_date' => $yearEnd, - 'registration_day' => $registrationDay, 'registration_starts_on' => $registrationDay, - 'end_of_registration' => $enrollmentDeadline, 'enrollment_deadline' => $enrollmentDeadline, - '1st_day_of_school' => $firstDay, 'first_day_of_school' => $firstDay, 'Installment_date' => $installment, 'installment_date' => $installment, - 'fall_semester_start' => $firstDay, + 'fall_semester_start' => $yearStart, 'school_start_date' => $firstDay, 'Due_date' => $firstDay, 'due_date' => $firstDay, 'last_day_of_school' => $lastDay, - 'last_school_day' => $lastDay, 'Final_Exam_day' => $finalExam, 'final_exam_day' => $finalExam, - 'Make_up_exam' => $makeupExam, 'make_up_exam' => $makeupExam, 'makeup_exam_day' => $makeupExam, 'Orientation_day' => $orientation, @@ -448,10 +441,8 @@ final class SchoolYearManagementService 'fall_makeup_exam_on' => $firstDay->modify('-1 week')->format('Y-m-d'), 'orientation_day' => $firstDay->modify('-2 weeks')->format('Y-m-d'), 'first_day_of_school' => $firstDay->format('Y-m-d'), - '1st_day_of_school' => $firstDay->format('Y-m-d'), 'installment_date' => sprintf('%04d-03-01', $endYear), 'last_day_of_school' => $finalExam->modify('+2 weeks')->format('Y-m-d'), - 'last_school_day' => $finalExam->modify('+2 weeks')->format('Y-m-d'), 'final_exam_day' => $finalExam->format('Y-m-d'), 'midterm_exam_day' => $midterm->format('Y-m-d'), 'spring_semester_start' => $midterm->modify('+1 week')->format('Y-m-d'), diff --git a/app/Services/SemesterRangeService.php b/app/Services/SemesterRangeService.php index 920edd0..2f1dafb 100644 --- a/app/Services/SemesterRangeService.php +++ b/app/Services/SemesterRangeService.php @@ -17,7 +17,7 @@ class SemesterRangeService public function getSchoolYearRange(string $schoolYear): array { $startCfg = (string)($this->configModel->getConfig('fall_semester_start') ?? ''); - $endCfg = (string)($this->configModel->getConfig('last_school_day') ?? ''); + $endCfg = (string)($this->configModel->getConfig('last_day_of_school') ?? ''); $start = null; $end = null; @@ -76,7 +76,7 @@ class SemesterRangeService $fallStartCfg = (string)($this->configModel->getConfig('fall_semester_start') ?? ''); $fallEndCfg = (string)($this->configModel->getConfig('fall_end_date') ?? ''); $springStartCfg = (string)($this->configModel->getConfig('spring_semester_start') ?? ''); - $springEndCfg = (string)($this->configModel->getConfig('last_school_day') ?? ''); + $springEndCfg = (string)($this->configModel->getConfig('last_day_of_school') ?? ''); $md = static fn(string $cfg, int $year, string $fallback): string => $cfg !== '' ? sprintf('%04d-%s', $year, date('m-d', strtotime($cfg))) : $fallback; @@ -112,8 +112,7 @@ class SemesterRangeService { $fallStartCfg = (string)($this->configModel->getConfig('fall_semester_start') ?? ''); $springStartCfg = (string)($this->configModel->getConfig('spring_semester_start') ?? ''); - $lastDayCfg = (string)($this->configModel->getConfig('last_school_day') - ?? $this->configModel->getConfig('last_day_of_school') ?? ''); + $lastDayCfg = (string)($this->configModel->getConfig('last_day_of_school') ?? ''); try { $target = new DateTimeImmutable($date ?: 'now'); diff --git a/app/Services/SemesterScoreService.php b/app/Services/SemesterScoreService.php index 69e1b84..ed28fb6 100644 --- a/app/Services/SemesterScoreService.php +++ b/app/Services/SemesterScoreService.php @@ -32,7 +32,9 @@ class SemesterScoreService $this->semesterScoreModel = $semesterScoreModel; $this->configModel = $configModel; - $this->semester = (string) $this->configModel->getConfig('semester'); + require_once APPPATH . 'Helpers/global_config_helper.php'; + + $this->semester = (string) \getSemester(); $this->schoolYear = (string) $this->configModel->getConfig('school_year'); // Default actor from session (can be overridden later) diff --git a/app/Views/administrator/financial_aid_queue.php b/app/Views/administrator/financial_aid_queue.php index c6fd7c0..89b4070 100644 --- a/app/Views/administrator/financial_aid_queue.php +++ b/app/Views/administrator/financial_aid_queue.php @@ -27,6 +27,7 @@ Year Status Requested + Approved Submitted @@ -40,12 +41,13 @@ + Review - No financial aid requests found. + No financial aid requests found. diff --git a/app/Views/discounts/apply_voucher.php b/app/Views/discounts/apply_voucher.php index 6d702b1..d726bf6 100644 --- a/app/Views/discounts/apply_voucher.php +++ b/app/Views/discounts/apply_voucher.php @@ -5,7 +5,7 @@

Apply Discount Voucher

include('partials/academic_filter') ?> -
+
diff --git a/app/Views/invoice_payment/invoice_management.php b/app/Views/invoice_payment/invoice_management.php index 27b5d6d..da78ff9 100644 --- a/app/Views/invoice_payment/invoice_management.php +++ b/app/Views/invoice_payment/invoice_management.php @@ -104,6 +104,18 @@ if (!list || !list.length) return 'No students enrolled.'; return list.map(k => `${esc(k.name)} (Grade: ${esc(k.grade)})`).join('
'); } + function renderRefundCell(r) { + const details = Array.isArray(r.refund_details) ? r.refund_details : []; + const lines = details.map(d => { + const ts = d.date ? Date.parse(d.date) : NaN; + const date = Number.isNaN(ts) ? '-' : formatDateTime(ts); + const method = d.method ? esc(d.method) : '-'; + const check = d.check_number ? `, Check # ${esc(d.check_number)}` : ''; + return `
${date} · ${method}${check}
`; + }).join(''); + + return `
${fmtMoney(r.refund_amount)}
${lines}`; + } function renderParentCell(r) { const pid = parseInt(r.parent_id || 0, 10); const name = esc(r.parent_name || ''); @@ -113,6 +125,21 @@ } return name; } + function renderInvoiceRow(r) { + const ts = Date.parse(r.invoice_date || new Date().toISOString()); + const genBtn = ``; + const pdf = r.invoice_id ? `/${esc(r.invoice_id)}\" target=\"_blank\" class=\"btn btn-info btn-sm external-link\">View/Print PDF` : 'No invoice yet'; + + return [ + renderParentCell(r), + renderStudents(r.enrolledKids || []), + genBtn, + fmtMoney(r.invoice_amount), + renderRefundCell(r), + `${formatDateTime(ts)}`, + pdf, + ]; + } async function generateInvoice(parentId) { const body = new URLSearchParams(); @@ -160,20 +187,7 @@ const resp = await loadInvoices(selectedSchoolYear()); syncSchoolYearSelect(resp); - const data = (resp.invoices || []).map(r => { - const ts = Date.parse(r.invoice_date || new Date().toISOString()); - const genBtn = ``; - const pdf = r.invoice_id ? `/${esc(r.invoice_id)}\" target=\"_blank\" class=\"btn btn-info btn-sm external-link\">View/Print PDF` : 'No invoice yet'; - return [ - renderParentCell(r), - renderStudents(r.enrolledKids || []), - genBtn, - fmtMoney(r.invoice_amount), - fmtMoney(r.refund_amount), - `${formatDateTime(ts)}`, - pdf, - ]; - }); + const data = (resp.invoices || []).map(renderInvoiceRow); if ($.fn.DataTable.isDataTable($tbl)) { const dti = $tbl.DataTable(); dti.clear(); @@ -190,20 +204,7 @@ const resp = await loadInvoices(); syncSchoolYearSelect(resp); - const data = (resp.invoices || []).map(r => { - const ts = Date.parse(r.invoice_date || new Date().toISOString()); - const genBtn = ``; - const pdf = r.invoice_id ? `/${esc(r.invoice_id)}\" target=\"_blank\" class=\"btn btn-info btn-sm external-link\">View/Print PDF` : 'No invoice yet'; - return [ - renderParentCell(r), - renderStudents(r.enrolledKids || []), - genBtn, - fmtMoney(r.invoice_amount), - fmtMoney(r.refund_amount), - `${formatDateTime(ts)}`, - pdf, - ]; - }); + const data = (resp.invoices || []).map(renderInvoiceRow); dt = $tbl.DataTable({ data, @@ -232,20 +233,7 @@ await generateInvoice(btn.getAttribute('data-parent-id')); // Refresh table minimally: reload data const resp = await loadInvoices(selectedSchoolYear()); - const data = (resp.invoices || []).map(r => { - const ts = Date.parse(r.invoice_date || new Date().toISOString()); - const genBtn = ``; - const pdf = r.invoice_id ? `/${esc(r.invoice_id)}\" target=\"_blank\" class=\"btn btn-info btn-sm external-link\">View/Print PDF` : 'No invoice yet'; - return [ - renderParentCell(r), - renderStudents(r.enrolledKids || []), - genBtn, - fmtMoney(r.invoice_amount), - fmtMoney(r.refund_amount), - `${formatDateTime(ts)}`, - pdf, - ]; - }); + const data = (resp.invoices || []).map(renderInvoiceRow); if ($.fn.DataTable.isDataTable($tbl)) { const dti = $tbl.DataTable(); dti.clear(); @@ -265,20 +253,7 @@ this.disabled = true; await generateInvoice(this.getAttribute('data-parent-id')); const resp = await loadInvoices(selectedSchoolYear()); - const data = (resp.invoices || []).map(r => { - const ts = Date.parse(r.invoice_date || new Date().toISOString()); - const genBtn = ``; - const pdf = r.invoice_id ? `/${esc(r.invoice_id)}\" target=\"_blank\" class=\"btn btn-info btn-sm external-link\">View/Print PDF` : 'No invoice yet'; - return [ - esc(r.parent_name || ''), - renderStudents(r.enrolledKids || []), - genBtn, - fmtMoney(r.invoice_amount), - fmtMoney(r.refund_amount), - `${formatDateTime(ts)}`, - pdf, - ]; - }); + const data = (resp.invoices || []).map(renderInvoiceRow); if ($.fn.DataTable.isDataTable($tbl)) { const dti = $tbl.DataTable(); dti.clear(); @@ -307,20 +282,7 @@ $year.addEventListener('change', async () => { try { const resp = await loadInvoices($year.value); - const data = (resp.invoices || []).map(r => { - const ts = Date.parse(r.invoice_date || new Date().toISOString()); - const genBtn = ``; - const pdf = r.invoice_id ? `/${esc(r.invoice_id)}\" target=\"_blank\" class=\"btn btn-info btn-sm external-link\">View/Print PDF` : 'No invoice yet'; - return [ - esc(r.parent_name || ''), - renderStudents(r.enrolledKids || []), - genBtn, - fmtMoney(r.invoice_amount), - fmtMoney(r.refund_amount), - `${formatDateTime(ts)}`, - pdf, - ]; - }); + const data = (resp.invoices || []).map(renderInvoiceRow); if ($.fn.DataTable.isDataTable($tbl)) { const dti = $tbl.DataTable(); dti.clear(); diff --git a/app/Views/layout/main_layout.php b/app/Views/layout/main_layout.php index 6013a0f..f21c22b 100644 --- a/app/Views/layout/main_layout.php +++ b/app/Views/layout/main_layout.php @@ -233,7 +233,7 @@ html, body { overflow-x: hidden; } } catch (\Throwable $e) { $sy = (string)($cfg->getConfig('school_year') ?? ''); } - $sem = (string)($cfg->getConfig('semester') ?? ''); + $sem = (string)(getSemester() ?? ''); $uid = (int)(session()->get('user_id') ?? 0); if ($uid) { $classOptions = $tcModel->getClassAssignmentsByUserId($uid, $sy, $sem); diff --git a/app/Views/parent/enroll_classes.php b/app/Views/parent/enroll_classes.php index 71cdb85..c7f5086 100644 --- a/app/Views/parent/enroll_classes.php +++ b/app/Views/parent/enroll_classes.php @@ -73,6 +73,21 @@ width: 100%; } + .enrollment-withdraw-control { + align-items: center; + display: inline-flex; + gap: .5rem; + justify-content: flex-end; + min-width: max-content; + padding-left: 0; + white-space: nowrap; + } + + .enrollment-withdraw-control .form-check-input { + flex: 0 0 auto; + margin-left: 0; + } + .enrollment-modal-footer { gap: .5rem; } @@ -150,6 +165,18 @@ .enrollment-status-table td > * { max-width: 58%; } + + .enrollment-status-table td[data-label="Withdraw"] { + align-items: center; + } + + .enrollment-status-table td[data-label="Withdraw"] > * { + max-width: none; + } + + .enrollment-status-table td[data-label="Withdraw"] .enrollment-withdraw-control { + margin-left: auto; + } } endSection() ?> @@ -217,24 +244,6 @@ foreach (($students ?? []) as $student) {
- -
-
Family Account Information
-
-
Previous-year carry-over balance:
-
Registration fee:
-
Tuition due now:
-
Mandatory fees:
-
Current-year account balance:
-
Total currently due:
-
- -
- - -
- - @@ -297,7 +306,7 @@ foreach (($students ?? []) as $student) { -
+
>
diff --git a/app/Views/parent/register_student.php b/app/Views/parent/register_student.php index 3c0d35f..de59e17 100644 --- a/app/Views/parent/register_student.php +++ b/app/Views/parent/register_student.php @@ -6,6 +6,68 @@ $disableEmergencyBtn = count($emergencies) >= $maxEmergency; ?> extend('layout/main_layout') ?> +section('styles') ?> + +endSection() ?> section('content') ?>

Student Registration

@@ -67,7 +129,7 @@ $disableEmergencyBtn = count($emergencies) >= $maxEmergency;
Student Information
- +
@@ -83,12 +145,12 @@ $disableEmergencyBtn = count($emergencies) >= $maxEmergency; - - - - - - + + + + + - -
School ID
format('m-d-Y')) ?> + format('m-d-Y')) ?> = $maxEmergency; echo implode('
', array_map('esc', $mc)); ?>
+ = $maxEmergency; echo implode('
', array_map('esc', $al)); ?>
+ = $maxEmergency;
Emergency Contacts
- +
@@ -159,10 +221,10 @@ $disableEmergencyBtn = count($emergencies) >= $maxEmergency; $lastName = $nameParts[1] ?? ''; ?> - - - - + + + + diff --git a/app/Views/parent/report_cards.php b/app/Views/parent/report_cards.php index bd78317..6f7bb7b 100644 --- a/app/Views/parent/report_cards.php +++ b/app/Views/parent/report_cards.php @@ -1,4 +1,81 @@ extend('layout/main_layout') ?> +section('styles') ?> + +endSection() ?> section('content') ?> No students available for report cards.
-
First Name
+
@@ -51,10 +128,10 @@ $hasReport = !empty(($reportAvailableMap ?? [])[$sid]); ?> - - - - + + + -
Student
+
@@ -62,7 +139,7 @@ Not signed
+ View Report diff --git a/app/Views/partials/academic_filter.php b/app/Views/partials/academic_filter.php index 940a157..76dcb1a 100644 --- a/app/Views/partials/academic_filter.php +++ b/app/Views/partials/academic_filter.php @@ -7,7 +7,7 @@ // - Reset link uses '?' to clear query string reliably. // Ensure helper functions are available -if (function_exists('helper')) { @helper('GlobalConfigHelper'); } +if (function_exists('helper')) { @helper('global_config'); } // Resolve defaults $currentYear = function_exists('getSchoolYear') ? (string) (getSchoolYear() ?? '') : ''; @@ -16,7 +16,7 @@ if ($currentYear === '' || $currentSem === '') { try { $cfg = new \App\Models\ConfigurationModel(); if ($currentYear === '') $currentYear = (string) ($cfg->getConfig('school_year') ?? ''); - if ($currentSem === '') $currentSem = (string) ($cfg->getConfig('semester') ?? ''); + if ($currentSem === '') $currentSem = (string) (getSemester() ?? ''); } catch (\Throwable $e) { /* ignore */ } } diff --git a/app/Views/partials/navbar.php b/app/Views/partials/navbar.php index 67e8e34..0f9e7d0 100644 --- a/app/Views/partials/navbar.php +++ b/app/Views/partials/navbar.php @@ -277,7 +277,7 @@ switch ($role) { } catch (\Throwable $e) { $year = $sess->get('school_year') ?? $configModel->getConfig('school_year'); } - $semester = $sess->get('semester') ?? $configModel->getConfig('semester'); + $semester = $sess->get('semester') ?? getSemester(); $activeEventCount = count($eventModel->getActiveEvents($year, $semester) ?? []); } ?> diff --git a/app/Views/payment/manual_pay.php b/app/Views/payment/manual_pay.php index 4ac86ac..b33e0ef 100644 --- a/app/Views/payment/manual_pay.php +++ b/app/Views/payment/manual_pay.php @@ -175,6 +175,7 @@ aria-autocomplete="list" aria-haspopup="listbox" value=""> +
@@ -1001,9 +1002,11 @@ function initSearchSuggest() { const input = _el('manualPaySearchInput'); const suggest = _el('manualPaySuggest'); + const clear = _el('manualPayClearSearch'); if (!input || !suggest) return; const suggestUrl = ; + const manualPayUrl = ; let lastRequest = 0; function hideSuggest() { @@ -1089,6 +1092,14 @@ if (e.key === 'Escape') hideSuggest(); }); + if (clear) { + clear.addEventListener('click', () => { + input.value = ''; + hideSuggest(); + window.location.href = manualPayUrl; + }); + } + document.addEventListener('click', (e) => { if (e.target === input || suggest.contains(e.target)) return; hideSuggest(); diff --git a/app/Views/print_requests/teacher_index.php b/app/Views/print_requests/teacher_index.php index d40e841..944326c 100644 --- a/app/Views/print_requests/teacher_index.php +++ b/app/Views/print_requests/teacher_index.php @@ -1,11 +1,27 @@ extend('layout/main_layout') ?> section('content') ?> - +getFlashdata('success'); +$errorMessage = session()->getFlashdata('error'); +?>

Print/Copy Requests

+ + + + + +
This school year is read-only. Existing requests are visible, but changes are disabled. @@ -46,6 +62,7 @@
+ has('errors')): ?>
    @@ -128,6 +145,7 @@
+
@@ -353,8 +371,14 @@ document.addEventListener('DOMContentLoaded', function() { const dateSelect = document.getElementById('required_by_date'); const timeSelect = document.getElementById('required_by_time'); const hiddenInput = document.getElementById('required_by'); + const submitButton = createForm.querySelector('button[type="submit"]'); if (dateSelect.value && timeSelect.value) { hiddenInput.value = dateSelect.value + 'T' + timeSelect.value; + if (submitButton) { + submitButton.disabled = true; + submitButton.dataset.originalText = submitButton.textContent; + submitButton.textContent = 'Submitting...'; + } } else { if (!dateSelect.value) { alert('Please select a date.'); @@ -483,6 +507,7 @@ if (copyForm) { const dateSelect = document.getElementById('copy_required_by_date'); const timeSelect = document.getElementById('copy_required_by_time'); const hiddenInput = document.getElementById('copy_required_by'); + const submitButton = copyForm.querySelector('button[type="submit"]'); if (!dateSelect || !dateSelect.value) { alert('Please select a date for the copy request.'); e.preventDefault(); @@ -496,6 +521,11 @@ if (copyForm) { if (hiddenInput) { hiddenInput.value = dateSelect.value + 'T' + timeSelect.value; } + if (submitButton) { + submitButton.disabled = true; + submitButton.dataset.originalText = submitButton.textContent; + submitButton.textContent = 'Submitting...'; + } }); } diff --git a/app/Views/printables_reports/badge_form.php b/app/Views/printables_reports/badge_form.php index 50b7ae0..884669a 100644 --- a/app/Views/printables_reports/badge_form.php +++ b/app/Views/printables_reports/badge_form.php @@ -1,384 +1,3 @@ - -Yesterday 11:12 PM -write sql querry to update school_year = 2025-2026 - -UPDATE your_table_name -SET school_year = '2025-2026' -WHERE your_condition; -To update every row: - -UPDATE your_table_name -SET school_year = '2025-2026'; -Use the second version only if you genuinely intend to rewrite the entire table, because databases are remarkably obedient when humans forget WHERE. - - -Today 12:49 AM -Screenshot 2026-07-14 at 12.49.07 AM.png - -Pasted code(6).php -File -remove the image content from the view - -Removed the School year / Semester filter from the view by deleting: - -include('partials/academic_filter') ?> -Pasted_code_6_without_academic_filter.php - - -Library -/ -Pasted_code_6_without_academic_filter.php - - -extend('layout/management_layout') ?> -section('content') ?> - -
-
-
- -
-
-

Generate Staff Badges

-
- -
- - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
#FirstnameLastnameRoleTeacher Class SectionBadge Prints -
- - -
-
- -
- - -
- - N/A - -
No staff found for the selected year.
- - -
-
-
-
-endSection() ?> - -section('scripts') ?> - -endSection() ?> -Library -/ -Pasted_code_6_without_academic_filter.php - - extend('layout/management_layout') ?> section('content') ?> -
-
- - -
-
- - - -
-
-
- - - - - - + - - - - - - - - - + + 'primary', - 'overpayment' => 'success', - 'extra' => 'info', - 'duplicate' => 'warning', - ][$req] ?? 'secondary'; - // Date formatting (keep raw if null) $fmt = function($dt) { if (empty($dt) || $dt === '0000-00-00 00:00:00') return '-'; // show date only; assumes DB is UTC return local_date($dt, 'm-d-Y'); }; + $statusRaw = (string)($r['status'] ?? ''); + $statusKey = strtolower(str_replace(' ', '_', trim($statusRaw))); + if ($statusKey === 'requested') { + $statusKey = 'pending'; + } elseif ($statusKey === 'partially_paid') { + $statusKey = 'partial'; + } + $statusLabel = [ + 'pending' => 'Pending', + 'approved' => 'Approved', + 'rejected' => 'Rejected', + 'partial' => 'Partial', + 'paid' => 'Paid', + ][$statusKey] ?? ($statusRaw !== '' ? $statusRaw : 'Pending'); + $statusClass = [ + 'pending' => 'warning text-dark', + 'approved' => 'primary', + 'rejected' => 'danger', + 'partial' => 'info text-dark', + 'paid' => 'success', + ][$statusKey] ?? 'secondary'; + $refundAmount = (float)($r['refund_amount'] ?? 0); + $paidAmount = (float)($r['refund_paid_amount'] ?? 0); + $remainingAmount = max(0, $refundAmount - $paidAmount); + $hasSource = !empty($r['source_type']) && !empty($r['source_id']); + $canApprove = $statusKey === 'pending' && $refundAmount > 0 && $hasSource; + $canReject = $statusKey === 'pending'; + $canRecord = in_array($statusKey, ['approved', 'partial'], true) && $remainingAmount > 0; ?> - + + - - - - - - - - - - - - - - @@ -231,7 +223,7 @@ $(function () { // DataTable $('#refundsTable').DataTable({ pageLength: 25, - order: [[7, 'desc']], // order by Requested desc + order: [[2, 'desc']], // order by Requested desc scrollX: true, autoWidth: false, }); @@ -277,21 +269,27 @@ $(function () { }); // ---- Actions ---- +function normalizeRefundStatus(status) { + return String(status || '').trim().toLowerCase().replace(/\s+/g, '_'); +} + function handleRecordRefundClick(refundId, status, amount) { - if (!['Approved','Partial'].includes(status)) { - alert('⚠ Refund must be Approved (or Partial) before recording a payout.'); + const normalized = normalizeRefundStatus(status); + if (!['approved','partial','partially_paid'].includes(normalized)) { + alert('Refund must be approved or partial before recording a payout.'); return; } if (!amount || parseFloat(amount) <= 0) { - alert('⚠ Refund amount not set.'); + alert('Refund amount is not set.'); return; } - showPaymentModal(refundId); + showPaymentModal(refundId, amount); } function handleStatusClick(refundId, status, amount) { - if (!amount || parseFloat(amount) <= 0) { - alert('⚠ Refund amount not set.'); + const normalized = normalizeRefundStatus(status); + if (normalized !== 'rejected' && (!amount || parseFloat(amount) <= 0)) { + alert('Refund amount is not set.'); return; } showStatusModal(refundId, status); @@ -308,7 +306,7 @@ function showStatusModal(refundId, status) { $('#statusModal').modal('show'); } -function showPaymentModal(refundId) { +function showPaymentModal(refundId, amount) { $('#statusForm').addClass('d-none'); $('#paymentForm').removeClass('d-none'); @@ -318,7 +316,8 @@ function showPaymentModal(refundId) { ? crypto.randomUUID() : ('refund-' + refundId + '-' + Date.now() + '-' + Math.random().toString(16).slice(2)) ); - $('#paidAmount').val(''); + $('#paidAmount').val(Number(amount || 0).toFixed(2)); + $('#paidAmount').attr('max', Number(amount || 0).toFixed(2)); $('#paymentMethod').val('').trigger('change'); $('#checkDetails').addClass('d-none'); $('#checkNumber').val(''); diff --git a/app/Views/teacher/teacher_navbar.php b/app/Views/teacher/teacher_navbar.php index 738b85c..9afeef3 100644 --- a/app/Views/teacher/teacher_navbar.php +++ b/app/Views/teacher/teacher_navbar.php @@ -71,7 +71,7 @@ } catch (\Throwable $e) { $sy = $configModel->getConfig('school_year'); } - $sem = $configModel->getConfig('semester'); + $sem = getSemester(); $classOptions = $teacherClassModel->getClassAssignmentsByUserId((int)$userId, (string)$sy, (string)$sem); } ?> diff --git a/cronJobs.txt b/cronJobs.txt index 998003f..0aec2cd 100644 --- a/cronJobs.txt +++ b/cronJobs.txt @@ -16,19 +16,9 @@ CI_ENVIRONMENT=production # Daily @ 8:00 AM - send registration opening email only when today matches school_years.registration_starts_on 0 8 * * * cd /opt/lampp/htdocs/alrahma_school_sunday && /usr/bin/php spark registration:send-opening-email --tz=America/New_York >> /var/log/ci4_registration_opening_email.log 2>&1 -# America/New_York -# 1st February @ 00:05 — Spring -5 0 1 2 * cd /opt/lampp/htdocs/alrahma_school_sunday && /usr/bin/php spark config:update set_semester_spring --tz=America/New_York >> /var/log/ci4_config_update.log 2>&1 - -# 1st June @ 00:05 — Fall -5 0 1 6 * cd /opt/lampp/htdocs/alrahma_school_sunday && /usr/bin/php spark config:update set_semester_fall --tz=America/New_York >> /var/log/ci4_config_update.log 2>&1 - - */15 * * * * /usr/bin/php /home/u280815660/domains/alrahmaisgl.org/alrahma/spark users:delete-inactive-users */15 * * * * /usr/bin/php /home/u280815660/domains/test.alrahmaisgl.org/alrahma/spark users:delete-inactive-users 0 2 * * * /usr/bin/php /home/u280815660/domains/alrahmaisgl.org/alrahma/spark payments:sync-paypal >> /home/u280815660/domains/alrahmaisgl.org/alrahma/writable/logs/paypal_cron.log 2>&1 50 9 * * 7 /usr/bin/php /home/u280815660/domains/alrahmaisgl.org/alrahma/spark config:update -t enable_attendance_on --tz=America/New_York >> /var/log/ci4_config_update.log 2>&1 0 13 * * 7 /usr/bin/php /home/u280815660/domains/alrahmaisgl.org/alrahma/spark config:update -t enable_attendance_off --tz=America/New_York >> /var/log/ci4_config_update.log 2>&1 0 0 1 7 * /usr/bin/php /home/u280815660/domains/alrahmaisgl.org/alrahma/spark config:update -t update_date_age_reference --tz=America/New_York >> /var/log/ci4_config_update.log 2>&1 -0 0 1 2 * /usr/bin/php /home/u280815660/domains/alrahmaisgl.org/alrahma/spark config:update set_semester_spring --tz=America/New_York >> /var/log/ci4_config_update.log 2>&1 -0 0 1 7 * /usr/bin/php /home/u280815660/domains/alrahmaisgl.org/alrahma/spark config:update set_semester_fall --tz=America/New_York >> /var/log/ci4_config_update.log 2>&1 diff --git a/tests/app/Controllers/Administrator/FinancialAidControllerTest.php b/tests/app/Controllers/Administrator/FinancialAidControllerTest.php new file mode 100644 index 0000000..e92f088 --- /dev/null +++ b/tests/app/Controllers/Administrator/FinancialAidControllerTest.php @@ -0,0 +1,66 @@ +post; + } + + return $this->post[$key] ?? null; + } +} + +class TestableFinancialAidController extends FinancialAidController +{ + public function __construct() + { + } + + public function setRequestObject($request): self + { + $this->request = $request; + return $this; + } +} + +class FinancialAidControllerTest extends CIUnitTestCase +{ + public function testPostedAdminAmountWinsOverRequestedAmount(): void + { + $controller = (new TestableFinancialAidController()) + ->setRequestObject(new FinancialAidRequestStub(['admin_amount' => '175.50'])); + + $this->assertSame(175.50, $this->approvalAmount($controller, [ + 'requested_amount' => '250.00', + ])); + } + + public function testBlankAdminAmountFallsBackToRequestedAmount(): void + { + $controller = (new TestableFinancialAidController()) + ->setRequestObject(new FinancialAidRequestStub(['admin_amount' => ''])); + + $this->assertSame(250.00, $this->approvalAmount($controller, [ + 'requested_amount' => '250.00', + ])); + } + + private function approvalAmount(FinancialAidController $controller, array $request): float + { + $reflection = new \ReflectionMethod($controller, 'approvalAmount'); + $reflection->setAccessible(true); + + return $reflection->invoke($controller, $request); + } +} diff --git a/tests/app/Controllers/Api/AttendanceControllerTest.php b/tests/app/Controllers/Api/AttendanceControllerTest.php index bbb06cd..2f48f09 100644 --- a/tests/app/Controllers/Api/AttendanceControllerTest.php +++ b/tests/app/Controllers/Api/AttendanceControllerTest.php @@ -241,7 +241,7 @@ class TestableAttendanceController extends AttendanceController $this->studentClassModel = $studentClassModel; $this->configModel = $configModel; $this->schoolYear = (string) $configModel->getConfig('school_year'); - $this->semester = (string) $configModel->getConfig('semester'); + $this->semester = (string) getSemester(); } public function setDatabaseConnection(StubDbConnection $db): void diff --git a/tests/app/Controllers/View/ExpenseControllerTest.php b/tests/app/Controllers/View/ExpenseControllerTest.php new file mode 100644 index 0000000..a516a95 --- /dev/null +++ b/tests/app/Controllers/View/ExpenseControllerTest.php @@ -0,0 +1,153 @@ + $name, 'data' => $data, 'options' => $options]; + } + } +} + +namespace Tests\App\Controllers\View { + + use App\Controllers\View\ExpenseController; + use CodeIgniter\Test\CIUnitTestCase; + use Config\Services; + + class TestableExpenseController extends ExpenseController + { + public function __construct() + { + // Skip the real constructor so tests can inject fakes. + } + + public function setExpenseModel(object $model): self + { + $this->expenseModel = $model; + return $this; + } + + public function setConfigModel(object $model): self + { + $this->configModel = $model; + return $this; + } + } + + class ExpenseIndexFakeModel + { + public array $whereCalls = []; + + public function select(string $select): self + { + return $this; + } + + public function join(string $table, string $condition, string $type = ''): self + { + return $this; + } + + public function where(string $field, mixed $value): self + { + $this->whereCalls[] = [$field, $value]; + return $this; + } + + public function orderBy(string $field, string $direction = ''): self + { + return $this; + } + + public function findAll(): array + { + return [ + [ + 'id' => 10, + 'category' => 'Expense', + 'amount' => '12.50', + 'receipt_path' => 'receipt.pdf', + 'school_year' => '2025-2026', + ], + ]; + } + } + + class ExpenseIndexFakeConfig + { + public function getConfig(string $key): ?string + { + return $key === 'school_year' ? '2025-2026' : null; + } + } + + class ExpenseFakeRenderer + { + private array $data = []; + private array $lastRender = []; + + public function setData(?array $data = null): self + { + $this->data = $data ?? []; + return $this; + } + + public function render(string $view, array $data = [], $options = null) + { + $this->lastRender = [ + 'view' => $view, + 'data' => $data ?: $this->data, + 'options' => $options, + ]; + + return 'fake-rendered:' . $view; + } + + public function getLastRender(): array + { + return $this->lastRender; + } + } + + class ExpenseControllerTest extends CIUnitTestCase + { + protected function tearDown(): void + { + Services::resetSingle('renderer'); + parent::tearDown(); + } + + public function testIndexScopesExpensesToActiveConfiguredSchoolYear(): void + { + $expenseModel = new ExpenseIndexFakeModel(); + $renderer = new ExpenseFakeRenderer(); + Services::injectMock('renderer', $renderer); + + $controller = (new TestableExpenseController()) + ->setExpenseModel($expenseModel) + ->setConfigModel(new ExpenseIndexFakeConfig()); + + $result = $controller->index(); + $this->assertSame('fake-rendered:expenses/index', $result); + $rendered = $renderer->getLastRender(); + + $this->assertSame('expenses/index', $rendered['view']); + $this->assertSame('2025-2026', $rendered['data']['schoolYear']); + $this->assertSame([ + ['expenses.school_year', '2025-2026'], + ], $expenseModel->whereCalls); + $this->assertSame( + site_url('receipts/receipt.pdf'), + $rendered['data']['expenses'][0]['receipt_url'] + ); + } + } +} diff --git a/tests/app/Libraries/InvoiceLedgerServiceTest.php b/tests/app/Libraries/InvoiceLedgerServiceTest.php index 84936db..51b3ad7 100644 --- a/tests/app/Libraries/InvoiceLedgerServiceTest.php +++ b/tests/app/Libraries/InvoiceLedgerServiceTest.php @@ -161,7 +161,7 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase $this->assertSame('0.00', $calculation['balance']); $this->assertSame('15.00', $calculation['customer_credit']); - $this->assertSame(-1500, $calculation['rawBalanceCents']); + $this->assertSame(-3500, $calculation['rawBalanceCents']); } public function testFullyRefundedOverpaymentClearsCustomerCredit(): void @@ -177,7 +177,7 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase $this->assertSame('0.00', $calculation['balance']); $this->assertSame('0.00', $calculation['customer_credit']); - $this->assertSame(0, $calculation['rawBalanceCents']); + $this->assertSame(-5000, $calculation['rawBalanceCents']); } public function testPaymentAfterRefundCanRestorePaidStatus(): void @@ -392,7 +392,7 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase $this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']); } - public function testCashRefundCanRestoreBalanceAfterOverpaymentIsReturned(): void + public function testCashRefundDoesNotCreateBalanceAfterOverpaymentIsReturned(): void { $service = new InvoiceLedgerServiceHarness([ 'id' => 12, @@ -405,8 +405,8 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase $calculation = $service->calculateInvoice(12); $this->assertSame('0.00', $calculation['customer_credit']); - $this->assertSame('5.00', $calculation['balance']); - $this->assertSame(FinancialStatus::INVOICE_PARTIALLY_PAID, $calculation['status']); + $this->assertSame('0.00', $calculation['balance']); + $this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']); } public function testIssuedInvoiceUsesPaidRefundsAsCashOutInsteadOfAdditionalCredit(): void @@ -428,4 +428,25 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase $this->assertSame('0.00', $calculation['balance']); $this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']); } + + public function testRefundedWithdrawalInvoiceHasNoBalanceDue(): void + { + $service = new InvoiceLedgerServiceHarness([ + 'id' => 14, + 'invoice_number' => 'INV-2026-00014', + 'total_amount' => '0.00', + 'semester' => 'Fall', + 'description' => 'Current year tuition invoice.', + ], 178.0, 178.0, 380.0, 0.0, 0.0, 100.0); + + $calculation = $service->calculateInvoice(14); + + $this->assertSame('380.00', $calculation['total_amount']); + $this->assertSame('100.00', $calculation['discount_total']); + $this->assertSame('178.00', $calculation['paid_amount']); + $this->assertSame('178.00', $calculation['refund_paid_total']); + $this->assertSame('0.00', $calculation['customer_credit']); + $this->assertSame('0.00', $calculation['balance']); + $this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']); + } } diff --git a/tests/app/Libraries/RefundEligibilityServiceTest.php b/tests/app/Libraries/RefundEligibilityServiceTest.php index b74f28e..c55008e 100644 --- a/tests/app/Libraries/RefundEligibilityServiceTest.php +++ b/tests/app/Libraries/RefundEligibilityServiceTest.php @@ -60,6 +60,20 @@ class RefundEligibilityServiceTest extends CIUnitTestCase $this->assertContains('HAS_APPROVED_RESERVATIONS', $result->reasonCodes); } + public function testTuitionWithdrawalAvailableCreditSubtractsCompletedPayoutsAndReservations(): void + { + $service = new RefundEligibilityServiceHarness(20000, 2500, 1500); + + $result = $service->calculateAvailableCredit(10, 20, 'tuition_withdrawal', 20); + + $this->assertSame(20000, $result->sourceCreditCents); + $this->assertSame(2500, $result->completedPayoutCents); + $this->assertSame(1500, $result->reservedAmountCents); + $this->assertSame(16000, $result->availableAmountCents); + $this->assertContains('HAS_COMPLETED_PAYOUTS', $result->reasonCodes); + $this->assertContains('HAS_APPROVED_RESERVATIONS', $result->reasonCodes); + } + public function testValidateRejectsAmountAboveAvailableCredit(): void { $service = new RefundEligibilityServiceHarness(10000, 7000, 1000); diff --git a/tests/app/Models/ManualPaymentModelMetadataTest.php b/tests/app/Models/ManualPaymentModelMetadataTest.php new file mode 100644 index 0000000..9884581 --- /dev/null +++ b/tests/app/Models/ManualPaymentModelMetadataTest.php @@ -0,0 +1,25 @@ +assertSame([], $fields); + return; + } + + foreach ($fields as $field) { + $this->assertContains($field, $columns); + } + } +} diff --git a/tests/app/Services/FeeCalculationServiceTest.php b/tests/app/Services/FeeCalculationServiceTest.php new file mode 100644 index 0000000..ef3bc32 --- /dev/null +++ b/tests/app/Services/FeeCalculationServiceTest.php @@ -0,0 +1,51 @@ +refundFeeStack(3, 2, 380.0, 280.0); + + $this->assertSame([280.0], $fees); + } + + public function testRefundKeepsReversingAdditionalFeesBeforeFirstStudentFee(): void + { + $fees = $this->refundFeeStack(3, 0, 380.0, 280.0); + + $this->assertSame([280.0, 280.0, 380.0], $fees); + } + + public function testSingleStudentWithdrawalRefundsFirstStudentFee(): void + { + $fees = $this->refundFeeStack(1, 0, 380.0, 280.0); + + $this->assertSame([380.0], $fees); + } + + /** + * @return array + */ + private function refundFeeStack( + int $originalStudentCount, + int $remainingStudentCount, + float $firstStudentFee, + float $additionalStudentFee + ): array { + $method = new \ReflectionMethod(FeeCalculationService::class, 'reverseTuitionRefundFeeStack'); + $method->setAccessible(true); + + return $method->invoke( + new FeeCalculationService(), + $originalStudentCount, + $remainingStudentCount, + $firstStudentFee, + $additionalStudentFee + ); + } +} diff --git a/tests/app/Services/SchoolYearManagementServiceCalendarTest.php b/tests/app/Services/SchoolYearManagementServiceCalendarTest.php index e56db4d..f4e1a6e 100644 --- a/tests/app/Services/SchoolYearManagementServiceCalendarTest.php +++ b/tests/app/Services/SchoolYearManagementServiceCalendarTest.php @@ -17,12 +17,10 @@ final class SchoolYearManagementServiceCalendarTest extends CIUnitTestCase $this->assertSame('2026-08-01', $calendar['registration_starts_on']); $this->assertSame('2026-10-05', $calendar['registration_ends_on']); $this->assertSame('2026-09-20', $calendar['first_day_of_school']); - $this->assertSame('2026-09-20', $calendar['1st_day_of_school']); $this->assertSame('2026-09-13', $calendar['fall_makeup_exam_on']); $this->assertSame('2026-09-06', $calendar['orientation_day']); $this->assertSame('2027-05-23', $calendar['final_exam_day']); $this->assertSame('2027-06-06', $calendar['last_day_of_school']); - $this->assertSame('2027-06-06', $calendar['last_school_day']); $this->assertSame('2027-01-17', $calendar['midterm_exam_day']); $this->assertSame('2027-01-24', $calendar['spring_semester_start']); $this->assertSame('2027-03-01', $calendar['installment_date']);
School ID ParentRequestTermInvoice IDRefund AmountStatusInvoice # RequestedApprovedApproved ByRefundedMethodCheck #Check FilePaid AmountSource AvailableParent AvailableStatusRefund Details Actions
- - - - - +
$
+
+
+
+ +
+ + + by + +
$ - - View - - - - +
Check #:
+
+ Check File: + + View + + - + +
+
Paid: $
$ - - - - + +
+