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
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]>
97 lines
5.9 KiB
Markdown
97 lines
5.9 KiB
Markdown
# 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)
|