246 lines
8.5 KiB
PHP
246 lines
8.5 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Fees;
|
|
|
|
use App\Models\Payment;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class FeeRefundCalculatorService
|
|
{
|
|
public function __construct(
|
|
private FeeConfigService $config,
|
|
private FeeStudentFeeService $studentFees
|
|
) {}
|
|
|
|
public function calculateRefund(array $students, int $parentId): array
|
|
{
|
|
$schoolYear = $this->config->getSchoolYear();
|
|
$refundDeadline = $this->config->getRefundDeadline();
|
|
$weeksOfStudy = $this->config->getWeeksOfStudy();
|
|
$schoolEndDate = $this->config->getSchoolEndDate();
|
|
|
|
$totalPaid = Payment::getTotalPaidByParentId($parentId, $schoolYear);
|
|
if ($totalPaid <= 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] = $this->partitionStudents($students);
|
|
|
|
if (empty($withdrawn)) {
|
|
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, []);
|
|
}
|
|
|
|
// 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 ($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) {
|
|
$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;
|
|
}
|
|
|
|
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;
|
|
$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);
|
|
}
|
|
}
|
|
|
|
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(
|
|
float $refund,
|
|
float $totalPaid,
|
|
?string $refundDeadline,
|
|
?string $schoolEndDate,
|
|
float $weeksOfStudy,
|
|
int $withdrawCount,
|
|
array $students
|
|
): array {
|
|
return [
|
|
'refund_amount' => round($refund, 2),
|
|
'total_paid' => round($totalPaid, 2),
|
|
'refund_deadline' => $refundDeadline,
|
|
'school_end_date' => $schoolEndDate,
|
|
'weeks_of_study' => $weeksOfStudy,
|
|
'withdrawn_students' => $withdrawCount,
|
|
'students' => $students,
|
|
];
|
|
}
|
|
}
|