update api and add more features
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 §9–10, §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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 5–7.
|
||||
*
|
||||
* 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user