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:
Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
Please register or sign in to comment