*/ 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 */ public const VALID_BILLING_MODES = [ self::BILLING_ONE_TIME, self::BILLING_FULL_TERM ]; /** Listed in the public catalogue; anyone with `book_lesson` may enrol. */ public const ACCESS_PUBLIC = 'public'; /** Hidden from the catalogue; only invited/added students may enrol (group classes). */ public const ACCESS_INVITE_ONLY = 'invite_only'; /** * All valid access modes. * * @var list */ public const VALID_ACCESS_MODES = [ self::ACCESS_PUBLIC, self::ACCESS_INVITE_ONLY ]; 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 string $accessMode = self::ACCESS_PUBLIC, public readonly bool $isActive = true, public readonly ?int $id = null, ) {} /** * Whether the offering is hidden from the public catalogue and reachable * only by invited or directly-added students. */ public function isInviteOnly(): bool { return self::ACCESS_INVITE_ONLY === $this->accessMode; } /** * 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 ), accessMode: '' !== Val::string( $row->access_mode ?? '' ) ? Val::string( $row->access_mode ) : self::ACCESS_PUBLIC, 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 */ 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, 'access_mode' => $this->accessMode, 'is_active' => $this->isActive, ]; if ( $includeEtransferEmail ) { $out['etransfer_email'] = $this->etransferEmail; } return $out; } }