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
235 lines
9.9 KiB
PHP
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' );
|
|
}
|
|
}
|