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
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:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
$route_namespace,
|
||||
'/bookings/(?P<id>\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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user