Files
unsupervised-scheduler/docs/features/scheduled-billing.md
T
thatguygriffandClaude Opus 4.8 e8e66eef3c
CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / PHPStan (pull_request) Successful in 3m12s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m42s
CI / Build Plugin Zip (pull_request) Skipped
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 2m52s
Credit students for cancelled paid lessons
Cancelling a lesson that was already paid for now credits the student
that money instead of leaving it as a manual refund, and the daily
scheduled-billing scan applies any available credit against their due
charges before emailing the notice.

- New us_credits ledger + us_payments.credit_applied column (Payment::netDue).
- PaymentService::creditForCancelledLesson issues a per-lesson share of the
  covering payment's total; wired into all three cancel paths (student
  self-cancel, instructor status update, admin student-detail cancel).
- PaymentService::applyCredits draws credit down FIFO across a run's charges,
  marking a fully-covered charge paid-by-credit; the notice shows the credit
  applied and reduced total, and the admin queue shows net due.
- Student detail page shows a student's credit balance and history.

Ships as part of the unreleased 1.2.0 (same release as scheduled billing).

Tests: composer test (585), composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-24 15:32:20 -03:00

5.7 KiB
Raw Permalink 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.

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)