From 5888032ed71a028fd14536a156cf494432f2992e Mon Sep 17 00:00:00 2001 From: James Griffin Date: Sun, 5 Jul 2026 17:02:38 -0300 Subject: [PATCH] Skip payment step for unpriced bookings, confirm them immediately, show students their lessons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Booking a slot with no priced offering created the lesson but no payment, yet the front end still called POST /payments/intent, which 400ed with "Could not start payment for this registration" — the student saw an error while the backend held a claimed slot and a lesson stuck at pending. - POST /bookings and POST /enrollments now return a `payment` summary ({id, method, status}) or null when nothing is owed; the JS only runs the payment step when a payment exists. - Bookings with nothing owed are confirmed at creation — there is no payment step that would ever confirm them later. - The booking page now shows the student's upcoming lessons (GET /bookings, now scoped to upcoming non-cancelled lessons with slot start/end times) with a pending-payment/confirmed status badge. Fixes #53 Co-Authored-By: Claude Fable 5 --- assets/css/frontend.css | 32 +++++ assets/js/booking.js | 52 ++++++++- assets/js/group-classes.js | 6 +- docs/features/group-classes.md | 5 +- docs/features/lesson-booking.md | 22 ++-- docs/features/payments.md | 2 +- src/Booking/BookingEndpoint.php | 41 ++++++- src/Booking/BookingRepository.php | 27 +++++ src/GroupClass/EnrollmentEndpoint.php | 9 +- src/Payment/Payment.php | 14 +++ templates/frontend/booking-page.php | 1 + tests/Unit/Booking/BookingEndpointTest.php | 110 +++++++++++++++++- tests/Unit/Booking/BookingRepositoryTest.php | 29 +++++ .../GroupClass/EnrollmentEndpointTest.php | 101 ++++++++++++++++ tests/Unit/Payment/PaymentTest.php | 7 ++ 15 files changed, 433 insertions(+), 25 deletions(-) create mode 100644 tests/Unit/GroupClass/EnrollmentEndpointTest.php diff --git a/assets/css/frontend.css b/assets/css/frontend.css index 7a540e4..bbf4340 100644 --- a/assets/css/frontend.css +++ b/assets/css/frontend.css @@ -34,6 +34,38 @@ margin-top: 8px; } +.us-my-lessons { + margin-bottom: 24px; +} + +.us-my-lesson { + border: 1px solid #ddd; + border-radius: 4px; + padding: 12px 16px; + margin-bottom: 8px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.us-lesson-status { + font-size: 0.85em; + font-weight: 600; + padding: 2px 10px; + border-radius: 10px; + background: #eee; +} + +.us-lesson-status-confirmed { + background: #e2f5e5; + color: #1a7d2e; +} + +.us-lesson-status-pending { + background: #fdf3d7; + color: #8a6d1a; +} + .us-view-toggle { display: flex; gap: 8px; diff --git a/assets/js/booking.js b/assets/js/booking.js index 2c3acac..a93b2dd 100644 --- a/assets/js/booking.js +++ b/assets/js/booking.js @@ -5,9 +5,10 @@ const app = document.getElementById('us-booking-app'); if (!app) return; - const slotList = document.getElementById('us-slot-list'); - const confirm = document.getElementById('us-booking-confirmation'); - const errorBox = document.getElementById('us-booking-error'); + const slotList = document.getElementById('us-slot-list'); + const myLessons = document.getElementById('us-my-lessons'); + const confirm = document.getElementById('us-booking-confirmation'); + const errorBox = document.getElementById('us-booking-error'); const { restUrl, nonce } = usScheduler; function apiFetch(path, options = {}) { @@ -274,11 +275,51 @@ accepted_policy_version_ids: accepted, }), }) - .then((res) => window.usPayment.collect('lesson', (res.ids || [])[0], slotList)) - .then((result) => showConfirmation(window.usPayment.message(result))) + // A booking with nothing owed has no payment, so there is no payment + // step to run — the booking is already confirmed server-side. + .then((res) => (res.payment + ? window.usPayment.collect('lesson', (res.ids || [])[0], slotList) + : null)) + .then((result) => { + loadMyLessons(); + showConfirmation(window.usPayment.message(result)); + }) .catch((err) => showError(err.message)); } + function lessonStatusLabel(status) { + if (status === 'pending') return 'Pending payment'; + if (status === 'confirmed') return 'Confirmed'; + return status.charAt(0).toUpperCase() + status.slice(1); + } + + function renderMyLessons(lessons) { + const upcoming = lessons.filter((l) => l.start_dt); + if (!upcoming.length) { + myLessons.innerHTML = ''; + return; + } + + myLessons.innerHTML = ` +
+

