Files
unsupervised-scheduler/src/Payment/ScheduledBillingRunner.php
T
thatguygriffandClaude Opus 5 b772e1811e 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]>
2026-07-29 16:07:52 -03:00

408 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
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 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
* day is simply picked up the next time. Dedup keeps a second run from
* double-billing — private lessons via `us_lessons.payment_id`, group enrolments
* via `us_payments.period_key`.
*/
class ScheduledBillingRunner {
public const HOOK = 'us_generate_due_payments';
public function __construct(
private PaymentService $payments,
private BookingRepository $bookings,
private EnrollmentRepository $enrollments,
private OfferingRepository $offerings,
private PaymentDueMailer $mailer,
private GuardianService $guardians,
) {}
public function register(): void {
add_action( self::HOOK, [ $this, 'run' ] );
}
/**
* Generate every payment now due and send the consolidated notices.
*/
public function run(): void {
$now = $this->now();
// 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 );
$this->billGroupEnrollments( $now, $buckets );
$this->sendNotices( $buckets );
}
/**
* Private-lesson billing. Weekly lessons are billed one payment each once they
* are within 24 hours; monthly lessons are grouped per calendar month and billed
* one payment for the month once its 1st has arrived.
*
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function billPrivateLessons( \DateTimeImmutable $now, array &$buckets ): void {
$today = $now->format( 'Y-m-d' );
$monthly = [];
foreach ( $this->bookings->findUnbilledScheduledLessons() as $row ) {
$price = Val::float( $row->price ?? 0 );
if ( $price <= 0.0 ) {
continue;
}
$startRaw = Val::string( $row->start_dt ?? '' );
$start = false !== strtotime( $startRaw ) ? new \DateTimeImmutable( $startRaw ) : null;
if ( null === $start ) {
continue;
}
$lessonId = Val::int( $row->id );
$studentId = Val::int( $row->student_id );
$instructorId = Val::int( $row->instructor_id );
$currency = Val::string( $row->currency ?? 'CAD' );
$etransfer = Val::stringOrNull( $row->etransfer_email ?? null );
$title = Val::string( $row->title ?? '' );
if ( Offering::BILLING_MONTHLY === Val::string( $row->billing_mode ?? '' ) ) {
$monthly[ $studentId . ':' . Val::int( $row->offering_id ) . ':' . $start->format( 'Y-m' ) ][] = [
'lesson_id' => $lessonId,
'student_id' => $studentId,
'instructor_id' => $instructorId,
'currency' => $currency,
'etransfer' => $etransfer,
'title' => $title,
'price' => $price,
'start' => $start,
];
continue;
}
// Weekly: due 24 hours before the lesson.
$due = $start->modify( '-1 day' );
if ( $due->format( 'Y-m-d H:i:s' ) > $now->format( 'Y-m-d H:i:s' ) ) {
continue;
}
$this->bill(
$buckets,
Payment::REG_LESSON,
$lessonId,
$studentId,
$instructorId,
$price,
$currency,
$etransfer,
$due->format( 'Y-m-d' ),
$start->format( 'Y-m-d' ),
$title . ' — ' . $start->format( 'M j, Y' )
);
}
$this->billMonthlyLessonGroups( $today, $monthly, $buckets );
}
/**
* Bill each month's worth of monthly private lessons as one payment (count ×
* fee), once the month's 1st has arrived. The payment links to the earliest
* lesson in the group; the rest are pointed at it so they are not re-billed.
*
* @param array<string, list<array{lesson_id: int, student_id: int, instructor_id: int, currency: string, etransfer: ?string, title: string, price: float, start: \DateTimeImmutable}>> $monthly
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function billMonthlyLessonGroups( string $today, array $monthly, array &$buckets ): void {
foreach ( $monthly as $group ) {
$first = $group[0]['start'];
$monthStart = $first->format( 'Y-m-01' );
// Not billable until the 1st of the lesson's month has arrived.
if ( $monthStart > $today ) {
continue;
}
$lessonIds = array_map( static fn( array $l ): int => $l['lesson_id'], $group );
$anchorId = $lessonIds[0];
$count = count( $group );
$payment = $this->bill(
$buckets,
Payment::REG_LESSON,
$anchorId,
$group[0]['student_id'],
$group[0]['instructor_id'],
$group[0]['price'] * $count,
$group[0]['currency'],
$group[0]['etransfer'],
$monthStart,
$first->format( 'Y-m' ),
sprintf(
/* translators: 1: offering title, 2: month, 3: number of lessons */
_n( '%1$s (%2$s): %3$d lesson', '%1$s (%2$s): %3$d lessons', $count, 'unsupervised-schedular' ),
$group[0]['title'],
$first->format( 'F Y' ),
$count
)
);
if ( null === $payment ) {
continue;
}
// createForRegistration links the anchor; point the rest of the month at
// the same payment so the next scan sees them as billed.
foreach ( array_slice( $lessonIds, 1 ) as $extraId ) {
$this->bookings->setPaymentId( $extraId, (int) $payment->id );
}
}
}
/**
* Group-class billing off each active enrolment's concrete session windows.
* Weekly bills one payment per session (24h before); monthly bills one payment
* per month (on the 1st) for that month's sessions. Dedup is by `period_key`
* since a single enrolment maps to many periodic charges.
*
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function billGroupEnrollments( \DateTimeImmutable $now, array &$buckets ): void {
$today = $now->format( 'Y-m-d' );
$offerings = [];
foreach ( $this->enrollments->findActiveByBillingModes( Offering::SCHEDULED_BILLING_MODES ) as $enrollment ) {
$offeringId = $enrollment->offeringId;
if ( ! array_key_exists( $offeringId, $offerings ) ) {
$offerings[ $offeringId ] = $this->offerings->findById( $offeringId );
}
$offering = $offerings[ $offeringId ];
if ( null === $offering || $offering->price <= 0.0 ) {
continue;
}
$windows = $offering->sessionWindows();
if ( [] === $windows ) {
continue;
}
if ( Offering::BILLING_MONTHLY === $offering->billingMode ) {
$this->billGroupMonthly( $now, $today, $enrollment, $offering, $windows, $buckets );
} else {
$this->billGroupWeekly( $now, $enrollment, $offering, $windows, $buckets );
}
}
}
/**
* Bill one payment per group-class session that is now within 24 hours.
*
* @param list<array{start: string, end: string}> $windows
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function billGroupWeekly( \DateTimeImmutable $now, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets ): void {
foreach ( $windows as $window ) {
$start = new \DateTimeImmutable( $window['start'] );
$due = $start->modify( '-1 day' );
if ( $due->format( 'Y-m-d H:i:s' ) > $now->format( 'Y-m-d H:i:s' ) ) {
continue;
}
$periodKey = $start->format( 'Y-m-d' );
if ( $this->payments->scheduledPaymentExists( Payment::REG_ENROLLMENT, (int) $enrollment->id, $periodKey ) ) {
continue;
}
$this->bill(
$buckets,
Payment::REG_ENROLLMENT,
(int) $enrollment->id,
$enrollment->studentId,
$enrollment->instructorId,
$offering->price,
$offering->currency,
$offering->etransferEmail,
$due->format( 'Y-m-d' ),
$periodKey,
$offering->title . ' — ' . $start->format( 'M j, Y' )
);
}
}
/**
* Bill one payment per calendar month of a group class, once its 1st arrives.
*
* A monthly group class is priced **per month**, not per session: the fee is
* charged once for the month however many times the class meets in it. This is
* what the student is quoted and agrees to on the way in ("40.00 CAD monthly"),
* and it is the one place the monthly rule differs from private lessons, whose
* per-lesson fee is multiplied by the lessons that fall in the month.
*
* @param list<array{start: string, end: string}> $windows
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function billGroupMonthly( \DateTimeImmutable $now, string $today, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
// Count this enrolment's sessions per calendar month. The count does not
// price the month — it names it on the student's notice ("3 sessions").
$months = [];
foreach ( $windows as $window ) {
$start = new \DateTimeImmutable( $window['start'] );
$months[ $start->format( 'Y-m' ) ] = ( $months[ $start->format( 'Y-m' ) ] ?? 0 ) + 1;
}
foreach ( $months as $month => $count ) {
$monthStart = ( new \DateTimeImmutable( $month . '-01' ) )->format( 'Y-m-d' );
if ( $monthStart > $today ) {
continue;
}
if ( $this->payments->scheduledPaymentExists( Payment::REG_ENROLLMENT, (int) $enrollment->id, $month ) ) {
continue;
}
$this->bill(
$buckets,
Payment::REG_ENROLLMENT,
(int) $enrollment->id,
$enrollment->studentId,
$enrollment->instructorId,
$offering->price,
$offering->currency,
$offering->etransferEmail,
$monthStart,
$month,
sprintf(
/* translators: 1: offering title, 2: month, 3: number of sessions */
_n( '%1$s (%2$s): %3$d session', '%1$s (%2$s): %3$d sessions', $count, 'unsupervised-schedular' ),
$offering->title,
( new \DateTimeImmutable( $month . '-01' ) )->format( 'F Y' ),
$count
)
);
}
}
/**
* Create one scheduled payment and, when it is pending (not a comp auto-pay),
* 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 {
$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[ $payerId ][] = [
'payment' => $payment,
'label' => $payerId === $studentId ? $label : $this->labelFor( $studentId, $label ),
];
}
return $payment;
}
/**
* 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
* already settled and carries no reference. A lump-sum e-transfer for the balance
* reconciles to the reference.
*
* @param array<int, list<array{payment: Payment, label: string}>> $buckets
*/
private function sendNotices( array $buckets ): void {
foreach ( $buckets as $payerId => $entries ) {
$payments = array_map( static fn( array $entry ): Payment => $entry['payment'], $entries );
$applied = $this->payments->applyCredits( $payerId, $payments );
$items = [];
$batchIds = [];
$creditTotal = 0.0;
foreach ( $entries as $entry ) {
$payment = $entry['payment'];
$id = (int) $payment->id;
$credited = $applied[ $id ] ?? 0.0;
$creditTotal += $credited;
$items[] = [
'label' => $entry['label'],
'amount' => $payment->total(),
'currency' => $payment->currency,
'due_date' => $payment->dueDate,
'etransfer_email' => $payment->etransferEmail,
];
// A charge still carrying a balance is what a lump-sum e-transfer covers;
// one fully settled by credit needs no reconciliation reference.
if ( round( $payment->total() - $credited, 2 ) > 0.0 ) {
$batchIds[] = $id;
}
}
$reference = [] !== $batchIds ? $this->reference() : '';
$this->payments->assignNoticeBatch( $batchIds, $reference );
$user = get_userdata( $payerId );
if ( $user instanceof \WP_User ) {
$this->mailer->send( $user, $items, $reference, round( $creditTotal, 2 ) );
}
}
}
/**
* A short, human-quotable reference shared by every payment in one student's
* notice, printed on the email and shown in the admin payments queue.
*/
private function reference(): string {
return strtoupper( substr( str_replace( '-', '', Val::string( wp_generate_uuid4() ) ), 0, 10 ) );
}
private function now(): \DateTimeImmutable {
$mysql = Val::string( current_time( 'mysql' ) );
return false !== strtotime( $mysql ) ? new \DateTimeImmutable( $mysql ) : new \DateTimeImmutable();
}
}