Fix semester context, attendance rosters, and billing workflows
- 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:
@@ -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');
|
||||
|
||||
@@ -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'];
|
||||
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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');
|
||||
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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') ?? '');
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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') ?? ''));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
@@ -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 ?? ''));
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
+31
@@ -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.
|
||||
}
|
||||
}
|
||||
+30
@@ -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.
|
||||
}
|
||||
}
|
||||
+59
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/GlobalConfigHelper.php';
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} · ${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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; ?>
|
||||
|
||||
@@ -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: ?>
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -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) ?? []);
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,384 +1,3 @@
|
||||
|
||||
Yesterday 11:12 PM
|
||||
write sql querry to update school_year = 2025-2026
|
||||
|
||||
UPDATE your_table_name
|
||||
SET school_year = '2025-2026'
|
||||
WHERE your_condition;
|
||||
To update every row:
|
||||
|
||||
UPDATE your_table_name
|
||||
SET school_year = '2025-2026';
|
||||
Use the second version only if you genuinely intend to rewrite the entire table, because databases are remarkably obedient when humans forget WHERE.
|
||||
|
||||
|
||||
Today 12:49 AM
|
||||
Screenshot 2026-07-14 at 12.49.07 AM.png
|
||||
|
||||
Pasted code(6).php
|
||||
File
|
||||
remove the image content from the view
|
||||
|
||||
Removed the School year / Semester filter from the view by deleting:
|
||||
|
||||
<?= $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
@@ -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 ID</th>
|
||||
<th>Parent</th>
|
||||
<th>Request</th>
|
||||
<th>Term</th>
|
||||
<th>Invoice ID</th>
|
||||
<th class="text-end">Refund Amount</th>
|
||||
<th>Status</th>
|
||||
<th>Invoice #</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('');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user