Add weekly and monthly scheduled billing for offerings
CI / Tests (PHP 8.2) (pull_request) Successful in 39s
CI / Tests (PHP 8.1) (pull_request) Successful in 1m12s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 2m52s
CI / Coding Standards (pull_request) Successful in 2m54s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m39s
CI / Build Plugin Zip (pull_request) Skipped

Offerings can now bill weekly (a pending payment 24h before each lesson)
or monthly (one payment on the 1st for that month's lessons), alongside
one-time and full-term. Applies to both private lessons and group classes.

- Offering: new `weekly`/`monthly` billing modes + `isScheduledBilling()`
- Booking/enrolment defer payment for scheduled modes; a single lesson
  booked after its due date has passed (e.g. an add-on in an already-billed
  month) is charged at booking instead
- ScheduledBillingRunner: daily WP-Cron scan generates due payments across
  four cases (private/group × weekly/monthly), deduped via lesson.payment_id
  and payments.period_key
- PaymentDueMailer: one consolidated itemised email per student per scan
- Notice batch: payments emailed together share a reference; the admin
  Payments queue groups them with a lump-sum total for e-transfer reconciliation
- Cancellation never voids a scheduled payment (Payment::isScheduled())
- Schema: us_payments gains due_date, period_key, notice_batch; USC_VERSION 1.2.0

composer test, composer lint, composer cs all pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
2026-07-24 12:06:37 -03:00
co-authored by Claude Opus 4.8
parent 36e7178158
commit 4328e8fb5f
29 changed files with 1514 additions and 48 deletions
+7 -1
View File
@@ -15,7 +15,7 @@ An offering is anything a student can register for: a private-lesson type (30 or
| `duration_minutes` | SMALLINT | Private lessons only (e.g. 30, 60); NULL for group classes |
| `price` | DECIMAL(10,2) | Price in dollars |
| `currency` | VARCHAR(3) | ISO 4217, e.g. `CAD` |
| `billing_mode` | VARCHAR(20) | `one_time` (single booking) or `full_term` (weekly / group) |
| `billing_mode` | VARCHAR(20) | `one_time`, `full_term`, `weekly`, or `monthly` (see Billing Mode below) |
| `allow_weekly` | TINYINT(1) | Private only — may be reserved weekly for the term |
| `capacity` | SMALLINT | Group only — max enrolments; NULL for private |
| `term_start` | DATE | Group / term offerings — first day; NULL otherwise |
@@ -31,6 +31,12 @@ An offering is anything a student can register for: a private-lesson type (30 or
## Billing Mode
- `one_time` — charged once at booking (a single private lesson).
- `full_term` — charged in full upfront at registration (a weekly private reservation or a year-long group class). See `payments.md`.
- `weekly`**not** charged at registration; a pending payment for one lesson's fee is generated **24 hours before each lesson** by the daily billing scan.
- `monthly`**not** charged at registration; on the **1st of each month** a single pending payment is generated for every lesson that falls in that month (4 lessons ⇒ 4 × fee).
`weekly` and `monthly` are *scheduled* billing (`Offering::isScheduledBilling()`): the
booking/enrolment succeeds with no payment step, and payments are created later by the
daily `us_generate_due_payments` cron scan. See `scheduled-billing.md` and `payments.md`.
## Term Dates
Group classes carry a term: `term_start` is the date of the first class and
+16
View File
@@ -88,6 +88,9 @@ After booking, the destination on a payment can be corrected per booking:
| `status` | VARCHAR(20) | `pending` / `paid` / `failed` / `refunded` |
| `tax_rate` | DECIMAL(5,2) | HST rate % frozen at booking; editable until paid |
| `tax_amount` | DECIMAL(10,2) | Computed tax in dollars (`amount × tax_rate / 100`) |
| `due_date` | DATE | When a *scheduled* payment is due; NULL = due at registration (`Payment::isScheduled()`) |
| `period_key` | VARCHAR(20) | Scheduled-billing dedup key: session date (weekly) or `YYYY-MM` (monthly); NULL otherwise |
| `notice_batch` | VARCHAR(32) | Shared reference for the payments one due-notice email covers, so a lump-sum e-transfer reconciles to them; NULL otherwise |
| `etransfer_email` | VARCHAR(191) | Frozen e-transfer destination; editable until confirmed |
| `stripe_payment_intent_id` | VARCHAR(255) | Stripe PaymentIntent id; NULL for e-transfer / comp |
| `receipt_number` | VARCHAR(50) | Sequential receipt id; set when `paid` |
@@ -102,6 +105,19 @@ After booking, the destination on a payment can be corrected per booking:
4. On transition to `paid`, `ReceiptMailer` assigns a `receipt_number`, emails the student a receipt, and stamps `receipt_sent_at`.
5. For an e-transfer, the studio admin later calls `PATCH /payments/{id}` to mark it `paid`, which triggers the same confirmation + receipt.
## Scheduled Billing (weekly / monthly)
`weekly` and `monthly` offerings are **not** charged at registration. The booking /
enrolment succeeds with `payment: null`; the lesson is confirmed (or the enrolment stays
active) immediately, and payments are generated later by the daily
`us_generate_due_payments` cron scan (`Payment\ScheduledBillingRunner`). Each generated
payment carries a `due_date` and `period_key`, flows through the same
`PaymentService::createForRegistration` (so HST, method resolution, e-transfer freezing
and comp auto-pay are identical), and the student is emailed one consolidated itemised
notice per scan (`Payment\PaymentDueMailer`). Because these payments are scheduled,
`PaymentService::voidPending` never voids them — cancelling one lesson leaves a shared
monthly charge (and every other lesson it covers) untouched, and never rebills. Full
model, dedup, and the four generation cases are documented in `scheduled-billing.md`.
## REST API
| Method | Endpoint | Permission |
|---------|---------------------------------------------|-----------------------------|
+90
View File
@@ -0,0 +1,90 @@
# 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)