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
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:
@@ -259,7 +259,16 @@ class BookingEndpoint {
|
||||
$payment = null;
|
||||
$status = Lesson::STATUS_PENDING;
|
||||
|
||||
if ( $offering->price > 0.0 ) {
|
||||
// Scheduled billing (weekly / monthly) normally defers payment to the daily
|
||||
// scan, but a single lesson booked once its scheduled due date has already
|
||||
// passed — e.g. an extra lesson added to a month that was already billed — is
|
||||
// charged at booking instead, so it is never missed or billed late.
|
||||
$chargeAtBooking = $offering->price > 0.0 && (
|
||||
! $offering->isScheduledBilling()
|
||||
|| ( 1 === count( $ids ) && $this->scheduledDueHasPassed( $offering, $slot->startDt ) )
|
||||
);
|
||||
|
||||
if ( $chargeAtBooking ) {
|
||||
// A full-term price already covers the whole reservation; a per-lesson
|
||||
// (one_time) price is owed once per occurrence actually claimed, so a
|
||||
// weekly reservation cannot hold a term while paying for one week.
|
||||
@@ -273,8 +282,10 @@ class BookingEndpoint {
|
||||
$status = Lesson::STATUS_CONFIRMED;
|
||||
}
|
||||
} else {
|
||||
// Free offering: there is no payment step that would confirm these
|
||||
// lessons later, so they are confirmed at booking time.
|
||||
// Either a free offering, or scheduled billing (weekly / monthly) whose
|
||||
// payment is deferred to the daily billing scan. Either way there is no
|
||||
// payment step now to confirm the lessons, so the reserved slots are
|
||||
// confirmed at booking time; the billing scan bills them when they come due.
|
||||
foreach ( $ids as $lessonId ) {
|
||||
$this->bookings->updateStatus( $lessonId, Lesson::STATUS_CONFIRMED );
|
||||
}
|
||||
@@ -306,6 +317,23 @@ class BookingEndpoint {
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a scheduled-billing offering's due date for a given session has
|
||||
* already passed at booking time. Weekly bills 24 hours before the lesson;
|
||||
* monthly bills on the 1st, so its due moment has passed once "now" is in the
|
||||
* lesson's month or later. Only meaningful for weekly / monthly offerings.
|
||||
*/
|
||||
private function scheduledDueHasPassed( Offering $offering, string $slotStart ): bool {
|
||||
$now = new \DateTimeImmutable( Val::string( current_time( 'mysql' ) ) );
|
||||
$start = new \DateTimeImmutable( $slotStart );
|
||||
|
||||
if ( Offering::BILLING_MONTHLY === $offering->billingMode ) {
|
||||
return $now->format( 'Y-m-d' ) >= $start->format( 'Y-m-01' );
|
||||
}
|
||||
|
||||
return $now >= $start->modify( '-1 day' );
|
||||
}
|
||||
|
||||
private function clientIp(): ?string {
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- IP stored verbatim for audit.
|
||||
$ip = sanitize_text_field( Val::string( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) );
|
||||
|
||||
Reference in New Issue
Block a user