add refund and event logic

This commit is contained in:
root
2026-03-10 23:12:49 -04:00
parent ba1206e314
commit f6be51576c
67 changed files with 4808 additions and 1000 deletions
@@ -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(),
];
}
}
+169
View File
@@ -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))),
];
}
}
+89
View File
@@ -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;
}
}
+76
View File
@@ -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,
];
}
}
-183
View File
@@ -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;
}
}
-785
View File
@@ -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.']);
}
}