update api and add more features
This commit is contained in:
Vendored
BIN
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,375 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Administrator;
|
||||
|
||||
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||
use App\Http\Requests\Promotions\AdminListPromotionsRequest;
|
||||
use App\Http\Requests\Promotions\EvaluateEligibilityRequest;
|
||||
use App\Http\Requests\Promotions\ParentEnrollmentStepRequest;
|
||||
use App\Http\Requests\Promotions\SendReminderRequest;
|
||||
use App\Http\Requests\Promotions\SetEnrollmentDeadlineRequest;
|
||||
use App\Http\Requests\Promotions\UpdatePromotionStatusRequest;
|
||||
use App\Http\Requests\Promotions\UpsertLevelProgressionRequest;
|
||||
use App\Http\Resources\Promotions\LevelProgressionResource;
|
||||
use App\Http\Resources\Promotions\PromotionAuditLogResource;
|
||||
use App\Http\Resources\Promotions\PromotionReminderResource;
|
||||
use App\Http\Resources\Promotions\StudentPromotionResource;
|
||||
use App\Models\PromotionAuditLog;
|
||||
use App\Models\PromotionReminderLog;
|
||||
use App\Models\StudentPromotionRecord;
|
||||
use App\Services\Promotions\LevelProgressionService;
|
||||
use App\Services\Promotions\PromotionAuditService;
|
||||
use App\Services\Promotions\PromotionEligibilityService;
|
||||
use App\Services\Promotions\PromotionEnrollmentService;
|
||||
use App\Services\Promotions\PromotionQueryService;
|
||||
use App\Services\Promotions\PromotionReminderService;
|
||||
use App\Services\Promotions\PromotionStatusService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Admin / registrar API for managing the promotion + parent enrollment
|
||||
* lifecycle (plan sections 13, 16, 17).
|
||||
*/
|
||||
class AdministratorPromotionController extends BaseApiController
|
||||
{
|
||||
public function __construct(
|
||||
private PromotionQueryService $query,
|
||||
private PromotionEligibilityService $eligibility,
|
||||
private PromotionStatusService $statusService,
|
||||
private PromotionEnrollmentService $enrollmentService,
|
||||
private PromotionReminderService $reminders,
|
||||
private PromotionAuditService $audit,
|
||||
private LevelProgressionService $progression
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function index(AdminListPromotionsRequest $request): JsonResponse
|
||||
{
|
||||
$result = $this->query->list($request->filters());
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => StudentPromotionResource::collection($result['data']),
|
||||
'pagination' => [
|
||||
'page' => $result['page'],
|
||||
'per_page' => $result['per_page'],
|
||||
'total' => $result['total'],
|
||||
'total_pages' => $result['total_pages'],
|
||||
],
|
||||
'counts_by_status' => $result['counts_by_status'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(int $promotionId): JsonResponse
|
||||
{
|
||||
$record = $this->findOrFail($promotionId);
|
||||
if ($record instanceof JsonResponse) {
|
||||
return $record;
|
||||
}
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => new StudentPromotionResource($this->query->detail($record)),
|
||||
]);
|
||||
}
|
||||
|
||||
public function summary(AdminListPromotionsRequest $request): JsonResponse
|
||||
{
|
||||
$counts = $this->query->countsByStatus($request->filters());
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'counts_by_status' => $counts,
|
||||
'total' => array_sum($counts),
|
||||
]);
|
||||
}
|
||||
|
||||
public function evaluate(EvaluateEligibilityRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$userId = $this->getCurrentUserId();
|
||||
$scope = $payload['scope'] ?? 'school_year';
|
||||
|
||||
try {
|
||||
$result = match ($scope) {
|
||||
'student' => [
|
||||
'scope' => 'student',
|
||||
'record' => optional(
|
||||
$this->eligibility->evaluateStudent(
|
||||
(int) $payload['student_id'],
|
||||
$payload['current_school_year'] ?? null,
|
||||
$userId
|
||||
)
|
||||
)?->toArray(),
|
||||
],
|
||||
'class_section' => array_merge(
|
||||
['scope' => 'class_section'],
|
||||
$this->eligibility->evaluateClassSection(
|
||||
(int) $payload['class_section_id'],
|
||||
$payload['current_school_year'] ?? null,
|
||||
$userId
|
||||
)
|
||||
),
|
||||
default => array_merge(
|
||||
['scope' => 'school_year'],
|
||||
$this->eligibility->evaluateSchoolYear(
|
||||
$payload['current_school_year'] ?? null,
|
||||
$userId
|
||||
)
|
||||
),
|
||||
};
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Promotion eligibility evaluation failed', ['exception' => $e]);
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'Failed to evaluate promotion eligibility.',
|
||||
], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'result' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateStatus(UpdatePromotionStatusRequest $request, int $promotionId): JsonResponse
|
||||
{
|
||||
$record = $this->findOrFail($promotionId);
|
||||
if ($record instanceof JsonResponse) {
|
||||
return $record;
|
||||
}
|
||||
|
||||
$payload = $request->validated();
|
||||
$userId = $this->getCurrentUserId();
|
||||
|
||||
try {
|
||||
$updated = !empty($payload['force'])
|
||||
? $this->statusService->forceStatus($record, $payload['status'], $userId, $payload['notes'] ?? null)
|
||||
: $this->statusService->transition($record, $payload['status'], $userId, $payload['notes'] ?? null);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json(['ok' => false, 'message' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['ok' => false, 'message' => $e->getMessage()], Response::HTTP_CONFLICT);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => new StudentPromotionResource($this->query->detail($updated->refresh())),
|
||||
]);
|
||||
}
|
||||
|
||||
public function setDeadline(SetEnrollmentDeadlineRequest $request, ?int $promotionId = null): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$userId = $this->getCurrentUserId();
|
||||
$newDeadline = $payload['enrollment_deadline'];
|
||||
$applyTo = $payload['apply_to'] ?? ($promotionId ? 'single' : 'school_year');
|
||||
|
||||
$records = collect();
|
||||
if ($promotionId !== null || $applyTo === 'single') {
|
||||
$id = $promotionId ?? null;
|
||||
if ($id === null) {
|
||||
return response()->json(['ok' => false, 'message' => 'promotion_id is required for single deadline updates.'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
$record = $this->findOrFail($id);
|
||||
if ($record instanceof JsonResponse) {
|
||||
return $record;
|
||||
}
|
||||
$records = collect([$record]);
|
||||
} elseif ($applyTo === 'filter' && !empty($payload['promotion_ids'])) {
|
||||
$records = StudentPromotionRecord::query()
|
||||
->whereIn('promotion_id', $payload['promotion_ids'])
|
||||
->get();
|
||||
} else {
|
||||
$year = $payload['next_school_year'] ?? null;
|
||||
if ($year === null || $year === '') {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'next_school_year is required when applying a bulk deadline.',
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
$records = StudentPromotionRecord::query()
|
||||
->forNextYear($year)
|
||||
->whereIn('promotion_status', StudentPromotionRecord::openStatuses())
|
||||
->get();
|
||||
}
|
||||
|
||||
$updatedIds = [];
|
||||
foreach ($records as $record) {
|
||||
$oldDeadline = $record->enrollment_deadline?->toDateString();
|
||||
DB::transaction(function () use ($record, $newDeadline, $oldDeadline, $userId, &$updatedIds) {
|
||||
$record->enrollment_deadline = $newDeadline;
|
||||
$record->updated_by = $userId;
|
||||
$record->save();
|
||||
$this->audit->logDeadlineSet($record, $oldDeadline, $newDeadline, $userId);
|
||||
$updatedIds[] = (int) $record->getKey();
|
||||
});
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'updated' => count($updatedIds),
|
||||
'promotion_ids' => $updatedIds,
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendReminder(SendReminderRequest $request, int $promotionId): JsonResponse
|
||||
{
|
||||
$record = $this->findOrFail($promotionId);
|
||||
if ($record instanceof JsonResponse) {
|
||||
return $record;
|
||||
}
|
||||
|
||||
$payload = $request->validated();
|
||||
$userId = $this->getCurrentUserId();
|
||||
|
||||
try {
|
||||
$log = $this->reminders->send(
|
||||
$record,
|
||||
$payload['reminder_type'] ?? PromotionReminderLog::TYPE_MANUAL,
|
||||
$userId,
|
||||
$payload['subject'] ?? null,
|
||||
$payload['message'] ?? null,
|
||||
$payload['channels'] ?? ['in_app', 'email']
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Promotion reminder send failed', [
|
||||
'promotion_id' => $promotionId,
|
||||
'exception' => $e,
|
||||
]);
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'Failed to send reminder.',
|
||||
], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'reminder' => new PromotionReminderResource($log),
|
||||
]);
|
||||
}
|
||||
|
||||
public function dispatchScheduledReminders(): JsonResponse
|
||||
{
|
||||
$userId = $this->getCurrentUserId();
|
||||
$result = $this->reminders->dispatchScheduledReminders(null, $userId);
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'result' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
public function reminders(int $promotionId): JsonResponse
|
||||
{
|
||||
$record = $this->findOrFail($promotionId);
|
||||
if ($record instanceof JsonResponse) {
|
||||
return $record;
|
||||
}
|
||||
$rows = PromotionReminderLog::query()
|
||||
->forPromotion((int) $record->getKey())
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'reminders' => PromotionReminderResource::collection($rows),
|
||||
]);
|
||||
}
|
||||
|
||||
public function audit(int $promotionId): JsonResponse
|
||||
{
|
||||
$record = $this->findOrFail($promotionId);
|
||||
if ($record instanceof JsonResponse) {
|
||||
return $record;
|
||||
}
|
||||
$rows = PromotionAuditLog::query()
|
||||
->forPromotion((int) $record->getKey())
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'entries' => PromotionAuditLogResource::collection($rows),
|
||||
]);
|
||||
}
|
||||
|
||||
public function expireDeadlines(): JsonResponse
|
||||
{
|
||||
$result = $this->enrollmentService->markExpiredDeadlines(null, $this->getCurrentUserId());
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'result' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
public function adminCompleteEnrollment(ParentEnrollmentStepRequest $request, int $promotionId): JsonResponse
|
||||
{
|
||||
$record = $this->findOrFail($promotionId);
|
||||
if ($record instanceof JsonResponse) {
|
||||
return $record;
|
||||
}
|
||||
$userId = $this->getCurrentUserId();
|
||||
$parentId = (int) $record->parent_id ?: $userId ?: 0;
|
||||
|
||||
if ($parentId <= 0) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'Promotion record has no associated parent.',
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
try {
|
||||
$updated = $this->enrollmentService->updateSteps(
|
||||
$record,
|
||||
$parentId,
|
||||
$request->steps(),
|
||||
$userId
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['ok' => false, 'message' => $e->getMessage()], Response::HTTP_CONFLICT);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => new StudentPromotionResource($this->query->detail($updated->refresh())),
|
||||
]);
|
||||
}
|
||||
|
||||
public function levelProgressions(): JsonResponse
|
||||
{
|
||||
$rows = $this->progression->list(false);
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'levels' => LevelProgressionResource::collection($rows),
|
||||
]);
|
||||
}
|
||||
|
||||
public function upsertLevelProgression(UpsertLevelProgressionRequest $request): JsonResponse
|
||||
{
|
||||
$row = $this->progression->upsertMapping($request->validated());
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'level' => new LevelProgressionResource($row),
|
||||
]);
|
||||
}
|
||||
|
||||
public function seedLevelProgressions(): JsonResponse
|
||||
{
|
||||
$created = $this->progression->seedDefaults();
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'created' => $created,
|
||||
]);
|
||||
}
|
||||
|
||||
private function findOrFail(int $promotionId): StudentPromotionRecord|JsonResponse
|
||||
{
|
||||
$record = StudentPromotionRecord::query()->find($promotionId);
|
||||
if (!$record) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'Promotion record not found.',
|
||||
], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
return $record;
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,7 @@ class AuthController extends BaseApiController
|
||||
$payload = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
// Match CodeIgniter apiLogin: raw email/password (no trim); empty check is identical to CI `!$email || !$password`.
|
||||
$email = (string) ($payload['email'] ?? '');
|
||||
$email = strtolower(trim((string) ($payload['email'] ?? '')));
|
||||
$password = (string) ($payload['password'] ?? '');
|
||||
|
||||
if ($email === '' || $password === '') {
|
||||
@@ -39,7 +38,7 @@ class AuthController extends BaseApiController
|
||||
], 429);
|
||||
}
|
||||
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
$user = User::query()->whereRaw('LOWER(email) = ?', [$email])->first();
|
||||
if (!$user) {
|
||||
$security->logIpAttempt((string) $ip);
|
||||
|
||||
@@ -49,13 +48,6 @@ class AuthController extends BaseApiController
|
||||
], 401);
|
||||
}
|
||||
|
||||
if ($user->is_suspended) {
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => 'Account suspended. Please reset your password.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
if (! ci_password_verify($password, (string) $user->password)) {
|
||||
$security->handleFailedLogin($user, $email, (string) $ip);
|
||||
|
||||
@@ -65,6 +57,15 @@ class AuthController extends BaseApiController
|
||||
], 401);
|
||||
}
|
||||
|
||||
// Suspension is checked AFTER credentials are verified so that the
|
||||
// response shape cannot be used to enumerate which emails exist.
|
||||
if ($user->is_suspended) {
|
||||
return response()->json([
|
||||
'status' => false,
|
||||
'message' => 'Account suspended. Please reset your password.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$security->resetFailedAttempts($user);
|
||||
$security->logSuccessfulLogin($user->fresh(), $request);
|
||||
|
||||
|
||||
@@ -245,28 +245,31 @@ class BaseApiController extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = app(User::class);
|
||||
$row = $user->find($userId);
|
||||
/** @var User|null $row */
|
||||
$row = User::query()->find($userId);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? ''));
|
||||
$firstname = (string) ($row->firstname ?? '');
|
||||
$lastname = (string) ($row->lastname ?? '');
|
||||
$email = $row->email;
|
||||
|
||||
$name = trim($firstname . ' ' . $lastname);
|
||||
if ($name === '') {
|
||||
$name = $row['email'] ?? 'User #' . $userId;
|
||||
$name = $email ?? 'User #' . $userId;
|
||||
}
|
||||
|
||||
try {
|
||||
$roles = DB::table('user_roles ur')
|
||||
->select('LOWER(r.name) AS name')
|
||||
->join('roles r', 'r.id', '=', 'ur.role_id')
|
||||
$roles = DB::table('user_roles as ur')
|
||||
->join('roles as r', 'r.id', '=', 'ur.role_id')
|
||||
->where('ur.user_id', $userId)
|
||||
->whereNull('ur.deleted_at')
|
||||
->pluck('name')
|
||||
->map(fn($name) => strtolower((string) $name))
|
||||
->pluck('r.name')
|
||||
->map(fn ($name) => strtolower((string) $name))
|
||||
->unique()
|
||||
->values()
|
||||
->toArray();
|
||||
->all();
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Unable to load user roles: ' . $e->getMessage());
|
||||
$roles = [];
|
||||
@@ -275,7 +278,7 @@ class BaseApiController extends Controller
|
||||
return (object) [
|
||||
'id' => (int) $userId,
|
||||
'name' => $name,
|
||||
'email' => $row['email'] ?? null,
|
||||
'email' => $email,
|
||||
'roles' => $roles,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Finance;
|
||||
|
||||
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||
use App\Http\Requests\Finance\ApplyExtraChargeRequest;
|
||||
use App\Http\Requests\Finance\ChargeListRequest;
|
||||
use App\Http\Requests\Finance\MarkEventChargePaidRequest;
|
||||
use App\Http\Requests\Finance\StoreEventChargeRequest;
|
||||
use App\Http\Requests\Finance\StoreExtraChargeRequest;
|
||||
use App\Http\Resources\Fees\ChargeActionResource;
|
||||
use App\Http\Resources\Fees\ChargeListResource;
|
||||
use App\Services\Billing\ChargeService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ChargeController extends BaseApiController
|
||||
{
|
||||
public function __construct(private ChargeService $charges)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function index(ChargeListRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$result = $this->charges->listForParent(
|
||||
(int) $payload['parent_id'],
|
||||
$payload['school_year'] ?? null
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'charges' => new ChargeListResource($result),
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeExtra(StoreExtraChargeRequest $request): JsonResponse
|
||||
{
|
||||
$guard = $this->authenticatedUserIdOrUnauthorized();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$payload = $request->validated();
|
||||
$payload['created_by'] = $guard;
|
||||
|
||||
try {
|
||||
$result = $this->charges->createExtraCharge($payload);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Store extra charge failed', ['exception' => $e]);
|
||||
|
||||
return response()->json(['ok' => false, 'message' => 'Unable to create extra charge.'], 500);
|
||||
}
|
||||
|
||||
$status = ($result['ok'] ?? false)
|
||||
? (isset($result['error']) && $result['error'] === 'duplicate_charge' ? 409 : 201)
|
||||
: 422;
|
||||
|
||||
return response()->json([
|
||||
'ok' => (bool) ($result['ok'] ?? false),
|
||||
'charge' => new ChargeActionResource($result),
|
||||
], $status);
|
||||
}
|
||||
|
||||
public function storeEvent(StoreEventChargeRequest $request): JsonResponse
|
||||
{
|
||||
$guard = $this->authenticatedUserIdOrUnauthorized();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$payload = $request->validated();
|
||||
$payload['created_by'] = $guard;
|
||||
|
||||
try {
|
||||
$result = $this->charges->createEventCharge($payload);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Store event charge failed', ['exception' => $e]);
|
||||
|
||||
return response()->json(['ok' => false, 'message' => 'Unable to create event charge.'], 500);
|
||||
}
|
||||
|
||||
$status = ($result['ok'] ?? false)
|
||||
? (isset($result['error']) && $result['error'] === 'duplicate_charge' ? 409 : 201)
|
||||
: 422;
|
||||
|
||||
return response()->json([
|
||||
'ok' => (bool) ($result['ok'] ?? false),
|
||||
'charge' => new ChargeActionResource($result),
|
||||
], $status);
|
||||
}
|
||||
|
||||
public function cancelExtra(int $id): JsonResponse
|
||||
{
|
||||
$result = $this->charges->cancelExtraCharge($id);
|
||||
$status = ($result['ok'] ?? false) ? 200 : 422;
|
||||
|
||||
return response()->json([
|
||||
'ok' => (bool) ($result['ok'] ?? false),
|
||||
'charge' => new ChargeActionResource($result),
|
||||
], $status);
|
||||
}
|
||||
|
||||
public function applyExtra(ApplyExtraChargeRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$result = $this->charges->applyExtraCharge($id, (int) $payload['invoice_id']);
|
||||
$status = ($result['ok'] ?? false) ? 200 : 422;
|
||||
|
||||
return response()->json([
|
||||
'ok' => (bool) ($result['ok'] ?? false),
|
||||
'charge' => new ChargeActionResource($result),
|
||||
], $status);
|
||||
}
|
||||
|
||||
public function cancelEvent(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$issueCredit = $request->boolean('issue_credit', true);
|
||||
$result = $this->charges->cancelEventCharge($id, $issueCredit);
|
||||
$status = ($result['ok'] ?? false) ? 200 : 422;
|
||||
|
||||
return response()->json([
|
||||
'ok' => (bool) ($result['ok'] ?? false),
|
||||
'charge' => new ChargeActionResource($result),
|
||||
], $status);
|
||||
}
|
||||
|
||||
public function markEventPaid(MarkEventChargePaidRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
$result = $this->charges->markEventChargePaid(
|
||||
$id,
|
||||
(bool) $payload['paid'],
|
||||
isset($payload['payment_id']) ? (int) $payload['payment_id'] : null
|
||||
);
|
||||
$status = ($result['ok'] ?? false) ? 200 : 422;
|
||||
|
||||
return response()->json([
|
||||
'ok' => (bool) ($result['ok'] ?? false),
|
||||
'charge' => new ChargeActionResource($result),
|
||||
], $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|JsonResponse
|
||||
*/
|
||||
private function authenticatedUserIdOrUnauthorized(): int|JsonResponse
|
||||
{
|
||||
$userId = (int) (auth()->id() ?? 0);
|
||||
if ($userId <= 0) {
|
||||
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
return $userId;
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,18 @@
|
||||
namespace App\Http\Controllers\Api\Finance;
|
||||
|
||||
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||
use App\Http\Requests\Finance\FeeFamilyBalanceRequest;
|
||||
use App\Http\Requests\Finance\FeeRefundRequest;
|
||||
use App\Http\Requests\Finance\FeeTuitionBreakdownRequest;
|
||||
use App\Http\Requests\Finance\FeeTuitionTotalRequest;
|
||||
use App\Http\Resources\Fees\FeeFamilyBalanceResource;
|
||||
use App\Http\Resources\Fees\FeeRefundResource;
|
||||
use App\Http\Resources\Fees\FeeTuitionBreakdownResource;
|
||||
use App\Http\Resources\Fees\FeeTuitionTotalResource;
|
||||
use App\Services\Billing\BalanceCalculationService;
|
||||
use App\Services\Fees\FeeRefundCalculatorService;
|
||||
use App\Services\Fees\FeeStudentFeeService;
|
||||
use App\Services\Fees\TuitionCalculationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
@@ -16,7 +22,9 @@ class FeeCalculationController extends BaseApiController
|
||||
{
|
||||
public function __construct(
|
||||
private FeeRefundCalculatorService $refunds,
|
||||
private FeeStudentFeeService $tuition
|
||||
private FeeStudentFeeService $tuition,
|
||||
private TuitionCalculationService $tuitionCalculator,
|
||||
private BalanceCalculationService $balance
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -69,4 +77,55 @@ class FeeCalculationController extends BaseApiController
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function tuitionBreakdown(FeeTuitionBreakdownRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
|
||||
try {
|
||||
$result = $this->tuitionCalculator->calculate($payload['students']);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Tuition breakdown calculation failed', [
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'Unable to calculate tuition breakdown.',
|
||||
], 500);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'tuition' => new FeeTuitionBreakdownResource($result),
|
||||
]);
|
||||
}
|
||||
|
||||
public function familyBalance(FeeFamilyBalanceRequest $request): JsonResponse
|
||||
{
|
||||
$payload = $request->validated();
|
||||
|
||||
try {
|
||||
$result = $this->balance->calculateFamilyAccount(
|
||||
$payload['students'],
|
||||
(int) $payload['parent_id'],
|
||||
$payload['school_year'] ?? null
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Family balance calculation failed', [
|
||||
'parent_id' => $payload['parent_id'] ?? null,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'message' => 'Unable to calculate family balance.',
|
||||
], 500);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'account' => new FeeFamilyBalanceResource($result),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,14 +37,19 @@ class AuthorizedUserInviteController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
$redirectUrl = (string) $result['redirect_url'];
|
||||
if (! $this->isTrustedRedirectUrl($redirectUrl)) {
|
||||
return response()->json(['message' => 'Invalid redirect URL.'], 400);
|
||||
}
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'message' => 'Confirmed.',
|
||||
'redirect_url' => $result['redirect_url'],
|
||||
'redirect_url' => $redirectUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->away($result['redirect_url']);
|
||||
return redirect()->away($redirectUrl);
|
||||
}
|
||||
|
||||
public function setPasswordForm(Request $request, int $authorizedUserId): JsonResponse|Response
|
||||
@@ -79,7 +84,8 @@ class AuthorizedUserInviteController extends Controller
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'password' => ['required', 'string', 'min:8', 'regex:/[A-Z]/', 'regex:/[a-z]/', 'regex:/[0-9]/', 'regex:/[\W_]/'],
|
||||
'password_confirm' => ['required', 'same:password'],
|
||||
'password_confirmation' => ['required_without:password_confirm', 'same:password'],
|
||||
'password_confirm' => ['required_without:password_confirmation', 'same:password'],
|
||||
'user_id' => ['required', 'integer'],
|
||||
'token' => ['required', 'string'],
|
||||
], [
|
||||
@@ -108,4 +114,23 @@ class AuthorizedUserInviteController extends Controller
|
||||
|
||||
return response()->json(['message' => 'Password has been successfully set.']);
|
||||
}
|
||||
|
||||
private function isTrustedRedirectUrl(string $url): bool
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
if (! is_string($host) || $host === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$trusted = [
|
||||
parse_url((string) config('app.url'), PHP_URL_HOST),
|
||||
parse_url((string) config('app.frontend_url'), PHP_URL_HOST),
|
||||
parse_url((string) config('services.frontend.url'), PHP_URL_HOST),
|
||||
];
|
||||
|
||||
return in_array(strtolower($host), array_filter(array_map(
|
||||
static fn ($value) => is_string($value) ? strtolower($value) : null,
|
||||
$trusted
|
||||
)), true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,14 +58,17 @@ class AuthorizedUsersController extends BaseApiController
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$email = strtolower($request->validated('email'));
|
||||
$email = strtolower(trim((string) $request->validated('email')));
|
||||
$result = $this->svc->inviteByEmail($guard, $email);
|
||||
|
||||
$message = 'Authorized user added. A confirmation email has been sent.';
|
||||
if (! $result['created']) {
|
||||
return $this->success(['message' => $message], $message, Response::HTTP_CREATED);
|
||||
$message = 'If the email belongs to a registered user, a confirmation email has been sent.';
|
||||
|
||||
return $this->success(['message' => $message], $message, Response::HTTP_OK);
|
||||
}
|
||||
|
||||
$message = 'Authorized user added. A confirmation email has been sent.';
|
||||
|
||||
return $this->success(
|
||||
['message' => $message, 'id' => $result['record']->id],
|
||||
$message,
|
||||
@@ -89,11 +92,18 @@ class AuthorizedUsersController extends BaseApiController
|
||||
$row = $resolution;
|
||||
|
||||
$email = $request->validated('email') ?? null;
|
||||
if (is_string($email) && $email !== '') {
|
||||
$row->email = strtolower($email);
|
||||
}
|
||||
if (is_string($email) && trim($email) !== '') {
|
||||
try {
|
||||
$this->svc->changeEmailAndReinvite($row, $email);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return $this->error($e->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$row->save();
|
||||
return $this->success(
|
||||
['message' => 'Authorized user email updated. A new confirmation email has been sent.'],
|
||||
'Authorized user email updated. A new confirmation email has been sent.'
|
||||
);
|
||||
}
|
||||
|
||||
return $this->success(['message' => 'Authorized user information updated.'], 'Authorized user information updated.');
|
||||
}
|
||||
@@ -127,15 +137,15 @@ class AuthorizedUsersController extends BaseApiController
|
||||
|
||||
private function authorizedUserForParent(int $id, int $parentId): AuthorizedUser|JsonResponse
|
||||
{
|
||||
$row = AuthorizedUser::query()->find($id);
|
||||
$row = AuthorizedUser::query()
|
||||
->where('id', $id)
|
||||
->where('user_id', $parentId)
|
||||
->first();
|
||||
|
||||
if (! $row) {
|
||||
return $this->error('Authorized user not found.', Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
if ((int) $row->user_id !== $parentId) {
|
||||
return $this->error('You do not have access to this resource.', Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,10 +148,14 @@ class ParentController extends BaseApiController
|
||||
$payload['emergency_contacts'] ?? []
|
||||
);
|
||||
|
||||
$createdCount = count($result['created_student_ids']);
|
||||
$skipped = $result['skipped'] ?? [];
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'created_student_ids' => $result['created_student_ids'],
|
||||
], 201);
|
||||
'skipped' => $skipped,
|
||||
], $createdCount > 0 ? 201 : 200);
|
||||
}
|
||||
|
||||
public function updateStudent(ParentStudentUpdateRequest $request, int $studentId): JsonResponse
|
||||
@@ -283,10 +287,21 @@ class ParentController extends BaseApiController
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|JsonResponse Authenticated parent user id, or 401 JSON for this controller's legacy `{ ok }` shape.
|
||||
* @return int|JsonResponse Effective primary-parent user id, or 401 JSON for this controller's legacy `{ ok }` shape.
|
||||
*
|
||||
* `parent.access` middleware resolves second parents (user_type=secondary)
|
||||
* and authorized users (user_type=tertiary) to their primary parent and
|
||||
* stashes the result as `primary_parent_id` on the request. If we are
|
||||
* called outside that middleware we fall back to the authenticated id.
|
||||
*/
|
||||
private function parentIdOrUnauthorized(): int|JsonResponse
|
||||
{
|
||||
$request = request();
|
||||
$primary = (int) ($request?->attributes?->get('primary_parent_id') ?? 0);
|
||||
if ($primary > 0) {
|
||||
return $primary;
|
||||
}
|
||||
|
||||
$parentId = (int) (auth()->id() ?? 0);
|
||||
if ($parentId <= 0) {
|
||||
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], 401);
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Parents;
|
||||
|
||||
use App\Http\Controllers\Api\Core\BaseApiController;
|
||||
use App\Http\Requests\Promotions\ParentEnrollmentStepRequest;
|
||||
use App\Http\Requests\Promotions\ParentPromotionOverviewRequest;
|
||||
use App\Http\Resources\Promotions\StudentPromotionResource;
|
||||
use App\Models\Student;
|
||||
use App\Models\StudentPromotionRecord;
|
||||
use App\Services\Promotions\PromotionEnrollmentService;
|
||||
use App\Services\Promotions\PromotionQueryService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Parent portal endpoints for the promotion + enrollment workflow
|
||||
* (plan section 12). The parent sees the promoted level and can step
|
||||
* through the enrollment checklist defined in plan section 8.
|
||||
*/
|
||||
class ParentPromotionController extends BaseApiController
|
||||
{
|
||||
public function __construct(
|
||||
private PromotionEnrollmentService $service,
|
||||
private PromotionQueryService $query
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function overview(ParentPromotionOverviewRequest $request): JsonResponse
|
||||
{
|
||||
$guard = $this->parentIdOrUnauthorized();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$data = $this->service->overviewForParent(
|
||||
$guard,
|
||||
$request->validated()['next_school_year'] ?? null
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'records' => $data['records'],
|
||||
'next_school_year' => $data['next_school_year'],
|
||||
'message' => $data['message'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(int $promotionId): JsonResponse
|
||||
{
|
||||
$guard = $this->parentIdOrUnauthorized();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$record = $this->loadOwnedRecord($promotionId, $guard);
|
||||
if ($record instanceof JsonResponse) {
|
||||
return $record;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => new StudentPromotionResource($this->query->detail($record)),
|
||||
]);
|
||||
}
|
||||
|
||||
public function start(int $promotionId): JsonResponse
|
||||
{
|
||||
$guard = $this->parentIdOrUnauthorized();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$record = $this->loadOwnedRecord($promotionId, $guard);
|
||||
if ($record instanceof JsonResponse) {
|
||||
return $record;
|
||||
}
|
||||
|
||||
try {
|
||||
$updated = $this->service->startEnrollment($record, $guard, $guard);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['ok' => false, 'message' => $e->getMessage()], Response::HTTP_CONFLICT);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => new StudentPromotionResource($this->query->detail($updated->refresh())),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateSteps(ParentEnrollmentStepRequest $request, int $promotionId): JsonResponse
|
||||
{
|
||||
$guard = $this->parentIdOrUnauthorized();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$record = $this->loadOwnedRecord($promotionId, $guard);
|
||||
if ($record instanceof JsonResponse) {
|
||||
return $record;
|
||||
}
|
||||
|
||||
try {
|
||||
$updated = $this->service->updateSteps($record, $guard, $request->steps(), $guard);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['ok' => false, 'message' => $e->getMessage()], Response::HTTP_CONFLICT);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => new StudentPromotionResource($this->query->detail($updated->refresh())),
|
||||
]);
|
||||
}
|
||||
|
||||
public function submit(int $promotionId): JsonResponse
|
||||
{
|
||||
$guard = $this->parentIdOrUnauthorized();
|
||||
if ($guard instanceof JsonResponse) {
|
||||
return $guard;
|
||||
}
|
||||
|
||||
$record = $this->loadOwnedRecord($promotionId, $guard);
|
||||
if ($record instanceof JsonResponse) {
|
||||
return $record;
|
||||
}
|
||||
|
||||
try {
|
||||
$updated = $this->service->submitEnrollment($record, $guard, $guard);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['ok' => false, 'message' => $e->getMessage()], Response::HTTP_CONFLICT);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'data' => new StudentPromotionResource($this->query->detail($updated->refresh())),
|
||||
]);
|
||||
}
|
||||
|
||||
private function loadOwnedRecord(int $promotionId, int $parentId): StudentPromotionRecord|JsonResponse
|
||||
{
|
||||
$record = StudentPromotionRecord::query()->find($promotionId);
|
||||
if (!$record) {
|
||||
return response()->json(['ok' => false, 'message' => 'Promotion record not found.'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$owns = (int) $record->parent_id === $parentId;
|
||||
if (!$owns) {
|
||||
$studentParentId = (int) (Student::query()
|
||||
->where('id', $record->student_id)
|
||||
->value('parent_id') ?? 0);
|
||||
$owns = $studentParentId === $parentId;
|
||||
}
|
||||
if (!$owns) {
|
||||
return response()->json(['ok' => false, 'message' => 'You are not authorised to view this promotion record.'], Response::HTTP_FORBIDDEN);
|
||||
}
|
||||
return $record;
|
||||
}
|
||||
|
||||
private function parentIdOrUnauthorized(): int|JsonResponse
|
||||
{
|
||||
$request = request();
|
||||
$primary = (int) ($request?->attributes?->get('primary_parent_id') ?? 0);
|
||||
if ($primary > 0) {
|
||||
return $primary;
|
||||
}
|
||||
$parentId = (int) (auth()->id() ?? 0);
|
||||
if ($parentId <= 0) {
|
||||
return response()->json(['ok' => false, 'message' => 'Unauthorized.'], Response::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
return $parentId;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user