Add group-class withdrawal deadline and kind-aware offering form
CI / Tests (PHP 8.1) (pull_request) Successful in 44s
CI / Tests (PHP 8.2) (pull_request) Successful in 59s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 2m53s
CI / PHPStan (pull_request) Successful in 2m55s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m41s
CI / Build Plugin Zip (pull_request) Skipped

Group classes now carry an optional per-class withdrawal deadline. Up to
that day a student may withdraw themselves from the class; the withdrawal
frees the seat and voids any pending payment but never issues an account
credit. After the deadline self-withdrawal closes and a studio admin must
withdraw the student by hand (the admin path is never subject to the
deadline). A blank deadline keeps self-withdrawal open indefinitely.

Also make the Add/Edit Offering form show only the fields relevant to the
selected kind: group settings for group classes, weekly reservation for
private lessons. Progressive enhancement — without JS every field renders.

- New nullable us_offerings.withdrawal_deadline column; Offering model gains
  $withdrawalDeadline + isWithdrawalOpen().
- New student endpoint POST /enrollments/{id}/withdraw, gated by the deadline
  (403 withdrawal_closed), ownership-checked, idempotent.
- Front-end group-class page shows a Withdraw button while open.
- No USC_VERSION bump: 1.2.0 is unreleased and accumulates schema changes
  under its section, matching the scheduled-billing and credit features.

Tests: composer test (596), composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
2026-07-24 15:56:40 -03:00
co-authored by Claude Opus 4.8
parent f552c3952a
commit 2c4b481077
14 changed files with 347 additions and 21 deletions
+27 -4
View File
@@ -62,11 +62,34 @@ deadline (and capacity) so a **late enrolment** can be added after the class has
closed. Past the deadline the details page labels these as late enrolments. See
**Admin Interface** below.
## Withdrawal Flow
A student may withdraw themselves from a class they are enrolled in through the same
group-class page: an active enrolment shows a **Withdraw** button.
`POST /enrollments/{id}/withdraw` marks the enrolment `cancelled` (freeing its
capacity seat) and voids any still-pending payment. It **never issues an account
credit** — a timely withdrawal is a clean exit, not a refund (credits are reserved
for cancelled lessons; see `credits.md`).
Self-withdrawal is bounded by the class's **withdrawal deadline** (the instructor's
`withdrawal_deadline`; see `offerings.md`). Unlike the enrolment deadline it has no
implicit default — a class with no deadline set stays open to withdrawal for its
whole life. Past the deadline `POST /enrollments/{id}/withdraw` rejects the request
with `403 withdrawal_closed`, and the class card shows "Withdrawal has closed —
contact the studio to withdraw." in place of the Withdraw button. The endpoint also
returns `404 not_found` for an unknown enrolment and `403 forbidden` when the
enrolment is not the caller's own; a withdrawal of an already-cancelled enrolment is
idempotent.
The deadline only bounds student **self**-withdrawal. A studio admin can withdraw a
student at any time from the **student detail page** (`Auth\StudentActions::withdrawEnrollment`),
which is never subject to the deadline.
## REST API
| Method | Endpoint | Permission |
|----------|----------------------------------------------|----------------------------------|
| `GET` | `/wp-json/us-scheduler/v1/enrollments` | Any logged-in user |
| `POST` | `/wp-json/us-scheduler/v1/enrollments` | `book_lesson` |
| Method | Endpoint | Permission |
|----------|-------------------------------------------------|----------------------------------|
| `GET` | `/wp-json/us-scheduler/v1/enrollments` | Any logged-in user |
| `POST` | `/wp-json/us-scheduler/v1/enrollments` | `book_lesson` |
| `POST` | `/wp-json/us-scheduler/v1/enrollments/{id}/withdraw` | Owner (the enrolled student) |
`POST /enrollments` body: `offering_id`, `answers[]` (`question_id` → value),
`accepted_policy_version_ids[]`, and payment data (see `payments.md`). The
+15
View File
@@ -22,6 +22,7 @@ An offering is anything a student can register for: a private-lesson type (30 or
| `term_end` | DATE | Group / term offerings — last day; NULL otherwise |
| `class_time` | TIME | Group only — time of day each session starts; NULL otherwise |
| `enrollment_deadline` | DATE | Group only — last day students may enrol; NULL defaults to `term_start` (the first class day) |
| `withdrawal_deadline` | DATE | Group only — last day a student may withdraw themselves; NULL keeps self-withdrawal open indefinitely |
| `schedule_note` | VARCHAR(191) | Group only — human-readable schedule, e.g. "Tuesdays 4:00pm"|
| `cancellation_cutoff_hours` | SMALLINT UNSIGNED | Optional per-offering cancellation cutoff in hours; NULL inherits the studio default (see `cancellation-cutoff.md`) |
| `access_mode` | VARCHAR(20) | `public` (listed in the catalog) or `invite_only` (group classes hidden from the catalog — see `group-classes.md`) |
@@ -68,6 +69,20 @@ against that effective deadline (inclusive — the deadline day is still open).
enrolment endpoint enforces it (`403 enrollment_closed`) and the front-end
group-class list mirrors the same rule; see `group-classes.md`.
## Withdrawal deadline
A group class also carries an optional `withdrawal_deadline` — the last day a
student may withdraw *themselves* from the class. Unlike the enrolment deadline it
has **no implicit default**: `Offering::isWithdrawalOpen($today)` treats an unset
(NULL) deadline as always open, so a class only closes to self-withdrawal once the
instructor sets a date and it passes (comparison is inclusive — the deadline day is
still open). A withdrawal made while open frees the seat and voids any still-pending
payment but **never issues an account credit** (credits are reserved for cancelled
lessons; see `credits.md`). Once the deadline passes the student must contact the
studio, and an admin withdraws them by hand from the student detail page — the admin
path is never subject to the deadline. The student endpoint enforces it
(`403 withdrawal_closed`) and the front-end group-class list mirrors the rule; see
`group-classes.md`.
## Instructor assignment
Every offering has an owning `instructor_id`. A studio admin
(`manage_instructors`) sees an **Instructor** picker on the offering form and may