Files
unsupervised-scheduler/src/GroupClass/EnrollmentEndpoint.php
T
thatguygriffandClaude Fable 5 5888032ed7
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 53s
CI / PHPStan (pull_request) Successful in 2m46s
CI / Tests (PHP 8.1) (pull_request) Successful in 42s
CI / Coding Standards (pull_request) Successful in 47s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m35s
CI / Build Plugin Zip (pull_request) Has been skipped
Skip payment step for unpriced bookings, confirm them immediately, show students their lessons
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 <[email protected]>
2026-07-05 17:02:38 -03:00

158 lines
5.3 KiB
PHP

<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\GroupClass;
use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Policy\PolicyAcceptance;
use Unsupervised\Schedular\Registration\RegistrationGate;
use Unsupervised\Schedular\Val;
class EnrollmentEndpoint {
public function __construct(
private EnrollmentRepository $enrollments,
private OfferingRepository $offerings,
private RegistrationGate $gate,
private PaymentService $payments,
) {}
/**
* Registers this endpoint's REST routes.
*
* @param non-falsy-string $route_namespace REST namespace the routes are registered under (e.g. `us-scheduler/v1`).
*/
public function registerRoutes( string $route_namespace ): void {
register_rest_route(
$route_namespace,
'/enrollments',
[
[
'methods' => \WP_REST_Server::READABLE,
'callback' => [ $this, 'index' ],
'permission_callback' => [ $this, 'isLoggedIn' ],
],
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'enroll' ],
'permission_callback' => [ $this, 'canBook' ],
'args' => [
'offering_id' => [
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
],
'answers' => [
'type' => 'object',
'default' => [],
],
'accepted_policy_version_ids' => [
'type' => 'array',
'default' => [],
],
],
],
]
);
}
public function index( \WP_REST_Request $request ): \WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
$userId = get_current_user_id();
if ( current_user_can( RoleManager::CAP_VIEW_ALL_LESSONS ) ) {
$enrollments = $this->enrollments->findAllActive();
} elseif ( current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) ) {
$enrollments = $this->enrollments->findByInstructor( $userId );
} else {
$enrollments = $this->enrollments->findByStudent( $userId );
}
return new \WP_REST_Response( array_map( fn( Enrollment $e ) => $e->toArray(), $enrollments ), 200 );
}
public function enroll( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$offeringId = absint( Val::int( $request->get_param( 'offering_id' ) ) );
$offering = $this->offerings->findById( $offeringId );
if ( null === $offering || Offering::KIND_GROUP_CLASS !== $offering->kind ) {
return new \WP_Error( 'invalid_offering', __( 'Group class not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
}
$studentId = get_current_user_id();
if ( $this->enrollments->hasActiveEnrollment( $offeringId, $studentId ) ) {
return new \WP_Error( 'already_enrolled', __( 'You are already enrolled in this class.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
if ( null !== $offering->capacity && $this->enrollments->countActiveForOffering( $offeringId ) >= $offering->capacity ) {
return new \WP_Error( 'class_full', __( 'This class is full.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
$answers = $this->answers( $request );
$acceptedVersionIds = array_values( array_map( static fn( mixed $v ): int => absint( Val::int( $v ) ), (array) $request->get_param( 'accepted_policy_version_ids' ) ) );
$gateError = $this->gate->validate( $offeringId, $answers, $acceptedVersionIds );
if ( $gateError instanceof \WP_Error ) {
return $gateError;
}
$id = $this->enrollments->insert(
new Enrollment(
offeringId: $offeringId,
studentId: $studentId,
instructorId: $offering->instructorId,
)
);
$this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
$payment = null;
if ( $offering->price > 0.0 ) {
$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,
'payment' => $payment?->toSummaryArray(),
],
201
);
}
public function isLoggedIn(): bool {
return is_user_logged_in();
}
public function canBook(): bool {
return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON );
}
/**
* Extract a question_id => value map from the request.
*
* @return array<int, string>
*/
private function answers( \WP_REST_Request $request ): array {
$out = [];
foreach ( (array) $request->get_param( 'answers' ) as $questionId => $value ) {
$out[ (int) $questionId ] = sanitize_text_field( Val::string( $value ) );
}
return $out;
}
private function clientIp(): ?string {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit.
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
return '' !== $ip ? $ip : null;
}
}