Commit 2b6e6997 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(settlements): let the desk put a player back in a programme

The console could tell an operator that a player had no enrolment and nothing
in the system was billing him. It could not do anything about it: all ten
settlement actions move money on an account that already exists, so the one
account that had stopped existing was the one the wizard could not fix. The
operator read the diagnosis and had no button.

`restore_enrollment` is that button. It delegates to EnrollmentService rather
than inserting a row, so a restored enrolment passes the same checks a new one
does — capacity, group status, one-group-per-programme, the branch rule — and
lands identical to every other row in the table. A repair that produces a
slightly different shape of row is a second bug waiting.

It raises no invoice. `next_billing_date` comes out as the 1st of next month,
so the cycle resumes on its own and the current month stays a deliberate act:
the operator adds `bill_month` for it if it is owed, with the amount in front
of them. Restoring a subscription and charging for it are two decisions and the
second is not ours to assume.

The group is suggested, not chosen. The enrolment that knew the programme is
gone; the only surviving record is the name on the player's last subscription
invoice, and that programme was deleted and recreated under a new one — so it
cannot be looked up, only matched. Matching is on the years in the name,
because these programmes are birth-year cohorts: «اكاديمية 2017 -2018» and
«أكاديمية (2017-2018)» share nothing as strings and are obviously one cohort to
a human. Cohorts written in two digits match nothing and a retired squad has no
successor, which is exactly where the person at the desk knows and this screen
does not — so the suggestion is labelled as a guess and the full list is always
there.

Two bugs found while proving it works, both real rather than test-only:

- EnrollmentService left `academy_id` to BelongsToAcademy, which fills it from
  the `current_academy` container binding — bound only inside a web request. An
  enrolment created from a command, a queued job or a service died on a NOT
  NULL violation. It is now stated from the participant, which is true under
  every caller and which the trait leaves alone.
- The group list sorted with `sortBy([fn, fn])`. sortBy reads an array as
  [column, direction] pairs, so it sorted by neither and buried the suggestion
  mid-list, where an operator in a hurry never sees it.

