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. $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. * * @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. $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 * $count, $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 student'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. * * @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 { $payment = $this->payments->createForRegistration( $type, $registrationId, $studentId, $instructorId, $amount, $currency, $etransferEmail, $dueDate, $periodKey ); if ( null !== $payment && null !== $payment->id && Payment::STATUS_PENDING === $payment->status ) { $buckets[ $studentId ][] = [ 'payment' => $payment, 'label' => $label, ]; } return $payment; } /** * For each student, 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 $studentId => $entries ) { $payments = array_map( static fn( array $entry ): Payment => $entry['payment'], $entries ); $applied = $this->payments->applyCredits( $studentId, $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( $studentId ); 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(); } }