diff --git a/docs/features/cancellation-cutoff.md b/docs/features/cancellation-cutoff.md new file mode 100644 index 0000000..ad8529f --- /dev/null +++ b/docs/features/cancellation-cutoff.md @@ -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) diff --git a/docs/features/offerings.md b/docs/features/offerings.md index 6a03f7d..bec8a12 100644 --- a/docs/features/offerings.md +++ b/docs/features/offerings.md @@ -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 | diff --git a/src/Booking/BookingEndpoint.php b/src/Booking/BookingEndpoint.php index 9518040..bce4219 100644 --- a/src/Booking/BookingEndpoint.php +++ b/src/Booking/BookingEndpoint.php @@ -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 ); diff --git a/src/Booking/CancellationPolicy.php b/src/Booking/CancellationPolicy.php new file mode 100644 index 0000000..85423c6 --- /dev/null +++ b/src/Booking/CancellationPolicy.php @@ -0,0 +1,69 @@ += 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 ); + } +} diff --git a/src/Offering/Offering.php b/src/Offering/Offering.php index c128488..7ea9663 100644 --- a/src/Offering/Offering.php +++ b/src/Offering/Offering.php @@ -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 ) { diff --git a/src/Offering/OfferingController.php b/src/Offering/OfferingController.php index 406b675..63c8d6b 100644 --- a/src/Offering/OfferingController.php +++ b/src/Offering/OfferingController.php @@ -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, ); diff --git a/src/Offering/OfferingEndpoint.php b/src/Offering/OfferingEndpoint.php index b37e625..3aaccf6 100644 --- a/src/Offering/OfferingEndpoint.php +++ b/src/Offering/OfferingEndpoint.php @@ -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, ); diff --git a/src/Offering/OfferingRepository.php b/src/Offering/OfferingRepository.php index a9ea76e..58ac8f2 100644 --- a/src/Offering/OfferingRepository.php +++ b/src/Offering/OfferingRepository.php @@ -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 */ - 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, ]; } diff --git a/src/Payment/StudioSettings.php b/src/Payment/StudioSettings.php index e32ba8a..e1b6a04 100644 --- a/src/Payment/StudioSettings.php +++ b/src/Payment/StudioSettings.php @@ -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 } diff --git a/src/RestRegistrar.php b/src/RestRegistrar.php index 9e81b19..916b1df 100644 --- a/src/RestRegistrar.php +++ b/src/RestRegistrar.php @@ -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 ); diff --git a/src/Schema.php b/src/Schema.php index da93889..9514f48 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -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), diff --git a/templates/admin/offerings.php b/templates/admin/offerings.php index 3a4f4cd..53171ff 100644 --- a/templates/admin/offerings.php +++ b/templates/admin/offerings.php @@ -103,6 +103,13 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e + + + + +

+ + diff --git a/templates/admin/settings.php b/templates/admin/settings.php index c4d3ba0..2250635 100644 --- a/templates/admin/settings.php +++ b/templates/admin/settings.php @@ -16,6 +16,7 @@ if (! defined('ABSPATH')) { * @var float $hstRate * @var bool $stripeConfigured * @var bool $openRegistration + * @var float $cancellationCutoffDays */ ?>
@@ -90,6 +91,17 @@ if (! defined('ABSPATH')) { +

+ + + + + +
+ +

+
+

diff --git a/tests/Unit/Booking/BookingEndpointTest.php b/tests/Unit/Booking/BookingEndpointTest.php index c87f25c..6d69657 100644 --- a/tests/Unit/Booking/BookingEndpointTest.php +++ b/tests/Unit/Booking/BookingEndpointTest.php @@ -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(); diff --git a/tests/Unit/Booking/CancellationPolicyTest.php b/tests/Unit/Booking/CancellationPolicyTest.php new file mode 100644 index 0000000..d5d70db --- /dev/null +++ b/tests/Unit/Booking/CancellationPolicyTest.php @@ -0,0 +1,100 @@ +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)); + } +} diff --git a/tests/Unit/Offering/OfferingRepositoryTest.php b/tests/Unit/Offering/OfferingRepositoryTest.php index d5c0aa8..8bc5271 100644 --- a/tests/Unit/Offering/OfferingRepositoryTest.php +++ b/tests/Unit/Offering/OfferingRepositoryTest.php @@ -182,6 +182,7 @@ class OfferingRepositoryTest extends TestCase 'term_end' => null, 'schedule_note' => null, 'etransfer_email' => null, + 'cancellation_cutoff_hours' => null, 'is_active' => '1', ]; } diff --git a/tests/Unit/Offering/OfferingTest.php b/tests/Unit/Offering/OfferingTest.php index e2c7258..a7a022d 100644 --- a/tests/Unit/Offering/OfferingTest.php +++ b/tests/Unit/Offering/OfferingTest.php @@ -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); diff --git a/tests/Unit/Payment/StudioSettingsTest.php b/tests/Unit/Payment/StudioSettingsTest.php index 4d76ef6..9b594a3 100644 --- a/tests/Unit/Payment/StudioSettingsTest.php +++ b/tests/Unit/Payment/StudioSettingsTest.php @@ -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) =>