reconstruction of the project
This commit is contained in:
@@ -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.']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user