# 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. Refunds/credits are a manual, admin-side decision. ## 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)