fix financials
Tests / PHPUnit (push) Failing after 1m21s

This commit is contained in:
root
2026-07-18 22:57:40 -04:00
parent 068e739408
commit a30c1398a1
61 changed files with 10908 additions and 1775 deletions
+137 -59
View File
@@ -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). */