From f6be51576c0e1d5f8d3a619e42b5c38dd7b328ea Mon Sep 17 00:00:00 2001 From: root Date: Tue, 10 Mar 2026 23:12:49 -0400 Subject: [PATCH] add refund and event logic --- .../Api/Finance/FeeCalculationController.php | 60 +- .../Api/Finance/RefundController.php | 186 +++++ .../Api/Settings/EventController.php | 166 ++++ .../Events/EventChargeIndexRequest.php | 25 + .../Events/EventChargeUpdateRequest.php | 24 + .../Requests/Events/EventIndexRequest.php | 27 + .../Requests/Events/EventStoreRequest.php | 29 + .../EventStudentsWithChargesRequest.php | 22 + .../Requests/Events/EventUpdateRequest.php | 29 + .../Requests/Finance/FeeRefundRequest.php | 26 + .../Finance/FeeTuitionTotalRequest.php | 22 + .../Refunds/RefundDecisionRequest.php | 25 + .../Requests/Refunds/RefundIndexRequest.php | 38 + .../Requests/Refunds/RefundPaymentRequest.php | 23 + .../Refunds/RefundRecalculateRequest.php | 20 + .../Requests/Refunds/RefundRejectRequest.php | 20 + .../Requests/Refunds/RefundStoreRequest.php | 27 + .../Resources/Events/EventChargeResource.php | 28 + app/Http/Resources/Events/EventResource.php | 27 + .../Events/EventStudentChargeResource.php | 18 + app/Http/Resources/Refunds/RefundResource.php | 36 + app/Services/Billing/BillingTotalsService.php | 52 ++ app/Services/Docs/OpenApiRouteExporter.php | 5 + app/Services/Events/EventCategoryService.php | 16 + .../Events/EventChargeQueryService.php | 60 ++ app/Services/Events/EventChargeService.php | 169 ++++ app/Services/Events/EventListService.php | 89 ++ .../Events/EventManagementService.php | 116 +++ .../Events/EventStudentChargeService.php | 40 + app/Services/Fees/FeeRefundDetailService.php | 322 +++++++ app/Services/Invoices/InvoiceService.php | 76 ++ .../Refunds/RefundDecisionService.php | 61 ++ .../RefundInvoiceAdjustmentService.php | 92 ++ .../Refunds/RefundNotificationService.php | 17 + .../Refunds/RefundOverpaymentService.php | 390 +++++++++ app/Services/Refunds/RefundPayoutService.php | 177 ++++ app/Services/Refunds/RefundPolicyService.php | 204 +++++ app/Services/Refunds/RefundQueryService.php | 83 ++ app/Services/Refunds/RefundRequestService.php | 126 +++ app/Services/Refunds/RefundSummaryService.php | 46 + app/old/FeeCalculationService.php | 183 ---- app/old/RefundController.php | 785 ------------------ routes/api.php | 25 + school_api | Bin 622592 -> 622592 bytes .../Finance/FeeCalculationControllerTest.php | 114 +++ .../Api/V1/Finance/RefundControllerTest.php | 335 ++++++++ .../Api/V1/Settings/EventControllerTest.php | 361 ++++++++ .../Billing/BillingTotalsServiceTest.php | 61 ++ .../Events/EventCategoryServiceTest.php | 17 + .../Events/EventChargeQueryServiceTest.php | 34 + .../Events/EventChargeServiceTest.php | 59 ++ .../Services/Events/EventListServiceTest.php | 46 + .../Events/EventManagementServiceTest.php | 26 + .../Events/EventStudentChargeServiceTest.php | 39 + .../Services/FeeCalculationServiceTest.php | 24 + .../Fees/FeeRefundDetailServiceTest.php | 71 ++ .../Fees/FeeStudentFeeServiceTest.php | 52 ++ .../Services/Invoices/InvoiceServiceTest.php | 47 ++ .../Refunds/RefundDecisionServiceTest.php | 38 + .../RefundInvoiceAdjustmentServiceTest.php | 51 ++ .../Refunds/RefundNotificationServiceTest.php | 23 + .../Refunds/RefundOverpaymentServiceTest.php | 57 ++ .../Refunds/RefundPayoutServiceTest.php | 42 + .../Refunds/RefundPolicyServiceTest.php | 104 +++ .../Refunds/RefundQueryServiceTest.php | 49 ++ .../Refunds/RefundRequestServiceTest.php | 82 ++ .../Refunds/RefundSummaryServiceTest.php | 64 ++ 67 files changed, 4808 insertions(+), 1000 deletions(-) create mode 100644 app/Http/Controllers/Api/Finance/RefundController.php create mode 100644 app/Http/Controllers/Api/Settings/EventController.php create mode 100644 app/Http/Requests/Events/EventChargeIndexRequest.php create mode 100644 app/Http/Requests/Events/EventChargeUpdateRequest.php create mode 100644 app/Http/Requests/Events/EventIndexRequest.php create mode 100644 app/Http/Requests/Events/EventStoreRequest.php create mode 100644 app/Http/Requests/Events/EventStudentsWithChargesRequest.php create mode 100644 app/Http/Requests/Events/EventUpdateRequest.php create mode 100644 app/Http/Requests/Finance/FeeRefundRequest.php create mode 100644 app/Http/Requests/Finance/FeeTuitionTotalRequest.php create mode 100644 app/Http/Requests/Refunds/RefundDecisionRequest.php create mode 100644 app/Http/Requests/Refunds/RefundIndexRequest.php create mode 100644 app/Http/Requests/Refunds/RefundPaymentRequest.php create mode 100644 app/Http/Requests/Refunds/RefundRecalculateRequest.php create mode 100644 app/Http/Requests/Refunds/RefundRejectRequest.php create mode 100644 app/Http/Requests/Refunds/RefundStoreRequest.php create mode 100644 app/Http/Resources/Events/EventChargeResource.php create mode 100644 app/Http/Resources/Events/EventResource.php create mode 100644 app/Http/Resources/Events/EventStudentChargeResource.php create mode 100644 app/Http/Resources/Refunds/RefundResource.php create mode 100644 app/Services/Billing/BillingTotalsService.php create mode 100644 app/Services/Events/EventCategoryService.php create mode 100644 app/Services/Events/EventChargeQueryService.php create mode 100644 app/Services/Events/EventChargeService.php create mode 100644 app/Services/Events/EventListService.php create mode 100644 app/Services/Events/EventManagementService.php create mode 100644 app/Services/Events/EventStudentChargeService.php create mode 100644 app/Services/Fees/FeeRefundDetailService.php create mode 100644 app/Services/Invoices/InvoiceService.php create mode 100644 app/Services/Refunds/RefundDecisionService.php create mode 100644 app/Services/Refunds/RefundInvoiceAdjustmentService.php create mode 100644 app/Services/Refunds/RefundNotificationService.php create mode 100644 app/Services/Refunds/RefundOverpaymentService.php create mode 100644 app/Services/Refunds/RefundPayoutService.php create mode 100644 app/Services/Refunds/RefundPolicyService.php create mode 100644 app/Services/Refunds/RefundQueryService.php create mode 100644 app/Services/Refunds/RefundRequestService.php create mode 100644 app/Services/Refunds/RefundSummaryService.php delete mode 100644 app/old/FeeCalculationService.php delete mode 100644 app/old/RefundController.php create mode 100644 tests/Feature/Api/V1/Finance/RefundControllerTest.php create mode 100644 tests/Feature/Api/V1/Settings/EventControllerTest.php create mode 100644 tests/Unit/Services/Billing/BillingTotalsServiceTest.php create mode 100644 tests/Unit/Services/Events/EventCategoryServiceTest.php create mode 100644 tests/Unit/Services/Events/EventChargeQueryServiceTest.php create mode 100644 tests/Unit/Services/Events/EventChargeServiceTest.php create mode 100644 tests/Unit/Services/Events/EventListServiceTest.php create mode 100644 tests/Unit/Services/Events/EventManagementServiceTest.php create mode 100644 tests/Unit/Services/Events/EventStudentChargeServiceTest.php create mode 100644 tests/Unit/Services/FeeCalculationServiceTest.php create mode 100644 tests/Unit/Services/Fees/FeeRefundDetailServiceTest.php create mode 100644 tests/Unit/Services/Fees/FeeStudentFeeServiceTest.php create mode 100644 tests/Unit/Services/Invoices/InvoiceServiceTest.php create mode 100644 tests/Unit/Services/Refunds/RefundDecisionServiceTest.php create mode 100644 tests/Unit/Services/Refunds/RefundInvoiceAdjustmentServiceTest.php create mode 100644 tests/Unit/Services/Refunds/RefundNotificationServiceTest.php create mode 100644 tests/Unit/Services/Refunds/RefundOverpaymentServiceTest.php create mode 100644 tests/Unit/Services/Refunds/RefundPayoutServiceTest.php create mode 100644 tests/Unit/Services/Refunds/RefundPolicyServiceTest.php create mode 100644 tests/Unit/Services/Refunds/RefundQueryServiceTest.php create mode 100644 tests/Unit/Services/Refunds/RefundRequestServiceTest.php create mode 100644 tests/Unit/Services/Refunds/RefundSummaryServiceTest.php diff --git a/app/Http/Controllers/Api/Finance/FeeCalculationController.php b/app/Http/Controllers/Api/Finance/FeeCalculationController.php index dd7dbf70..8aa1263d 100644 --- a/app/Http/Controllers/Api/Finance/FeeCalculationController.php +++ b/app/Http/Controllers/Api/Finance/FeeCalculationController.php @@ -3,13 +3,14 @@ namespace App\Http\Controllers\Api\Finance; use App\Http\Controllers\Api\BaseApiController; +use App\Http\Requests\Finance\FeeRefundRequest; +use App\Http\Requests\Finance\FeeTuitionTotalRequest; use App\Http\Resources\Fees\FeeRefundResource; use App\Http\Resources\Fees\FeeTuitionTotalResource; use App\Services\Fees\FeeRefundCalculatorService; use App\Services\Fees\FeeStudentFeeService; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Facades\Log; class FeeCalculationController extends BaseApiController { @@ -20,52 +21,47 @@ class FeeCalculationController extends BaseApiController parent::__construct(); } - public function refund(Request $request): JsonResponse + public function refund(FeeRefundRequest $request): JsonResponse { - $validator = Validator::make($request->all(), [ - 'parent_id' => ['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'], - ]); + $payload = $request->validated(); + + try { + $result = $this->refunds->calculateRefund($payload['students'], (int) $payload['parent_id']); + } catch (\Throwable $e) { + Log::error('Fee refund calculation failed', [ + 'parent_id' => $payload['parent_id'] ?? null, + 'exception' => $e, + ]); - if ($validator->fails()) { return response()->json([ - 'message' => 'Validation failed.', - 'errors' => $validator->errors(), - ], 422); + 'ok' => false, + 'message' => 'Unable to calculate refund.', + ], 500); } - $payload = $validator->validated(); - $result = $this->refunds->calculateRefund($payload['students'], (int) $payload['parent_id']); - return response()->json([ 'ok' => true, 'refund' => new FeeRefundResource($result), ]); } - public function tuitionTotal(Request $request): JsonResponse + public function tuitionTotal(FeeTuitionTotalRequest $request): JsonResponse { - $validator = Validator::make($request->all(), [ - 'students' => ['required', 'array'], - 'students.*.class_section_id' => ['required', 'integer', 'min:1'], - 'students.*.grade' => ['nullable', 'string', 'max:20'], - ]); + $payload = $request->validated(); + + try { + $total = $this->tuition->totalTuitionFee($payload['students']); + } catch (\Throwable $e) { + Log::error('Fee tuition total calculation failed', [ + 'exception' => $e, + ]); - if ($validator->fails()) { return response()->json([ - 'message' => 'Validation failed.', - 'errors' => $validator->errors(), - ], 422); + 'ok' => false, + 'message' => 'Unable to calculate tuition total.', + ], 500); } - $payload = $validator->validated(); - $total = $this->tuition->totalTuitionFee($payload['students']); - return response()->json([ 'ok' => true, 'tuition' => new FeeTuitionTotalResource([ diff --git a/app/Http/Controllers/Api/Finance/RefundController.php b/app/Http/Controllers/Api/Finance/RefundController.php new file mode 100644 index 00000000..4334e904 --- /dev/null +++ b/app/Http/Controllers/Api/Finance/RefundController.php @@ -0,0 +1,186 @@ +validated(); + $result = $this->queryService->list($payload); + + return response()->json([ + 'ok' => true, + 'refunds' => RefundResource::collection($result['refunds']), + 'pagination' => $result['pagination'], + ]); + } + + public function show(int $refundId): JsonResponse + { + $refund = $this->queryService->find($refundId); + if (!$refund) { + return response()->json(['ok' => false, 'message' => 'Refund not found.'], 404); + } + + return response()->json([ + 'ok' => true, + 'refund' => new RefundResource($refund), + ]); + } + + public function store(RefundStoreRequest $request): JsonResponse + { + $payload = $request->validated(); + $actorId = (int) (auth()->id() ?? 0); + + try { + $result = $this->requestService->requestRefund($payload, $actorId); + } catch (\Throwable $e) { + Log::error('Refund request failed.', ['error' => $e->getMessage()]); + return response()->json(['ok' => false, 'message' => 'Failed to submit refund request.'], 500); + } + + if (empty($result['ok'])) { + return response()->json([ + 'ok' => false, + 'message' => $result['message'] ?? 'Failed to submit refund request.', + 'available' => $result['available'] ?? null, + ], 422); + } + + return response()->json([ + 'ok' => true, + 'refund_id' => $result['refund_id'] ?? null, + 'refund_amount' => $result['refund_amount'] ?? null, + 'status' => $result['status'] ?? null, + ], 201); + } + + public function update(RefundDecisionRequest $request, int $refundId): JsonResponse + { + $payload = $request->validated(); + $actorId = (int) (auth()->id() ?? 0); + + $result = $this->decisionService->updateDecision( + $refundId, + (string) $payload['status'], + (string) $payload['reason'], + $actorId + ); + + if (empty($result['ok'])) { + return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to update refund.'], 422); + } + + return response()->json(['ok' => true]); + } + + public function destroy(int $refundId): JsonResponse + { + $actorId = (int) (auth()->id() ?? 0); + $result = $this->decisionService->cancel($refundId, $actorId); + + if (empty($result['ok'])) { + return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to cancel refund.'], 404); + } + + return response()->json(['ok' => true]); + } + + public function approve(int $refundId): JsonResponse + { + $actorId = (int) (auth()->id() ?? 0); + $result = $this->decisionService->approve($refundId, $actorId); + + if (empty($result['ok'])) { + return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Approve failed.'], 422); + } + + return response()->json(['ok' => true]); + } + + public function reject(RefundRejectRequest $request, int $refundId): JsonResponse + { + $payload = $request->validated(); + $actorId = (int) (auth()->id() ?? 0); + $result = $this->decisionService->reject($refundId, (string) $payload['reason'], $actorId); + + if (empty($result['ok'])) { + return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Reject failed.'], 422); + } + + return response()->json(['ok' => true]); + } + + public function recordPayment(RefundPaymentRequest $request, int $refundId): JsonResponse + { + $payload = $request->validated(); + $payload['check_file'] = $request->file('check_file'); + $actorId = (int) (auth()->id() ?? 0); + + $result = $this->payoutService->recordPayment($refundId, $payload, $actorId); + if (empty($result['ok'])) { + return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to update payment.'], 422); + } + + return response()->json(['ok' => true]); + } + + public function recalculateOverpayments(RefundRecalculateRequest $request): JsonResponse + { + $payload = $request->validated(); + $actorId = (int) (auth()->id() ?? 0); + + $result = $this->overpaymentService->recalculate(true, $payload['invoice_number'] ?? null, $actorId); + + return response()->json([ + 'ok' => true, + 'created_ids' => $result['created_ids'] ?? null, + 'updated_ids' => $result['updated_ids'] ?? null, + 'message' => $result['message'] ?? null, + ]); + } + + public function parentBalances(int $parentId): JsonResponse + { + $termSchoolYear = (string) (Configuration::getConfig('school_year') ?? ''); + $termSemester = (string) (Configuration::getConfig('semester') ?? ''); + + $summary = $this->summaryService->getParentFinancialSummary($parentId, $termSchoolYear, $termSemester); + + return response()->json([ + 'ok' => true, + 'summary' => $summary, + ]); + } +} diff --git a/app/Http/Controllers/Api/Settings/EventController.php b/app/Http/Controllers/Api/Settings/EventController.php new file mode 100644 index 00000000..80af70c6 --- /dev/null +++ b/app/Http/Controllers/Api/Settings/EventController.php @@ -0,0 +1,166 @@ +validated(); + $result = $this->listService->list($payload); + + return response()->json([ + 'ok' => true, + 'events' => EventResource::collection($result['events']), + 'pagination' => $result['pagination'], + 'active_count' => $result['active_count'], + 'categories' => EventCategoryService::categories(), + ]); + } + + public function show(int $eventId): JsonResponse + { + $event = $this->listService->find($eventId); + if (!$event) { + return response()->json(['ok' => false, 'message' => 'Event not found.'], 404); + } + + return response()->json([ + 'ok' => true, + 'event' => new EventResource($event), + ]); + } + + public function store(EventStoreRequest $request): JsonResponse + { + $payload = $request->validated(); + $actorId = (int) (auth()->id() ?? 0); + + try { + $result = $this->managementService->create($payload, $request->file('flyer'), $actorId); + } catch (\Throwable $e) { + Log::error('Event creation failed.', ['error' => $e->getMessage()]); + return response()->json(['ok' => false, 'message' => 'Failed to create event.'], 500); + } + + if (empty($result['ok'])) { + return response()->json(['ok' => false, 'message' => 'Failed to create event.'], 422); + } + + return response()->json([ + 'ok' => true, + 'event' => new EventResource($result['event']), + 'charges_created' => $result['charges_created'] ?? 0, + ], 201); + } + + public function update(EventUpdateRequest $request, int $eventId): JsonResponse + { + $payload = $request->validated(); + + $result = $this->managementService->update($eventId, $payload, $request->file('flyer')); + if (empty($result['ok'])) { + return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to update event.'], 404); + } + + return response()->json([ + 'ok' => true, + 'event' => new EventResource($result['event']), + ]); + } + + public function destroy(int $eventId): JsonResponse + { + $actorId = (int) (auth()->id() ?? 0); + $result = $this->managementService->delete($eventId, $actorId); + + if (empty($result['ok'])) { + return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to delete event.'], 404); + } + + return response()->json(['ok' => true]); + } + + public function charges(EventChargeIndexRequest $request): JsonResponse + { + $payload = $request->validated(); + $result = $this->chargeQueryService->list($payload); + + return response()->json([ + 'ok' => true, + 'charges' => EventChargeResource::collection($result['charges']), + 'pagination' => $result['pagination'], + ]); + } + + public function updateCharges(EventChargeUpdateRequest $request, int $eventId): JsonResponse + { + $payload = $request->validated(); + $actorId = (int) (auth()->id() ?? 0); + + $result = $this->chargeService->updateParticipation( + (int) $payload['parent_id'], + $eventId, + $payload['participation'], + (string) $payload['school_year'], + (string) $payload['semester'], + $actorId + ); + + if (empty($result['ok'])) { + return response()->json(['ok' => false, 'message' => $result['message'] ?? 'Failed to update charges.'], 422); + } + + return response()->json([ + 'ok' => true, + 'created' => $result['created'], + 'updated' => $result['updated'], + 'deleted' => $result['deleted'], + ]); + } + + public function studentsWithCharges(EventStudentsWithChargesRequest $request): JsonResponse + { + $payload = $request->validated(); + + $students = $this->studentChargeService->listStudentsWithCharges( + (int) $payload['parent_id'], + (string) $payload['school_year'], + (string) $payload['semester'] + ); + + return response()->json([ + 'ok' => true, + 'students' => EventStudentChargeResource::collection($students), + ]); + } +} diff --git a/app/Http/Requests/Events/EventChargeIndexRequest.php b/app/Http/Requests/Events/EventChargeIndexRequest.php new file mode 100644 index 00000000..11343b77 --- /dev/null +++ b/app/Http/Requests/Events/EventChargeIndexRequest.php @@ -0,0 +1,25 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'school_year' => ['nullable', 'string', 'max:20'], + 'semester' => ['nullable', 'string', 'max:20'], + 'parent_id' => ['nullable', 'integer', 'min:1'], + 'event_id' => ['nullable', 'integer', 'min:1'], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:200'], + ]; + } +} diff --git a/app/Http/Requests/Events/EventChargeUpdateRequest.php b/app/Http/Requests/Events/EventChargeUpdateRequest.php new file mode 100644 index 00000000..fe1fe486 --- /dev/null +++ b/app/Http/Requests/Events/EventChargeUpdateRequest.php @@ -0,0 +1,24 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'parent_id' => ['required', 'integer', 'min:1'], + 'school_year' => ['required', 'string', 'max:20'], + 'semester' => ['required', 'string', 'max:20'], + 'participation' => ['required', 'array'], + 'participation.*' => ['required', 'string', 'in:yes,no'], + ]; + } +} diff --git a/app/Http/Requests/Events/EventIndexRequest.php b/app/Http/Requests/Events/EventIndexRequest.php new file mode 100644 index 00000000..3756f5e8 --- /dev/null +++ b/app/Http/Requests/Events/EventIndexRequest.php @@ -0,0 +1,27 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'school_year' => ['nullable', 'string', 'max:20'], + 'semester' => ['nullable', 'string', 'max:20'], + 'q' => ['nullable', 'string', 'max:200'], + 'active' => ['nullable', 'boolean'], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:200'], + 'sort_by' => ['nullable', 'string', 'in:created_at,expiration_date,event_name'], + 'sort_dir' => ['nullable', 'string', 'in:asc,desc'], + ]; + } +} diff --git a/app/Http/Requests/Events/EventStoreRequest.php b/app/Http/Requests/Events/EventStoreRequest.php new file mode 100644 index 00000000..486a094e --- /dev/null +++ b/app/Http/Requests/Events/EventStoreRequest.php @@ -0,0 +1,29 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'event_name' => ['required', 'string', 'max:150'], + 'event_category' => ['required', 'string', Rule::in(EventCategoryService::categories())], + 'description' => ['nullable', 'string', 'max:2000'], + 'amount' => ['required', 'numeric', 'min:0'], + 'flyer' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png'], + 'expiration_date' => ['required', 'date'], + 'semester' => ['required', 'string', 'max:20'], + 'school_year' => ['required', 'string', 'max:20'], + ]; + } +} diff --git a/app/Http/Requests/Events/EventStudentsWithChargesRequest.php b/app/Http/Requests/Events/EventStudentsWithChargesRequest.php new file mode 100644 index 00000000..c79a43a7 --- /dev/null +++ b/app/Http/Requests/Events/EventStudentsWithChargesRequest.php @@ -0,0 +1,22 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'parent_id' => ['required', 'integer', 'min:1'], + 'school_year' => ['required', 'string', 'max:20'], + 'semester' => ['required', 'string', 'max:20'], + ]; + } +} diff --git a/app/Http/Requests/Events/EventUpdateRequest.php b/app/Http/Requests/Events/EventUpdateRequest.php new file mode 100644 index 00000000..da8491d7 --- /dev/null +++ b/app/Http/Requests/Events/EventUpdateRequest.php @@ -0,0 +1,29 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'event_name' => ['sometimes', 'required', 'string', 'max:150'], + 'event_category' => ['sometimes', 'required', 'string', Rule::in(EventCategoryService::categories())], + 'description' => ['nullable', 'string', 'max:2000'], + 'amount' => ['sometimes', 'required', 'numeric', 'min:0'], + 'flyer' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png'], + 'expiration_date' => ['sometimes', 'required', 'date'], + 'semester' => ['sometimes', 'required', 'string', 'max:20'], + 'school_year' => ['sometimes', 'required', 'string', 'max:20'], + ]; + } +} diff --git a/app/Http/Requests/Finance/FeeRefundRequest.php b/app/Http/Requests/Finance/FeeRefundRequest.php new file mode 100644 index 00000000..ba984a72 --- /dev/null +++ b/app/Http/Requests/Finance/FeeRefundRequest.php @@ -0,0 +1,26 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'parent_id' => ['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/Finance/FeeTuitionTotalRequest.php b/app/Http/Requests/Finance/FeeTuitionTotalRequest.php new file mode 100644 index 00000000..e4550079 --- /dev/null +++ b/app/Http/Requests/Finance/FeeTuitionTotalRequest.php @@ -0,0 +1,22 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'students' => ['required', 'array'], + 'students.*.class_section_id' => ['required', 'integer', 'min:1'], + 'students.*.grade' => ['nullable', 'string', 'max:20'], + ]; + } +} diff --git a/app/Http/Requests/Refunds/RefundDecisionRequest.php b/app/Http/Requests/Refunds/RefundDecisionRequest.php new file mode 100644 index 00000000..e5db2dea --- /dev/null +++ b/app/Http/Requests/Refunds/RefundDecisionRequest.php @@ -0,0 +1,25 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'status' => ['required', 'string', 'in:' . implode(',', [ + Refund::STATUS_APPROVED, + Refund::STATUS_REJECTED, + ])], + 'reason' => ['required', 'string', 'max:2000'], + ]; + } +} diff --git a/app/Http/Requests/Refunds/RefundIndexRequest.php b/app/Http/Requests/Refunds/RefundIndexRequest.php new file mode 100644 index 00000000..3f18109b --- /dev/null +++ b/app/Http/Requests/Refunds/RefundIndexRequest.php @@ -0,0 +1,38 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'status' => ['nullable', 'string', 'in:' . implode(',', [ + Refund::STATUS_PENDING, + Refund::STATUS_APPROVED, + Refund::STATUS_PARTIAL, + Refund::STATUS_PAID, + Refund::STATUS_REJECTED, + Refund::STATUS_CANCELED, + ])], + 'request' => ['nullable', 'string', 'in:tuition,overpayment,duplicate,extra'], + 'parent_id' => ['nullable', 'integer', 'min:1'], + 'invoice_id' => ['nullable', 'integer', 'min:1'], + 'school_year' => ['nullable', 'string', 'max:20'], + 'semester' => ['nullable', 'string', 'max:20'], + 'q' => ['nullable', 'string', 'max:200'], + 'page' => ['nullable', 'integer', 'min:1'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:200'], + 'sort_by' => ['nullable', 'string', 'in:created_at,updated_at,refund_amount,status,parent_id,invoice_id'], + 'sort_dir' => ['nullable', 'string', 'in:asc,desc'], + ]; + } +} diff --git a/app/Http/Requests/Refunds/RefundPaymentRequest.php b/app/Http/Requests/Refunds/RefundPaymentRequest.php new file mode 100644 index 00000000..b2f372a9 --- /dev/null +++ b/app/Http/Requests/Refunds/RefundPaymentRequest.php @@ -0,0 +1,23 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'paid_amount' => ['required', 'numeric', 'min:0.01'], + 'payment_method' => ['required', 'string', 'max:50', 'in:Check,Online,Cash'], + 'check_number' => ['nullable', 'string', 'max:80'], + 'check_file' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png'], + ]; + } +} diff --git a/app/Http/Requests/Refunds/RefundRecalculateRequest.php b/app/Http/Requests/Refunds/RefundRecalculateRequest.php new file mode 100644 index 00000000..9e9ff0b9 --- /dev/null +++ b/app/Http/Requests/Refunds/RefundRecalculateRequest.php @@ -0,0 +1,20 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'invoice_number' => ['nullable', 'string', 'max:100'], + ]; + } +} diff --git a/app/Http/Requests/Refunds/RefundRejectRequest.php b/app/Http/Requests/Refunds/RefundRejectRequest.php new file mode 100644 index 00000000..6d429e04 --- /dev/null +++ b/app/Http/Requests/Refunds/RefundRejectRequest.php @@ -0,0 +1,20 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'reason' => ['required', 'string', 'max:2000'], + ]; + } +} diff --git a/app/Http/Requests/Refunds/RefundStoreRequest.php b/app/Http/Requests/Refunds/RefundStoreRequest.php new file mode 100644 index 00000000..56543c25 --- /dev/null +++ b/app/Http/Requests/Refunds/RefundStoreRequest.php @@ -0,0 +1,27 @@ +user() !== null; + } + + public function rules(): array + { + return [ + 'parent_id' => ['required', 'integer', 'min:1'], + 'request_type' => ['required', 'string', 'in:tuition,overpayment,duplicate,extra'], + 'amount' => ['required_unless:request_type,tuition', 'numeric', 'min:0.01'], + 'invoice_id' => ['nullable', 'integer', 'min:1'], + 'payment_id' => ['nullable', 'integer', 'min:1'], + 'reason' => ['nullable', 'string', 'max:2000'], + 'school_year' => ['nullable', 'string', 'max:20'], + 'semester' => ['nullable', 'string', 'max:20'], + ]; + } +} diff --git a/app/Http/Resources/Events/EventChargeResource.php b/app/Http/Resources/Events/EventChargeResource.php new file mode 100644 index 00000000..d9622d53 --- /dev/null +++ b/app/Http/Resources/Events/EventChargeResource.php @@ -0,0 +1,28 @@ + (int) ($this->id ?? 0), + 'event_id' => (int) ($this->event_id ?? 0), + 'event_name' => $this->event_name ?? null, + 'parent_id' => (int) ($this->parent_id ?? 0), + 'parent_name' => trim(($this->parent_firstname ?? '') . ' ' . ($this->parent_lastname ?? '')), + 'student_id' => (int) ($this->student_id ?? 0), + 'student_name' => trim(($this->student_firstname ?? '') . ' ' . ($this->student_lastname ?? '')), + 'participation' => $this->getRawOriginal('participation') ?? $this->participation, + 'charged' => (float) ($this->getRawOriginal('charged') ?? $this->charged ?? 0), + 'school_year' => $this->school_year, + 'semester' => $this->semester, + 'created_at' => optional($this->created_at)->toDateTimeString(), + 'updated_at' => optional($this->updated_at)->toDateTimeString(), + ]; + } +} diff --git a/app/Http/Resources/Events/EventResource.php b/app/Http/Resources/Events/EventResource.php new file mode 100644 index 00000000..ef33ef44 --- /dev/null +++ b/app/Http/Resources/Events/EventResource.php @@ -0,0 +1,27 @@ + (int) ($this->id ?? 0), + 'event_name' => $this->event_name, + 'event_category' => $this->event_category, + 'description' => $this->description, + 'amount' => (float) ($this->amount ?? 0), + 'flyer' => $this->flyer, + 'expiration_date' => optional($this->expiration_date)->format('Y-m-d'), + 'semester' => $this->semester, + 'school_year' => $this->school_year, + 'created_by' => $this->created_by, + 'created_at' => optional($this->created_at)->toDateTimeString(), + 'updated_at' => optional($this->updated_at)->toDateTimeString(), + ]; + } +} diff --git a/app/Http/Resources/Events/EventStudentChargeResource.php b/app/Http/Resources/Events/EventStudentChargeResource.php new file mode 100644 index 00000000..28a8aa8f --- /dev/null +++ b/app/Http/Resources/Events/EventStudentChargeResource.php @@ -0,0 +1,18 @@ + (int) ($this['id'] ?? 0), + 'name' => $this['name'] ?? '', + 'charged' => (bool) ($this['charged'] ?? false), + ]; + } +} diff --git a/app/Http/Resources/Refunds/RefundResource.php b/app/Http/Resources/Refunds/RefundResource.php new file mode 100644 index 00000000..d268a702 --- /dev/null +++ b/app/Http/Resources/Refunds/RefundResource.php @@ -0,0 +1,36 @@ + (int) ($this->id ?? 0), + 'parent_id' => (int) ($this->parent_id ?? 0), + 'invoice_id' => $this->invoice_id ? (int) $this->invoice_id : null, + 'school_year' => $this->school_year, + 'semester' => $this->semester, + 'request' => $this->request, + 'status' => $this->status, + 'reason' => $this->reason, + 'refund_amount' => (float) ($this->refund_amount ?? 0), + 'refund_paid_amount' => (float) ($this->refund_paid_amount ?? 0), + 'refund_method' => $this->refund_method, + 'check_nbr' => $this->check_nbr, + 'check_file' => $this->check_file, + 'note' => $this->note, + 'requested_at' => optional($this->requested_at)->toDateTimeString(), + 'approved_at' => optional($this->approved_at)->toDateTimeString(), + 'refunded_at' => optional($this->refunded_at)->toDateTimeString(), + 'created_at' => optional($this->created_at)->toDateTimeString(), + 'updated_at' => optional($this->updated_at)->toDateTimeString(), + 'parent_name' => $this->parent_name ?? null, + 'approved_by_name' => $this->approved_by_name ?? null, + ]; + } +} diff --git a/app/Services/Billing/BillingTotalsService.php b/app/Services/Billing/BillingTotalsService.php new file mode 100644 index 00000000..a80e82e4 --- /dev/null +++ b/app/Services/Billing/BillingTotalsService.php @@ -0,0 +1,52 @@ + $e->getMessage()]); + } + + return round($eventSubtotal, 2); + } + + public function additionalSubtotal(int $invoiceId, ?string $schoolYear = null): float + { + $additionalSubtotal = 0.0; + try { + $builder = AdditionalCharge::query() + ->select('charge_type', 'amount') + ->where('invoice_id', $invoiceId) + ->where('status', 'applied'); + + if ($schoolYear !== null && $schoolYear !== '') { + $builder->where('school_year', $schoolYear); + } + + $rows = $builder->get()->map(fn ($row) => (array) $row)->all(); + foreach ($rows as $row) { + $amount = (float) ($row['amount'] ?? 0); + $type = strtolower((string) ($row['charge_type'] ?? 'add')); + $amount = $type === 'deduct' ? -abs($amount) : abs($amount); + $additionalSubtotal += $amount; + } + } catch (\Throwable $e) { + Log::warning('Failed to sum additional charges.', ['error' => $e->getMessage()]); + } + + return round($additionalSubtotal, 2); + } +} diff --git a/app/Services/Docs/OpenApiRouteExporter.php b/app/Services/Docs/OpenApiRouteExporter.php index a915d536..883e1ccb 100644 --- a/app/Services/Docs/OpenApiRouteExporter.php +++ b/app/Services/Docs/OpenApiRouteExporter.php @@ -88,11 +88,16 @@ class OpenApiRouteExporter } $segments = explode('/', $trimmed); $segment = $segments[0] ?: 'api'; + $secondary = $segments[1] ?? null; $normalized = Str::title(str_replace('-', ' ', $segment)); $overrides = [ 'Whatsapp' => 'WhatsApp', ]; + if ($segment === 'finance' && $secondary === 'refunds') { + return 'Refunds'; + } + return $overrides[$normalized] ?? $normalized; } diff --git a/app/Services/Events/EventCategoryService.php b/app/Services/Events/EventCategoryService.php new file mode 100644 index 00000000..2799552d --- /dev/null +++ b/app/Services/Events/EventCategoryService.php @@ -0,0 +1,16 @@ +select([ + 'event_charges.*', + 'users.firstname AS parent_firstname', + 'users.lastname AS parent_lastname', + 'students.firstname AS student_firstname', + 'students.lastname AS student_lastname', + 'events.event_name', + ]) + ->leftJoin('users', 'users.id', '=', 'event_charges.parent_id') + ->leftJoin('students', 'students.id', '=', 'event_charges.student_id') + ->leftJoin('events', 'events.id', '=', 'event_charges.event_id'); + + if (!empty($filters['school_year'])) { + $query->where('event_charges.school_year', (string) $filters['school_year']); + } + if (!empty($filters['semester'])) { + $query->where('event_charges.semester', (string) $filters['semester']); + } + if (!empty($filters['parent_id'])) { + $query->where('event_charges.parent_id', (int) $filters['parent_id']); + } + if (!empty($filters['event_id'])) { + $query->where('event_charges.event_id', (int) $filters['event_id']); + } + + $paginator = $query->orderByDesc('event_charges.created_at') + ->paginate($perPage, ['*'], 'page', $page); + + return [ + 'charges' => $paginator, + 'pagination' => $this->paginationPayload($paginator), + ]; + } + + private function paginationPayload(LengthAwarePaginator $paginator): array + { + return [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'total_pages' => $paginator->lastPage(), + ]; + } +} diff --git a/app/Services/Events/EventChargeService.php b/app/Services/Events/EventChargeService.php new file mode 100644 index 00000000..cac21166 --- /dev/null +++ b/app/Services/Events/EventChargeService.php @@ -0,0 +1,169 @@ +select('enrollments.student_id', 'students.parent_id') + ->join('students', 'students.id', '=', 'enrollments.student_id') + ->where('enrollments.school_year', $schoolYear) + ->whereIn('enrollments.enrollment_status', ['enrolled', 'payment pending']) + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + + $parentIds = []; + $created = 0; + + foreach ($enrollments as $row) { + $studentId = (int) ($row['student_id'] ?? 0); + $parentId = (int) ($row['parent_id'] ?? 0); + if ($studentId <= 0 || $parentId <= 0) { + continue; + } + + $exists = EventCharges::query() + ->where('event_id', $event->id) + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->where('semester', $semester) + ->first(); + + if ($exists) { + continue; + } + + EventCharges::query()->create([ + 'event_id' => (int) $event->id, + 'parent_id' => $parentId, + 'student_id' => $studentId, + 'participation' => 'yes', + 'charged' => $amount, + 'school_year' => $schoolYear, + 'semester' => $semester, + 'updated_by' => $actorId ?: null, + 'created_at' => utc_now(), + 'updated_at' => utc_now(), + ]); + + $created++; + $parentIds[] = $parentId; + } + + $parentIds = array_values(array_unique($parentIds)); + foreach ($parentIds as $parentId) { + $this->invoiceGeneration->generateInvoice((int) $parentId, $schoolYear); + } + + return [ + 'created' => $created, + 'parent_ids' => $parentIds, + ]; + } + + public function updateParticipation( + int $parentId, + int $eventId, + array $participations, + string $schoolYear, + string $semester, + int $actorId + ): array { + $event = Event::getEvent($eventId, $schoolYear); + if (!$event) { + return ['ok' => false, 'message' => 'Event not found.']; + } + + $created = 0; + $updated = 0; + $deleted = 0; + + foreach ($participations as $studentId => $value) { + $studentId = (int) $studentId; + if ($studentId <= 0) { + continue; + } + + $existing = EventCharges::query()->where([ + 'parent_id' => $parentId, + 'student_id' => $studentId, + 'event_id' => $eventId, + 'school_year' => $schoolYear, + 'semester' => $semester, + ])->first(); + + if ($value === 'no') { + if ($existing) { + $existing->delete(); + $deleted++; + } + continue; + } + + if ($existing) { + $existing->update([ + 'participation' => 'yes', + 'charged' => $event->amount, + 'updated_by' => $actorId, + 'updated_at' => utc_now(), + ]); + $updated++; + } else { + EventCharges::query()->create([ + 'parent_id' => $parentId, + 'student_id' => $studentId, + 'event_id' => $eventId, + 'participation' => 'yes', + 'charged' => $event->amount, + 'school_year' => $schoolYear, + 'semester' => $semester, + 'created_by' => $actorId, + 'updated_by' => $actorId, + 'created_at' => utc_now(), + 'updated_at' => utc_now(), + ]); + $created++; + } + } + + $this->invoiceGeneration->generateInvoice($parentId, $schoolYear); + + return [ + 'ok' => true, + 'created' => $created, + 'updated' => $updated, + 'deleted' => $deleted, + ]; + } + + public function deleteChargesForEvent(int $eventId): array + { + $charges = EventCharges::query() + ->where('event_id', $eventId) + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + + $parentIds = []; + foreach ($charges as $charge) { + EventCharges::query()->whereKey($charge['id'])->delete(); + $parentIds[] = (int) ($charge['parent_id'] ?? 0); + } + + return [ + 'parent_ids' => array_values(array_unique(array_filter($parentIds))), + ]; + } +} diff --git a/app/Services/Events/EventListService.php b/app/Services/Events/EventListService.php new file mode 100644 index 00000000..cd2283c9 --- /dev/null +++ b/app/Services/Events/EventListService.php @@ -0,0 +1,89 @@ +where('school_year', (string) $filters['school_year']); + } + if (!empty($filters['semester'])) { + $query->where('semester', (string) $filters['semester']); + } + if (!empty($filters['q'])) { + $term = trim((string) $filters['q']); + $query->where('event_name', 'like', "%{$term}%"); + } + if ($activeOnly !== null) { + $today = now()->toDateString(); + if (filter_var($activeOnly, FILTER_VALIDATE_BOOLEAN)) { + $query->whereDate('expiration_date', '>=', $today); + } else { + $query->whereDate('expiration_date', '<', $today); + } + } + + $paginator = $query + ->orderBy($sortBy, $sortDir) + ->paginate($perPage, ['*'], 'page', $page); + + $activeCount = $this->activeCount($filters); + + return [ + 'events' => $paginator, + 'pagination' => $this->paginationPayload($paginator), + 'active_count' => $activeCount, + ]; + } + + public function find(int $eventId, ?string $schoolYear = null): ?Event + { + $query = Event::query()->whereKey($eventId); + if ($schoolYear !== null && $schoolYear !== '') { + $query->where('school_year', $schoolYear); + } + + return $query->first(); + } + + private function activeCount(array $filters): int + { + $query = Event::query()->whereDate('expiration_date', '>=', now()->toDateString()); + if (!empty($filters['school_year'])) { + $query->where('school_year', (string) $filters['school_year']); + } + if (!empty($filters['semester'])) { + $query->where('semester', (string) $filters['semester']); + } + + return (int) $query->count(); + } + + private function paginationPayload(LengthAwarePaginator $paginator): array + { + return [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'total_pages' => $paginator->lastPage(), + ]; + } +} diff --git a/app/Services/Events/EventManagementService.php b/app/Services/Events/EventManagementService.php new file mode 100644 index 00000000..c03b1725 --- /dev/null +++ b/app/Services/Events/EventManagementService.php @@ -0,0 +1,116 @@ +storeFlyer($flyer); + + $event = Event::query()->create([ + 'event_name' => $payload['event_name'], + 'event_category' => $payload['event_category'], + 'description' => $payload['description'] ?? null, + 'amount' => $payload['amount'], + 'flyer' => $flyerPath, + 'expiration_date' => $payload['expiration_date'], + 'semester' => $payload['semester'], + 'school_year' => $payload['school_year'], + 'created_by' => $actorId ?: null, + 'created_at' => utc_now(), + 'updated_at' => utc_now(), + ]); + + $seed = $this->charges->seedChargesForEvent( + $event, + (string) $payload['school_year'], + (string) $payload['semester'], + (float) $payload['amount'], + $actorId + ); + + return [ + 'ok' => true, + 'event' => $event, + 'charges_created' => $seed['created'] ?? 0, + ]; + }); + } + + public function update(int $eventId, array $payload, ?UploadedFile $flyer): array + { + $event = Event::query()->find($eventId); + if (!$event) { + return ['ok' => false, 'message' => 'Event not found.']; + } + + $flyerPath = $event->flyer; + if ($flyer) { + $flyerPath = $this->storeFlyer($flyer) ?: $flyerPath; + } + + $event->update([ + 'event_name' => $payload['event_name'] ?? $event->event_name, + 'event_category' => $payload['event_category'] ?? $event->event_category, + 'description' => $payload['description'] ?? $event->description, + 'amount' => $payload['amount'] ?? $event->amount, + 'flyer' => $flyerPath, + 'expiration_date' => $payload['expiration_date'] ?? $event->expiration_date, + 'semester' => $payload['semester'] ?? $event->semester, + 'school_year' => $payload['school_year'] ?? $event->school_year, + 'updated_at' => utc_now(), + ]); + + return ['ok' => true, 'event' => $event]; + } + + public function delete(int $eventId, int $actorId): array + { + $event = Event::query()->find($eventId); + if (!$event) { + return ['ok' => false, 'message' => 'Event not found.']; + } + + return DB::transaction(function () use ($event) { + $charges = $this->charges->deleteChargesForEvent((int) $event->id); + $event->delete(); + + foreach ($charges['parent_ids'] ?? [] as $parentId) { + $this->invoiceGeneration->generateInvoice((int) $parentId, (string) $event->school_year); + } + + return ['ok' => true]; + }); + } + + private function storeFlyer(?UploadedFile $file): ?string + { + if (!$file || !$file->isValid()) { + return null; + } + + $dir = public_path('uploads/event_flyers'); + if (!File::exists($dir)) { + File::makeDirectory($dir, 0755, true); + } + + $name = $file->hashName(); + $file->move($dir, $name); + + return 'event_flyers/' . $name; + } +} diff --git a/app/Services/Events/EventStudentChargeService.php b/app/Services/Events/EventStudentChargeService.php new file mode 100644 index 00000000..26a2ca6c --- /dev/null +++ b/app/Services/Events/EventStudentChargeService.php @@ -0,0 +1,40 @@ +where('parent_id', $parentId) + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + + $chargedIds = EventCharges::query() + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->where('semester', $semester) + ->distinct() + ->pluck('student_id') + ->map(fn ($id) => (int) $id) + ->all(); + + $chargedMap = array_flip($chargedIds); + + $data = []; + foreach ($students as $student) { + $data[] = [ + 'id' => (int) ($student['id'] ?? 0), + 'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')), + 'charged' => isset($chargedMap[(int) ($student['id'] ?? 0)]), + ]; + } + + return $data; + } +} diff --git a/app/Services/Fees/FeeRefundDetailService.php b/app/Services/Fees/FeeRefundDetailService.php new file mode 100644 index 00000000..2affe056 --- /dev/null +++ b/app/Services/Fees/FeeRefundDetailService.php @@ -0,0 +1,322 @@ +attendance['timezone'] ?? (function_exists('user_timezone') ? user_timezone() : 'UTC')); + $tz = new \DateTimeZone($tzName); + $deadline = new \DateTimeImmutable($refundDeadlineRaw, $tz); + } catch (\Throwable $e) { + Log::warning('Refund deadline missing/invalid for tuition calc; withdrawn students will be billed.', [ + 'error' => $e->getMessage(), + ]); + } + + $tuitionStudents = $registeredKids; + foreach ($withdrawnKids as $kid) { + $rawDate = $kid['withdrawal_date'] ?? null; + if (empty($rawDate) || !$deadline) { + Log::warning('Missing withdrawal date or deadline; billing withdrawn student.', [ + 'student_id' => $kid['student_id'] ?? null, + ]); + $tuitionStudents[] = $kid; + continue; + } + + try { + $withdrawDate = new \DateTimeImmutable((string) $rawDate, $tz ?? new \DateTimeZone('UTC')); + } catch (\Throwable $e) { + Log::warning('Invalid withdrawal date; billing withdrawn student.', [ + 'student_id' => $kid['student_id'] ?? null, + ]); + $tuitionStudents[] = $kid; + continue; + } + + if ($withdrawDate->format('Y-m-d') > $deadline->format('Y-m-d')) { + $tuitionStudents[] = $kid; + } + } + + $tuitionStudents = array_values(array_filter($tuitionStudents, function ($student) use ($schoolYear) { + $sid = (int) ($student['student_id'] ?? 0); + return $sid > 0 && StudentClass::hasNonEventAssignment($sid, $schoolYear); + })); + + foreach ($tuitionStudents as &$student) { + $name = null; + if (!empty($student['class_section_id'])) { + $name = ClassSection::getClassSectionNameBySectionId($student['class_section_id']); + } + $student['grade_name'] = is_string($name) ? strtoupper(trim($name)) : 'N/A'; + } + unset($student); + + $gradeFee = (int) (Configuration::getConfig('grade_fee') ?? 9); + $firstStudentFee = (float) (Configuration::getConfig('first_student_fee') ?? 350); + $secondStudentFee = (float) (Configuration::getConfig('second_student_fee') ?? 200); + $youthFee = (float) (Configuration::getConfig('youth_fee') ?? 180); + + $regularCount = 0; + $youthCount = 0; + $grades = new InvoiceGradeService($gradeFee); + foreach ($tuitionStudents as $student) { + $level = (int) ($grades->getGradeLevel($student['grade_name'] ?? '')['level'] ?? 999); + if ($level > $gradeFee) { + $youthCount++; + } else { + $regularCount++; + } + } + + $tuitionSubtotal = 0.0; + $tuitionSubtotal += $youthCount * $youthFee; + if ($regularCount >= 2) { + $tuitionSubtotal += $firstStudentFee + ($regularCount - 1) * $secondStudentFee; + } elseif ($regularCount === 1) { + $tuitionSubtotal += $firstStudentFee; + } + + return [ + 'tuition_subtotal' => round($tuitionSubtotal, 2), + 'tuition_students' => $tuitionStudents, + 'regular_count' => $regularCount, + 'youth_count' => $youthCount, + ]; + } + + public function calculateRefundDetails(array $students, int $parentId): array + { + $schoolYear = (string) (Configuration::getConfig('school_year') ?? ''); + if ($schoolYear === '') { + return ['ok' => false, 'error' => 'Missing school_year configuration.']; + } + + $refundDeadlineRaw = (string) (Configuration::getConfig('refund_deadline') ?? ''); + if ($refundDeadlineRaw === '') { + return ['ok' => false, 'error' => 'Refund deadline is not configured.']; + } + + try { + $refundDeadline = (new \DateTimeImmutable($refundDeadlineRaw))->format('Y-m-d'); + } catch (\Throwable $e) { + return ['ok' => false, 'error' => 'Refund deadline is invalid.']; + } + + $bookPriceRaw = Configuration::getConfig('book_price'); + if ($bookPriceRaw === null || $bookPriceRaw === '') { + return ['ok' => false, 'error' => 'Book price is not configured.']; + } + if (!is_numeric($bookPriceRaw)) { + return ['ok' => false, 'error' => 'Book price must be numeric.']; + } + $bookPrice = round((float) $bookPriceRaw, 2); + if ($bookPrice < 0) { + return ['ok' => false, 'error' => 'Book price must be zero or greater.']; + } + + $withdrawDates = []; + $registered = []; + $withdrawnEligible = []; + $withdrawnLate = []; + $hasWithdrawn = false; + foreach ($students as $student) { + $status = (string) ($student['enrollment_status'] ?? ''); + if (in_array($status, ['enrolled', 'payment pending'], true)) { + $registered[] = $student; + continue; + } + if (!in_array($status, ['withdrawn', 'refund pending', 'withdraw under review'], true)) { + continue; + } + $hasWithdrawn = true; + $raw = $student['withdrawal_date'] ?? null; + if (empty($raw)) { + return ['ok' => false, 'error' => 'Missing withdrawal date for a withdrawn student.']; + } + try { + $withdrawDate = (new \DateTimeImmutable((string) $raw))->format('Y-m-d'); + $withdrawDates[] = $withdrawDate; + if (strtotime($withdrawDate) <= strtotime($refundDeadline)) { + $withdrawnEligible[] = $student; + } else { + $withdrawnLate[] = $student; + } + } catch (\Throwable $e) { + return ['ok' => false, 'error' => 'Invalid withdrawal date for a withdrawn student.']; + } + } + + if (!$hasWithdrawn) { + return ['ok' => false, 'error' => 'No withdrawn students found for refund evaluation.']; + } + + $withdrawDateUsed = max($withdrawDates); + if (count(array_unique($withdrawDates)) > 1) { + Log::warning('Multiple withdrawal dates detected for refund calc; using latest date.', [ + 'withdraw_date' => $withdrawDateUsed, + 'parent_id' => $parentId, + ]); + } + + $totalPaid = Payment::getTotalPaidByParentId($parentId, $schoolYear); + if ($totalPaid < 0) { + return ['ok' => false, 'error' => 'Paid amount is invalid.']; + } + $totalPaid = round((float) $totalPaid, 2); + + $invoiceTotal = 0.0; + $totalDiscount = 0.0; + $fullDiscount = false; + $invoice = Invoice::query() + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->orderByDesc('created_at') + ->first(); + if ($invoice) { + $invoiceTotal = (float) ($invoice->total_amount ?? 0.0); + $totalDiscount = DiscountUsage::getTotalDiscountByParentIdAndSchoolYear($parentId, $schoolYear); + if ($invoiceTotal > 0 && $totalDiscount >= $invoiceTotal - 0.00001) { + $fullDiscount = true; + } + } + + $eligible = !empty($withdrawnEligible); + + $refundAmount = 0.0; + $debitAmount = 0.0; + $refundableTuition = 0.0; + $remainingCount = 0; + if ($eligible) { + $totalStudentCount = (int) Student::query() + ->where('parent_id', $parentId) + ->distinct() + ->count('id'); + + $firstStudentFee = (float) (Configuration::getConfig('first_student_fee') ?? 350); + $secondStudentFee = (float) (Configuration::getConfig('second_student_fee') ?? 200); + + $withdrawnCount = count($withdrawnEligible); + $remainingCount = max(0, $totalStudentCount - $withdrawnCount); + if ($totalStudentCount >= 2 && $remainingCount >= 1) { + $refundableTuition = round($secondStudentFee * $withdrawnCount, 2); + } elseif ($totalStudentCount >= 2 && $remainingCount === 0) { + $refundableTuition = round($firstStudentFee + max(0, $withdrawnCount - 1) * $secondStudentFee, 2); + } else { + $refundableTuition = round($firstStudentFee * $withdrawnCount, 2); + } + + $refundableCash = min($totalPaid, $refundableTuition); + $raw = round($refundableCash - $bookPrice, 2); + if ($raw > 0 && !$fullDiscount) { + $refundAmount = $raw; + } elseif ($raw < 0 && !$fullDiscount) { + $debitAmount = abs($raw); + } + } + + return [ + 'ok' => true, + 'eligible' => $eligible, + 'withdraw_date' => $withdrawDateUsed, + 'refund_deadline' => $refundDeadline, + 'paid_amount' => $totalPaid, + 'book_price' => $bookPrice, + 'refund_amount' => $refundAmount, + 'debit_amount' => $debitAmount, + 'refundable_tuition' => round($refundableTuition, 2), + 'withdrawn_eligible_count' => count($withdrawnEligible), + 'total_student_count' => (int) ($totalStudentCount ?? 0), + 'remaining_count' => $remainingCount, + 'invoice_total' => round($invoiceTotal, 2), + 'discount_total' => round($totalDiscount, 2), + 'full_discount' => $fullDiscount, + ]; + } + + public function calculateRefund(array $students, int $parentId): float + { + $result = $this->calculateRefundDetails($students, $parentId); + return (float) ($result['refund_amount'] ?? 0.0); + } + + public function getGradeLevelInfo($grade, ?int $gradeFee = null): array + { + $resolvedGradeFee = $gradeFee ?? (int) (Configuration::getConfig('grade_fee') ?? 9); + $grades = new InvoiceGradeService($resolvedGradeFee); + return $grades->getGradeLevel($grade); + } + + public function compareGrades($gradeA, $gradeB, ?int $gradeFee = null): int + { + $resolvedGradeFee = $gradeFee ?? (int) (Configuration::getConfig('grade_fee') ?? 9); + $grades = new InvoiceGradeService($resolvedGradeFee); + return $grades->compareGrades((string) $gradeA, (string) $gradeB); + } + + public function calculateTotalTuitionFee( + array $students, + ?int $gradeFee = null, + ?float $firstStudentFee = null, + ?float $secondStudentFee = null, + ?float $youthFee = null + ): float { + $resolvedGradeFee = $gradeFee ?? (int) (Configuration::getConfig('grade_fee') ?? 9); + $resolvedFirstFee = $firstStudentFee ?? (float) (Configuration::getConfig('first_student_fee') ?? 350); + $resolvedSecondFee = $secondStudentFee ?? (float) (Configuration::getConfig('second_student_fee') ?? 200); + $resolvedYouthFee = $youthFee ?? (float) (Configuration::getConfig('youth_fee') ?? 180); + + foreach ($students as &$student) { + $gradeName = ClassSection::getClassSectionNameBySectionId($student['class_section_id'] ?? null); + $student['grade'] = strtoupper(trim((string) $gradeName)); + } + unset($student); + + $regularCount = 0; + $youthCount = 0; + $grades = new InvoiceGradeService($resolvedGradeFee); + + foreach ($students as $student) { + $level = (int) ($grades->getGradeLevel($student['grade'] ?? '')['level'] ?? 999); + if ($level > $resolvedGradeFee) { + $youthCount++; + } else { + $regularCount++; + } + } + + $total = 0.0; + $total += $youthCount * $resolvedYouthFee; + + if ($regularCount >= 2) { + $total += $resolvedFirstFee; + $total += ($regularCount - 1) * $resolvedSecondFee; + } elseif ($regularCount === 1) { + $total += $resolvedFirstFee; + } + + return $total; + } +} diff --git a/app/Services/Invoices/InvoiceService.php b/app/Services/Invoices/InvoiceService.php new file mode 100644 index 00000000..2325ab9d --- /dev/null +++ b/app/Services/Invoices/InvoiceService.php @@ -0,0 +1,76 @@ +config->getSchoolYear(); + } + + public function getDueDate(): ?string + { + return $this->config->getDueDate(); + } + + public function getManagementData(?string $schoolYear = null): array + { + return $this->management->getManagementData($schoolYear); + } + + public function generateInvoiceForParent(int $parentId, ?string $schoolYear = null): array + { + return $this->generation->generateInvoice($parentId, $schoolYear); + } + + public function hasClassAssignment(string $schoolYear, int $parentId): bool + { + if ($parentId <= 0) { + return false; + } + $students = Student::query()->where('parent_id', $parentId)->get(['id']); + if ($students->isEmpty()) { + return false; + } + + $studentIds = $students->pluck('id')->map(fn ($id) => (int) $id)->all(); + + return StudentClass::query() + ->whereIn('student_id', $studentIds) + ->where('school_year', $schoolYear) + ->exists(); + } + + public function getGradeLevelInfo(string $grade): array + { + return $this->grades->getGradeLevel($grade); + } + + public function compareGrades(string $gradeA, string $gradeB): int + { + return $this->grades->compareGrades($gradeA, $gradeB); + } + + public function calculateTotalTuitionFee(array $students): float + { + return $this->tuition->calculateTotalTuitionFee($students); + } + + public function calculateTuitionFee(array $registeredKids, array $withdrawnKids, ?string $refundDeadline = null): float + { + return $this->tuition->calculateTuitionFee($registeredKids, $withdrawnKids, $refundDeadline); + } +} diff --git a/app/Services/Refunds/RefundDecisionService.php b/app/Services/Refunds/RefundDecisionService.php new file mode 100644 index 00000000..c57baddf --- /dev/null +++ b/app/Services/Refunds/RefundDecisionService.php @@ -0,0 +1,61 @@ +find($refundId); + if (!$refund) { + return ['ok' => false, 'message' => 'Refund not found.']; + } + + if (!in_array($status, [Refund::STATUS_APPROVED, Refund::STATUS_REJECTED], true)) { + return ['ok' => false, 'message' => 'Invalid refund status.']; + } + + if ($status === Refund::STATUS_APPROVED && (float) ($refund->refund_amount ?? 0) <= 0) { + return ['ok' => false, 'message' => 'Cannot approve a zero refund.']; + } + + $refund->update([ + 'status' => $status, + 'reason' => $reason, + 'approved_at' => utc_now(), + 'approved_by' => $actorId, + 'updated_at' => utc_now(), + 'updated_by' => $actorId, + ]); + + return ['ok' => true]; + } + + public function approve(int $refundId, int $actorId): array + { + return $this->updateDecision($refundId, Refund::STATUS_APPROVED, 'Approved', $actorId); + } + + public function reject(int $refundId, string $reason, int $actorId): array + { + return $this->updateDecision($refundId, Refund::STATUS_REJECTED, $reason, $actorId); + } + + public function cancel(int $refundId, int $actorId): array + { + $refund = Refund::query()->find($refundId); + if (!$refund) { + return ['ok' => false, 'message' => 'Refund not found.']; + } + + $refund->update([ + 'status' => Refund::STATUS_CANCELED, + 'updated_at' => utc_now(), + 'updated_by' => $actorId, + ]); + + return ['ok' => true]; + } +} diff --git a/app/Services/Refunds/RefundInvoiceAdjustmentService.php b/app/Services/Refunds/RefundInvoiceAdjustmentService.php new file mode 100644 index 00000000..ecccc8ae --- /dev/null +++ b/app/Services/Refunds/RefundInvoiceAdjustmentService.php @@ -0,0 +1,92 @@ +where('invoice_id', (int) $invoice->id) + ->where('parent_id', (int) $parentId) + ->where('school_year', (string) $invoice->school_year) + ->where('title', $bookChargeTitle) + ->orderByDesc('id') + ->first(); + + if ($eligible && $bookPrice > 0) { + if ($existingCharge) { + $oldAmount = (float) ($existingCharge->amount ?? 0.0); + $bookChargeId = (int) $existingCharge->id; + if (abs($oldAmount - $bookPrice) > 0.00001 || ($existingCharge->status ?? '') !== 'applied') { + $existingCharge->update([ + 'charge_type' => 'add', + 'title' => $bookChargeTitle, + 'description' => $bookChargeDesc, + 'amount' => $bookPrice, + 'status' => 'applied', + ]); + $bookChargeDelta = round($bookPrice - $oldAmount, 2); + $bookChargeAction = 'updated'; + } + } else { + $bookChargeId = AdditionalCharge::query()->insertGetId([ + 'parent_id' => (int) $parentId, + 'invoice_id' => (int) $invoice->id, + 'school_year' => (string) $invoice->school_year, + 'semester' => $semester, + 'charge_type' => 'add', + 'title' => $bookChargeTitle, + 'description' => $bookChargeDesc, + 'amount' => $bookPrice, + 'status' => 'applied', + 'created_by' => $actorId, + 'created_at' => utc_now(), + 'updated_at' => utc_now(), + ]); + + if ($bookChargeId) { + $bookChargeDelta = round($bookPrice, 2); + $bookChargeAction = 'inserted'; + } + } + } elseif ($existingCharge) { + $oldAmount = (float) ($existingCharge->amount ?? 0.0); + $bookChargeId = (int) $existingCharge->id; + $existingCharge->delete(); + $bookChargeDelta = round(-$oldAmount, 2); + $bookChargeAction = 'deleted'; + } + + $this->invoiceService->recalculateInvoice((int) $invoice->id, (string) $invoice->school_year); + + return [ + 'book_charge_action' => $bookChargeAction, + 'book_charge_delta' => $bookChargeDelta, + 'book_charge_id' => $bookChargeId, + ]; + } +} diff --git a/app/Services/Refunds/RefundNotificationService.php b/app/Services/Refunds/RefundNotificationService.php new file mode 100644 index 00000000..1604ca1d --- /dev/null +++ b/app/Services/Refunds/RefundNotificationService.php @@ -0,0 +1,17 @@ + $parentId, + 'amount' => round($amount, 2), + 'portal_link' => $portalLink, + ]); + } +} diff --git a/app/Services/Refunds/RefundOverpaymentService.php b/app/Services/Refunds/RefundOverpaymentService.php new file mode 100644 index 00000000..52758867 --- /dev/null +++ b/app/Services/Refunds/RefundOverpaymentService.php @@ -0,0 +1,390 @@ +recalculateForInvoice($invoiceNumber, $triggerEvents, $actorId); + } + + $created = []; + $updated = []; + $schoolYear = (string) (Configuration::getConfig('school_year') ?? ''); + $semester = (string) (Configuration::getConfig('semester') ?? ''); + + $paidRows = $this->sumPaymentsByParent($schoolYear); + $invRows = $this->sumInvoicesByParent($schoolYear); + $refRows = $this->sumRefundsByParent($schoolYear); + + $paidMap = $this->indexByParent($paidRows, 'total_paid'); + $invMap = $this->indexByParent($invRows, 'total_invoiced'); + $refMap = $this->indexByParent($refRows, 'total_refunded'); + + $parentIds = array_unique(array_merge(array_keys($paidMap), array_keys($invMap))); + + foreach ($parentIds as $pid) { + $paid = (float) ($paidMap[$pid] ?? 0); + $invd = (float) ($invMap[$pid] ?? 0); + $rfnd = (float) ($refMap[$pid] ?? 0); + $over = round($paid - $invd - $rfnd, 2); + if ($over > 0.0001) { + $existing = Refund::query() + ->where('parent_id', $pid) + ->where('school_year', $schoolYear) + ->where('request', 'overpayment') + ->orderByDesc('id') + ->first(); + + if ($existing && in_array($existing->status, [Refund::STATUS_PENDING, Refund::STATUS_PARTIAL], true)) { + $existing->update([ + 'refund_amount' => $over, + 'updated_at' => utc_now(), + 'updated_by' => $actorId, + ]); + $updated[] = (int) $existing->id; + } + } + } + + try { + $createdUpdated = $this->recalculatePerInvoice($schoolYear, $semester, $triggerEvents, $actorId); + $created = array_merge($created, $createdUpdated['created_ids'] ?? []); + $updated = array_merge($updated, $createdUpdated['updated_ids'] ?? []); + } catch (\Throwable $e) { + Log::error('recalculateOverpayments per-invoice failed', ['error' => $e->getMessage()]); + } + + return ['created_ids' => $created, 'updated_ids' => $updated]; + } + + private function recalculateForInvoice(string $invoiceNumber, bool $triggerEvents, ?int $actorId): array + { + $invoice = Invoice::query()->where('invoice_number', $invoiceNumber)->first(); + if (!$invoice) { + return ['ok' => false, 'message' => 'Invoice not found.']; + } + + $iid = (int) $invoice->id; + $pid = (int) $invoice->parent_id; + $year = (string) $invoice->school_year; + + $paid = (float) DB::table('payments') + ->selectRaw('COALESCE(SUM(paid_amount),0) AS tot') + ->where('invoice_id', $iid) + ->value('tot'); + + $disc = (float) DB::table('discount_usages') + ->selectRaw('COALESCE(SUM(discount_amount),0) AS tot') + ->where('invoice_id', $iid) + ->value('tot'); + + $rfnd = (float) DB::table('refunds') + ->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS tot') + ->where('invoice_id', $iid) + ->whereIn('status', ['Partial', 'Paid']) + ->value('tot'); + + $total = (float) ($invoice->total_amount ?? 0); + $raw = round($total - $disc - $paid - $rfnd, 2); + if ($raw >= -0.0001) { + return ['ok' => true, 'message' => 'No overpayment detected.']; + } + + $over = abs($raw); + $openRow = Refund::query() + ->where('invoice_id', $iid) + ->whereIn('status', [Refund::STATUS_PENDING, Refund::STATUS_APPROVED, Refund::STATUS_PARTIAL]) + ->orderByDesc('id') + ->first(); + + if ($openRow) { + $openRow->update([ + 'refund_amount' => $over, + 'request' => 'overpayment', + 'updated_at' => utc_now(), + 'updated_by' => $actorId, + ]); + return ['ok' => true, 'message' => 'Overpayment updated for invoice.']; + } + + $existing = Refund::query() + ->where('invoice_id', $iid) + ->where('request', 'overpayment') + ->orderByDesc('id') + ->first(); + if ($existing) { + return ['ok' => true, 'message' => 'Overpayment already resolved for invoice.']; + } + + $refund = Refund::query()->create([ + 'parent_id' => $pid, + 'school_year' => $year, + 'semester' => $invoice->semester, + 'invoice_id' => $iid, + 'refund_amount' => $over, + 'refund_paid_amount' => 0.00, + 'status' => Refund::STATUS_PENDING, + 'request' => 'overpayment', + 'reason' => 'Auto-detected per-invoice overpayment (manual)', + 'updated_by' => $actorId, + 'created_at' => utc_now(), + 'updated_at' => utc_now(), + ]); + + if ($refund && $triggerEvents) { + $this->notifications->notifyPending($pid, $over, url('/login')); + } + + return ['ok' => true, 'message' => 'Overpayment detected and refund created.']; + } + + private function recalculatePerInvoice(string $schoolYear, string $semester, bool $triggerEvents, ?int $actorId): array + { + $created = []; + $updated = []; + + $invRows = Invoice::query() + ->select('id', 'parent_id', 'total_amount', 'school_year') + ->where('school_year', $schoolYear) + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + + if (empty($invRows)) { + return ['created_ids' => [], 'updated_ids' => []]; + } + + $invoiceIds = array_map(static fn ($row) => (int) $row['id'], $invRows); + + $paidByInv = $this->sumPaymentsByInvoice($invoiceIds); + $discByInv = $this->sumDiscountsByInvoice($invoiceIds); + $refByInv = $this->sumRefundsByInvoice($invoiceIds); + + foreach ($invRows as $inv) { + $iid = (int) $inv['id']; + $pid = (int) $inv['parent_id']; + $total = (float) ($inv['total_amount'] ?? 0); + $paid = (float) ($paidByInv[$iid] ?? 0); + $disc = (float) ($discByInv[$iid] ?? 0); + $rfnd = (float) ($refByInv[$iid] ?? 0); + + $rawBalance = round($total - $disc - $paid - $rfnd, 2); + if ($rawBalance >= -0.0001) { + continue; + } + + $over = abs($rawBalance); + + $openRow = Refund::query() + ->where('invoice_id', $iid) + ->whereIn('status', [Refund::STATUS_PENDING, Refund::STATUS_APPROVED, Refund::STATUS_PARTIAL]) + ->orderByDesc('id') + ->first(); + if ($openRow) { + $openRow->update([ + 'refund_amount' => $over, + 'updated_at' => utc_now(), + 'updated_by' => $actorId, + ]); + $updated[] = (int) $openRow->id; + continue; + } + + $parentLevelOpen = Refund::query() + ->where('parent_id', $pid) + ->where('school_year', $schoolYear) + ->whereNull('invoice_id') + ->where('request', 'overpayment') + ->whereIn('status', [Refund::STATUS_PENDING, Refund::STATUS_APPROVED, Refund::STATUS_PARTIAL]) + ->orderByDesc('id') + ->first(); + + if ($parentLevelOpen) { + $parentLevelOpen->update([ + 'invoice_id' => $iid, + 'refund_amount' => $over, + 'updated_at' => utc_now(), + 'updated_by' => $actorId, + ]); + $updated[] = (int) $parentLevelOpen->id; + continue; + } + + $existing = Refund::query() + ->where('invoice_id', $iid) + ->where('request', 'overpayment') + ->whereIn('status', [Refund::STATUS_PENDING, Refund::STATUS_APPROVED, Refund::STATUS_PARTIAL, Refund::STATUS_PAID]) + ->orderByDesc('id') + ->first(); + + if (!$existing || $existing->status === Refund::STATUS_PAID) { + $refund = Refund::query()->create([ + 'parent_id' => $pid, + 'school_year' => $schoolYear, + 'semester' => $semester, + 'invoice_id' => $iid, + 'refund_amount' => $over, + 'refund_paid_amount' => 0.00, + 'status' => Refund::STATUS_PENDING, + 'request' => 'overpayment', + 'reason' => 'Auto-detected per-invoice overpayment', + 'updated_by' => $actorId, + 'created_at' => utc_now(), + 'updated_at' => utc_now(), + ]); + + if ($refund) { + $created[] = (int) $refund->id; + if ($triggerEvents) { + $this->notifications->notifyPending($pid, $over, url('/login')); + } + } + } elseif (in_array($existing->status, [Refund::STATUS_PENDING, Refund::STATUS_PARTIAL], true)) { + $existing->update([ + 'refund_amount' => $over, + 'updated_at' => utc_now(), + 'updated_by' => $actorId, + ]); + $updated[] = (int) $existing->id; + } + } + + return ['created_ids' => $created, 'updated_ids' => $updated]; + } + + private function sumPaymentsByParent(string $schoolYear): array + { + $query = DB::table('payments') + ->select('parent_id', DB::raw('COALESCE(SUM(paid_amount),0) AS total_paid')) + ->where('school_year', $schoolYear) + ->groupBy('parent_id'); + + if (Schema::hasColumn('payments', 'status')) { + $query->where(function ($q) { + $q->whereNotIn('status', [ + 'void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled', + ])->orWhereNull('status'); + }); + } + if (Schema::hasColumn('payments', 'is_void')) { + $query->where(function ($q) { + $q->where('is_void', 0)->orWhereNull('is_void'); + }); + } + + return $query->get()->map(fn ($row) => (array) $row)->all(); + } + + private function sumInvoicesByParent(string $schoolYear): array + { + return DB::table('invoices') + ->select('parent_id', DB::raw('COALESCE(SUM(total_amount),0) AS total_invoiced')) + ->where('school_year', $schoolYear) + ->groupBy('parent_id') + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + } + + private function sumRefundsByParent(string $schoolYear): array + { + return DB::table('refunds') + ->select('parent_id', DB::raw('COALESCE(SUM(refund_paid_amount),0) AS total_refunded')) + ->where('school_year', $schoolYear) + ->whereIn('status', ['Partial', 'Paid']) + ->groupBy('parent_id') + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + } + + private function sumPaymentsByInvoice(array $invoiceIds): array + { + $query = DB::table('payments') + ->select('invoice_id', DB::raw('COALESCE(SUM(paid_amount),0) AS total_paid')) + ->whereIn('invoice_id', $invoiceIds) + ->groupBy('invoice_id'); + + if (Schema::hasColumn('payments', 'status')) { + $query->where(function ($q) { + $q->whereNotIn('status', [ + 'void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled', + ])->orWhereNull('status'); + }); + } + if (Schema::hasColumn('payments', 'is_void')) { + $query->where(function ($q) { + $q->where('is_void', 0)->orWhereNull('is_void'); + }); + } + + $rows = $query->get(); + $paidBy = []; + foreach ($rows as $row) { + $paidBy[(int) $row->invoice_id] = (float) ($row->total_paid ?? 0); + } + + return $paidBy; + } + + private function sumDiscountsByInvoice(array $invoiceIds): array + { + $rows = DB::table('discount_usages') + ->select('invoice_id', DB::raw('COALESCE(SUM(discount_amount),0) AS total_disc')) + ->whereIn('invoice_id', $invoiceIds) + ->groupBy('invoice_id') + ->get(); + + $discBy = []; + foreach ($rows as $row) { + $discBy[(int) $row->invoice_id] = (float) ($row->total_disc ?? 0); + } + + return $discBy; + } + + private function sumRefundsByInvoice(array $invoiceIds): array + { + $rows = DB::table('refunds') + ->select('invoice_id', DB::raw('COALESCE(SUM(refund_paid_amount),0) AS total_ref')) + ->whereIn('invoice_id', $invoiceIds) + ->whereIn('status', ['Partial', 'Paid']) + ->groupBy('invoice_id') + ->get(); + + $refBy = []; + foreach ($rows as $row) { + $refBy[(int) $row->invoice_id] = (float) ($row->total_ref ?? 0); + } + + return $refBy; + } + + private function indexByParent(array $rows, string $field): array + { + $map = []; + foreach ($rows as $row) { + $pid = (int) ($row['parent_id'] ?? 0); + if ($pid <= 0) { + continue; + } + $map[$pid] = (float) ($row[$field] ?? 0); + } + + return $map; + } +} diff --git a/app/Services/Refunds/RefundPayoutService.php b/app/Services/Refunds/RefundPayoutService.php new file mode 100644 index 00000000..33a687c1 --- /dev/null +++ b/app/Services/Refunds/RefundPayoutService.php @@ -0,0 +1,177 @@ +find($refundId); + if (!$refund) { + return ['ok' => false, 'message' => 'Refund not found.']; + } + + if (!in_array($refund->status, [Refund::STATUS_APPROVED, Refund::STATUS_PARTIAL], true)) { + return ['ok' => false, 'message' => 'Refund must be Approved or Partial to pay.']; + } + + $newPaidAmount = (float) ($payload['paid_amount'] ?? 0); + if ($newPaidAmount <= 0) { + return ['ok' => false, 'message' => 'Valid paid amount is required.']; + } + + $oldPaid = (float) ($refund->refund_paid_amount ?? 0); + $target = (float) ($refund->refund_amount ?? 0); + $total = $oldPaid + $newPaidAmount; + + if ($total > $target + 0.0001) { + return ['ok' => false, 'message' => 'Total paid cannot exceed refund amount.']; + } + + $refundMethod = (string) ($payload['payment_method'] ?? ''); + $checkNumber = $payload['check_number'] ?? null; + $checkFileName = $refund->check_file; + + if ($refundMethod === 'Check' && ($payload['check_file'] ?? null) instanceof UploadedFile) { + $file = $payload['check_file']; + if ($file->isValid()) { + $checkFileName = $file->hashName(); + $file->storeAs('checks', $checkFileName); + } + } + + $newStatus = ($total < $target) ? Refund::STATUS_PARTIAL : Refund::STATUS_PAID; + + return DB::transaction(function () use ( + $refund, + $total, + $newStatus, + $refundMethod, + $checkNumber, + $checkFileName, + $actorId + ) { + $assignInvoiceId = $refund->invoice_id ?: $this->assignInvoiceForRefund($refund); + + $refund->update([ + 'refund_paid_amount' => $total, + 'status' => $newStatus, + 'refunded_at' => utc_now(), + 'updated_at' => utc_now(), + 'updated_by' => $actorId, + 'refund_method' => $refundMethod, + 'check_nbr' => $checkNumber, + 'check_file' => $checkFileName, + 'invoice_id' => $assignInvoiceId, + ]); + + return ['ok' => true]; + }); + } + + private function assignInvoiceForRefund(Refund $refund): ?int + { + $parentId = (int) ($refund->parent_id ?? 0); + $schoolYear = (string) ($refund->school_year ?? ''); + if ($parentId <= 0 || $schoolYear === '') { + return null; + } + + try { + $invRows = Invoice::query() + ->select('id', 'total_amount') + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + + if (empty($invRows)) { + return null; + } + + $invoiceIds = array_map(static fn ($r) => (int) $r['id'], $invRows); + + $paidBy = $this->sumPaymentsByInvoice($invoiceIds); + $discBy = $this->sumDiscountsByInvoice($invoiceIds); + + $minRaw = 0.0; + $minId = null; + foreach ($invRows as $row) { + $iid = (int) $row['id']; + $raw = (float) ($row['total_amount'] ?? 0) - (float) ($discBy[$iid] ?? 0) - (float) ($paidBy[$iid] ?? 0); + if ($raw < $minRaw) { + $minRaw = $raw; + $minId = $iid; + } + } + + if ($minId !== null) { + return $minId; + } + + $latest = Invoice::query() + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->orderByDesc('created_at') + ->first(); + + return $latest ? (int) $latest->id : null; + } catch (\Throwable $e) { + Log::warning('Refund invoice assignment failed.', ['error' => $e->getMessage()]); + return null; + } + } + + private function sumPaymentsByInvoice(array $invoiceIds): array + { + $query = DB::table('payments') + ->select('invoice_id', DB::raw('COALESCE(SUM(paid_amount),0) AS total_paid')) + ->whereIn('invoice_id', $invoiceIds) + ->groupBy('invoice_id'); + + if (Schema::hasColumn('payments', 'status')) { + $query->where(function ($q) { + $q->whereNotIn('status', [ + 'void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled', + ])->orWhereNull('status'); + }); + } + if (Schema::hasColumn('payments', 'is_void')) { + $query->where(function ($q) { + $q->where('is_void', 0)->orWhereNull('is_void'); + }); + } + + $rows = $query->get(); + $paidBy = []; + foreach ($rows as $row) { + $paidBy[(int) $row->invoice_id] = (float) ($row->total_paid ?? 0); + } + + return $paidBy; + } + + private function sumDiscountsByInvoice(array $invoiceIds): array + { + $rows = DB::table('discount_usages') + ->select('invoice_id', DB::raw('COALESCE(SUM(discount_amount),0) AS total_disc')) + ->whereIn('invoice_id', $invoiceIds) + ->groupBy('invoice_id') + ->get(); + + $discBy = []; + foreach ($rows as $row) { + $discBy[(int) $row->invoice_id] = (float) ($row->total_disc ?? 0); + } + + return $discBy; + } +} diff --git a/app/Services/Refunds/RefundPolicyService.php b/app/Services/Refunds/RefundPolicyService.php new file mode 100644 index 00000000..2e0f4757 --- /dev/null +++ b/app/Services/Refunds/RefundPolicyService.php @@ -0,0 +1,204 @@ + false, 'error' => 'Missing school year for refund processing.']; + } + + $semester = $semester ?? (string) (Configuration::getConfig('semester') ?? ''); + + $invoice = $invoiceId + ? Invoice::query()->find($invoiceId) + : Invoice::query() + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->orderByDesc('created_at') + ->first(); + + if (!$invoice) { + return ['ok' => false, 'error' => 'No invoice found for refund processing.']; + } + + $students = Enrollment::query() + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->get() + ->map(fn ($row) => $row->toArray()) + ->all(); + + if (empty($students)) { + return ['ok' => false, 'error' => 'No enrollments found for refund processing.']; + } + + $details = $this->refundDetails->calculateRefundDetails($students, $parentId); + + return DB::transaction(function () use ( + $details, + $invoice, + $parentId, + $schoolYear, + $semester, + $actorId, + $students + ) { + if (empty($details['ok'])) { + $reason = $details['error'] ?? 'Refund calculation failed.'; + $note = json_encode([ + 'parent_id' => $parentId, + 'invoice_id' => (int) ($invoice->id ?? 0), + 'error' => $reason, + ]); + + $existingRefund = Refund::query()->where('invoice_id', (int) $invoice->id)->first(); + if ($existingRefund) { + $existingRefund->update([ + 'refund_amount' => 0.0, + 'status' => Refund::STATUS_REJECTED, + 'reason' => $reason, + 'request' => 'tuition', + 'note' => $note, + 'updated_at' => utc_now(), + 'updated_by' => $actorId, + ]); + } else { + Refund::query()->create([ + 'parent_id' => $parentId, + 'school_year' => $schoolYear, + 'semester' => $semester, + 'invoice_id' => (int) $invoice->id, + 'refund_amount' => 0.0, + 'refund_paid_amount' => 0.00, + 'status' => Refund::STATUS_REJECTED, + 'reason' => $reason, + 'request' => 'tuition', + 'note' => $note, + 'requested_at' => utc_now(), + 'created_at' => utc_now(), + 'updated_at' => utc_now(), + 'updated_by' => $actorId, + ]); + } + + return ['ok' => false, 'error' => $reason]; + } + + $invoiceUpdate = $this->invoiceAdjustments->applyBookChargeAndUpdateInvoice( + $invoice, + $parentId, + $details, + $semester, + $actorId + ); + + $refundAmount = (float) ($details['refund_amount'] ?? 0.0); + $debitAmount = (float) ($details['debit_amount'] ?? 0.0); + $eligible = !empty($details['eligible']); + $status = $refundAmount > 0 ? Refund::STATUS_PENDING : Refund::STATUS_REJECTED; + + $reason = null; + if ($refundAmount <= 0) { + if (!$eligible) { + $reason = 'No refund due to late withdrawal.'; + } elseif (!empty($details['full_discount'])) { + $reason = 'No refund due to full discount.'; + } elseif ($debitAmount > 0) { + $reason = 'No refund due; book fee balance remains.'; + } else { + $reason = 'No refund due after book fee deduction.'; + } + } + + $auditPayload = [ + 'student_ids' => array_map('intval', array_column($students, 'student_id')), + 'parent_id' => $parentId, + 'withdraw_date' => $details['withdraw_date'] ?? null, + 'refund_deadline' => $details['refund_deadline'] ?? null, + 'eligible' => $details['eligible'] ?? null, + 'paid_amount' => $details['paid_amount'] ?? null, + 'book_price' => $details['book_price'] ?? null, + 'invoice_total' => $details['invoice_total'] ?? null, + 'discount_total' => $details['discount_total'] ?? null, + 'full_discount' => $details['full_discount'] ?? null, + 'refundable_tuition' => $details['refundable_tuition'] ?? null, + 'withdrawn_eligible_count' => $details['withdrawn_eligible_count'] ?? null, + 'refund_amount' => $refundAmount, + 'debit_amount' => $debitAmount, + 'invoice_id' => (int) ($invoice->id ?? 0), + ]; + + $auditPayload = array_merge($auditPayload, $invoiceUpdate); + + $existingRefund = Refund::query()->where('invoice_id', (int) $invoice->id)->first(); + if ($existingRefund) { + $existingRefund->update([ + 'refund_amount' => $refundAmount, + 'status' => $status, + 'reason' => $reason, + 'request' => 'tuition', + 'note' => json_encode($auditPayload), + 'updated_at' => utc_now(), + 'updated_by' => $actorId, + ]); + + return [ + 'ok' => true, + 'refund_id' => (int) $existingRefund->id, + 'refund_amount' => $refundAmount, + 'status' => $status, + ]; + } + + $created = Refund::query()->create([ + 'parent_id' => $parentId, + 'school_year' => $schoolYear, + 'semester' => $semester, + 'invoice_id' => (int) $invoice->id, + 'refund_amount' => $refundAmount, + 'refund_paid_amount' => 0.00, + 'status' => $status, + 'reason' => $reason, + 'request' => 'tuition', + 'note' => json_encode($auditPayload), + 'requested_at' => utc_now(), + 'created_at' => utc_now(), + 'updated_at' => utc_now(), + 'updated_by' => $actorId, + ]); + + if (!$created) { + return ['ok' => false, 'error' => 'Failed to create refund record.']; + } + + return [ + 'ok' => true, + 'refund_id' => (int) $created->id, + 'refund_amount' => $refundAmount, + 'status' => $status, + ]; + }); + } +} diff --git a/app/Services/Refunds/RefundQueryService.php b/app/Services/Refunds/RefundQueryService.php new file mode 100644 index 00000000..cf3fb261 --- /dev/null +++ b/app/Services/Refunds/RefundQueryService.php @@ -0,0 +1,83 @@ +select([ + 'refunds.*', + DB::raw("CONCAT(COALESCE(parents.lastname, ''), ', ', COALESCE(parents.firstname, '')) AS parent_name"), + DB::raw("CONCAT(COALESCE(approvers.lastname, ''), ', ', COALESCE(approvers.firstname, '')) AS approved_by_name"), + ]) + ->leftJoin('users as parents', 'parents.id', '=', 'refunds.parent_id') + ->leftJoin('users as approvers', 'approvers.id', '=', 'refunds.approved_by'); + + if (!empty($filters['status'])) { + $query->where('refunds.status', $filters['status']); + } + if (!empty($filters['request'])) { + $query->where('refunds.request', $filters['request']); + } + if (!empty($filters['parent_id'])) { + $query->where('refunds.parent_id', (int) $filters['parent_id']); + } + if (!empty($filters['invoice_id'])) { + $query->where('refunds.invoice_id', (int) $filters['invoice_id']); + } + if (!empty($filters['school_year'])) { + $query->where('refunds.school_year', (string) $filters['school_year']); + } + if (!empty($filters['semester'])) { + $query->where('refunds.semester', (string) $filters['semester']); + } + if (!empty($filters['q'])) { + $term = trim((string) $filters['q']); + $query->where(function ($where) use ($term) { + $where->where('refunds.reason', 'like', "%{$term}%") + ->orWhere('refunds.note', 'like', "%{$term}%") + ->orWhere('parents.firstname', 'like', "%{$term}%") + ->orWhere('parents.lastname', 'like', "%{$term}%"); + }); + } + + $paginator = $query->orderBy('refunds.' . $sortBy, $sortDir) + ->paginate($perPage, ['*'], 'page', $page); + + return [ + 'refunds' => $paginator, + 'pagination' => $this->paginationPayload($paginator), + ]; + } + + public function find(int $refundId): ?Refund + { + return Refund::query()->find($refundId); + } + + private function paginationPayload(LengthAwarePaginator $paginator): array + { + return [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'total_pages' => $paginator->lastPage(), + ]; + } +} diff --git a/app/Services/Refunds/RefundRequestService.php b/app/Services/Refunds/RefundRequestService.php new file mode 100644 index 00000000..e2236118 --- /dev/null +++ b/app/Services/Refunds/RefundRequestService.php @@ -0,0 +1,126 @@ + false, 'message' => 'Invalid refund request type.']; + } + + $parent = User::query()->find($parentId); + if (!$parent) { + return ['ok' => false, 'message' => 'Parent not found.']; + } + + $termSchoolYear = (string) (Configuration::getConfig('school_year') ?? ''); + $termSemester = (string) (Configuration::getConfig('semester') ?? ''); + $schoolYear = (string) ($payload['school_year'] ?? $termSchoolYear); + $semester = (string) ($payload['semester'] ?? $termSemester); + + if ($requestType === 'tuition') { + $result = $this->policyService->processWithdrawalRefund($parentId, $invoiceId, $schoolYear, $semester, $actorId); + if (empty($result['ok'])) { + return ['ok' => false, 'message' => $result['error'] ?? 'Refund calculation failed.']; + } + + if (($result['refund_amount'] ?? 0) > 0) { + $this->notifications->notifyPending($parentId, (float) $result['refund_amount'], url('/login')); + } + + return [ + 'ok' => true, + 'refund_id' => $result['refund_id'] ?? null, + 'refund_amount' => $result['refund_amount'] ?? 0.0, + 'status' => $result['status'] ?? null, + ]; + } + + if (in_array($requestType, ['overpayment', 'extra'], true)) { + $summary = $this->summaryService->getParentFinancialSummary($parentId, $schoolYear, $semester); + if (($summary['unapplied_balance'] ?? 0) <= 0) { + return ['ok' => false, 'message' => 'No unapplied balance available.']; + } + if ($amount > (float) ($summary['unapplied_balance'] ?? 0) + 0.0001) { + return [ + 'ok' => false, + 'message' => 'Amount exceeds available unapplied balance.', + 'available' => number_format((float) ($summary['unapplied_balance'] ?? 0), 2), + ]; + } + } + + if ($amount <= 0) { + return ['ok' => false, 'message' => 'Refund amount must be greater than zero.']; + } + + return DB::transaction(function () use ( + $parentId, + $schoolYear, + $semester, + $invoiceId, + $amount, + $requestType, + $reason, + $paymentId, + $actorId + ) { + $refund = Refund::query()->create([ + 'parent_id' => $parentId, + 'school_year' => $schoolYear, + 'semester' => $semester, + 'invoice_id' => $invoiceId, + 'refund_amount' => $amount, + 'refund_paid_amount' => 0.00, + 'status' => Refund::STATUS_PENDING, + 'reason' => $reason, + 'request' => $requestType, + 'note' => $paymentId ? ('duplicate-of-payment#' . $paymentId) : null, + 'requested_at' => utc_now(), + 'created_at' => utc_now(), + 'updated_at' => utc_now(), + 'updated_by' => $actorId, + ]); + + if (!$refund) { + Log::error('Failed to create refund request.', [ + 'parent_id' => $parentId, + 'type' => $requestType, + ]); + return ['ok' => false, 'message' => 'Failed to create refund request.']; + } + + $this->notifications->notifyPending($parentId, $amount, url('/login')); + + return [ + 'ok' => true, + 'refund_id' => (int) $refund->id, + 'refund_amount' => (float) $refund->refund_amount, + ]; + }); + } +} diff --git a/app/Services/Refunds/RefundSummaryService.php b/app/Services/Refunds/RefundSummaryService.php new file mode 100644 index 00000000..aca7c0ee --- /dev/null +++ b/app/Services/Refunds/RefundSummaryService.php @@ -0,0 +1,46 @@ +selectRaw('COALESCE(SUM(total_amount),0) AS total_invoiced') + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->where('semester', $semester) + ->first(); + + $payRow = (array) DB::table('payments') + ->selectRaw('COALESCE(SUM(paid_amount),0) AS paid_in') + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->where('semester', $semester) + ->first(); + + $refRow = (array) DB::table('refunds') + ->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS refunded_cash') + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->where('semester', $semester) + ->whereIn('status', ['Partial', 'Paid']) + ->first(); + + $totalInvoiced = (float) ($invRow['total_invoiced'] ?? 0); + $paidIn = (float) ($payRow['paid_in'] ?? 0); + $refundedCash = (float) ($refRow['refunded_cash'] ?? 0); + + $unapplied = $paidIn - $totalInvoiced - $refundedCash; + + return [ + 'total_invoiced' => $totalInvoiced, + 'total_paid_in' => $paidIn, + 'total_refunded' => $refundedCash, + 'unapplied_balance' => $unapplied, + ]; + } +} diff --git a/app/old/FeeCalculationService.php b/app/old/FeeCalculationService.php deleted file mode 100644 index f5540f32..00000000 --- a/app/old/FeeCalculationService.php +++ /dev/null @@ -1,183 +0,0 @@ -getConfig('school_year'); - $refundDeadline = date('Y-m-d', strtotime($configModel->getConfig('refund_deadline'))); - $weekOfStudy = (float) ($configModel->getConfig('weeks_study') ?? 8); - $schoolEndDate = date('Y-m-d', strtotime($configModel->getConfig('last_school_day'))); - $totalPaid = $paymentModel->getTotalPaidByParentId($parentId, $schoolYear); - - if ($totalPaid <= 0) { - log_message('info', "No payments made. Refund = 0."); - return 0; - } - - // Classify and enrich student data - $registeredStudents = []; - $withdrawnStudents = []; - - foreach ($students as &$student) { - $gradeName = $classSectionModel->getClassSectionNameBySectionId($student['class_section_id']); - $student['grade'] = strtoupper(trim($gradeName)); - - if (in_array($student['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'])) { - $withdrawnStudents[] = $student; - } elseif ( - in_array($student['enrollment_status'], ['enrolled', 'payment pending']) && - $student['admission_status'] === 'accepted' - ) { - $registeredStudents[] = $student; - } - } - unset($student); - - if (empty($withdrawnStudents)) { - log_message('info', "No withdrawn students found. Refund = 0."); - return 0; - } - - // Combine all students for proper fee tiering - $allStudents = array_merge($registeredStudents, $withdrawnStudents); - - // Sort all students by grade for correct tiering - usort($allStudents, function ($a, $b) { - return $this->compareGrades($a['grade'], $b['grade']); - }); - - // Retrieve fee configs - $firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 350); - $secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 200); - $youthFee = (float) ($configModel->getConfig('youth_fee') ?? 180); - - // Assign tuition_fee to all students (before filtering refunds) - $regularCount = 0; - foreach ($allStudents as &$student) { - $gradeLevel = $this->getGradeLevel($student['grade']); - - if ($gradeLevel > 9) { - $studentFee = $youthFee; - } else { - $studentFee = ($regularCount === 0) ? $firstStudentFee : $secondStudentFee; - $regularCount++; - } - } - unset($student); - - // Calculate refund for withdrawn students - $refundAmount = 0; - - foreach ($withdrawnStudents as $student) { - if (empty($student['withdrawal_date'])) { - log_message('warning', "Missing withdraw date for student ID: {$student['student_id']}"); - continue; - } - - $withdrawDate = date('Y-m-d', strtotime($student['withdrawal_date'])); - if (strtotime($withdrawDate) > strtotime($refundDeadline)) { - log_message('info', "Withdraw date {$withdrawDate} is after refund deadline {$refundDeadline}. No refund for this student."); - continue; - } - - $withdrawDateObj = new \DateTime($withdrawDate); - $schoolEndDateObj = new \DateTime($schoolEndDate); - $daysRemaining = $withdrawDateObj->diff($schoolEndDateObj)->days; - $weeksRemaining = min($weekOfStudy, max(0, ceil($daysRemaining / 7))); - - //$studentFee = $student['tuition_fee']; - $proportionalRefund = ($studentFee / $weekOfStudy) * $weeksRemaining; - $refundAmount += $proportionalRefund; - - log_message('info', "Student ID {$student['student_id']} refund portion: {$proportionalRefund} of {$studentFee} for {$weeksRemaining} weeks."); - } - - if ($refundAmount > $totalPaid) { - log_message('info', "Refund capped at total paid amount: {$totalPaid}"); - return $totalPaid; - } - - log_message('info', "Final calculated refund: {$refundAmount}"); - return $refundAmount; - } - - - private function compareGrades($gradeA, $gradeB) - { - $valA = $this->getGradeLevel($gradeA); - $valB = $this->getGradeLevel($gradeB); - - if ($valA !== $valB) return $valA <=> $valB; - - // Same level, compare suffix - preg_match('/\d+([A-Z]*)$/i', strtoupper($gradeA), $suffixA); - preg_match('/\d+([A-Z]*)$/i', strtoupper($gradeB), $suffixB); - - return strcmp($suffixA[1] ?? '', $suffixB[1] ?? ''); - } - - private function getGradeLevel($grade) - { - if (strtoupper($grade) === 'K') return 0; - if (strtoupper($grade) === 'Y') return 99; - - if (preg_match('/^(\d+)([A-Z]*)$/i', $grade, $matches)) { - return (int) $matches[1]; - } - - return 999; // fallback for unknown/malformed grades - } - - - private function calculateTotalTuitionFee(array $students): float - { - $configModel = new ConfigurationModel(); - $classSectionModel = new \App\Models\ClassSectionModel(); - - $firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 350); - $secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 200); - $youthFee = (float) ($configModel->getConfig('youth_fee') ?? 180); - - // ✅ Pre-fetch and assign grade/class section names before sorting - foreach ($students as &$student) { - $gradeName = $classSectionModel->getClassSectionNameBySectionId($student['class_section_id']); - $student['grade'] = strtoupper(trim($gradeName)); - } - unset($student); // break reference - - // ✅ Sort students by grade - usort($students, function ($a, $b) { - return $this->compareGrades($a['grade'], $b['grade']); - }); - - $regularCount = 0; - $totalFee = 0; - - // ✅ Calculate fee - foreach ($students as $student) { - $gradeLevel = $this->getGradeLevel($student['grade']); - - if ($gradeLevel > 9) { - $totalFee += $youthFee; - } else { - $totalFee += ($regularCount === 0) ? $firstStudentFee : $secondStudentFee; - $regularCount++; - } - } - - return $totalFee; - } - -} diff --git a/app/old/RefundController.php b/app/old/RefundController.php deleted file mode 100644 index fd89b9ae..00000000 --- a/app/old/RefundController.php +++ /dev/null @@ -1,785 +0,0 @@ -refundModel = new RefundModel(); - $this->userModel = new UserModel(); - $this->paymentModel = new PaymentModel(); - $this->configModel = new ConfigurationModel(); - $this->invoiceModel = new InvoiceModel(); - $this->enrollmentModel = new EnrollmentModel(); - $this->db = \Config\Database::connect(); - } - - /** Get current term (school_year, semester) from configuration */ - private function getCurrentTerm(): array - { - $rows = $this->configModel - ->select('config_key, config_value') - ->whereIn('config_key', ['school_year','semester']) - ->findAll(); - - $map = []; - foreach ($rows as $r) { - $map[$r['config_key']] = $r['config_value']; - } - return [ - 'school_year' => $map['school_year'] ?? date('Y') . '-' . (date('Y') + 1), - 'semester' => $map['semester'] ?? 'Fall', - ]; - } - - /** - * Detect overpayments per parent for current school_year and create/update Pending refunds. - * When $triggerEvents is true, emits refundPending for newly created rows only. - * - * @return array [created_ids => int[], updated_ids => int[]] - */ - private function recalcOverpayments(bool $triggerEvents = false): array - { - $created = []; - $updated = []; - $term = $this->getCurrentTerm(); - $year = (string)$term['school_year']; - $sem = (string)$term['semester']; - - // Sum payments by parent for the term - $pm = $this->paymentModel; - $payTable = $pm->table; - $hasStatus = $this->paymentModel->db->fieldExists('status', $payTable); - $hasVoid = $this->paymentModel->db->fieldExists('is_void', $payTable); - - $qbPaid = $pm->select('parent_id, COALESCE(SUM(paid_amount),0) AS total_paid') - ->where('school_year', $year) - ->groupBy('parent_id'); - if ($hasStatus) { - $qbPaid->groupStart() - ->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled']) - ->orWhere('status IS NULL', null, false) - ->groupEnd(); - } - if ($hasVoid) { - $qbPaid->groupStart() - ->where('is_void', 0) - ->orWhere('is_void IS NULL', null, false) - ->groupEnd(); - } - $paidRows = $qbPaid->findAll(); - - // Sum invoices (charges) by parent for the term - $invRows = $this->invoiceModel - ->select('parent_id, COALESCE(SUM(total_amount),0) AS total_invoiced') - ->where('school_year', $year) - ->groupBy('parent_id') - ->findAll(); - - // Sum refunds PAID (cash out) by parent for the term - $refRows = $this->refundModel - ->select('parent_id, COALESCE(SUM(refund_paid_amount),0) AS total_refunded') - ->where('school_year', $year) - ->whereIn('status', ['Partial','Paid']) - ->groupBy('parent_id') - ->findAll(); - - $paidMap = []; - foreach ($paidRows as $r) $paidMap[(int)$r['parent_id']] = (float)($r['total_paid'] ?? 0); - $invMap = []; - foreach ($invRows as $r) $invMap[(int)$r['parent_id']] = (float)($r['total_invoiced'] ?? 0); - $refMap = []; - foreach ($refRows as $r) $refMap[(int)$r['parent_id']] = (float)($r['total_refunded'] ?? 0); - - $parentIds = array_unique(array_merge(array_keys($paidMap), array_keys($invMap))); - - foreach ($parentIds as $pid) { - $paid = (float)($paidMap[$pid] ?? 0); - $invd = (float)($invMap[$pid] ?? 0); - $rfnd = (float)($refMap[$pid] ?? 0); - $over = round($paid - $invd - $rfnd, 2); - if ($over > 0.00 + 0.0001) { - // Parent-level policy: DO NOT create new overpayment rows; only keep existing parent-level - // overpayment rows in sync if they are still open (Pending/Partial). Prefer per-invoice rows. - $existing = $this->refundModel - ->where('parent_id', $pid) - ->where('school_year', $year) - ->where('request', 'overpayment') - ->orderBy('id', 'DESC') - ->first(); - - if ($existing && in_array($existing['status'], ['Pending','Partial'], true)) { - $this->refundModel->update((int)$existing['id'], [ - 'refund_amount' => $over, - 'updated_at' => utc_now(), - 'updated_by' => session()->get('user_id'), - ]); - $updated[] = (int)$existing['id']; - } - // else: no parent-level row created - } - } - - // ===== Stage 2: Per-invoice overpayment detection (current school year) ===== - try { - $invRows = $this->invoiceModel - ->select('id, parent_id, total_amount, school_year') - ->where('school_year', $year) - ->findAll(); - if ($invRows) { - $invoiceIds = array_map(static fn($r) => (int)$r['id'], $invRows); - - // payments by invoice - $qbInvPaid = $this->paymentModel - ->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid') - ->whereIn('invoice_id', $invoiceIds) - ->groupBy('invoice_id'); - $payTable2 = $this->paymentModel->table; - $hasStatus2 = $this->paymentModel->db->fieldExists('status', $payTable2); - $hasVoid2 = $this->paymentModel->db->fieldExists('is_void', $payTable2); - if ($hasStatus2) { - $qbInvPaid->groupStart() - ->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled']) - ->orWhere('status IS NULL', null, false) - ->groupEnd(); - } - if ($hasVoid2) { - $qbInvPaid->groupStart() - ->where('is_void', 0) - ->orWhere('is_void IS NULL', null, false) - ->groupEnd(); - } - $invPaidRows = $qbInvPaid->findAll(); - - // discounts by invoice - $invDiscRows = $this->db->table('discount_usages') - ->select('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc') - ->whereIn('invoice_id', $invoiceIds) - ->groupBy('invoice_id') - ->get()->getResultArray(); - - // refunds paid by invoice - $invRefRows = $this->refundModel - ->select('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_ref') - ->whereIn('invoice_id', $invoiceIds) - ->whereIn('status', ['Partial','Paid']) - ->groupBy('invoice_id') - ->findAll(); - - $paidByInv = []; - foreach ($invPaidRows as $r) { $paidByInv[(int)$r['invoice_id']] = (float)($r['total_paid'] ?? 0); } - $discByInv = []; - foreach ($invDiscRows as $r) { $discByInv[(int)$r['invoice_id']] = (float)($r['total_disc'] ?? 0); } - $refByInv = []; - foreach ($invRefRows as $r) { $refByInv[(int)$r['invoice_id']] = (float)($r['total_ref'] ?? 0); } - - foreach ($invRows as $inv) { - $iid = (int)$inv['id']; - $pid = (int)$inv['parent_id']; - $total = (float)($inv['total_amount'] ?? 0); - $paid = (float)($paidByInv[$iid] ?? 0); - $disc = (float)($discByInv[$iid] ?? 0); - $rfnd = (float)($refByInv[$iid] ?? 0); - - $rawBalance = round($total - $disc - $paid - $rfnd, 2); - if ($rawBalance < -0.0001) { - $over = abs($rawBalance); - - // If ANY open refund exists for this invoice, update it instead of creating another line - $openRow = $this->refundModel - ->where('invoice_id', $iid) - ->whereIn('status', ['Pending','Approved','Partial']) - ->orderBy('id', 'DESC') - ->first(); - if ($openRow) { - // Prefer merging into 'overpayment' if present, else update the open row - $this->refundModel->update((int)$openRow['id'], [ - 'refund_amount' => $over, - 'updated_at' => utc_now(), - 'updated_by' => session()->get('user_id'), - ]); - $updated[] = (int)$openRow['id']; - continue; - } - - // If there is an open PARENT-LEVEL overpayment (invoice_id IS NULL), migrate it to this invoice - $parentLevelOpen = $this->refundModel - ->where('parent_id', $pid) - ->where('school_year', $year) - ->where('invoice_id', null) - ->where('request', 'overpayment') - ->whereIn('status', ['Pending','Approved','Partial']) - ->orderBy('id', 'DESC') - ->first(); - if ($parentLevelOpen) { - $this->refundModel->update((int)$parentLevelOpen['id'], [ - 'invoice_id' => $iid, - 'refund_amount' => $over, - 'updated_at' => utc_now(), - 'updated_by' => session()->get('user_id'), - ]); - $updated[] = (int)$parentLevelOpen['id']; - continue; // merged instead of creating new - } - - // Existing overpayment refund for this invoice? - $existing = $this->refundModel - ->where('invoice_id', $iid) - ->where('request', 'overpayment') - ->whereIn('status', ['Pending','Approved','Partial','Paid']) - ->orderBy('id', 'DESC') - ->first(); - - if (!$existing || $existing['status'] === 'Paid') { - $ok = $this->refundModel->insert([ - 'parent_id' => $pid, - 'school_year' => $year, - 'semester' => $sem, - 'invoice_id' => $iid, - 'refund_amount' => $over, - 'refund_paid_amount' => 0.00, - 'status' => 'Pending', - 'request' => 'overpayment', - 'reason' => 'Auto-detected per-invoice overpayment', - 'updated_by' => session()->get('user_id'), - 'created_at' => utc_now(), - 'updated_at' => utc_now(), - ]); - if ($ok) { - $created[] = (int)$this->refundModel->getInsertID(); - if ($triggerEvents) { - $user = (new UserModel())->select('id, email, firstname, lastname')->find($pid) ?: []; - $eventData = [ - 'user_id' => (int)($user['id'] ?? $pid), - 'email' => $user['email'] ?? null, - 'firstname' => $user['firstname'] ?? null, - 'lastname' => $user['lastname'] ?? null, - 'amount' => $over, - 'portalLink'=> base_url('/login'), - ]; - \CodeIgniter\Events\Events::trigger('refundPending', $eventData, []); - } - } - } else { - // Keep Pending/Partial in sync with current overpayment - if (in_array($existing['status'], ['Pending','Partial'], true)) { - $this->refundModel->update((int)$existing['id'], [ - 'refund_amount' => $over, - 'updated_at' => utc_now(), - 'updated_by' => session()->get('user_id'), - ]); - $updated[] = (int)$existing['id']; - } - } - } - } - } - } catch (\Throwable $e) { - log_message('error', 'recalcOverpayments per-invoice failed: ' . $e->getMessage()); - } - - return ['created_ids' => $created, 'updated_ids' => $updated]; - } - - /** - * Parent financial summary for *current term* (adjust if you want all-time). - * Assumes invoices.total_amount and payments.amount (positive=in, negative=out). - */ - private function getParentFinancialSummary(int $parentId, string $schoolYear, string $semester): array - { - // invoices total for term - $inv = $this->invoiceModel - ->select('COALESCE(SUM(total_amount),0) AS total_invoiced') - ->where('parent_id', $parentId) - ->where('school_year', $schoolYear) - ->where('semester', $semester) - ->first(); - - // payments in (amount > 0) for term - $payIn = $this->paymentModel - ->select('COALESCE(SUM(amount),0) AS paid_in') - ->where('parent_id', $parentId) - ->where('school_year', $schoolYear) - ->where('semester', $semester) - ->where('amount >', 0) - ->first(); - - // refunds already paid out (your refunds table) - $refPaid = $this->refundModel - ->select('COALESCE(SUM(refund_paid_amount),0) AS refunded_cash') - ->where('parent_id', $parentId) - ->where('school_year', $schoolYear) - ->where('semester', $semester) - ->whereIn('status', ['Partial','Paid']) - ->first(); - - $totalInvoiced = (float)($inv['total_invoiced'] ?? 0); - $paidIn = (float)($payIn['paid_in'] ?? 0); - $refundedCash = (float)($refPaid['refunded_cash'] ?? 0); - - // Unapplied balance = money in − invoices − cash refunds out - $unapplied = $paidIn - $totalInvoiced - $refundedCash; - - return [ - 'total_invoiced' => $totalInvoiced, - 'total_paid_in' => $paidIn, - 'total_refunded' => $refundedCash, - 'unapplied_balance' => $unapplied, - ]; - } - - /** Optional helper if you want a quick API for balances in UI */ - public function parentBalances(int $parentId) - { - $t = $this->getCurrentTerm(); - return $this->response->setJSON($this->getParentFinancialSummary($parentId, $t['school_year'], $t['semester'])); - } - - /** - * Create refund request. - * Stores source/type in `refunds.request` as: tuition|overpayment|duplicate|extra - * - * @param int $parentId - * @param float $amount - * @param string|null $requestType tuition|overpayment|duplicate|extra - * @param int|null $invoiceId required for tuition; useful for duplicate - * @param int|null $paymentId useful for duplicate - * @param string|null $reason - */ - public function requestRefund( - int $parentId, - float $amount, - ?string $requestType = 'tuition', - ?int $invoiceId = null, - ?int $paymentId = null, - ?string $reason = null - ) { - $requestType = strtolower((string)$requestType); - if (!in_array($requestType, self::REQUEST_TYPES, true)) { - return $this->response->setJSON(['error' => 'Invalid refund request type.']); - } - - $parent = $this->userModel->find($parentId); - if (!$parent) { - return $this->response->setJSON(['error' => 'Parent not found.']); - } - if ($amount <= 0) { - return $this->response->setJSON(['error' => 'Refund amount must be > 0.']); - } - - $term = $this->getCurrentTerm(); - $schoolYear = $term['school_year']; - $semester = $term['semester']; - - // Tuition requires an invoice check - if ($requestType === 'tuition') { - if (empty($invoiceId)) { - return $this->response->setJSON(['error' => 'invoice_id is required for tuition refunds.']); - } - $inv = $this->invoiceModel->find($invoiceId); - if (!$inv || (int)$inv['parent_id'] !== $parentId) { - return $this->response->setJSON(['error' => 'Invoice not found for parent.']); - } - // Optionally cap to eligible amount per your policy. - } - - // Overpayment/extra must not exceed unapplied balance - if (in_array($requestType, ['overpayment','extra'], true)) { - $sum = $this->getParentFinancialSummary($parentId, $schoolYear, $semester); - if ($sum['unapplied_balance'] <= 0) { - return $this->response->setJSON(['error' => 'No unapplied balance available.']); - } - if ($amount > $sum['unapplied_balance'] + 0.0001) { - return $this->response->setJSON([ - 'error' => 'Amount exceeds available unapplied balance.', - 'available' => number_format($sum['unapplied_balance'], 2) - ]); - } - } - - // Duplicate: optionally tie to payment; otherwise your staff can fill details in reason/note - if ($requestType === 'duplicate' && empty($paymentId) && empty($invoiceId)) { - // Not hard-failing; but better to guide: - // return $this->response->setJSON(['error' => 'Provide payment_id or invoice_id for duplicate refunds.']); - } - - $payload = [ - 'parent_id' => $parentId, - 'school_year' => $schoolYear, - 'semester' => $semester, - 'invoice_id' => $invoiceId, - 'refund_amount' => $amount, - 'refund_paid_amount' => 0.00, // IMPORTANT: your column is NOT NULL - 'status' => 'Pending', - 'reason' => $reason, - 'request' => $requestType, // <- store the source/type here - 'note' => $paymentId ? ('duplicate-of-payment#' . $paymentId) : null, - 'created_at' => utc_now(), - 'updated_at' => utc_now(), - ]; - $ok = $this->refundModel->insert($payload); - - if (!$ok) { - return $this->response->setJSON(['error' => 'Failed to create refund request.']); - } - - // Fire refundPending notification/event for newly created refunds - try { - $user = $this->userModel->select('id, email, firstname, lastname')->find($parentId) ?: []; - $eventData = [ - 'user_id' => (int)($user['id'] ?? $parentId), - 'email' => $user['email'] ?? null, - 'firstname' => $user['firstname'] ?? null, - 'lastname' => $user['lastname'] ?? null, - 'amount' => (float)$amount, - 'portalLink'=> base_url('/login'), - ]; - \CodeIgniter\Events\Events::trigger('refundPending', $eventData, []); - } catch (\Throwable $e) { - log_message('error', 'requestRefund: failed to trigger refundPending: ' . $e->getMessage()); - } - - return $this->response->setJSON(['success' => 'Refund requested']); - } - - // Approve refund (no money movement) - public function approveRefund(int $refundId) - { - $ok = $this->refundModel->update($refundId, [ - 'status' => 'Approved', - 'approved_at' => utc_now(), - 'approved_by' => session()->get('user_id'), - 'updated_at' => utc_now(), - 'updated_by' => session()->get('user_id'), - ]); - - return $this->response->setJSON($ok ? ['success' => 'Refund approved'] : ['error' => 'Approve failed']); - } - - // Reject refund - public function rejectRefund(int $refundId) - { - $ok = $this->refundModel->update($refundId, [ - 'status' => 'Rejected', - 'approved_at' => utc_now(), - 'approved_by' => session()->get('user_id'), - 'updated_at' => utc_now(), - 'updated_by' => session()->get('user_id'), - ]); - - return $this->response->setJSON($ok ? ['success' => 'Refund rejected'] : ['error' => 'Reject failed']); - } - - /** - * POST: refund_id, paid_amount, payment_method (Check|Online|Cash), check_number, check_file (optional) - * Marks payout (can be partial) and inserts a negative Payment row to keep balances correct. - */ - public function updatePayment() - { - log_message('debug', 'POST data: ' . print_r($_POST, true)); - - $refundId = (int)$this->request->getPost('refund_id'); - $newPaidAmount = (float)$this->request->getPost('paid_amount'); - $refundMethod = $this->request->getPost('payment_method'); // Check|Online|Cash - $checkNbr = $this->request->getPost('check_number'); - - if ($refundId <= 0) return $this->response->setJSON(['error' => 'Missing refund ID.']); - if ($newPaidAmount <= 0) return $this->response->setJSON(['error' => 'Valid paid amount is required.']); - - $refund = $this->refundModel->find($refundId); - if (!$refund) return $this->response->setJSON(['error' => 'Refund not found.']); - - if (!in_array($refund['status'], ['Approved','Partial'], true)) { - return $this->response->setJSON(['error' => 'Refund must be Approved/Partial to pay.']); - } - - $oldPaid = (float)$refund['refund_paid_amount']; - $target = (float)$refund['refund_amount']; - $total = $oldPaid + $newPaidAmount; - - if ($total > $target + 0.0001) { - return $this->response->setJSON(['error' => 'Total paid cannot exceed refund amount.']); - } - - // Optional file upload for Check - $checkFileName = $refund['check_file'] ?? null; - if ($refundMethod === 'Check') { - $checkFile = $this->request->getFile('check_file'); - if ($checkFile && $checkFile->isValid() && !$checkFile->hasMoved()) { - $checkFileName = $checkFile->getRandomName(); - $checkFile->move(WRITEPATH . 'uploads/checks/', $checkFileName); - } - } - - $newStatus = ($total < $target) ? 'Partial' : 'Paid'; - - $db = db_connect(); - $db->transStart(); - - // 0) If this refund is not tied to an invoice yet, assign it to the most overpaid invoice - $assignInvoiceId = null; - if (empty($refund['invoice_id'])) { - try { - // Gather invoices for this parent/year - $invRows = $this->invoiceModel - ->select('id, total_amount') - ->where('parent_id', (int)$refund['parent_id']) - ->where('school_year', $refund['school_year']) - ->findAll(); - - if ($invRows) { - $invoiceIds = array_map(static fn($r) => (int)$r['id'], $invRows); - - // Payments per invoice - $payRows = $this->paymentModel - ->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid') - ->whereIn('invoice_id', $invoiceIds) - ->groupBy('invoice_id') - ->findAll(); - $paidBy = []; - foreach ($payRows as $r) { $paidBy[(int)$r['invoice_id']] = (float)($r['total_paid'] ?? 0); } - - // Discounts per invoice - $discRows = $this->db->table('discount_usages') - ->select('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc') - ->whereIn('invoice_id', $invoiceIds) - ->groupBy('invoice_id') - ->get()->getResultArray(); - $discBy = []; - foreach ($discRows as $r) { $discBy[(int)$r['invoice_id']] = (float)($r['total_disc'] ?? 0); } - - // Choose invoice with most negative raw (total - disc - paid) - $minRaw = 0.0; $minId = null; - foreach ($invRows as $ir) { - $iid = (int)$ir['id']; - $raw = (float)($ir['total_amount'] ?? 0) - (float)($discBy[$iid] ?? 0) - (float)($paidBy[$iid] ?? 0); - if ($raw < $minRaw) { $minRaw = $raw; $minId = $iid; } - } - if ($minId !== null) { - $assignInvoiceId = $minId; - } else { - // fallback: latest invoice in this year - $row = $this->invoiceModel - ->select('id') - ->where('parent_id', (int)$refund['parent_id']) - ->where('school_year', $refund['school_year']) - ->orderBy('created_at', 'DESC') - ->first(); - if ($row && !empty($row['id'])) $assignInvoiceId = (int)$row['id']; - } - } - } catch (\Throwable $e) { - log_message('error', 'updatePayment: invoice assignment failed: ' . $e->getMessage()); - } - } - - // 1) Update refund row - $this->refundModel->update($refundId, [ - 'refund_paid_amount' => $total, - 'status' => $newStatus, - 'refunded_at' => utc_now(), - 'updated_at' => utc_now(), - 'updated_by' => session()->get('user_id'), - 'refund_method' => $refundMethod, - 'check_nbr' => $checkNbr, - 'check_file' => $checkFileName, - // tie to invoice if determined - 'invoice_id' => $assignInvoiceId ?? $refund['invoice_id'], - ]); - - // 2) Optional: If you want an accounting journal entry for payouts, write to a dedicated table. - // We no longer insert a negative row into payments to avoid schema/validation conflicts. - - $db->transComplete(); - - if ($db->transStatus() === false) { - return $this->response->setJSON(['error' => 'Failed to update payment.']); - } - - return $this->response->setJSON(['success' => 'Payment updated successfully.']); - } - - /** Keep your listing; added extra fields for clarity */ - public function listRefunds() - { - // NOTE: We no longer auto-create/adjust refunds on page load to avoid duplicate lines - // when staff are recording payouts. Use the "Recalculate" buttons to run detection on demand. - - // 2) List refunds with joins - $refunds = $this->refundModel - ->select('refunds.*, - u.firstname, u.lastname, u.school_id, - a.firstname AS approved_by_firstname, - a.lastname AS approved_by_lastname') - ->join('users u', 'refunds.parent_id = u.id') - ->join('users a', 'refunds.approved_by = a.id', 'left') - ->orderBy('refunds.created_at', 'DESC') - ->findAll(); - - foreach ($refunds as &$r) { - $r['approved_by_name'] = trim(($r['approved_by_firstname'] ?? '') . ' ' . ($r['approved_by_lastname'] ?? '')) ?: '-'; - } - - $parentId = !empty($refunds) ? $refunds[0]['parent_id'] : null; - - return view('refunds/list', [ - 'refunds' => $refunds, - 'parentId' => $parentId - ]); - } - - /** Manual endpoint to recalculate/sync overpayments and optionally notify newly created entries. */ - public function recalculateOverpayments() - { - // Optional targeted invoice_number to handle cross-year cases - $invoiceNumber = (string)($this->request->getPost('invoice_number') ?? ''); - if ($invoiceNumber !== '') { - try { - $inv = $this->invoiceModel->where('invoice_number', $invoiceNumber)->first(); - if ($inv) { - $pid = (int)$inv['parent_id']; - $iid = (int)$inv['id']; - $year = (string)$inv['school_year']; - - // Compute per-invoice overpayment now (independent of current term) - $paid = (float)($this->paymentModel - ->select('COALESCE(SUM(paid_amount),0) AS tot') - ->where('invoice_id', $iid) - ->first()['tot'] ?? 0); - $disc = (float)($this->db->table('discount_usages') - ->select('COALESCE(SUM(discount_amount),0) AS tot') - ->where('invoice_id', $iid) - ->get()->getRowArray()['tot'] ?? 0); - $rfnd = (float)($this->refundModel - ->select('COALESCE(SUM(refund_paid_amount),0) AS tot') - ->where('invoice_id', $iid) - ->whereIn('status', ['Partial','Paid']) - ->first()['tot'] ?? 0); - $total = (float)($inv['total_amount'] ?? 0); - $raw = round($total - $disc - $paid - $rfnd, 2); - if ($raw < -0.0001) { - $over = abs($raw); - // If there is ANY open refund for this invoice (any request), update it rather than create - $openRow = $this->refundModel - ->where('invoice_id', $iid) - ->whereIn('status', ['Pending','Approved','Partial']) - ->orderBy('id', 'DESC') - ->first(); - if ($openRow) { - $this->refundModel->update((int)$openRow['id'], [ - 'refund_amount' => $over, - 'request' => 'overpayment', - 'updated_at' => utc_now(), - 'updated_by' => session()->get('user_id'), - ]); - return redirect()->to(site_url('refunds/list'))->with('success', 'Overpayment updated for invoice ' . esc($invoiceNumber)); - } - - // Else, if no open rows exist, either update existing overpayment (Paid) — skip creating new - $existing = $this->refundModel - ->where('invoice_id', $iid) - ->where('request', 'overpayment') - ->orderBy('id', 'DESC') - ->first(); - if ($existing) { - // If it's Paid, do not create a new duplicate line - return redirect()->to(site_url('refunds/list'))->with('info', 'Overpayment was already resolved for this invoice.'); - } - // As a last resort, create a single overpayment row - $this->refundModel->insert([ - 'parent_id' => $pid, - 'school_year' => $year, - 'semester' => $inv['semester'] ?? null, - 'invoice_id' => $iid, - 'refund_amount' => $over, - 'refund_paid_amount' => 0.00, - 'status' => 'Pending', - 'request' => 'overpayment', - 'reason' => 'Auto-detected per-invoice overpayment (manual)', - 'updated_by' => session()->get('user_id'), - 'created_at' => utc_now(), - 'updated_at' => utc_now(), - ]); - $user = $this->userModel->select('id, email, firstname, lastname')->find($pid) ?: []; - $eventData = [ - 'user_id' => (int)($user['id'] ?? $pid), - 'email' => $user['email'] ?? null, - 'firstname' => $user['firstname'] ?? null, - 'lastname' => $user['lastname'] ?? null, - 'amount' => $over, - 'portalLink'=> base_url('/login'), - ]; - \CodeIgniter\Events\Events::trigger('refundPending', $eventData, []); - return redirect()->to(site_url('refunds/list'))->with('success', 'Overpayment detected and refund created for invoice ' . esc($invoiceNumber)); - } - return redirect()->to(site_url('refunds/list'))->with('info', 'No overpayment detected for invoice ' . esc($invoiceNumber)); - } - return redirect()->to(site_url('refunds/list'))->with('error', 'Invoice not found: ' . esc($invoiceNumber)); - } catch (\Throwable $e) { - return redirect()->to(site_url('refunds/list'))->with('error', 'Failed to recalc for invoice: ' . $e->getMessage()); - } - } - - $res = $this->recalcOverpayments(true); - $nNew = count($res['created_ids'] ?? []); - $nUpd = count($res['updated_ids'] ?? []); - return redirect()->to(site_url('refunds/list')) - ->with('success', "Recalculated overpayments: created {$nNew}, updated {$nUpd}."); - } - - /** Decision endpoint (Approved/Rejected) with reason */ - public function updateStatus() - { - log_message('debug', 'POST data: ' . print_r($_POST, true)); - - $refundId = (int)$this->request->getPost('refund_id'); - $status = $this->request->getPost('status'); - $reason = $this->request->getPost('reason'); - - if ($refundId <= 0 || !in_array($status, self::DECISIONS, true)) { - return $this->response->setJSON(['error' => 'Invalid status update request.']); - } - if (empty($reason)) { - return $this->response->setJSON(['error' => 'Reason is required for approval or rejection.']); - } - - $refund = $this->refundModel->find($refundId); - if (!$refund) { - return $this->response->setJSON(['error' => 'Refund not found.']); - } - - $ok = $this->refundModel->update($refundId, [ - 'status' => $status, - 'reason' => $reason, - 'approved_at' => utc_now(), - 'approved_by' => session()->get('user_id'), - 'updated_at' => utc_now(), - 'updated_by' => session()->get('user_id'), - ]); - - return $this->response->setJSON($ok ? ['success' => 'Refund status updated successfully.'] - : ['error' => 'Failed to update refund status.']); - } -} diff --git a/routes/api.php b/routes/api.php index 1e674c4f..26708fd7 100755 --- a/routes/api.php +++ b/routes/api.php @@ -68,6 +68,7 @@ use App\Http\Controllers\Api\Reports\PrintablesReportsController; use App\Http\Controllers\Api\Scores\ProjectController; use App\Http\Controllers\Api\Scores\QuizController; use App\Http\Controllers\Api\Finance\PaymentController; +use App\Http\Controllers\Api\Finance\RefundController; use App\Http\Controllers\Api\Finance\PaymentNotificationController; use App\Http\Controllers\Api\Finance\PaymentTransactionController; use App\Http\Controllers\Api\Finance\PaypalTransactionsController; @@ -223,6 +224,17 @@ Route::prefix('v1')->group(function () { Route::delete('events/{eventId}', [SchoolCalendarController::class, 'destroy']); }); + Route::prefix('settings/events')->group(function () { + Route::get('/', [EventController::class, 'index']); + Route::post('/', [EventController::class, 'store']); + Route::get('charges/list', [EventController::class, 'charges']); + Route::post('{eventId}/charges', [EventController::class, 'updateCharges']); + Route::get('charges/students', [EventController::class, 'studentsWithCharges']); + Route::get('{eventId}', [EventController::class, 'show']); + Route::patch('{eventId}', [EventController::class, 'update']); + Route::delete('{eventId}', [EventController::class, 'destroy']); + }); + Route::prefix('whatsapp')->group(function () { Route::get('links', [WhatsappController::class, 'index']); Route::post('links', [WhatsappController::class, 'store']); @@ -398,6 +410,19 @@ Route::prefix('v1')->group(function () { Route::post('fees/refund', [FeeCalculationController::class, 'refund']); Route::post('fees/tuition-total', [FeeCalculationController::class, 'tuitionTotal']); + Route::prefix('refunds')->group(function () { + Route::post('recalculate-overpayments', [RefundController::class, 'recalculateOverpayments']); + Route::get('parent-balances/{parentId}', [RefundController::class, 'parentBalances']); + Route::get('/', [RefundController::class, 'index']); + Route::post('/', [RefundController::class, 'store']); + Route::get('{refundId}', [RefundController::class, 'show']); + Route::patch('{refundId}', [RefundController::class, 'update']); + Route::delete('{refundId}', [RefundController::class, 'destroy']); + Route::post('{refundId}/approve', [RefundController::class, 'approve']); + Route::post('{refundId}/reject', [RefundController::class, 'reject']); + Route::post('{refundId}/payments', [RefundController::class, 'recordPayment']); + }); + Route::prefix('reimbursements')->group(function () { Route::get('under-processing', [ReimbursementController::class, 'underProcessing']); Route::post('mark-donation', [ReimbursementController::class, 'markDonation']); diff --git a/school_api b/school_api index 57e5ac8e8a733d7ca1dd9cd5d16a5dcefc47b51f..5a91352b12ac10a9666e54a7777340976d2a1a8f 100644 GIT binary patch delta 327 zcmZo@P-|#Vn;^~TG*QNx(Wx<^HG#1;fvGitxix{MHG#D?fvq)xy)}VjYXavIdk!WZ z83sN+9+~Yt4x9o!Ce8WmoUsh@jp41TEFqpQ>8_!Ec?QnTj=qK#=6-&uS)PVP?ghU7 zxj_Mj{%JnGfoA1~&eNUzIQz`PLfu?^-2UCAALj=~zIioV{0a5U2WrdPwkuC+rjW0Y#jrApud#4;pfh;0p)>4nYVN z6*Vw+s;a1}Vn4;Qo-MK5i5K)T@)CJbUScnam$8@hB6OaoXbc#m+MpL?b?e+0gIUoB zO=B~5fTjb}u?u=l6UO@?cekS@l_C$o13!ZndU3sua}@z582}*nz&&4s-q7P;bb4y0D!(LH2?qr diff --git a/tests/Feature/Api/V1/Finance/FeeCalculationControllerTest.php b/tests/Feature/Api/V1/Finance/FeeCalculationControllerTest.php index 9408a261..d8859ef7 100644 --- a/tests/Feature/Api/V1/Finance/FeeCalculationControllerTest.php +++ b/tests/Feature/Api/V1/Finance/FeeCalculationControllerTest.php @@ -3,6 +3,8 @@ namespace Tests\Feature\Api\V1\Finance; use App\Models\User; +use App\Services\Fees\FeeRefundCalculatorService; +use App\Services\Fees\FeeStudentFeeService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; use Laravel\Sanctum\Sanctum; @@ -63,6 +65,17 @@ class FeeCalculationControllerTest extends TestCase $response->assertOk(); $response->assertJsonPath('ok', true); + $response->assertJsonStructure([ + 'ok', + 'refund' => [ + 'refund_amount', + 'total_paid', + 'refund_deadline', + 'school_end_date', + 'weeks_of_study', + 'withdrawn_students', + ], + ]); $this->assertGreaterThan(0, (float) $response->json('refund.refund_amount')); } @@ -83,9 +96,110 @@ class FeeCalculationControllerTest extends TestCase $response->assertOk(); $response->assertJsonPath('ok', true); + $response->assertJsonStructure([ + 'ok', + 'tuition' => [ + 'total_tuition', + ], + ]); $this->assertGreaterThan(0, (float) $response->json('tuition.total_tuition')); } + public function test_refund_endpoint_requires_authentication(): void + { + $response = $this->postJson('/api/v1/finance/fees/refund', [ + 'parent_id' => 10, + 'students' => [], + ]); + + $response->assertStatus(401); + } + + public function test_tuition_total_endpoint_requires_authentication(): void + { + $response = $this->postJson('/api/v1/finance/fees/tuition-total', [ + 'students' => [], + ]); + + $response->assertStatus(401); + } + + public function test_refund_endpoint_validates_payload(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/v1/finance/fees/refund', [ + 'parent_id' => 0, + 'students' => [ + ['class_section_id' => null], + ], + ]); + + $response->assertStatus(422); + $response->assertJsonStructure(['message', 'errors']); + } + + public function test_tuition_total_endpoint_validates_payload(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/v1/finance/fees/tuition-total', [ + 'students' => [ + ['class_section_id' => null], + ], + ]); + + $response->assertStatus(422); + $response->assertJsonStructure(['message', 'errors']); + } + + public function test_refund_endpoint_handles_service_failure(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $this->mock(FeeRefundCalculatorService::class, function ($mock) { + $mock->shouldReceive('calculateRefund')->andThrow(new \RuntimeException('boom')); + }); + + $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->assertStatus(500); + $response->assertJsonPath('ok', false); + } + + public function test_tuition_total_endpoint_handles_service_failure(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $this->mock(FeeStudentFeeService::class, function ($mock) { + $mock->shouldReceive('totalTuitionFee')->andThrow(new \RuntimeException('boom')); + }); + + $response = $this->postJson('/api/v1/finance/fees/tuition-total', [ + 'students' => [ + ['class_section_id' => 101], + ], + ]); + + $response->assertStatus(500); + $response->assertJsonPath('ok', false); + } + private function seedConfig(): void { DB::table('configuration')->insert([ diff --git a/tests/Feature/Api/V1/Finance/RefundControllerTest.php b/tests/Feature/Api/V1/Finance/RefundControllerTest.php new file mode 100644 index 00000000..389a4ecf --- /dev/null +++ b/tests/Feature/Api/V1/Finance/RefundControllerTest.php @@ -0,0 +1,335 @@ +getJson('/api/v1/finance/refunds'); + + $response->assertStatus(401); + } + + public function test_index_returns_paginated_refunds(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + DB::table('refunds')->insert([ + [ + 'id' => 1, + 'parent_id' => 10, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'refund_amount' => 50, + 'refund_paid_amount' => 0, + 'status' => 'Pending', + 'request' => 'overpayment', + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $response = $this->getJson('/api/v1/finance/refunds'); + + $response->assertOk(); + $response->assertJsonPath('ok', true); + $response->assertJsonStructure([ + 'ok', + 'refunds', + 'pagination' => [ + 'current_page', + 'per_page', + 'total', + 'total_pages', + ], + ]); + } + + public function test_show_returns_refund(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + DB::table('refunds')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'refund_amount' => 50, + 'refund_paid_amount' => 0, + 'status' => 'Pending', + 'request' => 'overpayment', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = $this->getJson('/api/v1/finance/refunds/1'); + + $response->assertOk(); + $response->assertJsonPath('ok', true); + $response->assertJsonStructure(['ok', 'refund' => ['id', 'status', 'refund_amount']]); + } + + public function test_store_overpayment_creates_refund(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $this->seedConfig(); + + DB::table('invoices')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_number' => 'INV-1', + 'total_amount' => 100.00, + 'balance' => 100.00, + 'paid_amount' => 0.00, + 'status' => 'Unpaid', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'issue_date' => '2025-01-05', + ]); + + DB::table('payments')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_id' => 1, + 'paid_amount' => 150.00, + 'total_amount' => 150.00, + 'balance' => 0.00, + 'payment_date' => '2025-01-10', + 'payment_method' => 'manual', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'status' => 'Paid', + ]); + + $response = $this->postJson('/api/v1/finance/refunds', [ + 'parent_id' => 10, + 'request_type' => 'overpayment', + 'amount' => 25, + ]); + + $response->assertStatus(201); + $response->assertJsonPath('ok', true); + $this->assertDatabaseHas('refunds', [ + 'parent_id' => 10, + 'request' => 'overpayment', + ]); + } + + public function test_store_validates_payload(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/v1/finance/refunds', [ + 'parent_id' => 0, + 'request_type' => 'overpayment', + ]); + + $response->assertStatus(422); + $response->assertJsonStructure(['message', 'errors']); + } + + public function test_update_approves_refund(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + DB::table('refunds')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'refund_amount' => 50, + 'refund_paid_amount' => 0, + 'status' => 'Pending', + 'request' => 'overpayment', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = $this->patchJson('/api/v1/finance/refunds/1', [ + 'status' => 'Approved', + 'reason' => 'ok', + ]); + + $response->assertOk(); + $response->assertJsonPath('ok', true); + $this->assertDatabaseHas('refunds', [ + 'id' => 1, + 'status' => 'Approved', + ]); + } + + public function test_reject_requires_reason(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + DB::table('refunds')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'refund_amount' => 50, + 'refund_paid_amount' => 0, + 'status' => 'Pending', + 'request' => 'overpayment', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = $this->postJson('/api/v1/finance/refunds/1/reject', []); + + $response->assertStatus(422); + $response->assertJsonStructure(['message', 'errors']); + } + + public function test_record_payment_updates_refund(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + DB::table('refunds')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'refund_amount' => 100, + 'refund_paid_amount' => 0, + 'status' => 'Approved', + 'request' => 'overpayment', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = $this->postJson('/api/v1/finance/refunds/1/payments', [ + 'paid_amount' => 40, + 'payment_method' => 'Cash', + ]); + + $response->assertOk(); + $response->assertJsonPath('ok', true); + $this->assertDatabaseHas('refunds', [ + 'id' => 1, + 'status' => 'Partial', + ]); + } + + public function test_recalculate_overpayments_creates_refund(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $this->seedConfig(); + + DB::table('invoices')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_number' => 'INV-2', + 'total_amount' => 100.00, + 'balance' => 0.00, + 'paid_amount' => 100.00, + 'status' => 'Paid', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'issue_date' => '2025-01-05', + ]); + + DB::table('payments')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_id' => 1, + 'paid_amount' => 150.00, + 'total_amount' => 150.00, + 'balance' => 0.00, + 'payment_date' => '2025-01-10', + 'payment_method' => 'manual', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'status' => 'Paid', + ]); + + $response = $this->postJson('/api/v1/finance/refunds/recalculate-overpayments', []); + + $response->assertOk(); + $response->assertJsonPath('ok', true); + $this->assertDatabaseHas('refunds', [ + 'parent_id' => 10, + 'request' => 'overpayment', + ]); + } + + public function test_destroy_cancels_refund(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + DB::table('refunds')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'refund_amount' => 50, + 'refund_paid_amount' => 0, + 'status' => 'Pending', + 'request' => 'overpayment', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = $this->deleteJson('/api/v1/finance/refunds/1'); + + $response->assertOk(); + $this->assertDatabaseHas('refunds', [ + 'id' => 1, + 'status' => 'Canceled', + ]); + } + + private function seedConfig(): void + { + DB::table('configuration')->insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + } + + 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/Settings/EventControllerTest.php b/tests/Feature/Api/V1/Settings/EventControllerTest.php new file mode 100644 index 00000000..7e567180 --- /dev/null +++ b/tests/Feature/Api/V1/Settings/EventControllerTest.php @@ -0,0 +1,361 @@ +getJson('/api/v1/settings/events'); + + $response->assertStatus(401); + } + + public function test_index_returns_events(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + DB::table('events')->insert([ + 'id' => 1, + 'event_name' => 'Open House', + 'event_category' => 'workshops', + 'amount' => 10, + 'expiration_date' => '2025-02-01', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = $this->getJson('/api/v1/settings/events'); + + $response->assertOk(); + $response->assertJsonPath('ok', true); + $response->assertJsonStructure([ + 'ok', + 'events', + 'pagination', + 'active_count', + 'categories', + ]); + } + + public function test_store_creates_event(): void + { + $this->mock(InvoiceGenerationService::class, function ($mock) { + $mock->shouldReceive('generateInvoice')->andReturn(['ok' => true]); + }); + + $user = $this->createUser(); + Sanctum::actingAs($user); + + DB::table('students')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'firstname' => 'Kid', + 'lastname' => 'One', + ]); + + DB::table('enrollments')->insert([ + 'id' => 1, + 'student_id' => 1, + 'parent_id' => 10, + 'class_section_id' => 101, + 'enrollment_status' => 'enrolled', + 'school_year' => '2025-2026', + ]); + + $response = $this->postJson('/api/v1/settings/events', [ + 'event_name' => 'Field Trip', + 'event_category' => 'field trips', + 'description' => 'Visit museum', + 'amount' => 25, + 'expiration_date' => '2025-02-01', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $response->assertStatus(201); + $response->assertJsonPath('ok', true); + $this->assertDatabaseHas('events', [ + 'event_name' => 'Field Trip', + 'school_year' => '2025-2026', + ]); + $this->assertDatabaseHas('event_charges', [ + 'event_id' => 1, + 'student_id' => 1, + ]); + } + + public function test_store_requires_authentication(): void + { + $response = $this->postJson('/api/v1/settings/events', [ + 'event_name' => 'Field Trip', + 'event_category' => 'field trips', + 'amount' => 25, + 'expiration_date' => '2025-02-01', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $response->assertStatus(401); + } + + public function test_store_validates_payload(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/v1/settings/events', [ + 'event_name' => '', + 'event_category' => 'invalid', + ]); + + $response->assertStatus(422); + $response->assertJsonStructure(['message', 'errors']); + } + + public function test_store_handles_service_exception(): void + { + $this->mock(EventManagementService::class, function ($mock) { + $mock->shouldReceive('create')->andThrow(new \RuntimeException('boom')); + }); + + $user = $this->createUser(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/v1/settings/events', [ + 'event_name' => 'Field Trip', + 'event_category' => 'field trips', + 'description' => 'Visit museum', + 'amount' => 25, + 'expiration_date' => '2025-02-01', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ]); + + $response->assertStatus(500); + $response->assertJsonPath('ok', false); + } + + public function test_update_updates_event(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + DB::table('events')->insert([ + 'id' => 1, + 'event_name' => 'Open House', + 'event_category' => 'workshops', + 'amount' => 10, + 'expiration_date' => '2025-02-01', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = $this->patchJson('/api/v1/settings/events/1', [ + 'event_name' => 'Updated', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('events', [ + 'id' => 1, + 'event_name' => 'Updated', + ]); + } + + public function test_destroy_deletes_event(): void + { + $this->mock(InvoiceGenerationService::class, function ($mock) { + $mock->shouldReceive('generateInvoice')->andReturn(['ok' => true]); + }); + + $user = $this->createUser(); + Sanctum::actingAs($user); + + DB::table('events')->insert([ + 'id' => 1, + 'event_name' => 'Open House', + 'event_category' => 'workshops', + 'amount' => 10, + 'expiration_date' => '2025-02-01', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('event_charges')->insert([ + 'id' => 1, + 'event_id' => 1, + 'parent_id' => 10, + 'student_id' => 1, + 'participation' => 'yes', + 'charged' => 10, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + $response = $this->deleteJson('/api/v1/settings/events/1'); + + $response->assertOk(); + $this->assertDatabaseMissing('events', ['id' => 1]); + } + + public function test_charges_list_returns_data(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + DB::table('users')->insert([ + 'id' => 10, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'email' => 'p@example.com', + 'status' => 'Active', + ]); + + DB::table('students')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'firstname' => 'Kid', + 'lastname' => 'One', + ]); + + DB::table('events')->insert([ + 'id' => 1, + 'event_name' => 'Open House', + 'event_category' => 'workshops', + 'amount' => 10, + 'expiration_date' => '2025-02-01', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('event_charges')->insert([ + 'id' => 1, + 'event_id' => 1, + 'parent_id' => 10, + 'student_id' => 1, + 'participation' => 'yes', + 'charged' => 10, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + $response = $this->getJson('/api/v1/settings/events/charges/list'); + + $response->assertOk(); + $response->assertJsonPath('ok', true); + $response->assertJsonStructure(['ok', 'charges', 'pagination']); + } + + public function test_update_charges_updates_participation(): void + { + $this->mock(InvoiceGenerationService::class, function ($mock) { + $mock->shouldReceive('generateInvoice')->andReturn(['ok' => true]); + }); + + $user = $this->createUser(); + Sanctum::actingAs($user); + + DB::table('events')->insert([ + 'id' => 1, + 'event_name' => 'Open House', + 'event_category' => 'workshops', + 'amount' => 10, + 'expiration_date' => '2025-02-01', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $response = $this->postJson('/api/v1/settings/events/1/charges', [ + 'parent_id' => 10, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'participation' => [ + 1 => 'yes', + ], + ]); + + $response->assertOk(); + $this->assertDatabaseHas('event_charges', [ + 'event_id' => 1, + 'student_id' => 1, + 'parent_id' => 10, + ]); + } + + public function test_students_with_charges_returns_students(): void + { + $user = $this->createUser(); + Sanctum::actingAs($user); + + DB::table('students')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'firstname' => 'Kid', + 'lastname' => 'One', + ]); + + DB::table('event_charges')->insert([ + 'id' => 1, + 'event_id' => 1, + 'parent_id' => 10, + 'student_id' => 1, + 'participation' => 'yes', + 'charged' => 10, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + $response = $this->getJson('/api/v1/settings/events/charges/students?parent_id=10&school_year=2025-2026&semester=Fall'); + + $response->assertOk(); + $response->assertJsonPath('ok', true); + $response->assertJsonStructure(['ok', 'students']); + } + + 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/Unit/Services/Billing/BillingTotalsServiceTest.php b/tests/Unit/Services/Billing/BillingTotalsServiceTest.php new file mode 100644 index 00000000..5b55939e --- /dev/null +++ b/tests/Unit/Services/Billing/BillingTotalsServiceTest.php @@ -0,0 +1,61 @@ +insert([ + 'id' => 1, + 'event_name' => 'Field Trip', + 'amount' => 25.00, + ]); + + DB::table('event_charges')->insert([ + 'id' => 1, + 'event_id' => 1, + 'parent_id' => 10, + 'charged' => 25.00, + 'school_year' => '2025-2026', + ]); + + DB::table('additional_charges')->insert([ + [ + 'id' => 1, + 'invoice_id' => 99, + 'parent_id' => 10, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'charge_type' => 'add', + 'amount' => 15.00, + 'status' => 'applied', + ], + [ + 'id' => 2, + 'invoice_id' => 99, + 'parent_id' => 10, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'charge_type' => 'deduct', + 'amount' => 5.00, + 'status' => 'applied', + ], + ]); + + $service = new BillingTotalsService(); + + $eventSubtotal = $service->eventSubtotal(10, '2025-2026'); + $additionalSubtotal = $service->additionalSubtotal(99, '2025-2026'); + + $this->assertSame(25.0, $eventSubtotal); + $this->assertSame(10.0, $additionalSubtotal); + } +} diff --git a/tests/Unit/Services/Events/EventCategoryServiceTest.php b/tests/Unit/Services/Events/EventCategoryServiceTest.php new file mode 100644 index 00000000..218afe0f --- /dev/null +++ b/tests/Unit/Services/Events/EventCategoryServiceTest.php @@ -0,0 +1,17 @@ +assertContains('workshops', $categories); + $this->assertContains('field trips', $categories); + } +} diff --git a/tests/Unit/Services/Events/EventChargeQueryServiceTest.php b/tests/Unit/Services/Events/EventChargeQueryServiceTest.php new file mode 100644 index 00000000..e3a802e0 --- /dev/null +++ b/tests/Unit/Services/Events/EventChargeQueryServiceTest.php @@ -0,0 +1,34 @@ +insert([ + 'id' => 1, + 'event_id' => 1, + 'parent_id' => 10, + 'student_id' => 1, + 'participation' => 'yes', + 'charged' => 10, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = new EventChargeQueryService(); + $result = $service->list(['per_page' => 10, 'page' => 1]); + + $this->assertSame(1, $result['pagination']['total']); + } +} diff --git a/tests/Unit/Services/Events/EventChargeServiceTest.php b/tests/Unit/Services/Events/EventChargeServiceTest.php new file mode 100644 index 00000000..5902a0d1 --- /dev/null +++ b/tests/Unit/Services/Events/EventChargeServiceTest.php @@ -0,0 +1,59 @@ +insert([ + 'id' => 1, + 'event_name' => 'Trip', + 'event_category' => 'field trips', + 'amount' => 25, + 'expiration_date' => '2025-02-01', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('students')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'firstname' => 'Kid', + 'lastname' => 'One', + ]); + + DB::table('enrollments')->insert([ + 'id' => 1, + 'student_id' => 1, + 'parent_id' => 10, + 'class_section_id' => 101, + 'enrollment_status' => 'enrolled', + 'school_year' => '2025-2026', + ]); + + $invoiceService = Mockery::mock(InvoiceGenerationService::class); + $invoiceService->shouldReceive('generateInvoice')->andReturn(['ok' => true]); + + $service = new EventChargeService($invoiceService); + $event = Event::query()->findOrFail(1); + $service->seedChargesForEvent($event, '2025-2026', 'Fall', 25, 1); + + $this->assertDatabaseHas('event_charges', [ + 'event_id' => 1, + 'student_id' => 1, + ]); + } +} diff --git a/tests/Unit/Services/Events/EventListServiceTest.php b/tests/Unit/Services/Events/EventListServiceTest.php new file mode 100644 index 00000000..5248d9cf --- /dev/null +++ b/tests/Unit/Services/Events/EventListServiceTest.php @@ -0,0 +1,46 @@ +insert([ + [ + 'id' => 1, + 'event_name' => 'Active', + 'event_category' => 'workshops', + 'amount' => 10, + 'expiration_date' => now()->addDays(2)->toDateString(), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'id' => 2, + 'event_name' => 'Expired', + 'event_category' => 'workshops', + 'amount' => 10, + 'expiration_date' => now()->subDays(2)->toDateString(), + 'semester' => 'Fall', + 'school_year' => '2025-2026', + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $service = new EventListService(); + $result = $service->list(['active' => true, 'per_page' => 10]); + + $this->assertSame(1, $result['pagination']['total']); + } +} diff --git a/tests/Unit/Services/Events/EventManagementServiceTest.php b/tests/Unit/Services/Events/EventManagementServiceTest.php new file mode 100644 index 00000000..007c4668 --- /dev/null +++ b/tests/Unit/Services/Events/EventManagementServiceTest.php @@ -0,0 +1,26 @@ +update(999, ['event_name' => 'Missing'], null); + + $this->assertFalse($result['ok']); + } +} diff --git a/tests/Unit/Services/Events/EventStudentChargeServiceTest.php b/tests/Unit/Services/Events/EventStudentChargeServiceTest.php new file mode 100644 index 00000000..2e6c4d46 --- /dev/null +++ b/tests/Unit/Services/Events/EventStudentChargeServiceTest.php @@ -0,0 +1,39 @@ +insert([ + 'id' => 1, + 'parent_id' => 10, + 'firstname' => 'Kid', + 'lastname' => 'One', + ]); + + DB::table('event_charges')->insert([ + 'id' => 1, + 'event_id' => 1, + 'parent_id' => 10, + 'student_id' => 1, + 'participation' => 'yes', + 'charged' => 10, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + ]); + + $service = new EventStudentChargeService(); + $rows = $service->listStudentsWithCharges(10, '2025-2026', 'Fall'); + + $this->assertTrue($rows[0]['charged']); + } +} diff --git a/tests/Unit/Services/FeeCalculationServiceTest.php b/tests/Unit/Services/FeeCalculationServiceTest.php new file mode 100644 index 00000000..ae98c6ef --- /dev/null +++ b/tests/Unit/Services/FeeCalculationServiceTest.php @@ -0,0 +1,24 @@ +shouldReceive('calculateRefund') + ->once() + ->with([], 10) + ->andReturn(['refund_amount' => 123.45]); + + $service = new FeeCalculationService($calculator); + + $this->assertSame(123.45, $service->calculateRefund([], 10)); + } +} diff --git a/tests/Unit/Services/Fees/FeeRefundDetailServiceTest.php b/tests/Unit/Services/Fees/FeeRefundDetailServiceTest.php new file mode 100644 index 00000000..f901e9a9 --- /dev/null +++ b/tests/Unit/Services/Fees/FeeRefundDetailServiceTest.php @@ -0,0 +1,71 @@ +insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'refund_deadline', 'config_value' => '2025-02-01'], + ['config_key' => 'book_price', 'config_value' => '20'], + ['config_key' => 'first_student_fee', 'config_value' => '350'], + ['config_key' => 'second_student_fee', 'config_value' => '200'], + ]); + + DB::table('students')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'firstname' => 'Sam', + 'lastname' => 'Parent', + 'school_year' => '2025-2026', + ]); + + DB::table('invoices')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_number' => 'INV-1', + 'total_amount' => 350.00, + 'balance' => 0.00, + 'paid_amount' => 100.00, + 'status' => 'Paid', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'issue_date' => '2025-01-05', + ]); + + DB::table('payments')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_id' => 1, + 'paid_amount' => 100.00, + 'total_amount' => 100.00, + 'balance' => 0.00, + 'payment_date' => '2025-01-10', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'status' => 'Paid', + ]); + + $service = new FeeRefundDetailService(); + $result = $service->calculateRefundDetails([ + [ + 'student_id' => 1, + 'enrollment_status' => 'withdrawn', + 'withdrawal_date' => '2025-01-15', + ], + ], 10); + + $this->assertTrue($result['ok']); + $this->assertSame(80.0, $result['refund_amount']); + $this->assertTrue($result['eligible']); + } +} diff --git a/tests/Unit/Services/Fees/FeeStudentFeeServiceTest.php b/tests/Unit/Services/Fees/FeeStudentFeeServiceTest.php new file mode 100644 index 00000000..6b4a669e --- /dev/null +++ b/tests/Unit/Services/Fees/FeeStudentFeeServiceTest.php @@ -0,0 +1,52 @@ +insert([ + ['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', + ], + [ + 'id' => 2, + 'class_id' => 2, + 'class_section_id' => 102, + 'class_section_name' => '10', + 'semester' => 'Fall', + 'school_year' => '2025-2026', + ], + ]); + + $service = new FeeStudentFeeService(new FeeGradeService(), new FeeConfigService()); + + $total = $service->totalTuitionFee([ + ['class_section_id' => 101], + ['class_section_id' => 102], + ]); + + $this->assertSame(530.0, $total); + } +} diff --git a/tests/Unit/Services/Invoices/InvoiceServiceTest.php b/tests/Unit/Services/Invoices/InvoiceServiceTest.php new file mode 100644 index 00000000..d1ca4794 --- /dev/null +++ b/tests/Unit/Services/Invoices/InvoiceServiceTest.php @@ -0,0 +1,47 @@ +insert([ + 'id' => 1, + 'parent_id' => 10, + 'firstname' => 'Kid', + 'lastname' => 'One', + 'school_year' => '2025-2026', + ]); + + DB::table('student_class')->insert([ + 'id' => 1, + 'student_id' => 1, + 'class_section_id' => 101, + 'school_year' => '2025-2026', + ]); + + $service = new InvoiceService( + Mockery::mock(InvoiceConfigService::class), + Mockery::mock(InvoiceManagementService::class), + Mockery::mock(InvoiceGenerationService::class), + Mockery::mock(InvoiceTuitionService::class), + Mockery::mock(InvoiceGradeService::class) + ); + + $this->assertTrue($service->hasClassAssignment('2025-2026', 10)); + } +} diff --git a/tests/Unit/Services/Refunds/RefundDecisionServiceTest.php b/tests/Unit/Services/Refunds/RefundDecisionServiceTest.php new file mode 100644 index 00000000..298f6054 --- /dev/null +++ b/tests/Unit/Services/Refunds/RefundDecisionServiceTest.php @@ -0,0 +1,38 @@ +insert([ + 'id' => 1, + 'parent_id' => 10, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'refund_amount' => 50, + 'refund_paid_amount' => 0, + 'status' => 'Pending', + 'request' => 'overpayment', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = new RefundDecisionService(); + $result = $service->approve(1, 2); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('refunds', [ + 'id' => 1, + 'status' => 'Approved', + ]); + } +} diff --git a/tests/Unit/Services/Refunds/RefundInvoiceAdjustmentServiceTest.php b/tests/Unit/Services/Refunds/RefundInvoiceAdjustmentServiceTest.php new file mode 100644 index 00000000..f4e0b473 --- /dev/null +++ b/tests/Unit/Services/Refunds/RefundInvoiceAdjustmentServiceTest.php @@ -0,0 +1,51 @@ +insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_number' => 'INV-1', + 'total_amount' => 100.00, + 'balance' => 100.00, + 'paid_amount' => 0.00, + 'status' => 'Unpaid', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'issue_date' => '2025-01-05', + ]); + + $invoice = Invoice::query()->findOrFail(1); + + $invoiceService = Mockery::mock(DiscountInvoiceService::class); + $invoiceService->shouldReceive('recalculateInvoice')->once()->with(1, '2025-2026'); + + $service = new RefundInvoiceAdjustmentService($invoiceService); + + $result = $service->applyBookChargeAndUpdateInvoice($invoice, 10, [ + 'eligible' => true, + 'book_price' => 15.00, + ], 'Fall', 1); + + $this->assertSame('inserted', $result['book_charge_action']); + $this->assertDatabaseHas('additional_charges', [ + 'invoice_id' => 1, + 'title' => 'Book Fee (Non-Refundable)', + 'amount' => 15.00, + ]); + } +} diff --git a/tests/Unit/Services/Refunds/RefundNotificationServiceTest.php b/tests/Unit/Services/Refunds/RefundNotificationServiceTest.php new file mode 100644 index 00000000..4e687932 --- /dev/null +++ b/tests/Unit/Services/Refunds/RefundNotificationServiceTest.php @@ -0,0 +1,23 @@ +notifyPending(10, 50.25, 'https://example.test/login'); + + Log::shouldHaveReceived('info')->once(); + } +} diff --git a/tests/Unit/Services/Refunds/RefundOverpaymentServiceTest.php b/tests/Unit/Services/Refunds/RefundOverpaymentServiceTest.php new file mode 100644 index 00000000..7b648b67 --- /dev/null +++ b/tests/Unit/Services/Refunds/RefundOverpaymentServiceTest.php @@ -0,0 +1,57 @@ +insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + + DB::table('invoices')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_number' => 'INV-1', + 'total_amount' => 100.00, + 'balance' => 0.00, + 'paid_amount' => 100.00, + 'status' => 'Paid', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'issue_date' => '2025-01-05', + ]); + + DB::table('payments')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_id' => 1, + 'paid_amount' => 150.00, + 'total_amount' => 150.00, + 'balance' => 0.00, + 'payment_date' => '2025-01-10', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'status' => 'Paid', + ]); + + $service = new RefundOverpaymentService(new RefundNotificationService()); + $result = $service->recalculate(false, null, 1); + + $this->assertNotEmpty($result['created_ids']); + $this->assertDatabaseHas('refunds', [ + 'parent_id' => 10, + 'request' => 'overpayment', + ]); + } +} diff --git a/tests/Unit/Services/Refunds/RefundPayoutServiceTest.php b/tests/Unit/Services/Refunds/RefundPayoutServiceTest.php new file mode 100644 index 00000000..5c5cf9cf --- /dev/null +++ b/tests/Unit/Services/Refunds/RefundPayoutServiceTest.php @@ -0,0 +1,42 @@ +insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_id' => 1, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'refund_amount' => 100, + 'refund_paid_amount' => 0, + 'status' => 'Approved', + 'request' => 'overpayment', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = new RefundPayoutService(); + $result = $service->recordPayment(1, [ + 'paid_amount' => 40, + 'payment_method' => 'Cash', + ], 2); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('refunds', [ + 'id' => 1, + 'status' => 'Partial', + ]); + } +} diff --git a/tests/Unit/Services/Refunds/RefundPolicyServiceTest.php b/tests/Unit/Services/Refunds/RefundPolicyServiceTest.php new file mode 100644 index 00000000..593e1c63 --- /dev/null +++ b/tests/Unit/Services/Refunds/RefundPolicyServiceTest.php @@ -0,0 +1,104 @@ +insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ['config_key' => 'refund_deadline', 'config_value' => '2025-02-01'], + ['config_key' => 'book_price', 'config_value' => '10'], + ['config_key' => 'grade_fee', 'config_value' => '9'], + ['config_key' => 'first_student_fee', 'config_value' => '350'], + ['config_key' => 'second_student_fee', 'config_value' => '200'], + ['config_key' => 'youth_fee', 'config_value' => '180'], + ]); + + 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('students')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'firstname' => 'Kid', + 'lastname' => 'One', + 'school_year' => '2025-2026', + ]); + + DB::table('enrollments')->insert([ + 'id' => 1, + 'student_id' => 1, + 'parent_id' => 10, + 'class_section_id' => 101, + 'enrollment_status' => 'withdrawn', + 'withdrawal_date' => '2025-01-15', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'admission_status' => 'accepted', + 'is_withdrawn' => 1, + ]); + + DB::table('invoices')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_number' => 'INV-1', + 'total_amount' => 350.00, + 'balance' => 0.00, + 'paid_amount' => 100.00, + 'status' => 'Paid', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'issue_date' => '2025-01-05', + ]); + + DB::table('payments')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_id' => 1, + 'paid_amount' => 100.00, + 'total_amount' => 100.00, + 'balance' => 0.00, + 'payment_date' => '2025-01-10', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'status' => 'Paid', + ]); + + $service = new RefundPolicyService( + new FeeRefundDetailService(), + new RefundInvoiceAdjustmentService(new DiscountInvoiceService()) + ); + + $result = $service->processWithdrawalRefund(10, 1, '2025-2026', 'Fall', 1); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('refunds', [ + 'parent_id' => 10, + 'invoice_id' => 1, + 'request' => 'tuition', + ]); + $this->assertDatabaseHas('additional_charges', [ + 'invoice_id' => 1, + 'title' => 'Book Fee (Non-Refundable)', + ]); + } +} diff --git a/tests/Unit/Services/Refunds/RefundQueryServiceTest.php b/tests/Unit/Services/Refunds/RefundQueryServiceTest.php new file mode 100644 index 00000000..3e144468 --- /dev/null +++ b/tests/Unit/Services/Refunds/RefundQueryServiceTest.php @@ -0,0 +1,49 @@ +insert([ + [ + 'id' => 1, + 'parent_id' => 10, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'refund_amount' => 10, + 'refund_paid_amount' => 0, + 'status' => 'Pending', + 'request' => 'overpayment', + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'id' => 2, + 'parent_id' => 11, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'refund_amount' => 20, + 'refund_paid_amount' => 0, + 'status' => 'Approved', + 'request' => 'overpayment', + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + + $service = new RefundQueryService(); + $result = $service->list(['status' => 'Approved', 'per_page' => 10, 'page' => 1]); + + $this->assertSame(1, $result['pagination']['total']); + $this->assertSame('Approved', $result['refunds']->items()[0]->status); + } +} diff --git a/tests/Unit/Services/Refunds/RefundRequestServiceTest.php b/tests/Unit/Services/Refunds/RefundRequestServiceTest.php new file mode 100644 index 00000000..4ec216a9 --- /dev/null +++ b/tests/Unit/Services/Refunds/RefundRequestServiceTest.php @@ -0,0 +1,82 @@ +insert([ + ['config_key' => 'school_year', 'config_value' => '2025-2026'], + ['config_key' => 'semester', 'config_value' => 'Fall'], + ]); + + DB::table('users')->insert([ + 'id' => 10, + 'firstname' => 'Parent', + 'lastname' => 'User', + 'email' => 'parent@example.com', + 'status' => 'Active', + ]); + + DB::table('invoices')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_number' => 'INV-1', + 'total_amount' => 100.00, + 'balance' => 100.00, + 'paid_amount' => 0.00, + 'status' => 'Unpaid', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'issue_date' => '2025-01-05', + ]); + + DB::table('payments')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_id' => 1, + 'paid_amount' => 150.00, + 'total_amount' => 150.00, + 'balance' => 0.00, + 'payment_date' => '2025-01-10', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'status' => 'Paid', + ]); + + $service = new RefundRequestService( + new RefundPolicyService( + new FeeRefundDetailService(), + new RefundInvoiceAdjustmentService(new DiscountInvoiceService()) + ), + new RefundSummaryService(), + new RefundNotificationService() + ); + + $result = $service->requestRefund([ + 'parent_id' => 10, + 'request_type' => 'overpayment', + 'amount' => 25, + ], 1); + + $this->assertTrue($result['ok']); + $this->assertDatabaseHas('refunds', [ + 'parent_id' => 10, + 'request' => 'overpayment', + ]); + } +} diff --git a/tests/Unit/Services/Refunds/RefundSummaryServiceTest.php b/tests/Unit/Services/Refunds/RefundSummaryServiceTest.php new file mode 100644 index 00000000..04cfb10b --- /dev/null +++ b/tests/Unit/Services/Refunds/RefundSummaryServiceTest.php @@ -0,0 +1,64 @@ +insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_number' => 'INV-1', + 'total_amount' => 100.00, + 'balance' => 0.00, + 'paid_amount' => 100.00, + 'status' => 'Paid', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'issue_date' => '2025-01-05', + ]); + + DB::table('payments')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_id' => 1, + 'paid_amount' => 150.00, + 'total_amount' => 150.00, + 'balance' => 0.00, + 'payment_date' => '2025-01-10', + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'status' => 'Paid', + ]); + + DB::table('refunds')->insert([ + 'id' => 1, + 'parent_id' => 10, + 'invoice_id' => 1, + 'school_year' => '2025-2026', + 'semester' => 'Fall', + 'refund_amount' => 20, + 'refund_paid_amount' => 20, + 'status' => 'Paid', + 'request' => 'overpayment', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $service = new RefundSummaryService(); + $summary = $service->getParentFinancialSummary(10, '2025-2026', 'Fall'); + + $this->assertSame(100.0, $summary['total_invoiced']); + $this->assertSame(150.0, $summary['total_paid_in']); + $this->assertSame(20.0, $summary['total_refunded']); + $this->assertSame(30.0, $summary['unapplied_balance']); + } +}