diff --git a/app/Controllers/View/EventController.php b/app/Controllers/View/EventController.php index 87e6e07..fb86f3a 100644 --- a/app/Controllers/View/EventController.php +++ b/app/Controllers/View/EventController.php @@ -13,6 +13,7 @@ use App\Models\InvoiceModel; use App\Models\InvoiceEventModel; use App\Models\ClassSectionModel; use App\Models\PaymentModel; +use Config\Database; #use ??? use App\Controllers\View\InvoiceController; use CodeIgniter\RESTful\ResourceController; @@ -34,6 +35,7 @@ class EventController extends ResourceController protected $semester; protected $categories; protected $enrollmentModel; + private ?bool $eventChargesHasCreatedBy = null; public function __construct() { @@ -60,6 +62,22 @@ class EventController extends ResourceController ]; } + private function eventChargesSupportsCreatedBy(): bool + { + if ($this->eventChargesHasCreatedBy !== null) { + return $this->eventChargesHasCreatedBy; + } + + try { + $db = Database::connect(); + $this->eventChargesHasCreatedBy = $db->fieldExists('created_by', 'event_charges'); + } catch (\Throwable $e) { + $this->eventChargesHasCreatedBy = false; + } + + return $this->eventChargesHasCreatedBy; + } + public function index() { $eventModel = new EventModel(); @@ -216,7 +234,7 @@ class EventController extends ResourceController $semester = $this->request->getGet('semester') ?? $this->semester; $parents = $this->userModel->getParents(); - $events = $this->eventModel->getActiveEvents($this->schoolYear); + $events = $this->eventModel->getActiveEvents($schoolYear); $filterEventId = (int) ($this->request->getGet('event_id') ?? 0); $filterParentId = (int) ($this->request->getGet('parent_id') ?? 0); @@ -324,13 +342,24 @@ class EventController extends ResourceController $parentId = $this->request->getPost('parent_id'); $eventId = $this->request->getPost('event_id'); $participations = $this->request->getPost('participation') ?? []; + $externalParticipants = $this->request->getPost('external_participants') ?? []; - if (!$parentId || !$eventId || empty($participations)) { + // Allow "external only" submissions (no enrolled students selected). + if (!$eventId || (empty($participations) && empty($externalParticipants))) { return redirect()->back()->with('error', 'Missing required information.'); } + if (!empty($participations) && !$parentId) { + return redirect()->back()->with('error', 'Select a parent when adding enrolled students.'); + } $userId = session()->get('user_id'); $event = $this->eventModel->getEvent($eventId, $schoolYear); + if (!$event) { + return redirect()->back()->with('error', 'Selected event not found for the chosen school year.'); + } + + $parentsForInvoice = []; + $supportsCreatedBy = $this->eventChargesSupportsCreatedBy(); foreach ($participations as $studentId => $value) { $existing = $this->eventChargesModel->where([ @@ -363,7 +392,7 @@ class EventController extends ResourceController $this->eventChargesModel->update($existing['id'], $updateData); continue; } else { - $this->eventChargesModel->insert([ + $insertData = [ 'parent_id' => $parentId, 'student_id' => $studentId, 'event_id' => $eventId, @@ -373,15 +402,113 @@ class EventController extends ResourceController 'class_section_id' => $classSectionId, 'school_year' => $schoolYear, 'semester' => $semester, - 'created_by' => $userId, 'updated_by' => $userId - ]); + ]; + if ($supportsCreatedBy) { + $insertData['created_by'] = $userId; + } + $this->eventChargesModel->insert($insertData); } } - $this->invoiceController->generateInvoice($parentId); + foreach ($externalParticipants as $pieces) { + $firstname = $this->normalizeExternalName($pieces['firstname'] ?? ''); + $lastname = $this->normalizeExternalName($pieces['lastname'] ?? ''); + $note = trim($pieces['note'] ?? ''); + $parentFirst = $this->normalizeExternalName($pieces['parent_firstname'] ?? ''); + $parentLast = $this->normalizeExternalName($pieces['parent_lastname'] ?? ''); + $parentPhone = trim($pieces['parent_phone'] ?? ''); + $parentEmail = trim((string)($pieces['parent_email'] ?? '')); + $markPaid = (string)($pieces['paid'] ?? '0') === '1'; - $this->fixInvoiceStatusAfterCharge($parentId, $schoolYear); + if (!$firstname && !$lastname) { + continue; + } + + $matchedParentId = $this->findExistingParentIdByExternalContact( + $parentFirst, + $parentLast, + $parentEmail, + $parentPhone + ); + + // Match external participants by kid name + event context. + // Parent contact info can change (or be fixed later) and should be updated, not used for matching. + $matchConditions = [ + 'event_id' => $eventId, + 'external_firstname' => $firstname, + 'external_lastname' => $lastname, + 'school_year' => $schoolYear, + 'semester' => $semester, + ]; + if ($matchedParentId) { + $matchConditions['parent_id'] = $matchedParentId; + } else { + // External-only parent: match by external parent contact to avoid duplicates. + $matchConditions['parent_id'] = null; + $matchConditions['external_parent_firstname'] = $parentFirst ?: null; + $matchConditions['external_parent_lastname'] = $parentLast ?: null; + $matchConditions['external_parent_phone'] = $parentPhone ?: null; + $matchConditions['external_parent_email'] = $parentEmail ?: null; + } + $existingExternal = $this->eventChargesModel->where($matchConditions)->first(); + + if ($existingExternal) { + $this->eventChargesModel->update($existingExternal['id'], [ + 'participation' => 'yes', + 'charged' => $event['amount'], + 'updated_by' => $userId, + // Preserve existing payment status; only update contact fields when provided. + 'external_note' => $note !== '' ? $note : ($existingExternal['external_note'] ?? null), + 'external_parent_firstname' => $parentFirst !== '' ? $parentFirst : ($existingExternal['external_parent_firstname'] ?? null), + 'external_parent_lastname' => $parentLast !== '' ? $parentLast : ($existingExternal['external_parent_lastname'] ?? null), + 'external_parent_phone' => $parentPhone !== '' ? $parentPhone : ($existingExternal['external_parent_phone'] ?? null), + 'external_parent_email' => $parentEmail !== '' ? $parentEmail : ($existingExternal['external_parent_email'] ?? null), + ]); + continue; + } + + $insertData = [ + 'parent_id' => $matchedParentId ?: null, + 'student_id' => null, + 'event_id' => $eventId, + 'participation' => 'yes', + 'charged' => $event['amount'], + 'event_paid' => $markPaid ? 1 : 0, + 'external_firstname' => $firstname, + 'external_lastname' => $lastname, + 'external_note' => $note, + 'external_parent_firstname' => $parentFirst, + 'external_parent_lastname' => $parentLast, + 'external_parent_phone' => $parentPhone, + 'external_parent_email' => $parentEmail, + 'school_year' => $schoolYear, + 'semester' => $semester, + 'updated_by' => $userId, + ]; + if ($supportsCreatedBy) { + $insertData['created_by'] = $userId; + } + $this->eventChargesModel->insert($insertData); + + $newChargeId = (int)$this->eventChargesModel->getInsertID(); + if ($markPaid && $newChargeId > 0) { + $this->applyEventPaymentStatus($newChargeId, true); + } + + if ($matchedParentId && $this->parentHasEnrollment($matchedParentId, (string)$schoolYear)) { + $parentsForInvoice[$matchedParentId] = true; + } + } + + if ($parentId && $this->parentHasEnrollment((int)$parentId, (string)$schoolYear)) { + $parentsForInvoice[(int)$parentId] = true; + } + + foreach (array_keys($parentsForInvoice) as $pid) { + $this->invoiceController->generateInvoice((string)$pid, (string)$schoolYear, (string)$semester, false); + $this->fixInvoiceStatusAfterCharge((int)$pid, (string)$schoolYear); + } return redirect()->back()->with('success', 'Event charges updated successfully.'); } @@ -403,7 +530,9 @@ class EventController extends ResourceController } $this->eventChargesModel->delete($chargeId); - $this->invoiceController->generateInvoice($charge['parent_id']); + if (!empty($charge['parent_id']) && $this->parentHasEnrollment((int)$charge['parent_id'], (string)($charge['school_year'] ?? ''))) { + $this->invoiceController->generateInvoice((string)$charge['parent_id'], (string)($charge['school_year'] ?? $this->schoolYear), (string)($charge['semester'] ?? $this->semester), false); + } return redirect()->back()->with('success', 'Participation removed, invoices updated.'); } @@ -414,42 +543,209 @@ class EventController extends ResourceController return redirect()->back()->with('error', 'Invalid charge.'); } - $charge = $this->eventChargesModel->find($chargeId); - if (!$charge) { + $isPaid = $this->request->getPost('paid') === '1'; + $meta = $this->applyEventPaymentStatus((int)$chargeId, $isPaid); + if (!$meta) { return redirect()->back()->with('error', 'Charge not found.'); } - $isPaid = $this->request->getPost('paid') === '1'; + if (!empty($meta['invoice_id'])) { + $this->invoiceController->generateInvoice((string)$meta['parent_id'], (string)$meta['school_year'], (string)($meta['semester'] ?? $this->semester), false); + $this->fixInvoiceStatusAfterCharge((int)$meta['parent_id'], (string)$meta['school_year']); + } + + return redirect()->back()->with('success', 'Event payment status updated.'); + } + + private function parentHasEnrollment(int $parentId, string $schoolYear): bool + { + if ($parentId <= 0 || $schoolYear === '') { + return false; + } + + try { + return $this->enrollmentModel + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->countAllResults() > 0; + } catch (\Throwable $e) { + return false; + } + } + + private function findExistingParentIdByExternalContact(string $first, string $last, string $email, string $phone): ?int + { + $first = trim($first); + $last = trim($last); + $email = trim($email); + $phoneDigits = preg_replace('/\D+/', '', (string)$phone); + + if ($first === '' || $last === '' || $email === '' || $phoneDigits === '') { + return null; + } + + try { + $db = Database::connect(); + $expr = "REPLACE(REPLACE(REPLACE(REPLACE(cellphone, '(', ''), ')', ''), '-', ''), ' ', '') ="; + + $row = $this->userModel + ->select('id') + ->where('LOWER(firstname)', strtolower($first)) + ->where('LOWER(lastname)', strtolower($last)) + ->where('LOWER(email)', strtolower($email)) + ->where($expr, $db->escapeString($phoneDigits), false) + ->first(); + + $id = (int)($row['id'] ?? 0); + return $id > 0 ? $id : null; + } catch (\Throwable $e) { + log_message('error', 'Failed to match external parent to users: ' . $e->getMessage()); + return null; + } + } + + private function syncInvoicePaymentSummary(int $invoiceId): void + { + if ($invoiceId <= 0) { + return; + } + + $invoice = $this->invoiceModel->find($invoiceId); + if (!$invoice) { + return; + } + + $db = \Config\Database::connect(); + $exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled']; + + $paidSum = 0.0; + try { + $qb = $db->table('payments') + ->select('COALESCE(SUM(paid_amount),0) AS tot') + ->where('invoice_id', $invoiceId) + ->where('paid_amount >', 0); + + if ($db->fieldExists('status', 'payments')) { + $qb->groupStart() + ->whereNotIn('status', $exclude) + ->orWhere('status IS NULL', null, false) + ->groupEnd(); + } + if ($db->fieldExists('is_void', 'payments')) { + $qb->groupStart() + ->where('is_void', 0) + ->orWhere('is_void IS NULL', null, false) + ->groupEnd(); + } + + $row = $qb->get()->getRowArray(); + $paidSum = (float)($row['tot'] ?? 0.0); + } catch (\Throwable $e) { + log_message('error', 'Failed to sum payments for invoice ' . $invoiceId . ': ' . $e->getMessage()); + } + + $discountSum = 0.0; + try { + $row = $db->table('discount_usages') + ->select('COALESCE(SUM(discount_amount),0) AS tot') + ->where('invoice_id', $invoiceId) + ->get() + ->getRowArray(); + $discountSum = (float)($row['tot'] ?? 0.0); + } catch (\Throwable $e) { + log_message('error', 'Failed to sum discounts for invoice ' . $invoiceId . ': ' . $e->getMessage()); + } + + $refundSum = 0.0; + try { + $row = $db->table('refunds') + ->select('COALESCE(SUM(refund_paid_amount),0) AS tot') + ->where('invoice_id', $invoiceId) + ->whereIn('status', ['Partial', 'Paid']) + ->get() + ->getRowArray(); + $refundSum = (float)($row['tot'] ?? 0.0); + } catch (\Throwable $e) { + log_message('error', 'Failed to sum refunds for invoice ' . $invoiceId . ': ' . $e->getMessage()); + } + + $total = (float)($invoice['total_amount'] ?? 0.0); + $newBalance = round($total - $discountSum - $refundSum - $paidSum, 2); + $newStatus = ($newBalance <= 0.00001) + ? 'Paid' + : (($paidSum > 0) ? 'Partially Paid' : 'Unpaid'); + + $this->invoiceModel->update($invoiceId, [ + 'paid_amount' => $paidSum, + 'balance' => $newBalance, + 'status' => $newStatus, + 'updated_at' => utc_now(), + ]); + } + + private function applyEventPaymentStatus(int $chargeId, bool $isPaid): ?array + { + if ($chargeId <= 0) { + return null; + } + + $charge = $this->eventChargesModel->find($chargeId); + if (!$charge) { + return null; + } + $event = $this->eventModel->find($charge['event_id']); $eventAmount = max(0.0, (float)($event['amount'] ?? 0)); $paymentId = (int)($charge['event_payment_id'] ?? 0); - $parentId = $charge['parent_id']; - $schoolYear = $charge['school_year'] ?? $this->schoolYear; + $parentId = (int)($charge['parent_id'] ?? 0); + $schoolYear = (string)($charge['school_year'] ?? $this->schoolYear); + $semester = (string)($charge['semester'] ?? $this->semester); - if (!empty($eventAmount)) { - $invoice = $this->invoiceModel + if ($parentId > 0 && $eventAmount > 0) { + $hasEnrollment = $this->parentHasEnrollment($parentId, $schoolYear); + $invoiceQuery = $this->invoiceModel ->where('parent_id', $parentId) ->where('school_year', $schoolYear) - ->orderBy('id', 'DESC') - ->first(); + ->orderBy('id', 'DESC'); + $invoice = $invoiceQuery->first(); - if (!$invoice) { - $this->invoiceController->generateInvoice($parentId); - $invoice = $this->invoiceModel - ->where('parent_id', $parentId) - ->where('school_year', $schoolYear) - ->orderBy('id', 'DESC') - ->first(); + if (!$invoice && $hasEnrollment) { + $this->invoiceController->generateInvoice((string)$parentId, $schoolYear, $semester, false); + $invoice = $invoiceQuery->first(); } if ($invoice) { + $invoiceId = (int)($invoice['id'] ?? 0); + if ($invoiceId > 0) { + $this->syncInvoicePaymentSummary($invoiceId); + $invoice = $this->invoiceModel->find($invoiceId) ?? $invoice; + } + if ($isPaid && !$paymentId) { - $paymentId = $this->createEventPayment($parentId, (int)$invoice['id'], $eventAmount, $schoolYear, $charge['semester'] ?? $this->semester); + $paymentSchoolYear = (string)($invoice['school_year'] ?? $schoolYear); + $paymentSemester = (string)($invoice['semester'] ?? $semester); + $paymentId = (int)($this->createEventPayment( + $parentId, + (int)$invoice['id'], + $eventAmount, + $paymentSchoolYear, + $paymentSemester, + (float)($invoice['total_amount'] ?? 0.0), + (float)($invoice['paid_amount'] ?? 0.0), + (float)($invoice['balance'] ?? 0.0) + ) ?? 0); } elseif (!$isPaid && $paymentId > 0) { $this->paymentModel->delete($paymentId); $paymentId = 0; } + + if (!empty($invoiceId)) { + $this->syncInvoicePaymentSummary($invoiceId); + } + } elseif (!$hasEnrollment) { + // External-only parent: do not create an invoice/payment row; just persist the paid flag on the charge. + $paymentId = 0; } } @@ -459,10 +755,12 @@ class EventController extends ResourceController 'event_payment_id' => $paymentId > 0 ? $paymentId : null, ]); - $this->invoiceController->generateInvoice($parentId); - $this->fixInvoiceStatusAfterCharge($parentId, $schoolYear); - - return redirect()->back()->with('success', 'Event payment status updated.'); + return [ + 'parent_id' => $parentId, + 'school_year' => $schoolYear, + 'semester' => $semester, + 'invoice_id' => isset($invoiceId) && $invoiceId > 0 ? $invoiceId : null, + ]; } @@ -506,25 +804,48 @@ class EventController extends ResourceController return $this->response->setJSON($data); } - private function createEventPayment(int $parentId, int $invoiceId, float $amount, string $schoolYear, ?string $semester): ?int + private function createEventPayment( + int $parentId, + int $invoiceId, + float $amount, + string $schoolYear, + ?string $semester, + float $invoiceTotal = 0.0, + float $invoicePaid = 0.0, + float $invoiceBalance = 0.0 + ): ?int { if ($amount <= 0 || $invoiceId <= 0 || $parentId <= 0) { return null; } $monthSemester = $semester ?: $this->semester; + $newPaid = (float)$invoicePaid + $amount; + $newBalance = (float)$invoiceBalance - $amount; + $paymentStatus = ($newBalance <= 0.00001) + ? 'Paid' + : (($newPaid > 0) ? 'Partially Paid' : 'Unpaid'); + + $exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled']; + $priorCount = $this->paymentModel + ->where('invoice_id', $invoiceId) + ->where('paid_amount >', 0) + ->whereNotIn('status', $exclude) + ->countAllResults(); + $installmentSeq = $priorCount + 1; + $data = [ 'parent_id' => $parentId, 'invoice_id' => $invoiceId, - 'total_amount' => $amount, + 'total_amount' => $invoiceTotal > 0 ? $invoiceTotal : $amount, 'paid_amount' => $amount, - 'balance' => 0.0, - 'number_of_installments' => 1, - 'payment_method' => 'Cash', + 'balance' => $newBalance, + 'number_of_installments' => $installmentSeq, + 'payment_method' => 'cash', 'payment_date' => utc_now(), 'school_year' => $schoolYear, 'semester' => $monthSemester, - 'status' => 'Paid', + 'status' => $paymentStatus, 'transaction_id' => $this->paymentModel->generateNewTransactionId(), 'updated_by' => session()->get('user_id'), ]; @@ -537,6 +858,23 @@ class EventController extends ResourceController return (int)$this->paymentModel->getInsertID(); } + private function normalizeExternalName(?string $value): string + { + $raw = trim((string)$value); + if ($raw === '') { + return ''; + } + $parts = preg_split('/\s+/', $raw); + $formatted = array_map(function ($part) { + $part = trim($part); + if ($part === '') { + return ''; + } + return mb_strtoupper(mb_substr($part, 0, 1)) . mb_strtolower(mb_substr($part, 1)); + }, $parts); + return implode(' ', array_filter($formatted, fn($p) => $p !== '')); + } + private function fixInvoiceStatusAfterCharge($parentId, $schoolYear) { if (!$parentId || !$schoolYear) { diff --git a/app/Controllers/View/FinancialController.php b/app/Controllers/View/FinancialController.php index d537820..43c2939 100644 --- a/app/Controllers/View/FinancialController.php +++ b/app/Controllers/View/FinancialController.php @@ -68,17 +68,45 @@ public function financialReport() } // === Invoices === - $invoices = $invoiceModel - ->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name") - ->join('users', 'users.id = invoices.parent_id') - ->findAll(); + // Business rule: one active invoice per parent per school year. + // If legacy data has multiple invoices, show only the latest one to avoid reporting older invoices as "Unpaid" + // after new charges/payments update the current invoice. + $db = \Config\Database::connect(); + if (!empty($schoolYear)) { + $latestSub = $db->table('invoices') + ->select('parent_id, MAX(id) AS max_id') + ->where('school_year', $schoolYear) + ->groupBy('parent_id') + ->getCompiledSelect(); + + $invBuilder = $db->table('invoices') + ->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name", false) + ->join("($latestSub) latest", 'latest.max_id = invoices.id', 'inner', false) + ->join('users', 'users.id = invoices.parent_id', 'left'); + + if ($hasFrom) { + $invBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) >=', $dateFrom); + } + if ($hasTo) { + $invBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $dateTo); + } + + $invoices = $invBuilder->get()->getResultArray(); + } else { + $invoices = $invoiceModel + ->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name") + ->join('users', 'users.id = invoices.parent_id') + ->findAll(); + } + + $invoiceIds = array_values(array_unique(array_filter(array_map(static function ($inv) { + $id = (int)($inv['id'] ?? 0); + return $id > 0 ? $id : null; + }, $invoices)))); // Helper to build a fresh, filtered PaymentModel each time (so filters don't get lost between queries) $buildPaymentModel = function () use ($schoolYear, $dateFrom, $dateTo) { $pm = new PaymentModel(); - if ($schoolYear) { - $pm->where('school_year', $schoolYear); - } if (!empty($dateFrom)) { $pm->where('DATE(payment_date) >=', $dateFrom); } @@ -114,7 +142,13 @@ public function financialReport() }; // === Payments aggregated by invoice_id (for the "Paid" column) === - $payments = $applyPaymentFilters($buildPaymentModel()) + $paymentsQuery = $applyPaymentFilters($buildPaymentModel()); + if (!empty($invoiceIds)) { + $paymentsQuery->whereIn('invoice_id', $invoiceIds); + } else { + $paymentsQuery->where('invoice_id', -1); + } + $payments = $paymentsQuery ->select('invoice_id, SUM(paid_amount) AS paid_amount') ->where('invoice_id IS NOT NULL') ->groupBy('invoice_id') @@ -138,6 +172,7 @@ public function financialReport() SUM(paid_amount) AS amount ") ->where('invoice_id IS NOT NULL') + ->whereIn('invoice_id', $invoiceIds ?: [-1]) ->groupBy('invoice_id, method') ->findAll(); @@ -161,6 +196,7 @@ public function financialReport() SUM(CASE WHEN LOWER(TRIM(payment_method)) IN ('credit','card','credit card','debit','debit card','visa','mastercard') THEN paid_amount ELSE 0 END) AS total_credit ") ->where('invoice_id IS NOT NULL') + ->whereIn('invoice_id', $invoiceIds ?: [-1]) ->first() ?? []; $paymentTotals = [ diff --git a/app/Controllers/View/InvoiceController.php b/app/Controllers/View/InvoiceController.php index 04232d4..077571c 100644 --- a/app/Controllers/View/InvoiceController.php +++ b/app/Controllers/View/InvoiceController.php @@ -381,18 +381,24 @@ class InvoiceController extends ResourceController return false; } - public function generateInvoice(string $parentId = null) + 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) $this->schoolYear; + $schoolYear = (string) ($schoolYearOverride ?: $this->schoolYear); + $semester = (string) ($semesterOverride ?: $this->semester); // Fetch enrolled + withdrawn students $enrollments = $this->enrollmentModel ->where('parent_id', $parentId) - ->where('school_year', $this->schoolYear) + ->where('school_year', $schoolYear) ->findAll(); if (empty($enrollments)) { @@ -444,21 +450,23 @@ class InvoiceController extends ResourceController $tuitionFee = $fees['tuition_fee']; // ✅ Fetch event charges - $eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $this->schoolYear); + $eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear); $eventchargeTotal = array_sum(array_column($eventsList, 'charged')); - $totalDiscount = $this->recalculateAndUpdateDiscount( - $parentId, - $this->schoolYear, - $tuitionFee, - $enrollments - ); + $totalDiscount = 0.0; + if ($recalculateDiscounts) { + $totalDiscount = $this->recalculateAndUpdateDiscount( + $parentId, + $schoolYear, + $tuitionFee, + $enrollments + ); + } - $semester = $this->semester; // ✅ Refunds PAID to the parent for this year (Partial/Paid) - $refundPaid = (float) $this->refundModel->getTotalApprovedRefundByParentIdAndSchoolYear($parentId, $this->schoolYear); + $refundPaid = (float) $this->refundModel->getTotalApprovedRefundByParentIdAndSchoolYear($parentId, $schoolYear); - $totalPaid = $this->paymentModel->getTotalPaidByParentId($parentId, $this->schoolYear); + $totalPaid = $this->paymentModel->getTotalPaidByParentId($parentId, $schoolYear); $discountedTuition = max(0, $tuitionFee); $totalAmount = $discountedTuition + $eventchargeTotal; @@ -469,17 +477,22 @@ class InvoiceController extends ResourceController - $refundPaid // approved refunds paid to parent - $totalPaid; // payments received - // Your invoice fetch - $existingInvoices = $this->invoiceModel->getInvoicesByParentId($parentId, $this->schoolYear); + // 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($existingInvoices)) { - foreach ($existingInvoices as $invoice) { - if (!isset($invoice['id'])) { - continue; - } + 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; @@ -487,7 +500,7 @@ class InvoiceController extends ResourceController $rows = $this->db->table('additional_charges') ->select('charge_type, amount') ->where('invoice_id', (int)$invoice['id']) - ->where('school_year', $this->schoolYear) + ->where('school_year', $schoolYear) ->where('status', 'applied') ->get()->getResultArray(); foreach ($rows as $r) { @@ -520,7 +533,7 @@ class InvoiceController extends ResourceController $r = $this->db->table('refunds') ->select('COALESCE(SUM(refund_paid_amount),0) AS tot') ->where('invoice_id', (int)$invoice['id']) - ->where('school_year', $this->schoolYear) + ->where('school_year', $schoolYear) ->whereIn('status', ['Partial','Paid']) ->get()->getRowArray(); $invRefunds = (float)($r['tot'] ?? 0.0); @@ -528,8 +541,33 @@ class InvoiceController extends ResourceController log_message('error', 'refund sum failed for invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage()); } - // Payments recorded on this invoice (denormalized field kept in sync by PaymentController) - $paidOnInv = (float)($invoice['paid_amount'] ?? 0.0); + // 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) @@ -547,7 +585,6 @@ class InvoiceController extends ResourceController $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); @@ -578,7 +615,7 @@ class InvoiceController extends ResourceController // Initial balance equals the created total; discounts/refunds/payments will adjust later 'balance' => $totalAmount, 'status' => 'Unpaid', - 'school_year' => $this->schoolYear, + 'school_year' => $schoolYear, 'semester' => $semester, 'issue_date' => $issueUtc, 'due_date' => $dueUtc, @@ -615,6 +652,47 @@ class InvoiceController extends ResourceController ->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, @@ -1266,6 +1344,12 @@ class InvoiceController extends ResourceController } } } + 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); diff --git a/app/Database/Migrations/2026-04-13-000004_AddExternalParticipantFieldsToEventCharges.php b/app/Database/Migrations/2026-04-13-000004_AddExternalParticipantFieldsToEventCharges.php new file mode 100644 index 0000000..14e346e --- /dev/null +++ b/app/Database/Migrations/2026-04-13-000004_AddExternalParticipantFieldsToEventCharges.php @@ -0,0 +1,43 @@ + [ + 'type' => 'VARCHAR', + 'constraint' => 150, + 'null' => true, + 'after' => 'event_payment_id', + ], + 'external_lastname' => [ + 'type' => 'VARCHAR', + 'constraint' => 150, + 'null' => true, + ], + 'external_note' => [ + 'type' => 'VARCHAR', + 'constraint' => 254, + 'null' => true, + ], + ]; + + if (! $this->db->fieldExists('external_firstname', 'event_charges')) { + $this->forge->addColumn('event_charges', $fields); + } + } + + public function down() + { + foreach (['external_firstname', 'external_lastname', 'external_note'] as $field) { + if ($this->db->fieldExists($field, 'event_charges')) { + $this->forge->dropColumn('event_charges', $field); + } + } + } +} diff --git a/app/Database/Migrations/2026-04-13-000005_AddExternalParentInfoToEventCharges.php b/app/Database/Migrations/2026-04-13-000005_AddExternalParentInfoToEventCharges.php new file mode 100644 index 0000000..6341d01 --- /dev/null +++ b/app/Database/Migrations/2026-04-13-000005_AddExternalParentInfoToEventCharges.php @@ -0,0 +1,43 @@ + [ + 'type' => 'VARCHAR', + 'constraint' => 150, + 'null' => true, + 'after' => 'external_note', + ], + 'external_parent_lastname' => [ + 'type' => 'VARCHAR', + 'constraint' => 150, + 'null' => true, + ], + 'external_parent_phone' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + 'null' => true, + ], + ]; + + if (! $this->db->fieldExists('external_parent_firstname', 'event_charges')) { + $this->forge->addColumn('event_charges', $fields); + } + } + + public function down() + { + foreach (['external_parent_firstname', 'external_parent_lastname', 'external_parent_phone'] as $field) { + if ($this->db->fieldExists($field, 'event_charges')) { + $this->forge->dropColumn('event_charges', $field); + } + } + } +} diff --git a/app/Database/Migrations/2026-04-17-000001_AddExternalParentEmailToEventCharges.php b/app/Database/Migrations/2026-04-17-000001_AddExternalParentEmailToEventCharges.php new file mode 100644 index 0000000..0e09fca --- /dev/null +++ b/app/Database/Migrations/2026-04-17-000001_AddExternalParentEmailToEventCharges.php @@ -0,0 +1,32 @@ + [ + 'type' => 'VARCHAR', + 'constraint' => 254, + 'null' => true, + 'after' => 'external_parent_phone', + ], + ]; + + if (! $this->db->fieldExists('external_parent_email', 'event_charges')) { + $this->forge->addColumn('event_charges', $fields); + } + } + + public function down() + { + if ($this->db->fieldExists('external_parent_email', 'event_charges')) { + $this->forge->dropColumn('event_charges', 'external_parent_email'); + } + } +} + diff --git a/app/Models/EventChargesModel.php b/app/Models/EventChargesModel.php index aadd00d..426d641 100644 --- a/app/Models/EventChargesModel.php +++ b/app/Models/EventChargesModel.php @@ -18,8 +18,16 @@ class EventChargesModel extends Model 'event_paid', 'event_payment_id', 'class_section_id', + 'external_firstname', + 'external_lastname', + 'external_note', + 'external_parent_firstname', + 'external_parent_lastname', + 'external_parent_phone', + 'external_parent_email', 'semester', 'school_year', + 'created_by', 'updated_by', 'created_at', 'updated_at' diff --git a/app/Views/administrator/events/event_charges.php b/app/Views/administrator/events/event_charges.php index c28c292..db72985 100644 --- a/app/Views/administrator/events/event_charges.php +++ b/app/Views/administrator/events/event_charges.php @@ -73,13 +73,15 @@