Merge pull request 'Cancellation cutoff: limit how close to a lesson a student can cancel' (#94) from feature/cancellation-cutoff into main
CI / Tests (PHP 8.2) (push) Successful in 44s
CI / Tests (PHP 8.1) (push) Successful in 49s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m47s
CI / Coding Standards (push) Successful in 2m59s
CI / Tests (PHP 8.3) (push) Successful in 2m39s
CI / Build Plugin Zip (push) Successful in 2m44s
CI / Tests (PHP 8.2) (push) Successful in 44s
CI / Tests (PHP 8.1) (push) Successful in 49s
CI / No Debug Code (push) Successful in 2s
CI / PHPStan (push) Successful in 2m47s
CI / Coding Standards (push) Successful in 2m59s
CI / Tests (PHP 8.3) (push) Successful in 2m39s
CI / Build Plugin Zip (push) Successful in 2m44s
Reviewed-on: #94
This commit was merged in pull request #94.
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 |
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ class BookingEndpoint {
|
||||
private OfferingRepository $offerings,
|
||||
private RegistrationGate $gate,
|
||||
private PaymentService $payments,
|
||||
private CancellationPolicy $cancellationPolicy,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -320,6 +321,23 @@ class BookingEndpoint {
|
||||
}
|
||||
|
||||
if ( Lesson::STATUS_CANCELLED !== $lesson->status ) {
|
||||
$slot = $this->availability->findById( $lesson->slotId );
|
||||
if ( null !== $slot ) {
|
||||
$offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null;
|
||||
$overrideHours = $offering?->cancellationCutoffHours;
|
||||
if ( ! $this->cancellationPolicy->studentMayCancel( $slot->startDt, $overrideHours ) ) {
|
||||
return new \WP_Error(
|
||||
'cancellation_closed',
|
||||
sprintf(
|
||||
/* translators: %s: humanised cutoff window, e.g. "2 days" or "12 hours". */
|
||||
__( 'This lesson can no longer be cancelled online — cancellations close %s before the lesson starts. Please contact the studio.', 'unsupervised-schedular' ),
|
||||
$this->cancellationPolicy->describeCutoff( $this->cancellationPolicy->cutoffHours( $overrideHours ) )
|
||||
),
|
||||
[ 'status' => 403 ]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->bookings->updateStatus( $id, Lesson::STATUS_CANCELLED );
|
||||
$this->availability->release( $lesson->slotId );
|
||||
$this->payments->voidPending( $lesson->paymentId );
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Booking;
|
||||
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
|
||||
/**
|
||||
* Decides whether a student may still cancel a lesson. Cancellation closes once
|
||||
* the lesson starts within the effective cutoff window; instructors and studio
|
||||
* admins bypass this entirely and cancel through other paths.
|
||||
*
|
||||
* The window is resolved per lesson: the offering's own cutoff when it sets one,
|
||||
* otherwise the studio default. Both are expressed in hours.
|
||||
*/
|
||||
class CancellationPolicy {
|
||||
|
||||
public function __construct( private StudioSettings $settings ) {}
|
||||
|
||||
/**
|
||||
* The effective cutoff in hours for a lesson: the offering's override when
|
||||
* set (a non-negative value), otherwise the studio default.
|
||||
*/
|
||||
public function cutoffHours( ?int $offeringCutoffHours ): int {
|
||||
if ( null !== $offeringCutoffHours && $offeringCutoffHours >= 0 ) {
|
||||
return $offeringCutoffHours;
|
||||
}
|
||||
|
||||
return $this->settings->cancellationCutoffHours();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a student may still cancel a lesson starting at $slotStartDt
|
||||
* (WordPress-local `Y-m-d H:i:s`), given the offering's optional cutoff
|
||||
* override. A zero cutoff always allows cancellation; unparseable input
|
||||
* fails open so a student is never trapped by bad data. Pass $now to make
|
||||
* the comparison deterministic in tests.
|
||||
*/
|
||||
public function studentMayCancel( string $slotStartDt, ?int $offeringCutoffHours, ?string $now = null ): bool {
|
||||
$hours = $this->cutoffHours( $offeringCutoffHours );
|
||||
if ( $hours <= 0 ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$start = strtotime( $slotStartDt );
|
||||
$current = strtotime( $now ?? current_time( 'mysql' ) );
|
||||
if ( false === $start || false === $current ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ( $start - $current ) >= $hours * 3600;
|
||||
}
|
||||
|
||||
/**
|
||||
* A human-readable description of a cutoff for student-facing messages:
|
||||
* whole days as days, anything else as hours.
|
||||
*/
|
||||
public function describeCutoff( int $hours ): string {
|
||||
if ( $hours > 0 && 0 === $hours % 24 ) {
|
||||
$days = $hours / 24;
|
||||
|
||||
/* translators: %d: number of days. */
|
||||
return sprintf( _n( '%d day', '%d days', $days, 'unsupervised-schedular' ), $days );
|
||||
}
|
||||
|
||||
/* translators: %d: number of hours. */
|
||||
return sprintf( _n( '%d hour', '%d hours', $hours, 'unsupervised-schedular' ), $hours );
|
||||
}
|
||||
}
|
||||
+18
-15
@@ -42,6 +42,7 @@ class Offering {
|
||||
public readonly ?string $termEnd = null,
|
||||
public readonly ?string $scheduleNote = null,
|
||||
public readonly ?string $etransferEmail = null,
|
||||
public readonly ?int $cancellationCutoffHours = null,
|
||||
public readonly bool $isActive = true,
|
||||
public readonly ?int $id = null,
|
||||
) {}
|
||||
@@ -83,6 +84,7 @@ class Offering {
|
||||
termEnd: Val::stringOrNull( $row->term_end ),
|
||||
scheduleNote: Val::stringOrNull( $row->schedule_note ),
|
||||
etransferEmail: Val::stringOrNull( $row->etransfer_email ),
|
||||
cancellationCutoffHours: Val::intOrNull( $row->cancellation_cutoff_hours ),
|
||||
isActive: Val::bool( $row->is_active ),
|
||||
id: Val::int( $row->id ),
|
||||
);
|
||||
@@ -99,21 +101,22 @@ class Offering {
|
||||
*/
|
||||
public function toArray( bool $includeEtransferEmail = true ): array {
|
||||
$out = [
|
||||
'id' => $this->id,
|
||||
'instructor_id' => $this->instructorId,
|
||||
'kind' => $this->kind,
|
||||
'title' => $this->title,
|
||||
'description' => $this->description,
|
||||
'duration_minutes' => $this->durationMinutes,
|
||||
'price' => $this->price,
|
||||
'currency' => $this->currency,
|
||||
'billing_mode' => $this->billingMode,
|
||||
'allow_weekly' => $this->allowWeekly,
|
||||
'capacity' => $this->capacity,
|
||||
'term_start' => $this->termStart,
|
||||
'term_end' => $this->termEnd,
|
||||
'schedule_note' => $this->scheduleNote,
|
||||
'is_active' => $this->isActive,
|
||||
'id' => $this->id,
|
||||
'instructor_id' => $this->instructorId,
|
||||
'kind' => $this->kind,
|
||||
'title' => $this->title,
|
||||
'description' => $this->description,
|
||||
'duration_minutes' => $this->durationMinutes,
|
||||
'price' => $this->price,
|
||||
'currency' => $this->currency,
|
||||
'billing_mode' => $this->billingMode,
|
||||
'allow_weekly' => $this->allowWeekly,
|
||||
'capacity' => $this->capacity,
|
||||
'term_start' => $this->termStart,
|
||||
'term_end' => $this->termEnd,
|
||||
'schedule_note' => $this->scheduleNote,
|
||||
'cancellation_cutoff_hours' => $this->cancellationCutoffHours,
|
||||
'is_active' => $this->isActive,
|
||||
];
|
||||
|
||||
if ( $includeEtransferEmail ) {
|
||||
|
||||
@@ -104,6 +104,11 @@ class OfferingController {
|
||||
$duration = absint( Val::int( $_POST['duration_minutes'] ?? 0 ) );
|
||||
$capacity = absint( Val::int( $_POST['capacity'] ?? 0 ) );
|
||||
|
||||
// A blank cutoff means "use the studio default" (null); any entered value
|
||||
// (including 0 — cancel any time) is a per-offering override.
|
||||
$cutoffRaw = trim( sanitize_text_field( Val::string( wp_unslash( $_POST['cancellation_cutoff_hours'] ?? '' ) ) ) );
|
||||
$cutoffHours = '' === $cutoffRaw ? null : absint( Val::int( $cutoffRaw ) );
|
||||
|
||||
// Term dates: a class either meets once (term ends the day it starts)
|
||||
// or repeats weekly for a set number of sessions.
|
||||
$termStart = Offering::normalizeDate( sanitize_text_field( Val::string( wp_unslash( $_POST['term_start'] ?? '' ) ) ) );
|
||||
@@ -129,6 +134,7 @@ class OfferingController {
|
||||
termEnd: $termEnd,
|
||||
scheduleNote: $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) ),
|
||||
etransferEmail: $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ),
|
||||
cancellationCutoffHours: $cutoffHours,
|
||||
isActive: isset( $_POST['is_active'] ),
|
||||
id: $existing?->id,
|
||||
);
|
||||
|
||||
@@ -103,6 +103,7 @@ class OfferingEndpoint {
|
||||
termEnd: $this->nullableText( $request->get_param( 'term_end' ) ),
|
||||
scheduleNote: $this->nullableText( $request->get_param( 'schedule_note' ) ),
|
||||
etransferEmail: $this->nullableEmail( $request->get_param( 'etransfer_email' ) ),
|
||||
cancellationCutoffHours: $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ),
|
||||
isActive: null === $request->get_param( 'is_active' ) ? true : (bool) $request->get_param( 'is_active' ),
|
||||
);
|
||||
|
||||
@@ -148,6 +149,7 @@ class OfferingEndpoint {
|
||||
termEnd: $request->has_param( 'term_end' ) ? $this->nullableText( $request->get_param( 'term_end' ) ) : $existing->termEnd,
|
||||
scheduleNote: $request->has_param( 'schedule_note' ) ? $this->nullableText( $request->get_param( 'schedule_note' ) ) : $existing->scheduleNote,
|
||||
etransferEmail: $request->has_param( 'etransfer_email' ) ? $this->nullableEmail( $request->get_param( 'etransfer_email' ) ) : $existing->etransferEmail,
|
||||
cancellationCutoffHours: $request->has_param( 'cancellation_cutoff_hours' ) ? $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ) : $existing->cancellationCutoffHours,
|
||||
isActive: $request->has_param( 'is_active' ) ? (bool) $request->get_param( 'is_active' ) : $existing->isActive,
|
||||
id: $id,
|
||||
);
|
||||
|
||||
@@ -14,11 +14,12 @@ class OfferingRepository {
|
||||
/**
|
||||
* Column formats aligned to {@see columns()} (instructor_id, kind, title,
|
||||
* description, duration_minutes, price, currency, billing_mode, allow_weekly,
|
||||
* capacity, term_start, term_end, schedule_note, etransfer_email, is_active).
|
||||
* capacity, term_start, term_end, schedule_note, etransfer_email,
|
||||
* cancellation_cutoff_hours, is_active).
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%d' ];
|
||||
private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%d', '%d' ];
|
||||
|
||||
public function insert( Offering $offering ): int {
|
||||
$this->db->insert(
|
||||
@@ -47,21 +48,22 @@ class OfferingRepository {
|
||||
*/
|
||||
private function columns( Offering $offering ): array {
|
||||
return [
|
||||
'instructor_id' => $offering->instructorId,
|
||||
'kind' => $offering->kind,
|
||||
'title' => $offering->title,
|
||||
'description' => $offering->description,
|
||||
'duration_minutes' => $offering->durationMinutes,
|
||||
'price' => $offering->price,
|
||||
'currency' => $offering->currency,
|
||||
'billing_mode' => $offering->billingMode,
|
||||
'allow_weekly' => $offering->allowWeekly ? 1 : 0,
|
||||
'capacity' => $offering->capacity,
|
||||
'term_start' => $offering->termStart,
|
||||
'term_end' => $offering->termEnd,
|
||||
'schedule_note' => $offering->scheduleNote,
|
||||
'etransfer_email' => $offering->etransferEmail,
|
||||
'is_active' => $offering->isActive ? 1 : 0,
|
||||
'instructor_id' => $offering->instructorId,
|
||||
'kind' => $offering->kind,
|
||||
'title' => $offering->title,
|
||||
'description' => $offering->description,
|
||||
'duration_minutes' => $offering->durationMinutes,
|
||||
'price' => $offering->price,
|
||||
'currency' => $offering->currency,
|
||||
'billing_mode' => $offering->billingMode,
|
||||
'allow_weekly' => $offering->allowWeekly ? 1 : 0,
|
||||
'capacity' => $offering->capacity,
|
||||
'term_start' => $offering->termStart,
|
||||
'term_end' => $offering->termEnd,
|
||||
'schedule_note' => $offering->scheduleNote,
|
||||
'etransfer_email' => $offering->etransferEmail,
|
||||
'cancellation_cutoff_hours' => $offering->cancellationCutoffHours,
|
||||
'is_active' => $offering->isActive ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,14 @@ class StudioSettings {
|
||||
public const OPT_ETRANSFER_EMAIL = 'us_etransfer_email';
|
||||
public const OPT_HST_RATE = 'us_hst_rate';
|
||||
|
||||
/**
|
||||
* Studio-default cancellation cutoff, stored in hours. A student may not
|
||||
* cancel a lesson once it starts within this many hours. Displayed to the
|
||||
* admin in days; an offering may override it with its own hour value.
|
||||
*/
|
||||
public const OPT_CANCELLATION_CUTOFF_HOURS = 'us_cancellation_cutoff_hours';
|
||||
public const DEFAULT_CANCELLATION_CUTOFF_HOURS = 24;
|
||||
|
||||
public const OPT_REGISTRATION_MODE = 'us_registration_mode';
|
||||
public const MODE_INVITE = 'invite';
|
||||
public const MODE_SELF_APPROVAL = 'self_approval';
|
||||
@@ -69,6 +77,15 @@ class StudioSettings {
|
||||
return max( 0.0, Val::float( get_option( self::OPT_HST_RATE, 0 ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* The studio-default cancellation cutoff in hours: a student cannot cancel a
|
||||
* lesson once it starts within this window. 0 means students may cancel any
|
||||
* time. Offerings without their own override inherit this value.
|
||||
*/
|
||||
public function cancellationCutoffHours(): int {
|
||||
return max( 0, Val::int( get_option( self::OPT_CANCELLATION_CUTOFF_HOURS, self::DEFAULT_CANCELLATION_CUTOFF_HOURS ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Stripe is configured. When false the platform falls back to
|
||||
* e-transfer billing and card processing is unavailable.
|
||||
@@ -117,6 +134,8 @@ class StudioSettings {
|
||||
$hstRate = $this->hstRate();
|
||||
$stripeConfigured = $this->isStripeConfigured();
|
||||
$openRegistration = $this->openRegistrationEnabled();
|
||||
// Stored in hours, surfaced to the admin in whole days.
|
||||
$cancellationCutoffDays = (int) round( $this->cancellationCutoffHours() / 24 );
|
||||
|
||||
include USC_PLUGIN_DIR . 'templates/admin/settings.php';
|
||||
}
|
||||
@@ -143,6 +162,11 @@ class StudioSettings {
|
||||
$hstRate = isset( $_POST['hst_rate'] ) ? Val::float( $_POST['hst_rate'] ) : 0.0;
|
||||
update_option( self::OPT_HST_RATE, max( 0.0, $hstRate ) );
|
||||
|
||||
// The cutoff is entered in whole days but stored in hours.
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Val::int() coerces to int; slashes cannot survive numeric coercion.
|
||||
$cutoffDays = isset( $_POST['cancellation_cutoff_days'] ) ? max( 0, Val::int( $_POST['cancellation_cutoff_days'] ) ) : 0;
|
||||
update_option( self::OPT_CANCELLATION_CUTOFF_HOURS, $cutoffDays * 24 );
|
||||
|
||||
$this->applyRegistrationMode( isset( $_POST['open_registration'] ) );
|
||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||
}
|
||||
|
||||
@@ -7,12 +7,14 @@ use Unsupervised\Schedular\Availability\AvailabilityEndpoint;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Booking\BookingEndpoint;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\CancellationPolicy;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\Offering\OfferingEndpoint;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentEndpoint;
|
||||
use Unsupervised\Schedular\Payment\PaymentService;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
use Unsupervised\Schedular\Policy\PolicyEndpoint;
|
||||
use Unsupervised\Schedular\Policy\PolicyRepository;
|
||||
use Unsupervised\Schedular\Policy\PolicyService;
|
||||
@@ -35,7 +37,7 @@ class RestRegistrar {
|
||||
|
||||
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, PaymentService $paymentService ) {
|
||||
$this->availabilityEndpoint = new AvailabilityEndpoint( $availability, $offerings );
|
||||
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService );
|
||||
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, new CancellationPolicy( new StudioSettings() ) );
|
||||
$this->offeringEndpoint = new OfferingEndpoint( $offerings );
|
||||
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
|
||||
$this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService );
|
||||
|
||||
@@ -65,6 +65,7 @@ class Schema {
|
||||
term_end DATE DEFAULT NULL,
|
||||
schedule_note VARCHAR(191) DEFAULT NULL,
|
||||
etransfer_email VARCHAR(191) DEFAULT NULL,
|
||||
cancellation_cutoff_hours SMALLINT UNSIGNED DEFAULT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
|
||||
@@ -103,6 +103,13 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
|
||||
<th><label for="etransfer_email"><?php esc_html_e('E-transfer email', 'unsupervised-schedular'); ?></label></th>
|
||||
<td><input type="email" name="etransfer_email" id="etransfer_email" class="regular-text" placeholder="<?php esc_attr_e('Overrides the studio default', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr($editing->etransferEmail ?? ''); ?>"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><label for="cancellation_cutoff_hours"><?php esc_html_e('Cancellation cutoff (hours)', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<input type="number" name="cancellation_cutoff_hours" id="cancellation_cutoff_hours" min="0" step="1" placeholder="<?php esc_attr_e('Uses the studio default', 'unsupervised-schedular'); ?>" value="<?php echo esc_attr(null === ($editing->cancellationCutoffHours ?? null) ? '' : (string) $editing->cancellationCutoffHours); ?>">
|
||||
<p class="description"><?php esc_html_e('How many hours before a lesson a student may still cancel it. Leave blank to use the studio default; 0 lets students cancel any time.', 'unsupervised-schedular'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><?php esc_html_e('Active', 'unsupervised-schedular'); ?></th>
|
||||
<td><label><input type="checkbox" name="is_active" value="1" <?php echo null === $editing || $editing->isActive ? 'checked' : ''; ?>> <?php esc_html_e('Open for registration', 'unsupervised-schedular'); ?></label></td>
|
||||
|
||||
@@ -16,6 +16,7 @@ if (! defined('ABSPATH')) {
|
||||
* @var float $hstRate
|
||||
* @var bool $stripeConfigured
|
||||
* @var bool $openRegistration
|
||||
* @var int $cancellationCutoffDays
|
||||
*/
|
||||
?>
|
||||
<div class="wrap">
|
||||
@@ -90,6 +91,17 @@ if (! defined('ABSPATH')) {
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2><?php esc_html_e('Cancellations', 'unsupervised-schedular'); ?></h2>
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
<th><label for="cancellation_cutoff_days"><?php esc_html_e('Cancellation cutoff (days)', 'unsupervised-schedular'); ?></label></th>
|
||||
<td>
|
||||
<input type="number" name="cancellation_cutoff_days" id="cancellation_cutoff_days" class="small-text" min="0" step="1" value="<?php echo esc_attr((string) $cancellationCutoffDays); ?>">
|
||||
<p class="description"><?php esc_html_e('How far ahead of a lesson a student may still cancel it. Within this window only an instructor can cancel. 0 lets students cancel any time. An offering can override this with its own value.', 'unsupervised-schedular'); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2><?php esc_html_e('Registration', 'unsupervised-schedular'); ?></h2>
|
||||
<table class="form-table">
|
||||
<tr>
|
||||
|
||||
@@ -9,11 +9,13 @@ use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Availability\AvailabilitySlot;
|
||||
use Unsupervised\Schedular\Booking\BookingEndpoint;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\CancellationPolicy;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
use Unsupervised\Schedular\Payment\PaymentService;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
use Unsupervised\Schedular\Registration\RegistrationGate;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
@@ -24,6 +26,7 @@ class BookingEndpointTest extends TestCase
|
||||
private OfferingRepository $offerings;
|
||||
private RegistrationGate $gate;
|
||||
private PaymentService $payments;
|
||||
private StudioSettings $settings;
|
||||
private BookingEndpoint $endpoint;
|
||||
|
||||
protected function setUp(): void
|
||||
@@ -34,12 +37,17 @@ class BookingEndpointTest extends TestCase
|
||||
Functions\when('wp_unslash')->returnArg();
|
||||
Functions\when('sanitize_text_field')->returnArg();
|
||||
Functions\when('get_current_user_id')->justReturn(5);
|
||||
// Fixed "now" well before the fixture slot start (2026-07-01 10:00), so
|
||||
// the cancellation cutoff never trips unless a test moves it.
|
||||
Functions\when('current_time')->justReturn('2026-06-01 10:00:00');
|
||||
|
||||
$this->availability = Mockery::mock(AvailabilityRepository::class);
|
||||
$this->bookings = Mockery::mock(BookingRepository::class);
|
||||
$this->offerings = Mockery::mock(OfferingRepository::class);
|
||||
$this->gate = Mockery::mock(RegistrationGate::class);
|
||||
$this->payments = Mockery::mock(PaymentService::class);
|
||||
$this->settings = Mockery::mock(StudioSettings::class);
|
||||
$this->settings->shouldReceive('cancellationCutoffHours')->andReturn(24)->byDefault();
|
||||
|
||||
$this->endpoint = new BookingEndpoint(
|
||||
$this->availability,
|
||||
@@ -47,6 +55,7 @@ class BookingEndpointTest extends TestCase
|
||||
$this->offerings,
|
||||
$this->gate,
|
||||
$this->payments,
|
||||
new CancellationPolicy($this->settings),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -378,6 +387,57 @@ class BookingEndpointTest extends TestCase
|
||||
{
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_PENDING, paymentId: 12, id: 77);
|
||||
$this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson);
|
||||
$this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null));
|
||||
$this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CANCELLED)->once()->andReturn(true);
|
||||
$this->availability->shouldReceive('release')->with(10)->once()->andReturn(true);
|
||||
$this->payments->shouldReceive('voidPending')->with(12)->once();
|
||||
|
||||
$result = $this->endpoint->cancel(new \WP_REST_Request(['id' => 77]));
|
||||
|
||||
self::assertInstanceOf(\WP_REST_Response::class, $result);
|
||||
self::assertSame(Lesson::STATUS_CANCELLED, $result->get_data()['status']);
|
||||
}
|
||||
|
||||
public function testCancelWithinStudioCutoffIsRejected(): void
|
||||
{
|
||||
// Now (2026-06-01 10:00) is only 24h before a slot at 2026-06-02 10:00,
|
||||
// exactly the studio cutoff — inside the window, so cancellation closes.
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_PENDING, paymentId: 12, id: 77);
|
||||
$this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson);
|
||||
$this->availability->shouldReceive('findById')->with(10)->andReturn(new AvailabilitySlot(
|
||||
instructorId: 3,
|
||||
startDt: '2026-06-02 09:59:00',
|
||||
endDt: '2026-06-02 10:59:00',
|
||||
id: 10,
|
||||
));
|
||||
$this->bookings->shouldNotReceive('updateStatus');
|
||||
$this->availability->shouldNotReceive('release');
|
||||
$this->payments->shouldNotReceive('voidPending');
|
||||
|
||||
$result = $this->endpoint->cancel(new \WP_REST_Request(['id' => 77]));
|
||||
|
||||
self::assertInstanceOf(\WP_Error::class, $result);
|
||||
self::assertSame('cancellation_closed', $result->get_error_code());
|
||||
}
|
||||
|
||||
public function testCancelUsesOfferingCutoffOverrideWhenSet(): void
|
||||
{
|
||||
// Offering overrides the 24h studio default with 0 hours — cancel any time,
|
||||
// even for a lesson starting in a minute.
|
||||
$lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, offeringId: 4, status: Lesson::STATUS_PENDING, paymentId: 12, id: 77);
|
||||
$this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson);
|
||||
$this->availability->shouldReceive('findById')->with(10)->andReturn(new AvailabilitySlot(
|
||||
instructorId: 3,
|
||||
startDt: '2026-06-01 10:01:00',
|
||||
endDt: '2026-06-01 11:01:00',
|
||||
id: 10,
|
||||
));
|
||||
$this->offerings->shouldReceive('findById')->with(4)->andReturn(new Offering(
|
||||
instructorId: 3,
|
||||
kind: Offering::KIND_PRIVATE_LESSON,
|
||||
title: 'Trial',
|
||||
cancellationCutoffHours: 0,
|
||||
));
|
||||
$this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CANCELLED)->once()->andReturn(true);
|
||||
$this->availability->shouldReceive('release')->with(10)->once()->andReturn(true);
|
||||
$this->payments->shouldReceive('voidPending')->with(12)->once();
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Tests\Unit\Booking;
|
||||
|
||||
use Mockery;
|
||||
use Unsupervised\Schedular\Booking\CancellationPolicy;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
use Unsupervised\Schedular\Tests\Unit\TestCase;
|
||||
|
||||
class CancellationPolicyTest extends TestCase
|
||||
{
|
||||
private function policy(int $studioDefaultHours): CancellationPolicy
|
||||
{
|
||||
$settings = Mockery::mock(StudioSettings::class);
|
||||
$settings->shouldReceive('cancellationCutoffHours')->andReturn($studioDefaultHours);
|
||||
|
||||
return new CancellationPolicy($settings);
|
||||
}
|
||||
|
||||
public function testCutoffHoursFallsBackToStudioDefaultWhenOverrideNull(): void
|
||||
{
|
||||
self::assertSame(24, $this->policy(24)->cutoffHours(null));
|
||||
}
|
||||
|
||||
public function testCutoffHoursUsesOverrideWhenSet(): void
|
||||
{
|
||||
// Even a zero override wins over the studio default — it is a deliberate
|
||||
// "cancel any time" choice, not an absent value.
|
||||
self::assertSame(0, $this->policy(24)->cutoffHours(0));
|
||||
self::assertSame(72, $this->policy(24)->cutoffHours(72));
|
||||
}
|
||||
|
||||
public function testCutoffHoursIgnoresNegativeOverride(): void
|
||||
{
|
||||
self::assertSame(24, $this->policy(24)->cutoffHours(-5));
|
||||
}
|
||||
|
||||
public function testStudentMayCancelWhenOutsideWindow(): void
|
||||
{
|
||||
// 48h before a 24h cutoff — comfortably outside.
|
||||
self::assertTrue(
|
||||
$this->policy(24)->studentMayCancel('2026-07-03 10:00:00', null, '2026-07-01 10:00:00')
|
||||
);
|
||||
}
|
||||
|
||||
public function testStudentMayNotCancelWhenInsideWindow(): void
|
||||
{
|
||||
// 12h before a 24h cutoff — inside the window.
|
||||
self::assertFalse(
|
||||
$this->policy(24)->studentMayCancel('2026-07-01 22:00:00', null, '2026-07-01 10:00:00')
|
||||
);
|
||||
}
|
||||
|
||||
public function testExactlyAtCutoffBoundaryIsAllowed(): void
|
||||
{
|
||||
// Exactly 24h ahead of a 24h cutoff is still cancellable.
|
||||
self::assertTrue(
|
||||
$this->policy(24)->studentMayCancel('2026-07-02 10:00:00', null, '2026-07-01 10:00:00')
|
||||
);
|
||||
}
|
||||
|
||||
public function testZeroCutoffAlwaysAllowsCancellation(): void
|
||||
{
|
||||
self::assertTrue(
|
||||
$this->policy(0)->studentMayCancel('2026-07-01 10:00:01', null, '2026-07-01 10:00:00')
|
||||
);
|
||||
}
|
||||
|
||||
public function testOfferingOverrideNarrowsWindow(): void
|
||||
{
|
||||
// Studio default is 0 (any time), but this offering demands 48h notice.
|
||||
self::assertFalse(
|
||||
$this->policy(0)->studentMayCancel('2026-07-02 10:00:00', 48, '2026-07-01 10:00:00')
|
||||
);
|
||||
}
|
||||
|
||||
public function testPastLessonCannotBeCancelled(): void
|
||||
{
|
||||
self::assertFalse(
|
||||
$this->policy(24)->studentMayCancel('2026-07-01 09:00:00', null, '2026-07-01 10:00:00')
|
||||
);
|
||||
}
|
||||
|
||||
public function testDescribeCutoffUsesDaysForWholeDays(): void
|
||||
{
|
||||
$policy = $this->policy(24);
|
||||
|
||||
self::assertSame('1 day', $policy->describeCutoff(24));
|
||||
self::assertSame('2 days', $policy->describeCutoff(48));
|
||||
}
|
||||
|
||||
public function testDescribeCutoffUsesHoursOtherwise(): void
|
||||
{
|
||||
$policy = $this->policy(24);
|
||||
|
||||
self::assertSame('12 hours', $policy->describeCutoff(12));
|
||||
self::assertSame('1 hour', $policy->describeCutoff(1));
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,7 @@ class OfferingRepositoryTest extends TestCase
|
||||
'term_end' => null,
|
||||
'schedule_note' => null,
|
||||
'etransfer_email' => null,
|
||||
'cancellation_cutoff_hours' => null,
|
||||
'is_active' => '1',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ class OfferingTest extends TestCase
|
||||
'term_end' => '2027-06-30',
|
||||
'schedule_note' => 'Tuesdays 4:00pm',
|
||||
'etransfer_email' => null,
|
||||
'cancellation_cutoff_hours' => '48',
|
||||
'is_active' => '1',
|
||||
];
|
||||
|
||||
@@ -95,9 +96,37 @@ class OfferingTest extends TestCase
|
||||
self::assertSame(120.00, $offering->price);
|
||||
self::assertSame(20, $offering->capacity);
|
||||
self::assertSame(Offering::BILLING_FULL_TERM, $offering->billingMode);
|
||||
self::assertSame(48, $offering->cancellationCutoffHours);
|
||||
self::assertTrue($offering->isActive);
|
||||
}
|
||||
|
||||
public function testFromRowMapsNullCancellationCutoff(): void
|
||||
{
|
||||
$row = (object) [
|
||||
'id' => '7',
|
||||
'instructor_id' => '3',
|
||||
'kind' => Offering::KIND_PRIVATE_LESSON,
|
||||
'title' => '30 min lesson',
|
||||
'description' => null,
|
||||
'duration_minutes' => '30',
|
||||
'price' => '40.00',
|
||||
'currency' => 'CAD',
|
||||
'billing_mode' => Offering::BILLING_ONE_TIME,
|
||||
'allow_weekly' => '0',
|
||||
'capacity' => null,
|
||||
'term_start' => null,
|
||||
'term_end' => null,
|
||||
'schedule_note' => null,
|
||||
'etransfer_email' => null,
|
||||
'cancellation_cutoff_hours' => null,
|
||||
'is_active' => '1',
|
||||
];
|
||||
|
||||
$offering = Offering::fromRow($row);
|
||||
|
||||
self::assertNull($offering->cancellationCutoffHours);
|
||||
}
|
||||
|
||||
public function testToArrayContainsExpectedKeys(): void
|
||||
{
|
||||
$offering = new Offering(1, Offering::KIND_PRIVATE_LESSON, 'Lesson', id: 10);
|
||||
|
||||
@@ -20,6 +20,29 @@ class StudioSettingsTest extends TestCase
|
||||
self::assertFalse($settings->openRegistrationEnabled());
|
||||
}
|
||||
|
||||
public function testCancellationCutoffDefaultsToOneDay(): void
|
||||
{
|
||||
Functions\when('get_option')->alias(static fn (string $name, $default) => $default);
|
||||
|
||||
self::assertSame(24, (new StudioSettings())->cancellationCutoffHours());
|
||||
}
|
||||
|
||||
public function testCancellationCutoffReadsStoredHours(): void
|
||||
{
|
||||
Functions\when('get_option')->alias(static fn (string $name) =>
|
||||
$name === StudioSettings::OPT_CANCELLATION_CUTOFF_HOURS ? '72' : '');
|
||||
|
||||
self::assertSame(72, (new StudioSettings())->cancellationCutoffHours());
|
||||
}
|
||||
|
||||
public function testCancellationCutoffClampsNegativeToZero(): void
|
||||
{
|
||||
Functions\when('get_option')->alias(static fn (string $name) =>
|
||||
$name === StudioSettings::OPT_CANCELLATION_CUTOFF_HOURS ? '-5' : '');
|
||||
|
||||
self::assertSame(0, (new StudioSettings())->cancellationCutoffHours());
|
||||
}
|
||||
|
||||
public function testOpenRegistrationEnabledWhenStored(): void
|
||||
{
|
||||
Functions\when('get_option')->alias(static fn (string $name) =>
|
||||
|
||||
Reference in New Issue
Block a user