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