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:
@@ -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 );
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user