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