additionalChargeModel = new AdditionalChargeModel(); $this->classSectionModel = new ClassSectionModel(); $this->invoiceModel = new InvoiceModel(); $this->invoicestudentModel = new InvoiceStudentListModel(); $this->studentModel = new StudentModel(); $this->enrollmentModel = new EnrollmentModel(); $this->configModel = new ConfigurationModel(); $this->userModel = new UserModel(); $this->studentClassModel = new StudentClassModel(); $this->invoiceEventModel = new InvoiceEventModel(); $this->paymentModel = new PaymentModel(); $this->chargesModel = new EventChargesModel(); $this->discountUsageModel = new DiscountUsageModel(); $this->refundModel = new RefundModel(); $this->invoiceLedgerService = new InvoiceLedgerService(); $this->db = \Config\Database::connect(); $this->invoiceIssuanceService = new InvoiceIssuanceService($this->db, $this->invoiceModel, null, $this->invoiceLedgerService); $this->request = \Config\Services::request(); $this->gradeFee = $this->configModel->getConfig('grade_fee'); $this->schoolYear = $this->currentSchoolYearName(); $this->semester = getSemester(); $this->dueDate = $this->configModel->getConfig('first_day_of_school') ?: $this->configModel->getConfig('due_date'); $this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 380); $this->secondStudentFee = (float) ($this->configModel->getConfig('second_student_fee') ?? 280); $this->refundDeadline = date('Y-m-d', strtotime($this->configModel->getConfig('refund_deadline'))); } public function index($schoolYear = null) { // Get school years from invoices $schoolYears = $this->invoiceModel ->select('school_year') ->distinct() ->orderBy('school_year', 'DESC') ->findAll(); // Default school year if (!$schoolYear) { $schoolYear = $this->schoolYear; } log_message('info', "Selected school year for invoice retrieval: $schoolYear"); $invoiceData = []; $parents = $this->userModel->getUsersByRoleAndSchoolYear('parent', $schoolYear); foreach ($parents as $parent) { $students = $this->studentModel->where('parent_id', $parent['id'])->findAll(); $parentData = [ 'parent_name' => $parent['firstname'] . ' ' . $parent['lastname'], 'parent_id' => $parent['id'], 'enrolledKids' => [], 'withdrawnKids' => [], 'invoice_amount' => 0, 'refund_amount' => 0, // default 'last_updated' => null, 'invoice_date' => null ]; // Fetch most recent invoice $invoices = $this->invoiceModel->getInvoicesByParentId($parent['id'], $schoolYear); foreach ($invoices as $invoice) { if ($invoice) { $parentData['invoice_amount'] = $invoice['total_amount']; $parentData['last_updated'] = $invoice['updated_at']; // Prefer issue_date (UTC) and render in configured/user local time for display $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); $parentData['invoice_date'] = !empty($invoice['issue_date']) ? (new \DateTimeImmutable($invoice['issue_date'], new \DateTimeZone('UTC'))) ->setTimezone(new \DateTimeZone($tzName)) ->format('Y-m-d H:i:s') : ($invoice['updated_at'] ?? null); $parentData['invoice_id'] = $invoice['id']; $refundSummary = $this->paidRefundSummaryForParentYear((int) $parent['id'], (string) $schoolYear); $parentData['refund_amount'] = $refundSummary['amount']; $parentData['refund_details'] = $refundSummary['details']; log_message('info', "Latest invoice for parent {$parent['firstname']} {$parent['lastname']} in school year $schoolYear: Amount = {$invoice['total_amount']}, Updated at = {$invoice['updated_at']}"); } else { log_message('error', "No invoice found for parent {$parent['firstname']} {$parent['lastname']} in school year $schoolYear."); } } foreach ($students as $student) { $studentClass = $this->db->table('student_class') ->where('student_id', $student['id']) ->where('school_year', $schoolYear) ->get()->getRowArray(); $grade = 'N/A'; if ($studentClass && isset($studentClass['class_section_id'])) { $classSection = $this->db->table('classSection') ->where('class_section_id', $studentClass['class_section_id']) ->get()->getRowArray(); if ($classSection && isset($classSection['class_section_name'])) { $grade = $classSection['class_section_name']; } } $enrollments = $this->enrollmentModel ->where('student_id', $student['id']) ->where('school_year', $schoolYear) ->findAll(); foreach ($enrollments as $enrollment) { $kidData = [ 'name' => $student['firstname'] . ' ' . $student['lastname'], 'grade' => $grade, 'tuition_fee' => $enrollment['tuition_fee'] ?? 0 ]; switch ($enrollment['enrollment_status']) { case 'payment pending': case 'enrolled': $parentData['enrolledKids'][] = $kidData; break; case 'withdraw under review': case 'withdrawn': case 'refund pending': $parentData['withdrawnKids'][] = $kidData; break; case 'admission under review': log_message('info', "Student ID {$student['id']} is under admission review and not included in the invoice."); break; case 'waitlist': log_message('info', "Student ID {$student['id']} is in waitlist and not included in the invoice."); break; case 'denied': log_message('info', "Student ID {$student['id']} is denied and not included in the invoice."); break; default: log_message('error', "Unexpected enrollment status '{$enrollment['enrollment_status']}' for student ID {$student['id']}."); break; } } } if (!empty($parentData['enrolledKids']) || !empty($parentData['withdrawnKids'])) { $invoiceData[] = $parentData; } } return view('invoice_payment/invoice_management', [ 'invoices' => $invoiceData, 'schoolYears' => $schoolYears, 'selectedYear' => $schoolYear, 'parents' => array_column($invoiceData, 'parent_name') ]); } private function currentSchoolYearName(): string { try { return service('schoolYearContext')->resolve($this->request)->yearName(); } catch (\Throwable $e) { return (string) ($this->configModel->getConfig('school_year') ?? ''); } } private function paidRefundSummaryForParentYear(int $parentId, string $schoolYear): array { $refund = $this->db->table('refunds') ->select('COALESCE(SUM(refund_paid_amount),0) AS refund_paid_amount') ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->whereIn('status', ['Partial', 'Paid']) ->get() ->getRowArray(); $details = []; if ($this->db->tableExists('refund_payouts')) { $details = $this->db->table('refund_payouts rp') ->select('rp.amount_cents, rp.payment_method, rp.check_number, rp.processed_at, rp.created_at') ->join('refunds r', 'r.id = rp.refund_id', 'inner') ->where('r.parent_id', $parentId) ->where('r.school_year', $schoolYear) ->where('rp.payout_type', 'cash_out') ->whereIn('rp.status', ['completed', 'processing']) ->orderBy('COALESCE(rp.processed_at, rp.created_at)', 'DESC', false) ->get() ->getResultArray(); $details = array_map(static function (array $row): array { return [ 'amount' => ((int) ($row['amount_cents'] ?? 0)) / 100, 'date' => $row['processed_at'] ?? $row['created_at'] ?? null, 'method' => $row['payment_method'] ?? '', 'check_number' => $row['check_number'] ?? '', ]; }, $details); } if (empty($details)) { $legacyRows = $this->db->table('refunds') ->select('refund_paid_amount, refund_method, check_nbr, refunded_at, updated_at') ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->whereIn('status', ['Partial', 'Paid']) ->where('refund_paid_amount >', 0) ->orderBy('COALESCE(refunded_at, updated_at)', 'DESC', false) ->get() ->getResultArray(); $details = array_map(static function (array $row): array { return [ 'amount' => (float) ($row['refund_paid_amount'] ?? 0), 'date' => $row['refunded_at'] ?? $row['updated_at'] ?? null, 'method' => $row['refund_method'] ?? '', 'check_number' => $row['check_nbr'] ?? '', ]; }, $legacyRows); } return [ 'amount' => (float) ($refund['refund_paid_amount'] ?? 0.0), 'details' => $details, ]; } private function paidRefundDetailsForInvoice(int $invoiceId): array { $details = []; if ($this->db->tableExists('refund_payouts')) { $rows = $this->db->table('refund_payouts rp') ->select('rp.amount_cents, rp.payment_method, rp.check_number, rp.processed_at, rp.created_at, rp.payout_type') ->join('refunds r', 'r.id = rp.refund_id', 'inner') ->where('r.invoice_id', $invoiceId) ->whereIn('rp.payout_type', ['cash_out', 'reversal']) ->where('rp.status', 'completed') ->orderBy('COALESCE(rp.processed_at, rp.created_at)', 'ASC', false) ->get() ->getResultArray(); foreach ($rows as $row) { $amount = ((int) ($row['amount_cents'] ?? 0)) / 100; if (($row['payout_type'] ?? '') === 'reversal') { $amount *= -1; } $details[] = [ 'amount' => $amount, 'date' => $row['processed_at'] ?? $row['created_at'] ?? null, 'method' => $row['payment_method'] ?? '', 'check_number' => $row['check_number'] ?? '', 'type' => $row['payout_type'] ?? 'cash_out', ]; } } if (empty($details)) { $rows = $this->db->table('refunds') ->select('refund_paid_amount, refund_method, check_nbr, refunded_at, updated_at') ->where('invoice_id', $invoiceId) ->whereIn('status', ['Partial', 'Paid']) ->where('refund_paid_amount >', 0) ->orderBy('COALESCE(refunded_at, updated_at)', 'ASC', false) ->get() ->getResultArray(); foreach ($rows as $row) { $details[] = [ 'amount' => (float) ($row['refund_paid_amount'] ?? 0), 'date' => $row['refunded_at'] ?? $row['updated_at'] ?? null, 'method' => $row['refund_method'] ?? '', 'check_number' => $row['check_nbr'] ?? '', 'type' => 'cash_out', ]; } } return $details; } private function assertSchoolYearNameWritable(string $schoolYear): void { service('schoolYearWriteGuard')->assertWritable( service('schoolYearContext')->forYearName($schoolYear) ); } /** * API: Invoice management composite data (used by invoice_management view) * Returns the same structure previously rendered server-side in index(). */ public function managementData() { $schoolYear = trim((string)($this->request->getGet('schoolYear') ?? $this->request->getGet('year') ?? '')); if ($schoolYear === '') { $schoolYear = $this->currentSchoolYearName(); } $invoiceData = []; try { // Distinct school years (for selector) $yearsRows = $this->invoiceModel ->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->userModel->getUsersByRoleAndSchoolYear('parent', $schoolYear); foreach ($parents as $parent) { $students = $this->studentModel->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->invoiceModel->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 = (string) (config('School')->attendance['timezone'] ?? user_timezone()); $parentData['invoice_date'] = (new \DateTimeImmutable($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; $refundSummary = $this->paidRefundSummaryForParentYear((int) $parent['id'], (string) $schoolYear); $parentData['refund_amount'] = $refundSummary['amount']; $parentData['refund_details'] = $refundSummary['details']; break; // only most recent as before } } // Build kids lists based on enrollment statuses foreach ($students as $student) { $studentClass = $this->db->table('student_class') ->where('student_id', $student['id']) ->where('school_year', $schoolYear) ->get()->getRowArray(); $grade = 'N/A'; if ($studentClass && isset($studentClass['class_section_id'])) { $classSection = $this->db->table('classSection') ->where('class_section_id', $studentClass['class_section_id']) ->get()->getRowArray(); if ($classSection && isset($classSection['class_section_name'])) { $grade = $classSection['class_section_name']; } } $enrollments = $this->enrollmentModel ->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->response->setJSON([ 'ok' => true, 'schoolYear' => $schoolYear, 'schoolYears' => $schoolYears, 'invoices' => $invoiceData, ]); } catch (\Throwable $e) { log_message('error', 'managementData error: ' . $e->getMessage()); return $this->response->setStatusCode(500)->setJSON(['ok' => false, 'error' => 'Server error']); } } private function hasClassAssignment(string $schoolYear, string $parentId): bool { //$parentId = session()->get('user_id'); if (!$parentId) { return false; } // Get all students for the parent $students = $this->studentModel->where('parent_id', $parentId)->findAll(); if (empty($students)) { return false; } // Check if at least one student has a class assignment foreach ($students as $student) { $studentId = $student['id']; $exists = $this->studentClassModel ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->first(); if ($exists) { return true; } } return false; } public function generateInvoice( ?string $parentId = null, ?string $schoolYearOverride = null, ?string $semesterOverride = null, bool $recalculateDiscounts = true ) { $request = $this->request ?? service('request'); $isAjax = $request !== null && ( $request->isAJAX() || str_contains(strtolower($request->getHeaderLine('Accept')), 'application/json') ); // Programmatic callers (new InvoiceController() without initController) have no response object. $hasHttpResponse = $this->response !== null; if ($parentId == null) { $parentId = (int) ($request?->getPost('parent_id') ?? 0); } $schoolYear = (string) ($schoolYearOverride ?: $this->schoolYear); $semester = (string) ($semesterOverride ?: $this->semester); // Fetch enrolled + withdrawn students $enrollments = $this->enrollmentModel ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->findAll(); if (empty($enrollments)) { return $this->invoiceGenerationResult( $hasHttpResponse, $isAjax, ['ok' => false, 'message' => 'No enrollment records found.'], 422, 'No enrollment records 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'] ]; // Treat 'payment pending' and 'enrolled' as billable regardless of admission_status, // since admin mapping should have already normalized this to 'accepted'. // This makes the calculation resilient to legacy data. 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']} and admission_status: {$enrollment['admission_status']}"); } } $registeredKids = array_values(array_filter($registeredKids, function ($student) use ($schoolYear) { $sid = (int)($student['student_id'] ?? 0); return $sid > 0 && $this->studentClassModel->hasNonEventAssignment($sid, $schoolYear); })); $withdrawnKids = array_values(array_filter($withdrawnKids, function ($student) use ($schoolYear) { $sid = (int)($student['student_id'] ?? 0); return $sid > 0 && $this->studentClassModel->hasNonEventAssignment($sid, $schoolYear); })); // ✅ Use your helper to calculate tuition fee $fees = $this->calculateTuitionFee($registeredKids, $withdrawnKids); $tuitionFee = $fees['tuition_fee']; // ✅ Fetch event charges $eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear); $eventchargeTotal = array_sum(array_column($eventsList, 'charged')); $totalDiscount = 0.0; if ($recalculateDiscounts) { $totalDiscount = $this->recalculateAndUpdateDiscount( $parentId, $schoolYear, $tuitionFee, $enrollments ); } $discountedTuition = max(0, $tuitionFee); $totalAmount = $discountedTuition + $eventchargeTotal; // Business rule: single invoice per parent per school year. // If legacy duplicates exist, prefer the invoice that already has a discount applied, // otherwise use the latest invoice for the parent/year. $invoice = $this->selectActiveInvoiceForParentYear((int)$parentId, $schoolYear); $updated = false; $updatedIds = []; if (!empty($invoice) && isset($invoice['id'])) { $ledger = $this->invoiceLedgerService->recalculate((int) $invoice['id']); $updatedIds[] = (int) $ledger['invoice_id']; log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}."); $updated = true; } else { $hasNonZeroTuitionOrEvents = abs((float) $tuitionFee) > 0.00001 || abs((float) $eventchargeTotal) > 0.00001; $hasApprovedAdjustments = $this->parentHasApprovedInvoiceAdjustments( (int) $parentId, $schoolYear, $semester ); if (! $hasNonZeroTuitionOrEvents && ! $hasApprovedAdjustments) { return $this->invoiceGenerationResult( $hasHttpResponse, $isAjax, ['ok' => false, 'message' => 'Invoice requires at least one non-zero line.'], 422, 'Invoice requires at least one non-zero line.' ); } $issueUtc = (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s'); // Due date: interpret the date in configured/user local TZ, // set a default time (noon to avoid DST edge cases), then convert to UTC. $dueUtc = null; if (!empty($this->dueDate)) { $tzName = (string) (config('School')->attendance['timezone'] ?? 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'); } try { $issueResult = $this->invoiceIssuanceService->issueInvoice(new IssueInvoiceCommand([ 'parent_id' => $parentId, 'invoice_number' => $this->invoiceIssuanceService->generateInvoiceNumber($schoolYear, (int)$parentId), 'total_amount' => $totalAmount, 'paid_amount' => 0, 'balance' => $totalAmount, 'school_year' => $schoolYear, 'semester' => $semester, 'issue_date' => $issueUtc, 'due_date' => $dueUtc, 'created_at' => utc_now(), 'updated_at' => utc_now() ], (float) $tuitionFee, (float) $eventchargeTotal, [ 'parent_id' => (int) $parentId, 'school_year' => $schoolYear, 'semester' => $semester, 'registered_student_count' => count($registeredKids), 'withdrawn_student_count' => count($withdrawnKids), ])); $insertId = $issueResult->invoiceId; $ledger = $issueResult->ledger; log_message('info', "Invoice created successfully. Insert ID: {$insertId}"); } catch (\Throwable $e) { log_message('error', 'Invoice issuance failed: ' . $e->getMessage() . ' errors=' . json_encode($this->invoiceModel->errors())); $message = str_contains($e->getMessage(), 'non-zero invoice line') ? 'Invoice requires at least one non-zero line.' : 'Failed to create invoice.'; return $this->invoiceGenerationResult( $hasHttpResponse, $isAjax, ['ok' => false, 'message' => $message], 422, $message === 'Invoice requires at least one non-zero line.' ? $message : 'Failed to create invoice. Please check input values.' ); } $updated = false; } $successPayload = [ 'ok' => true, 'updated' => $updated, 'updated_ids' => $updatedIds, 'insert_id' => isset($insertId) ? (int)$insertId : null, csrf_token() => csrf_hash(), 'csrfTokenName' => csrf_token(), 'csrfHash' => csrf_hash(), ]; return $this->invoiceGenerationResult( $hasHttpResponse, $isAjax, $successPayload, 200, null, $updated ? 'Invoice updated.' : 'Invoice created.' ); } /** * Safe invoice response helper for both HTTP and programmatic callers. * * @param array $payload */ private function invoiceGenerationResult( bool $hasHttpResponse, bool $isAjax, array $payload, int $statusCode = 200, ?string $errorFlash = null, ?string $successFlash = null ) { if (! $hasHttpResponse || $this->response === null) { return $payload; } if ($isAjax) { return $this->response ->setStatusCode($statusCode) ->setJSON($payload); } if ($errorFlash !== null) { return redirect()->back()->with('error', $errorFlash); } return redirect()->to(route_to('InvoiceController::index')) ->with('success', $successFlash ?? 'Invoice saved.'); } private function parentHasApprovedInvoiceAdjustments(int $parentId, string $schoolYear, string $semester): bool { if ($parentId <= 0 || $schoolYear === '' || $semester === '') { return false; } try { return $this->additionalChargeModel ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->where('semester', $semester) ->where('status', FinancialStatus::ADDITIONAL_CHARGE_APPROVED) ->where('amount !=', 0) ->countAllResults() > 0; } catch (\Throwable $e) { log_message('warning', 'Unable to check approved invoice adjustments: {message}', [ 'message' => $e->getMessage(), ]); return false; } } private function selectActiveInvoiceForParentYear(int $parentId, string $schoolYear): ?array { if ($parentId <= 0 || $schoolYear === '') { return null; } // Prefer invoices flagged as having discounts. $invoice = $this->invoiceModel ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->where('has_discount', 1) ->orderBy('id', 'DESC') ->first(); if (!empty($invoice)) { return $invoice; } // Fallback: prefer invoices with discount_usages rows. try { $row = $this->db->table('invoices i') ->select('i.*') ->join('discount_usages du', 'du.invoice_id = i.id', 'inner') ->where('i.parent_id', $parentId) ->where('i.school_year', $schoolYear) ->orderBy('i.id', 'DESC') ->get() ->getRowArray(); if (!empty($row)) { return $row; } } catch (\Throwable $e) { } // Final fallback: latest invoice for parent/year. return $this->invoiceModel ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->orderBy('id', 'DESC') ->first(); } private function recalculateAndUpdateDiscount( int $parentId, string $schoolYear, float $tuitionFee, array $enrollments ): float { $invoices = $this->invoiceModel->getInvoicesByParentId($parentId, $schoolYear); $totalDiscount = 0.0; foreach ($invoices as $invoice) { $ledger = $this->invoiceLedgerService->recalculateInvoice((int)$invoice['id']); $totalDiscount += (float)($ledger['discount_total'] ?? 0.0); } 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 = (string) (config('School')->attendance['timezone'] ?? user_timezone()); $tz = new \DateTimeZone($tzName); $today = new \DateTimeImmutable('today', $tz); $deadline = new \DateTimeImmutable($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->classSectionModel ->getClassSectionNameBySectionId($student['class_section_id']); $student['grade'] = strtoupper(trim($gradeName)); } unset($student); // break reference // 2) First student pays the base fee; every additional student pays base minus $100. usort($students, fn (array $left, array $right): int => GradeLevelParser::parse($left['grade'] ?? null) <=> GradeLevelParser::parse($right['grade'] ?? null)); $studentCount = 0; $total = 0.0; foreach ($students as $student) { $total += ($studentCount === 0) ? $this->firstStudentFee : $this->secondStudentFee; $studentCount++; } return $total; } // Method to check and generate an invoice when enrollment status changes public function checkAndGenerateInvoice($parentId, $status) { if ($status == 'payment pending') { $this->generateInvoice($parentId); } elseif ($status == 'withdrawn' || $status == 'enrolled') { $this->generateInvoice($parentId); } } public function generatePdfInvoice($invoiceId) { $data = $this->prepareInvoiceData($invoiceId); if (isset($data['error'])) { return $this->generateErrorPdf($data['error']); } // Fetch discount_amount from discount_usages $discountResult = $this->db->table('discount_usages du') ->selectSum('du.discount_amount', 'total_discount') ->join('invoices i', 'du.invoice_id = i.id') ->where('du.invoice_id', $invoiceId) ->where('i.school_year', $this->schoolYear) ->get() ->getRowArray(); $discountAmount = isset($discountResult['total_discount']) ? (float)$discountResult['total_discount'] : 0.00; // Attach discount amount to data $data['discount_amount'] = $discountAmount; $this->renderPdfInvoice($data); } // Add this helper method in the same class (once) private function prepareInvoiceData($invoiceId) { $invoice = $this->invoiceModel->find($invoiceId); if (!$invoice) { return ['error' => "No invoice was generated. Please contact the school administration."]; } $parentId = $invoice['parent_id']; $schoolYear = $invoice['school_year']; // --- Payments --- $db = $this->db; $table = $this->paymentModel->table; $hasStatus = $db->fieldExists('status', $table); $hasVoid = $db->fieldExists('is_void', $table); $exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled']; $qb = $this->paymentModel ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->where('invoice_id', $invoiceId); if ($hasStatus) { $qb->groupStart() ->whereNotIn('status', $exclude) ->orWhere('status IS NULL', null, false) ->groupEnd(); } if ($hasVoid) { $qb->groupStart() ->where('is_void', 0) ->orWhere('is_void IS NULL', null, false) ->groupEnd(); } $payments = $qb->findAll(); $parent = $this->userModel->find($parentId); if (!$parent) { return ['error' => "Parent associated with the invoice was not found."]; } $ledger = $this->invoiceLedgerService->calculateInvoice((int) $invoiceId); $invoiceLines = $this->db->table('invoice_lines') ->select('description, quantity, unit_amount_cents, line_amount_cents, line_type, source_type, source_id, created_at, metadata_json') ->where('invoice_id', (int)$invoiceId) ->where('voided_at IS NULL', null, false) ->orderBy('id', 'ASC') ->get() ->getResultArray(); $enrollments = $this->enrollmentModel ->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->studentModel->find($enrollment['student_id']); if (!$student) { log_message('error', "Student not found for ID: {$enrollment['student_id']}"); continue; } $grade = $this->studentClassModel->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']}"); } } $registeredKids = array_values(array_filter($registeredKids, function ($student) use ($schoolYear) { $sid = (int)($student['student_id'] ?? 0); return $sid > 0 && $this->studentClassModel->hasNonEventAssignment($sid, $schoolYear); })); $withdrawnKids = array_values(array_filter($withdrawnKids, function ($student) use ($schoolYear) { $sid = (int)($student['student_id'] ?? 0); return $sid > 0 && $this->studentClassModel->hasNonEventAssignment($sid, $schoolYear); })); usort($registeredKids, fn($a, $b) => $this->compareGrades($a['grade'], $b['grade'])); usort($withdrawnKids, fn($a, $b) => $this->compareGrades($a['grade'], $b['grade'])); $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); $tz = new \DateTimeZone($tzName); $currentDate = new \DateTimeImmutable('today', $tz); $deadline = new \DateTimeImmutable($this->refundDeadline, $tz); $refundAllowed = $currentDate <= $deadline; $studentCharges = []; $studentCount = 0; /** * First student pays the base fee; every additional student pays base minus $100. */ $computeCharge = function (array $student) use (&$studentCount) { $fee = ($studentCount === 0) ? $this->firstStudentFee : $this->secondStudentFee; $studentCount++; return $fee; }; // Registered kids -> pay unit fee foreach ($registeredKids as $student) { $unitFee = $computeCharge($student); $studentCharges[$student['student_id']] = ['unit_fee' => $unitFee, 'refund' => 0]; } // Refund NOT allowed -> withdrawn students still owe unit fee if (!$refundAllowed) { foreach ($withdrawnKids as $student) { $unitFee = $computeCharge($student); $studentCharges[$student['student_id']] = ['unit_fee' => $unitFee, 'refund' => 0]; } } $eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear, $invoice['semester'] ?? null); // Attach SCHOOL IDs $allKids = array_merge($registeredKids, $withdrawnKids); $studentIds = array_column($allKids, 'student_id'); $schoolIdMap = []; if ($studentIds) { $rows = $this->studentModel ->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->discountUsageModel ->where('invoice_id', $invoiceId) ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->findAll(); $refundsPaidTotal = (float) ($ledger['refund_paid_total'] ?? 0.0); $refundDetails = $this->paidRefundDetailsForInvoice((int) $invoiceId); /* ============================================================ * ADDITIONAL CHARGES (itemized) for this invoice * - uses the additional_charges table for line items * - uses invoice.additional_charge as the authoritative total (Strategy B) * ============================================================ */ $acRows = $this->additionalChargeModel ->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'; $title = ''; //trim((string)($ac['title'] ?? 'Additional Charge')); $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, 'invoiceLines' => $invoiceLines, 'refundsPaidTotal' => $refundsPaidTotal, 'refundDetails' => $refundDetails, 'ledger' => $ledger, ]; } /** * Treat common KG spellings as Kindergarten. */ private function isKindergarten(string $grade): bool { $g = strtolower(trim($grade)); return in_array($g, ['KG', 'kg', 'k', 'kindergarten', 'k-g', 'k.g', 'k g'], true); } private function renderPdfInvoice($data) { // Unpack prepared data extract($data); // $data keys expected from prepareInvoiceData(): // - invoice, parent, registeredKids, withdrawnKids, studentCharges, events, students, payments, discounts // - additionalChargeLines, additionalChargesTotal $pdf = new class extends \FPDF { function Footer() { $this->SetY(-15); $this->SetFont('Arial', 'I', 8); $this->Cell(0, 10, 'Page ' . $this->PageNo() . '/{nb}', 0, 0, 'C'); } }; $pdf->AliasNbPages(); $pdf->AddPage(); // --- Column widths $dateCell = 30; $descripCell = 130; $amountCell = 30; // --- Header section if (defined('FCPATH')) { @$pdf->Image(FCPATH . 'images/logo.png', 170, 8, 30); @$pdf->Image(FCPATH . 'images/Isgl_logo.png', 10, 8, 30); } $pdf->Ln(25); $pdf->SetFont('Arial', 'B', 18); $pdf->Cell(0, 10, 'Al Rahma Sunday School', 0, 1, 'C'); $pdf->SetFont('Arial', 'B', 18); $pdf->Cell(0, 10, 'Invoice', 0, 1, 'C'); $pdf->Ln(15); $pdf->SetFont('Arial', 'B', 12); $pdf->Cell(40, 6, 'Parent Name:', 0, 0, 'L'); $pdf->SetFont('Arial', '', 12); $pdf->Cell(0, 6, ($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? ''), 0, 1, 'L'); $pdf->SetFont('Arial', 'B', 12); $pdf->Cell(40, 6, 'Invoice Date:', 0, 0, 'L'); $pdf->SetFont('Arial', '', 12); $issueLocal = null; try { $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); if (!empty($invoice['issue_date'])) { $issueLocal = (new \DateTimeImmutable($invoice['issue_date'], new \DateTimeZone('UTC'))) ->setTimezone(new \DateTimeZone($tzName)); } elseif (!empty($invoice['created_at'])) { $issueLocal = new \DateTimeImmutable($invoice['created_at'], new \DateTimeZone($tzName)); } } catch (\Throwable $e) {} if (!$issueLocal) { $issueLocal = new \DateTimeImmutable('now', new \DateTimeZone($tzName ?? 'UTC')); } $pdf->Cell(0, 6, $issueLocal->format('m-d-Y'), 0, 1, 'L'); $pdf->SetFont('Arial', 'B', 12); $pdf->Cell(40, 6, 'Invoice Number:', 0, 0, 'L'); $pdf->SetFont('Arial', '', 12); $pdf->Cell(0, 6, (string)($invoice['invoice_number'] ?? ''), 0, 1, 'L'); $pdf->SetFont('Arial', 'B', 12); $pdf->Cell(40, 6, 'Due Date:', 0, 0, 'L'); $pdf->SetFont('Arial', '', 12); $formatCalendarDate = static function ($raw): ?string { if ($raw instanceof \DateTimeInterface) { return $raw->format('m-d-Y'); } $value = trim((string)($raw ?? '')); if ($value === '' || preg_match('/^0{4}-0{2}-0{2}/', $value)) { return null; } if (preg_match('/^(\d{4})-(\d{2})-(\d{2})/', $value, $matches)) { return $matches[2] . '-' . $matches[3] . '-' . $matches[1]; } if (preg_match('/^(\d{1,2})[-\/](\d{1,2})[-\/](\d{4})/', $value, $matches)) { return sprintf('%02d-%02d-%04d', (int)$matches[1], (int)$matches[2], (int)$matches[3]); } $timestamp = strtotime($value); return $timestamp === false ? null : date('m-d-Y', $timestamp); }; $dueDisplay = $formatCalendarDate($invoice['due_date'] ?? null); try { $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); if ($dueDisplay === null && !empty($invoice['created_at'])) { $dueLocal = new \DateTimeImmutable($invoice['created_at'], new \DateTimeZone($tzName)); $dueDisplay = $dueLocal->format('m-d-Y'); } } catch (\Throwable $e) {} if ($dueDisplay === null) { $dueDisplay = (new \DateTimeImmutable('now', new \DateTimeZone($tzName ?? 'UTC')))->format('m-d-Y'); } $pdf->Cell(0, 6, $dueDisplay, 0, 1, 'L'); $pdf->Ln(5); $pdf->SetFont('Arial', '', 9); $pdf->MultiCell(0, 5, "Please make the payment by the due date mentioned above. For any queries regarding this invoice, contact the school administration at alrahma.isgl@gmail.com or call +1 978-364-0219."); $pdf->Ln(4); // --- Table header $pdf->SetFont('Arial', 'B', 12); $pdf->Cell($dateCell, 7, 'Date', 1, 0); $pdf->Cell($descripCell, 7, 'Description', 1); $pdf->Cell($amountCell, 7, 'Amount', 1, 1, 'R'); // --- Additional charges data presence $additionalChargeLines = $data['additionalChargeLines'] ?? []; $additionalChargesTotal = (float)($data['additionalChargesTotal'] ?? 0.0); $hasAdditional = abs($additionalChargesTotal) > 0.00001; // --- Helpers for robust date handling & row pushing --- $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); $tz = new \DateTimeZone($tzName); /** * Normalize a raw date string to configured/user local timezone. * @param ?string $raw * @param bool $assumeUtc If true, treat $raw as UTC and convert; if false, parse as local. */ $toLocal = function (?string $raw, bool $assumeUtc = false) use ($tz): \DateTimeImmutable { $fallback = new \DateTimeImmutable('now', $tz); if (!$raw) return $fallback; try { if ($assumeUtc) { $dt = new \DateTimeImmutable($raw, new \DateTimeZone('UTC')); return $dt->setTimezone($tz); } // Parse directly with local tz; PHP will interpret Y-m-d and Y-m-d H:i:s fine here $dt = new \DateTimeImmutable($raw, $tz); return $dt; } catch (\Throwable $e) { $ts = @strtotime($raw); if ($ts === false) return $fallback; return (new \DateTimeImmutable('@' . $ts))->setTimezone($tz); } }; $transactions = []; $totalPaid = 0.0; $totalDiscount = 0.0; $seq = 0; // stable tie-breaker to preserve insertion order for identical timestamps $push = function (\DateTimeImmutable $dt, string $desc, float $amount, string $category = 'other') use (&$transactions, &$seq) { $transactions[] = [ 'dt' => $dt, 'description' => $desc, 'amount' => $amount, 'cat' => $category, 'seq' => $seq++, ]; }; $studentById = []; foreach (($data['students'] ?? []) as $student) { $sid = (int)($student['student_id'] ?? 0); if ($sid <= 0) { continue; } $studentById[$sid] = trim((string)($student['student_firstname'] ?? '') . ' ' . (string)($student['student_lastname'] ?? '')); } $studentTuitionRows = []; foreach (array_merge($registeredKids ?? [], $withdrawnKids ?? []) as $student) { $sid = (int)($student['student_id'] ?? 0); $charge = $studentCharges[$sid] ?? null; $amount = (float)($charge['unit_fee'] ?? 0.0); if ($sid <= 0 || abs($amount) < 0.00001) { continue; } $name = trim((string)($student['student_firstname'] ?? '') . ' ' . (string)($student['student_lastname'] ?? '')); $grade = trim((string)($student['grade'] ?? '')); $desc = 'Tuition - ' . ($name !== '' ? $name : 'Student #' . $sid); if ($grade !== '' && strtoupper($grade) !== 'N/A') { $desc .= ' (' . $grade . ')'; } $studentTuitionRows[] = [ 'description' => $desc, 'amount' => $amount, ]; } $eventRows = []; foreach (($events ?? []) as $event) { $amount = (float)($event['charged'] ?? 0.0); if (abs($amount) < 0.00001) { continue; } $sid = (int)($event['student_id'] ?? 0); $studentName = $sid > 0 ? ($studentById[$sid] ?? '') : ''; if ($studentName === '') { $studentName = trim((string)($event['external_firstname'] ?? '') . ' ' . (string)($event['external_lastname'] ?? '')); } $desc = trim((string)($event['event_name'] ?? 'Event charge')); if ($studentName !== '') { $desc .= ' - ' . $studentName; } $eventRows[] = [ 'description' => $desc, 'amount' => $amount, ]; } // --- Frozen invoice charge lines remain authoritative for totals. // Aggregate tuition/event lines are expanded for display when invoice details are available. foreach (($data['invoiceLines'] ?? []) as $line) { $dt = $toLocal($line['created_at'] ?? ($invoice['created_at'] ?? null), true); $amount = ((int)($line['line_amount_cents'] ?? 0)) / 100; $type = (string)($line['line_type'] ?? 'other'); $category = str_contains($type, 'event') ? 'event' : (str_contains($type, 'additional') ? 'additional' : 'registration'); if (!str_contains($type, 'additional') && !str_contains($type, 'event') && !empty($studentTuitionRows)) { $expandedTotal = 0.0; foreach ($studentTuitionRows as $row) { $expandedTotal += (float)$row['amount']; $push($dt, $row['description'], (float)$row['amount'], 'registration'); } $delta = round($amount - $expandedTotal, 2); if (abs($delta) >= 0.01) { $push($dt, 'Tuition charges adjustment', $delta, 'registration'); } continue; } if (str_contains($type, 'event') && !empty($eventRows)) { $expandedTotal = 0.0; foreach ($eventRows as $row) { $expandedTotal += (float)$row['amount']; $push($dt, $row['description'], (float)$row['amount'], 'event'); } $delta = round($amount - $expandedTotal, 2); if (abs($delta) >= 0.01) { $push($dt, 'Event charges adjustment', $delta, 'event'); } continue; } $push($dt, (string)($line['description'] ?? 'Invoice line'), $amount, $category); } if (empty($data['invoiceLines'] ?? [])) { $fallbackDt = $toLocal($invoice['created_at'] ?? ($invoice['issue_date'] ?? null), true); foreach ($studentTuitionRows as $row) { $push($fallbackDt, $row['description'], (float)$row['amount'], 'registration'); } foreach ($eventRows as $row) { $push($fallbackDt, $row['description'], (float)$row['amount'], 'event'); } } // --- Payments (negative) — stored in local time foreach ($payments as $payment) { $dt = $toLocal($payment['payment_date'] ?? null, false /* local */); $amount = (float)($payment['paid_amount'] ?? 0.0); $totalPaid += $amount; $push($dt, 'Payment (' . ($payment['payment_method'] ?? 'Payment') . ')', -1 * $amount, 'payment'); } // --- Discounts (negative) integrated into the timeline foreach (($discounts ?? []) as $discount) { $amt = isset($discount['applied_discount_cents']) && $discount['applied_discount_cents'] !== null ? ((int)$discount['applied_discount_cents']) / 100 : (float)($discount['discount_amount'] ?? 0.0); $totalDiscount += $amt; $dt = $toLocal($discount['used_at'] ?? ($invoice['created_at'] ?? null), false); $desc = "Discount applied (Reason: {$discount['description']})"; $push($dt, $desc, -1 * $amt, 'discount'); } // --- Refund payouts (positive) integrated into the timeline foreach (($refundDetails ?? []) as $refund) { $amount = (float)($refund['amount'] ?? 0.0); if (abs($amount) < 0.00001) { continue; } $dt = $toLocal($refund['date'] ?? null, true); $method = trim((string)($refund['method'] ?? '')); $checkNumber = trim((string)($refund['check_number'] ?? '')); $isReversal = (string)($refund['type'] ?? 'cash_out') === 'reversal'; $desc = $isReversal ? 'Refund reversal' : 'Refund paid'; if ($method !== '') { $desc .= ' (' . $method . ')'; } if ($checkNumber !== '') { $desc .= ' - Check #' . $checkNumber; } $push($dt, $desc, $amount, 'refund'); } // --- Sort by exact timestamp, then by insertion sequence for stability usort($transactions, function ($a, $b) { // Different days: keep chronological by timestamp $dayA = $a['dt']->format('Y-m-d'); $dayB = $b['dt']->format('Y-m-d'); if ($dayA !== $dayB) { return $a['dt'] <=> $b['dt']; } // Same day: ensure desired category priority (registration before payment) $pri = [ 'registration' => 10, 'event' => 20, 'additional' => 25, 'discount' => 30, 'refund' => 40, 'payment' => 90, 'other' => 50, ]; $ca = $a['cat'] ?? 'other'; $cb = $b['cat'] ?? 'other'; $pa = $pri[$ca] ?? 50; $pb = $pri[$cb] ?? 50; if ($pa !== $pb) { return $pa <=> $pb; } // Same category: fall back to exact timestamp, then insertion order $cmp = $a['dt'] <=> $b['dt']; return $cmp !== 0 ? $cmp : ($a['seq'] <=> $b['seq']); }); // --- Render transactions (strict chronological order) $pdf->SetFont('Arial', '', 11); foreach ($transactions as $t) { $pdf->Cell($dateCell, 7, $t['dt']->format('m-d-Y'), 1); $pdf->Cell($descripCell, 7, $t['description'], 1); $pdf->Cell($amountCell, 7, ($t['amount'] < 0 ? '-$' : '$') . number_format(abs($t['amount']), 2), 1, 1, 'R'); } // ======== SUMMARY (bottom) ======== $ledger = $data['ledger'] ?? []; $totalAmount = (float) ($ledger['total_amount'] ?? 0.0); $totalDiscount = (float) ($ledger['discount_total'] ?? $totalDiscount); $totalPaid = (float) ($ledger['paid_amount'] ?? $totalPaid); $totalRefund = (float) ($ledger['refund_paid_total'] ?? 0.0); $displayBalance = (float) ($ledger['balance'] ?? 0.0); $creditOverpay = (float) ($ledger['customer_credit'] ?? 0.0); $pdf->Ln(5); $labelWidth = 165; $amountWidth = 25; $pdf->SetFont('Arial', 'B', 12); $pdf->Cell($labelWidth, 7, 'Total Charges:', 0, 0, 'R'); $pdf->SetFont('Arial', '', 12); $pdf->Cell($amountWidth, 7, '$' . number_format($totalAmount, 2), 0, 1, 'R'); // Show Additional Charges subtotal if any (informational) if ($hasAdditional) { $pdf->SetFont('Arial', 'B', 12); $pdf->Cell($labelWidth, 7, 'Additional Charges (included):', 0, 0, 'R'); $pdf->SetFont('Arial', '', 12); $pdf->Cell($amountWidth, 7, '$' . number_format($additionalChargesTotal, 2), 0, 1, 'R'); } if ($totalDiscount > 0.0) { $pdf->SetFont('Arial', 'B', 12); $pdf->Cell($labelWidth, 7, 'Total Discount:', 0, 0, 'R'); $pdf->SetFont('Arial', '', 12); $pdf->Cell($amountWidth, 7, '$' . number_format($totalDiscount, 2), 0, 1, 'R'); } if ($totalRefund > 0.0) { $pdf->SetFont('Arial', 'B', 12); $pdf->Cell($labelWidth, 7, 'Refunds Paid:', 0, 0, 'R'); $pdf->SetFont('Arial', '', 12); $pdf->Cell($amountWidth, 7, '$' . number_format($totalRefund, 2), 0, 1, 'R'); } // Show credit if overpaid if ($creditOverpay > 0.0) { $pdf->SetFont('Arial', 'B', 12); $pdf->Cell($labelWidth, 7, 'Credit (Overpayment):', 0, 0, 'R'); $pdf->SetFont('Arial', '', 12); $pdf->Cell($amountWidth, 7, '$' . number_format($creditOverpay, 2), 0, 1, 'R'); } $pdf->SetFont('Arial', 'B', 12); $pdf->Cell($labelWidth, 7, 'Total Paid:', 0, 0, 'R'); $pdf->SetFont('Arial', '', 12); $pdf->Cell($amountWidth, 7, '$' . number_format($totalPaid, 2), 0, 1, 'R'); $pdf->SetFont('Arial', 'B', 12); $pdf->Cell($labelWidth, 7, 'Balance Due:', 0, 0, 'R'); $pdf->SetFont('Arial', '', 12); $pdf->Cell($amountWidth, 7, '$' . number_format($displayBalance, 2), 0, 1, 'R'); // --- Output header('Content-Type: application/pdf'); header('Content-Disposition: inline; filename="invoice_' . ($invoice['invoice_number'] ?? 'N/A') . '.pdf"'); header('Cache-Control: private, max-age=0, must-revalidate'); $pdf->Output('I', 'invoice_' . ($invoice['invoice_number'] ?? 'N/A') . '.pdf'); exit; } // --- Grade helpers --- private function compareGrades($gradeA, $gradeB) { $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] * Examples: * - 'PK' => -1, 'K'/'Kindergarten' => 0 * - '1', '2A', 'Grade 5', 'G5' => 1,2,5 * - 'Y', 'Youth', 'Y1', 'Youth 2' => map to > 9 (10, 11, 12 ...) */ 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]; } // If you want, you could also handle Pre-K or Youth by number here 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]; } private function generateErrorPdf($message) { // Create a new instance of FPDF $pdf = new \FPDF(); $pdf->AddPage(); // Set title $pdf->SetFont('Arial', 'B', 16); $pdf->Cell(0, 10, 'Error', 0, 1, 'C'); // Add the error title centered // Set message font and size $pdf->SetFont('Arial', '', 12); $pdf->Ln(10); // Add a line break // Add the message to the PDF $pdf->MultiCell(0, 10, $message); // Allow the message to wrap in multiple lines // Output the PDF $pdf->Output('I', 'error.pdf'); // Output PDF inline (browser) } public function invoicePayment() { $parentId = session()->get('user_id'); if (!$parentId) { return redirect()->back()->with('error', 'Unable to retrieve student data. Please contact support.'); } $currentSchoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); $selectedYear = $currentSchoolYear; // Fetch invoices for the selected year $invoices = []; if ($selectedYear) { $invoices = $this->invoiceModel->getInvoicesByParentId($parentId, $selectedYear); } // Fetch refund amounts PAID for these invoices $invoiceIds = array_column($invoices, 'id'); $refunds = []; if (!empty($invoiceIds)) { $refundResults = $this->db->table('refunds') ->select('invoice_id, COALESCE(SUM(refund_paid_amount),0) as refund_paid_amount') ->whereIn('invoice_id', $invoiceIds) ->whereIn('status', ['Partial','Paid']) ->groupBy('invoice_id') ->get() ->getResultArray(); foreach ($refundResults as $row) { $refunds[$row['invoice_id']] = $row['refund_paid_amount']; } } // Fetch last payment data for these invoices $lastPayments = []; if (!empty($invoiceIds)) { $paymentResults = $this->paymentModel->getPaymentsByInvoice($invoiceIds); foreach ($paymentResults as $payment) { $lastPayments[$payment['invoice_id']] = [ 'last_paid_amount' => $payment['last_paid_amount'], 'last_payment_date' => $payment['last_payment_date'] ]; } } // Attach refund amount and last payment data to each invoice foreach ($invoices as &$invoice) { $invoice['refund_amount'] = $refunds[$invoice['id']] ?? 0.00; $invoice['last_paid_amount'] = $lastPayments[$invoice['id']]['last_paid_amount'] ?? 0.00; $invoice['last_payment_date'] = $lastPayments[$invoice['id']]['last_payment_date'] ?? null; } $invoiceEventCharges = []; foreach ($invoices as $invoice) { $year = $invoice['school_year'] ?? $this->schoolYear; $sem = $invoice['semester'] ?? $this->semester; $invoiceEventCharges[(int)$invoice['id']] = $this->chargesModel->getChargesWithEventInfo( $invoice['parent_id'], $year, $sem ); } return view('/parent/invoice_payment', [ 'invoices' => $invoices, 'selectedYear' => $selectedYear, 'currentSchoolYear' => $currentSchoolYear, 'dueDate' => $this->dueDate, 'invoiceEventCharges' => $invoiceEventCharges, ]); } // API: Create a new invoice public function createAPI() { $data = [ 'parent_id' => $this->request->getPost('parent_id'), 'registeredKids' => $this->request->getPost('registeredKids'), 'invoice_number' => $this->request->getPost('invoice_number'), 'total_amount' => $this->request->getPost('total_amount'), 'refund_amount' => $this->request->getPost('refund_amount'), 'balance' => $this->request->getPost('balance'), 'school_year' => $this->request->getPost('school_year'), 'issue_date' => $this->request->getPost('issue_date'), 'refund_issue_date' => $this->request->getPost('refund_issue_date'), 'due_date' => $this->request->getPost('due_date'), 'status' => FinancialStatus::INVOICE_UNPAID, 'description' => $this->request->getPost('description'), ]; if ($this->invoiceModel->save($data)) { return $this->respondCreated($data); // Send successful response } else { return $this->failValidationErrors($this->invoiceModel->errors()); // Return validation errors } } // API: Get invoices by parent ID public function getByParentAPI($parentId) { $invoices = $this->invoiceModel->getInvoicesByUserId($parentId, $this->schoolYear); if ($invoices) { return $this->respond($invoices); // Send the invoices in the response } else { return $this->failNotFound('Invoices not found for the parent.'); } } // View: Get invoices by parent ID (for web views) public function getByParent($parentId) { $parentId = (int) $parentId; $userId = (int) (session()->get('user_id') ?? 0); $roles = array_map( static fn ($role): string => strtolower(trim((string) $role)), array_filter(array_merge((array) session()->get('roles'), [session()->get('role')])) ); $isStaff = (bool) array_intersect($roles, [ 'administrator', 'administrative staff', 'principal', 'admin', ]); if ($userId <= 0 || (! $isStaff && $userId !== $parentId)) { return redirect()->to('/access_denied'); } $invoices = $this->invoiceModel->getInvoicesByUserId($parentId, $this->schoolYear); return view('invoice_list', ['invoices' => $invoices]); } // View: Create a new invoice public function create() { return view('invoice_create'); } // API: Update invoice status public function updateStatusAPI($invoiceId) { $invoice = $this->invoiceModel->find($invoiceId); if (!$invoice) { return $this->failNotFound('Invoice not found.'); } $this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? '')); $status = $this->request->getPost('status'); if ($this->invoiceModel->updateInvoiceStatus($invoiceId, $status)) { return $this->respond(['status' => 'success']); } else { return $this->failNotFound('Invoice not found.'); } } // View: Update invoice status public function updateStatus($invoiceId) { $invoice = $this->invoiceModel->find($invoiceId); if (!$invoice) { return redirect()->back()->with('error', 'Invoice not found.'); } $this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? '')); $status = $this->request->getPost('status'); if ($this->invoiceModel->updateInvoiceStatus($invoiceId, $status)) { return redirect()->to('/invoices'); } else { return redirect()->back()->with('error', 'Failed to update status.'); } } // View: Get unpaid invoices public function unpaidInvoices() { $unpaidInvoices = $this->invoiceModel->getUnpaidInvoices(); return view('unpaid_invoices', ['invoices' => $unpaidInvoices]); } }