db = \Config\Database::connect(); $this->configModel = new ConfigurationModel(); $this->additionalChargeModel = new AdditionalChargeModel(); $this->userModel = new UserModel(); $this->invoiceModel = new InvoiceModel(); $this->semester = $this->configModel->getConfig('semester'); $this->schoolYear = $this->configModel->getConfig('school_year'); $this->invoiceLedgerService = new InvoiceLedgerService(); } public function index() { // API-first: delegate to JSON list endpoint return $this->apiList(); } /** Render HTML management page */ public function page() { $parentId = (int)($this->request->getGet('parent_id') ?? 0); $status = $this->request->getGet('status') ?: null; $yearSelect = (string)($this->request->getGet('school_year') ?? $this->schoolYear); $rows = []; if ($parentId > 0) { $rows = $this->additionalChargeModel ->byParentTerm($parentId, $this->schoolYear, $this->semester, $status); } // Load ALL parents for current view $parents = $this->userModel->select('users.id, users.firstname, users.lastname') ->join('user_roles', 'user_roles.user_id = users.id') ->join('roles', 'roles.id = user_roles.role_id') ->where('LOWER(roles.name) =', 'parent') ->where('user_roles.deleted_at IS NULL', null, false) ->orderBy('users.lastname', 'ASC') ->orderBy('users.firstname', 'ASC') ->findAll(); // Map parent id -> "Lastname, Firstname" (no email/ID shown) $parentMap = []; foreach ($parents as $p) { $label = trim(($p['lastname'] ?? '') . ', ' . ($p['firstname'] ?? '')); $parentMap[(int)$p['id']] = $label !== '' && $label !== ',' ? $label : ('User #' . (int)$p['id']); } // 🔹 Get ALL invoices for ALL parents this year, group by parent_id $parentIds = array_map(fn($r) => (int)$r['id'], $parents); $invoicesByParent = []; if (!empty($parentIds)) { $all = $this->invoiceModel->getAllInvoicesByUserIds($parentIds, $yearSelect); foreach ($all as $inv) { $pid = (int)$inv['parent_id']; $invoicesByParent[$pid][] = [ 'id' => (int)$inv['id'], 'invoice_number' => (string)$inv['invoice_number'], 'status' => (string)($inv['status'] ?? ''), 'balance' => (float)($inv['balance'] ?? 0), 'issue_date' => $inv['issue_date'] ?? null, ]; } } // Label for preselect (if filtering by a parent) $selectedParentLabel = ($parentId > 0 && isset($parentMap[$parentId])) ? $parentMap[$parentId] : ''; // Optional filters $status = $this->request->getGet('status') ?: null; $q = trim((string)($this->request->getGet('q') ?? '')); // ✅ Always pull all charges for the selected year & current semester (all parents) $rows = $this->additionalChargeModel->listAllForTerm( $yearSelect, $this->semester, $status, $q, 50 // per-page ); $pager = $this->additionalChargeModel->pager; // Build school year options from data (additional_charges + invoices) $schoolYears = []; try { $q1 = $this->db->table('additional_charges')->select('DISTINCT school_year', false) ->where('school_year IS NOT NULL', null, false) ->orderBy('school_year', 'DESC')->get()->getResultArray(); foreach ($q1 as $r) { $val = (string)($r['school_year'] ?? ''); if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val; } } catch (\Throwable $e) {} try { $q2 = $this->db->table('invoices')->select('DISTINCT school_year', false) ->where('school_year IS NOT NULL', null, false) ->orderBy('school_year', 'DESC')->get()->getResultArray(); foreach ($q2 as $r) { $val = (string)($r['school_year'] ?? ''); if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val; } } catch (\Throwable $e) {} if (empty($schoolYears) && is_string($this->schoolYear) && $this->schoolYear !== '') { // fallback: generate recent years around configured schoolYear $schoolYears[] = $this->schoolYear; // Optionally add previous/next [$start, $end] = explode('-', $this->schoolYear) + [0 => date('Y'), 1 => date('Y')+1]; $start = (int)$start; for ($i = 1; $i <= 3; $i++) { $schoolYears[] = ($start - $i) . '-' . (($start - $i) + 1); } } rsort($schoolYears); return view('payment/extra_charges', [ 'q' => $q, 'pager' => $pager, 'rows' => $rows, 'parents' => $parents, 'parentMap' => $parentMap, 'invoicesByParent' => $invoicesByParent, // 🔹 pass the grouped map 'parentId' => $parentId, 'selectedParentLabel' => $selectedParentLabel, 'status' => $status, 'schoolYear' => $yearSelect, 'schoolYears' => $schoolYears, 'semester' => $this->semester, ]); } private function wantsJson(): bool { $accept = strtolower((string)($this->request->getHeaderLine('Accept') ?? '')); return $this->request->isAJAX() || str_contains($accept, 'application/json'); } /** Used by Select2 AJAX: returns {results:[{id,text}]} */ public function parentOptions() { $q = trim((string)($this->request->getGet('q') ?? '')); $limit = 20; $builder = $this->userModel ->select('users.id, users.firstname, users.lastname, users.email') ->join('user_roles', 'user_roles.user_id = users.id') ->join('roles', 'roles.id = user_roles.role_id') // If your role name may have different case, use LOWER(...): // ->where('LOWER(roles.name) =', 'parent') ->where('roles.name', 'parent') // Make sure this produces IS NULL; if not, use the raw condition line below ->where('user_roles.deleted_at', null); // Alternative guaranteed-IS-NULL: // $builder->where('user_roles.deleted_at IS NULL', null, false); if ($q !== '') { $builder->groupStart() ->like('users.firstname', $q) ->orLike('users.lastname', $q) ->orLike('users.email', $q) ->groupEnd(); } $rows = $builder ->orderBy('users.lastname', 'ASC') ->orderBy('users.firstname', 'ASC') ->limit($limit) ->find(); $results = array_map(function ($r) { $name = trim(($r['lastname'] ?? '') . ', ' . ($r['firstname'] ?? '')); $email = $r['email'] ?? ''; $label = $name !== ',' ? $name : ('User #' . $r['id']); if ($email) $label .= ' — ' . $email; $label .= ' (ID: ' . $r['id'] . ')'; return ['id' => (int)$r['id'], 'text' => $label]; }, $rows); return $this->response->setJSON(['results' => $results]); } /** Helper for preselect label in the modal */ private function getParentLabel(int $id): string { $p = $this->userModel->select('firstname, lastname, email')->find($id); if (!$p) return 'User #' . $id; $name = trim(($p['lastname'] ?? '') . ', ' . ($p['firstname'] ?? '')); $email = $p['email'] ?? ''; $label = $name !== ',' ? $name : ('User #' . $id); if ($email) $label .= ' — ' . $email; return $label . ' (ID: ' . $id . ')'; } public function update($id) { $row = $this->additionalChargeModel->find($id); if (!$row) { if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found', 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]); return redirect()->back()->with('error', 'Charge not found.'); } $data = $this->request->getPost(); $newAmount = isset($data['amount']) ? (float)$data['amount'] : (float)$row['amount']; $delta = $newAmount - (float)$row['amount']; $db = \Config\Database::connect(); $db->transStart(); // Update the charge first $this->additionalChargeModel->update($id, [ 'title' => trim($data['title'] ?? $row['title']), 'description' => trim($data['description'] ?? $row['description']), 'amount' => $newAmount, 'due_date' => $data['due_date'] ?? $row['due_date'], 'charge_type' => $data['charge_type'] ?? $row['charge_type'], // keep status as-is ]); if (($row['status'] ?? '') === FinancialStatus::ADDITIONAL_CHARGE_APPLIED && !empty($row['invoice_id'])) { $db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $row['invoice_id']]); $this->invoiceLedgerService->recalculateInvoice((int) $row['invoice_id']); } $db->transComplete(); if (!$db->transStatus()) { if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to update charge', 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]); return redirect()->back()->with('error', 'Failed to update charge.'); } if ($this->wantsJson()) return $this->response->setJSON(['ok' => true, 'id' => (int)$id, 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]); return redirect()->back()->with('status', 'Charge updated.'); } // (removed duplicate model-like listAllForTerm method; use AdditionalChargeModel::listAllForTerm) // ExtraChargesController.php public function invoicesForParent() { $parentId = (int)($this->request->getGet('parent_id') ?? 0); $schoolYear = (string)($this->request->getGet('school_year') ?? $this->schoolYear); $rows = ($parentId > 0) ? ($this->invoiceModel->getInvoicesByUserId($parentId, $schoolYear) ?? []) : []; $results = array_map(function ($inv) { $id = (int)($inv['id'] ?? 0); $num = (string)($inv['invoice_number'] ?? ('INV-' . $id)); $status = strtolower((string)($inv['status'] ?? '')); $balance = (float)($inv['balance'] ?? 0); $issue = $inv['issue_date'] ?? null; return [ 'id' => $id, 'invoice_number' => $num, 'status' => $status, 'balance' => $balance, 'issue_date' => $issue, 'text' => $num . ($status ? ' — ' . ucfirst($status) : '') . ' (Bal: $' . number_format($balance, 2) . ')', ]; }, $rows); return $this->response->setJSON(['results' => $results]); } public function store() { $data = $this->request->getPost(); $rules = [ 'parent_id' => 'required|integer', // this is actually the user_id of the parent 'title' => 'required|string|min_length[2]', 'amount' => 'required|decimal', 'charge_type' => 'required|in_list[add,deduct]', 'due_date' => 'permit_empty|valid_date', 'invoice_id' => 'permit_empty|integer', ]; if (!$this->validate($rules)) { $msg = implode(' ', $this->validator->getErrors()); if ($this->wantsJson()) { return $this->response->setJSON([ 'ok' => false, 'error' => $msg, 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash(), ]); } return redirect()->back()->withInput()->with('error', $msg); } $invoiceId = !empty($data['invoice_id']) ? (int)$data['invoice_id'] : null; $chargeType = (string)$data['charge_type']; $amountAbs = round(abs((float)$data['amount']), 2); $signedAmount = ($chargeType === 'add') ? $amountAbs : -$amountAbs; $payload = [ 'parent_id' => (int)$data['parent_id'], // ← users.id of the parent 'invoice_id' => $invoiceId, 'school_year' => $data['school_year'] ?? $this->schoolYear, 'semester' => $data['semester'] ?? $this->semester, 'charge_type' => $chargeType, 'title' => trim($data['title']), 'description' => trim($data['description'] ?? ''), 'amount' => $signedAmount, 'due_date' => !empty($data['due_date']) ? $data['due_date'] : null, 'status' => $invoiceId ? FinancialStatus::ADDITIONAL_CHARGE_APPLIED : FinancialStatus::ADDITIONAL_CHARGE_PENDING, 'created_by' => (int)(session()->get('user_id') ?? 0), 'created_at' => \CodeIgniter\I18n\Time::now('UTC')->toDateTimeString(), // store UTC ]; $this->db->transStart(); // BEFORE $invoiceBefore = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $this->schoolYear); // Insert charge $this->additionalChargeModel->insert($payload); $chargeId = (int)$this->additionalChargeModel->getInsertID(); if ($invoiceId) { $this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId]); $this->invoiceLedgerService->recalculateInvoice($invoiceId); } // AFTER $invoiceAfter = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $this->schoolYear); // Parent USER (not parent table) $parentUser = $this->userModel->getUserInfoById($data['parent_id']); $this->db->transComplete(); if (!$this->db->transStatus()) { $err = $this->db->error(); log_message('error', 'Transaction failed in ExtraChargesController::store: ' . json_encode($err)); if ($this->wantsJson()) { return $this->response->setJSON(['ok' => false, 'error' => 'Failed to save charge', 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]); } return redirect()->back()->withInput()->with('error', 'Failed to save charge.'); } // Build event payload (same style as your payment handler) // allow either first_name/last_name OR firstname/lastname from users table $first = $parentUser['first_name'] ?? $parentUser['firstname'] ?? ''; $last = $parentUser['last_name'] ?? $parentUser['lastname'] ?? ''; $parentName = trim($first . ' ' . $last); $invoiceBefore = $this->normalizeRow($invoiceBefore); $invoiceAfter = $this->normalizeRow($invoiceAfter); $invoiceTotal = $invoiceAfter ? (float)($invoiceAfter['total_amount'] ?? 0) : 0.0; $preBalance = $invoiceBefore ? (float)($invoiceBefore['balance'] ?? 0) : 0.0; $postBalance = $invoiceAfter ? (float)($invoiceAfter['balance'] ?? 0) : 0.0; $eventData = [ // user identity (parent user) 'user_id' => (int)($parentUser['id'] ?? 0), 'email' => $parentUser['email'] ?? null, 'firstname' => $first, 'lastname' => $last, 'parentName' => $parentName, // context 'school_year' => $payload['school_year'], 'semester' => $payload['semester'], // invoice 'invoice_id' => $invoiceId, 'invoice_number' => $invoiceAfter['invoice_number'] ?? ($invoiceId ? 'INV-' . $invoiceId : null), // charge details 'charge_id' => $chargeId, 'charge_title' => $payload['title'], 'charge_desc' => $payload['description'], 'charge_type' => $payload['charge_type'], // add|deduct 'amount_signed' => $signedAmount, 'amount_abs' => $amountAbs, 'due_date' => $payload['due_date'], 'created_at' => $payload['created_at'], // money snapshot 'pre_balance' => $preBalance, 'post_balance' => $postBalance, 'invoice_total' => $invoiceTotal, // links 'portal_link' => base_url('/login'), 'invoice_link' => $invoiceId ? site_url('parent/invoices/view/' . $invoiceId) : site_url('parent/invoices'), ]; $students = []; // keep [] unless you want to include children under this user Events::trigger('extraCharge', $eventData, $students); if ($this->wantsJson()) { return $this->response->setJSON([ 'ok' => true, 'id' => $chargeId, 'invoice_id' => $invoiceId, 'parent_id' => (int)$data['parent_id'], 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash(), ]); } return redirect()->to(site_url('admin/charges'))->with('status', 'Charge recorded.'); } /** * Mark a charge as void and roll back its impact on the invoice if applied. */ public function void($id) { $row = $this->additionalChargeModel->find((int)$id); if (!$row) { if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found']); return redirect()->back()->with('error', 'Charge not found.'); } $invoiceId = (int)($row['invoice_id'] ?? 0); $amountAbs = round(abs((float)($row['amount'] ?? 0)), 2); $chargeType = (string)($row['charge_type'] ?? 'add'); $status = (string)($row['status'] ?? 'pending'); $this->db->transStart(); $this->additionalChargeModel->update((int)$id, [ 'status' => FinancialStatus::ADDITIONAL_CHARGE_VOIDED, ]); if ($status === FinancialStatus::ADDITIONAL_CHARGE_APPLIED && $invoiceId > 0) { $this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId]); $this->invoiceLedgerService->recalculateInvoice($invoiceId); } $this->db->transComplete(); if (!$this->db->transStatus()) { if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to void charge']); return redirect()->back()->with('error', 'Failed to void charge.'); } if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]); return redirect()->back()->with('status', 'Charge voided.'); } /** * Reverse a previously applied charge: undo invoice impact and return to pending state. */ public function reverse($id) { $row = $this->additionalChargeModel->find((int)$id); if (!$row) { if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found']); return redirect()->back()->with('error', 'Charge not found.'); } $invoiceId = (int)($row['invoice_id'] ?? 0); $amountAbs = round(abs((float)($row['amount'] ?? 0)), 2); $chargeType = (string)($row['charge_type'] ?? 'add'); $status = (string)($row['status'] ?? 'pending'); if ($status !== FinancialStatus::ADDITIONAL_CHARGE_APPLIED || $invoiceId <= 0 || $amountAbs <= 0) { if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Nothing to reverse']); return redirect()->back()->with('error', 'Nothing to reverse.'); } $this->db->transStart(); $this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId]); $this->additionalChargeModel->update((int)$id, [ 'status' => FinancialStatus::ADDITIONAL_CHARGE_PENDING, 'invoice_id' => null, ]); $this->invoiceLedgerService->recalculateInvoice($invoiceId); $this->db->transComplete(); if (!$this->db->transStatus()) { if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to reverse charge']); return redirect()->back()->with('error', 'Failed to reverse charge.'); } if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]); return redirect()->back()->with('status', 'Charge reversed to pending.'); } /** JSON: list charges for the current term (with optional filters). */ public function apiList() { $year = (string)($this->request->getGet('school_year') ?? $this->schoolYear); $sem = (string)($this->request->getGet('semester') ?? $this->semester); $status = $this->request->getGet('status') ?: null; $q = trim((string)($this->request->getGet('q') ?? '')) ?: null; $per = (int)($this->request->getGet('per_page') ?? 50); $rows = $this->additionalChargeModel->listAllForTerm($year, $sem, $status, $q, $per); $pager = $this->additionalChargeModel->pager; $meta = [ 'perPage' => method_exists($pager, 'getPerPage') ? $pager->getPerPage() : $per, 'page' => method_exists($pager, 'getCurrentPage') ? $pager->getCurrentPage() : null, 'total' => method_exists($pager, 'getTotal') ? $pager->getTotal() : null, 'pageCount' => method_exists($pager, 'getPageCount') ? $pager->getPageCount() : null, ]; return $this->response->setJSON([ 'ok' => true, 'school_year' => $year, 'semester' => $sem, 'rows' => $rows, 'pager' => $meta, 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash(), ]); } // Normalize query result into a single associative row private function normalizeRow($row): ?array { if (!$row) return null; if (is_array($row) && isset($row[0]) && is_array($row[0])) { return $row[0]; // unwrap first row from a result set } return is_array($row) ? $row : null; } }