diff --git a/CHANGELOG.md b/CHANGELOG.md index ce4fb0b..ea20464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ each change under the current top section as you work. - Offerings can now bill on a schedule: **weekly** (a pending payment 24 hours before each lesson) or **monthly** (one payment on the 1st for that month's lessons), alongside the existing one-time and full-term modes. Applies to both private lessons and group classes. A daily job generates due payments, and each student receives one consolidated itemised email per scan; batched payments share a reference so the admin Payments queue groups them with a lump-sum total for e-transfer reconciliation. Cancelling a lesson never voids a scheduled payment. - Cancelling a lesson that was **already paid for** now credits the student that money instead of leaving it as a manual refund. The credit is one lesson's share of what they paid — the whole amount for a single lesson, or a per-lesson slice of a monthly charge or a full-term series. The daily billing scan automatically applies any available credit against a student's upcoming weekly/monthly charges before emailing their notice, which shows the credit applied and the reduced total due; a charge fully covered by credit is settled and leaves the admin Payments queue. A student's outstanding credit balance is shown on their **student detail** page in the studio admin. Still-pending (unpaid) payments continue to be voided on cancellation as before. - Group classes now carry an **enrolment deadline** the instructor sets on the offering. It defaults to the first day of the class, and once it passes students can no longer enrol — the enrolment page shows the class as closed and the API rejects late enrolments. While enrolment is open, each class card shows an "Enrol by" date. +- Group classes now also carry a **withdrawal deadline** the instructor sets per class. Up to that day a student can withdraw themselves from the class (the group-class page shows a **Withdraw** button) — this frees their seat and voids any pending payment but does **not** credit their account. After the deadline self-withdrawal closes and the student must ask the studio, who can still withdraw them by hand from the student detail page. Leaving the deadline blank keeps self-withdrawal open indefinitely. +- The **Add/Edit Offering** form now shows only the fields relevant to the selected kind: the group-class settings (capacity, dates, times, enrolment/withdrawal deadlines, sessions, schedule note, invite-only) appear only for a group class, and the weekly-reservation option only for a private lesson. - Instructors can add students to any group class by hand from its details page (**Add students directly**), which now appears for public classes too, not just invite-only ones. This bypasses the enrolment deadline and capacity, so a student can be enrolled as a **late enrolment** after the class has closed to self-enrolment. - Studio admins and instructors can open a **lesson detail view** from the Scheduler and My Lessons lists, showing the offering booked, the policy versions the student accepted (with acceptance time and IP), and their intake answers. On My Lessons an instructor may only open their own lessons; the studio Scheduler may open any. diff --git a/assets/js/group-classes.js b/assets/js/group-classes.js index fb4cee9..5b9f82d 100644 --- a/assets/js/group-classes.js +++ b/assets/js/group-classes.js @@ -123,7 +123,14 @@ return !deadline || todayYmd() <= deadline; } - function renderClasses(offerings, enrolledOfferingIds) { + // Self-withdrawal closes at the end of the withdrawal-deadline day. Unlike + // enrolment there is no implicit default: an unset deadline keeps withdrawal + // open. Mirrors the server-side Offering::isWithdrawalOpen() gate. + function isWithdrawalOpen(o) { + return !o.withdrawal_deadline || todayYmd() <= o.withdrawal_deadline; + } + + function renderClasses(offerings, enrolledMap) { let groups = offerings.filter((o) => o.kind === 'group_class'); if (singleOfferingId) { groups = groups.filter((o) => Number(o.id) === singleOfferingId); @@ -143,11 +150,14 @@ ${o.schedule_note ? `
${escHtml(o.schedule_note)}
` : ''} ${o.description ? `${escHtml(o.description)}
` : ''}${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}
- ${!enrolledOfferingIds.has(Number(o.id)) && isEnrollmentOpen(o) && enrolmentDeadline(o) + ${!enrolledMap.has(Number(o.id)) && isEnrollmentOpen(o) && enrolmentDeadline(o) ? `Enrol by ${escHtml(formatDate(enrolmentDeadline(o)))}
` : ''} - ${enrolledOfferingIds.has(Number(o.id)) - ? 'You are enrolled in this class.
' + ${enrolledMap.has(Number(o.id)) + ? `You are enrolled in this class.
+ ${isWithdrawalOpen(o) + ? `` + : 'Withdrawal has closed — contact the studio to withdraw.
'}` : (isEnrollmentOpen(o) ? `` : 'Enrolment has closed.
')} @@ -158,6 +168,20 @@ const offering = groups.find((o) => String(o.id) === btn.dataset.offeringId); btn.addEventListener('click', () => openEnrolment(offering)); }); + + list.querySelectorAll('.us-withdraw-btn').forEach((btn) => { + btn.addEventListener('click', () => withdraw(btn.dataset.enrollmentId)); + }); + } + + function withdraw(enrollmentId) { + clearError(); + if (!window.confirm('Withdraw from this class? Your seat is released and any pending payment is cancelled.')) { + return; + } + apiFetch(`enrollments/${enrollmentId}/withdraw`, { method: 'POST' }) + .then(loadClasses) + .catch((err) => showError(err.message)); } function openEnrolment(offering) { @@ -240,9 +264,9 @@ ]) .then(([offerings, enrollments]) => renderClasses( offerings, - new Set(enrollments + new Map(enrollments .filter((e) => e.status === 'active') - .map((e) => Number(e.offering_id))) + .map((e) => [Number(e.offering_id), e.id])) )) .catch((err) => showError(err.message)); } diff --git a/docs/features/group-classes.md b/docs/features/group-classes.md index 0c6f267..fb31d29 100644 --- a/docs/features/group-classes.md +++ b/docs/features/group-classes.md @@ -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 diff --git a/docs/features/offerings.md b/docs/features/offerings.md index 96af2e1..c3be69a 100644 --- a/docs/features/offerings.md +++ b/docs/features/offerings.md @@ -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 diff --git a/src/GroupClass/EnrollmentEndpoint.php b/src/GroupClass/EnrollmentEndpoint.php index fbfc413..28f8d8e 100644 --- a/src/GroupClass/EnrollmentEndpoint.php +++ b/src/GroupClass/EnrollmentEndpoint.php @@ -59,6 +59,18 @@ class EnrollmentEndpoint { ], ] ); + + register_rest_route( + $route_namespace, + '/enrollments/(?P