diff --git a/assets/css/frontend.css b/assets/css/frontend.css index bbf4340..132c17e 100644 --- a/assets/css/frontend.css +++ b/assets/css/frontend.css @@ -48,6 +48,25 @@ align-items: center; } +.us-my-lesson-actions { + display: flex; + gap: 12px; + align-items: center; +} + +.us-cancel-lesson { + background: transparent; + border: 1px solid #ccc; + border-radius: 4px; + padding: 4px 12px; + cursor: pointer; + color: #c00; +} + +.us-cancel-lesson:hover { + border-color: #c00; +} + .us-lesson-status { font-size: 0.85em; font-weight: 600; diff --git a/assets/js/booking.js b/assets/js/booking.js index a93b2dd..6a0839b 100644 --- a/assets/js/booking.js +++ b/assets/js/booking.js @@ -306,10 +306,27 @@ ${upcoming.map((l) => `
${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}–${escHtml(timeOf(l.end_dt))} - ${escHtml(lessonStatusLabel(String(l.status)))} + + ${escHtml(lessonStatusLabel(String(l.status)))} + +
`).join('')} `; + + myLessons.querySelectorAll('.us-cancel-lesson').forEach((btn) => { + btn.addEventListener('click', () => cancelLesson(Number(btn.dataset.lessonId))); + }); + } + + function cancelLesson(id) { + if (!window.confirm('Cancel this lesson? The time will be released for other students.')) { + return; + } + clearError(); + apiFetch(`bookings/${id}/cancel`, { method: 'POST' }) + .then(loadSlots) + .catch((err) => showError(err.message)); } function loadMyLessons() { diff --git a/docs/features/lesson-booking.md b/docs/features/lesson-booking.md index 3947f28..6cce231 100644 --- a/docs/features/lesson-booking.md +++ b/docs/features/lesson-booking.md @@ -29,7 +29,17 @@ Students register for a private lesson by choosing an offering, picking a time ( 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). +10. The booking page also shows the student their upcoming lessons (`GET /bookings`) with a per-lesson status badge (pending payment / confirmed) and a **Cancel** button. + +## Cancellation +Students cancel their own lessons via `POST /bookings/{id}/cancel` (idempotent). +Cancelling marks the lesson `cancelled`, frees the availability slot for +rebooking, and voids a still-pending payment (marked `failed` so it leaves the +admin confirmation queue). A `paid` payment is never touched — refunds are a +manual, admin-side decision. Instructors cancelling via +`PATCH /bookings/{id}/status` get the same slot release and payment voiding; +reinstating a cancelled lesson re-claims its slot and fails with `409 +slot_taken` if the freed time was booked by someone else in the meantime. ## Weekly Reservations A weekly reservation creates one `series_id` shared across N lesson rows (one per @@ -42,6 +52,7 @@ offering). |-----------|-------------------------------------------------|--------------------------------| | `GET` | `/wp-json/us-scheduler/v1/bookings` | Any logged-in user | | `POST` | `/wp-json/us-scheduler/v1/bookings` | `book_lesson` | +| `POST` | `/wp-json/us-scheduler/v1/bookings/{id}/cancel` | Logged-in owner of the lesson | | `PATCH` | `/wp-json/us-scheduler/v1/bookings/{id}/status` | `manage_availability` or admin | `POST /bookings` body: `offering_id`, `slot_id`, `recurrence`, `answers[]` diff --git a/src/Availability/AvailabilityRepository.php b/src/Availability/AvailabilityRepository.php index b685de4..1c4ae4a 100644 --- a/src/Availability/AvailabilityRepository.php +++ b/src/Availability/AvailabilityRepository.php @@ -211,6 +211,19 @@ class AvailabilityRepository { return 1 === $updated; } + /** + * Free a slot whose lesson was cancelled so the time can be booked again. + */ + public function release( int $id ): bool { + return false !== $this->db->update( + $this->table, + [ 'is_booked' => 0 ], + [ 'id' => $id ], + [ '%d' ], + [ '%d' ] + ); + } + /** * One-time upgrade for rows created before windows were split on save: a * window stored as a single row (e.g. 09:00–16:00 with 60-minute lessons) diff --git a/src/Booking/BookingEndpoint.php b/src/Booking/BookingEndpoint.php index 0f392ee..d0af35c 100644 --- a/src/Booking/BookingEndpoint.php +++ b/src/Booking/BookingEndpoint.php @@ -79,6 +79,18 @@ class BookingEndpoint { ] ); + 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', @@ -262,6 +274,38 @@ class BookingEndpoint { 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. Paid + * lessons keep their payment — refunds are a manual, admin-side decision. + */ + 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 ) { + $this->bookings->updateStatus( $id, Lesson::STATUS_CANCELLED ); + $this->availability->release( $lesson->slotId ); + $this->payments->voidPending( $lesson->paymentId ); + } + + 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 ); @@ -274,12 +318,23 @@ class BookingEndpoint { return new \WP_Error( 'forbidden', __( 'You cannot update this booking.', 'unsupervised-schedular' ), [ 'status' => 403 ] ); } - $this->bookings->updateStatus( $id, Val::string( $request->get_param( 'status' ) ) ); + $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 ); + } 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' => $request->get_param( 'status' ), + 'status' => $status, ], 200 ); diff --git a/src/Payment/PaymentService.php b/src/Payment/PaymentService.php index 3a7766e..d0ac40a 100644 --- a/src/Payment/PaymentService.php +++ b/src/Payment/PaymentService.php @@ -89,6 +89,22 @@ class PaymentService { return true; } + /** + * Void the still-pending payment of a cancelled registration so it drops + * out of the confirmation queue. Paid payments are left alone — refunds + * are a manual, admin-side decision. + */ + public function voidPending( ?int $paymentId ): void { + if ( null === $paymentId ) { + return; + } + + $payment = $this->payments->findById( $paymentId ); + if ( null !== $payment && Payment::STATUS_PENDING === $payment->status ) { + $this->payments->updateStatus( $paymentId, Payment::STATUS_FAILED ); + } + } + /** * Resolve the client-side payment step for a freshly created registration. * For a card payment a Stripe PaymentIntent is created (or replayed diff --git a/tests/Unit/Availability/AvailabilityRepositoryTest.php b/tests/Unit/Availability/AvailabilityRepositoryTest.php index 001693b..9b39672 100644 --- a/tests/Unit/Availability/AvailabilityRepositoryTest.php +++ b/tests/Unit/Availability/AvailabilityRepositoryTest.php @@ -137,6 +137,16 @@ class AvailabilityRepositoryTest extends TestCase self::assertFalse($this->repo->claim(7)); } + public function testReleaseFreesTheSlot(): void + { + $this->db->shouldReceive('update') + ->once() + ->with('wp_us_availability', ['is_booked' => 0], ['id' => 7], ['%d'], ['%d']) + ->andReturn(1); + + self::assertTrue($this->repo->release(7)); + } + public function testDeleteReturnsFalseWhenRowNotDeleted(): void { $this->db->shouldReceive('delete') diff --git a/tests/Unit/Booking/BookingEndpointTest.php b/tests/Unit/Booking/BookingEndpointTest.php index 9169599..9d4d737 100644 --- a/tests/Unit/Booking/BookingEndpointTest.php +++ b/tests/Unit/Booking/BookingEndpointTest.php @@ -221,6 +221,100 @@ class BookingEndpointTest extends TestCase self::assertSame(Payment::METHOD_COMP, $result->get_data()['payment']['method']); } + public function testCancelByOwnerCancelsReleasesSlotAndVoidsPayment(): void + { + $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_PENDING, paymentId: 12, id: 77); + $this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson); + $this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CANCELLED)->once()->andReturn(true); + $this->availability->shouldReceive('release')->with(10)->once()->andReturn(true); + $this->payments->shouldReceive('voidPending')->with(12)->once(); + + $result = $this->endpoint->cancel(new \WP_REST_Request(['id' => 77])); + + self::assertInstanceOf(\WP_REST_Response::class, $result); + self::assertSame(Lesson::STATUS_CANCELLED, $result->get_data()['status']); + } + + public function testCancelByAnotherStudentIsForbidden(): void + { + // Lesson belongs to student 9; current user is 5. + $lesson = new Lesson(slotId: 10, studentId: 9, instructorId: 3, status: Lesson::STATUS_PENDING, id: 77); + $this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson); + $this->bookings->shouldNotReceive('updateStatus'); + $this->availability->shouldNotReceive('release'); + + $result = $this->endpoint->cancel(new \WP_REST_Request(['id' => 77])); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('forbidden', $result->get_error_code()); + } + + public function testCancelUnknownLessonReturns404(): void + { + $this->bookings->shouldReceive('findById')->with(99)->andReturn(null); + + $result = $this->endpoint->cancel(new \WP_REST_Request(['id' => 99])); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('not_found', $result->get_error_code()); + } + + public function testCancelAlreadyCancelledLessonIsIdempotent(): void + { + $lesson = new Lesson(slotId: 10, studentId: 5, instructorId: 3, status: Lesson::STATUS_CANCELLED, id: 77); + $this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson); + $this->bookings->shouldNotReceive('updateStatus'); + $this->availability->shouldNotReceive('release'); + $this->payments->shouldNotReceive('voidPending'); + + $result = $this->endpoint->cancel(new \WP_REST_Request(['id' => 77])); + + self::assertInstanceOf(\WP_REST_Response::class, $result); + self::assertSame(Lesson::STATUS_CANCELLED, $result->get_data()['status']); + } + + public function testUpdateStatusToCancelledReleasesSlotAndVoidsPayment(): void + { + // Current user 5 is the lesson's instructor. + $lesson = new Lesson(slotId: 10, studentId: 9, instructorId: 5, status: Lesson::STATUS_PENDING, paymentId: 12, id: 77); + $this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson); + $this->availability->shouldReceive('release')->with(10)->once()->andReturn(true); + $this->payments->shouldReceive('voidPending')->with(12)->once(); + $this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CANCELLED)->once()->andReturn(true); + + $result = $this->endpoint->updateStatus(new \WP_REST_Request(['id' => 77, 'status' => Lesson::STATUS_CANCELLED])); + + self::assertInstanceOf(\WP_REST_Response::class, $result); + self::assertSame(Lesson::STATUS_CANCELLED, $result->get_data()['status']); + } + + public function testUpdateStatusReinstatingCancelledLessonReclaimsSlot(): void + { + $lesson = new Lesson(slotId: 10, studentId: 9, instructorId: 5, status: Lesson::STATUS_CANCELLED, id: 77); + $this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson); + $this->availability->shouldReceive('claim')->with(10)->once()->andReturn(true); + $this->bookings->shouldReceive('updateStatus')->with(77, Lesson::STATUS_CONFIRMED)->once()->andReturn(true); + + $result = $this->endpoint->updateStatus(new \WP_REST_Request(['id' => 77, 'status' => Lesson::STATUS_CONFIRMED])); + + self::assertInstanceOf(\WP_REST_Response::class, $result); + self::assertSame(Lesson::STATUS_CONFIRMED, $result->get_data()['status']); + } + + public function testUpdateStatusReinstatingFailsWhenSlotRebooked(): void + { + $lesson = new Lesson(slotId: 10, studentId: 9, instructorId: 5, status: Lesson::STATUS_CANCELLED, id: 77); + $this->bookings->shouldReceive('findById')->with(77)->andReturn($lesson); + // Someone booked the freed time in the meantime. + $this->availability->shouldReceive('claim')->with(10)->once()->andReturn(false); + $this->bookings->shouldNotReceive('updateStatus'); + + $result = $this->endpoint->updateStatus(new \WP_REST_Request(['id' => 77, 'status' => Lesson::STATUS_CONFIRMED])); + + self::assertInstanceOf(\WP_Error::class, $result); + self::assertSame('slot_taken', $result->get_error_code()); + } + public function testMyLessonsForStudentIncludesSlotTimes(): void { Functions\when('current_user_can')->justReturn(false); diff --git a/tests/Unit/Payment/PaymentServiceTest.php b/tests/Unit/Payment/PaymentServiceTest.php index d1f2f99..2f619a1 100644 --- a/tests/Unit/Payment/PaymentServiceTest.php +++ b/tests/Unit/Payment/PaymentServiceTest.php @@ -65,6 +65,30 @@ class PaymentServiceTest extends TestCase self::assertNull($this->service->createForRegistration(Payment::REG_LESSON, 12, 5, 3, 0.0, 'CAD')); } + public function testVoidPendingMarksPendingPaymentFailed(): void + { + $this->payments->shouldReceive('findById')->with(50)->andReturn($this->payment(Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, 50)); + $this->payments->shouldReceive('updateStatus')->once()->with(50, Payment::STATUS_FAILED)->andReturn(true); + + $this->service->voidPending(50); + } + + public function testVoidPendingLeavesPaidPaymentAlone(): void + { + // Refunds are manual: cancelling a paid lesson must not touch the ledger. + $this->payments->shouldReceive('findById')->with(50)->andReturn($this->payment(Payment::METHOD_CARD, Payment::STATUS_PAID, 50)); + $this->payments->shouldNotReceive('updateStatus'); + + $this->service->voidPending(50); + } + + public function testVoidPendingIgnoresNullPaymentId(): void + { + $this->payments->shouldNotReceive('findById'); + + $this->service->voidPending(null); + } + public function testEtransferStaysPending(): void { $this->resolver->shouldReceive('resolve')->with(5)->andReturn(Payment::METHOD_ETRANSFER);