Commit 190c0ccf authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(billing): add a command that puts unbilled players back on the cycle

enrollments is the only table GenerateRenewalInvoices bills from, so a player
whose enrolment row was destroyed is not "skipped" — that command never sees
them, every counter in its summary reads clean, and they train unbilled
indefinitely. GuardsEnrolmentDeletion refuses the delete that causes it and
GenerateRenewalInvoices names the players already affected. Neither puts
anybody back.

The rebuild is from evidence, never inference: audit_logs holds the created and
updated entries for the destroyed enrolment, and those name the group and
programme the player was actually in. Where that programme survives the player
goes straight back into it. Where it was deleted — the usual case, since
deleting the programme is what destroyed the enrolment — the command refuses to
pick a replacement by name similarity and demands an explicit --map old:new. A
player put in the wrong programme is billed the wrong price, and a wrong
invoice is worse than a missing one because it looks right.

next_billing_date is set to the cycle after the player's last invoice, capped
at the current one, so the renewal run raises the months they actually owe,
each dated the 1st of the cycle it buys — never a month nobody ever billed
them for. Dry run by default; this exists to be pointed at production.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 50f3a2bd
<?php
namespace App\Console\Commands;
use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Identity\Services\BranchSettingsService;
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\Models\TrainingProgram;
use App\Domain\Training\Services\EnrollmentService;
use App\Domain\Training\Support\BillingCycle;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Put paying players back on the billing cycle after their enrolment row was
* destroyed.
*
* `enrollments` is the only table GenerateRenewalInvoices bills from. A player
* whose row is gone is not "skipped" by that command — it never sees them at
* all, every counter in its summary reads clean, and they keep training
* unbilled indefinitely. GuardsEnrolmentDeletion now refuses the delete that
* causes this, and GenerateRenewalInvoices names the players already affected.
* Neither of those puts anybody back. This does.
*
* The rebuild is from evidence, never from inference. `audit_logs` holds the
* `created`/`updated` entries for the destroyed enrolment, and those name the
* group and the programme the player was actually in. Where that programme
* still exists the player goes straight back into it. Where it was deleted —
* the usual case, since deleting the programme is what destroyed the enrolment
* — the command REFUSES to pick a replacement by name similarity and demands
* an explicit old:new mapping. A player put into the wrong programme is billed
* the wrong programme's price, and a wrong invoice is worse than a missing one
* because it looks right.
*
* Writes only with --apply. The default is a dry run, because this exists to
* be pointed at production.
*/
class RestoreOrphanedEnrolments extends Command
{
protected $signature = 'enrollments:restore-orphaned
{--apply : Actually write. Without this the command only reports.}
{--map=* : Replacement for a deleted programme, as old_id:new_id. Repeatable.}
{--participant=* : Restrict to these participant ids.}
{--bill-from= : Cycle the restored enrolment owes from (YYYY-MM-DD). Defaults to the cycle after each player last billed.}';
protected $description = 'Rebuild destroyed enrolments for paying players who nothing is billing, using the audit trail';
private int $restored = 0;
private int $unmapped = 0;
private int $noEvidence = 0;
private int $failed = 0;
/** @var array<int, int> deleted programme id => the programme that replaced it */
private array $map = [];
public function handle(EnrollmentService $enrollmentService): int
{
$apply = (bool) $this->option('apply');
if (!$this->parseMap()) {
return self::FAILURE;
}
$orphans = $this->orphans();
if ($orphans->isEmpty()) {
$this->info('No paying player is missing an enrolment. Nothing to restore.');
return self::SUCCESS;
}
$this->info("{$orphans->count()} paying player(s) have no enrolment row.");
$this->newLine();
foreach ($orphans as $participant) {
$this->restore($participant, $apply, $enrollmentService);
}
return $this->report($apply);
}
/**
* Deliberately the same population GenerateRenewalInvoices reports as
* orphaned. If the two ever disagreed, this command would "fix" players
* that command was not complaining about and leave the ones it was.
*
* @return \Illuminate\Support\Collection<int, Participant>
*/
private function orphans()
{
$only = array_map('intval', (array) $this->option('participant'));
return Participant::query()
->whereIn('status', ['active', 'registered'])
->where('is_free', false)
->whereDoesntHave('enrollments')
->whereHas('invoices')
->when($only, fn ($q) => $q->whereIn('id', $only))
->with('person')
->orderBy('id')
->get()
->reject(fn (Participant $p) => app(BranchSettingsService::class)
->billingHandledExternally($p->branch_id));
}
private function restore(Participant $participant, bool $apply, EnrollmentService $enrollmentService): void
{
$name = $this->name($participant);
$evidence = $this->lastKnownEnrolment($participant);
if (!$evidence) {
$this->noEvidence++;
$this->line(" [NO EVIDENCE] {$name} — no enrolment in the audit trail; re-enrol by hand");
return;
}
$program = $this->targetProgram($evidence['program_id']);
if (!$program) {
$this->unmapped++;
$oldName = $this->deletedProgramName($evidence['program_id']) ?: "#{$evidence['program_id']}";
$this->warn(" [UNMAPPED] {$name} — was in «{$oldName}» (programme {$evidence['program_id']}), which no longer exists. Pass --map={$evidence['program_id']}:<new_id>");
return;
}
$billFrom = $this->billFrom($participant);
if ($apply === false) {
$this->line(" [DRY] {$name}{$program->name_ar} (programme {$program->id}), owing from {$billFrom->format('Y-m')}");
$this->restored++;
return;
}
try {
DB::transaction(function () use ($participant, $program, $billFrom, $enrollmentService, $name) {
$actor = $this->actorFor($participant);
if (!$actor) {
throw new DomainException('no user exists to attribute the enrolment to');
}
// skip_auto_invoice: this restores the enrolment, it does not
// raise the money. GenerateRenewalInvoices bills every cycle
// from next_billing_date on its next run, dated the 1st of the
// cycle it buys — which is what puts the missing month back in
// the month it belongs to instead of today.
$enrollment = $enrollmentService->enrollInProgram($participant, $program, $actor, [
'skip_auto_invoice' => true,
]);
// enroll() sets next_billing_date to the FIRST cycle of a brand
// new enrolment. This is not a new enrolment, it is a restored
// one, and the player already paid up to a point — so the
// anchor is the cycle after the last one they were billed for.
$enrollment->update(['next_billing_date' => $billFrom->toDateString()]);
$this->restored++;
$this->info(" [RESTORED] {$name}{$program->name_ar}, owing from {$billFrom->format('Y-m')}");
Log::info('Orphaned enrolment restored', [
'participant_id' => $participant->id,
'enrollment_id' => $enrollment->id,
'program_id' => $program->id,
'next_billing_date' => $billFrom->toDateString(),
]);
});
} catch (\Throwable $e) {
$this->failed++;
$this->error(" [FAIL] {$name}{$e->getMessage()}");
Log::error('Orphaned enrolment restore failed', [
'participant_id' => $participant->id,
'error' => $e->getMessage(),
]);
}
}
/**
* The group and programme the destroyed enrolment actually named.
*
* Newest entry wins: a player transferred between groups has several, and
* the last one is where they were when the row was destroyed.
*
* @return array{program_id:int, group_id:?int}|null
*/
private function lastKnownEnrolment(Participant $participant): ?array
{
$row = DB::table('audit_logs')
->where('auditable_type', Enrollment::class)
->where(function ($q) use ($participant) {
$q->where('new_values->participant_id', (string) $participant->id)
->orWhere('old_values->participant_id', (string) $participant->id)
->orWhereRaw("new_values->>'participant_id' = ?", [(string) $participant->id])
->orWhereRaw("old_values->>'participant_id' = ?", [(string) $participant->id]);
})
->orderByDesc('created_at')
->orderByDesc('id')
->first();
if (!$row) {
return null;
}
$new = json_decode($row->new_values ?? '{}', true) ?: [];
$old = json_decode($row->old_values ?? '{}', true) ?: [];
$programId = $new['training_program_id'] ?? $old['training_program_id'] ?? null;
if (!$programId) {
return null;
}
return [
'program_id' => (int) $programId,
'group_id' => isset($new['training_group_id']) ? (int) $new['training_group_id'] : null,
];
}
/**
* The programme to put the player back into.
*
* The original if it survives; otherwise strictly whatever --map names.
* There is no fuzzy fallback on purpose — see the class docblock.
*/
private function targetProgram(int $oldProgramId): ?TrainingProgram
{
$original = TrainingProgram::withoutBranchScope()->find($oldProgramId);
if ($original) {
return $original;
}
if (!isset($this->map[$oldProgramId])) {
return null;
}
return TrainingProgram::withoutBranchScope()->find($this->map[$oldProgramId]);
}
/**
* The cycle this player owes from.
*
* Their last subscription invoice is the evidence: whatever month it
* bought, they owe from the one after it. Never earlier — a month nobody
* ever billed them for is not a debt this command may invent — and never
* later than the current cycle, which is where a player with no invoice
* history at all starts.
*/
private function billFrom(Participant $participant): Carbon
{
if ($explicit = $this->option('bill-from')) {
return BillingCycle::startOf(Carbon::parse($explicit));
}
$lastIssued = Invoice::query()
->where('billable_type', $participant->getMorphClass())
->where('billable_id', $participant->id)
->where('status', '!=', InvoiceStatus::Cancelled->value)
->max('issue_date');
$current = BillingCycle::startOf(Carbon::today());
if (!$lastIssued) {
return $current;
}
$next = BillingCycle::startOf(Carbon::parse($lastIssued))->addMonthNoOverflow();
return $next->greaterThan($current) ? $current : $next;
}
/**
* @return bool false when a --map option is malformed, which must stop the
* run rather than silently restore a subset.
*/
private function parseMap(): bool
{
foreach ((array) $this->option('map') as $pair) {
if (!preg_match('/^(\d+):(\d+)$/', trim($pair), $m)) {
$this->error("--map expects old_id:new_id, got «{$pair}»");
return false;
}
$target = TrainingProgram::withoutBranchScope()->find((int) $m[2]);
if (!$target) {
$this->error("--map {$pair}: programme {$m[2]} does not exist");
return false;
}
$this->map[(int) $m[1]] = (int) $m[2];
}
return true;
}
private function deletedProgramName(int $programId): ?string
{
$row = DB::table('audit_logs')
->where('auditable_type', TrainingProgram::class)
->where('action', 'deleted')
->where('auditable_id', $programId)
->orderByDesc('id')
->first();
if (!$row) {
return null;
}
$old = json_decode($row->old_values ?? '{}', true) ?: [];
return isset($old['name_ar']) ? trim((string) $old['name_ar']) : null;
}
/**
* Who the restored enrolment is attributed to. Same fallback ladder as the
* renewal command: a missing user must never be the reason a paying player
* stays unbilled.
*/
private function actorFor(Participant $participant): ?User
{
return User::query()
->when($participant->academy_id, fn ($q) => $q->where('academy_id', $participant->academy_id))
->orderByDesc('is_super_admin')
->orderBy('id')
->first()
?? User::query()->orderBy('id')->first();
}
private function name(Participant $participant): string
{
$label = $participant->person?->name_ar ?? $participant->person?->name ?? "#{$participant->id}";
$number = $participant->participant_number ?: "#{$participant->id}";
return "{$number} {$label}";
}
private function report(bool $apply): int
{
$this->newLine();
$verb = $apply ? 'Restored' : 'Would restore';
$this->info("{$verb} {$this->restored} enrolment(s).");
foreach ([
'could not be mapped to a live programme (pass --map)' => $this->unmapped,
'have no enrolment in the audit trail' => $this->noEvidence,
'FAILED' => $this->failed,
] as $label => $count) {
if ($count > 0) {
$this->line(" {$count} {$label}");
}
}
if (!$apply && $this->restored > 0) {
$this->newLine();
$this->comment('Dry run — nothing was written. Re-run with --apply to commit.');
}
return $this->failed > 0 ? self::FAILURE : self::SUCCESS;
}
}
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