Files
unsupervised-scheduler/src/Offering/Offering.php
T
thatguygriffandClaude Opus 4.8 8b90b8d78d
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
Add cancellation cutoff limiting how close to a lesson a student can cancel
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]>
2026-07-23 11:57:18 -03:00

129 lines
4.6 KiB
PHP

<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Val;
class Offering {
public const KIND_PRIVATE_LESSON = 'private_lesson';
public const KIND_GROUP_CLASS = 'group_class';
/**
* All valid offering kinds.
*
* @var list<string>
*/
public const VALID_KINDS = [ self::KIND_PRIVATE_LESSON, self::KIND_GROUP_CLASS ];
public const BILLING_ONE_TIME = 'one_time';
public const BILLING_FULL_TERM = 'full_term';
/**
* All valid billing modes.
*
* @var list<string>
*/
public const VALID_BILLING_MODES = [ self::BILLING_ONE_TIME, self::BILLING_FULL_TERM ];
public function __construct(
public readonly int $instructorId,
public readonly string $kind,
public readonly string $title,
public readonly float $price = 0.0,
public readonly string $currency = 'CAD',
public readonly string $billingMode = self::BILLING_ONE_TIME,
public readonly ?string $description = null,
public readonly ?int $durationMinutes = null,
public readonly bool $allowWeekly = false,
public readonly ?int $capacity = null,
public readonly ?string $termStart = null,
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,
) {}
/**
* Normalise a submitted term date to canonical `Y-m-d`, or null when it is
* not a real calendar date. Round-trips through DateTimeImmutable so
* strings PHP would silently coerce (e.g. `2026-02-30`) are rejected.
*/
public static function normalizeDate( string $value ): ?string {
$date = \DateTimeImmutable::createFromFormat( '!Y-m-d', $value );
return false !== $date && $date->format( 'Y-m-d' ) === $value ? $date->format( 'Y-m-d' ) : null;
}
/**
* Last class date of a weekly term: the start date plus `$occurrences - 1`
* weeks. A one-off class (one occurrence) ends the day it starts.
*/
public static function weeklyTermEnd( string $termStart, int $occurrences ): string {
$weeks = max( 1, $occurrences ) - 1;
return ( new \DateTimeImmutable( $termStart ) )->modify( '+' . ( 7 * $weeks ) . ' days' )->format( 'Y-m-d' );
}
public static function fromRow( \stdClass $row ): self {
return new self(
instructorId: Val::int( $row->instructor_id ),
kind: Val::string( $row->kind ),
title: Val::string( $row->title ),
price: Val::float( $row->price ),
currency: Val::string( $row->currency ),
billingMode: Val::string( $row->billing_mode ),
description: Val::stringOrNull( $row->description ),
durationMinutes: Val::intOrNull( $row->duration_minutes ),
allowWeekly: Val::bool( $row->allow_weekly ),
capacity: Val::intOrNull( $row->capacity ),
termStart: Val::stringOrNull( $row->term_start ),
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 ),
);
}
/**
* Returns a plain array representation of the offering.
*
* The e-transfer destination email is a private payment-routing detail, so it
* is only included when $includeEtransferEmail is true (e.g. manager-only
* responses). The public offerings listing must omit it.
*
* @return array<string, mixed>
*/
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,
'cancellation_cutoff_hours' => $this->cancellationCutoffHours,
'is_active' => $this->isActive,
];
if ( $includeEtransferEmail ) {
$out['etransfer_email'] = $this->etransferEmail;
}
return $out;
}
}