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> $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> $monthly * @param array> $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> $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 $windows * @param array> $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 $windows * @param array> $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> $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> $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(); } }