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.
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'),
];
}
}
+1
View File
@@ -17,6 +17,7 @@ class AuthorizedUser extends BaseModel
'firstname',
'lastname',
'phone_number',
'gender',
'email',
'relation_to_user',
'token',
+3
View File
@@ -14,6 +14,9 @@ class EventCharges extends BaseModel
protected $fillable = [
'event_id',
'event_name',
'description',
'amount',
'parent_id',
'student_id',
'participation',
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
/**
* Maps current level → next level so the promotion workflow does not
* hard-code grade progression inside controller logic
* (see plan section 10).
*/
class LevelProgression extends BaseModel
{
protected $table = 'level_progressions';
public $timestamps = true;
protected $fillable = [
'current_level_id',
'current_level_name',
'next_level_id',
'next_level_name',
'order_index',
'is_terminal',
'is_active',
'notes',
];
protected $casts = [
'current_level_id' => 'integer',
'next_level_id' => 'integer',
'order_index' => 'integer',
'is_terminal' => 'boolean',
'is_active' => 'boolean',
];
public function scopeActive(Builder $q): Builder
{
return $q->where('is_active', 1);
}
public function scopeOrdered(Builder $q): Builder
{
return $q->orderBy('order_index')->orderBy('current_level_name');
}
public static function findByCurrentLevelId(int $levelId): ?self
{
return static::query()
->where('current_level_id', $levelId)
->where('is_active', 1)
->first();
}
public static function findByCurrentLevelName(string $name): ?self
{
return static::query()
->whereRaw('LOWER(current_level_name) = ?', [strtolower(trim($name))])
->where('is_active', 1)
->first();
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Append-only audit trail for promotion / enrollment workflow actions
* (see plan section 18).
*/
class PromotionAuditLog extends BaseModel
{
protected $table = 'promotion_audit_log';
public $timestamps = true;
public const ACTION_RECORD_CREATED = 'record_created';
public const ACTION_STATUS_CHANGED = 'status_changed';
public const ACTION_ELIGIBILITY_EVALUATED = 'eligibility_evaluated';
public const ACTION_PARENT_NOTIFIED = 'parent_notified';
public const ACTION_ENROLLMENT_STARTED = 'enrollment_started';
public const ACTION_ENROLLMENT_STEP_COMPLETED = 'enrollment_step_completed';
public const ACTION_ENROLLMENT_COMPLETED = 'enrollment_completed';
public const ACTION_PROMOTION_FINALIZED = 'promotion_finalized';
public const ACTION_DEADLINE_SET = 'deadline_set';
public const ACTION_REMINDER_SENT = 'reminder_sent';
public const ACTION_DEADLINE_EXPIRED = 'deadline_expired';
public const ACTION_MANUAL_OVERRIDE = 'manual_override';
protected $fillable = [
'promotion_id',
'student_id',
'user_id',
'action_type',
'field',
'old_value',
'new_value',
'notes',
'performed_at',
];
protected $casts = [
'promotion_id' => 'integer',
'student_id' => 'integer',
'user_id' => 'integer',
'performed_at' => 'datetime',
];
public function promotion(): BelongsTo
{
return $this->belongsTo(StudentPromotionRecord::class, 'promotion_id', 'promotion_id');
}
public function actor(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function scopeForPromotion(Builder $q, int $promotionId): Builder
{
return $q->where('promotion_id', $promotionId);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Records each reminder dispatched to parents for an open promotion
* record (see plan section 14).
*/
class PromotionReminderLog extends BaseModel
{
protected $table = 'promotion_reminders_log';
public $timestamps = true;
public const TYPE_FIRST = 'first';
public const TYPE_HALFWAY = 'halfway';
public const TYPE_FINAL = 'final';
public const TYPE_EXPIRATION = 'expiration';
public const TYPE_MANUAL = 'manual';
public const ALLOWED_TYPES = [
self::TYPE_FIRST,
self::TYPE_HALFWAY,
self::TYPE_FINAL,
self::TYPE_EXPIRATION,
self::TYPE_MANUAL,
];
protected $fillable = [
'promotion_id',
'student_id',
'parent_id',
'reminder_type',
'channel',
'subject',
'message',
'sent_at',
'sent_by',
];
protected $casts = [
'promotion_id' => 'integer',
'student_id' => 'integer',
'parent_id' => 'integer',
'sent_by' => 'integer',
'sent_at' => 'datetime',
];
public function promotion(): BelongsTo
{
return $this->belongsTo(StudentPromotionRecord::class, 'promotion_id', 'promotion_id');
}
public function scopeForPromotion(Builder $q, int $promotionId): Builder
{
return $q->where('promotion_id', $promotionId);
}
}
+194
View File
@@ -0,0 +1,194 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Per-student record tracking the promotion lifecycle for a single
* school year transition (see plan sections 3, 6, 9, 11).
*
* Promotion eligibility lives here separately from the actual class
* assignment the student is only "Promoted and Enrolled" once the
* parent finishes enrollment for the next school year.
*/
class StudentPromotionRecord extends BaseModel
{
protected $table = 'student_promotion_records';
protected $primaryKey = 'promotion_id';
public $timestamps = true;
public const STATUS_NOT_REVIEWED = 'not_reviewed';
public const STATUS_ELIGIBLE = 'eligible_for_promotion';
public const STATUS_AWAITING_PARENT = 'awaiting_parent_enrollment';
public const STATUS_ENROLLMENT_STARTED = 'enrollment_started';
public const STATUS_PROMOTED_AND_ENROLLED = 'promoted_and_enrolled';
public const STATUS_CONDITIONAL = 'conditional_promotion';
public const STATUS_REPEATED = 'repeated_level';
public const STATUS_ON_HOLD = 'on_hold';
public const STATUS_WITHDRAWN = 'withdrawn';
public const STATUS_GRADUATED = 'graduated';
public const STATUS_NOT_ENROLLED = 'not_enrolled_for_next_year';
public const ENROLLMENT_NOT_STARTED = 'not_started';
public const ENROLLMENT_IN_PROGRESS = 'in_progress';
public const ENROLLMENT_COMPLETED = 'completed';
public const ENROLLMENT_EXPIRED = 'expired';
public const ALL_STATUSES = [
self::STATUS_NOT_REVIEWED,
self::STATUS_ELIGIBLE,
self::STATUS_AWAITING_PARENT,
self::STATUS_ENROLLMENT_STARTED,
self::STATUS_PROMOTED_AND_ENROLLED,
self::STATUS_CONDITIONAL,
self::STATUS_REPEATED,
self::STATUS_ON_HOLD,
self::STATUS_WITHDRAWN,
self::STATUS_GRADUATED,
self::STATUS_NOT_ENROLLED,
];
protected $fillable = [
'student_id',
'parent_id',
'current_school_year',
'next_school_year',
'current_level_id',
'promoted_level_id',
'current_level_name',
'promoted_level_name',
'promotion_status',
'passed_current_level',
'final_average',
'attendance_ok',
'eligibility_notes',
'enrollment_required',
'enrollment_status',
'enrollment_id',
'parent_notified_at',
'enrollment_deadline',
'enrollment_started_at',
'enrollment_completed_at',
'promotion_finalized_at',
'info_confirmed',
'documents_uploaded',
'agreement_accepted',
'payment_completed',
'updated_by',
];
protected $casts = [
'student_id' => 'integer',
'parent_id' => 'integer',
'current_level_id' => 'integer',
'promoted_level_id' => 'integer',
'passed_current_level' => 'boolean',
'final_average' => 'float',
'attendance_ok' => 'boolean',
'enrollment_required' => 'boolean',
'enrollment_id' => 'integer',
'parent_notified_at' => 'datetime',
'enrollment_deadline' => 'date',
'enrollment_started_at' => 'datetime',
'enrollment_completed_at' => 'datetime',
'promotion_finalized_at' => 'datetime',
'info_confirmed' => 'boolean',
'documents_uploaded' => 'boolean',
'agreement_accepted' => 'boolean',
'payment_completed' => 'boolean',
'updated_by' => 'integer',
];
public function student(): BelongsTo
{
return $this->belongsTo(Student::class, 'student_id');
}
public function parent(): BelongsTo
{
return $this->belongsTo(User::class, 'parent_id');
}
public function enrollment(): BelongsTo
{
return $this->belongsTo(Enrollment::class, 'enrollment_id');
}
public function scopeForNextYear(Builder $q, string $year): Builder
{
return $q->where('next_school_year', $year);
}
public function scopeStatus(Builder $q, string $status): Builder
{
return $q->where('promotion_status', $status);
}
public function scopeForStudent(Builder $q, int $studentId): Builder
{
return $q->where('student_id', $studentId);
}
public function scopeForParent(Builder $q, int $parentId): Builder
{
return $q->where('parent_id', $parentId);
}
/**
* Statuses the parent portal should treat as "actionable" (parent
* can still complete enrollment).
*/
public static function parentActionableStatuses(): array
{
return [
self::STATUS_ELIGIBLE,
self::STATUS_AWAITING_PARENT,
self::STATUS_ENROLLMENT_STARTED,
];
}
/**
* Statuses considered "open" for the next school year (admin views
* may want to filter on these).
*/
public static function openStatuses(): array
{
return [
self::STATUS_NOT_REVIEWED,
self::STATUS_ELIGIBLE,
self::STATUS_AWAITING_PARENT,
self::STATUS_ENROLLMENT_STARTED,
self::STATUS_CONDITIONAL,
self::STATUS_ON_HOLD,
];
}
public function isFinalized(): bool
{
return in_array(
$this->promotion_status,
[
self::STATUS_PROMOTED_AND_ENROLLED,
self::STATUS_REPEATED,
self::STATUS_WITHDRAWN,
self::STATUS_GRADUATED,
self::STATUS_NOT_ENROLLED,
],
true
);
}
public function enrollmentChecklistComplete(): bool
{
// Mirrors plan section 8 parent-side completion criteria.
// Payment is only required when payment_completed has been
// explicitly set; treat null/false as still required by default.
return (bool) $this->info_confirmed
&& (bool) $this->documents_uploaded
&& (bool) $this->agreement_accepted
&& (bool) $this->payment_completed;
}
}
+22 -8
View File
@@ -6,6 +6,7 @@ use App\Models\IpAttempt;
use App\Models\LoginActivity;
use App\Models\Configuration;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
@@ -15,6 +16,10 @@ use Illuminate\Support\Facades\Log;
*/
class ApiLoginSecurityService
{
private const MAX_ATTEMPTS = 10;
private const BLOCK_HOURS = 24;
public function isIpBlocked(string $ip): bool
{
$row = IpAttempt::query()->where('ip_address', $ip)->first();
@@ -27,19 +32,28 @@ class ApiLoginSecurityService
public function logIpAttempt(string $ip): void
{
$now = utc_now();
$now = CarbonImmutable::now('UTC');
$nowStr = $now->toDateTimeString();
$attempt = IpAttempt::query()->where('ip_address', $ip)->first();
if ($attempt) {
$attempts = ((int) $attempt->attempts) + 1;
$blockedUntil = null;
if ($attempts >= 10) {
$blockedUntil = date('Y-m-d H:i:s', strtotime('+24 hours'));
}
$previouslyBlocked = $attempt->blocked_until
? CarbonImmutable::parse($attempt->blocked_until, 'UTC')->isFuture()
: false;
// If the previous block has already expired, start a fresh window
// so we don't perma-ban an IP after its first 10 lifetime failures.
$attempts = $previouslyBlocked
? ((int) $attempt->attempts) + 1
: 1;
$blockedUntil = $attempts >= self::MAX_ATTEMPTS
? $now->addHours(self::BLOCK_HOURS)->toDateTimeString()
: null;
$attempt->update([
'attempts' => $attempts,
'last_attempt_at' => $now,
'last_attempt_at' => $nowStr,
'blocked_until' => $blockedUntil,
]);
@@ -49,7 +63,7 @@ class ApiLoginSecurityService
IpAttempt::query()->create([
'ip_address' => $ip,
'attempts' => 1,
'last_attempt_at' => $now,
'last_attempt_at' => $nowStr,
]);
}
@@ -0,0 +1,172 @@
<?php
namespace App\Services\Billing;
use App\Models\DiscountUsage;
use App\Models\Payment;
use App\Models\Refund;
use App\Services\Fees\FeeConfigService;
use App\Services\Fees\TuitionCalculationService;
use Illuminate\Support\Facades\Log;
/**
* BalanceCalculationService (plan §6, §13 Phase 2)
*
* Combines tuition, extra charges, and event charges, then nets payments,
* credits, and refunds to produce family- and student-level balances.
*/
class BalanceCalculationService
{
public function __construct(
private FeeConfigService $config,
private TuitionCalculationService $tuition,
private BillingTotalsService $billingTotals
) {
}
/**
* @param array<int, array<string, mixed>> $students
*
* @return array<string, mixed>
*/
public function calculateFamilyAccount(array $students, int $parentId, ?string $schoolYear = null): array
{
$schoolYear = $schoolYear ?? $this->config->getSchoolYear();
$tuitionBreakdown = $this->tuition->calculate($students);
$tuitionTotal = (float) ($tuitionBreakdown['family_total'] ?? 0);
$extraCharges = $this->billingTotals->additionalSubtotalByParent($parentId, $schoolYear);
$eventByStudent = $this->billingTotals->eventSubtotalsByStudent($parentId, $schoolYear);
$eventCharges = round(array_sum($eventByStudent), 2);
$lateFees = 0.0;
$totalCharges = round($tuitionTotal + $extraCharges + $eventCharges + $lateFees, 2);
$totalPayments = round(Payment::getTotalPaidByParentId($parentId, $schoolYear), 2);
$totalCredits = round(
DiscountUsage::getTotalDiscountByParentIdAndSchoolYear($parentId, $schoolYear),
2
);
$refundsApplied = round(
Refund::totalApprovedPaidOutForParentAndYear($parentId, $schoolYear),
2
);
$netting = round($totalPayments + $totalCredits + $refundsApplied, 2);
$rawRemaining = round($totalCharges - $netting, 2);
$remainingBalance = max(0.0, $rawRemaining);
$accountCredit = $rawRemaining < 0 ? round(abs($rawRemaining), 2) : 0.0;
$studentRows = $this->buildStudentBalances(
$tuitionBreakdown['students'] ?? [],
$eventByStudent,
$totalCharges,
$totalPayments,
$totalCredits,
$refundsApplied
);
Log::info('Family account balance calculated', [
'parent_id' => $parentId,
'school_year' => $schoolYear,
'total_charges' => $totalCharges,
'remaining_balance' => $remainingBalance,
'account_credit' => $accountCredit,
]);
return [
'parent_id' => $parentId,
'school_year' => $schoolYear,
'charges' => [
'tuition' => $tuitionTotal,
'extra_charges' => $extraCharges,
'event_charges' => $eventCharges,
'late_fees' => $lateFees,
'total' => $totalCharges,
],
'credits' => [
'discounts' => $totalCredits,
'total' => $totalCredits,
],
'payments' => $totalPayments,
'refunds_applied' => $refundsApplied,
'remaining_balance' => $remainingBalance,
'account_credit' => $accountCredit,
'tuition' => $tuitionBreakdown,
'students' => $studentRows,
];
}
/**
* @param array<int, array<string, mixed>> $tuitionStudents
* @param array<int, float> $eventByStudent
*
* @return array<int, array<string, mixed>>
*/
private function buildStudentBalances(
array $tuitionStudents,
array $eventByStudent,
float $familyTotalCharges,
float $totalPayments,
float $totalCredits,
float $refundsApplied
): array {
$familyNetting = $totalPayments + $totalCredits + $refundsApplied;
$rows = [];
foreach ($tuitionStudents as $student) {
$studentId = (int) ($student['student_id'] ?? 0);
$tuitionAmount = (float) ($student['final_tuition_amount'] ?? $student['tuition_fee'] ?? 0);
$eventAmount = (float) ($eventByStudent[$studentId] ?? 0);
$extraAmount = 0.0;
$studentTotalCharges = round($tuitionAmount + $eventAmount + $extraAmount, 2);
$share = $familyTotalCharges > 0
? ($studentTotalCharges / $familyTotalCharges)
: 0.0;
$paymentsApplied = round($totalPayments * $share, 2);
$creditsApplied = round($totalCredits * $share, 2);
$refundsAppliedShare = round($refundsApplied * $share, 2);
$allocatedNetting = round($familyNetting * $share, 2);
$rawRemaining = round($studentTotalCharges - $allocatedNetting, 2);
$rows[] = [
'student_id' => $studentId > 0 ? $studentId : ($student['student_id'] ?? null),
'student_name' => $student['student_name'] ?? null,
'grade' => $student['grade'] ?? null,
'enrollment_status' => $student['enrollment_status'] ?? null,
'tuition' => round($tuitionAmount, 2),
'extra_charges' => $extraAmount,
'event_charges' => $eventAmount,
'total_charges' => $studentTotalCharges,
'payments_applied' => $paymentsApplied,
'credits_applied' => $creditsApplied,
'refunds_applied' => $refundsAppliedShare,
'remaining_balance' => max(0.0, $rawRemaining),
'account_credit' => $rawRemaining < 0 ? round(abs($rawRemaining), 2) : 0.0,
];
}
$unassignedEvent = (float) ($eventByStudent[0] ?? 0);
if ($unassignedEvent > 0) {
$rows[] = [
'student_id' => null,
'student_name' => null,
'grade' => null,
'enrollment_status' => null,
'tuition' => 0.0,
'extra_charges' => 0.0,
'event_charges' => $unassignedEvent,
'total_charges' => $unassignedEvent,
'payments_applied' => 0.0,
'credits_applied' => 0.0,
'refunds_applied' => 0.0,
'remaining_balance' => $unassignedEvent,
'account_credit' => 0.0,
];
}
return $rows;
}
}
+80 -12
View File
@@ -10,41 +10,109 @@ class BillingTotalsService
{
public function eventSubtotal(int $parentId, string $schoolYear): float
{
$eventSubtotal = 0.0;
return round(array_sum($this->eventSubtotalsByStudent($parentId, $schoolYear)), 2);
}
/**
* @return array<int, float> student_id => amount
*/
public function eventSubtotalsByStudent(int $parentId, string $schoolYear): array
{
$byStudent = [];
try {
$events = EventCharges::getChargesWithEventInfo($parentId, $schoolYear) ?? [];
foreach ($events as $event) {
$eventSubtotal += (float) ($event['charged'] ?? 0.0);
$amount = (float) ($event['charged'] ?? 0);
if ($amount <= 0) {
continue;
}
$studentId = (int) ($event['student_id'] ?? 0);
if ($studentId > 0) {
$byStudent[$studentId] = ($byStudent[$studentId] ?? 0.0) + $amount;
} else {
$byStudent[0] = ($byStudent[0] ?? 0.0) + $amount;
}
}
} catch (\Throwable $e) {
Log::warning('Failed to sum event charges.', ['error' => $e->getMessage()]);
}
return round($eventSubtotal, 2);
foreach ($byStudent as $studentId => $amount) {
$byStudent[$studentId] = round($amount, 2);
}
return $byStudent;
}
public function additionalSubtotal(int $invoiceId, ?string $schoolYear = null): float
{
$additionalSubtotal = 0.0;
return $this->sumAdditionalChargeRows(
$this->fetchAdditionalChargeRows(invoiceId: $invoiceId, schoolYear: $schoolYear)
);
}
public function additionalSubtotalByParent(int $parentId, string $schoolYear): float
{
return $this->sumAdditionalChargeRows(
$this->fetchAdditionalChargeRows(parentId: $parentId, schoolYear: $schoolYear)
);
}
/**
* @return array<int, array{charge_type: string, amount: float}>
*/
private function fetchAdditionalChargeRows(
?int $parentId = null,
?int $invoiceId = null,
?string $schoolYear = null
): array {
$rows = [];
try {
$builder = AdditionalCharge::query()
->select('charge_type', 'amount')
->where('invoice_id', $invoiceId)
->where('status', 'applied');
if ($parentId !== null) {
$builder->where('parent_id', $parentId);
}
if ($invoiceId !== null) {
$builder->where('invoice_id', $invoiceId);
}
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()]);
Log::warning('Failed to load additional charges.', ['error' => $e->getMessage()]);
}
return $rows;
}
/**
* @param array<int, array{charge_type?: string, amount?: float|int|string}> $rows
*/
private function sumAdditionalChargeRows(array $rows): float
{
$additionalSubtotal = 0.0;
foreach ($rows as $row) {
$amount = (float) ($row['amount'] ?? 0);
$type = strtolower((string) ($row['charge_type'] ?? 'add'));
if (in_array($type, ['deduct', 'previous'], true)) {
$amount = -abs($amount);
} else {
$amount = abs($amount);
}
$additionalSubtotal += $amount;
}
return round($additionalSubtotal, 2);
+456
View File
@@ -0,0 +1,456 @@
<?php
namespace App\Services\Billing;
use App\Models\AdditionalCharge;
use App\Models\Event;
use App\Models\EventCharges;
use App\Services\ExtraCharges\ExtraChargesChargeService;
use App\Services\ExtraCharges\ExtraChargesContextService;
use App\Services\ExtraCharges\ExtraChargesInvoiceService;
use App\Services\Invoices\InvoiceGenerationService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
/**
* ChargeService (plan §910, §13 Phase 3)
*
* Unified entry point for extra and event charges: create, cancel, apply,
* mark paid/unpaid, list by family, and prevent duplicate charges.
*/
class ChargeService
{
public function __construct(
private ExtraChargesChargeService $extraCharges,
private ExtraChargesInvoiceService $invoices,
private ExtraChargesContextService $context,
private InvoiceGenerationService $invoiceGeneration
) {
}
public function createExtraCharge(array $payload): array
{
$parentId = (int) ($payload['parent_id'] ?? 0);
$schoolYear = (string) ($payload['school_year'] ?? $this->context->getSchoolYear());
$title = trim((string) ($payload['title'] ?? ''));
$chargeType = (string) ($payload['charge_type'] ?? 'add');
$amountAbs = round(abs((float) ($payload['amount'] ?? 0)), 2);
if ($parentId <= 0 || $title === '' || $amountAbs <= 0) {
return ['ok' => false, 'message' => 'Invalid extra charge payload.'];
}
if ($this->findDuplicateExtraCharge($parentId, $schoolYear, $title, $amountAbs, $chargeType)) {
Log::warning('Duplicate extra charge blocked', [
'parent_id' => $parentId,
'title' => $title,
'school_year' => $schoolYear,
]);
return [
'ok' => false,
'message' => 'A matching extra charge already exists for this parent.',
'error' => 'duplicate_charge',
];
}
$result = $this->extraCharges->createCharge(array_merge($payload, [
'school_year' => $schoolYear,
'semester' => $payload['semester'] ?? $this->context->getSemester(),
]));
if (!($result['ok'] ?? false)) {
return $result;
}
return [
'ok' => true,
'charge_type' => 'extra',
'id' => (int) ($result['id'] ?? 0),
'parent_id' => $parentId,
'invoice_id' => $result['invoice_id'] ?? null,
'status' => !empty($payload['invoice_id']) ? 'applied' : 'pending',
];
}
public function cancelExtraCharge(int $chargeId): array
{
$charge = AdditionalCharge::query()->find($chargeId);
if (!$charge) {
return ['ok' => false, 'message' => 'Extra charge not found.'];
}
if ((string) $charge->status === 'void') {
return ['ok' => false, 'message' => 'Charge is already void.', 'error' => 'already_canceled'];
}
$ok = $this->extraCharges->voidCharge($charge);
return [
'ok' => $ok,
'charge_type' => 'extra',
'id' => $chargeId,
'status' => $ok ? 'void' : (string) $charge->status,
'message' => $ok ? null : 'Unable to void extra charge.',
];
}
public function applyExtraCharge(int $chargeId, int $invoiceId): array
{
$charge = AdditionalCharge::query()->find($chargeId);
if (!$charge) {
return ['ok' => false, 'message' => 'Extra charge not found.'];
}
if ((string) $charge->status === 'void') {
return ['ok' => false, 'message' => 'Cannot apply a void charge.'];
}
if ((string) $charge->status === 'applied' && (int) $charge->invoice_id === $invoiceId) {
return [
'ok' => true,
'charge_type' => 'extra',
'id' => $chargeId,
'status' => 'applied',
'invoice_id' => $invoiceId,
];
}
if ((string) $charge->status === 'applied') {
return ['ok' => false, 'message' => 'Charge is already applied to another invoice.'];
}
$chargeType = (string) ($charge->charge_type ?? 'add');
$amountAbs = round(abs((float) $charge->amount), 2);
DB::beginTransaction();
try {
$this->invoices->applyToInvoice($invoiceId, $chargeType, $amountAbs);
$charge->invoice_id = $invoiceId;
$charge->status = 'applied';
$charge->save();
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
Log::error('Apply extra charge failed', ['charge_id' => $chargeId, 'error' => $e->getMessage()]);
return ['ok' => false, 'message' => 'Unable to apply charge to invoice.'];
}
return [
'ok' => true,
'charge_type' => 'extra',
'id' => $chargeId,
'status' => 'applied',
'invoice_id' => $invoiceId,
];
}
public function createEventCharge(array $payload): array
{
$parentId = (int) ($payload['parent_id'] ?? 0);
$studentId = (int) ($payload['student_id'] ?? 0);
$schoolYear = (string) ($payload['school_year'] ?? $this->context->getSchoolYear());
$semester = (string) ($payload['semester'] ?? $this->context->getSemester());
$eventId = isset($payload['event_id']) ? (int) $payload['event_id'] : null;
$eventName = trim((string) ($payload['event_name'] ?? ''));
$actorId = (int) ($payload['created_by'] ?? $payload['updated_by'] ?? 0);
if ($parentId <= 0 || $studentId <= 0) {
return ['ok' => false, 'message' => 'Parent and student are required.'];
}
$amount = isset($payload['amount']) ? (float) $payload['amount'] : null;
if ($eventId > 0) {
$event = Event::getEvent($eventId, $schoolYear);
if (!$event) {
return ['ok' => false, 'message' => 'Event not found.'];
}
$eventName = (string) ($event->event_name ?? $eventName);
$amount = $amount ?? (float) ($event->amount ?? 0);
}
if ($eventName === '' || $amount === null || $amount < 0) {
return ['ok' => false, 'message' => 'Event name and amount are required.'];
}
if ($this->findDuplicateEventCharge($parentId, $studentId, $schoolYear, $semester, $eventId, $eventName)) {
Log::warning('Duplicate event charge blocked', [
'parent_id' => $parentId,
'student_id' => $studentId,
'event_id' => $eventId,
'event_name' => $eventName,
]);
return [
'ok' => false,
'message' => 'This student already has an active charge for this event.',
'error' => 'duplicate_charge',
];
}
$charge = EventCharges::query()->create([
'event_id' => $eventId > 0 ? $eventId : null,
'event_name' => $eventId > 0 ? null : $eventName,
'parent_id' => $parentId,
'student_id' => $studentId,
'participation' => 'yes',
'charged' => round($amount, 2),
'amount' => round($amount, 2),
'school_year' => $schoolYear,
'semester' => $semester,
'created_by' => $actorId ?: null,
'updated_by' => $actorId ?: null,
]);
$this->invoiceGeneration->generateInvoice($parentId, $schoolYear);
return [
'ok' => true,
'charge_type' => 'event',
'id' => (int) $charge->id,
'parent_id' => $parentId,
'student_id' => $studentId,
'event_id' => $eventId > 0 ? $eventId : null,
'event_name' => $eventName,
'amount' => round($amount, 2),
'participation_status' => 'yes',
'payment_status' => 'unpaid',
'cancellation_status' => 'active',
];
}
public function cancelEventCharge(int $chargeId, bool $issueCredit = true): array
{
$charge = EventCharges::query()->find($chargeId);
if (!$charge) {
return ['ok' => false, 'message' => 'Event charge not found.'];
}
if ($this->isEventChargeCanceled($charge)) {
return ['ok' => false, 'message' => 'Event charge is already canceled.', 'error' => 'already_canceled'];
}
$amount = round(abs((float) ($charge->charged ?? $charge->amount ?? 0)), 2);
$eventName = $this->resolveEventName($charge);
$parentId = (int) $charge->parent_id;
$schoolYear = (string) $charge->school_year;
DB::beginTransaction();
try {
$charge->participation = 'no';
$charge->charged = 0;
if (Schema::hasColumn('event_charges', 'amount')) {
$charge->amount = 0;
}
$charge->save();
$creditId = null;
if ($issueCredit && $amount > 0 && $parentId > 0) {
$credit = $this->createExtraCharge([
'parent_id' => $parentId,
'title' => 'Event credit: ' . $eventName,
'description' => 'Account credit for canceled event charge #' . $chargeId,
'amount' => $amount,
'charge_type' => 'deduct',
'school_year' => $schoolYear,
'semester' => (string) ($charge->semester ?? $this->context->getSemester()),
'created_by' => (int) ($charge->updated_by ?? 0),
]);
if ($credit['ok'] ?? false) {
$creditId = (int) ($credit['id'] ?? 0);
}
}
$this->invoiceGeneration->generateInvoice($parentId, $schoolYear);
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
Log::error('Cancel event charge failed', ['charge_id' => $chargeId, 'error' => $e->getMessage()]);
return ['ok' => false, 'message' => 'Unable to cancel event charge.'];
}
return [
'ok' => true,
'charge_type' => 'event',
'id' => $chargeId,
'cancellation_status' => 'canceled',
'participation_status' => 'no',
'account_credit_charge_id' => $creditId,
];
}
public function markEventChargePaid(int $chargeId, bool $paid, ?int $paymentId = null): array
{
$charge = EventCharges::query()->find($chargeId);
if (!$charge) {
return ['ok' => false, 'message' => 'Event charge not found.'];
}
if ($this->isEventChargeCanceled($charge)) {
return ['ok' => false, 'message' => 'Cannot update payment on a canceled charge.'];
}
if (Schema::hasColumn('event_charges', 'event_paid')) {
$charge->event_paid = $paid ? 1 : 0;
}
if (Schema::hasColumn('event_charges', 'event_payment_id') && $paymentId !== null) {
$charge->event_payment_id = $paymentId;
}
$charge->save();
return [
'ok' => true,
'charge_type' => 'event',
'id' => $chargeId,
'payment_status' => $paid ? 'paid' : 'unpaid',
];
}
/**
* @return array{extra_charges: array<int, array<string, mixed>>, event_charges: array<int, array<string, mixed>>}
*/
public function listForParent(int $parentId, ?string $schoolYear = null): array
{
$schoolYear = $schoolYear ?? $this->context->getSchoolYear();
$extraRows = AdditionalCharge::query()
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->orderByDesc('id')
->get();
$eventRows = EventCharges::getChargesWithEventInfo($parentId, $schoolYear);
return [
'parent_id' => $parentId,
'school_year' => $schoolYear,
'extra_charges' => $extraRows->map(fn (AdditionalCharge $row) => $this->formatExtraCharge($row))->all(),
'event_charges' => array_map(fn (array $row) => $this->formatEventCharge($row), $eventRows),
];
}
private function findDuplicateExtraCharge(
int $parentId,
string $schoolYear,
string $title,
float $amountAbs,
string $chargeType
): bool {
$normalizedTitle = strtolower($title);
$signedAmount = $chargeType === 'deduct' ? -$amountAbs : $amountAbs;
return AdditionalCharge::query()
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->whereIn('status', ['pending', 'applied'])
->whereRaw('LOWER(title) = ?', [$normalizedTitle])
->where('amount', $signedAmount)
->exists();
}
private function findDuplicateEventCharge(
int $parentId,
int $studentId,
string $schoolYear,
string $semester,
?int $eventId,
string $eventName
): bool {
$query = EventCharges::query()
->where('parent_id', $parentId)
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->where('participation', 'yes')
->where('charged', '>', 0);
if ($eventId > 0) {
$query->where('event_id', $eventId);
} else {
$query->where('event_name', $eventName);
}
return $query->exists();
}
private function isEventChargeCanceled(EventCharges $charge): bool
{
if (strtolower((string) $charge->participation) === 'no') {
return true;
}
return (float) ($charge->charged ?? 0) <= 0;
}
private function resolveEventName(EventCharges $charge): string
{
if (!empty($charge->event_name)) {
return (string) $charge->event_name;
}
if ($charge->event_id) {
$event = Event::query()->find($charge->event_id);
if ($event) {
return (string) $event->event_name;
}
}
return 'Event';
}
private function formatExtraCharge(AdditionalCharge $row): array
{
return [
'id' => (int) $row->id,
'charge_type' => 'extra',
'parent_id' => (int) $row->parent_id,
'invoice_id' => $row->invoice_id ? (int) $row->invoice_id : null,
'school_year' => $row->school_year,
'semester' => $row->semester,
'charge_name' => $row->title,
'charge_description' => $row->description,
'amount' => round(abs((float) $row->amount), 2),
'signed_amount' => round((float) $row->amount, 2),
'fee_type' => $row->charge_type,
'due_date' => $row->due_date,
'status' => $row->status,
'approval_status' => $row->status,
'created_by' => $row->created_by,
'created_at' => $row->created_at,
];
}
/**
* @param array<string, mixed> $row
*/
private function formatEventCharge(array $row): array
{
$charged = (float) ($row['charged'] ?? 0);
$canceled = strtolower((string) ($row['participation'] ?? '')) === 'no' || $charged <= 0;
$paid = false;
if (isset($row['event_paid'])) {
$paid = (bool) $row['event_paid'];
}
return [
'id' => (int) ($row['id'] ?? 0),
'charge_type' => 'event',
'event_id' => isset($row['event_id']) ? (int) $row['event_id'] : null,
'event_name' => $row['event_name'] ?? null,
'parent_id' => isset($row['parent_id']) ? (int) $row['parent_id'] : null,
'student_id' => isset($row['student_id']) ? (int) $row['student_id'] : null,
'school_year' => $row['school_year'] ?? null,
'semester' => $row['semester'] ?? null,
'participation_status' => $row['participation'] ?? null,
'charge_amount' => $charged,
'payment_status' => $paid ? 'paid' : ($canceled ? 'canceled' : 'unpaid'),
'cancellation_status' => $canceled ? 'canceled' : 'active',
'event_date' => $row['updated_at'] ?? $row['created_at'] ?? null,
];
}
}
+160 -42
View File
@@ -22,82 +22,198 @@ class FeeRefundCalculatorService
$totalPaid = Payment::getTotalPaidByParentId($parentId, $schoolYear);
if ($totalPaid <= 0) {
return $this->resultPayload(0.0, $totalPaid, $refundDeadline, $schoolEndDate, $weeksOfStudy, 0);
Log::info('Refund skipped: parent has no payments', [
'parent_id' => $parentId,
'school_year' => $schoolYear,
]);
return $this->resultPayload(0.0, $totalPaid, $refundDeadline, $schoolEndDate, $weeksOfStudy, 0, []);
}
$registered = [];
$withdrawn = [];
foreach ($students as $student) {
$status = strtolower((string) ($student['enrollment_status'] ?? ''));
$admission = strtolower((string) ($student['admission_status'] ?? ''));
if (in_array($status, ['withdrawn', 'refund pending', 'withdraw under review'], true)) {
$withdrawn[] = $student;
continue;
}
if (in_array($status, ['enrolled', 'payment pending'], true) && $admission === 'accepted') {
$registered[] = $student;
}
}
[$registered, $withdrawn] = $this->partitionStudents($students);
if (empty($withdrawn)) {
return $this->resultPayload(0.0, $totalPaid, $refundDeadline, $schoolEndDate, $weeksOfStudy, 0);
Log::info('Refund skipped: no withdrawn students found', [
'parent_id' => $parentId,
'registered_count' => count($registered),
]);
return $this->resultPayload(0.0, $totalPaid, $refundDeadline, $schoolEndDate, $weeksOfStudy, 0, []);
}
$allStudents = array_merge($registered, $withdrawn);
$allStudents = $this->studentFees->assignFees($allStudents);
$feeByStudentId = [];
foreach ($allStudents as $student) {
$key = $student['student_id'] ?? null;
if ($key === null) {
continue;
}
$feeByStudentId[(string) $key] = (float) ($student['tuition_fee'] ?? 0);
// Per the school billing plan (section 8), each student must carry the
// tuition fee that was assigned to them during fee calculation BEFORE
// the refund loop runs. We mark withdrawn vs registered before merging
// so the fee tier (first/second regular) is computed against the full
// family and we can still iterate only the withdrawn list afterwards.
foreach ($registered as &$student) {
$student['_is_withdrawn'] = false;
}
unset($student);
foreach ($withdrawn as &$student) {
$student['_is_withdrawn'] = true;
}
unset($student);
$allStudents = $this->studentFees->assignFees(array_merge($registered, $withdrawn));
$withdrawnWithFees = array_values(array_filter(
$allStudents,
fn (array $student): bool => !empty($student['_is_withdrawn'])
));
$refundAmount = 0.0;
$withdrawCount = 0;
$studentBreakdown = [];
foreach ($withdrawn as $student) {
foreach ($withdrawnWithFees as $student) {
$studentId = $student['student_id'] ?? null;
$studentFee = (float) ($student['tuition_fee'] ?? 0);
$withdrawalDate = $student['withdrawal_date'] ?? null;
$entry = [
'student_id' => $studentId,
'grade' => $student['grade'] ?? null,
'grade_level' => $student['grade_level'] ?? null,
'fee_category' => $student['fee_category'] ?? null,
'tuition_fee' => round($studentFee, 2),
'withdrawal_date' => $withdrawalDate,
'weeks_remaining' => 0,
'refund_amount' => 0.0,
'eligible' => false,
'reason' => null,
];
if (!$withdrawalDate) {
Log::warning('Missing withdraw date for student', ['student_id' => $student['student_id'] ?? null]);
$entry['reason'] = 'missing_withdrawal_date';
Log::warning('Refund skipped for student: missing withdrawal date', [
'parent_id' => $parentId,
'student_id' => $studentId,
]);
$studentBreakdown[] = $entry;
continue;
}
if (!$refundDeadline || strtotime($withdrawalDate) > strtotime($refundDeadline)) {
$entry['reason'] = 'after_refund_deadline';
$studentBreakdown[] = $entry;
continue;
}
if (!$schoolEndDate) {
$entry['reason'] = 'missing_school_end_date';
Log::warning('Refund skipped for student: missing school end date', [
'parent_id' => $parentId,
'student_id' => $studentId,
]);
$studentBreakdown[] = $entry;
continue;
}
$withdrawDateObj = new \DateTime(date('Y-m-d', strtotime($withdrawalDate)));
$schoolEndDateObj = new \DateTime($schoolEndDate);
$daysRemaining = $withdrawDateObj->diff($schoolEndDateObj)->days;
$weeksRemaining = min($weeksOfStudy, max(0, (int) ceil($daysRemaining / 7)));
$studentId = (string) ($student['student_id'] ?? '');
$studentFee = $feeByStudentId[$studentId] ?? 0.0;
if ($studentFee <= 0 || $weeksOfStudy <= 0) {
$entry['reason'] = 'zero_fee_or_weeks';
Log::warning('Refund skipped for student: zero fee or weeks_of_study', [
'parent_id' => $parentId,
'student_id' => $studentId,
'student_fee' => $studentFee,
'weeks_of_study' => $weeksOfStudy,
]);
$studentBreakdown[] = $entry;
continue;
}
$weeksRemaining = $this->weeksRemaining($withdrawalDate, $schoolEndDate, $weeksOfStudy);
// Plan section 7: Refund Amount = Student Tuition Fee / Weeks of Study * Weeks Remaining
$proportionalRefund = ($studentFee / $weeksOfStudy) * $weeksRemaining;
$refundAmount += $proportionalRefund;
$withdrawCount++;
$proportionalRefund = round(max(0.0, $proportionalRefund), 2);
$entry['weeks_remaining'] = $weeksRemaining;
$entry['refund_amount'] = $proportionalRefund;
$entry['eligible'] = $proportionalRefund > 0;
if ($entry['eligible']) {
$withdrawCount++;
$refundAmount += $proportionalRefund;
} else {
$entry['reason'] = 'no_weeks_remaining';
}
$studentBreakdown[] = $entry;
}
$uncappedRefund = $refundAmount;
if ($refundAmount > $totalPaid) {
Log::info('Refund capped at total paid', [
'parent_id' => $parentId,
'uncapped' => round($uncappedRefund, 2),
'capped' => round($totalPaid, 2),
]);
$refundAmount = $totalPaid;
// Scale per-student refunds proportionally so the breakdown
// adds up to the capped family total.
if ($uncappedRefund > 0) {
$ratio = $totalPaid / $uncappedRefund;
foreach ($studentBreakdown as &$row) {
if (!empty($row['eligible'])) {
$row['refund_amount'] = round($row['refund_amount'] * $ratio, 2);
}
}
unset($row);
}
}
return $this->resultPayload($refundAmount, $totalPaid, $refundDeadline, $schoolEndDate, $weeksOfStudy, $withdrawCount);
Log::info('Refund calculation complete', [
'parent_id' => $parentId,
'school_year' => $schoolYear,
'withdrawn_total' => count($withdrawnWithFees),
'withdrawn_eligible' => $withdrawCount,
'refund_amount' => round($refundAmount, 2),
'total_paid' => round($totalPaid, 2),
]);
return $this->resultPayload(
$refundAmount,
$totalPaid,
$refundDeadline,
$schoolEndDate,
$weeksOfStudy,
$withdrawCount,
$studentBreakdown
);
}
/**
* @return array{0: array<int, array<string, mixed>>, 1: array<int, array<string, mixed>>}
*/
private function partitionStudents(array $students): array
{
$registered = [];
$withdrawn = [];
foreach ($students as $student) {
if ($this->studentFees->isWithdrawn($student)) {
$withdrawn[] = $student;
continue;
}
if ($this->studentFees->isBillable($student)) {
$registered[] = $student;
}
}
return [$registered, $withdrawn];
}
private function weeksRemaining(string $withdrawalDate, string $schoolEndDate, float $weeksOfStudy): int
{
$withdrawDateObj = new \DateTime(date('Y-m-d', strtotime($withdrawalDate)));
$schoolEndDateObj = new \DateTime($schoolEndDate);
if ($withdrawDateObj > $schoolEndDateObj) {
return 0;
}
$daysRemaining = $withdrawDateObj->diff($schoolEndDateObj)->days;
return (int) min($weeksOfStudy, max(0, (int) ceil($daysRemaining / 7)));
}
private function resultPayload(
@@ -106,7 +222,8 @@ class FeeRefundCalculatorService
?string $refundDeadline,
?string $schoolEndDate,
float $weeksOfStudy,
int $withdrawCount
int $withdrawCount,
array $students
): array {
return [
'refund_amount' => round($refund, 2),
@@ -115,6 +232,7 @@ class FeeRefundCalculatorService
'school_end_date' => $schoolEndDate,
'weeks_of_study' => $weeksOfStudy,
'withdrawn_students' => $withdrawCount,
'students' => $students,
];
}
}
+157 -11
View File
@@ -3,29 +3,46 @@
namespace App\Services\Fees;
use App\Models\ClassSection;
use Illuminate\Support\Facades\Log;
class FeeStudentFeeService
{
public const CATEGORY_FIRST_REGULAR = 'first_regular';
public const CATEGORY_ADDITIONAL_REGULAR = 'additional_regular';
public const CATEGORY_YOUTH = 'youth';
public const CATEGORY_NOT_BILLABLE = 'not_billable';
private const BILLABLE_ENROLLMENT_STATUSES = ['enrolled', 'payment pending'];
private const WITHDRAWN_ENROLLMENT_STATUSES = ['withdrawn', 'refund pending', 'withdraw under review'];
private const ACCEPTED_ADMISSION_STATUS = 'accepted';
public function __construct(
private FeeGradeService $grades,
private FeeConfigService $config
) {
}
/**
* Normalize grades, sort by grade level, and assign a tuition fee to each
* student so that downstream callers (refund, balance, invoice) can read
* the stored fee directly from the student record.
*
* Each student gets the following keys mutated/added:
* - grade (normalized)
* - grade_level
* - fee_category (first_regular | additional_regular | youth)
* - tuition_fee
*/
public function assignFees(array $students): array
{
$fees = $this->config->getFees();
$firstFee = $fees['first_student_fee'];
$secondFee = $fees['second_student_fee'];
$youthFee = $fees['youth_fee'];
$firstFee = (float) $fees['first_student_fee'];
$secondFee = (float) $fees['second_student_fee'];
$youthFee = (float) $fees['youth_fee'];
foreach ($students as &$student) {
$grade = $student['grade'] ?? null;
if ($grade === null || trim((string) $grade) === '') {
$sectionId = $student['class_section_id'] ?? null;
$grade = $sectionId ? ClassSection::getClassSectionNameBySectionId($sectionId) : null;
}
$student['grade'] = $this->grades->normalizeGrade($grade);
$student['grade'] = $this->resolveGrade($student);
$student['grade_level'] = $this->grades->getGradeLevel($student['grade']);
}
unset($student);
@@ -35,11 +52,18 @@ class FeeStudentFeeService
$regularCount = 0;
foreach ($students as &$student) {
$gradeLevel = $this->grades->getGradeLevel($student['grade'] ?? '');
$gradeLevel = (int) ($student['grade_level'] ?? 999);
if ($gradeLevel > 9) {
$student['fee_category'] = self::CATEGORY_YOUTH;
$student['tuition_fee'] = $youthFee;
} else {
$student['tuition_fee'] = ($regularCount === 0) ? $firstFee : $secondFee;
if ($regularCount === 0) {
$student['fee_category'] = self::CATEGORY_FIRST_REGULAR;
$student['tuition_fee'] = $firstFee;
} else {
$student['fee_category'] = self::CATEGORY_ADDITIONAL_REGULAR;
$student['tuition_fee'] = $secondFee;
}
$regularCount++;
}
}
@@ -59,4 +83,126 @@ class FeeStudentFeeService
return $total;
}
/**
* Return a detailed student-level + family-level tuition breakdown.
*
* Only students that are accepted and either enrolled or payment-pending
* are billed. Withdrawn students appear in the breakdown with a zero
* tuition fee and the not_billable category so callers (refund service,
* invoice generation) can still see them in one consistent payload.
*
* @return array{
* family_total: float,
* billable_count: int,
* non_billable_count: int,
* students: array<int, array<string, mixed>>,
* fees: array<string, float>
* }
*/
public function calculateBreakdown(array $students): array
{
$fees = $this->config->getFees();
$billable = [];
$nonBillable = [];
foreach ($students as $index => $student) {
$student['_original_index'] = $index;
$student['grade'] = $this->resolveGrade($student);
$student['grade_level'] = $this->grades->getGradeLevel($student['grade']);
$student['enrollment_status'] = isset($student['enrollment_status'])
? strtolower((string) $student['enrollment_status'])
: null;
$student['admission_status'] = isset($student['admission_status'])
? strtolower((string) $student['admission_status'])
: null;
if ($this->isBillable($student)) {
$billable[] = $student;
} else {
$nonBillable[] = $student;
}
}
$billable = $this->assignFees($billable);
$nonBillable = array_map(function (array $student): array {
$student['fee_category'] = self::CATEGORY_NOT_BILLABLE;
$student['tuition_fee'] = 0.0;
return $student;
}, $nonBillable);
$combined = array_merge($billable, $nonBillable);
usort($combined, fn ($a, $b) => ($a['_original_index'] ?? 0) <=> ($b['_original_index'] ?? 0));
$familyTotal = 0.0;
$studentRows = [];
foreach ($combined as $student) {
unset($student['_original_index']);
$tuitionFee = (float) ($student['tuition_fee'] ?? 0);
$discount = (float) ($student['discount_amount'] ?? 0);
$finalTuition = max(0.0, $tuitionFee - $discount);
$familyTotal += $finalTuition;
$studentRows[] = [
'student_id' => $student['student_id'] ?? null,
'student_name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
'grade' => $student['grade'] ?? null,
'grade_level' => $student['grade_level'] ?? null,
'enrollment_status' => $student['enrollment_status'] ?? null,
'admission_status' => $student['admission_status'] ?? null,
'fee_category' => $student['fee_category'] ?? null,
'tuition_fee' => round($tuitionFee, 2),
'discount_amount' => round($discount, 2),
'final_tuition_amount' => round($finalTuition, 2),
];
}
Log::info('Tuition breakdown calculated', [
'total_students' => count($studentRows),
'billable' => count($billable),
'non_billable' => count($nonBillable),
'family_total' => round($familyTotal, 2),
]);
return [
'family_total' => round($familyTotal, 2),
'billable_count' => count($billable),
'non_billable_count' => count($nonBillable),
'students' => $studentRows,
'fees' => [
'first_student_fee' => (float) $fees['first_student_fee'],
'second_student_fee' => (float) $fees['second_student_fee'],
'youth_fee' => (float) $fees['youth_fee'],
],
];
}
public function isBillable(array $student): bool
{
$status = strtolower((string) ($student['enrollment_status'] ?? ''));
$admission = strtolower((string) ($student['admission_status'] ?? ''));
return in_array($status, self::BILLABLE_ENROLLMENT_STATUSES, true)
&& $admission === self::ACCEPTED_ADMISSION_STATUS;
}
public function isWithdrawn(array $student): bool
{
$status = strtolower((string) ($student['enrollment_status'] ?? ''));
return in_array($status, self::WITHDRAWN_ENROLLMENT_STATUSES, true);
}
private function resolveGrade(array $student): string
{
$grade = $student['grade'] ?? null;
if ($grade === null || trim((string) $grade) === '') {
$sectionId = $student['class_section_id'] ?? null;
$grade = $sectionId ? ClassSection::getClassSectionNameBySectionId($sectionId) : null;
}
return $this->grades->normalizeGrade($grade);
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Services\Fees;
use Illuminate\Support\Facades\Log;
/**
* TuitionCalculationService
*
* Responsible for producing a tuition calculation that answers the plan's
* key questions for a family:
* - What was each student charged?
* - Why was each student charged that amount? (fee_category)
* - What is the family total?
*
* This service is a thin orchestrator built on top of FeeStudentFeeService
* and FeeConfigService. It is the place where new business rules (discounts,
* required fees, balance integration) should be wired in for parent-facing
* tuition views without bloating the lower-level fee assignment service.
*/
class TuitionCalculationService
{
public function __construct(
private FeeConfigService $config,
private FeeStudentFeeService $studentFees
) {
}
/**
* Produce a family + per-student tuition calculation.
*
* @param array $students Each item may include: student_id, firstname,
* lastname, grade, class_section_id, enrollment_status,
* admission_status, discount_amount.
*
* @return array{
* school_year: string,
* fees: array<string, float>,
* family_total: float,
* billable_count: int,
* non_billable_count: int,
* students: array<int, array<string, mixed>>
* }
*/
public function calculate(array $students): array
{
$schoolYear = $this->config->getSchoolYear();
$breakdown = $this->studentFees->calculateBreakdown($students);
Log::info('Tuition calculation requested', [
'school_year' => $schoolYear,
'student_count' => count($students),
'family_total' => $breakdown['family_total'],
]);
return [
'school_year' => $schoolYear,
'fees' => $breakdown['fees'],
'family_total' => $breakdown['family_total'],
'billable_count' => $breakdown['billable_count'],
'non_billable_count' => $breakdown['non_billable_count'],
'students' => $breakdown['students'],
];
}
/**
* Convenience helper: the family-level tuition total only.
*/
public function familyTotal(array $students): float
{
return $this->calculate($students)['family_total'];
}
}
@@ -27,6 +27,9 @@ class AuthorizedUsersManagementService
/**
* CI create() first step — lookup by token after invite email (pending row, created within TTL).
*
* Tokens are stored as SHA-256 hashes; we only ever look up by hash so that
* a leaked database row cannot be replayed as a plaintext token.
*/
public function findPendingByIncomingToken(string $plainToken): ?AuthorizedUser
{
@@ -34,12 +37,8 @@ class AuthorizedUsersManagementService
return null;
}
$hash = $this->hashToken($plainToken);
return AuthorizedUser::query()
->where(function ($q) use ($hash, $plainToken) {
$q->where('token', $hash)->orWhere('token', $plainToken);
})
->where('token', $this->hashToken($plainToken))
->where('created_at', '>=', now()->subHours(self::TOKEN_TTL_HOURS))
->first();
}
@@ -49,16 +48,12 @@ class AuthorizedUsersManagementService
*/
public function findActiveSetupRow(int $authorizedUserId, string $plainToken): ?AuthorizedUser
{
if ($plainToken === '') {
if ($plainToken === '' || $authorizedUserId <= 0) {
return null;
}
$hash = $this->hashToken($plainToken);
return AuthorizedUser::query()
->where(function ($q) use ($hash, $plainToken) {
$q->where('token', $hash)->orWhere('token', $plainToken);
})
->where('token', $this->hashToken($plainToken))
->where('authorized_user_id', $authorizedUserId)
->where('status', 'Active')
->where('updated_at', '>=', now()->subHours(self::TOKEN_TTL_HOURS))
@@ -90,6 +85,9 @@ class AuthorizedUsersManagementService
/**
* CI `create` — when email is not a registered user, respond success without leaking (privacy).
*
* Refuses self-invites and silently re-uses an existing invite row to avoid
* letting a parent spam another user with confirmation emails.
*
* @return array{created: bool, record: AuthorizedUser|null}
*/
public function inviteByEmail(int $primaryUserId, string $email): array
@@ -104,22 +102,49 @@ class AuthorizedUsersManagementService
return ['created' => false, 'record' => null];
}
// A parent cannot invite themselves as their own authorized user.
if ((int) $user->id === $primaryUserId) {
return ['created' => false, 'record' => null];
}
// If the same parent already invited this user, rotate the token instead
// of creating a duplicate row (prevents spam + race-driven duplicates).
$existing = AuthorizedUser::query()
->where('user_id', $primaryUserId)
->where('authorized_user_id', (int) $user->id)
->first();
$plain = bin2hex(random_bytes(48));
$tokenHash = $this->hashToken($plain);
$phone = trim((string) ($user->cellphone ?? ''));
$record = AuthorizedUser::query()->create([
'user_id' => $primaryUserId,
'authorized_user_id' => (int) $user->id,
'firstname' => (string) ($user->firstname ?? ''),
'lastname' => (string) ($user->lastname ?? ''),
'phone_number' => $phone !== '' ? $phone : '-',
'gender' => trim((string) ($user->gender ?? '')) !== '' ? (string) $user->gender : '-',
'email' => $email,
'relation_to_user' => null,
'token' => $tokenHash,
'status' => 'Pending',
]);
$gender = trim((string) ($user->gender ?? ''));
if ($existing) {
$existing->update([
'firstname' => (string) ($user->firstname ?? ''),
'lastname' => (string) ($user->lastname ?? ''),
'phone_number' => $phone !== '' ? $phone : '-',
'gender' => $gender !== '' ? $gender : '-',
'email' => $email,
'token' => $tokenHash,
'status' => 'Pending',
]);
$record = $existing;
} else {
$record = AuthorizedUser::query()->create([
'user_id' => $primaryUserId,
'authorized_user_id' => (int) $user->id,
'firstname' => (string) ($user->firstname ?? ''),
'lastname' => (string) ($user->lastname ?? ''),
'phone_number' => $phone !== '' ? $phone : '-',
'gender' => $gender !== '' ? $gender : '-',
'email' => $email,
'relation_to_user' => null,
'token' => $tokenHash,
'status' => 'Pending',
]);
}
$this->sendConfirmationEmail($email, $plain);
@@ -107,10 +107,34 @@ class ParentEnrollmentService
$schoolYear = $context['school_year'];
$semester = $context['semester'];
// Authorize every submitted student id against this parent up-front to
// prevent IDOR — a parent must not be able to enroll or withdraw
// another family's students by passing arbitrary ids.
$requestedIds = array_values(array_unique(array_map(
'intval',
array_merge($enrollIds, $withdrawIds)
)));
$requestedIds = array_values(array_filter($requestedIds, static fn ($id) => $id > 0));
$ownedIds = $requestedIds === []
? []
: Student::query()
->where('parent_id', $parentId)
->whereIn('id', $requestedIds)
->pluck('id')
->map(fn ($id) => (int) $id)
->all();
$ownedSet = array_flip($ownedIds);
$enrolled = [];
$withdrawn = [];
foreach ($enrollIds as $studentId) {
$studentId = (int) $studentId;
if (! isset($ownedSet[$studentId])) {
continue;
}
$existing = Enrollment::query()
->where('student_id', $studentId)
->where('school_year', $schoolYear)
@@ -140,12 +164,18 @@ class ParentEnrollmentService
]);
}
$enrolled[] = (int) $studentId;
$enrolled[] = $studentId;
}
foreach ($withdrawIds as $studentId) {
$studentId = (int) $studentId;
if (! isset($ownedSet[$studentId])) {
continue;
}
$enrollment = Enrollment::query()
->where('student_id', $studentId)
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->where('is_withdrawn', 0)
@@ -195,7 +225,7 @@ class ParentEnrollmentService
}
}
$withdrawn[] = (int) $studentId;
$withdrawn[] = $studentId;
}
return [
@@ -5,6 +5,7 @@ namespace App\Services\Parents;
use App\Models\Event;
use App\Models\EventCharges;
use App\Models\Enrollment;
use App\Models\Student;
use App\Services\Invoices\InvoiceGenerationService;
class ParentEventParticipationService
@@ -48,15 +49,61 @@ class ParentEventParticipationService
$schoolYear = $context['school_year'];
$semester = $context['semester'];
// First pass: parse + validate every `student_id:event_id` key and
// collect the student ids that this parent actually owns. This stops
// a parent from creating or deleting charges on another family's
// students by manipulating the participation keys.
$parsed = [];
$candidateStudentIds = [];
foreach ($participations as $key => $value) {
[$studentId, $eventId] = array_map('intval', explode(':', (string) $key));
$parts = explode(':', (string) $key, 2);
if (count($parts) !== 2 || ! ctype_digit($parts[0]) || ! ctype_digit($parts[1])) {
continue;
}
$studentId = (int) $parts[0];
$eventId = (int) $parts[1];
if ($studentId <= 0 || $eventId <= 0) {
continue;
}
$normalized = strtolower(trim((string) $value));
if (! in_array($normalized, ['yes', 'no'], true)) {
continue;
}
$parsed[] = [
'student_id' => $studentId,
'event_id' => $eventId,
'value' => $normalized,
];
$candidateStudentIds[$studentId] = true;
}
if ($parsed === []) {
return;
}
$ownedIds = Student::query()
->where('parent_id', $parentId)
->whereIn('id', array_keys($candidateStudentIds))
->pluck('id')
->map(fn ($id) => (int) $id)
->all();
$ownedSet = array_flip($ownedIds);
foreach ($parsed as $row) {
if (! isset($ownedSet[$row['student_id']])) {
continue;
}
$existing = EventCharges::query()
->where('parent_id', $parentId)
->where('student_id', $studentId)
->where('event_id', $eventId)
->where('student_id', $row['student_id'])
->where('event_id', $row['event_id'])
->first();
if ($value === 'no') {
if ($row['value'] === 'no') {
if ($existing) {
$existing->delete();
}
@@ -64,14 +111,14 @@ class ParentEventParticipationService
}
if ($existing) {
$existing->update(['participation' => $value]);
$existing->update(['participation' => $row['value']]);
} else {
$event = Event::getEvent($eventId, $schoolYear);
$event = Event::getEvent($row['event_id'], $schoolYear);
EventCharges::query()->create([
'parent_id' => $parentId,
'student_id' => $studentId,
'event_id' => $eventId,
'participation' => $value,
'student_id' => $row['student_id'],
'event_id' => $row['event_id'],
'participation' => $row['value'],
'charged' => $event['amount'] ?? 0,
'school_year' => $schoolYear,
'semester' => $semester,
@@ -74,6 +74,7 @@ class ParentRegistrationService
}
$createdStudentIds = [];
$skippedStudents = [];
DB::transaction(function () use (
$parentId,
@@ -81,17 +82,28 @@ class ParentRegistrationService
$emergencyContacts,
$schoolYear,
$semester,
&$createdStudentIds
&$createdStudentIds,
&$skippedStudents
) {
foreach ($students as $student) {
// Duplicate check is scoped to THIS parent: one family's
// legitimately registered child must not block another family
// from registering a child with the same name and DOB.
$exists = Student::query()
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->where('dob', $student['dob'])
->where('firstname', $student['firstname'])
->where('lastname', $student['lastname'])
->whereRaw('LOWER(TRIM(firstname)) = ?', [strtolower(trim((string) $student['firstname']))])
->whereRaw('LOWER(TRIM(lastname)) = ?', [strtolower(trim((string) $student['lastname']))])
->first();
if ($exists) {
$skippedStudents[] = [
'firstname' => $student['firstname'],
'lastname' => $student['lastname'],
'dob' => $student['dob'],
'reason' => 'already_registered',
];
continue;
}
@@ -153,6 +165,7 @@ class ParentRegistrationService
return [
'created_student_ids' => $createdStudentIds,
'skipped' => $skippedStudents,
];
}
@@ -6,10 +6,15 @@ use App\Models\Configuration;
use App\Models\Enrollment;
use App\Models\StudentClass;
use App\Models\User;
use App\Services\Billing\ChargeService;
use Illuminate\Support\Facades\DB;
class PaymentEventChargesService
{
public function __construct(private ChargeService $chargeService)
{
}
public function listCharges(?string $schoolYear, ?string $semester): array
{
$schoolYear = $schoolYear ?: Configuration::getConfig('school_year');
@@ -44,34 +49,45 @@ class PaymentEventChargesService
{
$studentIds = $payload['student_ids'] ?? [];
$parentId = (int) $payload['parent_id'];
$commonData = [
'parent_id' => $parentId,
'event_name' => $payload['event_name'],
'description' => $payload['description'] ?? null,
'amount' => (float) $payload['amount'],
'semester' => $payload['semester'],
'school_year' => $payload['school_year'],
'charged' => 0,
'updated_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
];
$schoolYear = (string) $payload['school_year'];
$semester = (string) $payload['semester'];
$eventName = (string) $payload['event_name'];
$amount = (float) $payload['amount'];
$count = 0;
if (!empty($studentIds)) {
foreach ($studentIds as $studentId) {
DB::table('event_charges')->insert(array_merge($commonData, [
$result = $this->chargeService->createEventCharge([
'parent_id' => $parentId,
'student_id' => (int) $studentId,
]));
$count++;
'event_name' => $eventName,
'amount' => $amount,
'description' => $payload['description'] ?? null,
'school_year' => $schoolYear,
'semester' => $semester,
'created_by' => $userId,
]);
if ($result['ok'] ?? false) {
$count++;
}
}
} elseif (!empty($payload['student_id'])) {
DB::table('event_charges')->insert(array_merge($commonData, [
$result = $this->chargeService->createEventCharge([
'parent_id' => $parentId,
'student_id' => (int) $payload['student_id'],
]));
$count++;
'event_name' => $eventName,
'amount' => $amount,
'description' => $payload['description'] ?? null,
'school_year' => $schoolYear,
'semester' => $semester,
'created_by' => $userId,
]);
if ($result['ok'] ?? false) {
$count++;
}
}
return $count;
@@ -0,0 +1,202 @@
<?php
namespace App\Services\Promotions;
use App\Models\ClassSection;
use App\Models\LevelProgression;
use App\Models\SchoolClass;
use Illuminate\Support\Collection;
/**
* Resolves current → next level using the configurable
* `level_progressions` table (plan section 10).
*
* Falls back to `class_id + 1` only if no mapping is found, matching
* the legacy behaviour in {@see \App\Services\School\AccountEventService}.
*/
class LevelProgressionService
{
/**
* Default mapping shipped with the plan doc; used to seed the table
* if no rows exist when the service is first asked for a list.
*/
public const DEFAULT_MAP = [
['current' => 'KG1', 'next' => 'KG2', 'order' => 10, 'terminal' => false],
['current' => 'KG2', 'next' => 'Grade 1', 'order' => 20, 'terminal' => false],
['current' => 'Grade 1','next' => 'Grade 2', 'order' => 30, 'terminal' => false],
['current' => 'Grade 2','next' => 'Grade 3', 'order' => 40, 'terminal' => false],
['current' => 'Grade 3','next' => 'Grade 4', 'order' => 50, 'terminal' => false],
['current' => 'Grade 4','next' => 'Grade 5', 'order' => 60, 'terminal' => false],
['current' => 'Grade 5','next' => 'Grade 6', 'order' => 70, 'terminal' => false],
['current' => 'Grade 6','next' => 'Grade 7', 'order' => 80, 'terminal' => false],
['current' => 'Grade 7','next' => 'Grade 8', 'order' => 90, 'terminal' => false],
['current' => 'Grade 8','next' => 'Grade 9', 'order' => 100, 'terminal' => false],
['current' => 'Grade 9','next' => 'Youth', 'order' => 110, 'terminal' => false],
['current' => 'Youth', 'next' => null, 'order' => 120, 'terminal' => true],
];
/**
* Returns all configured level progressions, seeding defaults if the
* table is empty.
*
* @return Collection<int,LevelProgression>
*/
public function list(bool $activeOnly = true): Collection
{
$query = LevelProgression::query()->ordered();
if ($activeOnly) {
$query->active();
}
$rows = $query->get();
if ($rows->isEmpty() && $activeOnly) {
$this->seedDefaults();
$rows = LevelProgression::query()->ordered()->active()->get();
}
return $rows;
}
/**
* Resolve next-level information for a student based on their current
* `class_id` (i.e. classes.id). Returns null if no progression rule
* matches and no fallback can be derived.
*
* @return array{
* current_level_id:?int,
* current_level_name:?string,
* next_level_id:?int,
* next_level_name:?string,
* is_terminal:bool
* }|null
*/
public function resolveByCurrentClassId(?int $currentClassId): ?array
{
if ($currentClassId === null || $currentClassId <= 0) {
return null;
}
$current = SchoolClass::query()->find($currentClassId);
if (!$current) {
return null;
}
$currentName = (string) ($current->class_name ?? '');
$progression = LevelProgression::findByCurrentLevelId($currentClassId)
?: ($currentName !== '' ? LevelProgression::findByCurrentLevelName($currentName) : null);
if ($progression) {
return [
'current_level_id' => $currentClassId,
'current_level_name' => $currentName !== '' ? $currentName : $progression->current_level_name,
'next_level_id' => $progression->next_level_id !== null ? (int) $progression->next_level_id : null,
'next_level_name' => $progression->next_level_name,
'is_terminal' => (bool) $progression->is_terminal,
];
}
// Legacy fallback: numeric next class id (matches AccountEventService::preparePromotionQueue).
$nextId = $currentClassId + 1;
$next = SchoolClass::query()->find($nextId);
return [
'current_level_id' => $currentClassId,
'current_level_name' => $currentName !== '' ? $currentName : null,
'next_level_id' => $next ? (int) $next->id : null,
'next_level_name' => $next ? (string) $next->class_name : null,
'is_terminal' => false,
];
}
/**
* Resolve next-level info from a `classSection` row id (the
* "from_class_section_id" used elsewhere).
*/
public function resolveByCurrentClassSectionId(?int $classSectionId): ?array
{
if ($classSectionId === null || $classSectionId <= 0) {
return null;
}
$classId = ClassSection::getClassId($classSectionId);
if (!$classId) {
return null;
}
return $this->resolveByCurrentClassId((int) $classId);
}
/**
* Seed the table with the default mapping from {@see DEFAULT_MAP}.
* Idempotent relies on the unique key on `current_level_name`.
*/
public function seedDefaults(): int
{
$created = 0;
foreach (self::DEFAULT_MAP as $row) {
$existing = LevelProgression::query()
->whereRaw('LOWER(current_level_name) = ?', [strtolower($row['current'])])
->first();
if ($existing) {
continue;
}
$currentClassId = $this->resolveClassIdByName($row['current']);
$nextClassId = isset($row['next']) && $row['next'] !== null
? $this->resolveClassIdByName($row['next'])
: null;
LevelProgression::query()->create([
'current_level_id' => $currentClassId,
'current_level_name' => $row['current'],
'next_level_id' => $nextClassId,
'next_level_name' => $row['next'] ?? null,
'order_index' => (int) ($row['order'] ?? 0),
'is_terminal' => (bool) ($row['terminal'] ?? false),
'is_active' => true,
]);
$created++;
}
return $created;
}
public function upsertMapping(array $payload): LevelProgression
{
$currentName = (string) ($payload['current_level_name'] ?? '');
if ($currentName === '') {
throw new \InvalidArgumentException('current_level_name is required');
}
$existing = LevelProgression::findByCurrentLevelName($currentName);
$data = [
'current_level_id' => isset($payload['current_level_id']) ? (int) $payload['current_level_id'] : ($existing->current_level_id ?? null),
'current_level_name' => $currentName,
'next_level_id' => isset($payload['next_level_id']) ? (int) $payload['next_level_id'] : null,
'next_level_name' => $payload['next_level_name'] ?? null,
'order_index' => isset($payload['order_index']) ? (int) $payload['order_index'] : ($existing->order_index ?? 0),
'is_terminal' => (bool) ($payload['is_terminal'] ?? ($existing->is_terminal ?? false)),
'is_active' => array_key_exists('is_active', $payload) ? (bool) $payload['is_active'] : ($existing->is_active ?? true),
'notes' => $payload['notes'] ?? ($existing->notes ?? null),
];
if ($existing) {
$existing->fill($data);
$existing->save();
return $existing;
}
return LevelProgression::query()->create($data);
}
private function resolveClassIdByName(?string $name): ?int
{
if ($name === null || $name === '') {
return null;
}
$row = SchoolClass::query()
->whereRaw('LOWER(class_name) = ?', [strtolower(trim($name))])
->orderByDesc('id')
->first();
return $row ? (int) $row->id : null;
}
}
@@ -0,0 +1,153 @@
<?php
namespace App\Services\Promotions;
use App\Models\PromotionAuditLog;
use App\Models\StudentPromotionRecord;
/**
* Centralised writer for the promotion audit trail (plan section 18).
*/
class PromotionAuditService
{
public function logRecordCreated(StudentPromotionRecord $record, ?int $userId, ?string $notes = null): PromotionAuditLog
{
return $this->log($record, PromotionAuditLog::ACTION_RECORD_CREATED, [
'user_id' => $userId,
'new_value' => $record->promotion_status,
'notes' => $notes,
]);
}
public function logStatusChange(
StudentPromotionRecord $record,
?string $oldStatus,
string $newStatus,
?int $userId,
?string $notes = null
): PromotionAuditLog {
return $this->log($record, PromotionAuditLog::ACTION_STATUS_CHANGED, [
'user_id' => $userId,
'field' => 'promotion_status',
'old_value' => $oldStatus,
'new_value' => $newStatus,
'notes' => $notes,
]);
}
public function logEligibilityEvaluation(
StudentPromotionRecord $record,
bool $passed,
?int $userId,
?string $notes = null
): PromotionAuditLog {
return $this->log($record, PromotionAuditLog::ACTION_ELIGIBILITY_EVALUATED, [
'user_id' => $userId,
'field' => 'passed_current_level',
'new_value' => $passed ? '1' : '0',
'notes' => $notes,
]);
}
public function logParentNotified(StudentPromotionRecord $record, ?int $userId, ?string $notes = null): PromotionAuditLog
{
return $this->log($record, PromotionAuditLog::ACTION_PARENT_NOTIFIED, [
'user_id' => $userId,
'notes' => $notes,
]);
}
public function logEnrollmentStepCompleted(
StudentPromotionRecord $record,
string $field,
?int $userId,
?string $notes = null
): PromotionAuditLog {
return $this->log($record, PromotionAuditLog::ACTION_ENROLLMENT_STEP_COMPLETED, [
'user_id' => $userId,
'field' => $field,
'new_value' => '1',
'notes' => $notes,
]);
}
public function logEnrollmentStarted(StudentPromotionRecord $record, ?int $userId): PromotionAuditLog
{
return $this->log($record, PromotionAuditLog::ACTION_ENROLLMENT_STARTED, [
'user_id' => $userId,
]);
}
public function logEnrollmentCompleted(StudentPromotionRecord $record, ?int $userId): PromotionAuditLog
{
return $this->log($record, PromotionAuditLog::ACTION_ENROLLMENT_COMPLETED, [
'user_id' => $userId,
]);
}
public function logPromotionFinalized(StudentPromotionRecord $record, ?int $userId): PromotionAuditLog
{
return $this->log($record, PromotionAuditLog::ACTION_PROMOTION_FINALIZED, [
'user_id' => $userId,
'new_value' => $record->promotion_status,
]);
}
public function logDeadlineSet(
StudentPromotionRecord $record,
?string $oldDeadline,
?string $newDeadline,
?int $userId
): PromotionAuditLog {
return $this->log($record, PromotionAuditLog::ACTION_DEADLINE_SET, [
'user_id' => $userId,
'field' => 'enrollment_deadline',
'old_value' => $oldDeadline,
'new_value' => $newDeadline,
]);
}
public function logReminderSent(
StudentPromotionRecord $record,
string $reminderType,
?int $userId
): PromotionAuditLog {
return $this->log($record, PromotionAuditLog::ACTION_REMINDER_SENT, [
'user_id' => $userId,
'field' => 'reminder_type',
'new_value' => $reminderType,
]);
}
public function logDeadlineExpired(StudentPromotionRecord $record): PromotionAuditLog
{
return $this->log($record, PromotionAuditLog::ACTION_DEADLINE_EXPIRED, []);
}
public function logManualOverride(
StudentPromotionRecord $record,
string $field,
?string $oldValue,
?string $newValue,
?int $userId,
?string $notes = null
): PromotionAuditLog {
return $this->log($record, PromotionAuditLog::ACTION_MANUAL_OVERRIDE, [
'user_id' => $userId,
'field' => $field,
'old_value' => $oldValue,
'new_value' => $newValue,
'notes' => $notes,
]);
}
private function log(StudentPromotionRecord $record, string $action, array $extra): PromotionAuditLog
{
return PromotionAuditLog::query()->create(array_merge([
'promotion_id' => (int) $record->getKey(),
'student_id' => (int) $record->student_id,
'action_type' => $action,
'performed_at' => now(),
], $extra));
}
}
@@ -0,0 +1,393 @@
<?php
namespace App\Services\Promotions;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\Student;
use App\Models\StudentPromotionRecord;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Implements the eligibility engine described in plan sections 57.
*
* Builds (or refreshes) one `student_promotion_records` row per
* student per next-school-year and assigns a promotion status based on
* academic results stored in `semester_scores`.
*/
class PromotionEligibilityService
{
public const DEFAULT_PASS_THRESHOLD = 60.0;
public function __construct(
private LevelProgressionService $progression,
private PromotionStatusService $statusService,
private PromotionAuditService $audit
) {
}
/**
* Evaluate eligibility for a single student.
*
* @param int|null $userId actor id for audit
*/
public function evaluateStudent(
int $studentId,
?string $currentSchoolYear = null,
?int $userId = null
): ?StudentPromotionRecord {
$student = Student::query()->find($studentId);
if (!$student) {
return null;
}
$current = $currentSchoolYear ?: $this->resolveCurrentSchoolYear();
if ($current === null) {
return null;
}
$next = $this->nextSchoolYear($current);
if ($next === null) {
return null;
}
return $this->evaluateOne($student, $current, $next, $userId);
}
/**
* Evaluate eligibility for a class section worth of students at once.
*
* @return array{ evaluated:int, eligible:int, repeated:int, on_hold:int, conditional:int, graduated:int, errors:array<int,string> }
*/
public function evaluateClassSection(
int $classSectionId,
?string $currentSchoolYear = null,
?int $userId = null
): array {
$current = $currentSchoolYear ?: $this->resolveCurrentSchoolYear();
if ($current === null) {
return $this->emptySummary();
}
$next = $this->nextSchoolYear($current);
if ($next === null) {
return $this->emptySummary();
}
$studentIds = DB::table('student_class')
->where('class_section_id', $classSectionId)
->where('school_year', $current)
->pluck('student_id')
->map(fn ($id) => (int) $id)
->all();
$summary = $this->emptySummary();
foreach ($studentIds as $studentId) {
$student = Student::query()->find($studentId);
if (!$student) {
continue;
}
try {
$record = $this->evaluateOne($student, $current, $next, $userId);
if ($record) {
$summary['evaluated']++;
$key = $this->statusBucket($record->promotion_status);
if ($key !== null) {
$summary[$key]++;
}
}
} catch (\Throwable $e) {
Log::error('Promotion eligibility evaluation failed', [
'student_id' => $studentId,
'exception' => $e,
]);
$summary['errors'][] = sprintf('Student %d: %s', $studentId, $e->getMessage());
}
}
return $summary;
}
/**
* Evaluate eligibility for an entire school year (all students with
* class assignments). Convenience wrapper around `evaluateOne`.
*/
public function evaluateSchoolYear(?string $currentSchoolYear = null, ?int $userId = null): array
{
$current = $currentSchoolYear ?: $this->resolveCurrentSchoolYear();
if ($current === null) {
return $this->emptySummary();
}
$next = $this->nextSchoolYear($current);
if ($next === null) {
return $this->emptySummary();
}
$studentIds = DB::table('student_class')
->where('school_year', $current)
->distinct()
->pluck('student_id')
->map(fn ($id) => (int) $id)
->all();
$summary = $this->emptySummary();
foreach ($studentIds as $studentId) {
$student = Student::query()->find($studentId);
if (!$student) {
continue;
}
try {
$record = $this->evaluateOne($student, $current, $next, $userId);
if ($record) {
$summary['evaluated']++;
$key = $this->statusBucket($record->promotion_status);
if ($key !== null) {
$summary[$key]++;
}
}
} catch (\Throwable $e) {
Log::error('Promotion eligibility evaluation failed', [
'student_id' => $studentId,
'exception' => $e,
]);
$summary['errors'][] = sprintf('Student %d: %s', $studentId, $e->getMessage());
}
}
return $summary;
}
/**
* Core per-student logic implementing section 7's decision tree.
*/
public function evaluateOne(
Student $student,
string $currentSchoolYear,
string $nextSchoolYear,
?int $userId = null
): StudentPromotionRecord {
$studentId = (int) $student->getKey();
$sectionId = (int) DB::table('student_class')
->where('student_id', $studentId)
->where('school_year', $currentSchoolYear)
->orderByDesc('updated_at')
->orderByDesc('id')
->value('class_section_id');
$progressionInfo = $this->progression->resolveByCurrentClassSectionId($sectionId);
if ($progressionInfo === null && $sectionId > 0) {
// Even without a mapping we still want to record the eligibility decision.
$progressionInfo = [
'current_level_id' => null,
'current_level_name' => ClassSection::getClassSectionNameBySectionId($sectionId),
'next_level_id' => null,
'next_level_name' => null,
'is_terminal' => false,
];
}
$progressionInfo ??= [
'current_level_id' => null,
'current_level_name' => $student->registration_grade,
'next_level_id' => null,
'next_level_name' => null,
'is_terminal' => false,
];
$scoreInfo = $this->loadAcademicResult($studentId, $currentSchoolYear);
$passed = $scoreInfo['passed'];
$finalAvg = $scoreInfo['final_average'];
return DB::transaction(function () use (
$student,
$studentId,
$currentSchoolYear,
$nextSchoolYear,
$progressionInfo,
$passed,
$finalAvg,
$userId,
$scoreInfo
) {
$existing = StudentPromotionRecord::query()
->where('student_id', $studentId)
->where('next_school_year', $nextSchoolYear)
->first();
$isNew = $existing === null;
$record = $existing ?: new StudentPromotionRecord();
$record->fill([
'student_id' => $studentId,
'parent_id' => (int) ($student->parent_id ?? 0) ?: null,
'current_school_year' => $currentSchoolYear,
'next_school_year' => $nextSchoolYear,
'current_level_id' => $progressionInfo['current_level_id'],
'current_level_name' => $progressionInfo['current_level_name'],
'promoted_level_id' => $progressionInfo['next_level_id'],
'promoted_level_name' => $progressionInfo['next_level_name'],
'final_average' => $finalAvg,
'eligibility_notes' => $scoreInfo['notes'],
'updated_by' => $userId,
]);
if ($isNew) {
$record->promotion_status = StudentPromotionRecord::STATUS_NOT_REVIEWED;
$record->enrollment_required = true;
$record->enrollment_status = StudentPromotionRecord::ENROLLMENT_NOT_STARTED;
}
$previousStatus = (string) ($record->promotion_status ?? StudentPromotionRecord::STATUS_NOT_REVIEWED);
$record->passed_current_level = $passed;
$newStatus = $this->resolveStatusFromEvaluation(
$passed,
$progressionInfo['is_terminal'] ?? false,
$scoreInfo['has_data']
);
// Preserve admin overrides for terminal/holds.
if (in_array($previousStatus, [
StudentPromotionRecord::STATUS_WITHDRAWN,
StudentPromotionRecord::STATUS_GRADUATED,
StudentPromotionRecord::STATUS_PROMOTED_AND_ENROLLED,
StudentPromotionRecord::STATUS_NOT_ENROLLED,
], true)) {
$newStatus = $previousStatus;
}
$record->promotion_status = $newStatus;
$record->save();
if ($isNew) {
$this->audit->logRecordCreated($record, $userId);
}
$this->audit->logEligibilityEvaluation(
$record,
(bool) $passed,
$userId,
$scoreInfo['notes']
);
if ($previousStatus !== $newStatus) {
$this->audit->logStatusChange($record, $previousStatus, $newStatus, $userId, 'Auto eligibility evaluation');
}
return $record;
});
}
/**
* Best effort lookup of the student's final academic result for the
* given school year. Treats missing fall + spring scores as "no
* data" → on hold (plan section 4: On Hold for missing result).
*
* @return array{ passed:?bool, final_average:?float, notes:?string, has_data:bool }
*/
private function loadAcademicResult(int $studentId, string $schoolYear): array
{
$rows = DB::table('semester_scores')
->select('semester', 'semester_score')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->get();
$fall = null;
$spring = null;
foreach ($rows as $row) {
$semester = strtolower((string) ($row->semester ?? ''));
$score = isset($row->semester_score) ? (float) $row->semester_score : null;
if ($semester === 'fall') {
$fall = $score;
} elseif ($semester === 'spring') {
$spring = $score;
}
}
$threshold = $this->passThreshold();
if ($fall === null && $spring === null) {
return [
'passed' => null,
'final_average' => null,
'notes' => 'Missing fall and spring scores',
'has_data' => false,
];
}
if ($fall === null || $spring === null) {
$existing = $fall ?? $spring;
return [
'passed' => null,
'final_average' => $existing,
'notes' => 'Awaiting both fall and spring scores',
'has_data' => false,
];
}
$avg = round(($fall + $spring) / 2.0, 2);
return [
'passed' => $avg >= $threshold,
'final_average' => $avg,
'notes' => sprintf('Final average %.2f (threshold %.2f)', $avg, $threshold),
'has_data' => true,
];
}
private function resolveStatusFromEvaluation(?bool $passed, bool $terminal, bool $hasData): string
{
if (!$hasData) {
return StudentPromotionRecord::STATUS_ON_HOLD;
}
if ($passed === false) {
return StudentPromotionRecord::STATUS_REPEATED;
}
if ($terminal) {
return StudentPromotionRecord::STATUS_GRADUATED;
}
return StudentPromotionRecord::STATUS_AWAITING_PARENT;
}
private function statusBucket(string $status): ?string
{
return match ($status) {
StudentPromotionRecord::STATUS_AWAITING_PARENT,
StudentPromotionRecord::STATUS_ELIGIBLE => 'eligible',
StudentPromotionRecord::STATUS_REPEATED => 'repeated',
StudentPromotionRecord::STATUS_ON_HOLD => 'on_hold',
StudentPromotionRecord::STATUS_CONDITIONAL => 'conditional',
StudentPromotionRecord::STATUS_GRADUATED => 'graduated',
default => null,
};
}
private function emptySummary(): array
{
return [
'evaluated' => 0,
'eligible' => 0,
'repeated' => 0,
'on_hold' => 0,
'conditional' => 0,
'graduated' => 0,
'errors' => [],
];
}
private function resolveCurrentSchoolYear(): ?string
{
$year = (string) (Configuration::getConfigValueByKey('school_year') ?? '');
return $year !== '' ? $year : null;
}
private function nextSchoolYear(string $current): ?string
{
if (!preg_match('/^(\d{4})-(\d{4})$/', $current, $m)) {
return null;
}
return ((int) $m[1] + 1) . '-' . ((int) $m[2] + 1);
}
private function passThreshold(): float
{
$configured = Configuration::getConfigValueByKey('promotion_pass_threshold');
if ($configured !== null && is_numeric($configured)) {
return (float) $configured;
}
return self::DEFAULT_PASS_THRESHOLD;
}
}
@@ -0,0 +1,391 @@
<?php
namespace App\Services\Promotions;
use App\Models\Configuration;
use App\Models\Enrollment;
use App\Models\Student;
use App\Models\StudentPromotionRecord;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* Implements the parent-side enrollment workflow described in plan
* sections 6, 8, 9 and 12. Tracks the parent's progress through the
* checklist and finalises the promotion + creates the next-year
* enrollment record once everything is complete.
*/
class PromotionEnrollmentService
{
public const ALLOWED_STEPS = [
'info_confirmed',
'documents_uploaded',
'agreement_accepted',
'payment_completed',
];
public function __construct(
private PromotionStatusService $statusService,
private PromotionAuditService $audit
) {
}
/**
* Lists actionable promotion records for a parent (the parent
* portal landing page in plan section 12).
*/
public function overviewForParent(int $parentId, ?string $nextSchoolYear = null): array
{
$year = $nextSchoolYear ?: $this->guessNextSchoolYear();
$query = StudentPromotionRecord::query()->forParent($parentId);
if ($year !== null) {
$query->forNextYear($year);
}
$records = $query
->orderByRaw('CASE WHEN promotion_status IN (?, ?, ?) THEN 0 ELSE 1 END', [
StudentPromotionRecord::STATUS_AWAITING_PARENT,
StudentPromotionRecord::STATUS_ELIGIBLE,
StudentPromotionRecord::STATUS_ENROLLMENT_STARTED,
])
->orderBy('promotion_id', 'desc')
->get();
$studentIds = $records->pluck('student_id')->unique()->all();
/** @var EloquentCollection<int,Student> $studentsCollection */
$studentsCollection = !empty($studentIds)
? Student::query()->whereIn('id', $studentIds)->get()
: new EloquentCollection();
$studentsById = $studentsCollection->keyBy('id');
$payload = $records->map(function (StudentPromotionRecord $record) use ($studentsById) {
return $this->presentRecord($record, $studentsById->get($record->student_id));
})->all();
return [
'records' => $payload,
'next_school_year' => $year,
'message' => $this->parentBannerMessage($records),
];
}
/**
* Mark the start of the enrollment process for a single promotion
* record.
*/
public function startEnrollment(StudentPromotionRecord $record, int $parentId, ?int $userId = null): StudentPromotionRecord
{
$this->assertParentOwns($record, $parentId);
$this->assertActionable($record);
return DB::transaction(function () use ($record, $parentId, $userId) {
if (!$record->enrollment_started_at) {
$record->enrollment_started_at = now();
}
$record->enrollment_status = StudentPromotionRecord::ENROLLMENT_IN_PROGRESS;
$record->parent_id = $record->parent_id ?: $parentId;
$record->updated_by = $userId;
$record->save();
if ($record->promotion_status !== StudentPromotionRecord::STATUS_ENROLLMENT_STARTED) {
$this->statusService->transition(
$record,
StudentPromotionRecord::STATUS_ENROLLMENT_STARTED,
$userId,
'Parent started enrollment'
);
}
$this->audit->logEnrollmentStarted($record, $userId);
return $record->refresh();
});
}
/**
* Update one or more checklist steps. Returns the (possibly
* finalised) record.
*/
public function updateSteps(
StudentPromotionRecord $record,
int $parentId,
array $steps,
?int $userId = null
): StudentPromotionRecord {
$this->assertParentOwns($record, $parentId);
$this->assertActionable($record);
$sanitized = [];
foreach ($steps as $field => $value) {
if (!in_array($field, self::ALLOWED_STEPS, true)) {
continue;
}
$sanitized[$field] = (bool) $value;
}
if (empty($sanitized)) {
return $record;
}
return DB::transaction(function () use ($record, $parentId, $sanitized, $userId) {
$changed = [];
foreach ($sanitized as $field => $value) {
if ((bool) $record->{$field} !== $value) {
$record->{$field} = $value;
$changed[] = $field;
}
}
if (empty($changed)) {
return $record;
}
if (!$record->enrollment_started_at) {
$record->enrollment_started_at = now();
}
$record->enrollment_status = StudentPromotionRecord::ENROLLMENT_IN_PROGRESS;
$record->parent_id = $record->parent_id ?: $parentId;
$record->updated_by = $userId;
$record->save();
foreach ($changed as $field) {
$this->audit->logEnrollmentStepCompleted($record, $field, $userId);
}
if ($record->promotion_status !== StudentPromotionRecord::STATUS_ENROLLMENT_STARTED) {
if (in_array($record->promotion_status, [
StudentPromotionRecord::STATUS_ELIGIBLE,
StudentPromotionRecord::STATUS_AWAITING_PARENT,
], true)) {
$this->statusService->transition(
$record,
StudentPromotionRecord::STATUS_ENROLLMENT_STARTED,
$userId,
'Enrollment progress recorded'
);
}
}
// Auto-finalise once all checklist items are complete.
if ($record->enrollmentChecklistComplete()) {
$record = $this->finalize($record, $userId);
}
return $record;
});
}
/**
* Submit the enrollment, asserting the checklist is complete.
*/
public function submitEnrollment(
StudentPromotionRecord $record,
int $parentId,
?int $userId = null
): StudentPromotionRecord {
$this->assertParentOwns($record, $parentId);
$this->assertActionable($record);
if (!$record->enrollmentChecklistComplete()) {
throw new RuntimeException('Enrollment checklist is incomplete.');
}
return $this->finalize($record, $userId);
}
/**
* Mark expired records (past deadline, still incomplete) as
* "not_enrolled_for_next_year" (plan section 15).
*
* @return array{ expired:int, ids:array<int,int> }
*/
public function markExpiredDeadlines(?\DateTimeInterface $now = null, ?int $userId = null): array
{
$now = $now ?: now();
$records = StudentPromotionRecord::query()
->whereIn('promotion_status', [
StudentPromotionRecord::STATUS_ELIGIBLE,
StudentPromotionRecord::STATUS_AWAITING_PARENT,
StudentPromotionRecord::STATUS_ENROLLMENT_STARTED,
])
->whereNotNull('enrollment_deadline')
->whereDate('enrollment_deadline', '<', $now)
->get();
$ids = [];
foreach ($records as $record) {
DB::transaction(function () use ($record, $userId, &$ids) {
$record->enrollment_status = StudentPromotionRecord::ENROLLMENT_EXPIRED;
$record->save();
$this->audit->logDeadlineExpired($record);
$this->statusService->forceStatus(
$record,
StudentPromotionRecord::STATUS_NOT_ENROLLED,
$userId,
'Deadline expired without enrollment'
);
$ids[] = (int) $record->getKey();
});
}
return [
'expired' => count($ids),
'ids' => $ids,
];
}
/**
* Finalise promotion + create the next-year enrollment record
* (plan sections 5, 6 step 6 and section 9).
*/
private function finalize(StudentPromotionRecord $record, ?int $userId): StudentPromotionRecord
{
if ($record->promotion_status === StudentPromotionRecord::STATUS_PROMOTED_AND_ENROLLED) {
return $record;
}
return DB::transaction(function () use ($record, $userId) {
$record->enrollment_status = StudentPromotionRecord::ENROLLMENT_COMPLETED;
$record->enrollment_completed_at = $record->enrollment_completed_at ?: now();
$record->promotion_finalized_at = now();
$record->updated_by = $userId;
$enrollmentId = $this->ensureNextYearEnrollment($record);
if ($enrollmentId !== null) {
$record->enrollment_id = $enrollmentId;
}
$record->save();
$this->audit->logEnrollmentCompleted($record, $userId);
$this->statusService->transition(
$record,
StudentPromotionRecord::STATUS_PROMOTED_AND_ENROLLED,
$userId,
'Enrollment checklist complete'
);
$this->audit->logPromotionFinalized($record->refresh(), $userId);
return $record->refresh();
});
}
/**
* Create or update an `enrollments` row for the next school year so
* the student is officially placed in the new year (plan section 9
* record-update rule).
*/
private function ensureNextYearEnrollment(StudentPromotionRecord $record): ?int
{
$existing = Enrollment::query()
->where('student_id', $record->student_id)
->where('school_year', $record->next_school_year)
->first();
$payload = [
'student_id' => $record->student_id,
'parent_id' => $record->parent_id,
'school_year' => $record->next_school_year,
'enrollment_status' => 'enrolled',
'admission_status' => 'accepted',
'is_withdrawn' => false,
'enrollment_date' => now()->toDateString(),
];
if ($existing) {
$existing->fill($payload);
$existing->save();
return (int) $existing->getKey();
}
$created = Enrollment::query()->create($payload);
return $created ? (int) $created->getKey() : null;
}
private function presentRecord(StudentPromotionRecord $record, ?Student $student): array
{
return [
'promotion_id' => (int) $record->getKey(),
'student_id' => (int) $record->student_id,
'student_name' => $student
? trim((string) $student->firstname . ' ' . (string) $student->lastname)
: null,
'current_level' => $record->current_level_name,
'promoted_level' => $record->promoted_level_name,
'current_school_year' => $record->current_school_year,
'next_school_year' => $record->next_school_year,
'promotion_status' => $record->promotion_status,
'enrollment_status' => $record->enrollment_status,
'enrollment_deadline' => $record->enrollment_deadline?->toDateString(),
'enrollment_started_at' => $record->enrollment_started_at?->toDateTimeString(),
'enrollment_completed_at' => $record->enrollment_completed_at?->toDateTimeString(),
'parent_action_required' => in_array(
$record->promotion_status,
StudentPromotionRecord::parentActionableStatuses(),
true
),
'checklist' => [
'info_confirmed' => (bool) $record->info_confirmed,
'documents_uploaded' => (bool) $record->documents_uploaded,
'agreement_accepted' => (bool) $record->agreement_accepted,
'payment_completed' => (bool) $record->payment_completed,
],
'final_average' => $record->final_average !== null ? (float) $record->final_average : null,
'passed_current_level' => $record->passed_current_level,
];
}
private function parentBannerMessage(EloquentCollection $records): ?string
{
$actionable = $records->first(function (StudentPromotionRecord $r) {
return in_array(
$r->promotion_status,
StudentPromotionRecord::parentActionableStatuses(),
true
);
});
if (!$actionable) {
return null;
}
$level = $actionable->promoted_level_name ?: 'the next level';
$deadline = $actionable->enrollment_deadline?->toDateString();
$deadlineText = $deadline ? 'by ' . $deadline : 'as soon as possible';
return sprintf(
'Your child is eligible for promotion to %s. Please complete enrollment for the new school year %s.',
$level,
$deadlineText
);
}
private function assertParentOwns(StudentPromotionRecord $record, int $parentId): void
{
if ($parentId <= 0) {
throw new RuntimeException('Authenticated parent is required.');
}
if ((int) $record->parent_id !== $parentId) {
$studentParent = (int) (Student::query()->where('id', $record->student_id)->value('parent_id') ?? 0);
if ($studentParent !== $parentId) {
throw new RuntimeException('You are not authorised to act on this promotion record.');
}
}
}
private function assertActionable(StudentPromotionRecord $record): void
{
$actionable = StudentPromotionRecord::parentActionableStatuses();
if (!in_array($record->promotion_status, $actionable, true)) {
throw new RuntimeException(sprintf(
'Promotion record is not actionable in status "%s".',
$record->promotion_status
));
}
}
private function guessNextSchoolYear(): ?string
{
$current = (string) (Configuration::getConfigValueByKey('school_year') ?? '');
if (!preg_match('/^(\d{4})-(\d{4})$/', $current, $m)) {
return null;
}
return ((int) $m[1] + 1) . '-' . ((int) $m[2] + 1);
}
}
@@ -0,0 +1,223 @@
<?php
namespace App\Services\Promotions;
use App\Models\Student;
use App\Models\StudentPromotionRecord;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
/**
* Admin-side reads against `student_promotion_records` (plan sections 13
* and 16). Always returns hydrated student / parent context so the
* admin UI can render rows without N+1 queries.
*/
class PromotionQueryService
{
/**
* Filterable list of promotion records for admin views.
*
* Filters supported:
* - status: string|array
* - next_school_year: string
* - current_school_year: string
* - parent_id: int
* - student_id: int
* - search: string (matches student first/last name)
* - parent_action_required: bool (subset of parentActionableStatuses)
*
* Returns array<int, array<string, mixed>> when paginate=false,
* otherwise a Laravel paginator instance.
*
* @param array{
* status?: string|array<int,string>,
* next_school_year?: string|null,
* current_school_year?: string|null,
* parent_id?: int|null,
* student_id?: int|null,
* search?: string|null,
* parent_action_required?: bool,
* per_page?: int|null,
* } $filters
*
* @return array{
* data: array<int,array<string,mixed>>,
* total: int,
* page: int,
* per_page: int,
* total_pages: int,
* counts_by_status: array<string,int>
* }
*/
public function list(array $filters): array
{
$query = StudentPromotionRecord::query();
$this->applyFilters($query, $filters);
$perPage = isset($filters['per_page']) ? max(1, min(200, (int) $filters['per_page'])) : 50;
$page = isset($filters['page']) ? max(1, (int) $filters['page']) : 1;
/** @var LengthAwarePaginator $paginator */
$paginator = $query->orderByDesc('promotion_id')->paginate($perPage, ['*'], 'page', $page);
/** @var EloquentCollection<int,StudentPromotionRecord> $records */
$records = $paginator->getCollection();
$studentIds = $records->pluck('student_id')->unique()->values()->all();
$parentIds = $records->pluck('parent_id')->filter()->unique()->values()->all();
$studentsById = !empty($studentIds)
? Student::query()->whereIn('id', $studentIds)->get()->keyBy('id')
: new EloquentCollection();
$parentsById = !empty($parentIds)
? User::query()->whereIn('id', $parentIds)->get()->keyBy('id')
: new EloquentCollection();
$rows = $records->map(function (StudentPromotionRecord $record) use ($studentsById, $parentsById) {
return $this->presentAdminRow(
$record,
$studentsById->get($record->student_id),
$record->parent_id ? $parentsById->get($record->parent_id) : null
);
})->all();
return [
'data' => array_values($rows),
'total' => (int) $paginator->total(),
'page' => (int) $paginator->currentPage(),
'per_page' => (int) $paginator->perPage(),
'total_pages' => (int) $paginator->lastPage(),
'counts_by_status' => $this->countsByStatus($filters),
];
}
/**
* Returns the per-status count of promotion records that match the
* given filter set (ignoring `status` and pagination filters). Plan
* section 16's reports rely on these counts.
*
* @return array<string,int>
*/
public function countsByStatus(array $filters): array
{
$query = StudentPromotionRecord::query();
// Apply non-status filters only.
$filtersSansStatus = $filters;
unset($filtersSansStatus['status'], $filtersSansStatus['parent_action_required']);
$this->applyFilters($query, $filtersSansStatus);
$rows = $query
->select('promotion_status', DB::raw('count(*) as total'))
->groupBy('promotion_status')
->get();
$counts = array_fill_keys(StudentPromotionRecord::ALL_STATUSES, 0);
foreach ($rows as $row) {
$counts[(string) $row->promotion_status] = (int) $row->total;
}
return $counts;
}
/**
* Hydrates a single record with student + parent context for the
* admin detail view.
*/
public function detail(StudentPromotionRecord $record): array
{
$student = Student::query()->find($record->student_id);
$parent = $record->parent_id ? User::query()->find($record->parent_id) : null;
return $this->presentAdminRow($record, $student, $parent, true);
}
/**
* @param Builder<StudentPromotionRecord> $query
*/
private function applyFilters(Builder $query, array $filters): void
{
if (!empty($filters['status'])) {
$statuses = is_array($filters['status']) ? $filters['status'] : [$filters['status']];
$statuses = array_values(array_filter($statuses, static fn ($s) => is_string($s) && $s !== ''));
if (!empty($statuses)) {
$query->whereIn('promotion_status', $statuses);
}
}
if (!empty($filters['parent_action_required'])) {
$query->whereIn('promotion_status', StudentPromotionRecord::parentActionableStatuses());
}
if (!empty($filters['next_school_year'])) {
$query->where('next_school_year', $filters['next_school_year']);
}
if (!empty($filters['current_school_year'])) {
$query->where('current_school_year', $filters['current_school_year']);
}
if (!empty($filters['parent_id'])) {
$query->where('parent_id', (int) $filters['parent_id']);
}
if (!empty($filters['student_id'])) {
$query->where('student_id', (int) $filters['student_id']);
}
if (!empty($filters['search'])) {
$search = '%' . strtolower((string) $filters['search']) . '%';
$query->whereIn('student_id', function ($sub) use ($search) {
$sub->select('id')
->from('students')
->where(function ($w) use ($search) {
$w->whereRaw('LOWER(firstname) LIKE ?', [$search])
->orWhereRaw('LOWER(lastname) LIKE ?', [$search]);
});
});
}
}
private function presentAdminRow(
StudentPromotionRecord $record,
?Student $student,
?User $parent,
bool $detail = false
): array {
$row = [
'promotion_id' => (int) $record->getKey(),
'student_id' => (int) $record->student_id,
'student_name' => $student
? trim((string) $student->firstname . ' ' . (string) $student->lastname)
: null,
'school_id' => $student->school_id ?? null,
'parent_id' => $record->parent_id ? (int) $record->parent_id : null,
'parent_name' => $parent
? trim((string) $parent->firstname . ' ' . (string) $parent->lastname)
: null,
'parent_email' => $parent->email ?? null,
'current_school_year' => $record->current_school_year,
'next_school_year' => $record->next_school_year,
'current_level' => $record->current_level_name,
'promoted_level' => $record->promoted_level_name,
'promotion_status' => $record->promotion_status,
'enrollment_status' => $record->enrollment_status,
'enrollment_deadline' => $record->enrollment_deadline?->toDateString(),
'parent_notified_at' => $record->parent_notified_at?->toDateTimeString(),
'enrollment_started_at' => $record->enrollment_started_at?->toDateTimeString(),
'enrollment_completed_at' => $record->enrollment_completed_at?->toDateTimeString(),
'promotion_finalized_at' => $record->promotion_finalized_at?->toDateTimeString(),
'final_average' => $record->final_average !== null ? (float) $record->final_average : null,
'passed_current_level' => $record->passed_current_level,
'updated_by' => $record->updated_by ? (int) $record->updated_by : null,
'updated_at' => $record->updated_at?->toDateTimeString(),
];
if ($detail) {
$row['checklist'] = [
'info_confirmed' => (bool) $record->info_confirmed,
'documents_uploaded' => (bool) $record->documents_uploaded,
'agreement_accepted' => (bool) $record->agreement_accepted,
'payment_completed' => (bool) $record->payment_completed,
];
$row['eligibility_notes'] = $record->eligibility_notes;
$row['enrollment_id'] = $record->enrollment_id ? (int) $record->enrollment_id : null;
}
return $row;
}
}
@@ -0,0 +1,207 @@
<?php
namespace App\Services\Promotions;
use App\Models\PromotionReminderLog;
use App\Models\Student;
use App\Models\StudentPromotionRecord;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Sends reminders to parents and persists a log entry per dispatch
* (plan section 14).
*
* The actual delivery is delegated to whichever notification dispatcher
* is registered. A nullable dispatcher is supported so the service can
* be used from CLI/tests without external side effects.
*/
class PromotionReminderService
{
/** @var (callable(int $userId, string $title, string $body, array $channels):void)|null */
private $dispatcher;
public function __construct(
private PromotionAuditService $audit,
?callable $dispatcher = null
) {
$this->dispatcher = $dispatcher;
}
/**
* Records a single reminder dispatch.
*/
public function send(
StudentPromotionRecord $record,
string $reminderType,
?int $userId = null,
?string $subject = null,
?string $message = null,
array $channels = ['in_app', 'email']
): PromotionReminderLog {
if (!in_array($reminderType, PromotionReminderLog::ALLOWED_TYPES, true)) {
$reminderType = PromotionReminderLog::TYPE_MANUAL;
}
$studentName = $this->studentName($record);
$deadline = $record->enrollment_deadline?->toDateString();
$level = $record->promoted_level_name ?: 'the next level';
$defaultSubject = 'Reminder: complete enrollment for the next school year';
$defaultMessage = sprintf(
'%s is eligible for promotion to %s.%s Please complete enrollment as soon as possible.',
$studentName ?: 'Your child',
$level,
$deadline ? ' Deadline: ' . $deadline . '.' : ''
);
$finalSubject = $subject ?: $defaultSubject;
$finalMessage = $message ?: $defaultMessage;
return DB::transaction(function () use (
$record,
$reminderType,
$finalSubject,
$finalMessage,
$channels,
$userId
) {
$log = PromotionReminderLog::query()->create([
'promotion_id' => (int) $record->getKey(),
'student_id' => (int) $record->student_id,
'parent_id' => (int) $record->parent_id ?: null,
'reminder_type' => $reminderType,
'channel' => implode(',', array_values(array_unique(array_filter($channels)))),
'subject' => $finalSubject,
'message' => $finalMessage,
'sent_at' => now(),
'sent_by' => $userId,
]);
if ($record->parent_id) {
$this->dispatch((int) $record->parent_id, $finalSubject, $finalMessage, $channels);
}
if (!$record->parent_notified_at) {
$record->parent_notified_at = now();
$record->save();
$this->audit->logParentNotified($record, $userId, $reminderType);
}
$this->audit->logReminderSent($record, $reminderType, $userId);
return $log;
});
}
/**
* Plan section 14 schedule-driven reminders. Sends the next due
* reminder type for every actionable record whose deadline is set.
*
* @return array{ processed:int, sent:int, skipped:int }
*/
public function dispatchScheduledReminders(?\DateTimeInterface $now = null, ?int $userId = null): array
{
$now = CarbonImmutable::instance($now ?: now());
$records = StudentPromotionRecord::query()
->whereIn('promotion_status', StudentPromotionRecord::parentActionableStatuses())
->whereNotNull('enrollment_deadline')
->get();
$processed = 0;
$sent = 0;
$skipped = 0;
foreach ($records as $record) {
$processed++;
$type = $this->resolveReminderType($record, $now);
if ($type === null) {
$skipped++;
continue;
}
$alreadySent = PromotionReminderLog::query()
->where('promotion_id', $record->getKey())
->where('reminder_type', $type)
->exists();
if ($alreadySent) {
$skipped++;
continue;
}
try {
$this->send($record, $type, $userId);
$sent++;
} catch (\Throwable $e) {
Log::error('Promotion reminder dispatch failed', [
'promotion_id' => $record->getKey(),
'exception' => $e,
]);
$skipped++;
}
}
return [
'processed' => $processed,
'sent' => $sent,
'skipped' => $skipped,
];
}
/**
* Returns the reminder type that should be sent next based on the
* deadline / current time.
*/
private function resolveReminderType(StudentPromotionRecord $record, CarbonImmutable $now): ?string
{
$deadline = $record->enrollment_deadline ? CarbonImmutable::instance($record->enrollment_deadline) : null;
if ($deadline === null) {
return null;
}
$createdAt = $record->created_at ? CarbonImmutable::instance($record->created_at) : $now;
if ($now->greaterThan($deadline)) {
return PromotionReminderLog::TYPE_EXPIRATION;
}
$totalSeconds = max(1, $deadline->getTimestamp() - $createdAt->getTimestamp());
$elapsedSeconds = max(0, $now->getTimestamp() - $createdAt->getTimestamp());
$remainingSeconds = max(0, $deadline->getTimestamp() - $now->getTimestamp());
$remainingDays = (int) floor($remainingSeconds / 86400);
if ($elapsedSeconds === 0) {
return PromotionReminderLog::TYPE_FIRST;
}
if ($remainingDays <= 3) {
return PromotionReminderLog::TYPE_FINAL;
}
if ($elapsedSeconds * 2 >= $totalSeconds) {
return PromotionReminderLog::TYPE_HALFWAY;
}
return PromotionReminderLog::TYPE_FIRST;
}
private function studentName(StudentPromotionRecord $record): ?string
{
$student = Student::query()->find($record->student_id);
if (!$student) {
return null;
}
$name = trim((string) ($student->firstname ?? '') . ' ' . (string) ($student->lastname ?? ''));
return $name !== '' ? $name : null;
}
private function dispatch(int $userId, string $subject, string $message, array $channels): void
{
if (!$this->dispatcher) {
return;
}
try {
($this->dispatcher)($userId, $subject, $message, $channels);
} catch (\Throwable $e) {
Log::error('Promotion reminder dispatcher threw', [
'user_id' => $userId,
'exception' => $e,
]);
}
}
}
@@ -0,0 +1,164 @@
<?php
namespace App\Services\Promotions;
use App\Models\StudentPromotionRecord;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use RuntimeException;
/**
* State machine for promotion records (plan sections 3, 4, 7).
*
* All status transitions go through this service so the audit trail
* stays in sync (plan section 18).
*/
class PromotionStatusService
{
public function __construct(private PromotionAuditService $audit)
{
}
/**
* Map of allowed transitions from current → set of next statuses.
* Edge cases (manual overrides) are handled by `forceStatus`.
*/
private const ALLOWED_TRANSITIONS = [
StudentPromotionRecord::STATUS_NOT_REVIEWED => [
StudentPromotionRecord::STATUS_ELIGIBLE,
StudentPromotionRecord::STATUS_AWAITING_PARENT,
StudentPromotionRecord::STATUS_CONDITIONAL,
StudentPromotionRecord::STATUS_REPEATED,
StudentPromotionRecord::STATUS_ON_HOLD,
StudentPromotionRecord::STATUS_GRADUATED,
StudentPromotionRecord::STATUS_WITHDRAWN,
],
StudentPromotionRecord::STATUS_ELIGIBLE => [
StudentPromotionRecord::STATUS_AWAITING_PARENT,
StudentPromotionRecord::STATUS_ENROLLMENT_STARTED,
StudentPromotionRecord::STATUS_CONDITIONAL,
StudentPromotionRecord::STATUS_ON_HOLD,
StudentPromotionRecord::STATUS_NOT_ENROLLED,
StudentPromotionRecord::STATUS_WITHDRAWN,
],
StudentPromotionRecord::STATUS_AWAITING_PARENT => [
StudentPromotionRecord::STATUS_ENROLLMENT_STARTED,
StudentPromotionRecord::STATUS_PROMOTED_AND_ENROLLED,
StudentPromotionRecord::STATUS_NOT_ENROLLED,
StudentPromotionRecord::STATUS_ON_HOLD,
StudentPromotionRecord::STATUS_WITHDRAWN,
],
StudentPromotionRecord::STATUS_ENROLLMENT_STARTED => [
StudentPromotionRecord::STATUS_PROMOTED_AND_ENROLLED,
StudentPromotionRecord::STATUS_AWAITING_PARENT,
StudentPromotionRecord::STATUS_NOT_ENROLLED,
StudentPromotionRecord::STATUS_ON_HOLD,
StudentPromotionRecord::STATUS_WITHDRAWN,
],
StudentPromotionRecord::STATUS_CONDITIONAL => [
StudentPromotionRecord::STATUS_ELIGIBLE,
StudentPromotionRecord::STATUS_AWAITING_PARENT,
StudentPromotionRecord::STATUS_REPEATED,
StudentPromotionRecord::STATUS_ON_HOLD,
StudentPromotionRecord::STATUS_NOT_REVIEWED,
],
StudentPromotionRecord::STATUS_ON_HOLD => [
StudentPromotionRecord::STATUS_NOT_REVIEWED,
StudentPromotionRecord::STATUS_ELIGIBLE,
StudentPromotionRecord::STATUS_AWAITING_PARENT,
StudentPromotionRecord::STATUS_REPEATED,
StudentPromotionRecord::STATUS_CONDITIONAL,
StudentPromotionRecord::STATUS_WITHDRAWN,
StudentPromotionRecord::STATUS_NOT_ENROLLED,
],
// Terminal statuses are intentionally not in the map (no auto
// transitions); admins can still override via `forceStatus`.
];
public function canTransition(string $from, string $to): bool
{
if ($from === $to) {
return true;
}
if (!in_array($to, StudentPromotionRecord::ALL_STATUSES, true)) {
return false;
}
$allowed = self::ALLOWED_TRANSITIONS[$from] ?? [];
return in_array($to, $allowed, true);
}
/**
* Apply a status transition with audit logging. Returns the saved
* record. Throws if the new status is invalid or the transition is
* not allowed.
*/
public function transition(
StudentPromotionRecord $record,
string $newStatus,
?int $userId = null,
?string $notes = null
): StudentPromotionRecord {
if (!in_array($newStatus, StudentPromotionRecord::ALL_STATUSES, true)) {
throw new InvalidArgumentException(sprintf('Unknown promotion status "%s".', $newStatus));
}
$oldStatus = (string) $record->promotion_status;
if ($oldStatus === $newStatus) {
return $record;
}
if (!$this->canTransition($oldStatus, $newStatus)) {
throw new RuntimeException(sprintf(
'Transition from "%s" to "%s" is not allowed.',
$oldStatus,
$newStatus
));
}
return DB::transaction(function () use ($record, $oldStatus, $newStatus, $userId, $notes) {
$record->promotion_status = $newStatus;
$record->updated_by = $userId;
$record->save();
$this->audit->logStatusChange($record, $oldStatus, $newStatus, $userId, $notes);
return $record;
});
}
/**
* Force a status change ignoring the transition map (e.g. admin
* override). The audit trail records this as a manual override.
*/
public function forceStatus(
StudentPromotionRecord $record,
string $newStatus,
?int $userId = null,
?string $notes = null
): StudentPromotionRecord {
if (!in_array($newStatus, StudentPromotionRecord::ALL_STATUSES, true)) {
throw new InvalidArgumentException(sprintf('Unknown promotion status "%s".', $newStatus));
}
$oldStatus = (string) $record->promotion_status;
if ($oldStatus === $newStatus) {
return $record;
}
return DB::transaction(function () use ($record, $oldStatus, $newStatus, $userId, $notes) {
$record->promotion_status = $newStatus;
$record->updated_by = $userId;
$record->save();
$this->audit->logManualOverride(
$record,
'promotion_status',
$oldStatus,
$newStatus,
$userId,
$notes ?? 'Manual status override'
);
return $record;
});
}
}