Commit a6451e4e authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(training): stop a deleted programme from silently unbilling its players

Deleting a programme or a group called `$group->enrollments()->forceDelete()`
on both paths — hard delete, no soft delete, no audit row, nothing to restore
from. `enrollments` is the only table GenerateRenewalInvoices bills from, so
the players were not merely losing history: they stopped existing as far as
billing was concerned while still training, still members, still owing money.

OC-Sport reorganised its season on 28 August 2026 by deleting the programmes
and recreating them under new names. 181 enrolments went with them. On
2 September the renewal run raised 229 invoices, reported a clean success, and
95 paying players were not among them. Nobody found out until a parent asked
why no bill had come for his son.

Two halves, because neither alone is enough:

- A destructive delete is refused while anything is enrolled — active or
  cancelled, since a cancelled enrolment is still the only record of what an
  issued invoice bought. Archive the programme, or transfer the players and
  close the group. The count drops the branch scope: an enrolment hidden by
  the active branch is still an enrolment, and reading zero because of it is
  how a guard like this fails open.
- The renewal command now names every paying player who has invoice history
  and no enrolment at all, and logs them. No guard recovers the rows already
  lost, or catches the next way somebody finds to lose them. An empty enrolment
  set with such players left over is a FAILURE exit — "nothing to bill" and
  "every enrolment was destroyed" produce the same empty set, and the players
  left over are the only thing that tells them apart.

