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
+38 -15
View File
@@ -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 );
}