Files
alrahma_sunday_school/app/Libraries/Tuition/TuitionForecastService.php
T
2026-06-07 00:27:27 -04:00

664 lines
25 KiB
PHP

<?php
namespace App\Libraries\Tuition;
use App\Libraries\FinancialStatus;
use App\Models\ClassSectionModel;
use App\Models\ConfigurationModel;
use App\Models\EnrollmentModel;
use App\Models\PaymentModel;
use App\Models\RefundModel;
use App\Models\StudentClassModel;
use App\Models\UserModel;
class TuitionForecastService
{
protected ConfigurationModel $configurationModel;
protected EnrollmentModel $enrollmentModel;
protected StudentClassModel $studentClassModel;
protected ClassSectionModel $classSectionModel;
protected PaymentModel $paymentModel;
protected RefundModel $refundModel;
protected UserModel $userModel;
protected OldTuitionCalculatorService $oldCalculator;
protected NewTuitionCalculatorService $newCalculator;
public function __construct()
{
$this->configurationModel = new ConfigurationModel();
$this->enrollmentModel = new EnrollmentModel();
$this->studentClassModel = new StudentClassModel();
$this->classSectionModel = new ClassSectionModel();
$this->paymentModel = new PaymentModel();
$this->refundModel = new RefundModel();
$this->userModel = new UserModel();
$this->oldCalculator = new OldTuitionCalculatorService();
$this->newCalculator = new NewTuitionCalculatorService();
}
public function calculate(string $schoolYear, string $semester, string $mode = 'compare', array $options = []): array
{
$schoolYear = trim($schoolYear) !== '' ? trim($schoolYear) : $this->getDefaultSchoolYear();
$semester = trim($semester) !== '' ? trim($semester) : $this->getDefaultSemester();
$mode = $this->normalizeMode($mode);
$options = $this->normalizeOptions($options);
$this->unitPriceOverride = $options['unit_price'];
$this->youthUnitPriceOverride = $options['youth_unit_price'];
$tuitionConfig = $this->getTuitionConfig();
$familyRows = [];
$summary = [
'family_count' => 0,
'student_count' => 0,
'billable_student_count' => 0,
'old_projected_tuition' => '0.00',
'new_projected_tuition' => '0.00',
'old_projected_income' => '0.00',
'new_projected_income' => '0.00',
'difference' => '0.00',
'projected_tuition' => '0.00',
'projected_income' => '0.00',
'unit_price' => '0.00',
'youth_unit_price' => '0.00',
];
$oldProjectedCents = 0;
$newProjectedCents = 0;
$studentCount = 0;
$billableStudentCount = 0;
foreach ($this->loadFamilies($schoolYear, $semester) as $family) {
$parentId = (int) ($family['parent_id'] ?? 0);
if ($parentId <= 0) {
continue;
}
$studentContext = $this->loadFamilyStudents($parentId, $schoolYear, $semester, $options);
$students = $studentContext['students'];
$allStudents = $studentContext['all_students'];
$warnings = $studentContext['warnings'];
if (empty($allStudents)) {
continue;
}
$oldResult = $this->oldCalculator->calculateFamilyTuition($students, $tuitionConfig);
$newResult = $this->newCalculator->calculateFamilyTuition($students, $tuitionConfig);
$oldTotalCents = $this->toCents($oldResult['total'] ?? 0);
$newTotalCents = $this->toCents($newResult['total'] ?? 0);
$mergedDetails = $this->mergeStudentDetails($allStudents, $oldResult['details'] ?? [], $newResult['details'] ?? []);
$studentCount += count($allStudents);
$billableStudentCount += count($students);
$oldProjectedCents += $oldTotalCents;
$newProjectedCents += $newTotalCents;
$familyRows[] = [
'parent_id' => $parentId,
'parent_name' => $family['parent_name'] ?? ('Parent #' . $parentId),
'student_count' => count($allStudents),
'billable_student_count' => count($students),
'old_total' => $this->fromCents($oldTotalCents),
'new_total' => $this->fromCents($newTotalCents),
'difference' => $this->fromCents($newTotalCents - $oldTotalCents),
'warnings' => array_values(array_unique($warnings)),
'student_details' => $mergedDetails,
];
}
usort($familyRows, static fn (array $left, array $right): int => strcmp((string) ($left['parent_name'] ?? ''), (string) ($right['parent_name'] ?? '')));
$summary['family_count'] = count($familyRows);
$summary['student_count'] = $studentCount;
$summary['billable_student_count'] = $billableStudentCount;
$summary['old_projected_tuition'] = $this->fromCents($oldProjectedCents);
$summary['new_projected_tuition'] = $this->fromCents($newProjectedCents);
$summary['old_projected_income'] = $summary['old_projected_tuition'];
$summary['new_projected_income'] = $summary['new_projected_tuition'];
$summary['difference'] = $this->fromCents($newProjectedCents - $oldProjectedCents);
$summary['projected_tuition'] = $mode === 'old' ? $summary['old_projected_tuition'] : $summary['new_projected_tuition'];
$summary['projected_income'] = $mode === 'old' ? $summary['old_projected_income'] : $summary['new_projected_income'];
$summary['unit_price'] = number_format((float) ($tuitionConfig['new_tuition_full_amount'] ?? 0), 2, '.', '');
$summary['youth_unit_price'] = number_format((float) ($tuitionConfig['new_tuition_youth_amount'] ?? 0), 2, '.', '');
return [
'school_year' => $schoolYear,
'semester' => $semester,
'calculator_mode' => $mode,
'options' => $options,
'summary' => $summary,
'families' => $familyRows,
];
}
public function getAvailableSchoolYears(): array
{
$values = [];
if ($this->enrollmentModel->db->tableExists('enrollments')) {
$rows = $this->enrollmentModel->db->table('enrollments')
->select('school_year')
->where('school_year IS NOT NULL', null, false)
->where('school_year !=', '')
->groupBy('school_year')
->orderBy('school_year', 'DESC')
->get()
->getResultArray();
foreach ($rows as $row) {
$value = trim((string) ($row['school_year'] ?? ''));
if ($value !== '') {
$values[$value] = $value;
}
}
}
if ($this->paymentModel->db->tableExists('invoices')) {
$rows = $this->paymentModel->db->table('invoices')
->select('school_year')
->where('school_year IS NOT NULL', null, false)
->where('school_year !=', '')
->groupBy('school_year')
->orderBy('school_year', 'DESC')
->get()
->getResultArray();
foreach ($rows as $row) {
$value = trim((string) ($row['school_year'] ?? ''));
if ($value !== '') {
$values[$value] = $value;
}
}
}
if ($values === []) {
$default = $this->getDefaultSchoolYear();
if ($default !== '') {
$values[$default] = $default;
}
}
krsort($values);
return array_values($values);
}
public function getAvailableSemesters(): array
{
$values = [];
if ($this->enrollmentModel->db->tableExists('enrollments')) {
$rows = $this->enrollmentModel->db->table('enrollments')
->select('semester')
->where('semester IS NOT NULL', null, false)
->where('semester !=', '')
->groupBy('semester')
->orderBy('semester', 'ASC')
->get()
->getResultArray();
foreach ($rows as $row) {
$value = trim((string) ($row['semester'] ?? ''));
if ($value !== '') {
$values[$value] = $value;
}
}
}
if ($values === []) {
$default = $this->getDefaultSemester();
if ($default !== '') {
$values[$default] = $default;
}
}
return array_values($values);
}
protected function normalizeMode(string $mode): string
{
$value = strtolower(trim($mode));
return in_array($value, ['old', 'new'], true) ? $value : 'compare';
}
protected function normalizeOptions(array $options): array
{
$includeWithdrawnMode = strtolower(trim((string) ($options['include_withdrawn_mode'] ?? 'refund_deadline')));
if (!in_array($includeWithdrawnMode, ['refund_deadline', 'include', 'exclude'], true)) {
$includeWithdrawnMode = 'refund_deadline';
}
return [
'include_withdrawn_mode' => $includeWithdrawnMode,
'include_payment_pending' => $this->toBool($options['include_payment_pending'] ?? true),
'include_event_only' => $this->toBool($options['include_event_only'] ?? false),
'include_paid_invoices' => $this->toBool($options['include_paid_invoices'] ?? true),
'unit_price' => $this->normalizeMoney($options['unit_price'] ?? null),
'youth_unit_price' => $this->normalizeMoney($options['youth_unit_price'] ?? null),
];
}
protected function loadFamilies(string $schoolYear, string $semester): array
{
$builder = $this->enrollmentModel->db->table('enrollments e')
->select("e.parent_id, CONCAT(COALESCE(u.lastname, ''), ', ', COALESCE(u.firstname, '')) AS parent_name", false)
->join('users u', 'u.id = e.parent_id', 'left')
->where('e.school_year', $schoolYear)
->groupBy('e.parent_id')
->orderBy('parent_name', 'ASC');
$this->applyEnrollmentSemesterFilter($builder, 'e', $semester);
return $builder->get()->getResultArray();
}
protected function loadFamilyStudents(int $parentId, string $schoolYear, string $semester, array $options): array
{
$rows = $this->enrollmentModel->db->table('enrollments e')
->select('e.*, s.firstname, s.lastname, s.is_active')
->join('students s', 's.id = e.student_id', 'left')
->where('e.parent_id', $parentId)
->where('e.school_year', $schoolYear)
->orderBy('e.updated_at', 'DESC')
->orderBy('e.id', 'DESC')
->get()
->getResultArray();
$latestByStudent = [];
foreach ($rows as $row) {
$studentId = (int) ($row['student_id'] ?? 0);
if ($studentId <= 0 || isset($latestByStudent[$studentId])) {
continue;
}
if (!$this->matchesEnrollmentSemester($row, $semester)) {
continue;
}
$latestByStudent[$studentId] = $row;
}
ksort($latestByStudent);
$withinRefundWindow = $this->isWithinRefundWindow((string) ($this->configurationModel->getConfig('refund_deadline') ?? ''));
$students = [];
$allStudents = [];
$warnings = [];
$eventOnlyCount = 0;
$missingClassCount = 0;
$inactiveCount = 0;
$pendingSkippedCount = 0;
foreach ($latestByStudent as $row) {
$studentId = (int) ($row['student_id'] ?? 0);
$studentName = trim(((string) ($row['firstname'] ?? '')) . ' ' . ((string) ($row['lastname'] ?? '')));
$studentLabel = $studentName !== '' ? $studentName : ('Student #' . $studentId);
if ((int) ($row['is_active'] ?? 1) === 0) {
$inactiveCount++;
$allStudents[] = [
'student_id' => $studentId,
'student_name' => $studentLabel,
'grade_level' => 'N/A',
'billable' => false,
'excluded_reason' => 'inactive',
];
continue;
}
$inclusion = $this->resolveEnrollmentInclusion($row, $options, $withinRefundWindow);
if (!$inclusion['include']) {
if (($inclusion['reason'] ?? '') === 'payment_pending_excluded') {
$pendingSkippedCount++;
}
continue;
}
$classFlags = $this->resolveStudentClassFlags($studentId, $schoolYear);
$gradeName = $this->resolveGradeName($studentId, $schoolYear, $row['class_section_id'] ?? null);
$studentBase = [
'student_id' => $studentId,
'student_name' => $studentLabel,
'grade_level' => $gradeName,
'billable' => false,
'excluded_reason' => null,
];
if (!$classFlags['has_any_assignment']) {
$missingClassCount++;
$studentBase['excluded_reason'] = 'missing_class_assignment';
$allStudents[] = $studentBase;
continue;
}
if (!$classFlags['has_non_event_assignment']) {
$eventOnlyCount++;
$studentBase['excluded_reason'] = 'event_only';
$allStudents[] = $studentBase;
if ($options['include_event_only']) {
$warnings[] = $studentLabel . ' is event-only and excluded from tuition billing.';
}
continue;
}
if ($gradeName === 'N/A') {
$warnings[] = $studentLabel . ' has no resolved class section grade.';
}
$studentBase['billable'] = true;
$allStudents[] = $studentBase;
$students[] = [
'student_id' => $studentId,
'student_name' => $studentLabel,
'grade_level' => $gradeName,
];
}
if ($eventOnlyCount > 0) {
$warnings[] = $eventOnlyCount . ' event-only student(s) excluded from tuition.';
}
if ($missingClassCount > 0) {
$warnings[] = $missingClassCount . ' student(s) missing class assignments.';
}
if ($inactiveCount > 0) {
$warnings[] = $inactiveCount . ' inactive student(s) skipped.';
}
if ($pendingSkippedCount > 0) {
$warnings[] = $pendingSkippedCount . ' payment-pending student(s) skipped by filter.';
}
if ($this->toCents($this->configurationModel->getConfig('new_tuition_full_amount') ?? 0) <= 0) {
$warnings[] = 'New tuition full amount is missing or zero.';
}
return [
'students' => $students,
'all_students' => $allStudents,
'warnings' => array_values(array_unique($warnings)),
];
}
protected function resolveEnrollmentInclusion(array $row, array $options, bool $withinRefundWindow): array
{
$admissionStatus = strtolower(trim((string) ($row['admission_status'] ?? '')));
$enrollmentStatus = strtolower(trim((string) ($row['enrollment_status'] ?? '')));
if ($admissionStatus === 'denied' || $enrollmentStatus === 'admission under review') {
return ['include' => false, 'reason' => 'not_admitted'];
}
if ($enrollmentStatus === 'payment pending') {
return ['include' => $options['include_payment_pending'], 'reason' => 'payment_pending_excluded'];
}
if ($enrollmentStatus === 'enrolled') {
return ['include' => true, 'reason' => null];
}
if (in_array($enrollmentStatus, ['withdrawn', 'refund pending', 'withdraw under review'], true)) {
if ($options['include_withdrawn_mode'] === 'include') {
return ['include' => true, 'reason' => null];
}
if ($options['include_withdrawn_mode'] === 'exclude') {
return ['include' => false, 'reason' => 'withdrawn_excluded'];
}
return ['include' => !$withinRefundWindow, 'reason' => 'refund_deadline_excluded'];
}
return ['include' => $admissionStatus === 'accepted', 'reason' => 'accepted_only'];
}
protected function resolveStudentClassFlags(int $studentId, string $schoolYear): array
{
$rows = $this->studentClassModel->where('student_id', $studentId)
->where('school_year', $schoolYear)
->findAll();
$hasAnyAssignment = !empty($rows);
$hasNonEventAssignment = false;
$hasEventOnlyAssignment = false;
foreach ($rows as $row) {
$isEventOnly = (int) ($row['is_event_only'] ?? 0) === 1;
$hasEventOnlyAssignment = $hasEventOnlyAssignment || $isEventOnly;
$hasNonEventAssignment = $hasNonEventAssignment || !$isEventOnly;
}
return [
'has_any_assignment' => $hasAnyAssignment,
'has_non_event_assignment' => $hasNonEventAssignment,
'has_event_only_assignment' => $hasEventOnlyAssignment,
];
}
protected function resolveGradeName(int $studentId, string $schoolYear, $classSectionId = null): string
{
$sectionId = $classSectionId;
if (empty($sectionId)) {
$row = $this->studentClassModel
->select('class_section_id')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('is_event_only', 0)
->orderBy('updated_at', 'DESC')
->first();
$sectionId = $row['class_section_id'] ?? null;
}
if (empty($sectionId)) {
return 'N/A';
}
$name = $this->classSectionModel->getClassSectionNameBySectionId($sectionId);
return is_string($name) && trim($name) !== '' ? strtoupper(trim($name)) : 'N/A';
}
protected function calculateAlreadyCollected(int $parentId, string $schoolYear, string $semester): int
{
$paymentBuilder = $this->paymentModel->db->table('payments')
->select('COALESCE(SUM(paid_amount),0) AS total_amount')
->where('parent_id', $parentId)
->where('school_year', $schoolYear);
if ($semester !== '') {
$paymentBuilder->where('semester', $semester);
}
if ($this->paymentModel->db->fieldExists('status', 'payments')) {
$paymentBuilder->groupStart()
->whereNotIn('status', FinancialStatus::EXCLUDED_PAYMENT_STATUSES)
->orWhere('status IS NULL', null, false)
->groupEnd();
}
if ($this->paymentModel->db->fieldExists('is_void', 'payments')) {
$paymentBuilder->groupStart()
->where('is_void', 0)
->orWhere('is_void IS NULL', null, false)
->groupEnd();
}
$paymentRow = $paymentBuilder->get()->getRowArray();
$paidCents = $this->toCents($paymentRow['total_amount'] ?? 0);
$refundBuilder = $this->refundModel->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS total_amount')
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->whereIn('status', FinancialStatus::REFUND_REDUCES_INVOICE_STATUSES);
if ($semester !== '' && $this->refundModel->db->fieldExists('semester', 'refunds')) {
$refundBuilder->where('semester', $semester);
}
$refundRow = $refundBuilder->get()->getRowArray();
$refundCents = $this->toCents($refundRow['total_amount'] ?? 0);
return max(0, $paidCents - $refundCents);
}
protected function mergeStudentDetails(array $allStudents, array $oldDetails, array $newDetails): array
{
$oldMap = [];
foreach ($oldDetails as $detail) {
$oldMap[(int) ($detail['student_id'] ?? 0)] = $detail;
}
$newMap = [];
foreach ($newDetails as $detail) {
$newMap[(int) ($detail['student_id'] ?? 0)] = $detail;
}
$rows = [];
foreach ($allStudents as $student) {
$studentId = (int) ($student['student_id'] ?? 0);
$old = $oldMap[$studentId] ?? null;
$new = $newMap[$studentId] ?? null;
$rows[] = [
'student_id' => $studentId,
'student_name' => $student['student_name'] ?? '',
'grade_level' => $student['grade_level'] ?? 'N/A',
'billable' => (bool) ($student['billable'] ?? false),
'excluded_reason' => $student['excluded_reason'] ?? null,
'old_rule' => $old['rule'] ?? null,
'old_amount' => $old['amount'] ?? '0.00',
'new_rule' => $new['rule'] ?? null,
'new_amount' => $new['amount'] ?? '0.00',
];
}
return $rows;
}
protected function getTuitionConfig(): array
{
return [
'grade_fee' => $this->configurationModel->getConfig('grade_fee'),
'first_student_fee' => $this->configurationModel->getConfig('first_student_fee'),
'second_student_fee' => $this->configurationModel->getConfig('second_student_fee'),
'youth_fee' => $this->configurationModel->getConfig('youth_fee') ?? '200.00',
'new_tuition_full_amount' => $this->normalizedUnitPriceOverride(),
'new_tuition_youth_amount' => $this->normalizedYouthUnitPriceOverride(),
'new_tuition_second_student_discount' => $this->configurationModel->getConfig('new_tuition_second_student_discount'),
'new_tuition_third_student_discount' => $this->configurationModel->getConfig('new_tuition_third_student_discount'),
'new_tuition_fourth_plus_discount' => $this->configurationModel->getConfig('new_tuition_fourth_plus_discount'),
];
}
protected ?string $unitPriceOverride = null;
protected ?string $youthUnitPriceOverride = null;
protected function normalizedUnitPriceOverride(): string
{
return $this->unitPriceOverride
?? (string) ($this->configurationModel->getConfig('new_tuition_full_amount') ?? '0.00');
}
protected function normalizedYouthUnitPriceOverride(): string
{
return $this->youthUnitPriceOverride
?? (string) (
$this->configurationModel->getConfig('new_tuition_youth_amount')
?? $this->configurationModel->getConfig('youth_fee')
?? '200.00'
);
}
protected function normalizeMoney($value): ?string
{
if ($value === null || trim((string) $value) === '') {
return null;
}
$normalized = preg_replace('/[^0-9.]+/', '', (string) $value);
if ($normalized === '' || !is_numeric($normalized)) {
return null;
}
$amount = (float) $normalized;
return $amount > 0 ? number_format($amount, 2, '.', '') : null;
}
protected function isWithinRefundWindow(string $refundDeadline): bool
{
if ($refundDeadline === '') {
return true;
}
try {
$timeZone = new \DateTimeZone((string) (config('School')->attendance['timezone'] ?? user_timezone()));
$today = new \DateTimeImmutable('today', $timeZone);
$deadline = new \DateTimeImmutable($refundDeadline, $timeZone);
return $today <= $deadline;
} catch (\Throwable $e) {
return true;
}
}
protected function getDefaultSchoolYear(): string
{
return (string) ($this->configurationModel->getConfig('school_year') ?? '');
}
protected function getDefaultSemester(): string
{
return '';
}
protected function applyEnrollmentSemesterFilter($builder, string $alias, string $semester): void
{
if ($semester === '') {
return;
}
$builder->groupStart()
->where($alias . '.semester', $semester)
->orWhere($alias . '.semester', '')
->orWhere($alias . '.semester IS NULL', null, false)
->groupEnd();
}
protected function matchesEnrollmentSemester(array $row, string $semester): bool
{
if ($semester === '') {
return true;
}
$value = trim((string) ($row['semester'] ?? ''));
return $value === '' || strcasecmp($value, $semester) === 0;
}
protected function toBool($value): bool
{
if (is_bool($value)) {
return $value;
}
$normalized = strtolower(trim((string) $value));
return in_array($normalized, ['1', 'true', 'yes', 'on'], true);
}
protected function toCents($amount): int
{
return (int) round(((float) $amount) * 100);
}
protected function fromCents(int $cents): string
{
return number_format($cents / 100, 2, '.', '');
}
}