Files
2026-05-16 13:44:12 -04:00

2118 lines
85 KiB
PHP
Executable File

<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\ReimbursementModel;
use App\Models\ExpenseModel;
use App\Models\UserModel;
use App\Models\ConfigurationModel;
use App\Models\ReimbursementBatchModel;
use App\Models\ReimbursementBatchItemModel;
use App\Models\ReimbursementBatchAdminFileModel;
use App\Services\EmailService;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\Files\UploadedFile;
use ZipArchive;
class ReimbursementController extends BaseController
{
protected $db;
protected $configModel;
protected $semester;
protected $schoolYear;
protected $expenseModel;
protected $reimbModel;
protected $userModel;
protected $batchModel;
protected $batchItemModel;
protected $batchAdminFileModel;
public const SPECIAL_RECIPIENTS = [
// Reserved virtual recipients that should always be selectable.
990001 => 'Masjid',
990002 => 'Donation',
];
public function __construct()
{
$this->db = \Config\Database::connect();
if (!$this->db->connect()) {
log_message('error', 'Database connection failed.');
throw new \Exception('Database connection failed.');
}
$this->configModel = new ConfigurationModel();
$this->expenseModel = new ExpenseModel();
$this->reimbModel = new ReimbursementModel();
$this->userModel = new UserModel();
$this->batchModel = new ReimbursementBatchModel();
$this->batchItemModel = new ReimbursementBatchItemModel();
$this->batchAdminFileModel = new ReimbursementBatchAdminFileModel();
$this->semester = $this->configModel->getConfig('semester') ?? 'Fall';
$this->schoolYear = $this->configModel->getConfig('school_year') ?? date('Y');
}
/**
* Return staff users (admins/teachers/etc.) excluding parents/guests.
*/
private function staffUsers(): array
{
$rows = $this->userModel
->select('users.id, users.firstname, users.lastname, roles.name AS role_name')
->join('user_roles', 'user_roles.user_id = users.id', 'left')
->join('roles', 'roles.id = user_roles.role_id', 'left')
->where('roles.name IS NOT NULL', null, false)
->findAll();
$excludedRoles = array_map('strtolower', ['parent', 'student', 'guest']);
$staff = [];
foreach ($rows as $row) {
$roleName = strtolower((string) ($row['role_name'] ?? ''));
$id = (int) ($row['id'] ?? 0);
if ($id <= 0 || in_array($roleName, $excludedRoles, true)) {
continue;
}
if (!isset($staff[$id])) {
$staff[$id] = [
'id' => $id,
'firstname' => $row['firstname'] ?? '',
'lastname' => $row['lastname'] ?? '',
];
}
}
uasort($staff, static function ($a, $b) {
$nameA = trim(($a['firstname'] ?? '') . ' ' . ($a['lastname'] ?? ''));
$nameB = trim(($b['firstname'] ?? '') . ' ' . ($b['lastname'] ?? ''));
return strcasecmp($nameA, $nameB);
});
return array_values($staff);
}
private function fetchRecipientUsers(): array
{
return $this->staffUsers();
}
private function appendSpecialRecipients(array $users): array
{
$existingIds = array_map(static function ($row) {
return isset($row['id']) && is_numeric($row['id']) ? (int) $row['id'] : null;
}, $users);
foreach (self::SPECIAL_RECIPIENTS as $id => $label) {
if (!in_array($id, $existingIds, true)) {
$users[] = [
'id' => $id,
'firstname' => $label,
'lastname' => '',
];
}
}
return $users;
}
private function recipientOptions(): array
{
return $this->appendSpecialRecipients($this->fetchRecipientUsers());
}
private function nextYearlyBatchNumberForSchoolYear(?string $schoolYear = null): int
{
$year = trim((string) ($schoolYear ?? $this->schoolYear));
if ($year === '') {
return 1;
}
$record = $this->batchModel
->select('MAX(yearly_batch_number) AS max_number')
->where('school_year', $year)
->first();
$max = (int) ($record['max_number'] ?? 0);
return $max + 1;
}
private function adminRoster(): array
{
$admins = $this->staffUsers();
if (empty($admins)) {
$admins = $this->fetchRecipientUsers();
}
return $admins;
}
private function specialRecipientLabel($value): ?string
{
if ($value === null || !is_numeric($value)) {
return null;
}
$id = (int) $value;
return self::SPECIAL_RECIPIENTS[$id] ?? null;
}
/**
* Ensure a subdirectory exists under WRITEPATH . 'uploads'.
* Returns the absolute path to the subdirectory.
*/
private function ensureUploadSubdir(string $subdir): string
{
$base = rtrim(WRITEPATH, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'uploads';
$dir = $base . DIRECTORY_SEPARATOR . trim($subdir, '/\\');
if (!is_dir($dir)) {
// Try to create the directory tree if missing
if (!@mkdir($dir, 0755, true) && !is_dir($dir)) {
log_message('error', 'Failed to create upload directory: ' . $dir);
throw new \RuntimeException('Upload directory is not available.');
}
}
if (!is_writable($dir)) {
log_message('error', 'Upload directory not writable: ' . $dir);
throw new \RuntimeException('Upload directory is not writable.');
}
return $dir;
}
/**
* Ensure a subdirectory exists under FCPATH . 'uploads'.
* Returns the absolute path to the subdirectory.
*/
/**
* Save a reimbursement receipt to writable/uploads/reimbursements and
* return the stored filename (basename). Returns null if no valid file provided.
*/
private function saveReimbReceipt($file): ?string
{
if (!$file instanceof UploadedFile) {
return null;
}
if (!$file->isValid() || $file->hasMoved()) {
return null;
}
// Skip zero-length inputs (when no file chosen for optional uploads)
$size = (int) ($file->getSize() ?? 0);
if ($size <= 0) {
return null;
}
// Ensure target directory exists and is writable
$this->ensureUploadSubdir('reimbursements');
try {
// Prefer CI's store() API which places the file under WRITEPATH/uploads/<subdir>
$stored = $file->store('reimbursements');
if (!$stored) {
throw new \RuntimeException('store() returned empty result');
}
return basename($stored);
} catch (\Throwable $e) {
log_message('error', 'Reimbursement receipt store() failed: {message}', ['message' => $e->getMessage()]);
// Fallback to move() with a generated filename
$ext = $file->getClientExtension() ?: pathinfo($file->getName(), PATHINFO_EXTENSION);
$ext = $ext ? ('.' . strtolower($ext)) : '';
$newName = 'reimb_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . $ext;
$ok = $file->move(WRITEPATH . 'uploads' . DIRECTORY_SEPARATOR . 'reimbursements', $newName, true);
if (!$ok) {
log_message('error', 'Failed to move reimbursement receipt to reimbursements directory.');
throw new \RuntimeException('Unable to save uploaded file.');
}
return $newName;
}
}
private function buildReimbursementQuery($filters = [])
{
return $this->expenseModel->getReimbursedExpensesWithDetails($filters);
}
public function underProcessing()
{
$pendingQuery = $this->db->table('expenses e');
$pendingQuery->select('
e.id AS expense_id,
e.amount AS expense_amount,
e.description,
e.receipt_path AS expense_receipt,
e.retailor,
e.purchased_by,
u.firstname AS purchaser_firstname,
u.lastname AS purchaser_lastname
');
$pendingQuery->join('users u', 'u.id = e.purchased_by', 'left');
$pendingQuery->join('reimbursement_batch_items bi', 'bi.expense_id = e.id AND bi.unassigned_at IS NULL', 'left');
$pendingQuery->where('e.reimbursement_id IS NULL', null, false);
$pendingQuery->where('e.category !=', 'Donation');
$pendingQuery->groupStart()
->where('e.status', 'approved')
->orWhere('e.status', 'Approved')
->orWhere('e.status', 'APPROVED')
->groupEnd();
$pendingQuery->where('bi.id IS NULL', null, false);
$pendingQuery->orderBy('e.created_at', 'DESC');
$pendingRows = $pendingQuery->get()->getResultArray();
$pendingItems = [];
$itemsMap = [];
foreach ($pendingRows as $row) {
$expenseId = (int) ($row['expense_id'] ?? 0);
if ($expenseId <= 0) {
continue;
}
$receiptUrl = $this->expenseReceiptUrl($row['expense_receipt'] ?? null);
$fullName = trim(($row['purchaser_firstname'] ?? '') . ' ' . ($row['purchaser_lastname'] ?? '')) ?: 'Unknown';
$item = [
'id' => $expenseId,
'expense_id' => $expenseId,
'reimbursement_id' => null,
'expense_amount' => (float) ($row['expense_amount'] ?? 0),
'vendor' => (string) ($row['retailor'] ?? ''),
'description' => trim((string) ($row['description'] ?? '')),
'purchased_by' => $fullName,
'receipt_url' => $receiptUrl,
'batch_id' => null,
'batch_label' => null,
'admin_id' => null,
'admin_name' => null,
'batch_number' => 0,
];
$pendingItems[] = $item;
$itemsMap[$expenseId] = $item;
}
$assignedQuery = $this->db->table('reimbursement_batch_items bi');
$assignedQuery->select('
bi.id AS batch_item_id,
bi.batch_id,
bi.admin_id,
bi.reimbursement_id,
bi.expense_id,
b.title AS batch_title,
b.status AS batch_status,
b.yearly_batch_number AS batch_year_number,
e.amount AS expense_amount,
e.description,
e.receipt_path AS expense_receipt,
e.retailor,
purchaser.firstname AS purchaser_firstname,
purchaser.lastname AS purchaser_lastname,
admin.firstname AS admin_firstname,
admin.lastname AS admin_lastname
');
$assignedQuery->join('reimbursement_batches b', 'b.id = bi.batch_id', 'inner');
$assignedQuery->join('expenses e', 'e.id = bi.expense_id', 'inner');
$assignedQuery->join('users purchaser', 'purchaser.id = e.purchased_by', 'left');
$assignedQuery->join('users admin', 'admin.id = bi.admin_id', 'left');
$assignedQuery->where('b.status', 'open');
$assignedQuery->where('bi.unassigned_at IS NULL', null, false);
$assignedQuery->where('e.reimbursement_id IS NULL', null, false);
$assignedQuery->where('e.category !=', 'Donation');
$assignedQuery->groupStart()
->where('e.status', 'approved')
->orWhere('e.status', 'Approved')
->orWhere('e.status', 'APPROVED')
->groupEnd();
$assignedQuery->orderBy('b.opened_at', 'ASC');
$assignedQuery->orderBy('bi.assigned_at', 'ASC');
$assignedRows = $assignedQuery->get()->getResultArray();
$batchStructures = [];
foreach ($assignedRows as $row) {
$expenseId = (int) ($row['expense_id'] ?? 0);
$batchId = (int) ($row['batch_id'] ?? 0);
if ($expenseId <= 0 || $batchId <= 0) {
continue;
}
$receiptUrl = $this->expenseReceiptUrl($row['expense_receipt'] ?? null);
$purchaser = trim(($row['purchaser_firstname'] ?? '') . ' ' . ($row['purchaser_lastname'] ?? '')) ?: 'Unknown';
$yearlyNumber = (int) ($row['batch_year_number'] ?? 0);
$batchLabel = trim((string) ($row['batch_title'] ?? ''));
if ($batchLabel === '') {
$fallbackNumber = $yearlyNumber > 0 ? $yearlyNumber : $batchId;
$batchLabel = 'Batch #' . $fallbackNumber;
}
$adminId = $row['admin_id'] ? (int) $row['admin_id'] : 0;
$adminName = $adminId > 0
? (trim(($row['admin_firstname'] ?? '') . ' ' . ($row['admin_lastname'] ?? '')) ?: 'Admin')
: 'Unassigned';
$batchStatus = strtolower(trim((string) ($row['batch_status'] ?? ''))) ?: 'open';
$item = [
'id' => $expenseId,
'expense_id' => $expenseId,
'reimbursement_id' => $row['reimbursement_id'] ? (int) $row['reimbursement_id'] : null,
'expense_amount' => (float) ($row['expense_amount'] ?? 0),
'vendor' => (string) ($row['retailor'] ?? ''),
'description' => trim((string) ($row['description'] ?? '')),
'purchased_by' => $purchaser,
'receipt_url' => $receiptUrl,
'batch_id' => $batchId,
'batch_label' => $batchLabel,
'admin_id' => $adminId ?: null,
'admin_name' => $adminId > 0 ? $adminName : null,
'batch_number' => $batchId,
];
$itemsMap[$expenseId] = $item;
if (!isset($batchStructures[$batchId])) {
$batchStructures[$batchId] = [
'batchId' => $batchId,
'label' => $batchLabel,
'slots' => [],
'status' => $batchStatus,
'yearly_batch_number'=> $yearlyNumber,
];
} else {
$batchStructures[$batchId]['status'] = $batchStatus;
if ($yearlyNumber > 0) {
$batchStructures[$batchId]['yearly_batch_number'] = $yearlyNumber;
}
}
$slotKey = $adminId ?: 0;
if (!isset($batchStructures[$batchId]['slots'][$slotKey])) {
$batchStructures[$batchId]['slots'][$slotKey] = [
'admin_id' => $adminId ?: 0,
'admin_name' => $adminId > 0 ? $adminName : 'Unassigned',
'items' => [],
'check_file' => null,
];
}
$batchStructures[$batchId]['slots'][$slotKey]['items'][] = $expenseId;
}
$adminFileMap = [];
if (!empty($batchStructures)) {
$batchIds = array_keys($batchStructures);
$files = $this->batchAdminFileModel
->select('batch_id, admin_id, filename, original_filename')
->whereIn('batch_id', $batchIds)
->findAll();
foreach ($files as $file) {
$batchId = (int) ($file['batch_id'] ?? 0);
$adminId = (int) ($file['admin_id'] ?? 0);
if ($batchId <= 0) {
continue;
}
$adminFileMap[$batchId][$adminId] = [
'filename' => $file['filename'],
'original_filename' => $file['original_filename'] ?? null,
'url' => base_url('reimbursements/batch/admin-file/' . rawurlencode($file['filename']) . '/inline'),
];
}
}
foreach ($batchStructures as $batchId => &$batch) {
foreach ($batch['slots'] as $slotKey => &$slot) {
$adminId = $slot['admin_id'] ?? 0;
$slot['check_file'] = $adminFileMap[$batchId][$adminId] ?? null;
}
unset($slot);
}
unset($batch);
$existingBatches = array_map(static function (array $batch) {
$normalizedSlots = array_map(static function (array $slot) {
$slot['items'] = array_values(array_map('intval', $slot['items']));
return $slot;
}, array_values($batch['slots']));
$itemIds = [];
foreach ($normalizedSlots as $slot) {
$itemIds = array_merge($itemIds, $slot['items']);
}
return [
'batchId' => $batch['batchId'],
'batchNumber' => $batch['batchId'],
'label' => $batch['label'],
'sequence' => $batch['yearly_batch_number'] ?? 0,
'yearly_batch_number'=> $batch['yearly_batch_number'] ?? 0,
'slots' => $normalizedSlots,
'itemIds' => array_values(array_unique($itemIds)),
'status' => $batch['status'] ?? 'open',
];
}, array_values($batchStructures));
$admins = $this->adminRoster();
return view('reimbursements/under_processing', [
'pendingItems' => $pendingItems,
'existingBatches' => $existingBatches,
'adminUsers' => $admins,
'itemsPayload' => array_values($itemsMap),
'createBatchUrl' => base_url('reimbursements/batch/create'),
'checkUploadUrl' => base_url('reimbursements/batch/admin-file/upload'),
]);
}
public function markDonation()
{
if (strtolower($this->request->getMethod()) !== 'post') {
return $this->response->setStatusCode(405)->setJSON([
'success' => false,
'error' => 'Method not allowed',
]);
}
$expenseId = (int) $this->request->getPost('expense_id');
if ($expenseId <= 0) {
return $this->response->setStatusCode(422)->setJSON([
'success' => false,
'error' => 'Invalid expense id.',
]);
}
$expense = $this->expenseModel->find($expenseId);
if (!$expense) {
return $this->response->setStatusCode(404)->setJSON([
'success' => false,
'error' => 'Expense not found.',
]);
}
if (!empty($expense['reimbursement_id'])) {
return $this->response->setStatusCode(409)->setJSON([
'success' => false,
'error' => 'Expense is already tied to a reimbursement.',
]);
}
$userId = (int) (session()->get('user_id') ?? 0);
$now = date('Y-m-d H:i:s');
$this->db->transBegin();
try {
$this->batchItemModel
->where('expense_id', $expenseId)
->where('unassigned_at IS NULL', null, false)
->set('unassigned_at', $now)
->update();
$this->expenseModel->update($expenseId, [
'category' => 'Donation',
'status' => 'approved',
'status_reason' => 'Marked as Donation (non-reimbursable).',
'approved_by' => $userId ?: null,
'updated_by' => $userId ?: null,
'reimbursement_id'=> null,
]);
} catch (\Throwable $e) {
$this->db->transRollback();
log_message('error', 'Failed to mark expense #{expense} as donation: {msg}', [
'expense' => $expenseId,
'msg' => $e->getMessage(),
]);
return $this->response->setStatusCode(500)->setJSON([
'success' => false,
'error' => 'Unable to mark donation right now.',
]);
}
if (!$this->db->transStatus()) {
$this->db->transRollback();
return $this->response->setStatusCode(500)->setJSON([
'success' => false,
'error' => 'Unable to mark donation right now.',
]);
}
$this->db->transCommit();
$newHash = function_exists('csrf_hash') ? csrf_hash() : null;
return $this->response
->setHeader('X-CSRF-HASH', (string) $newHash)
->setJSON([
'success' => true,
'csrf_hash' => $newHash,
]);
}
public function createBatch()
{
if (strtolower($this->request->getMethod()) !== 'post') {
return $this->response->setStatusCode(405)->setJSON([
'success' => false,
'error' => 'Method not allowed',
]);
}
$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,
];
try {
$this->batchModel->insert($data);
$batchId = (int) $this->batchModel->getInsertID();
} catch (\Throwable $e) {
log_message('error', 'Failed to create reimbursement batch: {msg}', ['msg' => $e->getMessage()]);
return $this->response->setStatusCode(500)->setJSON([
'success' => false,
'error' => 'Unable to create batch right now.',
]);
}
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
->setHeader('X-CSRF-HASH', (string) $newHash)
->setJSON([
'success' => true,
'batch_id' => $batchId,
'label' => $label,
'sequence' => $sequence,
'csrf_hash'=> $newHash,
]);
}
public function updateBatchAssignment()
{
if (strtolower($this->request->getMethod()) !== 'post') {
return $this->response->setStatusCode(405)->setJSON([
'success' => false,
'error' => 'Method not allowed',
]);
}
$expenseId = (int) $this->request->getPost('expense_id');
$batchIdRaw = $this->request->getPost('batch_id');
$batchId = (int) $batchIdRaw;
$fallbackBatchNumber = $this->request->getPost('batch_number');
if ($batchId <= 0 && $fallbackBatchNumber !== null && trim((string) $fallbackBatchNumber) !== '') {
$batchId = (int) $fallbackBatchNumber;
}
$adminIdRaw = $this->request->getPost('admin_id');
$adminId = ($adminIdRaw === '' || $adminIdRaw === null) ? null : (int) $adminIdRaw;
$reimbursementId = (int) $this->request->getPost('reimbursement_id') ?: null;
if ($expenseId <= 0) {
return $this->response->setStatusCode(422)->setJSON([
'success' => false,
'error' => 'Invalid expense id.',
]);
}
$now = date('Y-m-d H:i:s');
$activeItem = $this->batchItemModel
->where('expense_id', $expenseId)
->where('unassigned_at IS NULL', null, false)
->first();
if ($batchId <= 0) {
if ($activeItem) {
$this->batchItemModel->update((int) $activeItem['id'], ['unassigned_at' => $now]);
if (!$reimbursementId && !empty($activeItem['reimbursement_id'])) {
$reimbursementId = (int) $activeItem['reimbursement_id'];
}
}
$newHash = function_exists('csrf_hash') ? csrf_hash() : null;
return $this->response
->setHeader('X-CSRF-HASH', (string) $newHash)
->setJSON([
'success' => true,
'batch_id' => 0,
'admin_id' => null,
'reimbursement_id' => $reimbursementId,
'csrf_hash' => $newHash,
]);
}
$batch = $this->batchModel->find($batchId);
if (!$batch || strtolower((string) ($batch['status'] ?? 'open')) !== 'open') {
return $this->response->setStatusCode(404)->setJSON([
'success' => false,
'error' => 'Batch not found or already closed.',
]);
}
$currentAdmin = $activeItem ? (int) ($activeItem['admin_id'] ?? 0) : null;
$activeSameBatch = $activeItem && (int) ($activeItem['batch_id'] ?? 0) === $batchId;
if ($activeSameBatch && ($currentAdmin === ($adminId ?? 0))) {
$newHash = function_exists('csrf_hash') ? csrf_hash() : null;
return $this->response
->setHeader('X-CSRF-HASH', (string) $newHash)
->setJSON([
'success' => true,
'batch_id' => $batchId,
'admin_id' => $adminId,
'reimbursement_id' => $activeItem['reimbursement_id'] ?? null,
'csrf_hash' => $newHash,
]);
}
if ($activeSameBatch) {
if (!$reimbursementId) {
$reimbursementId = $activeItem['reimbursement_id'] ? (int) $activeItem['reimbursement_id'] : $this->lookupReimbursementId($expenseId);
}
$updatePayload = [
'admin_id' => $adminId,
'assigned_at' => $now,
'reimbursement_id' => $reimbursementId,
'unassigned_at' => $adminId === null ? $now : null,
];
$this->batchItemModel->update((int) $activeItem['id'], $updatePayload);
$newHash = function_exists('csrf_hash') ? csrf_hash() : null;
return $this->response
->setHeader('X-CSRF-HASH', (string) $newHash)
->setJSON([
'success' => true,
'batch_id' => $batchId,
'admin_id' => $adminId,
'reimbursement_id' => $reimbursementId,
'csrf_hash' => $newHash,
]);
}
if ($activeItem) {
$this->batchItemModel->update((int) $activeItem['id'], ['unassigned_at' => $now]);
if (!$reimbursementId && !empty($activeItem['reimbursement_id'])) {
$reimbursementId = (int) $activeItem['reimbursement_id'];
}
}
if (!$reimbursementId) {
$reimbursementId = $this->lookupReimbursementId($expenseId);
}
$insertData = [
'batch_id' => $batchId,
'expense_id' => $expenseId,
'reimbursement_id' => $reimbursementId,
'admin_id' => $adminId,
'assigned_at' => $now,
'school_year' => $this->schoolYear,
'semester' => $this->semester,
];
try {
$this->batchItemModel->insert($insertData);
} catch (\Throwable $e) {
log_message('error', 'Failed to assign expense #{expense} to batch #{batch}: {msg}', [
'expense' => $expenseId,
'batch' => $batchId,
'msg' => $e->getMessage(),
]);
return $this->response->setStatusCode(500)->setJSON([
'success' => false,
'error' => 'Unable to update batch assignment right now.',
]);
}
$newHash = function_exists('csrf_hash') ? csrf_hash() : null;
return $this->response
->setHeader('X-CSRF-HASH', (string) $newHash)
->setJSON([
'success' => true,
'batch_id' => $batchId,
'admin_id' => $adminId,
'reimbursement_id' => $reimbursementId,
'csrf_hash' => $newHash,
]);
}
public function lockBatch()
{
if (strtolower($this->request->getMethod()) !== 'post') {
return $this->response->setStatusCode(405)->setJSON([
'success' => false,
'error' => 'Method not allowed',
]);
}
$batchId = (int) $this->request->getPost('batch_id');
if ($batchId <= 0) {
return $this->response->setStatusCode(422)->setJSON([
'success' => false,
'error' => 'Invalid batch id.',
]);
}
$batch = $this->batchModel->find($batchId);
if (!$batch || strtolower((string) ($batch['status'] ?? '')) !== 'open') {
return $this->response->setStatusCode(404)->setJSON([
'success' => false,
'error' => 'Batch not found or already closed.',
]);
}
$adminRows = $this->batchItemModel
->select('DISTINCT COALESCE(admin_id, 0) AS admin_id')
->where('batch_id', $batchId)
->where('unassigned_at IS NULL', null, false)
->get()
->getResultArray();
$adminIds = array_values(array_unique(array_map(static fn ($row) => isset($row['admin_id']) ? (int) $row['admin_id'] : 0, $adminRows)));
$requiredAdmins = array_filter($adminIds, static fn ($adminId) => $adminId > 0);
if (!empty($requiredAdmins)) {
$uploadedFiles = $this->batchAdminFileModel
->select('admin_id')
->where('batch_id', $batchId)
->whereIn('admin_id', $requiredAdmins)
->findAll();
$uploadedAdminIds = array_values(array_unique(array_map(static fn ($file) => isset($file['admin_id']) ? (int) $file['admin_id'] : 0, $uploadedFiles)));
foreach ($requiredAdmins as $adminId) {
if (!in_array($adminId, $uploadedAdminIds, true)) {
return $this->response->setStatusCode(409)->setJSON([
'success' => false,
'error' => 'All admin sections must have a check file before submitting the batch.',
]);
}
}
}
$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);
try {
foreach ($items as $item) {
$expenseId = (int) ($item['expense_id'] ?? 0);
$recipientId = (int) ($item['purchased_by'] ?? 0);
if ($expenseId <= 0 || $recipientId <= 0) {
continue;
}
$reimbId = $item['batch_reimb_id'] ?: ($item['expense_reimb_id'] ?: $this->lookupReimbursementId($expenseId));
if (!$reimbId) {
$payload = [
'expense_id' => $expenseId,
'amount' => (float) ($item['amount'] ?? 0),
'reimbursed_to' => $recipientId,
'approved_by' => $userId ?: null,
'description' => trim((string) ($item['description'] ?? '')),
'status' => '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);
}
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]);
}
}
}
$update = [
'status' => 'closed',
'closed_at' => $now,
];
if ($userId > 0) {
$update['closed_by'] = $userId;
}
$this->batchModel->update($batchId, $update);
} catch (\Throwable $e) {
$this->db->transRollback();
log_message('error', 'Failed to lock reimbursement batch #{batch}: {msg}', [
'batch' => $batchId,
'msg' => $e->getMessage(),
]);
return $this->response->setStatusCode(500)->setJSON([
'success' => false,
'error' => 'Unable to lock batch right now.',
]);
}
if (!$this->db->transStatus()) {
$this->db->transRollback();
return $this->response->setStatusCode(500)->setJSON([
'success' => false,
'error' => 'Unable to lock batch right now.',
]);
}
$this->db->transCommit();
$newHash = function_exists('csrf_hash') ? csrf_hash() : null;
return $this->response
->setHeader('X-CSRF-HASH', (string) $newHash)
->setJSON([
'success' => true,
'batch_id' => $batchId,
'status' => 'closed',
'csrf_hash'=> $newHash,
]);
}
public function uploadBatchAdminFile()
{
if (strtolower($this->request->getMethod()) !== 'post') {
return $this->response->setStatusCode(405)->setJSON([
'success' => false,
'error' => 'Method not allowed',
]);
}
$batchId = (int) $this->request->getPost('batch_id');
$adminId = (int) $this->request->getPost('admin_id');
$file = $this->request->getFile('check_file');
if ($batchId <= 0 || $adminId < 0) {
return $this->response->setStatusCode(422)->setJSON([
'success' => false,
'error' => 'Invalid batch or admin specified.',
]);
}
if (!$file instanceof UploadedFile || !$file->isValid()) {
return $this->response->setStatusCode(422)->setJSON([
'success' => false,
'error' => 'Please provide a valid file.',
]);
}
$batch = $this->batchModel->find($batchId);
if (!$batch) {
return $this->response->setStatusCode(404)->setJSON([
'success' => false,
'error' => 'Batch not found.',
]);
}
if (strtolower((string) ($batch['status'] ?? '')) !== 'open') {
return $this->response->setStatusCode(409)->setJSON([
'success' => false,
'error' => 'Cannot upload files for a locked batch.',
]);
}
$uploadDir = $this->ensureUploadSubdir('reimbursements');
$newName = $file->getRandomName();
try {
$file->move($uploadDir, $newName);
} catch (\Throwable $e) {
log_message('error', 'Failed to save batch check file: {msg}', ['msg' => $e->getMessage()]);
return $this->response->setStatusCode(500)->setJSON([
'success' => false,
'error' => 'Unable to store the uploaded file.',
]);
}
$existing = $this->batchAdminFileModel
->where('batch_id', $batchId)
->where('admin_id', $adminId)
->first();
if ($existing && !empty($existing['filename'])) {
$oldFile = $uploadDir . DIRECTORY_SEPARATOR . $existing['filename'];
if (is_file($oldFile)) {
@unlink($oldFile);
}
}
$now = date('Y-m-d H:i:s');
$userId = (int) (session()->get('user_id') ?? 0);
$payload = [
'batch_id' => $batchId,
'admin_id' => $adminId,
'filename' => $newName,
'original_filename'=> $file->getClientName(),
'uploaded_at' => $now,
'uploaded_by' => $userId ?: null,
];
if ($existing) {
$this->batchAdminFileModel->update((int) $existing['id'], $payload);
} else {
$this->batchAdminFileModel->insert($payload);
}
$newHash = function_exists('csrf_hash') ? csrf_hash() : null;
return $this->response
->setHeader('X-CSRF-HASH', (string) $newHash)
->setJSON([
'success' => true,
'batch_id' => $batchId,
'admin_id' => $adminId,
'filename' => $newName,
'original_filename' => $file->getClientName(),
'url' => base_url('reimbursements/batch/admin-file/' . rawurlencode($newName) . '/inline'),
'csrf_hash' => $newHash,
]);
}
public function serveAdminCheckFile(string $filename, string $mode = 'inline')
{
$safeName = basename(trim($filename));
if ($safeName === '') {
throw new PageNotFoundException('File not specified.');
}
$path = $this->ensureUploadSubdir('reimbursements') . DIRECTORY_SEPARATOR . $safeName;
if (!is_file($path)) {
throw new PageNotFoundException('File not found.');
}
$mime = mime_content_type($path) ?: 'application/octet-stream';
$disposition = strtolower(trim((string) $mode)) === 'download' ? 'attachment' : 'inline';
// Use CI's download helper to ensure correct headers/output; override to allow inline display
return $this->response
->download($path, null)
->setFileName($safeName)
->setHeader('Content-Type', $mime)
->setHeader('Content-Disposition', sprintf('%s; filename="%s"', $disposition, $safeName));
}
private function expenseReceiptUrl(?string $filename): ?string
{
if (!$filename) return null;
$safe = basename(trim($filename));
return $safe !== '' ? site_url('receipts/' . $safe) : null;
}
private function reimbReceiptUrl(?string $filename): ?string
{
if (!$filename) return null;
$safe = basename(trim($filename));
return $safe !== '' ? site_url('reimbreceipts/' . $safe) : null;
}
private function sanitizeAttachmentComponent(string $value): string
{
$clean = preg_replace('/[^A-Za-z0-9]+/', '_', trim((string) $value));
$clean = trim($clean, '_');
return $clean === '' ? 'unknown' : $clean;
}
private function formatAttachmentDate(?string $value): string
{
if (!$value) {
return 'unknown';
}
$timestamp = strtotime($value);
if ($timestamp === false) {
return 'unknown';
}
return date('Ymd', $timestamp);
}
private function uniqueZipEntryName(string $base, string $extension, array &$used): string
{
$candidate = $base . $extension;
$counter = 1;
while (isset($used[$candidate])) {
$candidate = $base . '_' . $counter . $extension;
$counter++;
}
$used[$candidate] = true;
return $candidate;
}
private function formatCsvDate(?string $value): string
{
if (!$value) {
return '';
}
$timestamp = strtotime($value);
if ($timestamp === false) {
return '';
}
return date('m-d-Y', $timestamp);
}
private function fetchBatchReceiptRows(int $batchId, array $receiptIds = []): array
{
$builder = $this->db->table('reimbursement_batch_items bi');
$builder->select('e.id AS expense_id, e.retailor, e.amount, e.description, e.receipt_path AS receipt_filename, e.date_of_purchase AS purchase_date, u.firstname AS purchaser_firstname, u.lastname AS purchaser_lastname');
$builder->join('expenses e', 'e.id = bi.expense_id', 'inner');
$builder->join('users u', 'u.id = e.purchased_by', 'left');
$builder->where('bi.batch_id', $batchId);
if (!empty($receiptIds)) {
$builder->whereIn('e.id', $receiptIds);
}
$rows = $builder->get()->getResultArray();
return array_values(array_filter($rows, static function ($row) {
return !empty($row['receipt_filename']);
}));
}
private function writeBatchCsvMetadata($handle, string $batchLabel, array $batch): void
{
$closedAtRaw = $batch['closed_at'] ?? null;
fputcsv($handle, ['Batch Title', $batchLabel]);
fputcsv($handle, ['School Year', $batch['school_year'] ?? '', 'Semester', $batch['semester'] ?? '', 'Closed At', $this->formatCsvDate($closedAtRaw)]);
fputcsv($handle, []);
fputcsv($handle, ['Amount', 'Credit', 'Paid', 'Balance', 'Date', 'By Who', 'Vendor', 'Description']);
}
private function writeBatchCsvSummary($handle, array $receiptRows, string $totalsDate = ''): void
{
$receiptTotalsByRecipient = [];
foreach ($receiptRows as $row) {
$recipientName = trim(($row['purchaser_firstname'] ?? '') . ' ' . ($row['purchaser_lastname'] ?? ''));
if ($recipientName === '') {
$recipientName = 'Unknown';
}
$vendor = trim((string) ($row['retailor'] ?? ''));
if ($vendor === '') {
$vendor = 'Vendor N/A';
}
$dateText = $this->formatCsvDate($row['purchase_date'] ?? null);
$descriptionValue = trim((string) ($row['description'] ?? ''));
$descriptionParts = $descriptionValue !== '' ? [$descriptionValue] : ['Receipt'];
$amountValue = (float) ($row['amount'] ?? 0);
$receiptTotalsByRecipient[$recipientName] = ($receiptTotalsByRecipient[$recipientName] ?? 0.0) + $amountValue;
fputcsv($handle, [
number_format($amountValue, 2, '.', ''),
'',
number_format($amountValue, 2, '.', ''),
'0.00',
$dateText,
$recipientName,
$vendor,
implode(' | ', $descriptionParts),
]);
}
if (!empty($receiptTotalsByRecipient)) {
fputcsv($handle, []);
fputcsv($handle, ['Totals by Recipient', '', '', '', '', '', '', '']);
foreach ($receiptTotalsByRecipient as $recipient => $total) {
$formatted = number_format($total, 2, '.', '');
fputcsv($handle, [
$formatted,
'',
$formatted,
'0.00',
$totalsDate,
$recipient,
'',
'',
]);
}
}
}
private function parseAttachmentIds($raw): array
{
if (is_array($raw)) {
$decoded = $raw;
} else {
$decoded = json_decode((string) $raw, true);
}
if (!is_array($decoded)) {
return [];
}
$ids = [];
foreach ($decoded as $entry) {
$id = (int) ($entry ?? 0);
if ($id > 0) {
$ids[$id] = $id;
}
}
return array_values($ids);
}
public function index()
{
$semester = $this->request->getGet('semester') ?: null;
$status = $this->request->getGet('status') ?: null;
$filterYear = $this->request->getGet('school_year') ?: $this->schoolYear;
$userId = $this->request->getGet('user_id') ?: null;
$filters = [
'school_year' => $filterYear,
'semester' => $semester,
'status' => $status, // e.g., 'Paid'
'user_id' => $userId,
];
$expenses = $this->expenseModel
->getReimbursedExpensesWithDetails($filters)
->get()
->getResultArray();
// Build closed batch summaries/details (all closed batches)
$batchSummaries = [];
$batchDetails = [];
$batchQuery = $this->db->table('reimbursement_batches b')
->select("
b.id AS batch_id,
b.title AS batch_title,
b.yearly_batch_number,
b.closed_at,
b.school_year AS batch_school_year,
e.id AS expense_id,
e.amount AS expense_amount,
e.description,
e.retailor,
e.receipt_path AS expense_receipt,
e.date_of_purchase AS expense_date_of_purchase,
e.purchased_by,
u.firstname AS purchaser_firstname,
u.lastname AS purchaser_lastname,
r.amount AS reimb_amount
")
->join('reimbursement_batch_items bi', 'bi.batch_id = b.id', 'inner')
->join('expenses e', 'e.id = bi.expense_id', 'inner')
->join('users u', 'u.id = e.purchased_by', 'left')
->join('reimbursements r', 'r.expense_id = e.id', 'left')
->where('b.status', 'closed')
->where('bi.unassigned_at IS NULL', null, false);
if (!empty($filterYear)) {
$batchQuery->groupStart()
->where('b.school_year', $filterYear)
->orWhere('e.school_year', $filterYear)
->groupEnd();
}
if (!empty($userId)) {
$batchQuery->where('e.purchased_by', $userId);
}
$batchRows = $batchQuery->get()->getResultArray();
$batchAttachments = [];
foreach ($batchRows as $row) {
$bid = (int) ($row['batch_id'] ?? 0);
if ($bid <= 0) {
continue;
}
$labelNumber = (int) ($row['yearly_batch_number'] ?? $bid);
if (!isset($batchSummaries[$bid])) {
$batchSummaries[$bid] = [
'batch_id' => $bid,
'title' => trim((string) ($row['batch_title'] ?? '')) ?: ('Batch #' . $labelNumber),
'sequence' => $labelNumber,
'closed_at' => $row['closed_at'] ?? null,
'amount' => 0.0,
'items' => 0,
];
}
$amount = $row['reimb_amount'] ?? $row['expense_amount'] ?? 0;
$batchSummaries[$bid]['amount'] += (float) $amount;
$batchSummaries[$bid]['items'] += 1;
if (!isset($batchDetails[$bid])) {
$batchDetails[$bid] = [
'title' => $batchSummaries[$bid]['title'],
'sequence' => $labelNumber,
'closed_at' => $batchSummaries[$bid]['closed_at'],
'items' => [],
'checks' => [],
];
}
if (!isset($batchAttachments[$bid])) {
$batchAttachments[$bid] = [
'receipts' => [],
'checks' => [],
];
}
$batchDetails[$bid]['items'][] = [
'amount' => (float) $amount,
'description' => $row['description'] ?? '',
'vendor' => $row['retailor'] ?? '',
'purchased_by' => trim(($row['purchaser_firstname'] ?? '') . ' ' . ($row['purchaser_lastname'] ?? '')),
'receipt_url' => $this->expenseReceiptUrl($row['expense_receipt'] ?? null),
];
$receiptFilename = trim((string) ($row['expense_receipt'] ?? ''));
if ($receiptFilename !== '') {
$batchAttachments[$bid]['receipts'][] = [
'expense_id' => (int) ($row['expense_id'] ?? 0),
'receipt_filename' => $receiptFilename,
'receipt_url' => $this->expenseReceiptUrl($receiptFilename),
'purchaser_firstname' => $row['purchaser_firstname'] ?? '',
'purchaser_lastname' => $row['purchaser_lastname'] ?? '',
'vendor' => $row['retailor'] ?? '',
'description' => $row['description'] ?? '',
'amount' => (float) ($row['expense_amount'] ?? 0),
'date_of_purchase' => $row['expense_date_of_purchase'] ?? '',
];
}
}
// Attach uploaded admin check files for all closed batches
if (!empty($batchSummaries)) {
$batchIds = array_keys($batchSummaries);
$files = $this->batchAdminFileModel
->select('id AS file_id, batch_id, admin_id, filename, original_filename, uploaded_at')
->whereIn('batch_id', $batchIds)
->findAll();
$adminNames = $this->adminRoster();
$adminNameMap = [];
foreach ($adminNames as $admin) {
$adminNameMap[(int) $admin['id']] = trim(($admin['firstname'] ?? '') . ' ' . ($admin['lastname'] ?? ''));
}
foreach ($files as $file) {
$bid = (int) ($file['batch_id'] ?? 0);
if ($bid <= 0 || !isset($batchDetails[$bid])) {
continue;
}
$aid = (int) ($file['admin_id'] ?? 0);
$batchDetails[$bid]['checks'][] = [
'admin' => $adminNameMap[$aid] ?? ($aid > 0 ? 'Admin #' . $aid : 'Unassigned'),
'filename' => $file['filename'],
'original' => $file['original_filename'] ?? $file['filename'],
'url' => base_url('reimbursements/batch/admin-file/' . rawurlencode($file['filename']) . '/inline'),
];
if (!isset($batchAttachments[$bid])) {
$batchAttachments[$bid] = [
'receipts' => [],
'checks' => [],
];
}
$batchAttachments[$bid]['checks'][] = [
'file_id' => (int) ($file['file_id'] ?? 0),
'filename' => $file['filename'],
'original_filename' => $file['original_filename'] ?? $file['filename'],
'admin' => $adminNameMap[$aid] ?? ($aid > 0 ? 'Admin #' . $aid : 'Unassigned'),
'url' => base_url('reimbursements/batch/admin-file/' . rawurlencode($file['filename']) . '/inline'),
'uploaded_at' => $file['uploaded_at'] ?? null,
];
}
}
if (!empty($batchSummaries)) {
$batchSummaries = array_values($batchSummaries);
usort($batchSummaries, static function ($a, $b) {
$aSeq = (int) ($a['sequence'] ?? 0);
$bSeq = (int) ($b['sequence'] ?? 0);
return $bSeq <=> $aSeq;
});
}
$donationBatch = null;
$donationDetails = [
'items' => [],
'checks' => [],
];
$donationQuery = $this->db->table('expenses e')
->select('
e.id AS expense_id,
e.amount AS expense_amount,
e.description,
e.retailor,
e.receipt_path AS expense_receipt,
e.purchased_by,
e.school_year,
e.semester,
e.status,
u.firstname AS purchaser_firstname,
u.lastname AS purchaser_lastname
')
->join('users u', 'u.id = e.purchased_by', 'left')
->where('e.category', 'Donation');
if (!empty($filterYear)) {
$donationQuery->where('e.school_year', $filterYear);
}
if (!empty($semester)) {
$donationQuery->where('e.semester', $semester);
}
if (!empty($userId)) {
$donationQuery->where('e.purchased_by', $userId);
}
if (!empty($status)) {
$donationQuery->groupStart()
->where('e.status', $status)
->orWhere('e.status', strtolower($status))
->orWhere('e.status', strtoupper($status))
->groupEnd();
}
$donationRows = $donationQuery->orderBy('e.created_at', 'DESC')->get()->getResultArray();
if (!empty($donationRows)) {
$donationTotal = 0.0;
foreach ($donationRows as $row) {
$amount = (float) ($row['expense_amount'] ?? 0);
$donationTotal += $amount;
$purchaser = trim(($row['purchaser_firstname'] ?? '') . ' ' . ($row['purchaser_lastname'] ?? ''));
if ($purchaser === '') {
$purchaser = 'Unknown';
}
$donationDetails['items'][] = [
'amount' => $amount,
'description' => $row['description'] ?? '',
'vendor' => $row['retailor'] ?? '',
'purchased_by' => $purchaser,
'receipt_url' => $this->expenseReceiptUrl($row['expense_receipt'] ?? null),
];
}
$donationBatch = [
'title' => 'Donations',
'items' => count($donationDetails['items']),
'amount' => $donationTotal,
];
}
foreach ($expenses as &$e) {
if (array_key_exists('expense_receipt', $e)) {
$e['expense_receipt_url'] = $this->expenseReceiptUrl($e['expense_receipt']);
} elseif (array_key_exists('receipt_path', $e)) {
$e['expense_receipt_url'] = $this->expenseReceiptUrl($e['receipt_path']);
}
if (array_key_exists('reimb_receipt', $e)) {
$e['reimb_receipt_url'] = $this->reimbReceiptUrl($e['reimb_receipt']);
} elseif (array_key_exists('receipt_path_reimb', $e)) {
$e['reimb_receipt_url'] = $this->reimbReceiptUrl($e['receipt_path_reimb']);
}
$recipientId = $e['reimb_recipient_id'] ?? ($e['reimbursed_to'] ?? null);
$hasName = trim(($e['reimb_firstname'] ?? '') . ' ' . ($e['reimb_lastname'] ?? '')) !== '';
if (!$hasName) {
$label = $this->specialRecipientLabel($recipientId);
if ($label !== null) {
$e['reimb_firstname'] = $label;
$e['reimb_lastname'] = '';
}
}
}
unset($e);
$users = $this->recipientOptions();
$years = $this->db->table('reimbursements')
->select('school_year')
->distinct()
->orderBy('school_year', 'DESC')
->get()
->getResultArray();
$schoolYears = array_column($years, 'school_year');
// Add school years from fallback expenses (closed batches without reimbursements)
if (!empty($fallbackRows)) {
$fallbackYears = array_values(array_unique(array_filter(array_column($fallbackRows, 'school_year'))));
$schoolYears = array_values(array_unique(array_merge($schoolYears, $fallbackYears)));
rsort($schoolYears);
}
return view('reimbursements/index', [
'expenses' => $expenses,
'users' => $users,
'schoolYears' => $schoolYears,
'batchSummaries' => $batchSummaries,
'batchDetails' => $batchDetails,
'batchAttachments' => $batchAttachments,
'donationBatch' => $donationBatch,
'donationDetails' => $donationDetails,
]);
}
public function sendBatchEmail()
{
$batchId = (int) ($this->request->getPost('batch_id') ?? 0);
if ($batchId <= 0) {
return $this->response->setStatusCode(400)->setJSON([
'success' => false,
'error' => 'Batch not specified.',
]);
}
$recipientEmail = trim((string) ($this->request->getPost('recipient_email') ?? ''));
if ($recipientEmail === '' || !filter_var($recipientEmail, FILTER_VALIDATE_EMAIL)) {
return $this->response->setStatusCode(422)->setJSON([
'success' => false,
'error' => 'Please provide a valid recipient email address.',
]);
}
$message = trim((string) ($this->request->getPost('message') ?? ''));
$receiptIds = $this->parseAttachmentIds($this->request->getPost('receipts'));
$checkIds = $this->parseAttachmentIds($this->request->getPost('checks'));
$batch = $this->batchModel->find($batchId);
if (!$batch) {
return $this->response->setStatusCode(404)->setJSON([
'success' => false,
'error' => 'Requested batch was not found.',
]);
}
$receiptRows = !empty($receiptIds) ? $this->fetchBatchReceiptRows($batchId, $receiptIds) : [];
$checkRows = [];
if (!empty($checkIds)) {
$checkRows = $this->batchAdminFileModel
->select('id AS file_id, admin_id, filename, original_filename, uploaded_at')
->where('batch_id', $batchId)
->whereIn('id', $checkIds)
->findAll();
$adminLookup = [];
foreach ($this->adminRoster() as $admin) {
$adminLookup[(int) ($admin['id'] ?? 0)] = trim((string) (($admin['firstname'] ?? '') . ' ' . ($admin['lastname'] ?? '')));
}
foreach ($checkRows as &$row) {
$aid = (int) ($row['admin_id'] ?? 0);
$row['admin'] = $adminLookup[$aid] ?? ($aid > 0 ? 'Admin #' . $aid : 'Admin');
}
unset($row);
}
$tempDir = WRITEPATH . 'tmp';
if (!is_dir($tempDir)) {
mkdir($tempDir, 0755, true);
}
$cleanupPaths = [];
$batchLabel = trim((string) ($batch['title'] ?? '')) ?: ('Batch #' . ((int) ($batch['yearly_batch_number'] ?? $batchId)));
$batchFileLabel = $this->sanitizeAttachmentComponent($batchLabel);
$csvPath = tempnam($tempDir, 'batch_csv_');
$cleanupPaths[] = $csvPath;
$csvHandle = fopen($csvPath, 'w');
if (!$csvHandle) {
if (is_file($csvPath)) {
@unlink($csvPath);
}
return $this->response->setStatusCode(500)->setJSON([
'success' => false,
'error' => 'Unable to create the summary file.',
]);
}
$batchClosedDate = $this->formatCsvDate($batch['closed_at'] ?? null);
$this->writeBatchCsvMetadata($csvHandle, $batchLabel, $batch);
$this->writeBatchCsvSummary($csvHandle, $receiptRows, $batchClosedDate);
fclose($csvHandle);
$csvAttachmentName = $batchFileLabel . '_summary.csv';
$receiptsArchivePath = null;
$receiptZipCreated = false;
if (!empty($receiptRows)) {
$archivePath = $tempDir . '/batch_receipts_' . $batchId . '_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.zip';
$zip = new ZipArchive();
$added = 0;
$usedNames = [];
if ($zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) {
foreach ($receiptRows as $row) {
$sourcePath = WRITEPATH . 'uploads/receipts/' . basename((string) ($row['receipt_filename'] ?? ''));
if (!is_file($sourcePath)) {
continue;
}
$firstName = $row['purchaser_firstname'] ?? '';
if (trim($firstName) === '' && !empty($row['purchaser_lastname'] ?? '')) {
$firstName = $row['purchaser_lastname'];
}
$firstName = $this->sanitizeAttachmentComponent($firstName);
$vendorPart = $this->sanitizeAttachmentComponent($row['retailor'] ?? '');
$datePart = $this->formatAttachmentDate($row['purchase_date'] ?? '');
$extension = pathinfo($sourcePath, PATHINFO_EXTENSION);
$extPart = $extension !== '' ? ('.' . strtolower($extension)) : '';
$baseName = $firstName . '_' . $vendorPart . '_' . $datePart;
$entryName = $this->uniqueZipEntryName($baseName, $extPart, $usedNames);
if ($zip->addFile($sourcePath, $entryName)) {
$added++;
}
}
$zip->close();
}
if ($added > 0 && is_file($archivePath)) {
$receiptsArchivePath = $archivePath;
$cleanupPaths[] = $archivePath;
$receiptZipCreated = true;
} elseif (is_file($archivePath)) {
@unlink($archivePath);
}
}
$checksArchivePath = null;
$checksAdded = 0;
if (!empty($checkRows)) {
$archivePath = $tempDir . '/batch_checks_' . $batchId . '_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.zip';
$zip = new ZipArchive();
$usedNames = [];
if ($zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) {
foreach ($checkRows as $row) {
$sourcePath = WRITEPATH . 'uploads/reimbursements/' . basename((string) ($row['filename'] ?? ''));
if (!is_file($sourcePath)) {
continue;
}
$adminLabel = $row['admin'] ?? 'Admin';
$firstToken = strtok($adminLabel, ' ');
$namePart = $this->sanitizeAttachmentComponent($firstToken ?: $adminLabel);
$extension = pathinfo($sourcePath, PATHINFO_EXTENSION);
$extPart = $extension !== '' ? ('.' . strtolower($extension)) : '';
$entryName = $this->uniqueZipEntryName($namePart, $extPart, $usedNames);
if ($zip->addFile($sourcePath, $entryName)) {
$checksAdded++;
}
}
$zip->close();
}
if ($checksAdded > 0 && is_file($archivePath)) {
$checksArchivePath = $archivePath;
$cleanupPaths[] = $archivePath;
} elseif (is_file($archivePath)) {
@unlink($archivePath);
}
}
$attachmentSummary = ['CSV Summary'];
if ($receiptZipCreated) {
$attachmentSummary[] = 'Receipt Files';
}
if ($checksArchivePath) {
$attachmentSummary[] = 'Check Scans';
}
$subject = 'Reimbursement Batch Export: ' . $batchLabel;
$bodyParts = [
'<p>Please find attached the export for <strong>' . htmlspecialchars($batchLabel, ENT_QUOTES, 'UTF-8') . '</strong>.</p>',
];
if ($message !== '') {
$bodyParts[] = '<p><strong>Message:</strong><br>' . nl2br(htmlspecialchars($message, ENT_QUOTES, 'UTF-8')) . '</p>';
}
$bodyParts[] = '<p>Attachments: ' . htmlspecialchars(implode(', ', $attachmentSummary), ENT_QUOTES, 'UTF-8') . '.</p>';
$bodyParts[] = '<p>Regards,<br>Al Rahma Sunday School</p>';
$htmlBody = implode("\n", $bodyParts);
$attachments = [
['path' => $csvPath, 'name' => $csvAttachmentName],
];
if ($receiptsArchivePath) {
$attachments[] = ['path' => $receiptsArchivePath, 'name' => $batchFileLabel . '_receipts.zip'];
}
if ($checksArchivePath) {
$attachments[] = ['path' => $checksArchivePath, 'name' => $batchFileLabel . '_checks.zip'];
}
$responseBody = [
'success' => true,
'message' => 'Batch export emailed.',
];
$statusCode = 200;
try {
$mailer = new EmailService();
if (!$mailer->send($recipientEmail, $subject, $htmlBody, 'general', $attachments)) {
throw new \RuntimeException('EmailService failed to send the batch export.');
}
$responseBody['message'] = 'Email sent successfully.';
} catch (\Throwable $e) {
log_message('error', 'Failed to send batch export email: {msg}', ['msg' => $e->getMessage()]);
$statusCode = 500;
$responseBody = [
'success' => false,
'error' => 'Failed to send email. Please try again.',
];
} finally {
foreach ($cleanupPaths as $path) {
if ($path && is_file($path)) {
@unlink($path);
}
}
}
return $this->response->setStatusCode($statusCode)->setJSON($responseBody);
}
public function export()
{
$type = $this->request->getGet('type'); // 'processed' or 'under_processing'
if ($type === 'under_processing') {
// Approved expenses not yet reimbursed
$builder = $this->db->table('expenses');
$builder->select('expenses.*, u.firstname AS purchaser_firstname, u.lastname AS purchaser_lastname');
$builder->join('users u', 'u.id = expenses.purchased_by', 'left');
$builder->where('expenses.status', 'approved'); // <-- fixed
$builder->where('expenses.reimbursement_id IS NULL', null, false);
$builder->where('expenses.category !=', 'Donation');
$records = $builder->orderBy('expenses.created_at', 'DESC')->get()->getResultArray();
// Group rows by purchaser so each person appears once in the CSV
$grouped = [];
foreach ($records as $row) {
$personId = (int)($row['purchased_by'] ?? 0);
$name = trim(($row['purchaser_firstname'] ?? '') . ' ' . ($row['purchaser_lastname'] ?? ''));
if ($name === '') {
$name = $row['purchaser_firstname'] ?? $row['purchaser_lastname'] ?? 'Unknown';
}
$key = $personId > 0 ? 'id_' . $personId : 'name_' . strtolower($name);
if (!isset($grouped[$key])) {
$grouped[$key] = [
'name' => $name ?: 'Unknown',
'total' => 0.0,
'vendors' => [],
'descriptions'=> [],
];
}
$amount = (float)($row['amount'] ?? 0);
$grouped[$key]['total'] += $amount;
$vendor = trim((string)($row['retailor'] ?? ''));
if ($vendor !== '' && !in_array($vendor, $grouped[$key]['vendors'], true)) {
$grouped[$key]['vendors'][] = $vendor;
}
$desc = trim((string)($row['description'] ?? ''));
if ($desc !== '' && !in_array($desc, $grouped[$key]['descriptions'], true)) {
$grouped[$key]['descriptions'][] = $desc;
}
}
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename=under_processing_expenses.csv');
$output = fopen('php://output', 'w');
// UTF-8 BOM for Excel
fprintf($output, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($output, ['Person', 'Total Amount', 'Vendors', 'Descriptions']);
foreach ($grouped as $entry) {
fputcsv($output, [
$entry['name'],
number_format($entry['total'], 2, '.', ''),
implode('; ', $entry['vendors']),
implode('; ', $entry['descriptions']),
]);
}
fclose($output);
exit;
}
// Default: processed reimbursements (filterable)
$builder = $this->reimbModel
->select('reimbursements.*, u.firstname AS reimb_firstname, u.lastname AS reimb_lastname')
->join('users u', 'u.id = reimbursements.reimbursed_to', 'left');
if ($semester = $this->request->getGet('semester')) {
$builder->where('reimbursements.semester', $semester);
}
if ($status = $this->request->getGet('status')) {
$builder->where('reimbursements.status', $status);
}
if ($userId = $this->request->getGet('user_id')) {
$builder->where('reimbursements.reimbursed_to', $userId);
}
if ($schoolYear = $this->request->getGet('school_year')) {
$builder->where('reimbursements.school_year', $schoolYear);
}
$records = $builder->orderBy('reimbursements.created_at', 'DESC')->get()->getResultArray();
foreach ($records as &$row) {
$hasName = trim(($row['reimb_firstname'] ?? '') . ' ' . ($row['reimb_lastname'] ?? '')) !== '';
if (!$hasName) {
$label = $this->specialRecipientLabel($row['reimbursed_to'] ?? null);
if ($label !== null) {
$row['reimb_firstname'] = $label;
$row['reimb_lastname'] = '';
}
}
}
unset($row);
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename=reimbursements_export.csv');
$output = fopen('php://output', 'w');
// UTF-8 BOM for Excel
fprintf($output, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($output, ['Amount', 'Reimbursed To', 'Status', 'Method', 'Check #', 'School Year', 'Semester', 'Date']);
foreach ($records as $r) {
fputcsv($output, [
$r['amount'],
trim(($r['reimb_firstname'] ?? '') . ' ' . ($r['reimb_lastname'] ?? '')),
$r['status'],
$r['reimbursement_method'] ?? '-',
$r['check_number'] ?? '-',
$r['school_year'] ?? '-',
$r['semester'] ?? '-',
$r['created_at'],
]);
}
fclose($output);
exit;
}
public function exportBatch()
{
$batchId = (int) ($this->request->getGet('batch_id') ?? 0);
if ($batchId <= 0) {
return $this->response->setStatusCode(400)->setBody('Invalid batch specified.');
}
$batch = $this->batchModel->find($batchId);
if (!$batch) {
throw new PageNotFoundException('Batch not found.');
}
$batchLabel = trim((string) ($batch['title'] ?? '')) ?: ('Batch #' . $batchId);
$receiptRows = $this->fetchBatchReceiptRows($batchId);
header('Content-Type: text/csv; charset=UTF-8');
$filename = sprintf('batch_%d_items.csv', $batchId);
header('Content-Disposition: attachment; filename=' . $filename);
$output = fopen('php://output', 'w');
// UTF-8 BOM for Excel
fprintf($output, chr(0xEF) . chr(0xBB) . chr(0xBF));
$batchClosedDate = $this->formatCsvDate($batch['closed_at'] ?? null);
$this->writeBatchCsvMetadata($output, $batchLabel, $batch);
$this->writeBatchCsvSummary($output, $receiptRows, $batchClosedDate);
fclose($output);
exit;
}
public function create()
{
$expenseId = $this->request->getGet('expense_id');
// users list (unchanged)
$users = $this->recipientOptions();
// fetch expense to prefill amount
$prefillAmount = null;
if ($expenseId) {
$exp = $this->expenseModel
->select('id, amount')
->find((int)$expenseId);
if ($exp) {
$prefillAmount = $exp['amount'];
}
}
return view('reimbursements/create', [
'users' => $users,
'expense_id' => $expenseId,
'prefill_amount' => $prefillAmount,
]);
}
public function store()
{
helper(['form']);
// Normalize method just in case (e.g., "cash" → "Cash")
$methodRaw = (string) $this->request->getPost('reimbursement_method');
$method = ucfirst(strtolower($methodRaw));
$expenseId = (int) ($this->request->getPost('expense_id') ?? 0);
// Base rules
$rules = [
'amount' => 'required|decimal|greater_than[0]',
'reimbursed_to' => 'required|is_natural_no_zero',
'reimbursement_method' => 'required|in_list[Cash,Check]',
];
// Messages (used only when the corresponding rule is present)
$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.',
],
];
// Conditional validation
if ($method === 'Check') {
// Require check number + receipt for checks
$rules['check_number'] = 'required|string|max_length[50]';
$rules['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]';
} elseif ($method === 'Cash') {
// Receipt optional for cash; if provided, validate it
$file = $this->request->getFile('receipt');
if ($file && $file->isValid() && ($file->getSize() ?? 0) > 0) {
$rules['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]';
}
}
if (!$this->validate($rules, $messages)) {
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'));
} catch (\Throwable $e) {
log_message('error', 'Failed to save reimbursement receipt in store(): {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');
// 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]);
}
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!');
}
public function reimbursedExpenses()
{
$expenses = $this->db->table('reimbursements r')
->select('
e.amount AS expense_amount,
e.category,
e.description,
e.receipt_path AS expense_receipt,
e.purchased_by,
r.amount AS reimb_amount,
r.reimbursement_method,
r.check_number,
r.receipt_path AS reimb_receipt,
r.status AS reimb_status,
r.created_at,
u.firstname AS purchaser_firstname,
u.lastname AS purchaser_lastname,
a.firstname AS approver_firstname,
a.lastname AS approver_lastname
')
->join('expenses e', 'e.id = r.expense_id')
->join('users u', 'u.id = e.purchased_by', 'left')
->join('users a', 'a.id = r.approved_by', 'left')
->orderBy('r.created_at', 'DESC')
->get()
->getResultArray();
foreach ($expenses as &$e) {
$e['expense_receipt_url'] = $this->expenseReceiptUrl($e['expense_receipt'] ?? null);
$e['reimb_receipt_url'] = $this->reimbReceiptUrl($e['reimb_receipt'] ?? null);
}
return view('reimbursements/reimbursed_expenses', ['expenses' => $expenses]);
}
// app/Controllers/ReimbursementController.php
public function edit(int $id)
{
$reimb = $this->reimbModel->find($id);
if (!$reimb) {
throw PageNotFoundException::forPageNotFound("Reimbursement #$id not found");
}
$users = $this->recipientOptions();
return view('reimbursements/edit', [
'reimb' => $reimb,
'users' => $users,
'receipt_url' => $reimb['receipt_path'] ? site_url('reimbreceipts/' . basename($reimb['receipt_path'])) : null,
]);
}
public function update(int $id)
{
helper(['form']);
$reimb = $this->reimbModel->find($id);
if (!$reimb) {
throw PageNotFoundException::forPageNotFound("Reimbursement #$id not found");
}
$methodRaw = (string) $this->request->getPost('reimbursement_method');
$method = ucfirst(strtolower($methodRaw));
$rules = [
'amount' => 'required|decimal|greater_than[0]',
'reimbursed_to' => 'required|is_natural_no_zero',
'reimbursement_method' => 'required|in_list[Cash,Check]',
];
// Optional file validation (only if provided)
$file = $this->request->getFile('receipt');
if ($method === 'Check') {
$rules['check_number'] = 'required|string|max_length[50]';
if ($file && $file->isValid() && ($file->getSize() ?? 0) > 0) {
$rules['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]';
}
} else { // Cash
if ($file && $file->isValid() && ($file->getSize() ?? 0) > 0) {
$rules['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]';
}
}
if (!$this->validate($rules)) {
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
// Handle new/removed receipt
$receiptName = $reimb['receipt_path'];
try {
$maybeNew = $this->saveReimbReceipt($file);
} catch (\Throwable $e) {
log_message('error', 'Failed to save reimbursement receipt in update(): {msg}', ['msg' => $e->getMessage()]);
return redirect()->back()->withInput()->with('errors', [
'receipt' => 'Failed to save uploaded file. Please try again or contact admin.'
]);
}
if ($maybeNew !== null) {
$receiptName = $maybeNew;
}
if ($this->request->getPost('remove_receipt') === '1') {
$receiptName = null;
}
$this->reimbModel->update($id, [
'amount' => $this->request->getPost('amount'),
'reimbursed_to' => (int) $this->request->getPost('reimbursed_to'),
'description' => $this->request->getPost('description'),
'reimbursement_method' => $method,
'check_number' => $method === 'Check' ? $this->request->getPost('check_number') : null,
'receipt_path' => $receiptName,
'updated_by' => (int) (session()->get('user_id') ?? 0),
// keep status as-is; or set from form if you add a status field
]);
return redirect()->to('/reimbursements')->with('success', 'Reimbursement updated.');
}
private function lookupReimbursementId(int $expenseId): ?int
{
if ($expenseId <= 0) {
return null;
}
$row = $this->reimbModel
->select('id')
->where('expense_id', $expenseId)
->orderBy('id', 'DESC')
->first();
return $row ? (int) $row['id'] : null;
}
}