expense = model(Expense::class); $this->user = model(User::class); $this->config = model(Configuration::class); $this->schoolYear = $this->config->getConfig('school_year'); $this->semester = $this->config->getConfig('semester'); // Default list of common retailors; adjust as needed $this->retailors = [ 'Amazon', 'Walmart', 'Costco', 'BJ\'s', 'Market Basket', 'Aldi', 'Hannaford', 'Sam\'s Club', 'HomeGoods', 'Hostinger', 'Wicked Cheesy', 'Shatila', 'Brothers Pizzeria', 'Paradise Biryani Pointe', 'Emad Leiman', 'Nova Trampoline Park', 'Lubin\'s Awards', 'Dollar Tree', 'Stop & Shop', 'Dunkin\' Donuts', 'Giovanni\'s Pizza' ]; } public function index() { $page = max(1, (int) ($this->request->getGet('page') ?? 1)); $perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20))); $status = $this->request->getGet('status'); $category = $this->request->getGet('category'); $query = $this->expense ->select(" expenses.*, u.firstname AS purchaser_firstname, u.lastname AS purchaser_lastname, approver.firstname AS approver_firstname, approver.lastname AS approver_lastname ") ->join('users u', 'u.id = expenses.purchased_by', 'left') ->join('users approver', 'approver.id = expenses.approved_by', 'left'); if ($this->schoolYear) { $query->where('expenses.school_year', $this->schoolYear); } if ($this->semester) { $query->where('expenses.semester', $this->semester); } if ($status) { $query->where('expenses.status', $status); } if ($category) { $query->where('expenses.category', $category); } $expenses = $query->orderBy('expenses.created_at', 'DESC')->findAll(); // Enrich each row with a URL that goes through Files::receipt($name) $expenses = array_map(function ($row) { $name = $row['receipt_path'] ?? null; $row['receipt_url'] = $this->receiptUrl($name); return $row; }, $expenses); // Apply pagination manually for array results $total = count($expenses); $offset = ($page - 1) * $perPage; $paginatedExpenses = array_slice($expenses, $offset, $perPage); $result = [ 'data' => $paginatedExpenses, 'pagination' => [ 'current_page' => $page, 'per_page' => $perPage, 'total' => $total, 'total_pages' => (int) ceil($total / $perPage), ], ]; return $this->success($result, 'Expenses retrieved successfully'); } public function show($id = null) { $expense = $this->expense ->select(" expenses.*, u.firstname AS purchaser_firstname, u.lastname AS purchaser_lastname, approver.firstname AS approver_firstname, approver.lastname AS approver_lastname ") ->join('users u', 'u.id = expenses.purchased_by', 'left') ->join('users approver', 'approver.id = expenses.approved_by', 'left') ->where('expenses.id', $id) ->first(); if (!$expense) { return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND); } // Add receipt URL $expense['receipt_url'] = $this->receiptUrl($expense['receipt_path'] ?? null); return $this->success($expense, 'Expense retrieved successfully'); } /** * Get metadata for creating an expense (users list and retailors) */ public function createMetadata() { // Limit users to non-teacher/non-parent $users = $this->user ->select('users.id, users.firstname, users.lastname') ->join('user_roles', 'user_roles.user_id = users.id') ->join('roles', 'roles.id = user_roles.role_id') ->whereNotIn('roles.name', ['teacher', 'parent']) ->distinct() ->findAll(); return $this->success([ 'users' => $users, 'retailors' => $this->retailors, ], 'Metadata retrieved successfully'); } public function create() { $user = $this->getCurrentUser(); if (!$user) { return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); } $data = $this->payloadData(); if (empty($data)) { return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); } $rules = [ 'category' => 'required|in:Expense,Purchase,Reimbursement', 'amount' => 'required|numeric|gt:0', 'purchased_by' => 'required', 'retailor' => 'nullable|max:255', 'date_of_purchase'=> 'nullable|date', ]; // Handle file upload validation if ($this->laravelRequest->hasFile('receipt')) { $rules['receipt'] = 'file|max:2048|mimes:jpg,jpeg,png,webp,gif,pdf'; } else { $rules['receipt'] = 'required|file|max:2048|mimes:jpg,jpeg,png,webp,gif,pdf'; } // Use Laravel Validator directly for file validation $validator = Validator::make($this->laravelRequest->all(), $rules); if ($validator->fails()) { return $this->respondValidationError($validator->errors()->toArray()); } // Parse "purchased_by" as "7|John Doe" if provided in that format $purchasedById = $user->id; if (isset($data['purchased_by'])) { $purchasedInfo = (string) $data['purchased_by']; if (strpos($purchasedInfo, '|') !== false) { [$purchasedById, $purchasedByName] = array_pad(explode('|', $purchasedInfo, 2), 2, null); $purchasedById = (int) $purchasedById; } else { $purchasedById = (int) $purchasedInfo; } } // Handle file upload $receiptName = null; if ($this->laravelRequest->hasFile('receipt')) { $file = $this->laravelRequest->file('receipt'); if ($file->isValid()) { $targetDir = storage_path('app/uploads/receipts'); if (!is_dir($targetDir)) { @mkdir($targetDir, 0755, true); } $extension = $file->getClientOriginalExtension(); $newName = uniqid('', true) . '.' . $extension; $file->move($targetDir, $newName); $receiptName = $newName; } } $schoolYear = $this->schoolYear ?: date('Y'); $semester = $this->semester ?: 'Fall'; $expenseData = [ 'category' => $data['category'], 'amount' => $data['amount'], 'receipt_path' => $receiptName, 'description' => $data['description'] ?? null, 'retailor' => !empty($data['retailor']) ? trim($data['retailor']) : null, 'date_of_purchase' => $data['date_of_purchase'] ?? null, 'purchased_by' => $purchasedById, 'added_by' => $user->id, 'school_year' => $schoolYear, 'semester' => $semester, 'status' => 'pending', ]; try { $expense = $this->expense->create($expenseData); $expense['receipt_url'] = $this->receiptUrl($receiptName); return $this->respondCreated($expense, 'Expense created successfully'); } catch (\Throwable $e) { log_message('error', 'Expense creation error: ' . $e->getMessage()); return $this->respondError('Failed to create expense', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * Get metadata for editing an expense */ public function editMetadata($id = null) { $expense = $this->expense->find($id); if (!$expense) { return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND); } // Get users list $users = $this->user ->select('users.id, users.firstname, users.lastname') ->join('user_roles', 'user_roles.user_id = users.id') ->join('roles', 'roles.id = user_roles.role_id') ->whereNotIn('roles.name', ['teacher', 'parent']) ->distinct() ->findAll(); $expense['receipt_url'] = $expense['receipt_path'] ? $this->receiptUrl(basename($expense['receipt_path'])) : null; return $this->success([ 'expense' => $expense, 'users' => $users, 'retailors' => $this->retailors, ], 'Metadata retrieved successfully'); } public function update($id = null) { $expense = $this->expense->find($id); if (!$expense) { return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND); } $user = $this->getCurrentUser(); if (!$user) { return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); } $data = $this->payloadData(); if (empty($data)) { return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); } $rules = [ 'category' => 'required|in:Expense,Purchase,Reimbursement', 'amount' => 'required|numeric|gt:0', 'purchased_by' => 'required', 'retailor' => 'nullable|max:255', 'date_of_purchase'=> 'nullable|date', ]; // Optional new receipt validation (only if provided) if ($this->laravelRequest->hasFile('receipt')) { $rules['receipt'] = 'file|max:2048|mimes:jpg,jpeg,png,webp,gif,pdf'; } // Use Laravel Validator directly for file validation $validator = Validator::make($this->laravelRequest->all(), $rules); if ($validator->fails()) { return $this->respondValidationError($validator->errors()->toArray()); } // Parse "purchased_by" as "id|Name" if provided in that format $purchasedById = $expense['purchased_by']; if (isset($data['purchased_by'])) { $purchasedInfo = (string) $data['purchased_by']; if (strpos($purchasedInfo, '|') !== false) { [$purchasedById] = array_pad(explode('|', $purchasedInfo, 2), 2, null); $purchasedById = (int) $purchasedById; } else { $purchasedById = (int) $purchasedInfo; } } // Keep old receipt unless replaced or removed $receiptName = $expense['receipt_path']; if ($this->laravelRequest->hasFile('receipt')) { $file = $this->laravelRequest->file('receipt'); if ($file->isValid()) { $targetDir = storage_path('app/uploads/receipts'); if (!is_dir($targetDir)) { @mkdir($targetDir, 0755, true); } $extension = $file->getClientOriginalExtension(); $newName = uniqid('', true) . '.' . $extension; $file->move($targetDir, $newName); $receiptName = $newName; } } if (isset($data['remove_receipt']) && $data['remove_receipt'] === '1') { $receiptName = null; } $updateData = [ 'category' => $data['category'], 'amount' => $data['amount'], 'description' => $data['description'] ?? $expense['description'], 'retailor' => isset($data['retailor']) ? (trim($data['retailor']) ?: null) : $expense['retailor'], 'date_of_purchase'=> $data['date_of_purchase'] ?? $expense['date_of_purchase'], 'purchased_by' => $purchasedById, 'receipt_path' => $receiptName, 'updated_by' => $user->id, ]; try { $this->expense->update($id, $updateData); $updatedExpense = $this->expense->find($id); $updatedExpense['receipt_url'] = $this->receiptUrl($receiptName); return $this->success($updatedExpense, 'Expense updated successfully'); } catch (\Throwable $e) { log_message('error', 'Expense update error: ' . $e->getMessage()); return $this->respondError('Failed to update expense', Response::HTTP_INTERNAL_SERVER_ERROR); } } public function updateStatus() { $user = $this->getCurrentUser(); if (!$user) { return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); } $data = $this->payloadData(); $id = isset($data['id']) ? (int)$data['id'] : null; $status = $data['status'] ?? null; $reason = $data['reason'] ?? ''; if (!$id || !in_array($status, ['approved', 'denied'], true)) { log_message('error', 'Invalid status or ID'); return $this->respondError('Invalid data', Response::HTTP_BAD_REQUEST); } $expense = $this->expense->find($id); if (!$expense) { log_message('error', 'Expense not found for ID ' . $id); return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND); } try { $this->expense->update($id, [ 'status' => $status, 'status_reason' => $reason, 'approved_by' => $user->id, 'updated_by' => $user->id ]); $updatedExpense = $this->expense->find($id); return $this->success($updatedExpense, 'Expense status updated successfully'); } catch (\Throwable $e) { log_message('error', 'Expense update failed for ID ' . $id . ': ' . $e->getMessage()); return $this->respondError('Update failed', Response::HTTP_INTERNAL_SERVER_ERROR); } } public function delete($id = null) { $expense = $this->expense->find($id); if (!$expense) { return $this->respondError('Expense not found', Response::HTTP_NOT_FOUND); } try { $expense->delete(); return $this->success(null, 'Expense deleted successfully'); } catch (\Throwable $e) { log_message('error', 'Expense delete error: ' . $e->getMessage()); return $this->respondError('Failed to delete expense', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * Build a public URL for a receipt filename through Files::receipt($name). * Expects just the filename (e.g., "1759113425_1c443e607e1900f92f62.png"). */ private function receiptUrl(?string $filename): ?string { if (!$filename) { return null; } // Using API route pattern - ensure route exists in routes/api.php // Example: Route::get('files/receipt/{name}', [FilesController::class, 'receipt']); return url('/api/v1/files/receipt/' . $filename); } }