invoiceModel = new InvoiceModel(); $this->paymentModel = new PaymentModel(); $this->refundModel = new RefundModel(); $this->refundPayoutModel = new RefundPayoutModel(); $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->invoiceLineModel = new InvoiceLineModel(); $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.'); } $frozenTotals = $this->calculateFrozenLineTotals($invoiceId); if ($frozenTotals !== null) { $tuitionTotal = $this->fromCentsNumber($frozenTotals['tuition_cents']); $eventTotal = $this->fromCentsNumber($frozenTotals['event_cents']); $additionalTotal = $this->fromCentsNumber($frozenTotals['additional_cents']); $frozenTotalCents = $frozenTotals['total_cents']; } else { $tuitionTotal = $this->calculateTuitionTotal($invoice); $eventTotal = $this->calculateEventTotal($invoice); $additionalTotal = $this->calculateAdditionalCharges($invoiceId); $frozenTotalCents = null; } $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); if ($frozenTotalCents !== null) { $totalAmountCents = $frozenTotalCents; $discountBaseCents = max(0, (int) ($frozenTotals['discount_eligible_base_cents'] ?? ($tuitionCents + $additionalCents))); $discountCents = $discountBaseCents > 0 ? min($discountRawCents, $discountBaseCents) : 0; } elseif ($this->isCarryForwardInvoice($invoice)) { $totalAmountCents = $this->toCents((float) ($invoice['total_amount'] ?? 0)); $discountBaseCents = 0; $discountCents = 0; } else { $discountBaseCents = max(0, $tuitionCents + $additionalCents); $discountCents = min($discountRawCents, $discountBaseCents); $totalAmountCents = $tuitionCents + $eventCents + $additionalCents; } $netChargeCents = $totalAmountCents - $discountCents; $rawBalanceCents = $netChargeCents - $paidCents + $refundPaidCents; $balanceCents = max(0, $rawBalanceCents); $customerCreditCents = max(0, $paidCents - $refundPaidCents - $netChargeCents); 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, 'gross_charge_cents' => $totalAmountCents, 'discount_eligible_base_cents' => $discountBaseCents, 'requested_discount_cents' => $discountRawCents, 'applied_discount_cents' => $discountCents, 'net_charge_cents' => $netChargeCents, 'totalAmountCents' => $totalAmountCents, 'discountCents' => $discountCents, 'paidCents' => $paidCents, 'completedRefundCents' => $refundPaidCents, 'rawBalanceCents' => $rawBalanceCents, 'balanceDueCents' => $balanceCents, 'customerCreditCents' => $customerCreditCents, '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), 'customer_credit' => $this->fromCents($customerCreditCents), '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']; } if (!$this->invoiceModel->update($invoiceId, $payload)) { throw new FinancialPersistenceException('INVOICE_LEDGER_UPDATE_FAILED', $this->invoiceModel->errors()); } return $calculation; } public function recalculate(int $invoiceId): array { return $this->recalculateInvoice($invoiceId); } /** * Refresh frozen tuition invoice lines to match the current enrollment/class assignment state. * Event and additional charge lines are left unchanged. */ public function syncTuitionLines(int $invoiceId): bool { $invoice = $this->loadInvoice($invoiceId); if ($invoice === null || $this->isCarryForwardInvoice($invoice) || ! $this->invoiceLinesAvailable()) { return false; } if (! $this->invoiceHasLines($invoiceId)) { return false; } $currentTuitionCents = $this->toCents($this->calculateTuitionTotal($invoice)); $frozen = $this->calculateFrozenLineTotals($invoiceId); $frozenTuitionCents = (int) ($frozen['tuition_cents'] ?? 0); if ($currentTuitionCents === $frozenTuitionCents) { return false; } $now = utc_now(); $this->invoiceLineModel() ->where('invoice_id', $invoiceId) ->where('voided_at IS NULL', null, false) ->groupStart() ->where('line_type', 'tuition') ->orWhere('source_type', 'tuition_calculation') ->groupEnd() ->set([ 'voided_at' => $now, 'updated_at' => $now, ]) ->update(); if ($currentTuitionCents === 0) { return true; } $metadata = [ 'parent_id' => (int) ($invoice['parent_id'] ?? 0), 'school_year' => (string) ($invoice['school_year'] ?? ''), 'semester' => (string) ($invoice['semester'] ?? ''), 'synced_at' => $now, ]; $lineId = $this->invoiceLineModel()->insert( $this->buildInvoiceLineRow( $invoiceId, 'tuition', 'tuition_calculation', null, 'Tuition charges', $currentTuitionCents, 1, $this->getCalculationVersion(), $metadata, $now ) ); if (! $lineId) { throw new FinancialPersistenceException('INVOICE_TUITION_SYNC_FAILED', $this->invoiceLineModel()->errors()); } return true; } public function getValidPaymentTotalCents(int $invoiceId): int { return $this->toCents($this->calculateValidPayments($invoiceId)); } public function issueInitialInvoiceLines( int $invoiceId, float $tuitionAmount, float $eventAmount, array $metadata = [] ): int { if ($invoiceId <= 0) { throw new \RuntimeException('Invoice ID is required to issue invoice lines.'); } if (!$this->invoiceLinesAvailable()) { throw new \RuntimeException('Invoice lines table is not available.'); } if ($this->invoiceHasLines($invoiceId)) { return 0; } $now = utc_now(); $version = (string) ($metadata['calculation_version'] ?? $this->getCalculationVersion()); $rows = []; $tuitionCents = $this->toCents($tuitionAmount); if ($tuitionCents !== 0) { $rows[] = $this->buildInvoiceLineRow( $invoiceId, 'tuition', 'tuition_calculation', null, 'Tuition charges', $tuitionCents, 1, $version, $metadata, $now ); } $eventCents = $this->toCents($eventAmount); if ($eventCents !== 0) { $rows[] = $this->buildInvoiceLineRow( $invoiceId, 'event_fee', 'event_charges', null, 'Event charges', $eventCents, 0, $version, $metadata, $now ); } foreach ($this->loadApprovedAdjustmentRowsForIssuance($invoiceId, $metadata) as $charge) { $adjustmentCents = (string)($charge['charge_type'] ?? 'add') === 'deduct' ? -1 * $this->toCents(abs((float)($charge['amount'] ?? 0))) : $this->toCents(abs((float)($charge['amount'] ?? 0))); if ($adjustmentCents === 0) { throw new \RuntimeException('Zero amount approved additional charges cannot be issued.'); } $row = $this->buildInvoiceLineRow( $invoiceId, $adjustmentCents > 0 ? 'additional_charge' : 'additional_deduction', 'additional_charge', (int)$charge['id'], trim((string)($charge['title'] ?? 'Additional charge')) ?: 'Additional charge', $adjustmentCents, 0, $version, $metadata + ['charge_type' => (string)($charge['charge_type'] ?? '')], $now ); $row['active_source_key'] = 'additional_charge:' . (int)$charge['id']; $rows[] = $row; } if ($rows === []) { throw new \RuntimeException('Invoice issuance requires at least one non-zero invoice line.'); } $inserted = 0; foreach ($rows as $row) { $lineId = $this->invoiceLineModel()->insert($row); if (!$lineId) { throw new FinancialPersistenceException('INVOICE_LINE_INSERT_FAILED', $this->invoiceLineModel()->errors()); } $inserted++; if (($row['source_type'] ?? null) === 'additional_charge' && !empty($row['source_id'])) { $this->additionalChargeModel->update((int)$row['source_id'], [ 'invoice_id' => $invoiceId, 'status' => FinancialStatus::ADDITIONAL_CHARGE_APPLIED, 'applied_invoice_line_id' => (int)$lineId, 'applied_at' => $now, ]); } } return $inserted; } protected function loadApprovedAdjustmentRowsForIssuance(int $invoiceId, array $metadata): array { $parentId = (int)($metadata['parent_id'] ?? 0); $schoolYear = (string)($metadata['school_year'] ?? ''); $semester = (string)($metadata['semester'] ?? ''); if ($parentId <= 0 || $schoolYear === '' || $semester === '') { return []; } return $this->additionalChargeModel ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->where('semester', $semester) ->where('status', FinancialStatus::ADDITIONAL_CHARGE_APPROVED) ->groupStart() ->where('invoice_id', $invoiceId) ->orWhere('invoice_id IS NULL', null, false) ->groupEnd() ->orderBy('id', 'ASC') ->findAll(); } protected function loadInvoice(int $invoiceId): ?array { return $this->invoiceModel->find($invoiceId); } protected function calculateFrozenLineTotals(int $invoiceId): ?array { if (!$this->invoiceLinesAvailable() || !$this->invoiceHasLines($invoiceId)) { return null; } $rows = $this->invoiceLineModel() ->select('line_type, line_amount_cents, discount_eligible') ->where('invoice_id', $invoiceId) ->where('voided_at IS NULL', null, false) ->findAll(); $totals = [ 'tuition_cents' => 0, 'event_cents' => 0, 'additional_cents' => 0, 'total_cents' => 0, 'discount_eligible_base_cents' => 0, ]; foreach ($rows as $row) { $amount = (int) ($row['line_amount_cents'] ?? 0); $type = (string) ($row['line_type'] ?? ''); $totals['total_cents'] += $amount; if ((int) ($row['discount_eligible'] ?? 0) === 1) { $totals['discount_eligible_base_cents'] += $amount; } if (str_contains($type, 'event')) { $totals['event_cents'] += $amount; } elseif (str_contains($type, 'additional') || str_contains($type, 'adjustment') || str_contains($type, 'charge')) { $totals['additional_cents'] += $amount; } else { $totals['tuition_cents'] += $amount; } } return $totals; } protected function invoiceHasLines(int $invoiceId): bool { if (!$this->invoiceLinesAvailable()) { return false; } return $this->invoiceLineModel() ->where('invoice_id', $invoiceId) ->where('voided_at IS NULL', null, false) ->countAllResults() > 0; } protected function invoiceLinesAvailable(): bool { try { return $this->invoiceLineModel()->db->tableExists('invoice_lines'); } catch (\Throwable $e) { return false; } } protected function invoiceLineModel(): InvoiceLineModel { if ($this->invoiceLineModel === null) { $this->invoiceLineModel = new InvoiceLineModel(); } return $this->invoiceLineModel; } protected function buildInvoiceLineRow( int $invoiceId, string $lineType, ?string $sourceType, ?int $sourceId, string $description, int $amountCents, int $discountEligible, string $version, array $metadata, string $timestamp ): array { return [ 'invoice_id' => $invoiceId, 'school_year' => (string)($metadata['school_year'] ?? ''), 'line_type' => $lineType, 'source_type' => $sourceType, 'source_id' => $sourceId, 'active_source_key' => null, 'description' => $description, 'quantity' => '1.00', 'unit_amount_cents' => $amountCents, 'line_amount_cents' => $amountCents, 'discount_eligible' => $discountEligible, 'calculation_version' => $version, 'metadata_json' => json_encode($metadata, JSON_UNESCAPED_SLASHES), 'created_at' => $timestamp, 'updated_at' => $timestamp, 'voided_at' => null, ]; } protected function getCalculationVersion(): string { return 'invoice_lines_v1:' . get_class($this->resolveActiveCalculator()); } protected function isCarryForwardInvoice(array $invoice): bool { $invoiceNumber = (string) ($invoice['invoice_number'] ?? ''); if (str_starts_with($invoiceNumber, 'CF-')) { return true; } if (strcasecmp((string) ($invoice['semester'] ?? ''), 'Opening Balance') === 0) { return true; } $description = strtolower((string) ($invoice['description'] ?? '')); return str_contains($description, 'carried over') || str_contains($description, 'carry-forward') || str_contains($description, 'carry over') || str_contains($description, 'previous school year'); } public function invoiceIsCarryForward(array $invoice): bool { return $this->isCarryForwardInvoice($invoice); } public function carryForwardDisplayDescription(array $invoice): string { $sourceYear = $this->carryForwardSourceSchoolYear($invoice); if ($sourceYear !== '') { return 'Carry over balance from last year ' . $sourceYear; } return 'Carry over balance from last year'; } /** * Issue a single carry-over line on an opening-balance invoice. No student names are used. */ public function issueCarryForwardInvoiceLine(int $invoiceId, float $amount, string $description): int { if ($invoiceId <= 0 || ! $this->invoiceLinesAvailable()) { return 0; } $invoice = $this->loadInvoice($invoiceId); if ($invoice === null || ! $this->isCarryForwardInvoice($invoice)) { return 0; } $amountCents = $this->toCents($amount); if ($amountCents === 0) { return 0; } $description = trim($description); if ($description === '') { $description = $this->carryForwardDisplayDescription($invoice); } $existingLine = $this->invoiceLineModel() ->where('invoice_id', $invoiceId) ->where('source_type', 'carry_forward_opening_balance') ->where('voided_at IS NULL', null, false) ->first(); $now = utc_now(); $this->invoiceLineModel() ->where('invoice_id', $invoiceId) ->where('voided_at IS NULL', null, false) ->groupStart() ->where('line_type', 'tuition') ->orWhere('source_type', 'tuition_calculation') ->groupEnd() ->set([ 'voided_at' => $now, 'updated_at' => $now, ]) ->update(); if ($existingLine !== null) { $this->invoiceLineModel()->update((int) $existingLine['id'], [ 'description' => $description, 'unit_amount_cents' => $amountCents, 'line_amount_cents' => $amountCents, 'updated_at' => $now, ]); return (int) $existingLine['id']; } $lineId = $this->invoiceLineModel()->insert( $this->buildInvoiceLineRow( $invoiceId, 'additional_charge', 'carry_forward_opening_balance', $invoiceId, $description, $amountCents, 0, $this->getCalculationVersion(), ['carry_forward' => true], $now ) ); if (! $lineId) { throw new FinancialPersistenceException('INVOICE_CARRY_FORWARD_LINE_FAILED', $this->invoiceLineModel()->errors()); } return (int) $lineId; } private function carryForwardSourceSchoolYear(array $invoice): string { $description = (string) ($invoice['description'] ?? ''); if (preg_match('/last year\s+(\d{4}-\d{4})/i', $description, $matches) === 1) { return $matches[1]; } if (preg_match('/previous school year\s+(\d{4}-\d{4})/i', $description, $matches) === 1) { return $matches[1]; } $invoiceNumber = (string) ($invoice['invoice_number'] ?? ''); if (preg_match('/^CF-(\d{8})-/i', $invoiceNumber, $matches) === 1) { $compact = $matches[1]; if (strlen($compact) === 8) { return substr($compact, 0, 4) . '-' . substr($compact, 4, 4); } } $targetYear = trim((string) ($invoice['school_year'] ?? '')); if (preg_match('/^(\d{4})-(\d{4})$/', $targetYear, $matches) === 1) { return ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1); } return ''; } 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(CASE WHEN charge_type = 'deduct' THEN -ABS(amount) ELSE ABS(amount) END),0) AS total_amount", false) ->where('invoice_id', $invoiceId) ->where('status', FinancialStatus::ADDITIONAL_CHARGE_APPLIED) ->findAll(); return (float) ($rows[0]['total_amount'] ?? 0); } protected function calculateDiscounts(int $invoiceId): float { if ($this->discountUsageModel->db->fieldExists('applied_discount_cents', $this->discountUsageModel->table)) { $row = $this->discountUsageModel ->select('COALESCE(SUM(applied_discount_cents),0) AS total_cents') ->where('invoice_id', $invoiceId) ->first(); return ((int) ($row['total_cents'] ?? 0)) / 100; } $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() ->whereIn('status', FinancialStatus::VALID_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 { if ($this->refundPayoutsAvailable()) { $row = $this->refundPayoutModel() ->select("COALESCE(SUM(CASE WHEN refund_payouts.payout_type = 'cash_out' AND refund_payouts.status = 'completed' THEN refund_payouts.amount_cents WHEN refund_payouts.payout_type = 'reversal' AND refund_payouts.status = 'completed' THEN -refund_payouts.amount_cents ELSE 0 END),0) AS total_cents", false) ->join('refunds', 'refunds.id = refund_payouts.refund_id', 'inner') ->where('refunds.invoice_id', $invoiceId) ->first(); return max(0, (float) ($row['total_cents'] ?? 0)) / 100; } $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 refundPayoutsAvailable(): bool { try { return $this->refundPayoutModel()->db->tableExists('refund_payouts'); } catch (\Throwable $e) { return false; } } protected function refundPayoutModel(): RefundPayoutModel { if ($this->refundPayoutModel === null) { $this->refundPayoutModel = new RefundPayoutModel(); } return $this->refundPayoutModel; } 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('first_student_fee'), 'new_tuition_full_amount' => $this->configurationModel->getConfig('new_tuition_full_amount'), 'new_tuition_second_student_discount' => $this->configurationModel->getConfig('new_tuition_second_student_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, '.', ''); } protected function fromCentsNumber(int $cents): float { return $cents / 100; } }