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]>
211 lines
14 KiB
Markdown
211 lines
14 KiB
Markdown
# Feature: Payments
|
||
|
||
## Overview
|
||
Payment is taken at registration. When Stripe is **not** configured the platform
|
||
falls back to **e-transfer** — a pending payment a studio admin marks received —
|
||
so everything works without any credentials. When Stripe **is** configured the
|
||
default rail becomes the **credit card**. The studio admin can override any
|
||
student's method (card / e-transfer / comp). Single bookings are charged once;
|
||
weekly reservations and group classes are charged the full term upfront (a
|
||
`full_term` price once, or a per-lesson `one_time` price × the occurrences
|
||
reserved — see `lesson-booking.md`). A
|
||
numbered receipt is emailed automatically when a payment is marked paid.
|
||
|
||
> **Implemented:** the payment ledger, studio settings, method resolution
|
||
> (e-transfer default with no Stripe; card default when configured; per-student
|
||
> override), the e-transfer/comp flow with admin confirmation + receipts,
|
||
> integration into booking/enrolment (a registration's `payment_id` is linked;
|
||
> comp auto-confirms; e-transfer stays pending until confirmed), and the **live
|
||
> Stripe card charge** — a PaymentIntent created on `POST /payments/intent`,
|
||
> confirmed in the browser with Stripe.js Payment Elements, and finalised by the
|
||
> `POST /payments/webhook` handler (signature-verified) on
|
||
> `payment_intent.succeeded`. Uses the `stripe/stripe-php` SDK.
|
||
|
||
## Stripe Configuration
|
||
Stripe credentials live in WordPress options, managed on the **Studio Settings**
|
||
page (`manage_billing`, studio admin only):
|
||
|
||
| Option | Notes |
|
||
|------------------------------|----------------------------------------|
|
||
| `us_stripe_publishable_key` | Stripe publishable key |
|
||
| `us_stripe_secret_key` | Stripe secret key |
|
||
| `us_stripe_webhook_secret` | Webhook signing secret (`whsec_…`) |
|
||
| `us_stripe_mode` | `test` or `live` |
|
||
| `us_currency` | Default ISO 4217 currency, e.g. `CAD` |
|
||
| `us_etransfer_email` | Studio-default e-transfer destination |
|
||
| `us_hst_rate` | Default HST/tax percentage, e.g. `13` |
|
||
|
||
## HST / Tax
|
||
|
||
A studio-default **HST rate** (percentage) is configured on **Studio Settings**
|
||
(`us_hst_rate`, `manage_billing`). At booking the rate is **frozen onto the
|
||
payment** (`us_payments.tax_rate`) and the tax is computed against the pre-tax
|
||
subtotal (`tax_amount = round(amount × rate / 100, 2)`). The billed total is
|
||
`amount + tax_amount` (`Payment::total()`). Comped registrations are never taxed
|
||
(rate and amount are 0).
|
||
|
||
The rate is overridable **per booking** on **My Lessons** (instructor) while the
|
||
payment is still unpaid; saving recomputes `tax_amount` from the current
|
||
subtotal (`PaymentRepository::updateTax`). Receipts break out subtotal, HST, and
|
||
total when tax applies.
|
||
|
||
## Per-Student Billing Method
|
||
Each student's billing method is stored in user meta `us_payment_method`, set by the
|
||
studio admin (`Students → student detail → Billing method`). When unset, the studio
|
||
default applies — `card` if Stripe is configured, otherwise `etransfer`
|
||
(`BillingMethodResolver`):
|
||
|
||
| Method | Behaviour |
|
||
|------------|-----------------------------------------------------------------------|
|
||
| `card` | Charged immediately via Stripe; payment `paid` on success |
|
||
| `etransfer`| Payment row created `pending`; admin marks it `paid` when funds arrive |
|
||
| `comp` | No charge; registration is confirmed immediately, no payment row required |
|
||
|
||
## E-transfer Destination Email
|
||
Where students send e-transfers is resolved and **frozen onto the payment** at
|
||
booking time (`us_payments.etransfer_email`), so each record keeps the destination
|
||
the student was given. Resolution at creation:
|
||
|
||
1. **Offering override** — `us_offerings.etransfer_email`, set by the instructor on the offering.
|
||
2. **Studio default** — the `us_etransfer_email` option (Studio Settings, `manage_billing`).
|
||
|
||
After booking, the destination on a payment can be corrected per booking:
|
||
- **My Lessons** — the instructor edits the e-transfer email for a pending lesson payment.
|
||
- **Payments queue** — when marking an e-transfer received, the studio admin can update the email it was actually sent to before confirming.
|
||
|
||
## Data Model — `{prefix}us_payments`
|
||
|
||
| Column | Type | Notes |
|
||
|----------------------------|------------------|--------------------------------------------------------|
|
||
| `id` | BIGINT UNSIGNED | Primary key |
|
||
| `student_id` | BIGINT UNSIGNED | WordPress user ID |
|
||
| `instructor_id` | BIGINT UNSIGNED | WordPress user ID (denormalised for reporting) |
|
||
| `registration_type` | VARCHAR(20) | `lesson` or `enrollment` |
|
||
| `registration_id` | BIGINT UNSIGNED | FK → `us_lessons.id` or `us_group_enrollments.id` |
|
||
| `amount` | DECIMAL(10,2) | Charged amount in dollars (matches the offering price) |
|
||
| `currency` | VARCHAR(3) | ISO 4217, e.g. `CAD` |
|
||
| `method` | VARCHAR(20) | `card` / `etransfer` / `comp` |
|
||
| `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` |
|
||
| `receipt_sent_at` | DATETIME | When the receipt email was sent; NULL until sent |
|
||
| `created_at` | DATETIME | Insertion time |
|
||
| `paid_at` | DATETIME | When marked `paid`; NULL otherwise |
|
||
|
||
## Price Display and the Pay Agreement
|
||
Every price a student is shown on the front end carries its **cadence** — the
|
||
offering's `billing_mode` in the words the student needs:
|
||
|
||
| `billing_mode` | Shown as | Explained beneath as |
|
||
|----------------|-----------------------------------|------------------------------------------------------------|
|
||
| `one_time` | `at booking` | Charged once, when you book. |
|
||
| `full_term` | `up front` | Charged once, up front, for the whole term. |
|
||
| `weekly` | `weekly` | Charged for each lesson, 24 hours before it starts. |
|
||
| `monthly` | `per lesson monthly` / `monthly` | Charged on the 1st of each month, for that month's lessons.|
|
||
|
||
So a lesson type reads `50.00 CAD at booking` in the booking form's type picker,
|
||
and a group class card reads `120.00 CAD up front`. A free offering shows `Free`.
|
||
|
||
**`monthly` reads differently per offering kind, because it *bills* differently.**
|
||
A private lesson's price is a per-lesson fee and its monthly charge is that
|
||
month's lessons × the fee, so the fee is quoted **per lesson**
|
||
(`50.00 CAD per lesson monthly`). A monthly group class is priced **per month** —
|
||
`ScheduledBillingRunner::billGroupMonthly()` charges the fee once for the month
|
||
however many times the class meets in it — so its figure is quoted as it stands
|
||
(`120.00 CAD monthly`). The display split is `isPerLessonMonthly()` in
|
||
`assets/js/pricing.js`; the billing split is the one place the monthly rule
|
||
differs between the two kinds.
|
||
|
||
Before a booking or enrolment can be submitted, the form shows the price again as
|
||
a summary block with a **required agreement checkbox** — the second confirmation,
|
||
distinct from the policy acceptances above it:
|
||
|
||
> ☐ I agree to pay 56.50 CAD at booking.
|
||
|
||
The agreed figure is the amount actually billed, so the studio **HST rate** is
|
||
added to it (`usScheduler.taxRate`, localized from `us_hst_rate`) and broken out
|
||
above the checkbox — matching the total `Payment::total()` charges. A comped
|
||
student is not taxed and is not charged at all, so for them the quoted figure is
|
||
an upper bound. A free offering has nothing to agree to and shows no block.
|
||
|
||
Cadence-specific wording:
|
||
|
||
- **Weekly reservation of a `one_time` lesson type** — the fee is charged once per
|
||
week claimed, so the agreement states the per-lesson amount and the total as a
|
||
ceiling ("up to 12 lessons, 678.00 CAD in total"). The occurrence count mirrors
|
||
`BookingEndpoint::MAX_WEEKLY_OCCURRENCES`; a slot another student takes first is
|
||
simply not claimed, so the real charge can come in under it.
|
||
- **`weekly` / `monthly`** — nothing is taken at registration, so the agreement is
|
||
to the recurring charge: "I agree to pay 56.50 CAD per lesson, billed monthly."
|
||
A monthly **group class** agrees to its monthly figure instead ("I agree to pay
|
||
138.00 CAD monthly."), matching how its price is quoted on the card.
|
||
|
||
All of this lives in `assets/js/pricing.js` (`window.usPricing`), shared by the
|
||
booking and group-class flows so a price reads the same wherever it is met. The
|
||
script is registered as `us-scheduler-pricing` and is a dependency of both
|
||
`us-scheduler` and `us-scheduler-group`.
|
||
|
||
## Payment Flow
|
||
1. During registration the front-end calls `POST /payments/intent` — but only when the registration response carried a `payment` summary (unpriced registrations return `payment: null` and skip the payment step). The intent call creates a Stripe PaymentIntent for a `card` student and returns the client secret. (`etransfer` returns a `pending` payment; `comp` returns none.)
|
||
2. The browser confirms the card payment with Stripe.
|
||
3. Stripe calls `POST /payments/webhook`; on `payment_intent.succeeded` the payment is marked `paid`, `paid_at` is stamped, and the linked lesson/enrolment is `confirmed`.
|
||
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`.
|
||
|
||
Cancelling a lesson that was **already paid** issues the student an account credit for
|
||
that lesson's share of what they paid; the next daily scan applies any available credit
|
||
against their due charges (reducing `us_payments.credit_applied` → `Payment::netDue()`)
|
||
before emailing the notice. See `credits.md`.
|
||
|
||
## REST API
|
||
| Method | Endpoint | Permission |
|
||
|---------|---------------------------------------------|-----------------------------|
|
||
| `POST` | `/wp-json/us-scheduler/v1/payments/intent` | `book_lesson` |
|
||
| `POST` | `/wp-json/us-scheduler/v1/payments/webhook` | Public (Stripe signature verified) |
|
||
| `PATCH` | `/wp-json/us-scheduler/v1/payments/{id}` | `manage_billing` |
|
||
|
||
See `payment-reporting.md` for the monthly report and CSV export endpoints.
|
||
|
||
## Implementation
|
||
- Repository: `Unsupervised\Schedular\Payment\PaymentRepository`
|
||
- Model: `Unsupervised\Schedular\Payment\Payment`
|
||
- Stripe gateway: `Unsupervised\Schedular\Payment\StripeGateway`
|
||
- Receipts: `Unsupervised\Schedular\Payment\ReceiptMailer`
|
||
- Settings page: `Unsupervised\Schedular\Payment\StudioSettings`
|
||
- REST endpoint: `Unsupervised\Schedular\Payment\PaymentEndpoint`
|
||
- Front-end price display + pay agreement: `assets/js/pricing.js` (`window.usPricing`), registered and localized with `taxRate` by `Unsupervised\Schedular\ShortcodeRegistrar`
|
||
|
||
## Tests
|
||
- `tests/Unit/ShortcodeRegistrarTest.php` (pricing helper registration + localized `taxRate`)
|
||
- `tests/Unit/Payment/PaymentRepositoryTest.php`
|
||
- `tests/Unit/Payment/PaymentTest.php`
|
||
- `tests/Unit/Payment/StripeGatewayTest.php`
|
||
- `tests/Unit/Payment/ReceiptMailerTest.php`
|
||
|
||
## Who Pays
|
||
`us_payments.student_id` names the student the charge is *for*;
|
||
`us_payments.payer_id` names who **owes** it — a child's guardian, or 0 meaning
|
||
the student pays for themselves (`Payment::payerOrStudent()`). The billing
|
||
method, receipts, payment notices and the Stripe payment step all resolve the
|
||
payer, so a family is billed and comped as one account while per-child reporting
|
||
is unchanged. See `parent-guardian-accounts.md`.
|