additionalCharge = model(AdditionalCharge::class); $this->classSection = model(ClassSection::class); $this->invoice = model(Invoice::class); $this->student = model(Student::class); $this->enrollment = model(Enrollment::class); $this->config = model(Configuration::class); $this->user = model(User::class); $this->studentClass = model(StudentClass::class); $this->charges = model(EventCharges::class); $this->discountUsage = model(DiscountUsage::class); $this->refund = model(Refund::class); $this->payment = model(Payment::class); $this->gradeFee = (int) ($this->config->getConfig('grade_fee') ?? 9); $this->schoolYear = (string) ($this->config->getConfig('school_year') ?? ''); $this->semester = (string) ($this->config->getConfig('semester') ?? ''); $this->dueDate = $this->config->getConfig('due_date'); $this->firstStudentFee = (float) ($this->config->getConfig('first_student_fee') ?? 350); $this->secondStudentFee = (float) ($this->config->getConfig('second_student_fee') ?? 200); $this->youthFee = (float) ($this->config->getConfig('youth_fee') ?? 180); $refundDeadlineRaw = $this->config->getConfig('refund_deadline'); $this->refundDeadline = $refundDeadlineRaw ? date('Y-m-d', strtotime($refundDeadlineRaw)) : date('Y-m-d'); } /** * GET /api/v1/invoices * List invoices with optional filters */ public function index(): JsonResponse { $page = max(1, (int) ($this->request->getGet('page') ?? 1)); $perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20))); $parentId = $this->request->getGet('parent_id'); $status = $this->request->getGet('status'); $schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear; $query = $this->invoice->newQuery(); if ($parentId) { $query->where('parent_id', (int) $parentId); } if ($status) { $query->where('status', $status); } if ($schoolYear) { $query->where('school_year', $schoolYear); } $result = $this->paginate($query, $page, $perPage); return $this->success($result, 'Invoices retrieved successfully'); } /** * GET /api/v1/invoices/{id} * Get a single invoice */ public function show($id = null): JsonResponse { $invoice = $this->invoice->find($id); if (!$invoice) { return $this->error('Invoice not found', Response::HTTP_NOT_FOUND); } return $this->success($invoice, 'Invoice retrieved successfully'); } /** * GET /api/v1/invoices/management-data * Invoice management composite data (used by invoice_management view) */ public function managementData(): JsonResponse { $schoolYear = trim((string) ($this->request->getGet('schoolYear') ?? $this->request->getGet('year') ?? '')); if ($schoolYear === '') { $schoolYear = $this->schoolYear; } $invoiceData = []; try { // Distinct school years (for selector) $yearsRows = $this->invoice ->select('school_year') ->distinct() ->orderBy('school_year', 'DESC') ->findAll(); $schoolYears = array_values(array_filter(array_map(static function ($r) { return isset($r['school_year']) ? (string) $r['school_year'] : null; }, $yearsRows))); if (empty($schoolYears)) { $schoolYears = [$this->schoolYear]; } $parents = $this->user->getUsersByRoleAndSchoolYear('parent', $schoolYear); foreach ($parents as $parent) { $students = $this->student->where('parent_id', $parent['id'])->findAll(); $parentData = [ 'parent_name' => trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? '')), 'parent_id' => (int) $parent['id'], 'enrolledKids' => [], 'withdrawnKids' => [], 'invoice_amount' => 0, 'refund_amount' => 0, 'last_updated' => null, 'invoice_date' => null, 'invoice_id' => null, ]; // Latest invoice $invoices = $this->invoice->getInvoicesByParentId($parent['id'], $schoolYear); foreach ($invoices as $invoice) { if ($invoice) { $parentData['invoice_amount'] = (float) ($invoice['total_amount'] ?? 0); $parentData['last_updated'] = $invoice['updated_at'] ?? null; // Prefer issue_date (UTC) -> local; fall back to updated_at/created_at if (!empty($invoice['issue_date'])) { $tzName = user_timezone(); $parentData['invoice_date'] = (new CarbonImmutable($invoice['issue_date'], new DateTimeZone('UTC'))) ->setTimezone(new DateTimeZone($tzName)) ->format('Y-m-d H:i:s'); } else { $parentData['invoice_date'] = date('Y-m-d H:i:s', strtotime($invoice['updated_at'] ?? $invoice['created_at'] ?? 'now')); } $parentData['invoice_id'] = $invoice['id'] ?? null; // Refund total paid for parent/year (Partial/Paid) $refund = DB::table('refunds') ->select(DB::raw('COALESCE(SUM(refund_paid_amount),0) AS refund_paid_amount')) ->where('parent_id', $parent['id']) ->where('school_year', $schoolYear) ->whereIn('status', ['Partial', 'Paid']) ->first(); $parentData['refund_amount'] = (float) ($refund->refund_paid_amount ?? 0.0); break; // only most recent as before } } // Build kids lists based on enrollment statuses foreach ($students as $student) { $studentClass = DB::table('student_class') ->where('student_id', $student['id']) ->where('school_year', $schoolYear) ->first(); $grade = 'N/A'; if ($studentClass && isset($studentClass->class_section_id)) { $classSection = DB::table('class_sections') ->where('class_section_id', $studentClass->class_section_id) ->first(); if ($classSection && isset($classSection->class_section_name)) { $grade = $classSection->class_section_name; } } $enrollments = $this->enrollment ->where('student_id', $student['id']) ->where('school_year', $schoolYear) ->findAll(); foreach ($enrollments as $enrollment) { $kid = [ 'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')), 'grade' => $grade, 'tuition_fee' => (float) ($enrollment['tuition_fee'] ?? 0), ]; switch ($enrollment['enrollment_status']) { case 'payment pending': case 'enrolled': $parentData['enrolledKids'][] = $kid; break; case 'withdraw under review': case 'withdrawn': case 'refund pending': $parentData['withdrawnKids'][] = $kid; break; default: // ignore others for invoice summary break; } } } if (!empty($parentData['enrolledKids']) || !empty($parentData['withdrawnKids'])) { $invoiceData[] = $parentData; } } return $this->success([ 'ok' => true, 'schoolYear' => $schoolYear, 'schoolYears' => $schoolYears, 'invoices' => $invoiceData, ], 'Management data retrieved successfully'); } catch (\Throwable $e) { log_message('error', 'managementData error: ' . $e->getMessage()); return $this->respondError('Server error: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * POST /api/v1/invoices/generate * Generate or update invoice for a parent */ public function generateInvoice(): JsonResponse { $payload = $this->payloadData(); $parentId = (int) ($payload['parent_id'] ?? $this->request->getPost('parent_id') ?? 0); if (!$parentId) { return $this->respondError('Parent ID is required', Response::HTTP_BAD_REQUEST); } // Fetch enrolled + withdrawn students $enrollments = $this->enrollment ->where('parent_id', $parentId) ->where('school_year', $this->schoolYear) ->findAll(); if (empty($enrollments)) { return $this->respondError('No enrollment records found.', Response::HTTP_NOT_FOUND); } $registeredKids = []; $withdrawnKids = []; foreach ($enrollments as $enrollment) { $studentData = [ 'student_id' => $enrollment['student_id'], 'parent_id' => $enrollment['parent_id'], 'class_section_id' => $enrollment['class_section_id'], 'enrollment_status' => $enrollment['enrollment_status'], 'school_year' => $enrollment['school_year'], 'semester' => $enrollment['semester'], 'admission_status' => $enrollment['admission_status'], 'is_withdrawn' => $enrollment['is_withdrawn'] ?? false, ]; if (in_array($enrollment['enrollment_status'], ['enrolled', 'payment pending'], true)) { $registeredKids[] = $studentData; } elseif (in_array($enrollment['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'])) { $withdrawnKids[] = $studentData; } else { log_message('info', "Enrollment skipped for student_id {$enrollment['student_id']} with status: {$enrollment['enrollment_status']}"); } } // Calculate tuition fee $fees = $this->calculateTuitionFee($registeredKids, $withdrawnKids); $tuitionFee = $fees['tuition_fee']; // Fetch event charges $eventsList = $this->charges->getChargesWithEventInfo($parentId, $this->schoolYear); $eventchargeTotal = array_sum(array_column($eventsList, 'charged')); $totalDiscount = $this->recalculateAndUpdateDiscount( $parentId, $this->schoolYear, $tuitionFee, $enrollments ); $semester = $this->semester; // Refunds PAID to the parent for this year (Partial/Paid) $refundPaid = (float) $this->refund->getTotalApprovedRefundByParentIdAndSchoolYear($parentId, $this->schoolYear); $totalPaid = $this->payment->getTotalPaidByParentId($parentId, $this->schoolYear); $discountedTuition = max(0, $tuitionFee); $totalAmount = $discountedTuition + $eventchargeTotal; // Your invoice fetch $existingInvoices = $this->invoice->getInvoicesByParentId($parentId, $this->schoolYear); $updated = false; $updatedIds = []; if (!empty($existingInvoices)) { foreach ($existingInvoices as $invoice) { if (!isset($invoice['id'])) { continue; } // Preserve applied additional charges and recalc this invoice only $extrasSum = 0.0; try { $rows = DB::table('additional_charges') ->select('charge_type', 'amount') ->where('invoice_id', (int) $invoice['id']) ->where('school_year', $this->schoolYear) ->where('status', 'applied') ->get(); foreach ($rows as $r) { $amt = (float) ($r->amount ?? 0); $typ = strtolower((string) ($r->charge_type ?? 'add')); if ($typ === 'deduct') { $amt = -abs($amt); } else { $amt = abs($amt); } $extrasSum += $amt; } } catch (\Throwable $e) { log_message('error', 'additional_charges sum failed for invoice ' . (int) $invoice['id'] . ': ' . $e->getMessage()); } $newTotal = round($totalAmount + $extrasSum, 2); // Per-invoice discount $invDiscount = 0.0; try { $d = DB::table('discount_usages') ->select(DB::raw('COALESCE(SUM(discount_amount),0) AS tot')) ->where('invoice_id', (int) $invoice['id']) ->first(); $invDiscount = (float) ($d->tot ?? 0.0); } catch (\Throwable $e) { log_message('error', 'discount sum failed for invoice ' . (int) $invoice['id'] . ': ' . $e->getMessage()); } // Per-invoice refunds paid $invRefunds = 0.0; try { $r = DB::table('refunds') ->select(DB::raw('COALESCE(SUM(refund_paid_amount),0) AS tot')) ->where('invoice_id', (int) $invoice['id']) ->where('school_year', $this->schoolYear) ->whereIn('status', ['Partial', 'Paid']) ->first(); $invRefunds = (float) ($r->tot ?? 0.0); } catch (\Throwable $e) { log_message('error', 'refund sum failed for invoice ' . (int) $invoice['id'] . ': ' . $e->getMessage()); } // Payments recorded on this invoice $paidOnInv = (float) ($invoice['paid_amount'] ?? 0.0); $newBalance = $newTotal - $invDiscount - $invRefunds - $paidOnInv; $newStatus = $newBalance <= 0.00001 ? 'paid' : 'unpaid'; $this->invoice->update($invoice['id'], [ 'total_amount' => $newTotal, 'paid_amount' => $paidOnInv, 'balance' => $newBalance, 'status' => $newStatus, 'updated_at' => utc_now(), ]); $updatedIds[] = (int) $invoice['id']; log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}."); $updated = true; } } else { // Generate invoice number $schoolId = $this->user->getSchoolIdByUserId($parentId); if (!empty($schoolId)) { $invoiceNumber = 'INV-' . $schoolId . '-' . uniqid(); } else { log_message('warning', "No school ID found for parent_id {$parentId}, generating fallback invoice number."); $invoiceNumber = uniqid('INV-'); } $issueUtc = (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s'); // Due date: interpret the date in configured/user local TZ $dueUtc = null; if (!empty($this->dueDate)) { $tzName = user_timezone(); $dueLocal = new DateTime($this->dueDate . ' 19:59:59', new DateTimeZone($tzName)); $dueLocal->setTimezone(new DateTimeZone('UTC')); $dueUtc = $dueLocal->format('Y-m-d H:i:s'); } $insertId = $this->invoice->insert([ 'parent_id' => $parentId, 'invoice_number' => $invoiceNumber, 'total_amount' => $totalAmount, 'paid_amount' => 0, 'balance' => $totalAmount, 'status' => 'unpaid', 'school_year' => $this->schoolYear, 'semester' => $semester, 'issue_date' => $issueUtc, 'due_date' => $dueUtc, 'created_at' => utc_now(), 'updated_at' => utc_now(), ]); if (!$insertId) { log_message('error', 'Invoice insert failed: ' . json_encode($this->invoice->errors())); return $this->respondError('Failed to create invoice.', Response::HTTP_INTERNAL_SERVER_ERROR); } else { log_message('info', "Invoice created successfully. Insert ID: {$insertId}"); } $updated = false; } return $this->success([ 'ok' => true, 'updated' => $updated, 'updated_ids' => $updatedIds, 'insert_id' => isset($insertId) ? (int) $insertId : null, ], $updated ? 'Invoice updated successfully' : 'Invoice created successfully'); } /** * GET /api/v1/invoices/{id}/pdf * Generate PDF invoice */ public function generatePdfInvoice($invoiceId): JsonResponse { $data = $this->prepareInvoiceData($invoiceId); if (isset($data['error'])) { return $this->respondError($data['error'], Response::HTTP_NOT_FOUND); } // Fetch discount_amount from discount_usages $discountResult = DB::table('discount_usages as du') ->select(DB::raw('SUM(du.discount_amount) as total_discount')) ->join('invoices as i', 'du.invoice_id', '=', 'i.id') ->where('du.invoice_id', $invoiceId) ->where('i.school_year', $this->schoolYear) ->first(); $discountAmount = isset($discountResult->total_discount) ? (float) $discountResult->total_discount : 0.00; // Attach discount amount to data $data['discount_amount'] = $discountAmount; // Note: PDF generation would need to be handled separately or via a service // For now, return the prepared data return $this->success($data, 'Invoice data prepared for PDF generation'); } /** * GET /api/v1/invoices/parent/{parentId} * Get invoices by parent ID */ public function getByParent($parentId): JsonResponse { $schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear; $invoices = $this->invoice->getInvoicesByParentId($parentId, $schoolYear); return $this->success($invoices, 'Invoices retrieved successfully'); } /** * POST /api/v1/invoices * Create a new invoice */ public function store(): JsonResponse { $payload = $this->payloadData(); if (empty($payload)) { return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST); } $invoiceData = array_intersect_key($payload, array_flip($this->invoice->getFillable())); $invoiceData['school_year'] = $invoiceData['school_year'] ?? $this->schoolYear; $invoiceData['semester'] = $invoiceData['semester'] ?? $this->semester; try { $invoice = $this->invoice->create($invoiceData); return $this->respondCreated($invoice->toArray(), 'Invoice created successfully'); } catch (\Throwable $e) { log_message('error', 'Invoice create error: ' . $e->getMessage()); return $this->respondError('Failed to create invoice', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * PATCH /api/v1/invoices/{id} * Update invoice */ public function update($id = null): JsonResponse { $invoice = $this->invoice->find($id); if (!$invoice) { return $this->error('Invoice not found', Response::HTTP_NOT_FOUND); } $payload = $this->payloadData(); if (empty($payload)) { return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST); } $allowedFields = ['status', 'paid_amount', 'balance', 'description']; $updateData = []; foreach ($allowedFields as $field) { if (array_key_exists($field, $payload)) { $updateData[$field] = $payload[$field]; } } if (empty($updateData)) { return $this->error('No valid fields to update', Response::HTTP_BAD_REQUEST); } try { $this->invoice->update($id, $updateData); $updatedInvoice = $this->invoice->find($id); return $this->success($updatedInvoice, 'Invoice updated successfully'); } catch (\Throwable $e) { log_message('error', 'Invoice update error: ' . $e->getMessage()); return $this->respondError('Failed to update invoice', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * PATCH /api/v1/invoices/{id}/status * Update invoice status */ public function updateStatus($id = null): JsonResponse { $invoice = $this->invoice->find($id); if (!$invoice) { return $this->error('Invoice not found', Response::HTTP_NOT_FOUND); } $payload = $this->payloadData(); $status = $payload['status'] ?? null; if (!$status) { return $this->respondError('Status is required', Response::HTTP_BAD_REQUEST); } if ($this->invoice->updateInvoiceStatus($id, $status)) { $updatedInvoice = $this->invoice->find($id); return $this->success($updatedInvoice, 'Invoice status updated successfully'); } else { return $this->respondError('Failed to update invoice status', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * GET /api/v1/invoices/unpaid * Get unpaid invoices */ public function unpaidInvoices(): JsonResponse { $unpaidInvoices = $this->invoice->getUnpaidInvoices(); return $this->success($unpaidInvoices, 'Unpaid invoices retrieved successfully'); } /** * POST /api/v1/invoices/check-and-generate * Check and generate invoice when enrollment status changes */ public function checkAndGenerateInvoice(): JsonResponse { $payload = $this->payloadData(); $parentId = (int) ($payload['parent_id'] ?? 0); $status = $payload['status'] ?? ''; if (!$parentId) { return $this->respondError('Parent ID is required', Response::HTTP_BAD_REQUEST); } if ($status === 'payment pending' || $status === 'withdrawn' || $status === 'enrolled') { // Call generateInvoice logic $this->generateInvoice(); return $this->success(null, 'Invoice checked and generated if needed'); } return $this->success(null, 'No invoice generation needed for this status'); } // ==================== Private Helper Methods ==================== private function recalculateAndUpdateDiscount( int $parentId, string $schoolYear, float $tuitionFee, array $enrollments ): float { $totalDiscount = 0.00; // Get all invoices for this parent and school year $invoices = $this->invoice->getInvoicesByParentId($parentId, $schoolYear); if (empty($invoices)) { log_message('info', "No invoices found for parent ID $parentId in school year $schoolYear."); return 0.00; } foreach ($invoices as $invoice) { if (!isset($invoice['id'])) { continue; } $invoiceId = $invoice['id']; // Get discount usage + voucher details $discountUsage = DB::table('discount_usages as du') ->select('du.id', 'dv.id as voucher_id', 'dv.discount_type', 'dv.discount_value') ->join('discount_vouchers as dv', 'du.voucher_id', '=', 'dv.id') ->join('invoices as i', 'du.invoice_id', '=', 'i.id') ->where('du.invoice_id', $invoiceId) ->where('i.school_year', $schoolYear) ->first(); if (!$discountUsage) { log_message('info', "No discount applied to invoice ID $invoiceId."); continue; } // Recalculate discount if ($discountUsage->discount_type === 'percent') { $discountAmount = round(($tuitionFee * $discountUsage->discount_value) / 100, 2); } else { $discountAmount = min($discountUsage->discount_value, $tuitionFee); } // Update discount usage DB::table('discount_usages') ->where('id', $discountUsage->id) ->update([ 'discount_amount' => $discountAmount, 'updated_at' => utc_now(), 'updated_by' => $this->getCurrentUserId(), ]); $totalDiscount += $discountAmount; // Log enrollment summary $added = []; $withdrawn = []; foreach ($enrollments as $e) { if ( in_array($e['enrollment_status'], ['enrolled', 'payment pending']) && ($e['admission_status'] ?? '') === 'accepted' ) { $added[] = $e['student_id']; } elseif (in_array($e['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'])) { $withdrawn[] = $e['student_id']; } } log_message('info', "Recalculated discount for invoice ID $invoiceId: Added students [" . implode(',', $added) . "], Withdrawn students [" . implode(',', $withdrawn) . "]. Discount updated to $discountAmount."); } return $totalDiscount; } /** * Returns an array: * [ * 'tuition_fee' => , // what the parent still owes after any withdrawals * 'refund_amount' => , // what the school should return (0 if none) * ] */ private function calculateTuitionFee(array $registeredKids, array $withdrawnKids): array { // If we are within the refund window, exclude withdrawn kids from billing. // After the deadline, treat withdrawn kids as billable (no refunds allowed). try { $tzName = user_timezone(); $tz = new DateTimeZone($tzName); $today = new CarbonImmutable('today', $tz); $deadline = new CarbonImmutable($this->refundDeadline, $tz); $refundOk = $today <= $deadline; } catch (\Throwable $e) { $refundOk = true; // default conservative } $tuitionStudents = $refundOk ? $registeredKids : array_merge($registeredKids, $withdrawnKids); $tuitionFee = $this->calculateTotalTuitionFee($tuitionStudents); log_message('info', 'Total Tuition Fee (refund ' . ($refundOk ? 'allowed' : 'not allowed') . ") = $tuitionFee"); return [ 'tuition_fee' => $tuitionFee, ]; } private function calculateTotalTuitionFee(array $students): float { // 1) Normalize each student's grade name (once) foreach ($students as &$student) { $gradeName = $this->classSection ->getClassSectionNameBySectionId($student['class_section_id']); $student['grade'] = strtoupper(trim($gradeName ?? '')); } unset($student); // break reference // 2) Partition into regular (<= grade 9) vs youth (> grade 9) $regularCount = 0; $youthCount = 0; foreach ($students as $student) { $levelInfo = $this->getGradeLevel($student['grade']); $level = (int) ($levelInfo['level'] ?? 999); // Youth if level > $this->gradeFee (e.g., gradeFee = 9) if ($level > $this->gradeFee) { $youthCount++; } else { $regularCount++; } } // 3) Calculate totals per your rules $total = 0.0; // Youth: flat youth fee per student $total += $youthCount * $this->youthFee; // Regulars: first student full price, others discounted — but only if 2+ regulars if ($regularCount >= 2) { $total += $this->firstStudentFee; // one full $total += ($regularCount - 1) * $this->secondStudentFee; // rest discounted } elseif ($regularCount === 1) { $total += $this->firstStudentFee; // single regular: no discount even if there are youths } return $total; } private function prepareInvoiceData($invoiceId): array { $invoice = $this->invoice->find($invoiceId); if (!$invoice) { return ['error' => "No invoice was generated. Please contact the school administration."]; } $parentId = $invoice['parent_id']; $schoolYear = $invoice['school_year']; // Payments $payments = $this->payment ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->findAll(); $parent = $this->user->find($parentId); if (!$parent) { return ['error' => "Parent associated with the invoice was not found."]; } $enrollments = $this->enrollment ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->findAll(); if (empty($enrollments)) { return ['error' => 'No enrollments found for this invoice.']; } $registeredKids = []; $withdrawnKids = []; foreach ($enrollments as $enrollment) { $student = $this->student->find($enrollment['student_id']); if (!$student) { log_message('error', "Student not found for ID: {$enrollment['student_id']}"); continue; } $grade = $this->studentClass->getStudentGrade($student['id']); $studentData = [ 'student_id' => $student['id'], 'student_firstname' => $student['firstname'], 'student_lastname' => $student['lastname'], 'grade' => $grade, 'tuition_fee' => $enrollment['tuition_fee'] ?? 0, 'enrollment_status' => $enrollment['enrollment_status'], ]; if (in_array($enrollment['enrollment_status'], ['enrolled', 'payment pending'], true)) { $registeredKids[] = $studentData; } elseif (in_array($enrollment['enrollment_status'], ['withdrawn', 'withdraw under review', 'refund pending'], true)) { $withdrawnKids[] = $studentData; } else { log_message('info', "Skipping student ID {$student['id']} with status {$enrollment['enrollment_status']}"); } } usort($registeredKids, fn($a, $b) => $this->compareGrades($a['grade'], $b['grade'])); usort($withdrawnKids, fn($a, $b) => $this->compareGrades($a['grade'], $b['grade'])); $tzName = user_timezone(); $tz = new DateTimeZone($tzName); $currentDate = new CarbonImmutable('today', $tz); $deadline = new CarbonImmutable($this->refundDeadline, $tz); $refundAllowed = $currentDate <= $deadline; $studentCharges = []; $regularCount = 0; /** * Computes the fee for a student given numeric level and original grade name. */ $computeCharge = function (int $gradeLevel, string $gradeName) use (&$regularCount) { $threshold = (int) $this->gradeFee; // Force Kindergarten to be regular regardless of numeric mapping $isRegular = $this->isKindergarten($gradeName) || ($gradeLevel > 0 && $gradeLevel <= $threshold); if ($isRegular) { $fee = ($regularCount === 0) ? $this->firstStudentFee : $this->secondStudentFee; $regularCount++; return $fee; } return $this->youthFee; }; // Registered kids -> pay unit fee foreach ($registeredKids as $student) { $gradeName = (string) $student['grade']; $gradeLevel = $this->gradeLevelInt($gradeName); $unitFee = $computeCharge($gradeLevel, $gradeName); $studentCharges[$student['student_id']] = ['unit_fee' => $unitFee, 'refund' => 0]; } // Refund NOT allowed -> withdrawn students still owe unit fee if (!$refundAllowed) { foreach ($withdrawnKids as $student) { $gradeName = (string) $student['grade']; $gradeLevel = $this->gradeLevelInt($gradeName); $unitFee = $computeCharge($gradeLevel, $gradeName); $studentCharges[$student['student_id']] = ['unit_fee' => $unitFee, 'refund' => 0]; } } $eventsList = $this->charges->getChargesWithEventInfo($parentId, $schoolYear); // Attach SCHOOL IDs $allKids = array_merge($registeredKids, $withdrawnKids); $studentIds = array_column($allKids, 'student_id'); $schoolIdMap = []; if ($studentIds) { $rows = $this->student ->select('id, school_id') ->whereIn('id', $studentIds) ->findAll(); foreach ($rows as $row) { $schoolIdMap[(int) $row['id']] = $row['school_id'] ?? 'N/A'; } } $students = $allKids; foreach ($students as $i => $s) { $sid = (int) $s['student_id']; $students[$i]['student_school_id'] = $schoolIdMap[$sid] ?? 'N/A'; } $discounts = $this->discountUsage ->where('invoice_id', $invoiceId) ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->findAll(); // Refunds PAID for this specific invoice (money returned to the parent) $refundsPaidTotal = 0.0; try { $r = DB::table('refunds') ->select(DB::raw('COALESCE(SUM(refund_paid_amount),0) AS tot')) ->where('invoice_id', $invoiceId) ->where('school_year', $schoolYear) ->whereIn('status', ['Partial', 'Paid']) ->first(); $refundsPaidTotal = (float) ($r->tot ?? 0.0); } catch (\Throwable $e) { log_message('error', 'Failed to sum refunds for invoice ' . (int) $invoiceId . ': ' . $e->getMessage()); } // Additional charges $acRows = $this->additionalCharge ->select('id, charge_type, title, description, amount, due_date, status, created_at') ->where('invoice_id', $invoiceId) ->where('status', '!=', 'void') ->orderBy('created_at', 'ASC') ->orderBy('id', 'ASC') ->findAll(); $additionalChargeLines = []; $additionalChargesTotal = 0.0; foreach ($acRows as $ac) { $signed = (float) ($ac['amount'] ?? 0); $ctype = strtolower((string) ($ac['charge_type'] ?? '')); if (in_array($ctype, ['deduct'], true) && $signed > 0) { $signed = -$signed; } elseif (in_array($ctype, ['add'], true) && $signed < 0) { $signed = abs($signed); } $lineDate = !empty($ac['created_at']) ? date('Y-m-d', strtotime($ac['created_at'])) : (!empty($invoice['created_at']) ? local_date($invoice['created_at'], 'Y-m-d') : local_date(utc_now(), 'Y-m-d')); $typeLabel = in_array($ctype, ['deduct'], true) ? 'Deduct' : 'Add'; $desc = $typeLabel . ': '; if (!empty($ac['description'])) { $desc = $ac['description']; } $additionalChargesTotal += $signed; $additionalChargeLines[] = [ 'date' => $lineDate, 'description' => $desc, 'amount' => $signed, 'meta' => [ 'id' => (int) $ac['id'], 'due_date' => $ac['due_date'] ?? null, 'status' => (string) ($ac['status'] ?? ''), 'type' => $ctype, ], ]; } $additionalChargesTotal = round($additionalChargesTotal, 2); return [ 'invoice' => $invoice, 'parent' => $parent, 'registeredKids' => $registeredKids, 'withdrawnKids' => $withdrawnKids, 'studentCharges' => $studentCharges, 'events' => $eventsList, 'students' => $students, 'payments' => $payments, 'discounts' => $discounts, 'additionalChargesTotal' => $additionalChargesTotal, 'additionalChargeLines' => $additionalChargeLines, 'refundsPaidTotal' => $refundsPaidTotal, ]; } /** * Treat common KG spellings as Kindergarten. */ private function isKindergarten(string $grade): bool { $g = strtolower(trim($grade)); return in_array($g, ['kg', 'k', 'kindergarten', 'k-g', 'k.g', 'k g'], true); } private function compareGrades($gradeA, $gradeB): int { $valA = $this->getGradeLevel($gradeA); $valB = $this->getGradeLevel($gradeB); // First compare numeric levels if ($valA['level'] !== $valB['level']) { return $valA['level'] <=> $valB['level']; } // Same level, compare suffix lexicographically (A < B) return strcmp($valA['suffix'] ?? '', $valB['suffix'] ?? ''); } private function gradeLevelInt($grade): int { $gl = $this->getGradeLevel($grade); return (int) ($gl['level'] ?? 0); } /** * Robust grade parser. Returns ['level' => int, 'suffix' => string] */ private function getGradeLevel($grade): array { // Normalize if (is_numeric($grade)) { $num = (int) $grade; if ($num === 13) { // KG special case return ['level' => 1, 'suffix' => '', 'classId' => 13]; } return ['level' => $num, 'suffix' => '', 'classId' => null]; } if (!is_string($grade)) { return ['level' => 999, 'suffix' => '', 'classId' => null]; } $g = strtoupper(trim($grade)); $g = preg_replace('/\s+/', ' ', $g); $g = str_replace(['.', '_'], '', $g); $g = str_replace('-', ' ', $g); // Kindergarten (string forms) $kg = ['K', 'KG', 'K G', 'KINDER', 'KINDERGARTEN']; if (in_array($g, $kg, true)) { return ['level' => 1, 'suffix' => '', 'classId' => 13]; } // Pre-K $pk = ['PK', 'P K', 'PREK', 'PRE K', 'PRE-K', 'PRE KINDER', 'PREKINDER']; if (in_array($g, $pk, true)) { return ['level' => -1, 'suffix' => '', 'classId' => null]; } // Youth: Y, YOUTH, Y1, YOUTH2, etc. if (preg_match('/^Y(?:OUTH)?(?:\s*(\d+))?$/', $g, $m)) { $n = isset($m[1]) && $m[1] !== '' ? max(1, (int) $m[1]) : 1; return [ 'level' => (int) $this->gradeFee + $n, 'suffix' => '', 'classId' => null, ]; } // Numeric grades like "5", "GRADE 5", "G5", "5A" if (preg_match('/^(?:GR?ADE\s*)?(\d{1,2})\s*([A-Z]*)$/', $g, $m)) { return [ 'level' => (int) $m[1], 'suffix' => $m[2] ?? '', 'classId' => null, ]; } return ['level' => 999, 'suffix' => '', 'classId' => null]; } }