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
+175 -40
View File
@@ -2,6 +2,7 @@
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Libraries\FinancialAttachmentService;
use App\Models\ExpenseModel;
use App\Models\UserModel;
use App\Models\ConfigurationModel;
@@ -12,6 +13,7 @@ class ExpenseController extends BaseController
protected $expenseModel;
protected $userModel;
protected $configModel;
protected FinancialAttachmentService $financialAttachmentService;
protected $schoolYear;
protected $semester;
protected $retailors;
@@ -21,6 +23,7 @@ class ExpenseController extends BaseController
$this->expenseModel = new ExpenseModel();
$this->userModel = new UserModel();
$this->configModel = new ConfigurationModel();
$this->financialAttachmentService = new FinancialAttachmentService();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
@@ -135,19 +138,19 @@ class ExpenseController extends BaseController
// Optional extra fields
'retailor' => 'permit_empty|max_length[255]',
'date_of_purchase' => 'permit_empty',
// allow JPG/JPEG/PNG/WEBP/GIF and PDF up to 2MB
// allow JPG/JPEG/PNG and PDF up to 2MB
'receipt' => 'uploaded[receipt]'
. '|max_size[receipt,2048]'
. '|ext_in[receipt,jpg,jpeg,png,webp,gif,pdf]'
. '|mime_in[receipt,image/jpg,image/jpeg,image/png,image/webp,image/gif,application/pdf]',
. '|max_size[receipt,5120]'
. '|ext_in[receipt,jpg,jpeg,png,pdf]'
. '|mime_in[receipt,image/jpg,image/jpeg,image/png,application/pdf]',
];
$messages = [
'receipt' => [
'uploaded' => 'Receipt file is required.',
'max_size' => 'Maximum file size is 2MB.',
'ext_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.',
'mime_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.',
'max_size' => 'Maximum file size is 5MB.',
'ext_in' => 'Allowed formats: JPG, JPEG, PNG, or PDF.',
'mime_in' => 'Allowed formats: JPG, JPEG, PNG, or PDF.',
]
];
@@ -170,24 +173,32 @@ class ExpenseController extends BaseController
$purchasedById = (int) $purchasedById;
// School context
$schoolYear = $this->schoolYear ?: date('Y');
$schoolYear = (string)$this->schoolYear;
if (!preg_match('/^\d{4}-\d{4}$/', $schoolYear)) {
return redirect()->back()->withInput()->with('error', 'Invalid school year configuration. Expected YYYY-YYYY.');
}
$semester = $this->semester ?: 'Fall';
// Handle upload: store under writable/uploads/receipts and save only the filename
$receiptName = null;
$file = $this->request->getFile('receipt');
if ($file && $file->isValid() && !$file->hasMoved()) {
$stored = $file->store('receipts'); // -> writable/uploads/receipts/<randomname>.ext
$receiptName = basename($stored);
$stagedReceipt = null;
try {
$stagedReceipt = $this->financialAttachmentService->stageUploadedFile(
$this->request->getFile('receipt'),
'receipts'
);
} catch (\RuntimeException $e) {
return redirect()->back()->withInput()->with('error', $e->getMessage());
}
$status = $isDonation ? 'approved' : 'pending';
$statusReason = $isDonation ? 'Marked as Donation (non-reimbursable).' : null;
$this->expenseModel->insert([
$db = \Config\Database::connect();
$db->transBegin();
try {
$expenseId = (int)$this->expenseModel->insert([
'category' => $category,
'amount' => $amount,
'receipt_path' => $receiptName, // filename only
'receipt_path' => null,
'description' => $description,
'retailor' => ($retailor !== '') ? $retailor : null,
'date_of_purchase' => ($datePurchase !== '') ? $datePurchase : null,
@@ -198,7 +209,33 @@ class ExpenseController extends BaseController
'approved_by' => $isDonation ? $userId : null,
'school_year' => $schoolYear,
'semester' => $semester,
]);
]);
if ($expenseId <= 0) {
throw new \RuntimeException('Expense insert failed.');
}
if ($db->transStatus() === false) {
throw new \RuntimeException('Expense transaction failed.');
}
$db->transCommit();
} catch (\Throwable $e) {
$db->transRollback();
$this->financialAttachmentService->discardStagedFile($stagedReceipt);
log_message('error', 'Expense creation failed: ' . $e->getMessage());
return redirect()->back()->withInput()->with('error', 'Expense could not be saved.');
}
if ($stagedReceipt !== null) {
try {
$receiptName = $this->financialAttachmentService->finalizeStagedFile($stagedReceipt);
if (!$this->expenseModel->update($expenseId, ['receipt_path' => $receiptName])) {
throw new \RuntimeException('Expense receipt update failed.');
}
} catch (\Throwable $e) {
$this->financialAttachmentService->discardStagedFile($stagedReceipt);
log_message('critical', 'Expense receipt incomplete for expense #' . $expenseId . ': ' . $e->getMessage());
return redirect()->to('/expenses/index')->with('error', 'Expense saved, but receipt could not be finalized. Operations must review.');
}
}
return redirect()->to('/expenses/index')->with('success', 'Record added successfully!');
}
@@ -217,18 +254,36 @@ class ExpenseController extends BaseController
return $this->response->setJSON(['error' => 'Invalid data']);
}
$expense = $this->expenseModel->find($id);
if (!$expense) {
log_message('error', 'Expense not found for ID ' . $id);
return $this->response->setJSON(['error' => 'Expense not found']);
}
$db = \Config\Database::connect();
$db->transBegin();
try {
$expense = $db->query('SELECT * FROM expenses WHERE id = ? FOR UPDATE', [$id])->getRowArray();
if (!$expense) {
throw new \RuntimeException('Expense not found');
}
$db->query(
"SELECT id FROM reimbursements WHERE expense_id = ? AND LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided') FOR UPDATE",
[$id]
)->getResultArray();
if ($this->hasActiveReimbursement($expense)) {
throw new \RuntimeException('Expense status cannot change after reimbursement without reversal.');
}
$success = $this->expenseModel->update($id, [
'status' => $status,
'status_reason' => $reason,
'approved_by' => $userId,
'updated_by' => $userId
]);
$success = $this->expenseModel->update($id, [
'status' => $status === 'denied' ? 'rejected' : $status,
'status_reason' => $reason,
'approved_by' => $status === 'approved' ? $userId : null,
'updated_by' => $userId
]);
if (!$success || $db->transStatus() === false) {
throw new \RuntimeException('Update failed');
}
$db->transCommit();
} catch (\Throwable $e) {
$db->transRollback();
log_message('error', 'Expense status update failed for ID ' . $id . ': ' . $e->getMessage());
return $this->response->setJSON(['error' => $e->getMessage()]);
}
if (!$success) {
log_message('error', 'Expense update failed for ID ' . $id);
@@ -260,6 +315,10 @@ class ExpenseController extends BaseController
throw PageNotFoundException::forPageNotFound("Expense #$id not found");
}
if ($this->hasActiveReimbursement($expense)) {
return redirect()->back()->with('error', 'Reimbursed expenses are immutable. Reverse the reimbursement and create a replacement expense.');
}
// same user list you use in create()
$users = $this->staffUsers();
@@ -308,14 +367,13 @@ class ExpenseController extends BaseController
$isDonation = ($category === 'Donation');
$userId = (int) (session()->get('user_id') ?? 0);
// Keep old receipt unless replaced or removed
$receiptName = $expense['receipt_path'];
$stagedReceipt = null;
if ($file && $file->isValid() && !$file->hasMoved() && ($file->getSize() ?? 0) > 0) {
$stored = $file->store('receipts');
$receiptName = basename($stored);
}
if ($this->request->getPost('remove_receipt') === '1') {
$receiptName = null;
try {
$stagedReceipt = $this->financialAttachmentService->stageUploadedFile($file, 'receipts');
} catch (\RuntimeException $e) {
return redirect()->back()->withInput()->with('error', $e->getMessage());
}
}
$updateData = [
@@ -325,9 +383,11 @@ class ExpenseController extends BaseController
'retailor' => trim((string) $this->request->getPost('retailor')) ?: null,
'date_of_purchase' => (string) $this->request->getPost('date_of_purchase') ?: null,
'purchased_by' => $purchasedById,
'receipt_path' => $receiptName,
'updated_by' => $userId,
];
if ($this->request->getPost('remove_receipt') === '1') {
$updateData['receipt_path'] = null;
}
if ($isDonation) {
$updateData['status'] = 'approved';
@@ -335,14 +395,89 @@ class ExpenseController extends BaseController
$updateData['approved_by'] = $userId ?: null;
$updateData['reimbursement_id'] = null;
} elseif (($expense['category'] ?? '') === 'Donation') {
// Moving a donation back to a reimbursable category: clear the marker.
// Moving a donation back to a reimbursable category must re-enter approval.
$updateData['status_reason'] = null;
$updateData['approved_by'] = $expense['approved_by'] ?? null;
$updateData['status'] = $expense['status'] ?? 'pending';
$updateData['approved_by'] = null;
$updateData['status'] = 'pending';
}
$this->expenseModel->update($id, $updateData);
$db = \Config\Database::connect();
$db->transBegin();
try {
$lockedExpense = $db->query('SELECT * FROM expenses WHERE id = ? FOR UPDATE', [$id])->getRowArray();
if (!$lockedExpense) {
throw new \RuntimeException('Expense not found.');
}
$activeReimbursements = $db->query(
"SELECT id FROM reimbursements WHERE expense_id = ? AND LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided') FOR UPDATE",
[$id]
)->getResultArray();
if ($activeReimbursements !== []) {
$protectedChanged = (
(float)$lockedExpense['amount'] !== (float)$updateData['amount']
|| (string)$lockedExpense['category'] !== (string)$updateData['category']
|| (int)$lockedExpense['purchased_by'] !== (int)$updateData['purchased_by']
|| array_key_exists('receipt_path', $updateData)
);
if ($protectedChanged) {
throw new \RuntimeException('Reimbursed expenses are immutable. Reverse the reimbursement and create a replacement expense.');
}
}
if (!$this->expenseModel->update($id, $updateData) || $db->transStatus() === false) {
throw new \RuntimeException('Expense update failed.');
}
$db->transCommit();
} catch (\Throwable $e) {
$db->transRollback();
$this->financialAttachmentService->discardStagedFile($stagedReceipt);
return redirect()->back()->withInput()->with('error', $e->getMessage());
}
if ($stagedReceipt !== null) {
try {
$receiptName = $this->financialAttachmentService->finalizeStagedFile($stagedReceipt);
if (!$this->expenseModel->update($id, ['receipt_path' => $receiptName])) {
throw new \RuntimeException('Expense receipt update failed.');
}
} catch (\Throwable $e) {
$this->financialAttachmentService->discardStagedFile($stagedReceipt);
log_message('critical', 'Expense receipt replacement incomplete for expense #' . $id . ': ' . $e->getMessage());
return redirect()->to('/expenses/index')->with('error', 'Expense updated, but receipt could not be finalized. Operations must review.');
}
}
return redirect()->to('/expenses/index')->with('success', 'Expense updated.');
}
private function hasActiveReimbursement(array $expense): bool
{
$expenseId = (int) ($expense['id'] ?? 0);
if ($expenseId <= 0) {
return false;
}
if (!empty($expense['reimbursement_id'])) {
$row = \Config\Database::connect()
->table('reimbursements')
->select('id')
->where('id', (int) $expense['reimbursement_id'])
->where("LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided')", null, false)
->get()
->getRowArray();
if ($row) {
return true;
}
}
$row = \Config\Database::connect()
->table('reimbursements')
->select('id')
->where('expense_id', $expenseId)
->where("LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided')", null, false)
->get()
->getRowArray();
return $row !== null;
}
}