Let students cancel their own lessons from the booking page
CI / No Debug Code (push) Successful in 3s
CI / Coding Standards (push) Successful in 46s
CI / Tests (PHP 8.1) (push) Successful in 45s
CI / Tests (PHP 8.2) (push) Successful in 45s
CI / PHPStan (push) Successful in 1m11s
CI / Tests (PHP 8.3) (push) Successful in 1m0s
CI / Build Plugin Zip (push) Successful in 1m10s

Adds POST /bookings/{id}/cancel (owner-only, idempotent): marks the lesson
cancelled, releases the availability slot for rebooking, and voids a
still-pending payment so it leaves the admin confirmation queue. Paid
payments are untouched — refunds stay a manual admin decision.

The instructor PATCH /bookings/{id}/status path now does the same slot
release and payment voiding on cancellation (previously cancelled lessons
left their slot permanently booked), and reinstating a cancelled lesson
re-claims the slot, rejecting with 409 if the freed time was rebooked.

The "Your upcoming lessons" panel gets a Cancel button with a confirm
prompt; on success both the lesson list and the slot calendar refresh.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-05 17:17:30 -03:00
co-authored by Claude Fable 5
parent 9dd3c39ddd
commit ab90dae638
9 changed files with 263 additions and 4 deletions
+19
View File
@@ -48,6 +48,25 @@
align-items: center; 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 { .us-lesson-status {
font-size: 0.85em; font-size: 0.85em;
font-weight: 600; font-weight: 600;
+17
View File
@@ -306,10 +306,27 @@
${upcoming.map((l) => ` ${upcoming.map((l) => `
<div class="us-my-lesson"> <div class="us-my-lesson">
<span>${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}${escHtml(timeOf(l.end_dt))}</span> <span>${escHtml(dayLabel(dayKey(l.start_dt)))} · ${escHtml(timeOf(l.start_dt))}${escHtml(timeOf(l.end_dt))}</span>
<span class="us-my-lesson-actions">
<span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span> <span class="us-lesson-status us-lesson-status-${escHtml(String(l.status))}">${escHtml(lessonStatusLabel(String(l.status)))}</span>
<button type="button" class="us-cancel-lesson" data-lesson-id="${l.id}">Cancel</button>
</span>
</div> </div>
`).join('')} `).join('')}
</div>`; </div>`;
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() { function loadMyLessons() {
+12 -1
View File
@@ -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. 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. 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`. 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 ## Weekly Reservations
A weekly reservation creates one `series_id` shared across N lesson rows (one per 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 | | `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` | `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 | | `PATCH` | `/wp-json/us-scheduler/v1/bookings/{id}/status` | `manage_availability` or admin |
`POST /bookings` body: `offering_id`, `slot_id`, `recurrence`, `answers[]` `POST /bookings` body: `offering_id`, `slot_id`, `recurrence`, `answers[]`
@@ -211,6 +211,19 @@ class AvailabilityRepository {
return 1 === $updated; 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 * One-time upgrade for rows created before windows were split on save: a
* window stored as a single row (e.g. 09:0016:00 with 60-minute lessons) * window stored as a single row (e.g. 09:0016:00 with 60-minute lessons)
+57 -2
View File
@@ -79,6 +79,18 @@ class BookingEndpoint {
] ]
); );
register_rest_route(
$route_namespace,
'/bookings/(?P<id>\d+)/cancel',
[
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'cancel' ],
'permission_callback' => [ $this, 'isLoggedIn' ],
],
]
);
register_rest_route( register_rest_route(
$route_namespace, $route_namespace,
'/bookings/(?P<id>\d+)/status', '/bookings/(?P<id>\d+)/status',
@@ -262,6 +274,38 @@ class BookingEndpoint {
return '' !== $ip ? $ip : null; 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 { public function updateStatus( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$id = absint( Val::int( $request->get_param( 'id' ) ) ); $id = absint( Val::int( $request->get_param( 'id' ) ) );
$lesson = $this->bookings->findById( $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 ] ); 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( return new \WP_REST_Response(
[ [
'id' => $id, 'id' => $id,
'status' => $request->get_param( 'status' ), 'status' => $status,
], ],
200 200
); );
+16
View File
@@ -89,6 +89,22 @@ class PaymentService {
return true; 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. * Resolve the client-side payment step for a freshly created registration.
* For a card payment a Stripe PaymentIntent is created (or replayed * For a card payment a Stripe PaymentIntent is created (or replayed
@@ -137,6 +137,16 @@ class AvailabilityRepositoryTest extends TestCase
self::assertFalse($this->repo->claim(7)); 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 public function testDeleteReturnsFalseWhenRowNotDeleted(): void
{ {
$this->db->shouldReceive('delete') $this->db->shouldReceive('delete')
@@ -221,6 +221,100 @@ class BookingEndpointTest extends TestCase
self::assertSame(Payment::METHOD_COMP, $result->get_data()['payment']['method']); 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 public function testMyLessonsForStudentIncludesSlotTimes(): void
{ {
Functions\when('current_user_can')->justReturn(false); Functions\when('current_user_can')->justReturn(false);
+24
View File
@@ -65,6 +65,30 @@ class PaymentServiceTest extends TestCase
self::assertNull($this->service->createForRegistration(Payment::REG_LESSON, 12, 5, 3, 0.0, 'CAD')); 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 public function testEtransferStaysPending(): void
{ {
$this->resolver->shouldReceive('resolve')->with(5)->andReturn(Payment::METHOD_ETRANSFER); $this->resolver->shouldReceive('resolve')->with(5)->andReturn(Payment::METHOD_ETRANSFER);