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