@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user