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]>
408 lines
14 KiB
PHP
408 lines
14 KiB
PHP
<?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();
|
||
}
|
||
}
|