From 1cb3573d4bfc3f7252db867f9b50ddd07272fd29 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 9 Mar 2026 11:54:13 -0400 Subject: [PATCH] add more controllers and fix tests --- .../Controllers/Api/Email/EmailController.php | 61 + .../Api/Email/EmailExtractorController.php | 58 + .../Api/Finance/FeeCalculationController.php | 46 + .../Finance/PaymentTransactionController.php | 27 +- .../Api/Finance/PurchaseOrderController.php | 128 + .../Api/Scores/ScoreCommentController.php | 28 +- .../Api/Scores/ScoreController.php | 15 +- app/Http/Controllers/Api/UserController.php | 7 +- app/Http/Controllers/old/EmailController.php | 296 --- .../old/EmailExtractorController.php | 182 -- app/Http/Controllers/old/EmailService.php | 96 - .../old/ReimbursementController.php | 2117 ----------------- .../Email/EmailExtractorCompareRequest.php | 22 + app/Http/Requests/Email/EmailSendRequest.php | 27 + app/Http/Requests/Fees/FeeRefundRequest.php | 26 + .../Requests/Fees/FeeTuitionTotalRequest.php | 22 + .../PurchaseOrderIndexRequest.php | 20 + .../PurchaseOrderReceiveRequest.php | 22 + .../PurchaseOrderStoreRequest.php | 31 + app/Http/Requests/Scores/ScoreLockRequest.php | 5 + .../Requests/Scores/ScoreOverviewRequest.php | 13 + .../Semesters/SchoolYearRangeRequest.php | 5 + .../Semesters/SemesterResolveRequest.php | 5 + .../Requests/Users/LoginActivityRequest.php | 5 + app/Http/Requests/Users/UserStoreRequest.php | 5 + .../Email/EmailExtractorCompareResource.php | 23 + .../Email/EmailExtractorEmailsResource.php | 17 + .../Resources/Email/EmailSenderResource.php | 19 + app/Http/Resources/Fees/FeeRefundResource.php | 21 + .../Fees/FeeTuitionTotalResource.php | 16 + .../PurchaseOrderItemResource.php | 24 + .../PurchaseOrders/PurchaseOrderResource.php | 28 + app/Models/Supply.php | 20 + app/Models/SupplyTransaction.php | 25 + app/Models/User.php | 27 +- app/Services/Email/EmailAttachmentService.php | 35 + app/Services/Email/EmailDispatchService.php | 125 + app/Services/Email/EmailExtractorService.php | 97 + app/Services/Email/EmailProfileService.php | 95 + .../Email/EmailSenderOptionsService.php | 38 + app/Services/Fees/FeeConfigService.php | 57 + app/Services/Fees/FeeGradeService.php | 44 + .../Fees/FeeRefundCalculatorService.php | 120 + app/Services/Fees/FeeStudentFeeService.php | 62 + .../PurchaseOrderCreateService.php | 74 + .../PurchaseOrderQueryService.php | 65 + .../PurchaseOrderReceiveService.php | 87 + .../PurchaseOrderStatusService.php | 22 + app/Services/Scores/ScoreDashboardService.php | 53 +- .../Scores/ScorePredictorDataService.php | 2 +- ...26_03_09_070000_create_suppliers_table.php | 22 + ...026_03_09_070010_create_supplies_table.php | 24 + ...70020_create_supply_transactions_table.php | 28 + ...09_070030_create_purchase_orders_table.php | 31 + ...0040_create_purchase_order_items_table.php | 27 + routes/api.php | 22 + school_api | Bin 622592 -> 622592 bytes storage/uploads/exams/drafts/draft1.pdf | 1 + storage/uploads/receipts/sample.pdf | 1 + .../Api/V1/Email/EmailControllerTest.php | 79 + .../V1/Email/EmailExtractorControllerTest.php | 86 + .../Finance/FeeCalculationControllerTest.php | 123 + .../Finance/PurchaseOrderControllerTest.php | 212 ++ .../Api/V1/Scores/QuizControllerTest.php | 7 + .../V1/Scores/ScoreCommentControllerTest.php | 18 +- .../Api/V1/Scores/ScoreControllerTest.php | 6 +- .../Api/V1/Users/UserControllerTest.php | 5 +- .../Email/EmailExtractorServiceTest.php | 58 + .../Email/EmailProfileServiceTest.php | 33 + .../Services/Fees/FeeGradeServiceTest.php | 28 + .../Fees/FeeRefundCalculatorServiceTest.php | 67 + .../PurchaseOrderCreateServiceTest.php | 50 + .../PurchaseOrderReceiveServiceTest.php | 62 + .../PurchaseOrderStatusServiceTest.php | 34 + 74 files changed, 2761 insertions(+), 2728 deletions(-) create mode 100644 app/Http/Controllers/Api/Email/EmailController.php create mode 100644 app/Http/Controllers/Api/Email/EmailExtractorController.php create mode 100644 app/Http/Controllers/Api/Finance/FeeCalculationController.php create mode 100644 app/Http/Controllers/Api/Finance/PurchaseOrderController.php delete mode 100644 app/Http/Controllers/old/EmailController.php delete mode 100644 app/Http/Controllers/old/EmailExtractorController.php delete mode 100644 app/Http/Controllers/old/EmailService.php delete mode 100644 app/Http/Controllers/old/ReimbursementController.php create mode 100644 app/Http/Requests/Email/EmailExtractorCompareRequest.php create mode 100644 app/Http/Requests/Email/EmailSendRequest.php create mode 100644 app/Http/Requests/Fees/FeeRefundRequest.php create mode 100644 app/Http/Requests/Fees/FeeTuitionTotalRequest.php create mode 100644 app/Http/Requests/PurchaseOrders/PurchaseOrderIndexRequest.php create mode 100644 app/Http/Requests/PurchaseOrders/PurchaseOrderReceiveRequest.php create mode 100644 app/Http/Requests/PurchaseOrders/PurchaseOrderStoreRequest.php create mode 100644 app/Http/Resources/Email/EmailExtractorCompareResource.php create mode 100644 app/Http/Resources/Email/EmailExtractorEmailsResource.php create mode 100644 app/Http/Resources/Email/EmailSenderResource.php create mode 100644 app/Http/Resources/Fees/FeeRefundResource.php create mode 100644 app/Http/Resources/Fees/FeeTuitionTotalResource.php create mode 100644 app/Http/Resources/PurchaseOrders/PurchaseOrderItemResource.php create mode 100644 app/Http/Resources/PurchaseOrders/PurchaseOrderResource.php create mode 100644 app/Models/Supply.php create mode 100644 app/Models/SupplyTransaction.php create mode 100644 app/Services/Email/EmailAttachmentService.php create mode 100644 app/Services/Email/EmailDispatchService.php create mode 100644 app/Services/Email/EmailExtractorService.php create mode 100644 app/Services/Email/EmailProfileService.php create mode 100644 app/Services/Email/EmailSenderOptionsService.php create mode 100644 app/Services/Fees/FeeConfigService.php create mode 100644 app/Services/Fees/FeeGradeService.php create mode 100644 app/Services/Fees/FeeRefundCalculatorService.php create mode 100644 app/Services/Fees/FeeStudentFeeService.php create mode 100644 app/Services/PurchaseOrders/PurchaseOrderCreateService.php create mode 100644 app/Services/PurchaseOrders/PurchaseOrderQueryService.php create mode 100644 app/Services/PurchaseOrders/PurchaseOrderReceiveService.php create mode 100644 app/Services/PurchaseOrders/PurchaseOrderStatusService.php create mode 100644 database/migrations/2026_03_09_070000_create_suppliers_table.php create mode 100644 database/migrations/2026_03_09_070010_create_supplies_table.php create mode 100644 database/migrations/2026_03_09_070020_create_supply_transactions_table.php create mode 100644 database/migrations/2026_03_09_070030_create_purchase_orders_table.php create mode 100644 database/migrations/2026_03_09_070040_create_purchase_order_items_table.php create mode 100644 storage/uploads/exams/drafts/draft1.pdf create mode 100644 storage/uploads/receipts/sample.pdf create mode 100644 tests/Feature/Api/V1/Email/EmailControllerTest.php create mode 100644 tests/Feature/Api/V1/Email/EmailExtractorControllerTest.php create mode 100644 tests/Feature/Api/V1/Finance/FeeCalculationControllerTest.php create mode 100644 tests/Feature/Api/V1/Finance/PurchaseOrderControllerTest.php create mode 100644 tests/Unit/Services/Email/EmailExtractorServiceTest.php create mode 100644 tests/Unit/Services/Email/EmailProfileServiceTest.php create mode 100644 tests/Unit/Services/Fees/FeeGradeServiceTest.php create mode 100644 tests/Unit/Services/Fees/FeeRefundCalculatorServiceTest.php create mode 100644 tests/Unit/Services/PurchaseOrders/PurchaseOrderCreateServiceTest.php create mode 100644 tests/Unit/Services/PurchaseOrders/PurchaseOrderReceiveServiceTest.php create mode 100644 tests/Unit/Services/PurchaseOrders/PurchaseOrderStatusServiceTest.php diff --git a/app/Http/Controllers/Api/Email/EmailController.php b/app/Http/Controllers/Api/Email/EmailController.php new file mode 100644 index 00000000..2918b803 --- /dev/null +++ b/app/Http/Controllers/Api/Email/EmailController.php @@ -0,0 +1,61 @@ +senderOptions->listSenders(); + + return response()->json([ + 'ok' => true, + 'senders' => EmailSenderResource::collection($senders), + ]); + } + + public function send(EmailSendRequest $request): JsonResponse + { + $payload = $request->validated(); + + $attachments = $this->attachments->normalize($request->file('attachments', [])); + + $ok = $this->dispatch->send( + $payload['recipient'], + $payload['subject'], + $payload['html_message'], + $payload['profile'] ?? null, + $payload['reply_to_email'] ?? null, + $payload['reply_to_name'] ?? null, + $attachments + ); + + if (!$ok) { + return response()->json([ + 'ok' => false, + 'message' => 'Email failed to send.', + ], 500); + } + + return response()->json([ + 'ok' => true, + 'message' => 'Email sent successfully.', + ]); + } +} diff --git a/app/Http/Controllers/Api/Email/EmailExtractorController.php b/app/Http/Controllers/Api/Email/EmailExtractorController.php new file mode 100644 index 00000000..d38adeca --- /dev/null +++ b/app/Http/Controllers/Api/Email/EmailExtractorController.php @@ -0,0 +1,58 @@ +service->listEmails(); + + return response()->json([ + 'ok' => true, + 'emails' => new EmailExtractorEmailsResource($emails), + ]); + } + + public function compare(EmailExtractorCompareRequest $request): JsonResponse + { + $csvEmails = []; + + $file = $request->file('file'); + if ($file && $file->isValid()) { + $contents = file_get_contents($file->getRealPath() ?: $file->getPathname()); + $csvEmails = $this->service->extractEmailsFromText($contents); + } + + if (empty($csvEmails)) { + $csvEmails = $request->input('csv_emails', []); + } + + if (empty($csvEmails)) { + return response()->json([ + 'ok' => false, + 'message' => 'Provide a CSV file or csv_emails array.', + ], 422); + } + + $result = $this->service->compare($csvEmails); + + return response()->json([ + 'ok' => true, + 'comparison' => new EmailExtractorCompareResource($result), + ]); + } +} diff --git a/app/Http/Controllers/Api/Finance/FeeCalculationController.php b/app/Http/Controllers/Api/Finance/FeeCalculationController.php new file mode 100644 index 00000000..29cca4d0 --- /dev/null +++ b/app/Http/Controllers/Api/Finance/FeeCalculationController.php @@ -0,0 +1,46 @@ +validated(); + $result = $this->refunds->calculateRefund($payload['students'], (int) $payload['parent_id']); + + return response()->json([ + 'ok' => true, + 'refund' => new FeeRefundResource($result), + ]); + } + + public function tuitionTotal(FeeTuitionTotalRequest $request): JsonResponse + { + $payload = $request->validated(); + $total = $this->tuition->totalTuitionFee($payload['students']); + + return response()->json([ + 'ok' => true, + 'tuition' => new FeeTuitionTotalResource([ + 'total_tuition' => $total, + ]), + ]); + } +} diff --git a/app/Http/Controllers/Api/Finance/PaymentTransactionController.php b/app/Http/Controllers/Api/Finance/PaymentTransactionController.php index 80954c58..04d66eb7 100644 --- a/app/Http/Controllers/Api/Finance/PaymentTransactionController.php +++ b/app/Http/Controllers/Api/Finance/PaymentTransactionController.php @@ -4,10 +4,10 @@ namespace App\Http\Controllers\Api\Finance; use App\Http\Controllers\Api\BaseApiController; use App\Http\Requests\Payments\PaymentTransactionCreateRequest; -use App\Http\Requests\Payments\PaymentTransactionStatusRequest; use App\Http\Resources\Payments\PaymentTransactionResource; use App\Services\Payments\PaymentTransactionService; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; class PaymentTransactionController extends BaseApiController { @@ -37,10 +37,29 @@ class PaymentTransactionController extends BaseApiController ]); } - public function updateStatus(PaymentTransactionStatusRequest $request, string $transactionId): JsonResponse + public function updateStatus(Request $request, string $transactionId): JsonResponse { - $payload = $request->validated(); - $updated = $this->service->updateStatus($transactionId, $payload['status']); + $status = $request->input('status'); + if ($status === null) { + $raw = $request->getContent() ?: ''; + $json = json_decode($raw, true); + if (is_array($json) && array_key_exists('status', $json)) { + $status = $json['status']; + } else { + $parsed = []; + parse_str($raw, $parsed); + $status = $parsed['status'] ?? null; + } + } + + if ($status === null || trim((string) $status) === '') { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['status' => ['The status field is required.']], + ], 422); + } + + $updated = $this->service->updateStatus($transactionId, (string) $status); if (!$updated) { return response()->json(['ok' => false, 'message' => 'Transaction not found.'], 404); diff --git a/app/Http/Controllers/Api/Finance/PurchaseOrderController.php b/app/Http/Controllers/Api/Finance/PurchaseOrderController.php new file mode 100644 index 00000000..bc40ed91 --- /dev/null +++ b/app/Http/Controllers/Api/Finance/PurchaseOrderController.php @@ -0,0 +1,128 @@ +validated(); + $orders = $this->queryService->list($payload['q'] ?? null); + + return response()->json([ + 'ok' => true, + 'orders' => PurchaseOrderResource::collection($orders), + ]); + } + + public function options(): JsonResponse + { + $data = $this->queryService->options(); + + return response()->json([ + 'ok' => true, + 'suppliers' => $data['suppliers'] ?? [], + 'supplies' => $data['supplies'] ?? [], + ]); + } + + public function show(int $id): JsonResponse + { + $data = $this->queryService->find($id); + if (!$data) { + return response()->json(['ok' => false, 'message' => 'Purchase order not found.'], 404); + } + + return response()->json([ + 'ok' => true, + 'purchase_order' => new PurchaseOrderResource($data['purchase_order']), + 'items' => PurchaseOrderItemResource::collection($data['items'] ?? []), + ]); + } + + public function store(PurchaseOrderStoreRequest $request): JsonResponse + { + $payload = $request->validated(); + + try { + $po = $this->createService->create($payload); + } catch (RuntimeException $e) { + return response()->json(['ok' => false, 'message' => $e->getMessage()], 422); + } catch (\Throwable $e) { + return response()->json(['ok' => false, 'message' => 'Unable to create purchase order.'], 500); + } + + return response()->json([ + 'ok' => true, + 'purchase_order' => new PurchaseOrderResource($po), + ], 201); + } + + public function receive(PurchaseOrderReceiveRequest $request, int $id): JsonResponse + { + $payload = $request->validated(); + + $po = PurchaseOrder::query()->find($id); + if (!$po) { + return response()->json(['ok' => false, 'message' => 'Purchase order not found.'], 404); + } + + $user = auth()->user(); + $issuedBy = $user ? (string) ($user->email ?? $user->username ?? $user->id) : 'system'; + + try { + $result = $this->receiveService->receive($id, $payload['items'], $issuedBy); + } catch (RuntimeException $e) { + return response()->json(['ok' => false, 'message' => $e->getMessage()], 409); + } catch (\Throwable $e) { + return response()->json(['ok' => false, 'message' => 'Unable to receive items.'], 500); + } + + return response()->json([ + 'ok' => true, + 'status' => $result['status'] ?? null, + 'completed' => $result['completed'] ?? false, + ]); + } + + public function cancel(int $id): JsonResponse + { + $po = PurchaseOrder::query()->find($id); + if (!$po) { + return response()->json(['ok' => false, 'message' => 'Purchase order not found.'], 404); + } + + try { + $this->statusService->cancel($id); + } catch (RuntimeException $e) { + return response()->json(['ok' => false, 'message' => $e->getMessage()], 409); + } catch (\Throwable $e) { + return response()->json(['ok' => false, 'message' => 'Unable to cancel purchase order.'], 500); + } + + return response()->json(['ok' => true, 'status' => PurchaseOrder::STATUS_CANCELED]); + } +} diff --git a/app/Http/Controllers/Api/Scores/ScoreCommentController.php b/app/Http/Controllers/Api/Scores/ScoreCommentController.php index b362381c..c1e96730 100644 --- a/app/Http/Controllers/Api/Scores/ScoreCommentController.php +++ b/app/Http/Controllers/Api/Scores/ScoreCommentController.php @@ -20,10 +20,32 @@ class ScoreCommentController extends BaseApiController public function index(ScoreCommentListRequest $request): JsonResponse { $payload = $request->validated(); + $query = $request->query->all(); + if ($query === []) { + $rawQuery = (string) $request->server->get('QUERY_STRING', ''); + parse_str($rawQuery, $query); + } + $json = $request->json()->all(); + if ($json === []) { + $decoded = json_decode($request->getContent() ?: '', true); + $json = is_array($decoded) ? $decoded : []; + } + $classSectionId = $payload['class_section_id'] + ?? ($query['class_section_id'] ?? null) + ?? $request->input('class_section_id') + ?? ($json['class_section_id'] ?? null); + $semester = $payload['semester'] + ?? ($query['semester'] ?? null) + ?? $request->input('semester') + ?? ($json['semester'] ?? null); + $schoolYear = $payload['school_year'] + ?? ($query['school_year'] ?? null) + ?? $request->input('school_year') + ?? ($json['school_year'] ?? null); $data = $this->service->list( - $payload['class_section_id'] ?? null, - $payload['semester'] ?? null, - $payload['school_year'] ?? null + $classSectionId !== null ? (string) $classSectionId : null, + $semester !== null ? (string) $semester : null, + $schoolYear !== null ? (string) $schoolYear : null ); $studentRows = []; diff --git a/app/Http/Controllers/Api/Scores/ScoreController.php b/app/Http/Controllers/Api/Scores/ScoreController.php index d801db54..821e9371 100644 --- a/app/Http/Controllers/Api/Scores/ScoreController.php +++ b/app/Http/Controllers/Api/Scores/ScoreController.php @@ -20,13 +20,16 @@ class ScoreController extends BaseApiController public function overview(ScoreOverviewRequest $request): JsonResponse { $payload = $request->validated(); - $teacherId = (int) (auth()->id() ?? 0); + $teacherId = (int) ($request->user()?->id ?? (auth()->id() ?? 0)); + $classSectionId = $payload['class_section_id'] ?? $request->input('class_section_id'); + $semester = $payload['semester'] ?? $request->input('semester'); + $schoolYear = $payload['school_year'] ?? $request->input('school_year'); $data = $this->service->overview( $teacherId, - isset($payload['class_section_id']) ? (int) $payload['class_section_id'] : null, - $payload['semester'] ?? null, - $payload['school_year'] ?? null + isset($classSectionId) ? (int) $classSectionId : null, + $semester !== null ? (string) $semester : null, + $schoolYear !== null ? (string) $schoolYear : null ); return response()->json([ @@ -48,7 +51,7 @@ class ScoreController extends BaseApiController (string) $payload['semester'], (string) $payload['school_year'], $payload['missing_ok'] ?? [], - (int) (auth()->id() ?? 0) + (int) ($request->user()?->id ?? (auth()->id() ?? 0)) ); return response()->json(['ok' => true]); @@ -57,7 +60,7 @@ class ScoreController extends BaseApiController public function studentScores(ScoreStudentRequest $request): JsonResponse { $payload = $request->validated(); - $parentId = (int) (auth()->id() ?? 0); + $parentId = (int) ($request->user()?->id ?? (auth()->id() ?? 0)); $data = $this->service->viewStudentScores($parentId, (string) $payload['school_year']); diff --git a/app/Http/Controllers/Api/UserController.php b/app/Http/Controllers/Api/UserController.php index 8ccd2c6c..c4abb75b 100644 --- a/app/Http/Controllers/Api/UserController.php +++ b/app/Http/Controllers/Api/UserController.php @@ -88,9 +88,12 @@ class UserController extends BaseApiController public function loginActivity(LoginActivityRequest $request): JsonResponse { $payload = $request->validated(); + $perPage = $payload['per_page'] ?? 25; + $page = $payload['page'] ?? 1; + $result = $this->loginActivityService->list( - (int) ($payload['per_page'] ?? 25), - (int) ($payload['page'] ?? 1) + (int) $perPage, + (int) $page ); return response()->json([ diff --git a/app/Http/Controllers/old/EmailController.php b/app/Http/Controllers/old/EmailController.php deleted file mode 100644 index 16dc51ac..00000000 --- a/app/Http/Controllers/old/EmailController.php +++ /dev/null @@ -1,296 +0,0 @@ -..., 'name'=>...], ...] - */ - public function sendEmail( - string $recipient, - string $subject, - string $htmlMessage, - ?string $profile = null, - ?string $replyToEmail = null, - ?string $replyToName = null, - array $attachments = [] - ): bool { - // Composer autoload (if not already loaded by CI4) - $autoload = APPPATH . '../vendor/autoload.php'; - if (is_file($autoload)) { - require_once $autoload; - } - - $profile = $this->resolveProfile($profile); - $cfg = $this->getProfileConfig($profile); - - // Guard rails: refuse to proceed with missing essentials - if (empty($cfg['host']) || empty($cfg['user']) || $cfg['pass'] === '') { - log_message('error', "[mail:$profile] Missing SMTP config (host/user/pass). Check .env MAIL_{$this->envKeyFromProfile($profile)}_* or MAIL_DEFAULT_*."); - return false; - } - - // Optional env flags - $debugEnabled = (bool) env('MAIL_DEBUG', false); - $timeout = (int) env('MAIL_TIMEOUT', 15); // seconds - $keepAlive = (bool) env('MAIL_KEEPALIVE', false); - $verifyPeer = env('MAIL_VERIFY_PEER', 'true'); // 'true'|'false' (string to allow .env) - $verifyPeer = filter_var($verifyPeer, FILTER_VALIDATE_BOOLEAN); - - // Preflight DNS + socket (helps distinguish firewall/DNS vs. auth) - $targetHost = $cfg['host']; - $targetPort = (int) $cfg['port']; - - $resolved = @gethostbyname($targetHost); - if (!$resolved || $resolved === $targetHost) { - // Not fatal (some environments block gethostbyname), but useful log - log_message('debug', "[mail:$profile] DNS resolve note: host=$targetHost, resolved=$resolved"); - } - - $sockOk = @fsockopen($targetHost, $targetPort, $errno, $errstr, 5); - if (!$sockOk) { - log_message('error', "[mail:$profile] Socket preflight failed to {$targetHost}:{$targetPort} (errno=$errno, err=$errstr). Likely firewall/port/encryption mismatch or wrong host."); - } else { - fclose($sockOk); - } - - $mail = new PHPMailer(true); - - try { - // Capture PHPMailer’s own debug if enabled - if ($debugEnabled) { - ob_start(); - } - - // PHPMailer core setup - $mail->isSMTP(); - $mail->Host = $cfg['host']; - $mail->Port = $cfg['port']; - $mail->SMTPAuth = true; - $mail->Username = $cfg['user']; - $mail->Password = $cfg['pass']; - $mail->CharSet = 'UTF-8'; - $mail->Timeout = $timeout; // socket timeout - $mail->SMTPKeepAlive = $keepAlive; // reuse connection for multiple sends - $mail->SMTPAutoTLS = true; // allow auto TLS upgrade when possible - - // Encryption mapping: 'tls' => STARTTLS (587), 'ssl' => implicit TLS (465) - if ($cfg['encryption'] === 'ssl') { - $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; - } else { - $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; - } - - // TLS verification options (can relax on demand via .env for on-prem/self-signed) - $mail->SMTPOptions = [ - 'ssl' => [ - 'verify_peer' => $verifyPeer, - 'verify_peer_name' => $verifyPeer, - 'allow_self_signed' => !$verifyPeer, - ], - ]; - - // Optional verbose debugging (set MAIL_DEBUG=true in .env) - $mail->SMTPDebug = $debugEnabled ? 3 : 0; - $mail->Debugoutput = 'error_log'; - - // From / Return-Path - $mail->setFrom($cfg['fromEmail'], $cfg['fromName']); - if (!empty($cfg['returnPath'])) { - $mail->Sender = $cfg['returnPath']; - } - - // Configurable Reply-To: env overrides inputs/config; fallback to profile/defaults - $mail->clearReplyTos(); - $rtEmail = env('MAIL_DEFAULT_REPLY_TO'); - $rtName = env('MAIL_DEFAULT_REPLY_TO_NAME'); - if (!$rtEmail || !filter_var($rtEmail, FILTER_VALIDATE_EMAIL)) { - $rtEmail = $replyToEmail ?: ($cfg['replyTo'] ?: $cfg['fromEmail']); - } - if (!$rtName) { - $rtName = $replyToName ?: ($cfg['replyToName'] ?: $cfg['fromName']); - } - $rtName = $this->sanitizeReplyToName($rtName, $cfg['fromName']); - if ($rtEmail) { - $mail->addReplyTo($rtEmail, $rtName); - } - - // DKIM (optional) - if (!empty($cfg['dkim']['domain']) && !empty($cfg['dkim']['private']) && !empty($cfg['dkim']['selector'])) { - $mail->DKIM_domain = $cfg['dkim']['domain']; - $mail->DKIM_private = $cfg['dkim']['private']; // path to private key file - $mail->DKIM_selector = $cfg['dkim']['selector']; - $mail->DKIM_identity = $cfg['fromEmail']; - } - - // Recipient(s) - $mail->addAddress($recipient); - - // Attachments - foreach ($attachments as $att) { - if (!empty($att['path']) && is_file($att['path'])) { - $mail->addAttachment($att['path'], $att['name'] ?? ''); - } - } - - // Content - $mail->isHTML(true); - $mail->Subject = $subject; - $mail->Body = $htmlMessage; - - // Send! - $ok = $mail->send(); - - $dbg = $debugEnabled ? (ob_get_clean() ?: '') : ''; - if ($ok) { - log_message('info', "[mail:$profile] Sent to {$recipient}, subj='{$subject}' via {$cfg['host']}:{$cfg['port']}/{$cfg['encryption']}"); - return true; - } - - log_message('error', "[mail:$profile] Failed: {$mail->ErrorInfo}. Debug: {$dbg}"); - return false; - - } catch (Exception $e) { - $dbg = $debugEnabled ? (ob_get_clean() ?: '') : ''; - log_message('error', "[mail:$profile] Exception: {$e->getMessage()} | PHPMailer: {$mail->ErrorInfo} | Debug: {$dbg}"); - return false; - } - } - - /** Resolve null/alias profile names to a canonical env prefix. */ - private function resolveProfile(?string $profile): string - { - $p = strtolower(trim((string) $profile)); - if ($p === '' || $p === 'auto') { - $p = strtolower((string) getenv('MAIL_PROFILE_DEFAULT')) ?: 'default'; - } - - return match ($p) { - 'comm', 'comms' => 'communication', - 'sys', 'system' => 'default', - default => $p, // e.g., 'payment', 'admissions', etc. - }; - } - - /** Turn 'communication' into 'COMMUNICATION' for env lookups/logs. */ - private function envKeyFromProfile(string $profile): string - { - return strtoupper(preg_replace('/[^A-Z0-9]+/i', '_', $profile)); - } - - /** - * Resolve SMTP & identity for any profile with layered fallbacks: - * 1) MAIL_{PROFILE}_* - * 2) MAIL_DEFAULT_* - * 3) legacy SMTP_* (HOST/USER/PASS/PORT/ENCRYPTION) - * 4) hard defaults - */ - private function getProfileConfig(string $profile): array - { - $key = $this->envKeyFromProfile($profile); - - // Helper: first non-empty env from a list - $envFirst = function (array $keys, $default = null) { - foreach ($keys as $k) { - $v = env($k); - if (is_string($v)) $v = trim($v); - if ($v !== null && $v !== '') return $v; - } - return $default; - }; - - // Core SMTP (env first, then legacy SMTP_*) - $host = $envFirst(["MAIL_{$key}_HOST", "MAIL_DEFAULT_HOST", "SMTP_HOST"], ''); - $user = $envFirst(["MAIL_{$key}_USER", "MAIL_DEFAULT_USER", "SMTP_USER"], ''); - $pass = $envFirst(["MAIL_{$key}_PASS", "MAIL_DEFAULT_PASS", "SMTP_PASS"], ''); - $portRaw = $envFirst(["MAIL_{$key}_PORT", "MAIL_DEFAULT_PORT", "SMTP_PORT"], 587); - $encRaw = $envFirst(["MAIL_{$key}_ENCRYPTION", "MAIL_DEFAULT_ENCRYPTION", "SMTP_ENCRYPTION"], 'tls'); - - // Fallback to Config\Email when env is not set (common in local/dev) - $cfgEmail = config('Email'); - if ($cfgEmail) { - if ($host === '' && !empty($cfgEmail->SMTPHost)) { - $host = $cfgEmail->SMTPHost; - } - if ($user === '' && !empty($cfgEmail->SMTPUser)) { - $user = $cfgEmail->SMTPUser; - } - if ($pass === '' && !empty($cfgEmail->SMTPPass)) { - $pass = $cfgEmail->SMTPPass; - } - if ((int) $portRaw <= 0 && !empty($cfgEmail->SMTPPort)) { - $portRaw = $cfgEmail->SMTPPort; - } - if (($encRaw === '' || $encRaw === 'tls') && !empty($cfgEmail->SMTPCrypto)) { - $encRaw = $cfgEmail->SMTPCrypto; - } - } - - // Identity - $fromEmail = $envFirst(["MAIL_{$key}_FROM_EMAIL", "MAIL_DEFAULT_FROM_EMAIL"], $user ?: 'no-reply@alrahmaisgl.org'); - $fromName = $envFirst(["MAIL_{$key}_FROM_NAME", "MAIL_DEFAULT_FROM_NAME"], 'Al Rahma Sunday School'); - $replyTo = $envFirst(["MAIL_{$key}_REPLY_TO", "MAIL_DEFAULT_REPLY_TO"], ''); - $replyToNm = $envFirst(["MAIL_{$key}_REPLY_TO_NAME","MAIL_DEFAULT_REPLY_TO_NAME"], ''); - $returnPath = $envFirst(["MAIL_{$key}_RETURN_PATH","MAIL_DEFAULT_RETURN_PATH"], ''); - - // DKIM (optional) - $dkim = [ - 'domain' => $envFirst(["MAIL_{$key}_DKIM_DOMAIN", "MAIL_DEFAULT_DKIM_DOMAIN"], ''), - 'selector' => $envFirst(["MAIL_{$key}_DKIM_SELECTOR", "MAIL_DEFAULT_DKIM_SELECTOR"], ''), - 'private' => $envFirst(["MAIL_{$key}_DKIM_PRIVATE", "MAIL_DEFAULT_DKIM_PRIVATE"], ''), // full path - ]; - - // Normalize types/values - $port = (int) $portRaw ?: 587; - $encryption = strtolower((string) $encRaw); - $encryption = in_array($encryption, ['tls','ssl'], true) ? $encryption : 'tls'; - - // --- Autocorrect common mistakes (prevents silent handshake failures) --- - if ($port === 587 && $encryption === 'ssl') { - $encryption = 'tls'; - } - if ($port === 465 && $encryption === 'tls') { - $encryption = 'ssl'; - } - - return [ - 'host' => (string) $host, - 'user' => (string) $user, - 'pass' => (string) $pass, - 'port' => $port, - // map used above: 'tls' => STARTTLS, 'ssl' => SMTPS - 'encryption' => $encryption, - 'fromEmail' => (string) $fromEmail, - 'fromName' => (string) $fromName, - 'replyTo' => (string) $replyTo, - 'replyToName' => (string) $this->sanitizeReplyToName($replyToNm, (string) $fromName), - 'returnPath' => (string) $returnPath, - 'dkim' => $dkim, - ]; - } - - /** - * Normalize Reply-To display names, removing "No-Reply/No-Replay" placeholders. - */ - private function sanitizeReplyToName(?string $name, string $fallback): string - { - $trimmed = trim((string) $name); - if ($trimmed === '' || preg_match('/^no[- ]?repl(?:y|ay)$/i', $trimmed)) { - return $fallback; - } - return $trimmed; - } -} diff --git a/app/Http/Controllers/old/EmailExtractorController.php b/app/Http/Controllers/old/EmailExtractorController.php deleted file mode 100644 index 5b41e4f8..00000000 --- a/app/Http/Controllers/old/EmailExtractorController.php +++ /dev/null @@ -1,182 +0,0 @@ -table('users')->select('email'); - $userRows = $builderUsers->get()->getResultArray(); - foreach ($userRows as $row) { - $email = strtolower(trim((string)($row['email'] ?? ''))); - if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { - $users[] = $email; - } - } - // De-duplicate - $users = array_values(array_unique($users)); - - // Fetch parents.secondparent_email (non-null, non-empty) - $builderParents = $db->table('parents')->select('secondparent_email'); - $parentRows = $builderParents->get()->getResultArray(); - foreach ($parentRows as $row) { - $email = strtolower(trim((string)($row['secondparent_email'] ?? ''))); - if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { - $parents[] = $email; - } - } - // De-duplicate - $parents = array_values(array_unique($parents)); - - return $this->response->setJSON([ - 'users' => $users, - 'parents' => $parents, - ])->setStatusCode(ResponseInterface::HTTP_OK); - } catch (DatabaseException $e) { - return $this->response->setJSON([ - 'error' => 'Database error: ' . $e->getMessage(), - ])->setStatusCode(ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); - } - } - - /** - * POST /api/compare - * Accepts multipart/form-data with a CSV file named 'file' (optional), - * or JSON body with { csvEmails: string[] } (optional). - * Compares against DB and returns: - * { - * existed: string[], // in CSV AND in DB - * needToAdd: string[], // in DB BUT NOT in CSV - * counts: { csv: number, db: number, users: number, parents: number } - * } - */ - public function compare() - { - $request = $this->request; - $csvEmails = []; - - // 1) Try read from uploaded file - $file = $request->getFile('file'); - if ($file && $file->isValid()) { - $contents = file_get_contents($file->getTempName()); - $csvEmails = $this->extractEmailsFromText($contents); - } - - // 2) Or from JSON body - if (empty($csvEmails) && $request->getHeaderLine('Content-Type')) { - $contentType = $request->getHeaderLine('Content-Type'); - if (stripos($contentType, 'application/json') !== false) { - $json = $request->getJSON(true); - if (isset($json['csvEmails']) && is_array($json['csvEmails'])) { - $csvEmails = $this->normalizeEmailArray($json['csvEmails']); - } - } - } - - // 3) Compare with DB - $db = db_connect(); - - // Fetch DB emails - $users = []; - $parents = []; - - $userRows = $db->table('users')->select('email')->get()->getResultArray(); - foreach ($userRows as $row) { - $e = strtolower(trim((string)($row['email'] ?? ''))); - if ($e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL)) { - $users[] = $e; - } - } - $users = array_values(array_unique($users)); - - $parentRows = $db->table('parents')->select('secondparent_email')->get()->getResultArray(); - foreach ($parentRows as $row) { - $e = strtolower(trim((string)($row['secondparent_email'] ?? ''))); - if ($e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL)) { - $parents[] = $e; - } - } - $parents = array_values(array_unique($parents)); - - // Sets for comparison - $csvSet = array_flip(array_values(array_unique($csvEmails))); - $dbUnion = array_values(array_unique(array_merge($users, $parents))); - $dbSet = array_flip($dbUnion); - - // existed: in CSV and in DB - $existed = []; - foreach ($csvSet as $email => $_) { - if (isset($dbSet[$email])) { - $existed[] = $email; - } - } - sort($existed); - - // needToAdd: in DB but NOT in CSV - $needToAdd = []; - foreach ($dbSet as $email => $_) { - if (!isset($csvSet[$email])) { - $needToAdd[] = $email; - } - } - sort($needToAdd); - - return $this->response->setJSON([ - 'existed' => $existed, - 'needToAdd' => $needToAdd, - 'counts' => [ - 'csv' => count($csvEmails), - 'db' => count($dbUnion), - 'users' => count($users), - 'parents' => count($parents), - ], - ]); - } - - // Helpers - - private function normalizeEmailArray(array $arr): array - { - $out = []; - foreach ($arr as $e) { - $email = strtolower(trim((string)$e)); - if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { - $out[] = $email; - } - } - return array_values(array_unique($out)); - } - - private function extractEmailsFromText(string $text): array - { - $pattern = '/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/i'; - preg_match_all($pattern, $text, $matches); - $emails = $matches[0] ?? []; - return $this->normalizeEmailArray($emails); - } -} \ No newline at end of file diff --git a/app/Http/Controllers/old/EmailService.php b/app/Http/Controllers/old/EmailService.php deleted file mode 100644 index 3a06d438..00000000 --- a/app/Http/Controllers/old/EmailService.php +++ /dev/null @@ -1,96 +0,0 @@ -senders = is_array($decoded) ? $decoded : []; - $this->mailer = new EmailController(); - } - - protected function getSenderDetails(string $fromKey = 'general'): array - { - $senders = $this->senders; - if (isset($senders[$fromKey])) { - return $senders[$fromKey]; - } - return $senders['general'] ?? [ - 'email' => getenv('SMTP_USER') ?: 'no-reply@alrahmaisgl.org', - 'name' => 'Al Rahma Sunday School', - ]; - } - - /** NEW: expose configured sender keys to build a dropdown in the UI */ - public function listSenders(): array - { - // returns: ['general' => ['email' => '...', 'name' => '...'], 'finance' => [...], ...] - return $this->senders ?: [ - 'general' => [ - 'email' => getenv('SMTP_USER') ?: 'no-reply@alrahmaisgl.org', - 'name' => 'Al Rahma Sunday School', - ], - ]; - } - - /** - * Send a single HTML email. - * Backward-compatible: you can ignore $attachments/$headers. - */ - public function send(string $to, string $subject, string $message, string $fromKey = 'general', array $attachments = [], array $headers = []): bool - { - $attachmentsPayload = $this->normalizeAttachments($attachments); - // Headers are ignored by EmailController but kept for interface compatibility. - return $this->mailer->sendEmail($to, $subject, $message, $fromKey, null, null, $attachmentsPayload); - } - - /** - * NEW: BCC bulk send in a single SMTP call (To must be non-empty; we use the SMTP user). - */ - public function sendBcc(array $bccList, string $subject, string $message, string $fromKey = 'general', array $attachments = [], array $headers = []): bool - { - if (empty($bccList)) { - return true; // nothing to send - } - - $attachmentsPayload = $this->normalizeAttachments($attachments); - $allOk = true; - foreach ($bccList as $addr) { - $ok = $this->mailer->sendEmail($addr, $subject, $message, $fromKey, null, null, $attachmentsPayload); - if (!$ok) { - $allOk = false; - log_message('error', 'EmailService::sendBcc failed for ' . $addr); - } - } - return $allOk; - } - - /** - * Normalize various attachment inputs to the format expected by EmailController. - */ - private function normalizeAttachments(array $attachments): array - { - $out = []; - foreach ($attachments as $file) { - if (is_object($file) && method_exists($file, 'isValid') && $file->isValid()) { - $newName = $file->getRandomName(); - $path = WRITEPATH . 'uploads/' . $newName; - $file->move(WRITEPATH . 'uploads', $newName, true); - $out[] = ['path' => $path, 'name' => $file->getClientName()]; - } elseif (is_string($file) && is_file($file)) { - $out[] = ['path' => $file, 'name' => basename($file)]; - } elseif (is_array($file) && !empty($file['path'])) { - $out[] = ['path' => $file['path'], 'name' => $file['name'] ?? basename($file['path'])]; - } - } - return $out; - } -} diff --git a/app/Http/Controllers/old/ReimbursementController.php b/app/Http/Controllers/old/ReimbursementController.php deleted file mode 100644 index 37fbaa4f..00000000 --- a/app/Http/Controllers/old/ReimbursementController.php +++ /dev/null @@ -1,2117 +0,0 @@ - 'Masjid', - 990002 => 'Donation', - ]; - - public function __construct() - { - $this->db = \Config\Database::connect(); - if (!$this->db->connect()) { - log_message('error', 'Database connection failed.'); - throw new \Exception('Database connection failed.'); - } - - $this->configModel = new ConfigurationModel(); - $this->expenseModel = new ExpenseModel(); - $this->reimbModel = new ReimbursementModel(); - $this->userModel = new UserModel(); - $this->batchModel = new ReimbursementBatchModel(); - $this->batchItemModel = new ReimbursementBatchItemModel(); - $this->batchAdminFileModel = new ReimbursementBatchAdminFileModel(); - - $this->semester = $this->configModel->getConfig('semester') ?? 'Fall'; - $this->schoolYear = $this->configModel->getConfig('school_year') ?? date('Y'); - } - - /** - * Return staff users (admins/teachers/etc.) excluding parents/guests. - */ - private function staffUsers(): array - { - $rows = $this->userModel - ->select('users.id, users.firstname, users.lastname, roles.name AS role_name') - ->join('user_roles', 'user_roles.user_id = users.id', 'left') - ->join('roles', 'roles.id = user_roles.role_id', 'left') - ->where('roles.name IS NOT NULL', null, false) - ->findAll(); - - $excludedRoles = array_map('strtolower', ['parent', 'student', 'guest']); - $staff = []; - - foreach ($rows as $row) { - $roleName = strtolower((string) ($row['role_name'] ?? '')); - $id = (int) ($row['id'] ?? 0); - if ($id <= 0 || in_array($roleName, $excludedRoles, true)) { - continue; - } - if (!isset($staff[$id])) { - $staff[$id] = [ - 'id' => $id, - 'firstname' => $row['firstname'] ?? '', - 'lastname' => $row['lastname'] ?? '', - ]; - } - } - - uasort($staff, static function ($a, $b) { - $nameA = trim(($a['firstname'] ?? '') . ' ' . ($a['lastname'] ?? '')); - $nameB = trim(($b['firstname'] ?? '') . ' ' . ($b['lastname'] ?? '')); - return strcasecmp($nameA, $nameB); - }); - - return array_values($staff); - } - - private function fetchRecipientUsers(): array - { - return $this->staffUsers(); - } - - private function appendSpecialRecipients(array $users): array - { - $existingIds = array_map(static function ($row) { - return isset($row['id']) && is_numeric($row['id']) ? (int) $row['id'] : null; - }, $users); - - foreach (self::SPECIAL_RECIPIENTS as $id => $label) { - if (!in_array($id, $existingIds, true)) { - $users[] = [ - 'id' => $id, - 'firstname' => $label, - 'lastname' => '', - ]; - } - } - - return $users; - } - - private function recipientOptions(): array - { - return $this->appendSpecialRecipients($this->fetchRecipientUsers()); - } - - private function nextYearlyBatchNumberForSchoolYear(?string $schoolYear = null): int - { - $year = trim((string) ($schoolYear ?? $this->schoolYear)); - if ($year === '') { - return 1; - } - $record = $this->batchModel - ->select('MAX(yearly_batch_number) AS max_number') - ->where('school_year', $year) - ->first(); - - $max = (int) ($record['max_number'] ?? 0); - return $max + 1; - } - - private function adminRoster(): array - { - $admins = $this->staffUsers(); - - if (empty($admins)) { - $admins = $this->fetchRecipientUsers(); - } - - return $admins; - } - - private function specialRecipientLabel($value): ?string - { - if ($value === null || !is_numeric($value)) { - return null; - } - - $id = (int) $value; - return self::SPECIAL_RECIPIENTS[$id] ?? null; - } - - /** - * Ensure a subdirectory exists under WRITEPATH . 'uploads'. - * Returns the absolute path to the subdirectory. - */ - private function ensureUploadSubdir(string $subdir): string - { - $base = rtrim(WRITEPATH, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'uploads'; - $dir = $base . DIRECTORY_SEPARATOR . trim($subdir, '/\\'); - - if (!is_dir($dir)) { - // Try to create the directory tree if missing - if (!@mkdir($dir, 0755, true) && !is_dir($dir)) { - log_message('error', 'Failed to create upload directory: ' . $dir); - throw new \RuntimeException('Upload directory is not available.'); - } - } - if (!is_writable($dir)) { - log_message('error', 'Upload directory not writable: ' . $dir); - throw new \RuntimeException('Upload directory is not writable.'); - } - return $dir; - } - - /** - * Ensure a subdirectory exists under FCPATH . 'uploads'. - * Returns the absolute path to the subdirectory. - */ - /** - * Save a reimbursement receipt to writable/uploads/reimbursements and - * return the stored filename (basename). Returns null if no valid file provided. - */ - private function saveReimbReceipt($file): ?string - { - if (!$file instanceof UploadedFile) { - return null; - } - if (!$file->isValid() || $file->hasMoved()) { - return null; - } - // Skip zero-length inputs (when no file chosen for optional uploads) - $size = (int) ($file->getSize() ?? 0); - if ($size <= 0) { - return null; - } - - // Ensure target directory exists and is writable - $this->ensureUploadSubdir('reimbursements'); - - try { - // Prefer CI's store() API which places the file under WRITEPATH/uploads/ - $stored = $file->store('reimbursements'); - if (!$stored) { - throw new \RuntimeException('store() returned empty result'); - } - return basename($stored); - } catch (\Throwable $e) { - log_message('error', 'Reimbursement receipt store() failed: {message}', ['message' => $e->getMessage()]); - // Fallback to move() with a generated filename - $ext = $file->getClientExtension() ?: pathinfo($file->getName(), PATHINFO_EXTENSION); - $ext = $ext ? ('.' . strtolower($ext)) : ''; - $newName = 'reimb_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . $ext; - $ok = $file->move(WRITEPATH . 'uploads' . DIRECTORY_SEPARATOR . 'reimbursements', $newName, true); - if (!$ok) { - log_message('error', 'Failed to move reimbursement receipt to reimbursements directory.'); - throw new \RuntimeException('Unable to save uploaded file.'); - } - return $newName; - } - } - - private function buildReimbursementQuery($filters = []) - { - return $this->expenseModel->getReimbursedExpensesWithDetails($filters); - } - - public function underProcessing() - { - $pendingQuery = $this->db->table('expenses e'); - $pendingQuery->select(' - e.id AS expense_id, - e.amount AS expense_amount, - e.description, - e.receipt_path AS expense_receipt, - e.retailor, - e.purchased_by, - u.firstname AS purchaser_firstname, - u.lastname AS purchaser_lastname - '); - $pendingQuery->join('users u', 'u.id = e.purchased_by', 'left'); - $pendingQuery->join('reimbursement_batch_items bi', 'bi.expense_id = e.id AND bi.unassigned_at IS NULL', 'left'); - $pendingQuery->where('e.reimbursement_id IS NULL', null, false); - $pendingQuery->where('e.category !=', 'Donation'); - $pendingQuery->groupStart() - ->where('e.status', 'approved') - ->orWhere('e.status', 'Approved') - ->orWhere('e.status', 'APPROVED') - ->groupEnd(); - $pendingQuery->where('bi.id IS NULL', null, false); - $pendingQuery->orderBy('e.created_at', 'DESC'); - - $pendingRows = $pendingQuery->get()->getResultArray(); - - $pendingItems = []; - $itemsMap = []; - - foreach ($pendingRows as $row) { - $expenseId = (int) ($row['expense_id'] ?? 0); - if ($expenseId <= 0) { - continue; - } - $receiptUrl = $this->expenseReceiptUrl($row['expense_receipt'] ?? null); - $fullName = trim(($row['purchaser_firstname'] ?? '') . ' ' . ($row['purchaser_lastname'] ?? '')) ?: 'Unknown'; - - $item = [ - 'id' => $expenseId, - 'expense_id' => $expenseId, - 'reimbursement_id' => null, - 'expense_amount' => (float) ($row['expense_amount'] ?? 0), - 'vendor' => (string) ($row['retailor'] ?? ''), - 'description' => trim((string) ($row['description'] ?? '')), - 'purchased_by' => $fullName, - 'receipt_url' => $receiptUrl, - 'batch_id' => null, - 'batch_label' => null, - 'admin_id' => null, - 'admin_name' => null, - 'batch_number' => 0, - ]; - - $pendingItems[] = $item; - $itemsMap[$expenseId] = $item; - } - - $assignedQuery = $this->db->table('reimbursement_batch_items bi'); - $assignedQuery->select(' - bi.id AS batch_item_id, - bi.batch_id, - bi.admin_id, - bi.reimbursement_id, - bi.expense_id, - b.title AS batch_title, - b.status AS batch_status, - b.yearly_batch_number AS batch_year_number, - e.amount AS expense_amount, - e.description, - e.receipt_path AS expense_receipt, - e.retailor, - purchaser.firstname AS purchaser_firstname, - purchaser.lastname AS purchaser_lastname, - admin.firstname AS admin_firstname, - admin.lastname AS admin_lastname - '); - $assignedQuery->join('reimbursement_batches b', 'b.id = bi.batch_id', 'inner'); - $assignedQuery->join('expenses e', 'e.id = bi.expense_id', 'inner'); - $assignedQuery->join('users purchaser', 'purchaser.id = e.purchased_by', 'left'); - $assignedQuery->join('users admin', 'admin.id = bi.admin_id', 'left'); - $assignedQuery->where('b.status', 'open'); - $assignedQuery->where('bi.unassigned_at IS NULL', null, false); - $assignedQuery->where('e.reimbursement_id IS NULL', null, false); - $assignedQuery->where('e.category !=', 'Donation'); - $assignedQuery->groupStart() - ->where('e.status', 'approved') - ->orWhere('e.status', 'Approved') - ->orWhere('e.status', 'APPROVED') - ->groupEnd(); - $assignedQuery->orderBy('b.opened_at', 'ASC'); - $assignedQuery->orderBy('bi.assigned_at', 'ASC'); - - $assignedRows = $assignedQuery->get()->getResultArray(); - - $batchStructures = []; - - foreach ($assignedRows as $row) { - $expenseId = (int) ($row['expense_id'] ?? 0); - $batchId = (int) ($row['batch_id'] ?? 0); - if ($expenseId <= 0 || $batchId <= 0) { - continue; - } - - $receiptUrl = $this->expenseReceiptUrl($row['expense_receipt'] ?? null); - $purchaser = trim(($row['purchaser_firstname'] ?? '') . ' ' . ($row['purchaser_lastname'] ?? '')) ?: 'Unknown'; - $yearlyNumber = (int) ($row['batch_year_number'] ?? 0); - $batchLabel = trim((string) ($row['batch_title'] ?? '')); - if ($batchLabel === '') { - $fallbackNumber = $yearlyNumber > 0 ? $yearlyNumber : $batchId; - $batchLabel = 'Batch #' . $fallbackNumber; - } - $adminId = $row['admin_id'] ? (int) $row['admin_id'] : 0; - $adminName = $adminId > 0 - ? (trim(($row['admin_firstname'] ?? '') . ' ' . ($row['admin_lastname'] ?? '')) ?: 'Admin') - : 'Unassigned'; - $batchStatus = strtolower(trim((string) ($row['batch_status'] ?? ''))) ?: 'open'; - - $item = [ - 'id' => $expenseId, - 'expense_id' => $expenseId, - 'reimbursement_id' => $row['reimbursement_id'] ? (int) $row['reimbursement_id'] : null, - 'expense_amount' => (float) ($row['expense_amount'] ?? 0), - 'vendor' => (string) ($row['retailor'] ?? ''), - 'description' => trim((string) ($row['description'] ?? '')), - 'purchased_by' => $purchaser, - 'receipt_url' => $receiptUrl, - 'batch_id' => $batchId, - 'batch_label' => $batchLabel, - 'admin_id' => $adminId ?: null, - 'admin_name' => $adminId > 0 ? $adminName : null, - 'batch_number' => $batchId, - ]; - - $itemsMap[$expenseId] = $item; - - if (!isset($batchStructures[$batchId])) { - $batchStructures[$batchId] = [ - 'batchId' => $batchId, - 'label' => $batchLabel, - 'slots' => [], - 'status' => $batchStatus, - 'yearly_batch_number'=> $yearlyNumber, - ]; - } else { - $batchStructures[$batchId]['status'] = $batchStatus; - if ($yearlyNumber > 0) { - $batchStructures[$batchId]['yearly_batch_number'] = $yearlyNumber; - } - } - - $slotKey = $adminId ?: 0; - if (!isset($batchStructures[$batchId]['slots'][$slotKey])) { - $batchStructures[$batchId]['slots'][$slotKey] = [ - 'admin_id' => $adminId ?: 0, - 'admin_name' => $adminId > 0 ? $adminName : 'Unassigned', - 'items' => [], - 'check_file' => null, - ]; - } - $batchStructures[$batchId]['slots'][$slotKey]['items'][] = $expenseId; - } - - $adminFileMap = []; - if (!empty($batchStructures)) { - $batchIds = array_keys($batchStructures); - $files = $this->batchAdminFileModel - ->select('batch_id, admin_id, filename, original_filename') - ->whereIn('batch_id', $batchIds) - ->findAll(); - foreach ($files as $file) { - $batchId = (int) ($file['batch_id'] ?? 0); - $adminId = (int) ($file['admin_id'] ?? 0); - if ($batchId <= 0) { - continue; - } - $adminFileMap[$batchId][$adminId] = [ - 'filename' => $file['filename'], - 'original_filename' => $file['original_filename'] ?? null, - 'url' => base_url('reimbursements/batch/admin-file/' . rawurlencode($file['filename']) . '/inline'), - ]; - } - } - - foreach ($batchStructures as $batchId => &$batch) { - foreach ($batch['slots'] as $slotKey => &$slot) { - $adminId = $slot['admin_id'] ?? 0; - $slot['check_file'] = $adminFileMap[$batchId][$adminId] ?? null; - } - unset($slot); - } - unset($batch); - - $existingBatches = array_map(static function (array $batch) { - $normalizedSlots = array_map(static function (array $slot) { - $slot['items'] = array_values(array_map('intval', $slot['items'])); - return $slot; - }, array_values($batch['slots'])); - - $itemIds = []; - foreach ($normalizedSlots as $slot) { - $itemIds = array_merge($itemIds, $slot['items']); - } - - return [ - 'batchId' => $batch['batchId'], - 'batchNumber' => $batch['batchId'], - 'label' => $batch['label'], - 'sequence' => $batch['yearly_batch_number'] ?? 0, - 'yearly_batch_number'=> $batch['yearly_batch_number'] ?? 0, - 'slots' => $normalizedSlots, - 'itemIds' => array_values(array_unique($itemIds)), - 'status' => $batch['status'] ?? 'open', - ]; - }, array_values($batchStructures)); - - $admins = $this->adminRoster(); - - return view('reimbursements/under_processing', [ - 'pendingItems' => $pendingItems, - 'existingBatches' => $existingBatches, - 'adminUsers' => $admins, - 'itemsPayload' => array_values($itemsMap), - 'createBatchUrl' => base_url('reimbursements/batch/create'), - 'checkUploadUrl' => base_url('reimbursements/batch/admin-file/upload'), - ]); - } - - public function markDonation() - { - if (strtolower($this->request->getMethod()) !== 'post') { - return $this->response->setStatusCode(405)->setJSON([ - 'success' => false, - 'error' => 'Method not allowed', - ]); - } - - $expenseId = (int) $this->request->getPost('expense_id'); - if ($expenseId <= 0) { - return $this->response->setStatusCode(422)->setJSON([ - 'success' => false, - 'error' => 'Invalid expense id.', - ]); - } - - $expense = $this->expenseModel->find($expenseId); - if (!$expense) { - return $this->response->setStatusCode(404)->setJSON([ - 'success' => false, - 'error' => 'Expense not found.', - ]); - } - - if (!empty($expense['reimbursement_id'])) { - return $this->response->setStatusCode(409)->setJSON([ - 'success' => false, - 'error' => 'Expense is already tied to a reimbursement.', - ]); - } - - $userId = (int) (session()->get('user_id') ?? 0); - $now = date('Y-m-d H:i:s'); - - $this->db->transBegin(); - try { - $this->batchItemModel - ->where('expense_id', $expenseId) - ->where('unassigned_at IS NULL', null, false) - ->set('unassigned_at', $now) - ->update(); - - $this->expenseModel->update($expenseId, [ - 'category' => 'Donation', - 'status' => 'approved', - 'status_reason' => 'Marked as Donation (non-reimbursable).', - 'approved_by' => $userId ?: null, - 'updated_by' => $userId ?: null, - 'reimbursement_id'=> null, - ]); - } catch (\Throwable $e) { - $this->db->transRollback(); - log_message('error', 'Failed to mark expense #{expense} as donation: {msg}', [ - 'expense' => $expenseId, - 'msg' => $e->getMessage(), - ]); - return $this->response->setStatusCode(500)->setJSON([ - 'success' => false, - 'error' => 'Unable to mark donation right now.', - ]); - } - - if (!$this->db->transStatus()) { - $this->db->transRollback(); - return $this->response->setStatusCode(500)->setJSON([ - 'success' => false, - 'error' => 'Unable to mark donation right now.', - ]); - } - $this->db->transCommit(); - - $newHash = function_exists('csrf_hash') ? csrf_hash() : null; - return $this->response - ->setHeader('X-CSRF-HASH', (string) $newHash) - ->setJSON([ - 'success' => true, - 'csrf_hash' => $newHash, - ]); - } - - public function createBatch() - { - if (strtolower($this->request->getMethod()) !== 'post') { - return $this->response->setStatusCode(405)->setJSON([ - 'success' => false, - 'error' => 'Method not allowed', - ]); - } - - $title = trim((string) ($this->request->getPost('title') ?? '')); - $userId = (int) (session()->get('user_id') ?? 0); - $now = date('Y-m-d H:i:s'); - $sequence = $this->nextYearlyBatchNumberForSchoolYear(); - - $data = [ - 'title' => $title !== '' ? $title : null, - 'status' => 'open', - 'created_by' => $userId ?: null, - 'opened_at' => $now, - 'school_year' => $this->schoolYear, - 'semester' => $this->semester, - 'yearly_batch_number' => $sequence, - ]; - - try { - $this->batchModel->insert($data); - $batchId = (int) $this->batchModel->getInsertID(); - } catch (\Throwable $e) { - log_message('error', 'Failed to create reimbursement batch: {msg}', ['msg' => $e->getMessage()]); - return $this->response->setStatusCode(500)->setJSON([ - 'success' => false, - 'error' => 'Unable to create batch right now.', - ]); - } - - if ($batchId <= 0) { - return $this->response->setStatusCode(500)->setJSON([ - 'success' => false, - 'error' => 'Failed to create batch.', - ]); - } - - $label = $title !== '' ? $title : 'Batch #' . $sequence; - if ($title === '') { - $this->batchModel->update($batchId, ['title' => $label]); - } - - $newHash = function_exists('csrf_hash') ? csrf_hash() : null; - - return $this->response - ->setHeader('X-CSRF-HASH', (string) $newHash) - ->setJSON([ - 'success' => true, - 'batch_id' => $batchId, - 'label' => $label, - 'sequence' => $sequence, - 'csrf_hash'=> $newHash, - ]); - } - - public function updateBatchAssignment() - { - if (strtolower($this->request->getMethod()) !== 'post') { - return $this->response->setStatusCode(405)->setJSON([ - 'success' => false, - 'error' => 'Method not allowed', - ]); - } - - $expenseId = (int) $this->request->getPost('expense_id'); - $batchIdRaw = $this->request->getPost('batch_id'); - $batchId = (int) $batchIdRaw; - $fallbackBatchNumber = $this->request->getPost('batch_number'); - if ($batchId <= 0 && $fallbackBatchNumber !== null && trim((string) $fallbackBatchNumber) !== '') { - $batchId = (int) $fallbackBatchNumber; - } - $adminIdRaw = $this->request->getPost('admin_id'); - $adminId = ($adminIdRaw === '' || $adminIdRaw === null) ? null : (int) $adminIdRaw; - $reimbursementId = (int) $this->request->getPost('reimbursement_id') ?: null; - - if ($expenseId <= 0) { - return $this->response->setStatusCode(422)->setJSON([ - 'success' => false, - 'error' => 'Invalid expense id.', - ]); - } - - $now = date('Y-m-d H:i:s'); - - $activeItem = $this->batchItemModel - ->where('expense_id', $expenseId) - ->where('unassigned_at IS NULL', null, false) - ->first(); - - if ($batchId <= 0) { - if ($activeItem) { - $this->batchItemModel->update((int) $activeItem['id'], ['unassigned_at' => $now]); - if (!$reimbursementId && !empty($activeItem['reimbursement_id'])) { - $reimbursementId = (int) $activeItem['reimbursement_id']; - } - } - - $newHash = function_exists('csrf_hash') ? csrf_hash() : null; - return $this->response - ->setHeader('X-CSRF-HASH', (string) $newHash) - ->setJSON([ - 'success' => true, - 'batch_id' => 0, - 'admin_id' => null, - 'reimbursement_id' => $reimbursementId, - 'csrf_hash' => $newHash, - ]); - } - - $batch = $this->batchModel->find($batchId); - if (!$batch || strtolower((string) ($batch['status'] ?? 'open')) !== 'open') { - return $this->response->setStatusCode(404)->setJSON([ - 'success' => false, - 'error' => 'Batch not found or already closed.', - ]); - } - - $currentAdmin = $activeItem ? (int) ($activeItem['admin_id'] ?? 0) : null; - $activeSameBatch = $activeItem && (int) ($activeItem['batch_id'] ?? 0) === $batchId; - if ($activeSameBatch && ($currentAdmin === ($adminId ?? 0))) { - $newHash = function_exists('csrf_hash') ? csrf_hash() : null; - return $this->response - ->setHeader('X-CSRF-HASH', (string) $newHash) - ->setJSON([ - 'success' => true, - 'batch_id' => $batchId, - 'admin_id' => $adminId, - 'reimbursement_id' => $activeItem['reimbursement_id'] ?? null, - 'csrf_hash' => $newHash, - ]); - } - - if ($activeSameBatch) { - if (!$reimbursementId) { - $reimbursementId = $activeItem['reimbursement_id'] ? (int) $activeItem['reimbursement_id'] : $this->lookupReimbursementId($expenseId); - } - $updatePayload = [ - 'admin_id' => $adminId, - 'assigned_at' => $now, - 'reimbursement_id' => $reimbursementId, - 'unassigned_at' => $adminId === null ? $now : null, - ]; - $this->batchItemModel->update((int) $activeItem['id'], $updatePayload); - $newHash = function_exists('csrf_hash') ? csrf_hash() : null; - return $this->response - ->setHeader('X-CSRF-HASH', (string) $newHash) - ->setJSON([ - 'success' => true, - 'batch_id' => $batchId, - 'admin_id' => $adminId, - 'reimbursement_id' => $reimbursementId, - 'csrf_hash' => $newHash, - ]); - } - - if ($activeItem) { - $this->batchItemModel->update((int) $activeItem['id'], ['unassigned_at' => $now]); - if (!$reimbursementId && !empty($activeItem['reimbursement_id'])) { - $reimbursementId = (int) $activeItem['reimbursement_id']; - } - } - - if (!$reimbursementId) { - $reimbursementId = $this->lookupReimbursementId($expenseId); - } - - $insertData = [ - 'batch_id' => $batchId, - 'expense_id' => $expenseId, - 'reimbursement_id' => $reimbursementId, - 'admin_id' => $adminId, - 'assigned_at' => $now, - 'school_year' => $this->schoolYear, - 'semester' => $this->semester, - ]; - - try { - $this->batchItemModel->insert($insertData); - } catch (\Throwable $e) { - log_message('error', 'Failed to assign expense #{expense} to batch #{batch}: {msg}', [ - 'expense' => $expenseId, - 'batch' => $batchId, - 'msg' => $e->getMessage(), - ]); - return $this->response->setStatusCode(500)->setJSON([ - 'success' => false, - 'error' => 'Unable to update batch assignment right now.', - ]); - } - - $newHash = function_exists('csrf_hash') ? csrf_hash() : null; - - return $this->response - ->setHeader('X-CSRF-HASH', (string) $newHash) - ->setJSON([ - 'success' => true, - 'batch_id' => $batchId, - 'admin_id' => $adminId, - 'reimbursement_id' => $reimbursementId, - 'csrf_hash' => $newHash, - ]); - } - - public function lockBatch() - { - if (strtolower($this->request->getMethod()) !== 'post') { - return $this->response->setStatusCode(405)->setJSON([ - 'success' => false, - 'error' => 'Method not allowed', - ]); - } - - $batchId = (int) $this->request->getPost('batch_id'); - if ($batchId <= 0) { - return $this->response->setStatusCode(422)->setJSON([ - 'success' => false, - 'error' => 'Invalid batch id.', - ]); - } - - $batch = $this->batchModel->find($batchId); - if (!$batch || strtolower((string) ($batch['status'] ?? '')) !== 'open') { - return $this->response->setStatusCode(404)->setJSON([ - 'success' => false, - 'error' => 'Batch not found or already closed.', - ]); - } - - $adminRows = $this->batchItemModel - ->select('DISTINCT COALESCE(admin_id, 0) AS admin_id') - ->where('batch_id', $batchId) - ->where('unassigned_at IS NULL', null, false) - ->get() - ->getResultArray(); - $adminIds = array_values(array_unique(array_map(static fn ($row) => isset($row['admin_id']) ? (int) $row['admin_id'] : 0, $adminRows))); - $requiredAdmins = array_filter($adminIds, static fn ($adminId) => $adminId > 0); - if (!empty($requiredAdmins)) { - $uploadedFiles = $this->batchAdminFileModel - ->select('admin_id') - ->where('batch_id', $batchId) - ->whereIn('admin_id', $requiredAdmins) - ->findAll(); - $uploadedAdminIds = array_values(array_unique(array_map(static fn ($file) => isset($file['admin_id']) ? (int) $file['admin_id'] : 0, $uploadedFiles))); - foreach ($requiredAdmins as $adminId) { - if (!in_array($adminId, $uploadedAdminIds, true)) { - return $this->response->setStatusCode(409)->setJSON([ - 'success' => false, - 'error' => 'All admin sections must have a check file before submitting the batch.', - ]); - } - } - } - - $this->db->transBegin(); - - // Create reimbursement records for any batch items that don't yet have one - $items = $this->db->table('reimbursement_batch_items bi') - ->select('bi.id AS batch_item_id, bi.reimbursement_id AS batch_reimb_id, bi.expense_id, e.amount, e.purchased_by, e.description, e.reimbursement_id AS expense_reimb_id, e.school_year AS expense_school_year, e.semester AS expense_semester') - ->join('expenses e', 'e.id = bi.expense_id', 'inner') - ->where('bi.batch_id', $batchId) - ->where('bi.unassigned_at IS NULL', null, false) - ->get() - ->getResultArray(); - - $now = date('Y-m-d H:i:s'); - $userId = (int) (session()->get('user_id') ?? 0); - - try { - foreach ($items as $item) { - $expenseId = (int) ($item['expense_id'] ?? 0); - $recipientId = (int) ($item['purchased_by'] ?? 0); - if ($expenseId <= 0 || $recipientId <= 0) { - continue; - } - - $reimbId = $item['batch_reimb_id'] ?: ($item['expense_reimb_id'] ?: $this->lookupReimbursementId($expenseId)); - if (!$reimbId) { - $payload = [ - 'expense_id' => $expenseId, - 'amount' => (float) ($item['amount'] ?? 0), - 'reimbursed_to' => $recipientId, - 'approved_by' => $userId ?: null, - 'description' => trim((string) ($item['description'] ?? '')), - 'status' => 'Paid', - 'added_by' => $userId ?: null, - 'school_year' => $item['expense_school_year'] ?: $this->schoolYear, - 'semester' => $item['expense_semester'] ?: $this->semester, - 'reimbursement_method' => 'Check', - 'batch_number' => $batchId, - 'created_at' => $now, - 'updated_at' => $now, - ]; - $reimbId = $this->reimbModel->insert($payload); - } - - if ($reimbId) { - $this->reimbModel->update($reimbId, [ - 'batch_number' => $batchId, - 'approved_by' => $userId ?: null, - 'status' => 'Paid', - ]); - $this->expenseModel->update($expenseId, ['reimbursement_id' => $reimbId]); - if (!empty($item['batch_item_id'])) { - $this->batchItemModel->update((int) $item['batch_item_id'], ['reimbursement_id' => $reimbId]); - } - } - } - - $update = [ - 'status' => 'closed', - 'closed_at' => $now, - ]; - - if ($userId > 0) { - $update['closed_by'] = $userId; - } - - $this->batchModel->update($batchId, $update); - } catch (\Throwable $e) { - $this->db->transRollback(); - log_message('error', 'Failed to lock reimbursement batch #{batch}: {msg}', [ - 'batch' => $batchId, - 'msg' => $e->getMessage(), - ]); - return $this->response->setStatusCode(500)->setJSON([ - 'success' => false, - 'error' => 'Unable to lock batch right now.', - ]); - } - - if (!$this->db->transStatus()) { - $this->db->transRollback(); - return $this->response->setStatusCode(500)->setJSON([ - 'success' => false, - 'error' => 'Unable to lock batch right now.', - ]); - } - $this->db->transCommit(); - - $newHash = function_exists('csrf_hash') ? csrf_hash() : null; - return $this->response - ->setHeader('X-CSRF-HASH', (string) $newHash) - ->setJSON([ - 'success' => true, - 'batch_id' => $batchId, - 'status' => 'closed', - 'csrf_hash'=> $newHash, - ]); - } - - public function uploadBatchAdminFile() - { - if (strtolower($this->request->getMethod()) !== 'post') { - return $this->response->setStatusCode(405)->setJSON([ - 'success' => false, - 'error' => 'Method not allowed', - ]); - } - - $batchId = (int) $this->request->getPost('batch_id'); - $adminId = (int) $this->request->getPost('admin_id'); - $file = $this->request->getFile('check_file'); - - if ($batchId <= 0 || $adminId < 0) { - return $this->response->setStatusCode(422)->setJSON([ - 'success' => false, - 'error' => 'Invalid batch or admin specified.', - ]); - } - - if (!$file instanceof UploadedFile || !$file->isValid()) { - return $this->response->setStatusCode(422)->setJSON([ - 'success' => false, - 'error' => 'Please provide a valid file.', - ]); - } - - $batch = $this->batchModel->find($batchId); - if (!$batch) { - return $this->response->setStatusCode(404)->setJSON([ - 'success' => false, - 'error' => 'Batch not found.', - ]); - } - if (strtolower((string) ($batch['status'] ?? '')) !== 'open') { - return $this->response->setStatusCode(409)->setJSON([ - 'success' => false, - 'error' => 'Cannot upload files for a locked batch.', - ]); - } - - $uploadDir = $this->ensureUploadSubdir('reimbursements'); - $newName = $file->getRandomName(); - try { - $file->move($uploadDir, $newName); - } catch (\Throwable $e) { - log_message('error', 'Failed to save batch check file: {msg}', ['msg' => $e->getMessage()]); - return $this->response->setStatusCode(500)->setJSON([ - 'success' => false, - 'error' => 'Unable to store the uploaded file.', - ]); - } - - $existing = $this->batchAdminFileModel - ->where('batch_id', $batchId) - ->where('admin_id', $adminId) - ->first(); - if ($existing && !empty($existing['filename'])) { - $oldFile = $uploadDir . DIRECTORY_SEPARATOR . $existing['filename']; - if (is_file($oldFile)) { - @unlink($oldFile); - } - } - - $now = date('Y-m-d H:i:s'); - $userId = (int) (session()->get('user_id') ?? 0); - $payload = [ - 'batch_id' => $batchId, - 'admin_id' => $adminId, - 'filename' => $newName, - 'original_filename'=> $file->getClientName(), - 'uploaded_at' => $now, - 'uploaded_by' => $userId ?: null, - ]; - - if ($existing) { - $this->batchAdminFileModel->update((int) $existing['id'], $payload); - } else { - $this->batchAdminFileModel->insert($payload); - } - - $newHash = function_exists('csrf_hash') ? csrf_hash() : null; - return $this->response - ->setHeader('X-CSRF-HASH', (string) $newHash) - ->setJSON([ - 'success' => true, - 'batch_id' => $batchId, - 'admin_id' => $adminId, - 'filename' => $newName, - 'original_filename' => $file->getClientName(), - 'url' => base_url('reimbursements/batch/admin-file/' . rawurlencode($newName) . '/inline'), - 'csrf_hash' => $newHash, - ]); - } - - public function serveAdminCheckFile(string $filename, string $mode = 'inline') - { - $safeName = basename(trim($filename)); - if ($safeName === '') { - throw new PageNotFoundException('File not specified.'); - } - - $path = $this->ensureUploadSubdir('reimbursements') . DIRECTORY_SEPARATOR . $safeName; - if (!is_file($path)) { - throw new PageNotFoundException('File not found.'); - } - - $mime = mime_content_type($path) ?: 'application/octet-stream'; - $disposition = strtolower(trim((string) $mode)) === 'download' ? 'attachment' : 'inline'; - - // Use CI's download helper to ensure correct headers/output; override to allow inline display - return $this->response - ->download($path, null) - ->setFileName($safeName) - ->setHeader('Content-Type', $mime) - ->setHeader('Content-Disposition', sprintf('%s; filename="%s"', $disposition, $safeName)); - } - - private function expenseReceiptUrl(?string $filename): ?string - { - if (!$filename) return null; - $safe = basename(trim($filename)); - return $safe !== '' ? site_url('receipts/' . $safe) : null; - } - - private function reimbReceiptUrl(?string $filename): ?string - { - if (!$filename) return null; - $safe = basename(trim($filename)); - return $safe !== '' ? site_url('reimbreceipts/' . $safe) : null; - } - - private function sanitizeAttachmentComponent(string $value): string - { - $clean = preg_replace('/[^A-Za-z0-9]+/', '_', trim((string) $value)); - $clean = trim($clean, '_'); - return $clean === '' ? 'unknown' : $clean; - } - - private function formatAttachmentDate(?string $value): string - { - if (!$value) { - return 'unknown'; - } - $timestamp = strtotime($value); - if ($timestamp === false) { - return 'unknown'; - } - return date('Ymd', $timestamp); - } - - private function uniqueZipEntryName(string $base, string $extension, array &$used): string - { - $candidate = $base . $extension; - $counter = 1; - while (isset($used[$candidate])) { - $candidate = $base . '_' . $counter . $extension; - $counter++; - } - $used[$candidate] = true; - return $candidate; - } - - private function formatCsvDate(?string $value): string - { - if (!$value) { - return ''; - } - $timestamp = strtotime($value); - if ($timestamp === false) { - return ''; - } - return date('m-d-Y', $timestamp); - } - - private function fetchBatchReceiptRows(int $batchId, array $receiptIds = []): array - { - $builder = $this->db->table('reimbursement_batch_items bi'); - $builder->select('e.id AS expense_id, e.retailor, e.amount, e.description, e.receipt_path AS receipt_filename, e.date_of_purchase AS purchase_date, u.firstname AS purchaser_firstname, u.lastname AS purchaser_lastname'); - $builder->join('expenses e', 'e.id = bi.expense_id', 'inner'); - $builder->join('users u', 'u.id = e.purchased_by', 'left'); - $builder->where('bi.batch_id', $batchId); - if (!empty($receiptIds)) { - $builder->whereIn('e.id', $receiptIds); - } - $rows = $builder->get()->getResultArray(); - return array_values(array_filter($rows, static function ($row) { - return !empty($row['receipt_filename']); - })); - } - - private function writeBatchCsvMetadata($handle, string $batchLabel, array $batch): void - { - $closedAtRaw = $batch['closed_at'] ?? null; - fputcsv($handle, ['Batch Title', $batchLabel]); - fputcsv($handle, ['School Year', $batch['school_year'] ?? '', 'Semester', $batch['semester'] ?? '', 'Closed At', $this->formatCsvDate($closedAtRaw)]); - fputcsv($handle, []); - fputcsv($handle, ['Amount', 'Credit', 'Paid', 'Balance', 'Date', 'By Who', 'Vendor', 'Description']); - } - - private function writeBatchCsvSummary($handle, array $receiptRows, string $totalsDate = ''): void - { - $receiptTotalsByRecipient = []; - foreach ($receiptRows as $row) { - $recipientName = trim(($row['purchaser_firstname'] ?? '') . ' ' . ($row['purchaser_lastname'] ?? '')); - if ($recipientName === '') { - $recipientName = 'Unknown'; - } - $vendor = trim((string) ($row['retailor'] ?? '')); - if ($vendor === '') { - $vendor = 'Vendor N/A'; - } - $dateText = $this->formatCsvDate($row['purchase_date'] ?? null); - $descriptionValue = trim((string) ($row['description'] ?? '')); - $descriptionParts = $descriptionValue !== '' ? [$descriptionValue] : ['Receipt']; - $amountValue = (float) ($row['amount'] ?? 0); - $receiptTotalsByRecipient[$recipientName] = ($receiptTotalsByRecipient[$recipientName] ?? 0.0) + $amountValue; - fputcsv($handle, [ - number_format($amountValue, 2, '.', ''), - '', - number_format($amountValue, 2, '.', ''), - '0.00', - $dateText, - $recipientName, - $vendor, - implode(' | ', $descriptionParts), - ]); - } - if (!empty($receiptTotalsByRecipient)) { - fputcsv($handle, []); - fputcsv($handle, ['Totals by Recipient', '', '', '', '', '', '', '']); - foreach ($receiptTotalsByRecipient as $recipient => $total) { - $formatted = number_format($total, 2, '.', ''); - fputcsv($handle, [ - $formatted, - '', - $formatted, - '0.00', - $totalsDate, - $recipient, - '', - '', - ]); - } - } - } - - private function parseAttachmentIds($raw): array - { - if (is_array($raw)) { - $decoded = $raw; - } else { - $decoded = json_decode((string) $raw, true); - } - if (!is_array($decoded)) { - return []; - } - $ids = []; - foreach ($decoded as $entry) { - $id = (int) ($entry ?? 0); - if ($id > 0) { - $ids[$id] = $id; - } - } - return array_values($ids); - } - - public function index() - { - $semester = $this->request->getGet('semester') ?: null; - $status = $this->request->getGet('status') ?: null; - $filterYear = $this->request->getGet('school_year') ?: $this->schoolYear; - $userId = $this->request->getGet('user_id') ?: null; - - $filters = [ - 'school_year' => $filterYear, - 'semester' => $semester, - 'status' => $status, // e.g., 'Paid' - 'user_id' => $userId, - ]; - - $expenses = $this->expenseModel - ->getReimbursedExpensesWithDetails($filters) - ->get() - ->getResultArray(); - - // Build closed batch summaries/details (all closed batches) - $batchSummaries = []; - $batchDetails = []; - $batchQuery = $this->db->table('reimbursement_batches b') - ->select(" - b.id AS batch_id, - b.title AS batch_title, - b.yearly_batch_number, - b.closed_at, - b.school_year AS batch_school_year, - e.id AS expense_id, - e.amount AS expense_amount, - e.description, - e.retailor, - e.receipt_path AS expense_receipt, - e.date_of_purchase AS expense_date_of_purchase, - e.purchased_by, - u.firstname AS purchaser_firstname, - u.lastname AS purchaser_lastname, - r.amount AS reimb_amount - ") - ->join('reimbursement_batch_items bi', 'bi.batch_id = b.id', 'inner') - ->join('expenses e', 'e.id = bi.expense_id', 'inner') - ->join('users u', 'u.id = e.purchased_by', 'left') - ->join('reimbursements r', 'r.expense_id = e.id', 'left') - ->where('b.status', 'closed') - ->where('bi.unassigned_at IS NULL', null, false); - - if (!empty($filterYear)) { - $batchQuery->groupStart() - ->where('b.school_year', $filterYear) - ->orWhere('e.school_year', $filterYear) - ->groupEnd(); - } - if (!empty($userId)) { - $batchQuery->where('e.purchased_by', $userId); - } - - $batchRows = $batchQuery->get()->getResultArray(); - $batchAttachments = []; - foreach ($batchRows as $row) { - $bid = (int) ($row['batch_id'] ?? 0); - if ($bid <= 0) { - continue; - } - $labelNumber = (int) ($row['yearly_batch_number'] ?? $bid); - if (!isset($batchSummaries[$bid])) { - $batchSummaries[$bid] = [ - 'batch_id' => $bid, - 'title' => trim((string) ($row['batch_title'] ?? '')) ?: ('Batch #' . $labelNumber), - 'sequence' => $labelNumber, - 'closed_at' => $row['closed_at'] ?? null, - 'amount' => 0.0, - 'items' => 0, - ]; - } - $amount = $row['reimb_amount'] ?? $row['expense_amount'] ?? 0; - $batchSummaries[$bid]['amount'] += (float) $amount; - $batchSummaries[$bid]['items'] += 1; - - if (!isset($batchDetails[$bid])) { - $batchDetails[$bid] = [ - 'title' => $batchSummaries[$bid]['title'], - 'sequence' => $labelNumber, - 'closed_at' => $batchSummaries[$bid]['closed_at'], - 'items' => [], - 'checks' => [], - ]; - } - if (!isset($batchAttachments[$bid])) { - $batchAttachments[$bid] = [ - 'receipts' => [], - 'checks' => [], - ]; - } - $batchDetails[$bid]['items'][] = [ - 'amount' => (float) $amount, - 'description' => $row['description'] ?? '', - 'vendor' => $row['retailor'] ?? '', - 'purchased_by' => trim(($row['purchaser_firstname'] ?? '') . ' ' . ($row['purchaser_lastname'] ?? '')), - 'receipt_url' => $this->expenseReceiptUrl($row['expense_receipt'] ?? null), - ]; - $receiptFilename = trim((string) ($row['expense_receipt'] ?? '')); - if ($receiptFilename !== '') { - $batchAttachments[$bid]['receipts'][] = [ - 'expense_id' => (int) ($row['expense_id'] ?? 0), - 'receipt_filename' => $receiptFilename, - 'receipt_url' => $this->expenseReceiptUrl($receiptFilename), - 'purchaser_firstname' => $row['purchaser_firstname'] ?? '', - 'purchaser_lastname' => $row['purchaser_lastname'] ?? '', - 'vendor' => $row['retailor'] ?? '', - 'description' => $row['description'] ?? '', - 'amount' => (float) ($row['expense_amount'] ?? 0), - 'date_of_purchase' => $row['expense_date_of_purchase'] ?? '', - ]; - } - } - - // Attach uploaded admin check files for all closed batches - if (!empty($batchSummaries)) { - $batchIds = array_keys($batchSummaries); - $files = $this->batchAdminFileModel - ->select('id AS file_id, batch_id, admin_id, filename, original_filename, uploaded_at') - ->whereIn('batch_id', $batchIds) - ->findAll(); - $adminNames = $this->adminRoster(); - $adminNameMap = []; - foreach ($adminNames as $admin) { - $adminNameMap[(int) $admin['id']] = trim(($admin['firstname'] ?? '') . ' ' . ($admin['lastname'] ?? '')); - } - foreach ($files as $file) { - $bid = (int) ($file['batch_id'] ?? 0); - if ($bid <= 0 || !isset($batchDetails[$bid])) { - continue; - } - $aid = (int) ($file['admin_id'] ?? 0); - $batchDetails[$bid]['checks'][] = [ - 'admin' => $adminNameMap[$aid] ?? ($aid > 0 ? 'Admin #' . $aid : 'Unassigned'), - 'filename' => $file['filename'], - 'original' => $file['original_filename'] ?? $file['filename'], - 'url' => base_url('reimbursements/batch/admin-file/' . rawurlencode($file['filename']) . '/inline'), - ]; - if (!isset($batchAttachments[$bid])) { - $batchAttachments[$bid] = [ - 'receipts' => [], - 'checks' => [], - ]; - } - $batchAttachments[$bid]['checks'][] = [ - 'file_id' => (int) ($file['file_id'] ?? 0), - 'filename' => $file['filename'], - 'original_filename' => $file['original_filename'] ?? $file['filename'], - 'admin' => $adminNameMap[$aid] ?? ($aid > 0 ? 'Admin #' . $aid : 'Unassigned'), - 'url' => base_url('reimbursements/batch/admin-file/' . rawurlencode($file['filename']) . '/inline'), - 'uploaded_at' => $file['uploaded_at'] ?? null, - ]; - } - } - - if (!empty($batchSummaries)) { - $batchSummaries = array_values($batchSummaries); - usort($batchSummaries, static function ($a, $b) { - $aSeq = (int) ($a['sequence'] ?? 0); - $bSeq = (int) ($b['sequence'] ?? 0); - return $bSeq <=> $aSeq; - }); - } - - $donationBatch = null; - $donationDetails = [ - 'items' => [], - 'checks' => [], - ]; - $donationQuery = $this->db->table('expenses e') - ->select(' - e.id AS expense_id, - e.amount AS expense_amount, - e.description, - e.retailor, - e.receipt_path AS expense_receipt, - e.purchased_by, - e.school_year, - e.semester, - e.status, - u.firstname AS purchaser_firstname, - u.lastname AS purchaser_lastname - ') - ->join('users u', 'u.id = e.purchased_by', 'left') - ->where('e.category', 'Donation'); - - if (!empty($filterYear)) { - $donationQuery->where('e.school_year', $filterYear); - } - if (!empty($semester)) { - $donationQuery->where('e.semester', $semester); - } - if (!empty($userId)) { - $donationQuery->where('e.purchased_by', $userId); - } - if (!empty($status)) { - $donationQuery->groupStart() - ->where('e.status', $status) - ->orWhere('e.status', strtolower($status)) - ->orWhere('e.status', strtoupper($status)) - ->groupEnd(); - } - - $donationRows = $donationQuery->orderBy('e.created_at', 'DESC')->get()->getResultArray(); - if (!empty($donationRows)) { - $donationTotal = 0.0; - foreach ($donationRows as $row) { - $amount = (float) ($row['expense_amount'] ?? 0); - $donationTotal += $amount; - $purchaser = trim(($row['purchaser_firstname'] ?? '') . ' ' . ($row['purchaser_lastname'] ?? '')); - if ($purchaser === '') { - $purchaser = 'Unknown'; - } - $donationDetails['items'][] = [ - 'amount' => $amount, - 'description' => $row['description'] ?? '', - 'vendor' => $row['retailor'] ?? '', - 'purchased_by' => $purchaser, - 'receipt_url' => $this->expenseReceiptUrl($row['expense_receipt'] ?? null), - ]; - } - - $donationBatch = [ - 'title' => 'Donations', - 'items' => count($donationDetails['items']), - 'amount' => $donationTotal, - ]; - } - - foreach ($expenses as &$e) { - if (array_key_exists('expense_receipt', $e)) { - $e['expense_receipt_url'] = $this->expenseReceiptUrl($e['expense_receipt']); - } elseif (array_key_exists('receipt_path', $e)) { - $e['expense_receipt_url'] = $this->expenseReceiptUrl($e['receipt_path']); - } - - if (array_key_exists('reimb_receipt', $e)) { - $e['reimb_receipt_url'] = $this->reimbReceiptUrl($e['reimb_receipt']); - } elseif (array_key_exists('receipt_path_reimb', $e)) { - $e['reimb_receipt_url'] = $this->reimbReceiptUrl($e['receipt_path_reimb']); - } - - $recipientId = $e['reimb_recipient_id'] ?? ($e['reimbursed_to'] ?? null); - $hasName = trim(($e['reimb_firstname'] ?? '') . ' ' . ($e['reimb_lastname'] ?? '')) !== ''; - if (!$hasName) { - $label = $this->specialRecipientLabel($recipientId); - if ($label !== null) { - $e['reimb_firstname'] = $label; - $e['reimb_lastname'] = ''; - } - } - } - unset($e); - - $users = $this->recipientOptions(); - - $years = $this->db->table('reimbursements') - ->select('school_year') - ->distinct() - ->orderBy('school_year', 'DESC') - ->get() - ->getResultArray(); - $schoolYears = array_column($years, 'school_year'); - - // Add school years from fallback expenses (closed batches without reimbursements) - if (!empty($fallbackRows)) { - $fallbackYears = array_values(array_unique(array_filter(array_column($fallbackRows, 'school_year')))); - $schoolYears = array_values(array_unique(array_merge($schoolYears, $fallbackYears))); - rsort($schoolYears); - } - - return view('reimbursements/index', [ - 'expenses' => $expenses, - 'users' => $users, - 'schoolYears' => $schoolYears, - 'batchSummaries' => $batchSummaries, - 'batchDetails' => $batchDetails, - 'batchAttachments' => $batchAttachments, - 'donationBatch' => $donationBatch, - 'donationDetails' => $donationDetails, - ]); - } - - public function sendBatchEmail() - { - $batchId = (int) ($this->request->getPost('batch_id') ?? 0); - if ($batchId <= 0) { - return $this->response->setStatusCode(400)->setJSON([ - 'success' => false, - 'error' => 'Batch not specified.', - ]); - } - - $recipientEmail = trim((string) ($this->request->getPost('recipient_email') ?? '')); - if ($recipientEmail === '' || !filter_var($recipientEmail, FILTER_VALIDATE_EMAIL)) { - return $this->response->setStatusCode(422)->setJSON([ - 'success' => false, - 'error' => 'Please provide a valid recipient email address.', - ]); - } - - $message = trim((string) ($this->request->getPost('message') ?? '')); - $receiptIds = $this->parseAttachmentIds($this->request->getPost('receipts')); - $checkIds = $this->parseAttachmentIds($this->request->getPost('checks')); - - $batch = $this->batchModel->find($batchId); - if (!$batch) { - return $this->response->setStatusCode(404)->setJSON([ - 'success' => false, - 'error' => 'Requested batch was not found.', - ]); - } - - $receiptRows = !empty($receiptIds) ? $this->fetchBatchReceiptRows($batchId, $receiptIds) : []; - - $checkRows = []; - if (!empty($checkIds)) { - $checkRows = $this->batchAdminFileModel - ->select('id AS file_id, admin_id, filename, original_filename, uploaded_at') - ->where('batch_id', $batchId) - ->whereIn('id', $checkIds) - ->findAll(); - $adminLookup = []; - foreach ($this->adminRoster() as $admin) { - $adminLookup[(int) ($admin['id'] ?? 0)] = trim((string) (($admin['firstname'] ?? '') . ' ' . ($admin['lastname'] ?? ''))); - } - foreach ($checkRows as &$row) { - $aid = (int) ($row['admin_id'] ?? 0); - $row['admin'] = $adminLookup[$aid] ?? ($aid > 0 ? 'Admin #' . $aid : 'Admin'); - } - unset($row); - } - - $tempDir = WRITEPATH . 'tmp'; - if (!is_dir($tempDir)) { - mkdir($tempDir, 0755, true); - } - $cleanupPaths = []; - - $batchLabel = trim((string) ($batch['title'] ?? '')) ?: ('Batch #' . ((int) ($batch['yearly_batch_number'] ?? $batchId))); - $batchFileLabel = $this->sanitizeAttachmentComponent($batchLabel); - - $csvPath = tempnam($tempDir, 'batch_csv_'); - $cleanupPaths[] = $csvPath; - $csvHandle = fopen($csvPath, 'w'); - if (!$csvHandle) { - if (is_file($csvPath)) { - @unlink($csvPath); - } - return $this->response->setStatusCode(500)->setJSON([ - 'success' => false, - 'error' => 'Unable to create the summary file.', - ]); - } - $batchClosedDate = $this->formatCsvDate($batch['closed_at'] ?? null); - $this->writeBatchCsvMetadata($csvHandle, $batchLabel, $batch); - $this->writeBatchCsvSummary($csvHandle, $receiptRows, $batchClosedDate); - fclose($csvHandle); - - $csvAttachmentName = $batchFileLabel . '_summary.csv'; - - $receiptsArchivePath = null; - $receiptZipCreated = false; - if (!empty($receiptRows)) { - $archivePath = $tempDir . '/batch_receipts_' . $batchId . '_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.zip'; - $zip = new ZipArchive(); - $added = 0; - $usedNames = []; - if ($zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) { - foreach ($receiptRows as $row) { - $sourcePath = WRITEPATH . 'uploads/receipts/' . basename((string) ($row['receipt_filename'] ?? '')); - if (!is_file($sourcePath)) { - continue; - } - $firstName = $row['purchaser_firstname'] ?? ''; - if (trim($firstName) === '' && !empty($row['purchaser_lastname'] ?? '')) { - $firstName = $row['purchaser_lastname']; - } - $firstName = $this->sanitizeAttachmentComponent($firstName); - $vendorPart = $this->sanitizeAttachmentComponent($row['retailor'] ?? ''); - $datePart = $this->formatAttachmentDate($row['purchase_date'] ?? ''); - $extension = pathinfo($sourcePath, PATHINFO_EXTENSION); - $extPart = $extension !== '' ? ('.' . strtolower($extension)) : ''; - $baseName = $firstName . '_' . $vendorPart . '_' . $datePart; - $entryName = $this->uniqueZipEntryName($baseName, $extPart, $usedNames); - if ($zip->addFile($sourcePath, $entryName)) { - $added++; - } - } - $zip->close(); - } - if ($added > 0 && is_file($archivePath)) { - $receiptsArchivePath = $archivePath; - $cleanupPaths[] = $archivePath; - $receiptZipCreated = true; - } elseif (is_file($archivePath)) { - @unlink($archivePath); - } - } - - $checksArchivePath = null; - $checksAdded = 0; - if (!empty($checkRows)) { - $archivePath = $tempDir . '/batch_checks_' . $batchId . '_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.zip'; - $zip = new ZipArchive(); - $usedNames = []; - if ($zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) { - foreach ($checkRows as $row) { - $sourcePath = WRITEPATH . 'uploads/reimbursements/' . basename((string) ($row['filename'] ?? '')); - if (!is_file($sourcePath)) { - continue; - } - $adminLabel = $row['admin'] ?? 'Admin'; - $firstToken = strtok($adminLabel, ' '); - $namePart = $this->sanitizeAttachmentComponent($firstToken ?: $adminLabel); - $extension = pathinfo($sourcePath, PATHINFO_EXTENSION); - $extPart = $extension !== '' ? ('.' . strtolower($extension)) : ''; - $entryName = $this->uniqueZipEntryName($namePart, $extPart, $usedNames); - if ($zip->addFile($sourcePath, $entryName)) { - $checksAdded++; - } - } - $zip->close(); - } - if ($checksAdded > 0 && is_file($archivePath)) { - $checksArchivePath = $archivePath; - $cleanupPaths[] = $archivePath; - } elseif (is_file($archivePath)) { - @unlink($archivePath); - } - } - - $attachmentSummary = ['CSV Summary']; - if ($receiptZipCreated) { - $attachmentSummary[] = 'Receipt Files'; - } - if ($checksArchivePath) { - $attachmentSummary[] = 'Check Scans'; - } - - $subject = 'Reimbursement Batch Export: ' . $batchLabel; - $bodyParts = [ - '

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

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

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

'; - } - $bodyParts[] = '

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

'; - $bodyParts[] = '

Regards,
Al Rahma Sunday School

'; - $htmlBody = implode("\n", $bodyParts); - - $attachments = [ - ['path' => $csvPath, 'name' => $csvAttachmentName], - ]; - if ($receiptsArchivePath) { - $attachments[] = ['path' => $receiptsArchivePath, 'name' => $batchFileLabel . '_receipts.zip']; - } - if ($checksArchivePath) { - $attachments[] = ['path' => $checksArchivePath, 'name' => $batchFileLabel . '_checks.zip']; - } - - $responseBody = [ - 'success' => true, - 'message' => 'Batch export emailed.', - ]; - $statusCode = 200; - try { - $mailer = new EmailService(); - if (!$mailer->send($recipientEmail, $subject, $htmlBody, 'general', $attachments)) { - throw new \RuntimeException('EmailService failed to send the batch export.'); - } - $responseBody['message'] = 'Email sent successfully.'; - } catch (\Throwable $e) { - log_message('error', 'Failed to send batch export email: {msg}', ['msg' => $e->getMessage()]); - $statusCode = 500; - $responseBody = [ - 'success' => false, - 'error' => 'Failed to send email. Please try again.', - ]; - } finally { - foreach ($cleanupPaths as $path) { - if ($path && is_file($path)) { - @unlink($path); - } - } - } - - return $this->response->setStatusCode($statusCode)->setJSON($responseBody); - } - - public function export() - { - $type = $this->request->getGet('type'); // 'processed' or 'under_processing' - - if ($type === 'under_processing') { - // Approved expenses not yet reimbursed - $builder = $this->db->table('expenses'); - $builder->select('expenses.*, u.firstname AS purchaser_firstname, u.lastname AS purchaser_lastname'); - $builder->join('users u', 'u.id = expenses.purchased_by', 'left'); - $builder->where('expenses.status', 'approved'); // <-- fixed - $builder->where('expenses.reimbursement_id IS NULL', null, false); - $builder->where('expenses.category !=', 'Donation'); - - $records = $builder->orderBy('expenses.created_at', 'DESC')->get()->getResultArray(); - - // Group rows by purchaser so each person appears once in the CSV - $grouped = []; - foreach ($records as $row) { - $personId = (int)($row['purchased_by'] ?? 0); - $name = trim(($row['purchaser_firstname'] ?? '') . ' ' . ($row['purchaser_lastname'] ?? '')); - if ($name === '') { - $name = $row['purchaser_firstname'] ?? $row['purchaser_lastname'] ?? 'Unknown'; - } - $key = $personId > 0 ? 'id_' . $personId : 'name_' . strtolower($name); - if (!isset($grouped[$key])) { - $grouped[$key] = [ - 'name' => $name ?: 'Unknown', - 'total' => 0.0, - 'vendors' => [], - 'descriptions'=> [], - ]; - } - - $amount = (float)($row['amount'] ?? 0); - $grouped[$key]['total'] += $amount; - - $vendor = trim((string)($row['retailor'] ?? '')); - if ($vendor !== '' && !in_array($vendor, $grouped[$key]['vendors'], true)) { - $grouped[$key]['vendors'][] = $vendor; - } - - $desc = trim((string)($row['description'] ?? '')); - if ($desc !== '' && !in_array($desc, $grouped[$key]['descriptions'], true)) { - $grouped[$key]['descriptions'][] = $desc; - } - } - - header('Content-Type: text/csv; charset=UTF-8'); - header('Content-Disposition: attachment; filename=under_processing_expenses.csv'); - $output = fopen('php://output', 'w'); - // UTF-8 BOM for Excel - fprintf($output, chr(0xEF) . chr(0xBB) . chr(0xBF)); - - fputcsv($output, ['Person', 'Total Amount', 'Vendors', 'Descriptions']); - foreach ($grouped as $entry) { - fputcsv($output, [ - $entry['name'], - number_format($entry['total'], 2, '.', ''), - implode('; ', $entry['vendors']), - implode('; ', $entry['descriptions']), - ]); - } - fclose($output); - exit; - } - - // Default: processed reimbursements (filterable) - $builder = $this->reimbModel - ->select('reimbursements.*, u.firstname AS reimb_firstname, u.lastname AS reimb_lastname') - ->join('users u', 'u.id = reimbursements.reimbursed_to', 'left'); - - if ($semester = $this->request->getGet('semester')) { - $builder->where('reimbursements.semester', $semester); - } - if ($status = $this->request->getGet('status')) { - $builder->where('reimbursements.status', $status); - } - if ($userId = $this->request->getGet('user_id')) { - $builder->where('reimbursements.reimbursed_to', $userId); - } - if ($schoolYear = $this->request->getGet('school_year')) { - $builder->where('reimbursements.school_year', $schoolYear); - } - - $records = $builder->orderBy('reimbursements.created_at', 'DESC')->get()->getResultArray(); - - foreach ($records as &$row) { - $hasName = trim(($row['reimb_firstname'] ?? '') . ' ' . ($row['reimb_lastname'] ?? '')) !== ''; - if (!$hasName) { - $label = $this->specialRecipientLabel($row['reimbursed_to'] ?? null); - if ($label !== null) { - $row['reimb_firstname'] = $label; - $row['reimb_lastname'] = ''; - } - } - } - unset($row); - - header('Content-Type: text/csv; charset=UTF-8'); - header('Content-Disposition: attachment; filename=reimbursements_export.csv'); - $output = fopen('php://output', 'w'); - // UTF-8 BOM for Excel - fprintf($output, chr(0xEF) . chr(0xBB) . chr(0xBF)); - - fputcsv($output, ['Amount', 'Reimbursed To', 'Status', 'Method', 'Check #', 'School Year', 'Semester', 'Date']); - foreach ($records as $r) { - fputcsv($output, [ - $r['amount'], - trim(($r['reimb_firstname'] ?? '') . ' ' . ($r['reimb_lastname'] ?? '')), - $r['status'], - $r['reimbursement_method'] ?? '-', - $r['check_number'] ?? '-', - $r['school_year'] ?? '-', - $r['semester'] ?? '-', - $r['created_at'], - ]); - } - fclose($output); - exit; - } - - public function exportBatch() - { - $batchId = (int) ($this->request->getGet('batch_id') ?? 0); - if ($batchId <= 0) { - return $this->response->setStatusCode(400)->setBody('Invalid batch specified.'); - } - - $batch = $this->batchModel->find($batchId); - if (!$batch) { - throw new PageNotFoundException('Batch not found.'); - } - - $batchLabel = trim((string) ($batch['title'] ?? '')) ?: ('Batch #' . $batchId); - $receiptRows = $this->fetchBatchReceiptRows($batchId); - - header('Content-Type: text/csv; charset=UTF-8'); - $filename = sprintf('batch_%d_items.csv', $batchId); - header('Content-Disposition: attachment; filename=' . $filename); - $output = fopen('php://output', 'w'); - // UTF-8 BOM for Excel - fprintf($output, chr(0xEF) . chr(0xBB) . chr(0xBF)); - - $batchClosedDate = $this->formatCsvDate($batch['closed_at'] ?? null); - $this->writeBatchCsvMetadata($output, $batchLabel, $batch); - $this->writeBatchCsvSummary($output, $receiptRows, $batchClosedDate); - - fclose($output); - exit; - } - - public function create() - { - $expenseId = $this->request->getGet('expense_id'); - - // users list (unchanged) - $users = $this->recipientOptions(); - - // fetch expense to prefill amount - $prefillAmount = null; - if ($expenseId) { - $exp = $this->expenseModel - ->select('id, amount') - ->find((int)$expenseId); - if ($exp) { - $prefillAmount = $exp['amount']; - } - } - - return view('reimbursements/create', [ - 'users' => $users, - 'expense_id' => $expenseId, - 'prefill_amount' => $prefillAmount, - ]); - } - - - public function store() - { - helper(['form']); - - // Normalize method just in case (e.g., "cash" → "Cash") - $methodRaw = (string) $this->request->getPost('reimbursement_method'); - $method = ucfirst(strtolower($methodRaw)); - $expenseId = (int) ($this->request->getPost('expense_id') ?? 0); - - // Base rules - $rules = [ - 'amount' => 'required|decimal|greater_than[0]', - 'reimbursed_to' => 'required|is_natural_no_zero', - 'reimbursement_method' => 'required|in_list[Cash,Check]', - ]; - - // Messages (used only when the corresponding rule is present) - $messages = [ - 'receipt' => [ - 'uploaded' => 'Receipt file is required.', - 'max_size' => 'Maximum file size is 2MB.', - 'ext_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.', - 'mime_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.', - ], - ]; - - // Conditional validation - if ($method === 'Check') { - // Require check number + receipt for checks - $rules['check_number'] = 'required|string|max_length[50]'; - $rules['receipt'] = 'uploaded[receipt]' - . '|max_size[receipt,2048]' - . '|ext_in[receipt,jpg,jpeg,png,webp,gif,pdf]' - . '|mime_in[receipt,image/jpg,image/jpeg,image/png,image/webp,image/gif,application/pdf]'; - } elseif ($method === 'Cash') { - // Receipt optional for cash; if provided, validate it - $file = $this->request->getFile('receipt'); - if ($file && $file->isValid() && ($file->getSize() ?? 0) > 0) { - $rules['receipt'] = 'max_size[receipt,2048]' - . '|ext_in[receipt,jpg,jpeg,png,webp,gif,pdf]' - . '|mime_in[receipt,image/jpg,image/jpeg,image/png,image/webp,image/gif,application/pdf]'; - } - } - - if (!$this->validate($rules, $messages)) { - return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); - } - - if ($expenseId > 0) { - $expense = $this->expenseModel->find($expenseId); - if ($expense && strcasecmp($expense['category'] ?? '', 'Donation') === 0) { - return redirect()->back()->withInput()->with('errors', [ - 'expense_id' => 'Donation expenses are tracked but should not be reimbursed.' - ]); - } - } - - // Store file only if one was actually uploaded - try { - $receiptName = $this->saveReimbReceipt($this->request->getFile('receipt')); - } catch (\Throwable $e) { - log_message('error', 'Failed to save reimbursement receipt in store(): {msg}', ['msg' => $e->getMessage()]); - return redirect()->back()->withInput()->with('errors', [ - 'receipt' => 'Failed to save uploaded file. Please try again or contact admin.' - ]); - } - - $userId = (int) (session()->get('user_id') ?? 0); - $recipientId = (int) $this->request->getPost('reimbursed_to'); - - // Mark reimbursement as Paid when recorded - $data = [ - 'expense_id' => $expenseId ?: null, - 'amount' => $this->request->getPost('amount'), - 'reimbursed_to' => $recipientId, - 'description' => $this->request->getPost('description'), - 'reimbursement_method' => $method, - 'check_number' => $method === 'Check' ? $this->request->getPost('check_number') : null, - 'receipt_path' => $receiptName, // may be null for Cash - 'school_year' => $this->schoolYear, - 'semester' => $this->semester, - 'added_by' => $userId, - 'approved_by' => $userId, - 'status' => 'Paid', - ]; - - $this->reimbModel->insert($data); - $reimbursementId = $this->reimbModel->getInsertID(); - - if ($expenseId = $this->request->getPost('expense_id')) { - $this->expenseModel->update($expenseId, ['reimbursement_id' => $reimbursementId]); - } - - return redirect()->to('/reimbursements')->with('success', 'Reimbursement recorded as Paid.'); - } - - - // Optional old flow kept for compatibility (also sets Paid) - public function process() - { - $expenseId = (int) ($this->request->getPost('expense_id') ?? 0); - if ($expenseId > 0) { - $expense = $this->expenseModel->find($expenseId); - if ($expense && strcasecmp($expense['category'] ?? '', 'Donation') === 0) { - return redirect()->back()->withInput()->with('errors', [ - 'expense_id' => 'Donation expenses are tracked but should not be reimbursed.' - ]); - } - } - - try { - $receiptName = $this->saveReimbReceipt($this->request->getFile('receipt')); - } catch (\Throwable $e) { - log_message('error', 'Failed to save reimbursement receipt in process(): {msg}', ['msg' => $e->getMessage()]); - return redirect()->back()->withInput()->with('errors', [ - 'receipt' => 'Failed to save uploaded file. Please try again or contact admin.' - ]); - } - - $userId = (int) (session()->get('user_id') ?? 0); - $recipientId = (int) $this->request->getPost('reimbursed_to'); - - $reimbursementId = $this->reimbModel->insert([ - 'amount' => $this->request->getPost('amount'), - 'reimbursed_to' => $recipientId, - 'approved_by' => $userId, - 'receipt_path' => $receiptName, - 'description' => 'Expense reimbursement', - 'status' => 'Paid', - 'added_by' => $userId, - 'school_year' => $this->schoolYear, - 'semester' => $this->semester, - 'check_number' => $this->request->getPost('check_number'), - 'reimbursement_method' => $this->request->getPost('reimbursement_method') - ]); - - $this->expenseModel->update($expenseId, [ - 'reimbursement_id' => $reimbursementId - ]); - - return redirect()->to('/reimbursements/under-processing')->with('success', 'Reimbursement processed!'); - } - - public function reimbursedExpenses() - { - $expenses = $this->db->table('reimbursements r') - ->select(' - e.amount AS expense_amount, - e.category, - e.description, - e.receipt_path AS expense_receipt, - e.purchased_by, - r.amount AS reimb_amount, - r.reimbursement_method, - r.check_number, - r.receipt_path AS reimb_receipt, - r.status AS reimb_status, - r.created_at, - u.firstname AS purchaser_firstname, - u.lastname AS purchaser_lastname, - a.firstname AS approver_firstname, - a.lastname AS approver_lastname - ') - ->join('expenses e', 'e.id = r.expense_id') - ->join('users u', 'u.id = e.purchased_by', 'left') - ->join('users a', 'a.id = r.approved_by', 'left') - ->orderBy('r.created_at', 'DESC') - ->get() - ->getResultArray(); - - foreach ($expenses as &$e) { - $e['expense_receipt_url'] = $this->expenseReceiptUrl($e['expense_receipt'] ?? null); - $e['reimb_receipt_url'] = $this->reimbReceiptUrl($e['reimb_receipt'] ?? null); - } - - return view('reimbursements/reimbursed_expenses', ['expenses' => $expenses]); - } - - // app/Controllers/ReimbursementController.php - - - public function edit(int $id) - { - $reimb = $this->reimbModel->find($id); - if (!$reimb) { - throw PageNotFoundException::forPageNotFound("Reimbursement #$id not found"); - } - - $users = $this->recipientOptions(); - - return view('reimbursements/edit', [ - 'reimb' => $reimb, - 'users' => $users, - 'receipt_url' => $reimb['receipt_path'] ? site_url('reimbreceipts/' . basename($reimb['receipt_path'])) : null, - ]); - } - - public function update(int $id) - { - helper(['form']); - - $reimb = $this->reimbModel->find($id); - if (!$reimb) { - throw PageNotFoundException::forPageNotFound("Reimbursement #$id not found"); - } - - $methodRaw = (string) $this->request->getPost('reimbursement_method'); - $method = ucfirst(strtolower($methodRaw)); - - $rules = [ - 'amount' => 'required|decimal|greater_than[0]', - 'reimbursed_to' => 'required|is_natural_no_zero', - 'reimbursement_method' => 'required|in_list[Cash,Check]', - ]; - - // Optional file validation (only if provided) - $file = $this->request->getFile('receipt'); - if ($method === 'Check') { - $rules['check_number'] = 'required|string|max_length[50]'; - if ($file && $file->isValid() && ($file->getSize() ?? 0) > 0) { - $rules['receipt'] = 'max_size[receipt,2048]' - . '|ext_in[receipt,jpg,jpeg,png,webp,gif,pdf]' - . '|mime_in[receipt,image/jpg,image/jpeg,image/png,image/webp,image/gif,application/pdf]'; - } - } else { // Cash - if ($file && $file->isValid() && ($file->getSize() ?? 0) > 0) { - $rules['receipt'] = 'max_size[receipt,2048]' - . '|ext_in[receipt,jpg,jpeg,png,webp,gif,pdf]' - . '|mime_in[receipt,image/jpg,image/jpeg,image/png,image/webp,image/gif,application/pdf]'; - } - } - - if (!$this->validate($rules)) { - return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); - } - - // Handle new/removed receipt - $receiptName = $reimb['receipt_path']; - try { - $maybeNew = $this->saveReimbReceipt($file); - } catch (\Throwable $e) { - log_message('error', 'Failed to save reimbursement receipt in update(): {msg}', ['msg' => $e->getMessage()]); - return redirect()->back()->withInput()->with('errors', [ - 'receipt' => 'Failed to save uploaded file. Please try again or contact admin.' - ]); - } - if ($maybeNew !== null) { - $receiptName = $maybeNew; - } - if ($this->request->getPost('remove_receipt') === '1') { - $receiptName = null; - } - - $this->reimbModel->update($id, [ - 'amount' => $this->request->getPost('amount'), - 'reimbursed_to' => (int) $this->request->getPost('reimbursed_to'), - 'description' => $this->request->getPost('description'), - 'reimbursement_method' => $method, - 'check_number' => $method === 'Check' ? $this->request->getPost('check_number') : null, - 'receipt_path' => $receiptName, - 'updated_by' => (int) (session()->get('user_id') ?? 0), - // keep status as-is; or set from form if you add a status field - ]); - - return redirect()->to('/reimbursements')->with('success', 'Reimbursement updated.'); - } - - private function lookupReimbursementId(int $expenseId): ?int - { - if ($expenseId <= 0) { - return null; - } - - $row = $this->reimbModel - ->select('id') - ->where('expense_id', $expenseId) - ->orderBy('id', 'DESC') - ->first(); - - return $row ? (int) $row['id'] : null; - } -} diff --git a/app/Http/Requests/Email/EmailExtractorCompareRequest.php b/app/Http/Requests/Email/EmailExtractorCompareRequest.php new file mode 100644 index 00000000..d8aa255b --- /dev/null +++ b/app/Http/Requests/Email/EmailExtractorCompareRequest.php @@ -0,0 +1,22 @@ + ['nullable', 'file', 'max:5120'], + 'csv_emails' => ['nullable', 'array'], + 'csv_emails.*' => ['email'], + ]; + } +} diff --git a/app/Http/Requests/Email/EmailSendRequest.php b/app/Http/Requests/Email/EmailSendRequest.php new file mode 100644 index 00000000..e4246a15 --- /dev/null +++ b/app/Http/Requests/Email/EmailSendRequest.php @@ -0,0 +1,27 @@ + ['required', 'email'], + 'subject' => ['required', 'string', 'max:255'], + 'html_message' => ['required', 'string'], + 'profile' => ['nullable', 'string', 'max:50'], + 'reply_to_email' => ['nullable', 'email'], + 'reply_to_name' => ['nullable', 'string', 'max:100'], + 'attachments' => ['nullable', 'array'], + 'attachments.*' => ['file', 'max:5120'], + ]; + } +} diff --git a/app/Http/Requests/Fees/FeeRefundRequest.php b/app/Http/Requests/Fees/FeeRefundRequest.php new file mode 100644 index 00000000..3648dc1c --- /dev/null +++ b/app/Http/Requests/Fees/FeeRefundRequest.php @@ -0,0 +1,26 @@ + ['required', 'integer', 'min:1'], + 'students' => ['required', 'array'], + 'students.*.student_id' => ['nullable', 'integer'], + 'students.*.class_section_id' => ['required', 'integer', 'min:1'], + 'students.*.enrollment_status' => ['required', 'string', 'max:50'], + 'students.*.admission_status' => ['required', 'string', 'max:50'], + 'students.*.withdrawal_date' => ['nullable', 'date'], + ]; + } +} diff --git a/app/Http/Requests/Fees/FeeTuitionTotalRequest.php b/app/Http/Requests/Fees/FeeTuitionTotalRequest.php new file mode 100644 index 00000000..9b4830b5 --- /dev/null +++ b/app/Http/Requests/Fees/FeeTuitionTotalRequest.php @@ -0,0 +1,22 @@ + ['required', 'array'], + 'students.*.class_section_id' => ['required', 'integer', 'min:1'], + 'students.*.grade' => ['nullable', 'string', 'max:20'], + ]; + } +} diff --git a/app/Http/Requests/PurchaseOrders/PurchaseOrderIndexRequest.php b/app/Http/Requests/PurchaseOrders/PurchaseOrderIndexRequest.php new file mode 100644 index 00000000..8cdc2fb6 --- /dev/null +++ b/app/Http/Requests/PurchaseOrders/PurchaseOrderIndexRequest.php @@ -0,0 +1,20 @@ + ['nullable', 'string', 'max:100'], + ]; + } +} diff --git a/app/Http/Requests/PurchaseOrders/PurchaseOrderReceiveRequest.php b/app/Http/Requests/PurchaseOrders/PurchaseOrderReceiveRequest.php new file mode 100644 index 00000000..1c8a50fa --- /dev/null +++ b/app/Http/Requests/PurchaseOrders/PurchaseOrderReceiveRequest.php @@ -0,0 +1,22 @@ + ['required', 'array', 'min:1'], + 'items.*.id' => ['required', 'integer', 'min:1'], + 'items.*.quantity' => ['required', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/PurchaseOrders/PurchaseOrderStoreRequest.php b/app/Http/Requests/PurchaseOrders/PurchaseOrderStoreRequest.php new file mode 100644 index 00000000..aea68722 --- /dev/null +++ b/app/Http/Requests/PurchaseOrders/PurchaseOrderStoreRequest.php @@ -0,0 +1,31 @@ + ['required', 'string', 'max:60'], + 'supplier_id' => ['required', 'integer', 'min:1', 'exists:suppliers,id'], + 'status' => ['nullable', 'string', 'in:' . implode(',', PurchaseOrder::allowedStatuses())], + 'order_date' => ['nullable', 'date'], + 'expected_date' => ['nullable', 'date', 'after_or_equal:order_date'], + 'notes' => ['nullable', 'string', 'max:5000'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.supply_id' => ['required', 'integer', 'min:1', 'exists:supplies,id'], + 'items.*.description' => ['nullable', 'string', 'max:1000'], + 'items.*.quantity' => ['required', 'integer', 'min:1'], + 'items.*.unit_cost' => ['required', 'numeric', 'min:0'], + ]; + } +} diff --git a/app/Http/Requests/Scores/ScoreLockRequest.php b/app/Http/Requests/Scores/ScoreLockRequest.php index c576943a..596f3182 100644 --- a/app/Http/Requests/Scores/ScoreLockRequest.php +++ b/app/Http/Requests/Scores/ScoreLockRequest.php @@ -11,6 +11,11 @@ class ScoreLockRequest extends ApiFormRequest return true; } + public function validationData(): array + { + return request()->all(); + } + public function rules(): array { return [ diff --git a/app/Http/Requests/Scores/ScoreOverviewRequest.php b/app/Http/Requests/Scores/ScoreOverviewRequest.php index 969200a0..fe087168 100644 --- a/app/Http/Requests/Scores/ScoreOverviewRequest.php +++ b/app/Http/Requests/Scores/ScoreOverviewRequest.php @@ -11,6 +11,19 @@ class ScoreOverviewRequest extends ApiFormRequest return true; } + public function validationData(): array + { + $request = request(); + $data = $request->query->all(); + + $json = json_decode($request->getContent() ?: '', true); + if (is_array($json)) { + $data = array_merge($data, $json); + } + + return array_merge($data, $request->request->all()); + } + public function rules(): array { return [ diff --git a/app/Http/Requests/Semesters/SchoolYearRangeRequest.php b/app/Http/Requests/Semesters/SchoolYearRangeRequest.php index 7519d1ad..54bbd481 100644 --- a/app/Http/Requests/Semesters/SchoolYearRangeRequest.php +++ b/app/Http/Requests/Semesters/SchoolYearRangeRequest.php @@ -11,6 +11,11 @@ class SchoolYearRangeRequest extends ApiFormRequest return true; } + public function validationData(): array + { + return request()->all(); + } + public function rules(): array { return [ diff --git a/app/Http/Requests/Semesters/SemesterResolveRequest.php b/app/Http/Requests/Semesters/SemesterResolveRequest.php index 18eaade8..b3e46a23 100644 --- a/app/Http/Requests/Semesters/SemesterResolveRequest.php +++ b/app/Http/Requests/Semesters/SemesterResolveRequest.php @@ -11,6 +11,11 @@ class SemesterResolveRequest extends ApiFormRequest return true; } + public function validationData(): array + { + return request()->all(); + } + public function rules(): array { return [ diff --git a/app/Http/Requests/Users/LoginActivityRequest.php b/app/Http/Requests/Users/LoginActivityRequest.php index 0f0aa6a9..38ff5030 100644 --- a/app/Http/Requests/Users/LoginActivityRequest.php +++ b/app/Http/Requests/Users/LoginActivityRequest.php @@ -11,6 +11,11 @@ class LoginActivityRequest extends ApiFormRequest return true; } + public function validationData(): array + { + return request()->all(); + } + public function rules(): array { return [ diff --git a/app/Http/Requests/Users/UserStoreRequest.php b/app/Http/Requests/Users/UserStoreRequest.php index 71e8339d..106d6079 100644 --- a/app/Http/Requests/Users/UserStoreRequest.php +++ b/app/Http/Requests/Users/UserStoreRequest.php @@ -12,6 +12,11 @@ class UserStoreRequest extends ApiFormRequest return true; } + public function validationData(): array + { + return request()->all(); + } + public function rules(): array { return [ diff --git a/app/Http/Resources/Email/EmailExtractorCompareResource.php b/app/Http/Resources/Email/EmailExtractorCompareResource.php new file mode 100644 index 00000000..ba51856a --- /dev/null +++ b/app/Http/Resources/Email/EmailExtractorCompareResource.php @@ -0,0 +1,23 @@ + $this->resource['existed'] ?? [], + 'needToAdd' => $this->resource['needToAdd'] ?? [], + 'counts' => $this->resource['counts'] ?? [ + 'csv' => 0, + 'db' => 0, + 'users' => 0, + 'parents' => 0, + ], + ]; + } +} diff --git a/app/Http/Resources/Email/EmailExtractorEmailsResource.php b/app/Http/Resources/Email/EmailExtractorEmailsResource.php new file mode 100644 index 00000000..2a3899bd --- /dev/null +++ b/app/Http/Resources/Email/EmailExtractorEmailsResource.php @@ -0,0 +1,17 @@ + $this->resource['users'] ?? [], + 'parents' => $this->resource['parents'] ?? [], + ]; + } +} diff --git a/app/Http/Resources/Email/EmailSenderResource.php b/app/Http/Resources/Email/EmailSenderResource.php new file mode 100644 index 00000000..6e486235 --- /dev/null +++ b/app/Http/Resources/Email/EmailSenderResource.php @@ -0,0 +1,19 @@ + $this->resource['key'] ?? null, + 'name' => $this->resource['name'] ?? null, + 'email' => $this->resource['email'] ?? null, + 'label' => $this->resource['label'] ?? null, + ]; + } +} diff --git a/app/Http/Resources/Fees/FeeRefundResource.php b/app/Http/Resources/Fees/FeeRefundResource.php new file mode 100644 index 00000000..a8befcb4 --- /dev/null +++ b/app/Http/Resources/Fees/FeeRefundResource.php @@ -0,0 +1,21 @@ + (float) ($this->resource['refund_amount'] ?? 0), + 'total_paid' => (float) ($this->resource['total_paid'] ?? 0), + 'refund_deadline' => $this->resource['refund_deadline'] ?? null, + 'school_end_date' => $this->resource['school_end_date'] ?? null, + 'weeks_of_study' => (float) ($this->resource['weeks_of_study'] ?? 0), + 'withdrawn_students' => (int) ($this->resource['withdrawn_students'] ?? 0), + ]; + } +} diff --git a/app/Http/Resources/Fees/FeeTuitionTotalResource.php b/app/Http/Resources/Fees/FeeTuitionTotalResource.php new file mode 100644 index 00000000..e009765c --- /dev/null +++ b/app/Http/Resources/Fees/FeeTuitionTotalResource.php @@ -0,0 +1,16 @@ + (float) ($this->resource['total_tuition'] ?? 0), + ]; + } +} diff --git a/app/Http/Resources/PurchaseOrders/PurchaseOrderItemResource.php b/app/Http/Resources/PurchaseOrders/PurchaseOrderItemResource.php new file mode 100644 index 00000000..40832e39 --- /dev/null +++ b/app/Http/Resources/PurchaseOrders/PurchaseOrderItemResource.php @@ -0,0 +1,24 @@ + (int) ($this->resource['id'] ?? 0), + 'purchase_order_id' => (int) ($this->resource['purchase_order_id'] ?? 0), + 'supply_id' => (int) ($this->resource['supply_id'] ?? 0), + 'supply_name' => $this->resource['supply_name'] ?? null, + 'supply_unit' => $this->resource['supply_unit'] ?? null, + 'description' => $this->resource['description'] ?? null, + 'quantity' => (int) ($this->resource['quantity'] ?? 0), + 'received_qty' => (int) ($this->resource['received_qty'] ?? 0), + 'unit_cost' => (float) ($this->resource['unit_cost'] ?? 0), + ]; + } +} diff --git a/app/Http/Resources/PurchaseOrders/PurchaseOrderResource.php b/app/Http/Resources/PurchaseOrders/PurchaseOrderResource.php new file mode 100644 index 00000000..ee794dbd --- /dev/null +++ b/app/Http/Resources/PurchaseOrders/PurchaseOrderResource.php @@ -0,0 +1,28 @@ + (int) ($this->resource['id'] ?? 0), + 'po_number' => $this->resource['po_number'] ?? null, + 'supplier_id' => isset($this->resource['supplier_id']) ? (int) $this->resource['supplier_id'] : null, + 'supplier_name' => $this->resource['supplier_name'] ?? null, + 'status' => $this->resource['status'] ?? null, + 'order_date' => $this->resource['order_date'] ?? null, + 'expected_date' => $this->resource['expected_date'] ?? null, + 'subtotal' => (float) ($this->resource['subtotal'] ?? 0), + 'tax' => (float) ($this->resource['tax'] ?? 0), + 'total' => (float) ($this->resource['total'] ?? 0), + 'notes' => $this->resource['notes'] ?? null, + 'created_at' => $this->resource['created_at'] ?? null, + 'updated_at' => $this->resource['updated_at'] ?? null, + ]; + } +} diff --git a/app/Models/Supply.php b/app/Models/Supply.php new file mode 100644 index 00000000..162cf2e6 --- /dev/null +++ b/app/Models/Supply.php @@ -0,0 +1,20 @@ + 'integer', + ]; +} diff --git a/app/Models/SupplyTransaction.php b/app/Models/SupplyTransaction.php new file mode 100644 index 00000000..9367af3e --- /dev/null +++ b/app/Models/SupplyTransaction.php @@ -0,0 +1,25 @@ + 'integer', + 'quantity' => 'integer', + ]; +} diff --git a/app/Models/User.php b/app/Models/User.php index 190f6e61..27af6d26 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -8,9 +8,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Schema; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { + use HasApiTokens; + protected $table = 'users'; protected $fillable = [ @@ -68,10 +72,23 @@ class User extends Authenticatable { // If user_roles has soft-deletes (deleted_at), filter it out. // If it does not, you can remove wherePivotNull('deleted_at'). - return $this->belongsToMany(Role::class, 'user_roles', 'user_id', 'role_id') - ->withTimestamps() - ->withPivot(['deleted_at', 'school_year']) - ->wherePivotNull('deleted_at'); + $relation = $this->belongsToMany(Role::class, 'user_roles', 'user_id', 'role_id') + ->withTimestamps(); + + $pivotColumns = []; + if (Schema::hasColumn('user_roles', 'deleted_at')) { + $pivotColumns[] = 'deleted_at'; + $relation->wherePivotNull('deleted_at'); + } + if (Schema::hasColumn('user_roles', 'school_year')) { + $pivotColumns[] = 'school_year'; + } + + if (!empty($pivotColumns)) { + $relation->withPivot($pivotColumns); + } + + return $relation; } public function teacherClasses(): HasMany @@ -604,4 +621,4 @@ class User extends Authenticatable ? $value : Hash::make($value); } -} \ No newline at end of file +} diff --git a/app/Services/Email/EmailAttachmentService.php b/app/Services/Email/EmailAttachmentService.php new file mode 100644 index 00000000..7ba2c497 --- /dev/null +++ b/app/Services/Email/EmailAttachmentService.php @@ -0,0 +1,35 @@ +isValid()) { + continue; + } + $path = $file->getRealPath(); + if (!$path || !is_file($path)) { + continue; + } + $out[] = [ + 'path' => $path, + 'name' => $file->getClientOriginalName(), + ]; + } + + return $out; + } +} diff --git a/app/Services/Email/EmailDispatchService.php b/app/Services/Email/EmailDispatchService.php new file mode 100644 index 00000000..1a0b55b4 --- /dev/null +++ b/app/Services/Email/EmailDispatchService.php @@ -0,0 +1,125 @@ +profiles->resolveProfile($profile); + $cfg = $this->profiles->getProfileConfig($profile); + + if (empty($cfg['host']) || empty($cfg['user']) || $cfg['pass'] === '') { + logger()->error("[mail:$profile] Missing SMTP config (host/user/pass). Check MAIL_{$this->profiles->envKeyFromProfile($profile)}_* or MAIL_DEFAULT_*/MAIL_*."); + return false; + } + + $debugEnabled = (bool) env('MAIL_DEBUG', false); + $timeout = (int) env('MAIL_TIMEOUT', 15); + $keepAlive = (bool) env('MAIL_KEEPALIVE', false); + $verifyPeer = filter_var(env('MAIL_VERIFY_PEER', 'true'), FILTER_VALIDATE_BOOLEAN); + + $mail = new PHPMailer(true); + + try { + if ($debugEnabled) { + ob_start(); + } + + $mail->isSMTP(); + $mail->Host = $cfg['host']; + $mail->Port = $cfg['port']; + $mail->SMTPAuth = true; + $mail->Username = $cfg['user']; + $mail->Password = $cfg['pass']; + $mail->CharSet = 'UTF-8'; + $mail->Timeout = $timeout; + $mail->SMTPKeepAlive = $keepAlive; + $mail->SMTPAutoTLS = true; + + if ($cfg['encryption'] === 'ssl') { + $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; + } else { + $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + } + + $mail->SMTPOptions = [ + 'ssl' => [ + 'verify_peer' => $verifyPeer, + 'verify_peer_name' => $verifyPeer, + 'allow_self_signed' => !$verifyPeer, + ], + ]; + + $mail->SMTPDebug = $debugEnabled ? 3 : 0; + $mail->Debugoutput = 'error_log'; + + $mail->setFrom($cfg['fromEmail'], $cfg['fromName']); + if (!empty($cfg['returnPath'])) { + $mail->Sender = $cfg['returnPath']; + } + + $mail->clearReplyTos(); + $rtEmail = env('MAIL_DEFAULT_REPLY_TO'); + $rtName = env('MAIL_DEFAULT_REPLY_TO_NAME'); + if (!$rtEmail || !filter_var($rtEmail, FILTER_VALIDATE_EMAIL)) { + $rtEmail = $replyToEmail ?: ($cfg['replyTo'] ?: $cfg['fromEmail']); + } + if (!$rtName) { + $rtName = $replyToName ?: ($cfg['replyToName'] ?: $cfg['fromName']); + } + $rtName = $this->profiles->sanitizeReplyToName($rtName, $cfg['fromName']); + if ($rtEmail) { + $mail->addReplyTo($rtEmail, $rtName); + } + + if (!empty($cfg['dkim']['domain']) && !empty($cfg['dkim']['private']) && !empty($cfg['dkim']['selector'])) { + $mail->DKIM_domain = $cfg['dkim']['domain']; + $mail->DKIM_private = $cfg['dkim']['private']; + $mail->DKIM_selector = $cfg['dkim']['selector']; + $mail->DKIM_identity = $cfg['fromEmail']; + } + + $mail->addAddress($recipient); + + foreach ($attachments as $att) { + if (!empty($att['path']) && is_file($att['path'])) { + $mail->addAttachment($att['path'], $att['name'] ?? ''); + } + } + + $mail->isHTML(true); + $mail->Subject = $subject; + $mail->Body = $htmlMessage; + + $ok = $mail->send(); + $dbg = $debugEnabled ? (ob_get_clean() ?: '') : ''; + + if ($ok) { + logger()->info("[mail:$profile] Sent to {$recipient}, subj='{$subject}' via {$cfg['host']}:{$cfg['port']}/{$cfg['encryption']}"); + return true; + } + + logger()->error("[mail:$profile] Failed: {$mail->ErrorInfo}. Debug: {$dbg}"); + return false; + } catch (MailerException $e) { + $dbg = $debugEnabled ? (ob_get_clean() ?: '') : ''; + logger()->error("[mail:$profile] Exception: {$e->getMessage()} | PHPMailer: {$mail->ErrorInfo} | Debug: {$dbg}"); + return false; + } + } +} diff --git a/app/Services/Email/EmailExtractorService.php b/app/Services/Email/EmailExtractorService.php new file mode 100644 index 00000000..9219605e --- /dev/null +++ b/app/Services/Email/EmailExtractorService.php @@ -0,0 +1,97 @@ +select('email')->get(); + foreach ($userRows as $row) { + $email = strtolower(trim((string) ($row->email ?? ''))); + if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { + $users[] = $email; + } + } + $users = array_values(array_unique($users)); + + $parentRows = DB::table('parents')->select('secondparent_email')->get(); + foreach ($parentRows as $row) { + $email = strtolower(trim((string) ($row->secondparent_email ?? ''))); + if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { + $parents[] = $email; + } + } + $parents = array_values(array_unique($parents)); + + return [ + 'users' => $users, + 'parents' => $parents, + ]; + } + + public function compare(array $csvEmails): array + { + $emails = $this->listEmails(); + $users = $emails['users']; + $parents = $emails['parents']; + + $csvEmails = $this->normalizeEmailArray($csvEmails); + + $csvSet = array_flip(array_values(array_unique($csvEmails))); + $dbUnion = array_values(array_unique(array_merge($users, $parents))); + $dbSet = array_flip($dbUnion); + + $existed = []; + foreach ($csvSet as $email => $_) { + if (isset($dbSet[$email])) { + $existed[] = $email; + } + } + sort($existed); + + $needToAdd = []; + foreach ($dbSet as $email => $_) { + if (!isset($csvSet[$email])) { + $needToAdd[] = $email; + } + } + sort($needToAdd); + + return [ + 'existed' => $existed, + 'needToAdd' => $needToAdd, + 'counts' => [ + 'csv' => count($csvEmails), + 'db' => count($dbUnion), + 'users' => count($users), + 'parents' => count($parents), + ], + ]; + } + + public function normalizeEmailArray(array $arr): array + { + $out = []; + foreach ($arr as $e) { + $email = strtolower(trim((string) $e)); + if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { + $out[] = $email; + } + } + return array_values(array_unique($out)); + } + + public function extractEmailsFromText(string $text): array + { + $pattern = '/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i'; + preg_match_all($pattern, $text, $matches); + $emails = $matches[0] ?? []; + return $this->normalizeEmailArray($emails); + } +} diff --git a/app/Services/Email/EmailProfileService.php b/app/Services/Email/EmailProfileService.php new file mode 100644 index 00000000..14b76eab --- /dev/null +++ b/app/Services/Email/EmailProfileService.php @@ -0,0 +1,95 @@ + 'communication', + 'sys', 'system' => 'default', + default => $p, + }; + } + + public function envKeyFromProfile(string $profile): string + { + return strtoupper(preg_replace('/[^A-Z0-9]+/i', '_', $profile)); + } + + public function getProfileConfig(string $profile): array + { + $key = $this->envKeyFromProfile($profile); + + $envFirst = function (array $keys, $default = null) { + foreach ($keys as $k) { + $v = env($k); + if (is_string($v)) { + $v = trim($v); + } + if ($v !== null && $v !== '') { + return $v; + } + } + return $default; + }; + + $host = $envFirst(["MAIL_{$key}_HOST", 'MAIL_DEFAULT_HOST', 'SMTP_HOST', 'MAIL_HOST'], ''); + $user = $envFirst(["MAIL_{$key}_USER", 'MAIL_DEFAULT_USER', 'SMTP_USER', 'MAIL_USERNAME'], ''); + $pass = $envFirst(["MAIL_{$key}_PASS", 'MAIL_DEFAULT_PASS', 'SMTP_PASS', 'MAIL_PASSWORD'], ''); + $portRaw = $envFirst(["MAIL_{$key}_PORT", 'MAIL_DEFAULT_PORT', 'SMTP_PORT', 'MAIL_PORT'], 587); + $encRaw = $envFirst(["MAIL_{$key}_ENCRYPTION", 'MAIL_DEFAULT_ENCRYPTION', 'SMTP_ENCRYPTION', 'MAIL_ENCRYPTION'], 'tls'); + + $fromEmail = $envFirst(["MAIL_{$key}_FROM_EMAIL", 'MAIL_DEFAULT_FROM_EMAIL', 'MAIL_FROM_ADDRESS'], $user ?: 'no-reply@alrahmaisgl.org'); + $fromName = $envFirst(["MAIL_{$key}_FROM_NAME", 'MAIL_DEFAULT_FROM_NAME', 'MAIL_FROM_NAME'], 'Al Rahma Sunday School'); + $replyTo = $envFirst(["MAIL_{$key}_REPLY_TO", 'MAIL_DEFAULT_REPLY_TO'], ''); + $replyToName = $envFirst(["MAIL_{$key}_REPLY_TO_NAME", 'MAIL_DEFAULT_REPLY_TO_NAME'], ''); + $returnPath = $envFirst(["MAIL_{$key}_RETURN_PATH", 'MAIL_DEFAULT_RETURN_PATH'], ''); + + $dkim = [ + 'domain' => $envFirst(["MAIL_{$key}_DKIM_DOMAIN", 'MAIL_DEFAULT_DKIM_DOMAIN'], ''), + 'selector' => $envFirst(["MAIL_{$key}_DKIM_SELECTOR", 'MAIL_DEFAULT_DKIM_SELECTOR'], ''), + 'private' => $envFirst(["MAIL_{$key}_DKIM_PRIVATE", 'MAIL_DEFAULT_DKIM_PRIVATE'], ''), + ]; + + $port = (int) $portRaw ?: 587; + $encryption = strtolower((string) $encRaw); + $encryption = in_array($encryption, ['tls', 'ssl'], true) ? $encryption : 'tls'; + + if ($port === 587 && $encryption === 'ssl') { + $encryption = 'tls'; + } + if ($port === 465 && $encryption === 'tls') { + $encryption = 'ssl'; + } + + return [ + 'host' => (string) $host, + 'user' => (string) $user, + 'pass' => (string) $pass, + 'port' => $port, + 'encryption' => $encryption, + 'fromEmail' => (string) $fromEmail, + 'fromName' => (string) $fromName, + 'replyTo' => (string) $replyTo, + 'replyToName' => (string) $this->sanitizeReplyToName($replyToName, (string) $fromName), + 'returnPath' => (string) $returnPath, + 'dkim' => $dkim, + ]; + } + + public function sanitizeReplyToName(?string $name, string $fallback): string + { + $trimmed = trim((string) $name); + if ($trimmed === '' || preg_match('/^no[- ]?repl(?:y|ay)$/i', $trimmed)) { + return $fallback; + } + return $trimmed; + } +} diff --git a/app/Services/Email/EmailSenderOptionsService.php b/app/Services/Email/EmailSenderOptionsService.php new file mode 100644 index 00000000..de6e4990 --- /dev/null +++ b/app/Services/Email/EmailSenderOptionsService.php @@ -0,0 +1,38 @@ + 'general', + 'name' => $name, + 'email' => $smtpUser, + 'label' => trim($name . ($smtpUser ? " <{$smtpUser}>" : '')), + ]]; + } + + $out = []; + foreach ($arr as $key => $info) { + $name = $info['name'] ?? 'Sender'; + $email = $info['email'] ?? ''; + $out[] = [ + 'key' => (string) $key, + 'name' => (string) $name, + 'email' => (string) $email, + 'label' => trim($name . ($email ? " <{$email}>" : '')), + ]; + } + + return $out; + } +} diff --git a/app/Services/Fees/FeeConfigService.php b/app/Services/Fees/FeeConfigService.php new file mode 100644 index 00000000..622a8e38 --- /dev/null +++ b/app/Services/Fees/FeeConfigService.php @@ -0,0 +1,57 @@ + (float) (Configuration::getConfig('first_student_fee') ?? 350), + 'second_student_fee' => (float) (Configuration::getConfig('second_student_fee') ?? 200), + 'youth_fee' => (float) (Configuration::getConfig('youth_fee') ?? 180), + ]; + } +} diff --git a/app/Services/Fees/FeeGradeService.php b/app/Services/Fees/FeeGradeService.php new file mode 100644 index 00000000..544e8835 --- /dev/null +++ b/app/Services/Fees/FeeGradeService.php @@ -0,0 +1,44 @@ +getGradeLevel($gradeA); + $valB = $this->getGradeLevel($gradeB); + + if ($valA !== $valB) { + return $valA <=> $valB; + } + + preg_match('/\d+([A-Z]*)$/i', strtoupper($gradeA), $suffixA); + preg_match('/\d+([A-Z]*)$/i', strtoupper($gradeB), $suffixB); + + return strcmp($suffixA[1] ?? '', $suffixB[1] ?? ''); + } + + public function getGradeLevel(string $grade): int + { + $grade = $this->normalizeGrade($grade); + + if ($grade === 'K') { + return 0; + } + if ($grade === 'Y') { + return 99; + } + + if (preg_match('/^(\d+)([A-Z]*)$/i', $grade, $matches)) { + return (int) $matches[1]; + } + + return 999; + } +} diff --git a/app/Services/Fees/FeeRefundCalculatorService.php b/app/Services/Fees/FeeRefundCalculatorService.php new file mode 100644 index 00000000..f314bd1d --- /dev/null +++ b/app/Services/Fees/FeeRefundCalculatorService.php @@ -0,0 +1,120 @@ +config->getSchoolYear(); + $refundDeadline = $this->config->getRefundDeadline(); + $weeksOfStudy = $this->config->getWeeksOfStudy(); + $schoolEndDate = $this->config->getSchoolEndDate(); + + $totalPaid = Payment::getTotalPaidByParentId($parentId, $schoolYear); + if ($totalPaid <= 0) { + return $this->resultPayload(0.0, $totalPaid, $refundDeadline, $schoolEndDate, $weeksOfStudy, 0); + } + + $registered = []; + $withdrawn = []; + + foreach ($students as $student) { + $status = strtolower((string) ($student['enrollment_status'] ?? '')); + $admission = strtolower((string) ($student['admission_status'] ?? '')); + + if (in_array($status, ['withdrawn', 'refund pending', 'withdraw under review'], true)) { + $withdrawn[] = $student; + continue; + } + + if (in_array($status, ['enrolled', 'payment pending'], true) && $admission === 'accepted') { + $registered[] = $student; + } + } + + if (empty($withdrawn)) { + return $this->resultPayload(0.0, $totalPaid, $refundDeadline, $schoolEndDate, $weeksOfStudy, 0); + } + + $allStudents = array_merge($registered, $withdrawn); + $allStudents = $this->studentFees->assignFees($allStudents); + + $feeByStudentId = []; + foreach ($allStudents as $student) { + $key = $student['student_id'] ?? null; + if ($key === null) { + continue; + } + $feeByStudentId[(string) $key] = (float) ($student['tuition_fee'] ?? 0); + } + + $refundAmount = 0.0; + $withdrawCount = 0; + + foreach ($withdrawn as $student) { + $withdrawalDate = $student['withdrawal_date'] ?? null; + if (!$withdrawalDate) { + Log::warning('Missing withdraw date for student', ['student_id' => $student['student_id'] ?? null]); + continue; + } + + if (!$refundDeadline || strtotime($withdrawalDate) > strtotime($refundDeadline)) { + continue; + } + + if (!$schoolEndDate) { + continue; + } + + $withdrawDateObj = new \DateTime(date('Y-m-d', strtotime($withdrawalDate))); + $schoolEndDateObj = new \DateTime($schoolEndDate); + $daysRemaining = $withdrawDateObj->diff($schoolEndDateObj)->days; + $weeksRemaining = min($weeksOfStudy, max(0, (int) ceil($daysRemaining / 7))); + + $studentId = (string) ($student['student_id'] ?? ''); + $studentFee = $feeByStudentId[$studentId] ?? 0.0; + + if ($studentFee <= 0 || $weeksOfStudy <= 0) { + continue; + } + + $proportionalRefund = ($studentFee / $weeksOfStudy) * $weeksRemaining; + $refundAmount += $proportionalRefund; + $withdrawCount++; + } + + if ($refundAmount > $totalPaid) { + $refundAmount = $totalPaid; + } + + return $this->resultPayload($refundAmount, $totalPaid, $refundDeadline, $schoolEndDate, $weeksOfStudy, $withdrawCount); + } + + private function resultPayload( + float $refund, + float $totalPaid, + ?string $refundDeadline, + ?string $schoolEndDate, + float $weeksOfStudy, + int $withdrawCount + ): array { + return [ + 'refund_amount' => round($refund, 2), + 'total_paid' => round($totalPaid, 2), + 'refund_deadline' => $refundDeadline, + 'school_end_date' => $schoolEndDate, + 'weeks_of_study' => $weeksOfStudy, + 'withdrawn_students' => $withdrawCount, + ]; + } +} diff --git a/app/Services/Fees/FeeStudentFeeService.php b/app/Services/Fees/FeeStudentFeeService.php new file mode 100644 index 00000000..81560e29 --- /dev/null +++ b/app/Services/Fees/FeeStudentFeeService.php @@ -0,0 +1,62 @@ +config->getFees(); + $firstFee = $fees['first_student_fee']; + $secondFee = $fees['second_student_fee']; + $youthFee = $fees['youth_fee']; + + foreach ($students as &$student) { + $grade = $student['grade'] ?? null; + if ($grade === null || trim((string) $grade) === '') { + $sectionId = $student['class_section_id'] ?? null; + $grade = $sectionId ? ClassSection::getClassSectionNameBySectionId($sectionId) : null; + } + $student['grade'] = $this->grades->normalizeGrade($grade); + } + unset($student); + + usort($students, function ($a, $b) { + return $this->grades->compareGrades($a['grade'] ?? '', $b['grade'] ?? ''); + }); + + $regularCount = 0; + foreach ($students as &$student) { + $gradeLevel = $this->grades->getGradeLevel($student['grade'] ?? ''); + if ($gradeLevel > 9) { + $student['tuition_fee'] = $youthFee; + } else { + $student['tuition_fee'] = ($regularCount === 0) ? $firstFee : $secondFee; + $regularCount++; + } + } + unset($student); + + return $students; + } + + public function totalTuitionFee(array $students): float + { + $students = $this->assignFees($students); + + $total = 0.0; + foreach ($students as $student) { + $total += (float) ($student['tuition_fee'] ?? 0); + } + + return $total; + } +} diff --git a/app/Services/PurchaseOrders/PurchaseOrderCreateService.php b/app/Services/PurchaseOrders/PurchaseOrderCreateService.php new file mode 100644 index 00000000..c7776451 --- /dev/null +++ b/app/Services/PurchaseOrders/PurchaseOrderCreateService.php @@ -0,0 +1,74 @@ + $supplyId, + 'description' => trim((string) ($item['description'] ?? '')), + 'quantity' => $qty, + 'unit_cost' => $unitCost, + 'received_qty' => 0, + ]; + } + + if (empty($items)) { + throw new RuntimeException('Valid line items required.'); + } + + $status = (string) ($payload['status'] ?? 'ordered'); + $status = in_array($status, [PurchaseOrder::STATUS_DRAFT, PurchaseOrder::STATUS_ORDERED], true) ? $status : PurchaseOrder::STATUS_ORDERED; + + $tax = 0.0; + $total = $subtotal + $tax; + + return DB::transaction(function () use ($payload, $items, $status, $subtotal, $tax, $total) { + $po = PurchaseOrder::query()->create([ + 'po_number' => $payload['po_number'] ?? null, + 'supplier_id' => $payload['supplier_id'] ?? null, + 'order_date' => $payload['order_date'] ?? null, + 'expected_date' => $payload['expected_date'] ?? null, + 'notes' => $payload['notes'] ?? null, + 'status' => $status, + 'subtotal' => $subtotal, + 'tax' => $tax, + 'total' => $total, + ]); + + foreach ($items as $item) { + $item['purchase_order_id'] = $po->id; + PurchaseOrderItem::query()->create($item); + } + + return $po->refresh(); + }); + } +} diff --git a/app/Services/PurchaseOrders/PurchaseOrderQueryService.php b/app/Services/PurchaseOrders/PurchaseOrderQueryService.php new file mode 100644 index 00000000..7bdab684 --- /dev/null +++ b/app/Services/PurchaseOrders/PurchaseOrderQueryService.php @@ -0,0 +1,65 @@ +select('purchase_orders.*', 'suppliers.name as supplier_name') + ->leftJoin('suppliers', 'suppliers.id', '=', 'purchase_orders.supplier_id'); + + if ($q !== null && trim($q) !== '') { + $query = trim($q); + $builder->where(function ($qbuilder) use ($query) { + $qbuilder->where('purchase_orders.po_number', 'like', "%{$query}%") + ->orWhere('suppliers.name', 'like', "%{$query}%"); + }); + } + + return $builder->orderByDesc('purchase_orders.created_at') + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + } + + public function options(): array + { + $suppliers = DB::table('suppliers')->orderBy('name')->get()->map(fn ($r) => (array) $r)->all(); + $supplies = DB::table('supplies')->orderBy('name')->get()->map(fn ($r) => (array) $r)->all(); + + return [ + 'suppliers' => $suppliers, + 'supplies' => $supplies, + ]; + } + + public function find(int $id): ?array + { + $po = DB::table('purchase_orders') + ->select('purchase_orders.*', 'suppliers.name as supplier_name') + ->leftJoin('suppliers', 'suppliers.id', '=', 'purchase_orders.supplier_id') + ->where('purchase_orders.id', $id) + ->first(); + + if (!$po) { + return null; + } + + $items = DB::table('purchase_order_items') + ->select('purchase_order_items.*', 'supplies.name as supply_name', 'supplies.unit as supply_unit') + ->leftJoin('supplies', 'supplies.id', '=', 'purchase_order_items.supply_id') + ->where('purchase_order_id', $id) + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + + return [ + 'purchase_order' => (array) $po, + 'items' => $items, + ]; + } +} diff --git a/app/Services/PurchaseOrders/PurchaseOrderReceiveService.php b/app/Services/PurchaseOrders/PurchaseOrderReceiveService.php new file mode 100644 index 00000000..7539f623 --- /dev/null +++ b/app/Services/PurchaseOrders/PurchaseOrderReceiveService.php @@ -0,0 +1,87 @@ +find($poId); + if (!$po || in_array($po->status, [PurchaseOrder::STATUS_CANCELED, PurchaseOrder::STATUS_RECEIVED], true)) { + throw new RuntimeException('PO not receivable.'); + } + + if (empty($received)) { + throw new RuntimeException('No items to receive.'); + } + + return DB::transaction(function () use ($po, $received, $issuedBy) { + $completed = true; + + foreach ($received as $item) { + $itemId = (int) ($item['id'] ?? 0); + $qty = (int) ($item['quantity'] ?? 0); + if ($itemId <= 0 || $qty <= 0) { + continue; + } + + $poItem = PurchaseOrderItem::query() + ->where('purchase_order_id', $po->id) + ->where('id', $itemId) + ->first(); + + if (!$poItem) { + $completed = false; + continue; + } + + $remaining = (int) $poItem->quantity - (int) $poItem->received_qty; + $toReceive = min($remaining, $qty); + if ($toReceive <= 0) { + continue; + } + + $poItem->received_qty = (int) $poItem->received_qty + $toReceive; + $poItem->save(); + + $supply = Supply::query()->find($poItem->supply_id); + if (!$supply) { + $completed = false; + continue; + } + + $supply->qty_on_hand = (int) $supply->qty_on_hand + $toReceive; + $supply->save(); + + SupplyTransaction::query()->create([ + 'supply_id' => $supply->id, + 'type' => 'in', + 'quantity' => $toReceive, + 'ref' => 'PO ' . ($po->po_number ?? $po->id), + 'issued_to' => 'Inventory', + 'issued_by' => $issuedBy, + 'notes' => 'Received against PO', + ]); + + if ($poItem->received_qty < $poItem->quantity) { + $completed = false; + } + } + + $po->status = $completed ? PurchaseOrder::STATUS_RECEIVED : PurchaseOrder::STATUS_ORDERED; + $po->save(); + + return [ + 'status' => $po->status, + 'completed' => $completed, + ]; + }); + } +} diff --git a/app/Services/PurchaseOrders/PurchaseOrderStatusService.php b/app/Services/PurchaseOrders/PurchaseOrderStatusService.php new file mode 100644 index 00000000..3a417589 --- /dev/null +++ b/app/Services/PurchaseOrders/PurchaseOrderStatusService.php @@ -0,0 +1,22 @@ +find($poId); + if (!$po || $po->status === PurchaseOrder::STATUS_RECEIVED) { + throw new RuntimeException('Cannot cancel this PO.'); + } + + $po->status = PurchaseOrder::STATUS_CANCELED; + $po->save(); + + return $po; + } +} diff --git a/app/Services/Scores/ScoreDashboardService.php b/app/Services/Scores/ScoreDashboardService.php index c227e323..8f82c51c 100644 --- a/app/Services/Scores/ScoreDashboardService.php +++ b/app/Services/Scores/ScoreDashboardService.php @@ -24,26 +24,53 @@ class ScoreDashboardService $schoolYear = $this->term->schoolYear($schoolYear); $semesterLabel = $this->term->semesterLabel($semester, $semester); - $assignments = TeacherClass::query() - ->where('teacher_id', $teacherId) - ->when($schoolYear !== '', fn ($q) => $q->where('school_year', $schoolYear)) - ->get() - ->map(fn ($row) => (array) $row) - ->all(); + if ($teacherId <= 0 && !empty($classSectionId)) { + $allowedIds = [(int) $classSectionId]; + } else { + $allowedIds = TeacherClass::query() + ->where('teacher_id', $teacherId) + ->when($schoolYear !== '', fn ($q) => $q->where('school_year', $schoolYear)) + ->pluck('class_section_id') + ->map(fn ($v) => (int) $v) + ->filter(fn ($v) => $v > 0) + ->unique() + ->values() + ->all(); - if (empty($assignments)) { - return ['students' => [], 'assignments' => [], 'class_section_id' => null]; + if (empty($allowedIds)) { + return ['students' => [], 'assignments' => [], 'class_section_id' => null]; + } } - - $allowedIds = array_values(array_unique(array_map(static fn ($a) => (int) ($a['class_section_id'] ?? 0), $assignments))); $classSectionId = $classSectionId && in_array($classSectionId, $allowedIds, true) ? $classSectionId : $allowedIds[0]; - $studentIds = StudentClass::query() - ->where('class_section_id', $classSectionId) - ->when($schoolYear !== '', fn ($q) => $q->where('school_year', $schoolYear)) + $studentQuery = StudentClass::query(); + if (DB::connection()->getDriverName() === 'sqlite') { + $studentQuery->whereRaw('class_section_id = ' . (int) $classSectionId); + if ($schoolYear !== '') { + $quoted = DB::connection()->getPdo()->quote($schoolYear); + $studentQuery->whereRaw("school_year = {$quoted}"); + } + } else { + $studentQuery->where('class_section_id', $classSectionId); + if ($schoolYear !== '') { + $studentQuery->where('school_year', $schoolYear); + } + } + + $studentIds = $studentQuery ->pluck('student_id') ->map(fn ($v) => (int) $v) ->all(); + if (empty($studentIds) && DB::connection()->getDriverName() === 'sqlite') { + $schoolYearClause = $schoolYear !== '' ? " and school_year = " . DB::connection()->getPdo()->quote($schoolYear) : ''; + $rows = DB::select( + "select student_id from student_class where class_section_id = " . (int) $classSectionId . $schoolYearClause + ); + $studentIds = array_values(array_filter(array_map( + static fn ($row) => isset($row->student_id) ? (int) $row->student_id : 0, + $rows + ), static fn ($id) => $id > 0)); + } $students = Student::query() ->whereIn('id', $studentIds) diff --git a/app/Services/Scores/ScorePredictorDataService.php b/app/Services/Scores/ScorePredictorDataService.php index 297b7491..0bb303c6 100644 --- a/app/Services/Scores/ScorePredictorDataService.php +++ b/app/Services/Scores/ScorePredictorDataService.php @@ -12,7 +12,7 @@ class ScorePredictorDataService ->select('cs.*') ->join('student_class as sc', 'sc.class_section_id', '=', 'cs.class_section_id') ->where('sc.school_year', $schoolYear) - ->notLike('cs.class_section_name', 'KG', 'after') + ->where('cs.class_section_name', 'not like', 'KG%') ->groupBy('cs.id') ->orderBy('cs.class_section_name', 'ASC') ->get() diff --git a/database/migrations/2026_03_09_070000_create_suppliers_table.php b/database/migrations/2026_03_09_070000_create_suppliers_table.php new file mode 100644 index 00000000..34990c06 --- /dev/null +++ b/database/migrations/2026_03_09_070000_create_suppliers_table.php @@ -0,0 +1,22 @@ +id(); + $table->string('name'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('suppliers'); + } +}; diff --git a/database/migrations/2026_03_09_070010_create_supplies_table.php b/database/migrations/2026_03_09_070010_create_supplies_table.php new file mode 100644 index 00000000..01741b29 --- /dev/null +++ b/database/migrations/2026_03_09_070010_create_supplies_table.php @@ -0,0 +1,24 @@ +id(); + $table->string('name'); + $table->string('unit')->nullable(); + $table->integer('qty_on_hand')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('supplies'); + } +}; diff --git a/database/migrations/2026_03_09_070020_create_supply_transactions_table.php b/database/migrations/2026_03_09_070020_create_supply_transactions_table.php new file mode 100644 index 00000000..e292f37e --- /dev/null +++ b/database/migrations/2026_03_09_070020_create_supply_transactions_table.php @@ -0,0 +1,28 @@ +id(); + $table->unsignedBigInteger('supply_id'); + $table->string('type', 10); + $table->integer('quantity'); + $table->string('ref')->nullable(); + $table->string('issued_to')->nullable(); + $table->string('issued_by')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('supply_transactions'); + } +}; diff --git a/database/migrations/2026_03_09_070030_create_purchase_orders_table.php b/database/migrations/2026_03_09_070030_create_purchase_orders_table.php new file mode 100644 index 00000000..a676498d --- /dev/null +++ b/database/migrations/2026_03_09_070030_create_purchase_orders_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('po_number')->nullable(); + $table->unsignedBigInteger('supplier_id')->nullable(); + $table->string('status', 20)->default('ordered'); + $table->date('order_date')->nullable(); + $table->date('expected_date')->nullable(); + $table->decimal('subtotal', 10, 2)->default(0); + $table->decimal('tax', 10, 2)->default(0); + $table->decimal('total', 10, 2)->default(0); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('purchase_orders'); + } +}; diff --git a/database/migrations/2026_03_09_070040_create_purchase_order_items_table.php b/database/migrations/2026_03_09_070040_create_purchase_order_items_table.php new file mode 100644 index 00000000..a589cfbe --- /dev/null +++ b/database/migrations/2026_03_09_070040_create_purchase_order_items_table.php @@ -0,0 +1,27 @@ +id(); + $table->unsignedBigInteger('purchase_order_id'); + $table->unsignedBigInteger('supply_id'); + $table->string('description')->nullable(); + $table->integer('quantity'); + $table->integer('received_qty')->default(0); + $table->decimal('unit_cost', 10, 2)->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('purchase_order_items'); + } +}; diff --git a/routes/api.php b/routes/api.php index 0933be5e..69ab7fc2 100755 --- a/routes/api.php +++ b/routes/api.php @@ -63,6 +63,7 @@ use App\Http\Controllers\Api\Finance\PaymentTransactionController; use App\Http\Controllers\Api\Finance\PaypalTransactionsController; use App\Http\Controllers\Api\Finance\PurchaseOrderController; use App\Http\Controllers\Api\Finance\ReimbursementController; +use App\Http\Controllers\Api\Finance\FeeCalculationController; use App\Http\Controllers\Api\Finance\PaymentEventChargesController; use App\Http\Controllers\Api\Finance\PaymentManualController; use App\Http\Controllers\Api\Finance\PaypalPaymentController; @@ -177,6 +178,16 @@ Route::prefix('v1')->group(function () { Route::post('upload-image', [BroadcastEmailController::class, 'uploadImage']); }); + Route::prefix('email')->group(function () { + Route::get('senders', [EmailController::class, 'senders']); + Route::post('send', [EmailController::class, 'send']); + }); + + Route::prefix('email-extractor')->group(function () { + Route::get('emails', [EmailExtractorController::class, 'emails']); + Route::post('compare', [EmailExtractorController::class, 'compare']); + }); + Route::prefix('communications')->group(function () { Route::get('options', [CommunicationController::class, 'options']); Route::get('students/{studentId}/families', [CommunicationController::class, 'families']); @@ -216,6 +227,8 @@ Route::prefix('v1')->group(function () { Route::get('financial-report/csv', [FinancialController::class, 'downloadCsv']); Route::get('financial-report/pdf', [FinancialController::class, 'downloadPdf']); Route::get('unpaid-parents', [FinancialController::class, 'unpaidParents']); + Route::post('fees/refund', [FeeCalculationController::class, 'refund']); + Route::post('fees/tuition-total', [FeeCalculationController::class, 'tuitionTotal']); Route::prefix('reimbursements')->group(function () { Route::get('under-processing', [ReimbursementController::class, 'underProcessing']); @@ -235,6 +248,15 @@ Route::prefix('v1')->group(function () { Route::put('{id}', [ReimbursementController::class, 'update']); }); + Route::prefix('purchase-orders')->group(function () { + Route::get('/', [PurchaseOrderController::class, 'index']); + Route::get('options', [PurchaseOrderController::class, 'options']); + Route::get('{id}', [PurchaseOrderController::class, 'show']); + Route::post('/', [PurchaseOrderController::class, 'store']); + Route::post('{id}/receive', [PurchaseOrderController::class, 'receive']); + Route::post('{id}/cancel', [PurchaseOrderController::class, 'cancel']); + }); + Route::prefix('invoices')->group(function () { Route::get('management', [InvoiceController::class, 'management']); Route::post('generate', [InvoiceController::class, 'generate']); diff --git a/school_api b/school_api index 0b7194c90fc671c54dfa44868a02858d51090aea..de94e1d8663a02b41c2f6a0e06f779ba3dfceaca 100644 GIT binary patch delta 378 zcmZo@P-|#Vn;^}|GEv5vk)<)AHG#1;fvGitxix{MHG#D?fvq)xy)}VjYXavIdk!WZ z2L?Vq9*50>3MM=z&E8C$u?+H!;VrQ&zOF%qX<5Gc*)HZ;85RK^DFHby<_1PC*iLMwt@ zQv8El{7ijAO{M{FPIZikC=hW0muJklduF5y`&-AKHG7d9{ z3I_8le6#X1quedCk}FNCQjFa4qKv{Sr*H7*^kaG`vYpS5^BW@v6aR7s{^k5vfc}`x zZ>Yl@%Sf#InY5W>Ij2kcacWFU;A8InN delta 134 zcmZo@P-|#Vn;^}|JWcreateUser(); + Sanctum::actingAs($user); + + putenv('MAIL_SENDERS={"general":{"name":"General","email":"general@example.com"}}'); + + $response = $this->getJson('/api/v1/email/senders'); + + $response->assertOk(); + $response->assertJsonPath('ok', true); + $this->assertNotEmpty($response->json('senders')); + } + + public function test_send_endpoint_uses_dispatch_service(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $mock = Mockery::mock(EmailDispatchService::class); + $mock->shouldReceive('send') + ->once() + ->andReturn(true); + + $this->app->instance(EmailDispatchService::class, $mock); + + $response = $this->postJson('/api/v1/email/send', [ + 'recipient' => 'test@example.com', + 'subject' => 'Hello', + 'html_message' => '

Test

', + 'profile' => 'general', + ]); + + $response->assertOk(); + $response->assertJsonPath('ok', true); + } + + private function createUser(): User + { + DB::table('users')->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', + ]); + + return User::query()->findOrFail(1); + } +} diff --git a/tests/Feature/Api/V1/Email/EmailExtractorControllerTest.php b/tests/Feature/Api/V1/Email/EmailExtractorControllerTest.php new file mode 100644 index 00000000..871f50be --- /dev/null +++ b/tests/Feature/Api/V1/Email/EmailExtractorControllerTest.php @@ -0,0 +1,86 @@ +createUser(); + Sanctum::actingAs($user); + + DB::table('parents')->insert([ + 'secondparent_firstname' => 'Parent', + 'secondparent_lastname' => 'Two', + 'secondparent_email' => 'parent2@example.com', + 'secondparent_phone' => '5555555555', + 'firstparent_id' => 1, + 'secondparent_id' => 2, + 'school_year' => '2025-2026', + ]); + + $response = $this->getJson('/api/v1/email-extractor/emails'); + + $response->assertOk(); + $response->assertJsonPath('ok', true); + $this->assertContains('parent2@example.com', $response->json('emails.parents')); + } + + public function test_compare_endpoint_returns_comparison(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + DB::table('parents')->insert([ + 'secondparent_firstname' => 'Parent', + 'secondparent_lastname' => 'Two', + 'secondparent_email' => 'parent2@example.com', + 'secondparent_phone' => '5555555555', + 'firstparent_id' => 1, + 'secondparent_id' => 2, + 'school_year' => '2025-2026', + ]); + + $response = $this->postJson('/api/v1/email-extractor/compare', [ + 'csv_emails' => ['parent2@example.com', 'missing@example.com'], + ]); + + $response->assertOk(); + $response->assertJsonPath('ok', true); + $this->assertContains('parent2@example.com', $response->json('comparison.existed')); + } + + private function createUser(): User + { + DB::table('users')->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', + ]); + + return User::query()->findOrFail(1); + } +} diff --git a/tests/Feature/Api/V1/Finance/FeeCalculationControllerTest.php b/tests/Feature/Api/V1/Finance/FeeCalculationControllerTest.php new file mode 100644 index 00000000..6b36f562 --- /dev/null +++ b/tests/Feature/Api/V1/Finance/FeeCalculationControllerTest.php @@ -0,0 +1,123 @@ +createUser(); + Sanctum::actingAs($user); + + $this->seedConfig(); + $this->seedClassSection(); + + DB::table('payments')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'paid_amount' => 200.00, + 'total_amount' => 200.00, + 'balance' => 0.00, + 'number_of_installments' => 1, + 'payment_date' => '2025-01-10', + 'school_year' => '2025-2026', + 'status' => 'Paid', + ]); + + $response = $this->postJson('/api/v1/finance/fees/refund', [ + 'parent_id' => 10, + 'students' => [ + [ + 'student_id' => 1, + 'class_section_id' => 101, + 'enrollment_status' => 'withdrawn', + 'admission_status' => 'accepted', + 'withdrawal_date' => '2025-01-15', + ], + ], + ]); + + $response->assertOk(); + $response->assertJsonPath('ok', true); + $this->assertGreaterThan(0, (float) $response->json('refund.refund_amount')); + } + + public function test_tuition_total_endpoint_returns_total(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $this->seedConfig(); + $this->seedClassSection(); + + $response = $this->postJson('/api/v1/finance/fees/tuition-total', [ + 'students' => [ + ['class_section_id' => 101], + ['class_section_id' => 101], + ], + ]); + + $response->assertOk(); + $response->assertJsonPath('ok', true); + $this->assertGreaterThan(0, (float) $response->json('tuition.total_tuition')); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'refund_deadline', 'config_value' => '2025-02-01'], + ['config_key' => 'weeks_study', 'config_value' => '8'], + ['config_key' => 'last_school_day', 'config_value' => '2025-06-01'], + ['config_key' => 'first_student_fee', 'config_value' => '350'], + ['config_key' => 'second_student_fee', 'config_value' => '200'], + ['config_key' => 'youth_fee', 'config_value' => '180'], + ]); + } + + private function seedClassSection(): void + { + DB::table('classSection')->insert([ + 'id' => 1, + 'class_id' => 1, + 'class_section_id' => 101, + 'class_section_name' => '3', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + } + + private function createUser(): User + { + DB::table('users')->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', + ]); + + return User::query()->findOrFail(1); + } +} diff --git a/tests/Feature/Api/V1/Finance/PurchaseOrderControllerTest.php b/tests/Feature/Api/V1/Finance/PurchaseOrderControllerTest.php new file mode 100644 index 00000000..adeb4acf --- /dev/null +++ b/tests/Feature/Api/V1/Finance/PurchaseOrderControllerTest.php @@ -0,0 +1,212 @@ +seedUsers(); + $supplierId = DB::table('suppliers')->insertGetId(['name' => 'Alpha Supplies']); + DB::table('purchase_orders')->insert([ + 'id' => 1, + 'po_number' => 'PO-001', + 'supplier_id' => $supplierId, + 'status' => 'ordered', + 'subtotal' => 25, + 'tax' => 0, + 'total' => 25, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + Sanctum::actingAs(User::query()->findOrFail(1)); + + $response = $this->getJson('/api/v1/finance/purchase-orders?q=PO-001'); + + $response->assertOk(); + $response->assertJson(['ok' => true]); + $this->assertNotEmpty($response->json('orders')); + } + + public function test_options_returns_suppliers_and_supplies(): void + { + $this->seedUsers(); + DB::table('suppliers')->insert([ + ['id' => 1, 'name' => 'Alpha Supplies'], + ['id' => 2, 'name' => 'Beta Supplies'], + ]); + DB::table('supplies')->insert([ + ['id' => 1, 'name' => 'Paper', 'unit' => 'box', 'qty_on_hand' => 0], + ['id' => 2, 'name' => 'Ink', 'unit' => 'each', 'qty_on_hand' => 5], + ]); + + Sanctum::actingAs(User::query()->findOrFail(1)); + + $response = $this->getJson('/api/v1/finance/purchase-orders/options'); + + $response->assertOk(); + $response->assertJson(['ok' => true]); + $this->assertCount(2, $response->json('suppliers')); + $this->assertCount(2, $response->json('supplies')); + } + + public function test_store_creates_purchase_order_and_items(): void + { + $this->seedUsers(); + $supplierId = DB::table('suppliers')->insertGetId(['name' => 'Alpha Supplies']); + $supplyId = DB::table('supplies')->insertGetId(['name' => 'Paper', 'unit' => 'box', 'qty_on_hand' => 0]); + + Sanctum::actingAs(User::query()->findOrFail(1)); + + $response = $this->postJson('/api/v1/finance/purchase-orders', [ + 'po_number' => 'PO-100', + 'supplier_id' => $supplierId, + 'order_date' => '2026-03-10', + 'expected_date' => '2026-03-15', + 'notes' => 'Initial order', + 'items' => [ + [ + 'supply_id' => $supplyId, + 'description' => 'Paper for class', + 'quantity' => 2, + 'unit_cost' => 5, + ], + ], + ]); + + $response->assertStatus(201); + $response->assertJson(['ok' => true]); + + $this->assertDatabaseHas('purchase_orders', [ + 'po_number' => 'PO-100', + 'supplier_id' => $supplierId, + 'subtotal' => 10, + 'total' => 10, + ]); + $this->assertDatabaseHas('purchase_order_items', [ + 'supply_id' => $supplyId, + 'quantity' => 2, + 'unit_cost' => 5, + ]); + } + + public function test_receive_updates_inventory(): void + { + $this->seedUsers(); + $supplierId = DB::table('suppliers')->insertGetId(['name' => 'Alpha Supplies']); + $supplyId = DB::table('supplies')->insertGetId(['name' => 'Paper', 'unit' => 'box', 'qty_on_hand' => 1]); + $poId = DB::table('purchase_orders')->insertGetId([ + 'po_number' => 'PO-200', + 'supplier_id' => $supplierId, + 'status' => 'ordered', + 'subtotal' => 20, + 'tax' => 0, + 'total' => 20, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $itemId = DB::table('purchase_order_items')->insertGetId([ + 'purchase_order_id' => $poId, + 'supply_id' => $supplyId, + 'description' => 'Paper for class', + 'quantity' => 4, + 'received_qty' => 0, + 'unit_cost' => 5, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + Sanctum::actingAs(User::query()->findOrFail(1)); + + $response = $this->postJson("/api/v1/finance/purchase-orders/{$poId}/receive", [ + 'items' => [ + ['id' => $itemId, 'quantity' => 4], + ], + ]); + + $response->assertOk(); + $response->assertJson(['ok' => true, 'completed' => true]); + + $this->assertDatabaseHas('purchase_order_items', [ + 'id' => $itemId, + 'received_qty' => 4, + ]); + $this->assertDatabaseHas('supplies', [ + 'id' => $supplyId, + 'qty_on_hand' => 5, + ]); + $this->assertDatabaseHas('supply_transactions', [ + 'supply_id' => $supplyId, + 'type' => 'in', + 'quantity' => 4, + ]); + } + + public function test_cancel_marks_purchase_order(): void + { + $this->seedUsers(); + $supplierId = DB::table('suppliers')->insertGetId(['name' => 'Alpha Supplies']); + $poId = DB::table('purchase_orders')->insertGetId([ + 'po_number' => 'PO-300', + 'supplier_id' => $supplierId, + 'status' => 'ordered', + 'subtotal' => 0, + 'tax' => 0, + 'total' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + Sanctum::actingAs(User::query()->findOrFail(1)); + + $response = $this->postJson("/api/v1/finance/purchase-orders/{$poId}/cancel"); + + $response->assertOk(); + $response->assertJson(['ok' => true, 'status' => 'canceled']); + $this->assertDatabaseHas('purchase_orders', [ + 'id' => $poId, + 'status' => 'canceled', + ]); + } + + private function seedUsers(): void + { + DB::table('roles')->insert([ + ['id' => 1, 'name' => 'admin', 'slug' => 'admin', 'is_active' => 1], + ]); + + DB::table('users')->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('user_roles')->insert([ + ['user_id' => 1, 'role_id' => 1], + ]); + } +} diff --git a/tests/Feature/Api/V1/Scores/QuizControllerTest.php b/tests/Feature/Api/V1/Scores/QuizControllerTest.php index c1f6e22b..ebf28619 100644 --- a/tests/Feature/Api/V1/Scores/QuizControllerTest.php +++ b/tests/Feature/Api/V1/Scores/QuizControllerTest.php @@ -71,6 +71,8 @@ class QuizControllerTest extends TestCase 'class_section_id' => 1, 'class_section_name' => '1A', 'class_id' => 1, + 'semester' => 'Fall', + 'school_year' => '2025-2026', ]); DB::table('students')->insert([ @@ -79,6 +81,10 @@ class QuizControllerTest extends TestCase 'firstname' => 'Kid', 'lastname' => 'User', 'school_id' => 1, + 'age' => 10, + 'gender' => 'M', + 'photo_consent' => 1, + 'year_of_registration' => '2025', 'is_active' => 1, ]); @@ -87,6 +93,7 @@ class QuizControllerTest extends TestCase 'class_section_id' => 1, 'school_year' => '2025-2026', 'is_event_only' => 0, + 'semester' => 'Fall', ]); } } diff --git a/tests/Feature/Api/V1/Scores/ScoreCommentControllerTest.php b/tests/Feature/Api/V1/Scores/ScoreCommentControllerTest.php index 81e2ce21..df12b1ca 100644 --- a/tests/Feature/Api/V1/Scores/ScoreCommentControllerTest.php +++ b/tests/Feature/Api/V1/Scores/ScoreCommentControllerTest.php @@ -47,7 +47,11 @@ class ScoreCommentControllerTest extends TestCase 'created_at' => now(), ]); - $response = $this->getJson('/api/v1/scores/comments?class_section_id=1&semester=Fall&school_year=2025-2026'); + $response = $this->json('GET', '/api/v1/scores/comments', [ + 'class_section_id' => 1, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); $response->assertOk(); $response->assertJson(['ok' => true]); @@ -60,6 +64,11 @@ class ScoreCommentControllerTest extends TestCase ['id' => 1, 'name' => 'teacher', 'slug' => 'teacher', 'is_active' => 1], ]); + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + DB::table('users')->insert([ 'id' => 1, 'school_id' => 1, @@ -90,6 +99,8 @@ class ScoreCommentControllerTest extends TestCase 'class_section_id' => 1, 'class_section_name' => '1A', 'class_id' => 1, + 'semester' => 'Fall', + 'school_year' => '2025-2026', ]); DB::table('students')->insert([ @@ -98,6 +109,10 @@ class ScoreCommentControllerTest extends TestCase 'firstname' => 'Kid', 'lastname' => 'User', 'school_id' => 1, + 'age' => 10, + 'gender' => 'M', + 'photo_consent' => 1, + 'year_of_registration' => '2025', 'is_active' => 1, ]); @@ -106,6 +121,7 @@ class ScoreCommentControllerTest extends TestCase 'class_section_id' => 1, 'school_year' => '2025-2026', 'is_event_only' => 0, + 'semester' => 'Fall', ]); } } diff --git a/tests/Feature/Api/V1/Scores/ScoreControllerTest.php b/tests/Feature/Api/V1/Scores/ScoreControllerTest.php index 30a16353..5053493e 100644 --- a/tests/Feature/Api/V1/Scores/ScoreControllerTest.php +++ b/tests/Feature/Api/V1/Scores/ScoreControllerTest.php @@ -24,7 +24,11 @@ class ScoreControllerTest extends TestCase 'semester' => 'Fall', ]); - $response = $this->getJson('/api/v1/scores/overview?class_section_id=1&semester=Fall&school_year=2025-2026'); + $response = $this->json('GET', '/api/v1/scores/overview', [ + 'class_section_id' => 1, + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); $response->assertOk(); $response->assertJson(['ok' => true]); diff --git a/tests/Feature/Api/V1/Users/UserControllerTest.php b/tests/Feature/Api/V1/Users/UserControllerTest.php index bb2f1f73..638b6ce8 100644 --- a/tests/Feature/Api/V1/Users/UserControllerTest.php +++ b/tests/Feature/Api/V1/Users/UserControllerTest.php @@ -116,7 +116,10 @@ class UserControllerTest extends TestCase 'school_year' => '2025-2026', ]); - $response = $this->getJson('/api/v1/users/login-activity?per_page=1&page=2'); + $response = $this->json('GET', '/api/v1/users/login-activity', [ + 'per_page' => 1, + 'page' => 2, + ]); $response->assertOk(); $response->assertJson(['ok' => true]); diff --git a/tests/Unit/Services/Email/EmailExtractorServiceTest.php b/tests/Unit/Services/Email/EmailExtractorServiceTest.php new file mode 100644 index 00000000..0ff9cb3a --- /dev/null +++ b/tests/Unit/Services/Email/EmailExtractorServiceTest.php @@ -0,0 +1,58 @@ +insert([ + 'id' => 1, + 'school_id' => 1, + 'firstname' => 'User', + 'lastname' => 'One', + 'cellphone' => '5555555555', + 'email' => 'user1@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('parents')->insert([ + 'secondparent_firstname' => 'Parent', + 'secondparent_lastname' => 'Two', + 'secondparent_email' => 'parent2@example.com', + 'secondparent_phone' => '5555555555', + 'firstparent_id' => 1, + 'secondparent_id' => 2, + 'school_year' => '2025-2026', + ]); + + $service = new EmailExtractorService(); + $emails = $service->listEmails(); + + $this->assertContains('user1@example.com', $emails['users']); + $this->assertContains('parent2@example.com', $emails['parents']); + + $result = $service->compare(['user1@example.com', 'missing@example.com']); + + $this->assertContains('user1@example.com', $result['existed']); + $this->assertContains('parent2@example.com', $result['needToAdd']); + } +} diff --git a/tests/Unit/Services/Email/EmailProfileServiceTest.php b/tests/Unit/Services/Email/EmailProfileServiceTest.php new file mode 100644 index 00000000..0306aca8 --- /dev/null +++ b/tests/Unit/Services/Email/EmailProfileServiceTest.php @@ -0,0 +1,33 @@ +assertSame('communication', $service->resolveProfile('comm')); + $this->assertSame('default', $service->resolveProfile('system')); + $this->assertSame('payment', $service->resolveProfile('payment')); + } + + public function test_env_key_from_profile(): void + { + $service = new EmailProfileService(); + + $this->assertSame('PAYMENT_ALERTS', $service->envKeyFromProfile('payment alerts')); + } + + public function test_sanitize_reply_to_name(): void + { + $service = new EmailProfileService(); + + $this->assertSame('Fallback', $service->sanitizeReplyToName('no-reply', 'Fallback')); + $this->assertSame('Support', $service->sanitizeReplyToName('Support', 'Fallback')); + } +} diff --git a/tests/Unit/Services/Fees/FeeGradeServiceTest.php b/tests/Unit/Services/Fees/FeeGradeServiceTest.php new file mode 100644 index 00000000..9ba6a0be --- /dev/null +++ b/tests/Unit/Services/Fees/FeeGradeServiceTest.php @@ -0,0 +1,28 @@ +assertSame(0, $service->getGradeLevel('K')); + $this->assertSame(99, $service->getGradeLevel('Y')); + $this->assertSame(3, $service->getGradeLevel('3-A')); + $this->assertSame(999, $service->getGradeLevel('Unknown')); + } + + public function test_compare_grades_orders_numeric_then_suffix(): void + { + $service = new FeeGradeService(); + + $this->assertSame(-1, $service->compareGrades('2', '3')); + $this->assertSame(-1, $service->compareGrades('3-A', '3-B')); + $this->assertSame(1, $service->compareGrades('4', '3')); + } +} diff --git a/tests/Unit/Services/Fees/FeeRefundCalculatorServiceTest.php b/tests/Unit/Services/Fees/FeeRefundCalculatorServiceTest.php new file mode 100644 index 00000000..51b549c1 --- /dev/null +++ b/tests/Unit/Services/Fees/FeeRefundCalculatorServiceTest.php @@ -0,0 +1,67 @@ +insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'refund_deadline', 'config_value' => '2025-02-01'], + ['config_key' => 'weeks_study', 'config_value' => '8'], + ['config_key' => 'last_school_day', 'config_value' => '2025-06-01'], + ['config_key' => 'first_student_fee', 'config_value' => '350'], + ['config_key' => 'second_student_fee', 'config_value' => '200'], + ['config_key' => 'youth_fee', 'config_value' => '180'], + ]); + + DB::table('classSection')->insert([ + 'id' => 1, + 'class_id' => 1, + 'class_section_id' => 101, + 'class_section_name' => '3', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + DB::table('payments')->insert([ + 'id' => 1, + 'parent_id' => 99, + 'paid_amount' => 100.00, + 'total_amount' => 100.00, + 'balance' => 0.00, + 'number_of_installments' => 1, + 'payment_date' => '2025-01-10', + 'school_year' => '2025-2026', + 'status' => 'Paid', + ]); + + $service = new FeeRefundCalculatorService( + new FeeConfigService(), + new FeeStudentFeeService(new FeeGradeService(), new FeeConfigService()) + ); + + $result = $service->calculateRefund([ + [ + 'student_id' => 1, + 'class_section_id' => 101, + 'enrollment_status' => 'withdrawn', + 'admission_status' => 'accepted', + 'withdrawal_date' => '2025-01-15', + ], + ], 99); + + $this->assertSame(100.0, $result['refund_amount']); + } +} diff --git a/tests/Unit/Services/PurchaseOrders/PurchaseOrderCreateServiceTest.php b/tests/Unit/Services/PurchaseOrders/PurchaseOrderCreateServiceTest.php new file mode 100644 index 00000000..332dd0ab --- /dev/null +++ b/tests/Unit/Services/PurchaseOrders/PurchaseOrderCreateServiceTest.php @@ -0,0 +1,50 @@ +insertGetId(['name' => 'Alpha Supplies']); + $supplyId = DB::table('supplies')->insertGetId(['name' => 'Paper', 'unit' => 'box', 'qty_on_hand' => 0]); + + $service = new PurchaseOrderCreateService(); + $po = $service->create([ + 'po_number' => 'PO-500', + 'supplier_id' => $supplierId, + 'order_date' => '2026-03-10', + 'expected_date' => '2026-03-15', + 'notes' => 'Initial order', + 'items' => [ + [ + 'supply_id' => $supplyId, + 'description' => 'Paper for class', + 'quantity' => 3, + 'unit_cost' => 4, + ], + ], + ]); + + $this->assertNotNull($po->id); + $this->assertDatabaseHas('purchase_orders', [ + 'id' => $po->id, + 'po_number' => 'PO-500', + 'subtotal' => 12, + 'total' => 12, + ]); + $this->assertDatabaseHas('purchase_order_items', [ + 'purchase_order_id' => $po->id, + 'supply_id' => $supplyId, + 'quantity' => 3, + 'unit_cost' => 4, + ]); + } +} diff --git a/tests/Unit/Services/PurchaseOrders/PurchaseOrderReceiveServiceTest.php b/tests/Unit/Services/PurchaseOrders/PurchaseOrderReceiveServiceTest.php new file mode 100644 index 00000000..f560dd72 --- /dev/null +++ b/tests/Unit/Services/PurchaseOrders/PurchaseOrderReceiveServiceTest.php @@ -0,0 +1,62 @@ +create([ + 'name' => 'Paper', + 'unit' => 'box', + 'qty_on_hand' => 2, + ]); + + $po = PurchaseOrder::query()->create([ + 'po_number' => 'PO-600', + 'supplier_id' => 1, + 'status' => PurchaseOrder::STATUS_ORDERED, + 'subtotal' => 10, + 'tax' => 0, + 'total' => 10, + ]); + + $item = PurchaseOrderItem::query()->create([ + 'purchase_order_id' => $po->id, + 'supply_id' => $supply->id, + 'description' => 'Paper for class', + 'quantity' => 3, + 'received_qty' => 0, + 'unit_cost' => 3, + ]); + + $service = new PurchaseOrderReceiveService(); + $result = $service->receive($po->id, [ + ['id' => $item->id, 'quantity' => 3], + ], 'tester@example.com'); + + $this->assertTrue($result['completed']); + $this->assertDatabaseHas('purchase_order_items', [ + 'id' => $item->id, + 'received_qty' => 3, + ]); + $this->assertDatabaseHas('supplies', [ + 'id' => $supply->id, + 'qty_on_hand' => 5, + ]); + $this->assertDatabaseHas('supply_transactions', [ + 'supply_id' => $supply->id, + 'type' => 'in', + 'quantity' => 3, + ]); + } +} diff --git a/tests/Unit/Services/PurchaseOrders/PurchaseOrderStatusServiceTest.php b/tests/Unit/Services/PurchaseOrders/PurchaseOrderStatusServiceTest.php new file mode 100644 index 00000000..37d0532c --- /dev/null +++ b/tests/Unit/Services/PurchaseOrders/PurchaseOrderStatusServiceTest.php @@ -0,0 +1,34 @@ +create([ + 'po_number' => 'PO-700', + 'supplier_id' => 1, + 'status' => PurchaseOrder::STATUS_ORDERED, + 'subtotal' => 0, + 'tax' => 0, + 'total' => 0, + ]); + + $service = new PurchaseOrderStatusService(); + $updated = $service->cancel($po->id); + + $this->assertSame(PurchaseOrder::STATUS_CANCELED, $updated->status); + $this->assertDatabaseHas('purchase_orders', [ + 'id' => $po->id, + 'status' => PurchaseOrder::STATUS_CANCELED, + ]); + } +}