Your upcoming lessons

+ ${upcoming.map((l) => ` +
+ ${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}–${escHtml(timeOf(l.end_dt))} + ${escHtml(lessonStatusLabel(String(l.status)))} +
+ `).join('')} +
`; + } + + function loadMyLessons() { + if (!myLessons) return; + // The lesson list is a bonus panel: never let it break slot browsing. + apiFetch('bookings') + .then(renderMyLessons) + .catch(() => { myLessons.innerHTML = ''; }); + } + function showConfirmation(message) { confirm.textContent = message; slotList.style.display = 'none'; @@ -289,6 +330,7 @@ clearError(); slotList.style.display = 'block'; confirm.style.display = 'none'; + loadMyLessons(); apiFetch('availability') .then((slots) => { allSlots = slots; diff --git a/assets/js/group-classes.js b/assets/js/group-classes.js index 8618498..375ddda 100644 --- a/assets/js/group-classes.js +++ b/assets/js/group-classes.js @@ -142,7 +142,11 @@ accepted_policy_version_ids: accepted, }), }) - .then((res) => window.usPayment.collect('enrollment', res.id, list)) + // An enrolment with nothing owed has no payment, so there is no + // payment step to run. + .then((res) => (res.payment + ? window.usPayment.collect('enrollment', res.id, list) + : null)) .then((result) => showConfirmation(window.usPayment.message(result))) .catch((err) => showError(err.message)); } diff --git a/docs/features/group-classes.md b/docs/features/group-classes.md index 825b3bc..fc60c49 100644 --- a/docs/features/group-classes.md +++ b/docs/features/group-classes.md @@ -33,7 +33,10 @@ a class at capacity rejects further enrolments. | `POST` | `/wp-json/us-scheduler/v1/enrollments` | `book_lesson` | `POST /enrollments` body: `offering_id`, `answers[]` (`question_id` → value), -`accepted_policy_version_ids[]`, and payment data (see `payments.md`). +`accepted_policy_version_ids[]`, and payment data (see `payments.md`). The +response includes `id`, `status`, and `payment` — a `{id, method, status}` +summary, or `null` when the class is free (the front end then skips the +payment step). `GET /enrollments` returns the caller's own enrolments, or all enrolments for the instructor's group classes if the caller has `view_own_lessons` on those offerings. diff --git a/docs/features/lesson-booking.md b/docs/features/lesson-booking.md index 14763e3..3947f28 100644 --- a/docs/features/lesson-booking.md +++ b/docs/features/lesson-booking.md @@ -26,9 +26,10 @@ Students register for a private lesson by choosing an offering, picking a time ( 4. Student answers the offering's questions (`GET /offerings/{id}/questions`). 5. Student accepts the current published policy versions (`GET /policies`) — required to continue. 6. Payment is taken per the student's billing method (card by default; `pending` for e-transfer; skipped for comp). See `payments.md`. -7. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment. +7. `POST /bookings` creates the lesson row(s) (`status = pending`), records answers and policy acceptances, marks `us_availability.is_booked = 1`, and links the payment. A booking with nothing owed (no offering, or a free offering) creates no payment and is `confirmed` immediately. 8. On successful payment (or comp) the lesson is `confirmed` and a receipt is emailed. 9. Instructor sees the booking under **My Lessons** and may update status via `PATCH /bookings/{id}/status`. +10. The booking page also shows the student their upcoming lessons (`GET /bookings`) with a per-lesson status badge (pending payment / confirmed). ## Weekly Reservations A weekly reservation creates one `series_id` shared across N lesson rows (one per @@ -45,9 +46,13 @@ offering). `POST /bookings` body: `offering_id`, `slot_id`, `recurrence`, `answers[]` (`question_id` → value), `accepted_policy_version_ids[]`, and payment data -(see `payments.md`). +(see `payments.md`). The response includes `ids`, the resulting lesson +`status`, and `payment` — a `{id, method, status}` summary, or `null` when +nothing is owed (the front end then skips the payment step). -`GET /bookings` returns the caller's own lessons (student view) or upcoming lessons for the instructor if the caller has `manage_availability`. +`GET /bookings` returns the caller's upcoming, non-cancelled lessons (their own +for students; the instructor's for callers with `manage_availability`), each +with the slot's `start_dt`/`end_dt`. Group classes follow the same registration flow but enrol against an offering of kind `group_class`; see `group-classes.md`. @@ -68,11 +73,12 @@ kind `group_class`; see `group-classes.md`. - REST endpoint: `Unsupervised\Schedular\Booking\BookingEndpoint` - Frontend: `Unsupervised\Schedular\Booking\BookingPage`, `Unsupervised\Schedular\Auth\LoginPage` -> **Payment seam:** payment is deferred to the Payments feature (#7). For now a -> booking is created with `status = pending` and `payment_id = null`; the -> instructor confirms via `PATCH /bookings/{id}/status`. When payments land, the -> pay→confirm + receipt step plugs into this seam. `GET /policies?scope=booking` -> returns just the booking-gate policies the form must collect. +> **Payment seam:** a priced booking is created with `status = pending` and its +> payment linked via `payment_id`; the lesson is confirmed when the payment is +> settled (see `payments.md`) or manually via `PATCH /bookings/{id}/status`. +> Unpriced bookings skip the seam entirely and are confirmed at creation. +> `GET /policies?scope=booking` returns just the booking-gate policies the form +> must collect. ## Tests - `tests/Unit/Booking/BookingRepositoryTest.php` diff --git a/docs/features/payments.md b/docs/features/payments.md index 7f069a7..90aca91 100644 --- a/docs/features/payments.md +++ b/docs/features/payments.md @@ -94,7 +94,7 @@ After booking, the destination on a payment can be corrected per booking: | `paid_at` | DATETIME | When marked `paid`; NULL otherwise | ## Payment Flow -1. During registration the front-end calls `POST /payments/intent`, which creates a Stripe PaymentIntent for a `card` student and returns the client secret. (`etransfer` returns a `pending` payment; `comp` returns none.) +1. During registration the front-end calls `POST /payments/intent` — but only when the registration response carried a `payment` summary (unpriced registrations return `payment: null` and skip the payment step). The intent call creates a Stripe PaymentIntent for a `card` student and returns the client secret. (`etransfer` returns a `pending` payment; `comp` returns none.) 2. The browser confirms the card payment with Stripe. 3. Stripe calls `POST /payments/webhook`; on `payment_intent.succeeded` the payment is marked `paid`, `paid_at` is stamped, and the linked lesson/enrolment is `confirmed`. 4. On transition to `paid`, `ReceiptMailer` assigns a `receipt_number`, emails the student a receipt, and stamps `receipt_sent_at`. diff --git a/src/Booking/BookingEndpoint.php b/src/Booking/BookingEndpoint.php index c17d19d..0f392ee 100644 --- a/src/Booking/BookingEndpoint.php +++ b/src/Booking/BookingEndpoint.php @@ -103,9 +103,24 @@ class BookingEndpoint { $userId = get_current_user_id(); $lessons = current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) ? $this->bookings->findUpcomingForInstructor( $userId ) - : $this->bookings->findByStudent( $userId ); + : $this->bookings->findUpcomingForStudent( $userId ); - return new \WP_REST_Response( array_map( fn( Lesson $l ) => $l->toArray(), $lessons ), 200 ); + 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, so front-end lists + * can show when the session happens without a second request. + * + * @return array + */ + private function lessonWithTimes( Lesson $lesson ): array { + $slot = $this->availability->findById( $lesson->slotId ); + + return $lesson->toArray() + [ + 'start_dt' => $slot?->startDt, + 'end_dt' => $slot?->endDt, + ]; } public function book( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { @@ -197,14 +212,30 @@ class BookingEndpoint { $this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() ); + $payment = null; + $status = Lesson::STATUS_PENDING; + if ( null !== $offering && $offering->price > 0.0 ) { - $this->payments->createForRegistration( Payment::REG_LESSON, $anchorId, $studentId, $slot->instructorId, $offering->price, $offering->currency, $offering->etransferEmail ); + $payment = $this->payments->createForRegistration( Payment::REG_LESSON, $anchorId, $studentId, $slot->instructorId, $offering->price, $offering->currency, $offering->etransferEmail ); + + if ( null !== $payment && $payment->isPaid() ) { + $status = Lesson::STATUS_CONFIRMED; + } + } else { + // Nothing owed: there is no payment step that would confirm these + // lessons later, so they are confirmed at booking time. + 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' => Lesson::STATUS_PENDING, + 'ids' => $ids, + 'status' => $status, + 'payment' => $payment?->toSummaryArray(), ], 201 ); diff --git a/src/Booking/BookingRepository.php b/src/Booking/BookingRepository.php index 9f76486..d5b9ea7 100644 --- a/src/Booking/BookingRepository.php +++ b/src/Booking/BookingRepository.php @@ -113,6 +113,33 @@ class BookingRepository { return array_map( Lesson::fromRow( ... ), $rows ?? [] ); } + /** + * Upcoming lessons for a student (status != cancelled, slot in the future). + * + * @return list + */ + public function findUpcomingForStudent( int $studentId ): array { + $avTable = str_replace( 'us_lessons', 'us_availability', $this->table ); + + $rows = $this->db->get_results( + $this->db->prepare( + 'SELECT l.* FROM %i l + JOIN %i a ON a.id = l.slot_id + WHERE l.student_id = %d + AND l.status != %s + AND a.start_dt >= %s + ORDER BY a.start_dt ASC', + $this->table, + $avTable, + $studentId, + Lesson::STATUS_CANCELLED, + current_time( 'mysql' ) + ) + ); + + return array_map( Lesson::fromRow( ... ), $rows ?? [] ); + } + /** * Count a student's upcoming, non-cancelled lessons (slot in the future). */ diff --git a/src/GroupClass/EnrollmentEndpoint.php b/src/GroupClass/EnrollmentEndpoint.php index a4677a9..7fcb86d 100644 --- a/src/GroupClass/EnrollmentEndpoint.php +++ b/src/GroupClass/EnrollmentEndpoint.php @@ -110,14 +110,17 @@ class EnrollmentEndpoint { $this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() ); + $payment = null; if ( $offering->price > 0.0 ) { - $this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail ); + $payment = $this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail ); } + // `payment: null` tells the front end to skip the payment step entirely. return new \WP_REST_Response( [ - 'id' => $id, - 'status' => Enrollment::STATUS_ACTIVE, + 'id' => $id, + 'status' => Enrollment::STATUS_ACTIVE, + 'payment' => $payment?->toSummaryArray(), ], 201 ); diff --git a/src/Payment/Payment.php b/src/Payment/Payment.php index b5fe8fb..7384b89 100644 --- a/src/Payment/Payment.php +++ b/src/Payment/Payment.php @@ -84,6 +84,20 @@ class Payment { return round( $this->amount + $this->taxAmount, 2 ); } + /** + * Minimal payment info embedded in registration-creation responses: enough + * for the front end to decide whether (and how) to run the payment step. + * + * @return array + */ + public function toSummaryArray(): array { + return [ + 'id' => $this->id, + 'method' => $this->method, + 'status' => $this->status, + ]; + } + /** * Returns a plain array representation of the payment. * diff --git a/templates/frontend/booking-page.php b/templates/frontend/booking-page.php index 1b2fc70..0733687 100644 --- a/templates/frontend/booking-page.php +++ b/templates/frontend/booking-page.php @@ -6,6 +6,7 @@ if (! defined('ABSPATH')) { } ?>
+

diff --git a/tests/Unit/Booking/BookingEndpointTest.php b/tests/Unit/Booking/BookingEndpointTest.php index f794dc5..9169599 100644 --- a/tests/Unit/Booking/BookingEndpointTest.php +++ b/tests/Unit/Booking/BookingEndpointTest.php @@ -9,8 +9,10 @@ use Unsupervised\Schedular\Availability\AvailabilityRepository; use Unsupervised\Schedular\Availability\AvailabilitySlot; use Unsupervised\Schedular\Booking\BookingEndpoint; use Unsupervised\Schedular\Booking\BookingRepository; +use Unsupervised\Schedular\Booking\Lesson; use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\OfferingRepository; +use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\PaymentService; use Unsupervised\Schedular\Registration\RegistrationGate; use Unsupervised\Schedular\Tests\Unit\TestCase; @@ -119,8 +121,9 @@ class BookingEndpointTest extends TestCase $this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true); $this->bookings->shouldReceive('insert')->once()->andReturn(77); $this->gate->shouldReceive('record')->once(); - // Free offering → no payment. + // Free offering → no payment, so the lesson is confirmed immediately. $this->payments->shouldNotReceive('createForRegistration'); + $this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CONFIRMED)->once()->andReturn(true); $request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]); $result = $this->endpoint->book($request); @@ -128,5 +131,110 @@ class BookingEndpointTest extends TestCase self::assertInstanceOf(\WP_REST_Response::class, $result); self::assertSame(201, $result->get_status()); self::assertSame([77], $result->get_data()['ids']); + self::assertSame(Lesson::STATUS_CONFIRMED, $result->get_data()['status']); + self::assertNull($result->get_data()['payment']); + } + + public function testBookWithoutOfferingConfirmsImmediatelyWithNoPayment(): void + { + $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null)); + $this->gate->shouldReceive('validate')->andReturn(null); + $this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true); + $this->bookings->shouldReceive('insert')->once()->andReturn(77); + $this->gate->shouldReceive('record')->once(); + $this->payments->shouldNotReceive('createForRegistration'); + $this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CONFIRMED)->once()->andReturn(true); + + $request = new \WP_REST_Request(['slot_id' => 10]); + $result = $this->endpoint->book($request); + + self::assertInstanceOf(\WP_REST_Response::class, $result); + self::assertSame(Lesson::STATUS_CONFIRMED, $result->get_data()['status']); + self::assertNull($result->get_data()['payment']); + } + + public function testBookWithPricedOfferingStaysPendingAndReturnsPaymentSummary(): void + { + $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null)); + $this->offerings->shouldReceive('findById')->with(8)->andReturn( + new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 50.0, id: 8) + ); + $this->gate->shouldReceive('validate')->andReturn(null); + $this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true); + $this->bookings->shouldReceive('insert')->once()->andReturn(77); + $this->gate->shouldReceive('record')->once(); + $this->payments->shouldReceive('createForRegistration') + ->once() + ->with(Payment::REG_LESSON, 77, 5, 3, 50.0, 'CAD', null) + ->andReturn(new Payment( + studentId: 5, + instructorId: 3, + registrationType: Payment::REG_LESSON, + registrationId: 77, + amount: 50.0, + method: Payment::METHOD_ETRANSFER, + status: Payment::STATUS_PENDING, + id: 12, + )); + // Awaiting payment: the lesson must not be confirmed yet. + $this->bookings->shouldNotReceive('updateStatus'); + + $request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]); + $result = $this->endpoint->book($request); + + self::assertInstanceOf(\WP_REST_Response::class, $result); + self::assertSame(Lesson::STATUS_PENDING, $result->get_data()['status']); + self::assertSame( + ['id' => 12, 'method' => Payment::METHOD_ETRANSFER, 'status' => Payment::STATUS_PENDING], + $result->get_data()['payment'] + ); + } + + public function testBookWithCompedPaymentReturnsConfirmedStatus(): void + { + $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null)); + $this->offerings->shouldReceive('findById')->with(8)->andReturn( + new Offering(instructorId: 3, kind: Offering::KIND_PRIVATE_LESSON, title: 'Lesson', price: 50.0, id: 8) + ); + $this->gate->shouldReceive('validate')->andReturn(null); + $this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true); + $this->bookings->shouldReceive('insert')->once()->andReturn(77); + $this->gate->shouldReceive('record')->once(); + // Comped students are paid on creation (PaymentService confirms the lesson itself). + $this->payments->shouldReceive('createForRegistration')->once()->andReturn(new Payment( + studentId: 5, + instructorId: 3, + registrationType: Payment::REG_LESSON, + registrationId: 77, + amount: 50.0, + method: Payment::METHOD_COMP, + status: Payment::STATUS_PAID, + id: 12, + )); + $this->bookings->shouldNotReceive('updateStatus'); + + $request = new \WP_REST_Request(['slot_id' => 10, 'offering_id' => 8]); + $result = $this->endpoint->book($request); + + self::assertInstanceOf(\WP_REST_Response::class, $result); + self::assertSame(Lesson::STATUS_CONFIRMED, $result->get_data()['status']); + self::assertSame(Payment::METHOD_COMP, $result->get_data()['payment']['method']); + } + + public function testMyLessonsForStudentIncludesSlotTimes(): void + { + Functions\when('current_user_can')->justReturn(false); + + $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_PENDING, id: 77); + $this->bookings->shouldReceive('findUpcomingForStudent')->with(5)->once()->andReturn([$lesson]); + $this->availability->shouldReceive('findById')->with(10)->andReturn($this->slot(10, 3, null)); + + $result = $this->endpoint->myLessons(new \WP_REST_Request([])); + + $data = $result->get_data(); + self::assertCount(1, $data); + self::assertSame(77, $data[0]['id']); + self::assertSame('2026-07-01 10:00:00', $data[0]['start_dt']); + self::assertSame('2026-07-01 11:00:00', $data[0]['end_dt']); } } diff --git a/tests/Unit/Booking/BookingRepositoryTest.php b/tests/Unit/Booking/BookingRepositoryTest.php index 2b0da4b..98242cf 100644 --- a/tests/Unit/Booking/BookingRepositoryTest.php +++ b/tests/Unit/Booking/BookingRepositoryTest.php @@ -132,6 +132,35 @@ class BookingRepositoryTest extends TestCase self::assertFalse($this->repo->updateStatus(1, Lesson::STATUS_CONFIRMED)); } + public function testFindUpcomingForStudentJoinsSlotAndExcludesCancelled(): void + { + Functions\when('current_time')->justReturn('2026-06-08 12:00:00'); + + $this->db->shouldReceive('prepare') + ->once() + ->with(Mockery::pattern('/SELECT l\.\*.*l.student_id = %d.*l.status != %s.*a.start_dt >= %s.*ORDER BY a.start_dt ASC/s'), 'wp_us_lessons', 'wp_us_availability', 5, Lesson::STATUS_CANCELLED, '2026-06-08 12:00:00') + ->andReturn('SELECT ...'); + + $row = (object) [ + 'id' => '15', + 'slot_id' => '10', + 'offering_id' => null, + 'student_id' => '5', + 'instructor_id' => '3', + 'recurrence' => Lesson::RECURRENCE_SINGLE, + 'series_id' => null, + 'status' => 'confirmed', + 'payment_id' => null, + 'notes' => null, + ]; + $this->db->shouldReceive('get_results')->andReturn([$row]); + + $lessons = $this->repo->findUpcomingForStudent(5); + + self::assertCount(1, $lessons); + self::assertSame(15, $lessons[0]->id); + } + public function testCountUpcomingForStudent(): void { Functions\when('current_time')->justReturn('2026-06-08 12:00:00'); diff --git a/tests/Unit/GroupClass/EnrollmentEndpointTest.php b/tests/Unit/GroupClass/EnrollmentEndpointTest.php new file mode 100644 index 0000000..95c9408 --- /dev/null +++ b/tests/Unit/GroupClass/EnrollmentEndpointTest.php @@ -0,0 +1,101 @@ +alias(static fn ($v): int => abs((int) $v)); + Functions\when('wp_unslash')->returnArg(); + Functions\when('sanitize_text_field')->returnArg(); + Functions\when('get_current_user_id')->justReturn(5); + + $this->enrollments = Mockery::mock(EnrollmentRepository::class); + $this->offerings = Mockery::mock(OfferingRepository::class); + $this->gate = Mockery::mock(RegistrationGate::class); + $this->payments = Mockery::mock(PaymentService::class); + + $this->endpoint = new EnrollmentEndpoint( + $this->enrollments, + $this->offerings, + $this->gate, + $this->payments, + ); + } + + private function offering(float $price): Offering + { + return new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', price: $price, id: 8); + } + + private function expectSuccessfulEnrollment(): void + { + $this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false); + $this->enrollments->shouldReceive('countActiveForOffering')->never(); + $this->gate->shouldReceive('validate')->andReturn(null); + $this->enrollments->shouldReceive('insert')->once()->andReturn(44); + $this->gate->shouldReceive('record')->once(); + } + + public function testEnrollInFreeClassReturnsNullPayment(): void + { + $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(0.0)); + $this->expectSuccessfulEnrollment(); + $this->payments->shouldNotReceive('createForRegistration'); + + $result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8])); + + self::assertInstanceOf(\WP_REST_Response::class, $result); + self::assertSame(201, $result->get_status()); + self::assertSame(44, $result->get_data()['id']); + self::assertNull($result->get_data()['payment']); + } + + public function testEnrollInPricedClassReturnsPaymentSummary(): void + { + $this->offerings->shouldReceive('findById')->with(8)->andReturn($this->offering(120.0)); + $this->expectSuccessfulEnrollment(); + $this->payments->shouldReceive('createForRegistration') + ->once() + ->with(Payment::REG_ENROLLMENT, 44, 5, 3, 120.0, 'CAD', null) + ->andReturn(new Payment( + studentId: 5, + instructorId: 3, + registrationType: Payment::REG_ENROLLMENT, + registrationId: 44, + amount: 120.0, + method: Payment::METHOD_ETRANSFER, + status: Payment::STATUS_PENDING, + id: 12, + )); + + $result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8])); + + self::assertInstanceOf(\WP_REST_Response::class, $result); + self::assertSame( + ['id' => 12, 'method' => Payment::METHOD_ETRANSFER, 'status' => Payment::STATUS_PENDING], + $result->get_data()['payment'] + ); + } +} diff --git a/tests/Unit/Payment/PaymentTest.php b/tests/Unit/Payment/PaymentTest.php index cec1221..af56050 100644 --- a/tests/Unit/Payment/PaymentTest.php +++ b/tests/Unit/Payment/PaymentTest.php @@ -71,6 +71,13 @@ class PaymentTest extends TestCase self::assertSame(100.00, $payment->total()); } + public function testToSummaryArrayContainsOnlyClientFacingFields(): void + { + $summary = (new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, id: 7))->toSummaryArray(); + + self::assertSame(['id' => 7, 'method' => Payment::METHOD_ETRANSFER, 'status' => Payment::STATUS_PENDING], $summary); + } + public function testToArrayContainsExpectedKeys(): void { $arr = (new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, id: 7))->toArray(); -- 2.54.0