*/ 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'; /** Billed 24 hours before each lesson, on a recurring schedule (see scheduled-billing.md). */ public const BILLING_WEEKLY = 'weekly'; /** Billed on the first of each month for every lesson that falls in the month. */ public const BILLING_MONTHLY = 'monthly'; /** * All valid billing modes. * * @var list */ public const VALID_BILLING_MODES = [ self::BILLING_ONE_TIME, self::BILLING_FULL_TERM, self::BILLING_WEEKLY, self::BILLING_MONTHLY ]; /** * Billing modes whose payment is generated later by the daily billing scan * rather than taken at registration. * * @var list */ public const SCHEDULED_BILLING_MODES = [ self::BILLING_WEEKLY, self::BILLING_MONTHLY ]; /** 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 ]; /** Maximum length of the title, matching the `title` VARCHAR(191) column. */ public const MAX_TITLE_LENGTH = 191; /** Maximum length of the schedule note, matching the `schedule_note` VARCHAR(191) column. */ public const MAX_SCHEDULE_NOTE_LENGTH = 191; /** Maximum length of the e-transfer email, matching the `etransfer_email` VARCHAR(191) column. */ public const MAX_ETRANSFER_EMAIL_LENGTH = 191; 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 $classTime = null, public readonly ?string $enrollmentDeadline = null, public readonly ?string $withdrawalDeadline = 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; } /** * Whether this offering's payment is deferred to the daily billing scan * (weekly / monthly) instead of being taken at registration. */ public function isScheduledBilling(): bool { return in_array( $this->billingMode, self::SCHEDULED_BILLING_MODES, true ); } /** * The last day on which a student may enrol in this group class. Defaults to * the first day of the class (`term_start`) when the instructor has not set an * explicit deadline; null only when the class has no dates at all. */ public function effectiveEnrollmentDeadline(): ?string { return $this->enrollmentDeadline ?? $this->termStart; } /** * Whether enrolment is still open on `$today` (a `Y-m-d` date). Enrolment stays * open through the end of the deadline day, so the first class is still * enrollable under the default deadline. A class with no deadline at all (no * dates configured) is always open. */ public function isEnrollmentOpen( string $today ): bool { $deadline = $this->effectiveEnrollmentDeadline(); return null === $deadline || $today <= $deadline; } /** * Whether a student may still withdraw themselves from this group class on * `$today` (a `Y-m-d` date). Withdrawal stays open through the end of the * deadline day. Unlike the enrolment deadline there is no implicit default: a * class with no withdrawal deadline set stays open to withdrawal for its whole * life, so the instructor must set a date to lock students in. A withdrawal * made while open never issues an account credit — it only frees the seat and * voids any still-pending payment. */ public function isWithdrawalOpen( string $today ): bool { return null === $this->withdrawalDeadline || $today <= $this->withdrawalDeadline; } /** * 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' ); } /** * Normalise a submitted time-of-day to canonical `H:i:s`, or null when it is * not a real time. Accepts the HTML `time` form (`H:i`, optionally with * seconds); anything else is rejected so garbage never reaches the TIME column. */ public static function normalizeTime( string $value ): ?string { foreach ( [ 'H:i:s', 'H:i' ] as $format ) { $time = \DateTimeImmutable::createFromFormat( '!' . $format, $value ); if ( false !== $time && $time->format( $format ) === $value ) { return $time->format( 'H:i:s' ); } } return null; } /** * The concrete start/end datetimes of every session of this group class, * derived from the class date(s), the class time, and the duration. A weekly * class yields one window per week from `term_start` through `term_end`; a * one-off class yields a single window. Returns an empty list unless the * schedule is fully specified (date, time, and a positive duration), so it can * never fabricate a session window from partial data. * * @return list */ public function sessionWindows(): array { if ( null === $this->termStart || null === $this->classTime || null === $this->durationMinutes || $this->durationMinutes <= 0 ) { return []; } $first = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $this->termStart . ' ' . $this->classTime ); if ( false === $first ) { return []; } $lastDay = null !== $this->termEnd ? $this->termEnd : $this->termStart; $step = new \DateInterval( 'PT' . $this->durationMinutes . 'M' ); $windows = []; $cursor = $first; $cursorDay = $cursor->format( 'Y-m-d' ); // Cap the walk at ten years of weeks so a term_end before term_start (or a // bad value) can never spin into an unbounded loop. for ( $i = 0; $i < 520 && $cursorDay <= $lastDay; $i++ ) { $windows[] = [ 'start' => $cursor->format( 'Y-m-d H:i:s' ), 'end' => $cursor->add( $step )->format( 'Y-m-d H:i:s' ), ]; $cursor = $cursor->modify( '+7 days' ); $cursorDay = $cursor->format( 'Y-m-d' ); } return $windows; } 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 ), classTime: Val::stringOrNull( $row->class_time ?? null ), enrollmentDeadline: Val::stringOrNull( $row->enrollment_deadline ?? null ), withdrawalDeadline: Val::stringOrNull( $row->withdrawal_deadline ?? null ), 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, 'class_time' => $this->classTime, 'enrollment_deadline' => $this->enrollmentDeadline, 'withdrawal_deadline' => $this->withdrawalDeadline, '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; } }