update api and add more features

This commit is contained in:
root
2026-06-04 02:24:41 -04:00
parent fa6c9519a0
commit 4e33882ac7
131 changed files with 34596 additions and 340 deletions
+160 -42
View File
@@ -22,82 +22,198 @@ class FeeRefundCalculatorService
$totalPaid = Payment::getTotalPaidByParentId($parentId, $schoolYear);
if ($totalPaid <= 0) {
return $this->resultPayload(0.0, $totalPaid, $refundDeadline, $schoolEndDate, $weeksOfStudy, 0);
Log::info('Refund skipped: parent has no payments', [
'parent_id' => $parentId,
'school_year' => $schoolYear,
]);
return $this->resultPayload(0.0, $totalPaid, $refundDeadline, $schoolEndDate, $weeksOfStudy, 0, []);
}
$registered = [];
$withdrawn = [];
foreach ($students as $student) {
$status = strtolower((string) ($student['enrollment_status'] ?? ''));
$admission = strtolower((string) ($student['admission_status'] ?? ''));
if (in_array($status, ['withdrawn', 'refund pending', 'withdraw under review'], true)) {
$withdrawn[] = $student;
continue;
}
if (in_array($status, ['enrolled', 'payment pending'], true) && $admission === 'accepted') {
$registered[] = $student;
}
}
[$registered, $withdrawn] = $this->partitionStudents($students);
if (empty($withdrawn)) {
return $this->resultPayload(0.0, $totalPaid, $refundDeadline, $schoolEndDate, $weeksOfStudy, 0);
Log::info('Refund skipped: no withdrawn students found', [
'parent_id' => $parentId,
'registered_count' => count($registered),
]);
return $this->resultPayload(0.0, $totalPaid, $refundDeadline, $schoolEndDate, $weeksOfStudy, 0, []);
}
$allStudents = array_merge($registered, $withdrawn);
$allStudents = $this->studentFees->assignFees($allStudents);
$feeByStudentId = [];
foreach ($allStudents as $student) {
$key = $student['student_id'] ?? null;
if ($key === null) {
continue;
}
$feeByStudentId[(string) $key] = (float) ($student['tuition_fee'] ?? 0);
// Per the school billing plan (section 8), each student must carry the
// tuition fee that was assigned to them during fee calculation BEFORE
// the refund loop runs. We mark withdrawn vs registered before merging
// so the fee tier (first/second regular) is computed against the full
// family and we can still iterate only the withdrawn list afterwards.
foreach ($registered as &$student) {
$student['_is_withdrawn'] = false;
}
unset($student);
foreach ($withdrawn as &$student) {
$student['_is_withdrawn'] = true;
}
unset($student);
$allStudents = $this->studentFees->assignFees(array_merge($registered, $withdrawn));
$withdrawnWithFees = array_values(array_filter(
$allStudents,
fn (array $student): bool => !empty($student['_is_withdrawn'])
));
$refundAmount = 0.0;
$withdrawCount = 0;
$studentBreakdown = [];
foreach ($withdrawn as $student) {
foreach ($withdrawnWithFees as $student) {
$studentId = $student['student_id'] ?? null;
$studentFee = (float) ($student['tuition_fee'] ?? 0);
$withdrawalDate = $student['withdrawal_date'] ?? null;
$entry = [
'student_id' => $studentId,
'grade' => $student['grade'] ?? null,
'grade_level' => $student['grade_level'] ?? null,
'fee_category' => $student['fee_category'] ?? null,
'tuition_fee' => round($studentFee, 2),
'withdrawal_date' => $withdrawalDate,
'weeks_remaining' => 0,
'refund_amount' => 0.0,
'eligible' => false,
'reason' => null,
];
if (!$withdrawalDate) {
Log::warning('Missing withdraw date for student', ['student_id' => $student['student_id'] ?? null]);
$entry['reason'] = 'missing_withdrawal_date';
Log::warning('Refund skipped for student: missing withdrawal date', [
'parent_id' => $parentId,
'student_id' => $studentId,
]);
$studentBreakdown[] = $entry;
continue;
}
if (!$refundDeadline || strtotime($withdrawalDate) > strtotime($refundDeadline)) {
$entry['reason'] = 'after_refund_deadline';
$studentBreakdown[] = $entry;
continue;
}
if (!$schoolEndDate) {
$entry['reason'] = 'missing_school_end_date';
Log::warning('Refund skipped for student: missing school end date', [
'parent_id' => $parentId,
'student_id' => $studentId,
]);
$studentBreakdown[] = $entry;
continue;
}
$withdrawDateObj = new \DateTime(date('Y-m-d', strtotime($withdrawalDate)));
$schoolEndDateObj = new \DateTime($schoolEndDate);
$daysRemaining = $withdrawDateObj->diff($schoolEndDateObj)->days;
$weeksRemaining = min($weeksOfStudy, max(0, (int) ceil($daysRemaining / 7)));
$studentId = (string) ($student['student_id'] ?? '');
$studentFee = $feeByStudentId[$studentId] ?? 0.0;
if ($studentFee <= 0 || $weeksOfStudy <= 0) {
$entry['reason'] = 'zero_fee_or_weeks';
Log::warning('Refund skipped for student: zero fee or weeks_of_study', [
'parent_id' => $parentId,
'student_id' => $studentId,
'student_fee' => $studentFee,
'weeks_of_study' => $weeksOfStudy,
]);
$studentBreakdown[] = $entry;
continue;
}
$weeksRemaining = $this->weeksRemaining($withdrawalDate, $schoolEndDate, $weeksOfStudy);
// Plan section 7: Refund Amount = Student Tuition Fee / Weeks of Study * Weeks Remaining
$proportionalRefund = ($studentFee / $weeksOfStudy) * $weeksRemaining;
$refundAmount += $proportionalRefund;
$withdrawCount++;
$proportionalRefund = round(max(0.0, $proportionalRefund), 2);
$entry['weeks_remaining'] = $weeksRemaining;
$entry['refund_amount'] = $proportionalRefund;
$entry['eligible'] = $proportionalRefund > 0;
if ($entry['eligible']) {
$withdrawCount++;
$refundAmount += $proportionalRefund;
} else {
$entry['reason'] = 'no_weeks_remaining';
}
$studentBreakdown[] = $entry;
}
$uncappedRefund = $refundAmount;
if ($refundAmount > $totalPaid) {
Log::info('Refund capped at total paid', [
'parent_id' => $parentId,
'uncapped' => round($uncappedRefund, 2),
'capped' => round($totalPaid, 2),
]);
$refundAmount = $totalPaid;
// Scale per-student refunds proportionally so the breakdown
// adds up to the capped family total.
if ($uncappedRefund > 0) {
$ratio = $totalPaid / $uncappedRefund;
foreach ($studentBreakdown as &$row) {
if (!empty($row['eligible'])) {
$row['refund_amount'] = round($row['refund_amount'] * $ratio, 2);
}
}
unset($row);
}
}
return $this->resultPayload($refundAmount, $totalPaid, $refundDeadline, $schoolEndDate, $weeksOfStudy, $withdrawCount);
Log::info('Refund calculation complete', [
'parent_id' => $parentId,
'school_year' => $schoolYear,
'withdrawn_total' => count($withdrawnWithFees),
'withdrawn_eligible' => $withdrawCount,
'refund_amount' => round($refundAmount, 2),
'total_paid' => round($totalPaid, 2),
]);
return $this->resultPayload(
$refundAmount,
$totalPaid,
$refundDeadline,
$schoolEndDate,
$weeksOfStudy,
$withdrawCount,
$studentBreakdown
);
}
/**
* @return array{0: array<int, array<string, mixed>>, 1: array<int, array<string, mixed>>}
*/
private function partitionStudents(array $students): array
{
$registered = [];
$withdrawn = [];
foreach ($students as $student) {
if ($this->studentFees->isWithdrawn($student)) {
$withdrawn[] = $student;
continue;
}
if ($this->studentFees->isBillable($student)) {
$registered[] = $student;
}
}
return [$registered, $withdrawn];
}
private function weeksRemaining(string $withdrawalDate, string $schoolEndDate, float $weeksOfStudy): int
{
$withdrawDateObj = new \DateTime(date('Y-m-d', strtotime($withdrawalDate)));
$schoolEndDateObj = new \DateTime($schoolEndDate);
if ($withdrawDateObj > $schoolEndDateObj) {
return 0;
}
$daysRemaining = $withdrawDateObj->diff($schoolEndDateObj)->days;
return (int) min($weeksOfStudy, max(0, (int) ceil($daysRemaining / 7)));
}
private function resultPayload(
@@ -106,7 +222,8 @@ class FeeRefundCalculatorService
?string $refundDeadline,
?string $schoolEndDate,
float $weeksOfStudy,
int $withdrawCount
int $withdrawCount,
array $students
): array {
return [
'refund_amount' => round($refund, 2),
@@ -115,6 +232,7 @@ class FeeRefundCalculatorService
'school_end_date' => $schoolEndDate,
'weeks_of_study' => $weeksOfStudy,
'withdrawn_students' => $withdrawCount,
'students' => $students,
];
}
}
+157 -11
View File
@@ -3,29 +3,46 @@
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 = $fees['first_student_fee'];
$secondFee = $fees['second_student_fee'];
$youthFee = $fees['youth_fee'];
$firstFee = (float) $fees['first_student_fee'];
$secondFee = (float) $fees['second_student_fee'];
$youthFee = (float) $fees['youth_fee'];
foreach ($students as &$student) {
$grade = $student['grade'] ?? null;
if ($grade === null || trim((string) $grade) === '') {
$sectionId = $student['class_section_id'] ?? null;
$grade = $sectionId ? ClassSection::getClassSectionNameBySectionId($sectionId) : null;
}
$student['grade'] = $this->grades->normalizeGrade($grade);
$student['grade'] = $this->resolveGrade($student);
$student['grade_level'] = $this->grades->getGradeLevel($student['grade']);
}
unset($student);
@@ -35,11 +52,18 @@ class FeeStudentFeeService
$regularCount = 0;
foreach ($students as &$student) {
$gradeLevel = $this->grades->getGradeLevel($student['grade'] ?? '');
$gradeLevel = (int) ($student['grade_level'] ?? 999);
if ($gradeLevel > 9) {
$student['fee_category'] = self::CATEGORY_YOUTH;
$student['tuition_fee'] = $youthFee;
} else {
$student['tuition_fee'] = ($regularCount === 0) ? $firstFee : $secondFee;
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++;
}
}
@@ -59,4 +83,126 @@ class FeeStudentFeeService
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);
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Services\Fees;
use Illuminate\Support\Facades\Log;
/**
* TuitionCalculationService
*
* Responsible for producing a tuition calculation that answers the plan's
* key questions for a family:
* - What was each student charged?
* - Why was each student charged that amount? (fee_category)
* - What is the family total?
*
* This service is a thin orchestrator built on top of FeeStudentFeeService
* and FeeConfigService. It is the place where new business rules (discounts,
* required fees, balance integration) should be wired in for parent-facing
* tuition views without bloating the lower-level fee assignment service.
*/
class TuitionCalculationService
{
public function __construct(
private FeeConfigService $config,
private FeeStudentFeeService $studentFees
) {
}
/**
* Produce a family + per-student tuition calculation.
*
* @param array $students Each item may include: student_id, firstname,
* lastname, grade, class_section_id, enrollment_status,
* admission_status, discount_amount.
*
* @return array{
* school_year: string,
* fees: array<string, float>,
* family_total: float,
* billable_count: int,
* non_billable_count: int,
* students: array<int, array<string, mixed>>
* }
*/
public function calculate(array $students): array
{
$schoolYear = $this->config->getSchoolYear();
$breakdown = $this->studentFees->calculateBreakdown($students);
Log::info('Tuition calculation requested', [
'school_year' => $schoolYear,
'student_count' => count($students),
'family_total' => $breakdown['family_total'],
]);
return [
'school_year' => $schoolYear,
'fees' => $breakdown['fees'],
'family_total' => $breakdown['family_total'],
'billable_count' => $breakdown['billable_count'],
'non_billable_count' => $breakdown['non_billable_count'],
'students' => $breakdown['students'],
];
}
/**
* Convenience helper: the family-level tuition total only.
*/
public function familyTotal(array $students): float
{
return $this->calculate($students)['family_total'];
}
}