refundModel = new RefundModel(); $this->userModel = new UserModel(); $this->paymentModel = new PaymentModel(); $this->configModel = new ConfigurationModel(); $this->invoiceModel = new InvoiceModel(); $this->enrollmentModel = new EnrollmentModel(); $this->invoiceLedgerService = new InvoiceLedgerService(); $this->financialAttachmentService = new FinancialAttachmentService(); $this->db = \Config\Database::connect(); } /** Get current term (school_year, semester) from configuration */ private function getCurrentTerm(): array { $rows = $this->configModel ->select('config_key, config_value') ->whereIn('config_key', ['school_year','semester']) ->findAll(); $map = []; foreach ($rows as $r) { $map[$r['config_key']] = $r['config_value']; } return [ 'school_year' => $map['school_year'] ?? date('Y') . '-' . (date('Y') + 1), 'semester' => $map['semester'] ?? 'Fall', ]; } /** * Detect overpayments per parent for current school_year and create/update Pending refunds. * When $triggerEvents is true, emits refundPending for newly created rows only. * * @return array [created_ids => int[], updated_ids => int[]] */ private function recalcOverpayments(bool $triggerEvents = false): array { $created = []; $updated = []; $term = $this->getCurrentTerm(); $year = (string)$term['school_year']; $sem = (string)$term['semester']; // Sum payments by parent for the term $pm = $this->paymentModel; $payTable = $pm->table; $hasStatus = $this->paymentModel->db->fieldExists('status', $payTable); $hasVoid = $this->paymentModel->db->fieldExists('is_void', $payTable); $qbPaid = $pm->select('parent_id, COALESCE(SUM(paid_amount),0) AS total_paid') ->where('school_year', $year) ->groupBy('parent_id'); if ($hasStatus) { $qbPaid->groupStart() ->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled']) ->orWhere('status IS NULL', null, false) ->groupEnd(); } if ($hasVoid) { $qbPaid->groupStart() ->where('is_void', 0) ->orWhere('is_void IS NULL', null, false) ->groupEnd(); } $paidRows = $qbPaid->findAll(); // Sum invoices (charges) by parent for the term $invRows = $this->invoiceModel ->select('parent_id, COALESCE(SUM(total_amount),0) AS total_invoiced') ->where('school_year', $year) ->groupBy('parent_id') ->findAll(); // Sum refunds PAID (cash out) by parent for the term $refRows = $this->refundModel ->select('parent_id, COALESCE(SUM(refund_paid_amount),0) AS total_refunded') ->where('school_year', $year) ->whereIn('status', ['Partial','Paid']) ->groupBy('parent_id') ->findAll(); $paidMap = []; foreach ($paidRows as $r) $paidMap[(int)$r['parent_id']] = (float)($r['total_paid'] ?? 0); $invMap = []; foreach ($invRows as $r) $invMap[(int)$r['parent_id']] = (float)($r['total_invoiced'] ?? 0); $refMap = []; foreach ($refRows as $r) $refMap[(int)$r['parent_id']] = (float)($r['total_refunded'] ?? 0); $parentIds = array_unique(array_merge(array_keys($paidMap), array_keys($invMap))); foreach ($parentIds as $pid) { $paid = (float)($paidMap[$pid] ?? 0); $invd = (float)($invMap[$pid] ?? 0); $rfnd = (float)($refMap[$pid] ?? 0); $over = round($paid - $invd - $rfnd, 2); if ($over > 0.00 + 0.0001) { // Parent-level policy: DO NOT create new overpayment rows; only keep existing parent-level // overpayment rows in sync if they are still open (Pending/Partial). Prefer per-invoice rows. $existing = $this->refundModel ->where('parent_id', $pid) ->where('school_year', $year) ->where('request', 'overpayment') ->orderBy('id', 'DESC') ->first(); if ($existing && in_array($existing['status'], ['Pending','Partial'], true)) { $this->refundModel->update((int)$existing['id'], [ 'refund_amount' => $over, 'updated_at' => utc_now(), 'updated_by' => session()->get('user_id'), ]); $updated[] = (int)$existing['id']; } // else: no parent-level row created } } // ===== Stage 2: Per-invoice overpayment detection (current school year) ===== try { $invRows = $this->invoiceModel ->select('id, parent_id, total_amount, school_year') ->where('school_year', $year) ->findAll(); if ($invRows) { $invoiceIds = array_map(static fn($r) => (int)$r['id'], $invRows); // payments by invoice $qbInvPaid = $this->paymentModel ->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid') ->whereIn('invoice_id', $invoiceIds) ->groupBy('invoice_id'); $payTable2 = $this->paymentModel->table; $hasStatus2 = $this->paymentModel->db->fieldExists('status', $payTable2); $hasVoid2 = $this->paymentModel->db->fieldExists('is_void', $payTable2); if ($hasStatus2) { $qbInvPaid->groupStart() ->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled']) ->orWhere('status IS NULL', null, false) ->groupEnd(); } if ($hasVoid2) { $qbInvPaid->groupStart() ->where('is_void', 0) ->orWhere('is_void IS NULL', null, false) ->groupEnd(); } $invPaidRows = $qbInvPaid->findAll(); // discounts by invoice $invDiscRows = $this->db->table('discount_usages') ->select('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc') ->whereIn('invoice_id', $invoiceIds) ->groupBy('invoice_id') ->get()->getResultArray(); // refunds paid by invoice $invRefRows = $this->refundModel ->select('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_ref') ->whereIn('invoice_id', $invoiceIds) ->whereIn('status', ['Partial','Paid']) ->groupBy('invoice_id') ->findAll(); $paidByInv = []; foreach ($invPaidRows as $r) { $paidByInv[(int)$r['invoice_id']] = (float)($r['total_paid'] ?? 0); } $discByInv = []; foreach ($invDiscRows as $r) { $discByInv[(int)$r['invoice_id']] = (float)($r['total_disc'] ?? 0); } $refByInv = []; foreach ($invRefRows as $r) { $refByInv[(int)$r['invoice_id']] = (float)($r['total_ref'] ?? 0); } foreach ($invRows as $inv) { $iid = (int)$inv['id']; $pid = (int)$inv['parent_id']; $total = (float)($inv['total_amount'] ?? 0); $paid = (float)($paidByInv[$iid] ?? 0); $disc = (float)($discByInv[$iid] ?? 0); $rfnd = (float)($refByInv[$iid] ?? 0); $rawBalance = round($total - $disc - $paid - $rfnd, 2); if ($rawBalance < -0.0001) { $over = abs($rawBalance); // If ANY open refund exists for this invoice, update it instead of creating another line $openRow = $this->refundModel ->where('invoice_id', $iid) ->whereIn('status', ['Pending','Approved','Partial']) ->orderBy('id', 'DESC') ->first(); if ($openRow) { // Prefer merging into 'overpayment' if present, else update the open row $this->refundModel->update((int)$openRow['id'], [ 'refund_amount' => $over, 'updated_at' => utc_now(), 'updated_by' => session()->get('user_id'), ]); $updated[] = (int)$openRow['id']; continue; } // If there is an open PARENT-LEVEL overpayment (invoice_id IS NULL), migrate it to this invoice $parentLevelOpen = $this->refundModel ->where('parent_id', $pid) ->where('school_year', $year) ->where('invoice_id', null) ->where('request', 'overpayment') ->whereIn('status', ['Pending','Approved','Partial']) ->orderBy('id', 'DESC') ->first(); if ($parentLevelOpen) { $this->refundModel->update((int)$parentLevelOpen['id'], [ 'invoice_id' => $iid, 'refund_amount' => $over, 'updated_at' => utc_now(), 'updated_by' => session()->get('user_id'), ]); $updated[] = (int)$parentLevelOpen['id']; continue; // merged instead of creating new } // Existing overpayment refund for this invoice? $existing = $this->refundModel ->where('invoice_id', $iid) ->where('request', 'overpayment') ->whereIn('status', ['Pending','Approved','Partial','Paid']) ->orderBy('id', 'DESC') ->first(); if (!$existing || $existing['status'] === 'Paid') { $ok = $this->refundModel->insert([ 'parent_id' => $pid, 'school_year' => $year, 'semester' => $sem, 'invoice_id' => $iid, 'refund_amount' => $over, 'refund_paid_amount' => 0.00, 'status' => 'Pending', 'request' => 'overpayment', 'reason' => 'Auto-detected per-invoice overpayment', 'updated_by' => session()->get('user_id'), 'created_at' => utc_now(), 'updated_at' => utc_now(), ]); if ($ok) { $created[] = (int)$this->refundModel->getInsertID(); if ($triggerEvents) { $user = (new UserModel())->select('id, email, firstname, lastname')->find($pid) ?: []; $eventData = [ 'user_id' => (int)($user['id'] ?? $pid), 'email' => $user['email'] ?? null, 'firstname' => $user['firstname'] ?? null, 'lastname' => $user['lastname'] ?? null, 'amount' => $over, 'portalLink'=> base_url('/login'), ]; \CodeIgniter\Events\Events::trigger('refundPending', $eventData, []); } } } else { // Keep Pending/Partial in sync with current overpayment if (in_array($existing['status'], ['Pending','Partial'], true)) { $this->refundModel->update((int)$existing['id'], [ 'refund_amount' => $over, 'updated_at' => utc_now(), 'updated_by' => session()->get('user_id'), ]); $updated[] = (int)$existing['id']; } } } } } } catch (\Throwable $e) { log_message('error', 'recalcOverpayments per-invoice failed: ' . $e->getMessage()); } return ['created_ids' => $created, 'updated_ids' => $updated]; } /** * Parent financial summary for *current term* (adjust if you want all-time). * Assumes invoices.total_amount and payments.amount (positive=in, negative=out). */ private function getParentFinancialSummary(int $parentId, string $schoolYear, string $semester): array { // invoices total for term $inv = $this->invoiceModel ->select('COALESCE(SUM(total_amount),0) AS total_invoiced') ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->where('semester', $semester) ->first(); // payments in (amount > 0) for term $payIn = $this->paymentModel ->select('COALESCE(SUM(amount),0) AS paid_in') ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->where('semester', $semester) ->where('amount >', 0) ->first(); // refunds already paid out (your refunds table) $refPaid = $this->refundModel ->select('COALESCE(SUM(refund_paid_amount),0) AS refunded_cash') ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->where('semester', $semester) ->whereIn('status', ['Partial','Paid']) ->first(); $totalInvoiced = (float)($inv['total_invoiced'] ?? 0); $paidIn = (float)($payIn['paid_in'] ?? 0); $refundedCash = (float)($refPaid['refunded_cash'] ?? 0); // Unapplied balance = money in − invoices − cash refunds out $unapplied = $paidIn - $totalInvoiced - $refundedCash; return [ 'total_invoiced' => $totalInvoiced, 'total_paid_in' => $paidIn, 'total_refunded' => $refundedCash, 'unapplied_balance' => $unapplied, ]; } /** Optional helper if you want a quick API for balances in UI */ public function parentBalances(int $parentId) { $t = $this->getCurrentTerm(); return $this->response->setJSON($this->getParentFinancialSummary($parentId, $t['school_year'], $t['semester'])); } /** * Create refund request. * Stores source/type in `refunds.request` as: tuition|overpayment|duplicate|extra * * @param int $parentId * @param float $amount * @param string|null $requestType tuition|overpayment|duplicate|extra * @param int|null $invoiceId required for tuition; useful for duplicate * @param int|null $paymentId useful for duplicate * @param string|null $reason */ public function requestRefund( int $parentId, float $amount, ?string $requestType = 'tuition', ?int $invoiceId = null, ?int $paymentId = null, ?string $reason = null ) { $requestType = strtolower((string)$requestType); if (!in_array($requestType, self::REQUEST_TYPES, true)) { return $this->response->setJSON(['error' => 'Invalid refund request type.']); } $parent = $this->userModel->find($parentId); if (!$parent) { return $this->response->setJSON(['error' => 'Parent not found.']); } if ($amount <= 0) { return $this->response->setJSON(['error' => 'Refund amount must be > 0.']); } $term = $this->getCurrentTerm(); $schoolYear = $term['school_year']; $semester = $term['semester']; // Tuition requires an invoice check if ($requestType === 'tuition') { if (empty($invoiceId)) { return $this->response->setJSON(['error' => 'invoice_id is required for tuition refunds.']); } $inv = $this->invoiceModel->find($invoiceId); if (!$inv || (int)$inv['parent_id'] !== $parentId) { return $this->response->setJSON(['error' => 'Invoice not found for parent.']); } // Optionally cap to eligible amount per your policy. } // Overpayment/extra must not exceed unapplied balance if (in_array($requestType, ['overpayment','extra'], true)) { $sum = $this->getParentFinancialSummary($parentId, $schoolYear, $semester); if ($sum['unapplied_balance'] <= 0) { return $this->response->setJSON(['error' => 'No unapplied balance available.']); } if ($amount > $sum['unapplied_balance'] + 0.0001) { return $this->response->setJSON([ 'error' => 'Amount exceeds available unapplied balance.', 'available' => number_format($sum['unapplied_balance'], 2) ]); } } // Duplicate: optionally tie to payment; otherwise your staff can fill details in reason/note if ($requestType === 'duplicate' && empty($paymentId) && empty($invoiceId)) { // Not hard-failing; but better to guide: // return $this->response->setJSON(['error' => 'Provide payment_id or invoice_id for duplicate refunds.']); } $payload = [ 'parent_id' => $parentId, 'school_year' => $schoolYear, 'semester' => $semester, 'invoice_id' => $invoiceId, 'refund_amount' => $amount, 'refund_paid_amount' => 0.00, // IMPORTANT: your column is NOT NULL 'status' => 'Pending', 'reason' => $reason, 'request' => $requestType, // <- store the source/type here 'note' => $paymentId ? ('duplicate-of-payment#' . $paymentId) : null, 'created_at' => utc_now(), 'updated_at' => utc_now(), ]; $ok = $this->refundModel->insert($payload); if (!$ok) { return $this->response->setJSON(['error' => 'Failed to create refund request.']); } if (!empty($invoiceId)) { try { $this->invoiceLedgerService->recalculateInvoice((int) $invoiceId); } catch (\Throwable $e) { log_message('error', 'requestRefund recalc failed: ' . $e->getMessage()); } } // Fire refundPending notification/event for newly created refunds try { $user = $this->userModel->select('id, email, firstname, lastname')->find($parentId) ?: []; $eventData = [ 'user_id' => (int)($user['id'] ?? $parentId), 'email' => $user['email'] ?? null, 'firstname' => $user['firstname'] ?? null, 'lastname' => $user['lastname'] ?? null, 'amount' => (float)$amount, 'portalLink'=> base_url('/login'), ]; \CodeIgniter\Events\Events::trigger('refundPending', $eventData, []); } catch (\Throwable $e) { log_message('error', 'requestRefund: failed to trigger refundPending: ' . $e->getMessage()); } return $this->response->setJSON(['success' => 'Refund requested']); } // Approve refund (no money movement) public function approveRefund(int $refundId) { $refund = $this->refundModel->find($refundId); if (!$refund) { return $this->response->setJSON(['error' => 'Refund not found']); } $this->db->transBegin(); try { if (!empty($refund['invoice_id'])) { $this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $refund['invoice_id']]); } $ok = $this->refundModel->update($refundId, [ 'status' => 'Approved', 'approved_at' => utc_now(), 'approved_by' => session()->get('user_id'), 'updated_at' => utc_now(), 'updated_by' => session()->get('user_id'), ]); if (!empty($refund['invoice_id'])) { $this->invoiceLedgerService->recalculateInvoice((int) $refund['invoice_id']); } $this->db->transCommit(); } catch (\Throwable $e) { $this->db->transRollback(); log_message('error', 'approveRefund failed: ' . $e->getMessage()); $ok = false; } return $this->response->setJSON($ok ? ['success' => 'Refund approved'] : ['error' => 'Approve failed']); } // Reject refund public function rejectRefund(int $refundId) { $refund = $this->refundModel->find($refundId); if (!$refund) { return $this->response->setJSON(['error' => 'Refund not found']); } $this->db->transBegin(); try { if (!empty($refund['invoice_id'])) { $this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $refund['invoice_id']]); } $ok = $this->refundModel->update($refundId, [ 'status' => 'Rejected', 'approved_at' => utc_now(), 'approved_by' => session()->get('user_id'), 'updated_at' => utc_now(), 'updated_by' => session()->get('user_id'), ]); if (!empty($refund['invoice_id'])) { $this->invoiceLedgerService->recalculateInvoice((int) $refund['invoice_id']); } $this->db->transCommit(); } catch (\Throwable $e) { $this->db->transRollback(); log_message('error', 'rejectRefund failed: ' . $e->getMessage()); $ok = false; } return $this->response->setJSON($ok ? ['success' => 'Refund rejected'] : ['error' => 'Reject failed']); } /** * POST: refund_id, paid_amount, payment_method (Check|Online|Cash), check_number, check_file (optional) * Marks payout (can be partial) and inserts a negative Payment row to keep balances correct. */ public function updatePayment() { log_message('debug', 'POST data: ' . print_r($_POST, true)); $refundId = (int)$this->request->getPost('refund_id'); $newPaidAmount = (float)$this->request->getPost('paid_amount'); $refundMethod = $this->request->getPost('payment_method'); // Check|Online|Cash $checkNbr = $this->request->getPost('check_number'); if ($refundId <= 0) return $this->response->setJSON(['error' => 'Missing refund ID.']); if ($newPaidAmount <= 0) return $this->response->setJSON(['error' => 'Valid paid amount is required.']); $refund = $this->refundModel->find($refundId); if (!$refund) return $this->response->setJSON(['error' => 'Refund not found.']); if (!in_array($refund['status'], ['Approved','Partial'], true)) { return $this->response->setJSON(['error' => 'Refund must be Approved/Partial to pay.']); } $oldPaid = (float)$refund['refund_paid_amount']; $target = (float)$refund['refund_amount']; $total = $oldPaid + $newPaidAmount; if ($total > $target + 0.0001) { return $this->response->setJSON(['error' => 'Total paid cannot exceed refund amount.']); } // Optional file upload for Check $checkFileName = $refund['check_file'] ?? null; if ($refundMethod === 'Check') { try { $checkFile = $this->request->getFile('check_file'); $uploaded = $this->financialAttachmentService->saveUploadedFile($checkFile, 'checks'); if ($uploaded !== null) { $checkFileName = $uploaded; } } catch (\RuntimeException $e) { return $this->response->setJSON(['error' => $e->getMessage()]); } } $newStatus = ($total < $target) ? 'Partial' : 'Paid'; $db = db_connect(); $db->transBegin(); // 0) If this refund is not tied to an invoice yet, assign it to the most overpaid invoice $assignInvoiceId = null; if (empty($refund['invoice_id'])) { try { // Gather invoices for this parent/year $invRows = $this->invoiceModel ->select('id, total_amount') ->where('parent_id', (int)$refund['parent_id']) ->where('school_year', $refund['school_year']) ->findAll(); if ($invRows) { $invoiceIds = array_map(static fn($r) => (int)$r['id'], $invRows); // Payments per invoice $payRows = $this->paymentModel ->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid') ->whereIn('invoice_id', $invoiceIds) ->groupBy('invoice_id') ->findAll(); $paidBy = []; foreach ($payRows as $r) { $paidBy[(int)$r['invoice_id']] = (float)($r['total_paid'] ?? 0); } // Discounts per invoice $discRows = $this->db->table('discount_usages') ->select('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc') ->whereIn('invoice_id', $invoiceIds) ->groupBy('invoice_id') ->get()->getResultArray(); $discBy = []; foreach ($discRows as $r) { $discBy[(int)$r['invoice_id']] = (float)($r['total_disc'] ?? 0); } // Choose invoice with most negative raw (total - disc - paid) $minRaw = 0.0; $minId = null; foreach ($invRows as $ir) { $iid = (int)$ir['id']; $raw = (float)($ir['total_amount'] ?? 0) - (float)($discBy[$iid] ?? 0) - (float)($paidBy[$iid] ?? 0); if ($raw < $minRaw) { $minRaw = $raw; $minId = $iid; } } if ($minId !== null) { $assignInvoiceId = $minId; } else { // fallback: latest invoice in this year $row = $this->invoiceModel ->select('id') ->where('parent_id', (int)$refund['parent_id']) ->where('school_year', $refund['school_year']) ->orderBy('created_at', 'DESC') ->first(); if ($row && !empty($row['id'])) $assignInvoiceId = (int)$row['id']; } } } catch (\Throwable $e) { log_message('error', 'updatePayment: invoice assignment failed: ' . $e->getMessage()); } } try { $affectedInvoiceId = (int) ($assignInvoiceId ?? $refund['invoice_id'] ?? 0); if ($affectedInvoiceId > 0) { $db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$affectedInvoiceId]); } $this->refundModel->update($refundId, [ 'refund_paid_amount' => $total, 'status' => $newStatus, 'refunded_at' => utc_now(), 'updated_at' => utc_now(), 'updated_by' => session()->get('user_id'), 'refund_method' => $refundMethod, 'check_nbr' => $checkNbr, 'check_file' => $checkFileName, 'invoice_id' => $affectedInvoiceId ?: null, ]); if ($affectedInvoiceId > 0) { $this->invoiceLedgerService->recalculateInvoice($affectedInvoiceId); } $db->transCommit(); } catch (\Throwable $e) { $db->transRollback(); log_message('error', 'updatePayment failed: ' . $e->getMessage()); return $this->response->setJSON(['error' => 'Failed to update payment.']); } return $this->response->setJSON(['success' => 'Payment updated successfully.']); } /** Keep your listing; added extra fields for clarity */ public function listRefunds() { // NOTE: We no longer auto-create/adjust refunds on page load to avoid duplicate lines // when staff are recording payouts. Use the "Recalculate" buttons to run detection on demand. // 2) List refunds with joins $refunds = $this->refundModel ->select('refunds.*, u.firstname, u.lastname, u.school_id, a.firstname AS approved_by_firstname, a.lastname AS approved_by_lastname') ->join('users u', 'refunds.parent_id = u.id') ->join('users a', 'refunds.approved_by = a.id', 'left') ->orderBy('refunds.created_at', 'DESC') ->findAll(); foreach ($refunds as &$r) { $r['approved_by_name'] = trim(($r['approved_by_firstname'] ?? '') . ' ' . ($r['approved_by_lastname'] ?? '')) ?: '-'; } $parentId = !empty($refunds) ? $refunds[0]['parent_id'] : null; return view('refunds/list', [ 'refunds' => $refunds, 'parentId' => $parentId ]); } /** Manual endpoint to recalculate/sync overpayments and optionally notify newly created entries. */ public function recalculateOverpayments() { // Optional targeted invoice_number to handle cross-year cases $invoiceNumber = (string)($this->request->getPost('invoice_number') ?? ''); if ($invoiceNumber !== '') { try { $inv = $this->invoiceModel->where('invoice_number', $invoiceNumber)->first(); if ($inv) { $pid = (int)$inv['parent_id']; $iid = (int)$inv['id']; $year = (string)$inv['school_year']; // Compute per-invoice overpayment now (independent of current term) $paid = (float)($this->paymentModel ->select('COALESCE(SUM(paid_amount),0) AS tot') ->where('invoice_id', $iid) ->first()['tot'] ?? 0); $disc = (float)($this->db->table('discount_usages') ->select('COALESCE(SUM(discount_amount),0) AS tot') ->where('invoice_id', $iid) ->get()->getRowArray()['tot'] ?? 0); $rfnd = (float)($this->refundModel ->select('COALESCE(SUM(refund_paid_amount),0) AS tot') ->where('invoice_id', $iid) ->whereIn('status', ['Partial','Paid']) ->first()['tot'] ?? 0); $total = (float)($inv['total_amount'] ?? 0); $raw = round($total - $disc - $paid - $rfnd, 2); if ($raw < -0.0001) { $over = abs($raw); // If there is ANY open refund for this invoice (any request), update it rather than create $openRow = $this->refundModel ->where('invoice_id', $iid) ->whereIn('status', ['Pending','Approved','Partial']) ->orderBy('id', 'DESC') ->first(); if ($openRow) { $this->refundModel->update((int)$openRow['id'], [ 'refund_amount' => $over, 'request' => 'overpayment', 'updated_at' => utc_now(), 'updated_by' => session()->get('user_id'), ]); return redirect()->to(site_url('refunds/list'))->with('success', 'Overpayment updated for invoice ' . esc($invoiceNumber)); } // Else, if no open rows exist, either update existing overpayment (Paid) — skip creating new $existing = $this->refundModel ->where('invoice_id', $iid) ->where('request', 'overpayment') ->orderBy('id', 'DESC') ->first(); if ($existing) { // If it's Paid, do not create a new duplicate line return redirect()->to(site_url('refunds/list'))->with('info', 'Overpayment was already resolved for this invoice.'); } // As a last resort, create a single overpayment row $this->refundModel->insert([ 'parent_id' => $pid, 'school_year' => $year, 'semester' => $inv['semester'] ?? null, 'invoice_id' => $iid, 'refund_amount' => $over, 'refund_paid_amount' => 0.00, 'status' => 'Pending', 'request' => 'overpayment', 'reason' => 'Auto-detected per-invoice overpayment (manual)', 'updated_by' => session()->get('user_id'), 'created_at' => utc_now(), 'updated_at' => utc_now(), ]); $user = $this->userModel->select('id, email, firstname, lastname')->find($pid) ?: []; $eventData = [ 'user_id' => (int)($user['id'] ?? $pid), 'email' => $user['email'] ?? null, 'firstname' => $user['firstname'] ?? null, 'lastname' => $user['lastname'] ?? null, 'amount' => $over, 'portalLink'=> base_url('/login'), ]; \CodeIgniter\Events\Events::trigger('refundPending', $eventData, []); return redirect()->to(site_url('refunds/list'))->with('success', 'Overpayment detected and refund created for invoice ' . esc($invoiceNumber)); } return redirect()->to(site_url('refunds/list'))->with('info', 'No overpayment detected for invoice ' . esc($invoiceNumber)); } return redirect()->to(site_url('refunds/list'))->with('error', 'Invoice not found: ' . esc($invoiceNumber)); } catch (\Throwable $e) { return redirect()->to(site_url('refunds/list'))->with('error', 'Failed to recalc for invoice: ' . $e->getMessage()); } } $res = $this->recalcOverpayments(true); $nNew = count($res['created_ids'] ?? []); $nUpd = count($res['updated_ids'] ?? []); return redirect()->to(site_url('refunds/list')) ->with('success', "Recalculated overpayments: created {$nNew}, updated {$nUpd}."); } /** Decision endpoint (Approved/Rejected) with reason */ public function updateStatus() { log_message('debug', 'POST data: ' . print_r($_POST, true)); $refundId = (int)$this->request->getPost('refund_id'); $status = $this->request->getPost('status'); $reason = $this->request->getPost('reason'); if ($refundId <= 0 || !in_array($status, self::DECISIONS, true)) { return $this->response->setJSON(['error' => 'Invalid status update request.']); } if (empty($reason)) { return $this->response->setJSON(['error' => 'Reason is required for approval or rejection.']); } $refund = $this->refundModel->find($refundId); if (!$refund) { return $this->response->setJSON(['error' => 'Refund not found.']); } $this->db->transBegin(); try { if (!empty($refund['invoice_id'])) { $this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $refund['invoice_id']]); } $ok = $this->refundModel->update($refundId, [ 'status' => $status, 'reason' => $reason, 'approved_at' => utc_now(), 'approved_by' => session()->get('user_id'), 'updated_at' => utc_now(), 'updated_by' => session()->get('user_id'), ]); if (!empty($refund['invoice_id'])) { $this->invoiceLedgerService->recalculateInvoice((int) $refund['invoice_id']); } $this->db->transCommit(); } catch (\Throwable $e) { $this->db->transRollback(); log_message('error', 'updateStatus failed: ' . $e->getMessage()); $ok = false; } return $this->response->setJSON($ok ? ['success' => 'Refund status updated successfully.'] : ['error' => 'Failed to update refund status.']); } public function serveRefundFile(int $refundId, string $mode = 'download') { $refund = $this->refundModel->find($refundId); if (!$refund || empty($refund['check_file'])) { throw PageNotFoundException::forPageNotFound('Refund file not found.'); } if (!$this->canViewRefund($refund)) { return $this->response->setStatusCode(403); } $path = $this->financialAttachmentService->resolvePath('checks', (string) $refund['check_file']); if ($path === null) { throw PageNotFoundException::forPageNotFound('Refund file not found.'); } if ($mode === 'inline') { return $this->response ->setHeader('Content-Type', $this->financialAttachmentService->detectMime($path)) ->setHeader('Content-Disposition', 'inline; filename="' . basename($path) . '"') ->setBody(file_get_contents($path)); } return $this->response->download($path, null); } private function canViewRefund(array $refund): bool { $roles = array_map('strtolower', (array) (session()->get('roles') ?? [])); $activeRole = strtolower((string) (session()->get('role') ?? '')); if ($activeRole !== '' && !in_array($activeRole, $roles, true)) { $roles[] = $activeRole; } foreach (['administrator', 'administrative staff', 'principal', 'teacher', 'teacher_assistant'] as $role) { if (in_array($role, $roles, true)) { return true; } } return in_array('parent', $roles, true) && (int) ($refund['parent_id'] ?? 0) === (int) session()->get('user_id'); } }