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]>
5.7 KiB
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 viacreateForRegistrationand the runner points the remaining lessons at the same payment. - Group enrolments (one row per whole term) dedup on
period_keyviaPaymentRepository::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 inunsupervised-schedular.phpdeactivation hook.
Tests
tests/Unit/Payment/ScheduledBillingRunnerTest.phptests/Unit/Payment/PaymentDueMailerTest.phptests/Unit/Payment/PaymentRepositoryTest.php(existsForPeriod,due_date/period_key)tests/Unit/Payment/PaymentServiceTest.php(voidPendingskips scheduled)tests/Unit/Booking/BookingEndpointTest.php/tests/Unit/GroupClass/EnrollmentEndpointTest.php(deferred payment)