Fix semester context, attendance rosters, and billing workflows
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 31s
Tests / PHPUnit (push) Failing after 55s

- load global semester helpers consistently and use date-based semester defaults
- fix grading and daily attendance duplicate student/section rows
- keep attendance violations scoped to the current semester by default
- update invoice, refund, discount, payment, and financial aid flows
- add configuration cleanup migrations for duplicate calendar/semester keys
- refresh parent registration/report-card and print request handling
- update related models, services, views, cron notes, and test coverage
This commit is contained in:
root
2026-08-16 17:41:11 -04:00
parent 36c7e3fc6d
commit 0ac3a8375e
99 changed files with 1598 additions and 814 deletions
+4 -6
View File
@@ -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)
@@ -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');
+1 -1
View File
@@ -98,6 +98,6 @@ class Autoload extends AutoloadConfig
*
* @var list<string>
*/
public $helpers = ['url', 'form', 'pbkdf2', 'document', 'time', 'api'];
public $helpers = ['url', 'form', 'pbkdf2', 'document', 'time', 'api', 'global_config'];
}
+1
View File
@@ -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']);
@@ -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');
+1 -1
View File
@@ -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);
@@ -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);
}
}
+2 -2
View File
@@ -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
];
+2 -2
View File
@@ -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];
}
@@ -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'))
+61 -1
View File
@@ -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');
@@ -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,
@@ -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');
}
+11 -5
View File
@@ -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)
@@ -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 {
+11 -2
View File
@@ -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);
}
}
}
+1 -1
View File
@@ -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');
}
@@ -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()
@@ -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,
+33 -6
View File
@@ -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
*/
+1 -1
View File
@@ -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',
+2 -2
View File
@@ -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
+8 -2
View File
@@ -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()
@@ -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);
+1 -1
View File
@@ -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();
}
+1 -1
View File
@@ -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
+6 -3
View File
@@ -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;
+1 -1
View File
@@ -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');
@@ -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') ?? '');
}
+1 -1
View File
@@ -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();
+146 -29
View File
@@ -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
@@ -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';
@@ -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') ?? ''));
+1 -1
View File
@@ -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();
}
@@ -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);
+34 -9
View File
@@ -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]);
}
}
@@ -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();
}
+43 -20
View File
@@ -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;
}
@@ -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');
+1 -1
View File
@@ -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();
}
+91 -17
View File
@@ -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();
+1 -1
View File
@@ -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');
}
@@ -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');
}
@@ -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()
@@ -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');
}
@@ -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']);
@@ -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');
}
+2 -2
View File
@@ -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";
+1 -1
View File
@@ -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');
@@ -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,
+1 -1
View File
@@ -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');
}
+1 -1
View File
@@ -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']);
}
+1 -1
View File
@@ -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 ?? ''));
}
+1 -1
View File
@@ -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');
}
@@ -0,0 +1,31 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class RemoveDuplicateCalendarConfigurationKeys extends Migration
{
private const DUPLICATE_KEYS = [
'1st_day_of_school',
'Make_up_exam',
'year_start_date',
'registration_day',
];
public function up(): void
{
if (! $this->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.
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class RemoveAdditionalDuplicateCalendarConfigurationKeys extends Migration
{
private const DUPLICATE_KEYS = [
'end_of_registration',
'last_school_day',
'year_end_date',
];
public function up(): void
{
if (! $this->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.
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AlignFallSemesterStartWithSchoolYearStart extends Migration
{
public function up(): void
{
if (! $this->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,
]);
}
}
+4 -1
View File
@@ -1,12 +1,15 @@
<?php
use App\Models\ConfigurationModel;
use App\Services\SemesterRangeService;
// app/Helpers/GlobalConfigHelper.php
if (!function_exists('getSemester')) {
function getSemester() {
$configModel = new \App\Models\ConfigurationModel();
return $configModel->getConfig('semester');
$semester = (new SemesterRangeService($configModel))->getSemesterForDate();
return $semester !== '' ? $semester : 'Fall';
}
}
+3
View File
@@ -0,0 +1,3 @@
<?php
require_once __DIR__ . '/GlobalConfigHelper.php';
+2 -2
View File
@@ -1,6 +1,7 @@
<?php
use App\Models\ConfigurationModel;
use App\Services\SemesterRangeService;
if (!function_exists('selected_teacher_semester')) {
function selected_teacher_semester(): string
@@ -16,8 +17,7 @@ if (!function_exists('selected_teacher_semester')) {
return ucfirst(strtolower($fallback));
}
$config = new ConfigurationModel();
$configSemester = trim((string) ($config->getConfig('semester') ?? ''));
$configSemester = trim((string) ((new SemesterRangeService(new ConfigurationModel()))->getSemesterForDate() ?: 'Fall'));
if ($configSemester !== '') {
return ucfirst(strtolower($configSemester));
}
+54
View File
@@ -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) {
+6 -4
View File
@@ -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,
@@ -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) {
+1 -1
View File
@@ -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);
+2 -5
View File
@@ -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';
}
}
+35 -1
View File
@@ -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 [];
}
}
}
+35
View File
@@ -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.
*
+35
View File
@@ -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 [];
}
}
}
+13 -2
View File
@@ -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;
}
}
}
+2 -5
View File
@@ -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('
@@ -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";
+50 -11
View File
@@ -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<int,float>
*/
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)
{
+2 -7
View File
@@ -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,
+1 -10
View File
@@ -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'),
+3 -4
View File
@@ -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');
+3 -1
View File
@@ -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)
@@ -27,6 +27,7 @@
<th>Year</th>
<th>Status</th>
<th>Requested</th>
<th>Approved</th>
<th>Submitted</th>
<th></th>
</tr>
@@ -40,12 +41,13 @@
<td><?= esc($row['school_year'] ?? '') ?></td>
<td><?= esc($row['status'] ?? '') ?></td>
<td><?= $row['requested_amount'] !== null && $row['requested_amount'] !== '' ? '$' . number_format((float) $row['requested_amount'], 2) : '—' ?></td>
<td><?= $row['admin_amount'] !== null && $row['admin_amount'] !== '' ? '$' . number_format((float) $row['admin_amount'], 2) : '—' ?></td>
<td><?= esc($row['created_at'] ?? '') ?></td>
<td><a class="btn btn-sm btn-outline-primary" href="<?= site_url('administrator/financial-aid/' . (int) $row['id']) ?>">Review</a></td>
</tr>
<?php endforeach; ?>
<?php if (empty($requests)): ?>
<tr><td colspan="7" class="text-muted text-center">No financial aid requests found.</td></tr>
<tr><td colspan="8" class="text-muted text-center">No financial aid requests found.</td></tr>
<?php endif; ?>
</tbody>
</table>
+1 -1
View File
@@ -5,7 +5,7 @@
<h1>Apply Discount Voucher</h1>
<?= $this->include('partials/academic_filter') ?>
<form method="post" action="">
<form method="post" action="<?= site_url('discount/apply') ?>">
<?= csrf_field() ?>
<div class="d-flex gap-2 mb-3 justify-content-end">
<div>
@@ -104,6 +104,18 @@
if (!list || !list.length) return '<em>No students enrolled.</em>';
return list.map(k => `${esc(k.name)} (Grade: ${esc(k.grade)})`).join('<br>');
}
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 `<div class="text-muted small">${date} &middot; ${method}${check}</div>`;
}).join('');
return `<div class="fw-semibold">${fmtMoney(r.refund_amount)}</div>${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 = `<button type=\"button\" class=\"btn btn-primary btn-sm gen-invoice\" data-parent-id=\"${esc(r.parent_id)}\" onclick=\"return window.__genInvoice && window.__genInvoice(this)\">Generate Invoice</button>`;
const pdf = r.invoice_id ? `<a href=\"<?= base_url('invoice/pdf') ?>/${esc(r.invoice_id)}\" target=\"_blank\" class=\"btn btn-info btn-sm external-link\">View/Print PDF</a>` : '<span class=\"text-muted\">No invoice yet</span>';
return [
renderParentCell(r),
renderStudents(r.enrolledKids || []),
genBtn,
fmtMoney(r.invoice_amount),
renderRefundCell(r),
`<span data-order=\"${ts}\">${formatDateTime(ts)}</span>`,
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 = `<button type=\"button\" class=\"btn btn-primary btn-sm gen-invoice\" data-parent-id=\"${esc(r.parent_id)}\" onclick=\"return window.__genInvoice && window.__genInvoice(this)\">Generate Invoice</button>`;
const pdf = r.invoice_id ? `<a href=\"<?= base_url('invoice/pdf') ?>/${esc(r.invoice_id)}\" target=\"_blank\" class=\"btn btn-info btn-sm external-link\">View/Print PDF</a>` : '<span class=\"text-muted\">No invoice yet</span>';
return [
renderParentCell(r),
renderStudents(r.enrolledKids || []),
genBtn,
fmtMoney(r.invoice_amount),
fmtMoney(r.refund_amount),
`<span data-order=\"${ts}\">${formatDateTime(ts)}</span>`,
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 = `<button type=\"button\" class=\"btn btn-primary btn-sm gen-invoice\" data-parent-id=\"${esc(r.parent_id)}\" onclick=\"return window.__genInvoice && window.__genInvoice(this)\">Generate Invoice</button>`;
const pdf = r.invoice_id ? `<a href=\"<?= base_url('invoice/pdf') ?>/${esc(r.invoice_id)}\" target=\"_blank\" class=\"btn btn-info btn-sm external-link\">View/Print PDF</a>` : '<span class=\"text-muted\">No invoice yet</span>';
return [
renderParentCell(r),
renderStudents(r.enrolledKids || []),
genBtn,
fmtMoney(r.invoice_amount),
fmtMoney(r.refund_amount),
`<span data-order=\"${ts}\">${formatDateTime(ts)}</span>`,
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 = `<button type=\"button\" class=\"btn btn-primary btn-sm gen-invoice\" data-parent-id=\"${esc(r.parent_id)}\" onclick=\"return window.__genInvoice && window.__genInvoice(this)\">Generate Invoice</button>`;
const pdf = r.invoice_id ? `<a href=\"<?= base_url('invoice/pdf') ?>/${esc(r.invoice_id)}\" target=\"_blank\" class=\"btn btn-info btn-sm external-link\">View/Print PDF</a>` : '<span class=\"text-muted\">No invoice yet</span>';
return [
renderParentCell(r),
renderStudents(r.enrolledKids || []),
genBtn,
fmtMoney(r.invoice_amount),
fmtMoney(r.refund_amount),
`<span data-order=\"${ts}\">${formatDateTime(ts)}</span>`,
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 = `<button type=\"button\" class=\"btn btn-primary btn-sm gen-invoice\" data-parent-id=\"${esc(r.parent_id)}\" onclick=\"return window.__genInvoice && window.__genInvoice(this)\">Generate Invoice</button>`;
const pdf = r.invoice_id ? `<a href=\"<?= base_url('invoice/pdf') ?>/${esc(r.invoice_id)}\" target=\"_blank\" class=\"btn btn-info btn-sm external-link\">View/Print PDF</a>` : '<span class=\"text-muted\">No invoice yet</span>';
return [
esc(r.parent_name || ''),
renderStudents(r.enrolledKids || []),
genBtn,
fmtMoney(r.invoice_amount),
fmtMoney(r.refund_amount),
`<span data-order=\"${ts}\">${formatDateTime(ts)}</span>`,
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 = `<button type=\"button\" class=\"btn btn-primary btn-sm gen-invoice\" data-parent-id=\"${esc(r.parent_id)}\" onclick=\"return window.__genInvoice && window.__genInvoice(this)\">Generate Invoice</button>`;
const pdf = r.invoice_id ? `<a href=\"<?= base_url('invoice/pdf') ?>/${esc(r.invoice_id)}\" target=\"_blank\" class=\"btn btn-info btn-sm external-link\">View/Print PDF</a>` : '<span class=\"text-muted\">No invoice yet</span>';
return [
esc(r.parent_name || ''),
renderStudents(r.enrolledKids || []),
genBtn,
fmtMoney(r.invoice_amount),
fmtMoney(r.refund_amount),
`<span data-order=\"${ts}\">${formatDateTime(ts)}</span>`,
pdf,
];
});
const data = (resp.invoices || []).map(renderInvoiceRow);
if ($.fn.DataTable.isDataTable($tbl)) {
const dti = $tbl.DataTable();
dti.clear();
+1 -1
View File
@@ -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);
+28 -19
View File
@@ -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;
}
}
</style>
<?= $this->endSection() ?>
@@ -217,24 +244,6 @@ foreach (($students ?? []) as $student) {
</ul>
</div>
<?php if ($familyFinancialSummary !== []): ?>
<div class="border rounded p-3 mb-3 bg-light">
<div class="fw-semibold mb-2">Family Account Information</div>
<div class="row g-2">
<div class="col-md-4"><span class="text-muted">Previous-year carry-over balance:</span> <strong data-financial-value="carry_over_balance"><?= esc($money($familyFinancialSummary['carry_over_balance'] ?? 0)) ?></strong></div>
<div class="col-md-4"><span class="text-muted">Registration fee:</span> <strong data-financial-value="registration_fee"><?= esc($money($familyFinancialSummary['registration_fee'] ?? 0)) ?></strong></div>
<div class="col-md-4"><span class="text-muted">Tuition due now:</span> <strong data-financial-value="tuition_due_at_registration"><?= esc($money($familyFinancialSummary['tuition_due_at_registration'] ?? 0)) ?></strong></div>
<div class="col-md-4"><span class="text-muted">Mandatory fees:</span> <strong data-financial-value="mandatory_fees"><?= esc($money($familyFinancialSummary['mandatory_fees'] ?? 0)) ?></strong></div>
<div class="col-md-4"><span class="text-muted">Current-year account balance:</span> <strong data-financial-value="current_balance"><?= esc($money($familyFinancialSummary['current_balance'] ?? 0)) ?></strong></div>
<div class="col-md-4"><span class="text-muted">Total currently due:</span> <strong data-financial-value="amount_due"><?= esc($money($familyFinancialSummary['amount_due'] ?? 0)) ?></strong></div>
</div>
<?php if (!empty($familyFinancialSummary['policy_message'])): ?>
<div class="small text-muted mt-2"><?= esc($familyFinancialSummary['policy_message']) ?></div>
<?php endif; ?>
<div class="small mt-2"><a href="<?= site_url('parent/financial-aid') ?>">Request financial aid</a></div>
</div>
<?php endif; ?>
<?php if (!empty($students)): ?>
<form action="<?= base_url('/parent/enroll_classes_handler') ?>" method="post" id="enrollmentFlowForm">
<?= csrf_field() ?>
@@ -297,7 +306,7 @@ foreach (($students ?? []) as $student) {
<td data-label="Status"><?= $statusBadge($student['enrollment_status'] ?? '') ?></td>
<td data-label="Withdraw">
<?php if (($student['enrollment_status'] ?? '') === 'enrolled'): ?>
<div class="form-check form-switch m-0">
<div class="form-check form-switch m-0 enrollment-withdraw-control">
<input class="form-check-input" type="checkbox" name="withdraw[]" value="<?= esc($student['id']) ?>" id="withdraw-<?= esc($student['id']) ?>" <?= !$isEditable ? 'disabled' : '' ?>>
<label class="form-check-label small" for="withdraw-<?= esc($student['id']) ?>">Request</label>
</div>
+76 -14
View File
@@ -6,6 +6,68 @@ $disableEmergencyBtn = count($emergencies) >= $maxEmergency;
?>
<?= $this->extend('layout/main_layout') ?>
<?= $this->section('styles') ?>
<style>
@media (max-width: 575.98px) {
.parent-register-stack-table {
border: 0;
}
.parent-register-stack-table thead {
display: none;
}
.parent-register-stack-table tbody,
.parent-register-stack-table tr,
.parent-register-stack-table td {
display: block;
width: 100%;
}
.parent-register-stack-table tr {
border: 1px solid #dee2e6;
border-radius: 8px;
margin-bottom: .85rem;
overflow: hidden;
}
.parent-register-stack-table td {
align-items: flex-start;
border-bottom: 1px solid #eef1f3;
display: flex;
gap: .75rem;
justify-content: space-between;
padding: .75rem;
text-align: right;
}
.parent-register-stack-table td:last-child {
border-bottom: 0;
}
.parent-register-stack-table td::before {
color: #6c757d;
content: attr(data-label);
flex: 0 0 42%;
font-size: .8rem;
font-weight: 700;
text-align: left;
}
.parent-register-stack-table td > * {
max-width: 58%;
}
.parent-register-stack-table td[data-label="Action"] {
align-items: center;
}
.parent-register-stack-table td[data-label="Action"] .btn {
white-space: nowrap;
}
}
</style>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<div class="container my-5">
<h3 class="text-center text-success mb-3" style="font-family: Arial, sans-serif;">Student Registration</h3>
@@ -67,7 +129,7 @@ $disableEmergencyBtn = count($emergencies) >= $maxEmergency;
<?php endif; ?>
<?php if (!empty($existingKids)): ?>
<h5>Student Information</h5>
<table class="table table-bordered">
<table class="table table-bordered parent-register-stack-table">
<thead class="table-light">
<tr>
<th>School ID</th>
@@ -83,12 +145,12 @@ $disableEmergencyBtn = count($emergencies) >= $maxEmergency;
<tbody>
<?php foreach ($existingKids as $kid): ?>
<tr>
<td><?= esc($kid['school_id']) ?></td>
<td><?= esc($kid['firstname']) ?></td>
<td><?= esc($kid['lastname']) ?></td>
<td><?= esc((new DateTime($kid['dob']))->format('m-d-Y')) ?></td>
<td><?= esc($kid['registration_grade']) ?></td>
<td>
<td data-label="School ID"><?= esc($kid['school_id']) ?></td>
<td data-label="First Name"><?= esc($kid['firstname']) ?></td>
<td data-label="Last Name"><?= esc($kid['lastname']) ?></td>
<td data-label="DOB"><?= esc((new DateTime($kid['dob']))->format('m-d-Y')) ?></td>
<td data-label="Grade"><?= esc($kid['registration_grade']) ?></td>
<td data-label="Medical Conditions">
<?php
$mc = is_array($kid['medical_conditions'] ?? null)
? $kid['medical_conditions']
@@ -99,7 +161,7 @@ $disableEmergencyBtn = count($emergencies) >= $maxEmergency;
echo implode('<br>', array_map('esc', $mc));
?>
</td>
<td>
<td data-label="Allergies">
<?php
$al = is_array($kid['allergies'] ?? null)
? $kid['allergies']
@@ -109,7 +171,7 @@ $disableEmergencyBtn = count($emergencies) >= $maxEmergency;
echo implode('<br>', array_map('esc', $al));
?>
</td>
<td class="text-center">
<td class="text-center" data-label="Action">
<?php if ($isEditable && !empty($kid['can_delete'])): ?>
<form action="<?= base_url('/parent/delete_student/' . $kid['id']) ?>"
method="post"
@@ -141,7 +203,7 @@ $disableEmergencyBtn = count($emergencies) >= $maxEmergency;
<?php if (!empty($emergencies)): ?>
<h5 class="mt-4">Emergency Contacts</h5>
<table class="table table-bordered">
<table class="table table-bordered parent-register-stack-table">
<thead class="table-light">
<tr>
<th>First Name</th>
@@ -159,10 +221,10 @@ $disableEmergencyBtn = count($emergencies) >= $maxEmergency;
$lastName = $nameParts[1] ?? '';
?>
<tr>
<td><?= esc($firstName) ?></td>
<td><?= esc($lastName) ?></td>
<td><?= esc($contact['cellphone']) ?></td>
<td><?= esc($contact['relation']) ?></td>
<td data-label="First Name"><?= esc($firstName) ?></td>
<td data-label="Last Name"><?= esc($lastName) ?></td>
<td data-label="Phone"><?= esc($contact['cellphone']) ?></td>
<td data-label="Relation"><?= esc($contact['relation']) ?></td>
</tr>
<?php include(APPPATH . 'Views/parent/edit_emergency_contact.php'); ?>
<?php endforeach; ?>
+83 -6
View File
@@ -1,4 +1,81 @@
<?= $this->extend('layout/main_layout') ?>
<?= $this->section('styles') ?>
<style>
@media (max-width: 575.98px) {
.parent-report-cards-table {
border: 0;
}
.parent-report-cards-table thead {
display: none;
}
.parent-report-cards-table tbody,
.parent-report-cards-table tr,
.parent-report-cards-table td {
display: block;
width: 100%;
}
.parent-report-cards-table tr {
border: 1px solid #dee2e6;
border-radius: 8px;
margin-bottom: .85rem;
overflow: hidden;
}
.parent-report-cards-table td {
align-items: flex-start;
border-bottom: 1px solid #eef1f3;
display: flex;
gap: .75rem;
justify-content: space-between;
padding: .75rem;
text-align: right;
}
.parent-report-cards-table td:last-child {
border-bottom: 0;
}
.parent-report-cards-table td::before {
color: #6c757d;
content: attr(data-label);
flex: 0 0 38%;
font-size: .8rem;
font-weight: 700;
text-align: left;
}
.parent-report-cards-table td > * {
max-width: 62%;
}
.parent-report-cards-table td[data-label="Action"] {
display: block;
text-align: left !important;
}
.parent-report-cards-table td[data-label="Action"]::before {
display: block;
margin-bottom: .5rem;
}
.parent-report-cards-table td[data-label="Action"] > * {
max-width: 100%;
}
.parent-report-cards-table td[data-label="Action"] form {
align-items: stretch !important;
display: flex !important;
flex-direction: column;
margin-left: 0 !important;
margin-top: .5rem;
width: 100%;
}
}
</style>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<?php
$isEditable = (bool) ($isEditable ?? true);
@@ -30,7 +107,7 @@
<div class="alert alert-info">No students available for report cards.</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-striped table-bordered align-middle">
<table class="table table-striped table-bordered align-middle parent-report-cards-table">
<thead class="table-light">
<tr>
<th>Student</th>
@@ -51,10 +128,10 @@
$hasReport = !empty(($reportAvailableMap ?? [])[$sid]);
?>
<tr>
<td><?= esc(trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? ''))) ?></td>
<td><?= esc($student['class_section_name'] ?? 'N/A') ?></td>
<td><?= $viewedAt ? esc(local_datetime($viewedAt, 'm-d-Y H:i')) : 'Not viewed' ?></td>
<td>
<td data-label="Student"><?= esc(trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? ''))) ?></td>
<td data-label="Class Section"><?= esc($student['class_section_name'] ?? 'N/A') ?></td>
<td data-label="Viewed"><?= $viewedAt ? esc(local_datetime($viewedAt, 'm-d-Y H:i')) : 'Not viewed' ?></td>
<td data-label="Signature">
<?php if ($signedAt): ?>
<?= esc($signedName ?: 'Signed') ?><br>
<small class="text-muted"><?= esc(local_datetime($signedAt, 'm-d-Y H:i')) ?></small>
@@ -62,7 +139,7 @@
<span class="text-muted">Not signed</span>
<?php endif; ?>
</td>
<td class="text-end">
<td class="text-end" data-label="Action">
<?php if ($hasReport): ?>
<a class="btn btn-sm btn-outline-primary" target="_blank" href="<?= base_url('parent/report-cards/view/' . $sid) ?>">View Report</a>
<?php else: ?>
+2 -2
View File
@@ -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 */ }
}
+1 -1
View File
@@ -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) ?? []);
}
?>
+11
View File
@@ -175,6 +175,7 @@
aria-autocomplete="list"
aria-haspopup="listbox"
value="<?= esc($searchTermUsedInSearch ?? '') ?>">
<button class="btn btn-outline-secondary" type="button" id="manualPayClearSearch">Clear</button>
<button class="btn btn-primary" type="submit">Search</button>
</div>
<div id="manualPaySuggest" class="list-group manual-pay-suggest d-none" role="listbox"></div>
@@ -1001,9 +1002,11 @@
function initSearchSuggest() {
const input = _el('manualPaySearchInput');
const suggest = _el('manualPaySuggest');
const clear = _el('manualPayClearSearch');
if (!input || !suggest) return;
const suggestUrl = <?= json_encode(site_url('payment/manual_pay_suggest')) ?>;
const manualPayUrl = <?= json_encode(site_url('payment/manual_pay')) ?>;
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();
+31 -1
View File
@@ -1,11 +1,27 @@
<?= $this->extend('layout/main_layout') ?>
<?= $this->section('content') ?>
<?php $isSchoolYearReadonly = (bool) ($isSchoolYearReadonly ?? false); ?>
<?php
$isSchoolYearReadonly = (bool) ($isSchoolYearReadonly ?? false);
$successMessage = session()->getFlashdata('success');
$errorMessage = session()->getFlashdata('error');
?>
<div class="container-fluid py-5">
<div class="row">
<div class="col-md-12">
<h2>Print/Copy Requests</h2>
<?php if ($successMessage): ?>
<div class="alert alert-success alert-dismissible fade show" role="alert">
<?= esc($successMessage) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<?php endif; ?>
<?php if ($errorMessage): ?>
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<?= esc($errorMessage) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<?php endif; ?>
<?php if ($isSchoolYearReadonly): ?>
<div class="alert alert-warning">
This school year is read-only. Existing requests are visible, but changes are disabled.
@@ -46,6 +62,7 @@
<div class="tab-pane fade show active" id="tab-print" role="tabpanel" aria-labelledby="tab-print-tab">
<form action="<?= site_url('print-requests/create') ?>" method="post" enctype="multipart/form-data">
<?= csrf_field() ?>
<input type="hidden" name="request_token" value="<?= esc($printRequestToken ?? '') ?>">
<?php if (session()->has('errors')): ?>
<div class="alert alert-danger">
<ul>
@@ -128,6 +145,7 @@
</div>
<form id="copyRequestForm" method="post" action="<?= site_url('print-requests/create-copy') ?>">
<?= csrf_field() ?>
<input type="hidden" name="request_token" value="<?= esc($copyRequestToken ?? '') ?>">
<div class="row g-3">
<div class="col-md-4">
<label for="copy_num_copies" class="form-label">Number of Copies</label>
@@ -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...';
}
});
}
</script>
-381
View File
@@ -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.07AM.png
Pasted code(6).php
File
remove the image content from the view
Removed the School year / Semester filter from the view by deleting:
<?= $this->include('partials/academic_filter') ?>
Pasted_code_6_without_academic_filter.php
Library
/
Pasted_code_6_without_academic_filter.php
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php
// Role formatter for display + posting
$formatRole = function (?string $role): string {
$role = (string)$role;
$role = str_replace(['-', '_'], ' ', $role);
$role = preg_replace('/\s+/', ' ', trim($role));
if ($role === '') return '';
$out = [];
foreach (explode(' ', $role) as $w) {
if ($w === '') continue;
if (preg_match('/^[A-Za-z]{1,3}$/', $w)) {
$out[] = strtoupper($w); // TA, PTA, HR, KG...
} else {
$out[] = ucfirst(strtolower($w)); // Teacher, Assistant, Admin...
}
}
return implode(' ', $out);
};
?>
<div class="container-fluid">
<div class="wrapper">
<div class="content">
<div class="text-center mx-auto mb-5 wow" data-wow-delay="0.1s" style="max-width: 600px;">
<br>
<h2 class="text-dark mb-3" style="font-family: Arial, sans-serif;">Generate Staff Badges</h2>
</div>
<div class="d-flex flex-column flex-md-row justify-content-md-end align-items-md-center gap-2">
<!-- Submit button associates to form below -->
<button type="submit" class="btn btn-success mt-3" form="badgeForm">Generate Badges</button>
<br>
</div>
<!-- Staff selection -->
<form method="post" action="<?= base_url('badge') ?>" target="_blank" id="badgeForm">
<?= csrf_field() ?>
<table id="staffTable" class="table table-bordered table-striped align-middle w-100">
<thead class="table-dark">
<tr>
<th style="width: 70px;">#</th>
<th scope="col">Firstname</th>
<th scope="col">Lastname</th>
<th scope="col">Role</th>
<th scope="col">Teacher Class Section</th>
<th scope="col" style="width: 140px;">Badge Prints</th>
<th scope="col" style="width: 150px;">
<div class="form-check m-0">
<input class="form-check-input" type="checkbox" id="select-all">
<label class="form-check-label" for="select-all">Select All (page)</label>
</div>
</th>
</tr>
</thead>
<tbody>
<?php if (!empty($users)): ?>
<?php $order = 1; ?>
<?php foreach ($users as $user): ?>
<?php
// Pick a representative role (first of CSV or role_name/active_role)
$roleRaw = $user['active_role'] ?? $user['role_name'] ?? ($user['roles'] ?? '');
if (strpos((string)$roleRaw, ',') !== false) {
$parts = array_filter(array_map('trim', explode(',', (string)$roleRaw)));
$roleRaw = $parts[0] ?? '';
}
//$roleLabel = $roleRaw !== '' ? $formatRole($roleRaw) : '-';
$roleLabel = $user['role_name'] ?? ($user['roles'] ?? '-'); // both are pre-formatted by $formatRole
// Normalize user id key for checkbox
$uid = $user['user_id'] ?? $user['id'] ?? ($user['users.id'] ?? null);
// Class section name (optional)
$className = !empty($user['class_section_name']) ? (string)$user['class_section_name'] : '';
?>
<tr
data-user-id="<?= esc($uid ?? '') ?>"
data-role-label="<?= esc($roleLabel) ?>"
data-class-name="<?= esc($className) ?>">
<td style="text-align: center;"><?= esc($order++) ?></td>
<td><?= esc($user['firstname'] ?? '') ?></td>
<td><?= esc($user['lastname'] ?? '') ?></td>
<td><?= esc($roleLabel) ?></td>
<td><?= $className !== '' ? esc($className) : '-' ?></td>
<td><span class="badge bg-secondary prints-badge" data-user-id="<?= esc($uid ?? '') ?>"></span></td>
<td>
<?php if ($uid !== null): ?>
<div class="form-check m-0">
<input type="checkbox" name="user_ids[]" value="<?= esc($uid) ?>" class="form-check-input user-checkbox" id="chk-<?= esc($uid) ?>">
<label class="form-check-label" for="chk-<?= esc($uid) ?>">Select</label>
</div>
<?php else: ?>
<span class="text-muted">N/A</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="7" class="text-center text-muted">No staff found for the selected year.</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</form>
<br>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Track selected user IDs across paging/filtering/sorting
const selected = new Set();
const formEl = document.getElementById('badgeForm');
let clearTimerId = null; // pending auto-clear timer, if any
let hiddenContainer = document.getElementById('selectedHiddenInputs');
// Ensure hidden container exists inside the form (some earlier layouts had it outside)
if (!hiddenContainer || !formEl.contains(hiddenContainer)) {
hiddenContainer = document.createElement('div');
hiddenContainer.id = 'selectedHiddenInputs';
hiddenContainer.style.display = 'none';
formEl.appendChild(hiddenContainer);
}
const selectAll = document.getElementById('select-all');
const csrfName = <?= json_encode(csrf_token()) ?>;
const csrfInput = document.querySelector(`input[name='${csrfName}']`);
// Build a meta map (userId -> { role, className }) from the DOM BEFORE DataTables paginates
const rowMeta = {};
document.querySelectorAll('#staffTable tbody tr').forEach(tr => {
const id = tr.getAttribute('data-user-id');
if (!id) return;
rowMeta[id] = {
role: tr.getAttribute('data-role-label') || '',
className: tr.getAttribute('data-class-name') || ''
};
});
const table = $('#staffTable').DataTable({
stateSave: true,
pageLength: 100,
lengthMenu: [10, 25, 50, 100],
order: [
[1, 'asc'],
[2, 'asc']
], // Firstname, Lastname
columnDefs: [{
targets: 0,
searchable: false
}, // row #
{
targets: 6,
orderable: false,
searchable: false
} // checkbox column
]
});
// Helper: keep hidden inputs in sync so selections submit even if not on current page
function syncHiddenInputs() {
hiddenContainer.innerHTML = '';
selected.forEach(function(id) {
const meta = rowMeta[id] || {
role: '',
className: ''
};
// user_ids[]
const i1 = document.createElement('input');
i1.type = 'hidden';
i1.name = 'user_ids[]';
i1.value = id;
hiddenContainer.appendChild(i1);
// roles[ID]
const i2 = document.createElement('input');
i2.type = 'hidden';
i2.name = `roles[${id}]`;
i2.value = meta.role;
hiddenContainer.appendChild(i2);
// classes[ID]
const i3 = document.createElement('input');
i3.type = 'hidden';
i3.name = `classes[${id}]`;
i3.value = meta.className;
hiddenContainer.appendChild(i3);
});
}
// Helper: refresh checkboxes on current page based on Set
function syncPageCheckboxes() {
const pageNodes = table.rows({
page: 'current'
}).nodes().to$();
let allChecked = true;
let anyChecked = false;
pageNodes.each(function() {
const row = this;
const userId = row.getAttribute('data-user-id');
const cb = row.querySelector('.user-checkbox');
if (!cb || !userId) return;
cb.checked = selected.has(userId);
if (!cb.checked) allChecked = false;
if (cb.checked) anyChecked = true;
});
// Update header select-all for current page
selectAll.checked = allChecked && pageNodes.length > 0;
selectAll.indeterminate = !allChecked && anyChecked;
}
// Helper: clear all selections and reset the current page UI
function clearSelectionsNow() {
selected.clear();
// Uncheck visible checkboxes on current page
table.rows({ page: 'current' }).every(function () {
const row = this.node();
const cb = row.querySelector('.user-checkbox');
if (cb) cb.checked = false;
});
// Reset select-all and hidden inputs
selectAll.checked = false;
selectAll.indeterminate = false;
syncHiddenInputs();
syncPageCheckboxes();
}
// On draw (paging/sort/search), just refresh checkbox UI states
table.on('draw', function() {
syncPageCheckboxes();
});
// Delegate checkbox change handling once at the table level (works across redraws)
document.getElementById('staffTable').addEventListener('change', function (e) {
const target = e.target;
if (!target || !target.classList.contains('user-checkbox')) return;
const tr = target.closest('tr[data-user-id]');
const userId = tr ? tr.getAttribute('data-user-id') : null;
if (!userId) return;
if (target.checked) selected.add(userId); else selected.delete(userId);
syncHiddenInputs();
syncPageCheckboxes();
// If user interacts, cancel any pending auto-clear to avoid wiping new selections
if (clearTimerId !== null) { clearTimeout(clearTimerId); clearTimerId = null; }
});
// Initial sync on first load
table.draw(false);
// Header "Select All (page)" toggler
selectAll.addEventListener('change', function() {
const check = this.checked;
table.rows({
page: 'current'
}).every(function() {
const row = this.node();
const userId = row.getAttribute('data-user-id');
const cb = row.querySelector('.user-checkbox');
if (!cb || !userId) return;
cb.checked = check;
if (check) selected.add(userId);
else selected.delete(userId);
});
syncHiddenInputs();
syncPageCheckboxes();
if (clearTimerId !== null) { clearTimeout(clearTimerId); clearTimerId = null; }
});
// Before submit, ensure CSRF is fresh and hidden inputs include all selections
const form = document.getElementById('badgeForm');
async function refreshCsrf() {
try {
const params = new URLSearchParams();
if (!resp.ok) return;
const json = await resp.json();
if (json && json.csrf_token && json.csrf_hash && csrfInput && json.csrf_token === csrfName) {
csrfInput.value = json.csrf_hash;
}
} catch (e) {
// No-op: if refresh fails we'll still attempt submit; server may accept existing token
}
}
form.addEventListener('submit', async function(e) {
e.preventDefault();
if (selected.size === 0) {
alert('Please select at least one staff member to generate badges.');
return;
}
syncHiddenInputs();
// CSRF is excluded for this endpoint now, but keeping refresh is safe
try { await refreshCsrf(); } catch (_) {}
// Native submit so browser handles PDF in a new tab
form.submit();
// Auto-clear selections 5 seconds after generating badges
clearTimerId = setTimeout(function() { clearSelectionsNow(); clearTimerId = null; }, 5000);
});
// --- Fetch and render print status ---
const selectedYear = <?= json_encode($selectedYear ?? '') ?>;
function fetchPrintStatus() {
const ids = Object.keys(rowMeta);
if (ids.length === 0) return;
const params = new URLSearchParams();
ids.forEach(id => params.append('user_ids[]', id));
if (selectedYear) params.append('school_year', selectedYear);
fetch('<?= base_url('api/printables/badges/status') ?>?' + params.toString(), {
method: 'GET',
headers: {
'Accept': 'application/json'
}
})
.then(r => r.ok ? r.json() : Promise.reject())
.then(json => {
if (!json || !json.ok || !json.data) return;
const data = json.data;
Object.keys(rowMeta).forEach(id => {
const info = data[id];
const badge = document.querySelector(`.prints-badge[data-user-id="${id}"]`);
const tr = document.querySelector(`#staffTable tbody tr[data-user-id="${id}"]`);
if (!badge || !tr) return;
const count = info ? (info.count || 0) : 0;
badge.textContent = count > 0 ? `Printed ${count}` : '—';
badge.className = 'badge prints-badge ' + (count > 0 ? 'bg-success' : 'bg-secondary');
tr.classList.toggle('table-success', count > 0);
});
// Refresh CSRF for subsequent submissions (so no page reload is needed)
if (json.csrf_token && json.csrf_hash && csrfInput && json.csrf_token === csrfName) {
csrfInput.value = json.csrf_hash;
}
})
.catch(() => {});
}
fetchPrintStatus();
window.addEventListener('focus', fetchPrintStatus);
});
</script>
<?= $this->endSection() ?>
Library
/
Pasted_code_6_without_academic_filter.php
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php
+79 -80
View File
@@ -34,112 +34,104 @@
<?php endif; ?>
</form>
<div class="d-flex justify-content-end mb-2 gap-2 flex-wrap">
<form method="post" action="/refunds/recalculateOverpayments" class="d-flex gap-2">
<?= csrf_field() ?>
<button class="btn btn-outline-primary btn-sm" type="submit" title="Current school year">
Recalculate Overpayments (This Year)
</button>
</form>
<form method="post" action="/refunds/recalculateOverpayments" class="d-flex gap-2">
<?= csrf_field() ?>
<input type="text" name="invoice_number" class="form-control form-control-sm" placeholder="Invoice # (e.g., INV-...)" style="min-width: 260px;">
<button class="btn btn-outline-secondary btn-sm" type="submit" title="Recalc a specific invoice across any year">
Recalc Specific Invoice
</button>
</form>
</div>
<div class="table-responsive">
<table id="refundsTable" class="table table-bordered table-striped align-middle w-100">
<thead>
<tr>
<th>School&nbsp;ID</th>
<th>Parent</th>
<th>Request</th>
<th>Term</th>
<th>Invoice&nbsp;ID</th>
<th class="text-end">Refund Amount</th>
<th>Status</th>
<th>Invoice&nbsp;#</th>
<th>Requested</th>
<th>Approved</th>
<th>Approved By</th>
<th>Refunded</th>
<th>Method</th>
<th>Check #</th>
<th>Check File</th>
<th class="text-end">Paid Amount</th>
<th class="text-end">Source Available</th>
<th class="text-end">Parent Available</th>
<th>Status</th>
<th>Refund Details</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($refunds as $r): ?>
<?php
// Friendly badges
$req = strtolower((string)($r['request'] ?? ''));
$reqBadgeClass = [
'tuition' => '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;
?>
<tr>
<td><?= esc($r['school_id']) ?></td>
<td><?= esc(($r['firstname'] ?? '').' '.($r['lastname'] ?? '')) ?></td>
<td><?= esc($r['invoice_number'] ?? '-') ?></td>
<td>
<?php if ($req): ?>
<span class="badge bg-<?= $reqBadgeClass ?> text-uppercase"><?= esc($req) ?></span>
<?php else: ?>
<span class="text-muted">-</span>
<div class="fw-semibold">$<?= esc(number_format($refundAmount, 2)) ?></div>
<div class="text-muted small"><?= esc($fmt($r['requested_at'] ?? null)) ?></div>
</td>
<td>
<div><span class="badge bg-<?= esc($statusClass) ?>"><?= esc($statusLabel) ?></span></div>
<?php if (!empty($r['approved_at']) && $r['approved_at'] !== '0000-00-00 00:00:00'): ?>
<div class="text-muted small mt-1">
<?= esc($fmt($r['approved_at'])) ?>
<?php if (!empty($r['approved_by_name']) && $r['approved_by_name'] !== '-'): ?>
by <?= esc($r['approved_by_name']) ?>
<?php endif; ?>
</div>
<?php endif; ?>
</td>
<td><?= esc(($r['school_year'] ?? '-'). ' / ' . ($r['semester'] ?? '-')) ?></td>
<td><?= esc($r['invoice_id'] ?? '-') ?></td>
<td class="text-end">$<?= esc(number_format((float)$r['refund_amount'], 2)) ?></td>
<td><?= esc($r['status']) ?></td>
<td><?= esc($fmt($r['requested_at'] ?? null)) ?></td>
<td><?= esc($fmt($r['approved_at'] ?? null)) ?></td>
<td><?= esc($r['approved_by_name'] ?? '-') ?></td>
<td><?= esc($fmt($r['refunded_at'] ?? null)) ?></td>
<td><?= esc($r['refund_method'] ?? '-') ?></td>
<td><?= esc($r['check_nbr'] ?? '-') ?></td>
<td>
<?php if (!empty($r['check_file'])): ?>
<a href="<?= base_url('refunds/file/' . (int) $r['id'] . '/inline') ?>" target="_blank">View</a>
<?php else: ?>
-
<?php endif; ?>
<div><span class="text-muted small">Check #:</span> <?= esc($r['check_nbr'] ?? '-') ?></div>
<div>
<span class="text-muted small">Check File:</span>
<?php if (!empty($r['check_file'])): ?>
<a href="<?= base_url('refunds/file/' . (int) $r['id'] . '/inline') ?>" target="_blank">View</a>
<?php else: ?>
-
<?php endif; ?>
</div>
<div><span class="text-muted small">Paid:</span> $<?= esc(number_format($paidAmount, 2)) ?></div>
</td>
<td class="text-end">$<?= esc(number_format((float)$r['refund_paid_amount'], 2)) ?></td>
<td class="text-end">
<?= $r['available_refundable_credit'] === null ? '-' : '$' . esc(number_format((float)$r['available_refundable_credit'], 2)) ?>
</td>
<td class="text-end">
<?= isset($r['parent_available_refundable_credit']) ? '$' . esc(number_format((float)$r['parent_available_refundable_credit'], 2)) : '-' ?>
</td>
<td class="d-flex gap-2">
<td>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-success btn-sm"
onclick="handleRecordRefundClick(<?= (int)$r['id'] ?>, '<?= esc($r['status']) ?>', <?= (float)$r['refund_amount'] ?>)">
<?= $canRecord ? '' : 'disabled' ?>
onclick="handleRecordRefundClick(<?= (int)$r['id'] ?>, '<?= esc($statusLabel) ?>', <?= $remainingAmount ?>)">
Record
</button>
<button class="btn btn-primary btn-sm"
onclick="handleStatusClick(<?= (int)$r['id'] ?>, 'Approved', <?= (float)$r['refund_amount'] ?>)">
<?= $canApprove ? '' : 'disabled' ?>
onclick="handleStatusClick(<?= (int)$r['id'] ?>, 'Approved', <?= $refundAmount ?>)">
Approve
</button>
<button class="btn btn-danger btn-sm"
onclick="handleStatusClick(<?= (int)$r['id'] ?>, 'Rejected', <?= (float)$r['refund_amount'] ?>)">
<?= $canReject ? '' : 'disabled' ?>
onclick="handleStatusClick(<?= (int)$r['id'] ?>, 'Rejected', <?= max($refundAmount, 0.01) ?>)">
Reject
</button>
</div>
</td>
</tr>
<?php endforeach; ?>
@@ -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('');
+1 -1
View File
@@ -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);
}
?>
-10
View File
@@ -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
@@ -0,0 +1,66 @@
<?php
namespace Tests\App\Controllers\Administrator;
use App\Controllers\Administrator\FinancialAidController;
use CodeIgniter\Test\CIUnitTestCase;
class FinancialAidRequestStub
{
public function __construct(private array $post = [])
{
}
public function getPost(?string $key = null)
{
if ($key === null) {
return $this->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);
}
}
@@ -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
@@ -0,0 +1,153 @@
<?php
namespace {
if (!function_exists('site_url')) {
function site_url($uri = '')
{
return 'https://test.alrahmaisgl.org/' . ltrim((string) $uri, '/');
}
}
if (!function_exists('view')) {
function view($name, $data = [], $options = [])
{
return ['view' => $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']
);
}
}
}
@@ -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']);
}
}
@@ -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);
@@ -0,0 +1,25 @@
<?php
namespace Tests\App\Models;
use App\Models\ManualPaymentModel;
use CodeIgniter\Test\CIUnitTestCase;
class ManualPaymentModelMetadataTest extends CIUnitTestCase
{
public function testAllowedFieldsOnlyIncludeExistingColumns(): void
{
$model = new ManualPaymentModel();
$fields = self::getPrivateProperty($model, 'allowedFields');
$columns = self::getPrivateProperty($model, 'manualPaymentColumns');
if ($columns === []) {
$this->assertSame([], $fields);
return;
}
foreach ($fields as $field) {
$this->assertContains($field, $columns);
}
}
}
@@ -0,0 +1,51 @@
<?php
namespace Tests\App\Services;
use App\Services\FeeCalculationService;
use CodeIgniter\Test\CIUnitTestCase;
class FeeCalculationServiceTest extends CIUnitTestCase
{
public function testRefundReversesLatestTuitionTierFirst(): void
{
$fees = $this->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<int,float>
*/
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
);
}
}
@@ -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']);