Verified end to end against the restored tenant: stage the restore, apply it,
and the player is enrolled in the suggested group with next month's billing
date and not one new invoice. 498 tests, 389 pass / 109 skip on Postgres and
313 / 185 on SQLite.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent d760eb10
......@@ -17,6 +17,8 @@
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Services\EnrollmentService;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
......@@ -62,6 +64,7 @@ class SettlementService
'move_payment',
'credit_wallet',
'void_duplicate',
'restore_enrollment',
];
private const ARABIC_MONTHS = [
......@@ -75,6 +78,7 @@ public function __construct(
private PaymentService $payments,
private InventoryService $inventory,
private WalletService $wallets,
private EnrollmentService $enrollments,
) {}
/**
......@@ -127,6 +131,7 @@ public function apply(
'move_payment' => $this->movePayment($participant, $action, $actor),
'credit_wallet' => $this->creditWallet($participant, $action, $actor),
'void_duplicate' => $this->voidDuplicate($participant, $action, $actor),
'restore_enrollment' => $this->restoreEnrollment($participant, $action, $actor),
};
$collected += $result['collected'] ?? 0;
......@@ -295,6 +300,60 @@ private function waiveInvoice(Participant $participant, array $a, User $actor):
* line, not implied by the issue date, so the roster files it under the
* month it pays for however late it is entered (see SubscriptionLine).
*/
/**
* Put a player back in a programme when their enrolment row is gone.
*
* Every other action here moves money on an account that exists. This one
* exists because an account can stop existing: `enrollments` is the only
* table monthly billing reads, and a player with no row there is not billed
* late — he is not billed at all, and no report lists him. OC-Sport lost 181
* of those rows when the season's programmes were deleted and recreated, and
* 95 paying players went a month unbilled with nothing anywhere to fix it
* from: the desk could see the diagnosis and had no button.
*
* It delegates to EnrollmentService rather than inserting a row, so the
* restored enrolment goes through exactly the checks a new one does —
* capacity, group status, one-group-per-programme, the branch rule — and
* lands identical to every other enrolment in the table. A repair that
* produces a slightly different shape of row is a second bug waiting.
*
* No invoice. `next_billing_date` comes out as the 1st of next month, so
* the cycle resumes on its own and the current month stays a deliberate
* act — the operator adds `bill_month` for it if it is owed, with the
* amount in front of them. Restoring a subscription and charging for it are
* two different decisions and the second one is not ours to assume.
*/
private function restoreEnrollment(Participant $participant, array $a, User $actor): array
{
$group = TrainingGroup::withoutGlobalScopes()
->with('program')
->find((int) ($a['training_group_id'] ?? 0));
if (! $group) {
throw new DomainException('المجموعة غير موجودة');
}
// Scopes are off above so a group the operator's branch cannot see
// still resolves; the tenancy check is therefore explicit, not implied.
if ((int) $group->academy_id !== (int) $participant->academy_id) {
throw new DomainException('المجموعة تتبع أكاديمية أخرى');
}
$enrollment = $this->enrollments->enroll($participant, $group, $actor, [
'skip_auto_invoice' => true,
]);
return [
'collected' => 0,
'waived' => 0,
'billed' => 0,
'enrollment_id' => $enrollment->id,
'program_name' => $group->program?->name_ar,
'group_name' => $group->name_ar,
'next_billing_date' => $enrollment->next_billing_date?->toDateString(),
];
}
private function billMonth(Participant $participant, array $a, User $actor, ?int $branchId): array
{
$amount = (int) ($a['amount'] ?? 0);
......
......@@ -109,6 +109,16 @@ public function enroll(Participant $participant, TrainingGroup $group, User $act
$waived = $this->billingIsWaived($participant, $group);
$enrollment = Enrollment::create([
// Stated, not inherited. BelongsToAcademy fills this from the
// `current_academy` container binding, which only exists inside
// a web request — so an enrolment created from a command, a
// queued job, or the settlement service died on a NOT NULL
// violation instead of working. An enrolment belongs to its
// participant's academy under every caller, and saying so is
// both cheaper and truer than depending on ambient state. The
// trait leaves an already-set value alone, so a web request is
// unaffected.
'academy_id' => $participant->academy_id,
// The enrolment belongs to the group's branch, not to whichever
// branch the person doing the enrolling was looking at.
'branch_id' => $group->branch_id ?? $participant->branch_id,
......
......@@ -11,6 +11,7 @@
use App\Domain\Pricing\Services\PricingService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Support\ArabicSearch;
use App\Domain\Training\Models\TrainingGroup;
use Illuminate\Support\Carbon;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
......@@ -194,7 +195,7 @@ public function getDiagnosisProperty(): array
return [
'money' => [], 'handtyped' => [], 'bundle_status' => [],
'missing_bundle' => [], 'partial_bundle' => [], 'over_billed' => [],
'unbilled_months' => [], 'duplicate_of' => [],
'unbilled_months' => [], 'duplicate_of' => [], 'no_enrollment' => null,
];
}
......@@ -310,6 +311,98 @@ public function getSuggestedMonthlyPriceProperty(): int
return (int) ($this->invoices->where('total_amount', '>', 0)->last()?->total_amount ?? 0);
}
/**
* The groups a lost player can be put back into, best guess first.
*
* The enrolment that knew which programme he was in is gone; the only
* surviving record is the programme name written on his last subscription
* invoice. That name belongs to a programme that no longer exists — it was
* deleted and recreated under a new one — so it cannot be looked up, only
* matched.
*
* Matching is on the years in the name, because this club's programmes are
* birth-year cohorts: «اكاديمية 2017 -2018» and «أكاديمية (2017-2018)» share
* nothing as strings and are obviously the same cohort to a human. Years
* overlap or they do not, and that survives every spelling of أكاديمية,
* every stray space and every kind of bracket.
*
* It is a suggestion and it is labelled as one. Cohorts that write their
* years in two digits («21/22/23») match nothing, and a squad whose
* programme was retired has no successor to point at — those are exactly the
* cases where the person at the desk knows and this screen does not, so the
* full list is always there and nothing is preselected without being shown.
*
* @return \Illuminate\Support\Collection<int, TrainingGroup>
*/
public function getRestorableGroupsProperty()
{
$participant = $this->participant;
if (! $participant) {
return collect();
}
$suggested = $this->suggestedGroupId;
return TrainingGroup::with('program')
->whereHas('program', fn ($q) => $q->whereNull('deleted_at'))
->whereIn('status', ['forming', 'active'])
->when($participant->branch_id, fn ($q) => $q->where('branch_id', $participant->branch_id))
->get()
// One composite key, not an array of closures: sortBy() reads an
// array as [column, direction] pairs, so passing two callbacks
// silently sorted by neither and the suggestion was buried
// mid-list — where an operator in a hurry never sees it.
->sortBy(fn ($group) => ($group->id === $suggested ? '0' : '1') . ($group->program?->name_ar ?? ''))
->values();
}
/** The group the player's own last invoice points at, or null. */
public function getSuggestedGroupIdProperty(): ?int
{
$wanted = $this->lastProgrammeYears();
if ($wanted === []) {
return null;
}
foreach ($this->restorableGroupsUnsorted() as $group) {
$years = $this->yearsIn((string) ($group->program?->name_ar ?? ''));
if (array_intersect($wanted, $years) !== []) {
return (int) $group->id;
}
}
return null;
}
/** @return \Illuminate\Support\Collection<int, TrainingGroup> */
private function restorableGroupsUnsorted()
{
$participant = $this->participant;
return TrainingGroup::with('program')
->whereHas('program', fn ($q) => $q->whereNull('deleted_at'))
->whereIn('status', ['forming', 'active'])
->when($participant?->branch_id, fn ($q) => $q->where('branch_id', $participant->branch_id))
->get();
}
/** @return array<int,int> */
private function lastProgrammeYears(): array
{
return $this->yearsIn((string) ($this->diagnosis['no_enrollment']['last_program'] ?? ''));
}
/** @return array<int,int> */
private function yearsIn(string $name): array
{
preg_match_all('/\b(20\d{2})\b/', $name, $matches);
return array_map('intval', $matches[1]);
}
public function getBundleProductsProperty()
{
$participant = $this->participant;
......@@ -423,6 +516,14 @@ private function defaultsFor(string $type, array $context): array
'product_name' => (string) ($context['product_name'] ?? ''),
'note' => '',
],
'restore_enrollment' => [
// Preselected only when the invoice actually points somewhere.
// A wrong default that looks confident is worse than none: the
// operator confirms what is in front of them, and a zero here
// makes them choose rather than nod.
'training_group_id' => (int) ($context['training_group_id'] ?? $this->suggestedGroupId ?? 0),
'note' => '',
],
'plan_installments' => [
'invoice_id' => (int) ($context['invoice_id'] ?? 0),
'invoice_number' => (string) ($context['invoice_number'] ?? ''),
......@@ -576,6 +677,22 @@ private function validateDraft(): array
}
}
if ($type === 'restore_enrollment') {
$groupId = (int) ($d['training_group_id'] ?? 0);
// Checked against the list this screen actually offered, not against
// the table: the id arrives from the browser, and a group from
// another branch would otherwise put the player on a roster nobody
// here can see. SettlementService re-checks the academy on apply.
if (! $this->restorableGroups->contains('id', $groupId)) {
$errors['training_group_id'] = __('اختر المجموعة التي سيُسجَّل فيها');
}
if ($this->participant?->enrollments->contains('training_group_id', $groupId)) {
$errors['training_group_id'] = __('المشترك مسجل بالفعل في هذه المجموعة');
}
}
return $errors;
}
......@@ -641,6 +758,18 @@ private function normaliseDraft(): array
'removed' => max(0, (int) ($d['current'] ?? 0) - $p('new_amount')),
'product_name' => (string) ($d['product_name'] ?? ''),
],
'restore_enrollment' => $base + [
'training_group_id' => (int) $d['training_group_id'],
// Carried for the review step and the settlement record, so the
// row reads "put back into أكاديمية 2015" a year from now
// rather than "training_group_id 45".
'group_label' => (string) optional(
$this->restorableGroups->firstWhere('id', (int) $d['training_group_id'])
)->name_ar,
'program_label' => (string) optional(optional(
$this->restorableGroups->firstWhere('id', (int) $d['training_group_id'])
)->program)->name_ar,
],
'plan_installments' => $base + [
'invoice_id' => (int) $d['invoice_id'],
'invoice_number' => (string) ($d['invoice_number'] ?? ''),
......
......@@ -125,6 +125,37 @@ class="text-sm text-amber-700 hover:text-amber-900 font-medium">
if (!empty($diagnosis['unbilled_months'])) $flags[] = 'unbilled_month';
if (!empty($diagnosis['duplicate_of'])) $flags[] = 'duplicate_person';
@endphp
{{-- A player nothing bills.
Deliberately above the amber block and not inside it: every other
flag is a number that is wrong, this one is a player who has
stopped existing as far as billing is concerned, and it has to be
fixed before any of the money below it means anything. --}}
@if(!empty($diagnosis['no_enrollment']))
@php $orphan = $diagnosis['no_enrollment']; @endphp
<div class="bg-rose-50 border border-rose-200 rounded-xl p-4 mb-4">
<h3 class="text-sm font-bold text-rose-900">{{ __('لا يوجد لهذا المشترك اشتراك مسجَّل') }}</h3>
<p class="mt-1.5 text-xs leading-relaxed text-rose-900 max-w-[70ch]">
{{ __('لا شيء في النظام يحاسبه، ولا يظهر في كشف أي مجموعة. آخر فاتورة له في') }}
<span class="font-bold tabular-nums" dir="ltr">{{ $orphan['last_month'] ?? '' }}</span>،
{{ __('وله') }} <span class="font-bold tabular-nums" dir="ltr">{{ $orphan['invoices'] }}</span> {{ __('فاتورة سابقة.') }}
@if($orphan['last_program'])
{{ __('البرنامج المكتوب على آخر فاتورة:') }}
<span class="font-bold">«{{ $orphan['last_program'] }}»</span>.
@else
{{ __('فواتيره لا تسمّي برنامجاً اسأل المدرب عن مجموعته قبل التسجيل.') }}
@endif
</p>
<p class="mt-1.5 text-[11px] text-rose-800">
{{ __('التسجيل هنا يعيده إلى المجموعة فقط ولا يصدر أي فاتورة. التجديد يستأنف تلقائياً من أول الشهر القادم؛ وإن كان الشهر الجاري مستحقاً عليه، أضِف «فوترة شهر» بعدها.') }}
</p>
<button type="button" wire:click="startDraft('restore_enrollment')"
class="mt-3 inline-flex items-center px-3 py-1.5 bg-rose-600 hover:bg-rose-700 text-white text-xs font-semibold rounded-lg transition-colors">
{{ __('إعادة تسجيله في مجموعة') }}
</button>
</div>
@endif
@if($flags)
<div class="bg-amber-50 border border-amber-200 rounded-xl p-4 mb-4">
<h3 class="text-sm font-bold text-amber-900 mb-2">{{ __('ما رصده النظام على هذا الحساب') }}</h3>
......@@ -530,6 +561,36 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
</div>
@endif
@if($draftType === 'restore_enrollment')
<div class="sm:col-span-3">
<label for="restore_group" class="block text-xs text-gray-600 mb-1">{{ __('المجموعة التي سيُسجَّل فيها') }}</label>
<select id="restore_group" wire:model="draft.training_group_id"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
<option value="0">{{ __('— اختر مجموعة —') }}</option>
@foreach($this->restorableGroups as $group)
{{-- Most groups here are the programme's only group and carry
its name, so printing both reads as a stutter. Say the
group only when it is actually telling you something. --}}
<option value="{{ $group->id }}">{{ $group->program?->name_ar }}@if($group->name_ar && $group->name_ar !== $group->program?->name_ar) — {{ $group->name_ar }}@endif @if($group->id === $this->suggestedGroupId)({{ __('الأقرب لآخر فاتورة') }})@endif</option>
@endforeach
</select>
@error('draft.training_group_id') <p class="text-[11px] text-red-600 mt-1">{{ $message }}</p> @enderror
@if($this->suggestedGroupId)
<p class="mt-1.5 text-[11px] text-gray-500 max-w-[70ch] leading-relaxed">
{{-- Said out loud because it is a guess: the old programme was
deleted, so this is matched on the years in the name, not
looked up. The person at the desk overrules it. --}}
{{ __('الاقتراح مبني على سنوات الميلاد في اسم البرنامج المكتوب على آخر فاتورة — راجعه قبل التأكيد، وغيّره إن كان المدرب يعرف غير ذلك.') }}
</p>
@else
<p class="mt-1.5 text-[11px] text-amber-700 max-w-[70ch] leading-relaxed">
{{ __('لا يوجد برنامج حالي يطابق ما هو مكتوب على فواتيره — اسأل المدرب عن مجموعته قبل الاختيار.') }}
</p>
@endif
</div>
@endif
@if($draftType === 'link_line')
<div class="sm:col-span-3 p-2.5 bg-blue-50 border border-blue-200 rounded-lg text-xs text-blue-900">
{{ __('سيُربط البند') }} «{{ $draft['description'] ?? '' }}» {{ __('بمنتج') }}
......
......@@ -44,6 +44,14 @@
@case('link_line')
{{ __('ربط بند') }} «{{ $item['description'] }}» {{ __('بمنتج') }} {{ $item['product_name'] }}
@break
@case('restore_enrollment')
{{-- Named, not numbered: a year from now this row has to say
where the player was put back, not which id it used. --}}
{{ __('إعادة تسجيل في') }}
<span class="font-bold">{{ $item['program_label'] ?: __('برنامج') }}</span>
@if($item['group_label']) — {{ $item['group_label'] }} @endif
<span class="text-gray-500">· {{ __('بدون فاتورة، والتجديد يبدأ أول الشهر القادم') }}</span>
@break
@case('move_payment')
{{ __('نقل دفعة') }} <span dir="ltr" class="font-bold">{{ $item['amount_display'] ?? '' }}</span>
{{ __('من') }} <span dir="ltr">{{ $item['from_invoice_number'] ?? '' }}</span> {{ __('إلى الفاتورة الصحيحة') }}
......
......@@ -5,9 +5,12 @@
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Services\AccountAnomalyScanner;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Models\Enrollment;
use App\Livewire\Admin\AccountSettlementWizard;
use App\Livewire\Admin\SettlementWorklist;
use App\Models\User;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
use Tests\TestCase;
......@@ -173,6 +176,88 @@ public function test_the_wizard_opens_on_a_participant_and_shows_the_account():
$response->assertSee($invoice->number);
}
public function test_the_wizard_can_put_a_lost_player_back_in_a_programme(): void
{
// The whole point of the action: the desk could see the diagnosis and
// had no button. This walks the real path — open the account, stage the
// restore, apply it — and then checks the player is enrolled, that no
// invoice was raised behind the operator's back, and that the cycle
// resumes next month rather than billing the current one by surprise.
$orphan = collect(app(AccountAnomalyScanner::class)->scan())
->first(fn ($row) => in_array('no_enrollment', $row['cases'], true));
if (! $orphan) {
$this->markTestSkipped('This tenant snapshot has no orphaned accounts.');
}
$participant = $orphan['participant'];
$invoicesBefore = Invoice::where('billable_type', Participant::class)
->where('billable_id', $participant->id)->count();
$component = Livewire::actingAs($this->anAdmin())
->test(AccountSettlementWizard::class, ['participant' => $participant->id]);
$group = $component->get('restorableGroups')->first();
$this->assertNotNull($group, 'There has to be somewhere to put him back.');
$this->assertSame(
$component->get('suggestedGroupId'),
$group->id,
'The group his own last invoice points at has to be the first one offered.'
);
DB::transaction(function () use ($component, $group, $participant, $invoicesBefore) {
$component->call('startDraft', 'restore_enrollment')
->set('draft.training_group_id', $group->id)
->call('addDraft')
->assertHasNoErrors()
->set('reason', 'إعادة تسجيل بعد حذف البرنامج القديم')
->call('applySettlement')
->assertHasNoErrors();
$enrollment = Enrollment::where('participant_id', $participant->id)->first();
$this->assertNotNull($enrollment, 'The player is enrolled again.');
$this->assertSame($group->id, (int) $enrollment->training_group_id);
$this->assertSame('active', $enrollment->status->value);
// Next month, not this one. Restoring a subscription and charging
// for it are two decisions, and the second is the operator's.
$this->assertSame(
now()->startOfMonth()->addMonth()->toDateString(),
$enrollment->next_billing_date->toDateString(),
);
$this->assertSame(
$invoicesBefore,
Invoice::where('billable_type', Participant::class)->where('billable_id', $participant->id)->count(),
'Restoring an enrolment must not raise an invoice.'
);
// Read-only test against a live snapshot: nothing it writes stays.
DB::rollBack();
});
}
public function test_a_group_from_another_branch_cannot_be_used_to_restore(): void
{
// The id arrives from the browser. A group outside the operator's branch
// would put the player on a roster nobody at this desk can see, while
// his own branch keeps billing him.
$orphan = collect(app(AccountAnomalyScanner::class)->scan())
->first(fn ($row) => in_array('no_enrollment', $row['cases'], true));
if (! $orphan) {
$this->markTestSkipped('This tenant snapshot has no orphaned accounts.');
}
Livewire::actingAs($this->anAdmin())
->test(AccountSettlementWizard::class, ['participant' => $orphan['participant']->id])
->call('startDraft', 'restore_enrollment')
->set('draft.training_group_id', 999999)
->call('addDraft')
->assertHasErrors('draft.training_group_id');
}
public function test_the_wizard_opens_empty_without_a_participant(): void
{
$response = $this->actingAs($this->anAdmin())->get(route('admin.account-settlement'));
......
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