@@ -4,6 +4,7 @@ namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Libraries\FinancialStatus;
|
||||
use App\Libraries\InvoiceAdjustmentService;
|
||||
use App\Libraries\InvoiceLedgerService;
|
||||
use App\Models\AdditionalChargeModel;
|
||||
use CodeIgniter\Controller;
|
||||
@@ -31,6 +32,7 @@ class ExtraChargesController extends BaseController
|
||||
protected $enableAttendance;
|
||||
protected $attendanceDayModel;
|
||||
protected $invoiceLedgerService;
|
||||
protected InvoiceAdjustmentService $invoiceAdjustmentService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -43,6 +45,7 @@ class ExtraChargesController extends BaseController
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||
$this->invoiceAdjustmentService = new InvoiceAdjustmentService($this->db);
|
||||
}
|
||||
|
||||
public function index()
|
||||
@@ -216,16 +219,19 @@ class ExtraChargesController extends BaseController
|
||||
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.');
|
||||
}
|
||||
if (($row['status'] ?? '') === FinancialStatus::ADDITIONAL_CHARGE_APPLIED) {
|
||||
$message = 'Applied charges are immutable. Void and create an adjustment instead.';
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => $message, 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||
return redirect()->back()->with('error', $message);
|
||||
}
|
||||
|
||||
$data = $this->request->getPost();
|
||||
$newAmount = isset($data['amount']) ? (float)$data['amount'] : (float)$row['amount'];
|
||||
$delta = $newAmount - (float)$row['amount'];
|
||||
$newAmount = isset($data['amount']) ? round(abs((float)$data['amount']), 2) : round(abs((float)$row['amount']), 2);
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$db->transStart();
|
||||
|
||||
// Update the charge first
|
||||
$this->additionalChargeModel->update($id, [
|
||||
$updated = $this->additionalChargeModel->update($id, [
|
||||
'title' => trim($data['title'] ?? $row['title']),
|
||||
'description' => trim($data['description'] ?? $row['description']),
|
||||
'amount' => $newAmount,
|
||||
@@ -233,6 +239,11 @@ class ExtraChargesController extends BaseController
|
||||
'charge_type' => $data['charge_type'] ?? $row['charge_type'],
|
||||
// keep status as-is
|
||||
]);
|
||||
if (!$updated) {
|
||||
$db->transRollback();
|
||||
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 (($row['status'] ?? '') === FinancialStatus::ADDITIONAL_CHARGE_APPLIED && !empty($row['invoice_id'])) {
|
||||
$db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $row['invoice_id']]);
|
||||
@@ -314,22 +325,30 @@ class ExtraChargesController extends BaseController
|
||||
}
|
||||
|
||||
$invoiceId = !empty($data['invoice_id']) ? (int)$data['invoice_id'] : null;
|
||||
$invoice = null;
|
||||
if ($invoiceId !== null) {
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
$msg = 'Invoice not found for charge.';
|
||||
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);
|
||||
}
|
||||
}
|
||||
$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
|
||||
'parent_id' => $invoice ? (int)$invoice['parent_id'] : (int)$data['parent_id'],
|
||||
'invoice_id' => $invoiceId,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => (string)$this->semester,
|
||||
'school_year' => $invoice ? (string)$invoice['school_year'] : $schoolYear,
|
||||
'semester' => $invoice ? (string)($invoice['semester'] ?? $this->semester) : (string)$this->semester,
|
||||
'charge_type' => $chargeType,
|
||||
'title' => trim($data['title']),
|
||||
'description' => trim($data['description'] ?? ''),
|
||||
'amount' => $signedAmount,
|
||||
'amount' => $amountAbs,
|
||||
'due_date' => !empty($data['due_date']) ? $data['due_date'] : null,
|
||||
'status' => $invoiceId ? FinancialStatus::ADDITIONAL_CHARGE_APPLIED : FinancialStatus::ADDITIONAL_CHARGE_PENDING,
|
||||
'status' => FinancialStatus::ADDITIONAL_CHARGE_PENDING,
|
||||
'created_by' => (int)(session()->get('user_id') ?? 0),
|
||||
'created_at' => \CodeIgniter\I18n\Time::now('UTC')->toDateTimeString(), // store UTC
|
||||
];
|
||||
@@ -337,22 +356,22 @@ class ExtraChargesController extends BaseController
|
||||
$this->db->transStart();
|
||||
|
||||
// BEFORE
|
||||
$invoiceBefore = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $schoolYear);
|
||||
$invoiceBefore = $this->invoiceModel->getInvoicesByParentId($payload['parent_id'], $payload['school_year']);
|
||||
|
||||
// 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);
|
||||
$chargeId = (int)$this->additionalChargeModel->insert($payload);
|
||||
if ($chargeId <= 0) {
|
||||
$this->db->transRollback();
|
||||
$msg = 'Failed to save charge.';
|
||||
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);
|
||||
}
|
||||
|
||||
// AFTER
|
||||
$invoiceAfter = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $schoolYear);
|
||||
$invoiceAfter = $this->invoiceModel->getInvoicesByParentId($payload['parent_id'], $payload['school_year']);
|
||||
|
||||
// Parent USER (not parent table)
|
||||
$parentUser = $this->userModel->getUserInfoById($data['parent_id']);
|
||||
$parentUser = $this->userModel->getUserInfoById($payload['parent_id']);
|
||||
|
||||
$this->db->transComplete();
|
||||
|
||||
@@ -399,7 +418,7 @@ class ExtraChargesController extends BaseController
|
||||
'charge_title' => $payload['title'],
|
||||
'charge_desc' => $payload['description'],
|
||||
'charge_type' => $payload['charge_type'], // add|deduct
|
||||
'amount_signed' => $signedAmount,
|
||||
'amount_signed' => $chargeType === 'add' ? $amountAbs : -$amountAbs,
|
||||
'amount_abs' => $amountAbs,
|
||||
'due_date' => $payload['due_date'],
|
||||
'created_at' => $payload['created_at'],
|
||||
@@ -422,7 +441,7 @@ class ExtraChargesController extends BaseController
|
||||
'ok' => true,
|
||||
'id' => $chargeId,
|
||||
'invoice_id' => $invoiceId,
|
||||
'parent_id' => (int)$data['parent_id'],
|
||||
'parent_id' => $payload['parent_id'],
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
@@ -430,6 +449,61 @@ class ExtraChargesController extends BaseController
|
||||
return redirect()->to(site_url('admin/charges'))->with('status', 'Charge recorded.');
|
||||
}
|
||||
|
||||
public function approve($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.');
|
||||
}
|
||||
if (($row['status'] ?? '') !== FinancialStatus::ADDITIONAL_CHARGE_PENDING) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Only pending charges can be approved']);
|
||||
return redirect()->back()->with('error', 'Only pending charges can be approved.');
|
||||
}
|
||||
|
||||
if (!$this->additionalChargeModel->update((int)$id, ['status' => FinancialStatus::ADDITIONAL_CHARGE_APPROVED])) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to approve charge']);
|
||||
return redirect()->back()->with('error', 'Failed to approve charge.');
|
||||
}
|
||||
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
||||
return redirect()->back()->with('status', 'Charge approved.');
|
||||
}
|
||||
|
||||
public function apply($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.');
|
||||
}
|
||||
if ((string)($row['status'] ?? '') !== FinancialStatus::ADDITIONAL_CHARGE_APPROVED) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Only approved charges can be applied']);
|
||||
return redirect()->back()->with('error', 'Only approved charges can be applied.');
|
||||
}
|
||||
|
||||
$invoiceId = (int)($row['invoice_id'] ?? 0);
|
||||
if ($invoiceId <= 0) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge must reference an invoice before application']);
|
||||
return redirect()->back()->with('error', 'Charge must reference an invoice before application.');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->invoiceAdjustmentService->applyAdditionalCharge(
|
||||
(int)$id,
|
||||
$invoiceId,
|
||||
(int)(session()->get('user_id') ?? 0)
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Additional charge apply failed: ' . $e->getMessage());
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => $e->getMessage()]);
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
||||
return redirect()->back()->with('status', 'Charge applied.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a charge as void and roll back its impact on the invoice if applied.
|
||||
*/
|
||||
@@ -441,27 +515,36 @@ class ExtraChargesController extends BaseController
|
||||
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');
|
||||
$reason = trim((string)($this->request->getPost('reason') ?? 'Voided by staff'));
|
||||
|
||||
$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.');
|
||||
try {
|
||||
if ($status === FinancialStatus::ADDITIONAL_CHARGE_APPLIED) {
|
||||
$this->invoiceAdjustmentService->reverseAdditionalCharge(
|
||||
(int)$id,
|
||||
$reason,
|
||||
(int)(session()->get('user_id') ?? 0)
|
||||
);
|
||||
} else {
|
||||
$this->db->transBegin();
|
||||
$this->additionalChargeModel->update((int)$id, [
|
||||
'status' => FinancialStatus::ADDITIONAL_CHARGE_VOIDED,
|
||||
'voided_by' => (int)(session()->get('user_id') ?? 0) ?: null,
|
||||
'voided_at' => utc_now(),
|
||||
'void_reason' => $reason,
|
||||
]);
|
||||
if (!$this->db->transStatus()) {
|
||||
throw new \RuntimeException('Failed to void charge.');
|
||||
}
|
||||
$this->db->transCommit();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->db->transStatus() === false) {
|
||||
$this->db->transRollback();
|
||||
}
|
||||
log_message('error', 'Additional charge void failed: ' . $e->getMessage());
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => $e->getMessage()]);
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
||||
@@ -469,7 +552,7 @@ class ExtraChargesController extends BaseController
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse a previously applied charge: undo invoice impact and return to pending state.
|
||||
* Reverse a previously applied charge with an immutable reversing invoice line.
|
||||
*/
|
||||
public function reverse($id)
|
||||
{
|
||||
@@ -480,33 +563,28 @@ class ExtraChargesController extends BaseController
|
||||
}
|
||||
|
||||
$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 ($status !== FinancialStatus::ADDITIONAL_CHARGE_APPLIED || $invoiceId <= 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.');
|
||||
$reason = trim((string)($this->request->getPost('reason') ?? 'Reversed by staff'));
|
||||
try {
|
||||
$this->invoiceAdjustmentService->reverseAdditionalCharge(
|
||||
(int)$id,
|
||||
$reason,
|
||||
(int)(session()->get('user_id') ?? 0)
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Additional charge reverse failed: ' . $e->getMessage());
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => $e->getMessage()]);
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
||||
return redirect()->back()->with('status', 'Charge reversed to pending.');
|
||||
return redirect()->back()->with('status', 'Charge reversed.');
|
||||
}
|
||||
|
||||
/** JSON: list charges for the current term (with optional filters). */
|
||||
|
||||
Reference in New Issue
Block a user