Let parents register once and book for their children
A parent registers once and manages lessons for one or more children, who need no login of their own. A child is a real wp_users row with the student role but no usable login — so student_id keeps meaning "a WordPress user" on every table, and booking, credits, policies and enrolments work unchanged. A us_guardians link table maps guardian to child. The signup form gains a parent/guardian tick that reveals a block per child, with the account-signup questions asked per child rather than per guardian — they describe the student, not the account holder. Signup policies are recorded once per child with the guardian as the acceptor, which is the record that actually means something. A family that half-creates is rolled back entirely rather than leaving a guardian who cannot re-register. The booking and enrolment forms gain a "Who is this for?" picker listing children first, so the default selection is never the parent — booking for the wrong child is correctable, quietly billing a parent for their kid's lesson is not. POST /bookings and POST /enrollments take an optional student_id honoured only for that child's guardian; anything else is a 403. That check is the authorisation boundary of the feature. Payments and credits gain a payer: the charge names the child it was for and the guardian who owes it, so per-child reporting is unchanged while notices, receipts and the payment step reach the parent. Credit is held by the payer, so one child's cancellation can settle a sibling's charge, and the daily billing scan sends a guardian one notice covering every child. Closes #132 Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+3
-2
@@ -15,6 +15,7 @@ use Unsupervised\Schedular\Auth\RegistrationMailer;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Auth\StudentActions;
|
||||
use Unsupervised\Schedular\Auth\StudentController;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Auth\StudentHistory;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\LessonController;
|
||||
@@ -64,7 +65,7 @@ class AdminMenu {
|
||||
private PaymentController $paymentController;
|
||||
private PaymentReportController $paymentReportController;
|
||||
|
||||
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer, CreditRepository $credits ) {
|
||||
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, AnswerRepository $answers, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, AcceptanceRepository $acceptances, InviteRepository $invites, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, StudioSettings $settings, PaymentRepository $payments, PaymentService $paymentService, BillingMethodResolver $resolver, RegistrationMailer $registrationMailer, CreditRepository $credits, GuardianService $guardians ) {
|
||||
$this->availabilityController = new AvailabilityController( $availability, $offerings, new WindowValidator( $offerings ) );
|
||||
$this->lessonController = new LessonController( $bookings, $payments, $availability, $offerings, new LessonDetail( $answers, $questions, $acceptances, $policies, $policyVersions ) );
|
||||
$this->offeringController = new OfferingController( $offerings, new ClassSlotReconciler( $availability ) );
|
||||
@@ -73,7 +74,7 @@ class AdminMenu {
|
||||
$this->registrationController = new RegistrationController( $invites );
|
||||
$this->registrationApprovalController = new RegistrationApprovalController( $registrationMailer );
|
||||
$this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments, $groupAccess, $paymentService, $invites, $registrationMailer );
|
||||
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments, $credits ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ) );
|
||||
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments, $credits ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ), $guardians );
|
||||
$this->instructorController = new InstructorController();
|
||||
$this->settings = $settings;
|
||||
$this->accessSettings = new AccessSettings();
|
||||
|
||||
+134
-11
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
use Unsupervised\Schedular\Policy\Policy;
|
||||
@@ -47,6 +48,7 @@ class RegistrationPage {
|
||||
private QuestionRepository $questions,
|
||||
private AnswerRepository $answers,
|
||||
private GroupAccessRepository $access,
|
||||
private GuardianService $guardians,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -117,8 +119,10 @@ class RegistrationPage {
|
||||
// gate, so it needs the plugin stylesheet that formats it.
|
||||
wp_enqueue_style( 'us-scheduler' );
|
||||
|
||||
// The two-step script only matters when there is a second step to reveal.
|
||||
if ( $canRegister && '' === $successType && [] !== $accountQuestions ) {
|
||||
// The script drives both the second step and the parent/guardian section
|
||||
// (revealing it, and cloning the child block for "add another"), so it is
|
||||
// needed whenever the form itself is on screen.
|
||||
if ( $canRegister && '' === $successType ) {
|
||||
wp_enqueue_script( 'us-scheduler-register' );
|
||||
}
|
||||
|
||||
@@ -270,14 +274,28 @@ class RegistrationPage {
|
||||
}
|
||||
}
|
||||
|
||||
// Account-signup questions (step two) — validate before creating the user so
|
||||
// a missing required answer never leaves a half-registered account behind.
|
||||
$accountQuestions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
||||
$answers = $this->submittedAnswers();
|
||||
|
||||
foreach ( $accountQuestions as $question ) {
|
||||
if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
|
||||
return esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
|
||||
// Registering as a parent/guardian turns the account-signup questions from
|
||||
// "about you" into "about each child" — they describe the student
|
||||
// (instrument, level, school), not the person holding the account.
|
||||
$isGuardian = $this->submittedIsGuardian();
|
||||
$children = $isGuardian ? $this->submittedChildren() : [];
|
||||
$answers = $isGuardian ? [] : $this->submittedAnswers();
|
||||
|
||||
// Everything is validated before a single user is created, so a bad child
|
||||
// block never leaves a half-registered family behind.
|
||||
if ( $isGuardian && [] === $children ) {
|
||||
return esc_html__( 'Please add at least one child, or uncheck the parent/guardian option.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
foreach ( $isGuardian ? array_column( $children, 'answers' ) : [ $answers ] as $set ) {
|
||||
foreach ( $accountQuestions as $question ) {
|
||||
if ( $question->isRequired && '' === trim( (string) ( $set[ (int) $question->id ] ?? '' ) ) ) {
|
||||
return $isGuardian
|
||||
? esc_html__( 'Please answer all required registration questions for each child.', 'unsupervised-schedular' )
|
||||
: esc_html__( 'Please answer all required registration questions.', 'unsupervised-schedular' );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,8 +317,16 @@ class RegistrationPage {
|
||||
return esc_html__( 'Could not create the account. Please contact the studio.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
$this->recordAcceptances( $policyForms, (int) $userId );
|
||||
$this->recordAnswers( $accountQuestions, $answers, (int) $userId );
|
||||
$this->recordAcceptances( $policyForms, (int) $userId, (int) $userId );
|
||||
|
||||
if ( $isGuardian ) {
|
||||
$failure = $this->createChildren( $children, $accountQuestions, $policyForms, (int) $userId );
|
||||
if ( '' !== $failure ) {
|
||||
return $failure;
|
||||
}
|
||||
} else {
|
||||
$this->recordAnswers( $accountQuestions, $answers, (int) $userId );
|
||||
}
|
||||
|
||||
if ( $inviteValid && ! $invite->isGroup() ) {
|
||||
$this->invites->markAccepted( (int) $invite->id, (int) $userId );
|
||||
@@ -440,6 +466,96 @@ class RegistrationPage {
|
||||
return add_query_arg( 'us_confirm', rawurlencode( $rawToken ), $base );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the "I'm registering as a parent or guardian" box was ticked.
|
||||
*/
|
||||
private function submittedIsGuardian(): bool {
|
||||
// The submit nonce is verified by the caller before this runs.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing
|
||||
return '1' === sanitize_text_field( Val::string( wp_unslash( $_POST['us_is_guardian'] ?? '' ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* The child blocks submitted with a guardian signup, as
|
||||
* `children[<n>][name|dob|answers]`. Blocks with no name are dropped rather
|
||||
* than rejected — the form always renders one spare block for "add another",
|
||||
* and an untouched spare is not a mistake the guardian needs telling about.
|
||||
*
|
||||
* @return list<array{name: string, dob: string, answers: array<int, string>}>
|
||||
*/
|
||||
private function submittedChildren(): array {
|
||||
// The submit nonce is verified by the caller before this runs.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- each field is unslashed and sanitized below.
|
||||
$raw = $_POST['children'] ?? [];
|
||||
if ( ! is_array( $raw ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ( $raw as $child ) {
|
||||
if ( ! is_array( $child ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = sanitize_text_field( Val::string( wp_unslash( $child['name'] ?? '' ) ) );
|
||||
if ( '' === trim( $name ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$answers = [];
|
||||
foreach ( (array) ( $child['answers'] ?? [] ) as $questionId => $value ) {
|
||||
$answers[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
|
||||
}
|
||||
|
||||
$out[] = [
|
||||
'name' => $name,
|
||||
'dob' => sanitize_text_field( Val::string( wp_unslash( $child['dob'] ?? '' ) ) ),
|
||||
'answers' => $answers,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create each child of a guardian signup: the login-less account, its answers
|
||||
* to the per-child questions, and a signup-policy acceptance recorded against
|
||||
* the child but attributed to the guardian who agreed for them.
|
||||
*
|
||||
* Returns an empty string on success, or an error message after rolling the
|
||||
* whole family back — every child created so far *and* the guardian. A signup
|
||||
* that half-worked would leave the guardian with an account they cannot
|
||||
* re-register and children they never confirmed, so it is undone entirely and
|
||||
* they simply try again.
|
||||
*
|
||||
* @param list<array{name: string, dob: string, answers: array<int, string>}> $children
|
||||
* @param list<Question> $questions
|
||||
* @param list<array{policy: Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
|
||||
*/
|
||||
private function createChildren( array $children, array $questions, array $policyForms, int $guardianId ): string {
|
||||
$created = [];
|
||||
|
||||
foreach ( $children as $child ) {
|
||||
$childId = $this->guardians->createChild( $guardianId, $child['name'], $child['dob'] );
|
||||
|
||||
if ( $childId instanceof \WP_Error ) {
|
||||
foreach ( $created as $id ) {
|
||||
$this->guardians->deleteUser( $id );
|
||||
}
|
||||
$this->guardians->deleteUser( $guardianId );
|
||||
|
||||
return esc_html__( 'Could not create the account. Please contact the studio.', 'unsupervised-schedular' );
|
||||
}
|
||||
|
||||
$created[] = $childId;
|
||||
|
||||
$this->recordAnswers( $questions, $child['answers'], $childId );
|
||||
$this->recordAcceptances( $policyForms, $childId, $guardianId );
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The account-question answers submitted with the form, keyed by question id.
|
||||
*
|
||||
@@ -489,9 +605,15 @@ class RegistrationPage {
|
||||
/**
|
||||
* Record account-time acceptances for each signup policy version.
|
||||
*
|
||||
* `$userId` is who the policy binds — the guardian for their own acceptance,
|
||||
* or the child for one accepted on their behalf — and `$acceptedBy` is who
|
||||
* actually ticked the box. Recording both is what makes the row legally
|
||||
* meaningful: "guardian X agreed to version N for child Y, at this time, from
|
||||
* this IP".
|
||||
*
|
||||
* @param list<array{policy: Policy, version: \Unsupervised\Schedular\Policy\PolicyVersion}> $policyForms
|
||||
*/
|
||||
private function recordAcceptances( array $policyForms, int $userId ): void {
|
||||
private function recordAcceptances( array $policyForms, int $userId, int $acceptedBy ): void {
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP is stored verbatim for audit.
|
||||
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
|
||||
|
||||
@@ -502,6 +624,7 @@ class RegistrationPage {
|
||||
studentId: $userId,
|
||||
registrationType: PolicyAcceptance::REG_ACCOUNT,
|
||||
registrationId: $userId,
|
||||
acceptedBy: $acceptedBy,
|
||||
ipAddress: '' !== $ip ? $ip : null,
|
||||
)
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\BillingMethodResolver;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
@@ -23,6 +24,7 @@ class StudentController {
|
||||
private BillingMethodResolver $resolver,
|
||||
private StudentHistory $history,
|
||||
private StudentActions $actions,
|
||||
private GuardianService $guardians,
|
||||
) {}
|
||||
|
||||
public function renderPage(): void {
|
||||
@@ -43,10 +45,14 @@ class StudentController {
|
||||
fn( \WP_User $user ): array => [
|
||||
'id' => (int) $user->ID,
|
||||
'name' => $user->display_name,
|
||||
'email' => $user->user_email,
|
||||
// A child's own address is an undeliverable placeholder, so the
|
||||
// list shows the guardian's — the address an admin would use.
|
||||
'email' => $this->guardians->contactFor( (int) $user->ID )['email'],
|
||||
'registered' => $user->user_registered,
|
||||
'upcoming' => $this->bookings->countUpcomingForStudent( (int) $user->ID ),
|
||||
'enrolments' => $this->enrollments->countActiveForStudent( (int) $user->ID ),
|
||||
'guardian' => $this->guardians->guardianOf( (int) $user->ID ),
|
||||
'children' => $this->guardians->children( (int) $user->ID ),
|
||||
],
|
||||
array_filter(
|
||||
get_users(
|
||||
@@ -150,10 +156,19 @@ class StudentController {
|
||||
$intake = $this->history->intakeAnswers( (int) $student->ID );
|
||||
$payments = $canBilling ? $this->history->payments( (int) $student->ID ) : [];
|
||||
$credits = $canBilling ? $this->history->credits( (int) $student->ID ) : [];
|
||||
$creditBalance = $canBilling ? $this->history->creditBalance( (int) $student->ID ) : 0.0;
|
||||
$creditCurrency = $this->creditCurrency( $credits );
|
||||
|
||||
$backUrl = admin_url( 'admin.php?page=us-students' );
|
||||
// The family panel, and the account whose balance actually settles this
|
||||
// student's charges — a child's is their guardian's, so showing the
|
||||
// child's own (always empty) balance would be actively misleading.
|
||||
$guardian = $this->guardians->guardianOf( (int) $student->ID );
|
||||
$children = $this->guardians->children( (int) $student->ID );
|
||||
$payer = $this->guardians->contactFor( (int) $student->ID );
|
||||
|
||||
$creditBalance = $canBilling ? $this->history->creditBalance( $payer['id'] ) : 0.0;
|
||||
|
||||
$backUrl = admin_url( 'admin.php?page=us-students' );
|
||||
$pageSlug = 'us-students';
|
||||
include USC_PLUGIN_DIR . 'templates/admin/student-detail.php';
|
||||
}
|
||||
|
||||
|
||||
@@ -169,6 +169,41 @@ class BlockPreview {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample family (manage-children) page: two representative children and the
|
||||
* add form, with the controls inert so the editor preview cannot post.
|
||||
*/
|
||||
public static function family(): string {
|
||||
$children = '';
|
||||
foreach ( [ 'Ada Lovelace', 'Alan Turing' ] as $name ) {
|
||||
$children .= sprintf(
|
||||
'<li class="us-family-child"><span class="us-family-child-name">%s</span>'
|
||||
. '<span class="us-family-child-actions"><a href="#">%s</a> <button type="button" disabled>%s</button></span></li>',
|
||||
esc_html( $name ),
|
||||
esc_html__( 'Edit', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Remove', 'unsupervised-schedular' )
|
||||
);
|
||||
}
|
||||
|
||||
$add = sprintf(
|
||||
'<h4>%s</h4><p><label for="us-child-name">%s</label><input type="text" id="us-child-name"></p>'
|
||||
. '<p><label for="us-child-dob">%s</label><input type="date" id="us-child-dob"></p>'
|
||||
. '<p><button type="button" disabled>%s</button></p>',
|
||||
esc_html__( 'Add a child', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Name', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Date of birth', 'unsupervised-schedular' ),
|
||||
esc_html__( 'Add child', 'unsupervised-schedular' )
|
||||
);
|
||||
|
||||
return sprintf(
|
||||
'<div class="us-family">%s<h3>%s</h3><ul class="us-family-list">%s</ul><form class="us-family-add">%s</form></div>',
|
||||
self::note( __( 'Editor preview — signed-in guardians see and manage their own children here.', 'unsupervised-schedular' ) ),
|
||||
esc_html__( 'Your family', 'unsupervised-schedular' ),
|
||||
$children,
|
||||
$add
|
||||
);
|
||||
}
|
||||
|
||||
private static function note( string $text ): string {
|
||||
return '<p class="us-editor-note">' . esc_html( $text ) . '</p>';
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use Unsupervised\Schedular\Auth\LoginPage;
|
||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||
use Unsupervised\Schedular\Booking\BookingPage;
|
||||
use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
||||
use Unsupervised\Schedular\Guardian\FamilyPage;
|
||||
|
||||
/**
|
||||
* Registers Gutenberg dynamic-block wrappers for the front-end shortcodes so
|
||||
@@ -28,6 +29,7 @@ class BlockRegistrar {
|
||||
private LoginPage $loginPage,
|
||||
private RegistrationPage $registrationPage,
|
||||
private GroupClassPage $groupClassPage,
|
||||
private FamilyPage $familyPage,
|
||||
) {}
|
||||
|
||||
public function register(): void {
|
||||
@@ -137,6 +139,15 @@ class BlockRegistrar {
|
||||
],
|
||||
],
|
||||
],
|
||||
'us-scheduler/family' => [
|
||||
'render' => [ $this, 'renderFamily' ],
|
||||
'attributes' => [
|
||||
'loginPageId' => [
|
||||
'type' => 'number',
|
||||
'default' => 0,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -184,6 +195,15 @@ class BlockRegistrar {
|
||||
return BlockPreview::groupClasses( Val::int( $attributes['offeringId'] ?? 0 ) > 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the family (manage-children) block.
|
||||
*
|
||||
* @param array<string, mixed> $attributes Block attributes.
|
||||
*/
|
||||
public function renderFamily( array $attributes = [] ): string {
|
||||
return $this->isEditorPreview() ? BlockPreview::family() : $this->familyPage->render( $attributes );
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side auto-redirect for blocks that opt in via their autoRedirect
|
||||
* attribute: logged-out visitors on a page containing the booking block
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Booking;
|
||||
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
@@ -28,6 +29,7 @@ class BookingEndpoint {
|
||||
private RegistrationGate $gate,
|
||||
private PaymentService $payments,
|
||||
private CancellationPolicy $cancellationPolicy,
|
||||
private GuardianService $guardians,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -59,6 +61,13 @@ class BookingEndpoint {
|
||||
'type' => 'integer',
|
||||
'default' => 0,
|
||||
],
|
||||
// Who the lesson is for. 0/absent means the caller books for
|
||||
// themselves; a child's id is honoured only for their guardian.
|
||||
'student_id' => [
|
||||
'type' => 'integer',
|
||||
'default' => 0,
|
||||
'sanitize_callback' => 'absint',
|
||||
],
|
||||
'recurrence' => [
|
||||
'type' => 'string',
|
||||
'default' => 'single',
|
||||
@@ -114,12 +123,26 @@ class BookingEndpoint {
|
||||
}
|
||||
|
||||
public function myLessons( \WP_REST_Request $request ): \WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
||||
$userId = get_current_user_id();
|
||||
$lessons = current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY )
|
||||
? $this->bookings->findUpcomingForInstructor( $userId )
|
||||
: $this->bookings->findUpcomingForStudent( $userId );
|
||||
$userId = get_current_user_id();
|
||||
|
||||
return new \WP_REST_Response( array_map( fn( Lesson $l ): array => $this->lessonWithTimes( $l ), $lessons ), 200 );
|
||||
if ( current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) ) {
|
||||
$lessons = $this->bookings->findUpcomingForInstructor( $userId );
|
||||
} else {
|
||||
// A guardian's list covers the whole household — their own lessons and
|
||||
// every child's — merged and re-sorted so the soonest is first
|
||||
// regardless of whose it is.
|
||||
$lessons = [];
|
||||
foreach ( $this->guardians->householdIds( $userId ) as $studentId ) {
|
||||
$lessons = array_merge( $lessons, $this->bookings->findUpcomingForStudent( $studentId ) );
|
||||
}
|
||||
}
|
||||
|
||||
$rows = array_map( fn( Lesson $l ): array => $this->lessonWithTimes( $l ), $lessons );
|
||||
|
||||
// usort reindexes in place, so the response is already a list.
|
||||
usort( $rows, static fn( array $a, array $b ): int => Val::string( $a['start_dt'] ?? '' ) <=> Val::string( $b['start_dt'] ?? '' ) );
|
||||
|
||||
return new \WP_REST_Response( $rows, 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,10 +167,21 @@ class BookingEndpoint {
|
||||
'end_dt' => $slot?->endDt,
|
||||
'offering_title' => $offering?->title,
|
||||
'duration_minutes' => $duration,
|
||||
// Whose lesson it is, so a guardian's merged list can say which child
|
||||
// each row belongs to.
|
||||
'student_name' => $this->guardians->studentName( $lesson->studentId ),
|
||||
];
|
||||
}
|
||||
|
||||
public function book( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
|
||||
// Who the lesson is for is settled before anything else is touched: an
|
||||
// unauthorised student id must never get as far as claiming a slot, and
|
||||
// certainly never as far as raising a payment against someone's account.
|
||||
$studentId = $this->resolveStudent( $request );
|
||||
if ( $studentId instanceof \WP_Error ) {
|
||||
return $studentId;
|
||||
}
|
||||
|
||||
$slotId = Val::int( $request->get_param( 'slot_id' ) );
|
||||
$slot = $this->availability->findById( $slotId );
|
||||
|
||||
@@ -214,7 +248,6 @@ class BookingEndpoint {
|
||||
return $gateError;
|
||||
}
|
||||
|
||||
$studentId = get_current_user_id();
|
||||
$notes = Val::string( $request->get_param( 'notes' ) );
|
||||
$recurrence = Lesson::RECURRENCE_WEEKLY === $request->get_param( 'recurrence' )
|
||||
? Lesson::RECURRENCE_WEEKLY
|
||||
@@ -254,7 +287,9 @@ class BookingEndpoint {
|
||||
$ids = [ $anchorId ];
|
||||
}
|
||||
|
||||
$this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
|
||||
// The acceptance binds the student but is attributed to whoever actually
|
||||
// ticked the boxes — the guardian, when they booked for a child.
|
||||
$this->gate->record( PolicyAcceptance::REG_LESSON, $anchorId, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp(), get_current_user_id() );
|
||||
|
||||
$payment = null;
|
||||
$status = Lesson::STATUS_PENDING;
|
||||
@@ -276,7 +311,16 @@ class BookingEndpoint {
|
||||
? $offering->price
|
||||
: $offering->price * count( $ids );
|
||||
|
||||
$payment = $this->payments->createForRegistration( Payment::REG_LESSON, $anchorId, $studentId, $slot->instructorId, $amount, $offering->currency, $offering->etransferEmail );
|
||||
$payment = $this->payments->createForRegistration(
|
||||
Payment::REG_LESSON,
|
||||
$anchorId,
|
||||
$studentId,
|
||||
$slot->instructorId,
|
||||
$amount,
|
||||
$offering->currency,
|
||||
$offering->etransferEmail,
|
||||
payerId: $this->guardians->payerFor( $studentId )
|
||||
);
|
||||
|
||||
if ( null !== $payment && $payment->isPaid() ) {
|
||||
$status = Lesson::STATUS_CONFIRMED;
|
||||
@@ -303,6 +347,35 @@ class BookingEndpoint {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who this booking is for: the caller by default, or one of their children
|
||||
* when a `student_id` is supplied and they are that child's guardian.
|
||||
*
|
||||
* This is the authorisation boundary of guardian booking — without it any
|
||||
* signed-in student could book, and bill, against any user id they chose to
|
||||
* send. An id the caller may not act for is a 403, never a silent fallback to
|
||||
* themselves: a guardian who picked the wrong child needs to be told, not to
|
||||
* have the lesson quietly booked in their own name.
|
||||
*/
|
||||
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 book on behalf of that student.', 'unsupervised-schedular' ),
|
||||
[ 'status' => 403 ]
|
||||
);
|
||||
}
|
||||
|
||||
return $requested;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a question_id => value map from the request.
|
||||
*
|
||||
@@ -342,7 +415,8 @@ class BookingEndpoint {
|
||||
}
|
||||
|
||||
/**
|
||||
* Student-initiated cancellation of their own lesson: marks it cancelled,
|
||||
* Student-initiated cancellation of their own lesson — or a guardian's, of one
|
||||
* of their children's: marks it cancelled,
|
||||
* frees the slot for rebooking, and voids any still-pending payment. A lesson
|
||||
* already paid for is credited back to the student's account (a per-lesson
|
||||
* share of the covering payment) to offset their future scheduled billing.
|
||||
@@ -355,7 +429,7 @@ class BookingEndpoint {
|
||||
return new \WP_Error( 'not_found', __( 'Booking not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
|
||||
}
|
||||
|
||||
if ( get_current_user_id() !== $lesson->studentId ) {
|
||||
if ( ! $this->guardians->canActFor( get_current_user_id(), $lesson->studentId ) ) {
|
||||
return new \WP_Error( 'forbidden', __( 'You cannot cancel this booking.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Unsupervised\Schedular\Booking;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RegistrationStatus;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
class BookingPage {
|
||||
@@ -18,6 +19,8 @@ class BookingPage {
|
||||
/** The student's upcoming lessons only — nothing bookable. */
|
||||
public const MODE_UPCOMING = 'upcoming';
|
||||
|
||||
public function __construct( private GuardianService $guardians ) {}
|
||||
|
||||
/**
|
||||
* Renders the booking shortcode/block output.
|
||||
*
|
||||
@@ -64,6 +67,11 @@ class BookingPage {
|
||||
$showBooking = self::MODE_UPCOMING !== $mode;
|
||||
$showUpcoming = self::MODE_BOOKING !== $mode;
|
||||
|
||||
// Who this account may book for. A single-student account gets one entry
|
||||
// (themselves) and no selector at all; a guardian's list leads with their
|
||||
// children, so the default choice is never the parent.
|
||||
$students = $this->guardians->bookableStudents( get_current_user_id() );
|
||||
|
||||
ob_start();
|
||||
include USC_PLUGIN_DIR . 'templates/frontend/booking-page.php';
|
||||
return (string) ob_get_clean();
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace Unsupervised\Schedular\GroupClass;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
@@ -20,6 +21,7 @@ class EnrollmentEndpoint {
|
||||
private RegistrationGate $gate,
|
||||
private PaymentService $payments,
|
||||
private GroupAccessRepository $access,
|
||||
private GuardianService $guardians,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -47,6 +49,13 @@ class EnrollmentEndpoint {
|
||||
'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' => [],
|
||||
@@ -81,13 +90,25 @@ class EnrollmentEndpoint {
|
||||
} elseif ( current_user_can( RoleManager::CAP_MANAGE_AVAILABILITY ) ) {
|
||||
$enrollments = $this->enrollments->findByInstructor( $userId );
|
||||
} else {
|
||||
$enrollments = $this->enrollments->findByStudent( $userId );
|
||||
// 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 );
|
||||
|
||||
@@ -95,8 +116,6 @@ class EnrollmentEndpoint {
|
||||
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 ] );
|
||||
}
|
||||
@@ -133,7 +152,9 @@ class EnrollmentEndpoint {
|
||||
)
|
||||
);
|
||||
|
||||
$this->gate->record( PolicyAcceptance::REG_ENROLLMENT, $id, $studentId, $offeringId, $answers, $acceptedVersionIds, $this->clientIp() );
|
||||
// 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).
|
||||
@@ -146,7 +167,16 @@ class EnrollmentEndpoint {
|
||||
// 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 = $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.
|
||||
@@ -176,7 +206,7 @@ class EnrollmentEndpoint {
|
||||
return new \WP_Error( 'not_found', __( 'Enrolment not found.', 'unsupervised-schedular' ), [ 'status' => 404 ] );
|
||||
}
|
||||
|
||||
if ( get_current_user_id() !== $enrollment->studentId ) {
|
||||
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 ] );
|
||||
}
|
||||
|
||||
@@ -212,6 +242,30 @@ class EnrollmentEndpoint {
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -4,10 +4,13 @@ declare(strict_types=1);
|
||||
namespace Unsupervised\Schedular\GroupClass;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
class GroupClassPage {
|
||||
|
||||
public function __construct( private GuardianService $guardians ) {}
|
||||
|
||||
/**
|
||||
* Renders the group-class enrolment shortcode output.
|
||||
*
|
||||
@@ -39,6 +42,10 @@ class GroupClassPage {
|
||||
|
||||
$offeringId = absint( Val::int( $atts['offering'] ?? $atts['offeringId'] ?? 0 ) );
|
||||
|
||||
// Who this account may enrol — children first, the account holder last, so
|
||||
// a guardian's default choice is a child rather than themselves.
|
||||
$students = $this->guardians->bookableStudents( get_current_user_id() );
|
||||
|
||||
ob_start();
|
||||
include USC_PLUGIN_DIR . 'templates/frontend/group-classes-page.php';
|
||||
return (string) ob_get_clean();
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Guardian;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
|
||||
/**
|
||||
* Keeps child accounts unusable as logins. A child holds the `us_student` role
|
||||
* so every `student_id` lookup in the schema keeps working, but nobody is ever
|
||||
* given its credentials — this closes the door the role would otherwise leave
|
||||
* open:
|
||||
*
|
||||
* - authentication is refused outright, and
|
||||
* - the booking capability is withheld, so nothing that reaches a capability
|
||||
* check on a child's own session (there should be none) can book as them.
|
||||
*
|
||||
* Both key off the `us_child` meta, so ordinary students are untouched.
|
||||
*/
|
||||
class ChildLoginGate {
|
||||
|
||||
public function register(): void {
|
||||
add_filter( 'wp_authenticate_user', [ $this, 'blockChildLogin' ], 10, 1 );
|
||||
add_filter( 'user_has_cap', [ $this, 'withholdBooking' ], 10, 4 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse authentication for a child account. Runs after password
|
||||
* verification, so it holds even if a password were somehow set on one.
|
||||
*
|
||||
* @param \WP_User|\WP_Error $user Authenticating user, or an earlier error.
|
||||
* @return \WP_User|\WP_Error
|
||||
*/
|
||||
public function blockChildLogin( $user ) {
|
||||
if ( $user instanceof \WP_User && GuardianService::isChild( (int) $user->ID ) ) {
|
||||
return new \WP_Error(
|
||||
'us_child_account',
|
||||
esc_html__( 'This is a child account and cannot be signed in to. Please sign in with the parent or guardian account.', 'unsupervised-schedular' )
|
||||
);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the booking capability from a child account, so the only route to a
|
||||
* lesson in their name is their guardian's authorised booking.
|
||||
*
|
||||
* @param array<string, bool> $allcaps All capabilities currently held.
|
||||
* @param array<int, string> $caps Required capabilities (unused).
|
||||
* @param array<int, mixed> $args Callback args (unused).
|
||||
* @param mixed $user The user being checked (a WP_User in practice).
|
||||
* @return array<string, bool>
|
||||
*/
|
||||
public function withholdBooking( array $allcaps, array $caps, array $args, mixed $user ): array {
|
||||
if ( $user instanceof \WP_User && GuardianService::isChild( (int) $user->ID ) ) {
|
||||
unset( $allcaps[ RoleManager::CAP_BOOK_LESSON ] );
|
||||
}
|
||||
|
||||
return $allcaps;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Guardian;
|
||||
|
||||
use Unsupervised\Schedular\Registration\Answer;
|
||||
use Unsupervised\Schedular\Registration\AnswerRepository;
|
||||
use Unsupervised\Schedular\Registration\Question;
|
||||
use Unsupervised\Schedular\Registration\QuestionRepository;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* The guardian's "my family" screen (`[us_family]`): list, add, edit and remove
|
||||
* the children they book for.
|
||||
*
|
||||
* Submissions are processed on `template_redirect` — before any output — and
|
||||
* post/redirect/get back to the page, so a refresh cannot resubmit and add the
|
||||
* same child twice.
|
||||
*/
|
||||
class FamilyPage {
|
||||
|
||||
/** Query flag carrying a completed action back to {@see render()}. */
|
||||
private const RESULT_ADDED = 'added';
|
||||
private const RESULT_UPDATED = 'updated';
|
||||
private const RESULT_REMOVED = 'removed';
|
||||
|
||||
/**
|
||||
* Error from the most recent submission processed on `template_redirect`,
|
||||
* carried over to {@see render()} so it can be shown inline with the form.
|
||||
*/
|
||||
private string $submitError = '';
|
||||
|
||||
public function __construct(
|
||||
private GuardianService $guardians,
|
||||
private QuestionRepository $questions,
|
||||
private AnswerRepository $answers,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Renders the family shortcode/block output.
|
||||
*
|
||||
* @param array<int|string, mixed> $atts Block attributes (`loginPageId`) or
|
||||
* shortcode attributes (`login_page_id`).
|
||||
*/
|
||||
public function render( array $atts ): string {
|
||||
if ( ! is_user_logged_in() ) {
|
||||
$loginPageId = Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 );
|
||||
|
||||
return sprintf(
|
||||
'<p>%s <a href="%s">%s</a>.</p>',
|
||||
esc_html__( 'Please', 'unsupervised-schedular' ),
|
||||
esc_url( $this->loginUrl( $loginPageId ) ),
|
||||
esc_html__( 'log in to manage your family', 'unsupervised-schedular' )
|
||||
);
|
||||
}
|
||||
|
||||
wp_enqueue_style( 'us-scheduler' );
|
||||
|
||||
$userId = get_current_user_id();
|
||||
|
||||
$children = $this->guardians->children( $userId );
|
||||
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
||||
$error = $this->submitError;
|
||||
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag; the submit that set it was nonce-checked.
|
||||
$result = sanitize_key( Val::string( wp_unslash( $_GET['us_family'] ?? '' ) ) );
|
||||
$notice = $this->noticeFor( $result );
|
||||
|
||||
// Which child the "edit" link opened, if any — the row is swapped for an
|
||||
// editable form rather than every row carrying one.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing; the edit submit is nonce-checked.
|
||||
$editingId = absint( Val::int( $_GET['us_edit_child'] ?? 0 ) );
|
||||
|
||||
ob_start();
|
||||
include USC_PLUGIN_DIR . 'templates/frontend/family-page.php';
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an add/edit/remove submission on `template_redirect`, before any
|
||||
* page output, then post/redirect/get back to the page. An error is stashed
|
||||
* for {@see render()} to show inline with the form.
|
||||
*/
|
||||
public function maybeHandleSubmit(): void {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- routing only; the action is nonce-checked immediately below.
|
||||
$action = sanitize_key( Val::string( wp_unslash( $_POST['us_family_action'] ?? '' ) ) );
|
||||
|
||||
if ( '' === $action || ! is_user_logged_in() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! check_admin_referer( 'us_family' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$userId = get_current_user_id();
|
||||
|
||||
$result = match ( $action ) {
|
||||
'add' => $this->handleAdd( $userId ),
|
||||
'edit' => $this->handleEdit( $userId ),
|
||||
'remove' => $this->handleRemove( $userId ),
|
||||
default => new \WP_Error( 'unknown_action', __( 'Unrecognised request.', 'unsupervised-schedular' ) ),
|
||||
};
|
||||
|
||||
if ( $result instanceof \WP_Error ) {
|
||||
$this->submitError = $result->get_error_message();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->redirect( add_query_arg( 'us_family', $result, $this->currentUrl() ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a child, then record their answers to the account-signup questions —
|
||||
* asked per child, since they describe the student rather than the account.
|
||||
*
|
||||
* Required answers are validated *before* the child is created, so a missing
|
||||
* one never leaves a nameless half-added child behind.
|
||||
*/
|
||||
private function handleAdd( int $guardianId ): string|\WP_Error {
|
||||
$name = $this->postString( 'child_name' );
|
||||
$dateOfBirth = $this->postString( 'child_dob' );
|
||||
$relationship = $this->postString( 'child_relationship' );
|
||||
|
||||
$questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true );
|
||||
$answers = $this->submittedAnswers();
|
||||
|
||||
$missing = $this->firstMissingAnswer( $questions, $answers );
|
||||
if ( null !== $missing ) {
|
||||
return $missing;
|
||||
}
|
||||
|
||||
$childId = $this->guardians->createChild( $guardianId, $name, $dateOfBirth, $relationship );
|
||||
if ( $childId instanceof \WP_Error ) {
|
||||
return $childId;
|
||||
}
|
||||
|
||||
$this->recordAnswers( $questions, $answers, $childId );
|
||||
|
||||
return self::RESULT_ADDED;
|
||||
}
|
||||
|
||||
private function handleEdit( int $guardianId ): string|\WP_Error {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
|
||||
$childId = absint( Val::int( $_POST['child_id'] ?? 0 ) );
|
||||
|
||||
$result = $this->guardians->updateChild( $guardianId, $childId, $this->postString( 'child_name' ), $this->postString( 'child_dob' ) );
|
||||
|
||||
return $result instanceof \WP_Error ? $result : self::RESULT_UPDATED;
|
||||
}
|
||||
|
||||
private function handleRemove( int $guardianId ): string|\WP_Error {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
|
||||
$childId = absint( Val::int( $_POST['child_id'] ?? 0 ) );
|
||||
|
||||
$result = $this->guardians->removeChild( $guardianId, $childId );
|
||||
|
||||
return $result instanceof \WP_Error ? $result : self::RESULT_REMOVED;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first required question left unanswered, as the error to show — or null
|
||||
* when every required question has a value.
|
||||
*
|
||||
* @param list<Question> $questions
|
||||
* @param array<int, string> $answers question_id => submitted value
|
||||
*/
|
||||
private function firstMissingAnswer( array $questions, array $answers ): ?\WP_Error {
|
||||
foreach ( $questions as $question ) {
|
||||
if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) {
|
||||
return new \WP_Error( 'missing_answer', __( 'Please answer all required questions for this child.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a child's answers to the account-signup questions. The answer is
|
||||
* recorded against the child, not the guardian, so a studio admin reading a
|
||||
* child's screen sees the information that describes them.
|
||||
*
|
||||
* @param list<Question> $questions
|
||||
* @param array<int, string> $answers question_id => submitted value
|
||||
*/
|
||||
private function recordAnswers( array $questions, array $answers, int $childId ): void {
|
||||
foreach ( $questions as $question ) {
|
||||
$value = trim( (string) ( $answers[ (int) $question->id ] ?? '' ) );
|
||||
if ( '' === $value ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->answers->insert(
|
||||
new Answer(
|
||||
questionId: (int) $question->id,
|
||||
registrationType: Answer::REG_ACCOUNT,
|
||||
registrationId: $childId,
|
||||
studentId: $childId,
|
||||
answerValue: $value,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The account-question answers submitted with the form, keyed by question id.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function submittedAnswers(): array {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- nonce checked by the caller; each value is unslashed and sanitized in the loop below.
|
||||
$raw = $_POST['us_answers'] ?? [];
|
||||
if ( ! is_array( $raw ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ( $raw as $questionId => $value ) {
|
||||
$out[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) );
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A sanitized text field from the submission. The caller has already verified
|
||||
* the nonce.
|
||||
*/
|
||||
private function postString( string $key ): string {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
|
||||
return sanitize_text_field( Val::string( wp_unslash( $_POST[ $key ] ?? '' ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* The confirmation to show for a completed action, or an empty string when
|
||||
* the flag is absent or unrecognised.
|
||||
*/
|
||||
private function noticeFor( string $result ): string {
|
||||
return match ( $result ) {
|
||||
self::RESULT_ADDED => __( 'Child added.', 'unsupervised-schedular' ),
|
||||
self::RESULT_UPDATED => __( 'Details updated.', 'unsupervised-schedular' ),
|
||||
self::RESULT_REMOVED => __( 'Child removed.', 'unsupervised-schedular' ),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The current page's clean permalink, used as the post/redirect/get target so
|
||||
* the edit flag and any stale notice are dropped from the URL.
|
||||
*/
|
||||
private function currentUrl(): string {
|
||||
$url = get_permalink();
|
||||
|
||||
return is_string( $url ) ? $url : home_url( '/' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Issues the post-submit redirect and stops the request. Split out so tests
|
||||
* can observe the target without the process exiting.
|
||||
*/
|
||||
protected function redirect( string $url ): void {
|
||||
wp_safe_redirect( $url );
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL the logged-out prompt sends visitors to: the chosen login page when one
|
||||
* is configured (and still exists), otherwise the WordPress login screen with
|
||||
* a redirect back to the current page.
|
||||
*/
|
||||
public function loginUrl( int $loginPageId ): string {
|
||||
if ( $loginPageId > 0 ) {
|
||||
$url = get_permalink( $loginPageId );
|
||||
|
||||
if ( is_string( $url ) ) {
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
||||
$permalink = get_permalink();
|
||||
|
||||
return wp_login_url( false === $permalink ? '' : $permalink );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Guardian;
|
||||
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* One parent/guardian ↔ child link. The child is a real (login-less) WordPress
|
||||
* user, so `studentId` is a `wp_users` ID exactly like every other student id in
|
||||
* the schema — this row only records who books and pays on their behalf.
|
||||
*/
|
||||
class GuardianLink {
|
||||
|
||||
public function __construct(
|
||||
public readonly int $guardianId,
|
||||
public readonly int $studentId,
|
||||
public readonly string $relationship = '',
|
||||
public readonly ?string $createdAt = null,
|
||||
public readonly ?int $id = null,
|
||||
) {}
|
||||
|
||||
public static function fromRow( \stdClass $row ): self {
|
||||
return new self(
|
||||
guardianId: Val::int( $row->guardian_id ),
|
||||
studentId: Val::int( $row->student_id ),
|
||||
relationship: Val::string( $row->relationship ?? '' ),
|
||||
createdAt: Val::stringOrNull( $row->created_at ?? null ),
|
||||
id: Val::int( $row->id ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a plain array representation of the link.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'guardian_id' => $this->guardianId,
|
||||
'student_id' => $this->studentId,
|
||||
'relationship' => $this->relationship,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Guardian;
|
||||
|
||||
class GuardianRepository {
|
||||
|
||||
private string $table;
|
||||
|
||||
public function __construct( private \wpdb $db ) {
|
||||
$this->table = $db->prefix . 'us_guardians';
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a child to a guardian. Returns 0 without inserting when the child
|
||||
* already has a guardian: v1 is one guardian per child, and the check lives
|
||||
* here so every caller (signup, the family screen, admin) gets it.
|
||||
*/
|
||||
public function insert( GuardianLink $link ): int {
|
||||
if ( null !== $this->findByStudent( $link->studentId ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->db->insert(
|
||||
$this->table,
|
||||
[
|
||||
'guardian_id' => $link->guardianId,
|
||||
'student_id' => $link->studentId,
|
||||
'relationship' => $link->relationship,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
],
|
||||
[ '%d', '%d', '%s', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The link naming this child's guardian, or null when they book for
|
||||
* themselves.
|
||||
*/
|
||||
public function findByStudent( int $studentId ): ?GuardianLink {
|
||||
$row = $this->db->get_row(
|
||||
$this->db->prepare(
|
||||
'SELECT * FROM %i WHERE student_id = %d LIMIT 1',
|
||||
$this->table,
|
||||
$studentId
|
||||
)
|
||||
);
|
||||
|
||||
return $row ? GuardianLink::fromRow( $row ) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every child linked to a guardian, oldest link first — the order they are
|
||||
* offered in the booking selector, so it stays stable as children are added.
|
||||
*
|
||||
* @return list<GuardianLink>
|
||||
*/
|
||||
public function findByGuardian( int $guardianId ): array {
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
'SELECT * FROM %i WHERE guardian_id = %d ORDER BY created_at ASC, id ASC',
|
||||
$this->table,
|
||||
$guardianId
|
||||
)
|
||||
);
|
||||
|
||||
return array_map( GuardianLink::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this exact guardian↔child pair is linked — the authorisation check
|
||||
* behind every "act for this student" boundary.
|
||||
*/
|
||||
public function isGuardianOf( int $guardianId, int $studentId ): bool {
|
||||
$found = $this->db->get_var(
|
||||
$this->db->prepare(
|
||||
'SELECT id FROM %i WHERE guardian_id = %d AND student_id = %d LIMIT 1',
|
||||
$this->table,
|
||||
$guardianId,
|
||||
$studentId
|
||||
)
|
||||
);
|
||||
|
||||
return null !== $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the link between a guardian and one of their children. Deleting the
|
||||
* child user itself is the caller's decision ({@see GuardianService::removeChild()});
|
||||
* this only unlinks.
|
||||
*/
|
||||
public function delete( int $guardianId, int $studentId ): bool {
|
||||
$deleted = $this->db->delete(
|
||||
$this->table,
|
||||
[
|
||||
'guardian_id' => $guardianId,
|
||||
'student_id' => $studentId,
|
||||
],
|
||||
[ '%d', '%d' ]
|
||||
);
|
||||
|
||||
return (int) $deleted > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many children a guardian has — enough to decide whether the booking
|
||||
* page needs a "who is this for?" selector at all.
|
||||
*/
|
||||
public function countChildren( int $guardianId ): int {
|
||||
$count = $this->db->get_var(
|
||||
$this->db->prepare(
|
||||
'SELECT COUNT(*) FROM %i WHERE guardian_id = %d',
|
||||
$this->table,
|
||||
$guardianId
|
||||
)
|
||||
);
|
||||
|
||||
return (int) $count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Guardian;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Auth\UserName;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Everything a guardian does on a child's behalf: creating the child's
|
||||
* login-less account, deciding who may act for whom, and resolving the payer and
|
||||
* contact behind a student id.
|
||||
*/
|
||||
class GuardianService {
|
||||
|
||||
/**
|
||||
* Marks a `wp_users` row as a child account: created by a guardian, holding
|
||||
* the student role so every `student_id` lookup keeps working, but with no
|
||||
* usable login. {@see ChildLoginGate} enforces the "no login" half.
|
||||
*/
|
||||
public const META_CHILD = 'us_child';
|
||||
|
||||
/** A child's date of birth (`Y-m-d`), collected at signup and editable after. */
|
||||
public const META_DOB = 'us_date_of_birth';
|
||||
|
||||
/**
|
||||
* Domain used for a child's placeholder login address. `.invalid` is reserved
|
||||
* by RFC 2606 and can never resolve, so a child's address is guaranteed
|
||||
* undeliverable — nothing about a child's account can ever be emailed to
|
||||
* somewhere real by mistake.
|
||||
*/
|
||||
private const CHILD_EMAIL_DOMAIN = 'child.invalid';
|
||||
|
||||
public function __construct(
|
||||
private GuardianRepository $guardians,
|
||||
private BookingRepository $bookings,
|
||||
private EnrollmentRepository $enrollments,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a login-less child account and link it to its guardian. The password
|
||||
* is random and discarded — it is never stored anywhere readable, emailed, or
|
||||
* shown — so the account cannot be signed into even if the gate were removed.
|
||||
*
|
||||
* Returns the new user ID, or a `WP_Error` when the name is blank or WordPress
|
||||
* refuses the insert.
|
||||
*/
|
||||
public function createChild( int $guardianId, string $name, string $dateOfBirth = '', string $relationship = '' ): int|\WP_Error {
|
||||
$name = trim( $name );
|
||||
if ( '' === $name ) {
|
||||
return new \WP_Error( 'missing_name', __( 'Please give each child a name.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$email = $this->childEmail();
|
||||
$userId = wp_insert_user(
|
||||
[
|
||||
'user_login' => $email,
|
||||
'user_email' => $email,
|
||||
'user_pass' => wp_generate_password( 24, true, true ),
|
||||
'display_name' => $name,
|
||||
'nickname' => $name,
|
||||
'role' => RoleManager::STUDENT,
|
||||
]
|
||||
);
|
||||
|
||||
if ( is_wp_error( $userId ) ) {
|
||||
return $userId;
|
||||
}
|
||||
|
||||
$userId = (int) $userId;
|
||||
|
||||
update_user_meta( $userId, self::META_CHILD, '1' );
|
||||
$this->setDateOfBirth( $userId, $dateOfBirth );
|
||||
|
||||
$linkId = $this->guardians->insert(
|
||||
new GuardianLink(
|
||||
guardianId: $guardianId,
|
||||
studentId: $userId,
|
||||
relationship: trim( $relationship ),
|
||||
)
|
||||
);
|
||||
|
||||
// The child was just created, so it cannot already be linked — a failure
|
||||
// here means the insert itself failed, and leaving an unreachable orphan
|
||||
// user behind would be worse than reporting it.
|
||||
if ( $linkId <= 0 ) {
|
||||
$this->deleteUser( $userId );
|
||||
|
||||
return new \WP_Error( 'link_failed', __( 'Could not add this child. Please contact the studio.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
return $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a child and update their date of birth. Refuses a student the caller
|
||||
* is not the guardian of, so the family screen cannot be turned into an
|
||||
* arbitrary user editor by posting someone else's id.
|
||||
*/
|
||||
public function updateChild( int $guardianId, int $studentId, string $name, string $dateOfBirth = '' ): true|\WP_Error {
|
||||
if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) {
|
||||
return new \WP_Error( 'forbidden', __( 'That is not one of your children.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$name = trim( $name );
|
||||
if ( '' === $name ) {
|
||||
return new \WP_Error( 'missing_name', __( 'Please give each child a name.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
$result = wp_update_user(
|
||||
[
|
||||
'ID' => $studentId,
|
||||
'display_name' => $name,
|
||||
'nickname' => $name,
|
||||
]
|
||||
);
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$this->setDateOfBirth( $studentId, $dateOfBirth );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink a child and delete their account. Refused once the child has any
|
||||
* lesson or enrolment history: their id is referenced by lessons, payments and
|
||||
* credits, and deleting the user would orphan all of it. A studio admin
|
||||
* handles those cases by hand.
|
||||
*/
|
||||
public function removeChild( int $guardianId, int $studentId ): true|\WP_Error {
|
||||
if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) {
|
||||
return new \WP_Error( 'forbidden', __( 'That is not one of your children.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
if ( [] !== $this->bookings->findByStudent( $studentId ) || [] !== $this->enrollments->findByStudent( $studentId ) ) {
|
||||
return new \WP_Error(
|
||||
'has_history',
|
||||
__( 'This child has lessons or enrolments on record and cannot be removed here. Please contact the studio.', 'unsupervised-schedular' )
|
||||
);
|
||||
}
|
||||
|
||||
$this->guardians->delete( $guardianId, $studentId );
|
||||
$this->deleteUser( $studentId );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `$actorId` may book, cancel and pay as `$studentId` — true for
|
||||
* themselves, and for a guardian acting as one of their own children. This is
|
||||
* the authorisation boundary the REST endpoints and form handlers check before
|
||||
* honouring a submitted student id.
|
||||
*/
|
||||
public function canActFor( int $actorId, int $studentId ): bool {
|
||||
if ( $actorId <= 0 || $studentId <= 0 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $actorId === $studentId || $this->guardians->isGuardianOf( $actorId, $studentId );
|
||||
}
|
||||
|
||||
/**
|
||||
* Who owes a student's charges: their guardian when they have one, otherwise
|
||||
* themselves. Payments, credits and the billing-method override all resolve
|
||||
* through this, so a family shares one balance and one billing setting.
|
||||
*/
|
||||
public function payerFor( int $studentId ): int {
|
||||
$link = $this->guardians->findByStudent( $studentId );
|
||||
|
||||
return null !== $link ? $link->guardianId : $studentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* The student ids whose lessons `$userId` may see: their own plus every child
|
||||
* they are guardian for.
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function householdIds( int $userId ): array {
|
||||
$ids = [ $userId ];
|
||||
|
||||
foreach ( $this->guardians->findByGuardian( $userId ) as $link ) {
|
||||
$ids[] = $link->studentId;
|
||||
}
|
||||
|
||||
return array_values( array_unique( $ids ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* The people a user may book or enrol for: **children first**, then
|
||||
* themselves. The order is the point — a guardian's normal case is booking for
|
||||
* a child, so the first option (and hence the default selection) is a child,
|
||||
* never the parent. Booking for a child by mistake is a correctable
|
||||
* inconvenience; silently billing a parent's account for a lesson meant for
|
||||
* their kid is the error worth designing out.
|
||||
*
|
||||
* The guardian is still offered, last, so a parent taking lessons alongside
|
||||
* their children can book for themselves from the same account.
|
||||
*
|
||||
* @return list<array{id: int, name: string, is_self: bool}>
|
||||
*/
|
||||
public function bookableStudents( int $userId ): array {
|
||||
$out = [];
|
||||
|
||||
foreach ( $this->children( $userId ) as $child ) {
|
||||
$out[] = [
|
||||
'id' => $child['id'],
|
||||
'name' => $child['name'],
|
||||
'is_self' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$self = get_userdata( $userId );
|
||||
|
||||
$out[] = [
|
||||
'id' => $userId,
|
||||
'name' => UserName::format( $self instanceof \WP_User ? $self : null, $userId ),
|
||||
'is_self' => true,
|
||||
];
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A guardian's children, in link order, with the details the family and admin
|
||||
* screens display.
|
||||
*
|
||||
* @return list<array{id: int, name: string, date_of_birth: string, relationship: string}>
|
||||
*/
|
||||
public function children( int $guardianId ): array {
|
||||
$out = [];
|
||||
|
||||
foreach ( $this->guardians->findByGuardian( $guardianId ) as $link ) {
|
||||
$user = get_userdata( $link->studentId );
|
||||
|
||||
$out[] = [
|
||||
'id' => $link->studentId,
|
||||
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $link->studentId ),
|
||||
'date_of_birth' => Val::string( get_user_meta( $link->studentId, self::META_DOB, true ) ),
|
||||
'relationship' => $link->relationship,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The guardian behind a child, or null when the student books for themselves.
|
||||
*
|
||||
* @return array{id: int, name: string, email: string}|null
|
||||
*/
|
||||
public function guardianOf( int $studentId ): ?array {
|
||||
$link = $this->guardians->findByStudent( $studentId );
|
||||
if ( null === $link ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = get_userdata( $link->guardianId );
|
||||
|
||||
return [
|
||||
'id' => $link->guardianId,
|
||||
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $link->guardianId ),
|
||||
'email' => $user instanceof \WP_User ? $user->user_email : '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Who to contact about a student: their guardian when they have one, otherwise
|
||||
* the student. What an instructor looking at a child's lesson actually needs —
|
||||
* a child's own address is an undeliverable placeholder.
|
||||
*
|
||||
* @return array{id: int, name: string, email: string}
|
||||
*/
|
||||
public function contactFor( int $studentId ): array {
|
||||
$guardian = $this->guardianOf( $studentId );
|
||||
if ( null !== $guardian ) {
|
||||
return $guardian;
|
||||
}
|
||||
|
||||
$user = get_userdata( $studentId );
|
||||
|
||||
return [
|
||||
'id' => $studentId,
|
||||
'name' => UserName::format( $user instanceof \WP_User ? $user : null, $studentId ),
|
||||
'email' => $user instanceof \WP_User ? $user->user_email : '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A student's display name, or an empty string when the user is gone. Used
|
||||
* wherever a charge or lesson has to say whose it is.
|
||||
*/
|
||||
public function studentName( int $studentId ): string {
|
||||
$user = get_userdata( $studentId );
|
||||
|
||||
return UserName::format( $user instanceof \WP_User ? $user : null );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a user is a child account (created by a guardian, cannot sign in).
|
||||
*/
|
||||
public static function isChild( int $userId ): bool {
|
||||
return '1' === Val::string( get_user_meta( $userId, self::META_CHILD, true ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a child's user account. Split out so the front-end paths pull in the
|
||||
* admin user functions `wp_delete_user()` lives in — it is not loaded on the
|
||||
* front end, where the family screen runs.
|
||||
*/
|
||||
public function deleteUser( int $userId ): void {
|
||||
if ( ! function_exists( 'wp_delete_user' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/user.php';
|
||||
}
|
||||
|
||||
wp_delete_user( $userId );
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a child's date of birth, or clear it when blank or unparseable. Kept
|
||||
* as `Y-m-d` so it sorts and displays consistently wherever it is read.
|
||||
*/
|
||||
private function setDateOfBirth( int $userId, string $dateOfBirth ): void {
|
||||
$dateOfBirth = trim( $dateOfBirth );
|
||||
|
||||
if ( '' === $dateOfBirth ) {
|
||||
delete_user_meta( $userId, self::META_DOB );
|
||||
return;
|
||||
}
|
||||
|
||||
$parsed = \DateTimeImmutable::createFromFormat( 'Y-m-d', $dateOfBirth );
|
||||
if ( false === $parsed ) {
|
||||
delete_user_meta( $userId, self::META_DOB );
|
||||
return;
|
||||
}
|
||||
|
||||
update_user_meta( $userId, self::META_DOB, $parsed->format( 'Y-m-d' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* An unused placeholder address for a child's account. WordPress requires a
|
||||
* unique email per user, so the random suffix is retried against
|
||||
* `email_exists()` rather than assumed unique.
|
||||
*/
|
||||
private function childEmail(): string {
|
||||
do {
|
||||
$email = 'us-child-' . wp_generate_password( 12, false, false ) . '@' . self::CHILD_EMAIL_DOMAIN;
|
||||
} while ( false !== email_exists( $email ) );
|
||||
|
||||
return strtolower( $email );
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,10 @@ namespace Unsupervised\Schedular;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Payment\CreditRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||
use Unsupervised\Schedular\Payment\ScheduledBillingRunner;
|
||||
use Unsupervised\Schedular\Policy\AcceptanceRepository;
|
||||
|
||||
class Installer {
|
||||
|
||||
@@ -50,5 +53,13 @@ class Installer {
|
||||
}
|
||||
|
||||
( new AvailabilityRepository( $wpdb ) )->splitOversizedWindows();
|
||||
|
||||
// Guardian accounts introduced "who pays" / "who agreed" alongside "who the
|
||||
// student is". Every row written before then had them one and the same, so
|
||||
// point the new columns at the student rather than leaving them 0 — the
|
||||
// balance and acceptance lookups key on them directly.
|
||||
( new PaymentRepository( $wpdb ) )->backfillPayerIds();
|
||||
( new CreditRepository( $wpdb ) )->backfillPayerIds();
|
||||
( new AcceptanceRepository( $wpdb ) )->backfillAcceptedBy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ class Credit {
|
||||
public readonly int $studentId,
|
||||
public readonly float $amount,
|
||||
public readonly float $remaining,
|
||||
/**
|
||||
* The account holding this balance — a child's guardian, or 0 meaning
|
||||
* "the student themselves". A family's credits all sit on the guardian,
|
||||
* so one child's cancellation can settle a sibling's charge.
|
||||
*/
|
||||
public readonly int $payerId = 0,
|
||||
public readonly string $currency = 'CAD',
|
||||
public readonly ?int $sourcePaymentId = null,
|
||||
public readonly ?int $sourceLessonId = null,
|
||||
@@ -41,6 +47,7 @@ class Credit {
|
||||
studentId: Val::int( $row->student_id ),
|
||||
amount: Val::float( $row->amount ),
|
||||
remaining: Val::float( $row->remaining ),
|
||||
payerId: Val::int( $row->payer_id ?? 0 ),
|
||||
currency: Val::string( $row->currency ),
|
||||
sourcePaymentId: Val::intOrNull( $row->source_payment_id ?? null ),
|
||||
sourceLessonId: Val::intOrNull( $row->source_lesson_id ?? null ),
|
||||
@@ -56,6 +63,15 @@ class Credit {
|
||||
return self::STATUS_AVAILABLE === $this->status && $this->remaining > 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whose balance this credit sits in: the recorded payer, falling back to the
|
||||
* student. Callers go through here rather than reading `payerId`, so the `0`
|
||||
* default of a pre-guardian credit never leaks out as a user id.
|
||||
*/
|
||||
public function payerOrStudent(): int {
|
||||
return $this->payerId > 0 ? $this->payerId : $this->studentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a plain array representation of the credit.
|
||||
*
|
||||
@@ -65,6 +81,7 @@ class Credit {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'student_id' => $this->studentId,
|
||||
'payer_id' => $this->payerOrStudent(),
|
||||
'amount' => $this->amount,
|
||||
'remaining' => $this->remaining,
|
||||
'currency' => $this->currency,
|
||||
|
||||
@@ -16,6 +16,7 @@ class CreditRepository {
|
||||
$this->table,
|
||||
[
|
||||
'student_id' => $credit->studentId,
|
||||
'payer_id' => $credit->payerOrStudent(),
|
||||
'amount' => $credit->amount,
|
||||
'remaining' => $credit->remaining,
|
||||
'currency' => $credit->currency,
|
||||
@@ -25,7 +26,7 @@ class CreditRepository {
|
||||
'status' => $credit->status,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
],
|
||||
[ '%d', '%f', '%f', '%s', '%d', '%d', '%s', '%s', '%s' ]
|
||||
[ '%d', '%d', '%f', '%f', '%s', '%d', '%d', '%s', '%s', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
@@ -56,15 +57,16 @@ class CreditRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* A student's total unused credit balance (sum of the remaining amounts of every
|
||||
* still-available credit).
|
||||
* A payer's total unused credit balance (sum of the remaining amounts of every
|
||||
* still-available credit). Keyed on the payer, so a guardian's balance covers
|
||||
* credits earned by any of their children — one family, one balance.
|
||||
*/
|
||||
public function availableBalance( int $studentId ): float {
|
||||
public function availableBalance( int $payerId ): float {
|
||||
$total = $this->db->get_var(
|
||||
$this->db->prepare(
|
||||
'SELECT COALESCE( SUM( remaining ), 0 ) FROM %i WHERE student_id = %d AND status = %s',
|
||||
'SELECT COALESCE( SUM( remaining ), 0 ) FROM %i WHERE payer_id = %d AND status = %s',
|
||||
$this->table,
|
||||
$studentId,
|
||||
$payerId,
|
||||
Credit::STATUS_AVAILABLE
|
||||
)
|
||||
);
|
||||
@@ -73,17 +75,17 @@ class CreditRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* A student's still-available credits, oldest first — the FIFO order they are
|
||||
* A payer's still-available credits, oldest first — the FIFO order they are
|
||||
* consumed in.
|
||||
*
|
||||
* @return list<Credit>
|
||||
*/
|
||||
public function findAvailableByStudent( int $studentId ): array {
|
||||
public function findAvailableByPayer( int $payerId ): array {
|
||||
$rows = $this->db->get_results(
|
||||
$this->db->prepare(
|
||||
'SELECT * FROM %i WHERE student_id = %d AND status = %s AND remaining > 0 ORDER BY created_at ASC, id ASC',
|
||||
'SELECT * FROM %i WHERE payer_id = %d AND status = %s AND remaining > 0 ORDER BY created_at ASC, id ASC',
|
||||
$this->table,
|
||||
$studentId,
|
||||
$payerId,
|
||||
Credit::STATUS_AVAILABLE
|
||||
)
|
||||
);
|
||||
@@ -92,7 +94,10 @@ class CreditRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every credit for a student, newest first (admin history).
|
||||
* Every credit earned by a student, newest first — the admin history on their
|
||||
* own screen. Unlike the balance this is keyed on the student, so a child's
|
||||
* screen shows the credits their cancellations produced even though the
|
||||
* balance itself sits with their guardian.
|
||||
*
|
||||
* @return list<Credit>
|
||||
*/
|
||||
@@ -109,17 +114,30 @@ class CreditRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw down a student's credit balance by $amount, consuming their available
|
||||
* Backfill `payer_id` on credits written before guardian accounts existed,
|
||||
* where the student was always the payer. Run once from the installer so the
|
||||
* payer-keyed balance queries see those rows.
|
||||
*/
|
||||
public function backfillPayerIds(): void {
|
||||
$sql = $this->db->prepare( 'UPDATE %i SET payer_id = student_id WHERE payer_id = 0', $this->table );
|
||||
|
||||
if ( null !== $sql ) {
|
||||
$this->db->query( $sql );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw down a payer's credit balance by $amount, consuming their available
|
||||
* credits oldest first and marking each fully-spent credit `consumed`. Stops once
|
||||
* the amount is exhausted; a balance shorter than $amount simply drains to zero.
|
||||
*/
|
||||
public function consume( int $studentId, float $amount ): void {
|
||||
public function consume( int $payerId, float $amount ): void {
|
||||
$remaining = round( $amount, 2 );
|
||||
if ( $remaining <= 0.0 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ( $this->findAvailableByStudent( $studentId ) as $credit ) {
|
||||
foreach ( $this->findAvailableByPayer( $payerId ) as $credit ) {
|
||||
if ( $remaining <= 0.0 ) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,13 @@ class Payment {
|
||||
public readonly string $registrationType,
|
||||
public readonly int $registrationId,
|
||||
public readonly float $amount,
|
||||
/**
|
||||
* The account that owes this charge — a child's guardian, or 0 meaning
|
||||
* "the student themselves". Zero rather than a copy of `studentId` so
|
||||
* every payment written before guardian accounts existed reads back with
|
||||
* its original meaning without a data migration.
|
||||
*/
|
||||
public readonly int $payerId = 0,
|
||||
public readonly string $currency = 'CAD',
|
||||
public readonly string $method = self::METHOD_ETRANSFER,
|
||||
public readonly string $status = self::STATUS_PENDING,
|
||||
@@ -64,6 +71,7 @@ class Payment {
|
||||
registrationType: Val::string( $row->registration_type ),
|
||||
registrationId: Val::int( $row->registration_id ),
|
||||
amount: Val::float( $row->amount ),
|
||||
payerId: Val::int( $row->payer_id ?? 0 ),
|
||||
currency: Val::string( $row->currency ),
|
||||
method: Val::string( $row->method ),
|
||||
status: Val::string( $row->status ),
|
||||
@@ -87,6 +95,25 @@ class Payment {
|
||||
return self::STATUS_PAID === $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who actually owes this charge: the recorded payer, falling back to the
|
||||
* student. Every caller that needs a person to bill, receipt or credit goes
|
||||
* through here rather than reading `payerId` directly, so the `0` default
|
||||
* never leaks out as a user id.
|
||||
*/
|
||||
public function payerOrStudent(): int {
|
||||
return $this->payerId > 0 ? $this->payerId : $this->studentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether someone other than the student is paying — a guardian. Drives the
|
||||
* "paid by" line on admin screens, which is noise when they are the same
|
||||
* person.
|
||||
*/
|
||||
public function hasSeparatePayer(): bool {
|
||||
return $this->payerId > 0 && $this->payerId !== $this->studentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this payment was generated by the daily billing scan (weekly /
|
||||
* monthly) rather than taken at registration. Scheduled payments carry a due
|
||||
@@ -134,6 +161,7 @@ class Payment {
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'student_id' => $this->studentId,
|
||||
'payer_id' => $this->payerOrStudent(),
|
||||
'instructor_id' => $this->instructorId,
|
||||
'registration_type' => $this->registrationType,
|
||||
'etransfer_email' => $this->etransferEmail,
|
||||
|
||||
@@ -16,6 +16,7 @@ class PaymentRepository {
|
||||
$this->table,
|
||||
[
|
||||
'student_id' => $payment->studentId,
|
||||
'payer_id' => $payment->payerOrStudent(),
|
||||
'instructor_id' => $payment->instructorId,
|
||||
'registration_type' => $payment->registrationType,
|
||||
'registration_id' => $payment->registrationId,
|
||||
@@ -36,12 +37,24 @@ class PaymentRepository {
|
||||
'paid_at' => $payment->paidAt,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
],
|
||||
[ '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ]
|
||||
[ '%d', '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill `payer_id` on payments written before guardian accounts existed,
|
||||
* where the student was always the payer. Run once from the installer.
|
||||
*/
|
||||
public function backfillPayerIds(): void {
|
||||
$sql = $this->db->prepare( 'UPDATE %i SET payer_id = student_id WHERE payer_id = 0', $this->table );
|
||||
|
||||
if ( null !== $sql ) {
|
||||
$this->db->query( $sql );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the Stripe PaymentIntent id created for a card payment so the webhook
|
||||
* can later reconcile the charge back to this row.
|
||||
|
||||
@@ -34,14 +34,19 @@ class PaymentService {
|
||||
* A `$dueDate`/`$periodKey` mark a payment generated later by the daily billing
|
||||
* scan (weekly / monthly) rather than taken at registration; both stay null for
|
||||
* the pay-now flow.
|
||||
*
|
||||
* `$payerId` is who owes it — a child's guardian, or 0 (the default) when the
|
||||
* student pays for themselves. The billing method resolves against the payer,
|
||||
* so comping or card-billing a family is one setting on the guardian.
|
||||
*/
|
||||
public function createForRegistration( string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $offeringEtransferEmail = null, ?string $dueDate = null, ?string $periodKey = null ): ?Payment {
|
||||
public function createForRegistration( string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $offeringEtransferEmail = null, ?string $dueDate = null, ?string $periodKey = null, int $payerId = 0 ): ?Payment {
|
||||
if ( $amount <= 0.0 ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$method = $this->resolver->resolve( $studentId );
|
||||
$status = Payment::METHOD_COMP === $method ? Payment::STATUS_PAID : Payment::STATUS_PENDING;
|
||||
$payerId = $payerId > 0 ? $payerId : $studentId;
|
||||
$method = $this->resolver->resolve( $payerId );
|
||||
$status = Payment::METHOD_COMP === $method ? Payment::STATUS_PAID : Payment::STATUS_PENDING;
|
||||
|
||||
$etransferEmail = null !== $offeringEtransferEmail && '' !== $offeringEtransferEmail
|
||||
? $offeringEtransferEmail
|
||||
@@ -58,6 +63,7 @@ class PaymentService {
|
||||
registrationType: $type,
|
||||
registrationId: $registrationId,
|
||||
amount: $amount,
|
||||
payerId: $payerId,
|
||||
currency: $currency,
|
||||
method: $method,
|
||||
status: $status,
|
||||
@@ -72,7 +78,9 @@ class PaymentService {
|
||||
$this->linkPayment( $type, $registrationId, $id );
|
||||
|
||||
if ( Payment::STATUS_PAID === $status ) {
|
||||
$this->finalizePaid( $id, $type, $registrationId, $studentId );
|
||||
// The receipt goes to whoever paid, which for a child's lesson is the
|
||||
// guardian — a child's own address is an undeliverable placeholder.
|
||||
$this->finalizePaid( $id, $type, $registrationId, $payerId );
|
||||
}
|
||||
|
||||
return $this->payments->findById( $id );
|
||||
@@ -111,7 +119,7 @@ class PaymentService {
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->finalizePaid( $paymentId, $payment->registrationType, $payment->registrationId, $payment->studentId );
|
||||
$this->finalizePaid( $paymentId, $payment->registrationType, $payment->registrationId, $payment->payerOrStudent() );
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -174,11 +182,15 @@ class PaymentService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The credit records the child it was earned for, but the balance itself
|
||||
// lands on whoever paid — so a family's credits pool on the guardian and
|
||||
// one child's cancellation can settle a sibling's next charge.
|
||||
$id = $this->credits->insert(
|
||||
new Credit(
|
||||
studentId: $payment->studentId,
|
||||
amount: $share,
|
||||
remaining: $share,
|
||||
payerId: $payment->payerOrStudent(),
|
||||
currency: $payment->currency,
|
||||
sourcePaymentId: $payment->id,
|
||||
sourceLessonId: $lesson->id,
|
||||
@@ -209,19 +221,22 @@ class PaymentService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a student's available credit balance against a set of freshly-created
|
||||
* Apply a payer's available credit balance against a set of freshly-created
|
||||
* pending payments (the ones a billing scan just generated for them), oldest
|
||||
* charge first. Each payment's `credit_applied` is raised by the amount covered;
|
||||
* a payment fully covered is marked paid-by-credit and its registration confirmed
|
||||
* so it leaves the confirmation queue. The credit ledger is drawn down by the
|
||||
* total applied. Returns a map of payment id to the credit applied to it, so the
|
||||
* caller can reflect the reduction on the student's notice.
|
||||
* caller can reflect the reduction on the payer's notice.
|
||||
*
|
||||
* Keyed on the payer, so a guardian's balance settles charges raised against
|
||||
* any of their children — the payments passed in may name several students.
|
||||
*
|
||||
* @param list<Payment> $payments
|
||||
* @return array<int, float>
|
||||
*/
|
||||
public function applyCredits( int $studentId, array $payments ): array {
|
||||
$balance = $this->credits->availableBalance( $studentId );
|
||||
public function applyCredits( int $payerId, array $payments ): array {
|
||||
$balance = $this->credits->availableBalance( $payerId );
|
||||
if ( $balance <= 0.0 ) {
|
||||
return [];
|
||||
}
|
||||
@@ -258,7 +273,7 @@ class PaymentService {
|
||||
}
|
||||
|
||||
if ( $consumed > 0.0 ) {
|
||||
$this->credits->consume( $studentId, $consumed );
|
||||
$this->credits->consume( $payerId, $consumed );
|
||||
}
|
||||
|
||||
return $applied;
|
||||
@@ -272,11 +287,19 @@ class PaymentService {
|
||||
* needs no further action. Returns null when the registration has no payment,
|
||||
* the caller does not own it, or Stripe could not create the intent.
|
||||
*
|
||||
* `$userId` is the caller: either the student the registration is for, or the
|
||||
* guardian who owes it — anyone else gets null rather than a payment step for
|
||||
* a charge that is not theirs.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function createIntent( string $type, int $registrationId, int $studentId ): ?array {
|
||||
public function createIntent( string $type, int $registrationId, int $userId ): ?array {
|
||||
$payment = $this->payments->findByRegistration( $type, $registrationId );
|
||||
if ( null === $payment || null === $payment->id || $payment->studentId !== $studentId ) {
|
||||
if ( null === $payment || null === $payment->id ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ( $payment->studentId !== $userId && $payment->payerOrStudent() !== $userId ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -334,7 +357,7 @@ class PaymentService {
|
||||
}
|
||||
|
||||
if ( 'payment_intent.succeeded' === $event->type && ! $payment->isPaid() ) {
|
||||
$this->finalizePaid( $payment->id, $payment->registrationType, $payment->registrationId, $payment->studentId );
|
||||
$this->finalizePaid( $payment->id, $payment->registrationType, $payment->registrationId, $payment->payerOrStudent() );
|
||||
} elseif ( 'payment_intent.payment_failed' === $event->type && ! $payment->isPaid() ) {
|
||||
$this->payments->updateStatus( $payment->id, Payment::STATUS_FAILED );
|
||||
}
|
||||
@@ -342,12 +365,12 @@ class PaymentService {
|
||||
return true;
|
||||
}
|
||||
|
||||
private function finalizePaid( int $paymentId, string $type, int $registrationId, int $studentId ): void {
|
||||
private function finalizePaid( int $paymentId, string $type, int $registrationId, int $payerId ): void {
|
||||
$this->payments->markPaid( $paymentId, 'USC-' . $paymentId );
|
||||
$this->confirmRegistration( $type, $registrationId );
|
||||
|
||||
$paid = $this->payments->findById( $paymentId );
|
||||
$user = get_userdata( $studentId );
|
||||
$user = get_userdata( $payerId );
|
||||
if ( null !== $paid && $this->mailer->send( $paid, $user instanceof \WP_User ? $user : null ) ) {
|
||||
$this->payments->markReceiptSent( $paymentId );
|
||||
}
|
||||
|
||||
@@ -6,13 +6,14 @@ namespace Unsupervised\Schedular\Payment;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
/**
|
||||
* Generates the pending payments that scheduled-billing offerings (weekly /
|
||||
* monthly) owe as they come due, then emails each student one itemised notice.
|
||||
* monthly) owe as they come due, then emails each payer one itemised notice.
|
||||
*
|
||||
* Runs from the daily WP-Cron action `us_generate_due_payments`. It is
|
||||
* self-healing: every run re-scans from the current ledger state, so a missed
|
||||
@@ -30,6 +31,7 @@ class ScheduledBillingRunner {
|
||||
private EnrollmentRepository $enrollments,
|
||||
private OfferingRepository $offerings,
|
||||
private PaymentDueMailer $mailer,
|
||||
private GuardianService $guardians,
|
||||
) {}
|
||||
|
||||
public function register(): void {
|
||||
@@ -42,12 +44,13 @@ class ScheduledBillingRunner {
|
||||
public function run(): void {
|
||||
$now = $this->now();
|
||||
|
||||
// One notice bucket per student, filled as pending payments are created and
|
||||
// flushed to a single email at the end, so a student billed for several
|
||||
// lessons on one day is emailed once — never once per lesson. Each entry keeps
|
||||
// the created payment and its label; credits are applied across the whole
|
||||
// bucket before the notice is built, so a student's account credit offsets the
|
||||
// run's charges oldest-first.
|
||||
// One notice bucket per *payer*, filled as pending payments are created and
|
||||
// flushed to a single email at the end, so a payer billed for several
|
||||
// lessons on one day is emailed once — never once per lesson, and a guardian
|
||||
// gets one notice covering every child rather than one per child. Each entry
|
||||
// keeps the created payment and its label; credits are applied across the
|
||||
// whole bucket before the notice is built, so the family's account credit
|
||||
// offsets the run's charges oldest-first.
|
||||
$buckets = [];
|
||||
|
||||
$this->billPrivateLessons( $now, $buckets );
|
||||
@@ -303,19 +306,24 @@ class ScheduledBillingRunner {
|
||||
|
||||
/**
|
||||
* Create one scheduled payment and, when it is pending (not a comp auto-pay),
|
||||
* add it to the student's notice bucket with the label to show on the notice.
|
||||
* add it to the payer's notice bucket with the label to show on the notice.
|
||||
* Credits are applied later, once the whole bucket is known. Returns the created
|
||||
* payment, or null when there was nothing to charge.
|
||||
*
|
||||
* The charge is bucketed against whoever owes it, so a guardian's notice covers
|
||||
* all their children; the label names the child when that differs from the
|
||||
* payer, or a parent cannot tell whose lesson each line is.
|
||||
*
|
||||
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
|
||||
*/
|
||||
private function bill( array &$buckets, string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $etransferEmail, string $dueDate, string $periodKey, string $label ): ?Payment {
|
||||
$payment = $this->payments->createForRegistration( $type, $registrationId, $studentId, $instructorId, $amount, $currency, $etransferEmail, $dueDate, $periodKey );
|
||||
$payerId = $this->guardians->payerFor( $studentId );
|
||||
$payment = $this->payments->createForRegistration( $type, $registrationId, $studentId, $instructorId, $amount, $currency, $etransferEmail, $dueDate, $periodKey, $payerId );
|
||||
|
||||
if ( null !== $payment && null !== $payment->id && Payment::STATUS_PENDING === $payment->status ) {
|
||||
$buckets[ $studentId ][] = [
|
||||
$buckets[ $payerId ][] = [
|
||||
'payment' => $payment,
|
||||
'label' => $label,
|
||||
'label' => $payerId === $studentId ? $label : $this->labelFor( $studentId, $label ),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -323,7 +331,17 @@ class ScheduledBillingRunner {
|
||||
}
|
||||
|
||||
/**
|
||||
* For each student, apply any account credit they hold against the run's charges,
|
||||
* Prefix a notice line with the student it is for — "Ada: Piano Lesson —
|
||||
* Mar 3, 2026" — used only when the payer is not the student.
|
||||
*/
|
||||
private function labelFor( int $studentId, string $label ): string {
|
||||
$name = $this->guardians->studentName( $studentId );
|
||||
|
||||
return '' === $name ? $label : $name . ': ' . $label;
|
||||
}
|
||||
|
||||
/**
|
||||
* For each payer, apply any account credit they hold against the run's charges,
|
||||
* tag the payments they still owe with a shared batch reference, and email them
|
||||
* one itemised notice. The notice lists each charge at its full amount, then the
|
||||
* credit applied and the reduced total due; a charge fully covered by credit is
|
||||
@@ -333,9 +351,9 @@ class ScheduledBillingRunner {
|
||||
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
|
||||
*/
|
||||
private function sendNotices( array $buckets ): void {
|
||||
foreach ( $buckets as $studentId => $entries ) {
|
||||
foreach ( $buckets as $payerId => $entries ) {
|
||||
$payments = array_map( static fn( array $entry ): Payment => $entry['payment'], $entries );
|
||||
$applied = $this->payments->applyCredits( $studentId, $payments );
|
||||
$applied = $this->payments->applyCredits( $payerId, $payments );
|
||||
|
||||
$items = [];
|
||||
$batchIds = [];
|
||||
@@ -366,7 +384,7 @@ class ScheduledBillingRunner {
|
||||
$reference = [] !== $batchIds ? $this->reference() : '';
|
||||
$this->payments->assignNoticeBatch( $batchIds, $reference );
|
||||
|
||||
$user = get_userdata( $studentId );
|
||||
$user = get_userdata( $payerId );
|
||||
if ( $user instanceof \WP_User ) {
|
||||
$this->mailer->send( $user, $items, $reference, round( $creditTotal, 2 ) );
|
||||
}
|
||||
|
||||
+17
-8
@@ -17,6 +17,10 @@ use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
||||
use Unsupervised\Schedular\Guardian\ChildLoginGate;
|
||||
use Unsupervised\Schedular\Guardian\FamilyPage;
|
||||
use Unsupervised\Schedular\Guardian\GuardianRepository;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\BillingMethodResolver;
|
||||
use Unsupervised\Schedular\Payment\CreditRepository;
|
||||
@@ -76,6 +80,9 @@ class Plugin {
|
||||
$groupAccess = new GroupAccessRepository( $wpdb );
|
||||
$registrationGate = new RegistrationGate( $questions, $answers, $policies, $policyVersions, $acceptances );
|
||||
|
||||
$guardianRepo = new GuardianRepository( $wpdb );
|
||||
$guardians = new GuardianService( $guardianRepo, $bookings, $enrollments );
|
||||
|
||||
$paymentRepo = new PaymentRepository( $wpdb );
|
||||
$creditRepo = new CreditRepository( $wpdb );
|
||||
$settings = new StudioSettings();
|
||||
@@ -87,21 +94,23 @@ class Plugin {
|
||||
// front-end output is identical whichever way a page embeds them.
|
||||
$registrationMailer = new RegistrationMailer();
|
||||
|
||||
$bookingPage = new BookingPage();
|
||||
$bookingPage = new BookingPage( $guardians );
|
||||
$loginPage = new LoginPage();
|
||||
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess );
|
||||
$groupClassPage = new GroupClassPage();
|
||||
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer, $questions, $answers, $groupAccess, $guardians );
|
||||
$groupClassPage = new GroupClassPage( $guardians );
|
||||
$familyPage = new FamilyPage( $guardians, $questions, $answers );
|
||||
|
||||
( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer() ) )->register();
|
||||
( new ScheduledBillingRunner( $paymentService, $bookings, $enrollments, $offerings, new PaymentDueMailer(), $guardians ) )->register();
|
||||
|
||||
( new UpdateChecker() )->register();
|
||||
( new RoleManager() )->register();
|
||||
( new RegistrationLoginGate() )->register();
|
||||
( new ChildLoginGate() )->register();
|
||||
( new StudentAdminGuard() )->register();
|
||||
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
|
||||
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo ) )->register();
|
||||
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService ) )->register();
|
||||
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
|
||||
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage ) )->register();
|
||||
( new AdminMenu( $availability, $bookings, $offerings, $questions, $answers, $policies, $policyVersions, $policyService, $acceptances, $invites, $enrollments, $groupAccess, $settings, $paymentRepo, $paymentService, $resolver, $registrationMailer, $creditRepo, $guardians ) )->register();
|
||||
( new RestRegistrar( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $registrationGate, $enrollments, $groupAccess, $paymentService, $guardians ) )->register();
|
||||
( new ShortcodeRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage ) )->register();
|
||||
( new BlockRegistrar( $bookingPage, $loginPage, $registrationPage, $groupClassPage, $familyPage ) )->register();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,12 +17,13 @@ class AcceptanceRepository {
|
||||
[
|
||||
'policy_version_id' => $acceptance->policyVersionId,
|
||||
'student_id' => $acceptance->studentId,
|
||||
'accepted_by' => $acceptance->acceptorOrStudent(),
|
||||
'registration_type' => $acceptance->registrationType,
|
||||
'registration_id' => $acceptance->registrationId,
|
||||
'ip_address' => $acceptance->ipAddress,
|
||||
'accepted_at' => current_time( 'mysql' ),
|
||||
],
|
||||
[ '%d', '%d', '%s', '%d', '%s', '%s' ]
|
||||
[ '%d', '%d', '%d', '%s', '%d', '%s', '%s' ]
|
||||
);
|
||||
|
||||
return $this->db->insert_id;
|
||||
@@ -38,6 +39,19 @@ class AcceptanceRepository {
|
||||
return array_map( fn( PolicyAcceptance $a ): int => $this->insert( $a ), $acceptances );
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill `accepted_by` on acceptances recorded before guardian accounts
|
||||
* existed, where the student always agreed for themselves. Run once from the
|
||||
* installer so the acceptor is a real user id on every row.
|
||||
*/
|
||||
public function backfillAcceptedBy(): void {
|
||||
$sql = $this->db->prepare( 'UPDATE %i SET accepted_by = student_id WHERE accepted_by = 0', $this->table );
|
||||
|
||||
if ( null !== $sql ) {
|
||||
$this->db->query( $sql );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all acceptances attached to a registration (lesson or enrolment).
|
||||
*
|
||||
|
||||
@@ -24,6 +24,13 @@ class PolicyAcceptance {
|
||||
public readonly int $studentId,
|
||||
public readonly string $registrationType,
|
||||
public readonly int $registrationId,
|
||||
/**
|
||||
* Who actually clicked "I agree" — a child's guardian, or 0 meaning the
|
||||
* student agreed for themselves. Zero rather than a copy of `studentId`
|
||||
* so every acceptance recorded before guardian accounts existed keeps its
|
||||
* original meaning.
|
||||
*/
|
||||
public readonly int $acceptedBy = 0,
|
||||
public readonly ?string $ipAddress = null,
|
||||
public readonly ?string $acceptedAt = null,
|
||||
public readonly ?int $id = null,
|
||||
@@ -35,12 +42,31 @@ class PolicyAcceptance {
|
||||
studentId: Val::int( $row->student_id ),
|
||||
registrationType: Val::string( $row->registration_type ),
|
||||
registrationId: Val::int( $row->registration_id ),
|
||||
acceptedBy: Val::int( $row->accepted_by ?? 0 ),
|
||||
ipAddress: Val::stringOrNull( $row->ip_address ),
|
||||
acceptedAt: Val::stringOrNull( $row->accepted_at ),
|
||||
id: Val::int( $row->id ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who this acceptance is legally attributable to: the recorded acceptor,
|
||||
* falling back to the student. Callers go through here so the `0` default of a
|
||||
* pre-guardian acceptance never leaks out as a user id.
|
||||
*/
|
||||
public function acceptorOrStudent(): int {
|
||||
return $this->acceptedBy > 0 ? $this->acceptedBy : $this->studentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether someone other than the student agreed — a guardian accepting on a
|
||||
* child's behalf. Drives the "accepted by" line on the admin screen, which is
|
||||
* noise when they are the same person.
|
||||
*/
|
||||
public function acceptedOnBehalf(): bool {
|
||||
return $this->acceptedBy > 0 && $this->acceptedBy !== $this->studentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a plain array representation of the acceptance.
|
||||
*
|
||||
@@ -51,6 +77,7 @@ class PolicyAcceptance {
|
||||
'id' => $this->id,
|
||||
'policy_version_id' => $this->policyVersionId,
|
||||
'student_id' => $this->studentId,
|
||||
'accepted_by' => $this->acceptorOrStudent(),
|
||||
'registration_type' => $this->registrationType,
|
||||
'registration_id' => $this->registrationId,
|
||||
'ip_address' => $this->ipAddress,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Registration;
|
||||
|
||||
/**
|
||||
* Renders one registration question as a form field. Shared by the signup form
|
||||
* and the guardian's family screen, which ask the same account-scope questions —
|
||||
* once per guardian at signup, and once per child either way.
|
||||
*/
|
||||
class QuestionField {
|
||||
|
||||
/**
|
||||
* The field's markup, escaped and ready to echo.
|
||||
*
|
||||
* `$name` is the full input name (e.g. `us_answers[7]`), and `$id` the DOM id
|
||||
* the label points at — both supplied by the caller so the same question can
|
||||
* appear more than once on a page (one block per child) without colliding.
|
||||
*
|
||||
* `$enforceRequired` false keeps the required marker in the label but drops
|
||||
* the HTML attribute, for a block the browser must not block submission on
|
||||
* because it may not apply at all — the child blocks, which only count when
|
||||
* the parent/guardian box is ticked. The server validates those either way.
|
||||
*/
|
||||
public static function render( Question $question, string $name, string $id, bool $enforceRequired = true ): string {
|
||||
$required = $question->isRequired && $enforceRequired ? ' required' : '';
|
||||
|
||||
$label = '<label for="' . esc_attr( $id ) . '">' . esc_html( $question->label )
|
||||
. ( $question->isRequired ? ' <span class="us-required" aria-hidden="true">*</span>' : '' )
|
||||
. '</label>';
|
||||
|
||||
return '<p>' . $label . self::input( $question, $name, $id, $required ) . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* The input element itself, chosen by the question's field type. `$required`
|
||||
* is a literal attribute string (' required' or ''), not user input.
|
||||
*/
|
||||
private static function input( Question $question, string $name, string $id, string $required ): string {
|
||||
$common = ' name="' . esc_attr( $name ) . '" id="' . esc_attr( $id ) . '"' . $required;
|
||||
|
||||
if ( Question::FIELD_TEXTAREA === $question->fieldType ) {
|
||||
return '<textarea' . $common . ' rows="4"></textarea>';
|
||||
}
|
||||
|
||||
if ( Question::FIELD_SELECT === $question->fieldType ) {
|
||||
$options = '<option value="">' . esc_html__( '— Select —', 'unsupervised-schedular' ) . '</option>';
|
||||
foreach ( (array) $question->options as $option ) {
|
||||
$options .= '<option value="' . esc_attr( (string) $option ) . '">' . esc_html( (string) $option ) . '</option>';
|
||||
}
|
||||
|
||||
return '<select' . $common . '>' . $options . '</select>';
|
||||
}
|
||||
|
||||
if ( Question::FIELD_CHECKBOX === $question->fieldType ) {
|
||||
return '<input type="checkbox"' . $common . ' value="1">';
|
||||
}
|
||||
|
||||
return '<input type="text"' . $common . '>';
|
||||
}
|
||||
}
|
||||
@@ -58,10 +58,14 @@ class RegistrationGate {
|
||||
/**
|
||||
* Persist answers and policy acceptances for a created registration.
|
||||
*
|
||||
* `$acceptedBy` is who actually agreed, when that is not the student — a
|
||||
* guardian booking for a child. It defaults to 0, read back as "the student
|
||||
* agreed for themselves".
|
||||
*
|
||||
* @param array<int, string> $answers question_id => answer value
|
||||
* @param list<int> $acceptedVersionIds Accepted policy version IDs
|
||||
*/
|
||||
public function record( string $registrationType, int $registrationId, int $studentId, int $offeringId, array $answers, array $acceptedVersionIds, ?string $ipAddress = null ): void {
|
||||
public function record( string $registrationType, int $registrationId, int $studentId, int $offeringId, array $answers, array $acceptedVersionIds, ?string $ipAddress = null, int $acceptedBy = 0 ): void {
|
||||
foreach ( $this->questions->findByOffering( $offeringId, true ) as $question ) {
|
||||
$value = (string) ( $answers[ (int) $question->id ] ?? '' );
|
||||
if ( '' === $value ) {
|
||||
@@ -90,6 +94,7 @@ class RegistrationGate {
|
||||
studentId: $studentId,
|
||||
registrationType: $registrationType,
|
||||
registrationId: $registrationId,
|
||||
acceptedBy: $acceptedBy > 0 ? $acceptedBy : $studentId,
|
||||
ipAddress: $ipAddress,
|
||||
)
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ use Unsupervised\Schedular\Booking\CancellationPolicy;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentEndpoint;
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\Guardian\GuardianService;
|
||||
use Unsupervised\Schedular\Offering\OfferingEndpoint;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\PaymentEndpoint;
|
||||
@@ -37,13 +38,13 @@ class RestRegistrar {
|
||||
private EnrollmentEndpoint $enrollmentEndpoint;
|
||||
private PaymentEndpoint $paymentEndpoint;
|
||||
|
||||
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService ) {
|
||||
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, RegistrationGate $gate, EnrollmentRepository $enrollments, GroupAccessRepository $groupAccess, PaymentService $paymentService, GuardianService $guardians ) {
|
||||
$this->availabilityEndpoint = new AvailabilityEndpoint( $availability, new WindowValidator( $offerings ) );
|
||||
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, new CancellationPolicy( new StudioSettings() ) );
|
||||
$this->bookingEndpoint = new BookingEndpoint( $availability, $bookings, $offerings, $gate, $paymentService, new CancellationPolicy( new StudioSettings() ), $guardians );
|
||||
$this->offeringEndpoint = new OfferingEndpoint( $offerings, $groupAccess );
|
||||
$this->questionEndpoint = new QuestionEndpoint( $questions, $offerings );
|
||||
$this->policyEndpoint = new PolicyEndpoint( $policies, $policyVersions, $policyService );
|
||||
$this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService, $groupAccess );
|
||||
$this->enrollmentEndpoint = new EnrollmentEndpoint( $enrollments, $offerings, $gate, $paymentService, $groupAccess, $guardians );
|
||||
$this->paymentEndpoint = new PaymentEndpoint( $paymentService );
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ class Schema {
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
policy_version_id BIGINT UNSIGNED NOT NULL,
|
||||
student_id BIGINT UNSIGNED NOT NULL,
|
||||
accepted_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
registration_type VARCHAR(20) NOT NULL,
|
||||
registration_id BIGINT UNSIGNED NOT NULL,
|
||||
accepted_at DATETIME NOT NULL,
|
||||
@@ -145,12 +146,14 @@ class Schema {
|
||||
PRIMARY KEY (id),
|
||||
KEY policy_version_id (policy_version_id),
|
||||
KEY student_id (student_id),
|
||||
KEY accepted_by (accepted_by),
|
||||
KEY registration (registration_type, registration_id)
|
||||
) {$charset};",
|
||||
|
||||
"CREATE TABLE {$prefix}us_payments (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
student_id BIGINT UNSIGNED NOT NULL,
|
||||
payer_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
instructor_id BIGINT UNSIGNED NOT NULL,
|
||||
registration_type VARCHAR(20) NOT NULL,
|
||||
registration_id BIGINT UNSIGNED NOT NULL,
|
||||
@@ -172,6 +175,7 @@ class Schema {
|
||||
paid_at DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY student_id (student_id),
|
||||
KEY payer_id (payer_id),
|
||||
KEY instructor_id (instructor_id),
|
||||
KEY registration (registration_type, registration_id),
|
||||
KEY status (status)
|
||||
@@ -180,6 +184,7 @@ class Schema {
|
||||
"CREATE TABLE {$prefix}us_credits (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
student_id BIGINT UNSIGNED NOT NULL,
|
||||
payer_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
amount DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
remaining DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
currency VARCHAR(3) NOT NULL DEFAULT 'CAD',
|
||||
@@ -191,6 +196,7 @@ class Schema {
|
||||
updated_at DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY student_id (student_id),
|
||||
KEY payer_id (payer_id),
|
||||
KEY status (status),
|
||||
KEY source_lesson_id (source_lesson_id)
|
||||
) {$charset};",
|
||||
@@ -229,6 +235,21 @@ class Schema {
|
||||
KEY status (status)
|
||||
) {$charset};",
|
||||
|
||||
// Links a parent/guardian account to a child who books through it. The
|
||||
// child is a real (login-less) wp_users row, so student_id keeps meaning
|
||||
// "a WordPress user" on every other table.
|
||||
"CREATE TABLE {$prefix}us_guardians (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
guardian_id BIGINT UNSIGNED NOT NULL,
|
||||
student_id BIGINT UNSIGNED NOT NULL,
|
||||
relationship VARCHAR(50) NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY guardian_student (guardian_id, student_id),
|
||||
KEY guardian_id (guardian_id),
|
||||
KEY student_id (student_id)
|
||||
) {$charset};",
|
||||
|
||||
"CREATE TABLE {$prefix}us_group_access (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
offering_id BIGINT UNSIGNED NOT NULL,
|
||||
|
||||
@@ -7,6 +7,7 @@ use Unsupervised\Schedular\Auth\LoginPage;
|
||||
use Unsupervised\Schedular\Auth\RegistrationPage;
|
||||
use Unsupervised\Schedular\Booking\BookingPage;
|
||||
use Unsupervised\Schedular\GroupClass\GroupClassPage;
|
||||
use Unsupervised\Schedular\Guardian\FamilyPage;
|
||||
use Unsupervised\Schedular\Payment\StudioSettings;
|
||||
|
||||
class ShortcodeRegistrar {
|
||||
@@ -16,6 +17,7 @@ class ShortcodeRegistrar {
|
||||
private LoginPage $loginPage,
|
||||
private RegistrationPage $registrationPage,
|
||||
private GroupClassPage $groupClassPage,
|
||||
private FamilyPage $familyPage,
|
||||
) {}
|
||||
|
||||
public function register(): void {
|
||||
@@ -23,10 +25,14 @@ class ShortcodeRegistrar {
|
||||
add_shortcode( 'us_student_login', self::shortcode( [ $this->loginPage, 'render' ] ) );
|
||||
add_shortcode( 'us_student_register', self::shortcode( [ $this->registrationPage, 'render' ] ) );
|
||||
add_shortcode( 'us_group_classes', self::shortcode( [ $this->groupClassPage, 'render' ] ) );
|
||||
add_shortcode( 'us_family', self::shortcode( [ $this->familyPage, 'render' ] ) );
|
||||
// Process registration submissions before output so the invite branch's
|
||||
// auth cookie is actually sent (render() runs too late, during the_content).
|
||||
add_action( 'template_redirect', [ $this->registrationPage, 'maybeHandleSubmit' ] );
|
||||
add_action( 'template_redirect', [ $this->registrationPage, 'maybeRedirectToRegistrationPage' ] );
|
||||
// Same reason as registration: the family form redirects after handling,
|
||||
// which render() (running during the_content) is too late to do.
|
||||
add_action( 'template_redirect', [ $this->familyPage, 'maybeHandleSubmit' ] );
|
||||
add_action( 'wp_enqueue_scripts', [ $this, 'enqueueAssets' ] );
|
||||
}
|
||||
|
||||
@@ -76,8 +82,11 @@ class ShortcodeRegistrar {
|
||||
// Price formatting and the pay agreement, shared by booking and enrolment.
|
||||
wp_register_script( 'us-scheduler-pricing', USC_PLUGIN_URL . 'assets/js/pricing.js', [ 'us-scheduler-payment' ], USC_VERSION, true );
|
||||
|
||||
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [ 'us-scheduler-pricing' ], USC_VERSION, true );
|
||||
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-pricing' ], USC_VERSION, true );
|
||||
// The "who is this for?" picker, shared by booking and enrolment.
|
||||
wp_register_script( 'us-scheduler-guardian', USC_PLUGIN_URL . 'assets/js/guardian.js', [], USC_VERSION, true );
|
||||
|
||||
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [ 'us-scheduler-pricing', 'us-scheduler-guardian' ], USC_VERSION, true );
|
||||
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-pricing', 'us-scheduler-guardian' ], USC_VERSION, true );
|
||||
|
||||
// Progressive enhancement for the two-step registration form (no dependencies).
|
||||
wp_register_script( 'us-scheduler-register', USC_PLUGIN_URL . 'assets/js/register.js', [], USC_VERSION, true );
|
||||
|
||||
Reference in New Issue
Block a user