fix financial issues
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use CodeIgniter\Files\File;
|
||||
use CodeIgniter\HTTP\Files\UploadedFile;
|
||||
|
||||
final class FinancialAttachmentService
|
||||
{
|
||||
private const MAX_BYTES = 5242880;
|
||||
private const ALLOWED_MIMES = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
];
|
||||
private const ALLOWED_EXTENSIONS = [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'pdf',
|
||||
'png',
|
||||
];
|
||||
|
||||
public function saveUploadedFile($file, string $subdir): ?string
|
||||
{
|
||||
if (!$file instanceof UploadedFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$file->isValid() || $file->hasMoved()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$size = (int) ($file->getSize() ?? 0);
|
||||
if ($size <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mime = strtolower((string) $file->getMimeType());
|
||||
$ext = strtolower((string) $file->getClientExtension());
|
||||
|
||||
if (!in_array($mime, self::ALLOWED_MIMES, true) || !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||||
throw new \RuntimeException('Unsupported file type. Use PDF, JPG, or PNG.');
|
||||
}
|
||||
|
||||
if ($size > self::MAX_BYTES) {
|
||||
throw new \RuntimeException('File too large. Maximum size is 5 MB.');
|
||||
}
|
||||
|
||||
$dir = $this->ensureSubdir($subdir);
|
||||
$name = $file->getRandomName();
|
||||
$file->move($dir, $name);
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
public function resolvePath(string $subdir, string $filename): ?string
|
||||
{
|
||||
$safeName = basename($filename);
|
||||
if ($safeName === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = $this->ensureSubdir($subdir) . DIRECTORY_SEPARATOR . $safeName;
|
||||
|
||||
return is_file($path) ? $path : null;
|
||||
}
|
||||
|
||||
public function ensureSubdir(string $subdir): string
|
||||
{
|
||||
$path = WRITEPATH . 'uploads' . DIRECTORY_SEPARATOR . trim($subdir, '/');
|
||||
if (!is_dir($path) && !mkdir($path, 0775, true) && !is_dir($path)) {
|
||||
throw new \RuntimeException('Unable to prepare upload directory.');
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
public function detectMime(string $path): string
|
||||
{
|
||||
if (function_exists('finfo_open')) {
|
||||
$handle = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($handle) {
|
||||
$mime = finfo_file($handle, $path);
|
||||
finfo_close($handle);
|
||||
if (is_string($mime) && $mime !== '') {
|
||||
return $mime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (function_exists('mime_content_type')) {
|
||||
$mime = mime_content_type($path);
|
||||
if (is_string($mime) && $mime !== '') {
|
||||
return $mime;
|
||||
}
|
||||
}
|
||||
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
final class FinancialStatus
|
||||
{
|
||||
public const INVOICE_UNPAID = 'unpaid';
|
||||
public const INVOICE_PARTIALLY_PAID = 'partially_paid';
|
||||
public const INVOICE_PAID = 'paid';
|
||||
|
||||
public const PAYMENT_RECORDED = 'recorded';
|
||||
public const PAYMENT_VOIDED = 'voided';
|
||||
public const PAYMENT_FAILED = 'failed';
|
||||
public const PAYMENT_REFUNDED = 'refunded';
|
||||
public const PAYMENT_CHARGEBACK = 'chargeback';
|
||||
public const PAYMENT_DECLINED = 'declined';
|
||||
public const PAYMENT_REVERSED = 'reversed';
|
||||
public const PAYMENT_CANCELED = 'canceled';
|
||||
public const PAYMENT_CANCELLED = 'cancelled';
|
||||
|
||||
public const REFUND_PENDING = 'pending';
|
||||
public const REFUND_APPROVED = 'approved';
|
||||
public const REFUND_REJECTED = 'rejected';
|
||||
public const REFUND_PARTIALLY_PAID = 'partially_paid';
|
||||
public const REFUND_PAID = 'paid';
|
||||
public const REFUND_VOIDED = 'voided';
|
||||
|
||||
public const ADDITIONAL_CHARGE_PENDING = 'pending';
|
||||
public const ADDITIONAL_CHARGE_APPLIED = 'applied';
|
||||
public const ADDITIONAL_CHARGE_VOIDED = 'voided';
|
||||
|
||||
public const EXCLUDED_PAYMENT_STATUSES = [
|
||||
self::PAYMENT_VOIDED,
|
||||
self::PAYMENT_FAILED,
|
||||
self::PAYMENT_REFUNDED,
|
||||
self::PAYMENT_CHARGEBACK,
|
||||
self::PAYMENT_DECLINED,
|
||||
self::PAYMENT_REVERSED,
|
||||
self::PAYMENT_CANCELED,
|
||||
self::PAYMENT_CANCELLED,
|
||||
'void',
|
||||
];
|
||||
|
||||
public const REFUND_REDUCES_INVOICE_STATUSES = [
|
||||
self::REFUND_PARTIALLY_PAID,
|
||||
self::REFUND_PAID,
|
||||
'partial',
|
||||
'paid',
|
||||
'Partial',
|
||||
'Paid',
|
||||
];
|
||||
|
||||
public static function normalizeInvoiceStatus(?string $status): string
|
||||
{
|
||||
return match (self::normalize($status)) {
|
||||
'paid', 'full' => self::INVOICE_PAID,
|
||||
'partially paid', 'partially_paid', 'partial' => self::INVOICE_PARTIALLY_PAID,
|
||||
default => self::INVOICE_UNPAID,
|
||||
};
|
||||
}
|
||||
|
||||
public static function normalizePaymentStatus(?string $status): string
|
||||
{
|
||||
return match (self::normalize($status)) {
|
||||
'completed', 'paid', 'full', 'recorded' => self::PAYMENT_RECORDED,
|
||||
'void', 'voided' => self::PAYMENT_VOIDED,
|
||||
'failed' => self::PAYMENT_FAILED,
|
||||
'refunded' => self::PAYMENT_REFUNDED,
|
||||
'chargeback' => self::PAYMENT_CHARGEBACK,
|
||||
'declined' => self::PAYMENT_DECLINED,
|
||||
'reversed' => self::PAYMENT_REVERSED,
|
||||
'cancelled' => self::PAYMENT_CANCELLED,
|
||||
'canceled' => self::PAYMENT_CANCELED,
|
||||
default => self::normalize($status),
|
||||
};
|
||||
}
|
||||
|
||||
public static function normalizeRefundStatus(?string $status): string
|
||||
{
|
||||
return match (self::normalize($status)) {
|
||||
'approved' => self::REFUND_APPROVED,
|
||||
'rejected' => self::REFUND_REJECTED,
|
||||
'partial', 'partially paid', 'partially_paid' => self::REFUND_PARTIALLY_PAID,
|
||||
'paid', 'full' => self::REFUND_PAID,
|
||||
'void', 'voided' => self::REFUND_VOIDED,
|
||||
default => self::REFUND_PENDING,
|
||||
};
|
||||
}
|
||||
|
||||
public static function normalize(?string $status): string
|
||||
{
|
||||
$value = strtolower(trim((string) $status));
|
||||
$value = str_replace('_', ' ', $value);
|
||||
$value = preg_replace('/\s+/', ' ', $value) ?? $value;
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use App\Interfaces\TuitionCalculatorInterface;
|
||||
use App\Libraries\Tuition\NewTuitionCalculatorService;
|
||||
use App\Libraries\Tuition\OldTuitionCalculatorService;
|
||||
use App\Models\AdditionalChargeModel;
|
||||
use App\Models\ClassSectionModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\DiscountUsageModel;
|
||||
use App\Models\EnrollmentModel;
|
||||
use App\Models\EventChargesModel;
|
||||
use App\Models\InvoiceEventModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\RefundModel;
|
||||
use App\Models\StudentClassModel;
|
||||
use App\Models\StudentModel;
|
||||
|
||||
class InvoiceLedgerService
|
||||
{
|
||||
protected InvoiceModel $invoiceModel;
|
||||
protected PaymentModel $paymentModel;
|
||||
protected RefundModel $refundModel;
|
||||
protected DiscountUsageModel $discountUsageModel;
|
||||
protected AdditionalChargeModel $additionalChargeModel;
|
||||
protected ConfigurationModel $configurationModel;
|
||||
protected EnrollmentModel $enrollmentModel;
|
||||
protected StudentClassModel $studentClassModel;
|
||||
protected ClassSectionModel $classSectionModel;
|
||||
protected EventChargesModel $eventChargesModel;
|
||||
protected InvoiceEventModel $invoiceEventModel;
|
||||
protected StudentModel $studentModel;
|
||||
protected TuitionCalculatorInterface $oldCalculator;
|
||||
protected TuitionCalculatorInterface $newCalculator;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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_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, '.', '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries\Tuition;
|
||||
|
||||
final class GradeLevelParser
|
||||
{
|
||||
public static function parse($grade, int $gradeFee = 9): int
|
||||
{
|
||||
if (is_numeric($grade)) {
|
||||
return (int) $grade;
|
||||
}
|
||||
|
||||
if (!is_string($grade)) {
|
||||
return 999;
|
||||
}
|
||||
|
||||
$value = strtoupper(trim($grade));
|
||||
$value = preg_replace('/\s+/', ' ', $value) ?? $value;
|
||||
$value = str_replace(['.', '_', '-'], ['', '', ' '], $value);
|
||||
|
||||
if (in_array($value, ['K', 'KG', 'K G', 'KINDER', 'KINDERGARTEN'], true)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (in_array($value, ['PK', 'P K', 'PREK', 'PRE K', 'PRE KINDER', 'PREKINDER'], true)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (preg_match('/^Y(?:OUTH)?\s*(\d+)?$/', $value, $matches)) {
|
||||
$offset = isset($matches[1]) && $matches[1] !== '' ? max(1, (int) $matches[1]) : 1;
|
||||
return $gradeFee + $offset;
|
||||
}
|
||||
|
||||
if (preg_match('/^(?:GR?ADE\s*)?(\d{1,2})\s*([A-Z]*)$/', $value, $matches)) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
|
||||
return 999;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries\Tuition;
|
||||
|
||||
use App\Interfaces\TuitionCalculatorInterface;
|
||||
|
||||
final class NewTuitionCalculatorService implements TuitionCalculatorInterface
|
||||
{
|
||||
public function calculateFamilyTuition(array $students, array $config): array
|
||||
{
|
||||
$gradeFee = (int) ($config['grade_fee'] ?? 9);
|
||||
$fullAmountCents = $this->toCents($config['new_tuition_full_amount'] ?? 370);
|
||||
$secondDiscountCents = $this->toCents($config['new_tuition_second_student_discount'] ?? 50);
|
||||
$thirdDiscountCents = $this->toCents($config['new_tuition_third_student_discount'] ?? 50);
|
||||
$fourthPlusDiscountCents = $this->toCents($config['new_tuition_fourth_plus_discount'] ?? 100);
|
||||
|
||||
usort($students, function (array $left, array $right) use ($gradeFee): int {
|
||||
$leftLevel = GradeLevelParser::parse($left['grade_level'] ?? null, $gradeFee);
|
||||
$rightLevel = GradeLevelParser::parse($right['grade_level'] ?? null, $gradeFee);
|
||||
|
||||
return [$leftLevel, (int) ($left['student_id'] ?? 0)] <=> [$rightLevel, (int) ($right['student_id'] ?? 0)];
|
||||
});
|
||||
|
||||
$details = [];
|
||||
|
||||
foreach (array_values($students) as $index => $student) {
|
||||
$position = $index + 1;
|
||||
|
||||
if ($position === 1) {
|
||||
$discountCents = 0;
|
||||
$rule = 'new_first_student_full_amount';
|
||||
} elseif ($position === 2) {
|
||||
$discountCents = $secondDiscountCents;
|
||||
$rule = 'new_second_student_discount';
|
||||
} elseif ($position === 3) {
|
||||
$discountCents = $thirdDiscountCents;
|
||||
$rule = 'new_third_student_discount';
|
||||
} else {
|
||||
$discountCents = $fourthPlusDiscountCents;
|
||||
$rule = 'new_fourth_plus_student_discount';
|
||||
}
|
||||
|
||||
$amountCents = max(0, $fullAmountCents - $discountCents);
|
||||
|
||||
$details[] = [
|
||||
'student_id' => (int) ($student['student_id'] ?? 0),
|
||||
'student_name' => (string) ($student['student_name'] ?? ''),
|
||||
'grade_level' => $student['grade_level'] ?? null,
|
||||
'family_position' => $position,
|
||||
'full_amount' => $this->fromCents($fullAmountCents),
|
||||
'discount' => $this->fromCents($discountCents),
|
||||
'rule' => $rule,
|
||||
'amount' => $this->fromCents($amountCents),
|
||||
];
|
||||
}
|
||||
|
||||
$total = array_sum(array_map(fn (array $detail): int => $this->toCents($detail['amount']), $details));
|
||||
|
||||
return [
|
||||
'calculator' => 'new',
|
||||
'total' => $this->fromCents($total),
|
||||
'details' => $details,
|
||||
];
|
||||
}
|
||||
|
||||
private function toCents($amount): int
|
||||
{
|
||||
return (int) round(((float) $amount) * 100);
|
||||
}
|
||||
|
||||
private function fromCents(int $cents): string
|
||||
{
|
||||
return number_format($cents / 100, 2, '.', '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries\Tuition;
|
||||
|
||||
use App\Interfaces\TuitionCalculatorInterface;
|
||||
|
||||
final class OldTuitionCalculatorService implements TuitionCalculatorInterface
|
||||
{
|
||||
public function calculateFamilyTuition(array $students, array $config): array
|
||||
{
|
||||
$gradeFee = (int) ($config['grade_fee'] ?? 9);
|
||||
$firstStudentFee = $this->toCents($config['first_student_fee'] ?? 370);
|
||||
$secondStudentFee = $this->toCents($config['second_student_fee'] ?? 200);
|
||||
$youthFee = $this->toCents($config['youth_fee'] ?? 180);
|
||||
|
||||
usort($students, function (array $left, array $right) use ($gradeFee): int {
|
||||
$leftLevel = GradeLevelParser::parse($left['grade_level'] ?? null, $gradeFee);
|
||||
$rightLevel = GradeLevelParser::parse($right['grade_level'] ?? null, $gradeFee);
|
||||
|
||||
return [$leftLevel, (int) ($left['student_id'] ?? 0)] <=> [$rightLevel, (int) ($right['student_id'] ?? 0)];
|
||||
});
|
||||
|
||||
$regularCount = 0;
|
||||
$details = [];
|
||||
|
||||
foreach ($students as $student) {
|
||||
$level = GradeLevelParser::parse($student['grade_level'] ?? null, $gradeFee);
|
||||
|
||||
if ($level > $gradeFee) {
|
||||
$amountCents = $youthFee;
|
||||
$rule = 'old_youth_fee';
|
||||
} else {
|
||||
$regularCount++;
|
||||
$amountCents = $regularCount === 1 ? $firstStudentFee : $secondStudentFee;
|
||||
$rule = $regularCount === 1 ? 'old_first_student_fee' : 'old_second_student_fee';
|
||||
}
|
||||
|
||||
$details[] = [
|
||||
'student_id' => (int) ($student['student_id'] ?? 0),
|
||||
'student_name' => (string) ($student['student_name'] ?? ''),
|
||||
'grade_level' => $student['grade_level'] ?? null,
|
||||
'rule' => $rule,
|
||||
'amount' => $this->fromCents($amountCents),
|
||||
];
|
||||
}
|
||||
|
||||
$total = array_sum(array_map(fn (array $detail): int => $this->toCents($detail['amount']), $details));
|
||||
|
||||
return [
|
||||
'calculator' => 'old',
|
||||
'total' => $this->fromCents($total),
|
||||
'details' => $details,
|
||||
];
|
||||
}
|
||||
|
||||
private function toCents($amount): int
|
||||
{
|
||||
return (int) round(((float) $amount) * 100);
|
||||
}
|
||||
|
||||
private function fromCents(int $cents): string
|
||||
{
|
||||
return number_format($cents / 100, 2, '.', '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
<?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'];
|
||||
$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',
|
||||
];
|
||||
|
||||
$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, '.', '');
|
||||
|
||||
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),
|
||||
];
|
||||
}
|
||||
|
||||
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'),
|
||||
'new_tuition_full_amount' => $this->normalizedUnitPriceOverride(),
|
||||
'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 function normalizedUnitPriceOverride(): string
|
||||
{
|
||||
return $this->unitPriceOverride
|
||||
?? (string) ($this->configurationModel->getConfig('new_tuition_full_amount') ?? '0.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, '.', '');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user