Files
unsupervised-scheduler/docs/features/scheduled-billing.md
T
thatguygriffandClaude Opus 5 bfdc3b3380
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
Bill a monthly group class its fee once per month
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]>
2026-07-28 15:22:46 -03:00

5.9 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. A private lesson's fee is per lesson, so the month costs (#lessons) × fee. A group class's fee is per month: the class is billed that fee once for the month, however many times it meets in it.

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 1 × fee (a monthly class is priced per month, not per session) 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.

Cancelling a lesson that was already paid credits the student one lesson's share of what they paid (PaymentService::creditForCancelledLesson), and the next scan applies that credit against their due charges before emailing the notice (PaymentService::applyCredits). See credits.md for the full model.

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)