Files
unsupervised-scheduler/docs/features/scheduled-billing.md
T
thatguygriffandClaude Opus 4.8 4328e8fb5f
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
Add weekly and monthly scheduled billing for offerings
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]>
2026-07-24 12:06:37 -03:00

5.4 KiB
Raw Blame History

Feature: Scheduled Billing (weekly / monthly)

Overview

Two offering billing modes defer payment past registration and generate pending payments on a recurring schedule:

  • weekly — one payment per lesson, due 24 hours before that lesson.
  • monthly — one payment per calendar month, due on the 1st, covering every lesson that falls in the month (4 lessons ⇒ 4 × fee).

Both apply to private lessons and group classes. At registration the booking/enrolment succeeds with payment: null (no payment step); the lesson is confirmed / the enrolment stays active immediately. Payments are created later by a daily WP-Cron scan, and the student is emailed one consolidated notice per scan. Collection uses the existing rails (e-transfer confirmed by the studio admin, or card) — there is no automatic card charging.

The daily scan — Payment\ScheduledBillingRunner

Hooked to the WP-Cron action us_generate_due_payments (scheduled daily by Installer, cleared on plugin deactivation). run() is self-healing: it re-derives everything due from current ledger state each run, so a missed day is simply picked up next time. Every payment is created through PaymentService::createForRegistration (HST, method resolution, e-transfer freezing, comp auto-pay reused) with a due_date and period_key set.

The four generation cases

Source When it bills Amount Dedup
Private weekly lesson start_dt ≤ now + 24h 1 × fee us_lessons.payment_id set on the lesson
Private monthly the lesson's month's 1st ≤ today (#lessons in month) × fee payment_id set on every lesson in the month
Group weekly session (from Offering::sessionWindows()) 1 day ≤ now 1 × fee us_payments.period_key = session date
Group monthly the month's 1st ≤ today (#sessions in month) × fee period_key = YYYY-MM
  • Private lessons dedup on us_lessons.payment_id IS NULL — a lesson with no payment is unbilled. A monthly group links its earliest lesson via createForRegistration and the runner points the remaining lessons at the same payment.
  • Group enrolments (one row per whole term) dedup on period_key via PaymentRepository::existsForPeriod(), since one enrolment maps to many periodic charges.
  • Only offerings with a positive price are billed; cancelled lessons are excluded, so a lesson cancelled before its payment is generated is simply never billed.

Late bookings charge at booking time

A single scheduled lesson booked after its due date has already passed is charged at booking instead of deferred (BookingEndpoint::scheduledDueHasPassed): an extra monthly lesson added to a month that was already billed (its 1st has arrived), or a weekly lesson booked within 24 hours of the session. These create a normal at-registration payment (no due_date), so the fee is collected once, at booking, and never billed late by the scan. This applies only to single bookings — a weekly reservation series always defers, each lesson billed by the scan on its own schedule.

Notification — Payment\PaymentDueMailer

As the runner creates each pending payment it appends an itemised line to that student's notice bucket; after all cases run it sends one email per student with a line per item (label · due date · amount) and a grand total, plus the e-transfer destination(s). A student billed for several lessons on one day is emailed once, never per lesson. Comp payments (auto-paid) are not bucketed.

Notice batch (lump-sum reconciliation)

All the payments in one student's notice are tagged with a shared notice batch reference (us_payments.notice_batch, PaymentRepository::assignNoticeBatch), which is printed on the email so the student can quote it. In the Payments admin queue those payments are shown grouped under that reference with a combined lump-sum total (PaymentController::groupPending), so when one e-transfer arrives for the whole notice the admin can see exactly which pending payments — and therefore which bookings — it covers. Each is still confirmed individually with Mark received. Legacy at-registration payments have no batch and appear on their own.

Cancellation

Scheduled payments are never auto-voided. PaymentService::voidPending acts only on legacy at-registration payments (! Payment::isScheduled()), so cancelling one lesson never voids a shared monthly charge, never refunds, and never rebills. Refunds/credits are a manual, admin-side decision.

Implementation

  • Runner: Unsupervised\Schedular\Payment\ScheduledBillingRunner
  • Notice email: Unsupervised\Schedular\Payment\PaymentDueMailer
  • Finders: Booking\BookingRepository::findUnbilledScheduledLessons, GroupClass\EnrollmentRepository::findActiveByBillingModes
  • Dedup: Payment\PaymentRepository::existsForPeriod
  • Session windows: Offering\Offering::sessionWindows
  • Cron scheduling: Installer::scheduleBilling; cleared in unsupervised-schedular.php deactivation hook.

Tests

  • tests/Unit/Payment/ScheduledBillingRunnerTest.php
  • tests/Unit/Payment/PaymentDueMailerTest.php
  • tests/Unit/Payment/PaymentRepositoryTest.php (existsForPeriod, due_date/period_key)
  • tests/Unit/Payment/PaymentServiceTest.php (voidPending skips scheduled)
  • tests/Unit/Booking/BookingEndpointTest.php / tests/Unit/GroupClass/EnrollmentEndpointTest.php (deferred payment)