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
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:
@@ -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
@@ -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'] ?? '' ) ) );
|
||||
|
||||
@@ -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
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' );
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user