Cancellation cutoff: limit how close to a lesson a student can cancel #94

Merged
thatguygriff merged 2 commits from feature/cancellation-cutoff into main 2026-07-23 15:14:56 +00:00
18 changed files with 462 additions and 33 deletions
+69
View File
@@ -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)
+1
View File
@@ -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 |
+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 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
}
+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),
+7
View File
@@ -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>
+12
View File
@@ -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',
];
}
+29
View File
@@ -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);
+23
View File
@@ -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) =>