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

- load global semester helpers consistently and use date-based semester defaults
- fix grading and daily attendance duplicate student/section rows
- keep attendance violations scoped to the current semester by default
- update invoice, refund, discount, payment, and financial aid flows
- add configuration cleanup migrations for duplicate calendar/semester keys
- refresh parent registration/report-card and print request handling
- update related models, services, views, cron notes, and test coverage
This commit is contained in:
root
2026-08-16 17:41:11 -04:00
parent 36c7e3fc6d
commit 0ac3a8375e
99 changed files with 1598 additions and 814 deletions
@@ -76,7 +76,7 @@ class AdministratorController extends BaseController
$this->classSectionModel = new ClassSectionModel();
$this->adminNotificationSubjectModel = new AdminNotificationSubjectModel();
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->studentClassModel = new StudentClassModel();
$this->staffAttendanceModel = new StaffAttendanceModel();
@@ -701,7 +701,7 @@ class AdministratorController extends BaseController
public function teacherSubmissionsReport()
{
$semester = (string)($this->configModel->getConfig('semester') ?? $this->semester ?? '');
$semester = (string)(getSemester() ?? $this->semester ?? '');
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? ''));
if ($schoolYear === '') {
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
@@ -1106,7 +1106,7 @@ class AdministratorController extends BaseController
$semesterResolver = new SemesterRangeService($this->configModel);
$schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
$semester = (string)($this->configModel->getConfig('semester') ?? '');
$semester = (string)(getSemester() ?? '');
$schoolYearForRange = $schoolYear !== '' ? $schoolYear : $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
[$rangeStart, $rangeEnd] = $semesterResolver->getSchoolYearRange($schoolYearForRange);
$semesterNorm = $semesterResolver->normalizeSemester($semester);
@@ -1224,7 +1224,7 @@ class AdministratorController extends BaseController
if (!is_array($notify)) {
return redirect()->back()->with('info', 'Select at least one teacher to notify.');
}
$semester = (string)($this->configModel->getConfig('semester') ?? $this->semester ?? '');
$semester = (string)(getSemester() ?? $this->semester ?? '');
$missingItemsPayload = $this->request->getPost('missing_items') ?? [];
$homeworkNotifyAll = (bool) $this->request->getPost('homework_notify_all');
$examTerm = $this->resolveExamTermLabel($semester);
@@ -3208,7 +3208,7 @@ class AdministratorController extends BaseController
'currency' => 'USD',
'refund_paid_amount' => 0.0,
'status' => 'Pending',
'source_type' => 'invoice_overpayment',
'source_type' => 'tuition_withdrawal',
'source_id' => (int)$invoice['id'],
'requested_at' => utc_now(),
'updated_by' => session()->get('user_id') ?? null,
@@ -33,7 +33,7 @@ class AssignmentController extends BaseController
$this->teacherClassModel = new TeacherClassModel();
$this->studentClassModel = new StudentClassModel();
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->schoolYear = $this->configModel->getConfig('school_year');
}
+11 -5
View File
@@ -63,7 +63,7 @@ class AttendanceController extends Controller
$this->calendarModel = model(CalendarModel::class);
$this->userRoleModel = new UserRoleModel();
$this->semesterScoreService = service('semesterScoreService');
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->schoolYear = $this->currentSchoolYearName();
$this->enableAttendance = $this->configModel->getConfig('enable_attendance');
$this->semesterRangeService = new SemesterRangeService($this->configModel);
@@ -534,19 +534,19 @@ public function showUpdateAttendanceForm()
// If there are no assignments yet for the requested semester (e.g., Spring roster not populated),
// fall back to the school year only so the roster still renders and can be used for the new term.
$classSections = $this->classSectionModel
->select('classSection.id, classSection.class_id, classSection.class_section_id, classSection.class_section_name')
->select('MIN(classSection.id) AS id, classSection.class_id, classSection.class_section_id, classSection.class_section_name', false)
->join('student_class sc', 'sc.class_section_id = classSection.class_section_id', 'inner')
->where('sc.school_year', $termYear)
->groupBy(['classSection.id', 'classSection.class_id', 'classSection.class_section_id', 'classSection.class_section_name'])
->groupBy(['classSection.class_id', 'classSection.class_section_id', 'classSection.class_section_name'])
->findAll();
$useRosterFallback = false;
if (empty($classSections) && $termYear !== '') {
$classSections = $this->classSectionModel
->select('classSection.id, classSection.class_id, classSection.class_section_id, classSection.class_section_name')
->select('MIN(classSection.id) AS id, classSection.class_id, classSection.class_section_id, classSection.class_section_name', false)
->join('student_class sc', 'sc.class_section_id = classSection.class_section_id', 'inner')
->where('sc.school_year', $termYear)
->groupBy(['classSection.id', 'classSection.class_id', 'classSection.class_section_id', 'classSection.class_section_name'])
->groupBy(['classSection.class_id', 'classSection.class_section_id', 'classSection.class_section_name'])
->findAll();
$useRosterFallback = !empty($classSections);
}
@@ -588,9 +588,15 @@ public function showUpdateAttendanceForm()
}
$hasRoster = false;
$seenStudentIds = [];
foreach ($students as $sc) {
$studentId = (int)$sc['student_id'];
if ($studentId <= 0 || isset($seenStudentIds[$studentId])) {
continue;
}
$seenStudentIds[$studentId] = true;
$student = $this->studentModel
->select('id, firstname, lastname, school_id')
->where('id', $studentId)
@@ -40,7 +40,7 @@ class AttendanceTrackingController extends BaseController
$this->db = \Config\Database::connect();
$this->notificationModel = new ParentNotificationModel();
$this->attendanceEmailTemplateModel = new AttendanceEmailTemplateModel();
$this->semester = (string) $this->configModel->getConfig('semester');
$this->semester = (string) getSemester();
$this->schoolYear = (string) $this->configModel->getConfig('school_year');
}
@@ -49,7 +49,8 @@ class AttendanceTrackingController extends BaseController
$syParam = $this->request->getGet('school_year');
$semParam = $this->request->getGet('semester');
$schoolYear = (is_string($syParam) && $syParam !== '') ? (string)$syParam : (string)$this->schoolYear;
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : null; // semester filter disabled
$semester = (is_string($semParam) && $semParam !== '') ? (string)$semParam : (string)$this->semester;
$semesterWasRequested = is_string($semParam) && $semParam !== '';
$debugInfo = [
'school_year_param' => $schoolYear,
@@ -325,7 +326,8 @@ class AttendanceTrackingController extends BaseController
'semester' => $semester,
'student_ids' => $studentIds,
]);
// Second chance: detect the latest attendance_data term for these students
// Second chance: detect the latest attendance_data term only when the user has not
// requested a semester and the date-based current semester is unavailable.
$latestAttendanceTerm = $this->db->table('attendance_data')
->select('school_year, semester, date')
->whereIn('student_id', $studentIds)
@@ -334,7 +336,7 @@ class AttendanceTrackingController extends BaseController
->get()
->getRowArray();
if ($latestAttendanceTerm) {
if ($latestAttendanceTerm && !$semesterWasRequested && trim($semester) === '') {
$schoolYear = !empty($latestAttendanceTerm['school_year'])
? (string)$latestAttendanceTerm['school_year']
: $schoolYear;
@@ -2140,7 +2142,7 @@ class AttendanceTrackingController extends BaseController
$wrapped = $this->renderWithEmailLayout($subject, $safeHtmlBody);
// Resolve term (prefer controller properties if set)
$semester = $this->semester ?? (new \App\Models\ConfigurationModel())->getConfig('semester');
$semester = $this->semester ?? getSemester();
$schoolYear = $this->schoolYear ?? (new \App\Models\ConfigurationModel())->getConfig('school_year');
try {
+11 -2
View File
@@ -37,6 +37,7 @@ class CertificateController extends BaseController
->join('classSection cs', 'cs.class_section_id = sc.class_section_id')
->where('s.is_active', 1)
->where('sc.school_year', $schoolYear)
->groupBy('s.id, s.firstname, s.lastname, sc.class_section_id, cs.class_section_name')
->orderBy('s.firstname', 'ASC')
->orderBy('s.lastname', 'ASC')
->get()
@@ -105,10 +106,18 @@ class CertificateController extends BaseController
// ── Build per-class buckets and stats ──────────────────────────────────
$studentsByClass = [];
$statsPerClass = [];
$seenEnrollments = [];
foreach ($allEnrolled as $row) {
$sid = (int)$row['student_id'];
$csid = (int)$row['class_section_id'];
$enrollmentKey = $csid . ':' . $sid;
if (isset($seenEnrollments[$enrollmentKey])) {
continue;
}
$seenEnrollments[$enrollmentKey] = true;
if (!isset($statsPerClass[$csid])) {
$statsPerClass[$csid] = [
@@ -293,7 +302,7 @@ class CertificateController extends BaseController
->with('error', 'Please select at least one student.');
}
$studentIds = array_filter(array_map('intval', $studentIds));
$studentIds = array_values(array_unique(array_filter(array_map('intval', $studentIds))));
$certDate = preg_replace('/[^0-9\/\-]/', '', $certDate);
if (empty($studentIds)) {
@@ -661,4 +670,4 @@ class CertificateController extends BaseController
$pdf->SetTextColor(0, 0, 0);
$pdf->Text($x, $y, $text);
}
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ class ClassController extends BaseController
$this->configModel = new ConfigurationModel();
// Get the semester from the configuration table
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->schoolYear = $this->configModel->getConfig('school_year');
}
@@ -50,7 +50,7 @@ class ClassPreparationController extends BaseController
$this->db = \Config\Database::connect();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
}
public function index()
@@ -244,7 +244,7 @@ class CompetitionScoresController extends BaseController
{
$userId = (int) (session()->get('user_id') ?? 0);
$schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
$semester = (string) ($this->configModel->getConfig('semester') ?? '');
$semester = (string) (getSemester() ?? '');
$assignments = $this->teacherClassModel->getClassAssignmentsByUserId(
$userId,
$schoolYear,
+33 -6
View File
@@ -45,12 +45,14 @@ class DiscountController extends BaseController
$this->classSectionModel = new ClassSectionModel();
$this->invoiceLedgerService = new InvoiceLedgerService();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
$this->schoolYear = $this->activeSchoolYearName();
$this->semester = getSemester();
}
public function applyVoucher()
{
$this->schoolYear = $this->activeSchoolYearName();
if (strtolower($this->request->getMethod()) === 'post') {
$voucherId = $this->request->getPost('voucher_id');
$parentIds = $this->request->getPost('parent_ids') ?? [];
@@ -402,7 +404,10 @@ class DiscountController extends BaseController
unset($parent);
return view('discounts/apply_voucher', [
'vouchers' => $this->voucherModel->where('is_active', 1)->findAll(),
'vouchers' => $this->voucherModel
->where('is_active', 1)
->where('school_year', $this->schoolYear)
->findAll(),
'parents' => $parents,
]);
}
@@ -464,7 +469,7 @@ class DiscountController extends BaseController
->orWhere('enrollment_status', 'Payment pending')
->orWhere('enrollment_status', 'Payment Pending')
->orWhere('enrollment_status', 'PAYMENT PENDING')
->orWhere("LOWER(TRIM(REPLACE(enrollment_status, CHAR(160), ' '))) = 'payment pending'", null, false)
->orWhere("LOWER(TRIM(REPLACE(enrollment_status, CONVERT(0xC2A0 USING utf8mb4), ' '))) = 'payment pending'", null, false)
->groupEnd();
if (!empty($paidEnrollmentIds)) {
@@ -524,13 +529,20 @@ class DiscountController extends BaseController
public function listVouchers()
{
$this->schoolYear = $this->activeSchoolYearName();
$vouchers = $this->voucherModel
->where('school_year', $this->schoolYear)
->orderBy('code', 'ASC')
->findAll();
$vouchers = $this->voucherModel->findAll();
return view('discounts/list', ['vouchers' => $vouchers]);
}
public function createVoucher()
{
$this->schoolYear = $this->activeSchoolYearName();
if (strtolower($this->request->getMethod()) === 'post') {
// -------- Gather & normalize inputs --------
@@ -606,6 +618,7 @@ class DiscountController extends BaseController
'valid_until' => $validUntil,
'is_active' => $isActive,
'description' => $description, // <-- NEW
'school_year' => $this->schoolYear,
];
if ($this->voucherModel->save($data)) {
@@ -624,7 +637,11 @@ class DiscountController extends BaseController
public function editVoucher($id)
{
$voucher = $this->voucherModel->find($id);
$this->schoolYear = $this->activeSchoolYearName();
$voucher = $this->voucherModel
->where('school_year', $this->schoolYear)
->find($id);
if (!$voucher) {
return redirect()->to('discounts/list')->with('error', 'Voucher not found');
@@ -640,6 +657,7 @@ class DiscountController extends BaseController
'valid_from' => $this->request->getPost('valid_from') ?: null,
'valid_until' => $this->request->getPost('valid_until') ?: null,
'is_active' => $this->request->getPost('is_active') ? 1 : 0,
'school_year' => $this->schoolYear,
];
$this->voucherModel->save($data);
@@ -649,6 +667,15 @@ class DiscountController extends BaseController
return view('discounts/edit', ['voucher' => $voucher]);
}
private function activeSchoolYearName(): string
{
try {
return service('schoolYearContext')->active()->yearName();
} catch (\Throwable) {
return (string) ($this->configModel->getConfig('school_year') ?? '');
}
}
/**
* 🔄 Helper: Current invoice balance (school-year scoped) = total - payments - discounts - refundsPaid
*/
+1 -1
View File
@@ -67,7 +67,7 @@ class EventController extends ResourceController
$this->invoiceLedgerService = new InvoiceLedgerService();
$this->schoolYear = $this->currentSchoolYearName();
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->categories = [
'workshops',
'orientations',
+2 -2
View File
@@ -65,7 +65,7 @@ class ExamDraftController extends BaseController
$this->db = Database::connect();
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
$this->semester = (string) (getSemester() ?? '');
$this->hasFinalPdfColumn = $this->schemaHasColumn('exam_drafts', 'final_pdf_file');
$this->hasIsLegacyColumn = $this->schemaHasColumn('exam_drafts', 'is_legacy');
$this->hasAcceptanceTypeColumn = $this->schemaHasColumn('exam_drafts', 'acceptance_type');
@@ -882,7 +882,7 @@ class ExamDraftController extends BaseController
private function syncAcademicContext(): void
{
$this->schoolYear = $this->currentSchoolYearName((string) ($this->configModel->getConfig('school_year') ?? ''));
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
$this->semester = (string) (getSemester() ?? '');
}
private function applyExamDraftYearScope($query): void
+8 -2
View File
@@ -26,7 +26,7 @@ class ExpenseController extends BaseController
$this->financialAttachmentService = new FinancialAttachmentService();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
// Default list of common retailors; adjust as needed
$this->retailors = [
@@ -96,6 +96,8 @@ class ExpenseController extends BaseController
public function index()
{
$schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
$expenses = $this->expenseModel
->select("
expenses.*,
@@ -104,6 +106,7 @@ class ExpenseController extends BaseController
")
->join('users u', 'u.id = expenses.purchased_by', 'left')
->join('users approver', 'approver.id = expenses.approved_by', 'left')
->where('expenses.school_year', $schoolYear)
->orderBy('expenses.created_at', 'DESC')
->findAll();
@@ -115,7 +118,10 @@ class ExpenseController extends BaseController
return $row;
}, $expenses);
return view('expenses/index', ['expenses' => $expenses]);
return view('expenses/index', [
'expenses' => $expenses,
'schoolYear' => $schoolYear,
]);
}
public function create()
@@ -42,7 +42,7 @@ class ExtraChargesController extends BaseController
$this->additionalChargeModel = new AdditionalChargeModel();
$this->userModel = new UserModel();
$this->invoiceModel = new InvoiceModel();
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->invoiceLedgerService = new InvoiceLedgerService();
$this->invoiceAdjustmentService = new InvoiceAdjustmentService($this->db);
+1 -1
View File
@@ -27,7 +27,7 @@ class FinalController extends BaseController
$this->studentModel = new StudentModel();
$this->teacherClassModel = new TeacherClassModel();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->gradingLockModel = new GradingLockModel();
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
}
+1 -1
View File
@@ -367,7 +367,7 @@ class FlagController extends Controller
$userId = session()->get('user_id');
// Get the semester and school year from configuration
$semester = $configModel->getConfig('semester');
$semester = getSemester();
$schoolYear = $configModel->getConfig('school_year');
// Set the current system date and time for flag_datetime
+6 -3
View File
@@ -67,7 +67,7 @@ class GradingController extends BaseController
$this->configModel = new ConfigurationModel();
$this->db = \Config\Database::connect();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->classSection = new ClassSectionModel();
$this->attendanceCalculator = new AttendanceCalculator(
new AttendanceRecordModel(),
@@ -93,7 +93,7 @@ class GradingController extends BaseController
$configModel = new ConfigurationModel();
$schoolYear = $configModel->getConfig('school_year');
$semester = $configModel->getConfig('semester');
$semester = getSemester();
$student = $studentModel->find($studentId);
$scores = $scoreModel->where([
@@ -140,7 +140,7 @@ class GradingController extends BaseController
$studentModel = new StudentModel();
$schoolYear = $configModel->getConfig('school_year');
$semester = $configModel->getConfig('semester');
$semester = getSemester();
$model = $this->getModelByType($type);
$classSectionIdInt = (int) ($classSectionId ?? 0);
@@ -433,6 +433,7 @@ class GradingController extends BaseController
// Build structures keyed by BUSINESS section id
$grades = []; // class_id => [ ['class_section_id','class_section_name'], ... ]
$studentsBySection = []; // section_id => [ students... ]
$seenStudentsBySection = [];
foreach ($rows as $r) {
$sectionId = (int) ($r['section_id'] ?? 0); // BUSINESS id
@@ -457,6 +458,8 @@ class GradingController extends BaseController
$sid = (int) ($r['student_id'] ?? 0);
if ($sid <= 0) continue;
if (isset($seenStudentsBySection[$sectionId][$sid])) continue;
$seenStudentsBySection[$sectionId][$sid] = true;
$ptapScore = $r['ss_ptap_score'] ?? null;
$semesterScore = $r['ss_semester_score'] ?? null;
+1 -1
View File
@@ -42,7 +42,7 @@ class HomeworkController extends BaseController
$this->configModel = new ConfigurationModel();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
// Log the service initialization
log_message('debug', 'Initializing SemesterScoreService');
@@ -26,7 +26,7 @@ class HomeworkTrackingController extends BaseController
$this->homeworkModel = new HomeworkModel();
$this->db = \Config\Database::connect();
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
$this->semester = (string) (getSemester() ?? '');
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
}
+1 -1
View File
@@ -44,7 +44,7 @@ class InventoryController extends BaseController
$this->teacherClassModel = new TeacherClassModel();
$this->studentClassModel = new StudentClassModel();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->classSectionModel = new ClassSectionModel();
$this->db = \Config\Database::connect();
$this->teacherModel = new TeacherModel();
+146 -29
View File
@@ -80,7 +80,7 @@ class InvoiceController extends ResourceController
$this->gradeFee = $this->configModel->getConfig('grade_fee');
$this->schoolYear = $this->currentSchoolYearName();
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->dueDate = $this->configModel->getConfig('first_day_of_school')
?: $this->configModel->getConfig('due_date');
$this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 380);
@@ -136,16 +136,9 @@ class InvoiceController extends ResourceController
: ($invoice['updated_at'] ?? null);
$parentData['invoice_id'] = $invoice['id'];
// ✅ Fetch refund amount actually PAID this year (Partial/Paid)
$refund = $this->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS refund_paid_amount')
->where('parent_id', $parent['id'])
->where('school_year', $schoolYear)
->whereIn('status', ['Partial','Paid'])
->get()
->getRowArray();
$parentData['refund_amount'] = (float)($refund['refund_paid_amount'] ?? 0.0);
$refundSummary = $this->paidRefundSummaryForParentYear((int) $parent['id'], (string) $schoolYear);
$parentData['refund_amount'] = $refundSummary['amount'];
$parentData['refund_details'] = $refundSummary['details'];
log_message('info', "Latest invoice for parent {$parent['firstname']} {$parent['lastname']} in school year $schoolYear: Amount = {$invoice['total_amount']}, Updated at = {$invoice['updated_at']}");
} else {
@@ -230,6 +223,120 @@ class InvoiceController extends ResourceController
}
}
private function paidRefundSummaryForParentYear(int $parentId, string $schoolYear): array
{
$refund = $this->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS refund_paid_amount')
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->whereIn('status', ['Partial', 'Paid'])
->get()
->getRowArray();
$details = [];
if ($this->db->tableExists('refund_payouts')) {
$details = $this->db->table('refund_payouts rp')
->select('rp.amount_cents, rp.payment_method, rp.check_number, rp.processed_at, rp.created_at')
->join('refunds r', 'r.id = rp.refund_id', 'inner')
->where('r.parent_id', $parentId)
->where('r.school_year', $schoolYear)
->where('rp.payout_type', 'cash_out')
->whereIn('rp.status', ['completed', 'processing'])
->orderBy('COALESCE(rp.processed_at, rp.created_at)', 'DESC', false)
->get()
->getResultArray();
$details = array_map(static function (array $row): array {
return [
'amount' => ((int) ($row['amount_cents'] ?? 0)) / 100,
'date' => $row['processed_at'] ?? $row['created_at'] ?? null,
'method' => $row['payment_method'] ?? '',
'check_number' => $row['check_number'] ?? '',
];
}, $details);
}
if (empty($details)) {
$legacyRows = $this->db->table('refunds')
->select('refund_paid_amount, refund_method, check_nbr, refunded_at, updated_at')
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->whereIn('status', ['Partial', 'Paid'])
->where('refund_paid_amount >', 0)
->orderBy('COALESCE(refunded_at, updated_at)', 'DESC', false)
->get()
->getResultArray();
$details = array_map(static function (array $row): array {
return [
'amount' => (float) ($row['refund_paid_amount'] ?? 0),
'date' => $row['refunded_at'] ?? $row['updated_at'] ?? null,
'method' => $row['refund_method'] ?? '',
'check_number' => $row['check_nbr'] ?? '',
];
}, $legacyRows);
}
return [
'amount' => (float) ($refund['refund_paid_amount'] ?? 0.0),
'details' => $details,
];
}
private function paidRefundDetailsForInvoice(int $invoiceId): array
{
$details = [];
if ($this->db->tableExists('refund_payouts')) {
$rows = $this->db->table('refund_payouts rp')
->select('rp.amount_cents, rp.payment_method, rp.check_number, rp.processed_at, rp.created_at, rp.payout_type')
->join('refunds r', 'r.id = rp.refund_id', 'inner')
->where('r.invoice_id', $invoiceId)
->whereIn('rp.payout_type', ['cash_out', 'reversal'])
->where('rp.status', 'completed')
->orderBy('COALESCE(rp.processed_at, rp.created_at)', 'ASC', false)
->get()
->getResultArray();
foreach ($rows as $row) {
$amount = ((int) ($row['amount_cents'] ?? 0)) / 100;
if (($row['payout_type'] ?? '') === 'reversal') {
$amount *= -1;
}
$details[] = [
'amount' => $amount,
'date' => $row['processed_at'] ?? $row['created_at'] ?? null,
'method' => $row['payment_method'] ?? '',
'check_number' => $row['check_number'] ?? '',
'type' => $row['payout_type'] ?? 'cash_out',
];
}
}
if (empty($details)) {
$rows = $this->db->table('refunds')
->select('refund_paid_amount, refund_method, check_nbr, refunded_at, updated_at')
->where('invoice_id', $invoiceId)
->whereIn('status', ['Partial', 'Paid'])
->where('refund_paid_amount >', 0)
->orderBy('COALESCE(refunded_at, updated_at)', 'ASC', false)
->get()
->getResultArray();
foreach ($rows as $row) {
$details[] = [
'amount' => (float) ($row['refund_paid_amount'] ?? 0),
'date' => $row['refunded_at'] ?? $row['updated_at'] ?? null,
'method' => $row['refund_method'] ?? '',
'check_number' => $row['check_nbr'] ?? '',
'type' => 'cash_out',
];
}
}
return $details;
}
private function assertSchoolYearNameWritable(string $schoolYear): void
{
service('schoolYearWriteGuard')->assertWritable(
@@ -297,14 +404,9 @@ class InvoiceController extends ResourceController
}
$parentData['invoice_id'] = $invoice['id'] ?? null;
// Refund total paid for parent/year (Partial/Paid)
$refund = $this->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS refund_paid_amount')
->where('parent_id', $parent['id'])
->where('school_year', $schoolYear)
->whereIn('status', ['Partial','Paid'])
->get()->getRowArray();
$parentData['refund_amount'] = (float)($refund['refund_paid_amount'] ?? 0.0);
$refundSummary = $this->paidRefundSummaryForParentYear((int) $parent['id'], (string) $schoolYear);
$parentData['refund_amount'] = $refundSummary['amount'];
$parentData['refund_details'] = $refundSummary['details'];
break; // only most recent as before
}
}
@@ -503,15 +605,6 @@ class InvoiceController extends ResourceController
log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}.");
$updated = true;
} else {
// Generate invoice number
$schoolId = $this->userModel->getSchoolIdByUserId($parentId);
if (!empty($schoolId)) {
$invoiceNumber = 'INV-' . $schoolId . '-' . uniqid();
} else {
log_message('warning', "No school ID found for parent_id {$parentId}, generating fallback invoice number.");
$invoiceNumber = uniqid('INV-');
}
$issueUtc = (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s');
// Due date: interpret the date in configured/user local TZ,
@@ -527,7 +620,7 @@ class InvoiceController extends ResourceController
try {
$issueResult = $this->invoiceIssuanceService->issueInvoice(new IssueInvoiceCommand([
'parent_id' => $parentId,
'invoice_number' => $invoiceNumber,
'invoice_number' => $this->invoiceIssuanceService->generateInvoiceNumber($schoolYear, (int)$parentId),
'total_amount' => $totalAmount,
'paid_amount' => 0,
'balance' => $totalAmount,
@@ -894,6 +987,7 @@ class InvoiceController extends ResourceController
->findAll();
$refundsPaidTotal = (float) ($ledger['refund_paid_total'] ?? 0.0);
$refundDetails = $this->paidRefundDetailsForInvoice((int) $invoiceId);
/* ============================================================
* ADDITIONAL CHARGES (itemized) for this invoice
@@ -964,6 +1058,7 @@ class InvoiceController extends ResourceController
'additionalChargeLines' => $additionalChargeLines,
'invoiceLines' => $invoiceLines,
'refundsPaidTotal' => $refundsPaidTotal,
'refundDetails' => $refundDetails,
'ledger' => $ledger,
];
}
@@ -1265,6 +1360,28 @@ class InvoiceController extends ResourceController
$push($dt, $desc, -1 * $amt, 'discount');
}
// --- Refund payouts (positive) integrated into the timeline
foreach (($refundDetails ?? []) as $refund) {
$amount = (float)($refund['amount'] ?? 0.0);
if (abs($amount) < 0.00001) {
continue;
}
$dt = $toLocal($refund['date'] ?? null, true);
$method = trim((string)($refund['method'] ?? ''));
$checkNumber = trim((string)($refund['check_number'] ?? ''));
$isReversal = (string)($refund['type'] ?? 'cash_out') === 'reversal';
$desc = $isReversal ? 'Refund reversal' : 'Refund paid';
if ($method !== '') {
$desc .= ' (' . $method . ')';
}
if ($checkNumber !== '') {
$desc .= ' - Check #' . $checkNumber;
}
$push($dt, $desc, $amount, 'refund');
}
// --- Sort by exact timestamp, then by insertion sequence for stability
usort($transactions, function ($a, $b) {
// Different days: keep chronological by timestamp
@@ -56,7 +56,7 @@ class LandingPageController extends BaseController
// Fetch Enrollment and Refund Deadlines from Configuration
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->schoolYear = $this->selectedSchoolYearName((string) ($this->schoolYear ?? ''));
$this->lastDayOfRegistration = $this->configModel->getConfig('enrollment_deadline') ?? 'Not set';
$this->refundDeadline = $this->configModel->getConfig('refund_deadline') ?? 'Not set';
@@ -22,7 +22,7 @@ class LateSlipLogsController extends BaseController
$req = $this->request;
$defaultYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
$defaultSem = (string) ($this->configModel->getConfig('semester') ?? '');
$defaultSem = (string) (getSemester() ?? '');
$schoolYear = trim((string) ($req->getGet('school_year') ?? $defaultYear));
$semester = trim((string) ($req->getGet('semester') ?? $defaultSem));
$q = trim((string) ($req->getGet('q') ?? ''));
+1 -1
View File
@@ -36,7 +36,7 @@ class MidtermController extends BaseController
$this->teacherClassModel = new TeacherClassModel();
$this->semesterScoreService = service('semesterScoreService');
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->gradingLockModel = new GradingLockModel();
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
}
@@ -247,7 +247,7 @@ class ParentAttendanceReportController extends BaseController
// Upper bound already enforced by $validDates building
$semester = (string) $this->configModel->getConfig('semester');
$semester = (string) getSemester();
$semesterResolver = new SemesterRangeService($this->configModel);
$inserted = 0;
@@ -996,7 +996,7 @@ class ParentAttendanceReportController extends BaseController
: (string)$this->configModel->getConfig('school_year');
$semester = (is_string($semesterParam) && $semesterParam !== '')
? (string)$semesterParam
: (string)$this->configModel->getConfig('semester');
: (string) getSemester();
$rows = $this->reportModel->listForDateRange($start, $end ?: null, $schoolYear, $semester);
@@ -1306,7 +1306,7 @@ class ParentAttendanceReportController extends BaseController
}
$schoolYear = (string)$this->configModel->getConfig('school_year');
$semester = (string)$this->configModel->getConfig('semester');
$semester = (string) getSemester();
// Fetch students with their current-year class section (if any)
try {
@@ -1362,7 +1362,7 @@ class ParentAttendanceReportController extends BaseController
}
$schoolYear = (string)$this->configModel->getConfig('school_year');
$semester = (string)$this->configModel->getConfig('semester');
$semester = (string) getSemester();
// Resolve parent_id of the student
$stu = $this->studentModel->select('id, parent_id, firstname, lastname')->find($studentId);
+34 -9
View File
@@ -86,7 +86,7 @@ class ParentController extends BaseController
$this->schoolStartDate = $this->configModel->getConfig('fall_semester_start');
$this->withdrawalDeadline = $this->configModel->getConfig('refund_deadline');
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->dateAgeReference = $this->configModel->getConfig('date_age_reference');
$this->maxChilds = (int) $this->configModel->getConfig('max_kids') ?? 0;
$this->maxEmergency = (int) $this->configModel->getConfig('max_emergency') ?? 0;
@@ -548,7 +548,7 @@ class ParentController extends BaseController
if ($existingEnrollment['is_withdrawn'] == 1) {
// Reactivate the enrollment if the student was previously withdrawn
$this->enrollmentModel->where('id', $existingEnrollment['id'])->update($update);
$this->enrollmentModel->update((int) $existingEnrollment['id'], $update);
log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) has been re-enrolled in enrollment ID {$existingEnrollment['id']}.");
// Apply promotion-based class placement for the upcoming year
$this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment);
@@ -557,7 +557,7 @@ class ParentController extends BaseController
if ($currentStatus === 'enrolled') {
$update['admission_status'] = 'accepted';
}
$this->enrollmentModel->where('id', $existingEnrollment['id'])->update($update);
$this->enrollmentModel->update((int) $existingEnrollment['id'], $update);
log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) is already actively enrolled.");
$this->applyPromotionAssignment((int)$studentId, $selectedYear, $isReturningReEnrollment);
}
@@ -642,7 +642,7 @@ class ParentController extends BaseController
if ($enrollment !== null) {
// Update enrollment as withdrawn
$this->enrollmentModel->where('id', $enrollment['id'])->update([
$this->enrollmentModel->update((int) $enrollment['id'], [
'withdrawal_date' => local_date(utc_now(), 'Y-m-d'),
'enrollment_status' => 'withdraw under review', // Withdrawal needs review
'updated_at' => utc_now()
@@ -660,6 +660,12 @@ class ParentController extends BaseController
if ($invoice !== null) {
$invoiceId = $invoice['id'];
$studentsForRefund = $this->enrollmentModel
->where('parent_id', $parentId)
->where('school_year', $this->schoolYear)
->findAll();
$refundAmount = $refundService->calculateRefund($studentsForRefund, (int) $parentId);
$refundCents = max(0, (int) round($refundAmount * 100));
$refundTable = $this->db->table('refunds');
@@ -667,21 +673,29 @@ class ParentController extends BaseController
->where('parent_id', $parentId)
->where('invoice_id', $invoiceId)
->where('school_year', $this->schoolYear)
->whereIn('status', ['Pending', 'Approved', 'Partial', 'pending', 'requested', 'approved', 'partial', 'partially_paid'])
->get()
->getRow();
if ($existingRefund) {
// Only update the fields that should change
$updateData = [
'refund_amount' => $refundAmount,
'requested_amount_cents' => $refundCents,
'currency' => 'USD',
'reason' => 'Withdrawal under review for student ID ' . $studentId,
'note' => null,
'request' => 'tuition',
'source_type' => 'tuition_withdrawal',
'source_id' => (int) $invoiceId,
'status' => 'Pending',
'updated_by' => session()->get('user_id'), // optionally track updates
// Add other fields if *and only if* they must be changed
];
$refundTable
->where('id', $existingRefund->id)
->update($updateData);
->update($this->filterPayloadByTableColumns($updateData, 'refunds'));
log_message('info', "Refund record updated for invoice ID {$invoiceId}, student ID {$studentId}.");
} else {
@@ -689,16 +703,22 @@ class ParentController extends BaseController
$insertData = [
'parent_id' => $parentId,
'invoice_id' => $invoiceId,
'refund_amount' => $refundAmount,
'requested_amount_cents' => $refundCents,
'approved_amount_cents' => null,
'currency' => 'USD',
'requested_at' => utc_now(),
'school_year' => $this->schoolYear,
'status' => 'Pending review',
'status' => 'Pending',
'reason' => 'Withdrawal under review for student ID ' . $studentId,
'request' => 'new',
'request' => 'tuition',
'source_type' => 'tuition_withdrawal',
'source_id' => (int) $invoiceId,
'semester' => $this->semester,
'refund_paid_amount' => 0.0,
];
$refundTable->insert($insertData);
$refundTable->insert($this->filterPayloadByTableColumns($insertData, 'refunds'));
log_message('info', "Refund record created for invoice ID {$invoiceId}, student ID {$studentId}.");
}
@@ -893,9 +913,14 @@ class ParentController extends BaseController
}
private function filterEnrollmentPayloadByColumns(array $payload): array
{
return $this->filterPayloadByTableColumns($payload, 'enrollments');
}
private function filterPayloadByTableColumns(array $payload, string $table): array
{
foreach (array_keys($payload) as $column) {
if (! $this->db->fieldExists($column, 'enrollments')) {
if (! $this->db->fieldExists($column, $table)) {
unset($payload[$column]);
}
}
@@ -37,7 +37,7 @@ class ParticipationController extends BaseController
$this->semesterScoreService = service('semesterScoreService');
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->gradingLockModel = new GradingLockModel();
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
}
+43 -20
View File
@@ -64,7 +64,7 @@ class PaymentController extends ResourceController
$this->studentModel = new StudentModel();
$this->studentClassModel = new StudentClassModel();
$this->schoolYear = $this->currentSchoolYearName();
$this->semester = $this->configModel->getConfig('semester'); //installment_date
$this->semester = getSemester(); //installment_date
$this->installmentDate = $this->configModel->getConfig('installment_date');
$this->discountUsageModel = new DiscountUsageModel();
$this->additionalChargeModel = new AdditionalChargeModel();
@@ -151,6 +151,15 @@ class PaymentController extends ResourceController
}
}
private function activeSchoolYearName(): string
{
try {
return service('schoolYearContext')->active()->yearName();
} catch (\Throwable $e) {
return (string) ($this->configModel->getConfig('school_year') ?? '');
}
}
private function assertSchoolYearNameWritable(string $schoolYear): void
{
service('schoolYearWriteGuard')->assertWritable(
@@ -323,6 +332,7 @@ class PaymentController extends ResourceController
// Read the search term (email or phone)
$searchTerm = trim((string) $this->request->getGet('search_term'));
$manualPaySchoolYear = $this->activeSchoolYearName();
// --- Installment end date comes ONLY from config ---
$installmentDateRaw = (string) ($this->installmentDate ?? '');
@@ -338,29 +348,36 @@ class PaymentController extends ResourceController
$searchClean = preg_replace('/\D/', '', $searchTerm);
$searchFormatted = $this->formatPhone($searchClean);
$builder = $this->db->table('users');
$builder->select('*');
$builder = $this->db->table('users u');
$builder->distinct();
$builder->select('u.*');
$builder->join('user_roles ur', 'ur.user_id = u.id', 'inner');
$builder->join('roles r', 'r.id = ur.role_id', 'inner');
$builder->where('LOWER(r.name)', 'parent');
// Build search conditions
$builder->groupStart();
$builder->where('email', $searchTerm);
$builder->where('u.email', $searchTerm);
$builder->orLike("CONCAT_WS(' ', u.firstname, u.lastname)", $searchTerm, 'both', null, true);
$builder->orLike('u.firstname', $searchTerm);
$builder->orLike('u.lastname', $searchTerm);
if (!empty($searchClean)) {
if (strlen($searchClean) === 10) {
$formattedPhone = substr($searchClean, 0, 3) . '-' . substr($searchClean, 3, 3) . '-' . substr($searchClean, 6, 4);
$builder->orWhere('cellphone', $formattedPhone);
$builder->orWhere('u.cellphone', $formattedPhone);
}
// compare stripped formatting
$builder->orWhere(
"REPLACE(REPLACE(REPLACE(REPLACE(cellphone, '(', ''), ')', ''), '-', ''), ' ', '') =",
"REPLACE(REPLACE(REPLACE(REPLACE(u.cellphone, '(', ''), ')', ''), '-', ''), ' ', '') =",
$this->db->escapeString($searchClean),
false
);
}
if (!empty($searchFormatted)) {
$builder->orWhere('cellphone', $searchFormatted);
$builder->orWhere('u.cellphone', $searchFormatted);
}
$builder->groupEnd();
@@ -374,7 +391,7 @@ class PaymentController extends ResourceController
if ($parent && !empty($parent['id'])) {
$parentData = $parent;
$parentId = (int) $parent['id'];
$carryForwardPaymentRequired = $this->parentHasActiveCarryForwardBalance($parentId, $this->schoolYear);
$carryForwardPaymentRequired = $this->parentHasActiveCarryForwardBalance($parentId, $manualPaySchoolYear);
if ($carryForwardPaymentRequired) {
$carryForwardPaymentMessage = 'This parent has a balance carried over from a previous school year. Manual payments must be paid in full; installments are not allowed.';
}
@@ -386,14 +403,14 @@ class PaymentController extends ResourceController
// Payments (paginated). Join invoices so history is filtered by invoice term
// and displays current invoice state instead of stale payment snapshots.
$selectedYear = $this->getSelectedPaymentHistoryYear();
$paymentHistory = $this->paymentModel->parentPaymentHistoryQuery($parentId, $selectedYear);
$paymentHistory = $this->paymentModel->parentPaymentHistoryQuery($parentId, $manualPaySchoolYear);
$payments = $paymentHistory->paginate(10);
$pager = $paymentHistory->pager;
// Invoices
$rawInvoices = $this->invoiceModel
->where('parent_id', $parentId) // <- fixed (removed "value:")
->where('parent_id', $parentId)
->where('school_year', $manualPaySchoolYear)
->orderBy('issue_date', 'DESC')
->findAll();
@@ -460,21 +477,23 @@ class PaymentController extends ResourceController
$qs = '%' . $db->escapeLikeString($q) . '%';
$digits = preg_replace('/\D/', '', $q);
$sql = "SELECT id, firstname, lastname, email, cellphone
FROM users
$sql = "SELECT DISTINCT u.id, u.firstname, u.lastname, u.email, u.cellphone
FROM users u
JOIN user_roles ur ON ur.user_id = u.id
JOIN roles r ON r.id = ur.role_id AND LOWER(r.name) = 'parent'
WHERE (
CONCAT_WS(' ', firstname, lastname) LIKE ?
OR email LIKE ?
OR cellphone LIKE ?";
CONCAT_WS(' ', u.firstname, u.lastname) LIKE ?
OR u.email LIKE ?
OR u.cellphone LIKE ?";
$params = [$qs, $qs, $qs];
if ($digits !== '') {
$sql .= " OR REPLACE(REPLACE(REPLACE(REPLACE(cellphone, '(', ''), ')', ''), '-', ''), ' ', '') LIKE ?";
$sql .= " OR REPLACE(REPLACE(REPLACE(REPLACE(u.cellphone, '(', ''), ')', ''), '-', ''), ' ', '') LIKE ?";
$params[] = '%' . $digits . '%';
}
$sql .= ")
ORDER BY lastname, firstname
ORDER BY u.lastname, u.firstname
LIMIT 20";
$rows = $db->query($sql, $params)->getResultArray();
@@ -562,7 +581,7 @@ class PaymentController extends ResourceController
->orWhere('enrollment_status', 'Payment pending')
->orWhere('enrollment_status', 'Payment Pending')
->orWhere('enrollment_status', 'PAYMENT PENDING')
->orWhere("LOWER(TRIM(REPLACE(enrollment_status, CHAR(160), ' '))) = 'payment pending'", null, false)
->orWhere("LOWER(TRIM(REPLACE(enrollment_status, CONVERT(0xC2A0 USING utf8mb4), ' '))) = 'payment pending'", null, false)
->groupEnd();
if ($semester !== null) {
@@ -1457,12 +1476,15 @@ class PaymentController extends ResourceController
if ($amount > $preBalance + 0.00001) {
return false;
}
$postBalance = max(0.0, round($preBalance - $amount, 2));
$paymentData = [
'parent_id' => (int) $invoice['parent_id'],
'invoice_id' => $invoiceId,
'total_amount' => $invoice['total_amount'],
'paid_amount' => $amount,
'balance' => null,
'balance' => $postBalance,
'balance_amount' => $postBalance,
'balance_after_payment' => $postBalance,
'number_of_installments' => $installmentSeq, // <-- installment sequence (1,2,3,...)
'installment_seq' => $installmentSeq,
'transaction_id' => $transactionId,
@@ -1480,6 +1502,7 @@ class PaymentController extends ResourceController
$paymentId = $this->paymentModel->insert($paymentData);
if (!$paymentId) {
log_message('error', '[processPayment] Failed to insert payment: ' . json_encode($this->paymentModel->errors()));
return false;
}
@@ -58,7 +58,7 @@ class PrintablesBaseController extends BaseController
$this->attendanceRecordModel = new AttendanceRecordModel();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->stickerWidth = $this->configModel->getConfig('stickerWidth');
$this->stickerHeight = $this->configModel->getConfig('stickerHeight');
$this->pageW = $this->configModel->getConfig('pageWidth');
+1 -1
View File
@@ -45,7 +45,7 @@ class QuizController extends BaseController
$this->quizModel = new QuizModel();
$this->semesterScoreService = service('semesterScoreService');
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->gradingLockModel = new GradingLockModel();
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
}
+91 -17
View File
@@ -16,6 +16,7 @@ use App\Models\PaymentModel;
use App\Models\ConfigurationModel;
use App\Models\InvoiceModel;
use App\Models\EnrollmentModel;
use App\Services\FeeCalculationService;
use CodeIgniter\Exceptions\PageNotFoundException;
class RefundController extends BaseController
@@ -31,6 +32,7 @@ class RefundController extends BaseController
protected ParentLedgerService $parentLedgerService;
protected RefundEligibilityService $refundEligibilityService;
protected FinancialAttachmentService $financialAttachmentService;
protected FeeCalculationService $feeCalculationService;
protected $db;
// Allowed request types (mapped to your `refunds.request` column)
@@ -52,6 +54,7 @@ class RefundController extends BaseController
$this->parentLedgerService = new ParentLedgerService();
$this->refundEligibilityService = new RefundEligibilityService();
$this->financialAttachmentService = new FinancialAttachmentService();
$this->feeCalculationService = new FeeCalculationService();
$this->db = \Config\Database::connect();
}
@@ -70,6 +73,22 @@ class RefundController extends BaseController
}
}
private function refundStatusForStorage(string $status): string
{
return match (FinancialStatus::normalizeRefundStatus($status)) {
FinancialStatus::REFUND_APPROVED => 'Approved',
FinancialStatus::REFUND_REJECTED => 'Rejected',
FinancialStatus::REFUND_PARTIALLY_PAID => 'Partial',
FinancialStatus::REFUND_PAID => 'Paid',
default => 'Pending',
};
}
private function refundOpenStatuses(): array
{
return ['Pending', 'Approved', 'Partial', 'pending', 'requested', 'approved', 'partial', 'partially_paid'];
}
private function refundFailureResponse(
string $publicCode,
string $publicMessage,
@@ -89,12 +108,12 @@ class RefundController extends BaseController
]);
}
/** Get current term (school_year, semester) from configuration */
/** Get current school year from configuration and derive the current semester from calendar dates. */
private function getCurrentTerm(): array
{
$rows = $this->configModel
->select('config_key, config_value')
->whereIn('config_key', ['school_year','semester'])
->whereIn('config_key', ['school_year'])
->findAll();
$map = [];
@@ -103,7 +122,7 @@ class RefundController extends BaseController
}
return [
'school_year' => $map['school_year'] ?? date('Y') . '-' . (date('Y') + 1),
'semester' => $map['semester'] ?? 'Fall',
'semester' => getSemester(),
];
}
@@ -140,7 +159,7 @@ class RefundController extends BaseController
->where('invoice_id', $iid)
->where('source_type', 'invoice_overpayment')
->where('source_id', $iid)
->whereIn('status', ['Pending','Approved','Partial'])
->whereIn('status', $this->refundOpenStatuses())
->orderBy('id', 'DESC')
->first();
if ($openRow) {
@@ -161,7 +180,7 @@ class RefundController extends BaseController
->where('invoice_id', $iid)
->where('source_type', 'invoice_overpayment')
->where('source_id', $iid)
->whereIn('status', ['Pending','Approved','Partial','Paid'])
->whereIn('status', array_merge($this->refundOpenStatuses(), ['Paid', 'paid']))
->orderBy('id', 'DESC')
->first();
@@ -176,7 +195,7 @@ class RefundController extends BaseController
'approved_amount_cents' => null,
'currency' => 'USD',
'refund_paid_amount' => 0.00,
'status' => FinancialStatus::REFUND_REQUESTED,
'status' => $this->refundStatusForStorage(FinancialStatus::REFUND_REQUESTED),
'request' => 'overpayment',
'source_type' => 'invoice_overpayment',
'source_id' => $iid,
@@ -393,7 +412,7 @@ class RefundController extends BaseController
'approved_amount_cents' => null,
'currency' => 'USD',
'refund_paid_amount' => 0.00, // IMPORTANT: your column is NOT NULL
'status' => FinancialStatus::REFUND_REQUESTED,
'status' => $this->refundStatusForStorage(FinancialStatus::REFUND_REQUESTED),
'reason' => $reason,
'request' => $requestType, // <- store the source/type here
'source_type' => $sourceType,
@@ -470,7 +489,7 @@ class RefundController extends BaseController
$this->refundEligibilityService->validateRequestedAmount($eligibility, $requestedCents);
$ok = $this->refundModel->update($refundId, [
'status' => FinancialStatus::REFUND_APPROVED,
'status' => $this->refundStatusForStorage(FinancialStatus::REFUND_APPROVED),
'approved_amount_cents' => $requestedCents,
'approved_at' => utc_now(),
'approved_by' => session()->get('user_id'),
@@ -513,7 +532,7 @@ class RefundController extends BaseController
}
$ok = $this->refundModel->update($refundId, [
'status' => FinancialStatus::REFUND_REJECTED,
'status' => $this->refundStatusForStorage(FinancialStatus::REFUND_REJECTED),
'reason' => $this->request->getPost('reason') ?: ($refund['reason'] ?? null),
'approved_at' => utc_now(),
'approved_by' => session()->get('user_id'),
@@ -697,7 +716,7 @@ class RefundController extends BaseController
];
if (!$isOnline) {
$refundProjection['refund_paid_amount'] = $total;
$refundProjection['status'] = $newStatus;
$refundProjection['status'] = $this->refundStatusForStorage($newStatus);
$refundProjection['refunded_at'] = utc_now();
}
if (!$this->refundModel->update($refundId, $refundProjection)) {
@@ -865,7 +884,7 @@ class RefundController extends BaseController
if (!$this->refundModel->update($refundId, [
'refund_paid_amount' => $netPaidCents / 100,
'status' => $newStatus,
'status' => $this->refundStatusForStorage($newStatus),
'refunded_at' => $netPaidCents > 0 ? ($refund['refunded_at'] ?? utc_now()) : null,
'updated_at' => utc_now(),
'updated_by' => session()->get('user_id'),
@@ -912,15 +931,17 @@ class RefundController extends BaseController
/** Keep your listing; added extra fields for clarity */
public function listRefunds()
{
// NOTE: We no longer auto-create/adjust refunds on page load to avoid duplicate lines
// when staff are recording payouts. Use the "Recalculate" buttons to run detection on demand.
// Repair legacy withdrawal placeholders only; overpayment recalculation remains explicit.
$this->repairPendingWithdrawalRefunds();
// 2) List refunds with joins
$refunds = $this->refundModel
->select('refunds.*,
i.invoice_number,
u.firstname, u.lastname, u.school_id,
a.firstname AS approved_by_firstname,
a.lastname AS approved_by_lastname')
->join('invoices i', 'refunds.invoice_id = i.id', 'left')
->join('users u', 'refunds.parent_id = u.id')
->join('users a', 'refunds.approved_by = a.id', 'left')
->orderBy('refunds.created_at', 'DESC')
@@ -970,6 +991,59 @@ class RefundController extends BaseController
]);
}
private function repairPendingWithdrawalRefunds(): void
{
try {
$rows = $this->refundModel
->groupStart()
->where('refund_amount <=', 0)
->orWhere('source_type IS NULL', null, false)
->orWhere("(request = 'tuition' AND source_type = 'invoice_overpayment')", null, false)
->orWhere('source_id IS NULL', null, false)
->groupEnd()
->whereIn('status', ['Pending', 'pending', 'requested'])
->like('reason', 'Withdrawal under review')
->findAll();
foreach ($rows as $refund) {
$parentId = (int)($refund['parent_id'] ?? 0);
$schoolYear = (string)($refund['school_year'] ?? '');
if ($parentId <= 0 || $schoolYear === '') {
continue;
}
$invoice = $this->invoiceModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->orderBy('created_at', 'DESC')
->first();
if (!$invoice) {
continue;
}
$students = $this->enrollmentModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->findAll();
$refundAmount = $this->feeCalculationService->calculateRefund($students, $parentId);
$refundCents = max(0, (int)round($refundAmount * 100));
$this->refundModel->update((int)$refund['id'], [
'invoice_id' => (int)$invoice['id'],
'refund_amount' => $refundAmount,
'requested_amount_cents' => $refundCents,
'currency' => 'USD',
'request' => 'tuition',
'source_type' => 'tuition_withdrawal',
'source_id' => (int)$invoice['id'],
'updated_at' => utc_now(),
]);
}
} catch (\Throwable $e) {
log_message('error', 'Pending withdrawal refund repair failed: ' . $e->getMessage());
}
}
/** Manual endpoint to recalculate/sync overpayments and optionally notify newly created entries. */
public function recalculateOverpayments()
{
@@ -991,7 +1065,7 @@ class RefundController extends BaseController
->where('invoice_id', $iid)
->where('source_type', 'invoice_overpayment')
->where('source_id', $iid)
->whereIn('status', ['Pending','Approved','Partial'])
->whereIn('status', $this->refundOpenStatuses())
->orderBy('id', 'DESC')
->first();
if ($openRow) {
@@ -1028,7 +1102,7 @@ class RefundController extends BaseController
'approved_amount_cents' => null,
'currency' => 'USD',
'refund_paid_amount' => 0.00,
'status' => FinancialStatus::REFUND_REQUESTED,
'status' => $this->refundStatusForStorage(FinancialStatus::REFUND_REQUESTED),
'request' => 'overpayment',
'source_type' => 'invoice_overpayment',
'source_id' => $iid,
@@ -1113,7 +1187,7 @@ class RefundController extends BaseController
}
$ok = $this->refundModel->update($refundId, [
'status' => $status,
'status' => $this->refundStatusForStorage($status),
'reason' => $reason,
'approved_amount_cents' => $approvedCents,
'approved_at' => utc_now(),
@@ -1188,7 +1262,7 @@ class RefundController extends BaseController
private function lockRefundSourceForUpdate(string $sourceType, int $sourceId, ?int $invoiceId): void
{
if ($sourceType === 'invoice_overpayment') {
if (in_array($sourceType, ['invoice_overpayment', 'tuition_withdrawal'], true)) {
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId ?: $sourceId])->getRowArray();
} elseif (in_array($sourceType, ['payment_duplicate', 'payment_correction'], true)) {
$payment = $this->db->query('SELECT * FROM payments WHERE id = ? FOR UPDATE', [$sourceId])->getRowArray();
+1 -1
View File
@@ -44,7 +44,7 @@ class RegisterController extends Controller
$this->parentModel = new ParentModel();
$this->policyAcceptanceModel = new ParentPolicyAcceptanceModel();
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->schoolYear = $this->configModel->getConfig('school_year');
}
@@ -50,7 +50,7 @@ class ReimbursementController extends BaseController
$this->batchItemModel = new ReimbursementBatchItemModel();
$this->batchAdminFileModel = new ReimbursementBatchAdminFileModel();
$this->semester = $this->configModel->getConfig('semester') ?? 'Fall';
$this->semester = getSemester() ?? 'Fall';
$this->schoolYear = $this->configModel->getConfig('school_year') ?? date('Y');
}
@@ -41,7 +41,7 @@ class RolePermissionController extends Controller
$this->request = \Config\Services::request();
$this->db = \Config\Database::connect();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
}
public function index()
@@ -28,7 +28,7 @@ class RoleSwitcherController extends BaseController
}
$this->configModel = new ConfigurationModel();
$this->userRoleModel = new UserRoleModel();
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->schoolYear = $this->configModel->getConfig('school_year');
}
@@ -45,7 +45,7 @@ class SchoolCalendarController extends BaseController
$this->meetingModel = new ParentMeetingScheduleModel();
$this->db = \Config\Database::connect();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
// Load helpers
helper(['form', 'url']);
@@ -39,7 +39,7 @@ class ScoreCommentController extends BaseController
$this->semesterScoreModel = new SemesterScoreModel();
$this->gradingLockModel = new GradingLockModel();
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->schoolYear = $this->configModel->getConfig('school_year');
}
+2 -2
View File
@@ -77,7 +77,7 @@ class ScoreController extends BaseController
$this->gradingLockModel = new GradingLockModel();
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
// Retrieve the configuration values
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->targetHigh = $this->configModel->getConfig('trophy_score');
$this->targetLow = $this->configModel->getConfig('pass_score');
@@ -1030,7 +1030,7 @@ class ScoreController extends BaseController
$fallStartCfg = (string)($this->configModel->getConfig('fall_semester_start') ?? '');
$fallEndCfg = (string)($this->configModel->getConfig('fall_end_date') ?? ''); // e.g., 'YYYY-01-15'
$springStartCfg = (string)($this->configModel->getConfig('spring_semester_start') ?? ''); // e.g., 'YYYY-01-16'
$springEndCfg = (string)($this->configModel->getConfig('last_school_day') ?? '');
$springEndCfg = (string)($this->configModel->getConfig('last_day_of_school') ?? '');
if ($norm === 'fall') {
$start = ($fallStartCfg !== '') ? date($y1 . '-m-d', strtotime($fallStartCfg)) : "{$y1}-09-01";
+1 -1
View File
@@ -36,7 +36,7 @@ class ScorePredictor extends BaseController
$this->classSectionModel = new ClassSectionModel();
// Retrieve the configuration values
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->targetTrophy = $this->configModel->getConfig('trophy_score');
$this->targetLow = $this->configModel->getConfig('pass_score');
@@ -28,7 +28,7 @@ class SlipPrinterController extends BaseController
{
helper(['form', 'url']);
$this->configModel = new ConfigurationModel();
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->schoolYear = $this->configModel->getConfig('school_year');
}
/**
@@ -66,7 +66,7 @@ class SlipPrinterController extends BaseController
try {
$printedBy = (int) (session()->get('user_id') ?? 0) ?: null;
$cfg = new ConfigurationModel();
$semester = (string)($cfg->getConfig('semester') ?? '');
$semester = (string)(getSemester() ?? '');
$logData = [
'school_year' => $data['school_year'],
'semester' => $semester,
@@ -325,7 +325,7 @@ class SlipPrinterController extends BaseController
try {
$printedBy = (int) (session()->get('user_id') ?? 0) ?: null;
$cfg = new ConfigurationModel();
$semester = (string)($cfg->getConfig('semester') ?? '');
$semester = (string)(getSemester() ?? '');
$logData = [
'school_year' => $data['school_year'],
'semester' => $semester,
+1 -1
View File
@@ -29,7 +29,7 @@ class StaffController extends BaseController
$this->staffDirectorySync = service('staffDirectorySync');
// Retrieve the configuration values
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->schoolYear = $this->configModel->getConfig('school_year');
}
+1 -1
View File
@@ -58,7 +58,7 @@ class StudentController extends BaseController
$this->classSectionModel = new ClassSectionModel();
$this->emergencyContact = new EmergencyContactModel();
$this->enrollmentModel = new EnrollmentModel();
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->schoolYear = $this->configModel->getConfig('school_year');
helper(['url', 'form']);
}
+1 -1
View File
@@ -55,7 +55,7 @@ class TeacherController extends BaseController
$this->staffAttendanceModel = new StaffAttendanceModel();
// Retrieve the configuration values
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
}
+1 -1
View File
@@ -51,7 +51,7 @@ class WhatsappController extends BaseController
$this->membershipModel = new WhatsappGroupMembershipModel();
$this->configModel = new ConfigurationModel();
$this->semester = $this->configModel->getConfig('semester');
$this->semester = getSemester();
$this->schoolYear = $this->configModel->getConfig('school_year');
}