CI / Tests (PHP 8.1) (pull_request) Successful in 49s
CI / Tests (PHP 8.2) (pull_request) Successful in 52s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m50s
CI / Coding Standards (pull_request) Successful in 2m58s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / Build Plugin Zip (pull_request) Skipped
A monthly group class multiplied its price by the sessions falling in the month, the same rule private lessons use — so a class priced at 40.00 CAD meeting weekly was billed 160.00 CAD on the 1st, and no studio could quote the price on a class card without lying about it. A group class is now billed its fee once for the month however many times it meets, which is what the card quotes and what the student ticks to agree to. Private lessons keep the per-lesson rule: their price is a per-lesson fee, and that is why the card quotes it per lesson. The session count still labels the month on the student's payment notice; it no longer prices it. Co-Authored-By: Claude Opus 5 <[email protected]>
390 lines
14 KiB
PHP
390 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\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.
|
||
*
|
||
* 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,
|
||
) {}
|
||
|
||
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 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<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 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<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 );
|
||
|
||
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<int, list<array{payment: Payment, label: string}>> $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();
|
||
}
|
||
}
|