eventChargesModel = new EventChargesModel(); //eventChargesModel $this->studentModel = new StudentModel(); $this->configModel = new ConfigurationModel(); $this->userModel = new UserModel(); // Add this $this->eventModel = new EventModel(); $this->invoiceController = new InvoiceController(); $this->invoiceModel = new InvoiceModel(); $this->invoiceEventModel = new InvoiceEventModel(); $this->studentClassModel = new StudentClassModel(); $this->classSectionModel = new ClassSectionModel(); $this->paymentModel = new PaymentModel(); $this->enrollmentModel = new EnrollmentModel(); $this->parentModel = new ParentModel(); $this->emailService = new EmailService(); $this->schoolYear = $this->configModel->getConfig('school_year'); $this->semester = $this->configModel->getConfig('semester'); $this->categories = [ 'workshops', 'orientations', 'field trips', 'Ramadan programs', ]; } 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(); $today = local_date(utc_now(), 'Y-m-d'); // Fetch all events $events = $eventModel ->orderBy('created_at', 'DESC') ->findAll(); // Fetch active events (not expired) $activeEventCount = $eventModel ->where('expiration_date >=', $today) ->countAllResults(); return view('administrator/events/event_list', [ 'events' => $events, 'activeEventCount' => $activeEventCount ]); } public function create() { helper(['form']); if (strtolower($this->request->getMethod()) === 'post') { $file = $this->request->getFile('flyer'); $flyerPath = null; if ($file && $file->isValid() && !$file->hasMoved()) { // Move to public/uploads/event_flyers $newName = $file->getRandomName(); $file->move(FCPATH . 'uploads/event_flyers', $newName); $flyerPath = 'event_flyers/' . $newName; // store relative path } $eventId = $this->eventModel->insert([ 'event_name' => $this->request->getPost('event_name'), 'event_category' => $this->request->getPost('event_category'), 'description' => $this->request->getPost('description'), 'amount' => $this->request->getPost('amount'), 'flyer' => $flyerPath, 'expiration_date' => $this->request->getPost('expiration_date'), 'semester' => $this->request->getPost('semester'), 'school_year' => $this->request->getPost('school_year'), 'created_by' => session()->get('user_id'), ]); if ($eventId && $this->request->getPost('add_to_calendar')) { $calendarModel = new CalendarModel(); $title = trim((string)$this->request->getPost('event_name')); $date = (string)$this->request->getPost('expiration_date'); $schoolYear = (string)$this->request->getPost('school_year'); $semester = (string)$this->request->getPost('semester'); if ($title !== '' && $date !== '' && $schoolYear !== '') { $data = [ 'title' => $title, 'description' => (string)$this->request->getPost('description'), 'event_type' => 'Event', 'date' => $date, 'notify_parent' => $this->request->getPost('notify_parent') ? 1 : 0, 'notify_teacher' => $this->request->getPost('notify_teacher') ? 1 : 0, 'notify_admin' => $this->request->getPost('notify_admin') ? 1 : 0, 'no_school' => 0, 'school_year' => $schoolYear, 'semester' => $semester ?: $this->semester, ]; if (!$calendarModel->supportsEventType()) { unset($data['event_type']); } $dupQuery = $calendarModel ->where('school_year', $data['school_year']) ->where('date', $data['date']) ->where('title', $data['title']); if ($calendarModel->supportsEventType() && isset($data['event_type'])) { $dupQuery = $dupQuery->where('event_type', $data['event_type']); } $existing = $dupQuery->first(); if (!$existing) { $calendarModel->save($data); } } } $redirect = redirect()->to('/administrator/events')->with('success', 'Event created successfully'); if ($eventId && $this->request->getPost('send_email_parent')) { $emailStatus = $this->broadcastEventToParents((int) $eventId); $redirect = $this->applyEventEmailFlash($redirect, $emailStatus); } return $redirect; } return view('administrator/events/create_event', [ 'categories' => $this->categories, ]); } public function edit($id = null) { helper(['form']); $event = $this->eventModel->find($id); if (!$event) { return redirect()->to('/administrator/events')->with('error', 'Event not found'); } if (strtolower($this->request->getMethod()) === 'post') { log_message('debug', 'POST detected'); $file = $this->request->getFile('flyer'); $flyerPath = $event['flyer']; // Default: keep old flyer if ($file && $file->isValid() && !$file->hasMoved()) { $newName = $file->getRandomName(); $file->move(FCPATH . 'uploads/event_flyers', $newName); $flyerPath = 'event_flyers/' . $newName; // store relative path } $updated = $this->eventModel->update($id, [ 'event_name' => $this->request->getPost('event_name'), 'event_category' => $this->request->getPost('event_category'), 'description' => $this->request->getPost('description'), 'amount' => $this->request->getPost('amount'), 'flyer' => $flyerPath, 'expiration_date' => $this->request->getPost('expiration_date'), 'semester' => $this->request->getPost('semester'), 'school_year' => $this->request->getPost('school_year'), ]); if ($updated) { $redirect = redirect()->to('/administrator/events')->with('success', 'Event updated successfully'); if ($this->request->getPost('send_email_parent')) { $emailStatus = $this->broadcastEventToParents((int) $id); $redirect = $this->applyEventEmailFlash($redirect, $emailStatus); } return $redirect; } else { log_message('debug', 'GET detected'); return redirect()->back()->with('error', 'Failed to update event'); } } return view('administrator/events/edit_event', [ 'event' => $event, 'categories' => $this->categories, ]); } private function broadcastEventToParents(int $eventId): array { $event = $this->eventModel->find($eventId); if (!$event) { return ['ok' => false, 'count' => 0, 'message' => 'Event email was skipped because the event could not be loaded.']; } $emails = $this->collectParentBroadcastEmails((string) ($event['school_year'] ?? '')); if ($emails === []) { return ['ok' => false, 'count' => 0, 'message' => 'Event email was skipped because no parent email addresses were found.']; } $subject = 'School Event: ' . trim((string) ($event['event_name'] ?? 'Upcoming Event')); $innerBody = view('emails/event_broadcast', [ 'event' => $event, 'eventUrl' => base_url('parent/events'), 'flyerUrl' => !empty($event['flyer']) ? base_url('uploads/' . ltrim((string) $event['flyer'], '/')) : null, ]); $body = view('emails/_wrap_layout', [ 'title' => $subject, 'subject' => $subject, 'body_html' => $innerBody, ]); $ok = $this->emailService->sendCc($emails, $subject, $body, 'general'); $batchCount = $this->emailService->getLastCcBatchCount(); return [ 'ok' => $ok, 'count' => count($emails), 'batches' => $batchCount, 'message' => $ok ? 'Event email was sent in ' . $batchCount . ' CC batch(es) for ' . count($emails) . ' recipient(s).' : 'The event was saved, but sending the parent CC email failed.', ]; } private function collectParentBroadcastEmails(string $schoolYear = ''): array { $emails = []; $db = Database::connect(); $parentRole = $db->table('roles') ->select('id') ->where('LOWER(name)', 'parent') ->get() ->getRow(); if ($parentRole) { $firstParentQuery = $db->table('users') ->select('users.email') ->join('user_roles', 'user_roles.user_id = users.id') ->where('user_roles.role_id', (int) $parentRole->id) ->where('users.email IS NOT NULL', null, false) ->where('users.email !=', ''); if ($schoolYear !== '' && $db->fieldExists('school_year', 'users')) { $firstParentQuery->where('users.school_year', $schoolYear); } foreach ($firstParentQuery->get()->getResultArray() as $row) { $email = trim((string) ($row['email'] ?? '')); if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { $emails[strtolower($email)] = $email; } } } $secondParentQuery = $db->table('parents') ->select('secondparent_email') ->where('secondparent_email IS NOT NULL', null, false) ->where('secondparent_email !=', ''); if ($schoolYear !== '' && $db->fieldExists('school_year', 'parents')) { $secondParentQuery->where('school_year', $schoolYear); } foreach ($secondParentQuery->get()->getResultArray() as $row) { $email = trim((string) ($row['secondparent_email'] ?? '')); if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { $emails[strtolower($email)] = $email; } } return array_values($emails); } private function applyEventEmailFlash($redirect, array $emailStatus) { if (!empty($emailStatus['message'])) { return $redirect->with( ($emailStatus['ok'] ?? false) ? 'email_status' : 'email_error', $emailStatus['message'] ); } return $redirect; } public function delete($id = null) { $event = $this->eventModel->find($id); if (!$event) { return redirect()->to('/administrator/events')->with('error', 'Event not found'); } // Delete related charges and collect parent IDs $charges = $this->eventChargesModel->where('event_id', $id)->findAll(); $parentIds = []; foreach ($charges as $charge) { $this->eventChargesModel->delete($charge['id']); $parentIds[] = $charge['parent_id']; } // Also remove any invoice_event rows tied to this event $eventName = $event['event_name'] ?? null; if ($eventName) { $builder = $this->invoiceEventModel->where('event_name', $eventName); if (!empty($event['school_year'])) { $builder->where('school_year', $event['school_year']); } if (!empty($event['semester'])) { $builder->where('semester', $event['semester']); } $builder->delete(); } // Delete event $this->eventModel->delete($id); $parentIds = array_unique($parentIds); foreach ($parentIds as $parentId) { $this->invoiceController->generateInvoice($parentId); } return redirect()->to('/administrator/events')->with('success', 'Event, charges, and invoices updated.'); } // Optionally keep your eventShow / eventUpdate for legacy administrator event charges public function eventShow() { $schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear; $semester = $this->request->getGet('semester') ?? $this->semester; $parents = $this->userModel->getParents(); $events = $this->eventModel->getActiveEvents($schoolYear); $filterEventId = (int) ($this->request->getGet('event_id') ?? 0); $filterParentId = (int) ($this->request->getGet('parent_id') ?? 0); $chargesBuilder = $this->eventChargesModel ->select('event_charges.*, users.firstname AS parent_firstname, users.lastname AS parent_lastname, students.firstname AS student_firstname, students.lastname AS student_lastname, events.event_name, events.description AS event_description, events.amount AS event_amount') ->join('users', 'users.id = event_charges.parent_id', 'left') ->join('students', 'students.id = event_charges.student_id', 'left') ->join('events', 'events.id = event_charges.event_id', 'left') ->where('event_charges.school_year', $schoolYear) ->where('event_charges.semester', $semester); if ($filterEventId > 0) { $chargesBuilder->where('event_charges.event_id', $filterEventId); } $charges = $chargesBuilder ->orderBy('event_charges.created_at', 'DESC') ->findAll(); foreach ($charges as &$charge) { if (empty($charge['class_section_id']) && !empty($charge['student_id'])) { $sections = $this->studentClassModel->getClassSectionIdsByStudentId( (int)$charge['student_id'], $schoolYear ); if (!empty($sections)) { $charge['class_section_id'] = $sections[0]; } } } unset($charge); $parentBalances = []; try { $balanceRows = $this->invoiceModel ->select('parent_id, COALESCE(SUM(balance),0) AS total_balance') ->where('school_year', $schoolYear) ->where('semester', $semester) ->groupBy('parent_id') ->findAll(); foreach ($balanceRows as $row) { $parentBalances[(int)($row['parent_id'] ?? 0)] = (float)($row['total_balance'] ?? 0.0); } } catch (\Throwable $e) { log_message('error', 'Failed to load parent balances: ' . $e->getMessage()); } $sectionIds = array_unique(array_filter(array_column($charges, 'class_section_id'))); $classSectionNames = []; if (!empty($sectionIds)) { $sections = $this->classSectionModel ->select('class_section_id, class_section_name') ->whereIn('class_section_id', $sectionIds) ->findAll(); foreach ($sections as $section) { $classSectionNames[(int)($section['class_section_id'] ?? 0)] = $section['class_section_name'] ?? ''; } } $semesterOptions = (new EventChargesModel()) ->select('semester') ->distinct() ->orderBy('semester', 'ASC') ->findColumn('semester'); $semesterOptions = array_values(array_filter(array_unique($semesterOptions ?? []))); if (empty($semesterOptions)) { $semesterOptions = [$this->semester]; } $schoolYearOptions = (new EventChargesModel()) ->select('school_year') ->distinct() ->orderBy('school_year', 'ASC') ->findColumn('school_year'); $schoolYearOptions = array_values(array_filter(array_unique($schoolYearOptions ?? []))); if (empty($schoolYearOptions)) { $schoolYearOptions = [$this->schoolYear]; } return view('administrator/events/event_charges', [ 'charges' => $charges, 'parents' => $parents, 'events' => $events, 'school_year' => $schoolYear, 'semester' => $semester, 'filterEventId' => $filterEventId, 'filterParentId' => $filterParentId, 'parentBalances' => $parentBalances, 'classSectionNames' => $classSectionNames, 'semesterOptions' => $semesterOptions, 'schoolYearOptions' => $schoolYearOptions, ]); } public function eventUpdate() { $schoolYear = $this->request->getPost('school_year') ?? $this->schoolYear; $semester = $this->request->getPost('semester') ?? $this->semester; $parentId = $this->request->getPost('parent_id'); $eventId = $this->request->getPost('event_id'); $participations = $this->request->getPost('participation') ?? []; $externalParticipants = $this->request->getPost('external_participants') ?? []; // 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([ 'parent_id' => $parentId, 'student_id' => $studentId, 'event_id' => $eventId, 'school_year' => $schoolYear, 'semester' => $semester ])->first(); if ($value === 'no') { if ($existing) { $this->eventChargesModel->delete($existing['id']); } continue; } $sectionIds = $this->studentClassModel->getClassSectionIdsByStudentId($studentId, $schoolYear); $classSectionId = !empty($sectionIds) ? $sectionIds[0] : null; if ($existing) { $updateData = [ 'participation' => 'yes', 'updated_by' => $userId, 'class_section_id'=> $classSectionId, ]; if (isset($event['amount']) && ((float)$event['amount'] !== (float)($existing['charged'] ?? 0))) { $updateData['charged'] = $event['amount']; } $this->eventChargesModel->update($existing['id'], $updateData); continue; } else { $insertData = [ 'parent_id' => $parentId, 'student_id' => $studentId, 'event_id' => $eventId, 'participation' => 'yes', 'charged' => $event['amount'], 'event_paid' => 0, 'class_section_id' => $classSectionId, 'school_year' => $schoolYear, 'semester' => $semester, 'updated_by' => $userId ]; if ($supportsCreatedBy) { $insertData['created_by'] = $userId; } $this->eventChargesModel->insert($insertData); } } 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'; 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.'); } public function removeCharge($chargeId = null) { if (!$chargeId) { return redirect()->back()->with('error', 'Invalid charge.'); } $charge = $this->eventChargesModel->find($chargeId); if (!$charge) { return redirect()->back()->with('error', 'Charge not found.'); } $paymentId = (int)($charge['event_payment_id'] ?? 0); if ($paymentId > 0) { $this->paymentModel->delete($paymentId); } $this->eventChargesModel->delete($chargeId); 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.'); } public function toggleEventPayment($chargeId = null) { if (!$chargeId) { return redirect()->back()->with('error', 'Invalid charge.'); } $isPaid = $this->request->getPost('paid') === '1'; $meta = $this->applyEventPaymentStatus((int)$chargeId, $isPaid); if (!$meta) { return redirect()->back()->with('error', 'Charge not found.'); } 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 = (int)($charge['parent_id'] ?? 0); $schoolYear = (string)($charge['school_year'] ?? $this->schoolYear); $semester = (string)($charge['semester'] ?? $this->semester); if ($parentId > 0 && $eventAmount > 0) { $hasEnrollment = $this->parentHasEnrollment($parentId, $schoolYear); $invoiceQuery = $this->invoiceModel ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->orderBy('id', 'DESC'); $invoice = $invoiceQuery->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) { $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; } } $this->eventChargesModel->update($chargeId, [ 'event_paid' => $isPaid ? 1 : 0, 'charged' => $eventAmount, 'event_payment_id' => $paymentId > 0 ? $paymentId : null, ]); return [ 'parent_id' => $parentId, 'school_year' => $schoolYear, 'semester' => $semester, 'invoice_id' => isset($invoiceId) && $invoiceId > 0 ? $invoiceId : null, ]; } public function getStudentsWithCharges() { $parentId = $this->request->getGet('parent_id'); $semester = $this->request->getGet('semester'); $schoolYear = $this->request->getGet('school_year'); // Get students for parent $students = $this->studentModel->where('parent_id', $parentId)->findAll(); // Get student_ids that already have charges $eventId = $this->request->getGet('event_id'); $chargesBuilder = $this->eventChargesModel ->where('parent_id', $parentId) ->where('semester', $semester) ->where('school_year', $schoolYear); if (!empty($eventId)) { $chargesBuilder->where('event_id', $eventId); } $chargedStudentIds = $chargesBuilder ->groupBy('student_id') ->select('student_id') ->findColumn('student_id'); $data = []; foreach ($students as $student) { $data[] = [ 'id' => $student['id'], 'name' => $student['firstname'] . ' ' . $student['lastname'], 'charged' => in_array($student['id'], $chargedStudentIds ?? []), ]; } return $this->response->setJSON($data); } 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' => $invoiceTotal > 0 ? $invoiceTotal : $amount, 'paid_amount' => $amount, 'balance' => $newBalance, 'number_of_installments' => $installmentSeq, 'payment_method' => 'cash', 'payment_date' => utc_now(), 'school_year' => $schoolYear, 'semester' => $monthSemester, 'status' => $paymentStatus, 'transaction_id' => $this->paymentModel->generateNewTransactionId(), 'updated_by' => session()->get('user_id'), ]; if (!$this->paymentModel->insert($data)) { log_message('error', 'Failed to insert event payment: ' . json_encode($this->paymentModel->errors())); return null; } 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) { return; } $invoices = $this->invoiceModel ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->findAll(); foreach ($invoices as $invoice) { $status = strtolower(trim($invoice['status'] ?? '')); $balance = (float)($invoice['balance'] ?? 0.0); if ($balance <= 0.00001 && $status !== 'paid') { $this->invoiceModel->update($invoice['id'], ['status' => 'Paid']); } elseif ($status === 'paid' && $balance > 0) { $this->invoiceModel->update($invoice['id'], ['status' => 'Unpaid']); } } } }