reconstruction of the project

This commit is contained in:
root
2026-03-08 16:33:24 -04:00
parent 23b7db1107
commit c8de5f7edc
9157 changed files with 77877 additions and 1073823 deletions
@@ -0,0 +1,785 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\RefundModel;
use App\Models\UserModel;
use App\Models\PaymentModel;
use App\Models\ConfigurationModel;
use App\Models\InvoiceModel;
use App\Models\EnrollmentModel;
class RefundController extends BaseController
{
protected RefundModel $refundModel;
protected UserModel $userModel;
protected PaymentModel $paymentModel;
protected ConfigurationModel $configModel;
protected InvoiceModel $invoiceModel;
protected EnrollmentModel $enrollmentModel;
protected $db;
// Allowed request types (mapped to your `refunds.request` column)
private const REQUEST_TYPES = ['tuition','overpayment','duplicate','extra'];
// Allowed finalization decisions
private const DECISIONS = ['Approved','Rejected'];
public function __construct()
{
$this->refundModel = new RefundModel();
$this->userModel = new UserModel();
$this->paymentModel = new PaymentModel();
$this->configModel = new ConfigurationModel();
$this->invoiceModel = new InvoiceModel();
$this->enrollmentModel = new EnrollmentModel();
$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.']);
}
// 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)
{
$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'),
]);
return $this->response->setJSON($ok ? ['success' => 'Refund approved'] : ['error' => 'Approve failed']);
}
// Reject refund
public function rejectRefund(int $refundId)
{
$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'),
]);
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') {
$checkFile = $this->request->getFile('check_file');
if ($checkFile && $checkFile->isValid() && !$checkFile->hasMoved()) {
$checkFileName = $checkFile->getRandomName();
$checkFile->move(WRITEPATH . 'uploads/checks/', $checkFileName);
}
}
$newStatus = ($total < $target) ? 'Partial' : 'Paid';
$db = db_connect();
$db->transStart();
// 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());
}
}
// 1) Update refund row
$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,
// tie to invoice if determined
'invoice_id' => $assignInvoiceId ?? $refund['invoice_id'],
]);
// 2) Optional: If you want an accounting journal entry for payouts, write to a dedicated table.
// We no longer insert a negative row into payments to avoid schema/validation conflicts.
$db->transComplete();
if ($db->transStatus() === false) {
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.']);
}
$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'),
]);
return $this->response->setJSON($ok ? ['success' => 'Refund status updated successfully.']
: ['error' => 'Failed to update refund status.']);
}
}