invoiceModel = new InvoiceModel(); $this->paymentModel = new PaymentModel(); $this->refundModel = new RefundModel(); $this->discountUsageModel = new DiscountUsageModel(); $this->additionalChargeModel = new AdditionalChargeModel(); $this->configurationModel = new ConfigurationModel(); $this->enrollmentModel = new EnrollmentModel(); $this->studentClassModel = new StudentClassModel(); $this->classSectionModel = new ClassSectionModel(); $this->eventChargesModel = new EventChargesModel(); $this->invoiceEventModel = new InvoiceEventModel(); $this->studentModel = new StudentModel(); $this->oldCalculator = new OldTuitionCalculatorService(); $this->newCalculator = new NewTuitionCalculatorService(); } public function calculateInvoice(int $invoiceId): array { $invoice = $this->loadInvoice($invoiceId); if ($invoice === null) { throw new \RuntimeException('Invoice not found.'); } $tuitionTotal = $this->calculateTuitionTotal($invoice); $eventTotal = $this->calculateEventTotal($invoice); $additionalTotal = $this->calculateAdditionalCharges($invoiceId); $discountRawTotal = $this->calculateDiscounts($invoiceId); $paidTotal = $this->calculateValidPayments($invoiceId); $refundPaidTotal = $this->calculatePaidRefunds($invoiceId); $tuitionCents = $this->toCents($tuitionTotal); $eventCents = $this->toCents($eventTotal); $additionalCents = $this->toCents($additionalTotal); $discountRawCents = $this->toCents($discountRawTotal); $paidCents = $this->toCents($paidTotal); $refundPaidCents = $this->toCents($refundPaidTotal); $discountBaseCents = max(0, $tuitionCents + $additionalCents); $discountCents = min($discountRawCents, $discountBaseCents); $totalAmountCents = $tuitionCents + $eventCents + $additionalCents; $balanceCents = max(0, $totalAmountCents - $discountCents - $paidCents - $refundPaidCents); if ($balanceCents === 0) { $status = FinancialStatus::INVOICE_PAID; } elseif ($paidCents > 0 || $discountCents > 0 || $refundPaidCents > 0) { $status = FinancialStatus::INVOICE_PARTIALLY_PAID; } else { $status = FinancialStatus::INVOICE_UNPAID; } return [ 'invoice_id' => $invoiceId, 'tuition_total' => $this->fromCents($tuitionCents), 'event_total' => $this->fromCents($eventCents), 'additional_total' => $this->fromCents($additionalCents), 'discount_total' => $this->fromCents($discountCents), 'discount_raw_total' => $this->fromCents($discountRawCents), 'paid_amount' => $this->fromCents($paidCents), 'refund_paid_total' => $this->fromCents($refundPaidCents), 'total_amount' => $this->fromCents($totalAmountCents), 'balance' => $this->fromCents($balanceCents), 'status' => $status, 'has_discount' => $discountCents > 0 ? 1 : 0, ]; } public function recalculateInvoice(int $invoiceId): array { $calculation = $this->calculateInvoice($invoiceId); $payload = [ 'total_amount' => $calculation['total_amount'], 'paid_amount' => $calculation['paid_amount'], 'balance' => $calculation['balance'], 'status' => $calculation['status'], 'has_discount' => $calculation['has_discount'], 'updated_at' => utc_now(), ]; if ($this->invoiceModel->db->fieldExists('discount', $this->invoiceModel->table)) { $payload['discount'] = $calculation['discount_total']; } $this->invoiceModel->update($invoiceId, $payload); return $calculation; } protected function loadInvoice(int $invoiceId): ?array { return $this->invoiceModel->find($invoiceId); } protected function calculateTuitionTotal(array $invoice): float { $parentId = (int) ($invoice['parent_id'] ?? 0); $schoolYear = (string) ($invoice['school_year'] ?? ''); if ($parentId <= 0 || $schoolYear === '') { return 0.0; } $students = $this->loadTuitionStudents($parentId, $schoolYear); $config = $this->getTuitionConfig(); $calculator = $this->resolveActiveCalculator(); return (float) ($calculator->calculateFamilyTuition($students, $config)['total'] ?? 0.0); } protected function calculateEventTotal(array $invoice): float { $invoiceId = (int) ($invoice['id'] ?? 0); $invoiceEventRows = $this->invoiceEventModel ->select('COALESCE(SUM(amount),0) AS total_amount') ->where('invoice_id', $invoiceId) ->findAll(); if (!empty($invoiceEventRows)) { $total = (float) ($invoiceEventRows[0]['total_amount'] ?? 0); if ($total > 0) { return $total; } } $rows = $this->eventChargesModel ->select('COALESCE(SUM(charged),0) AS total_amount') ->where('parent_id', (int) ($invoice['parent_id'] ?? 0)) ->where('school_year', (string) ($invoice['school_year'] ?? '')) ->where('semester', (string) ($invoice['semester'] ?? '')) ->findAll(); return (float) ($rows[0]['total_amount'] ?? 0); } protected function calculateAdditionalCharges(int $invoiceId): float { $rows = $this->additionalChargeModel ->select('COALESCE(SUM(amount),0) AS total_amount') ->where('invoice_id', $invoiceId) ->whereNotIn('status', ['void', FinancialStatus::ADDITIONAL_CHARGE_VOIDED, 'cancelled', 'canceled']) ->findAll(); return (float) ($rows[0]['total_amount'] ?? 0); } protected function calculateDiscounts(int $invoiceId): float { $row = $this->discountUsageModel ->select('COALESCE(SUM(discount_amount),0) AS total_amount') ->where('invoice_id', $invoiceId) ->first(); return (float) ($row['total_amount'] ?? 0); } protected function calculateValidPayments(int $invoiceId): float { $query = $this->paymentModel ->select('COALESCE(SUM(paid_amount),0) AS total_amount') ->where('invoice_id', $invoiceId); if ($this->paymentModel->db->fieldExists('status', $this->paymentModel->table)) { $query->groupStart() ->whereNotIn('status', FinancialStatus::EXCLUDED_PAYMENT_STATUSES) ->orWhere('status IS NULL', null, false) ->groupEnd(); } if ($this->paymentModel->db->fieldExists('is_void', $this->paymentModel->table)) { $query->groupStart() ->where('is_void', 0) ->orWhere('is_void IS NULL', null, false) ->groupEnd(); } $row = $query->first(); return (float) ($row['total_amount'] ?? 0); } protected function calculatePaidRefunds(int $invoiceId): float { $row = $this->refundModel ->select('COALESCE(SUM(refund_paid_amount),0) AS total_amount') ->where('invoice_id', $invoiceId) ->whereIn('status', FinancialStatus::REFUND_REDUCES_INVOICE_STATUSES) ->first(); return (float) ($row['total_amount'] ?? 0); } protected function loadTuitionStudents(int $parentId, string $schoolYear): array { $enrollments = $this->enrollmentModel ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->findAll(); if (empty($enrollments)) { return []; } $refundDeadline = (string) ($this->configurationModel->getConfig('refund_deadline') ?? ''); $includeWithdrawn = !$this->isWithinRefundWindow($refundDeadline); $eligibleStatuses = ['enrolled', 'payment pending']; if ($includeWithdrawn) { array_push($eligibleStatuses, 'withdrawn', 'refund pending', 'withdraw under review'); } $students = []; foreach ($enrollments as $enrollment) { $status = strtolower(trim((string) ($enrollment['enrollment_status'] ?? ''))); $studentId = (int) ($enrollment['student_id'] ?? 0); if ($studentId <= 0 || !in_array($status, $eligibleStatuses, true)) { continue; } if (!$this->studentClassModel->hasNonEventAssignment($studentId, $schoolYear)) { continue; } $gradeName = $this->resolveGradeName($studentId, $schoolYear, $enrollment['class_section_id'] ?? null); $student = $this->studentModel->find($studentId) ?? []; $students[] = [ 'student_id' => $studentId, 'student_name' => trim(((string) ($student['firstname'] ?? '')) . ' ' . ((string) ($student['lastname'] ?? ''))), 'grade_level' => $gradeName, ]; } return $students; } 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) ->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) && $name !== '' ? strtoupper(trim($name)) : 'N/A'; } protected function resolveActiveCalculator(): TuitionCalculatorInterface { $version = strtolower(trim((string) ($this->configurationModel->getConfig('tuition_calculator_version') ?? 'old'))); return $version === 'new' ? $this->newCalculator : $this->oldCalculator; } 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'), 'new_tuition_full_amount' => $this->configurationModel->getConfig('new_tuition_full_amount'), 'new_tuition_youth_amount' => $this->configurationModel->getConfig('new_tuition_youth_amount'), '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 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 toCents($amount): int { return (int) round(((float) $amount) * 100); } protected function fromCents(int $cents): string { return number_format($cents / 100, 2, '.', ''); } }