Add cancellation cutoff limiting how close to a lesson a student can cancel
CI / Tests (PHP 8.1) (pull_request) Successful in 41s
CI / Tests (PHP 8.2) (pull_request) Successful in 53s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Coding Standards (pull_request) Successful in 2m54s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 41s
CI / Tests (PHP 8.2) (pull_request) Successful in 53s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Coding Standards (pull_request) Successful in 2m54s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / Build Plugin Zip (pull_request) Skipped
Students can no longer cancel their own lesson online once it starts within a configured window; instructors and studio admins can always cancel. - Studio default `us_cancellation_cutoff_hours` (stored/computed in hours, entered and displayed in days under Studio Settings → Cancellations). - Optional per-offering override `cancellation_cutoff_hours` (entered in hours); blank inherits the studio default, 0 allows anytime cancellation. - `Booking\CancellationPolicy` resolves the effective window and decides; `BookingEndpoint::cancel()` returns a 403 `cancellation_closed` when too late. The instructor status endpoint and studio-admin student actions bypass it. Closes #93 Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
@@ -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 );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user