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:
2026-07-29 16:07:52 -03:00
co-authored by Claude Opus 5
parent c25260a367
commit b772e1811e
71 changed files with 4192 additions and 191 deletions
+134 -11
View File
@@ -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,
)
);
+18 -3
View File
@@ -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';
}