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]>
70 lines
2.2 KiB
PHP
70 lines
2.2 KiB
PHP
<?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 );
|
|
}
|
|
}
|