update api and add more features

This commit is contained in:
root
2026-06-04 02:24:41 -04:00
parent fa6c9519a0
commit 4e33882ac7
131 changed files with 34596 additions and 340 deletions
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,375 @@
<?php
namespace App\Http\Controllers\Api\Administrator;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Promotions\AdminListPromotionsRequest;
use App\Http\Requests\Promotions\EvaluateEligibilityRequest;
use App\Http\Requests\Promotions\ParentEnrollmentStepRequest;
use App\Http\Requests\Promotions\SendReminderRequest;
use App\Http\Requests\Promotions\SetEnrollmentDeadlineRequest;
use App\Http\Requests\Promotions\UpdatePromotionStatusRequest;
use App\Http\Requests\Promotions\UpsertLevelProgressionRequest;
use App\Http\Resources\Promotions\LevelProgressionResource;
use App\Http\Resources\Promotions\PromotionAuditLogResource;
use App\Http\Resources\Promotions\PromotionReminderResource;
use App\Http\Resources\Promotions\StudentPromotionResource;
use App\Models\PromotionAuditLog;
use App\Models\PromotionReminderLog;
use App\Models\StudentPromotionRecord;
use App\Services\Promotions\LevelProgressionService;
use App\Services\Promotions\PromotionAuditService;
use App\Services\Promotions\PromotionEligibilityService;
use App\Services\Promotions\PromotionEnrollmentService;
use App\Services\Promotions\PromotionQueryService;
use App\Services\Promotions\PromotionReminderService;
use App\Services\Promotions\PromotionStatusService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
/**
* Admin / registrar API for managing the promotion + parent enrollment
* lifecycle (plan sections 13, 16, 17).
*/
class AdministratorPromotionController extends BaseApiController
{
public function __construct(
private PromotionQueryService $query,
private PromotionEligibilityService $eligibility,
private PromotionStatusService $statusService,
private PromotionEnrollmentService $enrollmentService,
private PromotionReminderService $reminders,
private PromotionAuditService $audit,
private LevelProgressionService $progression
) {
parent::__construct();
}
public function index(AdminListPromotionsRequest $request): JsonResponse
{
$result = $this->query->list($request->filters());
return response()->json([
'ok' => true,
'data' => StudentPromotionResource::collection($result['data']),
'pagination' => [
'page' => $result['page'],
'per_page' => $result['per_page'],
'total' => $result['total'],
'total_pages' => $result['total_pages'],
],
'counts_by_status' => $result['counts_by_status'],
]);
}
public function show(int $promotionId): JsonResponse
{
$record = $this->findOrFail($promotionId);
if ($record instanceof JsonResponse) {
return $record;
}
return response()->json([
'ok' => true,
'data' => new StudentPromotionResource($this->query->detail($record)),
]);
}
public function summary(AdminListPromotionsRequest $request): JsonResponse
{
$counts = $this->query->countsByStatus($request->filters());
return response()->json([
'ok' => true,
'counts_by_status' => $counts,
'total' => array_sum($counts),
]);
}
public function evaluate(EvaluateEligibilityRequest $request): JsonResponse
{
$payload = $request->validated();
$userId = $this->getCurrentUserId();
$scope = $payload['scope'] ?? 'school_year';
try {
$result = match ($scope) {
'student' => [
'scope' => 'student',
'record' => optional(
$this->eligibility->evaluateStudent(
(int) $payload['student_id'],
$payload['current_school_year'] ?? null,
$userId
)
)?->toArray(),
],
'class_section' => array_merge(
['scope' => 'class_section'],
$this->eligibility->evaluateClassSection(
(int) $payload['class_section_id'],
$payload['current_school_year'] ?? null,
$userId
)
),
default => array_merge(
['scope' => 'school_year'],
$this->eligibility->evaluateSchoolYear(
$payload['current_school_year'] ?? null,
$userId
)
),
};
} catch (\Throwable $e) {
Log::error('Promotion eligibility evaluation failed', ['exception' => $e]);
return response()->json([
'ok' => false,
'message' => 'Failed to evaluate promotion eligibility.',
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
return response()->json([
'ok' => true,
'result' => $result,
]);
}
public function updateStatus(UpdatePromotionStatusRequest $request, int $promotionId): JsonResponse
{
$record = $this->findOrFail($promotionId);
if ($record instanceof JsonResponse) {
return $record;
}
$payload = $request->validated();
$userId = $this->getCurrentUserId();
try {
$updated = !empty($payload['force'])
? $this->statusService->forceStatus($record, $payload['status'], $userId, $payload['notes'] ?? null)
: $this->statusService->transition($record, $payload['status'], $userId, $payload['notes'] ?? null);
} catch (\InvalidArgumentException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], Response::HTTP_CONFLICT);
}
return response()->json([
'ok' => true,
'data' => new StudentPromotionResource($this->query->detail($updated->refresh())),
]);
}
public function setDeadline(SetEnrollmentDeadlineRequest $request, ?int $promotionId = null): JsonResponse
{
$payload = $request->validated();
$userId = $this->getCurrentUserId();
$newDeadline = $payload['enrollment_deadline'];
$applyTo = $payload['apply_to'] ?? ($promotionId ? 'single' : 'school_year');
$records = collect();
if ($promotionId !== null || $applyTo === 'single') {
$id = $promotionId ?? null;
if ($id === null) {
return response()->json(['ok' => false, 'message' => 'promotion_id is required for single deadline updates.'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$record = $this->findOrFail($id);
if ($record instanceof JsonResponse) {
return $record;
}
$records = collect([$record]);
} elseif ($applyTo === 'filter' && !empty($payload['promotion_ids'])) {
$records = StudentPromotionRecord::query()
->whereIn('promotion_id', $payload['promotion_ids'])
->get();
} else {
$year = $payload['next_school_year'] ?? null;
if ($year === null || $year === '') {
return response()->json([
'ok' => false,
'message' => 'next_school_year is required when applying a bulk deadline.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$records = StudentPromotionRecord::query()
->forNextYear($year)
->whereIn('promotion_status', StudentPromotionRecord::openStatuses())
->get();
}
$updatedIds = [];
foreach ($records as $record) {
$oldDeadline = $record->enrollment_deadline?->toDateString();
DB::transaction(function () use ($record, $newDeadline, $oldDeadline, $userId, &$updatedIds) {
$record->enrollment_deadline = $newDeadline;
$record->updated_by = $userId;
$record->save();
$this->audit->logDeadlineSet($record, $oldDeadline, $newDeadline, $userId);
$updatedIds[] = (int) $record->getKey();
});
}
return response()->json([
'ok' => true,
'updated' => count($updatedIds),
'promotion_ids' => $updatedIds,
]);
}
public function sendReminder(SendReminderRequest $request, int $promotionId): JsonResponse
{
$record = $this->findOrFail($promotionId);
if ($record instanceof JsonResponse) {
return $record;
}
$payload = $request->validated();
$userId = $this->getCurrentUserId();
try {
$log = $this->reminders->send(
$record,
$payload['reminder_type'] ?? PromotionReminderLog::TYPE_MANUAL,
$userId,
$payload['subject'] ?? null,
$payload['message'] ?? null,
$payload['channels'] ?? ['in_app', 'email']
);
} catch (\Throwable $e) {
Log::error('Promotion reminder send failed', [
'promotion_id' => $promotionId,
'exception' => $e,
]);
return response()->json([
'ok' => false,
'message' => 'Failed to send reminder.',
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
return response()->json([
'ok' => true,
'reminder' => new PromotionReminderResource($log),
]);
}
public function dispatchScheduledReminders(): JsonResponse
{
$userId = $this->getCurrentUserId();
$result = $this->reminders->dispatchScheduledReminders(null, $userId);
return response()->json([
'ok' => true,
'result' => $result,
]);
}
public function reminders(int $promotionId): JsonResponse
{
$record = $this->findOrFail($promotionId);
if ($record instanceof JsonResponse) {
return $record;
}
$rows = PromotionReminderLog::query()
->forPromotion((int) $record->getKey())
->orderByDesc('id')
->get();
return response()->json([
'ok' => true,
'reminders' => PromotionReminderResource::collection($rows),
]);
}
public function audit(int $promotionId): JsonResponse
{
$record = $this->findOrFail($promotionId);
if ($record instanceof JsonResponse) {
return $record;
}
$rows = PromotionAuditLog::query()
->forPromotion((int) $record->getKey())
->orderByDesc('id')
->get();
return response()->json([
'ok' => true,
'entries' => PromotionAuditLogResource::collection($rows),
]);
}
public function expireDeadlines(): JsonResponse
{
$result = $this->enrollmentService->markExpiredDeadlines(null, $this->getCurrentUserId());
return response()->json([
'ok' => true,
'result' => $result,
]);
}
public function adminCompleteEnrollment(ParentEnrollmentStepRequest $request, int $promotionId): JsonResponse
{
$record = $this->findOrFail($promotionId);
if ($record instanceof JsonResponse) {
return $record;
}
$userId = $this->getCurrentUserId();
$parentId = (int) $record->parent_id ?: $userId ?: 0;
if ($parentId <= 0) {
return response()->json([
'ok' => false,
'message' => 'Promotion record has no associated parent.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
try {
$updated = $this->enrollmentService->updateSteps(
$record,
$parentId,
$request->steps(),
$userId
);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], Response::HTTP_CONFLICT);
}
return response()->json([
'ok' => true,
'data' => new StudentPromotionResource($this->query->detail($updated->refresh())),
]);
}
public function levelProgressions(): JsonResponse
{
$rows = $this->progression->list(false);
return response()->json([
'ok' => true,
'levels' => LevelProgressionResource::collection($rows),
]);
}
public function upsertLevelProgression(UpsertLevelProgressionRequest $request): JsonResponse
{
$row = $this->progression->upsertMapping($request->validated());
return response()->json([
'ok' => true,
'level' => new LevelProgressionResource($row),
]);
}
public function seedLevelProgressions(): JsonResponse
{
$created = $this->progression->seedDefaults();
return response()->json([
'ok' => true,
'created' => $created,
]);
}
private function findOrFail(int $promotionId): StudentPromotionRecord|JsonResponse
{
$record = StudentPromotionRecord::query()->find($promotionId);
if (!$record) {
return response()->json([
'ok' => false,
'message' => 'Promotion record not found.',
], Response::HTTP_NOT_FOUND);
}
return $record;
}
}
@@ -20,8 +20,7 @@ class AuthController extends BaseApiController
$payload = is_array($decoded) ? $decoded : [];
}
// Match CodeIgniter apiLogin: raw email/password (no trim); empty check is identical to CI `!$email || !$password`.
$email = (string) ($payload['email'] ?? '');
$email = strtolower(trim((string) ($payload['email'] ?? '')));
$password = (string) ($payload['password'] ?? '');
if ($email === '' || $password === '') {
@@ -39,7 +38,7 @@ class AuthController extends BaseApiController
], 429);
}
$user = User::query()->where('email', $email)->first();
$user = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
if (!$user) {
$security->logIpAttempt((string) $ip);
@@ -49,13 +48,6 @@ class AuthController extends BaseApiController
], 401);
}
if ($user->is_suspended) {
return response()->json([
'status' => false,
'message' => 'Account suspended. Please reset your password.',
], 403);
}
if (! ci_password_verify($password, (string) $user->password)) {
$security->handleFailedLogin($user, $email, (string) $ip);
@@ -65,6 +57,15 @@ class AuthController extends BaseApiController
], 401);
}
// Suspension is checked AFTER credentials are verified so that the
// response shape cannot be used to enumerate which emails exist.
if ($user->is_suspended) {
return response()->json([
'status' => false,
'message' => 'Account suspended. Please reset your password.',
], 403);
}
$security->resetFailedAttempts($user);
$security->logSuccessfulLogin($user->fresh(), $request);
@@ -245,28 +245,31 @@ class BaseApiController extends Controller
return null;
}
$user = app(User::class);
$row = $user->find($userId);
/** @var User|null $row */
$row = User::query()->find($userId);
if (!$row) {
return null;
}
$name = trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? ''));
$firstname = (string) ($row->firstname ?? '');
$lastname = (string) ($row->lastname ?? '');
$email = $row->email;
$name = trim($firstname . ' ' . $lastname);
if ($name === '') {
$name = $row['email'] ?? 'User #' . $userId;
$name = $email ?? 'User #' . $userId;
}
try {
$roles = DB::table('user_roles ur')
->select('LOWER(r.name) AS name')
->join('roles r', 'r.id', '=', 'ur.role_id')
$roles = DB::table('user_roles as ur')
->join('roles as r', 'r.id', '=', 'ur.role_id')
->where('ur.user_id', $userId)
->whereNull('ur.deleted_at')
->pluck('name')
->map(fn($name) => strtolower((string) $name))
->pluck('r.name')
->map(fn ($name) => strtolower((string) $name))
->unique()
->values()
->toArray();
->all();
} catch (\Throwable $e) {
Log::error('Unable to load user roles: ' . $e->getMessage());
$roles = [];
@@ -275,7 +278,7 @@ class BaseApiController extends Controller
return (object) [
'id' => (int) $userId,
'name' => $name,
'email' => $row['email'] ?? null,
'email' => $email,
'roles' => $roles,
];
}
@@ -0,0 +1,158 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Finance\ApplyExtraChargeRequest;
use App\Http\Requests\Finance\ChargeListRequest;
use App\Http\Requests\Finance\MarkEventChargePaidRequest;
use App\Http\Requests\Finance\StoreEventChargeRequest;
use App\Http\Requests\Finance\StoreExtraChargeRequest;
use App\Http\Resources\Fees\ChargeActionResource;
use App\Http\Resources\Fees\ChargeListResource;
use App\Services\Billing\ChargeService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class ChargeController extends BaseApiController
{
public function __construct(private ChargeService $charges)
{
parent::__construct();
}
public function index(ChargeListRequest $request): JsonResponse
{
$payload = $request->validated();
$result = $this->charges->listForParent(
(int) $payload['parent_id'],
$payload['school_year'] ?? null
);
return response()->json([
'ok' => true,
'charges' => new ChargeListResource($result),
]);
}
public function storeExtra(StoreExtraChargeRequest $request): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated();
$payload['created_by'] = $guard;
try {
$result = $this->charges->createExtraCharge($payload);
} catch (\Throwable $e) {
Log::error('Store extra charge failed', ['exception' => $e]);
return response()->json(['ok' => false, 'message' => 'Unable to create extra charge.'], 500);
}
$status = ($result['ok'] ?? false)
? (isset($result['error']) && $result['error'] === 'duplicate_charge' ? 409 : 201)
: 422;
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
'charge' => new ChargeActionResource($result),
], $status);
}
public function storeEvent(StoreEventChargeRequest $request): JsonResponse
{
$guard = $this->authenticatedUserIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$payload = $request->validated();
$payload['created_by'] = $guard;
try {
$result = $this->charges->createEventCharge($payload);
} catch (\Throwable $e) {
Log::error('Store event charge failed', ['exception' => $e]);
return response()->json(['ok' => false, 'message' => 'Unable to create event charge.'], 500);
}
$status = ($result['ok'] ?? false)
? (isset($result['error']) && $result['error'] === 'duplicate_charge' ? 409 : 201)
: 422;
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
'charge' => new ChargeActionResource($result),
], $status);
}
public function cancelExtra(int $id): JsonResponse
{
$result = $this->charges->cancelExtraCharge($id);
$status = ($result['ok'] ?? false) ? 200 : 422;
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
'charge' => new ChargeActionResource($result),
], $status);
}
public function applyExtra(ApplyExtraChargeRequest $request, int $id): JsonResponse
{
$payload = $request->validated();
$result = $this->charges->applyExtraCharge($id, (int) $payload['invoice_id']);
$status = ($result['ok'] ?? false) ? 200 : 422;
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
'charge' => new ChargeActionResource($result),
], $status);
}
public function cancelEvent(Request $request, int $id): JsonResponse
{
$issueCredit = $request->boolean('issue_credit', true);
$result = $this->charges->cancelEventCharge($id, $issueCredit);
$status = ($result['ok'] ?? false) ? 200 : 422;
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
'charge' => new ChargeActionResource($result),
], $status);
}
public function markEventPaid(MarkEventChargePaidRequest $request, int $id): JsonResponse
{
$payload = $request->validated();
$result = $this->charges->markEventChargePaid(
$id,
(bool) $payload['paid'],
isset($payload['payment_id']) ? (int) $payload['payment_id'] : null
);
$status = ($result['ok'] ?? false) ? 200 : 422;
return response()->json([
'ok' => (bool) ($result['ok'] ?? false),
'charge' => new ChargeActionResource($result),
], $status);
}
/**
* @return int|JsonResponse
*/
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
{
$userId = (int) (auth()->id() ?? 0);
if ($userId <= 0) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
}
return $userId;
}
}
@@ -3,12 +3,18 @@
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Finance\FeeFamilyBalanceRequest;
use App\Http\Requests\Finance\FeeRefundRequest;
use App\Http\Requests\Finance\FeeTuitionBreakdownRequest;
use App\Http\Requests\Finance\FeeTuitionTotalRequest;
use App\Http\Resources\Fees\FeeFamilyBalanceResource;
use App\Http\Resources\Fees\FeeRefundResource;
use App\Http\Resources\Fees\FeeTuitionBreakdownResource;
use App\Http\Resources\Fees\FeeTuitionTotalResource;
use App\Services\Billing\BalanceCalculationService;
use App\Services\Fees\FeeRefundCalculatorService;
use App\Services\Fees\FeeStudentFeeService;
use App\Services\Fees\TuitionCalculationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
@@ -16,7 +22,9 @@ class FeeCalculationController extends BaseApiController
{
public function __construct(
private FeeRefundCalculatorService $refunds,
private FeeStudentFeeService $tuition
private FeeStudentFeeService $tuition,
private TuitionCalculationService $tuitionCalculator,
private BalanceCalculationService $balance
) {
parent::__construct();
}
@@ -69,4 +77,55 @@ class FeeCalculationController extends BaseApiController
]),
]);
}
public function tuitionBreakdown(FeeTuitionBreakdownRequest $request): JsonResponse
{
$payload = $request->validated();
try {
$result = $this->tuitionCalculator->calculate($payload['students']);
} catch (\Throwable $e) {
Log::error('Tuition breakdown calculation failed', [
'exception' => $e,
]);
return response()->json([
'ok' => false,
'message' => 'Unable to calculate tuition breakdown.',
], 500);
}
return response()->json([
'ok' => true,
'tuition' => new FeeTuitionBreakdownResource($result),
]);
}
public function familyBalance(FeeFamilyBalanceRequest $request): JsonResponse
{
$payload = $request->validated();
try {
$result = $this->balance->calculateFamilyAccount(
$payload['students'],
(int) $payload['parent_id'],
$payload['school_year'] ?? null
);
} catch (\Throwable $e) {
Log::error('Family balance calculation failed', [
'parent_id' => $payload['parent_id'] ?? null,
'exception' => $e,
]);
return response()->json([
'ok' => false,
'message' => 'Unable to calculate family balance.',
], 500);
}
return response()->json([
'ok' => true,
'account' => new FeeFamilyBalanceResource($result),
]);
}
}
@@ -37,14 +37,19 @@ class AuthorizedUserInviteController extends Controller
);
}
$redirectUrl = (string) $result['redirect_url'];
if (! $this->isTrustedRedirectUrl($redirectUrl)) {
return response()->json(['message' => 'Invalid redirect URL.'], 400);
}
if ($request->expectsJson()) {
return response()->json([
'message' => 'Confirmed.',
'redirect_url' => $result['redirect_url'],
'redirect_url' => $redirectUrl,
]);
}
return redirect()->away($result['redirect_url']);
return redirect()->away($redirectUrl);
}
public function setPasswordForm(Request $request, int $authorizedUserId): JsonResponse|Response
@@ -79,7 +84,8 @@ class AuthorizedUserInviteController extends Controller
{
$validator = Validator::make($request->all(), [
'password' => ['required', 'string', 'min:8', 'regex:/[A-Z]/', 'regex:/[a-z]/', 'regex:/[0-9]/', 'regex:/[\W_]/'],
'password_confirm' => ['required', 'same:password'],
'password_confirmation' => ['required_without:password_confirm', 'same:password'],
'password_confirm' => ['required_without:password_confirmation', 'same:password'],
'user_id' => ['required', 'integer'],
'token' => ['required', 'string'],
], [
@@ -108,4 +114,23 @@ class AuthorizedUserInviteController extends Controller
return response()->json(['message' => 'Password has been successfully set.']);
}
private function isTrustedRedirectUrl(string $url): bool
{
$host = parse_url($url, PHP_URL_HOST);
if (! is_string($host) || $host === '') {
return false;
}
$trusted = [
parse_url((string) config('app.url'), PHP_URL_HOST),
parse_url((string) config('app.frontend_url'), PHP_URL_HOST),
parse_url((string) config('services.frontend.url'), PHP_URL_HOST),
];
return in_array(strtolower($host), array_filter(array_map(
static fn ($value) => is_string($value) ? strtolower($value) : null,
$trusted
)), true);
}
}
@@ -58,14 +58,17 @@ class AuthorizedUsersController extends BaseApiController
return $guard;
}
$email = strtolower($request->validated('email'));
$email = strtolower(trim((string) $request->validated('email')));
$result = $this->svc->inviteByEmail($guard, $email);
$message = 'Authorized user added. A confirmation email has been sent.';
if (! $result['created']) {
return $this->success(['message' => $message], $message, Response::HTTP_CREATED);
$message = 'If the email belongs to a registered user, a confirmation email has been sent.';
return $this->success(['message' => $message], $message, Response::HTTP_OK);
}
$message = 'Authorized user added. A confirmation email has been sent.';
return $this->success(
['message' => $message, 'id' => $result['record']->id],
$message,
@@ -89,11 +92,18 @@ class AuthorizedUsersController extends BaseApiController
$row = $resolution;
$email = $request->validated('email') ?? null;
if (is_string($email) && $email !== '') {
$row->email = strtolower($email);
}
if (is_string($email) && trim($email) !== '') {
try {
$this->svc->changeEmailAndReinvite($row, $email);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY);
}
$row->save();
return $this->success(
['message' => 'Authorized user email updated. A new confirmation email has been sent.'],
'Authorized user email updated. A new confirmation email has been sent.'
);
}
return $this->success(['message' => 'Authorized user information updated.'], 'Authorized user information updated.');
}
@@ -127,15 +137,15 @@ class AuthorizedUsersController extends BaseApiController
private function authorizedUserForParent(int $id, int $parentId): AuthorizedUser|JsonResponse
{
$row = AuthorizedUser::query()->find($id);
$row = AuthorizedUser::query()
->where('id', $id)
->where('user_id', $parentId)
->first();
if (! $row) {
return $this->error('Authorized user not found.', Response::HTTP_NOT_FOUND);
}
if ((int) $row->user_id !== $parentId) {
return $this->error('You do not have access to this resource.', Response::HTTP_FORBIDDEN);
}
return $row;
}
}
@@ -148,10 +148,14 @@ class ParentController extends BaseApiController
$payload['emergency_contacts'] ?? []
);
$createdCount = count($result['created_student_ids']);
$skipped = $result['skipped'] ?? [];
return response()->json([
'ok' => true,
'created_student_ids' => $result['created_student_ids'],
], 201);
'skipped' => $skipped,
], $createdCount > 0 ? 201 : 200);
}
public function updateStudent(ParentStudentUpdateRequest $request, int $studentId): JsonResponse
@@ -283,10 +287,21 @@ class ParentController extends BaseApiController
}
/**
* @return int|JsonResponse Authenticated parent user id, or 401 JSON for this controller's legacy `{ ok }` shape.
* @return int|JsonResponse Effective primary-parent user id, or 401 JSON for this controller's legacy `{ ok }` shape.
*
* `parent.access` middleware resolves second parents (user_type=secondary)
* and authorized users (user_type=tertiary) to their primary parent and
* stashes the result as `primary_parent_id` on the request. If we are
* called outside that middleware we fall back to the authenticated id.
*/
private function parentIdOrUnauthorized(): int|JsonResponse
{
$request = request();
$primary = (int) ($request?->attributes?->get('primary_parent_id') ?? 0);
if ($primary > 0) {
return $primary;
}
$parentId = (int) (auth()->id() ?? 0);
if ($parentId <= 0) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
@@ -0,0 +1,173 @@
<?php
namespace App\Http\Controllers\Api\Parents;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Promotions\ParentEnrollmentStepRequest;
use App\Http\Requests\Promotions\ParentPromotionOverviewRequest;
use App\Http\Resources\Promotions\StudentPromotionResource;
use App\Models\Student;
use App\Models\StudentPromotionRecord;
use App\Services\Promotions\PromotionEnrollmentService;
use App\Services\Promotions\PromotionQueryService;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* Parent portal endpoints for the promotion + enrollment workflow
* (plan section 12). The parent sees the promoted level and can step
* through the enrollment checklist defined in plan section 8.
*/
class ParentPromotionController extends BaseApiController
{
public function __construct(
private PromotionEnrollmentService $service,
private PromotionQueryService $query
) {
parent::__construct();
}
public function overview(ParentPromotionOverviewRequest $request): JsonResponse
{
$guard = $this->parentIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$data = $this->service->overviewForParent(
$guard,
$request->validated()['next_school_year'] ?? null
);
return response()->json([
'ok' => true,
'records' => $data['records'],
'next_school_year' => $data['next_school_year'],
'message' => $data['message'],
]);
}
public function show(int $promotionId): JsonResponse
{
$guard = $this->parentIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$record = $this->loadOwnedRecord($promotionId, $guard);
if ($record instanceof JsonResponse) {
return $record;
}
return response()->json([
'ok' => true,
'data' => new StudentPromotionResource($this->query->detail($record)),
]);
}
public function start(int $promotionId): JsonResponse
{
$guard = $this->parentIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$record = $this->loadOwnedRecord($promotionId, $guard);
if ($record instanceof JsonResponse) {
return $record;
}
try {
$updated = $this->service->startEnrollment($record, $guard, $guard);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], Response::HTTP_CONFLICT);
}
return response()->json([
'ok' => true,
'data' => new StudentPromotionResource($this->query->detail($updated->refresh())),
]);
}
public function updateSteps(ParentEnrollmentStepRequest $request, int $promotionId): JsonResponse
{
$guard = $this->parentIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$record = $this->loadOwnedRecord($promotionId, $guard);
if ($record instanceof JsonResponse) {
return $record;
}
try {
$updated = $this->service->updateSteps($record, $guard, $request->steps(), $guard);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], Response::HTTP_CONFLICT);
}
return response()->json([
'ok' => true,
'data' => new StudentPromotionResource($this->query->detail($updated->refresh())),
]);
}
public function submit(int $promotionId): JsonResponse
{
$guard = $this->parentIdOrUnauthorized();
if ($guard instanceof JsonResponse) {
return $guard;
}
$record = $this->loadOwnedRecord($promotionId, $guard);
if ($record instanceof JsonResponse) {
return $record;
}
try {
$updated = $this->service->submitEnrollment($record, $guard, $guard);
} catch (\RuntimeException $e) {
return response()->json(['ok' => false, 'message' => $e->getMessage()], Response::HTTP_CONFLICT);
}
return response()->json([
'ok' => true,
'data' => new StudentPromotionResource($this->query->detail($updated->refresh())),
]);
}
private function loadOwnedRecord(int $promotionId, int $parentId): StudentPromotionRecord|JsonResponse
{
$record = StudentPromotionRecord::query()->find($promotionId);
if (!$record) {
return response()->json(['ok' => false, 'message' => 'Promotion record not found.'], Response::HTTP_NOT_FOUND);
}
$owns = (int) $record->parent_id === $parentId;
if (!$owns) {
$studentParentId = (int) (Student::query()
->where('id', $record->student_id)
->value('parent_id') ?? 0);
$owns = $studentParentId === $parentId;
}
if (!$owns) {
return response()->json(['ok' => false, 'message' => 'You are not authorised to view this promotion record.'], Response::HTTP_FORBIDDEN);
}
return $record;
}
private function parentIdOrUnauthorized(): int|JsonResponse
{
$request = request();
$primary = (int) ($request?->attributes?->get('primary_parent_id') ?? 0);
if ($primary > 0) {
return $primary;
}
$parentId = (int) (auth()->id() ?? 0);
if ($parentId <= 0) {
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], Response::HTTP_UNAUTHORIZED);
}
return $parentId;
}
}
-68
View File
@@ -1,68 +0,0 @@
<?php
namespace App\Http;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
{
/**
* The application's global HTTP middleware stack.
*
* These middleware run during every request to your application.
*/
protected $middleware = [
// Handles CORS
\Illuminate\Http\Middleware\HandleCors::class,
// Trust proxies
\Illuminate\Http\Middleware\TrustProxies::class,
// Prevent request size attacks
\Illuminate\Http\Middleware\ValidatePostSize::class,
// Trim whitespace
\App\Http\Middleware\TrimStrings::class,
// Convert empty strings to null
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
];
/**
* The application's route middleware groups.
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'api' => [
// Throttling (optional)
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
// JWT Authentication
\PHPOpenSourceSaver\JWTAuth\Http\Middleware\Authenticate::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
/**
* The application's route middleware.
*
* These may be assigned to routes individually.
*/
protected $routeMiddleware = [
'auth' => \App\Http\Middleware\Authenticate::class,
// Laravel default middleware
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
// JWT middleware
'jwt.auth' => \PHPOpenSourceSaver\JWTAuth\Http\Middleware\Authenticate::class,
'jwt.refresh' => \PHPOpenSourceSaver\JWTAuth\Http\Middleware\RefreshToken::class,
];
}
@@ -3,12 +3,21 @@
namespace App\Http\Middleware;
use App\Models\User;
use App\Services\Parents\PrimaryParentUserResolver;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/** CodeIgniter route filter `auth:parent`. */
/**
* CodeIgniter route filter `auth:parent`.
*
* Allows access for any caller that ultimately maps to a primary parent
* record: the primary parent themselves (parent role / user_type=primary),
* a registered second parent (user_type=secondary) or an authorized user
* (user_type=tertiary). The resolved primary parent id is stashed on the
* request as `primary_parent_id` for controllers to consume.
*/
class EnsureParentProgressAccess
{
public function handle(Request $request, Closure $next): Response
@@ -25,9 +34,23 @@ class EnsureParentProgressAccess
->toArray();
if (in_array('parent', $roles, true)) {
$request->attributes->set('primary_parent_id', (int) $user->id);
return $next($request);
}
$userType = strtolower(trim((string) ($user->user_type ?? '')));
if (in_array($userType, ['secondary', 'tertiary'], true)) {
/** @var PrimaryParentUserResolver $resolver */
$resolver = app(PrimaryParentUserResolver::class);
$primaryId = $resolver->resolveFromCredentials((int) $user->id, $userType);
if ($primaryId !== null && $primaryId > 0) {
$request->attributes->set('primary_parent_id', $primaryId);
return $next($request);
}
}
return response()->json(['message' => 'Forbidden.'], 403);
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Finance;
use App\Http\Requests\ApiFormRequest;
class ApplyExtraChargeRequest extends ApiFormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'invoice_id' => ['required', 'integer', 'min:1'],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Finance;
use App\Http\Requests\ApiFormRequest;
class ChargeListRequest extends ApiFormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'parent_id' => ['required', 'integer', 'min:1'],
'school_year' => ['nullable', 'string', 'max:20'],
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Finance;
use App\Http\Requests\ApiFormRequest;
class FeeFamilyBalanceRequest extends ApiFormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'parent_id' => ['required', 'integer', 'min:1'],
'school_year' => ['nullable', 'string', 'max:20'],
'students' => ['required', 'array', 'min:1'],
'students.*.student_id' => ['nullable', 'integer', 'min:1'],
'students.*.firstname' => ['nullable', 'string', 'max:100'],
'students.*.lastname' => ['nullable', 'string', 'max:100'],
'students.*.class_section_id' => ['nullable', 'integer', 'min:1'],
'students.*.grade' => ['nullable', 'string', 'max:20'],
'students.*.enrollment_status' => ['nullable', 'string', 'max:50'],
'students.*.admission_status' => ['nullable', 'string', 'max:50'],
'students.*.discount_amount' => ['nullable', 'numeric', 'min:0'],
];
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Finance;
use App\Http\Requests\ApiFormRequest;
class FeeTuitionBreakdownRequest extends ApiFormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'students' => ['required', 'array', 'min:1'],
'students.*.student_id' => ['nullable', 'integer', 'min:1'],
'students.*.firstname' => ['nullable', 'string', 'max:100'],
'students.*.lastname' => ['nullable', 'string', 'max:100'],
'students.*.class_section_id' => ['nullable', 'integer', 'min:1'],
'students.*.grade' => ['nullable', 'string', 'max:20'],
'students.*.enrollment_status' => ['nullable', 'string', 'max:50'],
'students.*.admission_status' => ['nullable', 'string', 'max:50'],
'students.*.discount_amount' => ['nullable', 'numeric', 'min:0'],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Finance;
use App\Http\Requests\ApiFormRequest;
class MarkEventChargePaidRequest extends ApiFormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'paid' => ['required', 'boolean'],
'payment_id' => ['nullable', 'integer', 'min:1'],
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\Finance;
use App\Http\Requests\ApiFormRequest;
class StoreEventChargeRequest extends ApiFormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'parent_id' => ['required', 'integer', 'min:1'],
'student_id' => ['required', 'integer', 'min:1'],
'event_id' => ['nullable', 'integer', 'min:1'],
'event_name' => ['required_without:event_id', 'nullable', 'string', 'max:255'],
'amount' => ['nullable', 'numeric', 'min:0'],
'description' => ['nullable', 'string', 'max:2000'],
'school_year' => ['nullable', 'string', 'max:20'],
'semester' => ['nullable', 'string', 'max:20'],
];
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Finance;
use App\Http\Requests\ApiFormRequest;
class StoreExtraChargeRequest extends ApiFormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'parent_id' => ['required', 'integer', 'min:1'],
'title' => ['required', 'string', 'min:2', 'max:255'],
'amount' => ['required', 'numeric', 'min:0.01'],
'charge_type' => ['required', 'in:add,deduct'],
'description' => ['nullable', 'string', 'max:2000'],
'due_date' => ['nullable', 'date_format:Y-m-d'],
'invoice_id' => ['nullable', 'integer', 'min:1'],
'school_year' => ['nullable', 'string', 'max:20'],
'semester' => ['nullable', 'string', 'max:20'],
];
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Requests\Promotions;
use App\Http\Requests\ApiFormRequest;
use App\Models\StudentPromotionRecord;
use Illuminate\Validation\Rule;
class AdminListPromotionsRequest extends ApiFormRequest
{
public function rules(): array
{
return [
'status' => ['nullable'],
'status.*' => ['string', Rule::in(StudentPromotionRecord::ALL_STATUSES)],
'next_school_year' => ['nullable', 'string', 'max:9'],
'current_school_year' => ['nullable', 'string', 'max:9'],
'parent_id' => ['nullable', 'integer', 'min:1'],
'student_id' => ['nullable', 'integer', 'min:1'],
'search' => ['nullable', 'string', 'max:191'],
'parent_action_required' => ['nullable', 'boolean'],
'page' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:200'],
];
}
public function filters(): array
{
$data = $this->validated();
$status = $data['status'] ?? null;
if (is_string($status) && $status !== '') {
$status = [$status];
} elseif (!is_array($status)) {
$status = null;
}
return [
'status' => $status,
'next_school_year' => $data['next_school_year'] ?? null,
'current_school_year' => $data['current_school_year'] ?? null,
'parent_id' => isset($data['parent_id']) ? (int) $data['parent_id'] : null,
'student_id' => isset($data['student_id']) ? (int) $data['student_id'] : null,
'search' => $data['search'] ?? null,
'parent_action_required' => isset($data['parent_action_required'])
? (bool) $data['parent_action_required']
: false,
'page' => isset($data['page']) ? (int) $data['page'] : 1,
'per_page' => isset($data['per_page']) ? (int) $data['per_page'] : 50,
];
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Requests\Promotions;
use App\Http\Requests\ApiFormRequest;
class EvaluateEligibilityRequest extends ApiFormRequest
{
public function rules(): array
{
return [
'scope' => ['nullable', 'string', 'in:student,class_section,school_year'],
'student_id' => ['nullable', 'integer', 'min:1', 'required_if:scope,student'],
'class_section_id' => ['nullable', 'integer', 'min:1', 'required_if:scope,class_section'],
'current_school_year' => ['nullable', 'string', 'max:9'],
];
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Promotions;
use App\Http\Requests\ApiFormRequest;
class ParentEnrollmentStepRequest extends ApiFormRequest
{
public function rules(): array
{
return [
'info_confirmed' => ['nullable', 'boolean'],
'documents_uploaded' => ['nullable', 'boolean'],
'agreement_accepted' => ['nullable', 'boolean'],
'payment_completed' => ['nullable', 'boolean'],
];
}
public function steps(): array
{
$data = $this->validated();
$steps = [];
foreach (['info_confirmed', 'documents_uploaded', 'agreement_accepted', 'payment_completed'] as $field) {
if (array_key_exists($field, $data) && $data[$field] !== null) {
$steps[$field] = (bool) $data[$field];
}
}
return $steps;
}
}
@@ -0,0 +1,15 @@
<?php
namespace App\Http\Requests\Promotions;
use App\Http\Requests\ApiFormRequest;
class ParentPromotionOverviewRequest extends ApiFormRequest
{
public function rules(): array
{
return [
'next_school_year' => ['nullable', 'string', 'max:9'],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Promotions;
use App\Http\Requests\ApiFormRequest;
use App\Models\PromotionReminderLog;
use Illuminate\Validation\Rule;
class SendReminderRequest extends ApiFormRequest
{
public function rules(): array
{
return [
'reminder_type' => ['nullable', 'string', Rule::in(PromotionReminderLog::ALLOWED_TYPES)],
'subject' => ['nullable', 'string', 'max:191'],
'message' => ['nullable', 'string', 'max:5000'],
'channels' => ['nullable', 'array'],
'channels.*' => ['string', Rule::in(['in_app', 'email', 'sms'])],
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Requests\Promotions;
use App\Http\Requests\ApiFormRequest;
class SetEnrollmentDeadlineRequest extends ApiFormRequest
{
public function rules(): array
{
return [
'enrollment_deadline' => ['required', 'date_format:Y-m-d'],
'apply_to' => ['nullable', 'string', 'in:single,school_year,filter'],
'next_school_year' => ['nullable', 'string', 'max:9'],
'promotion_ids' => ['nullable', 'array'],
'promotion_ids.*' => ['integer', 'min:1'],
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Requests\Promotions;
use App\Http\Requests\ApiFormRequest;
use App\Models\StudentPromotionRecord;
use Illuminate\Validation\Rule;
class UpdatePromotionStatusRequest extends ApiFormRequest
{
public function rules(): array
{
return [
'status' => ['required', 'string', Rule::in(StudentPromotionRecord::ALL_STATUSES)],
'force' => ['nullable', 'boolean'],
'notes' => ['nullable', 'string', 'max:500'],
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Promotions;
use App\Http\Requests\ApiFormRequest;
class UpsertLevelProgressionRequest extends ApiFormRequest
{
public function rules(): array
{
return [
'current_level_id' => ['nullable', 'integer', 'min:1'],
'current_level_name' => ['required', 'string', 'max:100'],
'next_level_id' => ['nullable', 'integer', 'min:1'],
'next_level_name' => ['nullable', 'string', 'max:100'],
'order_index' => ['nullable', 'integer', 'min:0', 'max:65535'],
'is_terminal' => ['nullable', 'boolean'],
'is_active' => ['nullable', 'boolean'],
'notes' => ['nullable', 'string', 'max:500'],
];
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Resources\Fees;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ChargeActionResource extends JsonResource
{
public function toArray(Request $request): array
{
return array_filter([
'ok' => (bool) ($this->resource['ok'] ?? false),
'charge_type' => $this->resource['charge_type'] ?? null,
'id' => isset($this->resource['id']) ? (int) $this->resource['id'] : null,
'parent_id' => isset($this->resource['parent_id']) ? (int) $this->resource['parent_id'] : null,
'student_id' => isset($this->resource['student_id']) ? (int) $this->resource['student_id'] : null,
'invoice_id' => isset($this->resource['invoice_id']) ? (int) $this->resource['invoice_id'] : null,
'event_id' => isset($this->resource['event_id']) ? (int) $this->resource['event_id'] : null,
'event_name' => $this->resource['event_name'] ?? null,
'amount' => isset($this->resource['amount']) ? (float) $this->resource['amount'] : null,
'status' => $this->resource['status'] ?? null,
'payment_status' => $this->resource['payment_status'] ?? null,
'participation_status' => $this->resource['participation_status'] ?? null,
'cancellation_status' => $this->resource['cancellation_status'] ?? null,
'account_credit_charge_id' => isset($this->resource['account_credit_charge_id'])
? (int) $this->resource['account_credit_charge_id']
: null,
'message' => $this->resource['message'] ?? null,
'error' => $this->resource['error'] ?? null,
], fn ($value) => $value !== null);
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources\Fees;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ChargeListResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'parent_id' => (int) ($this->resource['parent_id'] ?? 0),
'school_year' => $this->resource['school_year'] ?? null,
'extra_charges' => $this->resource['extra_charges'] ?? [],
'event_charges' => $this->resource['event_charges'] ?? [],
];
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Resources\Fees;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class FeeFamilyBalanceResource extends JsonResource
{
public function toArray(Request $request): array
{
$charges = $this->resource['charges'] ?? [];
$credits = $this->resource['credits'] ?? [];
$students = $this->resource['students'] ?? [];
return [
'parent_id' => (int) ($this->resource['parent_id'] ?? 0),
'school_year' => $this->resource['school_year'] ?? null,
'charges' => [
'tuition' => (float) ($charges['tuition'] ?? 0),
'extra_charges' => (float) ($charges['extra_charges'] ?? 0),
'event_charges' => (float) ($charges['event_charges'] ?? 0),
'late_fees' => (float) ($charges['late_fees'] ?? 0),
'total' => (float) ($charges['total'] ?? 0),
],
'credits' => [
'discounts' => (float) ($credits['discounts'] ?? 0),
'total' => (float) ($credits['total'] ?? 0),
],
'payments' => (float) ($this->resource['payments'] ?? 0),
'refunds_applied' => (float) ($this->resource['refunds_applied'] ?? 0),
'remaining_balance' => (float) ($this->resource['remaining_balance'] ?? 0),
'account_credit' => (float) ($this->resource['account_credit'] ?? 0),
'students' => array_map(function (array $row): array {
return [
'student_id' => $row['student_id'] ?? null,
'student_name' => $row['student_name'] ?? null,
'grade' => $row['grade'] ?? null,
'enrollment_status' => $row['enrollment_status'] ?? null,
'tuition' => (float) ($row['tuition'] ?? 0),
'extra_charges' => (float) ($row['extra_charges'] ?? 0),
'event_charges' => (float) ($row['event_charges'] ?? 0),
'total_charges' => (float) ($row['total_charges'] ?? 0),
'payments_applied' => (float) ($row['payments_applied'] ?? 0),
'credits_applied' => (float) ($row['credits_applied'] ?? 0),
'refunds_applied' => (float) ($row['refunds_applied'] ?? 0),
'remaining_balance' => (float) ($row['remaining_balance'] ?? 0),
'account_credit' => (float) ($row['account_credit'] ?? 0),
];
}, $students),
];
}
}
@@ -9,6 +9,8 @@ class FeeRefundResource extends JsonResource
{
public function toArray(Request $request): array
{
$students = $this->resource['students'] ?? [];
return [
'refund_amount' => (float) ($this->resource['refund_amount'] ?? 0),
'total_paid' => (float) ($this->resource['total_paid'] ?? 0),
@@ -16,6 +18,20 @@ class FeeRefundResource extends JsonResource
'school_end_date' => $this->resource['school_end_date'] ?? null,
'weeks_of_study' => (float) ($this->resource['weeks_of_study'] ?? 0),
'withdrawn_students' => (int) ($this->resource['withdrawn_students'] ?? 0),
'students' => array_map(function (array $row): array {
return [
'student_id' => $row['student_id'] ?? null,
'grade' => $row['grade'] ?? null,
'grade_level' => $row['grade_level'] ?? null,
'fee_category' => $row['fee_category'] ?? null,
'tuition_fee' => (float) ($row['tuition_fee'] ?? 0),
'withdrawal_date' => $row['withdrawal_date'] ?? null,
'weeks_remaining' => (int) ($row['weeks_remaining'] ?? 0),
'refund_amount' => (float) ($row['refund_amount'] ?? 0),
'eligible' => (bool) ($row['eligible'] ?? false),
'reason' => $row['reason'] ?? null,
];
}, $students),
];
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Resources\Fees;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class FeeTuitionBreakdownResource extends JsonResource
{
public function toArray(Request $request): array
{
$students = $this->resource['students'] ?? [];
$fees = $this->resource['fees'] ?? [];
return [
'school_year' => $this->resource['school_year'] ?? null,
'family_total' => (float) ($this->resource['family_total'] ?? 0),
'billable_count' => (int) ($this->resource['billable_count'] ?? 0),
'non_billable_count' => (int) ($this->resource['non_billable_count'] ?? 0),
'fees' => [
'first_student_fee' => (float) ($fees['first_student_fee'] ?? 0),
'second_student_fee' => (float) ($fees['second_student_fee'] ?? 0),
'youth_fee' => (float) ($fees['youth_fee'] ?? 0),
],
'students' => array_map(function (array $row): array {
return [
'student_id' => $row['student_id'] ?? null,
'student_name' => $row['student_name'] ?? null,
'grade' => $row['grade'] ?? null,
'grade_level' => $row['grade_level'] ?? null,
'enrollment_status' => $row['enrollment_status'] ?? null,
'admission_status' => $row['admission_status'] ?? null,
'fee_category' => $row['fee_category'] ?? null,
'tuition_fee' => (float) ($row['tuition_fee'] ?? 0),
'discount_amount' => (float) ($row['discount_amount'] ?? 0),
'final_tuition_amount' => (float) ($row['final_tuition_amount'] ?? 0),
];
}, $students),
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources\Promotions;
use Illuminate\Http\Resources\Json\JsonResource;
class LevelProgressionResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => (int) ($this->id ?? 0),
'current_level_id' => $this->current_level_id !== null ? (int) $this->current_level_id : null,
'current_level_name' => (string) ($this->current_level_name ?? ''),
'next_level_id' => $this->next_level_id !== null ? (int) $this->next_level_id : null,
'next_level_name' => $this->next_level_name,
'order_index' => (int) ($this->order_index ?? 0),
'is_terminal' => (bool) $this->is_terminal,
'is_active' => (bool) $this->is_active,
'notes' => $this->notes,
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources\Promotions;
use Illuminate\Http\Resources\Json\JsonResource;
class PromotionAuditLogResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => (int) ($this->id ?? 0),
'promotion_id' => $this->promotion_id ? (int) $this->promotion_id : null,
'student_id' => $this->student_id ? (int) $this->student_id : null,
'user_id' => $this->user_id ? (int) $this->user_id : null,
'action_type' => $this->action_type,
'field' => $this->field,
'old_value' => $this->old_value,
'new_value' => $this->new_value,
'notes' => $this->notes,
'performed_at' => optional($this->performed_at)->toDateTimeString(),
'created_at' => optional($this->created_at)->toDateTimeString(),
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Resources\Promotions;
use Illuminate\Http\Resources\Json\JsonResource;
class PromotionReminderResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => (int) ($this->id ?? 0),
'promotion_id' => (int) ($this->promotion_id ?? 0),
'student_id' => $this->student_id ? (int) $this->student_id : null,
'parent_id' => $this->parent_id ? (int) $this->parent_id : null,
'reminder_type' => $this->reminder_type,
'channel' => $this->channel,
'subject' => $this->subject,
'message' => $this->message,
'sent_at' => optional($this->sent_at)->toDateTimeString(),
'sent_by' => $this->sent_by ? (int) $this->sent_by : null,
];
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Resources\Promotions;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* Admin/list shape for `student_promotion_records`. Accepts either an
* Eloquent model or an associative array (so list endpoints can
* decorate the row with student/parent context once and pass it
* through unchanged).
*/
class StudentPromotionResource extends JsonResource
{
public function toArray($request): array
{
$r = $this->resource;
$get = static function (string $key, $default = null) use ($r) {
if (is_array($r)) {
return $r[$key] ?? $default;
}
return $r->{$key} ?? $default;
};
return [
'promotion_id' => (int) ($get('promotion_id') ?? 0),
'student_id' => (int) ($get('student_id') ?? 0),
'student_name' => $get('student_name'),
'school_id' => $get('school_id'),
'parent_id' => $get('parent_id') ? (int) $get('parent_id') : null,
'parent_name' => $get('parent_name'),
'parent_email' => $get('parent_email'),
'current_school_year' => $get('current_school_year'),
'next_school_year' => $get('next_school_year'),
'current_level' => $get('current_level') ?? $get('current_level_name'),
'promoted_level' => $get('promoted_level') ?? $get('promoted_level_name'),
'promotion_status' => $get('promotion_status'),
'enrollment_status' => $get('enrollment_status'),
'enrollment_deadline' => $get('enrollment_deadline'),
'parent_notified_at' => $get('parent_notified_at'),
'enrollment_started_at' => $get('enrollment_started_at'),
'enrollment_completed_at' => $get('enrollment_completed_at'),
'promotion_finalized_at' => $get('promotion_finalized_at'),
'final_average' => $get('final_average') !== null ? (float) $get('final_average') : null,
'passed_current_level' => $get('passed_current_level'),
'checklist' => $get('checklist'),
'eligibility_notes' => $get('eligibility_notes'),
'enrollment_id' => $get('enrollment_id') ? (int) $get('enrollment_id') : null,
'updated_by' => $get('updated_by') ? (int) $get('updated_by') : null,
'updated_at' => $get('updated_at'),
];
}
}