664 lines
25 KiB
PHP
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, '.', '');
|
|
}
|
|
}
|