Files
alrahma_sunday_school/app/Services/FeeCalculationService.php
T
root 0ac3a8375e
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 31s
Tests / PHPUnit (push) Failing after 55s
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
2026-08-16 17:41:11 -04:00

215 lines
7.7 KiB
PHP

<?php
namespace App\Services;
use App\Models\ConfigurationModel;
use App\Models\PaymentModel;
use App\Models\InvoiceModel;
use App\Models\ClassSectionModel;
class FeeCalculationService
{
public function calculateEnrollmentTuition(array $students): float
{
return round($this->calculateTotalTuitionFee($students), 2);
}
public function calculateRefund(array $students, int $parentId): float
{
$configModel = new ConfigurationModel();
$paymentModel = new PaymentModel();
$invoiceModel = new InvoiceModel();
$classSectionModel = new ClassSectionModel();
$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_day_of_school')));
$totalPaid = $paymentModel->getTotalPaidByParentId($parentId, $schoolYear);
if ($totalPaid <= 0) {
log_message('info', "No payments made. Refund = 0.");
return 0;
}
// Classify and enrich student data
$registeredStudents = [];
$withdrawnStudents = [];
foreach ($students as &$student) {
$gradeName = $classSectionModel->getClassSectionNameBySectionId($student['class_section_id']);
$student['grade'] = strtoupper(trim($gradeName));
if (in_array($student['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'])) {
$withdrawnStudents[] = $student;
} elseif (
in_array($student['enrollment_status'], ['enrolled', 'payment pending']) &&
$student['admission_status'] === 'accepted'
) {
$registeredStudents[] = $student;
}
}
unset($student);
if (empty($withdrawnStudents)) {
log_message('info', "No withdrawn students found. Refund = 0.");
return 0;
}
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
usort($allStudents, function ($a, $b) {
return $this->compareGrades($a['grade'], $b['grade']);
});
// Retrieve fee configs
$firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 380);
$secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 280);
$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'])) {
log_message('warning', "Missing withdraw date for student ID: {$student['student_id']}");
continue;
}
$withdrawDate = date('Y-m-d', strtotime($student['withdrawal_date']));
if (strtotime($withdrawDate) > strtotime($refundDeadline)) {
log_message('info', "Withdraw date {$withdrawDate} is after refund deadline {$refundDeadline}. No refund for this student.");
continue;
}
$withdrawDateObj = new \DateTime($withdrawDate);
$schoolEndDateObj = new \DateTime($schoolEndDate);
$daysRemaining = $withdrawDateObj->diff($schoolEndDateObj)->days;
$weeksRemaining = min($weekOfStudy, max(0, ceil($daysRemaining / 7)));
$studentFee = (float) ($refundFeeStack[$withdrawnRefundIndex] ?? 0);
$withdrawnRefundIndex++;
$proportionalRefund = ($studentFee / $weekOfStudy) * $weeksRemaining;
$refundAmount += $proportionalRefund;
log_message('info', "Student ID {$student['student_id']} refund portion: {$proportionalRefund} of {$studentFee} for {$weeksRemaining} weeks.");
}
if ($refundAmount > $totalPaid) {
log_message('info', "Refund capped at total paid amount: {$totalPaid}");
return $totalPaid;
}
log_message('info', "Final calculated refund: {$refundAmount}");
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)
{
$valA = $this->getGradeLevel($gradeA);
$valB = $this->getGradeLevel($gradeB);
if ($valA !== $valB) return $valA <=> $valB;
// Same level, compare suffix
preg_match('/\d+([A-Z]*)$/i', strtoupper($gradeA), $suffixA);
preg_match('/\d+([A-Z]*)$/i', strtoupper($gradeB), $suffixB);
return strcmp($suffixA[1] ?? '', $suffixB[1] ?? '');
}
private function getGradeLevel($grade)
{
if (strtoupper($grade) === 'K') return 0;
if (strtoupper($grade) === 'Y') return 99;
if (preg_match('/^(\d+)([A-Z]*)$/i', $grade, $matches)) {
return (int) $matches[1];
}
return 999; // fallback for unknown/malformed grades
}
private function calculateTotalTuitionFee(array $students): float
{
$configModel = new ConfigurationModel();
$classSectionModel = new \App\Models\ClassSectionModel();
$firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 380);
$secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 280);
// ✅ Pre-fetch and assign grade/class section names before sorting
foreach ($students as &$student) {
$gradeName = $classSectionModel->getClassSectionNameBySectionId((int) ($student['class_section_id'] ?? 0));
$student['grade'] = strtoupper(trim($gradeName));
}
unset($student); // break reference
// ✅ Sort students by grade
usort($students, function ($a, $b) {
return $this->compareGrades($a['grade'], $b['grade']);
});
$studentCount = 0;
$totalFee = 0;
// ✅ Calculate fee
foreach ($students as $student) {
$totalFee += ($studentCount === 0) ? $firstStudentFee : $secondStudentFee;
$studentCount++;
}
return $totalFee;
}
}