'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/ $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'); $newHashResponse = function (array $payload = []) { $newHash = function_exists('csrf_hash') ? csrf_hash() : null; return $this->response ->setHeader('X-CSRF-HASH', (string) $newHash) ->setJSON(array_merge($payload, [ 'csrf_hash' => $newHash, ])); }; $this->db->transBegin(); try { $activeItem = $this->batchItemModel ->where('expense_id', $expenseId) ->where('unassigned_at IS NULL', null, false) ->first(); /** * If no batch is provided, remove the item from active batch processing. * This means "unassigned from batch", not "unassigned admin". */ 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']; } } if (!$this->db->transStatus()) { throw new \RuntimeException('Transaction failed while unassigning batch item.'); } $this->db->transCommit(); return $newHashResponse([ 'success' => true, 'batch_id' => 0, 'admin_id' => null, 'reimbursement_id' => $reimbursementId, ]); } $batch = $this->batchModel->find($batchId); if (!$batch || strtolower((string) ($batch['status'] ?? 'open')) !== 'open') { $this->db->transRollback(); return $this->response->setStatusCode(404)->setJSON([ 'success' => false, 'error' => 'Batch not found or already closed.', ]); } if (!$reimbursementId) { if ($activeItem && !empty($activeItem['reimbursement_id'])) { $reimbursementId = (int) $activeItem['reimbursement_id']; } else { $reimbursementId = $this->lookupReimbursementId($expenseId); } } $activeSameBatch = $activeItem && (int) ($activeItem['batch_id'] ?? 0) === $batchId; $currentAdmin = $activeItem ? (int) ($activeItem['admin_id'] ?? 0) : null; $requestedAdmin = $adminId ?? 0; /** * If the item is already active in the same batch/admin slot, just return success. */ if ($activeSameBatch && $currentAdmin === $requestedAdmin) { if (!$this->db->transStatus()) { throw new \RuntimeException('Transaction failed while checking existing assignment.'); } $this->db->transCommit(); return $newHashResponse([ 'success' => true, 'batch_id' => $batchId, 'admin_id' => $adminId, 'reimbursement_id' => $activeItem['reimbursement_id'] ?? $reimbursementId, ]); } /** * If same batch but different admin, update the existing active row. * * Important: * admin_id = null means active in the batch but not assigned to an admin. * unassigned_at != null means removed from active batch processing. */ if ($activeSameBatch) { $this->batchItemModel->update((int) $activeItem['id'], [ 'admin_id' => $adminId, 'assigned_at' => $now, 'reimbursement_id' => $reimbursementId, 'unassigned_at' => null, ]); if (!$this->db->transStatus()) { throw new \RuntimeException('Transaction failed while updating existing assignment.'); } $this->db->transCommit(); return $newHashResponse([ 'success' => true, 'batch_id' => $batchId, 'admin_id' => $adminId, 'reimbursement_id' => $reimbursementId, ]); } /** * If the item is active in another batch, soft-unassign that row first. */ if ($activeItem) { $this->batchItemModel->update((int) $activeItem['id'], [ 'unassigned_at' => $now, ]); } /** * Critical fix: * Check whether this expense was previously assigned to this batch. * Because uq_batch_expense(batch_id, expense_id) blocks duplicate rows, * we must reactivate/update the old row instead of inserting again. */ $existingBatchItem = $this->batchItemModel ->where('batch_id', $batchId) ->where('expense_id', $expenseId) ->first(); $assignmentData = [ 'batch_id' => $batchId, 'expense_id' => $expenseId, 'reimbursement_id' => $reimbursementId, 'admin_id' => $adminId, 'assigned_at' => $now, 'unassigned_at' => null, 'school_year' => $this->schoolYear, 'semester' => $this->semester, ]; if ($existingBatchItem) { $this->batchItemModel->update((int) $existingBatchItem['id'], $assignmentData); } else { $this->batchItemModel->insert($assignmentData); } if (!$this->db->transStatus()) { throw new \RuntimeException('Transaction failed while saving assignment.'); } $this->db->transCommit(); return $newHashResponse([ 'success' => true, 'batch_id' => $batchId, 'admin_id' => $adminId, 'reimbursement_id' => $reimbursementId, ]); } catch (\Throwable $e) { $this->db->transRollback(); log_message('error', 'Failed to update batch assignment for expense #{expense}, 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.', ]); } } 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 = [ '

Please find attached the export for ' . htmlspecialchars($batchLabel, ENT_QUOTES, 'UTF-8') . '.

', ]; if ($message !== '') { $bodyParts[] = '

Message:
' . nl2br(htmlspecialchars($message, ENT_QUOTES, 'UTF-8')) . '

'; } $bodyParts[] = '

Attachments: ' . htmlspecialchars(implode(', ', $attachmentSummary), ENT_QUOTES, 'UTF-8') . '.

'; $bodyParts[] = '

Regards,
Al Rahma Sunday School

'; $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; } }