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
+278 -126
View File
@@ -9,6 +9,7 @@ use App\Models\ConfigurationModel;
use App\Models\ReimbursementBatchModel;
use App\Models\ReimbursementBatchItemModel;
use App\Models\ReimbursementBatchAdminFileModel;
use App\Libraries\FinancialStatus;
use App\Services\EmailService;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\Files\UploadedFile;
@@ -127,10 +128,13 @@ class ReimbursementController extends BaseController
if ($year === '') {
return 1;
}
$record = $this->batchModel
->select('MAX(yearly_batch_number) AS max_number')
->where('school_year', $year)
->first();
$record = $this->db->query(
'SELECT COALESCE(MAX(yearly_batch_number), 0) AS max_number
FROM reimbursement_batches
WHERE school_year = ?
FOR UPDATE',
[$year]
)->getRowArray();
$max = (int) ($record['max_number'] ?? 0);
return $max + 1;
@@ -553,22 +557,38 @@ class ReimbursementController extends BaseController
$title = trim((string) ($this->request->getPost('title') ?? ''));
$userId = (int) (session()->get('user_id') ?? 0);
$now = date('Y-m-d H:i:s');
$sequence = $this->nextYearlyBatchNumberForSchoolYear();
$data = [
'title' => $title !== '' ? $title : null,
'status' => 'open',
'created_by' => $userId ?: null,
'opened_at' => $now,
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'yearly_batch_number' => $sequence,
];
$this->db->transBegin();
try {
$sequence = $this->nextYearlyBatchNumberForSchoolYear();
$data = [
'title' => $title !== '' ? $title : null,
'status' => 'open',
'created_by' => $userId ?: null,
'opened_at' => $now,
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'yearly_batch_number' => $sequence,
];
$this->batchModel->insert($data);
$batchId = (int) $this->batchModel->getInsertID();
if ($batchId <= 0) {
throw new \RuntimeException('Batch insert failed.');
}
$label = $title !== '' ? $title : 'Batch #' . $sequence;
if ($title === '' && !$this->batchModel->update($batchId, ['title' => $label])) {
throw new \RuntimeException('Batch title update failed.');
}
if (!$this->db->transStatus()) {
throw new \RuntimeException('Batch transaction failed.');
}
$this->db->transCommit();
} catch (\Throwable $e) {
$this->db->transRollback();
log_message('error', 'Failed to create reimbursement batch: {msg}', ['msg' => $e->getMessage()]);
return $this->response->setStatusCode(500)->setJSON([
'success' => false,
@@ -576,18 +596,6 @@ class ReimbursementController extends BaseController
]);
}
if ($batchId <= 0) {
return $this->response->setStatusCode(500)->setJSON([
'success' => false,
'error' => 'Failed to create batch.',
]);
}
$label = $title !== '' ? $title : 'Batch #' . $sequence;
if ($title === '') {
$this->batchModel->update($batchId, ['title' => $label]);
}
$newHash = function_exists('csrf_hash') ? csrf_hash() : null;
return $this->response
@@ -865,26 +873,55 @@ public function updateBatchAssignment()
}
}
$this->db->transBegin();
// Create reimbursement records for any batch items that don't yet have one
$items = $this->db->table('reimbursement_batch_items bi')
->select('bi.id AS batch_item_id, bi.reimbursement_id AS batch_reimb_id, bi.expense_id, e.amount, e.purchased_by, e.description, e.reimbursement_id AS expense_reimb_id, e.school_year AS expense_school_year, e.semester AS expense_semester')
->join('expenses e', 'e.id = bi.expense_id', 'inner')
->where('bi.batch_id', $batchId)
->where('bi.unassigned_at IS NULL', null, false)
->get()
->getResultArray();
$now = date('Y-m-d H:i:s');
$userId = (int) (session()->get('user_id') ?? 0);
$this->db->transBegin();
try {
$lockedBatch = $this->db->query('SELECT * FROM reimbursement_batches WHERE id = ? FOR UPDATE', [$batchId])->getRowArray();
if (!$lockedBatch || strtolower((string) ($lockedBatch['status'] ?? '')) !== 'open') {
throw new \RuntimeException('Batch is not open.');
}
$items = $this->db->query(
'SELECT bi.id AS batch_item_id,
bi.reimbursement_id AS batch_reimb_id,
bi.expense_id,
e.amount,
e.purchased_by,
e.category,
e.status AS expense_status,
e.description,
e.reimbursement_id AS expense_reimb_id,
e.school_year AS expense_school_year,
e.semester AS expense_semester
FROM reimbursement_batch_items bi
JOIN expenses e ON e.id = bi.expense_id
WHERE bi.batch_id = ?
AND bi.unassigned_at IS NULL
FOR UPDATE',
[$batchId]
)->getResultArray();
if ($items === []) {
throw new \RuntimeException('Batch has no active items.');
}
foreach ($items as $item) {
$expenseId = (int) ($item['expense_id'] ?? 0);
$recipientId = (int) ($item['purchased_by'] ?? 0);
if ($expenseId <= 0 || $recipientId <= 0) {
continue;
throw new \RuntimeException('Batch contains an invalid expense or recipient.');
}
if (FinancialStatus::normalize((string) ($item['expense_status'] ?? '')) !== 'approved') {
throw new \RuntimeException('Every batch expense must be approved before closing.');
}
if ((float) ($item['amount'] ?? 0) <= 0) {
throw new \RuntimeException('Every batch expense amount must be positive.');
}
if (strcasecmp((string) ($item['category'] ?? ''), 'Donation') === 0) {
throw new \RuntimeException('Donation expenses cannot be reimbursed.');
}
$reimbId = $item['batch_reimb_id'] ?: ($item['expense_reimb_id'] ?: $this->lookupReimbursementId($expenseId));
@@ -895,28 +932,31 @@ public function updateBatchAssignment()
'reimbursed_to' => $recipientId,
'approved_by' => $userId ?: null,
'description' => trim((string) ($item['description'] ?? '')),
'status' => 'Paid',
'status' => FinancialStatus::REIMBURSEMENT_PAID,
'added_by' => $userId ?: null,
'school_year' => $item['expense_school_year'] ?: $this->schoolYear,
'semester' => $item['expense_semester'] ?: $this->semester,
'reimbursement_method' => 'Check',
'batch_number' => $batchId,
'created_at' => $now,
'updated_at' => $now,
];
$reimbId = $this->reimbModel->insert($payload);
$reimbId = (int) $this->reimbModel->insert($payload);
if ($reimbId <= 0) {
throw new \RuntimeException('Failed to create reimbursement for batch item.');
}
}
if ($reimbId) {
$this->reimbModel->update($reimbId, [
'batch_number' => $batchId,
'approved_by' => $userId ?: null,
'status' => 'Paid',
]);
$this->expenseModel->update($expenseId, ['reimbursement_id' => $reimbId]);
if (!empty($item['batch_item_id'])) {
$this->batchItemModel->update((int) $item['batch_item_id'], ['reimbursement_id' => $reimbId]);
}
if (!$this->reimbModel->update($reimbId, [
'batch_number' => $batchId,
'approved_by' => $userId ?: null,
'status' => FinancialStatus::REIMBURSEMENT_PAID,
])) {
throw new \RuntimeException('Failed to update batch reimbursement.');
}
if (!$this->expenseModel->update($expenseId, ['reimbursement_id' => $reimbId])) {
throw new \RuntimeException('Failed to link batch expense reimbursement.');
}
if (empty($item['batch_item_id']) || !$this->batchItemModel->update((int) $item['batch_item_id'], ['reimbursement_id' => $reimbId])) {
throw new \RuntimeException('Failed to link batch item reimbursement.');
}
}
@@ -929,7 +969,9 @@ public function updateBatchAssignment()
$update['closed_by'] = $userId;
}
$this->batchModel->update($batchId, $update);
if (!$this->batchModel->update($batchId, $update)) {
throw new \RuntimeException('Failed to close reimbursement batch.');
}
} catch (\Throwable $e) {
$this->db->transRollback();
log_message('error', 'Failed to lock reimbursement batch #{batch}: {msg}', [
@@ -1979,15 +2021,6 @@ public function updateBatchAssignment()
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
if ($expenseId > 0) {
$expense = $this->expenseModel->find($expenseId);
if ($expense && strcasecmp($expense['category'] ?? '', 'Donation') === 0) {
return redirect()->back()->withInput()->with('errors', [
'expense_id' => 'Donation expenses are tracked but should not be reimbursed.'
]);
}
}
// Store file only if one was actually uploaded
try {
$receiptName = $this->saveReimbReceipt($this->request->getFile('receipt'));
@@ -2001,77 +2034,34 @@ public function updateBatchAssignment()
$userId = (int) (session()->get('user_id') ?? 0);
$recipientId = (int) $this->request->getPost('reimbursed_to');
// Mark reimbursement as Paid when recorded
$data = [
'expense_id' => $expenseId ?: null,
'amount' => $this->request->getPost('amount'),
'reimbursed_to' => $recipientId,
'description' => $this->request->getPost('description'),
'reimbursement_method' => $method,
'check_number' => $method === 'Check' ? $this->request->getPost('check_number') : null,
'receipt_path' => $receiptName, // may be null for Cash
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'added_by' => $userId,
'approved_by' => $userId,
'status' => 'Paid',
];
$this->reimbModel->insert($data);
$reimbursementId = $this->reimbModel->getInsertID();
if ($expenseId = $this->request->getPost('expense_id')) {
$this->expenseModel->update($expenseId, ['reimbursement_id' => $reimbursementId]);
try {
$this->createPaidReimbursementForExpense(
$expenseId,
(float) $this->request->getPost('amount'),
$recipientId,
$method,
$method === 'Check' ? (string) $this->request->getPost('check_number') : null,
$receiptName,
(string) $this->request->getPost('description'),
$userId
);
} catch (\Throwable $e) {
if ($receiptName !== null) {
@unlink(WRITEPATH . 'uploads' . DIRECTORY_SEPARATOR . 'reimbursements' . DIRECTORY_SEPARATOR . basename($receiptName));
}
log_message('error', 'Reimbursement creation failed: {msg}', ['msg' => $e->getMessage()]);
return redirect()->back()->withInput()->with('errors', [
'expense_id' => $e->getMessage(),
]);
}
return redirect()->to('/reimbursements')->with('success', 'Reimbursement recorded as Paid.');
}
// Optional old flow kept for compatibility (also sets Paid)
public function process()
{
$expenseId = (int) ($this->request->getPost('expense_id') ?? 0);
if ($expenseId > 0) {
$expense = $this->expenseModel->find($expenseId);
if ($expense && strcasecmp($expense['category'] ?? '', 'Donation') === 0) {
return redirect()->back()->withInput()->with('errors', [
'expense_id' => 'Donation expenses are tracked but should not be reimbursed.'
]);
}
}
try {
$receiptName = $this->saveReimbReceipt($this->request->getFile('receipt'));
} catch (\Throwable $e) {
log_message('error', 'Failed to save reimbursement receipt in process(): {msg}', ['msg' => $e->getMessage()]);
return redirect()->back()->withInput()->with('errors', [
'receipt' => 'Failed to save uploaded file. Please try again or contact admin.'
]);
}
$userId = (int) (session()->get('user_id') ?? 0);
$recipientId = (int) $this->request->getPost('reimbursed_to');
$reimbursementId = $this->reimbModel->insert([
'amount' => $this->request->getPost('amount'),
'reimbursed_to' => $recipientId,
'approved_by' => $userId,
'receipt_path' => $receiptName,
'description' => 'Expense reimbursement',
'status' => 'Paid',
'added_by' => $userId,
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'check_number' => $this->request->getPost('check_number'),
'reimbursement_method' => $this->request->getPost('reimbursement_method')
]);
$this->expenseModel->update($expenseId, [
'reimbursement_id' => $reimbursementId
]);
return redirect()->to('/reimbursements/under-processing')->with('success', 'Reimbursement processed!');
return $this->store();
}
public function reimbursedExpenses()
@@ -2118,6 +2108,9 @@ public function updateBatchAssignment()
if (!$reimb) {
throw PageNotFoundException::forPageNotFound("Reimbursement #$id not found");
}
if ($this->isPaidReimbursement($reimb)) {
return redirect()->to('/reimbursements')->with('error', 'Paid reimbursements are immutable. Reverse and replace the transaction instead.');
}
$users = $this->recipientOptions();
@@ -2136,6 +2129,9 @@ public function updateBatchAssignment()
if (!$reimb) {
throw PageNotFoundException::forPageNotFound("Reimbursement #$id not found");
}
if ($this->isPaidReimbursement($reimb)) {
return redirect()->to('/reimbursements')->with('error', 'Paid reimbursements are immutable. Reverse and replace the transaction instead.');
}
$methodRaw = (string) $this->request->getPost('reimbursement_method');
$method = ucfirst(strtolower($methodRaw));
@@ -2198,6 +2194,162 @@ public function updateBatchAssignment()
return redirect()->to('/reimbursements')->with('success', 'Reimbursement updated.');
}
public function reverse(int $id)
{
$reason = trim((string)$this->request->getPost('reason'));
if ($reason === '') {
return redirect()->back()->with('error', 'Reversal reason is required.');
}
$this->db->transBegin();
try {
$reimbursement = $this->db->query('SELECT * FROM reimbursements WHERE id = ? FOR UPDATE', [$id])->getRowArray();
if (!$reimbursement) {
throw new \RuntimeException('Reimbursement not found.');
}
if (FinancialStatus::normalizeReimbursementStatus($reimbursement['status'] ?? null) !== FinancialStatus::REIMBURSEMENT_PAID) {
throw new \RuntimeException('Only paid reimbursements can be reversed.');
}
$expenseId = (int)($reimbursement['expense_id'] ?? 0);
if ($expenseId > 0) {
$this->db->query('SELECT * FROM expenses WHERE id = ? FOR UPDATE', [$expenseId])->getRowArray();
}
$existingReversal = $this->db->table('reimbursement_reversals')
->where('reimbursement_id', $id)
->get()
->getRowArray();
if ($existingReversal) {
throw new \RuntimeException('Reimbursement has already been reversed.');
}
$amountCents = (int)round(((float)($reimbursement['amount'] ?? 0)) * 100);
$now = utc_now();
if (!$this->db->table('reimbursement_reversals')->insert([
'reimbursement_id' => $id,
'amount_cents' => $amountCents,
'reason' => $reason,
'reversed_by' => (int)(session()->get('user_id') ?? 0) ?: null,
'reversed_at' => $now,
'created_at' => $now,
])) {
throw new \RuntimeException('Reimbursement reversal could not be recorded.');
}
if (!$this->reimbModel->update($id, ['status' => FinancialStatus::REIMBURSEMENT_REVERSED])) {
throw new \RuntimeException('Reimbursement status could not be updated.');
}
if ($expenseId > 0 && !$this->expenseModel->update($expenseId, ['reimbursement_id' => null])) {
throw new \RuntimeException('Expense reimbursement link could not be cleared.');
}
if ($this->db->transStatus() === false) {
throw new \RuntimeException('Reimbursement reversal transaction failed.');
}
$this->db->transCommit();
} catch (\Throwable $e) {
$this->db->transRollback();
return redirect()->back()->with('error', $e->getMessage());
}
return redirect()->to('/reimbursements')->with('success', 'Reimbursement reversed.');
}
private function createPaidReimbursementForExpense(
int $expenseId,
float $amount,
int $recipientId,
string $method,
?string $checkNumber,
?string $receiptName,
string $description,
int $userId,
?int $batchId = null
): int {
if ($expenseId <= 0) {
throw new \RuntimeException('A valid approved expense is required.');
}
$this->db->transBegin();
try {
$expense = $this->db->query('SELECT * FROM expenses WHERE id = ? FOR UPDATE', [$expenseId])->getRowArray();
if (!$expense) {
throw new \RuntimeException('Expense not found.');
}
if (FinancialStatus::normalize((string) ($expense['status'] ?? '')) !== 'approved') {
throw new \RuntimeException('Expense must be approved before reimbursement.');
}
if ((float) ($expense['amount'] ?? 0) <= 0 || $amount <= 0) {
throw new \RuntimeException('Reimbursement amount must be positive.');
}
if (abs((float) ($expense['amount'] ?? 0) - $amount) > 0.005) {
throw new \RuntimeException('Reimbursement amount must match the approved expense.');
}
if (strcasecmp((string) ($expense['category'] ?? ''), 'Donation') === 0) {
throw new \RuntimeException('Donation expenses are tracked but should not be reimbursed.');
}
if ($recipientId !== (int) ($expense['purchased_by'] ?? 0)) {
throw new \RuntimeException('Reimbursement recipient must match the expense purchaser.');
}
if ((string) ($expense['school_year'] ?? '') !== (string) $this->schoolYear) {
throw new \RuntimeException('Expense is outside the active school year.');
}
if (!empty($expense['semester']) && (string) $expense['semester'] !== (string) $this->semester) {
throw new \RuntimeException('Expense is outside the active semester.');
}
$active = $this->db->query(
"SELECT id FROM reimbursements
WHERE expense_id = ?
AND LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided')
FOR UPDATE",
[$expenseId]
)->getRowArray();
if ($active || !empty($expense['reimbursement_id'])) {
throw new \RuntimeException('Expense already has an active reimbursement.');
}
$reimbursementId = (int) $this->reimbModel->insert([
'expense_id' => $expenseId,
'amount' => $amount,
'reimbursed_to' => $recipientId,
'description' => $description !== '' ? $description : (string) ($expense['description'] ?? ''),
'reimbursement_method' => $method,
'check_number' => $checkNumber,
'receipt_path' => $receiptName,
'school_year' => $expense['school_year'] ?: $this->schoolYear,
'semester' => $expense['semester'] ?: $this->semester,
'added_by' => $userId ?: null,
'approved_by' => $userId ?: null,
'status' => FinancialStatus::REIMBURSEMENT_PAID,
'batch_number' => $batchId,
]);
if ($reimbursementId <= 0) {
throw new \RuntimeException('Reimbursement insert failed.');
}
if (!$this->expenseModel->update($expenseId, ['reimbursement_id' => $reimbursementId])) {
throw new \RuntimeException('Expense reimbursement link failed.');
}
if (!$this->db->transStatus()) {
throw new \RuntimeException('Reimbursement transaction failed.');
}
$this->db->transCommit();
return $reimbursementId;
} catch (\Throwable $e) {
$this->db->transRollback();
throw $e;
}
}
private function isPaidReimbursement(array $reimbursement): bool
{
return FinancialStatus::normalizeReimbursementStatus((string) ($reimbursement['status'] ?? '')) === FinancialStatus::REIMBURSEMENT_PAID;
}
private function lookupReimbursementId(int $expenseId): ?int
{
if ($expenseId <= 0) {