queryService->underProcessing(); return response()->json([ 'ok' => true, 'pendingItems' => ReimbursementUnderProcessingItemResource::collection($data['pendingItems'] ?? []), 'existingBatches' => ReimbursementBatchResource::collection($data['existingBatches'] ?? []), 'adminUsers' => ReimbursementRecipientResource::collection($data['adminUsers'] ?? []), 'itemsPayload' => ReimbursementUnderProcessingItemResource::collection($data['itemsPayload'] ?? []), ]); } public function markDonation(Request $request): JsonResponse { $guard = $this->authenticatedUserIdOrUnauthorized(); if ($guard instanceof JsonResponse) { return $guard; } $data = array_merge($request->query->all(), $request->all(), $request->json()->all()); $validator = Validator::make($data, [ 'expense_id' => ['required', 'integer', 'min:1'], ]); if ($validator->fails()) { return response()->json([ 'message' => 'Validation failed.', 'errors' => $validator->errors(), ], 422); } $payload = $validator->validated(); try { $this->donationService->markDonation((int) $payload['expense_id'], $guard); } catch (RuntimeException $e) { $message = $e->getMessage(); $status = str_contains($message, 'not found') ? 404 : (str_contains($message, 'already') ? 409 : 422); return response()->json(['ok' => false, 'message' => $message], $status); } catch (\Throwable $e) { return response()->json(['ok' => false, 'message' => 'Unable to mark donation right now.'], 500); } return response()->json(['ok' => true]); } public function createBatch(Request $request): JsonResponse { $guard = $this->authenticatedUserIdOrUnauthorized(); if ($guard instanceof JsonResponse) { return $guard; } $data = array_merge($request->query->all(), $request->all(), $request->json()->all()); $validator = Validator::make($data, [ 'title' => ['nullable', 'string', 'max:255'], ]); if ($validator->fails()) { return response()->json([ 'message' => 'Validation failed.', 'errors' => $validator->errors(), ], 422); } $payload = $validator->validated(); $schoolYear = $this->context->getSchoolYear(); $semester = $this->context->getSemester(); $batch = $this->batchService->createBatch($payload['title'] ?? null, $guard, $schoolYear, $semester); return response()->json([ 'ok' => true, 'batch' => $batch, ], 201); } public function updateBatchAssignment(ReimbursementBatchAssignmentRequest $request): JsonResponse { $payload = $request->validated(); $batchId = (int) ($payload['batch_id'] ?? 0); $batchNumber = $payload['batch_number'] ?? null; if ($batchId <= 0 && $batchNumber !== null && trim((string) $batchNumber) !== '') { $batchId = (int) $batchNumber; } $adminIdRaw = $payload['admin_id'] ?? null; $adminId = ($adminIdRaw === '' || $adminIdRaw === null) ? null : (int) $adminIdRaw; $reimbursementId = !empty($payload['reimbursement_id']) ? (int) $payload['reimbursement_id'] : null; try { $result = $this->assignmentService->updateAssignment( (int) $payload['expense_id'], $batchId, $adminId, $reimbursementId, $this->context->getSchoolYear(), $this->context->getSemester() ); } catch (RuntimeException $e) { return response()->json(['ok' => false, 'message' => $e->getMessage()], 422); } return response()->json([ 'ok' => true, 'assignment' => $result, ]); } public function lockBatch(ReimbursementBatchLockRequest $request): JsonResponse { $guard = $this->authenticatedUserIdOrUnauthorized(); if ($guard instanceof JsonResponse) { return $guard; } $payload = $request->validated(); try { $this->batchService->lockBatch( (int) $payload['batch_id'], $guard, $this->context->getSchoolYear(), $this->context->getSemester() ); } catch (RuntimeException $e) { $message = $e->getMessage(); $status = str_contains($message, 'check file') ? 409 : 404; return response()->json(['ok' => false, 'message' => $message], $status); } catch (\Throwable $e) { return response()->json(['ok' => false, 'message' => 'Unable to lock batch right now.'], 500); } return response()->json(['ok' => true, 'status' => 'closed']); } public function uploadBatchAdminFile(ReimbursementBatchAdminFileRequest $request): JsonResponse { $guard = $this->authenticatedUserIdOrUnauthorized(); if ($guard instanceof JsonResponse) { return $guard; } $payload = $request->validated(); $batchId = (int) $payload['batch_id']; $adminId = (int) $payload['admin_id']; $batch = ReimbursementBatch::query()->find($batchId); if (!$batch) { return response()->json(['ok' => false, 'message' => 'Batch not found.'], 404); } if (strtolower((string) ($batch->status ?? '')) !== ReimbursementBatch::STATUS_OPEN) { return response()->json(['ok' => false, 'message' => 'Cannot upload files for a locked batch.'], 409); } try { $file = $this->fileService->storeAdminFile($batchId, $adminId, $request->file('check_file'), $guard); } catch (\Throwable $e) { return response()->json(['ok' => false, 'message' => 'Unable to store the uploaded file.'], 500); } return response()->json([ 'ok' => true, 'file' => $file, ]); } public function serveAdminCheckFile(FileNameRequest $request, string $name, string $mode = 'inline'): Response|JsonResponse { $allowedExtensions = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf']; $meta = $this->fileServeService->meta(storage_path('uploads/reimbursements'), $name, $allowedExtensions, null); $disposition = strtolower(trim($mode)) === 'download' ? 'attachment' : 'inline'; return response(file_get_contents($meta['path']), 200, [ 'Content-Type' => $meta['mime'], 'Content-Disposition' => $disposition . '; filename="' . $meta['download_name'] . '"', 'Content-Length' => (string) $meta['size'], 'ETag' => $meta['etag'], 'Last-Modified' => $meta['last_modified'], ]); } public function index(ReimbursementIndexRequest $request): JsonResponse { $payload = $request->validated(); $filters = [ 'school_year' => $payload['school_year'] ?? null, 'semester' => $payload['semester'] ?? null, 'status' => $payload['status'] ?? null, 'user_id' => $payload['user_id'] ?? null, ]; $data = $this->queryService->index($filters); return response()->json([ 'ok' => true, 'expenses' => ReimbursementExpenseResource::collection($data['expenses'] ?? []), 'users' => ReimbursementRecipientResource::collection($data['users'] ?? []), 'schoolYears' => $data['schoolYears'] ?? [], 'batchSummaries' => $data['batchSummaries'] ?? [], 'batchDetails' => $data['batchDetails'] ?? [], 'batchAttachments' => $data['batchAttachments'] ?? [], 'donationBatch' => $data['donationBatch'] ?? null, 'donationDetails' => $data['donationDetails'] ?? null, ]); } public function sendBatchEmail(ReimbursementBatchEmailRequest $request): JsonResponse { $payload = $request->validated(); $receiptIds = $payload['receipts'] ?? []; $checkIds = $payload['checks'] ?? []; try { $ok = $this->emailService->sendBatchEmail( (int) $payload['batch_id'], $payload['recipient_email'], (string) ($payload['message'] ?? ''), $receiptIds, $checkIds ); } catch (RuntimeException $e) { return response()->json(['ok' => false, 'message' => $e->getMessage()], 404); } if (!$ok) { return response()->json(['ok' => false, 'message' => 'Failed to send email. Please try again.'], 500); } return response()->json(['ok' => true, 'message' => 'Email sent successfully.']); } public function export(ReimbursementExportRequest $request): Response { $payload = $request->validated(); $type = $payload['type'] ?? 'processed'; if ($type === 'under_processing') { $csv = $this->exportService->buildUnderProcessingCsv(); } else { $csv = $this->exportService->buildProcessedCsv($payload); } return response()->streamDownload(function () use ($csv) { $out = fopen('php://output', 'w'); fprintf($out, chr(0xEF) . chr(0xBB) . chr(0xBF)); foreach ($csv['rows'] as $row) { fputcsv($out, $row); } fclose($out); }, $csv['filename'], [ 'Content-Type' => 'text/csv; charset=UTF-8', ]); } public function exportBatch(ReimbursementExportBatchRequest $request): Response { $payload = $request->validated(); $csv = $this->exportService->buildBatchCsv((int) $payload['batch_id']); return response()->streamDownload(function () use ($csv) { $out = fopen('php://output', 'w'); fprintf($out, chr(0xEF) . chr(0xBB) . chr(0xBF)); foreach ($csv['rows'] as $row) { fputcsv($out, $row); } fclose($out); }, $csv['filename'], [ 'Content-Type' => 'text/csv; charset=UTF-8', ]); } public function store(Request $request): JsonResponse { $guard = $this->authenticatedUserIdOrUnauthorized(); if ($guard instanceof JsonResponse) { return $guard; } $data = array_merge($request->query->all(), $request->all(), $request->json()->all()); $validator = Validator::make($data, [ 'amount' => ['required', 'numeric', 'gt:0'], 'reimbursed_to' => ['required', 'integer', 'min:1'], 'reimbursement_method' => ['required', 'in:Cash,Check'], 'check_number' => ['required_if:reimbursement_method,Check', 'nullable', 'string', 'max:50'], 'receipt' => ['required_if:reimbursement_method,Check', 'nullable', 'file', 'max:2048', 'mimes:jpg,jpeg,png,webp,gif,pdf'], 'expense_id' => ['nullable', 'integer', 'min:1'], 'description' => ['nullable', 'string'], 'school_year' => ['nullable', 'string', 'max:20'], 'semester' => ['nullable', 'string', 'max:20'], ]); if ($validator->fails()) { return response()->json([ 'message' => 'Validation failed.', 'errors' => $validator->errors(), ], 422); } $payload = $validator->validated(); $receiptName = null; if ($request->hasFile('receipt')) { $receiptName = $this->fileService->storeReceipt($request->file('receipt')); } try { $reimb = $this->crudService->store( $payload, $guard, $this->context->getSchoolYear(), $this->context->getSemester(), $receiptName ); } catch (RuntimeException $e) { return response()->json(['ok' => false, 'message' => $e->getMessage()], 422); } $data = $reimb->toArray(); $data['receipt_url'] = $this->fileService->receiptUrl($reimb->receipt_path ?? null); return response()->json([ 'ok' => true, 'reimbursement' => new ReimbursementResource($data), ], 201); } public function process(ReimbursementProcessRequest $request): JsonResponse { $guard = $this->authenticatedUserIdOrUnauthorized(); if ($guard instanceof JsonResponse) { return $guard; } $payload = $request->validated(); $receiptName = null; if ($request->hasFile('receipt')) { $receiptName = $this->fileService->storeReceipt($request->file('receipt')); } try { $reimb = $this->crudService->process( $payload, $guard, $this->context->getSchoolYear(), $this->context->getSemester(), $receiptName ); } catch (RuntimeException $e) { return response()->json(['ok' => false, 'message' => $e->getMessage()], 422); } $data = $reimb->toArray(); $data['receipt_url'] = $this->fileService->receiptUrl($reimb->receipt_path ?? null); return response()->json([ 'ok' => true, 'reimbursement' => new ReimbursementResource($data), ], 201); } public function reimbursedExpenses(): JsonResponse { $expenses = $this->queryService->reimbursedExpenses(); return response()->json([ 'ok' => true, 'expenses' => $expenses, ]); } public function update(Request $request, int $id): JsonResponse { $guard = $this->authenticatedUserIdOrUnauthorized(); if ($guard instanceof JsonResponse) { return $guard; } $reimb = Reimbursement::query()->find($id); if (!$reimb) { return response()->json(['ok' => false, 'message' => 'Reimbursement not found.'], 404); } $data = array_merge($request->query->all(), $request->all(), $request->json()->all()); $validator = Validator::make($data, [ 'amount' => ['required', 'numeric', 'gt:0'], 'reimbursed_to' => ['required', 'integer', 'min:1'], 'reimbursement_method' => ['required', 'in:Cash,Check'], 'check_number' => ['required_if:reimbursement_method,Check', 'nullable', 'string', 'max:50'], 'receipt' => ['nullable', 'file', 'max:2048', 'mimes:jpg,jpeg,png,webp,gif,pdf'], 'remove_receipt' => ['nullable', 'boolean'], 'description' => ['nullable', 'string'], ]); if ($validator->fails()) { return response()->json([ 'message' => 'Validation failed.', 'errors' => $validator->errors(), ], 422); } $payload = $validator->validated(); $receiptName = $reimb->receipt_path; if ($request->hasFile('receipt')) { $receiptName = $this->fileService->storeReceipt($request->file('receipt')); } if (!empty($payload['remove_receipt'])) { $receiptName = null; } $updated = $this->crudService->update($reimb, $payload, $guard, $receiptName); $data = $updated->toArray(); $data['receipt_url'] = $this->fileService->receiptUrl($updated->receipt_path ?? null); return response()->json([ 'ok' => true, 'reimbursement' => new ReimbursementResource($data), ]); } /** * @return int|JsonResponse */ private function authenticatedUserIdOrUnauthorized(): int|JsonResponse { $userId = (int) (auth()->id() ?? 0); if ($userId <= 0) { return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); } return $userId; } }