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
+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(