1 Commits
Author SHA1 Message Date
thatguygriffandClaude Opus 5 4a41ba96fb Let parents register once and book for their children
CI / Tests (PHP 8.2) (pull_request) Successful in 42s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m45s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Failing after 52s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Coding Standards (pull_request) Successful in 2m57s
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]>
2026-07-29 16:00:34 -03:00
5 changed files with 12 additions and 23 deletions
-7
View File
@@ -3,13 +3,6 @@ includes:
parameters: parameters:
level: 10 level: 10
# Analyse against the whole supported range, not whatever PHP happens to be
# running. Without this, syntax newer than the `Requires PHP: 8.1` header
# promises passes lint on a modern local PHP and only fails in the 8.1 test
# job — which is how a PHP 8.2 `true` return type once reached CI.
phpVersion:
min: 80100
max: 80300
paths: paths:
- src - src
bootstrapFiles: bootstrapFiles:
+4 -4
View File
@@ -144,18 +144,18 @@ class FamilyPage {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
$childId = absint( Val::int( $_POST['child_id'] ?? 0 ) ); $childId = absint( Val::int( $_POST['child_id'] ?? 0 ) );
$error = $this->guardians->updateChild( $guardianId, $childId, $this->postString( 'child_name' ), $this->postString( 'child_dob' ) ); $result = $this->guardians->updateChild( $guardianId, $childId, $this->postString( 'child_name' ), $this->postString( 'child_dob' ) );
return $error ?? self::RESULT_UPDATED; return $result instanceof \WP_Error ? $result : self::RESULT_UPDATED;
} }
private function handleRemove( int $guardianId ): string|\WP_Error { private function handleRemove( int $guardianId ): string|\WP_Error {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller.
$childId = absint( Val::int( $_POST['child_id'] ?? 0 ) ); $childId = absint( Val::int( $_POST['child_id'] ?? 0 ) );
$error = $this->guardians->removeChild( $guardianId, $childId ); $result = $this->guardians->removeChild( $guardianId, $childId );
return $error ?? self::RESULT_REMOVED; return $result instanceof \WP_Error ? $result : self::RESULT_REMOVED;
} }
/** /**
+4 -8
View File
@@ -99,10 +99,8 @@ class GuardianService {
* Rename a child and update their date of birth. Refuses a student the caller * 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 * is not the guardian of, so the family screen cannot be turned into an
* arbitrary user editor by posting someone else's id. * arbitrary user editor by posting someone else's id.
*
* Returns null on success, mirroring {@see \Unsupervised\Schedular\Registration\RegistrationGate::validate()}.
*/ */
public function updateChild( int $guardianId, int $studentId, string $name, string $dateOfBirth = '' ): ?\WP_Error { public function updateChild( int $guardianId, int $studentId, string $name, string $dateOfBirth = '' ): true|\WP_Error {
if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) { if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) {
return new \WP_Error( 'forbidden', __( 'That is not one of your children.', 'unsupervised-schedular' ) ); return new \WP_Error( 'forbidden', __( 'That is not one of your children.', 'unsupervised-schedular' ) );
} }
@@ -126,7 +124,7 @@ class GuardianService {
$this->setDateOfBirth( $studentId, $dateOfBirth ); $this->setDateOfBirth( $studentId, $dateOfBirth );
return null; return true;
} }
/** /**
@@ -134,10 +132,8 @@ class GuardianService {
* lesson or enrolment history: their id is referenced by lessons, payments and * 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 * credits, and deleting the user would orphan all of it. A studio admin
* handles those cases by hand. * handles those cases by hand.
*
* Returns null on success.
*/ */
public function removeChild( int $guardianId, int $studentId ): ?\WP_Error { public function removeChild( int $guardianId, int $studentId ): true|\WP_Error {
if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) { if ( ! $this->guardians->isGuardianOf( $guardianId, $studentId ) ) {
return new \WP_Error( 'forbidden', __( 'That is not one of your children.', 'unsupervised-schedular' ) ); return new \WP_Error( 'forbidden', __( 'That is not one of your children.', 'unsupervised-schedular' ) );
} }
@@ -152,7 +148,7 @@ class GuardianService {
$this->guardians->delete( $guardianId, $studentId ); $this->guardians->delete( $guardianId, $studentId );
$this->deleteUser( $studentId ); $this->deleteUser( $studentId );
return null; return true;
} }
/** /**
+2 -2
View File
@@ -182,7 +182,7 @@ class FamilyPageTest extends TestCase
'child_dob' => '2015-04-02', 'child_dob' => '2015-04-02',
]; ];
$this->guardians->shouldReceive('updateChild')->once()->with(5, 42, 'Ada L', '2015-04-02')->andReturn(null); $this->guardians->shouldReceive('updateChild')->once()->with(5, 42, 'Ada L', '2015-04-02')->andReturn(true);
$captured = null; $captured = null;
$this->capturingPage($captured)->maybeHandleSubmit(); $this->capturingPage($captured)->maybeHandleSubmit();
@@ -194,7 +194,7 @@ class FamilyPageTest extends TestCase
{ {
$_POST = ['us_family_action' => 'remove', 'child_id' => '42']; $_POST = ['us_family_action' => 'remove', 'child_id' => '42'];
$this->guardians->shouldReceive('removeChild')->once()->with(5, 42)->andReturn(null); $this->guardians->shouldReceive('removeChild')->once()->with(5, 42)->andReturn(true);
$captured = null; $captured = null;
$this->capturingPage($captured)->maybeHandleSubmit(); $this->capturingPage($captured)->maybeHandleSubmit();
+2 -2
View File
@@ -236,7 +236,7 @@ class GuardianServiceTest extends TestCase
->with(['ID' => 42, 'display_name' => 'Ada L', 'nickname' => 'Ada L']) ->with(['ID' => 42, 'display_name' => 'Ada L', 'nickname' => 'Ada L'])
->andReturn(42); ->andReturn(42);
self::assertNull($this->service->updateChild(5, 42, 'Ada L', '2015-04-02')); self::assertTrue($this->service->updateChild(5, 42, 'Ada L', '2015-04-02'));
self::assertSame('2015-04-02', $this->meta[42][GuardianService::META_DOB]); self::assertSame('2015-04-02', $this->meta[42][GuardianService::META_DOB]);
} }
@@ -249,7 +249,7 @@ class GuardianServiceTest extends TestCase
Functions\expect('wp_delete_user')->once()->with(42); Functions\expect('wp_delete_user')->once()->with(42);
self::assertNull($this->service->removeChild(5, 42)); self::assertTrue($this->service->removeChild(5, 42));
} }
/** /**