CI / Tests (PHP 8.2) (pull_request) Successful in 42s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m45s
CI / Build Plugin Zip (pull_request) Skipped
CI / Tests (PHP 8.1) (pull_request) Failing after 52s
CI / No Debug Code (pull_request) Successful in 2s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Coding Standards (pull_request) Successful in 2m57s
A parent registers once and manages lessons for one or more children, who need no login of their own. A child is a real wp_users row with the student role but no usable login — so student_id keeps meaning "a WordPress user" on every table, and booking, credits, policies and enrolments work unchanged. A us_guardians link table maps guardian to child. The signup form gains a parent/guardian tick that reveals a block per child, with the account-signup questions asked per child rather than per guardian — they describe the student, not the account holder. Signup policies are recorded once per child with the guardian as the acceptor, which is the record that actually means something. A family that half-creates is rolled back entirely rather than leaving a guardian who cannot re-register. The booking and enrolment forms gain a "Who is this for?" picker listing children first, so the default selection is never the parent — booking for the wrong child is correctable, quietly billing a parent for their kid's lesson is not. POST /bookings and POST /enrollments take an optional student_id honoured only for that child's guardian; anything else is a 403. That check is the authorisation boundary of the feature. Payments and credits gain a payer: the charge names the child it was for and the guardian who owes it, so per-child reporting is unchanged while notices, receipts and the payment step reach the parent. Credit is held by the payer, so one child's cancellation can settle a sibling's charge, and the daily billing scan sends a guardian one notice covering every child. Closes #132 Co-Authored-By: Claude Opus 5 <[email protected]>
104 lines
6.2 KiB
Markdown
104 lines
6.2 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)
|
||
|
||
## One Notice Per Family
|
||
Charges are bucketed by **payer**, not student, so a guardian gets a single
|
||
notice covering every child rather than one email per child. Each line names the
|
||
student it is for when that is not the payer ("Ada: Piano Lesson — Mar 3, 2026"),
|
||
and account credit is applied across the whole bucket from the family balance.
|
||
See `parent-guardian-accounts.md`.
|