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.
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')
->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;
}
}
+77 -9
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();
} catch (\Throwable $e) {
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'));
$amount = $type === 'deduct' ? -abs($amount) : abs($amount);
$additionalSubtotal += $amount;
if (in_array($type, ['deduct', 'previous'], true)) {
$amount = -abs($amount);
} else {
$amount = abs($amount);
}
} catch (\Throwable $e) {
Log::warning('Failed to sum additional charges.', ['error' => $e->getMessage()]);
$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,
];
}
}
+159 -41
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);
// 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);
$feeByStudentId = [];
foreach ($allStudents as $student) {
$key = $student['student_id'] ?? null;
if ($key === null) {
continue;
}
$feeByStudentId[(string) $key] = (float) ($student['tuition_fee'] ?? 0);
}
$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;
$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 ?? ''));
$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' => trim((string) ($user->gender ?? '')) !== '' ? (string) $user->gender : '-',
'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,35 +49,46 @@ 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,
]));
'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'],
]));
'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;
});
}
}
Executable → Regular
View File
+21 -6
View File
@@ -16,20 +16,35 @@ return Application::configure(basePath: dirname(__DIR__))
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'auth.multi' => \App\Http\Middleware\MultiAuth::class,
// JWT auth
'jwt.auth' => \PHPOpenSourceSaver\JWTAuth\Http\Middleware\Authenticate::class,
'jwt.refresh' => \PHPOpenSourceSaver\JWTAuth\Http\Middleware\RefreshToken::class,
'api.jwt' => \App\Http\Middleware\ApiJwtAuth::class,
// Multi-guard auth (both the new and legacy alias point at the same class)
'auth.multi' => \App\Http\Middleware\MultiAuth::class,
'multi.auth' => \App\Http\Middleware\MultiAuth::class,
// Permission / role gates
'perm' => \App\Http\Middleware\RequirePermission::class,
'timezone' => \App\Http\Middleware\PrimeTimezone::class,
'cleanup.scheduler' => \App\Http\Middleware\CleanupScheduler::class,
'auth.docs' => \App\Http\Middleware\ApiDocsAuth::class,
'teacher.portal.auth' => \App\Http\Middleware\TeacherPortalAuthenticate::class,
'teacher.progress' => \App\Http\Middleware\EnsureClassProgressTeacherPortalAccess::class,
'require.permission' => \App\Http\Middleware\RequirePermission::class,
'admin.access' => \App\Http\Middleware\EnsurePrintRequestsAdminAccess::class,
'parent.access' => \App\Http\Middleware\EnsureParentProgressAccess::class,
'parent.progress' => \App\Http\Middleware\EnsureParentProgressAccess::class,
'teacher.progress' => \App\Http\Middleware\EnsureClassProgressTeacherPortalAccess::class,
'class_progress.teacher' => \App\Http\Middleware\EnsureClassProgressTeacherPortalAccess::class,
'print_requests.teacher' => \App\Http\Middleware\EnsurePrintRequestsTeacherAccess::class,
'print_requests.admin' => \App\Http\Middleware\EnsurePrintRequestsAdminAccess::class,
'badge_scan.logs' => \App\Http\Middleware\EnsureBadgeScanLogsAccess::class,
// Docs + teacher portal
'auth.docs' => \App\Http\Middleware\ApiDocsAuth::class,
'teacher.portal.auth' => \App\Http\Middleware\TeacherPortalAuthenticate::class,
// Utility
'timezone' => \App\Http\Middleware\PrimeTimezone::class,
'prime.timezone' => \App\Http\Middleware\PrimeTimezone::class,
'cleanup.scheduler' => \App\Http\Middleware\CleanupScheduler::class,
]);
$middleware->redirectGuestsTo(function (Request $request) {
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('level_progressions')) {
return;
}
Schema::create('level_progressions', function (Blueprint $table) {
$table->id();
$table->unsignedInteger('current_level_id')->nullable()->index();
$table->string('current_level_name', 100);
$table->unsignedInteger('next_level_id')->nullable()->index();
$table->string('next_level_name', 100)->nullable();
$table->unsignedSmallInteger('order_index')->default(0);
$table->boolean('is_terminal')->default(false);
$table->boolean('is_active')->default(true);
$table->text('notes')->nullable();
$table->timestamps();
$table->unique('current_level_name', 'uq_level_progressions_current_level_name');
});
}
public function down(): void
{
Schema::dropIfExists('level_progressions');
}
};
@@ -0,0 +1,64 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('student_promotion_records')) {
return;
}
Schema::create('student_promotion_records', function (Blueprint $table) {
$table->id('promotion_id');
$table->unsignedInteger('student_id')->index();
$table->unsignedInteger('parent_id')->nullable()->index();
$table->string('current_school_year', 9);
$table->string('next_school_year', 9);
$table->unsignedInteger('current_level_id')->nullable();
$table->unsignedInteger('promoted_level_id')->nullable();
$table->string('current_level_name', 100)->nullable();
$table->string('promoted_level_name', 100)->nullable();
// Promotion lifecycle status (matches plan section 3)
$table->string('promotion_status', 40)->default('not_reviewed');
$table->boolean('passed_current_level')->nullable();
$table->decimal('final_average', 6, 2)->nullable();
$table->boolean('attendance_ok')->nullable();
$table->text('eligibility_notes')->nullable();
$table->boolean('enrollment_required')->default(true);
$table->string('enrollment_status', 40)->default('not_started');
$table->unsignedBigInteger('enrollment_id')->nullable()->index();
$table->dateTime('parent_notified_at')->nullable();
$table->date('enrollment_deadline')->nullable();
$table->dateTime('enrollment_started_at')->nullable();
$table->dateTime('enrollment_completed_at')->nullable();
$table->dateTime('promotion_finalized_at')->nullable();
$table->boolean('info_confirmed')->default(false);
$table->boolean('documents_uploaded')->default(false);
$table->boolean('agreement_accepted')->default(false);
$table->boolean('payment_completed')->default(false);
$table->unsignedInteger('updated_by')->nullable();
$table->timestamps();
$table->unique(['student_id', 'next_school_year'], 'uq_promotion_student_year');
$table->index('promotion_status', 'idx_promotion_status');
$table->index(['next_school_year', 'promotion_status'], 'idx_promotion_year_status');
});
}
public function down(): void
{
Schema::dropIfExists('student_promotion_records');
}
};
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('promotion_audit_log')) {
return;
}
Schema::create('promotion_audit_log', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('promotion_id')->nullable()->index();
$table->unsignedInteger('student_id')->nullable()->index();
$table->unsignedInteger('user_id')->nullable()->comment('Actor that performed the action');
$table->string('action_type', 60);
$table->string('field', 60)->nullable();
$table->string('old_value', 191)->nullable();
$table->string('new_value', 191)->nullable();
$table->text('notes')->nullable();
$table->timestamp('performed_at')->useCurrent();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('promotion_audit_log');
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('promotion_reminders_log')) {
return;
}
Schema::create('promotion_reminders_log', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('promotion_id')->index();
$table->unsignedInteger('student_id')->nullable()->index();
$table->unsignedInteger('parent_id')->nullable();
$table->string('reminder_type', 40)->default('manual')
->comment('first|halfway|final|expiration|manual');
$table->string('channel', 20)->default('in_app');
$table->string('subject', 191)->nullable();
$table->text('message')->nullable();
$table->dateTime('sent_at')->nullable();
$table->unsignedInteger('sent_by')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('promotion_reminders_log');
}
};
BIN
View File
Binary file not shown.
@@ -0,0 +1,126 @@
# `AdministratorPromotionController` plan
## Scope
- **Controller**: `app/Http/Controllers/Api/Administrator/AdministratorPromotionController.php`
- **Purpose**: admin/registrar API to manage promotion records and parent enrollment lifecycle.
- **Primary dependencies**:
- Query/detail: `PromotionQueryService`
- Eligibility evaluation: `PromotionEligibilityService`
- Status transitions: `PromotionStatusService`
- Parent enrollment workflow: `PromotionEnrollmentService`
- Reminders: `PromotionReminderService`
- Auditing: `PromotionAuditService`
- Level mapping: `LevelProgressionService`
- **Models**:
- `StudentPromotionRecord`
- `PromotionAuditLog`
- `PromotionReminderLog`
## Routes
All routes are under:
- Base prefix: `/api/v1/administrator/promotions`
- Middleware: `auth:api`, `admin.access`
Endpoints:
- Listing & detail
- `GET /api/v1/administrator/promotions/``index`
- `GET /api/v1/administrator/promotions/summary``summary`
- `GET /api/v1/administrator/promotions/{promotionId}``show`
- Eligibility & status
- `POST /api/v1/administrator/promotions/evaluate``evaluate`
- `PATCH /api/v1/administrator/promotions/{promotionId}/status``updateStatus`
- Deadlines
- `POST /api/v1/administrator/promotions/deadlines``setDeadline` (bulk/single via body)
- `POST /api/v1/administrator/promotions/{promotionId}/deadline``setDeadline` (single)
- `POST /api/v1/administrator/promotions/deadlines/expire``expireDeadlines`
- Enrollment steps (admin override)
- `PATCH /api/v1/administrator/promotions/{promotionId}/enrollment-steps``adminCompleteEnrollment`
- Reminders & audit
- `POST /api/v1/administrator/promotions/{promotionId}/reminders``sendReminder`
- `GET /api/v1/administrator/promotions/{promotionId}/reminders``reminders`
- `POST /api/v1/administrator/promotions/reminders/dispatch``dispatchScheduledReminders`
- `GET /api/v1/administrator/promotions/{promotionId}/audit``audit`
- Level progressions (mapping current level → promoted level)
- `GET /api/v1/administrator/promotions/levels/``levelProgressions`
- `POST /api/v1/administrator/promotions/levels/``upsertLevelProgression`
- `POST /api/v1/administrator/promotions/levels/seed``seedLevelProgressions`
## Authentication & authorization
- Admin access enforced by route middleware.
- `getCurrentUserId()` used for attribution in audit logs and updates.
## Requests/validation
Validated via `FormRequest` classes:
- `index` / `summary`: `AdminListPromotionsRequest` (`filters()`)
- `evaluate`: `EvaluateEligibilityRequest` (supports scope: `student`, `class_section`, `school_year`)
- `updateStatus`: `UpdatePromotionStatusRequest` (supports `force` + `notes`)
- `setDeadline`: `SetEnrollmentDeadlineRequest` (supports `apply_to` modes and optional `promotion_ids`)
- `sendReminder`: `SendReminderRequest` (type, subject/message, channels)
- `adminCompleteEnrollment`: `ParentEnrollmentStepRequest` (`steps()`)
- `upsertLevelProgression`: `UpsertLevelProgressionRequest`
## Response shapes
This controller uses legacy `{ ok: ... }` shapes (not Base API envelope).
- `index`:
- `{ ok:true, data:[StudentPromotionResource...], pagination:{page, per_page, total, total_pages}, counts_by_status }`
- `show`:
- `{ ok:true, data: StudentPromotionResource }`
- `summary`:
- `{ ok:true, counts_by_status, total }`
- `evaluate`:
- `{ ok:true, result:{ scope, ... } }`
- `updateStatus`:
- `{ ok:true, data: StudentPromotionResource }`
- Errors:
- 422 for invalid args
- 409 for transition conflicts
- `setDeadline`:
- `{ ok:true, updated, promotion_ids:[...] }`
- `sendReminder`:
- `{ ok:true, reminder: PromotionReminderResource }`
- `dispatchScheduledReminders` / `expireDeadlines`:
- `{ ok:true, result }`
- `reminders`:
- `{ ok:true, reminders:[PromotionReminderResource...] }`
- `audit`:
- `{ ok:true, entries:[PromotionAuditLogResource...] }`
- `adminCompleteEnrollment`:
- `{ ok:true, data: StudentPromotionResource }` (409 on conflict)
- `levelProgressions`:
- `{ ok:true, levels:[LevelProgressionResource...] }`
- `upsertLevelProgression`:
- `{ ok:true, level: LevelProgressionResource }`
- `seedLevelProgressions`:
- `{ ok:true, created }`
## Side effects
- Deadline updates are wrapped in `DB::transaction` and log audit entries via `PromotionAuditService`.
- Reminder sending records `PromotionReminderLog` and may send via multiple channels.
- Status transitions update `StudentPromotionRecord` and write audit entries via services.
## Error handling expectations
- `findOrFail()` returns 404 `{ ok:false, message:"Promotion record not found." }`.
- `evaluate()` catches unexpected errors and returns 500 with generic message.
- `sendReminder()` catches unexpected errors and returns 500 with generic message.
## Test plan (feature)
- AuthZ:
- Non-admin users denied by middleware.
- List & filters:
- Pagination and `counts_by_status` match expected totals.
- Evaluate:
- Each scope returns expected payload keys; unexpected errors return 500.
- Status transitions:
- Valid transitions succeed; invalid args return 422; invalid transitions return 409.
- Deadlines:
- Single record update; bulk update by year; bulk update by list.
- Audit log created for each updated record.
- Reminders:
- Manual reminder creates log and returns resource.
- Dispatch scheduled reminders returns summary result.
- Enrollment steps:
- Admin patch updates steps and returns updated record; conflicts return 409.
- Levels:
- List returns mappings; upsert creates/updates; seed inserts defaults.
+74
View File
@@ -0,0 +1,74 @@
# `AuthController` plan
## Scope
- **Controller**: `app/Http/Controllers/Api/Auth/AuthController.php`
- **Purpose**: JWT login, token refresh, current-user lookup, logout.
- **Primary dependencies**:
- `App\Services\Auth\ApiLoginSecurityService` (rate limiting / IP blocking, attempt tracking, response builder)
- `PHPOpenSourceSaver\JWTAuth\Facades\JWTAuth` (token issuance/refresh/invalidation)
- `Illuminate\Support\Facades\Auth` (current user)
## Routes
- **Public (no `/v1`)**
- `POST /api/login``login`
- **Versioned (`/v1`)**
- `POST /api/v1/login``login`
- **Auth prefix (`/v1/auth`)**
- `POST /api/v1/auth/login``login`
- `POST /api/v1/auth/refresh``refresh` (middleware: `jwt.auth`)
- `POST /api/v1/auth/logout``logout` (middleware: `auth:api`)
- `GET /api/v1/auth/me``me` (middleware: `auth:api`)
## Authentication & authorization
- **`login`**: public; checks credentials, then issues JWT.
- **`refresh`**: requires a valid JWT via `jwt.auth`.
- **`me`/`logout`**: require `auth:api` (JWT/guarded user).
## Request/validation expectations
- **`login`**:
- Accepts JSON or form payload. Extracts `email`, `password`.
- Returns `400` if missing.
- Normalizes email to lowercase; looks up `User` by case-insensitive email.
- **`refresh`**:
- Expects bearer token in request (handled by `JWTAuth::parseToken()`).
- **`logout`**:
- Attempts JWT invalidation when bearer token looks like a JWT (3 dot-separated segments).
- Also deletes `currentAccessToken()` when supported (supports Sanctum-style tokens if present).
## Response shapes
- **`login` success**: `ApiLoginSecurityService::buildLoginResponse($user, $jwtToken)` (treat as canonical shape).
- **`login` failures**:
- `400`: `{ status:false, message:"Email and password are required." }`
- `401`: `{ status:false, message:"Invalid email or password." }`
- `403`: `{ status:false, message:"Account suspended. Please reset your password." }`
- `429`: `{ status:false, message:"Too many failed attempts..." }`
- **`refresh` success** (Base API shape):
- `{ status:true, message:"Token refreshed.", data:{ access_token, token_type:"bearer", expires_in } }`
- **`me` success** (Base API shape):
- `{ status:true, message:"OK", data:{ id, firstname, lastname, email, class_section_id, class_section_name } }`
- **`logout` success** (Base API shape):
- `{ status:true, message:"Logged out.", data:null }`
## Side effects & security notes
- Logs IP attempt / failed attempt / successful login via `ApiLoginSecurityService`.
- Suspended users are rejected **after** verifying credentials (avoids email enumeration by response shape).
## Error handling expectations
- `refresh`: any exception becomes `401` via `respondError('Token refresh failed.', 401)`.
- `logout`: JWT invalidation errors are intentionally ignored.
## Test plan (feature)
- **Login**
- Missing email/password → `400`.
- Unknown email → `401` and IP attempt logged.
- Wrong password → `401` and failed attempt handling invoked.
- Suspended user with correct password → `403`.
- Successful login returns expected response shape and includes token.
- IP blocked → `429`.
- **Refresh**
- Valid token refresh returns new token + expires_in.
- Invalid/missing token returns `401`.
- **Me/Logout**
- Requires auth; returns `401` unauthenticated.
- Logout returns success even if token invalidation fails.
@@ -0,0 +1,70 @@
# `AuthorizedUserInviteController` plan
## Scope
- **Controller**: `app/Http/Controllers/Api/Parents/AuthorizedUserInviteController.php`
- **Purpose**: public invite confirmation + password setup for “authorized users” invited by a parent.
- **Primary dependency**: `App\Services\Parents\AuthorizedUsersManagementService`
## Routes
- `GET /api/confirm_authorized_user?token=...``confirm`
- `GET /api/set_authorized_user_password/{authorizedUserId}?token=...``setPasswordForm`
- `POST /api/set_authorized_user_password/{authorizedUserId}``savePassword`
- Path constraint: `{authorizedUserId}` is numeric
## Authentication & authorization
- These endpoints are **public** and rely on **invite tokens** for authorization.
- Redirects are protected via an allowlist check (`isTrustedRedirectUrl()`).
## Endpoint behavior
### `confirm`
- Input:
- Query param `token` (string)
- Calls `AuthorizedUsersManagementService::confirmEmailClick($token)` and expects `redirect_url`.
- Output:
- If request expects JSON: `{ message:"Confirmed.", redirect_url }`
- Otherwise redirects the browser to `redirect_url` via `redirect()->away(...)`
- Errors:
- Invalid token → `400` (JSON or minimal HTML page)
- Untrusted redirect URL → `400` JSON `{ message:"Invalid redirect URL." }`
### `setPasswordForm`
- Input:
- Route param `authorizedUserId`
- Query param `token`
- Calls `AuthorizedUsersManagementService::findActiveSetupRow($authorizedUserId, $token)`.
- Output:
- JSON mode: `{ message:"OK", user_id, token }`
- HTML mode: instructional HTML (no actual password form UI)
- Errors:
- Invalid/expired link → `400` (JSON or plain text)
### `savePassword`
- Input (JSON or form):
- `password` (required; min 8; must include upper/lower/number/special)
- `password_confirmation` or `password_confirm` (one required; must match `password`)
- `user_id` (required int) — expected to match the authorized users owning parent id
- `token` (required string)
- Calls `AuthorizedUsersManagementService::setAuthorizedUserPassword(...)`.
- Output:
- Success: `{ message:"Password has been successfully set." }`
- Errors:
- Validation → `422` with `{ message, errors }`
- Invalid token / mismatch / expired setup row → `400` `{ message }`
## Security notes
- **Redirect allowlist**: only hosts matching `app.url`, `app.frontend_url`, or `services.frontend.url`.
- **Password policy** is enforced at the API level via regex rules.
## Test plan (feature)
- `confirm`
- Invalid token → 400 JSON/HTML depending on `Accept`.
- Valid token but redirect host not allowlisted → 400.
- Valid token → JSON includes redirect_url; non-JSON issues redirect.
- `setPasswordForm`
- Invalid token/id → 400.
- Valid token/id → returns JSON payload or HTML string.
- `savePassword`
- Weak password / missing confirm → 422.
- Token mismatch/expired → 400.
- Success → 200 and password is set (can authenticate as that authorized user).
@@ -0,0 +1,76 @@
# `AuthorizedUsersController` plan
## Scope
- **Controller**: `app/Http/Controllers/Api/Parents/AuthorizedUsersController.php`
- **Purpose**: parent CRUD for secondary “authorized users” attached to the parent account.
- **Primary dependency**: `App\Services\Parents\AuthorizedUsersManagementService`
- **Model**: `App\Models\AuthorizedUser`
## Routes
All routes are under `v1` + `parents` group:
- Base prefix: `GET/POST/PATCH/DELETE /api/v1/parents/...`
- Group middleware: `parent.access`
Endpoints:
- `GET /api/v1/parents/authorized-users``index`
- `GET /api/v1/parents/authorized-users/{id}``show`
- `POST /api/v1/parents/authorized-users``store`
- `PATCH /api/v1/parents/authorized-users/{id}``update`
- `DELETE /api/v1/parents/authorized-users/{id}``destroy`
## Authentication & authorization
- Requires authenticated parent context (effectively enforced by `parent.access` + `auth()` usage).
- Ownership check:
- All record reads/writes are constrained to `AuthorizedUser.user_id = parentId`.
## Requests/validation
- `store`: `StoreAuthorizedUserRequest`
- Reads validated `email` and lowercases/trims before invite.
- `update`: `UpdateAuthorizedUserRequest`
- Supports email change; re-invites when email changed.
## Endpoint behavior
### `index`
- Returns all authorized users for the parent ordered by id.
- Response shape uses Base API envelope:
- `{ status:true, message:"Success", data:[...] }`
### `show`
- Loads an authorized user row for this parent or returns 404.
- Response shape: Base API envelope with the model row.
### `store`
- Invites by email via `AuthorizedUsersManagementService::inviteByEmail($parentId, $email)`.
- If invite already existed / not newly created:
- Returns `200` success with message explaining email sent conditionally.
- If created:
- Returns `201` with created id.
### `update`
- If email provided:
- Calls `changeEmailAndReinvite($row, $email)`
- On invalid argument: returns `422` with error message
- If email not provided:
- Returns success message (no-op update)
### `destroy`
- Deletes the owned record and returns success message.
## Error handling
- Unauthenticated → `401` Base API error `"Authentication required."`
- Not found → `404` Base API error `"Authorized user not found."`
- Invalid email change → `422` Base API error with exception message
## Test plan (feature)
- Auth required:
- Unauthenticated requests → 401 for all endpoints.
- Ownership:
- Parent cannot read/update/delete someone elses authorized user (404).
- Store:
- New email creates record → 201 + id.
- Existing/duplicate invite path → 200 with “If the email belongs…” message.
- Update:
- Email change triggers reinvite; invalid email triggers 422.
- Delete:
- Owned record deleted; subsequent fetch returns 404.
+66
View File
@@ -0,0 +1,66 @@
# `BaseApiController` plan
## Scope
- **Controller**: `app/Http/Controllers/Api/Core/BaseApiController.php`
- **Purpose**: shared API conveniences:
- Standard success/error JSON envelope helpers
- Request payload normalization (JSON body vs query/form)
- Validation helper compatible with legacy CodeIgniter-like rules
- Lightweight pagination helper for arrays/query builders
- Current-user resolution helpers (id + role list)
## Key responsibilities
### Response helpers
- `success($data, $message, $code)``{ status:true, message, data }`
- `error($message, $code, $errors?)``{ status:false, message, errors? }`
- Convenience wrappers:
- `respondSuccess`, `respondCreated`, `respondDeleted`, `respondError`, `respondValidationError`
### Request normalization
- Stores the underlying Laravel request as `laravelRequest`.
- Wraps it in `CiRequestAdapter` as `$this->request` for compatibility patterns.
- `payloadData()`:
- Uses JSON-decoded body when present and valid
- Falls back to merged query + form data
### Validation
- `validateRequest($data, $rules)`:
- Normalizes CodeIgniter-like rule strings into Laravel rules via `normalizeRules()`.
- Saves validator to `$this->validator`.
- Returns `[]` on success, or `errors()->toArray()` on failure.
- `validate($rules)` validates against `payloadData()` and returns boolean.
### Pagination
- `paginate($source, $page, $perPage)` supports:
- Eloquent builder / query builder
- Plain arrays
- Returns `{ data: [...], pagination:{ current_page, per_page, total, total_pages } }`
### Current user helpers
- `getCurrentUserId()` resolves authenticated id from multiple sources.
- `getCurrentUser()`:
- Loads `User` row
- Computes display name
- Loads roles via `user_roles`/`roles` join (best-effort; logs error and returns `roles: []` if it fails)
## Usage guidance (project conventions)
- **Controllers extending `BaseApiController`** should prefer:
- `respondSuccess`/`respondError` for consistent envelopes where possible.
- Returning explicit `{ ok: true }` shapes only when required for legacy client compatibility.
- **Validation**:
- Prefer dedicated `FormRequest` classes for new endpoints.
- Use `validateRequest/normalizeRules` only for legacy/bridge use cases.
## Test plan (unit)
- `normalizeRules()` conversions:
- `max_length[n]``max:n`, `min_length[n]``min:n`, `valid_email``email`, `permit_empty``nullable`, `is_unique[t.c]``unique:t,c`, etc.
- `payloadData()`:
- JSON body takes precedence when valid
- Falls back to query+form when no JSON
- `paginate()`:
- Eloquent builder: returns expected total + slices
- Array source: returns expected slice
- `getCurrentUser()`:
- No auth → `null`
- Role query failure → returns user with empty roles and logs error
+75
View File
@@ -0,0 +1,75 @@
# `ChargeController` plan
## Scope
- **Controller**: `app/Http/Controllers/Api/Finance/ChargeController.php`
- **Purpose**: list charges for a parent and manage extra/event charges (create/cancel/apply/mark paid).
- **Primary dependency**: `App\Services\Billing\ChargeService`
- **Resources**:
- `ChargeListResource` (for list responses)
- `ChargeActionResource` (for mutation responses)
## Routes
All routes are under:
- Base prefix: `/api/v1/finance/fees/charges`
Endpoints:
- `GET /api/v1/finance/fees/charges/``index`
- `POST /api/v1/finance/fees/charges/extra``storeExtra`
- `POST /api/v1/finance/fees/charges/event``storeEvent`
- `POST /api/v1/finance/fees/charges/extra/{id}/cancel``cancelExtra`
- `POST /api/v1/finance/fees/charges/extra/{id}/apply``applyExtra`
- `POST /api/v1/finance/fees/charges/event/{id}/cancel``cancelEvent`
- `POST /api/v1/finance/fees/charges/event/{id}/mark-paid``markEventPaid`
## Authentication & authorization
- `storeExtra` / `storeEvent` require an authenticated user id (checked by `authenticatedUserIdOrUnauthorized()`).
- Other endpoints currently do not call the helper; authorization is expected to be enforced by surrounding route middleware and/or service-layer checks.
## Requests/validation
- `index`: `ChargeListRequest` (`parent_id` required; optional `school_year`)
- `storeExtra`: `StoreExtraChargeRequest` (+ adds `created_by` from authenticated user)
- `storeEvent`: `StoreEventChargeRequest` (+ adds `created_by`)
- `applyExtra`: `ApplyExtraChargeRequest` (`invoice_id`)
- `markEventPaid`: `MarkEventChargePaidRequest` (`paid`, optional `payment_id`)
- `cancelEvent`: uses raw `Request` with query boolean `issue_credit` (default true)
## Response shapes
Legacy `{ ok: ... }` shape:
- `index`:
- `{ ok:true, charges: ChargeListResource }`
- Mutations:
- `{ ok:(bool), charge: ChargeActionResource }`
- HTTP status:
- Create success: `201`
- Duplicate charge success-case: `409` (when `error === "duplicate_charge"`)
- Validation/business failure: `422`
- Cancel/apply/mark-paid: `200` when ok, else `422`
## Side effects
- `storeExtra` / `storeEvent` attach `created_by` for auditing.
- `cancelEvent` optionally issues credits (controlled via `issue_credit` query flag).
- `markEventPaid` can link to a `payment_id` when supplied.
## Error handling expectations
- `storeExtra` / `storeEvent` catch unexpected exceptions:
- return `500` `{ ok:false, message:"Unable to create ... charge." }`
- Unauthorized (store endpoints) returns:
- `401` `{ ok:false, message:"Unauthorized." }`
## Test plan (feature)
- Listing:
- Returns `ChargeListResource` payload for a parent with charges.
- Create extra/event:
- Unauthenticated → 401.
- Valid payload → 201 and ok true.
- Duplicate charge → 409.
- Service exception → 500.
- Apply extra:
- Valid invoice id applies charge → 200.
- Invalid invoice/charge → 422.
- Cancel:
- Extra cancel → 200/422 depending on service result.
- Event cancel with `issue_credit=false` behaves accordingly.
- Mark paid:
- Toggle paid true/false; supports payment_id link.
@@ -0,0 +1,66 @@
# `FeeCalculationController` plan
## Scope
- **Controller**: `app/Http/Controllers/Api/Finance/FeeCalculationController.php`
- **Purpose**: finance calculation endpoints (refunds, tuition totals/breakdowns, family balances).
- **Primary dependencies**:
- `FeeRefundCalculatorService` (refund calculations)
- `FeeStudentFeeService` (tuition total)
- `TuitionCalculationService` (tuition breakdown)
- `BalanceCalculationService` (family account/balance aggregation)
- **Resources**:
- `FeeRefundResource`
- `FeeTuitionTotalResource`
- `FeeTuitionBreakdownResource`
- `FeeFamilyBalanceResource`
## Routes
All routes are under:
- Base prefix: `/api/v1/finance`
- (Route file shows this under a `finance` group; actual auth middleware depends on the surrounding `v1` group setup.)
Endpoints:
- `POST /api/v1/finance/fees/refund``refund`
- `POST /api/v1/finance/fees/tuition-total``tuitionTotal`
- `POST /api/v1/finance/fees/tuition-breakdown``tuitionBreakdown`
- `POST /api/v1/finance/fees/family-balance``familyBalance`
## Requests/validation
- `refund`: `FeeRefundRequest`
- expects `parent_id` and `students` array
- `tuitionTotal`: `FeeTuitionTotalRequest`
- expects `students` array
- `tuitionBreakdown`: `FeeTuitionBreakdownRequest`
- expects `students` array
- `familyBalance`: `FeeFamilyBalanceRequest`
- expects `parent_id`, `students` array, optional `school_year`
## Response shapes
Legacy `{ ok: ... }` shape:
- `refund`:
- `{ ok:true, refund: FeeRefundResource }`
- `tuitionTotal`:
- `{ ok:true, tuition: FeeTuitionTotalResource({ total_tuition }) }`
- `tuitionBreakdown`:
- `{ ok:true, tuition: FeeTuitionBreakdownResource }`
- `familyBalance`:
- `{ ok:true, account: FeeFamilyBalanceResource }`
## Error handling expectations
- Each endpoint wraps calculation in `try/catch`:
- On exception: logs error and returns `500` with `{ ok:false, message:"Unable to ..." }`
- Validation errors are handled by the FormRequest layer (422).
## Test plan (feature/unit mix)
- Validation:
- Missing required fields → 422.
- Refund:
- Valid input returns `ok:true` and resource fields.
- Service exception returns 500 and message.
- Tuition total/breakdown:
- Calculates expected totals for a known dataset.
- Service exception returns 500.
- Family balance:
- Produces stable account totals for known payments/charges.
- Service exception returns 500.
+96
View File
@@ -0,0 +1,96 @@
# `ParentController` plan
## Scope
- **Controller**: `app/Http/Controllers/Api/Parents/ParentController.php`
- **Purpose**: parent portal endpoints for attendance, invoices, enrollments, registration, emergency contacts, profile, and event participation.
- **Primary dependencies**:
- `App\Services\Parents\ParentAttendanceService`
- `App\Services\Parents\ParentInvoiceService`
- `App\Services\Parents\ParentEnrollmentService`
- `App\Services\Parents\ParentRegistrationService`
- `App\Services\Parents\ParentEmergencyContactService`
- `App\Services\Parents\ParentProfileService`
- `App\Services\Parents\ParentEventParticipationService`
## Routes
All routes are under `v1` + `parents` group:
- Base prefix: `GET/POST/PATCH/DELETE /api/v1/parents/...`
- Group middleware: `parent.access`
Endpoints:
- `GET /api/v1/parents/attendance``attendance`
- `GET /api/v1/parents/invoices``invoices`
- `GET /api/v1/parents/enrollments``enrollments`
- `POST /api/v1/parents/enrollments``updateEnrollments`
- `GET /api/v1/parents/registration``registration`
- `POST /api/v1/parents/registration``storeRegistration`
- `PATCH /api/v1/parents/students/{studentId}``updateStudent`
- `DELETE /api/v1/parents/students/{studentId}``deleteStudent`
- `GET /api/v1/parents/emergency-contacts``emergencyContacts`
- `POST /api/v1/parents/emergency-contacts``storeEmergencyContact`
- `PATCH /api/v1/parents/emergency-contacts/{contactId}``updateEmergencyContact`
- `GET /api/v1/parents/profile``profile`
- `PATCH /api/v1/parents/profile``updateProfile`
- `GET /api/v1/parents/events``events`
- `POST /api/v1/parents/events/participation``updateParticipation`
## Authentication & authorization
- Uses `parent.access` middleware which resolves “secondary”/“authorized” users to a primary parent id and stores it in the request attribute `primary_parent_id`.
- Controller helper `parentIdOrUnauthorized()`:
- Prefer `request()->attributes->get('primary_parent_id')`
- Fallback to `auth()->id()`
- On failure returns JSON `{ ok:false, message:"Unauthorized." }` with `401`
## Requests/validation
Validated via `FormRequest` classes per endpoint:
- `attendance`: `ParentAttendanceRequest` (optional `school_year`)
- `invoices`: `ParentInvoiceRequest` (optional `school_year`)
- `enrollments`: `ParentEnrollmentRequest` (optional `school_year`)
- `updateEnrollments`: `ParentEnrollmentActionRequest` (`enroll[]`, `withdraw[]`)
- `storeRegistration`: `ParentRegistrationRequest` (`students[]`, `emergency_contacts[]`)
- `updateStudent`: `ParentStudentUpdateRequest`
- `store/update emergency contact`: `ParentEmergencyContactRequest`
- `updateProfile`: `ParentProfileUpdateRequest`
- `updateParticipation`: `ParentEventParticipationRequest` (`participation[]`)
## Response shapes (legacy `{ ok: ... }`)
This controller largely returns a legacy parent-portal shape rather than the Base API envelope.
- `attendance`:
- `{ ok:true, attendance:[...], schoolYears:[...], selectedYear }`
- `attendance` uses `ParentAttendanceResource::collection(...)`
- `invoices`:
- `{ ok:true, invoices:[...] }` (`InvoiceResource::collection(...)`)
- `enrollments`:
- `{ ok:true, students:[...], schoolYears:[...], selectedYear, withdrawalDeadline, lastDayOfRegistration, schoolStartDate }`
- `updateEnrollments`:
- `{ ok:true, enrolled:[...], withdrawn:[...] }`
- `registration`:
- `{ ok:true, parent, existingKids:[...], emergencies:[...], maxChilds, maxEmergency, lastDayOfRegistration, registrationAgeDeadline }`
- `storeRegistration`:
- `{ ok:true, created_student_ids:[...], skipped:[...] }` with status `201` if created_count>0 else `200`
- `updateStudent` / `deleteStudent` / `updateParticipation`:
- `{ ok:true }`
- `emergencyContacts`:
- `{ ok:true, contacts:[...] }`
- `store/update emergency contact`:
- `{ ok:true, contact:{...} }` (store returns `201`)
- `profile` / `updateProfile`:
- `{ ok:true, profile:{...} }`
- `events`:
- `{ ok:true, activeEvents, charges, yourStudents }`
## Error handling expectations
- Unauthorized: `{ ok:false, message:"Unauthorized." }` with `401` (from `parentIdOrUnauthorized()`).
- Service-layer exceptions are generally allowed to bubble unless handled in the service; controller does minimal try/catch.
## Test plan (feature)
- Auth/parent.access:
- Without auth → 401 `{ ok:false }`.
- With secondary/authorized user → resolved to primary parent id and returns correct data.
- Each endpoint:
- Valid request returns `ok:true` and expected keys.
- Validation errors are returned by FormRequest (422).
- Registration:
- Returns 201 when at least one student is created, 200 when all entries skipped.
@@ -0,0 +1,69 @@
# `ParentPromotionController` plan
## Scope
- **Controller**: `app/Http/Controllers/Api/Parents/ParentPromotionController.php`
- **Purpose**: parent portal endpoints for promotion + enrollment workflow.
- **Primary dependencies**:
- `App\Services\Promotions\PromotionEnrollmentService`
- `App\Services\Promotions\PromotionQueryService`
- **Models**:
- `App\Models\StudentPromotionRecord`
- `App\Models\Student` (ownership verification fallback)
## Routes
All routes are under `v1` + `parents` group:
- Base prefix: `... /api/v1/parents/promotions`
- Group middleware: `parent.access`
Endpoints:
- `GET /api/v1/parents/promotions/``overview`
- `GET /api/v1/parents/promotions/{promotionId}``show`
- `POST /api/v1/parents/promotions/{promotionId}/start``start`
- `PATCH /api/v1/parents/promotions/{promotionId}/steps``updateSteps`
- `POST /api/v1/parents/promotions/{promotionId}/submit``submit`
## Authentication & authorization
- Parent context resolved via `parentIdOrUnauthorized()`:
- prefers request attribute `primary_parent_id` (set by `parent.access`)
- falls back to `auth()->id()`
- Ownership is enforced in `loadOwnedRecord()`:
- Primary check: `StudentPromotionRecord.parent_id == parentId`
- Fallback: loads `Student.parent_id` for the records `student_id`
- If not owned: returns `403` `{ ok:false, message:"You are not authorised..." }`
## Requests/validation
- `overview`: `ParentPromotionOverviewRequest` (optional `next_school_year`)
- `updateSteps`: `ParentEnrollmentStepRequest` (`steps()` accessor provides structured step payload)
## Response shapes
- `overview`:
- `{ ok:true, records, next_school_year, message }`
- Note: `records` are returned as provided by `PromotionEnrollmentService::overviewForParent(...)` (already shaped for the client)
- `show` / `start` / `updateSteps` / `submit`:
- `{ ok:true, data: StudentPromotionResource(...) }`
## State transitions (high-level)
- `start``PromotionEnrollmentService::startEnrollment(...)`
- `updateSteps``PromotionEnrollmentService::updateSteps(...)`
- `submit``PromotionEnrollmentService::submitEnrollment(...)`
- Conflicts (invalid transition / deadline / status) surface as `409` with `{ ok:false, message }`.
## Error handling expectations
- Unauthorized → `401` `{ ok:false, message:"Unauthorized." }`
- Record not found → `404` `{ ok:false, message:"Promotion record not found." }`
- Not owned → `403` `{ ok:false, message:"You are not authorised..." }`
- Invalid transition → `409` `{ ok:false, message }`
## Test plan (feature)
- Ownership:
- Parent cannot access another parents record (403).
- Record with mismatched `parent_id` but student belongs to parent passes ownership check.
- Happy path:
- `overview` returns list for parent.
- `start` moves record to started state and returns updated resource.
- `updateSteps` persists steps and returns updated resource.
- `submit` transitions to submitted state.
- Conflict cases:
- Submitting without required steps → 409.
- Past deadline → 409 (as enforced by service).
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
# Islamic Sunday School Modular Plan Update Index
This bundle updates the global modular plan and Phases 1-6 so the extension domain is explicitly **Islamic Sunday School**.
Core rule: `SchoolCore` remains neutral. Islamic-specific behavior belongs in `App\Domain\IslamicSundaySchool` through policies, resolvers, services, templates, and extension profile tables.
## Updated Files
- `app_project_plan_global_modular_islamic.md`
- `phase_1_school_context_execution_plan_islamic.md`
- `phase_2_core_contracts_execution_plan_islamic.md`
- `phase_3_modular_finance_execution_plan_islamic.md`
- `phase_4_modular_attendance_execution_plan_islamic.md`
- `phase_5_modular_student_lifecycle_execution_plan_islamic.md`
- `phase_6_modular_communication_execution_plan_islamic.md`
## Key Renames
```text
App\Domain\SundaySchool -> App\Domain\IslamicSundaySchool
sunday_school -> islamic_sunday_school
Sunday School profile -> Islamic Sunday School profile
church/ministry/pastoral terms -> masjid/community, volunteer/program, imam/admin or religious-sensitive terms
Bible curriculum -> Qur'an/Arabic/Islamic studies curriculum
```
## Non-Negotiable Boundary
`SchoolCore` must not import `IslamicSundaySchool`.
Allowed:
```text
IslamicSundaySchool -> SchoolCore contracts
SchoolCore -> Shared neutral infrastructure
Controllers -> contracts
```
Forbidden:
```text
SchoolCore -> IslamicSundaySchool
SchoolCore contracts containing Islamic-specific vocabulary
Controllers hardcoding Islamic Sunday School rules
```
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,11 @@
# Phase 9 module ownership. Replace usernames/team slugs with real owners before enforcing.
/app/Domain/SchoolCore/Context/ @platform-owner
/app/Domain/SchoolCore/Finance/ @finance-owner @platform-owner
/app/Domain/SchoolCore/Attendance/ @attendance-owner @platform-owner
/app/Domain/SchoolCore/Students/ @students-owner @platform-owner
/app/Domain/SchoolCore/Communication/ @communication-owner @platform-owner
/app/Domain/SchoolCore/Reporting/ @reporting-owner @data-security-owner
/app/Domain/IslamicSundaySchool/ @islamic-sunday-school-owner @platform-owner
/app/Http/Controllers/ @api-owner
/routes/ @api-owner @platform-owner
/docs/ @platform-owner
@@ -0,0 +1,19 @@
## Modular Architecture Checklist
- [ ] SchoolContext is used where required.
- [ ] No SchoolCore dependency on IslamicSundaySchool.
- [ ] Controllers remain thin and delegate to contracts.
- [ ] FormRequest validates mutable endpoint input.
- [ ] Resource or ApiResponse controls output.
- [ ] Authorization is tested.
- [ ] Wrong-school access is tested for scoped data.
- [ ] Sensitive fields are protected or masked.
- [ ] New route appears in route inventory.
- [ ] New report/export is audited.
- [ ] Bulk communication requires preview.
- [ ] Finance changes avoid float math and direct controller mutation.
- [ ] Module owner reviewed.
## Risk Notes
List any boundary exceptions, deprecated paths, new routes, new reports/exports, or sensitive data exposure. If this section is empty for a high-risk change, assume the PR is lying by omission.
@@ -0,0 +1,29 @@
name: Architecture Governance
on:
pull_request:
push:
branches: [ main ]
jobs:
architecture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- name: Validate composer
run: composer validate --no-check-publish || true
- name: Install dependencies
run: composer install --no-interaction --prefer-dist || true
- name: Run architecture tests
run: php artisan test tests/Architecture || true
- name: Run architecture scan in warning mode
run: php artisan app:architecture-scan --fail-on=none || true
- name: Generate route inventory
run: php artisan api:route-inventory --markdown || true
- name: Check route inventory
run: php artisan app:route-inventory-check || true
- name: Generate dependency map
run: php artisan app:dependency-map --markdown || true
@@ -0,0 +1,23 @@
name: Release Gate
on:
workflow_dispatch:
push:
tags:
- 'v*'
jobs:
release-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- run: composer install --no-interaction --prefer-dist || true
- run: php artisan test tests/Architecture
- run: php artisan app:architecture-scan --fail-on=error
- run: php artisan api:route-inventory --markdown
- run: php artisan app:route-inventory-check
- run: php artisan app:docs-coverage
- run: php artisan app:dependency-map --markdown
@@ -0,0 +1,27 @@
name: Release Readiness
on:
workflow_dispatch:
pull_request:
paths:
- 'app/**'
- 'config/**'
- 'routes/**'
- 'docs/release/**'
- '.github/workflows/release-readiness.yml'
jobs:
release-readiness:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- run: composer install --no-interaction --prefer-dist
- run: php artisan app:release-feature-flags
- run: php artisan app:release-migration-validate
- run: php artisan app:release-shadow-compare
- run: php artisan app:legacy-unsafe-route-audit
- run: php artisan app:release-readiness-gate --warning-mode
- run: php artisan test tests/Feature/Release tests/Architecture/Phase10ReleaseReadinessArchitectureTest.php

Some files were not shown because too many files have changed in this diff Show More