0ac3a8375e
- load global semester helpers consistently and use date-based semester defaults - fix grading and daily attendance duplicate student/section rows - keep attendance violations scoped to the current semester by default - update invoice, refund, discount, payment, and financial aid flows - add configuration cleanup migrations for duplicate calendar/semester keys - refresh parent registration/report-card and print request handling - update related models, services, views, cron notes, and test coverage
296 lines
12 KiB
PHP
296 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Libraries\IssueInvoiceCommand;
|
|
use App\Libraries\InvoiceIssuanceService;
|
|
use App\Libraries\InvoiceLedgerService;
|
|
use App\Libraries\Tuition\GradeLevelParser;
|
|
use App\Models\ClassSectionModel;
|
|
use App\Models\ConfigurationModel;
|
|
use App\Models\DiscountUsageModel;
|
|
use App\Models\DiscountVoucherModel;
|
|
use App\Models\EnrollmentModel;
|
|
use App\Models\EventChargesModel;
|
|
use App\Models\FinancialAidRequestModel;
|
|
use App\Models\InvoiceModel;
|
|
use App\Models\StudentClassModel;
|
|
use App\Models\UserModel;
|
|
use DateTime;
|
|
use DateTimeZone;
|
|
use RuntimeException;
|
|
|
|
final class FinancialAidService
|
|
{
|
|
public function __construct(
|
|
private readonly FinancialAidRequestModel $requestModel,
|
|
private readonly InvoiceModel $invoiceModel,
|
|
private readonly DiscountVoucherModel $voucherModel,
|
|
private readonly DiscountUsageModel $usageModel,
|
|
private readonly InvoiceLedgerService $invoiceLedgerService,
|
|
private readonly ?InvoiceIssuanceService $invoiceIssuanceService = null,
|
|
private readonly ?EnrollmentModel $enrollmentModel = null,
|
|
private readonly ?StudentClassModel $studentClassModel = null,
|
|
private readonly ?ClassSectionModel $classSectionModel = null,
|
|
private readonly ?EventChargesModel $eventChargesModel = null,
|
|
private readonly ?ConfigurationModel $configurationModel = null,
|
|
private readonly ?UserModel $userModel = null,
|
|
) {
|
|
}
|
|
|
|
public function applyApprovedAmount(array $request, float $amount, int $reviewedBy, string $adminNote = ''): array
|
|
{
|
|
if ($amount <= 0) {
|
|
throw new RuntimeException('Enter a financial aid amount greater than zero.');
|
|
}
|
|
|
|
$parentId = (int) ($request['parent_id'] ?? 0);
|
|
$schoolYear = (string) ($request['school_year'] ?? '');
|
|
$invoice = $this->latestInvoice($parentId, $schoolYear);
|
|
if ($invoice === null) {
|
|
$invoice = $this->createInvoiceForParentYear($parentId, $schoolYear);
|
|
}
|
|
|
|
$db = $this->requestModel->db;
|
|
$db->transStart();
|
|
|
|
$code = 'FA-' . (int) ($request['id'] ?? 0) . '-' . date('YmdHis');
|
|
$voucherId = $this->voucherModel->insert([
|
|
'code' => $code,
|
|
'discount_type' => 'fixed',
|
|
'discount_value' => $amount,
|
|
'max_uses' => 1,
|
|
'times_used' => 0,
|
|
'valid_from' => date('Y-m-d'),
|
|
'valid_until' => date('Y-m-d', strtotime('+1 year')),
|
|
'school_year' => $schoolYear,
|
|
'is_active' => 1,
|
|
'description' => 'Financial aid request #' . (int) ($request['id'] ?? 0),
|
|
], true);
|
|
|
|
if ($voucherId === false) {
|
|
$db->transRollback();
|
|
throw new RuntimeException('Unable to create the financial aid voucher.');
|
|
}
|
|
|
|
$now = function_exists('utc_now') ? utc_now() : date('Y-m-d H:i:s');
|
|
$amountCents = (int) round($amount * 100);
|
|
$usagePayload = [
|
|
'voucher_id' => (int) $voucherId,
|
|
'invoice_id' => (int) $invoice['id'],
|
|
'parent_id' => $parentId,
|
|
'discount_amount' => $amount,
|
|
'description' => 'Financial aid',
|
|
'school_year' => $schoolYear,
|
|
'updated_by' => $reviewedBy,
|
|
'used_at' => $now,
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
];
|
|
if ($db->fieldExists('requested_discount_cents', 'discount_usages')) {
|
|
$usagePayload['requested_discount_cents'] = $amountCents;
|
|
$usagePayload['eligible_base_cents'] = $amountCents;
|
|
$usagePayload['eligible_base_before_cents'] = $amountCents;
|
|
$usagePayload['applied_discount_cents'] = $amountCents;
|
|
$usagePayload['application_order'] = 1;
|
|
}
|
|
|
|
$usageId = $this->usageModel->insert($usagePayload, true);
|
|
if ($usageId === false) {
|
|
$db->transRollback();
|
|
throw new RuntimeException('Unable to record the financial aid discount.');
|
|
}
|
|
|
|
$this->voucherModel->update((int) $voucherId, ['times_used' => 1, 'is_active' => 0]);
|
|
$this->invoiceLedgerService->recalculateInvoice((int) $invoice['id']);
|
|
|
|
$this->requestModel->update((int) $request['id'], [
|
|
'status' => 'approved',
|
|
'admin_amount' => $amount,
|
|
'admin_note' => $adminNote,
|
|
'reviewed_by' => $reviewedBy,
|
|
'reviewed_at' => $now,
|
|
'invoice_id' => (int) $invoice['id'],
|
|
'discount_usage_id' => (int) $usageId,
|
|
'voucher_id' => (int) $voucherId,
|
|
]);
|
|
|
|
$db->transComplete();
|
|
if ($db->transStatus() === false) {
|
|
throw new RuntimeException('Unable to apply the financial aid discount.');
|
|
}
|
|
|
|
return $this->requestModel->find((int) $request['id']) ?? $request;
|
|
}
|
|
|
|
private function latestInvoice(int $parentId, string $schoolYear): ?array
|
|
{
|
|
if ($parentId <= 0 || $schoolYear === '') {
|
|
return null;
|
|
}
|
|
|
|
return $this->invoiceModel
|
|
->where('parent_id', $parentId)
|
|
->where('school_year', $schoolYear)
|
|
->orderBy('id', 'DESC')
|
|
->first();
|
|
}
|
|
|
|
private function createInvoiceForParentYear(int $parentId, string $schoolYear): array
|
|
{
|
|
if ($parentId <= 0 || $schoolYear === '') {
|
|
throw new RuntimeException('Cannot create an invoice because the parent or school year is missing.');
|
|
}
|
|
|
|
$enrollmentModel = $this->enrollmentModel ?? new EnrollmentModel();
|
|
$studentClassModel = $this->studentClassModel ?? new StudentClassModel();
|
|
$classSectionModel = $this->classSectionModel ?? new ClassSectionModel();
|
|
$eventChargesModel = $this->eventChargesModel ?? new EventChargesModel();
|
|
$configurationModel = $this->configurationModel ?? new ConfigurationModel();
|
|
$invoiceIssuanceService = $this->invoiceIssuanceService ?? new InvoiceIssuanceService(
|
|
$this->requestModel->db,
|
|
$this->invoiceModel,
|
|
null,
|
|
$this->invoiceLedgerService
|
|
);
|
|
|
|
$semester = (string) (getSemester() ?: '');
|
|
$enrollments = $enrollmentModel
|
|
->where('parent_id', $parentId)
|
|
->where('school_year', $schoolYear)
|
|
->findAll();
|
|
|
|
if ($enrollments === []) {
|
|
throw new RuntimeException('No enrollment records were found, so an invoice could not be created for this parent.');
|
|
}
|
|
|
|
$registeredKids = [];
|
|
$withdrawnKids = [];
|
|
foreach ($enrollments as $enrollment) {
|
|
$studentData = [
|
|
'student_id' => (int) ($enrollment['student_id'] ?? 0),
|
|
'parent_id' => (int) ($enrollment['parent_id'] ?? 0),
|
|
'class_section_id' => (int) ($enrollment['class_section_id'] ?? 0),
|
|
'enrollment_status' => (string) ($enrollment['enrollment_status'] ?? ''),
|
|
'school_year' => (string) ($enrollment['school_year'] ?? ''),
|
|
'semester' => (string) ($enrollment['semester'] ?? ''),
|
|
'admission_status' => (string) ($enrollment['admission_status'] ?? ''),
|
|
'is_withdrawn' => (int) ($enrollment['is_withdrawn'] ?? 0),
|
|
];
|
|
|
|
if (in_array($studentData['enrollment_status'], ['enrolled', 'payment pending'], true)) {
|
|
$registeredKids[] = $studentData;
|
|
} elseif (in_array($studentData['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'], true)) {
|
|
$withdrawnKids[] = $studentData;
|
|
}
|
|
}
|
|
|
|
$registeredKids = $this->onlyStudentsWithClassAssignment($registeredKids, $studentClassModel, $schoolYear);
|
|
$withdrawnKids = $this->onlyStudentsWithClassAssignment($withdrawnKids, $studentClassModel, $schoolYear);
|
|
$tuitionAmount = $this->calculateTuitionAmount($registeredKids, $withdrawnKids, $classSectionModel, $configurationModel);
|
|
$eventAmount = array_sum(array_map(
|
|
static fn(array $row): float => (float) ($row['charged'] ?? 0),
|
|
$eventChargesModel->getChargesWithEventInfo($parentId, $schoolYear)
|
|
));
|
|
$totalAmount = $tuitionAmount + $eventAmount;
|
|
|
|
if ($totalAmount <= 0) {
|
|
throw new RuntimeException('Invoice could not be created because this parent has no billable tuition or event charges.');
|
|
}
|
|
|
|
$issueUtc = (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s');
|
|
$dueUtc = $this->invoiceDueUtc($configurationModel);
|
|
|
|
$result = $invoiceIssuanceService->issueInvoice(new IssueInvoiceCommand([
|
|
'parent_id' => $parentId,
|
|
'invoice_number' => $invoiceIssuanceService->generateInvoiceNumber($schoolYear, $parentId),
|
|
'total_amount' => $totalAmount,
|
|
'paid_amount' => 0,
|
|
'balance' => $totalAmount,
|
|
'school_year' => $schoolYear,
|
|
'semester' => $semester,
|
|
'issue_date' => $issueUtc,
|
|
'due_date' => $dueUtc,
|
|
'created_at' => function_exists('utc_now') ? utc_now() : $issueUtc,
|
|
'updated_at' => function_exists('utc_now') ? utc_now() : $issueUtc,
|
|
], $tuitionAmount, $eventAmount, [
|
|
'parent_id' => $parentId,
|
|
'school_year' => $schoolYear,
|
|
'semester' => $semester,
|
|
'registered_student_count' => count($registeredKids),
|
|
'withdrawn_student_count' => count($withdrawnKids),
|
|
]));
|
|
|
|
$invoice = $this->invoiceModel->find($result->invoiceId);
|
|
if (!is_array($invoice)) {
|
|
throw new RuntimeException('Invoice was created but could not be reloaded.');
|
|
}
|
|
|
|
return $invoice;
|
|
}
|
|
|
|
private function onlyStudentsWithClassAssignment(array $students, StudentClassModel $studentClassModel, string $schoolYear): array
|
|
{
|
|
return array_values(array_filter($students, static function (array $student) use ($studentClassModel, $schoolYear): bool {
|
|
$studentId = (int) ($student['student_id'] ?? 0);
|
|
return $studentId > 0 && $studentClassModel->hasNonEventAssignment($studentId, $schoolYear);
|
|
}));
|
|
}
|
|
|
|
private function calculateTuitionAmount(
|
|
array $registeredKids,
|
|
array $withdrawnKids,
|
|
ClassSectionModel $classSectionModel,
|
|
ConfigurationModel $configurationModel
|
|
): float {
|
|
$tuitionStudents = $this->isBeforeRefundDeadline($configurationModel)
|
|
? $registeredKids
|
|
: array_merge($registeredKids, $withdrawnKids);
|
|
|
|
foreach ($tuitionStudents as &$student) {
|
|
$gradeName = $classSectionModel->getClassSectionNameBySectionId((int) ($student['class_section_id'] ?? 0));
|
|
$student['grade'] = strtoupper(trim((string) $gradeName));
|
|
}
|
|
unset($student);
|
|
|
|
usort($tuitionStudents, static fn(array $left, array $right): int => GradeLevelParser::parse($left['grade'] ?? null) <=> GradeLevelParser::parse($right['grade'] ?? null));
|
|
|
|
$firstStudentFee = (float) ($configurationModel->getConfig('first_student_fee') ?? 380);
|
|
$secondStudentFee = (float) ($configurationModel->getConfig('second_student_fee') ?? 280);
|
|
$total = 0.0;
|
|
foreach (array_values($tuitionStudents) as $index => $student) {
|
|
$total += $index === 0 ? $firstStudentFee : $secondStudentFee;
|
|
}
|
|
|
|
return $total;
|
|
}
|
|
|
|
private function isBeforeRefundDeadline(ConfigurationModel $configurationModel): bool
|
|
{
|
|
try {
|
|
$refundDeadline = (string) ($configurationModel->getConfig('refund_deadline') ?? '');
|
|
if ($refundDeadline === '') {
|
|
return true;
|
|
}
|
|
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
|
$tz = new DateTimeZone($tzName);
|
|
return new \DateTimeImmutable('today', $tz) <= new \DateTimeImmutable($refundDeadline, $tz);
|
|
} catch (\Throwable) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private function invoiceDueUtc(ConfigurationModel $configurationModel): ?string
|
|
{
|
|
$dueDate = (string) ($configurationModel->getConfig('first_day_of_school') ?: $configurationModel->getConfig('due_date') ?: '');
|
|
if ($dueDate === '') {
|
|
return null;
|
|
}
|
|
|
|
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
|
$dueLocal = new DateTime($dueDate . ' 19:59:59', new DateTimeZone($tzName));
|
|
$dueLocal->setTimezone(new DateTimeZone('UTC'));
|
|
return $dueLocal->format('Y-m-d H:i:s');
|
|
}
|
|
}
|