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