diff --git a/app/Config/Services.php b/app/Config/Services.php index f98dc979..668e31bd 100755 --- a/app/Config/Services.php +++ b/app/Config/Services.php @@ -3,7 +3,7 @@ namespace Config; use App\Services\EmailService; -use App\Services\SemesterScoreService; +use App\Services\Scores\SemesterScoreService; class Services { diff --git a/app/Http/Controllers/Api/Api.zip b/app/Http/Controllers/Api/Api.zip deleted file mode 100644 index fbcb1390..00000000 Binary files a/app/Http/Controllers/Api/Api.zip and /dev/null differ diff --git a/app/Http/Controllers/Api/AttendanceTracking/AttendanceTrackingController.php b/app/Http/Controllers/Api/AttendanceTracking/AttendanceTrackingController.php index 7348d6ea..bdb79f31 100644 --- a/app/Http/Controllers/Api/AttendanceTracking/AttendanceTrackingController.php +++ b/app/Http/Controllers/Api/AttendanceTracking/AttendanceTrackingController.php @@ -8,7 +8,7 @@ use App\Http\Requests\AttendanceTracking\ParentsInfoRequest; use App\Http\Requests\AttendanceTracking\RecordAttendanceTrackingRequest; use App\Http\Requests\AttendanceTracking\SaveAttendanceNotificationNoteRequest; use App\Http\Requests\AttendanceTracking\SendAttendanceManualEmailRequest; -use App\Services\AttendanceTrackingService; +use App\Services\AttendanceTracking\AttendanceTrackingService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; diff --git a/app/Http/Controllers/Api/Auth/RegisterController.php b/app/Http/Controllers/Api/Auth/RegisterController.php new file mode 100644 index 00000000..5ec3221e --- /dev/null +++ b/app/Http/Controllers/Api/Auth/RegisterController.php @@ -0,0 +1,64 @@ +captchaService->generate(); + + return response()->json([ + 'ok' => true, + 'captcha' => $captcha, + ]); + } + + public function store(RegisterRequest $request): JsonResponse + { + if (app()->runningUnitTests() && $request->header('X-Debug-Request') === '1') { + return response()->json([ + 'ok' => true, + 'debug' => [ + 'content_type' => $request->header('Content-Type'), + 'content_length' => strlen((string) $request->getContent()), + 'request_all' => $request->all(), + 'json_all' => $request->json()->all(), + 'payload_data' => $this->payloadData(), + ], + ]); + } + + $result = $this->service->register($request->validated()); + + if (empty($result['ok'])) { + $code = $result['code'] ?? 'registration_failed'; + $status = $code === 'pending_activation' || $code === 'email_exists' ? 409 : 422; + + return response()->json([ + 'ok' => false, + 'message' => $result['message'] ?? 'Registration failed.', + 'code' => $code, + ], $status); + } + + return response()->json([ + 'ok' => true, + 'registration' => new RegisterResource($result), + ], 201); + } +} diff --git a/app/Http/Controllers/Api/ClassPrep/ClassPrepController.php b/app/Http/Controllers/Api/ClassPrep/ClassPrepController.php new file mode 100644 index 00000000..003c13a6 --- /dev/null +++ b/app/Http/Controllers/Api/ClassPrep/ClassPrepController.php @@ -0,0 +1,53 @@ +stickerCounts = $stickerCounts; + $this->roster = $roster; + } + + public function stickerCounts(Request $request): JsonResponse + { + $schoolYear = (string) ($request->query('school_year') ?? ''); + $semester = (string) ($request->query('semester') ?? ''); + $classSectionId = $request->query('class_section_id') ?? $request->query('class_id'); + $classSectionId = is_numeric($classSectionId) ? (int) $classSectionId : null; + + if ($classSectionId !== null && $classSectionId > 0) { + $payload = $this->stickerCounts->listForClass($schoolYear, $semester, $classSectionId); + } else { + $payload = $this->stickerCounts->listAll($schoolYear, $semester); + } + + return response()->json([ + 'ok' => true, + 'data' => $payload, + ]); + } + + public function studentsByClass(Request $request, int $classSectionId): JsonResponse + { + $schoolYear = (string) ($request->query('school_year') ?? ''); + + $students = $this->roster->listStudentsByClass($classSectionId, $schoolYear !== '' ? $schoolYear : null); + + return response()->json([ + 'ok' => true, + 'students' => $students, + ]); + } +} diff --git a/app/Http/Controllers/Api/ClassPreparation/ClassPreparationController.php b/app/Http/Controllers/Api/ClassPreparation/ClassPreparationController.php new file mode 100644 index 00000000..584ff75d --- /dev/null +++ b/app/Http/Controllers/Api/ClassPreparation/ClassPreparationController.php @@ -0,0 +1,60 @@ +service = $service; + } + + public function index(Request $request): JsonResponse + { + $schoolYear = (string) ($request->query('school_year') ?? ''); + $semester = (string) ($request->query('semester') ?? ''); + + $payload = $this->service->listPrep( + $schoolYear !== '' ? $schoolYear : null, + $semester !== '' ? $semester : null + ); + + return response()->json([ + 'ok' => true, + 'schoolYear' => $payload['schoolYear'], + 'semester' => $payload['semester'], + 'results' => $payload['results'], + 'totals' => $payload['totals'], + 'shortages' => $payload['shortages'], + ]); + } + + public function markPrinted(Request $request): JsonResponse + { + $schoolYear = (string) ($request->input('school_year') ?? ''); + $semester = (string) ($request->input('semester') ?? ''); + $ids = $request->input('class_section_ids', []); + + if ($schoolYear === '' || !is_array($ids)) { + return response()->json([ + 'ok' => false, + 'message' => 'school_year and class_section_ids are required.', + ], 422); + } + + $count = $this->service->markPrinted($schoolYear, $semester, $ids); + + return response()->json([ + 'ok' => true, + 'updated' => $count, + ]); + } +} diff --git a/app/Http/Controllers/Api/Communication/CommunicationController.php b/app/Http/Controllers/Api/Communication/CommunicationController.php new file mode 100644 index 00000000..74300f85 --- /dev/null +++ b/app/Http/Controllers/Api/Communication/CommunicationController.php @@ -0,0 +1,178 @@ +students = $students; + $this->templates = $templates; + $this->families = $families; + $this->previewService = $previewService; + $this->sendService = $sendService; + } + + public function options(): JsonResponse + { + return response()->json([ + 'ok' => true, + 'students' => $this->students->listStudents(), + 'templates' => $this->templates->listActiveTemplates(), + ]); + } + + public function families(int $studentId): JsonResponse + { + return response()->json([ + 'ok' => true, + 'data' => $this->families->familiesForStudent($studentId), + ]); + } + + public function guardians(int $familyId): JsonResponse + { + return response()->json([ + 'ok' => true, + 'data' => $this->families->guardiansForFamily($familyId), + ]); + } + + public function preview(Request $request): JsonResponse + { + $templateKey = (string) $request->input('template_key', ''); + $studentId = (int) $request->input('student_id', 0); + $familyId = (int) $request->input('family_id', 0); + $vars = $this->normalizeArray($request->input('vars', [])); + + if ($templateKey === '' || $studentId <= 0 || $familyId <= 0) { + return response()->json([ + 'ok' => false, + 'message' => 'template_key, student_id, and family_id are required.', + ], 422); + } + + $teacherName = $this->resolveTeacherName(); + $result = $this->previewService->buildPreview($templateKey, $studentId, $familyId, $vars, $teacherName); + + if (!$result['ok']) { + return response()->json([ + 'ok' => false, + 'message' => $result['message'] ?? 'Preview failed.', + ], $result['status'] ?? 400); + } + + return response()->json([ + 'ok' => true, + 'subject' => $result['subject'], + 'html' => $result['html'], + ]); + } + + public function send(Request $request): JsonResponse + { + $studentId = (int) $request->input('student_id', 0); + $familyId = (int) $request->input('family_id', 0); + $templateKey = (string) $request->input('template_key', ''); + $subject = (string) $request->input('subject', ''); + $body = (string) $request->input('body', ''); + $recipients = $this->normalizeArray($request->input('recipients', [])); + $cc = $this->normalizeArray($request->input('cc', [])); + $bcc = $this->normalizeArray($request->input('bcc', [])); + + if ($studentId <= 0 || $familyId <= 0 || $templateKey === '' || $subject === '' || $body === '') { + return response()->json([ + 'ok' => false, + 'message' => 'student_id, family_id, template_key, subject, and body are required.', + ], 422); + } + + if (empty($recipients)) { + return response()->json([ + 'ok' => false, + 'message' => 'At least one recipient is required.', + ], 422); + } + + $student = $this->students->getStudentBasic($studentId); + if (!$student) { + return response()->json([ + 'ok' => false, + 'message' => 'Student not found.', + ], 404); + } + + $result = $this->sendService->send([ + 'student_id' => $studentId, + 'family_id' => $familyId, + 'template_key' => $templateKey, + 'subject' => $subject, + 'body' => $body, + 'recipients' => $recipients, + 'cc' => $cc, + 'bcc' => $bcc, + 'student_name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')), + 'sent_by' => (int) (auth()->id() ?? 0), + ]); + + if (!$result['ok']) { + return response()->json([ + 'ok' => false, + 'message' => 'Failed to send email.', + 'error' => $result['error'], + ], 500); + } + + return response()->json([ + 'ok' => true, + 'message' => 'Email sent successfully.', + ]); + } + + private function normalizeArray($value): array + { + if (is_array($value)) { + return $value; + } + + if (is_string($value) && $value !== '') { + $decoded = json_decode($value, true); + if (is_array($decoded)) { + return $decoded; + } + } + + return []; + } + + private function resolveTeacherName(): string + { + $user = auth()->user(); + if ($user) { + $name = trim(($user->firstname ?? '') . ' ' . ($user->lastname ?? '')); + return $name !== '' ? $name : 'Teacher'; + } + return 'Teacher'; + } +} diff --git a/app/Http/Controllers/Api/CompetitionScores/CompetitionScoresController.php b/app/Http/Controllers/Api/CompetitionScores/CompetitionScoresController.php new file mode 100644 index 00000000..52b96100 --- /dev/null +++ b/app/Http/Controllers/Api/CompetitionScores/CompetitionScoresController.php @@ -0,0 +1,212 @@ +contextService = $contextService; + $this->queryService = $queryService; + $this->rosterService = $rosterService; + $this->saveService = $saveService; + } + + public function index(Request $request): JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + [$assignments, $schoolYear, $semester] = $this->contextService->getTeacherContext($userId); + + $activeClassId = $this->contextService->resolveActiveClassId( + $assignments, + (int) $request->query('class_section_id', 0) + ); + $activeClassName = $this->contextService->getActiveClassName($assignments, $activeClassId); + + if (empty($assignments) || $activeClassId <= 0) { + return response()->json([ + 'ok' => true, + 'competitions' => [], + 'activeClassId' => $activeClassId, + 'activeClassName' => $activeClassName, + 'questionCounts' => [], + 'scoreCounts' => [], + 'studentTotal' => 0, + 'sectionMap' => $this->contextService->getClassSectionMap(), + 'schoolYear' => $schoolYear, + 'semester' => $semester, + 'hasClasses' => false, + ]); + } + + $competitions = $this->queryService->getCompetitionsForClass($activeClassId, $schoolYear, $semester); + $competitionIds = array_values(array_filter(array_map(static function ($row) { + return (int) ($row['id'] ?? 0); + }, $competitions))); + + return response()->json([ + 'ok' => true, + 'competitions' => $competitions, + 'activeClassId' => $activeClassId, + 'activeClassName' => $activeClassName, + 'questionCounts' => $this->queryService->getQuestionCounts($competitionIds, $activeClassId), + 'scoreCounts' => $this->queryService->getScoreCounts($competitionIds, $activeClassId), + 'studentTotal' => $this->rosterService->getClassStudentCount($activeClassId, $schoolYear), + 'sectionMap' => $this->contextService->getClassSectionMap(), + 'schoolYear' => $schoolYear, + 'semester' => $semester, + 'hasClasses' => true, + ]); + } + + public function edit(Request $request, int $id): JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + [$assignments, $schoolYear, $semester] = $this->contextService->getTeacherContext($userId); + $allowedClassIds = $this->contextService->getAllowedClassIds($assignments); + + if (empty($allowedClassIds)) { + return response()->json([ + 'ok' => false, + 'message' => 'No class assignments found for your account.', + ], 403); + } + + $competition = Competition::query()->find($id); + if (!$competition) { + return response()->json([ + 'ok' => false, + 'message' => 'Competition not found.', + ], 404); + } + + $isLocked = (bool) $competition->is_locked; + $lockedClassId = (int) ($competition->class_section_id ?? 0); + $requestedClassId = (int) $request->query('class_section_id', 0); + $activeClassId = $this->contextService->resolveActiveClassId($assignments, $requestedClassId); + $classSectionId = $lockedClassId > 0 ? $lockedClassId : $activeClassId; + + if ($classSectionId <= 0) { + return response()->json([ + 'ok' => false, + 'message' => 'Select a class section before entering scores.', + ], 422); + } + + if (!in_array($classSectionId, $allowedClassIds, true)) { + return response()->json([ + 'ok' => false, + 'message' => 'You are not assigned to that class.', + ], 403); + } + + $competitionArray = $competition->toArray(); + $students = $this->rosterService->getStudentsForCompetition($competitionArray, $classSectionId); + $scoreMap = $this->saveService->getScoreMap($id, $classSectionId); + $questionCount = $this->queryService->getQuestionCountForCompetition($id, $classSectionId); + $classStudentCount = $this->rosterService->getClassStudentCount( + $classSectionId, + $competitionArray['school_year'] ?? $schoolYear + ); + + return response()->json([ + 'ok' => true, + 'competition' => $competitionArray, + 'students' => $students, + 'scoreMap' => $scoreMap, + 'classSectionId' => $classSectionId, + 'classSectionName' => $this->contextService->getActiveClassName($assignments, $classSectionId), + 'classStudentCount' => $classStudentCount, + 'questionCount' => $questionCount, + 'classSelectionLocked' => $lockedClassId > 0, + 'isLocked' => $isLocked, + 'schoolYear' => $schoolYear, + 'semester' => $semester, + ]); + } + + public function save(Request $request, int $id): JsonResponse + { + $userId = (int) (auth()->id() ?? 0); + [$assignments] = $this->contextService->getTeacherContext($userId); + $allowedClassIds = $this->contextService->getAllowedClassIds($assignments); + + if (empty($allowedClassIds)) { + return response()->json([ + 'ok' => false, + 'message' => 'No class assignments found for your account.', + ], 403); + } + + $competition = Competition::query()->find($id); + if (!$competition) { + return response()->json([ + 'ok' => false, + 'message' => 'Competition not found.', + ], 404); + } + + if ((bool) $competition->is_locked) { + return response()->json([ + 'ok' => false, + 'message' => 'Competition is locked. You cannot update scores.', + ], 423); + } + + $lockedClassId = (int) ($competition->class_section_id ?? 0); + $classSectionId = $lockedClassId > 0 ? $lockedClassId : (int) $request->input('class_section_id', 0); + + if ($classSectionId <= 0) { + return response()->json([ + 'ok' => false, + 'message' => 'Select a class section before saving scores.', + ], 422); + } + + if (!in_array($classSectionId, $allowedClassIds, true)) { + return response()->json([ + 'ok' => false, + 'message' => 'You are not assigned to that class.', + ], 403); + } + + $scores = $request->input('scores', []); + $scores = is_array($scores) ? $scores : []; + [$cleanScores, $invalidScores] = $this->saveService->filterScores($scores); + + if (!empty($invalidScores)) { + return response()->json([ + 'ok' => false, + 'message' => 'Scores must be whole numbers (no decimals).', + 'invalidScores' => $invalidScores, + ], 422); + } + + $this->saveService->saveScores($id, $classSectionId, $cleanScores); + + return response()->json([ + 'ok' => true, + 'message' => 'Scores saved.', + 'savedCount' => count($cleanScores), + ]); + } +} diff --git a/app/Http/Controllers/Api/Discounts/DiscountController.php b/app/Http/Controllers/Api/Discounts/DiscountController.php new file mode 100644 index 00000000..d7885a8d --- /dev/null +++ b/app/Http/Controllers/Api/Discounts/DiscountController.php @@ -0,0 +1,107 @@ +context = $context; + $this->voucherService = $voucherService; + $this->parentService = $parentService; + $this->applyService = $applyService; + } + + public function options(): JsonResponse + { + $schoolYear = $this->context->getSchoolYear(); + $vouchers = $this->voucherService->listActive(); + $parents = $this->parentService->listParentsWithDiscounts($schoolYear); + + return response()->json([ + 'ok' => true, + 'vouchers' => DiscountVoucherResource::collection($vouchers), + 'parents' => DiscountParentResource::collection($parents), + 'schoolYear' => $schoolYear, + ]); + } + + public function apply(ApplyDiscountVoucherRequest $request): JsonResponse + { + $payload = $request->validated(); + $result = $this->applyService->applyVoucher( + (int) $payload['voucher_id'], + $payload['parent_ids'], + (bool) ($payload['allow_additional'] ?? false), + (int) (auth()->id() ?? 0) + ); + + return response()->json($result, $result['ok'] ? 200 : 422); + } + + public function listVouchers(): JsonResponse + { + $vouchers = $this->voucherService->listAll(); + return response()->json([ + 'ok' => true, + 'vouchers' => DiscountVoucherResource::collection($vouchers), + ]); + } + + public function storeVoucher(StoreDiscountVoucherRequest $request): JsonResponse + { + $voucher = $this->voucherService->create($request->validated()); + return response()->json([ + 'ok' => true, + 'voucher' => new DiscountVoucherResource($voucher), + ], 201); + } + + public function updateVoucher(UpdateDiscountVoucherRequest $request, int $id): JsonResponse + { + $voucher = $this->voucherService->update($id, $request->validated()); + return response()->json([ + 'ok' => true, + 'voucher' => new DiscountVoucherResource($voucher), + ]); + } + + public function showVoucher(int $id): JsonResponse + { + $voucher = $this->voucherService->find($id); + return response()->json([ + 'ok' => true, + 'voucher' => new DiscountVoucherResource($voucher), + ]); + } + + public function deleteVoucher(int $id): JsonResponse + { + $this->voucherService->delete($id); + return response()->json([ + 'ok' => true, + ]); + } +} diff --git a/app/Http/Controllers/Api/Email/BroadcastEmailController.php b/app/Http/Controllers/Api/Email/BroadcastEmailController.php new file mode 100644 index 00000000..16fd3d93 --- /dev/null +++ b/app/Http/Controllers/Api/Email/BroadcastEmailController.php @@ -0,0 +1,184 @@ +senderOptions = $senderOptions; + $this->recipients = $recipients; + $this->composer = $composer; + $this->dispatch = $dispatch; + $this->imageService = $imageService; + } + + public function options(): JsonResponse + { + return response()->json([ + 'ok' => true, + 'parents' => $this->recipients->parentsWithEmails(), + 'fromOptions' => $this->senderOptions->listOptions(), + ]); + } + + public function send(Request $request): JsonResponse + { + $mode = (string) ($request->input('mode') ?? 'standard'); + $subject = trim((string) ($request->input('subject') ?? '')); + $fromKey = trim((string) ($request->input('from_key') ?? 'general')); + $body = (string) ($request->input('body_html') ?? ''); + $wrapLayout = (bool) $request->boolean('wrap_layout'); + $preheader = (string) ($request->input('preheader') ?? ''); + $ctaText = (string) ($request->input('cta_text') ?? ''); + $ctaUrl = (string) ($request->input('cta_url') ?? ''); + $testEmail = trim((string) ($request->input('test_email') ?? '')); + $isTestOnly = (bool) $request->boolean('send_test_only'); + + $body = $this->composer->sanitizeHtml($body); + + if ($subject === '' || $body === '') { + return response()->json([ + 'ok' => false, + 'message' => 'Subject and Body are required.', + ], 422); + } + + $personalize = $mode === 'personalized'; + + if ($isTestOnly) { + if ($testEmail === '') { + return response()->json([ + 'ok' => false, + 'message' => 'Provide a test email address.', + ], 422); + } + + $result = $this->dispatch->sendTest([ + 'wrap_layout' => $wrapLayout, + 'subject' => $subject, + 'body_html' => $body, + 'recipient_name' => 'Parent', + 'preheader' => $preheader, + 'cta_text' => $ctaText, + 'cta_url' => $ctaUrl, + 'personalize' => $personalize, + 'test_email' => $testEmail, + 'from_key' => $fromKey, + ]); + + return response()->json($result, $result['ok'] ? 200 : 500); + } + + $rawIds = $request->input('parent_ids', []); + $ids = $this->normalizeIds($rawIds); + + if (empty($ids)) { + return response()->json([ + 'ok' => false, + 'message' => 'Please select at least one parent.', + ], 422); + } + + $recipients = $this->recipients->recipientsByIds($ids); + + if (empty($recipients)) { + return response()->json([ + 'ok' => false, + 'message' => 'No valid parent emails found.', + ], 422); + } + + $stats = $this->dispatch->sendBroadcast([ + 'wrap_layout' => $wrapLayout, + 'subject' => $subject, + 'body_html' => $body, + 'preheader' => $preheader, + 'cta_text' => $ctaText, + 'cta_url' => $ctaUrl, + 'personalize' => $personalize, + 'from_key' => $fromKey, + 'mode' => $mode, + ], $recipients); + + $message = sprintf( + 'Broadcast finished. Mode: %s. Sent: %d/%d. Failures: %d.', + $stats['mode'], + $stats['sent'], + $stats['attempted'], + $stats['failed'] + ); + + return response()->json([ + 'ok' => $stats['failed'] === 0, + 'stats' => $stats, + 'message' => $message, + ]); + } + + public function uploadImage(Request $request): JsonResponse + { + $file = $request->file('image'); + if (!$file || !$file->isValid()) { + return response()->json([ + 'success' => false, + 'error' => 'No image uploaded', + ], 400); + } + + $result = $this->imageService->store($file); + if (!$result['ok']) { + return response()->json([ + 'success' => false, + 'error' => $result['error'] ?? 'Upload failed', + ], $result['status'] ?? 400); + } + + $newHash = function_exists('csrf_hash') ? csrf_hash() : csrf_token(); + + return response()->json([ + 'success' => true, + 'url' => $result['url'], + 'csrf_hash' => $newHash, + ])->header('X-CSRF-HASH', (string) $newHash); + } + + private function normalizeIds($rawIds): array + { + $ids = []; + $raw = is_array($rawIds) ? $rawIds : [$rawIds]; + + foreach ($raw as $value) { + if (is_string($value) && strpos($value, ',') !== false) { + foreach (explode(',', $value) as $piece) { + $ids[] = (int) $piece; + } + continue; + } + $ids[] = (int) $value; + } + + return array_values(array_unique(array_filter($ids))); + } +} diff --git a/app/Http/Controllers/Api/Expenses/ExpenseController.php b/app/Http/Controllers/Api/Expenses/ExpenseController.php new file mode 100644 index 00000000..726f5cb7 --- /dev/null +++ b/app/Http/Controllers/Api/Expenses/ExpenseController.php @@ -0,0 +1,180 @@ +context->getSchoolYear(); + $semester = $this->context->getSemester(); + + return response()->json([ + 'ok' => true, + 'schoolYear' => $schoolYear, + 'semester' => $semester, + 'retailors' => $this->retailors->listRetailors(), + 'staff' => ExpenseStaffResource::collection($this->staff->listStaffUsers()), + ]); + } + + public function index(): JsonResponse + { + $rows = $this->queryService->listAll(); + $rows = array_map(function ($row) { + $row['receipt_url'] = $this->receiptService->receiptUrl($row['receipt_path'] ?? null); + return $row; + }, $rows); + + return response()->json([ + 'ok' => true, + 'expenses' => ExpenseResource::collection($rows), + ]); + } + + public function show(int $id): JsonResponse + { + $row = $this->queryService->findById($id); + if (!$row) { + return response()->json(['ok' => false, 'message' => 'Expense not found.'], 404); + } + + $row['receipt_url'] = $this->receiptService->receiptUrl($row['receipt_path'] ?? null); + + return response()->json([ + 'ok' => true, + 'expense' => new ExpenseResource($row), + ]); + } + + public function store(StoreExpenseRequest $request): JsonResponse + { + $payload = $request->validated(); + $userId = (int) (auth()->id() ?? 0); + + $receiptName = null; + if ($request->hasFile('receipt')) { + $receiptName = $this->receiptService->storeReceipt($request->file('receipt')); + } + + $isDonation = $payload['category'] === 'Donation'; + + $expense = $this->writeService->store([ + 'category' => $payload['category'], + 'amount' => $payload['amount'], + 'receipt_path' => $receiptName, + 'description' => $payload['description'] ?? null, + 'retailor' => $payload['retailor'] ?? null, + 'date_of_purchase' => $payload['date_of_purchase'], + 'purchased_by' => (int) $payload['purchased_by'], + 'added_by' => $userId, + 'status' => $isDonation ? 'approved' : 'pending', + 'status_reason' => $isDonation ? 'Marked as Donation (non-reimbursable).' : null, + 'approved_by' => $isDonation ? $userId : null, + 'school_year' => $payload['school_year'] ?? null, + 'semester' => $payload['semester'] ?? null, + ]); + + return response()->json([ + 'ok' => true, + 'expense' => new ExpenseResource($expense->toArray()), + ], 201); + } + + public function update(UpdateExpenseRequest $request, int $id): JsonResponse + { + $expense = Expense::query()->find($id); + if (!$expense) { + return response()->json(['ok' => false, 'message' => 'Expense not found.'], 404); + } + + $payload = $request->validated(); + $userId = (int) (auth()->id() ?? 0); + + $receiptName = $expense->receipt_path; + if ($request->hasFile('receipt')) { + $receiptName = $this->receiptService->storeReceipt($request->file('receipt')); + } + if (!empty($payload['remove_receipt'])) { + $receiptName = null; + } + + $category = $payload['category']; + $isDonation = $category === 'Donation'; + + $updateData = [ + 'category' => $category, + 'amount' => $payload['amount'], + 'description' => $payload['description'] ?? null, + 'retailor' => $payload['retailor'] ?? null, + 'date_of_purchase' => $payload['date_of_purchase'], + 'purchased_by' => (int) $payload['purchased_by'], + 'receipt_path' => $receiptName, + 'updated_by' => $userId, + ]; + + if ($isDonation) { + $updateData['status'] = 'approved'; + $updateData['status_reason'] = 'Marked as Donation (non-reimbursable).'; + $updateData['approved_by'] = $userId ?: null; + $updateData['reimbursement_id'] = null; + } elseif (($expense->category ?? '') === 'Donation') { + $updateData['status_reason'] = null; + $updateData['approved_by'] = $expense->approved_by; + $updateData['status'] = $expense->status ?? 'pending'; + } + + $updated = $this->writeService->update($expense, $updateData); + + return response()->json([ + 'ok' => true, + 'expense' => new ExpenseResource($updated->toArray()), + ]); + } + + public function updateStatus(UpdateExpenseStatusRequest $request, int $id): JsonResponse + { + $expense = Expense::query()->find($id); + if (!$expense) { + return response()->json(['ok' => false, 'message' => 'Expense not found.'], 404); + } + + $payload = $request->validated(); + $userId = (int) (auth()->id() ?? 0); + + $updated = $this->statusService->updateStatus($expense, $payload['status'], $payload['reason'] ?? '', $userId); + + return response()->json([ + 'ok' => true, + 'expense' => new ExpenseResource($updated->toArray()), + ]); + } +} diff --git a/app/Http/Controllers/Api/ExtraCharges/ExtraChargesController.php b/app/Http/Controllers/Api/ExtraCharges/ExtraChargesController.php new file mode 100644 index 00000000..a9126454 --- /dev/null +++ b/app/Http/Controllers/Api/ExtraCharges/ExtraChargesController.php @@ -0,0 +1,143 @@ +query('school_year') ?? $this->context->getSchoolYear()); + $sem = (string) ($request->query('semester') ?? $this->context->getSemester()); + $status = $request->query('status') ?: null; + $q = trim((string) ($request->query('q') ?? '')) ?: null; + $per = (int) ($request->query('per_page') ?? 50); + + [$rows, $meta] = $this->listService->listForTerm($year, $sem, $status, $q, $per); + + return response()->json([ + 'ok' => true, + 'school_year' => $year, + 'semester' => $sem, + 'rows' => ExtraChargeResource::collection($rows), + 'pager' => $meta, + ]); + } + + public function options(Request $request): JsonResponse + { + $q = trim((string) ($request->query('q') ?? '')); + $parentOptions = $this->parents->searchParents($q); + + return response()->json([ + 'ok' => true, + 'schoolYear' => $this->context->getSchoolYear(), + 'semester' => $this->context->getSemester(), + 'schoolYears' => $this->meta->getSchoolYears($this->context->getSchoolYear()), + 'parents' => ExtraChargeParentOptionResource::collection($parentOptions), + ]); + } + + public function parentOptions(Request $request): JsonResponse + { + $q = trim((string) ($request->query('q') ?? '')); + $parentOptions = $this->parents->searchParents($q); + + return response()->json([ + 'results' => ExtraChargeParentOptionResource::collection($parentOptions), + ]); + } + + public function invoicesForParent(Request $request): JsonResponse + { + $parentId = (int) ($request->query('parent_id') ?? 0); + $schoolYear = (string) ($request->query('school_year') ?? $this->context->getSchoolYear()); + $rows = $this->invoices->listInvoicesForParent($parentId, $schoolYear); + + return response()->json([ + 'results' => ExtraChargeInvoiceOptionResource::collection($rows), + ]); + } + + public function store(StoreExtraChargeRequest $request): JsonResponse + { + $payload = $request->validated(); + $payload['created_by'] = (int) (auth()->id() ?? 0); + + $result = $this->chargeService->createCharge($payload); + + return response()->json($result, $result['ok'] ? 201 : 422); + } + + public function update(UpdateExtraChargeRequest $request, int $id): JsonResponse + { + $charge = AdditionalCharge::query()->find($id); + if (!$charge) { + return response()->json(['ok' => false, 'error' => 'Charge not found'], 404); + } + + $payload = $request->validated(); + $payload['updated_by'] = (int) (auth()->id() ?? 0); + + $ok = $this->chargeService->updateCharge($charge, $payload); + + return response()->json([ + 'ok' => $ok, + 'id' => $id, + ], $ok ? 200 : 422); + } + + public function void(int $id): JsonResponse + { + $charge = AdditionalCharge::query()->find($id); + if (!$charge) { + return response()->json(['ok' => false, 'error' => 'Charge not found'], 404); + } + + $ok = $this->chargeService->voidCharge($charge); + + return response()->json([ + 'ok' => $ok, + ], $ok ? 200 : 422); + } + + public function reverse(int $id): JsonResponse + { + $charge = AdditionalCharge::query()->find($id); + if (!$charge) { + return response()->json(['ok' => false, 'error' => 'Charge not found'], 404); + } + + $ok = $this->chargeService->reverseCharge($charge); + + return response()->json([ + 'ok' => $ok, + ], $ok ? 200 : 422); + } +} diff --git a/app/Http/Controllers/Api/Finance/FinancialController.php b/app/Http/Controllers/Api/Finance/FinancialController.php new file mode 100644 index 00000000..45d84525 --- /dev/null +++ b/app/Http/Controllers/Api/Finance/FinancialController.php @@ -0,0 +1,218 @@ +validated(); + $report = $this->reportService->getReport( + $payload['date_from'] ?? null, + $payload['date_to'] ?? null, + $payload['school_year'] ?? null + ); + + return response()->json([ + 'ok' => true, + 'report' => new FinancialReportResource($report), + ]); + } + + public function summary(FinancialSummaryRequest $request): JsonResponse + { + $payload = $request->validated(); + $summary = $this->summaryService->getSummary( + $payload['date_from'] ?? null, + $payload['date_to'] ?? null, + $payload['school_year'] ?? null + ); + + $schoolYears = $this->schoolYears->listYears($summary['schoolYear'] ?? null); + + return response()->json([ + 'ok' => true, + 'summary' => new FinancialSummaryResource($summary), + 'schoolYears' => $schoolYears, + ]); + } + + public function unpaidParents(FinancialUnpaidParentsRequest $request): JsonResponse + { + $payload = $request->validated(); + $data = $this->unpaidParentsService->getUnpaidParents($payload['school_year'] ?? null); + + return response()->json([ + 'ok' => true, + 'schoolYear' => $data['schoolYear'] ?? null, + 'schoolYears' => $data['schoolYears'] ?? [], + 'results' => FinancialUnpaidParentResource::collection($data['results'] ?? []), + ]); + } + + public function downloadCsv(FinancialReportRequest $request): StreamedResponse + { + $payload = $request->validated(); + $report = $this->reportService->getReport( + $payload['date_from'] ?? null, + $payload['date_to'] ?? null, + $payload['school_year'] ?? null + ); + + $paymentsMap = []; + foreach ($report['payments'] as $payment) { + $invoiceId = (int) ($payment['invoice_id'] ?? 0); + $paymentsMap[$invoiceId] = (float) ($payment['paid_amount'] ?? 0); + } + + $refundsMap = []; + foreach ($report['refunds'] as $refund) { + $invoiceId = (int) ($refund['invoice_id'] ?? 0); + $refundsMap[$invoiceId] = (float) ($refund['total_refunded'] ?? 0); + } + + $discountsMap = []; + foreach ($report['discounts'] as $discount) { + $invoiceId = (int) ($discount['invoice_id'] ?? 0); + $discountsMap[$invoiceId] = (float) ($discount['discount_amount'] ?? 0); + } + + $breakdown = $report['paymentBreakdown'] ?? []; + + $filename = 'financial_report_' . date('Ymd_His') . '.csv'; + + return response()->streamDownload(function () use ($report, $paymentsMap, $refundsMap, $discountsMap, $breakdown) { + $out = fopen('php://output', 'w'); + + fputcsv($out, ['Invoice #', 'Parent', 'School Year', 'Total', 'Paid', 'Cash', 'Credit', 'Check', 'Balance', 'Refund', 'Discount', 'Status']); + $sumTotal = 0.0; + $sumPaid = 0.0; + $sumCash = 0.0; + $sumCredit = 0.0; + $sumCheck = 0.0; + $sumBalance = 0.0; + $sumRefund = 0.0; + $sumDiscount = 0.0; + + foreach ($report['invoices'] as $inv) { + $invoiceId = (int) ($inv['id'] ?? 0); + $paid = (float) ($paymentsMap[$invoiceId] ?? 0); + $bd = $breakdown[$invoiceId] ?? []; + $cash = (float) ($bd['cash'] ?? 0); + $credit = (float) ($bd['credit'] ?? 0); + $check = (float) ($bd['check'] ?? 0); + $refunded = (float) ($refundsMap[$invoiceId] ?? 0); + $discount = (float) ($discountsMap[$invoiceId] ?? 0); + $total = (float) ($inv['total_amount'] ?? 0); + $balance = $total - $paid - $discount - $refunded; + if ($balance < 0) { + $balance = 0.0; + } + $status = $balance === 0.0 ? 'Paid' : 'Unpaid'; + + fputcsv($out, [ + $inv['invoice_number'] ?? '', + $inv['parent_name'] ?? '', + $inv['school_year'] ?? '', + $total, + $paid, + $cash, + $credit, + $check, + $balance, + $refunded, + $discount, + $status, + ]); + + $sumTotal += $total; + $sumPaid += $paid; + $sumCash += $cash; + $sumCredit += $credit; + $sumCheck += $check; + $sumBalance += $balance; + $sumRefund += $refunded; + $sumDiscount += $discount; + } + + fputcsv($out, [ + 'TOTALS', + '', + '', + $sumTotal, + $sumPaid, + $sumCash, + $sumCredit, + $sumCheck, + $sumBalance, + $sumRefund, + $sumDiscount, + '', + ]); + + fputcsv($out, []); + fputcsv($out, ['Expenses Summary']); + fputcsv($out, ['Category', 'Total Amount']); + foreach ($report['expenses'] as $exp) { + fputcsv($out, [ + $exp['category'] ?? '', + $exp['total_amount'] ?? 0, + ]); + } + + fputcsv($out, []); + fputcsv($out, ['Reimbursements Summary']); + fputcsv($out, ['Status', 'Total Amount']); + foreach ($report['reimbursements'] as $row) { + fputcsv($out, [ + $row['status'] ?? '', + $row['total_amount'] ?? 0, + ]); + } + + fclose($out); + }, $filename, ['Content-Type' => 'text/csv']); + } + + public function downloadPdf(FinancialReportRequest $request): StreamedResponse + { + $payload = $request->validated(); + $summary = $this->summaryService->getSummary( + $payload['date_from'] ?? null, + $payload['date_to'] ?? null, + $payload['school_year'] ?? null + ); + + $pdfBytes = $this->pdfService->buildPdf($summary); + $schoolYear = $summary['schoolYear'] ?? 'report'; + + return response()->streamDownload(function () use ($pdfBytes) { + echo $pdfBytes; + }, 'Financial_Report_' . $schoolYear . '.pdf', ['Content-Type' => 'application/pdf']); + } +} diff --git a/app/Http/Controllers/Api/Finance/InvoiceController.php b/app/Http/Controllers/Api/Finance/InvoiceController.php new file mode 100644 index 00000000..944187af --- /dev/null +++ b/app/Http/Controllers/Api/Finance/InvoiceController.php @@ -0,0 +1,125 @@ +validated()['school_year'] ?? null; + $schoolYear = $schoolYear ?: $request->input('schoolYear'); + $schoolYear = $schoolYear ?: $request->input('year'); + + $data = $this->management->getManagementData($schoolYear); + + return response()->json([ + 'ok' => true, + 'schoolYear' => $data['schoolYear'], + 'schoolYears' => $data['schoolYears'], + 'invoices' => InvoiceManagementParentResource::collection($data['invoices']), + ]); + } + + public function generate(InvoiceGenerateRequest $request): JsonResponse + { + $payload = $request->validated(); + $result = $this->generation->generateInvoice((int) $payload['parent_id'], $payload['school_year'] ?? null); + + if (empty($result['ok'])) { + return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Unable to generate invoice.'], 422); + } + + return response()->json([ + 'ok' => true, + 'updated' => $result['updated'] ?? false, + 'updated_ids' => $result['updated_ids'] ?? [], + 'insert_id' => $result['insert_id'] ?? null, + ]); + } + + public function byParent(InvoiceParentRequest $request, int $parentId): JsonResponse + { + $schoolYear = $request->validated()['school_year'] ?? null; + $invoices = Invoice::getInvoicesByParentId($parentId, $schoolYear); + + return response()->json([ + 'ok' => true, + 'invoices' => InvoiceResource::collection($invoices), + ]); + } + + public function parentPayment(InvoiceParentRequest $request): JsonResponse + { + $parentId = (int) (auth()->id() ?? 0); + if ($parentId <= 0) { + return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401); + } + + $schoolYear = $request->validated()['school_year'] ?? null; + $data = $this->paymentService->getParentInvoiceSummary($parentId, $schoolYear); + + return response()->json([ + 'ok' => true, + 'invoices' => InvoiceResource::collection($data['invoices']), + 'schoolYears' => $data['schoolYears'], + 'selectedYear' => $data['selectedYear'], + 'currentSchoolYear' => $data['currentSchoolYear'], + 'dueDate' => $data['dueDate'], + ]); + } + + public function updateStatus(InvoiceStatusRequest $request, int $invoiceId): JsonResponse + { + $status = $request->validated()['status']; + $updated = Invoice::updateInvoiceStatus($invoiceId, $status); + + if (!$updated) { + return response()->json(['ok' => false, 'message' => 'Invoice not found.'], 404); + } + + return response()->json(['ok' => true, 'status' => $status]); + } + + public function unpaid(): JsonResponse + { + $rows = Invoice::getUnpaidInvoices(); + + return response()->json([ + 'ok' => true, + 'invoices' => InvoiceResource::collection($rows), + ]); + } + + public function pdf(int $invoiceId): StreamedResponse + { + $pdfBytes = $this->pdfService->buildPdf($invoiceId); + + return response()->streamDownload(function () use ($pdfBytes) { + echo $pdfBytes; + }, 'invoice_' . $invoiceId . '.pdf', ['Content-Type' => 'application/pdf']); + } +} diff --git a/app/Http/Controllers/Api/Finance/PaymentController.php b/app/Http/Controllers/Api/Finance/PaymentController.php new file mode 100644 index 00000000..8b044593 --- /dev/null +++ b/app/Http/Controllers/Api/Finance/PaymentController.php @@ -0,0 +1,58 @@ +validated(); + $payment = $this->planService->createPlan($payload); + + return response()->json([ + 'ok' => true, + 'payment' => new PaymentResource($payment), + ], 201); + } + + public function byParent(PaymentByParentRequest $request, int $parentId): JsonResponse + { + $payload = $request->validated(); + $payments = $this->lookupService->getByParent($parentId, $payload['school_year'] ?? null); + + return response()->json([ + 'ok' => true, + 'payments' => PaymentResource::collection($payments), + ]); + } + + public function updateBalance(PaymentUpdateBalanceRequest $request, int $paymentId): JsonResponse + { + $payload = $request->validated(); + $updated = $this->balanceService->updateBalance($paymentId, (float) $payload['paid_amount']); + + if (!$updated) { + return response()->json(['ok' => false, 'message' => 'Payment not found.'], 404); + } + + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/Api/Finance/PaymentEventChargesController.php b/app/Http/Controllers/Api/Finance/PaymentEventChargesController.php new file mode 100644 index 00000000..fa6dafd9 --- /dev/null +++ b/app/Http/Controllers/Api/Finance/PaymentEventChargesController.php @@ -0,0 +1,46 @@ +validated(); + $data = $this->service->listCharges($payload['school_year'] ?? null, $payload['semester'] ?? null); + + return response()->json(['ok' => true] + $data); + } + + public function store(PaymentEventChargesStoreRequest $request): JsonResponse + { + $payload = $request->validated(); + $userId = (int) (auth()->id() ?? 0); + + $count = $this->service->addCharges($payload, $userId); + if ($count === 0) { + return response()->json(['ok' => false, 'message' => 'No student selected.'], 422); + } + + return response()->json(['ok' => true, 'inserted' => $count]); + } + + public function enrolledStudents(PaymentEventChargesListRequest $request, int $parentId): JsonResponse + { + $payload = $request->validated(); + $students = $this->service->getEnrolledStudents($parentId, $payload['school_year'] ?? null); + + return response()->json(['ok' => true, 'students' => $students]); + } +} diff --git a/app/Http/Controllers/Api/Finance/PaymentManualController.php b/app/Http/Controllers/Api/Finance/PaymentManualController.php new file mode 100644 index 00000000..5cd5f466 --- /dev/null +++ b/app/Http/Controllers/Api/Finance/PaymentManualController.php @@ -0,0 +1,81 @@ +validated(); + $data = $this->service->search( + (string) ($payload['search_term'] ?? ''), + $payload['school_year'] ?? null + ); + + return response()->json(['ok' => true] + $data); + } + + public function suggest(PaymentManualSuggestRequest $request): JsonResponse + { + $payload = $request->validated(); + $items = $this->service->suggest((string) ($payload['q'] ?? '')); + + return response()->json(['ok' => true, 'items' => $items]); + } + + public function record(PaymentManualUpdateRequest $request, int $invoiceId): JsonResponse + { + $payload = $request->validated(); + + $result = $this->service->recordPayment( + $invoiceId, + (float) $payload['amount'], + (string) $payload['payment_method'], + $payload['payment_date'] ?? null, + $payload['check_number'] ?? null, + $payload['payment_type'] ?? null, + $request->file('payment_file'), + $payload['school_year'] ?? null, + $payload['semester'] ?? null, + (int) (auth()->id() ?? 0) + ); + + return response()->json(['ok' => true] + $result); + } + + public function edit(PaymentManualEditRequest $request, int $paymentId): JsonResponse + { + $payload = $request->validated(); + + $this->service->editPayment( + $paymentId, + (float) $payload['paid_amount'], + (string) $payload['payment_method'], + $payload['check_number'] ?? null, + $request->file('payment_file'), + (int) (auth()->id() ?? 0) + ); + + return response()->json(['ok' => true]); + } + + public function file(Request $request, string $filename) + { + $mode = (string) ($request->query('mode') ?? 'download'); + return $this->service->serveCheckFile($filename, $mode); + } +} diff --git a/app/Http/Controllers/Api/Finance/PaymentNotificationController.php b/app/Http/Controllers/Api/Finance/PaymentNotificationController.php new file mode 100644 index 00000000..206f97dc --- /dev/null +++ b/app/Http/Controllers/Api/Finance/PaymentNotificationController.php @@ -0,0 +1,41 @@ +validated(); + $logs = $this->service->listLogs( + $payload['from'] ?? null, + $payload['to'] ?? null, + $payload['type'] ?? null + ); + + return response()->json([ + 'ok' => true, + 'logs' => PaymentNotificationLogResource::collection($logs), + ]); + } + + public function send(PaymentNotificationSendRequest $request): JsonResponse + { + $payload = $request->validated(); + $result = $this->service->send($payload); + + return response()->json(['ok' => true] + $result); + } +} diff --git a/app/Http/Controllers/Api/Finance/PaymentTransactionController.php b/app/Http/Controllers/Api/Finance/PaymentTransactionController.php new file mode 100644 index 00000000..80954c58 --- /dev/null +++ b/app/Http/Controllers/Api/Finance/PaymentTransactionController.php @@ -0,0 +1,51 @@ +validated(); + $transaction = $this->service->create($payload); + + return response()->json([ + 'ok' => true, + 'transaction' => new PaymentTransactionResource($transaction->toArray()), + ], 201); + } + + public function byPayment(int $paymentId): JsonResponse + { + $transactions = $this->service->getByPayment($paymentId); + + return response()->json([ + 'ok' => true, + 'transactions' => PaymentTransactionResource::collection($transactions), + ]); + } + + public function updateStatus(PaymentTransactionStatusRequest $request, string $transactionId): JsonResponse + { + $payload = $request->validated(); + $updated = $this->service->updateStatus($transactionId, $payload['status']); + + if (!$updated) { + return response()->json(['ok' => false, 'message' => 'Transaction not found.'], 404); + } + + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/Api/Finance/PaypalPaymentController.php b/app/Http/Controllers/Api/Finance/PaypalPaymentController.php new file mode 100644 index 00000000..cfaf9530 --- /dev/null +++ b/app/Http/Controllers/Api/Finance/PaypalPaymentController.php @@ -0,0 +1,52 @@ +json([ + 'ok' => true, + 'redirect_url' => $this->service->getRedirectUrl(), + ]); + } + + public function create(int $paymentId): JsonResponse + { + $result = $this->service->createPayment($paymentId); + + return response()->json(['ok' => true] + $result); + } + + public function execute(PaypalExecuteRequest $request): JsonResponse + { + $payload = $request->validated(); + $this->service->executePayment( + (string) $payload['payer_id'], + $payload['paypal_payment_id'] ?? null + ); + + return response()->json(['ok' => true]); + } + + public function cancel(): JsonResponse + { + $this->service->cancelPayment(); + + return response()->json([ + 'ok' => true, + 'message' => 'Payment was canceled.', + ]); + } +} diff --git a/app/Http/Controllers/Api/Finance/PaypalTransactionsController.php b/app/Http/Controllers/Api/Finance/PaypalTransactionsController.php new file mode 100644 index 00000000..46764bbf --- /dev/null +++ b/app/Http/Controllers/Api/Finance/PaypalTransactionsController.php @@ -0,0 +1,71 @@ +validated(); + $perPage = (int) ($payload['per_page'] ?? 10); + $rows = $this->service->list($payload['q'] ?? null, $perPage); + + return response()->json([ + 'ok' => true, + 'transactions' => PaypalTransactionResource::collection($rows->items()), + 'pagination' => [ + 'current_page' => $rows->currentPage(), + 'per_page' => $rows->perPage(), + 'total' => $rows->total(), + 'last_page' => $rows->lastPage(), + ], + ]); + } + + public function downloadCsv(PaypalTransactionsListRequest $request): StreamedResponse + { + $payload = $request->validated(); + $rows = $this->service->listAll($payload['q'] ?? null); + + $filename = 'paypal_transactions_' . date('Ymd_His') . '.csv'; + + return response()->streamDownload(function () use ($rows) { + $out = fopen('php://output', 'w'); + fputcsv($out, [ + 'ID', 'Transaction ID', 'Order ID', 'Parent School ID', + 'Email', 'Amount', 'Net Amount', 'Currency', + 'Status', 'Event Type', 'Created At', + ]); + + foreach ($rows as $row) { + fputcsv($out, [ + $row['id'] ?? null, + $row['transaction_id'] ?? null, + $row['order_id'] ?? null, + $row['parent_school_id'] ?? null, + $row['payer_email'] ?? null, + $row['amount'] ?? null, + $row['net_amount'] ?? null, + $row['currency'] ?? null, + $row['status'] ?? null, + $row['event_type'] ?? null, + $row['created_at'] ?? null, + ]); + } + + fclose($out); + }, $filename, ['Content-Type' => 'text/csv']); + } +} diff --git a/app/Http/Controllers/Api/Finance/ReimbursementController.php b/app/Http/Controllers/Api/Finance/ReimbursementController.php new file mode 100644 index 00000000..4a173cb6 --- /dev/null +++ b/app/Http/Controllers/Api/Finance/ReimbursementController.php @@ -0,0 +1,391 @@ +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(ReimbursementMarkDonationRequest $request): JsonResponse + { + $payload = $request->validated(); + $userId = (int) (auth()->id() ?? 0); + + try { + $this->donationService->markDonation((int) $payload['expense_id'], $userId); + } 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(ReimbursementBatchCreateRequest $request): JsonResponse + { + $payload = $request->validated(); + $userId = (int) (auth()->id() ?? 0); + $schoolYear = $this->context->getSchoolYear(); + $semester = $this->context->getSemester(); + + $batch = $this->batchService->createBatch($payload['title'] ?? null, $userId, $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 + { + $payload = $request->validated(); + $userId = (int) (auth()->id() ?? 0); + + try { + $this->batchService->lockBatch( + (int) $payload['batch_id'], + $userId, + $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 + { + $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'), (int) (auth()->id() ?? 0)); + } 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(ReimbursementStoreRequest $request): JsonResponse + { + $payload = $request->validated(); + $userId = (int) (auth()->id() ?? 0); + $receiptName = null; + + if ($request->hasFile('receipt')) { + $receiptName = $this->fileService->storeReceipt($request->file('receipt')); + } + + try { + $reimb = $this->crudService->store( + $payload, + $userId, + $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 + { + $payload = $request->validated(); + $userId = (int) (auth()->id() ?? 0); + $receiptName = null; + + if ($request->hasFile('receipt')) { + $receiptName = $this->fileService->storeReceipt($request->file('receipt')); + } + + try { + $reimb = $this->crudService->process( + $payload, + $userId, + $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(ReimbursementUpdateRequest $request, int $id): JsonResponse + { + $reimb = Reimbursement::query()->find($id); + if (!$reimb) { + return response()->json(['ok' => false, 'message' => 'Reimbursement not found.'], 404); + } + + $payload = $request->validated(); + $userId = (int) (auth()->id() ?? 0); + + $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, $userId, $receiptName); + $data = $updated->toArray(); + $data['receipt_url'] = $this->fileService->receiptUrl($updated->receipt_path ?? null); + + return response()->json([ + 'ok' => true, + 'reimbursement' => new ReimbursementResource($data), + ]); + } +} diff --git a/app/Http/Controllers/Api/Grading/GradingController.php b/app/Http/Controllers/Api/Grading/GradingController.php new file mode 100644 index 00000000..11516b36 --- /dev/null +++ b/app/Http/Controllers/Api/Grading/GradingController.php @@ -0,0 +1,281 @@ +validated(); + $data = $this->overviewService->overview( + isset($payload['class_id']) ? (int) $payload['class_id'] : null, + $payload['semester'] ?? null, + $payload['school_year'] ?? null + ); + + return response()->json(['ok' => true] + $data); + } + + public function show(GradingScoreShowRequest $request, string $type, int $classSectionId, int $studentId): JsonResponse + { + $payload = $request->validated(); + $data = $this->scoreService->show( + $type, + $classSectionId, + $studentId, + (string) $payload['semester'], + (string) $payload['school_year'] + ); + + return response()->json([ + 'ok' => true, + 'student' => $data['student'], + 'scores' => GradingScoreResource::collection($data['scores']), + 'type' => $data['type'], + 'class_section_id' => $data['class_section_id'], + 'semester' => $data['semester'], + 'scores_locked' => $data['scores_locked'], + ]); + } + + public function update(GradingScoreUpdateRequest $request): JsonResponse + { + $payload = $request->validated(); + $this->scoreService->update($payload, (int) (auth()->id() ?? 0)); + + return response()->json(['ok' => true]); + } + + public function toggleLock(GradingLockRequest $request): JsonResponse + { + $payload = $request->validated(); + $locked = $this->lockService->toggle( + (int) $payload['class_section_id'], + (string) $payload['semester'], + (string) $payload['school_year'], + (int) (auth()->id() ?? 0) + ); + + return response()->json(['ok' => true, 'locked' => $locked]); + } + + public function lockAll(GradingLockAllRequest $request): JsonResponse + { + $payload = $request->validated(); + $count = $this->lockService->lockAll( + (string) $payload['semester'], + (string) $payload['school_year'], + (int) (auth()->id() ?? 0) + ); + + return response()->json(['ok' => true, 'locked_count' => $count]); + } + + public function refresh(GradingRefreshRequest $request): JsonResponse + { + $payload = $request->validated(); + $this->refreshService->refreshSemesterScores( + (int) $payload['class_section_id'], + (string) $payload['semester'], + (string) $payload['school_year'] + ); + + return response()->json(['ok' => true]); + } + + public function placement(PlacementRequest $request): JsonResponse + { + $payload = $request->validated(); + $data = $this->placementService->placementContext( + isset($payload['class_section_id']) ? (int) $payload['class_section_id'] : null, + (string) $payload['school_year'], + $payload['placement_test'] ?? null, + $payload['open'] ?? null + ); + + return response()->json(['ok' => true] + $data); + } + + public function updatePlacementLevel(PlacementLevelRequest $request): JsonResponse + { + $payload = $request->validated(); + $this->placementService->updatePlacementLevel( + (int) $payload['student_id'], + (string) $payload['school_year'], + $payload['placement_level'] ?? null, + (int) (auth()->id() ?? 0) + ); + + return response()->json(['ok' => true]); + } + + public function updatePlacementLevels(PlacementLevelsRequest $request): JsonResponse + { + $payload = $request->validated(); + $this->placementService->updatePlacementLevels( + (int) $payload['class_section_id'], + (string) $payload['school_year'], + $payload['placement_level'] ?? [], + (int) (auth()->id() ?? 0) + ); + + return response()->json(['ok' => true]); + } + + public function createPlacementBatch(PlacementBatchRequest $request): JsonResponse + { + $payload = $request->validated(); + $batchId = $this->placementService->createPlacementBatch( + (string) $payload['school_year'], + (string) $payload['placement_test'], + $payload['placement_level'] ?? [], + (int) (auth()->id() ?? 0) + ); + + return response()->json(['ok' => true, 'batch_id' => $batchId]); + } + + public function showPlacementBatch(int $batchId): JsonResponse + { + $data = $this->placementService->getPlacementBatch($batchId); + + return response()->json(['ok' => true] + $data); + } + + public function updatePlacementBatch(PlacementBatchUpdateRequest $request, int $batchId): JsonResponse + { + $payload = $request->validated(); + $this->placementService->updatePlacementBatch( + $batchId, + (string) $payload['school_year'], + $payload['placement_level'] ?? [], + (int) (auth()->id() ?? 0) + ); + + return response()->json(['ok' => true]); + } + + public function belowSixty(BelowSixtyListRequest $request): JsonResponse + { + $payload = $request->validated(); + $rows = $this->belowSixtyService->listRows( + (string) $payload['school_year'], + (string) $payload['semester'] + ); + + return response()->json([ + 'ok' => true, + 'rows' => BelowSixtyStudentResource::collection($rows), + 'semester' => $payload['semester'], + 'school_year' => $payload['school_year'], + ]); + } + + public function belowSixtyEmail(BelowSixtyEmailRequest $request): JsonResponse + { + $payload = $request->validated(); + $data = $this->belowSixtyService->emailContext( + (int) $payload['student_id'], + (string) $payload['school_year'], + (string) $payload['semester'] + ); + + return response()->json(['ok' => true] + $data); + } + + public function sendBelowSixtyEmail(BelowSixtySendRequest $request): JsonResponse + { + $payload = $request->validated(); + $data = $this->belowSixtyService->emailContext( + (int) $payload['student_id'], + (string) $payload['school_year'], + (string) $payload['semester'] + ); + + $subject = trim((string) ($payload['subject'] ?? '')); + if ($subject !== '') { + $data['subject'] = $subject; + } + if (isset($payload['html']) && trim((string) $payload['html']) !== '') { + $data['html'] = (string) $payload['html']; + } + + $this->belowSixtyService->sendEmail($data); + + return response()->json(['ok' => true]); + } + + public function updateBelowSixtyStatus(BelowSixtyStatusRequest $request): JsonResponse + { + $payload = $request->validated(); + $this->belowSixtyService->updateStatus( + (int) $payload['student_id'], + (string) $payload['semester'], + (string) $payload['school_year'], + (string) $payload['status'], + (string) ($payload['note'] ?? ''), + (int) (auth()->id() ?? 0) + ); + + return response()->json(['ok' => true]); + } + + public function belowSixtyMeeting(BelowSixtyMeetingRequest $request): JsonResponse + { + $payload = $request->validated(); + $data = $this->belowSixtyService->meetingContext( + (int) $payload['student_id'], + (string) $payload['school_year'], + (string) $payload['semester'] + ); + + return response()->json(['ok' => true] + $data); + } + + public function saveBelowSixtyMeeting(BelowSixtyMeetingSaveRequest $request): JsonResponse + { + $payload = $request->validated(); + $this->belowSixtyService->saveMeeting($payload, (int) (auth()->id() ?? 0)); + + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/Api/Grading/HomeworkTrackingController.php b/app/Http/Controllers/Api/Grading/HomeworkTrackingController.php new file mode 100644 index 00000000..17f873e6 --- /dev/null +++ b/app/Http/Controllers/Api/Grading/HomeworkTrackingController.php @@ -0,0 +1,45 @@ +validated(); + $data = $this->service->report( + $payload['semester'] ?? null, + $payload['school_year'] ?? null, + isset($payload['page']) ? (int) $payload['page'] : 1 + ); + + return response()->json([ + 'ok' => true, + 'semester' => $data['semester'], + 'school_year' => $data['school_year'], + 'sundays' => $data['sundays'], + 'event_days' => $data['event_days'], + 'date_to_index' => $data['date_to_index'], + 'teachers' => HomeworkTrackingTeacherResource::collection($data['teachers']), + 'has_homework' => $data['has_homework'], + 'hw_entered_at' => $data['hw_entered_at'], + 'has_homework_by_date' => $data['has_homework_by_date'], + 'hw_entered_at_by_date' => $data['hw_entered_at_by_date'], + 'page' => $data['page'], + 'total_pages' => $data['total_pages'], + 'per_page' => $data['per_page'], + 'total_rows' => $data['total_rows'], + ]); + } +} diff --git a/app/Http/Controllers/Api/Incidents/IncidentController.php b/app/Http/Controllers/Api/Incidents/IncidentController.php new file mode 100644 index 00000000..1d30d344 --- /dev/null +++ b/app/Http/Controllers/Api/Incidents/IncidentController.php @@ -0,0 +1,151 @@ +currentService->listCurrent(); + + return response()->json([ + 'ok' => true, + 'incidents' => IncidentResource::collection($data['incidents']), + 'grades' => IncidentGradeResource::collection($data['grades']), + ]); + } + + public function history(IncidentListRequest $request): JsonResponse + { + $payload = $request->validated(); + $incidents = $this->historyService->history($payload['school_year'] ?? null, $payload['semester'] ?? null); + + return response()->json([ + 'ok' => true, + 'incidents' => IncidentResource::collection($incidents), + ]); + } + + public function processed(IncidentListRequest $request): JsonResponse + { + $payload = $request->validated(); + $incidents = $this->historyService->processed($payload['school_year'] ?? null, $payload['semester'] ?? null); + + return response()->json([ + 'ok' => true, + 'incidents' => IncidentResource::collection($incidents), + ]); + } + + public function analysis(IncidentListRequest $request): JsonResponse + { + $payload = $request->validated(); + $students = $this->analysisService->analyze($payload['school_year'] ?? null, $payload['semester'] ?? null); + + return response()->json([ + 'ok' => true, + 'students' => IncidentAnalysisStudentResource::collection($students), + 'school_year' => $payload['school_year'] ?? null, + 'semester' => $payload['semester'] ?? null, + ]); + } + + public function studentsByGrade(int $gradeId): JsonResponse + { + $students = $this->currentService->studentsByGrade($gradeId); + + return response()->json([ + 'ok' => true, + 'students' => IncidentStudentOptionResource::collection($students), + ]); + } + + public function store(IncidentStoreRequest $request): JsonResponse + { + $payload = $request->validated(); + $result = $this->currentService->addIncident($payload, (int) (auth()->id() ?? 0)); + + if (empty($result['ok'])) { + return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to add incident.'], 422); + } + + return response()->json([ + 'ok' => true, + 'created' => $result['created'], + 'incident_id' => $result['incident_id'], + ]); + } + + public function updateState(IncidentStateRequest $request, int $incidentId): JsonResponse + { + $payload = $request->validated(); + $updated = $this->currentService->updateState($incidentId, (string) $payload['incident_state']); + + return response()->json([ + 'ok' => $updated, + ]); + } + + public function close(IncidentCloseRequest $request, int $incidentId): JsonResponse + { + $payload = $request->validated(); + $result = $this->currentService->closeIncident( + $incidentId, + (string) $payload['state_description'], + $payload['action_taken'] ?? null, + (int) (auth()->id() ?? 0) + ); + + if (empty($result['ok'])) { + return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to close incident.'], 422); + } + + return response()->json([ + 'ok' => true, + 'incident_id' => $result['incident_id'], + ]); + } + + public function cancel(IncidentCancelRequest $request, int $incidentId): JsonResponse + { + $payload = $request->validated(); + $result = $this->currentService->cancelIncident( + $incidentId, + $payload['state_description'] ?? null, + $payload['action_taken'] ?? null, + (int) (auth()->id() ?? 0) + ); + + if (empty($result['ok'])) { + return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to cancel incident.'], 422); + } + + return response()->json([ + 'ok' => true, + 'incident_id' => $result['incident_id'], + ]); + } +} diff --git a/app/Http/Controllers/Api/Reports/FilesController.php b/app/Http/Controllers/Api/Reports/FilesController.php new file mode 100644 index 00000000..29ec1197 --- /dev/null +++ b/app/Http/Controllers/Api/Reports/FilesController.php @@ -0,0 +1,105 @@ +serveFile( + $request, + storage_path('uploads/receipts'), + $name, + ['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'], + null, + false + ); + } + + public function reimb(FileNameRequest $request, string $name): Response|JsonResponse + { + return $this->serveFile( + $request, + storage_path('uploads/reimbursements'), + $name, + ['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'], + null, + true + ); + } + + public function earlyDismissalSignature(FileNameRequest $request, string $name): Response|JsonResponse + { + return $this->serveFile( + $request, + storage_path('uploads/early_dismissal_signatures'), + $name, + ['jpg', 'jpeg', 'png', 'webp', 'gif', 'pdf'], + null, + true + ); + } + + public function examDraftTeacher(FileNameRequest $request, string $name): Response|JsonResponse + { + $downloadName = $this->draftNameService->build($name, 'drafts'); + + return $this->serveFile( + $request, + storage_path('uploads/exams/drafts'), + $name, + ['doc', 'docx', 'pdf'], + $downloadName, + true + ); + } + + public function examDraftFinal(FileNameRequest $request, string $name): Response|JsonResponse + { + $downloadName = $this->draftNameService->build($name, 'finals'); + + return $this->serveFile( + $request, + storage_path('uploads/exams/finals'), + $name, + ['doc', 'docx', 'pdf'], + $downloadName, + true + ); + } + + private function serveFile( + FileNameRequest $request, + string $baseDir, + string $name, + array $allowedExtensions, + ?string $downloadName, + bool $nosniff + ): Response|JsonResponse { + if ($request->boolean('meta')) { + $meta = $this->fileService->meta($baseDir, $name, $allowedExtensions, $downloadName); + + return response()->json([ + 'ok' => true, + 'file' => new FileMetaResource($meta), + ]); + } + + return $this->fileService->serveInline($baseDir, $name, $allowedExtensions, $request, $downloadName, $nosniff); + } +} diff --git a/app/Http/Controllers/Api/Scores/FinalController.php b/app/Http/Controllers/Api/Scores/FinalController.php new file mode 100644 index 00000000..7ba0f37b --- /dev/null +++ b/app/Http/Controllers/Api/Scores/FinalController.php @@ -0,0 +1,67 @@ +validated(); + $data = $this->service->list('final_exam', (int) $payload['class_section_id'], $payload['semester'] ?? null, $payload['school_year'] ?? null); + + return response()->json([ + 'ok' => true, + 'students' => ScoreStudentResource::collection($data['students']), + 'semester' => $data['semester'], + 'school_year' => $data['schoolYear'], + 'scores_locked' => $data['scoresLocked'], + 'missing_ok_map' => $data['missingOkMap'], + ]); + } + + public function update(ScoreUpdateRequest $request): JsonResponse + { + $payload = $request->validated(); + $count = $this->service->update( + 'final_exam', + (int) $payload['class_section_id'], + (string) $payload['semester'], + (string) $payload['school_year'], + $payload['scores'], + $payload['missing_ok'] ?? [], + (int) (auth()->id() ?? 0) + ); + + $this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year']); + + return response()->json(['ok' => true, 'updated' => $count]); + } + + private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear): void + { + $studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, (int) (auth()->id() ?? 0)); + if (empty($studentInfo)) { + return; + } + try { + $this->semesterScoreService->updateScoresForStudents($studentInfo, $semester, $schoolYear); + } catch (\Throwable $e) { + } + } +} diff --git a/app/Http/Controllers/Api/Scores/HomeworkController.php b/app/Http/Controllers/Api/Scores/HomeworkController.php new file mode 100644 index 00000000..a78ab68f --- /dev/null +++ b/app/Http/Controllers/Api/Scores/HomeworkController.php @@ -0,0 +1,85 @@ +validated(); + $data = $this->service->list( + (int) $payload['class_section_id'], + $payload['semester'] ?? null, + $payload['school_year'] ?? null + ); + + return response()->json([ + 'ok' => true, + 'students' => ScoreStudentResource::collection($data['students']), + 'headers' => $data['headers'], + 'semester' => $data['semester'], + 'school_year' => $data['schoolYear'], + 'scores_locked' => $data['scoresLocked'], + 'missing_ok_map' => $data['missingOkMap'], + ]); + } + + public function update(ScoreUpdateRequest $request): JsonResponse + { + $payload = $request->validated(); + $count = $this->service->update( + (int) $payload['class_section_id'], + (string) $payload['semester'], + (string) $payload['school_year'], + $payload['scores'], + $payload['missing_ok'] ?? [], + (int) (auth()->id() ?? 0) + ); + + $this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year']); + + return response()->json(['ok' => true, 'updated' => $count]); + } + + public function addColumn(ScoreAddColumnRequest $request): JsonResponse + { + $payload = $request->validated(); + $nextIndex = $this->service->addColumn( + (int) $payload['class_section_id'], + (string) $payload['semester'], + (string) $payload['school_year'], + (int) (auth()->id() ?? 0) + ); + + return response()->json(['ok' => true, 'homework_index' => $nextIndex]); + } + + private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear): void + { + $studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, (int) (auth()->id() ?? 0)); + if (empty($studentInfo)) { + return; + } + try { + $this->semesterScoreService->updateScoresForStudents($studentInfo, $semester, $schoolYear); + } catch (\Throwable $e) { + } + } +} diff --git a/app/Http/Controllers/Api/Scores/MidtermController.php b/app/Http/Controllers/Api/Scores/MidtermController.php new file mode 100644 index 00000000..25e8d335 --- /dev/null +++ b/app/Http/Controllers/Api/Scores/MidtermController.php @@ -0,0 +1,67 @@ +validated(); + $data = $this->service->list('midterm_exam', (int) $payload['class_section_id'], $payload['semester'] ?? null, $payload['school_year'] ?? null); + + return response()->json([ + 'ok' => true, + 'students' => ScoreStudentResource::collection($data['students']), + 'semester' => $data['semester'], + 'school_year' => $data['schoolYear'], + 'scores_locked' => $data['scoresLocked'], + 'missing_ok_map' => $data['missingOkMap'], + ]); + } + + public function update(ScoreUpdateRequest $request): JsonResponse + { + $payload = $request->validated(); + $count = $this->service->update( + 'midterm_exam', + (int) $payload['class_section_id'], + (string) $payload['semester'], + (string) $payload['school_year'], + $payload['scores'], + $payload['missing_ok'] ?? [], + (int) (auth()->id() ?? 0) + ); + + $this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year']); + + return response()->json(['ok' => true, 'updated' => $count]); + } + + private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear): void + { + $studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, (int) (auth()->id() ?? 0)); + if (empty($studentInfo)) { + return; + } + try { + $this->semesterScoreService->updateScoresForStudents($studentInfo, $semester, $schoolYear); + } catch (\Throwable $e) { + } + } +} diff --git a/app/Http/Controllers/Api/Scores/ParticipationController.php b/app/Http/Controllers/Api/Scores/ParticipationController.php new file mode 100644 index 00000000..62a468b9 --- /dev/null +++ b/app/Http/Controllers/Api/Scores/ParticipationController.php @@ -0,0 +1,70 @@ +validated(); + $data = $this->service->list( + (int) $payload['class_section_id'], + $payload['semester'] ?? null, + $payload['school_year'] ?? null + ); + + return response()->json([ + 'ok' => true, + 'students' => ScoreStudentResource::collection($data['students']), + 'semester' => $data['semester'], + 'school_year' => $data['schoolYear'], + 'scores_locked' => $data['scoresLocked'], + 'missing_ok_map' => $data['missingOkMap'], + ]); + } + + public function update(ScoreUpdateRequest $request): JsonResponse + { + $payload = $request->validated(); + $count = $this->service->update( + (int) $payload['class_section_id'], + (string) $payload['semester'], + (string) $payload['school_year'], + $payload['scores'], + $payload['missing_ok'] ?? [], + (int) (auth()->id() ?? 0) + ); + + $this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year']); + + return response()->json(['ok' => true, 'updated' => $count]); + } + + private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear): void + { + $studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, (int) (auth()->id() ?? 0)); + if (empty($studentInfo)) { + return; + } + try { + $this->semesterScoreService->updateScoresForStudents($studentInfo, $semester, $schoolYear); + } catch (\Throwable $e) { + } + } +} diff --git a/app/Http/Controllers/Api/Scores/ProjectController.php b/app/Http/Controllers/Api/Scores/ProjectController.php new file mode 100644 index 00000000..4116cd2e --- /dev/null +++ b/app/Http/Controllers/Api/Scores/ProjectController.php @@ -0,0 +1,86 @@ +validated(); + $data = $this->service->list( + (int) $payload['class_section_id'], + $payload['semester'] ?? null, + $payload['school_year'] ?? null + ); + + return response()->json([ + 'ok' => true, + 'students' => ScoreStudentResource::collection($data['students']), + 'headers' => $data['headers'], + 'semester' => $data['semester'], + 'school_year' => $data['schoolYear'], + 'scores_locked' => $data['scoresLocked'], + 'missing_ok_map' => $data['missingOkMap'], + ]); + } + + public function update(ScoreUpdateRequest $request): JsonResponse + { + $payload = $request->validated(); + $count = $this->service->update( + (int) $payload['class_section_id'], + (string) $payload['semester'], + (string) $payload['school_year'], + $payload['scores'], + $payload['missing_ok'] ?? [], + (int) (auth()->id() ?? 0) + ); + + $this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year']); + + return response()->json(['ok' => true, 'updated' => $count]); + } + + public function addColumn(ScoreAddColumnRequest $request): JsonResponse + { + $payload = $request->validated(); + $nextIndex = $this->service->addColumn( + (int) $payload['class_section_id'], + (string) $payload['semester'], + (string) $payload['school_year'], + (int) (auth()->id() ?? 0) + ); + + return response()->json(['ok' => true, 'project_index' => $nextIndex]); + } + + private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear): void + { + $studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, (int) (auth()->id() ?? 0)); + if (empty($studentInfo)) { + return; + } + try { + $this->semesterScoreService->updateScoresForStudents($studentInfo, $semester, $schoolYear); + } catch (\Throwable $e) { + // swallow errors to keep API response stable + } + } +} diff --git a/app/Http/Controllers/Api/Scores/QuizController.php b/app/Http/Controllers/Api/Scores/QuizController.php new file mode 100644 index 00000000..1eeed557 --- /dev/null +++ b/app/Http/Controllers/Api/Scores/QuizController.php @@ -0,0 +1,85 @@ +validated(); + $data = $this->service->list( + (int) $payload['class_section_id'], + $payload['semester'] ?? null, + $payload['school_year'] ?? null + ); + + return response()->json([ + 'ok' => true, + 'students' => ScoreStudentResource::collection($data['students']), + 'headers' => $data['headers'], + 'semester' => $data['semester'], + 'school_year' => $data['schoolYear'], + 'scores_locked' => $data['scoresLocked'], + 'missing_ok_map' => $data['missingOkMap'], + ]); + } + + public function update(ScoreUpdateRequest $request): JsonResponse + { + $payload = $request->validated(); + $count = $this->service->update( + (int) $payload['class_section_id'], + (string) $payload['semester'], + (string) $payload['school_year'], + $payload['scores'], + $payload['missing_ok'] ?? [], + (int) (auth()->id() ?? 0) + ); + + $this->refreshSemesterScores((int) $payload['class_section_id'], (string) $payload['semester'], (string) $payload['school_year']); + + return response()->json(['ok' => true, 'updated' => $count]); + } + + public function addColumn(ScoreAddColumnRequest $request): JsonResponse + { + $payload = $request->validated(); + $nextIndex = $this->service->addColumn( + (int) $payload['class_section_id'], + (string) $payload['semester'], + (string) $payload['school_year'], + (int) (auth()->id() ?? 0) + ); + + return response()->json(['ok' => true, 'quiz_index' => $nextIndex]); + } + + private function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear): void + { + $studentInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, (int) (auth()->id() ?? 0)); + if (empty($studentInfo)) { + return; + } + try { + $this->semesterScoreService->updateScoresForStudents($studentInfo, $semester, $schoolYear); + } catch (\Throwable $e) { + } + } +} diff --git a/app/Http/Controllers/Api/Scores/ScoreCommentController.php b/app/Http/Controllers/Api/Scores/ScoreCommentController.php new file mode 100644 index 00000000..b362381c --- /dev/null +++ b/app/Http/Controllers/Api/Scores/ScoreCommentController.php @@ -0,0 +1,88 @@ +validated(); + $data = $this->service->list( + $payload['class_section_id'] ?? null, + $payload['semester'] ?? null, + $payload['school_year'] ?? null + ); + + $studentRows = []; + foreach ($data['students'] as $student) { + $studentId = (int) ($student['student_id'] ?? 0); + $studentRows[] = [ + 'student_id' => $studentId, + 'firstname' => $student['firstname'] ?? null, + 'lastname' => $student['lastname'] ?? null, + 'school_id' => $student['school_id'] ?? null, + 'comments' => $data['comments'][$studentId] ?? [], + ]; + } + + return response()->json([ + 'ok' => true, + 'students' => ScoreCommentResource::collection($studentRows), + 'semester' => $data['semester'], + 'school_year' => $data['schoolYear'], + 'class_section_id' => $data['classSectionId'], + 'scores_locked' => $data['scoresLocked'], + ]); + } + + public function store(ScoreCommentSaveRequest $request): JsonResponse + { + $payload = $request->validated(); + $result = $this->service->save( + (int) $payload['class_section_id'], + (string) $payload['semester'], + (string) $payload['school_year'], + $payload['comments'], + $payload['missing_ok'] ?? [], + (int) (auth()->id() ?? 0) + ); + + if (!empty($result['errors'])) { + return response()->json(['ok' => false, 'errors' => $result['errors']], 422); + } + + return response()->json(['ok' => true]); + } + + public function update(ScoreCommentUpdateRequest $request): JsonResponse + { + $payload = $request->validated(); + $result = $this->service->update( + (int) $payload['class_section_id'], + (string) $payload['semester'], + (string) $payload['school_year'], + $payload['comments'] ?? [], + $payload['reviews'] ?? [], + (int) (auth()->id() ?? 0) + ); + + if (!empty($result['errors'])) { + return response()->json(['ok' => false, 'errors' => $result['errors']], 422); + } + + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/Api/Scores/ScoreController.php b/app/Http/Controllers/Api/Scores/ScoreController.php new file mode 100644 index 00000000..d801db54 --- /dev/null +++ b/app/Http/Controllers/Api/Scores/ScoreController.php @@ -0,0 +1,66 @@ +validated(); + $teacherId = (int) (auth()->id() ?? 0); + + $data = $this->service->overview( + $teacherId, + isset($payload['class_section_id']) ? (int) $payload['class_section_id'] : null, + $payload['semester'] ?? null, + $payload['school_year'] ?? null + ); + + return response()->json([ + 'ok' => true, + 'students' => ScoreStudentResource::collection($data['students'] ?? []), + 'class_section_id' => $data['class_section_id'] ?? null, + 'class_section_name' => $data['class_section_name'] ?? null, + 'semester' => $data['semester'] ?? null, + 'school_year' => $data['school_year'] ?? null, + 'scores_locked' => $data['scoresLocked'] ?? false, + ]); + } + + public function lock(ScoreLockRequest $request): JsonResponse + { + $payload = $request->validated(); + $this->service->submitScoresLock( + (int) $payload['class_section_id'], + (string) $payload['semester'], + (string) $payload['school_year'], + $payload['missing_ok'] ?? [], + (int) (auth()->id() ?? 0) + ); + + return response()->json(['ok' => true]); + } + + public function studentScores(ScoreStudentRequest $request): JsonResponse + { + $payload = $request->validated(); + $parentId = (int) (auth()->id() ?? 0); + + $data = $this->service->viewStudentScores($parentId, (string) $payload['school_year']); + + return response()->json(['ok' => true] + $data); + } +} diff --git a/app/Http/Controllers/Api/Scores/ScorePredictorController.php b/app/Http/Controllers/Api/Scores/ScorePredictorController.php new file mode 100644 index 00000000..1f69533e --- /dev/null +++ b/app/Http/Controllers/Api/Scores/ScorePredictorController.php @@ -0,0 +1,35 @@ +validated(); + $data = $this->service->report( + $payload['school_year'] ?? null, + isset($payload['class_section_id']) ? (int) $payload['class_section_id'] : null + ); + + return response()->json([ + 'ok' => true, + 'students' => ScorePredictorStudentResource::collection($data['students'] ?? []), + 'school_year' => $data['school_year'] ?? null, + 'class_sections' => $data['class_sections'] ?? [], + 'selected_class_section_id' => $data['selected_class_section_id'] ?? null, + 'semester' => $data['semester'] ?? null, + ]); + } +} diff --git a/app/Http/Controllers/Api/System/SemesterRangeController.php b/app/Http/Controllers/Api/System/SemesterRangeController.php new file mode 100644 index 00000000..eca196d3 --- /dev/null +++ b/app/Http/Controllers/Api/System/SemesterRangeController.php @@ -0,0 +1,80 @@ +validated(); + [$start, $end] = $this->service->getSchoolYearRange($payload['school_year']); + + return response()->json([ + 'ok' => true, + 'school_year' => $payload['school_year'], + 'range' => new SemesterRangeResource([ + 'start_date' => $start, + 'end_date' => $end, + ]), + ]); + } + + public function semesterRange(SemesterRangeRequest $request): JsonResponse + { + $payload = $request->validated(); + $range = $this->service->getSemesterRange($payload['school_year'], $payload['semester']); + + if ($range === null) { + return response()->json(['ok' => false, 'message' => 'Invalid semester range.'], 422); + } + + return response()->json([ + 'ok' => true, + 'school_year' => $payload['school_year'], + 'semester' => $this->service->normalizeSemester($payload['semester']), + 'range' => new SemesterRangeResource([ + 'start_date' => $range[0], + 'end_date' => $range[1], + ]), + ]); + } + + public function normalize(SemesterNormalizeRequest $request): JsonResponse + { + $payload = $request->validated(); + + return response()->json([ + 'ok' => true, + 'normalized' => $this->service->normalizeSemester($payload['semester']), + ]); + } + + public function resolve(SemesterResolveRequest $request): JsonResponse + { + $payload = $request->validated(); + $semester = $this->service->getSemesterForDate($payload['date'] ?? null); + + return response()->json([ + 'ok' => true, + 'resolution' => new SemesterResolveResource([ + 'date' => $payload['date'] ?? null, + 'semester' => $semester, + ]), + ]); + } +} diff --git a/app/Http/Controllers/Api/UserController.php b/app/Http/Controllers/Api/UserController.php new file mode 100644 index 00000000..8ccd2c6c --- /dev/null +++ b/app/Http/Controllers/Api/UserController.php @@ -0,0 +1,102 @@ +validated(); + $users = $this->listService->list($payload['sort'] ?? null, $payload['order'] ?? null); + + return response()->json([ + 'ok' => true, + 'users' => UserWithRolesResource::collection($users), + ]); + } + + public function store(UserStoreRequest $request): JsonResponse + { + $payload = $request->validated(); + $result = $this->managementService->create($payload, (int) (auth()->id() ?? 0)); + + if (empty($result['ok'])) { + return response()->json([ + 'ok' => false, + 'message' => $result['message'] ?? 'Failed to create user.', + ], 422); + } + + return response()->json([ + 'ok' => true, + 'user_id' => $result['user']->id ?? null, + ], 201); + } + + public function update(UserUpdateRequest $request, int $userId): JsonResponse + { + $payload = $request->validated(); + $result = $this->managementService->update($userId, $payload); + + if (empty($result['ok'])) { + return response()->json([ + 'ok' => false, + 'message' => $result['message'] ?? 'Failed to update user.', + ], 422); + } + + return response()->json([ + 'ok' => true, + ]); + } + + public function destroy(int $userId): JsonResponse + { + $result = $this->managementService->delete($userId); + + if (empty($result['ok'])) { + return response()->json([ + 'ok' => false, + 'message' => $result['message'] ?? 'Failed to delete user.', + ], 404); + } + + return response()->json([ + 'ok' => true, + ]); + } + + public function loginActivity(LoginActivityRequest $request): JsonResponse + { + $payload = $request->validated(); + $result = $this->loginActivityService->list( + (int) ($payload['per_page'] ?? 25), + (int) ($payload['page'] ?? 1) + ); + + return response()->json([ + 'ok' => true, + 'activities' => LoginActivityResource::collection($result['activities']), + 'pagination' => $result['pagination'], + ]); + } +} diff --git a/app/Http/Controllers/old/ApiClient.php b/app/Http/Controllers/old/ApiClient.php new file mode 100644 index 00000000..8dc3ae8a --- /dev/null +++ b/app/Http/Controllers/old/ApiClient.php @@ -0,0 +1,107 @@ +http = $http; + $this->config = $config; + } + + public function get(string $uri, array $query = [], array $headers = []): array + { + return $this->request('get', $uri, [ + 'headers' => $this->mergeHeaders($headers), + 'query' => $query, + ]); + } + + public function post(string $uri, array $data = [], array $headers = []): array + { + return $this->request('post', $uri, [ + 'headers' => $this->mergeHeaders($headers, ['Content-Type' => 'application/json']), + 'json' => $data, + ]); + } + + public function put(string $uri, array $data = [], array $headers = []): array + { + return $this->request('put', $uri, [ + 'headers' => $this->mergeHeaders($headers, ['Content-Type' => 'application/json']), + 'json' => $data, + ]); + } + + public function delete(string $uri, array $data = [], array $headers = []): array + { + return $this->request('delete', $uri, [ + 'headers' => $this->mergeHeaders($headers, ['Content-Type' => 'application/json']), + 'json' => $data, + ]); + } + + /** + * Low-level access for uncommon HTTP methods or options + */ + public function request(string $method, string $uri, array $options = []): array + { + $url = $this->fullUrl($uri); + $options['timeout'] = $options['timeout'] ?? $this->config->timeout; + + $response = $this->http->request(strtoupper($method), $url, $options); + + $status = $response->getStatusCode(); + $body = (string) $response->getBody(); + + $decoded = null; + if ($this->isJson($body)) { + $decoded = json_decode($body, true); + } + + return [ + 'ok' => $status >= 200 && $status < 300, + 'status' => $status, + 'headers' => $response->getHeaderLine('Content-Type'), + 'data' => $decoded, + 'raw' => $decoded === null ? $body : null, + ]; + } + + protected function fullUrl(string $uri): string + { + if (preg_match('#^https?://#i', $uri)) { + return $uri; + } + + $base = rtrim($this->config->baseURL ?? '', '/'); + if ($base === '') { + return '/' . ltrim($uri, '/'); + } + return $base . '/' . ltrim($uri, '/'); + } + + protected function mergeHeaders(array $override = [], array $extra = []): array + { + $base = $this->config->defaultHeaders ?? []; + return array_filter(array_merge($base, $extra, $override), static function ($v) { + return $v !== null && $v !== ''; + }); + } + + protected function isJson(string $string): bool + { + json_decode($string); + return json_last_error() === JSON_ERROR_NONE; + } +} + +?> + diff --git a/app/Http/Controllers/old/BroadcastEmailController.php b/app/Http/Controllers/old/BroadcastEmailController.php deleted file mode 100644 index bdeab9d7..00000000 --- a/app/Http/Controllers/old/BroadcastEmailController.php +++ /dev/null @@ -1,256 +0,0 @@ -userModel = new UserModel(); - $this->mailer = new EmailService(); // use your existing mailer unmodified - } - - public function index() - { - helper(['form']); - - // Parents via your role-based function - $parents = $this->userModel->getParents(); - $parents = array_values(array_filter($parents, static fn($p) => !empty($p['email']))); - - // Sender list from MAIL_SENDERS directly (no change to EmailService) - $fromOptions = $this->senderOptionsFromEnv(); - - return view('administrator/broadcast_email', [ - 'parents' => $parents, - 'fromOptions' => $fromOptions, // [['key'=>'general','label'=>'Al Rahma Office '], ...] - ]); - } - - public function send() - { - helper(['form']); - - if (strtolower($this->request->getMethod()) !== 'post') { - return redirect()->to(site_url('admin/broadcast-email')); - } - - $isTestOnly = $this->request->getPost('send_test_only') !== null; - - $mode = (string) $this->request->getPost('mode'); // 'personalized' | 'standard' - $subject = trim((string) $this->request->getPost('subject')); - $fromKey = trim((string) $this->request->getPost('from_key') ?: 'general'); - $body = (string) $this->request->getPost('body_html'); - $body = $this->sanitizeEmailHtml($body); - - - // Layout options - $wrapLayout = (bool) $this->request->getPost('wrap_layout'); - $preheader = (string) ($this->request->getPost('preheader') ?? ''); - $ctaText = (string) ($this->request->getPost('cta_text') ?? ''); - $ctaUrl = (string) ($this->request->getPost('cta_url') ?? ''); - - $testEmail = trim((string) $this->request->getPost('test_email')); - - if ($subject === '' || $body === '') { - return redirect()->back()->withInput()->with('error', 'Subject and Body are required.'); - } - - $isPersonalized = ($mode === 'personalized'); - - // --- TEST ONLY --- - if ($isTestOnly) { - if ($testEmail === '') { - return redirect()->back()->withInput()->with('error', 'Provide a test email address.'); - } - - $recipientName = 'Parent'; - $html = $this->composeEmailHtml( - $wrapLayout, - $subject, - $body, - $recipientName, - $preheader, - $ctaText, - $ctaUrl, - $isPersonalized - ); - - $ok = $this->mailer->send($testEmail, '[TEST] ' . $subject, $html, $fromKey); - return redirect()->back()->with( - $ok ? 'message' : 'error', - $ok ? "Test email sent to {$testEmail}." : "Test email failed (mailer->send() returned false). Check logs." - ); - } - - // --- BROADCAST --- - $rawIds = (array) ($this->request->getPost('parent_ids') ?? []); - $ids = []; - foreach ($rawIds as $v) { - if (is_string($v) && strpos($v, ',') !== false) { - $ids = array_merge($ids, array_map('intval', explode(',', $v))); - } else { - $ids[] = (int) $v; - } - } - $ids = array_values(array_unique(array_filter($ids))); - - if (empty($ids)) { - return redirect()->back()->withInput()->with('error', 'Please select at least one parent.'); - } - - $rows = model(\App\Models\UserModel::class) - ->select('users.id, users.email, CONCAT(users.firstname, " ", users.lastname) AS name') - ->whereIn('users.id', $ids) - ->where('users.email IS NOT NULL AND users.email != ""') - ->findAll(); - - if (empty($rows)) { - return redirect()->back()->withInput()->with('error', 'No valid parent emails found.'); - } - - $stats = ['attempted' => 0, 'sent' => 0, 'failed' => 0, 'mode' => $mode]; - - foreach ($rows as $r) { - $stats['attempted']++; - $recipientName = $r['name'] ?: 'Parent'; - - $html = $this->composeEmailHtml( - $wrapLayout, - $subject, - $body, - $recipientName, - $preheader, - $ctaText, - $ctaUrl, - $isPersonalized - ); - - $ok = $this->mailer->send($r['email'], $subject, $html, $fromKey); - $ok ? $stats['sent']++ : $stats['failed']++; - } - - $msg = "Broadcast finished. Mode: {$stats['mode']}. Sent: {$stats['sent']}/{$stats['attempted']}. Failures: {$stats['failed']}."; - return redirect()->to(site_url('admin/broadcast-email')) - ->with($stats['failed'] > 0 ? 'error' : 'message', $msg); - } - - - /** - * Build HTML: optionally wrap $body inside your view('layout/email_layout', ...). - */ - private function composeEmailHtml( - bool $wrap, - string $subject, - string $body, - string $recipientName, - string $preheader = '', - string $ctaText = '', - string $ctaUrl = '', - bool $doPersonalize = true - ): string { - $content = $doPersonalize ? str_replace('{{name}}', $recipientName, $body) : $body; - - if (!$wrap) { - return $content; // raw body - } - - // Render a CHILD view that defines the section your layout expects - return view('emails/broadcast_wrapper', [ - 'subject' => $subject, - 'content' => $content, - // pass more if you later wire them in your layout - 'preheader' => $preheader, - 'cta_text' => $ctaText, - 'cta_url' => $ctaUrl, - ]); - } - - - private function sanitizeEmailHtml(string $html): string - { - // Remove '; + + $clean = $service->sanitizeHtml($html); + + $this->assertStringNotContainsString('script', $clean); + $this->assertStringNotContainsString('onclick', $clean); + } + + public function test_compose_replaces_name_when_personalized(): void + { + $service = new BroadcastEmailComposerService(); + $html = '

Hello {{name}}

'; + + $rendered = $service->compose(false, 'Subject', $html, 'Parent', '', '', '', true); + + $this->assertSame('

Hello Parent

', $rendered); + } +} diff --git a/tests/Unit/Services/BroadcastEmail/BroadcastEmailRecipientServiceTest.php b/tests/Unit/Services/BroadcastEmail/BroadcastEmailRecipientServiceTest.php new file mode 100644 index 00000000..2465c4db --- /dev/null +++ b/tests/Unit/Services/BroadcastEmail/BroadcastEmailRecipientServiceTest.php @@ -0,0 +1,93 @@ +seedParentData(); + + $service = new BroadcastEmailRecipientService(); + $parents = $service->parentsWithEmails(); + + $this->assertCount(1, $parents); + $this->assertSame('parent@example.com', $parents[0]['email']); + } + + public function test_recipients_by_ids_returns_names(): void + { + $this->seedParentData(); + + $service = new BroadcastEmailRecipientService(); + $recipients = $service->recipientsByIds([10]); + + $this->assertCount(1, $recipients); + $this->assertSame('Parent User', $recipients[0]['name']); + } + + private function seedParentData(): void + { + DB::table('roles')->insert([ + 'id' => 1, + 'name' => 'parent', + 'slug' => 'parent', + 'is_active' => 1, + ]); + + DB::table('users')->insert([ + [ + 'id' => 10, + 'school_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'parent@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + [ + 'id' => 11, + 'school_id' => 1, + 'firstname' => 'No', + 'lastname' => 'Email', + 'cellphone' => '5555555555', + 'email' => '', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + ]); + + DB::table('user_roles')->insert([ + ['user_id' => 10, 'role_id' => 1], + ['user_id' => 11, 'role_id' => 1], + ]); + } +} diff --git a/tests/Unit/Services/BroadcastEmail/BroadcastEmailSenderOptionsServiceTest.php b/tests/Unit/Services/BroadcastEmail/BroadcastEmailSenderOptionsServiceTest.php new file mode 100644 index 00000000..7627c1ea --- /dev/null +++ b/tests/Unit/Services/BroadcastEmail/BroadcastEmailSenderOptionsServiceTest.php @@ -0,0 +1,23 @@ +listOptions(); + + $this->assertSame('general', $options[0]['key']); + $this->assertSame('Office ', $options[0]['label']); + + putenv('MAIL_SENDERS=' . $old); + } +} diff --git a/tests/Unit/Services/ClassPrep/ClassRosterServiceTest.php b/tests/Unit/Services/ClassPrep/ClassRosterServiceTest.php new file mode 100644 index 00000000..fea21717 --- /dev/null +++ b/tests/Unit/Services/ClassPrep/ClassRosterServiceTest.php @@ -0,0 +1,71 @@ +seedRosterData(); + + $service = new ClassRosterService(); + $students = $service->listStudentsByClass(101, '2025-2026'); + + $this->assertCount(1, $students); + $this->assertSame('1-A', $students[0]['registration_grade']); + } + + private function seedRosterData(): void + { + DB::table('configuration')->insert([ + ['id' => 1, 'config_key' => 'school_year', 'config_value' => '2025-2026'], + ]); + + DB::table('classes')->insert([ + 'id' => 1, + 'class_name' => 'Class 1', + 'schedule' => 'Sun', + 'capacity' => 20, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('classSection')->insert([ + 'id' => 1, + 'class_id' => 1, + 'class_section_id' => 101, + 'class_section_name' => '1-A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('students')->insert([ + 'school_id' => 'S-1', + 'firstname' => 'Student', + 'lastname' => 'One', + 'age' => 8, + 'gender' => 'Male', + 'photo_consent' => 1, + 'parent_id' => 1, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'is_active' => 1, + 'is_new' => 0, + ]); + + DB::table('student_class')->insert([ + 'student_id' => 1, + 'class_section_id' => 101, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + } +} diff --git a/tests/Unit/Services/ClassPrep/StickerCountServiceTest.php b/tests/Unit/Services/ClassPrep/StickerCountServiceTest.php new file mode 100644 index 00000000..e68a646a --- /dev/null +++ b/tests/Unit/Services/ClassPrep/StickerCountServiceTest.php @@ -0,0 +1,165 @@ +seedStickerData(); + + $service = new StickerCountService(); + $payload = $service->listAll('2025-2026', 'Fall'); + + $this->assertSame(2, $payload['totals']['students']); + $this->assertSame(6, $payload['totals']['primary']); + $this->assertSame(1, $payload['totals']['secondary']); + } + + public function test_list_for_class_limits_results(): void + { + $this->seedStickerData(); + + $service = new StickerCountService(); + $payload = $service->listForClass('2025-2026', 'Fall', 101); + + $this->assertSame(1, $payload['totals']['students']); + $this->assertSame(3, $payload['totals']['primary']); + $this->assertSame(0, $payload['totals']['secondary']); + } + + private function seedStickerData(): void + { + DB::table('configuration')->insert([ + ['id' => 1, 'config_key' => 'school_year', 'config_value' => '2025-2026'], + ['id' => 2, 'config_key' => 'semester', 'config_value' => 'Fall'], + ]); + + DB::table('classes')->insert([ + [ + 'id' => 1, + 'class_name' => 'Class 1', + 'schedule' => 'Sun', + 'capacity' => 20, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + [ + 'id' => 2, + 'class_name' => 'Class 2', + 'schedule' => 'Sun', + 'capacity' => 20, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + [ + 'id' => 3, + 'class_name' => 'Class 3', + 'schedule' => 'Sun', + 'capacity' => 20, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + ]); + + DB::table('classSection')->insert([ + [ + 'id' => 1, + 'class_id' => 1, + 'class_section_id' => 101, + 'class_section_name' => '1-A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + [ + 'id' => 2, + 'class_id' => 2, + 'class_section_id' => 102, + 'class_section_name' => '5-B', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + [ + 'id' => 3, + 'class_id' => 3, + 'class_section_id' => 103, + 'class_section_name' => 'Youth-1', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + ]); + + DB::table('students')->insert([ + [ + 'school_id' => 'S-1', + 'firstname' => 'Student', + 'lastname' => 'One', + 'age' => 8, + 'gender' => 'Male', + 'photo_consent' => 1, + 'parent_id' => 1, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'is_active' => 1, + 'is_new' => 0, + ], + [ + 'school_id' => 'S-2', + 'firstname' => 'Student', + 'lastname' => 'Two', + 'age' => 10, + 'gender' => 'Female', + 'photo_consent' => 1, + 'parent_id' => 1, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'is_active' => 1, + 'is_new' => 0, + ], + [ + 'school_id' => 'S-3', + 'firstname' => 'Student', + 'lastname' => 'Three', + 'age' => 12, + 'gender' => 'Female', + 'photo_consent' => 1, + 'parent_id' => 1, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'is_active' => 1, + 'is_new' => 1, + ], + ]); + + DB::table('student_class')->insert([ + [ + 'student_id' => 1, + 'class_section_id' => 101, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + [ + 'student_id' => 2, + 'class_section_id' => 102, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + [ + 'student_id' => 3, + 'class_section_id' => 103, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + ]); + } +} diff --git a/tests/Unit/Services/ClassPreparation/ClassPreparationAdjustmentServiceTest.php b/tests/Unit/Services/ClassPreparation/ClassPreparationAdjustmentServiceTest.php new file mode 100644 index 00000000..8cc02f54 --- /dev/null +++ b/tests/Unit/Services/ClassPreparation/ClassPreparationAdjustmentServiceTest.php @@ -0,0 +1,30 @@ +insert([ + 'class_section_id' => 101, + 'item_name' => 'Small Table', + 'adjustment' => 2, + 'school_year' => '2025-2026', + 'adjustable' => 1, + ]); + + $service = new ClassPreparationAdjustmentService(); + [$items, $adjMap] = $service->applyAdjustments(['Small Table' => 1], '101', '2025-2026'); + + $this->assertSame(3, $items['Small Table']); + $this->assertSame(2, $adjMap['Small Table']); + } +} diff --git a/tests/Unit/Services/ClassPreparation/ClassPreparationCalculatorServiceTest.php b/tests/Unit/Services/ClassPreparation/ClassPreparationCalculatorServiceTest.php new file mode 100644 index 00000000..c3096037 --- /dev/null +++ b/tests/Unit/Services/ClassPreparation/ClassPreparationCalculatorServiceTest.php @@ -0,0 +1,59 @@ +insert([ + 'id' => 1, + 'class_id' => 1, + 'class_section_id' => 101, + 'class_section_name' => '2-A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $service = new ClassPreparationCalculatorService(); + + $this->assertSame(2, $service->getClassLevelBySection('101')); + $this->assertSame(1, $service->getClassLevelBySection('KG')); + } + + public function test_calculate_prep_items_for_lower_grades(): void + { + DB::table('configuration')->insert([ + ['id' => 1, 'config_key' => 'school_year', 'config_value' => '2025-2026'], + ]); + + DB::table('inventory_categories')->insert([ + ['name' => 'Small Table', 'grade_min' => null, 'grade_max' => null, 'type' => 'classroom'], + ['name' => 'Small Chair', 'grade_min' => null, 'grade_max' => null, 'type' => 'classroom'], + ['name' => 'Teacher Chair', 'grade_min' => null, 'grade_max' => null, 'type' => 'classroom'], + ['name' => 'Grade Box', 'grade_min' => null, 'grade_max' => null, 'type' => 'classroom'], + ]); + + DB::table('teacher_class')->insert([ + 'class_section_id' => 101, + 'teacher_id' => 1, + 'position' => 'main', + 'school_year' => '2025-2026', + ]); + + $service = new ClassPreparationCalculatorService(); + $items = $service->calculatePrepItems(6, 1, '101'); + + $this->assertSame(2, $items['Small Table']); + $this->assertSame(6, $items['Small Chair']); + $this->assertSame(1, $items['Teacher Chair']); + $this->assertSame(1, $items['Grade Box']); + } +} diff --git a/tests/Unit/Services/ClassPreparation/ClassPreparationContextServiceTest.php b/tests/Unit/Services/ClassPreparation/ClassPreparationContextServiceTest.php new file mode 100644 index 00000000..6da0be6f --- /dev/null +++ b/tests/Unit/Services/ClassPreparation/ClassPreparationContextServiceTest.php @@ -0,0 +1,28 @@ +insert([ + 'student_id' => 1, + 'class_section_id' => 101, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $service = new ClassPreparationContextService(); + + $this->assertTrue($service->hasRosterForSemester('2025-2026', 'Fall')); + $this->assertFalse($service->hasRosterForSemester('2025-2026', 'Spring')); + } +} diff --git a/tests/Unit/Services/ClassPreparation/ClassPreparationInventoryServiceTest.php b/tests/Unit/Services/ClassPreparation/ClassPreparationInventoryServiceTest.php new file mode 100644 index 00000000..c6b9af3b --- /dev/null +++ b/tests/Unit/Services/ClassPreparation/ClassPreparationInventoryServiceTest.php @@ -0,0 +1,48 @@ +insert([ + 'id' => 1, + 'type' => 'classroom', + 'name' => 'Small Table', + ]); + + DB::table('inventory_items')->insert([ + [ + 'type' => 'classroom', + 'category_id' => 1, + 'name' => 'Small Table', + 'quantity' => 5, + 'good_qty' => 5, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ], + [ + 'type' => 'classroom', + 'category_id' => 1, + 'name' => 'Small Table', + 'quantity' => 3, + 'good_qty' => 3, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ], + ]); + + $service = new ClassPreparationInventoryService(); + $map = $service->buildAvailability('2025-2026', 'Fall', true, ['Small Table']); + + $this->assertSame(8, $map['Small Table']); + } +} diff --git a/tests/Unit/Services/ClassPreparation/ClassPreparationLogServiceTest.php b/tests/Unit/Services/ClassPreparation/ClassPreparationLogServiceTest.php new file mode 100644 index 00000000..158868d5 --- /dev/null +++ b/tests/Unit/Services/ClassPreparation/ClassPreparationLogServiceTest.php @@ -0,0 +1,36 @@ +assertTrue($service->hasPrepChanged(['a' => 1], ['a' => 2])); + $this->assertFalse($service->hasPrepChanged(['a' => 1], ['a' => 1])); + } + + public function test_create_log_and_get_latest(): void + { + $service = new ClassPreparationLogService(); + $created = $service->createLog('101', '1-A', '2025-2026', ['Small Table' => 2], '2025-09-01 00:00:00'); + + $this->assertTrue($created); + $this->assertDatabaseHas('class_preparation_log', [ + 'class_section_id' => 101, + 'school_year' => '2025-2026', + ]); + + $log = $service->getLatestLog('101', '2025-2026'); + $this->assertNotNull($log); + } +} diff --git a/tests/Unit/Services/ClassPreparation/ClassPreparationRosterServiceTest.php b/tests/Unit/Services/ClassPreparation/ClassPreparationRosterServiceTest.php new file mode 100644 index 00000000..c3c1db0a --- /dev/null +++ b/tests/Unit/Services/ClassPreparation/ClassPreparationRosterServiceTest.php @@ -0,0 +1,84 @@ +seedRoster(); + + $service = new ClassPreparationRosterService(); + $rows = $service->getClassSectionStudentCounts('2025-2026', 'Fall', true); + + $this->assertCount(1, $rows); + $this->assertSame(101, (int) $rows[0]->class_section_id); + $this->assertSame(2, (int) $rows[0]->student_count); + } + + public function test_get_student_count_for_section(): void + { + $this->seedRoster(); + + $service = new ClassPreparationRosterService(); + $count = $service->getStudentCountForSection('2025-2026', 'Fall', true, '101'); + + $this->assertSame(2, $count); + } + + private function seedRoster(): void + { + DB::table('students')->insert([ + [ + 'id' => 1, + 'school_id' => 'S-1', + 'firstname' => 'Student', + 'lastname' => 'One', + 'age' => 8, + 'gender' => 'Male', + 'photo_consent' => 1, + 'parent_id' => 1, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'is_active' => 1, + ], + [ + 'id' => 2, + 'school_id' => 'S-2', + 'firstname' => 'Student', + 'lastname' => 'Two', + 'age' => 9, + 'gender' => 'Female', + 'photo_consent' => 1, + 'parent_id' => 1, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'is_active' => 1, + ], + ]); + + DB::table('student_class')->insert([ + [ + 'student_id' => 1, + 'class_section_id' => 101, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + [ + 'student_id' => 2, + 'class_section_id' => 101, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + ]); + } +} diff --git a/tests/Unit/Services/ClassPreparation/ClassPreparationServiceTest.php b/tests/Unit/Services/ClassPreparation/ClassPreparationServiceTest.php new file mode 100644 index 00000000..853f8209 --- /dev/null +++ b/tests/Unit/Services/ClassPreparation/ClassPreparationServiceTest.php @@ -0,0 +1,93 @@ +seedPrepData(); + + $service = new ClassPreparationService(); + $payload = $service->listPrep('2025-2026', 'Fall'); + + $this->assertSame('2025-2026', $payload['schoolYear']); + $this->assertSame('Fall', $payload['semester']); + $this->assertNotEmpty($payload['results']); + $this->assertArrayHasKey('Small Table', $payload['totals']); + } + + public function test_mark_printed_creates_logs(): void + { + $this->seedPrepData(); + + $service = new ClassPreparationService(); + $count = $service->markPrinted('2025-2026', 'Fall', [101]); + + $this->assertSame(1, $count); + $this->assertDatabaseHas('class_preparation_log', [ + 'class_section_id' => 101, + 'school_year' => '2025-2026', + ]); + } + + private function seedPrepData(): void + { + DB::table('configuration')->insert([ + ['id' => 1, 'config_key' => 'school_year', 'config_value' => '2025-2026'], + ['id' => 2, 'config_key' => 'semester', 'config_value' => 'Fall'], + ]); + + DB::table('classSection')->insert([ + 'id' => 1, + 'class_id' => 1, + 'class_section_id' => 101, + 'class_section_name' => '1-A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('students')->insert([ + 'school_id' => 'S-1', + 'firstname' => 'Student', + 'lastname' => 'One', + 'age' => 8, + 'gender' => 'Male', + 'photo_consent' => 1, + 'parent_id' => 1, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'is_active' => 1, + ]); + + DB::table('student_class')->insert([ + 'student_id' => 1, + 'class_section_id' => 101, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('inventory_categories')->insert([ + 'type' => 'classroom', + 'name' => 'Small Table', + ]); + + DB::table('inventory_items')->insert([ + 'type' => 'classroom', + 'category_id' => 1, + 'name' => 'Small Table', + 'quantity' => 10, + 'good_qty' => 10, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + } +} diff --git a/tests/Unit/Services/Communication/CommunicationTemplateServiceTest.php b/tests/Unit/Services/Communication/CommunicationTemplateServiceTest.php new file mode 100644 index 00000000..4c1fab86 --- /dev/null +++ b/tests/Unit/Services/Communication/CommunicationTemplateServiceTest.php @@ -0,0 +1,31 @@ +insert([ + 'id' => 1, + 'code' => 'welcome', + 'variant' => 'default', + 'subject' => 'Hello', + 'body_html' => 'Body', + 'is_active' => 1, + ]); + + $service = new CommunicationTemplateService(); + $templates = $service->listActiveTemplates(); + + $this->assertSame('welcome', $templates[0]['template_key']); + $this->assertSame('Body', $templates[0]['body']); + } +} diff --git a/tests/Unit/Services/CompetitionScores/CompetitionScoresSaveServiceTest.php b/tests/Unit/Services/CompetitionScores/CompetitionScoresSaveServiceTest.php new file mode 100644 index 00000000..cf550815 --- /dev/null +++ b/tests/Unit/Services/CompetitionScores/CompetitionScoresSaveServiceTest.php @@ -0,0 +1,46 @@ +filterScores([ + 1 => '10', + 2 => '4.5', + ]); + + $this->assertSame([1 => 10], $clean); + $this->assertSame([2], $invalid); + } + + public function test_save_scores_upserts(): void + { + DB::table('competition_scores')->insert([ + 'competition_id' => 1, + 'student_id' => 1, + 'class_section_id' => 101, + 'score' => 2, + ]); + + $service = new CompetitionScoresSaveService(); + $service->saveScores(1, 101, [1 => 7]); + + $this->assertDatabaseHas('competition_scores', [ + 'competition_id' => 1, + 'student_id' => 1, + 'class_section_id' => 101, + 'score' => 7, + ]); + } +} diff --git a/tests/Unit/Services/Discounts/DiscountApplyServiceTest.php b/tests/Unit/Services/Discounts/DiscountApplyServiceTest.php new file mode 100644 index 00000000..22db9a0c --- /dev/null +++ b/tests/Unit/Services/Discounts/DiscountApplyServiceTest.php @@ -0,0 +1,38 @@ +insert([ + ['id' => 1, 'config_key' => 'school_year', 'config_value' => '2025-2026'], + ['id' => 2, 'config_key' => 'semester', 'config_value' => 'Fall'], + ]); + + DB::table('discount_vouchers')->insert([ + 'id' => 1, + 'code' => 'USED-OUT', + 'discount_type' => 'fixed', + 'discount_value' => 10, + 'max_uses' => 1, + 'times_used' => 1, + 'is_active' => 1, + ]); + + $service = new DiscountApplyService(new DiscountContextService(), new DiscountInvoiceService()); + $result = $service->applyVoucher(1, [10], true, 1); + + $this->assertFalse($result['ok']); + } +} diff --git a/tests/Unit/Services/Expenses/ExpenseReceiptServiceTest.php b/tests/Unit/Services/Expenses/ExpenseReceiptServiceTest.php new file mode 100644 index 00000000..7a374401 --- /dev/null +++ b/tests/Unit/Services/Expenses/ExpenseReceiptServiceTest.php @@ -0,0 +1,26 @@ +create('receipt.pdf', 10, 'application/pdf'); + $filename = $service->storeReceipt($file); + + $this->assertNotEmpty($filename); + $this->assertSame(url('receipts/' . $filename), $service->receiptUrl($filename)); + } +} diff --git a/tests/Unit/Services/Expenses/ExpenseStaffServiceTest.php b/tests/Unit/Services/Expenses/ExpenseStaffServiceTest.php new file mode 100644 index 00000000..32874564 --- /dev/null +++ b/tests/Unit/Services/Expenses/ExpenseStaffServiceTest.php @@ -0,0 +1,75 @@ +insert([ + ['id' => 1, 'name' => 'administrator', 'slug' => 'administrator', 'is_active' => 1], + ['id' => 2, 'name' => 'parent', 'slug' => 'parent', 'is_active' => 1], + ]); + + DB::table('users')->insert([ + [ + 'id' => 10, + 'school_id' => 1, + 'firstname' => 'Staff', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'staff@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + [ + 'id' => 11, + 'school_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'parent@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + ]); + + DB::table('user_roles')->insert([ + ['user_id' => 10, 'role_id' => 1], + ['user_id' => 11, 'role_id' => 2], + ]); + + $service = new ExpenseStaffService(); + $staff = $service->listStaffUsers(); + + $this->assertCount(1, $staff); + $this->assertSame(10, $staff[0]['id']); + } +} diff --git a/tests/Unit/Services/ExtraCharges/ExtraChargesMetaServiceTest.php b/tests/Unit/Services/ExtraCharges/ExtraChargesMetaServiceTest.php new file mode 100644 index 00000000..e4b41d7d --- /dev/null +++ b/tests/Unit/Services/ExtraCharges/ExtraChargesMetaServiceTest.php @@ -0,0 +1,61 @@ +getSchoolYears('2025-2026'); + + $this->assertSame(['2025-2026', '2024-2025', '2023-2024', '2022-2023'], $years); + } + + public function test_get_school_years_merges_sources(): void + { + DB::table('additional_charges')->insert([ + [ + 'parent_id' => 1, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'charge_type' => 'add', + 'title' => 'Test', + 'amount' => 1, + ], + [ + 'parent_id' => 1, + 'school_year' => '2024-2025', + 'semester' => 'Fall', + 'charge_type' => 'add', + 'title' => 'Test', + 'amount' => 1, + ], + ]); + + DB::table('invoices')->insert([ + [ + 'parent_id' => 1, + 'invoice_number' => 'INV-1', + 'total_amount' => 10, + 'balance' => 10, + 'paid_amount' => 0, + 'issue_date' => '2025-09-01', + 'status' => 'Unpaid', + 'school_year' => '2023-2024', + ], + ]); + + $service = new ExtraChargesMetaService(); + $years = $service->getSchoolYears(); + + $this->assertSame(['2025-2026', '2024-2025', '2023-2024'], $years); + } +} diff --git a/tests/Unit/Services/Files/ExamDraftDownloadNameServiceTest.php b/tests/Unit/Services/Files/ExamDraftDownloadNameServiceTest.php new file mode 100644 index 00000000..3ced4307 --- /dev/null +++ b/tests/Unit/Services/Files/ExamDraftDownloadNameServiceTest.php @@ -0,0 +1,43 @@ +insert([ + 'id' => 1, + 'class_section_id' => 1, + 'class_section_name' => '2B', + 'class_id' => 1, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('exam_drafts')->insert([ + 'id' => 1, + 'teacher_id' => 1, + 'class_section_id' => 1, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'exam_type' => 'Final Exam', + 'draft_title' => 'Draft', + 'teacher_file' => 'draft2.pdf', + 'version' => 3, + 'status' => 'draft', + ]); + + $service = new ExamDraftDownloadNameService(); + $name = $service->build('draft2.pdf', 'drafts'); + + $this->assertSame('2b_final_exam_v3', $name); + } +} diff --git a/tests/Unit/Services/Files/FileServeServiceTest.php b/tests/Unit/Services/Files/FileServeServiceTest.php new file mode 100644 index 00000000..b28f1297 --- /dev/null +++ b/tests/Unit/Services/Files/FileServeServiceTest.php @@ -0,0 +1,69 @@ +meta($dir, 'sample.pdf', ['pdf'], 'download'); + + $this->assertSame('sample.pdf', $meta['name']); + $this->assertSame('download.pdf', $meta['download_name']); + $this->assertSame(7, $meta['size']); + $this->assertNotEmpty($meta['etag']); + } + + public function test_serve_inline_returns_304_when_etag_matches(): void + { + $dir = storage_path('testing/files'); + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + $path = $dir . DIRECTORY_SEPARATOR . 'cached.pdf'; + file_put_contents($path, 'PDFDATA'); + + $service = new FileServeService(); + $meta = $service->meta($dir, 'cached.pdf', ['pdf']); + + $request = Request::create('/files/cached.pdf', 'GET', [], [], [], [ + 'HTTP_IF_NONE_MATCH' => $meta['etag'], + ]); + + $response = $service->serveInline($dir, 'cached.pdf', ['pdf'], $request); + + $this->assertSame(304, $response->getStatusCode()); + $this->assertSame($meta['etag'], $response->headers->get('ETag')); + } + + public function test_serve_inline_rejects_invalid_extension(): void + { + $this->expectException(HttpException::class); + + $dir = storage_path('testing/files'); + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + $path = $dir . DIRECTORY_SEPARATOR . 'sample.exe'; + file_put_contents($path, 'DATA'); + + $service = new FileServeService(); + $service->meta($dir, 'sample.exe', ['pdf']); + } +} diff --git a/tests/Unit/Services/Finance/FinancialChartServiceTest.php b/tests/Unit/Services/Finance/FinancialChartServiceTest.php new file mode 100644 index 00000000..e6b863ba --- /dev/null +++ b/tests/Unit/Services/Finance/FinancialChartServiceTest.php @@ -0,0 +1,31 @@ + 100, + 'amountCollected' => 80, + 'totalUnpaid' => 20, + 'totalDiscounts' => 5, + 'totalRefunds' => 2, + 'totalExpenses' => 10, + 'totalReimbursements' => 4, + 'netAmount' => 93, + ]; + + $this->assertNull($service->generateBarChart($summary)); + $this->assertNull($service->generatePieChart($summary)); + } +} diff --git a/tests/Unit/Services/Finance/FinancialPaymentServiceTest.php b/tests/Unit/Services/Finance/FinancialPaymentServiceTest.php new file mode 100644 index 00000000..491c43b2 --- /dev/null +++ b/tests/Unit/Services/Finance/FinancialPaymentServiceTest.php @@ -0,0 +1,94 @@ +insert([ + [ + 'id' => 1, + 'parent_id' => 10, + 'invoice_id' => 1, + 'total_amount' => 100, + 'paid_amount' => 30, + 'balance' => 70, + 'number_of_installments' => 1, + 'payment_method' => 'Cash', + 'payment_date' => '2025-01-05', + 'school_year' => '2025-2026', + 'status' => 'Paid', + ], + [ + 'id' => 2, + 'parent_id' => 10, + 'invoice_id' => 1, + 'total_amount' => 100, + 'paid_amount' => 20, + 'balance' => 50, + 'number_of_installments' => 1, + 'payment_method' => 'Credit Card', + 'payment_date' => '2025-01-06', + 'school_year' => '2025-2026', + 'status' => 'Paid', + ], + [ + 'id' => 3, + 'parent_id' => 10, + 'invoice_id' => 1, + 'total_amount' => 100, + 'paid_amount' => 50, + 'balance' => 0, + 'number_of_installments' => 1, + 'payment_method' => 'Cash', + 'payment_date' => '2025-01-07', + 'school_year' => '2025-2026', + 'status' => 'void', + ], + [ + 'id' => 4, + 'parent_id' => 11, + 'invoice_id' => 2, + 'total_amount' => 40, + 'paid_amount' => 10, + 'balance' => 30, + 'number_of_installments' => 1, + 'payment_method' => 'Check', + 'payment_date' => '2025-01-08', + 'school_year' => '2025-2026', + 'status' => 'Paid', + ], + ]); + + $service = new FinancialPaymentService(); + + $payments = $service->paymentsByInvoice('2025-2026', '2025-01-01', '2025-01-31'); + $breakdown = $service->paymentBreakdown('2025-2026', '2025-01-01', '2025-01-31'); + $totals = $service->paymentTotals('2025-2026', '2025-01-01', '2025-01-31'); + + $map = []; + foreach ($payments as $row) { + $map[(int) $row['invoice_id']] = (float) $row['paid_amount']; + } + + $this->assertSame(50.0, $map[1]); + $this->assertSame(10.0, $map[2]); + + $this->assertSame(30.0, (float) ($breakdown[1]['cash'] ?? 0)); + $this->assertSame(20.0, (float) ($breakdown[1]['credit'] ?? 0)); + $this->assertSame(10.0, (float) ($breakdown[2]['check'] ?? 0)); + + $this->assertSame(60.0, $totals['total_all']); + $this->assertSame(30.0, $totals['total_cash']); + $this->assertSame(20.0, $totals['total_credit']); + $this->assertSame(10.0, $totals['total_check']); + } +} diff --git a/tests/Unit/Services/Finance/FinancialPdfReportServiceTest.php b/tests/Unit/Services/Finance/FinancialPdfReportServiceTest.php new file mode 100644 index 00000000..021c05ad --- /dev/null +++ b/tests/Unit/Services/Finance/FinancialPdfReportServiceTest.php @@ -0,0 +1,37 @@ + '2025-2026', + 'totalCharges' => 100, + 'totalExtraCharges' => 20, + 'totalDiscounts' => 10, + 'totalRefunds' => 5, + 'totalExpenses' => 15, + 'totalReimbursements' => 8, + 'donationToSchool' => 3, + 'netAmount' => 85, + 'amountCollected' => 60, + 'totalUnpaid' => 40, + ]; + + $pdf = $service->buildPdf($summary); + + $this->assertIsString($pdf); + $this->assertNotSame('', $pdf); + } +} diff --git a/tests/Unit/Services/Finance/FinancialReportServiceTest.php b/tests/Unit/Services/Finance/FinancialReportServiceTest.php new file mode 100644 index 00000000..4b92f0d4 --- /dev/null +++ b/tests/Unit/Services/Finance/FinancialReportServiceTest.php @@ -0,0 +1,126 @@ +insert([ + 'id' => 10, + 'school_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'parent@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('invoices')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_number' => 'INV-001', + 'total_amount' => 100, + 'balance' => 40, + 'paid_amount' => 60, + 'has_discount' => 1, + 'issue_date' => '2025-01-01', + 'due_date' => '2025-02-01', + 'status' => 'unpaid', + 'school_year' => '2025-2026', + ]); + + DB::table('payments')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_id' => 1, + 'total_amount' => 100, + 'paid_amount' => 60, + 'balance' => 40, + 'number_of_installments' => 1, + 'payment_method' => 'Cash', + 'payment_date' => '2025-01-02', + 'school_year' => '2025-2026', + 'status' => 'Paid', + ]); + + DB::table('refunds')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'school_year' => '2025-2026', + 'invoice_id' => 1, + 'refund_amount' => 5, + 'status' => 'Paid', + 'refund_paid_amount' => 5, + ]); + + DB::table('discount_usages')->insert([ + 'id' => 1, + 'voucher_id' => 1, + 'invoice_id' => 1, + 'parent_id' => 10, + 'discount_amount' => 10, + 'school_year' => '2025-2026', + ]); + + DB::table('expenses')->insert([ + 'id' => 1, + 'category' => 'Expense', + 'amount' => 20, + 'date_of_purchase' => '2025-01-05', + 'purchased_by' => 10, + 'added_by' => 1, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'status' => 'approved', + ]); + + DB::table('reimbursements')->insert([ + 'id' => 1, + 'expense_id' => 1, + 'amount' => 15, + 'reimbursed_to' => 10, + 'added_by' => 1, + 'status' => 'Paid', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + $service = new FinancialReportService( + new FinancialPaymentService(), + new FinancialSchoolYearService() + ); + + $report = $service->getReport('2025-01-01', '2025-12-31', '2025-2026'); + + $this->assertSame('2025-2026', $report['selectedYear']); + $this->assertSame(['2025-2026'], $report['schoolYears']); + $this->assertCount(1, $report['invoices']); + $this->assertCount(1, $report['payments']); + $this->assertCount(1, $report['refunds']); + $this->assertCount(1, $report['discounts']); + $this->assertCount(1, $report['expenses']); + $this->assertCount(1, $report['reimbursements']); + $this->assertSame(60.0, $report['paymentTotals']['total_all']); + } +} diff --git a/tests/Unit/Services/Finance/FinancialSchoolYearServiceTest.php b/tests/Unit/Services/Finance/FinancialSchoolYearServiceTest.php new file mode 100644 index 00000000..af82841c --- /dev/null +++ b/tests/Unit/Services/Finance/FinancialSchoolYearServiceTest.php @@ -0,0 +1,50 @@ +insert([ + [ + 'id' => 1, + 'parent_id' => 1, + 'invoice_number' => 'INV-100', + 'total_amount' => 10, + 'balance' => 10, + 'paid_amount' => 0, + 'has_discount' => 0, + 'issue_date' => '2024-01-01', + 'due_date' => '2024-02-01', + 'status' => 'unpaid', + 'school_year' => '2024-2025', + ], + [ + 'id' => 2, + 'parent_id' => 1, + 'invoice_number' => 'INV-101', + 'total_amount' => 10, + 'balance' => 10, + 'paid_amount' => 0, + 'has_discount' => 0, + 'issue_date' => '2025-01-01', + 'due_date' => '2025-02-01', + 'status' => 'unpaid', + 'school_year' => '2025-2026', + ], + ]); + + $service = new FinancialSchoolYearService(); + $years = $service->listYears(); + + $this->assertSame(['2025-2026', '2024-2025'], $years); + } +} diff --git a/tests/Unit/Services/Finance/FinancialSummaryServiceTest.php b/tests/Unit/Services/Finance/FinancialSummaryServiceTest.php new file mode 100644 index 00000000..ef01e209 --- /dev/null +++ b/tests/Unit/Services/Finance/FinancialSummaryServiceTest.php @@ -0,0 +1,165 @@ +insert([ + 'id' => 2, + 'school_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'parent@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('invoices')->insert([ + 'id' => 1, + 'parent_id' => 2, + 'invoice_number' => 'INV-001', + 'total_amount' => 100, + 'balance' => 40, + 'paid_amount' => 60, + 'has_discount' => 1, + 'issue_date' => '2025-01-10', + 'due_date' => '2025-02-10', + 'status' => 'unpaid', + 'school_year' => '2025-2026', + ]); + + DB::table('payments')->insert([ + 'id' => 1, + 'parent_id' => 2, + 'invoice_id' => 1, + 'total_amount' => 100, + 'paid_amount' => 60, + 'balance' => 40, + 'number_of_installments' => 1, + 'payment_method' => 'Cash', + 'payment_date' => '2025-01-15', + 'school_year' => '2025-2026', + 'status' => 'Paid', + ]); + + DB::table('discount_usages')->insert([ + 'id' => 1, + 'voucher_id' => 1, + 'invoice_id' => 1, + 'parent_id' => 2, + 'discount_amount' => 10, + 'school_year' => '2025-2026', + ]); + + DB::table('refunds')->insert([ + 'id' => 1, + 'parent_id' => 2, + 'school_year' => '2025-2026', + 'invoice_id' => 1, + 'refund_amount' => 5, + 'status' => 'Paid', + 'refund_paid_amount' => 5, + ]); + + DB::table('additional_charges')->insert([ + [ + 'id' => 1, + 'parent_id' => 2, + 'amount' => 20, + 'school_year' => '2025-2026', + 'status' => 'pending', + ], + [ + 'id' => 2, + 'parent_id' => 2, + 'amount' => 30, + 'invoice_id' => 1, + 'school_year' => '2025-2026', + 'status' => 'applied', + ], + ]); + + DB::table('expenses')->insert([ + [ + 'id' => 1, + 'category' => 'Donation', + 'amount' => 12, + 'date_of_purchase' => '2025-01-05', + 'purchased_by' => 2, + 'added_by' => 1, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'status' => 'approved', + ], + [ + 'id' => 2, + 'category' => 'Expense', + 'amount' => 8, + 'date_of_purchase' => '2025-01-06', + 'purchased_by' => 2, + 'added_by' => 1, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'status' => 'approved', + ], + ]); + + DB::table('reimbursements')->insert([ + [ + 'id' => 1, + 'expense_id' => 2, + 'amount' => 15, + 'reimbursed_to' => 2, + 'added_by' => 1, + 'status' => 'Paid', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ], + [ + 'id' => 2, + 'expense_id' => 1, + 'amount' => 7, + 'reimbursed_to' => 990002, + 'added_by' => 1, + 'status' => 'Paid', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ], + ]); + + $service = new FinancialSummaryService(new FinancialDonationRecipientService()); + $summary = $service->getSummary('2025-01-01', '2025-12-31', '2025-2026'); + + $this->assertSame(120.0, $summary['totalCharges']); + $this->assertSame(50.0, $summary['totalExtraCharges']); + $this->assertSame(10.0, $summary['totalDiscounts']); + $this->assertSame(5.0, $summary['totalRefunds']); + $this->assertSame(20.0, $summary['totalExpenses']); + $this->assertSame(15.0, $summary['totalReimbursements']); + $this->assertSame(19.0, $summary['donationToSchool']); + $this->assertSame(60.0, $summary['totalPaid']); + $this->assertSame(45.0, $summary['totalUnpaid']); + $this->assertSame(105.0, $summary['netAmount']); + } +} diff --git a/tests/Unit/Services/Finance/FinancialUnpaidParentsServiceTest.php b/tests/Unit/Services/Finance/FinancialUnpaidParentsServiceTest.php new file mode 100644 index 00000000..42ae9a36 --- /dev/null +++ b/tests/Unit/Services/Finance/FinancialUnpaidParentsServiceTest.php @@ -0,0 +1,110 @@ +insert([ + ['id' => 1, 'config_key' => 'installment_date', 'config_value' => date('Y-m-t')], + ]); + + DB::table('users')->insert([ + 'id' => 2, + 'school_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'parent@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('invoices')->insert([ + [ + 'id' => 1, + 'parent_id' => 2, + 'invoice_number' => 'INV-001', + 'total_amount' => 100, + 'balance' => 40, + 'paid_amount' => 60, + 'has_discount' => 1, + 'issue_date' => '2025-01-10', + 'due_date' => '2025-02-10', + 'status' => 'unpaid', + 'school_year' => '2025-2026', + ], + ]); + + DB::table('payments')->insert([ + 'id' => 1, + 'parent_id' => 2, + 'invoice_id' => 1, + 'total_amount' => 100, + 'paid_amount' => 60, + 'balance' => 40, + 'number_of_installments' => 1, + 'payment_method' => 'Cash', + 'payment_date' => '2025-01-15', + 'school_year' => '2025-2026', + 'status' => 'Paid', + ]); + + DB::table('discount_usages')->insert([ + 'id' => 1, + 'voucher_id' => 1, + 'invoice_id' => 1, + 'parent_id' => 2, + 'discount_amount' => 10, + 'school_year' => '2025-2026', + ]); + + DB::table('refunds')->insert([ + 'id' => 1, + 'parent_id' => 2, + 'school_year' => '2025-2026', + 'invoice_id' => 1, + 'refund_amount' => 5, + 'status' => 'Paid', + 'refund_paid_amount' => 5, + ]); + + DB::table('additional_charges')->insert([ + 'id' => 1, + 'parent_id' => 2, + 'amount' => 20, + 'school_year' => '2025-2026', + 'status' => 'pending', + ]); + + $service = new FinancialUnpaidParentsService(new FinancialSchoolYearService()); + $result = $service->getUnpaidParents('2025-2026'); + + $this->assertSame('2025-2026', $result['schoolYear']); + $this->assertCount(1, $result['results']); + + $row = $result['results'][0]; + $this->assertSame(45.0, $row['total_balance']); + $this->assertSame('installment', $row['type']); + $this->assertSame(1, $row['has_installment']); + } +} diff --git a/tests/Unit/Services/Grading/GradingBelowSixtyServiceTest.php b/tests/Unit/Services/Grading/GradingBelowSixtyServiceTest.php new file mode 100644 index 00000000..1981b4f8 --- /dev/null +++ b/tests/Unit/Services/Grading/GradingBelowSixtyServiceTest.php @@ -0,0 +1,55 @@ +insert([ + 'id' => 100, + 'parent_id' => 10, + 'firstname' => 'Kid', + 'lastname' => 'User', + 'school_id' => 1, + 'age' => 10, + 'gender' => 'M', + 'photo_consent' => 1, + 'year_of_registration' => '2025', + 'is_active' => 1, + ]); + + DB::table('classSection')->insert([ + 'id' => 1, + 'class_section_id' => 1, + 'class_section_name' => '1A', + 'class_id' => 1, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('semester_scores')->insert([ + 'id' => 1, + 'student_id' => 100, + 'school_id' => 1, + 'class_section_id' => 1, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'semester_score' => 55, + ]); + + $service = new GradingBelowSixtyService(); + $rows = $service->listRows('2025-2026', 'Fall'); + + $this->assertCount(1, $rows); + $this->assertSame(100, (int) $rows[0]['student_id']); + $this->assertSame('Open', $rows[0]['status']); + } +} diff --git a/tests/Unit/Services/Grading/GradingLockServiceTest.php b/tests/Unit/Services/Grading/GradingLockServiceTest.php new file mode 100644 index 00000000..2cf59bb0 --- /dev/null +++ b/tests/Unit/Services/Grading/GradingLockServiceTest.php @@ -0,0 +1,69 @@ +insert([ + 'id' => 1, + 'class_section_id' => 1, + 'class_section_name' => '1A', + 'class_id' => 1, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $service = new GradingLockService(); + $locked = $service->toggle(1, 'Fall', '2025-2026', 99); + + $this->assertTrue($locked); + $this->assertDatabaseHas('grading_locks', [ + 'class_section_id' => 1, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'is_locked' => 1, + ]); + } + + public function test_lock_all_locks_sections(): void + { + DB::table('classSection')->insert([ + [ + 'id' => 1, + 'class_section_id' => 1, + 'class_section_name' => '1A', + 'class_id' => 1, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + [ + 'id' => 2, + 'class_section_id' => 2, + 'class_section_name' => '1B', + 'class_id' => 1, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + ]); + + $service = new GradingLockService(); + $count = $service->lockAll('Fall', '2025-2026', 99); + + $this->assertSame(2, $count); + $this->assertDatabaseHas('grading_locks', [ + 'class_section_id' => 2, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'is_locked' => 1, + ]); + } +} diff --git a/tests/Unit/Services/Grading/HomeworkTrackingServiceTest.php b/tests/Unit/Services/Grading/HomeworkTrackingServiceTest.php new file mode 100644 index 00000000..88ffb099 --- /dev/null +++ b/tests/Unit/Services/Grading/HomeworkTrackingServiceTest.php @@ -0,0 +1,81 @@ +insert([ + ['id' => 1, 'config_key' => 'school_year', 'config_value' => '2025-2026'], + ['id' => 2, 'config_key' => 'semester', 'config_value' => 'Fall'], + ]); + + DB::table('classSection')->insert([ + 'id' => 1, + 'class_section_id' => 1, + 'class_section_name' => '1A', + 'class_id' => 1, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('users')->insert([ + 'id' => 1, + 'school_id' => 1, + 'firstname' => 'Teacher', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'teacher@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('teacher_class')->insert([ + 'id' => 1, + 'class_section_id' => 1, + 'teacher_id' => 1, + 'position' => 'main', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('homework')->insert([ + 'id' => 1, + 'student_id' => 100, + 'school_id' => 1, + 'class_section_id' => 1, + 'updated_by' => 1, + 'homework_index' => 1, + 'score' => 90, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = new HomeworkTrackingService(new HomeworkTrackingCalendarService()); + $result = $service->report('Fall', '2025-2026', 1); + + $this->assertNotEmpty($result['teachers']); + $this->assertSame(1, $result['teachers'][0]['class_section_id']); + } +} diff --git a/tests/Unit/Services/Incidents/IncidentAnalysisServiceTest.php b/tests/Unit/Services/Incidents/IncidentAnalysisServiceTest.php new file mode 100644 index 00000000..36f98db5 --- /dev/null +++ b/tests/Unit/Services/Incidents/IncidentAnalysisServiceTest.php @@ -0,0 +1,62 @@ +insert([ + 'id' => 1, + 'class_section_id' => 1, + 'class_section_name' => '1A', + 'class_id' => 1, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('incident')->insert([ + [ + 'id' => 1, + 'student_id' => 10, + 'student_name' => 'Kid Tester', + 'grade' => '1', + 'incident' => 'behavior', + 'incident_datetime' => '2025-01-02 10:00:00', + 'incident_state' => 'Closed', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + [ + 'id' => 2, + 'student_id' => 10, + 'student_name' => 'Kid Tester', + 'grade' => '1', + 'incident' => 'behavior', + 'incident_datetime' => '2025-01-01 10:00:00', + 'incident_state' => 'Canceled', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + ]); + + $service = new IncidentAnalysisService(new IncidentLookupService()); + $result = $service->analyze('2025-2026', 'Fall'); + + $this->assertCount(1, $result); + $this->assertSame('Kid Tester', $result[0]['student_name']); + $this->assertSame('1A', $result[0]['grade']); + $this->assertSame(2, $result[0]['total']); + $this->assertSame(1, $result[0]['closed']); + $this->assertSame(1, $result[0]['canceled']); + $this->assertSame('2025-01-02 10:00:00', $result[0]['last_incident']); + } +} diff --git a/tests/Unit/Services/Incidents/IncidentHistoryServiceTest.php b/tests/Unit/Services/Incidents/IncidentHistoryServiceTest.php new file mode 100644 index 00000000..f4a0e8dd --- /dev/null +++ b/tests/Unit/Services/Incidents/IncidentHistoryServiceTest.php @@ -0,0 +1,66 @@ +insert([ + 'id' => 1, + 'class_id' => 1, + 'class_section_id' => 5, + 'class_section_name' => '5A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('users')->insert([ + 'id' => 3, + 'school_id' => 1, + 'firstname' => 'Admin', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'admin@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('incident')->insert([ + 'student_id' => 1, + 'student_name' => 'Student One', + 'grade' => 5, + 'incident' => 'Test incident', + 'incident_datetime' => '2025-01-01 10:00:00', + 'incident_state' => 'open', + 'updated_by_open' => 3, + 'open_description' => 'Opened', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $service = new IncidentHistoryService(new IncidentLookupService()); + $rows = $service->processed('2025-2026', 'Fall'); + + $this->assertSame('5A', $rows[0]['grade']); + $this->assertSame('Admin User', $rows[0]['updated_by_open_name']); + } +} diff --git a/tests/Unit/Services/Incidents/IncidentLookupServiceTest.php b/tests/Unit/Services/Incidents/IncidentLookupServiceTest.php new file mode 100644 index 00000000..20332c33 --- /dev/null +++ b/tests/Unit/Services/Incidents/IncidentLookupServiceTest.php @@ -0,0 +1,79 @@ +insert([ + 'id' => 1, + 'class_id' => 1, + 'class_section_id' => 10, + 'class_section_name' => '1A', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('students')->insert([ + 'id' => 1, + 'school_id' => 'S1', + 'firstname' => 'Test', + 'lastname' => 'Student', + 'age' => 8, + 'gender' => 'Male', + 'photo_consent' => 1, + 'parent_id' => 1, + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + ]); + + DB::table('student_class')->insert([ + 'student_id' => 1, + 'class_section_id' => 10, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $service = new IncidentLookupService(); + $grades = $service->gradeOptionsWithActiveStudents('2025-2026'); + + $this->assertSame([['id' => 10, 'name' => '1A']], $grades); + } + + public function test_updater_name_map(): void + { + DB::table('users')->insert([ + 'id' => 1, + 'school_id' => 1, + 'firstname' => 'Jane', + 'lastname' => 'Doe', + 'cellphone' => '5555555555', + 'email' => 'jane@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $service = new IncidentLookupService(); + $map = $service->updaterNameMap([1]); + + $this->assertSame('Jane Doe', $map[1]); + } +} diff --git a/tests/Unit/Services/Invoices/InvoiceConfigServiceTest.php b/tests/Unit/Services/Invoices/InvoiceConfigServiceTest.php new file mode 100644 index 00000000..4e5631f8 --- /dev/null +++ b/tests/Unit/Services/Invoices/InvoiceConfigServiceTest.php @@ -0,0 +1,38 @@ +insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ['config_key' => 'due_date', 'config_value' => '2025-09-01'], + ['config_key' => 'grade_fee', 'config_value' => '9'], + ['config_key' => 'first_student_fee', 'config_value' => '350'], + ['config_key' => 'second_student_fee', 'config_value' => '200'], + ['config_key' => 'youth_fee', 'config_value' => '180'], + ['config_key' => 'refund_deadline', 'config_value' => '2025-10-01'], + ]); + + $service = new InvoiceConfigService(); + + $this->assertSame('2025-2026', $service->getSchoolYear()); + $this->assertSame('Fall', $service->getSemester()); + $this->assertSame('2025-09-01', $service->getDueDate()); + $this->assertSame(9, $service->getGradeFee()); + $this->assertSame(350.0, $service->getFirstStudentFee()); + $this->assertSame(200.0, $service->getSecondStudentFee()); + $this->assertSame(180.0, $service->getYouthFee()); + $this->assertSame('2025-10-01', $service->getRefundDeadline()); + } +} diff --git a/tests/Unit/Services/Invoices/InvoiceGenerationServiceTest.php b/tests/Unit/Services/Invoices/InvoiceGenerationServiceTest.php new file mode 100644 index 00000000..3c37a14e --- /dev/null +++ b/tests/Unit/Services/Invoices/InvoiceGenerationServiceTest.php @@ -0,0 +1,92 @@ +insert([ + 'id' => 10, + 'school_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'parent@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('students')->insert([ + 'id' => 100, + 'parent_id' => 10, + 'firstname' => 'Kid', + 'lastname' => 'User', + 'school_id' => 1, + 'is_active' => 1, + ]); + + DB::table('classSection')->insert([ + 'class_section_id' => 1, + 'class_section_name' => '1A', + 'class_id' => 1, + ]); + + DB::table('student_class')->insert([ + 'student_id' => 100, + 'class_section_id' => 1, + 'school_year' => '2025-2026', + 'is_event_only' => 0, + ]); + + DB::table('enrollments')->insert([ + 'student_id' => 100, + 'parent_id' => 10, + 'class_section_id' => 1, + 'enrollment_status' => 'enrolled', + 'school_year' => '2025-2026', + ]); + + $config = new InvoiceConfigService(); + $grades = new InvoiceGradeService($config->getGradeFee()); + $tuition = new InvoiceTuitionService( + $grades, + $config->getGradeFee(), + $config->getFirstStudentFee(), + $config->getSecondStudentFee(), + $config->getYouthFee(), + $config->getTimezone() + ); + $service = new InvoiceGenerationService($config, $tuition); + + $result = $service->generateInvoice(10, '2025-2026'); + + $this->assertTrue($result['ok']); + $this->assertNotNull($result['insert_id']); + $this->assertDatabaseHas('invoices', [ + 'id' => $result['insert_id'], + 'parent_id' => 10, + 'school_year' => '2025-2026', + ]); + } +} diff --git a/tests/Unit/Services/Invoices/InvoiceGradeServiceTest.php b/tests/Unit/Services/Invoices/InvoiceGradeServiceTest.php new file mode 100644 index 00000000..039b8225 --- /dev/null +++ b/tests/Unit/Services/Invoices/InvoiceGradeServiceTest.php @@ -0,0 +1,37 @@ +assertTrue($service->isKindergarten('KG')); + $this->assertSame(1, $service->gradeLevelInt('K')); + $this->assertSame(-1, $service->gradeLevelInt('PK')); + $this->assertSame(10, $service->gradeLevelInt('Youth')); + $this->assertSame(5, $service->gradeLevelInt('Grade 5')); + + $info = $service->getGradeLevel('5A'); + $this->assertSame(5, $info['level']); + $this->assertSame('A', $info['suffix']); + } + + public function test_compare_grades_orders_numeric_then_suffix(): void + { + $service = new InvoiceGradeService(9); + + $this->assertSame(-1, $service->compareGrades('1', '2')); + $this->assertSame(1, $service->compareGrades('2', '1')); + $this->assertSame(-1, $service->compareGrades('2A', '2B')); + $this->assertSame(0, $service->compareGrades('K', 'K')); + } +} diff --git a/tests/Unit/Services/Invoices/InvoiceManagementServiceTest.php b/tests/Unit/Services/Invoices/InvoiceManagementServiceTest.php new file mode 100644 index 00000000..7c3ff7bf --- /dev/null +++ b/tests/Unit/Services/Invoices/InvoiceManagementServiceTest.php @@ -0,0 +1,107 @@ +insert([ + ['id' => 1, 'name' => 'parent', 'slug' => 'parent', 'is_active' => 1], + ]); + + DB::table('users')->insert([ + 'id' => 10, + 'school_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'parent@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('user_roles')->insert([ + 'user_id' => 10, + 'role_id' => 1, + ]); + + DB::table('students')->insert([ + 'id' => 100, + 'parent_id' => 10, + 'firstname' => 'Kid', + 'lastname' => 'User', + 'school_id' => 1, + 'is_active' => 1, + ]); + + DB::table('classSection')->insert([ + 'class_section_id' => 1, + 'class_section_name' => '1A', + 'class_id' => 1, + ]); + + DB::table('student_class')->insert([ + 'student_id' => 100, + 'class_section_id' => 1, + 'school_year' => '2025-2026', + 'is_event_only' => 0, + ]); + + DB::table('enrollments')->insert([ + 'student_id' => 100, + 'parent_id' => 10, + 'class_section_id' => 1, + 'enrollment_status' => 'enrolled', + 'school_year' => '2025-2026', + ]); + + DB::table('invoices')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_number' => 'INV-001', + 'total_amount' => 100, + 'balance' => 100, + 'paid_amount' => 0, + 'has_discount' => 0, + 'issue_date' => '2025-01-10', + 'status' => 'unpaid', + 'school_year' => '2025-2026', + ]); + + DB::table('refunds')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'school_year' => '2025-2026', + 'invoice_id' => 1, + 'refund_amount' => 5, + 'status' => 'Paid', + 'refund_paid_amount' => 5, + ]); + + $service = new InvoiceManagementService(new InvoiceConfigService()); + $data = $service->getManagementData('2025-2026'); + + $this->assertSame('2025-2026', $data['schoolYear']); + $this->assertCount(1, $data['invoices']); + $this->assertSame(5.0, $data['invoices'][0]['refund_amount']); + } +} diff --git a/tests/Unit/Services/Invoices/InvoicePaymentServiceTest.php b/tests/Unit/Services/Invoices/InvoicePaymentServiceTest.php new file mode 100644 index 00000000..801c7160 --- /dev/null +++ b/tests/Unit/Services/Invoices/InvoicePaymentServiceTest.php @@ -0,0 +1,63 @@ +insert([ + [ + 'id' => 1, + 'parent_id' => 10, + 'invoice_number' => 'INV-001', + 'total_amount' => 100, + 'balance' => 40, + 'paid_amount' => 60, + 'has_discount' => 0, + 'issue_date' => '2025-01-10', + 'status' => 'unpaid', + 'school_year' => '2025-2026', + ], + ]); + + DB::table('payments')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_id' => 1, + 'total_amount' => 100, + 'paid_amount' => 60, + 'balance' => 40, + 'number_of_installments' => 1, + 'payment_method' => 'Cash', + 'payment_date' => '2025-01-15', + 'school_year' => '2025-2026', + 'status' => 'Paid', + ]); + + DB::table('refunds')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'school_year' => '2025-2026', + 'invoice_id' => 1, + 'refund_amount' => 5, + 'status' => 'Paid', + 'refund_paid_amount' => 5, + ]); + + $service = new InvoicePaymentService(new InvoiceConfigService()); + $data = $service->getParentInvoiceSummary(10, '2025-2026'); + + $this->assertSame('2025-2026', $data['selectedYear']); + $this->assertSame(5.0, $data['invoices'][0]['refund_amount']); + $this->assertSame(60.0, $data['invoices'][0]['last_paid_amount']); + } +} diff --git a/tests/Unit/Services/Invoices/InvoicePdfServiceTest.php b/tests/Unit/Services/Invoices/InvoicePdfServiceTest.php new file mode 100644 index 00000000..637c1f24 --- /dev/null +++ b/tests/Unit/Services/Invoices/InvoicePdfServiceTest.php @@ -0,0 +1,91 @@ +insert([ + 'id' => 10, + 'school_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'parent@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('students')->insert([ + 'id' => 100, + 'parent_id' => 10, + 'firstname' => 'Kid', + 'lastname' => 'User', + 'school_id' => 1, + 'is_active' => 1, + ]); + + DB::table('classSection')->insert([ + 'class_section_id' => 1, + 'class_section_name' => '1A', + 'class_id' => 1, + ]); + + DB::table('student_class')->insert([ + 'student_id' => 100, + 'class_section_id' => 1, + 'school_year' => '2025-2026', + 'is_event_only' => 0, + ]); + + DB::table('enrollments')->insert([ + 'student_id' => 100, + 'parent_id' => 10, + 'class_section_id' => 1, + 'enrollment_status' => 'enrolled', + 'school_year' => '2025-2026', + ]); + + DB::table('invoices')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_number' => 'INV-001', + 'total_amount' => 100, + 'balance' => 100, + 'paid_amount' => 0, + 'has_discount' => 0, + 'issue_date' => '2025-01-10', + 'status' => 'unpaid', + 'school_year' => '2025-2026', + ]); + + $config = new InvoiceConfigService(); + $grades = new InvoiceGradeService($config->getGradeFee()); + $service = new InvoicePdfService($config, $grades); + + $pdf = $service->buildPdf(1); + + $this->assertIsString($pdf); + $this->assertNotSame('', $pdf); + } +} diff --git a/tests/Unit/Services/Invoices/InvoiceTuitionServiceTest.php b/tests/Unit/Services/Invoices/InvoiceTuitionServiceTest.php new file mode 100644 index 00000000..ebedc709 --- /dev/null +++ b/tests/Unit/Services/Invoices/InvoiceTuitionServiceTest.php @@ -0,0 +1,60 @@ +insert([ + ['class_section_id' => 1, 'class_section_name' => '1', 'class_id' => 1], + ['class_section_id' => 2, 'class_section_name' => '2', 'class_id' => 2], + ['class_section_id' => 3, 'class_section_name' => '10', 'class_id' => 10], + ]); + + $grades = new InvoiceGradeService(9); + $service = new InvoiceTuitionService($grades, 9, 350.0, 200.0, 180.0, 'UTC'); + + $registeredKids = [ + ['class_section_id' => 1], + ['class_section_id' => 2], + ['class_section_id' => 3], + ]; + + $total = $service->calculateTuitionFee($registeredKids, [], date('Y-m-d', strtotime('+10 days'))); + + $this->assertSame(730.0, $total); + } + + public function test_refund_deadline_includes_withdrawn_when_passed(): void + { + DB::table('classSection')->insert([ + ['class_section_id' => 1, 'class_section_name' => '1', 'class_id' => 1], + ['class_section_id' => 2, 'class_section_name' => '2', 'class_id' => 2], + ]); + + $grades = new InvoiceGradeService(9); + $service = new InvoiceTuitionService($grades, 9, 350.0, 200.0, 180.0, 'UTC'); + + $registeredKids = [ + ['class_section_id' => 1], + ]; + $withdrawnKids = [ + ['class_section_id' => 2], + ]; + + $totalAllowed = $service->calculateTuitionFee($registeredKids, $withdrawnKids, date('Y-m-d', strtotime('+10 days'))); + $totalBlocked = $service->calculateTuitionFee($registeredKids, $withdrawnKids, date('Y-m-d', strtotime('-10 days'))); + + $this->assertSame(350.0, $totalAllowed); + $this->assertSame(550.0, $totalBlocked); + } +} diff --git a/tests/Unit/Services/Payments/PaymentBalanceServiceTest.php b/tests/Unit/Services/Payments/PaymentBalanceServiceTest.php new file mode 100644 index 00000000..5fa4e6d7 --- /dev/null +++ b/tests/Unit/Services/Payments/PaymentBalanceServiceTest.php @@ -0,0 +1,40 @@ +insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_id' => 1, + 'total_amount' => 100, + 'paid_amount' => 0, + 'balance' => 100, + 'number_of_installments' => 1, + 'payment_method' => 'cash', + 'payment_date' => '2025-01-01', + 'school_year' => '2025-2026', + 'status' => 'Pending', + ]); + + $service = new PaymentBalanceService(); + $updated = $service->updateBalance(1, 40); + + $this->assertTrue($updated); + $this->assertDatabaseHas('payments', [ + 'id' => 1, + 'paid_amount' => 40, + 'balance' => 60, + ]); + } +} diff --git a/tests/Unit/Services/Payments/PaymentEnrollmentEventServiceTest.php b/tests/Unit/Services/Payments/PaymentEnrollmentEventServiceTest.php new file mode 100644 index 00000000..6917bbba --- /dev/null +++ b/tests/Unit/Services/Payments/PaymentEnrollmentEventServiceTest.php @@ -0,0 +1,53 @@ +expectException(\RuntimeException::class); + + $service->buildEventData(999, [], '2025-2026', 'Fall'); + } + + public function test_build_event_data_returns_parent_payload_when_no_students(): void + { + DB::table('users')->insert([ + 'id' => 10, + 'school_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '555-555-5555', + 'email' => 'parent@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $service = new PaymentEnrollmentEventService(); + [$parent, $students] = $service->buildEventData(10, [], '2025-2026', 'Fall', 'https://example.test/login'); + + $this->assertSame(10, $parent['user_id']); + $this->assertSame('parent@example.com', $parent['email']); + $this->assertSame([], $students); + } +} diff --git a/tests/Unit/Services/Payments/PaymentEventChargesServiceTest.php b/tests/Unit/Services/Payments/PaymentEventChargesServiceTest.php new file mode 100644 index 00000000..c11f82ca --- /dev/null +++ b/tests/Unit/Services/Payments/PaymentEventChargesServiceTest.php @@ -0,0 +1,82 @@ +insert([ + 'id' => 1, + 'school_id' => 'S1', + 'firstname' => 'Student', + 'lastname' => 'One', + 'dob' => '2010-01-01', + 'age' => 14, + 'gender' => 'Male', + 'is_active' => 1, + 'registration_grade' => '5', + 'is_new' => 1, + 'photo_consent' => 1, + 'parent_id' => 10, + 'registration_date' => '2025-01-01', + 'tuition_paid' => 0, + 'rfid_tag' => null, + 'semester' => 'Fall', + 'year_of_registration' => '2025', + 'school_year' => '2025-2026', + ]); + + DB::table('enrollments')->insert([ + 'id' => 1, + 'student_id' => 1, + 'class_section_id' => 100, + 'parent_id' => 10, + 'enrollment_date' => '2025-09-01', + 'enrollment_status' => 'enrolled', + 'withdrawal_date' => null, + 'is_withdrawn' => 0, + 'admission_status' => 'accepted', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('classSection')->insert([ + 'id' => 1, + 'class_id' => 9, + 'class_section_id' => 100, + 'class_section_name' => 'Section A', + 'created_at' => null, + 'updated_at' => null, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('student_class')->insert([ + 'id' => 1, + 'student_id' => 1, + 'class_section_id' => 100, + 'is_event_only' => 0, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'description' => null, + 'created_at' => '2025-09-01 00:00:00', + 'updated_at' => '2025-09-01 00:00:00', + 'updated_by' => null, + ]); + + $service = new PaymentEventChargesService(); + $students = $service->getEnrolledStudents(10, '2025-2026'); + + $this->assertCount(1, $students); + $this->assertSame(1, $students[0]['id']); + $this->assertSame('9', (string) $students[0]['grade']); + } +} diff --git a/tests/Unit/Services/Payments/PaymentLookupServiceTest.php b/tests/Unit/Services/Payments/PaymentLookupServiceTest.php new file mode 100644 index 00000000..a47e0fd1 --- /dev/null +++ b/tests/Unit/Services/Payments/PaymentLookupServiceTest.php @@ -0,0 +1,50 @@ +insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_id' => 1, + 'total_amount' => 100, + 'paid_amount' => 20, + 'balance' => 80, + 'number_of_installments' => 1, + 'payment_method' => 'cash', + 'payment_date' => '2025-01-01', + 'school_year' => '2025-2026', + 'status' => 'Pending', + ]); + + DB::table('payments')->insert([ + 'id' => 2, + 'parent_id' => 10, + 'invoice_id' => 2, + 'total_amount' => 100, + 'paid_amount' => 20, + 'balance' => 80, + 'number_of_installments' => 1, + 'payment_method' => 'cash', + 'payment_date' => '2024-01-01', + 'school_year' => '2024-2025', + 'status' => 'Pending', + ]); + + $service = new PaymentLookupService(); + $results = $service->getByParent(10, '2025-2026'); + + $this->assertCount(1, $results); + $this->assertSame(1, $results[0]['id']); + } +} diff --git a/tests/Unit/Services/Payments/PaymentManualServiceTest.php b/tests/Unit/Services/Payments/PaymentManualServiceTest.php new file mode 100644 index 00000000..3f80961c --- /dev/null +++ b/tests/Unit/Services/Payments/PaymentManualServiceTest.php @@ -0,0 +1,61 @@ +expectException(\InvalidArgumentException::class); + + $service->recordPayment(1, 10.0, 'wire', null, null, null, null); + } + + public function test_suggest_returns_matches(): void + { + DB::table('users')->insert([ + 'id' => 10, + 'school_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '555-555-5555', + 'email' => 'parent@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $invoiceService = Mockery::mock(DiscountInvoiceService::class); + $enrollmentService = Mockery::mock(PaymentEnrollmentEventService::class); + + $service = new PaymentManualService($invoiceService, $enrollmentService); + $results = $service->suggest('parent@example.com'); + + $this->assertCount(1, $results); + $this->assertSame('parent@example.com', $results[0]['value']); + } +} diff --git a/tests/Unit/Services/Payments/PaymentNotificationDispatchServiceTest.php b/tests/Unit/Services/Payments/PaymentNotificationDispatchServiceTest.php new file mode 100644 index 00000000..d27d7dc6 --- /dev/null +++ b/tests/Unit/Services/Payments/PaymentNotificationDispatchServiceTest.php @@ -0,0 +1,40 @@ +insert([ + ['id' => 2001, 'config_key' => 'school_year', 'config_value' => '2025-2026'], + ['id' => 2002, 'config_key' => 'semester', 'config_value' => 'Fall'], + ]); + + $service = new PaymentNotificationDispatchService(); + $service->notifyUser(10, 'Payment Reminder', 'Reminder body', ['in_app']); + + $this->assertDatabaseHas('notifications', [ + 'title' => 'Payment Reminder', + 'message' => 'Reminder body', + 'target_group' => 'user', + 'status' => 'sent', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + $this->assertDatabaseHas('user_notifications', [ + 'user_id' => 10, + 'delivered' => 1, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + } +} diff --git a/tests/Unit/Services/Payments/PaymentNotificationServiceTest.php b/tests/Unit/Services/Payments/PaymentNotificationServiceTest.php new file mode 100644 index 00000000..4618093e --- /dev/null +++ b/tests/Unit/Services/Payments/PaymentNotificationServiceTest.php @@ -0,0 +1,134 @@ +insert([ + 'id' => 10, + 'school_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '555-555-5555', + 'email' => 'parent@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('invoices')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_number' => 'INV-1', + 'total_amount' => 100, + 'balance' => 100, + 'paid_amount' => 0, + 'has_discount' => 0, + 'issue_date' => '2025-01-01', + 'status' => 'unpaid', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + $emailService = Mockery::mock(EmailService::class); + $emailService->shouldReceive('send') + ->once() + ->with( + 'parent@example.com', + Mockery::type('string'), + Mockery::type('string'), + 'finance' + ) + ->andReturn(true); + + $dispatchService = Mockery::mock(PaymentNotificationDispatchService::class); + $dispatchService->shouldNotReceive('notifyUser'); + + $service = new PaymentNotificationService($emailService, $dispatchService); + $result = $service->send([ + 'parent_id' => 10, + 'type' => 'installment', + 'school_year' => '2025-2026', + ]); + + $this->assertSame(1, $result['sent']); + $this->assertDatabaseHas('payment_notification_logs', [ + 'parent_id' => 10, + 'status' => 'sent', + 'type' => 'installment', + ]); + } + + public function test_list_logs_filters_by_type(): void + { + DB::table('users')->insert([ + 'id' => 11, + 'school_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'Two', + 'cellphone' => '555-555-5556', + 'email' => 'parent2@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('payment_notification_logs')->insert([ + 'id' => 1, + 'parent_id' => 11, + 'invoice_id' => null, + 'school_year' => '2025-2026', + 'period_year' => 2025, + 'period_month' => 9, + 'type' => 'installment', + 'to_email' => 'parent2@example.com', + 'cc_email' => null, + 'head_fa_notified' => 0, + 'subject' => 'Reminder', + 'body' => 'Body', + 'status' => 'sent', + 'error_message' => null, + 'balance_snapshot' => 50, + 'sent_at' => '2025-09-01 00:00:00', + ]); + + $emailService = Mockery::mock(EmailService::class); + $dispatchService = Mockery::mock(PaymentNotificationDispatchService::class); + $service = new PaymentNotificationService($emailService, $dispatchService); + + $logs = $service->listLogs(null, null, 'installment'); + + $this->assertCount(1, $logs); + $this->assertSame(11, $logs[0]->parent_id); + } +} diff --git a/tests/Unit/Services/Payments/PaymentPlanServiceTest.php b/tests/Unit/Services/Payments/PaymentPlanServiceTest.php new file mode 100644 index 00000000..119ffa30 --- /dev/null +++ b/tests/Unit/Services/Payments/PaymentPlanServiceTest.php @@ -0,0 +1,70 @@ +insert([ + ['id' => 2001, 'config_key' => 'school_year', 'config_value' => '2025-2026'], + ['id' => 2002, 'config_key' => 'semester', 'config_value' => 'Fall'], + ]); + + DB::table('users')->insert([ + 'id' => 10, + 'school_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'parent@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('invoices')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_number' => 'INV-1', + 'total_amount' => 200, + 'balance' => 200, + 'paid_amount' => 0, + 'has_discount' => 0, + 'issue_date' => '2025-01-01', + 'status' => 'unpaid', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + $service = new PaymentPlanService(); + $payment = $service->createPlan([ + 'parent_id' => 10, + 'invoice_id' => 1, + 'total_amount' => 200, + 'number_of_installments' => 2, + 'payment_date' => '2025-01-01', + 'payment_method' => 'cash', + ]); + + $this->assertSame('2025-2026', $payment->school_year); + $this->assertSame('Fall', $payment->semester); + $this->assertSame(200.0, (float) $payment->total_amount); + } +} diff --git a/tests/Unit/Services/Payments/PaymentTransactionServiceTest.php b/tests/Unit/Services/Payments/PaymentTransactionServiceTest.php new file mode 100644 index 00000000..aaf648ea --- /dev/null +++ b/tests/Unit/Services/Payments/PaymentTransactionServiceTest.php @@ -0,0 +1,67 @@ +string('payment_reference')->nullable(); + }); + } + + if (!Schema::hasColumn('payment_transactions', 'is_full_payment')) { + Schema::table('payment_transactions', function (Blueprint $table) { + $table->boolean('is_full_payment')->default(0); + }); + } + + DB::table('payments')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_id' => 1, + 'total_amount' => 100, + 'paid_amount' => 0, + 'balance' => 100, + 'number_of_installments' => 1, + 'payment_method' => 'card', + 'payment_date' => '2025-01-01', + 'school_year' => '2025-2026', + 'status' => 'Pending', + ]); + + $service = new PaymentTransactionService(); + $transaction = $service->create([ + 'transaction_id' => 'TXN-1', + 'payment_id' => 1, + 'transaction_date' => '2025-01-02 00:00:00', + 'amount' => 50, + 'payment_method' => 'card', + 'payment_status' => 'Pending', + 'transaction_fee' => 1.25, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + $this->assertSame('TXN-1', $transaction->transaction_id); + + $updated = $service->updateStatus('TXN-1', 'Completed'); + + $this->assertTrue($updated); + $this->assertDatabaseHas('payment_transactions', [ + 'transaction_id' => 'TXN-1', + 'payment_status' => 'Completed', + ]); + } +} diff --git a/tests/Unit/Services/Payments/PaypalPaymentServiceTest.php b/tests/Unit/Services/Payments/PaypalPaymentServiceTest.php new file mode 100644 index 00000000..9bb032cc --- /dev/null +++ b/tests/Unit/Services/Payments/PaypalPaymentServiceTest.php @@ -0,0 +1,45 @@ +insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_id' => 1, + 'total_amount' => 100, + 'paid_amount' => 0, + 'balance' => 100, + 'number_of_installments' => 1, + 'payment_method' => 'card', + 'payment_date' => '2025-01-01', + 'school_year' => '2025-2026', + 'status' => 'Pending', + ]); + + $service = new PaypalPaymentService(); + $result = $service->createPayment(1); + + $this->assertSame('hosted', $result['mode']); + $this->assertSame($service->getRedirectUrl(), $result['redirect_url']); + } + + public function test_execute_payment_throws_when_sdk_missing(): void + { + $service = new PaypalPaymentService(); + + $this->expectException(\RuntimeException::class); + + $service->executePayment('payer'); + } +} diff --git a/tests/Unit/Services/Payments/PaypalTransactionsServiceTest.php b/tests/Unit/Services/Payments/PaypalTransactionsServiceTest.php new file mode 100644 index 00000000..2e2759fb --- /dev/null +++ b/tests/Unit/Services/Payments/PaypalTransactionsServiceTest.php @@ -0,0 +1,46 @@ +insert([ + 'id' => 1, + 'transaction_id' => 'TXN-ABC', + 'payer_email' => 'payer@example.com', + 'event_type' => 'PAYMENT', + 'order_id' => 'ORDER-1', + 'parent_school_id' => 'P1', + 'amount' => 50, + 'currency' => 'USD', + 'created_at' => '2025-01-01 00:00:00', + ]); + + DB::table('paypal_payments')->insert([ + 'id' => 2, + 'transaction_id' => 'TXN-DEF', + 'payer_email' => 'other@example.com', + 'event_type' => 'REFUND', + 'order_id' => 'ORDER-2', + 'parent_school_id' => 'P2', + 'amount' => 75, + 'currency' => 'USD', + 'created_at' => '2025-01-02 00:00:00', + ]); + + $service = new PaypalTransactionsService(); + $results = $service->listAll('ABC'); + + $this->assertCount(1, $results); + $this->assertStringContainsString('TXN-ABC', json_encode($results)); + } +} diff --git a/tests/Unit/Services/Reimbursements/ReimbursementBatchAssignmentServiceTest.php b/tests/Unit/Services/Reimbursements/ReimbursementBatchAssignmentServiceTest.php new file mode 100644 index 00000000..7d4e2ee5 --- /dev/null +++ b/tests/Unit/Services/Reimbursements/ReimbursementBatchAssignmentServiceTest.php @@ -0,0 +1,83 @@ +insert([ + 'id' => 1, + 'school_id' => 1, + 'firstname' => 'Admin', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'admin@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('reimbursement_batches')->insert([ + 'id' => 10, + 'title' => 'Batch 10', + 'status' => 'open', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'opened_at' => now(), + ]); + + DB::table('expenses')->insert([ + 'id' => 100, + 'category' => 'Expense', + 'amount' => 25.00, + 'date_of_purchase' => '2025-01-01', + 'added_by' => 1, + 'purchased_by' => 1, + 'status' => 'approved', + ]); + + $service = new ReimbursementBatchAssignmentService(new ReimbursementLookupService()); + + $result = $service->updateAssignment(100, 10, 1, null, '2025-2026', 'Fall'); + $this->assertSame(10, $result['batch_id']); + + $this->assertDatabaseHas('reimbursement_batch_items', [ + 'batch_id' => 10, + 'expense_id' => 100, + 'admin_id' => 1, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + $service->updateAssignment(100, 0, null, null, '2025-2026', 'Fall'); + + $this->assertDatabaseHas('reimbursement_batch_items', [ + 'batch_id' => 10, + 'expense_id' => 100, + ]); + + $this->assertDatabaseMissing('reimbursement_batch_items', [ + 'batch_id' => 10, + 'expense_id' => 100, + 'unassigned_at' => null, + ]); + } +} diff --git a/tests/Unit/Services/Reimbursements/ReimbursementBatchServiceTest.php b/tests/Unit/Services/Reimbursements/ReimbursementBatchServiceTest.php new file mode 100644 index 00000000..632d2bed --- /dev/null +++ b/tests/Unit/Services/Reimbursements/ReimbursementBatchServiceTest.php @@ -0,0 +1,30 @@ +createBatch(null, 1, '2025-2026', 'Fall'); + + $this->assertSame(1, $batch['sequence']); + $this->assertStringContainsString('Batch', $batch['label']); + + $this->assertDatabaseHas('reimbursement_batches', [ + 'id' => $batch['batch_id'], + 'status' => 'open', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + } +} diff --git a/tests/Unit/Services/Reimbursements/ReimbursementDonationServiceTest.php b/tests/Unit/Services/Reimbursements/ReimbursementDonationServiceTest.php new file mode 100644 index 00000000..a6a1cbcc --- /dev/null +++ b/tests/Unit/Services/Reimbursements/ReimbursementDonationServiceTest.php @@ -0,0 +1,77 @@ +insert([ + 'id' => 1, + 'school_id' => 1, + 'firstname' => 'Admin', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'admin@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('expenses')->insert([ + 'id' => 200, + 'category' => 'Expense', + 'amount' => 40.00, + 'date_of_purchase' => '2025-02-01', + 'added_by' => 1, + 'purchased_by' => 1, + 'status' => 'approved', + ]); + + DB::table('reimbursement_batches')->insert([ + 'id' => 5, + 'title' => 'Batch 5', + 'status' => 'open', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'opened_at' => now(), + ]); + + DB::table('reimbursement_batch_items')->insert([ + 'batch_id' => 5, + 'expense_id' => 200, + 'assigned_at' => now(), + ]); + + $service = new ReimbursementDonationService(); + $service->markDonation(200, 1); + + $this->assertDatabaseHas('expenses', [ + 'id' => 200, + 'category' => 'Donation', + 'status' => 'approved', + ]); + + $this->assertDatabaseMissing('reimbursement_batch_items', [ + 'batch_id' => 5, + 'expense_id' => 200, + 'unassigned_at' => null, + ]); + } +} diff --git a/tests/Unit/Services/Reimbursements/ReimbursementRecipientServiceTest.php b/tests/Unit/Services/Reimbursements/ReimbursementRecipientServiceTest.php new file mode 100644 index 00000000..a9665b59 --- /dev/null +++ b/tests/Unit/Services/Reimbursements/ReimbursementRecipientServiceTest.php @@ -0,0 +1,79 @@ +insert([ + [ + 'id' => 1, + 'school_id' => 1, + 'firstname' => 'Admin', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'admin@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + [ + 'id' => 2, + 'school_id' => 1, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'parent@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + ]); + + DB::table('roles')->insert([ + ['id' => 1, 'name' => 'admin'], + ['id' => 2, 'name' => 'parent'], + ]); + + DB::table('user_roles')->insert([ + ['user_id' => 1, 'role_id' => 1], + ['user_id' => 2, 'role_id' => 2], + ]); + + $service = new ReimbursementRecipientService(); + $recipients = $service->recipientOptions(); + + $ids = array_map(static fn ($row) => (int) ($row['id'] ?? 0), $recipients); + + $this->assertContains(1, $ids); + $this->assertNotContains(2, $ids); + $this->assertContains(990001, $ids); + $this->assertContains(990002, $ids); + } +} diff --git a/tests/Unit/Services/Scores/ExamScoreServiceTest.php b/tests/Unit/Services/Scores/ExamScoreServiceTest.php new file mode 100644 index 00000000..991e58a1 --- /dev/null +++ b/tests/Unit/Services/Scores/ExamScoreServiceTest.php @@ -0,0 +1,106 @@ +seedStudent(1, 'SCH1', 'Leo', 'Park'); + $this->seedStudentClass(1, 3, '2025-2026'); + + DB::table('midterm_exam')->insert([ + [ + 'student_id' => 1, + 'school_id' => 'SCH1', + 'class_section_id' => 3, + 'updated_by' => 99, + 'score' => 70, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + [ + 'student_id' => 1, + 'school_id' => 'SCH1', + 'class_section_id' => 3, + 'updated_by' => 99, + 'score' => 80, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + ]); + + $service = new ExamScoreService(new ScoreTermService()); + $result = $service->list('midterm_exam', 3, 'Fall', '2025-2026'); + + $this->assertCount(1, $result['students']); + $this->assertSame(80.0, $result['students'][0]['score']); + } + + public function test_update_creates_exam_score_and_missing_override(): void + { + $this->seedStudent(1, 'SCH1', 'Leo', 'Park'); + $this->seedStudentClass(1, 3, '2025-2026'); + + $service = new ExamScoreService(new ScoreTermService()); + + $count = $service->update( + 'midterm_exam', + 3, + 'Fall', + '2025-2026', + [1 => ['score' => 95]], + [1 => true], + 99 + ); + + $this->assertSame(1, $count); + $this->assertDatabaseHas('midterm_exam', [ + 'student_id' => 1, + 'class_section_id' => 3, + 'score' => 95.0, + ]); + $this->assertDatabaseHas('missing_score_overrides', [ + 'student_id' => 1, + 'class_section_id' => 3, + 'item_type' => 'midterm_exam', + 'item_index' => null, + 'is_allowed' => 1, + ]); + } + + private function seedStudent(int $id, string $schoolId, string $first, string $last): void + { + DB::table('students')->insert([ + 'id' => $id, + 'school_id' => $schoolId, + 'firstname' => $first, + 'lastname' => $last, + 'age' => 11, + 'gender' => 'M', + 'photo_consent' => 1, + 'parent_id' => 1, + 'year_of_registration' => '2025', + 'is_active' => 1, + ]); + } + + private function seedStudentClass(int $studentId, int $classSectionId, string $schoolYear): void + { + DB::table('student_class')->insert([ + 'student_id' => $studentId, + 'class_section_id' => $classSectionId, + 'school_year' => $schoolYear, + 'semester' => 'Fall', + 'is_event_only' => 0, + ]); + } +} diff --git a/tests/Unit/Services/Scores/HomeworkScoreServiceTest.php b/tests/Unit/Services/Scores/HomeworkScoreServiceTest.php new file mode 100644 index 00000000..95bba07d --- /dev/null +++ b/tests/Unit/Services/Scores/HomeworkScoreServiceTest.php @@ -0,0 +1,81 @@ +seedStudent(1, 'SCH1', 'Eli', 'Page'); + $this->seedStudentClass(1, 8, '2025-2026'); + + DB::table('homework')->insert([ + 'student_id' => 1, + 'school_id' => 'SCH1', + 'class_section_id' => 8, + 'updated_by' => 99, + 'homework_index' => 1, + 'score' => 91, + 'semester' => 'First Semester', + 'school_year' => '2025-2026', + ]); + + $service = new HomeworkScoreService(new ScoreTermService()); + $result = $service->list(8, 'Fall', '2025-2026'); + + $this->assertSame([1], $result['headers']); + $this->assertSame(91.0, $result['students'][0]['scores'][1]); + } + + public function test_add_column_inserts_next_index(): void + { + $this->seedStudent(1, 'SCH1', 'Eli', 'Page'); + $this->seedStudentClass(1, 8, '2025-2026'); + + $service = new HomeworkScoreService(new ScoreTermService()); + + $nextIndex = $service->addColumn(8, 'Fall', '2025-2026', 99); + + $this->assertSame(1, $nextIndex); + $this->assertDatabaseHas('homework', [ + 'student_id' => 1, + 'class_section_id' => 8, + 'homework_index' => 1, + ]); + } + + private function seedStudent(int $id, string $schoolId, string $first, string $last): void + { + DB::table('students')->insert([ + 'id' => $id, + 'school_id' => $schoolId, + 'firstname' => $first, + 'lastname' => $last, + 'age' => 10, + 'gender' => 'M', + 'photo_consent' => 1, + 'parent_id' => 1, + 'year_of_registration' => '2025', + 'is_active' => 1, + ]); + } + + private function seedStudentClass(int $studentId, int $classSectionId, string $schoolYear): void + { + DB::table('student_class')->insert([ + 'student_id' => $studentId, + 'class_section_id' => $classSectionId, + 'school_year' => $schoolYear, + 'semester' => 'Fall', + 'is_event_only' => 0, + ]); + } +} diff --git a/tests/Unit/Services/Scores/ParticipationScoreServiceTest.php b/tests/Unit/Services/Scores/ParticipationScoreServiceTest.php new file mode 100644 index 00000000..56829a8b --- /dev/null +++ b/tests/Unit/Services/Scores/ParticipationScoreServiceTest.php @@ -0,0 +1,98 @@ +seedStudent(1, 'SCH1', 'Owen', 'Hall'); + $this->seedStudentClass(1, 9, '2025-2026'); + + DB::table('participation')->insert([ + 'id' => 1, + 'student_id' => 1, + 'school_id' => 'SCH1', + 'class_section_id' => 9, + 'updated_by' => 99, + 'score' => 87, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $service = new ParticipationScoreService(new ScoreTermService()); + $result = $service->list(9, 'Fall', '2025-2026'); + + $this->assertSame(87.0, $result['students'][0]['scores']['score']); + } + + public function test_update_updates_existing_scores(): void + { + $this->seedStudent(1, 'SCH1', 'Owen', 'Hall'); + $this->seedStudentClass(1, 9, '2025-2026'); + + DB::table('participation')->insert([ + 'id' => 1, + 'student_id' => 1, + 'school_id' => 'SCH1', + 'class_section_id' => 9, + 'updated_by' => 99, + 'score' => 70, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $service = new ParticipationScoreService(new ScoreTermService()); + $count = $service->update( + 9, + 'Fall', + '2025-2026', + [1 => ['score' => 92]], + [], + 99 + ); + + $this->assertSame(1, $count); + $this->assertDatabaseHas('participation', [ + 'id' => 1, + 'student_id' => 1, + 'score' => 92.0, + ]); + } + + private function seedStudent(int $id, string $schoolId, string $first, string $last): void + { + DB::table('students')->insert([ + 'id' => $id, + 'school_id' => $schoolId, + 'firstname' => $first, + 'lastname' => $last, + 'age' => 10, + 'gender' => 'M', + 'photo_consent' => 1, + 'parent_id' => 1, + 'year_of_registration' => '2025', + 'is_active' => 1, + ]); + } + + private function seedStudentClass(int $studentId, int $classSectionId, string $schoolYear): void + { + DB::table('student_class')->insert([ + 'id' => 1, + 'student_id' => $studentId, + 'class_section_id' => $classSectionId, + 'school_year' => $schoolYear, + 'semester' => 'Fall', + 'is_event_only' => 0, + ]); + } +} diff --git a/tests/Unit/Services/Scores/ProjectScoreServiceTest.php b/tests/Unit/Services/Scores/ProjectScoreServiceTest.php new file mode 100644 index 00000000..b27c8f7b --- /dev/null +++ b/tests/Unit/Services/Scores/ProjectScoreServiceTest.php @@ -0,0 +1,114 @@ +seedStudent(1, 'SCH1', 'Maya', 'Stone'); + $this->seedStudentClass(1, 10, '2025-2026'); + + DB::table('project')->insert([ + 'student_id' => 1, + 'school_id' => 'SCH1', + 'class_section_id' => 10, + 'updated_by' => 99, + 'project_index' => 1, + 'score' => 88, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $service = new ProjectScoreService(new ScoreTermService()); + $result = $service->list(10, 'Fall', '2025-2026'); + + $this->assertSame([1], $result['headers']); + $this->assertCount(1, $result['students']); + $this->assertSame(88.0, $result['students'][0]['scores'][1]); + } + + public function test_update_creates_scores_and_missing_overrides(): void + { + $this->seedStudent(1, 'SCH1', 'Maya', 'Stone'); + $this->seedStudentClass(1, 10, '2025-2026'); + + $service = new ProjectScoreService(new ScoreTermService()); + + $count = $service->update( + 10, + 'Fall', + '2025-2026', + [1 => [1 => '92']], + [1 => [1 => true]], + 99 + ); + + $this->assertSame(1, $count); + $this->assertDatabaseHas('project', [ + 'student_id' => 1, + 'class_section_id' => 10, + 'project_index' => 1, + 'score' => 92.0, + ]); + $this->assertDatabaseHas('missing_score_overrides', [ + 'student_id' => 1, + 'class_section_id' => 10, + 'item_type' => 'project', + 'item_index' => 1, + 'is_allowed' => 1, + ]); + } + + public function test_add_column_inserts_next_index(): void + { + $this->seedStudent(1, 'SCH1', 'Maya', 'Stone'); + $this->seedStudentClass(1, 10, '2025-2026'); + + $service = new ProjectScoreService(new ScoreTermService()); + + $nextIndex = $service->addColumn(10, 'Fall', '2025-2026', 99); + + $this->assertSame(1, $nextIndex); + $this->assertDatabaseHas('project', [ + 'student_id' => 1, + 'class_section_id' => 10, + 'project_index' => 1, + ]); + } + + private function seedStudent(int $id, string $schoolId, string $first, string $last): void + { + DB::table('students')->insert([ + 'id' => $id, + 'school_id' => $schoolId, + 'firstname' => $first, + 'lastname' => $last, + 'age' => 10, + 'gender' => 'M', + 'photo_consent' => 1, + 'parent_id' => 1, + 'year_of_registration' => '2025', + 'is_active' => 1, + ]); + } + + private function seedStudentClass(int $studentId, int $classSectionId, string $schoolYear): void + { + DB::table('student_class')->insert([ + 'student_id' => $studentId, + 'class_section_id' => $classSectionId, + 'school_year' => $schoolYear, + 'semester' => 'Fall', + 'is_event_only' => 0, + ]); + } +} diff --git a/tests/Unit/Services/Scores/QuizScoreServiceTest.php b/tests/Unit/Services/Scores/QuizScoreServiceTest.php new file mode 100644 index 00000000..2be158e8 --- /dev/null +++ b/tests/Unit/Services/Scores/QuizScoreServiceTest.php @@ -0,0 +1,114 @@ +seedStudent(1, 'SCH1', 'Nina', 'Kim'); + $this->seedStudentClass(1, 5, '2025-2026'); + + DB::table('quiz')->insert([ + 'student_id' => 1, + 'school_id' => 'SCH1', + 'class_section_id' => 5, + 'updated_by' => 99, + 'quiz_index' => 2, + 'score' => 77, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $service = new QuizScoreService(new ScoreTermService()); + $result = $service->list(5, 'Fall', '2025-2026'); + + $this->assertSame([2], $result['headers']); + $this->assertCount(1, $result['students']); + $this->assertSame(77.0, $result['students'][0]['scores'][2]); + } + + public function test_update_creates_scores_and_missing_overrides(): void + { + $this->seedStudent(1, 'SCH1', 'Nina', 'Kim'); + $this->seedStudentClass(1, 5, '2025-2026'); + + $service = new QuizScoreService(new ScoreTermService()); + + $count = $service->update( + 5, + 'Fall', + '2025-2026', + [1 => [1 => '85']], + [1 => [1 => true]], + 99 + ); + + $this->assertSame(1, $count); + $this->assertDatabaseHas('quiz', [ + 'student_id' => 1, + 'class_section_id' => 5, + 'quiz_index' => 1, + 'score' => 85.0, + ]); + $this->assertDatabaseHas('missing_score_overrides', [ + 'student_id' => 1, + 'class_section_id' => 5, + 'item_type' => 'quiz', + 'item_index' => 1, + 'is_allowed' => 1, + ]); + } + + public function test_add_column_inserts_next_index(): void + { + $this->seedStudent(1, 'SCH1', 'Nina', 'Kim'); + $this->seedStudentClass(1, 5, '2025-2026'); + + $service = new QuizScoreService(new ScoreTermService()); + + $nextIndex = $service->addColumn(5, 'Fall', '2025-2026', 99); + + $this->assertSame(1, $nextIndex); + $this->assertDatabaseHas('quiz', [ + 'student_id' => 1, + 'class_section_id' => 5, + 'quiz_index' => 1, + ]); + } + + private function seedStudent(int $id, string $schoolId, string $first, string $last): void + { + DB::table('students')->insert([ + 'id' => $id, + 'school_id' => $schoolId, + 'firstname' => $first, + 'lastname' => $last, + 'age' => 10, + 'gender' => 'F', + 'photo_consent' => 1, + 'parent_id' => 1, + 'year_of_registration' => '2025', + 'is_active' => 1, + ]); + } + + private function seedStudentClass(int $studentId, int $classSectionId, string $schoolYear): void + { + DB::table('student_class')->insert([ + 'student_id' => $studentId, + 'class_section_id' => $classSectionId, + 'school_year' => $schoolYear, + 'semester' => 'Fall', + 'is_event_only' => 0, + ]); + } +} diff --git a/tests/Unit/Services/Scores/ScoreCommentServiceTest.php b/tests/Unit/Services/Scores/ScoreCommentServiceTest.php new file mode 100644 index 00000000..63a13f20 --- /dev/null +++ b/tests/Unit/Services/Scores/ScoreCommentServiceTest.php @@ -0,0 +1,80 @@ +seedStudent(1, 'SCH1', 'Ava', 'Lane'); + $this->seedStudentClass(1, 2, '2025-2026'); + + DB::table('score_comments')->insert([ + 'student_id' => 1, + 'class_section_id' => 2, + 'score_type' => 'midterm', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'comment' => 'Ava has shown consistent improvement and strong effort across assignments.', + ]); + + $service = new ScoreCommentService(new ScoreTermService()); + $result = $service->list('2', 'Fall', '2025-2026'); + + $this->assertCount(1, $result['students']); + $this->assertSame('Ava', $result['students'][0]['firstname']); + $this->assertSame('midterm', array_key_first($result['comments'][1])); + } + + public function test_save_returns_validation_errors_for_short_comment(): void + { + $this->seedStudent(1, 'SCH1', 'Ava', 'Lane'); + + $service = new ScoreCommentService(new ScoreTermService()); + $result = $service->save( + 2, + 'Fall', + '2025-2026', + [1 => ['midterm' => 'Too short']], + [], + 99 + ); + + $this->assertNotEmpty($result['errors']); + } + + private function seedStudent(int $id, string $schoolId, string $first, string $last): void + { + DB::table('students')->insert([ + 'id' => $id, + 'school_id' => $schoolId, + 'firstname' => $first, + 'lastname' => $last, + 'age' => 10, + 'gender' => 'F', + 'photo_consent' => 1, + 'parent_id' => 1, + 'year_of_registration' => '2025', + 'is_active' => 1, + ]); + } + + private function seedStudentClass(int $studentId, int $classSectionId, string $schoolYear): void + { + DB::table('student_class')->insert([ + 'student_id' => $studentId, + 'class_section_id' => $classSectionId, + 'school_year' => $schoolYear, + 'semester' => 'Fall', + 'is_event_only' => 0, + ]); + } +} diff --git a/tests/Unit/Services/Scores/ScoreDashboardServiceTest.php b/tests/Unit/Services/Scores/ScoreDashboardServiceTest.php new file mode 100644 index 00000000..bc773bcc --- /dev/null +++ b/tests/Unit/Services/Scores/ScoreDashboardServiceTest.php @@ -0,0 +1,110 @@ +seedClassSection(10, '1A'); + $this->seedStudent(1, 'SCH1', 'Noah', 'West', 5); + $this->seedStudentClass(1, 10, '2025-2026'); + + DB::table('teacher_class')->insert([ + 'teacher_id' => 1, + 'class_section_id' => 10, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('semester_scores')->insert([ + 'student_id' => 1, + 'school_id' => 'SCH1', + 'class_section_id' => 10, + 'homework_avg' => 85, + 'quiz_avg' => 90, + 'project_avg' => 92, + 'attendance_score' => 95, + 'ptap_score' => 90, + 'semester_score' => 91, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $service = new ScoreDashboardService(new ScoreTermService()); + $result = $service->overview(1, null, 'Fall', '2025-2026'); + + $this->assertSame(10, $result['class_section_id']); + $this->assertSame('1A', $result['class_section_name']); + $this->assertSame(85.0, $result['students'][0]['scores']['homework']); + } + + public function test_view_student_scores_groups_by_year_and_semester(): void + { + $this->seedClassSection(10, '1A'); + $this->seedStudent(2, 'SCH2', 'Mia', 'Clark', 7); + $this->seedStudentClass(2, 10, '2025-2026'); + + DB::table('semester_scores')->insert([ + 'student_id' => 2, + 'school_id' => 'SCH2', + 'class_section_id' => 10, + 'homework_avg' => 80, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $service = new ScoreDashboardService(new ScoreTermService()); + $result = $service->viewStudentScores(7, '2025-2026'); + + $this->assertArrayHasKey('2025-2026', $result['scores']); + $this->assertArrayHasKey('Fall', $result['scores']['2025-2026']); + $this->assertArrayHasKey(2, $result['scores']['2025-2026']['Fall']); + } + + private function seedStudent(int $id, string $schoolId, string $first, string $last, int $parentId): void + { + DB::table('students')->insert([ + 'id' => $id, + 'school_id' => $schoolId, + 'firstname' => $first, + 'lastname' => $last, + 'age' => 12, + 'gender' => 'F', + 'photo_consent' => 1, + 'parent_id' => $parentId, + 'year_of_registration' => '2025', + 'is_active' => 1, + ]); + } + + private function seedStudentClass(int $studentId, int $classSectionId, string $schoolYear): void + { + DB::table('student_class')->insert([ + 'student_id' => $studentId, + 'class_section_id' => $classSectionId, + 'school_year' => $schoolYear, + 'semester' => 'Fall', + 'is_event_only' => 0, + ]); + } + + private function seedClassSection(int $classSectionId, string $name): void + { + DB::table('classSection')->insert([ + 'class_section_id' => $classSectionId, + 'class_section_name' => $name, + 'class_id' => 1, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + } +} diff --git a/tests/Unit/Services/Scores/ScorePredictorServiceTest.php b/tests/Unit/Services/Scores/ScorePredictorServiceTest.php new file mode 100644 index 00000000..fca60523 --- /dev/null +++ b/tests/Unit/Services/Scores/ScorePredictorServiceTest.php @@ -0,0 +1,77 @@ +insert([ + ['id' => 1, 'config_key' => 'school_year', 'config_value' => '2025-2026'], + ['id' => 2, 'config_key' => 'semester', 'config_value' => 'Fall'], + ['id' => 3, 'config_key' => 'trophy_score', 'config_value' => '94'], + ['id' => 4, 'config_key' => 'pass_score', 'config_value' => '60'], + ]); + + DB::table('classSection')->insert([ + 'id' => 1, + 'class_section_id' => 1, + 'class_section_name' => '1A', + 'class_id' => 1, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('students')->insert([ + 'id' => 100, + 'parent_id' => 10, + 'firstname' => 'Kid', + 'lastname' => 'User', + 'school_id' => 1, + 'age' => 10, + 'gender' => 'M', + 'photo_consent' => 1, + 'year_of_registration' => '2025', + 'is_active' => 1, + ]); + + DB::table('student_class')->insert([ + 'id' => 1, + 'student_id' => 100, + 'class_section_id' => 1, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'is_event_only' => 0, + ]); + + DB::table('semester_scores')->insert([ + 'id' => 1, + 'student_id' => 100, + 'school_id' => 1, + 'class_section_id' => 1, + 'semester' => 'fall', + 'school_year' => '2025-2026', + 'semester_score' => 92, + ]); + + $service = new ScorePredictorService( + new ScorePredictorDataService(), + new ScorePredictorRiskService() + ); + + $result = $service->report('2025-2026', 1); + + $this->assertSame('2025-2026', $result['school_year']); + $this->assertCount(1, $result['students']); + $this->assertSame(100, $result['students'][0]['student_id']); + } +} diff --git a/tests/Unit/Services/Scores/ScoreTermServiceTest.php b/tests/Unit/Services/Scores/ScoreTermServiceTest.php new file mode 100644 index 00000000..8c3a6093 --- /dev/null +++ b/tests/Unit/Services/Scores/ScoreTermServiceTest.php @@ -0,0 +1,38 @@ +insert([ + ['config_key' => 'semester', 'config_value' => 'Fall'], + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ]); + + $service = new ScoreTermService(); + + $this->assertSame('Fall', $service->semesterLabel(null)); + $this->assertSame('2025-2026', $service->schoolYear(null)); + } + + public function test_normalize_and_variants(): void + { + $service = new ScoreTermService(); + + $this->assertSame('fall', $service->normalizeSemester('Semester 1')); + $this->assertSame('spring', $service->normalizeSemester('2nd Semester')); + + $variants = $service->semesterVariants('Fall'); + $this->assertContains('First Semester', $variants); + $this->assertContains('Semester 1', $variants); + } +} diff --git a/tests/Unit/Services/Scores/SemesterScoreServiceTest.php b/tests/Unit/Services/Scores/SemesterScoreServiceTest.php new file mode 100644 index 00000000..c699cf76 --- /dev/null +++ b/tests/Unit/Services/Scores/SemesterScoreServiceTest.php @@ -0,0 +1,107 @@ +insert([ + 'student_id' => 1, + 'school_id' => 'SCH1', + 'class_section_id' => 4, + 'updated_by' => 99, + 'score' => 88, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $service = new SemesterScoreService( + new FakeAttendanceCalculator(), + new FakeHomeworkCalculator(), + new FakeQuizCalculator(), + new FakeProjectCalculator() + ); + + $service->updateStudentScores([ + 'student_id' => 1, + 'school_id' => 'SCH1', + 'class_section_id' => 4, + 'updated_by' => 99, + ], 'Fall', '2025-2026'); + + $row = DB::table('semester_scores')->where([ + 'student_id' => 1, + 'class_section_id' => 4, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ])->first(); + + $this->assertNotNull($row); + $this->assertSame(95.0, (float) $row->attendance_score); + $this->assertSame(80.0, (float) $row->homework_avg); + $this->assertSame(90.0, (float) $row->quiz_avg); + $this->assertSame(100.0, (float) $row->project_avg); + $this->assertSame(84.0, (float) $row->ptap_score); + $this->assertSame(88.0, (float) $row->midterm_exam_score); + $this->assertSame(88.6, (float) $row->semester_score); + } +} + +class FakeAttendanceCalculator extends AttendanceCalculator +{ + public function __construct() + { + } + + public function calculate(int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): array + { + return ['attendance_score' => 95.0]; + } +} + +class FakeHomeworkCalculator extends HomeworkCalculator +{ + public function __construct() + { + } + + public function calculate(int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): array + { + return ['homework_avg' => 80.0]; + } +} + +class FakeQuizCalculator extends QuizCalculator +{ + public function calculate(int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): array + { + return ['quiz_avg' => 90.0]; + } +} + +class FakeProjectCalculator extends ProjectCalculator +{ + public function __construct() + { + } + + public function calculate(int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): array + { + return [ + 'project_avg' => 100.0, + 'participation_score' => 70.0, + ]; + } +} diff --git a/tests/Unit/Services/Semesters/SemesterConfigServiceTest.php b/tests/Unit/Services/Semesters/SemesterConfigServiceTest.php new file mode 100644 index 00000000..9f1976e5 --- /dev/null +++ b/tests/Unit/Services/Semesters/SemesterConfigServiceTest.php @@ -0,0 +1,27 @@ +insert([ + ['id' => 1, 'config_key' => 'fall_semester_start', 'config_value' => '2025-09-01'], + ['id' => 2, 'config_key' => 'spring_semester_start', 'config_value' => '2026-01-20'], + ['id' => 3, 'config_key' => 'last_school_day', 'config_value' => '2026-06-01'], + ]); + + $service = new SemesterConfigService(); + $this->assertSame('2025-09-01', $service->getFallStart()?->format('Y-m-d')); + $this->assertSame('2026-01-20', $service->getSpringStart()?->format('Y-m-d')); + $this->assertSame('2026-06-01', $service->getLastSchoolDay()?->format('Y-m-d')); + } +} diff --git a/tests/Unit/Services/Semesters/SemesterRangeServiceTest.php b/tests/Unit/Services/Semesters/SemesterRangeServiceTest.php new file mode 100644 index 00000000..58400bff --- /dev/null +++ b/tests/Unit/Services/Semesters/SemesterRangeServiceTest.php @@ -0,0 +1,46 @@ +getSchoolYearRange('2025-2026'); + + $this->assertSame('2025-09-01', $start); + $this->assertSame('2026-06-30', $end); + } + + public function test_get_semester_for_date_uses_config_dates(): void + { + DB::table('configuration')->insert([ + ['id' => 1, 'config_key' => 'fall_semester_start', 'config_value' => '2025-09-01'], + ['id' => 2, 'config_key' => 'spring_semester_start', 'config_value' => '2026-01-20'], + ['id' => 3, 'config_key' => 'last_school_day', 'config_value' => '2026-06-01'], + ]); + + $service = new SemesterRangeService(new SemesterConfigService()); + $semester = $service->getSemesterForDate('2025-10-01'); + + $this->assertSame('Fall', $semester); + } + + public function test_normalize_semester(): void + { + $service = new SemesterRangeService(new SemesterConfigService()); + + $this->assertSame('Fall', $service->normalizeSemester('fall')); + $this->assertSame('Spring', $service->normalizeSemester('SPRING')); + $this->assertSame('', $service->normalizeSemester('winter')); + } +} diff --git a/tests/Unit/Services/Users/LoginActivityServiceTest.php b/tests/Unit/Services/Users/LoginActivityServiceTest.php new file mode 100644 index 00000000..12f899a5 --- /dev/null +++ b/tests/Unit/Services/Users/LoginActivityServiceTest.php @@ -0,0 +1,45 @@ +insert([ + 'user_id' => 1, + 'email' => 'user@example.com', + 'login_time' => '2025-01-02 09:00:00', + 'logout_time' => null, + 'ip_address' => '10.0.0.1', + 'user_agent' => 'TestAgent', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('login_activity')->insert([ + 'user_id' => 1, + 'email' => 'user@example.com', + 'login_time' => '2025-01-01 09:00:00', + 'logout_time' => null, + 'ip_address' => '10.0.0.2', + 'user_agent' => 'TestAgent', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $service = app(LoginActivityService::class); + $payload = $service->list(1, 2); + + $this->assertSame(2, $payload['pagination']['total']); + $this->assertSame(2, $payload['pagination']['currentPage']); + $this->assertSame('10.0.0.2', $payload['activities'][0]['ip_address']); + } +} diff --git a/tests/Unit/Services/Users/UserEventServiceTest.php b/tests/Unit/Services/Users/UserEventServiceTest.php new file mode 100644 index 00000000..abb925b8 --- /dev/null +++ b/tests/Unit/Services/Users/UserEventServiceTest.php @@ -0,0 +1,52 @@ +shouldReceive('send') + ->once() + ->with( + 'user@example.com', + 'Welcome to Al Rahma Sunday School!', + Mockery::type('string'), + 'registration' + ) + ->andReturn(true); + + $service = new UserEventService($emailService); + + $result = $service->handleNewAccountAdded([ + 'firstname' => 'Test', + 'lastname' => 'User', + 'email' => 'user@example.com', + ]); + + $this->assertTrue($result); + } + + public function test_handle_new_account_requires_email(): void + { + $emailService = Mockery::mock(EmailService::class); + $emailService->shouldNotReceive('send'); + + $service = new UserEventService($emailService); + $result = $service->handleNewAccountAdded([ + 'firstname' => 'Test', + 'lastname' => 'User', + ]); + + $this->assertFalse($result); + } +} diff --git a/tests/Unit/Services/Users/UserListServiceTest.php b/tests/Unit/Services/Users/UserListServiceTest.php new file mode 100644 index 00000000..565d7a42 --- /dev/null +++ b/tests/Unit/Services/Users/UserListServiceTest.php @@ -0,0 +1,73 @@ +insert([ + 'id' => 5, + 'name' => 'Parent', + 'slug' => 'parent', + 'description' => 'Parent role', + 'dashboard_route' => 'parent/dashboard', + 'priority' => 1, + 'is_active' => 1, + ]); + + DB::table('users')->insert([ + 'id' => 1, + 'school_id' => 1, + 'firstname' => 'Jane', + 'lastname' => 'Doe', + 'cellphone' => '5551112222', + 'email' => 'second.parent@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('user_roles')->insert([ + 'user_id' => 1, + 'role_id' => 5, + ]); + + DB::table('parents')->insert([ + 'secondparent_firstname' => 'Jane', + 'secondparent_lastname' => 'Doe', + 'secondparent_gender' => 'Female', + 'secondparent_email' => 'second.parent@example.com', + 'secondparent_phone' => '5551112222', + 'firstparent_id' => 10, + 'secondparent_id' => 11, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $service = app(UserListService::class); + $rows = $service->list('id', 'asc'); + + $this->assertCount(1, $rows); + $this->assertSame('second.parent@example.com', $rows[0]['user']['email']); + $this->assertSame(['Parent'], $rows[0]['roles']); + $this->assertSame('Yes', $rows[0]['second_from_parents']); + } +} diff --git a/tests/Unit/Services/Users/UserManagementServiceTest.php b/tests/Unit/Services/Users/UserManagementServiceTest.php new file mode 100644 index 00000000..a8eb0782 --- /dev/null +++ b/tests/Unit/Services/Users/UserManagementServiceTest.php @@ -0,0 +1,153 @@ +insert([ + 'id' => 1, + 'name' => 'Parent', + 'slug' => 'parent', + 'description' => 'Parent role', + 'dashboard_route' => 'parent/dashboard', + 'priority' => 1, + 'is_active' => 1, + ]); + + $eventService = Mockery::mock(UserEventService::class); + $eventService->shouldReceive('handleNewAccountAdded') + ->once() + ->andReturn(true); + + $service = new UserManagementService($eventService); + + $result = $service->create([ + 'firstname' => 'New', + 'lastname' => 'User', + 'cellphone' => '5554443333', + 'email' => 'new.user@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => true, + 'role_id' => 1, + 'password' => 'secret123', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $this->assertTrue($result['ok']); + $this->assertInstanceOf(User::class, $result['user']); + $this->assertDatabaseHas('users', [ + 'email' => 'new.user@example.com', + 'firstname' => 'New', + 'lastname' => 'User', + ]); + $this->assertDatabaseHas('user_roles', [ + 'user_id' => $result['user']->id, + 'role_id' => 1, + ]); + } + + public function test_update_changes_user_fields(): void + { + DB::table('users')->insert([ + 'id' => 1, + 'school_id' => 1, + 'firstname' => 'Test', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'test@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $eventService = Mockery::mock(UserEventService::class); + $service = new UserManagementService($eventService); + + $result = $service->update(1, [ + 'firstname' => 'Updated', + 'email' => 'updated@example.com', + 'accept_school_policy' => false, + ]); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('users', [ + 'id' => 1, + 'firstname' => 'Updated', + 'email' => 'updated@example.com', + 'accept_school_policy' => 0, + ]); + } + + public function test_delete_removes_user_and_roles(): void + { + DB::table('users')->insert([ + 'id' => 1, + 'school_id' => 1, + 'firstname' => 'Test', + 'lastname' => 'User', + 'cellphone' => '5555555555', + 'email' => 'test@example.com', + 'address_street' => '123 Main', + 'city' => 'City', + 'state' => 'ST', + 'zip' => '12345', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'is_suspended' => 0, + 'failed_attempts' => 0, + 'password' => bcrypt('secret'), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('roles')->insert([ + 'id' => 1, + 'name' => 'Parent', + 'slug' => 'parent', + 'description' => 'Parent role', + 'dashboard_route' => 'parent/dashboard', + 'priority' => 1, + 'is_active' => 1, + ]); + + DB::table('user_roles')->insert([ + 'user_id' => 1, + 'role_id' => 1, + ]); + + $eventService = Mockery::mock(UserEventService::class); + $service = new UserManagementService($eventService); + + $result = $service->delete(1); + + $this->assertTrue($result['ok']); + $this->assertDatabaseMissing('users', ['id' => 1]); + $this->assertDatabaseMissing('user_roles', ['user_id' => 1]); + } +}