Group deletion moves into TrainingGroupService so the guard sits on every path
to it; GroupList duplicating the cleanup inline is how one path ended up
without it.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent d3113b08
......@@ -6,6 +6,7 @@
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Services\InvoiceService;
use App\Domain\Identity\Services\BranchSettingsService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Enums\EnrollmentStatus;
......@@ -68,6 +69,7 @@ class GenerateRenewalInvoices extends Command
private int $waived = 0;
private int $adopted = 0;
private int $deferred = 0;
private int $orphaned = 0;
/** @var array<int, User|null> academy id => the user renewal invoices are attributed to */
private array $academyActors = [];
......@@ -97,8 +99,13 @@ public function handle(InvoiceService $invoiceService, PricingService $pricingSe
if ($enrollments->isEmpty()) {
$this->info('No renewing enrolments found.');
// Not an early exit any more. "Nothing to bill" and "every enrolment
// was destroyed" produce the same empty set here, and the second one
// is the emergency — it has to be the loudest line in the output,
// not the quietest.
$this->reportOrphanedPayers();
return self::SUCCESS;
return $this->orphaned > 0 ? self::FAILURE : self::SUCCESS;
}
$this->info("Checking {$enrollments->count()} renewing enrolment(s) against cycle {$today->format('Y-m')}.");
......@@ -107,9 +114,77 @@ public function handle(InvoiceService $invoiceService, PricingService $pricingSe
$this->process($enrollment, $today, $cap, $dryRun, $invoiceService, $pricingService);
}
$this->reportOrphanedPayers();
return $this->report($dryRun);
}
/**
* Name every paying player who is on no enrolment at all.
*
* The loop above can only bill what `enrollments` holds. A player whose
* enrolment row is gone is therefore not "skipped" — the command never sees
* them, every counter in the summary reads clean, and they keep training
* unbilled until a parent asks why no invoice came. That is precisely what
* happened at OC-Sport: the season's programmes were deleted and recreated
* under new names on 28 August 2026, the delete took 181 enrolments with
* them, and 95 paying players went through the whole of September with no
* invoice while this command reported a clean run every day.
*
* GuardsEnrolmentDeletion now stops the cause. This stops the silence — the
* two are a pair, because no guard catches the rows already lost, or the
* next way somebody finds to lose them.
*
* Deliberately narrow: only a player with NO enrolment row whatsoever. A
* player who left has a cancelled one and belongs off the cycle; counting
* them here would bury the real cases in noise until nobody read the list.
*
* Orphans alongside enrolments that did bill are a warning, not a failure
* exit: somebody has to sit down and re-enrol them, and a command that
* exits non-zero every night until they do is a command whose exit code
* stops meaning anything. Orphans with NO billable enrolment at all is the
* other case entirely — see handle(), where it is a failure — because
* "nothing to bill" and "every enrolment was destroyed" produce the same
* empty set, and the players left over are what tells them apart.
*/
private function reportOrphanedPayers(): void
{
// No withoutBranchScope() here, on purpose: this must report on exactly
// the population the loop above tried to bill. Unscoping one and not
// the other would list players from a branch this run never touched.
$orphans = Participant::query()
->whereIn('status', ['active', 'registered'])
->where('is_free', false)
->whereDoesntHave('enrollments')
->whereHas('invoices')
->with('person')
->get()
->reject(fn (Participant $p) => app(BranchSettingsService::class)
->billingHandledExternally($p->branch_id));
if ($orphans->isEmpty()) {
return;
}
$this->orphaned = $orphans->count();
$this->newLine();
$this->warn("{$this->orphaned} paying player(s) have invoice history but no enrolment — nothing bills them:");
foreach ($orphans as $participant) {
$name = $participant->person?->name_ar ?? $participant->person?->name ?? '—';
$number = $participant->participant_number ?: "#{$participant->id}";
$this->line(" {$number} {$name}");
}
$this->line(' Re-enrol them, or cancel them if they left. Until then they train unbilled.');
Log::error('Renewal run found paying players with no enrolment', [
'count' => $this->orphaned,
'participant_ids' => $orphans->pluck('id')->all(),
]);
}
private function process(
Enrollment $enrollment,
Carbon $today,
......@@ -471,6 +546,7 @@ private function report(bool $dryRun): int
'adopted onto the cycle' => $this->adopted,
'waived (price resolved to zero)' => $this->waived,
'older cycles deferred to the next run' => $this->deferred,
'paying players with NO enrolment (billed by nothing)' => $this->orphaned,
'FAILED' => $this->failed,
] as $label => $count) {
if ($count > 0) {
......
......@@ -2,14 +2,19 @@
namespace App\Domain\Training\Services;
use App\Domain\Scheduling\Models\Assignment;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingProgram;
use App\Domain\Training\Support\GuardsEnrolmentDeletion;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class TrainingGroupService
{
use GuardsEnrolmentDeletion;
private const VALID_TRANSITIONS = [
'forming' => ['active', 'cancelled'],
'active' => ['full', 'on_hold', 'completed', 'cancelled'],
......@@ -87,6 +92,45 @@ public function changeStatus(TrainingGroup $group, string $newStatus, User $acto
});
}
/**
* Delete a group nobody was ever enrolled in.
*
* A group holding enrolments is not deletable — see
* GuardsEnrolmentDeletion for why, and for what it cost the last time it
* was. Move the players (TransferGroup) or close the group (changeStatus →
* completed / cancelled); both keep the rows that invoices and attendance
* point at. Deletion stays for what it is actually for: a group created by
* mistake.
*/
public function delete(TrainingGroup $group): void
{
$this->guardEnrolmentsExist(
Enrollment::withoutBranchScope()->where('training_group_id', $group->id),
'المجموعة',
'انقل اللاعبين إلى مجموعة أخرى أو غيّر حالة المجموعة إلى «مكتملة»',
);
DB::transaction(function () use ($group) {
// Nullify transferred_to_id references from other enrollments.
// Deliberately across branches: a transfer may have moved a
// player to a group in another branch, and enrollments.
// transferred_to_id has no ON DELETE clause — a reference left
// behind because the scope hid it would abort the delete with a
// foreign-key violation.
Enrollment::withoutBranchScope()
->where('transferred_to_id', $group->id)
->update(['transferred_to_id' => null]);
// Sessions cascade-delete their attendance records automatically
$group->sessions()->forceDelete();
$group->schedules()->delete();
$group->waitlists()->delete();
Assignment::where('assignable_type', TrainingGroup::class)
->where('assignable_id', $group->id)
->delete();
$group->forceDelete();
});
}
public function incrementCount(TrainingGroup $group): void
{
DB::transaction(function () use ($group) {
......
......@@ -3,15 +3,19 @@
namespace App\Domain\Training\Services;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingProgram;
use App\Domain\Training\Models\TrainingSchedule;
use App\Domain\Training\Support\GuardsEnrolmentDeletion;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class TrainingProgramService
{
use GuardsEnrolmentDeletion;
public function create(array $data, User $actor, bool $skipDefaultGroup = false): TrainingProgram
{
return DB::transaction(function () use ($data, $actor, $skipDefaultGroup) {
......@@ -169,28 +173,55 @@ public function changeStatus(TrainingProgram $program, string $newStatus): Train
return $program->fresh();
}
/**
* Delete a programme nobody was ever enrolled in.
*
* A programme is not a label you can rewrite over — it is what a paid
* subscription points at, and a programme holding enrolments is not
* deletable. See GuardsEnrolmentDeletion for why, and for what it cost the
* last time it was. Archive it instead (changeStatus → archived): that
* takes it off every picker while leaving the enrolments, invoices and
* attendance that reference it intact.
*
* The groups are queried without their branch scope for the same reason
* the enrolment count is: a group in another branch is still a group whose
* enrolments this delete would destroy, and one hidden by the scope would
* walk straight past the guard.
*/
public function delete(TrainingProgram $program): void
{
DB::transaction(function () use ($program) {
$groupIds = $program->groups()->pluck('id')->toArray();
$groupIds = TrainingGroup::withoutBranchScope()
->where('training_program_id', $program->id)
->pluck('id');
$this->guardEnrolmentsExist(
Enrollment::withoutBranchScope()->where(function ($q) use ($program, $groupIds) {
$q->where('training_program_id', $program->id);
if ($groupIds->isNotEmpty()) {
$q->orWhereIn('training_group_id', $groupIds);
}
}),
'البرنامج',
'أرشِف البرنامج بدلاً من حذفه',
);
DB::transaction(function () use ($program, $groupIds) {
// Nullify transferred_to_id references from other enrollments pointing to these groups
if ($groupIds) {
\App\Domain\Training\Models\Enrollment::whereIn('transferred_to_id', $groupIds)
if ($groupIds->isNotEmpty()) {
Enrollment::withoutBranchScope()
->whereIn('transferred_to_id', $groupIds)
->update(['transferred_to_id' => null]);
}
foreach ($program->groups as $group) {
foreach (TrainingGroup::withoutBranchScope()->whereIn('id', $groupIds)->get() as $group) {
$group->sessions()->forceDelete();
$group->schedules()->delete();
$group->enrollments()->forceDelete();
$group->waitlists()->delete();
\App\Domain\Scheduling\Models\Assignment::where('assignable_type', TrainingGroup::class)
->where('assignable_id', $group->id)
->delete();
$group->forceDelete();
}
$program->enrollments()->forceDelete();
$program->forceDelete();
});
}
......
<?php
namespace App\Domain\Training\Support;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Enums\EnrollmentStatus;
use App\Domain\Training\Models\Enrollment;
use Illuminate\Database\Eloquent\Builder;
/**
* Refuse a destructive delete while enrolments still point at the thing.
*
* `enrollments` is the only table GenerateRenewalInvoices bills from, so an
* enrolment destroyed alongside its group or programme does not merely lose
* history: the player silently stops being billed, keeps training, and no
* screen, log or report anywhere says so. That is not hypothetical. OC-Sport's
* season programmes were deleted and recreated under new names on 28 August
* 2026; `forceDelete()` took 181 enrolments with them; 95 paying players got no
* September invoice at all, and the first anyone knew of it was a parent asking
* why there was no bill.
*
* Terminal enrolments count too. A cancelled enrolment is still the only record
* of what an issued invoice bought, and issued invoices are frozen.
*/
trait GuardsEnrolmentDeletion
{
/**
* The count deliberately drops the branch scope. An enrolment hidden
* because the user is looking at another branch is still an enrolment, and
* reading zero because of it is exactly how a guard like this fails open.
*
* @param Builder<Enrollment> $enrolments every enrolment the delete would destroy
* @param string $noun what is being deleted, in Arabic, for the message
* @param string $instead the non-destructive route to offer, in Arabic
*/
protected function guardEnrolmentsExist(Builder $enrolments, string $noun, string $instead): void
{
$active = (clone $enrolments)
->whereIn('status', [EnrollmentStatus::Active->value, EnrollmentStatus::Pending->value])
->count();
if ($active > 0) {
throw new DomainException(
"لا يمكن حذف {$noun}: بها {$active} اشتراك نشط. {$instead}."
);
}
$historical = $enrolments->count();
if ($historical > 0) {
throw new DomainException(
"لا يمكن حذف {$noun}: مرتبط بها {$historical} اشتراك سابق تستند إليه فواتير صادرة. {$instead}."
);
}
}
}
......@@ -11,7 +11,6 @@
use App\Domain\Shared\Exceptions\DomainException;
use App\Livewire\Concerns\AppliesRoleScope;
use App\Livewire\Concerns\WithSorting;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -86,32 +85,23 @@ public function changeStatus(string $uuid, string $newStatus, TrainingGroupServi
}
}
public function deleteGroup(string $uuid): void
public function deleteGroup(string $uuid, TrainingGroupService $service): void
{
$this->authorize('groups.delete');
$group = TrainingGroup::where('uuid', $uuid)->firstOrFail();
try {
DB::transaction(function () use ($group) {
// Nullify transferred_to_id references from other enrollments.
// Deliberately across branches: a transfer may have moved a
// player to a group in another branch, and enrollments.
// transferred_to_id has no ON DELETE clause — a reference left
// behind because the scope hid it would abort the delete with a
// foreign-key violation.
\App\Domain\Training\Models\Enrollment::withoutBranchScope()
->where('transferred_to_id', $group->id)
->update(['transferred_to_id' => null]);
// Sessions cascade-delete their attendance records automatically
$group->sessions()->forceDelete();
$group->schedules()->delete();
$group->enrollments()->forceDelete();
$group->waitlists()->delete();
$group->forceDelete();
});
// The delete lives in the service, not here, because the guard that
// refuses to destroy a group's enrolments has to sit on every path
// to it — this screen, the programme screen, and whatever calls it
// next. Duplicating the cleanup here is how one of them ended up
// without the guard in the first place.
$service->delete($group);
session()->flash('success', __('تم حذف المجموعة وجميع البيانات المرتبطة بها'));
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
} catch (\Throwable $e) {
session()->flash('error', __('فشل حذف المجموعة: ') . $e->getMessage());
}
......
......@@ -327,8 +327,97 @@ public function test_a_pricing_discount_is_subtracted_once_not_twice(): void
$this->assertSame(self::MEMBER_PRICE - 5000, (int) $invoice->total_amount, 'Discount applied exactly once.');
}
// ---- players nothing bills at all -------------------------------------
public function test_an_empty_enrolment_set_with_paying_players_left_over_is_a_failure(): void
{
// OC-Sport, 28 August 2026. The season's programmes were deleted and
// recreated under new names; the delete force-deleted 181 enrolments
// with them; 95 paying players got no September invoice. The command
// found nothing to bill, said "No renewing enrolments found." and
// exited SUCCESS every night for a week. Nobody knew until a parent
// asked why there was no bill. "Nothing to bill" and "every enrolment
// was destroyed" look identical from in here — the players left over
// are the only thing that tells them apart.
$this->orphanedPayer(50);
$this->artisan('enrollments:generate-renewals')
->expectsOutputToContain('1 paying player(s) have invoice history but no enrolment')
->assertFailed();
}
public function test_an_orphan_is_named_without_stopping_the_players_who_can_be_billed(): void
{
$this->player(1, 'member');
$this->orphanedPayer(50);
$this->artisan('enrollments:generate-renewals')
->expectsOutputToContain('no enrolment')
->assertSuccessful();
// Player 1 starts with nothing and gains September's invoice. Player 50
// starts with August's and gains nothing: the report is all this run
// can do for them.
$this->assertSame(1, Invoice::where('billable_id', 1)->count(), 'The billable player still bills.');
$this->assertSame(
['2026-08-01'],
Invoice::where('billable_id', 50)->get()->map(fn ($i) => $i->issue_date->toDateString())->all(),
'The orphan cannot be billed — only reported.'
);
}
public function test_a_player_who_left_is_not_reported_as_an_orphan(): void
{
// A cancelled enrolment is somebody who left, and they belong off the
// cycle. Listing them would bury the real cases until nobody read the
// list, which is the same failure as not printing it at all.
$this->player(1, 'member');
DB::table('enrollments')->where('id', 1)->update(['status' => 'cancelled']);
$this->invoiceFor(1);
$this->artisan('enrollments:generate-renewals')
->doesntExpectOutputToContain('no enrolment')
->assertSuccessful();
}
public function test_a_player_who_was_never_billed_is_not_reported_as_an_orphan(): void
{
// No enrolment and no invoice either: a half-finished registration, not
// money walking out of the door.
$this->orphanedPayer(50);
DB::table('invoices')->where('billable_id', 50)->delete();
$this->artisan('enrollments:generate-renewals')
->doesntExpectOutputToContain('no enrolment')
->assertSuccessful();
}
// ---- fixtures ---------------------------------------------------------
/**
* A paying player with invoice history whose enrolment row no longer
* exists — what a deleted programme used to leave behind.
*/
private function orphanedPayer(int $id): void
{
$this->player($id, 'member');
DB::table('enrollments')->where('id', $id)->delete();
$this->invoiceFor($id);
}
private function invoiceFor(int $participantId): void
{
DB::table('invoices')->insert([
'academy_id' => self::ACADEMY, 'branch_id' => self::BRANCH,
'number' => "INV-{$participantId}", 'type' => 'standard', 'status' => 'paid',
'billable_type' => 'App\\Domain\\Participant\\Models\\Participant',
'billable_id' => $participantId,
'subtotal_amount' => self::MEMBER_PRICE, 'total_amount' => self::MEMBER_PRICE,
'paid_amount' => self::MEMBER_PRICE, 'due_amount' => 0,
'issue_date' => '2026-08-01', 'created_at' => now(), 'updated_at' => now(),
]);
}
private function player(
int $id,
string $membership,
......@@ -345,6 +434,7 @@ private function player(
DB::table('participants')->insert([
'id' => $id, 'academy_id' => self::ACADEMY, 'branch_id' => self::BRANCH,
'person_id' => $id, 'status' => 'active', 'membership_type' => $membership,
'participant_number' => sprintf('P%05d', $id),
'is_free' => $isFree, 'classification' => 'regular',
'created_at' => now(), 'updated_at' => now(),
]);
......@@ -437,11 +527,25 @@ private function createSchema(): void
$t->string('status')->default('active');
$t->string('membership_type')->nullable();
$t->string('classification')->nullable();
$t->string('participant_number')->nullable();
$t->boolean('is_free')->default(false);
$t->timestamps();
$t->softDeletes();
});
// The orphan scan asks whether the player's branch bills externally,
// and the branch fixtures here give participants a real branch_id — so
// unlike the enrolment loop above, it actually reaches this table.
Schema::create('branch_settings', function (Blueprint $t) {
$t->id();
$t->unsignedBigInteger('academy_id')->nullable();
$t->unsignedBigInteger('branch_id');
$t->string('key', 100);
$t->text('value')->nullable();
$t->string('group', 50)->default('general');
$t->timestamps();
});
Schema::create('guardians', function (Blueprint $t) {
$t->id();
$t->unsignedBigInteger('academy_id')->nullable();
......
<?php
namespace Tests\Feature;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingProgram;
use App\Domain\Training\Services\TrainingGroupService;
use App\Domain\Training\Services\TrainingProgramService;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
/**
* Deleting a programme or a group must never take enrolments with it.
*
* On 28 August 2026 OC-Sport reorganised its season: the existing programmes
* were deleted and recreated under new names. Both delete paths called
* `$group->enrollments()->forceDelete()`, so 181 enrolment rows went with them
* — hard-deleted, no soft delete, no audit row, nothing to restore from.
*
* `enrollments` is the only table GenerateRenewalInvoices bills from. The
* players were still training, still members, still owed money; they simply
* stopped existing as far as billing was concerned. On 2 September the renewal
* run raised 229 invoices and reported a clean success, and 95 paying players
* were not among them. Nobody found out until a parent asked why no bill had
* come for his son.
*
* A destructive delete is now refused while anything is enrolled. These tests
* pin the refusal, and — more importantly — pin that the enrolments are still
* there afterwards.
*/
class ProgramDeleteKeepsEnrolmentsTest extends TestCase
{
private const ACADEMY = 1;
private const BRANCH = 1;
private const PROGRAM = 7;
private const GROUP = 3;
protected function setUp(): void
{
parent::setUp();
if (config('database.default') !== 'sqlite') {
$this->markTestSkipped('Builds its own schema; runs on the in-memory SQLite connection.');
}
$this->createSchema();
$this->seedProgramme();
}
public function test_a_programme_with_an_active_enrolment_cannot_be_deleted(): void
{
$this->enrol(1, 'active');
try {
app(TrainingProgramService::class)->delete(TrainingProgram::findOrFail(self::PROGRAM));
$this->fail('The delete went through and took a paying player off the billing run with it.');
} catch (DomainException $e) {
$this->assertStringContainsString('اشتراك نشط', $e->getMessage());
}
$this->assertSame(1, Enrollment::count(), 'The enrolment survives the refused delete.');
$this->assertNotNull(TrainingProgram::find(self::PROGRAM));
}
public function test_a_programme_whose_enrolments_are_all_cancelled_still_cannot_be_deleted(): void
{
// A cancelled enrolment is the only record of what an issued invoice
// bought, and issued invoices are frozen. Destroying it leaves money in
// the ledger pointing at nothing.
$this->enrol(1, 'cancelled');
$this->expectException(DomainException::class);
$this->expectExceptionMessageMatches('/فواتير صادرة/');
app(TrainingProgramService::class)->delete(TrainingProgram::findOrFail(self::PROGRAM));
}
public function test_a_group_with_an_enrolment_cannot_be_deleted(): void
{
$this->enrol(1, 'active');
try {
app(TrainingGroupService::class)->delete(TrainingGroup::findOrFail(self::GROUP));
$this->fail('The group delete went through and took the enrolment with it.');
} catch (DomainException $e) {
$this->assertStringContainsString('اشتراك نشط', $e->getMessage());
}
$this->assertSame(1, Enrollment::count());
$this->assertNotNull(TrainingGroup::find(self::GROUP));
}
public function test_an_enrolment_in_another_branch_still_blocks_the_delete(): void
{
// The guard counts without the branch scope on purpose. An enrolment
// hidden because the user is looking at another branch is still an
// enrolment, and reading zero because of it is how a guard like this
// fails open — quietly, and only in the multi-branch clubs.
$this->enrol(1, 'active', branchId: 2);
$this->expectException(DomainException::class);
app(TrainingProgramService::class)->delete(TrainingProgram::findOrFail(self::PROGRAM));
}
public function test_a_programme_nobody_was_enrolled_in_is_still_deletable(): void
{
// The whole point of keeping delete around: a programme created by
// mistake, with nothing riding on it.
app(TrainingProgramService::class)->delete(TrainingProgram::findOrFail(self::PROGRAM));
$this->assertNull(TrainingProgram::withTrashed()->find(self::PROGRAM));
$this->assertNull(TrainingGroup::withTrashed()->find(self::GROUP));
}
public function test_a_group_nobody_was_enrolled_in_is_still_deletable(): void
{
app(TrainingGroupService::class)->delete(TrainingGroup::findOrFail(self::GROUP));
$this->assertNull(TrainingGroup::withTrashed()->find(self::GROUP));
$this->assertNotNull(TrainingProgram::find(self::PROGRAM), 'Deleting a group leaves its programme alone.');
}
// ---- fixtures ---------------------------------------------------------
private function enrol(int $id, string $status, int $branchId = self::BRANCH): void
{
DB::table('enrollments')->insert([
'id' => $id, 'academy_id' => self::ACADEMY, 'branch_id' => $branchId,
'participant_id' => $id, 'training_group_id' => self::GROUP,
'training_program_id' => self::PROGRAM,
'enrollment_date' => '2026-07-01', 'start_date' => '2026-07-01',
'status' => $status, 'payment_status' => 'pending',
'next_billing_date' => '2026-09-01',
'created_at' => now(), 'updated_at' => now(),
]);
}
private function seedProgramme(): void
{
DB::table('training_programs')->insert([
'id' => self::PROGRAM, 'academy_id' => self::ACADEMY, 'branch_id' => self::BRANCH,
'name' => 'Academy 2017-2018', 'name_ar' => 'اكاديمية 2017 -2018',
'slug' => 'academy-2017-2018', 'status' => 'active',
'renewal_policy' => 'auto_renew', 'billing_cycle' => 'monthly',
'created_at' => now(), 'updated_at' => now(),
]);
DB::table('training_groups')->insert([
'id' => self::GROUP, 'academy_id' => self::ACADEMY, 'branch_id' => self::BRANCH,
'training_program_id' => self::PROGRAM, 'name' => 'G1', 'name_ar' => 'مجموعة',
'code' => 'ACAD01', 'status' => 'active',
'created_at' => now(), 'updated_at' => now(),
]);
}
private function createSchema(): void
{
Schema::create('academies', function (Blueprint $t) {
$t->id();
$t->string('name')->nullable();
$t->timestamps();
});
DB::table('academies')->insert(['id' => self::ACADEMY, 'name' => 'OC']);
Schema::create('training_programs', function (Blueprint $t) {
$t->id();
$t->uuid('uuid')->nullable();
$t->unsignedBigInteger('academy_id')->nullable();
$t->unsignedBigInteger('branch_id')->nullable();
$t->string('name')->nullable();
$t->string('name_ar')->nullable();
$t->string('slug')->nullable();
$t->string('status')->default('active');
$t->string('renewal_policy')->nullable();
$t->string('billing_cycle')->nullable();
$t->unsignedBigInteger('created_by')->nullable();
$t->timestamps();
$t->softDeletes();
});
Schema::create('training_groups', function (Blueprint $t) {
$t->id();
$t->uuid('uuid')->nullable();
$t->unsignedBigInteger('academy_id')->nullable();
$t->unsignedBigInteger('branch_id')->nullable();
$t->unsignedBigInteger('training_program_id')->nullable();
$t->string('name')->nullable();
$t->string('name_ar')->nullable();
$t->string('code')->nullable();
$t->string('status')->default('active');
$t->timestamp('status_changed_at')->nullable();
$t->unsignedInteger('current_count')->default(0);
$t->unsignedInteger('waitlist_count')->default(0);
$t->unsignedBigInteger('created_by')->nullable();
$t->timestamps();
$t->softDeletes();
});
Schema::create('enrollments', function (Blueprint $t) {
$t->id();
$t->uuid('uuid')->nullable();
$t->unsignedBigInteger('academy_id')->nullable();
$t->unsignedBigInteger('branch_id')->nullable();
$t->unsignedBigInteger('participant_id')->nullable();
$t->unsignedBigInteger('training_group_id')->nullable();
$t->unsignedBigInteger('training_program_id')->nullable();
$t->unsignedBigInteger('transferred_to_id')->nullable();
$t->date('enrollment_date')->nullable();
$t->date('start_date')->nullable();
$t->date('next_billing_date')->nullable();
$t->string('status')->default('active');
$t->string('payment_status')->nullable();
$t->unsignedBigInteger('enrolled_by')->nullable();
$t->unsignedBigInteger('invoice_id')->nullable();
$t->json('metadata')->nullable();
$t->timestamps();
});
Schema::create('training_sessions', function (Blueprint $t) {
$t->id();
$t->unsignedBigInteger('academy_id')->nullable();
$t->unsignedBigInteger('training_group_id')->nullable();
$t->date('session_date')->nullable();
$t->timestamps();
});
Schema::create('training_schedules', function (Blueprint $t) {
$t->id();
$t->unsignedBigInteger('academy_id')->nullable();
$t->unsignedBigInteger('training_group_id')->nullable();
$t->unsignedBigInteger('facility_id')->nullable();
$t->unsignedTinyInteger('day_of_week')->nullable();
$t->timestamps();
});
Schema::create('waitlists', function (Blueprint $t) {
$t->id();
$t->unsignedBigInteger('academy_id')->nullable();
$t->unsignedBigInteger('training_group_id')->nullable();
$t->unsignedBigInteger('participant_id')->nullable();
$t->timestamps();
});
Schema::create('assignments', function (Blueprint $t) {
$t->id();
$t->unsignedBigInteger('academy_id')->nullable();
$t->string('assignable_type')->nullable();
$t->unsignedBigInteger('assignable_id')->nullable();
$t->timestamps();
$t->softDeletes();
});
Schema::create('audit_logs', function (Blueprint $t) {
$t->id();
$t->unsignedBigInteger('academy_id')->nullable();
$t->unsignedBigInteger('user_id')->nullable();
$t->string('auditable_type')->nullable();
$t->unsignedBigInteger('auditable_id')->nullable();
$t->string('action')->nullable();
$t->json('old_values')->nullable();
$t->json('new_values')->nullable();
$t->string('ip_address')->nullable();
$t->text('user_agent')->nullable();
$t->text('url')->nullable();
$t->boolean('is_financial')->default(false);
$t->timestamp('created_at')->nullable();
});
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment