Files
unsupervised-scheduler/src/Booking/LessonBooker.php
T
thatguygriffandClaude Opus 5 8c21a3fa9d
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
Let the studio book lessons and record intake collected elsewhere
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
2026-08-24 14:06:16 -03:00

235 lines
9.9 KiB
PHP

<?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' );
}
}