\WP_REST_Server::READABLE, 'callback' => [ $this, 'myLessons' ], 'permission_callback' => [ $this, 'isLoggedIn' ], ], [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'book' ], 'permission_callback' => [ $this, 'canBook' ], 'args' => [ 'slot_id' => [ 'type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint', ], 'offering_id' => [ 'type' => 'integer', 'default' => 0, ], 'recurrence' => [ 'type' => 'string', 'default' => 'single', ], 'answers' => [ 'type' => 'object', 'default' => [], ], 'accepted_policy_version_ids' => [ 'type' => 'array', 'default' => [], ], 'notes' => [ 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_textarea_field', ], ], ], ] ); register_rest_route( $route_namespace, '/bookings/(?P\d+)/cancel', [ [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'cancel' ], 'permission_callback' => [ $this, 'isLoggedIn' ], ], ] ); register_rest_route( $route_namespace, '/bookings/(?P\d+)/status', [ [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [ $this, 'updateStatus' ], 'permission_callback' => [ $this, 'canManage' ], 'args' => [ 'status' => [ 'type' => 'string', 'required' => true, 'enum' => Lesson::VALID_STATUSES, ], ], ], ] ); } public function myLessons( \WP_REST_Request $request ): \WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found $userId = get_current_user_id(); $lessons = current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) ? $this->bookings->findUpcomingForInstructor( $userId ) : $this->bookings->findUpcomingForStudent( $userId ); return new \WP_REST_Response( array_map( fn( Lesson $l ): array => $this->lessonWithTimes( $l ), $lessons ), 200 ); } /** * A lesson's array form plus its slot's start/end times and the booked * offering's name, so front-end lists can show what the session is and when * it happens without a second request. * * @return array */ private function lessonWithTimes( Lesson $lesson ): array { $slot = $this->availability->findById( $lesson->slotId ); $offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null; // Prefer the offering's own length; fall back to the slot's when the // offering has none (a generic, duration-less type). $duration = null !== $offering && null !== $offering->durationMinutes ? $offering->durationMinutes : $slot?->durationMinutes; return $lesson->toArray() + [ 'start_dt' => $slot?->startDt, 'end_dt' => $slot?->endDt, 'offering_title' => $offering?->title, 'duration_minutes' => $duration, ]; } public function book( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $slotId = Val::int( $request->get_param( 'slot_id' ) ); $slot = $this->availability->findById( $slotId ); if ( null === $slot ) { return new \WP_Error( 'not_found', __( 'Slot not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] ); } if ( $slot->isBooked ) { 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; } // 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 ] ); } } $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' ) ) ); $gateError = $this->gate->validate( $offeringId, $answers, $acceptedVersionIds ); if ( $gateError instanceof \WP_Error ) { return $gateError; } $studentId = get_current_user_id(); $notes = Val::string( $request->get_param( 'notes' ) ); $recurrence = Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' ) ? Lesson::RECURRENCE_WEEKLY : Lesson::RECURRENCE_SINGLE; $template = new Lesson( slotId: $slotId, studentId: $studentId, instructorId: $slot->instructorId, offeringId: $offeringId, recurrence: $recurrence, notes: '' !== $notes ? $notes : null, ); // 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 ]; } $this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() ); $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 ); 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; } // `payment: null` tells the front end to skip the payment step entirely. return new \WP_REST_Response( [ 'ids' => $ids, 'status' => $status, 'payment' => $payment?->toSummaryArray(), ], 201 ); } /** * Extract a question_id => value map from the request. * * @return array */ private function answers( \WP_REST_Request $request ): array { $out = []; foreach ( (array) $request->get_param( 'answers' ) as $questionId => $value ) { $out[ (int) $questionId ] = sanitize_text_field( Val::string( $value ) ); } 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'] ?? '' ) ) ); return '' !== $ip ? $ip : null; } /** * Student-initiated cancellation of their own lesson: marks it cancelled, * frees the slot for rebooking, and voids any still-pending payment. A lesson * already paid for is credited back to the student's account (a per-lesson * share of the covering payment) to offset their future scheduled billing. */ public function cancel( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $id = absint( Val::int( $request->get_param( 'id' ) ) ); $lesson = $this->bookings->findById( $id ); if ( null === $lesson ) { return new \WP_Error( 'not_found', __( 'Booking not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] ); } if ( get_current_user_id() !== $lesson->studentId ) { return new \WP_Error( 'forbidden', __( 'You cannot cancel this booking.', 'unsupervised-schedular' ), [ 'status' => 403 ] ); } if ( Lesson::STATUS_CANCELLED !== $lesson->status ) { $slot = $this->availability->findById( $lesson->slotId ); if ( null !== $slot ) { $offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null; $overrideHours = $offering?->cancellationCutoffHours; if ( ! $this->cancellationPolicy->studentMayCancel( $slot->startDt, $overrideHours ) ) { return new \WP_Error( 'cancellation_closed', sprintf( /* translators: %s: humanised cutoff window, e.g. "2 days" or "12 hours". */ __( 'This lesson can no longer be cancelled online — cancellations close %s before the lesson starts. Please contact the studio.', 'unsupervised-schedular' ), $this->cancellationPolicy->describeCutoff( $this->cancellationPolicy->cutoffHours( $overrideHours ) ) ), [ 'status' => 403 ] ); } } $this->bookings->updateStatus( $id, Lesson::STATUS_CANCELLED ); $this->availability->release( $lesson->slotId ); $this->payments->voidPending( $lesson->paymentId ); $this->payments->creditForCancelledLesson( $lesson ); } return new \WP_REST_Response( [ 'id' => $id, 'status' => Lesson::STATUS_CANCELLED, ], 200 ); } public function updateStatus( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $id = absint( Val::int( $request->get_param( 'id' ) ) ); $lesson = $this->bookings->findById( $id ); if ( null === $lesson ) { return new \WP_Error( 'not_found', __( 'Booking not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] ); } if ( get_current_user_id() !== $lesson->instructorId && ! current_user_can( 'manage_options' ) ) { return new \WP_Error( 'forbidden', __( 'You cannot update this booking.', 'unsupervised-schedular' ), [ 'status' => 403 ] ); } $status = Val::string( $request->get_param( 'status' ) ); if ( Lesson::STATUS_CANCELLED === $status && Lesson::STATUS_CANCELLED !== $lesson->status ) { $this->availability->release( $lesson->slotId ); $this->payments->voidPending( $lesson->paymentId ); $this->payments->creditForCancelledLesson( $lesson ); } elseif ( Lesson::STATUS_CANCELLED === $lesson->status && Lesson::STATUS_CANCELLED !== $status && ! $this->availability->claim( $lesson->slotId ) ) { // Reinstating a cancelled lesson must re-reserve its slot, and // someone else may have booked the freed time in the meantime. return new \WP_Error( 'slot_taken', __( 'This slot is already booked.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); } $this->bookings->updateStatus( $id, $status ); return new \WP_REST_Response( [ 'id' => $id, 'status' => $status, ], 200 ); } public function isLoggedIn(): bool { return is_user_logged_in(); } public function canBook(): bool { return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON ); } public function canManage(): bool { return is_user_logged_in() && ( current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) || current_user_can( 'manage_options' ) ); } }