Files
2026-06-07 00:27:27 -04:00

1798 lines
74 KiB
PHP

<?php
namespace App\Controllers\View;
require_once APPPATH . 'ThirdParty/fpdf/fpdf.php';
use App\Models\InvoiceModel;
use App\Models\InvoiceStudentListModel;
use App\Models\StudentModel;
use App\Models\EnrollmentModel;
use App\Models\StudentClassModel;
use App\Models\ConfigurationModel;
use App\Models\EventChargesModel;
use App\Models\UserModel;
use App\Models\AdditionalChargeModel;
use App\Models\PaymentModel;
use App\Models\InvoiceEventModel;
use App\Models\ClassSectionModel;
use App\Models\DiscountUsageModel;
use App\Models\RefundModel;
use DateTime;
use DateTimeZone;
use CodeIgniter\RESTful\ResourceController;
class InvoiceController extends ResourceController
{
protected $invoiceModel;
protected $additionalChargeModel;
protected $studentModel;
protected $enrollmentModel;
protected $invoicestudentModel;
protected $schoolYear; // Declare global variable for the controller
protected $semester;
protected $dueDate;
protected $db;
protected $configModel;
protected $userModel;
protected $studentClassModel;
protected $firstStudentFee;
protected $secondStudentFee;
protected $youthFee;
protected $refundDeadline;
protected $invoiceEventModel;
protected $paymentModel;
protected $chargesModel;
protected $discountUsageModel;
protected $refundModel;
protected $request;
protected $gradeFee;
protected $classSectionModel;
public function __construct()
{
$this->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->gradeFee = $this->configModel->getConfig('grade_fee');
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
$this->dueDate = $this->configModel->getConfig('due_date');
$this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 350);
$this->secondStudentFee = (float) ($this->configModel->getConfig('second_student_fee') ?? 200);
$this->youthFee = (float) ($this->configModel->getConfig('youth_fee') ?? 200);
$this->refundDeadline = date('Y-m-d', strtotime($this->configModel->getConfig('refund_deadline')));
$this->db = \Config\Database::connect();
$this->request = \Config\Services::request();
}
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'];
// ✅ Fetch refund amount actually PAID this year (Partial/Paid)
$refund = $this->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS refund_paid_amount')
->where('parent_id', $parent['id'])
->where('school_year', $schoolYear)
->whereIn('status', ['Partial','Paid'])
->get()
->getRowArray();
$parentData['refund_amount'] = (float)($refund['refund_paid_amount'] ?? 0.0);
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')
]);
}
/**
* 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->schoolYear;
}
$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;
// Refund total paid for parent/year (Partial/Paid)
$refund = $this->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS refund_paid_amount')
->where('parent_id', $parent['id'])
->where('school_year', $schoolYear)
->whereIn('status', ['Partial','Paid'])
->get()->getRowArray();
$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 = $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
)
{
$isAjax = $this->request->isAJAX() || str_contains(strtolower($this->request->getHeaderLine('Accept')), 'application/json');
if ($parentId == null) {
$parentId = (int)$this->request->getPost('parent_id');
}
$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)) {
if ($isAjax) {
return $this->response->setJSON(['ok' => false, 'message' => 'No enrollment records found.']);
}
return redirect()->back()->with('error', '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
);
}
// ✅ Refunds PAID to the parent for this year (Partial/Paid)
$refundPaid = (float) $this->refundModel->getTotalApprovedRefundByParentIdAndSchoolYear($parentId, $schoolYear);
$totalPaid = $this->paymentModel->getTotalPaidByParentId($parentId, $schoolYear);
$discountedTuition = max(0, $tuitionFee);
$totalAmount = $discountedTuition + $eventchargeTotal;
// Parent-level balance (informational); we will recalc per invoice below
$parentBalance = $totalAmount // original charges (tuition + events)
- $totalDiscount // any applied discounts/vouchers
- $refundPaid // approved refunds paid to parent
- $totalPaid; // payments received
// 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'])) {
$paymentExclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
$paymentsHasStatus = false;
$paymentsHasVoid = false;
try {
$paymentsHasStatus = $this->db->fieldExists('status', 'payments');
$paymentsHasVoid = $this->db->fieldExists('is_void', 'payments');
} catch (\Throwable $e) {
}
// Preserve applied additional charges and recalc this invoice only
$extrasSum = 0.0;
try {
$rows = $this->db->table('additional_charges')
->select('charge_type, amount')
->where('invoice_id', (int)$invoice['id'])
->where('school_year', $schoolYear)
->where('status', 'applied')
->get()->getResultArray();
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 = $this->db->table('discount_usages')
->select('COALESCE(SUM(discount_amount),0) AS tot')
->where('invoice_id', (int)$invoice['id'])
->get()->getRowArray();
$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 = $this->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
->where('invoice_id', (int)$invoice['id'])
->where('school_year', $schoolYear)
->whereIn('status', ['Partial','Paid'])
->get()->getRowArray();
$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 (sum payments to avoid stale invoice.paid_amount)
$paidOnInv = 0.0;
try {
$qb = $this->db->table('payments')
->select('COALESCE(SUM(paid_amount),0) AS tot')
->where('invoice_id', (int)$invoice['id'])
->where('paid_amount >', 0);
if ($paymentsHasStatus) {
$qb->groupStart()
->whereNotIn('status', $paymentExclude)
->orWhere('status IS NULL', null, false)
->groupEnd();
}
if ($paymentsHasVoid) {
$qb->groupStart()
->where('is_void', 0)
->orWhere('is_void IS NULL', null, false)
->groupEnd();
}
$row = $qb->get()->getRowArray();
$paidOnInv = (float)($row['tot'] ?? 0.0);
} catch (\Throwable $e) {
log_message('error', 'payment sum failed for invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
$paidOnInv = (float)($invoice['paid_amount'] ?? 0.0);
}
$newBalance = $newTotal - $invDiscount - $invRefunds - $paidOnInv;
$newStatus = ($newBalance <= 0.00001)
? 'Paid'
: (($paidOnInv > 0) ? 'Partially Paid' : 'Unpaid');
$this->invoiceModel->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->userModel->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,
// 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');
}
$insertId = $this->invoiceModel->insert([
'parent_id' => $parentId,
'invoice_number' => $invoiceNumber,
'total_amount' => $totalAmount,
'paid_amount' => 0,
// Initial balance equals the created total; discounts/refunds/payments will adjust later
'balance' => $totalAmount,
'status' => 'Unpaid',
'school_year' => $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->invoiceModel->errors()));
if ($isAjax) {
return $this->response->setJSON(['ok' => false, 'message' => 'Failed to create invoice.']);
}
return redirect()->back()->with('error', 'Failed to create invoice. Please check input values.');
} else {
log_message('info', "Invoice created successfully. Insert ID: {$insertId}");
}
$updated = false;
}
// Success response
if ($isAjax) {
return $this->response->setJSON([
'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 redirect()->to(route_to('InvoiceController::index'))
->with('success', $updated ? 'Invoice updated.' : 'Invoice created.');
}
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 {
$totalDiscount = 0.00;
$eventchargeTotal = 0.0;
try {
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear);
$eventchargeTotal = array_sum(array_column($eventsList, 'charged'));
} catch (\Throwable $e) {
log_message('error', 'Failed to load event charges for discount recalculation: ' . $e->getMessage());
}
// Get all invoices for this parent and school year
$invoices = $this->invoiceModel->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 = $this->db->table('discount_usages du')
->select('du.id, dv.id as voucher_id, dv.discount_type, dv.discount_value')
->join('discount_vouchers dv', 'du.voucher_id = dv.id')
->join('invoices i', 'du.invoice_id = i.id')
->where('du.invoice_id', $invoiceId)
->where('i.school_year', $schoolYear)
->get()
->getRowArray();
if (!$discountUsage) {
log_message('info', "No discount applied to invoice ID $invoiceId.");
continue;
}
$extrasSum = 0.0;
try {
$rows = $this->db->table('additional_charges')
->select('charge_type, amount')
->where('invoice_id', (int)$invoice['id'])
->where('school_year', $schoolYear)
->where('status', 'applied')
->get()->getResultArray();
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 discount recalculation invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
}
$baseTotal = round($tuitionFee + $extrasSum, 2);
// Recalculate discount
if ($discountUsage['discount_type'] === 'percent') {
$discountAmount = round(($baseTotal * $discountUsage['discount_value']) / 100, 2);
} else {
$discountAmount = min($discountUsage['discount_value'], $baseTotal);
}
// Update discount usage
$this->db->table('discount_usages')
->where('id', $discountUsage['id'])
->update([
'discount_amount' => $discountAmount,
'updated_at' => utc_now(),
'updated_by' => session()->get('user_id')
]);
$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' => <float>, // what the parent still owes after any withdrawals
* 'refund_amount' => <float>, // 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) 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
}
// if 0 regulars, nothing to add here
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."];
}
$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 = [];
$regularCount = 0;
/**
* Computes the fee for a student given numeric level and original grade name.
* - KG/K/Kindergarten are always treated as "regular".
* - Otherwise, "regular" means gradeLevel <= $this->gradeFee threshold.
*/
$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->chargesModel->getChargesWithEventInfo($parentId, $schoolYear);
// 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();
// Refunds PAID for this specific invoice (money returned to the parent)
$refundsPaidTotal = 0.0;
try {
$r = $this->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear)
->whereIn('status', ['Partial','Paid'])
->get()->getRowArray();
$refundsPaidTotal = (float)($r['tot'] ?? 0.0);
} catch (\Throwable $e) {
log_message('error', 'Failed to sum refunds for invoice ' . (int)$invoiceId . ': ' . $e->getMessage());
}
/* ============================================================
* 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,
'refundsPaidTotal' => $refundsPaidTotal,
];
}
/**
* 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);
$dueLocal = null;
try {
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
if (!empty($invoice['due_date'])) {
$dueLocal = (new \DateTimeImmutable($invoice['due_date'], new \DateTimeZone('UTC')))
->setTimezone(new \DateTimeZone($tzName));
} elseif (!empty($invoice['created_at'])) {
$dueLocal = new \DateTimeImmutable($invoice['created_at'], new \DateTimeZone($tzName));
}
} catch (\Throwable $e) {}
if (!$dueLocal) { $dueLocal = new \DateTimeImmutable('now', new \DateTimeZone($tzName ?? 'UTC')); }
$pdf->Cell(0, 6, $dueLocal->format('m-d-Y'), 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++,
];
};
// --- Tuition/registration lines (charges) ---
foreach ($registeredKids as $student) {
$id = $student['student_id'];
$unit = (float)($studentCharges[$id]['unit_fee'] ?? 0.0);
$name = $student['student_firstname'] . ' ' . $student['student_lastname'];
$classSectionName = $this->classSectionModel->getClassSectionNameByClassId($student['grade']);
$lowerCaseName = strtolower((string)$classSectionName);
$gradeName = ($lowerCaseName === 'kg')
? 'in Kindergarten '
: (($lowerCaseName === 'youth') ? 'in Youth ' : ('in Grade ' . $classSectionName));
$dt = $toLocal($invoice['created_at'] ?? null, false);
$push($dt, 'Registration of student "' . $name . '" ' . $gradeName, $unit, 'registration');
}
// --- Event charges (charges) ---
foreach ($events as $event) {
$studentName = 'N/A';
if (!empty($event['student_id'])) {
foreach ($students as $st) {
if (($st['student_id'] ?? null) == $event['student_id']) {
$studentName = $st['student_firstname'] . ' ' . $st['student_lastname'];
break;
}
}
}
if ($studentName === 'N/A') {
$externalName = trim((string)($event['external_firstname'] ?? '') . ' ' . (string)($event['external_lastname'] ?? ''));
if ($externalName !== '') {
$studentName = $externalName . ' (external)';
}
}
$dt = $toLocal($event['created_at'] ?? null, false);
$amount = (float)($event['charged'] ?? 0.0);
$eventName = !empty($event['event_name']) ? $event['event_name'] : 'with no name';
$push($dt, 'Event ' . $eventName . ' charge for "' . $studentName . '"', $amount, 'event');
}
// --- Withdrawn refunds (negative) — only if a non-zero refund figure exists
foreach ($withdrawnKids as $student) {
$id = $student['student_id'];
$ref = (float)($studentCharges[$id]['refund'] ?? 0.0);
if ($ref <= 0) { continue; }
$name = $student['student_firstname'] . ' ' . $student['student_lastname'];
$dt = $toLocal($invoice['created_at'] ?? null, false);
$push($dt, 'Refund for student "' . $name . '"', -1 * $ref, 'refund');
}
// --- 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');
}
// --- Additional charges (already signed: deduct < 0, add > 0)
foreach ($additionalChargeLines as $l) {
$dt = $toLocal($l['date'] ?? null, false);
$desc = (string)($l['description'] ?? 'Additional Charge');
$amt = (float)($l['amount'] ?? 0.0);
$push($dt, $desc, $amt, 'additional');
}
// --- Discounts (negative) integrated into the timeline
foreach (($discounts ?? []) as $discount) {
$amt = (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');
}
// --- 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) ========
// Compute total charges from components (tuition + event charges + additional charges)
// to ensure the PDF always reflects all elements accurately.
$tuitionSubtotal = 0.0;
foreach (($studentCharges ?? []) as $sc) {
$tuitionSubtotal += (float)($sc['unit_fee'] ?? 0.0);
}
$eventSubtotal = 0.0;
foreach (($events ?? []) as $ev) {
$eventSubtotal += (float)($ev['charged'] ?? 0.0);
}
$additionalSubtotal = (float)($additionalChargesTotal ?? 0.0);
$totalRefund = (float)($data['refundsPaidTotal'] ?? 0.0);
$totalAmount = round($tuitionSubtotal + $eventSubtotal + $additionalSubtotal, 2);
$calcBalance = $totalAmount - $totalPaid - $totalDiscount - $totalRefund;
// Prefer computed balance in PDF to avoid stale DB values
$totalBalance = $calcBalance;
// Display rule: if negative, show as Credit (Overpayment) and clamp balance due to 0.00
$displayBalance = $totalBalance;
$creditOverpay = 0.0;
if ($displayBalance < -0.00001) {
$creditOverpay = abs($displayBalance);
$displayBalance = 0.00;
}
$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->schoolYear;
// Get available school years from invoices
$schoolYears = $this->invoiceModel
->select('school_year')
->distinct()
->where('parent_id', $parentId)
->orderBy('school_year', 'DESC')
->findAll();
// Determine which year to show
$selectedYear = $this->request->getGet('school_year');
if (empty($selectedYear)) {
$hasCurrentYear = in_array($currentSchoolYear, array_column($schoolYears, 'school_year'));
$selectedYear = $hasCurrentYear ? $currentSchoolYear : (!empty($schoolYears) ? $schoolYears[0]['school_year'] : null);
}
// 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,
'schoolYears' => $schoolYears,
'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' => '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)
{
$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)
{
$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)
{
$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]);
}
}