add refund and event logic
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Finance;
|
||||
|
||||
use App\Http\Controllers\Api\BaseApiController;
|
||||
use App\Http\Requests\Refunds\RefundDecisionRequest;
|
||||
use App\Http\Requests\Refunds\RefundIndexRequest;
|
||||
use App\Http\Requests\Refunds\RefundPaymentRequest;
|
||||
use App\Http\Requests\Refunds\RefundRecalculateRequest;
|
||||
use App\Http\Requests\Refunds\RefundRejectRequest;
|
||||
use App\Http\Requests\Refunds\RefundStoreRequest;
|
||||
use App\Http\Resources\Refunds\RefundResource;
|
||||
use App\Models\Configuration;
|
||||
use App\Services\Refunds\RefundDecisionService;
|
||||
use App\Services\Refunds\RefundOverpaymentService;
|
||||
use App\Services\Refunds\RefundPayoutService;
|
||||
use App\Services\Refunds\RefundQueryService;
|
||||
use App\Services\Refunds\RefundRequestService;
|
||||
use App\Services\Refunds\RefundSummaryService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class RefundController extends BaseApiController
|
||||
{
|
||||
public function __construct(
|
||||
private RefundQueryService $queryService,
|
||||
private RefundRequestService $requestService,
|
||||
private RefundDecisionService $decisionService,
|
||||
private RefundPayoutService $payoutService,
|
||||
private RefundOverpaymentService $overpaymentService,
|
||||
private RefundSummaryService $summaryService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function index(RefundIndexRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Settings;
|
||||
|
||||
use App\Http\Controllers\Api\BaseApiController;
|
||||
use App\Http\Requests\Events\EventChargeIndexRequest;
|
||||
use App\Http\Requests\Events\EventChargeUpdateRequest;
|
||||
use App\Http\Requests\Events\EventIndexRequest;
|
||||
use App\Http\Requests\Events\EventStoreRequest;
|
||||
use App\Http\Requests\Events\EventStudentsWithChargesRequest;
|
||||
use App\Http\Requests\Events\EventUpdateRequest;
|
||||
use App\Http\Resources\Events\EventChargeResource;
|
||||
use App\Http\Resources\Events\EventResource;
|
||||
use App\Http\Resources\Events\EventStudentChargeResource;
|
||||
use App\Services\Events\EventCategoryService;
|
||||
use App\Services\Events\EventChargeQueryService;
|
||||
use App\Services\Events\EventChargeService;
|
||||
use App\Services\Events\EventListService;
|
||||
use App\Services\Events\EventManagementService;
|
||||
use App\Services\Events\EventStudentChargeService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class EventController extends BaseApiController
|
||||
{
|
||||
public function __construct(
|
||||
private EventListService $listService,
|
||||
private EventManagementService $managementService,
|
||||
private EventChargeQueryService $chargeQueryService,
|
||||
private EventChargeService $chargeService,
|
||||
private EventStudentChargeService $studentChargeService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function index(EventIndexRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Events;
|
||||
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
|
||||
class EventChargeIndexRequest extends ApiFormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Events;
|
||||
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
|
||||
class EventChargeUpdateRequest extends ApiFormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Events;
|
||||
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
|
||||
class EventIndexRequest extends ApiFormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Events;
|
||||
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
use App\Services\Events\EventCategoryService;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class EventStoreRequest extends ApiFormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Events;
|
||||
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
|
||||
class EventStudentsWithChargesRequest extends ApiFormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'parent_id' => ['required', 'integer', 'min:1'],
|
||||
'school_year' => ['required', 'string', 'max:20'],
|
||||
'semester' => ['required', 'string', 'max:20'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Events;
|
||||
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
use App\Services\Events\EventCategoryService;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class EventUpdateRequest extends ApiFormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Finance;
|
||||
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
|
||||
class FeeRefundRequest extends ApiFormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Finance;
|
||||
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
|
||||
class FeeTuitionTotalRequest extends ApiFormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'students' => ['required', 'array'],
|
||||
'students.*.class_section_id' => ['required', 'integer', 'min:1'],
|
||||
'students.*.grade' => ['nullable', 'string', 'max:20'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Refunds;
|
||||
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
use App\Models\Refund;
|
||||
|
||||
class RefundDecisionRequest extends ApiFormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status' => ['required', 'string', 'in:' . implode(',', [
|
||||
Refund::STATUS_APPROVED,
|
||||
Refund::STATUS_REJECTED,
|
||||
])],
|
||||
'reason' => ['required', 'string', 'max:2000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Refunds;
|
||||
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
use App\Models\Refund;
|
||||
|
||||
class RefundIndexRequest extends ApiFormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Refunds;
|
||||
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
|
||||
class RefundPaymentRequest extends ApiFormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Refunds;
|
||||
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
|
||||
class RefundRecalculateRequest extends ApiFormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'invoice_number' => ['nullable', 'string', 'max:100'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Refunds;
|
||||
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
|
||||
class RefundRejectRequest extends ApiFormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'reason' => ['required', 'string', 'max:2000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Refunds;
|
||||
|
||||
use App\Http\Requests\ApiFormRequest;
|
||||
|
||||
class RefundStoreRequest extends ApiFormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Events;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class EventChargeResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => (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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Events;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class EventResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => (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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Events;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class EventStudentChargeResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) ($this['id'] ?? 0),
|
||||
'name' => $this['name'] ?? '',
|
||||
'charged' => (bool) ($this['charged'] ?? false),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Refunds;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class RefundResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => (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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\AdditionalCharge;
|
||||
use App\Models\EventCharges;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class BillingTotalsService
|
||||
{
|
||||
public function eventSubtotal(int $parentId, string $schoolYear): float
|
||||
{
|
||||
$eventSubtotal = 0.0;
|
||||
try {
|
||||
$events = EventCharges::getChargesWithEventInfo($parentId, $schoolYear) ?? [];
|
||||
foreach ($events as $event) {
|
||||
$eventSubtotal += (float) ($event['charged'] ?? 0.0);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to sum event charges.', ['error' => $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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Events;
|
||||
|
||||
class EventCategoryService
|
||||
{
|
||||
public static function categories(): array
|
||||
{
|
||||
return [
|
||||
'workshops',
|
||||
'orientations',
|
||||
'field trips',
|
||||
'Ramadan programs',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Events;
|
||||
|
||||
use App\Models\EventCharges;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class EventChargeQueryService
|
||||
{
|
||||
public function list(array $filters): array
|
||||
{
|
||||
$perPage = (int) ($filters['per_page'] ?? 25);
|
||||
$page = (int) ($filters['page'] ?? 1);
|
||||
|
||||
$query = EventCharges::query()
|
||||
->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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Events;
|
||||
|
||||
use App\Models\Enrollment;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventCharges;
|
||||
use App\Services\Invoices\InvoiceGenerationService;
|
||||
|
||||
class EventChargeService
|
||||
{
|
||||
public function __construct(private InvoiceGenerationService $invoiceGeneration)
|
||||
{
|
||||
}
|
||||
|
||||
public function seedChargesForEvent(Event $event, string $schoolYear, string $semester, float $amount, int $actorId): array
|
||||
{
|
||||
$enrollments = Enrollment::query()
|
||||
->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))),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Events;
|
||||
|
||||
use App\Models\Event;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
|
||||
class EventListService
|
||||
{
|
||||
public function list(array $filters): array
|
||||
{
|
||||
$perPage = (int) ($filters['per_page'] ?? 25);
|
||||
$page = (int) ($filters['page'] ?? 1);
|
||||
$sortBy = (string) ($filters['sort_by'] ?? 'created_at');
|
||||
$sortDir = strtolower((string) ($filters['sort_dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
$activeOnly = $filters['active'] ?? null;
|
||||
|
||||
$allowedSorts = ['created_at', 'expiration_date', 'event_name'];
|
||||
if (!in_array($sortBy, $allowedSorts, true)) {
|
||||
$sortBy = 'created_at';
|
||||
}
|
||||
|
||||
$query = Event::query();
|
||||
|
||||
if (!empty($filters['school_year'])) {
|
||||
$query->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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Events;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Services\Invoices\InvoiceGenerationService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class EventManagementService
|
||||
{
|
||||
public function __construct(
|
||||
private EventChargeService $charges,
|
||||
private InvoiceGenerationService $invoiceGeneration
|
||||
) {
|
||||
}
|
||||
|
||||
public function create(array $payload, ?UploadedFile $flyer, int $actorId): array
|
||||
{
|
||||
return DB::transaction(function () use ($payload, $flyer, $actorId) {
|
||||
$flyerPath = $this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Events;
|
||||
|
||||
use App\Models\EventCharges;
|
||||
use App\Models\Student;
|
||||
|
||||
class EventStudentChargeService
|
||||
{
|
||||
public function listStudentsWithCharges(int $parentId, string $schoolYear, string $semester): array
|
||||
{
|
||||
$students = Student::query()
|
||||
->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Fees;
|
||||
|
||||
use App\Models\ClassSection;
|
||||
use App\Models\Configuration;
|
||||
use App\Models\DiscountUsage;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Payment;
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentClass;
|
||||
use App\Services\Invoices\InvoiceGradeService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class FeeRefundDetailService
|
||||
{
|
||||
public function calculateTuitionSubtotalFromStudents(
|
||||
array $registeredKids,
|
||||
array $withdrawnKids,
|
||||
string $schoolYear,
|
||||
?string $refundDeadlineOverride = null
|
||||
): array {
|
||||
$refundDeadlineRaw = $refundDeadlineOverride ?? (string) (Configuration::getConfig('refund_deadline') ?? '');
|
||||
|
||||
$deadline = null;
|
||||
$tz = null;
|
||||
try {
|
||||
$tzName = (string) (config('School')->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Invoices;
|
||||
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentClass;
|
||||
|
||||
class InvoiceService
|
||||
{
|
||||
public function __construct(
|
||||
private InvoiceConfigService $config,
|
||||
private InvoiceManagementService $management,
|
||||
private InvoiceGenerationService $generation,
|
||||
private InvoiceTuitionService $tuition,
|
||||
private InvoiceGradeService $grades
|
||||
) {
|
||||
}
|
||||
|
||||
public function getDefaultSchoolYear(): string
|
||||
{
|
||||
return $this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Refunds;
|
||||
|
||||
use App\Models\Refund;
|
||||
|
||||
class RefundDecisionService
|
||||
{
|
||||
public function updateDecision(int $refundId, string $status, string $reason, int $actorId): array
|
||||
{
|
||||
$refund = Refund::query()->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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Refunds;
|
||||
|
||||
use App\Models\AdditionalCharge;
|
||||
use App\Models\Invoice;
|
||||
use App\Services\Discounts\DiscountInvoiceService;
|
||||
|
||||
class RefundInvoiceAdjustmentService
|
||||
{
|
||||
public function __construct(private DiscountInvoiceService $invoiceService)
|
||||
{
|
||||
}
|
||||
|
||||
public function applyBookChargeAndUpdateInvoice(
|
||||
Invoice $invoice,
|
||||
int $parentId,
|
||||
array $refundDetails,
|
||||
string $semester,
|
||||
?int $actorId
|
||||
): array {
|
||||
$bookChargeTitle = 'Book Fee (Non-Refundable)';
|
||||
$bookChargeDesc = 'Book fee retained for withdrawals before the refund deadline.';
|
||||
|
||||
$bookPrice = (float) ($refundDetails['book_price'] ?? 0.0);
|
||||
$eligible = !empty($refundDetails['eligible']);
|
||||
|
||||
$bookChargeDelta = 0.0;
|
||||
$bookChargeAction = 'none';
|
||||
$bookChargeId = null;
|
||||
|
||||
$existingCharge = AdditionalCharge::query()
|
||||
->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Refunds;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class RefundNotificationService
|
||||
{
|
||||
public function notifyPending(int $parentId, float $amount, ?string $portalLink = null): void
|
||||
{
|
||||
Log::info('Refund pending notification queued.', [
|
||||
'parent_id' => $parentId,
|
||||
'amount' => round($amount, 2),
|
||||
'portal_link' => $portalLink,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Refunds;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Refund;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class RefundOverpaymentService
|
||||
{
|
||||
public function __construct(private RefundNotificationService $notifications)
|
||||
{
|
||||
}
|
||||
|
||||
public function recalculate(bool $triggerEvents = false, ?string $invoiceNumber = null, ?int $actorId = null): array
|
||||
{
|
||||
if ($invoiceNumber !== null && $invoiceNumber !== '') {
|
||||
return $this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Refunds;
|
||||
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Refund;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class RefundPayoutService
|
||||
{
|
||||
public function recordPayment(int $refundId, array $payload, int $actorId): array
|
||||
{
|
||||
$refund = Refund::query()->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Refunds;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Enrollment;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Refund;
|
||||
use App\Services\Fees\FeeRefundDetailService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RefundPolicyService
|
||||
{
|
||||
public function __construct(
|
||||
private FeeRefundDetailService $refundDetails,
|
||||
private RefundInvoiceAdjustmentService $invoiceAdjustments
|
||||
) {
|
||||
}
|
||||
|
||||
public function processWithdrawalRefund(
|
||||
int $parentId,
|
||||
?int $invoiceId = null,
|
||||
?string $schoolYear = null,
|
||||
?string $semester = null,
|
||||
?int $actorId = null
|
||||
): array {
|
||||
$schoolYear = $schoolYear ?? (string) (Configuration::getConfig('school_year') ?? '');
|
||||
if ($schoolYear === '') {
|
||||
return ['ok' => 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,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Refunds;
|
||||
|
||||
use App\Models\Refund;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RefundQueryService
|
||||
{
|
||||
public function list(array $filters): array
|
||||
{
|
||||
$perPage = (int) ($filters['per_page'] ?? 25);
|
||||
$page = (int) ($filters['page'] ?? 1);
|
||||
$sortBy = (string) ($filters['sort_by'] ?? 'created_at');
|
||||
$sortDir = strtolower((string) ($filters['sort_dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
$allowedSorts = ['created_at', 'updated_at', 'refund_amount', 'status', 'parent_id', 'invoice_id'];
|
||||
if (!in_array($sortBy, $allowedSorts, true)) {
|
||||
$sortBy = 'created_at';
|
||||
}
|
||||
|
||||
$query = Refund::query()
|
||||
->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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Refunds;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Refund;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class RefundRequestService
|
||||
{
|
||||
public const REQUEST_TYPES = ['tuition', 'overpayment', 'duplicate', 'extra'];
|
||||
|
||||
public function __construct(
|
||||
private RefundPolicyService $policyService,
|
||||
private RefundSummaryService $summaryService,
|
||||
private RefundNotificationService $notifications
|
||||
) {
|
||||
}
|
||||
|
||||
public function requestRefund(array $payload, int $actorId): array
|
||||
{
|
||||
$parentId = (int) ($payload['parent_id'] ?? 0);
|
||||
$requestType = strtolower((string) ($payload['request_type'] ?? ''));
|
||||
$amount = (float) ($payload['amount'] ?? 0);
|
||||
$invoiceId = isset($payload['invoice_id']) ? (int) $payload['invoice_id'] : null;
|
||||
$paymentId = isset($payload['payment_id']) ? (int) $payload['payment_id'] : null;
|
||||
$reason = $payload['reason'] ?? null;
|
||||
|
||||
if (!in_array($requestType, self::REQUEST_TYPES, true)) {
|
||||
return ['ok' => 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,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Refunds;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RefundSummaryService
|
||||
{
|
||||
public function getParentFinancialSummary(int $parentId, string $schoolYear, string $semester): array
|
||||
{
|
||||
$invRow = (array) DB::table('invoices')
|
||||
->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
<?php
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\ClassSectionModel;
|
||||
|
||||
class FeeCalculationService
|
||||
{
|
||||
public function calculateRefund(array $students, int $parentId): float
|
||||
{
|
||||
$configModel = new ConfigurationModel();
|
||||
$paymentModel = new PaymentModel();
|
||||
$invoiceModel = new InvoiceModel();
|
||||
$classSectionModel = new ClassSectionModel();
|
||||
|
||||
$schoolYear = $configModel->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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,785 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\RefundModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\EnrollmentModel;
|
||||
|
||||
class RefundController extends BaseController
|
||||
{
|
||||
protected RefundModel $refundModel;
|
||||
protected UserModel $userModel;
|
||||
protected PaymentModel $paymentModel;
|
||||
protected ConfigurationModel $configModel;
|
||||
protected InvoiceModel $invoiceModel;
|
||||
protected EnrollmentModel $enrollmentModel;
|
||||
protected $db;
|
||||
|
||||
// Allowed request types (mapped to your `refunds.request` column)
|
||||
private const REQUEST_TYPES = ['tuition','overpayment','duplicate','extra'];
|
||||
// Allowed finalization decisions
|
||||
private const DECISIONS = ['Approved','Rejected'];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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.']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user