update api and add more features
This commit is contained in:
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user