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'); } }