CI / Tests (PHP 8.2) (pull_request) Successful in 58s
CI / PHPStan (pull_request) Successful in 2m53s
CI / Coding Standards (pull_request) Successful in 2m58s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m47s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 1m1s
CI / No Debug Code (pull_request) Successful in 3s
Three fixes from testing the branch. A group class was only listed when its schedule resolved to exact datetimes, which needs a class time *and* a duration — both optional on the offering form, and the schedule note exists precisely so a studio can write "Tuesdays 4:00pm" instead. A class configured that way vanished from the list, which is the one thing this feature must never do. So Offering::sessionStarts() splits "when does it meet" from "how long does it run" (sessionWindows() is that plus the duration, unchanged), and SessionSchedule degrades instead of disappearing: dated rows with an open end when there is no duration, and a single row carrying Offering::scheduleLabel() when there is no time to derive dates from. Only a class whose last day has passed drops out. Deleting a guardian now deletes the children linked to them, releasing each one's lessons and enrolments first. A child account is login-less and exists only so the guardian has somebody to book for; without the guardian nobody can reach it, book for it, or be billed for it, so it was left stranded on the roster still holding slots. A `handled` set makes the re-entrant delete_user each child deletion fires a no-op, and stops a circular link recursing. The upcoming panel never stated its own line-height, so a theme setting line-height: 0 above it — the usual icon-font reset — was inherited straight through. Below 1 that produces both reported symptoms at once: stacked lines overlap, and the status pill's background is shorter than the text in it. Pinned at the same id-level specificity as the rest. Tests: composer test (863), composer lint, composer cs all pass. Co-Authored-By: Claude Opus 5 <[email protected]>
347 lines
13 KiB
PHP
347 lines
13 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';
|
||
|
||
/** 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<string>
|
||
*/
|
||
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<string>
|
||
*/
|
||
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<string>
|
||
*/
|
||
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 datetime each session of this group class starts, derived from the class
|
||
* date(s) and the class time. A weekly class yields one per week from
|
||
* `term_start` through `term_end`; a one-off class yields a single one.
|
||
*
|
||
* Deliberately does **not** need a duration: knowing *when* a class meets is a
|
||
* separate question from knowing how long it runs, and a studio can quite
|
||
* reasonably set the first without the second. Returns an empty list when
|
||
* there is no date or no time, since neither can be invented.
|
||
*
|
||
* @return list<string> `Y-m-d H:i:s` starts, earliest first.
|
||
*/
|
||
public function sessionStarts(): array {
|
||
if ( null === $this->termStart || null === $this->classTime ) {
|
||
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;
|
||
|
||
$starts = [];
|
||
$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++ ) {
|
||
$starts[] = $cursor->format( 'Y-m-d H:i:s' );
|
||
|
||
$cursor = $cursor->modify( '+7 days' );
|
||
$cursorDay = $cursor->format( 'Y-m-d' );
|
||
}
|
||
|
||
return $starts;
|
||
}
|
||
|
||
/**
|
||
* The concrete start/end datetimes of every session of this group class:
|
||
* {@see sessionStarts()} closed off with the class duration. 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 —
|
||
* callers that block availability or bill per session need both ends.
|
||
*
|
||
* @return list<array{start: string, end: string}>
|
||
*/
|
||
public function sessionWindows(): array {
|
||
if ( null === $this->durationMinutes || $this->durationMinutes <= 0 ) {
|
||
return [];
|
||
}
|
||
|
||
$step = new \DateInterval( 'PT' . $this->durationMinutes . 'M' );
|
||
|
||
return array_map(
|
||
static fn( string $start ): array => [
|
||
'start' => $start,
|
||
'end' => ( new \DateTimeImmutable( $start ) )->add( $step )->format( 'Y-m-d H:i:s' ),
|
||
],
|
||
$this->sessionStarts()
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The last day this class meets, or null when it has no dates at all.
|
||
*/
|
||
public function lastClassDay(): ?string {
|
||
return $this->termEnd ?? $this->termStart;
|
||
}
|
||
|
||
/**
|
||
* Plain-language wording for when this class meets, for the places that have
|
||
* to say something about a class whose schedule cannot be resolved to dates.
|
||
* Prefers the studio's own note ("Tuesdays 4:00pm") — that field exists
|
||
* precisely so a class can describe its schedule without pinning it to a
|
||
* time — then the term dates, and finally an honest admission that nothing
|
||
* has been set.
|
||
*/
|
||
public function scheduleLabel(): string {
|
||
$note = null !== $this->scheduleNote ? trim( $this->scheduleNote ) : '';
|
||
if ( '' !== $note ) {
|
||
return $note;
|
||
}
|
||
|
||
if ( null === $this->termStart ) {
|
||
return __( 'Schedule to be confirmed', 'unsupervised-schedular' );
|
||
}
|
||
|
||
$start = (string) mysql2date( 'M j, Y', $this->termStart );
|
||
|
||
if ( null === $this->termEnd || $this->termEnd === $this->termStart ) {
|
||
return $start;
|
||
}
|
||
|
||
return sprintf(
|
||
/* translators: 1: first class date, 2: last class date. */
|
||
__( '%1$s – %2$s', 'unsupervised-schedular' ),
|
||
$start,
|
||
(string) mysql2date( 'M j, Y', $this->termEnd )
|
||
);
|
||
}
|
||
|
||
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<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,
|
||
'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;
|
||
}
|
||
}
|