Files
unsupervised-scheduler/src/GroupClass/EnrollmentEndpoint.php
T
thatguygriffandClaude Opus 4.8 4328e8fb5f
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m12s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Coding Standards (pull_request) Successful in 2m54s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m39s
CI / Build Plugin Zip (pull_request) Skipped
Add weekly and monthly scheduled billing for offerings
Offerings can now bill weekly (a pending payment 24h before each lesson)
or monthly (one payment on the 1st for that month's lessons), alongside
one-time and full-term. Applies to both private lessons and group classes.

- Offering: new `weekly`/`monthly` billing modes + `isScheduledBilling()`
- Booking/enrolment defer payment for scheduled modes; a single lesson
  booked after its due date has passed (e.g. an add-on in an already-billed
  month) is charged at booking instead
- ScheduledBillingRunner: daily WP-Cron scan generates due payments across
  four cases (private/group × weekly/monthly), deduped via lesson.payment_id
  and payments.period_key
- PaymentDueMailer: one consolidated itemised email per student per scan
- Notice batch: payments emailed together share a reference; the admin
  Payments queue groups them with a lump-sum total for e-transfer reconciliation
- Cancellation never voids a scheduled payment (Payment::isScheduled())
- Schema: us_payments gains due_date, period_key, notice_batch; USC_VERSION 1.2.0

composer test, composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 12:06:37 -03:00

180 lines
6.5 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,
private GroupAccessRepository $access,
) {}
/**
* 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 ] );
}
// 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,
)
);
$this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
// 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 );
}
// `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;
}
}