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

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:
2026-07-23 11:57:18 -03:00
co-authored by Claude Opus 4.8
parent a777f5d05a
commit 8b90b8d78d
18 changed files with 462 additions and 33 deletions
+18
View File
@@ -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 );
+69
View File
@@ -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
View File
@@ -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 ) {
+6
View File
@@ -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,
);
+2
View File
@@ -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,
);
+19 -17
View File
@@ -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,
];
}
+24
View File
@@ -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 days.
$cancellationCutoffDays = $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 days but stored in hours.
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Val::float() coerces to float; slashes cannot survive numeric coercion.
$cutoffDays = isset( $_POST['cancellation_cutoff_days'] ) ? max( 0.0, Val::float( $_POST['cancellation_cutoff_days'] ) ) : 0.0;
update_option( self::OPT_CANCELLATION_CUTOFF_HOURS, (int) round( $cutoffDays * 24 ) );
$this->applyRegistrationMode( isset( $_POST['open_registration'] ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
}
+3 -1
View File
@@ -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 );
+1
View File
@@ -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),