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