Files
unsupervised-scheduler/docs/features/payments.md
T
thatguygriffandClaude Opus 5 c077a653fb
CI / No Debug Code (pull_request) Successful in 5s
CI / Tests (PHP 8.2) (pull_request) Successful in 54s
CI / Tests (PHP 8.1) (pull_request) Successful in 56s
CI / Coding Standards (pull_request) Successful in 2m55s
CI / PHPStan (pull_request) Successful in 3m48s
CI / Tests (PHP 8.3) (pull_request) Successful in 5m31s
CI / Build Plugin Zip (pull_request) Skipped
Let a studio pick its default payment method and disconnect Stripe
Stripe configuration was a one-way door. Keys could be entered but never
removed, and entering them moved every student onto card billing at once,
so there was no way to have Stripe live and satisfy yourself that card
payments worked before committing the studio to them.

Two settings-page changes open both directions:

Default payment method (`us_default_payment_method`) is now an explicit
choice between card and e-transfer for students with no per-student
override, rather than something inferred from whether keys exist. Card
remains the default, so a site that adds keys and changes nothing else
behaves as before. BillingMethodResolver still degrades a card default to
e-transfer while Stripe is unconfigured — there is nothing to charge a
card with — and `comp` is deliberately not offerable studio-wide, since
it would silently stop billing everybody; an unrecognised stored value
reads back as card. Holding the default on e-transfer with Stripe live is
the staged-rollout path: move individual students to card on their detail
page, watch real charges land, then flip the studio over.

Clear Stripe configuration deletes the publishable key, secret key and
webhook signing secret and returns the mode to test, so a re-configuration
later cannot inherit live. Currency, HST, e-transfer and registration
settings are untouched, as are recorded payments. The button only appears
when some Stripe value is stored, and reuses the page's existing nonce and
`manage_billing` check.

`composer test` (915), `composer lint` and `composer cs` all pass. Options
only — no schema change, so no version bump.

Closes #173

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WyktWmwNRgMYuwe5eBuPZm
2026-08-20 12:43:29 -03:00

237 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` |
| `us_default_payment_method` | Studio-default billing method (`card` \| `etransfer`) |
Secrets are write-only in the form: a stored secret is never echoed back, and a
blank field keeps it. To disconnect Stripe entirely, **Clear Stripe
configuration** (shown once any Stripe value is stored) deletes the publishable
key, secret key and webhook secret and drops the mode back to `test`
(`StudioSettings::clearStripeConfig()`). Currency, HST, e-transfer and
registration settings are untouched, as are payments already recorded; billing
falls back to e-transfer until keys are entered again.
## 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 (`BillingMethodResolver::defaultMethod()`): the
`us_default_payment_method` option, degraded to `etransfer` whenever Stripe is not
configured, since a card cannot be charged without keys.
| 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 |
## Studio Default Billing Method
**Studio Settings → Billing → Default payment method** (`manage_billing`) chooses
between `card` and `etransfer` for every student without an override. Card is the
default, so a studio that adds Stripe keys and changes nothing else behaves as it
always has.
Setting it to `etransfer` is the **staged rollout** path: Stripe stays live, but
the studio keeps billing by e-transfer while individual students are switched to
`card` on their student detail page. Their bookings exercise real Stripe charges
end to end; once that is proven, flipping the studio default to `card` moves
everyone at once and the per-student overrides can be cleared.
`comp` is deliberately not offered as a studio default — it is a per-student
decision, and a studio-wide `comp` would silently stop billing everybody. A stored
value that is neither `card` nor `etransfer` reads back as `card`.
## 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`.