Let the studio book lessons and record intake collected elsewhere
CI / Tests (PHP 8.1) (pull_request) Successful in 6m39s
CI / Tests (PHP 8.2) (pull_request) Successful in 57s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m59s
CI / Tests (PHP 8.5) (pull_request) Successful in 3m31s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards & Static Analysis (pull_request) Successful in 3m28s
CI / Build Plugin Zip (pull_request) Skipped

Two related gaps, closed together because the second is created by the first.

A private lesson could only be booked by the student or their guardian, so a
booking taken over the phone had no way in — where group classes have had "Add
students directly" all along. "Book a lesson for a student" is now a panel on
Scheduler and My Lessons: student, open time, lesson type, with weekly term
reservations and a no-charge option for make-up lessons. The booking core is
extracted to Booking\LessonBooker and shared with POST /bookings, so the two
paths cannot drift on offering rules, slot claiming, or billing.

That leaves a registration with no intake answers and no policy acceptances,
because nobody was at a keyboard to give them — already true of every directly
added group-class student. Ticking the boxes on a student's behalf would be an
audit trail that says something untrue, so instead the answers are collected
another way and recorded afterwards, from a lesson's or an enrolment's detail
page. Every recording must say how it was collected, which is stamped on each
row along with who typed it and shown in a new "How it was given" column: a
policy ticked online and one transcribed from paper must never look alike.

Only staff-made registrations qualify (us_lessons.booked_by,
us_group_enrollments.enrolled_by) — one the student made already holds their
own answers. Only what is still missing can be recorded, re-checked at write
time, so a stale or double-posted form cannot duplicate or overwrite. No IP is
stored for a transcription, and accepted_by stays the student while recorded_by
names the staff member.

Intake is now generic over Registration\IntakeSubject, which Lesson and
Enrollment both implement; LessonDetail became Registration\IntakeAudit and is
shared by both detail views rather than duplicated.

