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
+17
View File
@@ -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,
+32 -14
View File
@@ -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;
}
+28
View File
@@ -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,
+14 -1
View File
@@ -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.
+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 );
}
+33 -15
View File
@@ -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 ) );
}