215 lines
7.6 KiB
PHP
215 lines
7.6 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Fees;
|
|
|
|
use App\Models\ClassSection;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class FeeStudentFeeService
|
|
{
|
|
public const CATEGORY_FIRST_REGULAR = 'first_regular';
|
|
|
|
public const CATEGORY_ADDITIONAL_REGULAR = 'additional_regular';
|
|
|
|
public const CATEGORY_YOUTH = 'youth';
|
|
|
|
public const CATEGORY_NOT_BILLABLE = 'not_billable';
|
|
|
|
private const BILLABLE_ENROLLMENT_STATUSES = ['enrolled', 'payment pending'];
|
|
|
|
private const WITHDRAWN_ENROLLMENT_STATUSES = ['withdrawn', 'refund pending', 'withdraw under review'];
|
|
|
|
private const ACCEPTED_ADMISSION_STATUS = 'accepted';
|
|
|
|
public function __construct(
|
|
private FeeGradeService $grades,
|
|
private FeeConfigService $config
|
|
) {}
|
|
|
|
/**
|
|
* Normalize grades, sort by grade level, and assign a tuition fee to each
|
|
* student so that downstream callers (refund, balance, invoice) can read
|
|
* the stored fee directly from the student record.
|
|
*
|
|
* Each student gets the following keys mutated/added:
|
|
* - grade (normalized)
|
|
* - grade_level
|
|
* - fee_category (first_regular | additional_regular | youth)
|
|
* - tuition_fee
|
|
*/
|
|
public function assignFees(array $students): array
|
|
{
|
|
$fees = $this->config->getFees();
|
|
$firstFee = (float) $fees['first_student_fee'];
|
|
$secondFee = (float) $fees['second_student_fee'];
|
|
$youthFee = (float) $fees['youth_fee'];
|
|
|
|
foreach ($students as &$student) {
|
|
$student['grade'] = $this->resolveGrade($student);
|
|
$student['grade_level'] = $this->grades->getGradeLevel($student['grade']);
|
|
}
|
|
unset($student);
|
|
|
|
usort($students, function ($a, $b) {
|
|
return $this->grades->compareGrades($a['grade'] ?? '', $b['grade'] ?? '');
|
|
});
|
|
|
|
$regularCount = 0;
|
|
foreach ($students as &$student) {
|
|
$gradeLevel = (int) ($student['grade_level'] ?? 999);
|
|
if ($gradeLevel > 9) {
|
|
$student['fee_category'] = self::CATEGORY_YOUTH;
|
|
$student['tuition_fee'] = $youthFee;
|
|
} else {
|
|
if ($regularCount === 0) {
|
|
$student['fee_category'] = self::CATEGORY_FIRST_REGULAR;
|
|
$student['tuition_fee'] = $firstFee;
|
|
} else {
|
|
$student['fee_category'] = self::CATEGORY_ADDITIONAL_REGULAR;
|
|
$student['tuition_fee'] = $secondFee;
|
|
}
|
|
$regularCount++;
|
|
}
|
|
}
|
|
unset($student);
|
|
|
|
return $students;
|
|
}
|
|
|
|
public function totalTuitionFee(array $students): float
|
|
{
|
|
$students = $this->assignFees($students);
|
|
|
|
$total = 0.0;
|
|
foreach ($students as $student) {
|
|
$total += (float) ($student['tuition_fee'] ?? 0);
|
|
}
|
|
|
|
return $total;
|
|
}
|
|
|
|
/**
|
|
* Return a detailed student-level + family-level tuition breakdown.
|
|
*
|
|
* Only students that are accepted and either enrolled or payment-pending
|
|
* are billed. Withdrawn students appear in the breakdown with a zero
|
|
* tuition fee and the not_billable category so callers (refund service,
|
|
* invoice generation) can still see them in one consistent payload.
|
|
*
|
|
* @return array{
|
|
* family_total: float,
|
|
* billable_count: int,
|
|
* non_billable_count: int,
|
|
* students: array<int, array<string, mixed>>,
|
|
* fees: array<string, float>
|
|
* }
|
|
*/
|
|
public function calculateBreakdown(array $students): array
|
|
{
|
|
$fees = $this->config->getFees();
|
|
|
|
$billable = [];
|
|
$nonBillable = [];
|
|
|
|
foreach ($students as $index => $student) {
|
|
$student['_original_index'] = $index;
|
|
$student['grade'] = $this->resolveGrade($student);
|
|
$student['grade_level'] = $this->grades->getGradeLevel($student['grade']);
|
|
$student['enrollment_status'] = isset($student['enrollment_status'])
|
|
? strtolower((string) $student['enrollment_status'])
|
|
: null;
|
|
$student['admission_status'] = isset($student['admission_status'])
|
|
? strtolower((string) $student['admission_status'])
|
|
: null;
|
|
|
|
if ($this->isBillable($student)) {
|
|
$billable[] = $student;
|
|
} else {
|
|
$nonBillable[] = $student;
|
|
}
|
|
}
|
|
|
|
$billable = $this->assignFees($billable);
|
|
|
|
$nonBillable = array_map(function (array $student): array {
|
|
$student['fee_category'] = self::CATEGORY_NOT_BILLABLE;
|
|
$student['tuition_fee'] = 0.0;
|
|
|
|
return $student;
|
|
}, $nonBillable);
|
|
|
|
$combined = array_merge($billable, $nonBillable);
|
|
usort($combined, fn ($a, $b) => ($a['_original_index'] ?? 0) <=> ($b['_original_index'] ?? 0));
|
|
|
|
$familyTotal = 0.0;
|
|
$studentRows = [];
|
|
foreach ($combined as $student) {
|
|
unset($student['_original_index']);
|
|
|
|
$tuitionFee = (float) ($student['tuition_fee'] ?? 0);
|
|
$discount = (float) ($student['discount_amount'] ?? 0);
|
|
$finalTuition = max(0.0, $tuitionFee - $discount);
|
|
$familyTotal += $finalTuition;
|
|
|
|
$studentRows[] = [
|
|
'student_id' => $student['student_id'] ?? null,
|
|
'student_name' => trim(($student['firstname'] ?? '').' '.($student['lastname'] ?? '')),
|
|
'grade' => $student['grade'] ?? null,
|
|
'grade_level' => $student['grade_level'] ?? null,
|
|
'enrollment_status' => $student['enrollment_status'] ?? null,
|
|
'admission_status' => $student['admission_status'] ?? null,
|
|
'fee_category' => $student['fee_category'] ?? null,
|
|
'tuition_fee' => round($tuitionFee, 2),
|
|
'discount_amount' => round($discount, 2),
|
|
'final_tuition_amount' => round($finalTuition, 2),
|
|
];
|
|
}
|
|
|
|
Log::info('Tuition breakdown calculated', [
|
|
'total_students' => count($studentRows),
|
|
'billable' => count($billable),
|
|
'non_billable' => count($nonBillable),
|
|
'family_total' => round($familyTotal, 2),
|
|
]);
|
|
|
|
return [
|
|
'family_total' => round($familyTotal, 2),
|
|
'billable_count' => count($billable),
|
|
'non_billable_count' => count($nonBillable),
|
|
'students' => $studentRows,
|
|
'fees' => [
|
|
'first_student_fee' => (float) $fees['first_student_fee'],
|
|
'second_student_fee' => (float) $fees['second_student_fee'],
|
|
'youth_fee' => (float) $fees['youth_fee'],
|
|
],
|
|
];
|
|
}
|
|
|
|
public function isBillable(array $student): bool
|
|
{
|
|
$status = strtolower((string) ($student['enrollment_status'] ?? ''));
|
|
$admission = strtolower((string) ($student['admission_status'] ?? ''));
|
|
|
|
return in_array($status, self::BILLABLE_ENROLLMENT_STATUSES, true)
|
|
&& $admission === self::ACCEPTED_ADMISSION_STATUS;
|
|
}
|
|
|
|
public function isWithdrawn(array $student): bool
|
|
{
|
|
$status = strtolower((string) ($student['enrollment_status'] ?? ''));
|
|
|
|
return in_array($status, self::WITHDRAWN_ENROLLMENT_STATUSES, true);
|
|
}
|
|
|
|
private function resolveGrade(array $student): string
|
|
{
|
|
$grade = $student['grade'] ?? null;
|
|
if ($grade === null || trim((string) $grade) === '') {
|
|
$sectionId = $student['class_section_id'] ?? null;
|
|
$grade = $sectionId ? ClassSection::getClassSectionNameBySectionId($sectionId) : null;
|
|
}
|
|
|
|
return $this->grades->normalizeGrade($grade);
|
|
}
|
|
}
|