Closes #182

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QfHt6CyJHz6KkA4RuaS7WK
This commit is contained in:
2026-08-24 14:06:16 -03:00
co-authored by Claude Opus 5
parent 8a34ec41e9
commit 8c21a3fa9d
46 changed files with 3020 additions and 306 deletions
+280
View File
@@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Auth\UserName;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Val;
/**
* Booking a private lesson **for** a student, from wp-admin — the studio's
* counterpart to the group class's "Add students directly". The front desk takes
* a phone call, an instructor slots in a make-up lesson; neither of them can log
* in as the student, and only a guardian may book through the student-facing
* flow.
*
* It reuses `LessonBooker` — the same offering rules, the same atomic slot claim,
* the same billing — and differs from a student's own booking in exactly three
* ways, each deliberate:
*
* 1. **No intake questions or policy acceptances are recorded.** They are the
* student's to answer and agree to; a staff member ticking boxes on their
* behalf would be an audit trail that says something untrue. The lesson's
* detail page simply shows none.
* 2. **It is not bounded by what the student could book themselves.** Any open
* slot of the instructor's, including one only reachable past a deadline.
* 3. **It can be booked at no charge**, for a make-up or goodwill lesson, which
* skips the payment entirely and confirms the lesson at once.
*/
class AdminBooking {
/**
* How far ahead the form's list of open times reaches. Long enough to book a
* term ahead, short enough that the select stays a select.
*/
private const HORIZON_DAYS = 56;
public function __construct(
private AvailabilityRepository $availability,
private OfferingRepository $offerings,
private LessonBooker $booker,
) {}
/**
* Book a lesson on a student's behalf, returning the notice to show. When
* `$onlyInstructorId` is non-zero the slot must belong to that instructor —
* how an instructor's own **My Lessons** page is kept to their own schedule,
* where the studio **Scheduler** passes 0 and may book any instructor's time.
*
* @return string|\WP_Error Success notice, or why nothing was booked.
*/
public function book( int $studentId, int $slotId, int $offeringId, string $recurrence, bool $noCharge, string $notes, int $onlyInstructorId = 0 ): string|\WP_Error {
if ( $studentId <= 0 || ! user_can( $studentId, RoleManager::CAP_BOOK_LESSON ) ) {
return new \WP_Error( 'invalid_student', __( 'Choose a student to book for.', 'unsupervised-schedular' ) );
}
$slot = $slotId > 0 ? $this->availability->findById( $slotId ) : null;
if ( null === $slot || ( $onlyInstructorId > 0 && $slot->instructorId !== $onlyInstructorId ) ) {
return new \WP_Error( 'invalid_slot', __( 'Choose a time to book.', 'unsupervised-schedular' ) );
}
if ( $slot->isBooked ) {
return new \WP_Error( 'slot_taken', __( 'That time has already been booked.', 'unsupervised-schedular' ) );
}
$offering = $this->booker->resolveOffering( $slot, $offeringId );
if ( $offering instanceof \WP_Error ) {
return $offering;
}
// A weekly reservation needs a weekly time to reserve. The student-facing
// flow quietly books a single lesson when the slot does not repeat; here the
// staff member asked for a term and must be told they are not getting one,
// rather than discovering it later on the roster.
$weekly = Lesson::RECURRENCE_WEEKLY === $recurrence;
if ( $weekly && null === $slot->recurrenceGroup ) {
return new \WP_Error( 'not_weekly', __( 'That time does not repeat weekly, so it cannot be reserved for the term.', 'unsupervised-schedular' ) );
}
$reservation = $this->booker->reserve(
$slot,
$offering,
$studentId,
$weekly ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE,
$notes,
// Stamped on the lesson so it can be told apart later: only a lesson the
// studio booked may have its intake recorded after the fact.
get_current_user_id()
);
if ( $reservation instanceof \WP_Error ) {
return $reservation;
}
$settlement = $this->booker->settle(
$reservation['ids'],
$reservation['anchor_id'],
$slot,
$offering,
$studentId,
$noCharge
);
return $this->notice( $studentId, $offering, $slot, count( $reservation['ids'] ), $settlement['status'] );
}
/**
* What was booked and what it left owing, so the notice answers the two things
* the person who booked it needs to know.
*/
private function notice( int $studentId, Offering $offering, AvailabilitySlot $slot, int $count, string $status ): string {
$who = $this->studentName( $studentId );
$what = $count > 1
? sprintf(
/* translators: 1: student name, 2: lesson type, 3: number of weekly occurrences, 4: first lesson date and time. */
__( 'Booked %1$s into %2$s — %3$d weekly lessons from %4$s.', 'unsupervised-schedular' ),
$who,
$offering->title,
$count,
Val::string( mysql2date( 'M j, Y g:i A', $slot->startDt ) )
)
: sprintf(
/* translators: 1: student name, 2: lesson type, 3: lesson date and time. */
__( 'Booked %1$s into %2$s on %3$s.', 'unsupervised-schedular' ),
$who,
$offering->title,
Val::string( mysql2date( 'M j, Y g:i A', $slot->startDt ) )
);
$owing = Lesson::STATUS_CONFIRMED === $status
? __( 'Nothing is owed, so it is confirmed.', 'unsupervised-schedular' )
: __( 'A pending payment has been raised; the lesson is confirmed once it settles.', 'unsupervised-schedular' );
return $what . ' ' . $owing;
}
/**
* Everything the form's three selects need. `$onlyInstructorId` scopes both the
* open times and the lesson types to one instructor's, the same way `book()`
* scopes what may be booked.
*
* @return array{students: list<array{id: int, name: string}>, offerings: list<array{id: int, label: string}>, slots: list<array{id: int, label: string, weekly: bool}>}
*/
public function formData( int $onlyInstructorId = 0 ): array {
$slots = $this->openSlots( $onlyInstructorId );
$offerings = array_values(
array_filter(
$this->offerings->findAll( $onlyInstructorId, Offering::KIND_PRIVATE_LESSON, true ),
static fn( Offering $o ): bool => null !== $o->id
)
);
// Whose lesson type / whose time only needs saying when the page spans more
// than one instructor — on an instructor's own page it is noise.
$named = 0 === $onlyInstructorId;
return [
'students' => $this->studentOptions(),
'offerings' => array_map(
fn( Offering $o ): array => [
'id' => (int) $o->id,
'label' => $this->offeringLabel( $o, $named ),
],
$offerings
),
'slots' => array_map(
fn( AvailabilitySlot $s ): array => [
'id' => (int) $s->id,
'label' => $this->slotLabel( $s, $named ),
'weekly' => null !== $s->recurrenceGroup,
],
$slots
),
];
}
/**
* Open slots inside the booking horizon, newest last.
*
* @return list<AvailabilitySlot>
*/
private function openSlots( int $instructorId ): array {
$until = ( new \DateTimeImmutable( Val::string( current_time( 'mysql' ) ) ) )
->modify( '+' . self::HORIZON_DAYS . ' days' )
->format( 'Y-m-d H:i:s' );
return array_values(
array_filter(
$this->availability->findAvailable( $instructorId, 0, 0, '', $until ),
static fn( AvailabilitySlot $s ): bool => null !== $s->id
)
);
}
/**
* A lesson type as "60 min piano (60 min) — Jane Doe", the instructor named
* only when the list spans several.
*/
private function offeringLabel( Offering $offering, bool $withInstructor ): string {
$label = $offering->title;
if ( null !== $offering->durationMinutes ) {
/* translators: %d: lesson length in minutes. */
$label .= ' (' . sprintf( __( '%d min', 'unsupervised-schedular' ), $offering->durationMinutes ) . ')';
}
return $withInstructor ? $label . ' — ' . $this->instructorName( $offering->instructorId ) : $label;
}
/**
* An open time as "Mon Sep 2, 4:00 PM (30 min) — Jane Doe — 30 min piano —
* repeats weekly": when it is, how long, whose, and what it is already tied to,
* since all four decide whether a given student can be booked into it.
*/
private function slotLabel( AvailabilitySlot $slot, bool $withInstructor ): string {
/* translators: %d: lesson length in minutes. */
$label = Val::string( mysql2date( 'D M j, Y g:i A', $slot->startDt ) ) . ' (' . sprintf( __( '%d min', 'unsupervised-schedular' ), $slot->durationMinutes ) . ')';
if ( $withInstructor ) {
$label .= ' — ' . $this->instructorName( $slot->instructorId );
}
$tied = null !== $slot->offeringId ? $this->offerings->findById( $slot->offeringId ) : null;
if ( null !== $tied ) {
$label .= ' — ' . $tied->title;
}
if ( null !== $slot->recurrenceGroup ) {
$label .= ' — ' . __( 'repeats weekly', 'unsupervised-schedular' );
}
return $label;
}
private function instructorName( int $instructorId ): string {
return $this->studentName( $instructorId );
}
/** A person's display name, however little the account has on file. */
private function studentName( int $userId ): string {
$user = get_userdata( $userId );
return UserName::format( $user instanceof \WP_User ? $user : null, $userId );
}
/**
* Everyone who can be booked for, by name — students and the children a
* guardian books for alike, since both hold `book_lesson`.
*
* @return list<array{id: int, name: string}>
*/
private function studentOptions(): array {
$users = array_filter(
get_users(
[
'role' => RoleManager::STUDENT,
'orderby' => 'display_name',
'order' => 'ASC',
]
),
static fn( mixed $u ): bool => $u instanceof \WP_User
);
return array_values(
array_map(
static fn( \WP_User $u ): array => [
'id' => (int) $u->ID,
'name' => UserName::format( $u, (int) $u->ID ),
],
$users
)
);
}
}
+18 -147
View File
@@ -7,9 +7,7 @@ use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\GroupClass\SessionSchedule;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Registration\RegistrationGate;
@@ -17,18 +15,13 @@ use Unsupervised\Schedular\Val;
class BookingEndpoint {
/**
* The most occurrences a single weekly booking may reserve at once, so one
* student cannot lock up an instructor's entire recurring schedule.
*/
private const MAX_WEEKLY_OCCURRENCES = 12;
public function __construct(
private AvailabilityRepository $availability,
private BookingRepository $bookings,
private OfferingRepository $offerings,
private RegistrationGate $gate,
private PaymentService $payments,
private LessonBooker $booker,
private CancellationPolicy $cancellationPolicy,
private GuardianService $guardians,
private SessionSchedule $sessions,
@@ -227,52 +220,12 @@ class BookingEndpoint {
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
// Resolve the offering for this booking. A client-supplied offering must
// never override the slot's price or payment routing: when the slot is tied
// to a specific offering that offering is authoritative, and any offering
// used must belong to the slot's instructor. This prevents substituting a
// cheaper/free offering to dodge payment, or another instructor's offering
// to misroute it.
$requestedOfferingId = absint( Val::int( $request->get_param( 'offering_id' ) ) );
$slotOfferingId = (int) ( $slot->offeringId ?? 0 );
if ( $slotOfferingId > 0 ) {
if ( $requestedOfferingId > 0 && $requestedOfferingId !== $slotOfferingId ) {
return new \WP_Error( 'offering_mismatch', __( 'This slot is tied to a different offering.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
$offeringId = $slotOfferingId;
} else {
$offeringId = $requestedOfferingId;
$offering = $this->booker->resolveOffering( $slot, absint( Val::int( $request->get_param( 'offering_id' ) ) ) );
if ( $offering instanceof \WP_Error ) {
return $offering;
}
// Every lesson books against an offering: it carries the price, intake
// questions, and payment routing. Without one the booking would silently
// be free and unquestioned, so generic slots require the student's choice.
if ( $offeringId <= 0 ) {
return new \WP_Error( 'offering_required', __( 'Choose a lesson type to book this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
$offering = $this->offerings->findById( $offeringId );
if ( null === $offering ) {
return new \WP_Error( 'invalid_offering', __( 'Offering not found.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
if ( $offering->instructorId !== $slot->instructorId ) {
return new \WP_Error( 'offering_mismatch', __( 'That offering is not available for this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
// A slot-tied offering was the instructor's explicit choice and is honoured
// as-is; a student-chosen one must be something the catalog actually offers
// for this slot: an active private-lesson type whose length fits the slot.
if ( 0 === $slotOfferingId ) {
if ( ! $offering->isActive || Offering::KIND_PRIVATE_LESSON !== $offering->kind ) {
return new \WP_Error( 'invalid_offering', __( 'That offering cannot be booked as a private lesson.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
if ( null !== $offering->durationMinutes && $offering->durationMinutes !== $slot->durationMinutes ) {
return new \WP_Error( 'offering_mismatch', __( 'That offering does not match this slot\'s lesson length.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
}
$offeringId = (int) $offering->id;
$answers = $this->answers( $request );
$acceptedVersionIds = array_values( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) $request->get_param( 'accepted_policy_version_ids' ) ) );
@@ -282,93 +235,28 @@ class BookingEndpoint {
return $gateError;
}
$notes = Val::string( $request->get_param( 'notes' ) );
$recurrence = Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' )
? Lesson::RECURRENCE_WEEKLY
: Lesson::RECURRENCE_SINGLE;
$notes = Val::string( $request->get_param( 'notes' ) );
$template = new Lesson(
slotId: $slotId,
studentId: $studentId,
instructorId: $slot->instructorId,
offeringId: $offeringId,
recurrence: $recurrence,
notes: '' !== $notes ? $notes : null,
$reservation = $this->booker->reserve(
$slot,
$offering,
$studentId,
Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' ) ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE,
$notes
);
// Weekly reservation across the slot's recurring group; otherwise a single lesson.
if ( Lesson::RECURRENCE_WEEKLY === $recurrence && null !== $slot->recurrenceGroup ) {
// Claim each occurrence atomically (capped so one booking cannot lock an
// instructor's entire schedule), then create a lesson only for the slots
// this request actually won — never for one already taken by someone else.
$candidates = array_map( static fn( $s ): int => (int) $s->id, $this->availability->findUnbookedInGroup( $slot->recurrenceGroup ) );
$candidates = array_slice( $candidates, 0, self::MAX_WEEKLY_OCCURRENCES );
$claimed = array_values( array_filter( $candidates, fn( int $candidateId ): bool => $this->availability->claim( $candidateId ) ) );
if ( [] === $claimed ) {
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
$ids = $this->bookings->insertSeries( $template, $claimed );
$anchorId = $ids[0] ?? 0;
} else {
// Claim before inserting: if another request already took the slot, the
// guarded update reports no rows and we reject rather than double-book.
if ( ! $this->availability->claim( $slotId ) ) {
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
$anchorId = $this->bookings->insert( $template );
$ids = [ $anchorId ];
if ( $reservation instanceof \WP_Error ) {
return $reservation;
}
$ids = $reservation['ids'];
$anchorId = $reservation['anchor_id'];
// The acceptance binds the student but is attributed to whoever actually
// ticked the boxes — the guardian, when they booked for a child.
$this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp(), get_current_user_id() );
$payment = null;
$status = Lesson::STATUS_PENDING;
// Scheduled billing (weekly / monthly) normally defers payment to the daily
// scan, but a single lesson booked once its scheduled due date has already
// passed — e.g. an extra lesson added to a month that was already billed — is
// charged at booking instead, so it is never missed or billed late.
$chargeAtBooking = $offering->price > 0.0 && (
! $offering->isScheduledBilling()
|| ( 1 === count( $ids ) && $this->scheduledDueHasPassed( $offering, $slot->startDt ) )
);
if ( $chargeAtBooking ) {
// A full-term price already covers the whole reservation; a per-lesson
// (one_time) price is owed once per occurrence actually claimed, so a
// weekly reservation cannot hold a term while paying for one week.
$amount = Offering::BILLING_FULL_TERM === $offering->billingMode
? $offering->price
: $offering->price * count( $ids );
$payment = $this->payments->createForRegistration(
Payment::REG_LESSON,
$anchorId,
$studentId,
$slot->instructorId,
$amount,
$offering->currency,
$offering->etransferEmail,
payerId: $this->guardians->payerFor( $studentId )
);
if ( null !== $payment && $payment->isPaid() ) {
$status = Lesson::STATUS_CONFIRMED;
}
} else {
// Either a free offering, or scheduled billing (weekly / monthly) whose
// payment is deferred to the daily billing scan. Either way there is no
// payment step now to confirm the lessons, so the reserved slots are
// confirmed at booking time; the billing scan bills them when they come due.
foreach ( $ids as $lessonId ) {
$this->bookings->updateStatus( $lessonId, Lesson::STATUS_CONFIRMED );
}
$status = Lesson::STATUS_CONFIRMED;
}
[ 'status' => $status, 'payment' => $payment ] = $this->booker->settle( $ids, $anchorId, $slot, $offering, $studentId );
// `payment: null` tells the front end to skip the payment step entirely.
return new \WP_REST_Response(
@@ -424,23 +312,6 @@ class BookingEndpoint {
return $out;
}
/**
* Whether a scheduled-billing offering's due date for a given session has
* already passed at booking time. Weekly bills 24 hours before the lesson;
* monthly bills on the 1st, so its due moment has passed once "now" is in the
* lesson's month or later. Only meaningful for weekly / monthly offerings.
*/
private function scheduledDueHasPassed( Offering $offering, string $slotStart ): bool {
$now = new \DateTimeImmutable( Val::string( current_time( 'mysql' ) ) );
$start = new \DateTimeImmutable( $slotStart );
if ( Offering::BILLING_MONTHLY === $offering->billingMode ) {
return $now->format( 'Y-m-d' ) >= $start->format( 'Y-m-01' );
}
return $now >= $start->modify( '-1 day' );
}
private function clientIp(): ?string {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit.
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
+3 -1
View File
@@ -24,9 +24,10 @@ class BookingRepository {
'status' => $lesson->status,
'payment_id' => $lesson->paymentId,
'notes' => $lesson->notes,
'booked_by' => $lesson->bookedBy,
'created_at' => current_time( 'mysql' ),
],
[ '%d', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%s', '%s' ]
[ '%d', '%d', '%d', '%d', '%s', '%d', '%s', '%d', '%s', '%d', '%s' ]
);
return $this->db->insert_id;
@@ -54,6 +55,7 @@ class BookingRepository {
seriesId: $seriesId > 0 ? $seriesId : null,
status: $template->status,
notes: $template->notes,
bookedBy: $template->bookedBy,
)
);
+43 -1
View File
@@ -3,9 +3,11 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Registration\Answer;
use Unsupervised\Schedular\Registration\IntakeSubject;
use Unsupervised\Schedular\Val;
class Lesson {
class Lesson implements IntakeSubject {
public const STATUS_PENDING = 'pending';
public const STATUS_CONFIRMED = 'confirmed';
@@ -38,9 +40,47 @@ class Lesson {
public readonly string $status = self::STATUS_PENDING,
public readonly ?int $paymentId = null,
public readonly ?string $notes = null,
/**
* The staff member who booked this lesson on the student's behalf, from
* wp-admin; 0 when it was booked through the student-facing flow, by the
* student or their guardian. It is what marks a lesson whose intake answers
* and policy acceptances may be recorded after the fact — nobody was at a
* keyboard to give them at booking time.
*/
public readonly int $bookedBy = 0,
public readonly ?int $id = null,
) {}
public function intakeRegistrationType(): string {
return Answer::REG_LESSON;
}
/**
* The lesson id this booking's intake answers and policy acceptances hang
* off: the series anchor for a weekly reservation, the lesson itself
* otherwise. A series is answered for and agreed to once, so every occurrence
* reads and writes the same registration.
*/
public function intakeRegistrationId(): int {
return $this->seriesId ?? (int) $this->id;
}
public function intakeOfferingId(): int {
return (int) $this->offeringId;
}
public function intakeStudentId(): int {
return $this->studentId;
}
/**
* Whether the studio booked this lesson on the student's behalf, rather than
* the student (or their guardian) booking it themselves.
*/
public function isStaffRegistered(): bool {
return $this->bookedBy > 0;
}
public static function fromRow( \stdClass $row ): self {
return new self(
slotId: Val::int( $row->slot_id ),
@@ -52,6 +92,7 @@ class Lesson {
status: Val::string( $row->status ),
paymentId: Val::intOrNull( $row->payment_id ),
notes: Val::stringOrNull( $row->notes ),
bookedBy: Val::int( $row->booked_by ?? 0 ),
id: Val::int( $row->id ),
);
}
@@ -73,6 +114,7 @@ class Lesson {
'status' => $this->status,
'payment_id' => $this->paymentId,
'notes' => $this->notes,
'booked_by' => $this->bookedBy,
];
}
}
+234
View File
@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Availability\AvailabilityRepository;
use Unsupervised\Schedular\Availability\AvailabilitySlot;
use Unsupervised\Schedular\Guardian\GuardianService;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Val;
/**
* The booking core shared by the student-facing REST endpoint and the admin
* "book a lesson for a student" form: which offering a slot may be booked as,
* claiming the slot(s) and writing the lesson row(s), and raising the payment
* that confirms them.
*
* It deliberately knows nothing about who is asking. Authorisation — a guardian
* booking for their own child, a studio admin booking for anyone — is settled by
* the caller before anything here is touched, and so are the intake answers and
* policy acceptances that gate a student's own booking (an admin booking on
* someone's behalf has none to collect). What must not diverge between the two
* paths is everything below: the offering rules that decide a slot's price and
* payment routing, the atomic claim that stops a double-booking, and the billing
* that follows.
*/
class LessonBooker {
/**
* The most occurrences a single weekly booking may reserve at once, so one
* student cannot lock up an instructor's entire recurring schedule.
*/
public const MAX_WEEKLY_OCCURRENCES = 12;
public function __construct(
private AvailabilityRepository $availability,
private BookingRepository $bookings,
private OfferingRepository $offerings,
private PaymentService $payments,
private GuardianService $guardians,
) {}
/**
* Resolve the offering a slot is to be booked as. A caller-supplied offering
* must never override the slot's price or payment routing: when the slot is
* tied to a specific offering that offering is authoritative, and any offering
* used must belong to the slot's instructor. This prevents substituting a
* cheaper/free offering to dodge payment, or another instructor's offering to
* misroute it.
*/
public function resolveOffering( AvailabilitySlot $slot, int $requestedOfferingId ): Offering|\WP_Error {
$requestedOfferingId = absint( $requestedOfferingId );
$slotOfferingId = (int) ( $slot->offeringId ?? 0 );
if ( $slotOfferingId > 0 ) {
if ( $requestedOfferingId > 0 && $requestedOfferingId !== $slotOfferingId ) {
return new \WP_Error( 'offering_mismatch', __( 'This slot is tied to a different offering.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
$offeringId = $slotOfferingId;
} else {
$offeringId = $requestedOfferingId;
}
// Every lesson books against an offering: it carries the price, intake
// questions, and payment routing. Without one the booking would silently
// be free and unquestioned, so generic slots require an explicit choice.
if ( $offeringId <= 0 ) {
return new \WP_Error( 'offering_required', __( 'Choose a lesson type to book this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
$offering = $this->offerings->findById( $offeringId );
if ( null === $offering ) {
return new \WP_Error( 'invalid_offering', __( 'Offering not found.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
if ( $offering->instructorId !== $slot->instructorId ) {
return new \WP_Error( 'offering_mismatch', __( 'That offering is not available for this slot.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
// A slot-tied offering was the instructor's explicit choice and is honoured
// as-is; a chosen one must be something the catalog actually offers for this
// slot: an active private-lesson type whose length fits the slot.
if ( 0 === $slotOfferingId ) {
if ( ! $offering->isActive || Offering::KIND_PRIVATE_LESSON !== $offering->kind ) {
return new \WP_Error( 'invalid_offering', __( 'That offering cannot be booked as a private lesson.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
if ( null !== $offering->durationMinutes && $offering->durationMinutes !== $slot->durationMinutes ) {
return new \WP_Error( 'offering_mismatch', __( 'That offering does not match this slot\'s lesson length.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
}
return $offering;
}
/**
* Claim the slot(s) and write the lesson row(s) — a single lesson, or one per
* remaining occurrence of the slot's weekly group. The rows are created
* `pending`; `settle()` decides what confirms them.
*
* `$bookedBy` is the staff member booking on the student's behalf, and 0 for a
* booking made through the student-facing flow. It is recorded on every lesson
* of a series, since a series is booked once.
*
* @return array{ids: list<int>, anchor_id: int}|\WP_Error
*/
public function reserve( AvailabilitySlot $slot, Offering $offering, int $studentId, string $recurrence, ?string $notes = null, int $bookedBy = 0 ): array|\WP_Error {
$slotId = (int) $slot->id;
$recurrence = Lesson::RECURRENCE_WEEKLY === $recurrence ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE;
$template = new Lesson(
slotId: $slotId,
studentId: $studentId,
instructorId: $slot->instructorId,
offeringId: (int) $offering->id,
recurrence: $recurrence,
notes: null !== $notes && '' !== $notes ? $notes : null,
bookedBy: $bookedBy,
);
// Weekly reservation across the slot's recurring group; otherwise a single lesson.
if ( Lesson::RECURRENCE_WEEKLY === $recurrence && null !== $slot->recurrenceGroup ) {
// Claim each occurrence atomically (capped so one booking cannot lock an
// instructor's entire schedule), then create a lesson only for the slots
// this request actually won — never for one already taken by someone else.
$candidates = array_map( static fn( $s ): int => (int) $s->id, $this->availability->findUnbookedInGroup( $slot->recurrenceGroup ) );
$candidates = array_slice( $candidates, 0, self::MAX_WEEKLY_OCCURRENCES );
$claimed = array_values( array_filter( $candidates, fn( int $candidateId ): bool => $this->availability->claim( $candidateId ) ) );
if ( [] === $claimed ) {
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
$ids = $this->bookings->insertSeries( $template, $claimed );
return [
'ids' => $ids,
'anchor_id' => $ids[0] ?? 0,
];
}
// Claim before inserting: if another request already took the slot, the
// guarded update reports no rows and we reject rather than double-book.
if ( ! $this->availability->claim( $slotId ) ) {
return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
$anchorId = $this->bookings->insert( $template );
return [
'ids' => [ $anchorId ],
'anchor_id' => $anchorId,
];
}
/**
* Raise the payment for a reservation and report the status its lessons end up
* in. A priced booking stays `pending` until its payment settles; anything with
* nothing to charge now — a free offering, scheduled billing, or a booking the
* caller marked `$noCharge` — is confirmed here and then.
*
* @param list<int> $ids
*
* @return array{status: string, payment: ?Payment}
*/
public function settle( array $ids, int $anchorId, AvailabilitySlot $slot, Offering $offering, int $studentId, bool $noCharge = false ): array {
// Scheduled billing (weekly / monthly) normally defers payment to the daily
// scan, but a single lesson booked once its scheduled due date has already
// passed — e.g. an extra lesson added to a month that was already billed — is
// charged at booking instead, so it is never missed or billed late.
$chargeAtBooking = ! $noCharge && $offering->price > 0.0 && (
! $offering->isScheduledBilling()
|| ( 1 === count( $ids ) && $this->scheduledDueHasPassed( $offering, $slot->startDt ) )
);
if ( $chargeAtBooking ) {
// A full-term price already covers the whole reservation; a per-lesson
// (one_time) price is owed once per occurrence actually claimed, so a
// weekly reservation cannot hold a term while paying for one week.
$amount = Offering::BILLING_FULL_TERM === $offering->billingMode
? $offering->price
: $offering->price * count( $ids );
$payment = $this->payments->createForRegistration(
Payment::REG_LESSON,
$anchorId,
$studentId,
$slot->instructorId,
$amount,
$offering->currency,
$offering->etransferEmail,
payerId: $this->guardians->payerFor( $studentId )
);
return [
'status' => null !== $payment && $payment->isPaid() ? Lesson::STATUS_CONFIRMED : Lesson::STATUS_PENDING,
'payment' => $payment,
];
}
// Either nothing is owed — a free offering, or a booking the studio comped —
// or scheduled billing (weekly / monthly) whose payment is deferred to the
// daily billing scan. Either way there is no payment step now to confirm the
// lessons, so the reserved slots are confirmed at booking time; the billing
// scan bills the scheduled ones when they come due.
foreach ( $ids as $lessonId ) {
$this->bookings->updateStatus( $lessonId, Lesson::STATUS_CONFIRMED );
}
return [
'status' => Lesson::STATUS_CONFIRMED,
'payment' => null,
];
}
/**
* Whether a scheduled-billing offering's due date for a lesson has already
* gone by — monthly bills on the first of the lesson's month, weekly the day
* before the lesson.
*/
private function scheduledDueHasPassed( Offering $offering, string $slotStart ): bool {
$now = new \DateTimeImmutable( Val::string( current_time( 'mysql' ) ) );
$start = new \DateTimeImmutable( $slotStart );
if ( Offering::BILLING_MONTHLY === $offering->billingMode ) {
return $now->format( 'Y-m-d' ) >= $start->format( 'Y-m-01' );
}
return $now >= $start->modify( '-1 day' );
}
}
+148 -14
View File
@@ -10,6 +10,9 @@ use Unsupervised\Schedular\Availability\WeekCalendar;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Registration\IntakeAudit;
use Unsupervised\Schedular\Registration\IntakeProvenance;
use Unsupervised\Schedular\Registration\IntakeRecording;
use Unsupervised\Schedular\Val;
class LessonController {
@@ -19,7 +22,9 @@ class LessonController {
private PaymentRepository $payments,
private AvailabilityRepository $availability,
private OfferingRepository $offerings,
private LessonDetail $detail,
private IntakeAudit $detail,
private AdminBooking $adminBooking,
private IntakeRecording $intake,
) {}
public function renderAdminDashboard(): void {
@@ -31,11 +36,11 @@ class LessonController {
return;
}
$this->handleEtransferUpdate( false );
[ $notice, $error ] = $this->handleFormAction( false, 0 );
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findAllUpcoming() );
$this->renderLessonsPage( $rows, 'us-scheduler' );
$this->renderLessonsPage( $rows, 'us-scheduler', 0, $notice, $error );
}
public function renderInstructorLessons(): void {
@@ -47,11 +52,13 @@ class LessonController {
return;
}
$this->handleEtransferUpdate( true );
$instructorId = get_current_user_id();
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( get_current_user_id() ) );
[ $notice, $error ] = $this->handleFormAction( true, $instructorId );
$this->renderLessonsPage( $rows, 'us-my-lessons' );
$rows = array_map( fn( Lesson $lesson ): array => $this->row( $lesson ), $this->repository->findUpcomingForInstructor( $instructorId ) );
$this->renderLessonsPage( $rows, 'us-my-lessons', $instructorId, $notice, $error );
}
/**
@@ -68,15 +75,23 @@ class LessonController {
$lesson = $this->repository->findById( $lessonId );
$backUrl = admin_url( 'admin.php?page=' . $pageSlug );
$notice = '';
$error = '';
if ( null === $lesson || ( $onlyOwn && get_current_user_id() !== $lesson->instructorId ) ) {
$row = null;
$answers = [];
$accepts = [];
$intake = $this->emptyIntake();
} else {
// Recorded before the tables are read, so what was just entered appears
// on the page that reports it.
[ $notice, $error ] = $this->recordIntake( $lesson );
$row = $this->row( $lesson );
$answers = $this->detail->answers( $lesson );
$accepts = $this->detail->acceptances( $lesson );
$intake = $this->intakeForm( $lesson );
}
include USC_PLUGIN_DIR . 'templates/admin/lesson-detail.php';
@@ -84,13 +99,86 @@ class LessonController {
return true;
}
/**
* Handle a submitted "record intake collected elsewhere" form.
*
* @return array{string, string} Success notice and error message.
*/
private function recordIntake( Lesson $lesson ): array {
if ( ! isset( $_POST['usc_action'] ) || ! check_admin_referer( 'usc_lesson_action' ) ) {
return [ '', '' ];
}
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above.
if ( 'record_intake' !== sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) ) ) {
return [ '', '' ];
}
$answers = [];
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each value is unslashed and sanitized below.
foreach ( (array) ( $_POST['answers'] ?? [] ) as $questionId => $value ) {
$answers[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each element is coerced to a positive int; slashes cannot survive integer coercion.
$rawVersionIds = (array) ( $_POST['accepted_policy_version_ids'] ?? [] );
$versionIds = array_values( array_filter( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), $rawVersionIds ) ) );
$result = $this->intake->record(
$lesson,
$answers,
$versionIds,
sanitize_key( Val::string( wp_unslash( $_POST['collected_via'] ?? '' ) ) ),
sanitize_text_field( Val::string( wp_unslash( $_POST['collected_note'] ?? '' ) ) ),
get_current_user_id()
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
return $result instanceof \WP_Error
? [ '', $result->get_error_message() ]
: [ $result, '' ];
}
/**
* What the detail template needs to offer the recording form: whether this
* lesson qualifies at all, what is still missing, and the collection methods
* to choose between.
*
* @return array{recordable: bool, questions: list<array{id: int, label: string, required: bool}>, policies: list<array{version_id: int, policy: string, version: string}>, methods: array<string, string>}
*/
private function intakeForm( Lesson $lesson ): array {
if ( ! $lesson->isStaffRegistered() ) {
return $this->emptyIntake();
}
return [ 'recordable' => true ] + $this->intake->pending( $lesson ) + [ 'methods' => IntakeProvenance::choices() ];
}
/**
* The form data for a lesson that cannot be recorded against — one the student
* booked, or one that could not be opened at all.
*
* @return array{recordable: bool, questions: list<array{id: int, label: string, required: bool}>, policies: list<array{version_id: int, policy: string, version: string}>, methods: array<string, string>}
*/
private function emptyIntake(): array {
return [
'recordable' => false,
'questions' => [],
'policies' => [],
'methods' => [],
];
}
/**
* Render the lessons template with its calendar view state: week (default)
* or list, plus which week the week view shows.
* or list, plus which week the week view shows, and the choices the
* book-for-a-student form offers — scoped to one instructor's own schedule on
* **My Lessons**, studio-wide (0) on the **Scheduler**.
*
* @param list<array<string, mixed>> $rows
*/
private function renderLessonsPage( array $rows, string $pageSlug ): void {
// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter -- $notice and $error are read by the included template.
private function renderLessonsPage( array $rows, string $pageSlug, int $onlyInstructorId, string $notice, string $error ): void {
// View-state query params only (which view, which week) — nothing is
// mutated from them, so no nonce applies.
// phpcs:disable WordPress.Security.NonceVerification.Recommended
@@ -103,21 +191,67 @@ class LessonController {
$prevWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '-7 days' )->format( 'Y-m-d' );
$nextWeek = ( new \DateTimeImmutable( $weekStart ) )->modify( '+7 days' )->format( 'Y-m-d' );
$baseUrl = admin_url( 'admin.php?page=' . $pageSlug );
$bookForm = $this->adminBooking->formData( $onlyInstructorId );
include USC_PLUGIN_DIR . 'templates/admin/lessons.php';
}
/**
* Handle a per-lesson payment override (e-transfer email or HST rate). When
* $onlyOwn, the payment must belong to the current instructor.
* Run the submitted action and report what happened: a per-lesson payment
* override (e-transfer email or HST rate), or a lesson booked for a student.
* When $onlyOwn, the payment or slot must belong to the current instructor.
*
* @return array{string, string} Success notice and error message; each is
* empty when it does not apply.
*/
private function handleEtransferUpdate( bool $onlyOwn ): void {
private function handleFormAction( bool $onlyOwn, int $instructorId ): array {
if ( ! isset( $_POST['usc_action'] ) || ! check_admin_referer( 'usc_lesson_action' ) ) {
return;
return [ '', '' ];
}
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked above.
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) );
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked above.
$action = sanitize_key( Val::string( wp_unslash( $_POST['usc_action'] ) ) );
if ( 'book_for_student' === $action ) {
return $this->bookForStudent( $onlyOwn ? $instructorId : 0 );
}
$this->updatePayment( $action, $onlyOwn );
return [ '', '' ];
}
/**
* Book a lesson on a student's behalf from the submitted form. The slot is
* scoped to the instructor's own schedule on **My Lessons** ($onlyInstructorId
* non-zero) and studio-wide on the **Scheduler**.
*
* @return array{string, string}
*/
private function bookForStudent( int $onlyInstructorId ): array {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
$result = $this->adminBooking->book(
absint( Val::int( $_POST['student_id'] ?? 0 ) ),
absint( Val::int( $_POST['slot_id'] ?? 0 ) ),
absint( Val::int( $_POST['offering_id'] ?? 0 ) ),
isset( $_POST['recurrence_weekly'] ) ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE,
isset( $_POST['no_charge'] ),
sanitize_text_field( Val::string( wp_unslash( $_POST['notes'] ?? '' ) ) ),
$onlyInstructorId
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
return $result instanceof \WP_Error
? [ '', $result->get_error_message() ]
: [ $result, '' ];
}
/**
* Apply a per-lesson payment override. When $onlyOwn, the payment must belong
* to the current instructor.
*/
private function updatePayment( string $action, bool $onlyOwn ): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
$paymentId = absint( Val::int( $_POST['payment_id'] ?? 0 ) );
$email = sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) );
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Val::float() coerces to float; slashes cannot survive numeric coercion.
-87
View File
@@ -1,87 +0,0 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Booking;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
use Unsupervised\Schedular\Registration\Answer;
use Unsupervised\Schedular\Registration\AnswerRepository;
use Unsupervised\Schedular\Registration\QuestionRepository;
/**
* Builds the display rows for the admin lesson detail view: the intake answers
* the student submitted and the policy versions they accepted when booking.
*
* Scoped to a single lesson (the `lesson` registration type), mirroring the
* per-student history in {@see \Unsupervised\Schedular\Auth\StudentHistory}.
*
* A weekly reservation is answered for and agreed to once, so its answers and
* acceptances hang off the series anchor. Every occurrence therefore reads its
* series' registration, not its own id — otherwise only the first lesson of a
* series showed the intake and the audit trail, and the rest looked as though
* nothing had been accepted.
*/
class LessonDetail {
public function __construct(
private AnswerRepository $answers,
private QuestionRepository $questions,
private AcceptanceRepository $acceptances,
private PolicyRepository $policies,
private PolicyVersionRepository $versions,
) {}
/**
* The intake-question answers recorded for this lesson, in submission order.
*
* @return list<array{question: string, answer: string}>
*/
public function answers( Lesson $lesson ): array {
return array_map(
function ( Answer $answer ): array {
$question = $this->questions->findById( $answer->questionId );
$value = $answer->answerValue ?? '';
return [
'question' => $question ? $question->label : sprintf( '#%d', $answer->questionId ),
'answer' => '' === $value ? '—' : $value,
];
},
$this->answers->findByRegistration( Answer::REG_LESSON, $this->registrationId( $lesson ) )
);
}
/**
* The policy versions the student accepted when booking this lesson, with the
* captured acceptance time and IP for the audit trail.
*
* @return list<array{policy: string, version: string, accepted_at: string, ip: string}>
*/
public function acceptances( Lesson $lesson ): array {
return array_map(
function ( PolicyAcceptance $acceptance ): array {
$version = $this->versions->findById( $acceptance->policyVersionId );
$policy = $version ? $this->policies->findById( $version->policyId ) : null;
return [
'policy' => $policy ? $policy->title : sprintf( '#%d', $acceptance->policyVersionId ),
'version' => $version ? sprintf( 'v%d', $version->versionNumber ) : '—',
'accepted_at' => $acceptance->acceptedAt ?? '',
'ip' => $acceptance->ipAddress ?? '',
];
},
$this->acceptances->findByRegistration( PolicyAcceptance::REG_LESSON, $this->registrationId( $lesson ) )
);
}
/**
* The lesson id the booking's answers and acceptances were recorded against:
* the series anchor for a weekly reservation, the lesson itself otherwise.
*/
private function registrationId( Lesson $lesson ): int {
return $lesson->seriesId ?? (int) $lesson->id;
}
}