Add weekly and monthly scheduled billing for offerings
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m12s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Coding Standards (pull_request) Successful in 2m54s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m39s
CI / Build Plugin Zip (pull_request) Skipped

Offerings can now bill weekly (a pending payment 24h before each lesson)
or monthly (one payment on the 1st for that month's lessons), alongside
one-time and full-term. Applies to both private lessons and group classes.

- Offering: new `weekly`/`monthly` billing modes + `isScheduledBilling()`
- Booking/enrolment defer payment for scheduled modes; a single lesson
  booked after its due date has passed (e.g. an add-on in an already-billed
  month) is charged at booking instead
- ScheduledBillingRunner: daily WP-Cron scan generates due payments across
  four cases (private/group × weekly/monthly), deduped via lesson.payment_id
  and payments.period_key
- PaymentDueMailer: one consolidated itemised email per student per scan
- Notice batch: payments emailed together share a reference; the admin
  Payments queue groups them with a lump-sum total for e-transfer reconciliation
- Cancellation never voids a scheduled payment (Payment::isScheduled())
- Schema: us_payments gains due_date, period_key, notice_batch; USC_VERSION 1.2.0

composer test, composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
2026-07-24 12:06:37 -03:00
co-authored by Claude Opus 4.8
parent 36e7178158
commit 4328e8fb5f
29 changed files with 1514 additions and 48 deletions
+18
View File
@@ -44,6 +44,9 @@ class Payment {
public readonly string $status = self::STATUS_PENDING,
public readonly float $taxRate = 0.0,
public readonly float $taxAmount = 0.0,
public readonly ?string $dueDate = null,
public readonly ?string $periodKey = null,
public readonly ?string $noticeBatch = null,
public readonly ?string $etransferEmail = null,
public readonly ?string $stripePaymentIntentId = null,
public readonly ?string $receiptNumber = null,
@@ -65,6 +68,9 @@ class Payment {
status: Val::string( $row->status ),
taxRate: Val::float( $row->tax_rate ),
taxAmount: Val::float( $row->tax_amount ),
dueDate: Val::stringOrNull( $row->due_date ?? null ),
periodKey: Val::stringOrNull( $row->period_key ?? null ),
noticeBatch: Val::stringOrNull( $row->notice_batch ?? null ),
etransferEmail: Val::stringOrNull( $row->etransfer_email ),
stripePaymentIntentId: Val::stringOrNull( $row->stripe_payment_intent_id ),
receiptNumber: Val::stringOrNull( $row->receipt_number ),
@@ -79,6 +85,15 @@ class Payment {
return self::STATUS_PAID === $this->status;
}
/**
* Whether this payment was generated by the daily billing scan (weekly /
* monthly) rather than taken at registration. Scheduled payments carry a due
* date, can cover several lessons, and are never auto-voided on cancellation.
*/
public function isScheduled(): bool {
return null !== $this->dueDate;
}
/**
* Amount billed including tax.
*/
@@ -120,6 +135,9 @@ class Payment {
'currency' => $this->currency,
'method' => $this->method,
'status' => $this->status,
'due_date' => $this->dueDate,
'period_key' => $this->periodKey,
'notice_batch' => $this->noticeBatch,
'receipt_number' => $this->receiptNumber,
'paid_at' => $this->paidAt,
'created_at' => $this->createdAt,
+52 -15
View File
@@ -33,22 +33,59 @@ class PaymentController {
}
}
$rows = array_map(
static function ( Payment $payment ): array {
$student = get_userdata( $payment->studentId );
return [
'id' => (int) $payment->id,
'student' => $student ? $student->display_name : (string) $payment->studentId,
'amount' => number_format( $payment->amount, 2 ) . ' ' . $payment->currency,
'method' => $payment->method,
'for' => $payment->registrationType . ' #' . $payment->registrationId,
'etransfer_email' => (string) $payment->etransferEmail,
];
},
$this->payments->findPending()
);
$groups = $this->groupPending( $this->payments->findPending() );
include USC_PLUGIN_DIR . 'templates/admin/payments.php';
}
/**
* Group pending payments by their shared notice batch, so payments the daily
* scan emailed a student together (and which a single lump-sum e-transfer
* covers) are shown as one group with a combined total. Payments with no batch
* — legacy at-registration e-transfers — are each their own single-item group.
*
* @param list<Payment> $pending
* @return list<array{reference: string, is_group: bool, total: string, rows: list<array{id: int, student: string, amount: string, method: string, for: string, etransfer_email: string}>}>
*/
private function groupPending( array $pending ): array {
$groups = [];
foreach ( $pending as $payment ) {
$batch = (string) $payment->noticeBatch;
$key = '' !== $batch ? 'b:' . $batch : 's:' . (string) $payment->id;
if ( ! isset( $groups[ $key ] ) ) {
$groups[ $key ] = [
'reference' => $batch,
'currency' => $payment->currency,
'total_raw' => 0.0,
'rows' => [],
];
}
$student = get_userdata( $payment->studentId );
$groups[ $key ]['total_raw'] += $payment->total();
$groups[ $key ]['rows'][] = [
'id' => (int) $payment->id,
'student' => $student ? $student->display_name : (string) $payment->studentId,
'amount' => number_format( $payment->amount, 2 ) . ' ' . $payment->currency,
'method' => $payment->method,
'for' => $payment->registrationType . ' #' . $payment->registrationId,
'etransfer_email' => (string) $payment->etransferEmail,
];
}
return array_values(
array_map(
static fn( array $group ): array => [
'reference' => $group['reference'],
'is_group' => count( $group['rows'] ) > 1,
'total' => number_format( $group['total_raw'], 2 ) . ' ' . $group['currency'],
'rows' => $group['rows'],
],
$groups
)
);
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
/**
* Emails a student a single itemised notice for every payment the daily billing
* scan generated for them in one run, so a student billed for several lessons on
* the same day receives one email with a line per item and a grand total — never
* one email per lesson.
*/
class PaymentDueMailer {
/**
* Send one student their consolidated due-payment notice for the current scan.
* The optional `$reference` is the shared notice-batch code the student can quote
* on a lump-sum e-transfer so the studio can reconcile it to these payments.
*
* @param list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}> $items
* @return bool False when there is no recipient or nothing to bill.
*/
public function send( \WP_User $student, array $items, string $reference = '' ): bool {
if ( '' === (string) $student->user_email || [] === $items ) {
return false;
}
$currency = (string) $items[0]['currency'];
$total = 0.0;
$lines = [];
$emails = [];
foreach ( $items as $item ) {
$amount = (float) $item['amount'];
$total += $amount;
$lines[] = sprintf(
/* translators: 1: item description, 2: due date, 3: currency, 4: amount */
__( '- %1$s (due %2$s): %3$s %4$s', 'unsupervised-schedular' ),
(string) $item['label'],
$this->formatDate( $item['due_date'] ?? null ),
$currency,
number_format( $amount, 2 )
);
$etransfer = (string) ( $item['etransfer_email'] ?? '' );
if ( '' !== $etransfer ) {
$emails[ $etransfer ] = true;
}
}
$body = __( 'You have upcoming payments due:', 'unsupervised-schedular' ) . "\n\n"
. implode( "\n", $lines ) . "\n\n"
. sprintf(
/* translators: 1: currency, 2: total amount */
__( 'Total due: %1$s %2$s', 'unsupervised-schedular' ),
$currency,
number_format( $total, 2 )
);
if ( [] !== $emails ) {
$body .= "\n\n" . sprintf(
/* translators: %s: e-transfer destination email address(es) */
__( 'Please send your e-transfer to: %s', 'unsupervised-schedular' ),
implode( ', ', array_keys( $emails ) )
);
}
if ( '' !== $reference ) {
$body .= "\n\n" . sprintf(
/* translators: %s: payment reference code */
__( 'Please include this reference with your payment: %s', 'unsupervised-schedular' ),
$reference
);
}
return (bool) wp_mail( $student->user_email, __( 'Payment due', 'unsupervised-schedular' ), $body );
}
/**
* Present a stored `Y-m-d` due date in a friendlier form; falls back to the
* raw value (or an empty string) when it is not a parseable date.
*/
private function formatDate( ?string $date ): string {
if ( null === $date || '' === $date ) {
return '';
}
$parsed = \DateTimeImmutable::createFromFormat( '!Y-m-d', $date );
return false !== $parsed ? $parsed->format( 'M j, Y' ) : $date;
}
}
+49 -1
View File
@@ -25,6 +25,9 @@ class PaymentRepository {
'status' => $payment->status,
'tax_rate' => $payment->taxRate,
'tax_amount' => $payment->taxAmount,
'due_date' => $payment->dueDate,
'period_key' => $payment->periodKey,
'notice_batch' => $payment->noticeBatch,
'etransfer_email' => $payment->etransferEmail,
'stripe_payment_intent_id' => $payment->stripePaymentIntentId,
'receipt_number' => $payment->receiptNumber,
@@ -32,7 +35,7 @@ class PaymentRepository {
'paid_at' => $payment->paidAt,
'created_at' => current_time( 'mysql' ),
],
[ '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s' ]
[ '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ]
);
return $this->db->insert_id;
@@ -119,6 +122,51 @@ class PaymentRepository {
return $row ? Payment::fromRow( $row ) : null;
}
/**
* Tag a set of payments with a shared notice-batch reference — the payments the
* daily scan emailed a student together, so the admin can see which pending
* payments a single lump-sum e-transfer covers. No-op for an empty id list.
*
* @param list<int> $ids
*/
public function assignNoticeBatch( array $ids, string $batch ): void {
if ( [] === $ids ) {
return;
}
$placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
$sql = $this->db->prepare(
"UPDATE %i SET notice_batch = %s WHERE id IN ( {$placeholders} )",
$this->table,
$batch,
...$ids
);
if ( null !== $sql ) {
$this->db->query( $sql );
}
}
/**
* Whether a scheduled payment already exists for a registration and billing
* period. The daily billing scan uses this to avoid double-billing an
* enrolment for the same session (weekly) or month (monthly). A voided
* (`failed`) row still counts so a cancelled charge is not silently re-created.
*/
public function existsForPeriod( string $registrationType, int $registrationId, string $periodKey ): bool {
$found = $this->db->get_var(
$this->db->prepare(
'SELECT id FROM %i WHERE registration_type = %s AND registration_id = %d AND period_key = %s LIMIT 1',
$this->table,
$registrationType,
$registrationId,
$periodKey
)
);
return null !== $found;
}
public function findByRegistration( string $registrationType, int $registrationId ): ?Payment {
$row = $this->db->get_row(
$this->db->prepare(
+32 -3
View File
@@ -29,8 +29,12 @@ class PaymentService {
* (card via Stripe — coming soon; e-transfer confirmed manually). The
* e-transfer destination is frozen now from the offering override or the studio
* default. Returns null when the registration has no price to charge.
*
* A `$dueDate`/`$periodKey` mark a payment generated later by the daily billing
* scan (weekly / monthly) rather than taken at registration; both stay null for
* the pay-now flow.
*/
public function createForRegistration( string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $offeringEtransferEmail = null ): ?Payment {
public function createForRegistration( string $type, int $registrationId, int $studentId, int $instructorId, float $amount, string $currency, ?string $offeringEtransferEmail = null, ?string $dueDate = null, ?string $periodKey = null ): ?Payment {
if ( $amount <= 0.0 ) {
return null;
}
@@ -58,6 +62,8 @@ class PaymentService {
status: $status,
taxRate: $taxRate,
taxAmount: $taxAmount,
dueDate: $dueDate,
periodKey: $periodKey,
etransferEmail: $etransferEmail,
)
);
@@ -71,6 +77,26 @@ class PaymentService {
return $this->payments->findById( $id );
}
/**
* Whether a scheduled payment already exists for a registration and billing
* period — the daily billing scan's dedup check for group enrolments (whose one
* row maps to many periodic charges). Delegates to the ledger.
*/
public function scheduledPaymentExists( string $type, int $registrationId, string $periodKey ): bool {
return $this->payments->existsForPeriod( $type, $registrationId, $periodKey );
}
/**
* Tag the payments the daily scan emailed a student together with a shared
* notice-batch reference, so a lump-sum e-transfer can be reconciled to the
* pending payments it covers. Delegates to the ledger.
*
* @param list<int> $ids
*/
public function assignNoticeBatch( array $ids, string $batch ): void {
$this->payments->assignNoticeBatch( $ids, $batch );
}
/**
* Studio-admin confirmation that a pending payment (e-transfer) was received.
* Marks it paid, confirms the registration, and emails the receipt.
@@ -92,7 +118,10 @@ class PaymentService {
/**
* Void the still-pending payment of a cancelled registration so it drops
* out of the confirmation queue. Paid payments are left alone — refunds
* are a manual, admin-side decision.
* are a manual, admin-side decision. Scheduled payments (weekly / monthly)
* are also left alone: a monthly charge can cover several lessons and may
* already be collected, so cancelling one lesson must never void it or
* trigger a rebill.
*/
public function voidPending( ?int $paymentId ): void {
if ( null === $paymentId ) {
@@ -100,7 +129,7 @@ class PaymentService {
}
$payment = $this->payments->findById( $paymentId );
if ( null !== $payment && Payment::STATUS_PENDING === $payment->status ) {
if ( null !== $payment && ! $payment->isScheduled() && Payment::STATUS_PENDING === $payment->status ) {
$this->payments->updateStatus( $paymentId, Payment::STATUS_FAILED );
}
}
+365
View File
@@ -0,0 +1,365 @@
<?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. $batchIds
// tracks the payment ids behind each student's bucket so they can be tagged
// with a shared reference for lump-sum e-transfer reconciliation.
$buckets = [];
$batchIds = [];
$this->billPrivateLessons( $now, $buckets, $batchIds );
$this->billGroupEnrollments( $now, $buckets, $batchIds );
$this->sendNotices( $buckets, $batchIds );
}
/**
* 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{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
* @param array<int, list<int>> $batchIds
*/
private function billPrivateLessons( \DateTimeImmutable $now, array &$buckets, array &$batchIds ): 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,
$batchIds,
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, $batchIds );
}
/**
* 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{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
* @param array<int, list<int>> $batchIds
*/
private function billMonthlyLessonGroups( string $today, array $monthly, array &$buckets, array &$batchIds ): 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,
$batchIds,
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{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
* @param array<int, list<int>> $batchIds
*/
private function billGroupEnrollments( \DateTimeImmutable $now, array &$buckets, array &$batchIds ): 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, $batchIds );
} else {
$this->billGroupWeekly( $now, $enrollment, $offering, $windows, $buckets, $batchIds );
}
}
}
/**
* 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{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
* @param array<int, list<int>> $batchIds
*/
private function billGroupWeekly( \DateTimeImmutable $now, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets, array &$batchIds ): 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,
$batchIds,
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<array{start: string, end: string}> $windows
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
* @param array<int, list<int>> $batchIds
*/
private function billGroupMonthly( \DateTimeImmutable $now, string $today, Enrollment $enrollment, Offering $offering, array $windows, array &$buckets, array &$batchIds ): 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,
$batchIds,
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 an itemised line to the student's notice bucket and record its payment id
* for the shared notice batch. Returns the created payment, or null when there
* was nothing to charge.
*
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
* @param array<int, list<int>> $batchIds
*/
private function bill( array &$buckets, array &$batchIds, 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 ][] = [
'label' => $label,
'amount' => $payment->total(),
'currency' => $payment->currency,
'due_date' => $payment->dueDate,
'etransfer_email' => $payment->etransferEmail,
];
$batchIds[ $studentId ][] = $payment->id;
}
return $payment;
}
/**
* Tag each student's payments with a shared batch reference and email them one
* itemised notice quoting it, so a lump-sum e-transfer can be reconciled to the
* exact pending payments it covers.
*
* @param array<int, list<array{label: string, amount: float, currency: string, due_date: ?string, etransfer_email: ?string}>> $buckets
* @param array<int, list<int>> $batchIds
*/
private function sendNotices( array $buckets, array $batchIds ): void {
foreach ( $buckets as $studentId => $items ) {
$reference = $this->reference();
$this->payments->assignNoticeBatch( $batchIds[ $studentId ] ?? [], $reference );
$user = get_userdata( $studentId );
if ( $user instanceof \WP_User ) {
$this->mailer->send( $user, $items, $reference );
}
}
}
/**
* 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();
}
}