attendance['timezone'] ?? (function_exists('user_timezone') ? user_timezone() : 'UTC')); $tz = new \DateTimeZone($tzName); $deadline = new \DateTimeImmutable($refundDeadlineRaw, $tz); } catch (\Throwable $e) { Log::warning('Refund deadline missing/invalid for tuition calc; withdrawn students will be billed.', [ 'error' => $e->getMessage(), ]); } $tuitionStudents = $registeredKids; foreach ($withdrawnKids as $kid) { $rawDate = $kid['withdrawal_date'] ?? null; if (empty($rawDate) || ! $deadline) { Log::warning('Missing withdrawal date or deadline; billing withdrawn student.', [ 'student_id' => $kid['student_id'] ?? null, ]); $tuitionStudents[] = $kid; continue; } try { $withdrawDate = new \DateTimeImmutable((string) $rawDate, $tz ?? new \DateTimeZone('UTC')); } catch (\Throwable $e) { Log::warning('Invalid withdrawal date; billing withdrawn student.', [ 'student_id' => $kid['student_id'] ?? null, ]); $tuitionStudents[] = $kid; continue; } if ($withdrawDate->format('Y-m-d') > $deadline->format('Y-m-d')) { $tuitionStudents[] = $kid; } } $tuitionStudents = array_values(array_filter($tuitionStudents, function ($student) use ($schoolYear) { $sid = (int) ($student['student_id'] ?? 0); return $sid > 0 && StudentClass::hasNonEventAssignment($sid, $schoolYear); })); foreach ($tuitionStudents as &$student) { $name = null; if (! empty($student['class_section_id'])) { $name = ClassSection::getClassSectionNameBySectionId($student['class_section_id']); } $student['grade_name'] = is_string($name) ? strtoupper(trim($name)) : 'N/A'; } unset($student); $gradeFee = (int) (Configuration::getConfig('grade_fee') ?? 9); $firstStudentFee = (float) (Configuration::getConfig('first_student_fee') ?? 350); $secondStudentFee = (float) (Configuration::getConfig('second_student_fee') ?? 200); $youthFee = (float) (Configuration::getConfig('youth_fee') ?? 180); $regularCount = 0; $youthCount = 0; $grades = new InvoiceGradeService($gradeFee); foreach ($tuitionStudents as $student) { $level = (int) ($grades->getGradeLevel($student['grade_name'] ?? '')['level'] ?? 999); if ($level > $gradeFee) { $youthCount++; } else { $regularCount++; } } $tuitionSubtotal = 0.0; $tuitionSubtotal += $youthCount * $youthFee; if ($regularCount >= 2) { $tuitionSubtotal += $firstStudentFee + ($regularCount - 1) * $secondStudentFee; } elseif ($regularCount === 1) { $tuitionSubtotal += $firstStudentFee; } return [ 'tuition_subtotal' => round($tuitionSubtotal, 2), 'tuition_students' => $tuitionStudents, 'regular_count' => $regularCount, 'youth_count' => $youthCount, ]; } public function calculateRefundDetails(array $students, int $parentId): array { $schoolYear = (string) (Configuration::getConfig('school_year') ?? ''); if ($schoolYear === '') { return ['ok' => false, 'error' => 'Missing school_year configuration.']; } $refundDeadlineRaw = (string) (Configuration::getConfig('refund_deadline') ?? ''); if ($refundDeadlineRaw === '') { return ['ok' => false, 'error' => 'Refund deadline is not configured.']; } try { $refundDeadline = (new \DateTimeImmutable($refundDeadlineRaw))->format('Y-m-d'); } catch (\Throwable $e) { return ['ok' => false, 'error' => 'Refund deadline is invalid.']; } $bookPriceRaw = Configuration::getConfig('book_price'); if ($bookPriceRaw === null || $bookPriceRaw === '') { return ['ok' => false, 'error' => 'Book price is not configured.']; } if (! is_numeric($bookPriceRaw)) { return ['ok' => false, 'error' => 'Book price must be numeric.']; } $bookPrice = round((float) $bookPriceRaw, 2); if ($bookPrice < 0) { return ['ok' => false, 'error' => 'Book price must be zero or greater.']; } $withdrawDates = []; $registered = []; $withdrawnEligible = []; $withdrawnLate = []; $hasWithdrawn = false; foreach ($students as $student) { $status = (string) ($student['enrollment_status'] ?? ''); if (in_array($status, ['enrolled', 'payment pending'], true)) { $registered[] = $student; continue; } if (! in_array($status, ['withdrawn', 'refund pending', 'withdraw under review'], true)) { continue; } $hasWithdrawn = true; $raw = $student['withdrawal_date'] ?? null; if (empty($raw)) { return ['ok' => false, 'error' => 'Missing withdrawal date for a withdrawn student.']; } try { $withdrawDate = (new \DateTimeImmutable((string) $raw))->format('Y-m-d'); $withdrawDates[] = $withdrawDate; if (strtotime($withdrawDate) <= strtotime($refundDeadline)) { $withdrawnEligible[] = $student; } else { $withdrawnLate[] = $student; } } catch (\Throwable $e) { return ['ok' => false, 'error' => 'Invalid withdrawal date for a withdrawn student.']; } } if (! $hasWithdrawn) { return ['ok' => false, 'error' => 'No withdrawn students found for refund evaluation.']; } $withdrawDateUsed = max($withdrawDates); if (count(array_unique($withdrawDates)) > 1) { Log::warning('Multiple withdrawal dates detected for refund calc; using latest date.', [ 'withdraw_date' => $withdrawDateUsed, 'parent_id' => $parentId, ]); } $totalPaid = Payment::getTotalPaidByParentId($parentId, $schoolYear); if ($totalPaid < 0) { return ['ok' => false, 'error' => 'Paid amount is invalid.']; } $totalPaid = round((float) $totalPaid, 2); $invoiceTotal = 0.0; $totalDiscount = 0.0; $fullDiscount = false; $invoice = Invoice::query() ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->orderByDesc('created_at') ->first(); if ($invoice) { $invoiceTotal = (float) ($invoice->total_amount ?? 0.0); $totalDiscount = DiscountUsage::getTotalDiscountByParentIdAndSchoolYear($parentId, $schoolYear); if ($invoiceTotal > 0 && $totalDiscount >= $invoiceTotal - 0.00001) { $fullDiscount = true; } } $eligible = ! empty($withdrawnEligible); $refundAmount = 0.0; $debitAmount = 0.0; $refundableTuition = 0.0; $remainingCount = 0; if ($eligible) { $totalStudentCount = (int) Student::query() ->where('parent_id', $parentId) ->distinct() ->count('id'); $firstStudentFee = (float) (Configuration::getConfig('first_student_fee') ?? 350); $secondStudentFee = (float) (Configuration::getConfig('second_student_fee') ?? 200); $withdrawnCount = count($withdrawnEligible); $remainingCount = max(0, $totalStudentCount - $withdrawnCount); if ($totalStudentCount >= 2 && $remainingCount >= 1) { $refundableTuition = round($secondStudentFee * $withdrawnCount, 2); } elseif ($totalStudentCount >= 2 && $remainingCount === 0) { $refundableTuition = round($firstStudentFee + max(0, $withdrawnCount - 1) * $secondStudentFee, 2); } else { $refundableTuition = round($firstStudentFee * $withdrawnCount, 2); } $refundableCash = min($totalPaid, $refundableTuition); $raw = round($refundableCash - $bookPrice, 2); if ($raw > 0 && ! $fullDiscount) { $refundAmount = $raw; } elseif ($raw < 0 && ! $fullDiscount) { $debitAmount = abs($raw); } } return [ 'ok' => true, 'eligible' => $eligible, 'withdraw_date' => $withdrawDateUsed, 'refund_deadline' => $refundDeadline, 'paid_amount' => $totalPaid, 'book_price' => $bookPrice, 'refund_amount' => $refundAmount, 'debit_amount' => $debitAmount, 'refundable_tuition' => round($refundableTuition, 2), 'withdrawn_eligible_count' => count($withdrawnEligible), 'total_student_count' => (int) ($totalStudentCount ?? 0), 'remaining_count' => $remainingCount, 'invoice_total' => round($invoiceTotal, 2), 'discount_total' => round($totalDiscount, 2), 'full_discount' => $fullDiscount, ]; } public function calculateRefund(array $students, int $parentId): float { $result = $this->calculateRefundDetails($students, $parentId); return (float) ($result['refund_amount'] ?? 0.0); } public function getGradeLevelInfo($grade, ?int $gradeFee = null): array { $resolvedGradeFee = $gradeFee ?? (int) (Configuration::getConfig('grade_fee') ?? 9); $grades = new InvoiceGradeService($resolvedGradeFee); return $grades->getGradeLevel($grade); } public function compareGrades($gradeA, $gradeB, ?int $gradeFee = null): int { $resolvedGradeFee = $gradeFee ?? (int) (Configuration::getConfig('grade_fee') ?? 9); $grades = new InvoiceGradeService($resolvedGradeFee); return $grades->compareGrades((string) $gradeA, (string) $gradeB); } public function calculateTotalTuitionFee( array $students, ?int $gradeFee = null, ?float $firstStudentFee = null, ?float $secondStudentFee = null, ?float $youthFee = null ): float { $resolvedGradeFee = $gradeFee ?? (int) (Configuration::getConfig('grade_fee') ?? 9); $resolvedFirstFee = $firstStudentFee ?? (float) (Configuration::getConfig('first_student_fee') ?? 350); $resolvedSecondFee = $secondStudentFee ?? (float) (Configuration::getConfig('second_student_fee') ?? 200); $resolvedYouthFee = $youthFee ?? (float) (Configuration::getConfig('youth_fee') ?? 180); foreach ($students as &$student) { $gradeName = ClassSection::getClassSectionNameBySectionId($student['class_section_id'] ?? null); $student['grade'] = strtoupper(trim((string) $gradeName)); } unset($student); $regularCount = 0; $youthCount = 0; $grades = new InvoiceGradeService($resolvedGradeFee); foreach ($students as $student) { $level = (int) ($grades->getGradeLevel($student['grade'] ?? '')['level'] ?? 999); if ($level > $resolvedGradeFee) { $youthCount++; } else { $regularCount++; } } $total = 0.0; $total += $youthCount * $resolvedYouthFee; if ($regularCount >= 2) { $total += $resolvedFirstFee; $total += ($regularCount - 1) * $resolvedSecondFee; } elseif ($regularCount === 1) { $total += $resolvedFirstFee; } return $total; } }