Add cancellation cutoff limiting how close to a lesson a student can cancel
CI / Tests (PHP 8.1) (pull_request) Successful in 41s
CI / Tests (PHP 8.2) (pull_request) Successful in 53s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Coding Standards (pull_request) Successful in 2m54s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 41s
CI / Tests (PHP 8.2) (pull_request) Successful in 53s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Coding Standards (pull_request) Successful in 2m54s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / Build Plugin Zip (pull_request) Skipped
Students can no longer cancel their own lesson online once it starts within a configured window; instructors and studio admins can always cancel. - Studio default `us_cancellation_cutoff_hours` (stored/computed in hours, entered and displayed in days under Studio Settings → Cancellations). - Optional per-offering override `cancellation_cutoff_hours` (entered in hours); blank inherits the studio default, 0 allows anytime cancellation. - `Booking\CancellationPolicy` resolves the effective window and decides; `BookingEndpoint::cancel()` returns a 403 `cancellation_closed` when too late. The instructor status endpoint and studio-admin student actions bypass it. Closes #93 Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
# Feature: Cancellation Cutoff
|
||||
|
||||
## Overview
|
||||
Students may cancel their own lessons online — but not indefinitely close to the
|
||||
start time. A **cancellation cutoff** closes student-initiated cancellation once
|
||||
a lesson begins within a configured window. Instructors and studio admins are
|
||||
never subject to the cutoff: they can cancel a lesson at any time through the
|
||||
lesson-status and student-management flows.
|
||||
|
||||
The window is resolved per lesson:
|
||||
|
||||
1. If the lesson's **offering** sets its own cutoff, that value is used.
|
||||
2. Otherwise the **studio default** applies.
|
||||
|
||||
Both values are expressed and computed in **hours**. The studio default is
|
||||
entered and displayed to the admin in **days** for convenience; a per-offering
|
||||
override is entered directly in hours (a finer-grained "time").
|
||||
|
||||
## Data Model
|
||||
|
||||
### Option `us_cancellation_cutoff_hours`
|
||||
Studio-wide default cutoff, stored as an integer number of **hours**. Defaults to
|
||||
`24` (one day) when unset. `0` means students may cancel at any time.
|
||||
|
||||
### Column `{prefix}us_offerings.cancellation_cutoff_hours`
|
||||
Nullable `SMALLINT UNSIGNED`. `NULL` means "inherit the studio default"; any set
|
||||
value (including `0` — cancel any time) overrides it for that offering.
|
||||
|
||||
## Resolution & Enforcement
|
||||
`Booking\CancellationPolicy` owns the logic:
|
||||
|
||||
- `cutoffHours(?int $offeringCutoffHours): int` — the offering's override when it
|
||||
is a non-negative value, otherwise the studio default.
|
||||
- `studentMayCancel(string $slotStartDt, ?int $offeringCutoffHours, ?string $now): bool`
|
||||
— false once `now` is within the effective cutoff of the slot start. A zero
|
||||
cutoff always allows cancellation; unparseable datetimes fail open so a student
|
||||
is never trapped by bad data. Comparisons use WordPress-local time
|
||||
(`current_time('mysql')`), matching how upcoming lessons are computed.
|
||||
- `describeCutoff(int $hours): string` — humanises a cutoff for messages
|
||||
(whole days as days, otherwise hours).
|
||||
|
||||
`Booking\BookingEndpoint::cancel()` (the student endpoint,
|
||||
`POST /bookings/{id}/cancel`) consults the policy before cancelling and returns a
|
||||
`cancellation_closed` (HTTP 403) error explaining the window when it is too late.
|
||||
The instructor status endpoint (`PATCH /bookings/{id}/status`) and
|
||||
`Auth\StudentActions::cancelLesson()` (studio-admin student view) bypass the
|
||||
policy entirely.
|
||||
|
||||
## Admin Interface
|
||||
- **Studio Settings → Cancellations**: "Cancellation cutoff (days)" — the studio
|
||||
default, entered/displayed in days, stored in hours.
|
||||
- **Offerings** add/edit form: "Cancellation cutoff (hours)" — an optional
|
||||
per-offering override; blank inherits the studio default, `0` allows anytime
|
||||
cancellation.
|
||||
|
||||
## Implementation
|
||||
- Service: `Unsupervised\Schedular\Booking\CancellationPolicy`
|
||||
- Studio default: `Unsupervised\Schedular\Payment\StudioSettings::cancellationCutoffHours()`
|
||||
(option `us_cancellation_cutoff_hours`)
|
||||
- Per-offering value: `Unsupervised\Schedular\Offering\Offering::$cancellationCutoffHours`
|
||||
- Enforcement: `Unsupervised\Schedular\Booking\BookingEndpoint::cancel()`
|
||||
- Wiring: `RestRegistrar` constructs `new CancellationPolicy( new StudioSettings() )`
|
||||
|
||||
## Tests
|
||||
- `tests/Unit/Booking/CancellationPolicyTest.php`
|
||||
- `tests/Unit/Booking/BookingEndpointTest.php` (cutoff cases in `cancel()`)
|
||||
- `tests/Unit/Payment/StudioSettingsTest.php` (default getter)
|
||||
- `tests/Unit/Offering/OfferingTest.php`, `tests/Unit/Offering/OfferingRepositoryTest.php`
|
||||
(new column round-trips)
|
||||
@@ -21,6 +21,7 @@ An offering is anything a student can register for: a private-lesson type (30 or
|
||||
| `term_start` | DATE | Group / term offerings — first day; NULL otherwise |
|
||||
| `term_end` | DATE | Group / term offerings — last day; NULL otherwise |
|
||||
| `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`) |
|
||||
| `is_active` | TINYINT(1) | 0 = hidden from registration, 1 = bookable |
|
||||
| `created_at` | DATETIME | Insertion time |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user