\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', ], // Who is being enrolled. 0/absent means the caller enrols // themselves; a child's id is honoured only for their guardian. 'student_id' => [ 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint', ], 'answers' => [ 'type' => 'object', 'default' => [], ], 'accepted_policy_version_ids' => [ 'type' => 'array', 'default' => [], ], ], ], ] ); register_rest_route( $route_namespace, '/enrollments/(?P\d+)/withdraw', [ [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'withdraw' ], 'permission_callback' => [ $this, 'isLoggedIn' ], ], ] ); } 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 { // A guardian sees the whole household's enrolments — their own and // every child's — so one account covers the family. $enrollments = []; foreach ( $this->guardians->householdIds( $userId ) as $studentId ) { $enrollments = array_merge( $enrollments, $this->enrollments->findByStudent( $studentId ) ); } } 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 { // Who is being enrolled is settled before anything else, so an // unauthorised student id never reaches a seat claim or a charge. $studentId = $this->resolveStudent( $request ); if ( $studentId instanceof \WP_Error ) { return $studentId; } $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 ] ); } if ( $this->enrollments->hasActiveEnrollment( $offeringId, $studentId ) ) { return new \WP_Error( 'already_enrolled', __( 'You are already enrolled in this class.', 'unsupervised-schedular' ), [ 'status' => 409 ] ); } // Invite-only classes can only be enrolled in by students who were granted // access (or added directly); everyone else never sees the class at all. if ( $offering->isInviteOnly() && ! $this->access->hasGrant( $offeringId, $studentId ) ) { return new \WP_Error( 'invite_required', __( 'This class is by invitation only.', 'unsupervised-schedular' ), [ 'status' => 403 ] ); } // Enrolment closes at the end of the deadline day — the instructor's set // deadline, or the first class day by default. if ( ! $offering->isEnrollmentOpen( Val::string( current_time( 'Y-m-d' ) ) ) ) { return new \WP_Error( 'enrollment_closed', __( 'Enrolment for this class has closed.', 'unsupervised-schedular' ), [ 'status' => 403 ] ); } 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, ) ); // The acceptance binds the student but is attributed to whoever ticked the // boxes — the guardian, when they enrolled a child. $this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp(), get_current_user_id() ); // Mark the access grant used so instructor rosters distinguish invited // students from enrolled ones (a no-op for public classes). if ( $offering->isInviteOnly() ) { $this->access->markEnrolled( $offeringId, $studentId ); } // Scheduled billing (weekly / monthly) is generated later by the daily // billing scan, so nothing is charged at enrolment; the enrolment is active // regardless of payment. $payment = null; if ( $offering->price > 0.0 && ! $offering->isScheduledBilling() ) { $payment = $this->payments->createForRegistration( Payment::REG_ENROLLMENT, $id, $studentId, $offering->instructorId, $offering->price, $offering->currency, $offering->etransferEmail, payerId: $this->guardians->payerFor( $studentId ) ); } // `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 ); } /** * Withdraw the current student from a group class they enrolled in. Allowed * only while the offering's withdrawal deadline is open (a class with no * deadline set stays open indefinitely); once it passes, the student must * contact the studio and an admin withdraws them by hand. A timely withdrawal * frees the seat and voids any still-pending payment but never issues an * account credit — that is reserved for cancelled lessons. */ public function withdraw( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $id = absint( Val::int( $request->get_param( 'id' ) ) ); $enrollment = $this->enrollments->findById( $id ); if ( null === $enrollment ) { return new \WP_Error( 'not_found', __( 'Enrolment not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] ); } if ( ! $this->guardians->canActFor( get_current_user_id(), $enrollment->studentId ) ) { return new \WP_Error( 'forbidden', __( 'You cannot withdraw from this class.', 'unsupervised-schedular' ), [ 'status' => 403 ] ); } if ( Enrollment::STATUS_ACTIVE === $enrollment->status ) { $offering = $this->offerings->findById( $enrollment->offeringId ); if ( null !== $offering && ! $offering->isWithdrawalOpen( Val::string( current_time( 'Y-m-d' ) ) ) ) { return new \WP_Error( 'withdrawal_closed', __( 'Withdrawal for this class has closed. Please contact the studio.', 'unsupervised-schedular' ), [ 'status' => 403 ] ); } $this->enrollments->updateStatus( $id, Enrollment::STATUS_CANCELLED ); $this->payments->voidPending( $enrollment->paymentId ); } return new \WP_REST_Response( [ 'id' => $id, 'status' => Enrollment::STATUS_CANCELLED, ], 200 ); } 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 ); } /** * Who this enrolment is for: the caller by default, or one of their children * when a `student_id` is supplied and they are that child's guardian. An id * the caller may not act for is a 403, never a silent fallback to themselves. */ private function resolveStudent( \WP_REST_Request $request ): int|\WP_Error { $userId = get_current_user_id(); $requested = absint( Val::int( $request->get_param( 'student_id' ) ) ); if ( $requested <= 0 || $requested === $userId ) { return $userId; } if ( ! $this->guardians->canActFor( $userId, $requested ) ) { return new \WP_Error( 'forbidden', __( 'You cannot enrol that student.', 'unsupervised-schedular' ), [ 'status' => 403 ] ); } return $requested; } /** * Extract a question_id => value map from the request. * * @return array */ 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; } }