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]>
14 KiB
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_idis linked; comp auto-confirms; e-transfer stays pending until confirmed), and the live Stripe card charge — a PaymentIntent created onPOST /payments/intent, confirmed in the browser with Stripe.js Payment Elements, and finalised by thePOST /payments/webhookhandler (signature-verified) onpayment_intent.succeeded. Uses thestripe/stripe-phpSDK.
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:
- Offering override —
us_offerings.etransfer_email, set by the instructor on the offering. - Studio default — the
us_etransfer_emailoption (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_timelesson 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 mirrorsBookingEndpoint::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
- During registration the front-end calls
POST /payments/intent— but only when the registration response carried apaymentsummary (unpriced registrations returnpayment: nulland skip the payment step). The intent call creates a Stripe PaymentIntent for acardstudent and returns the client secret. (etransferreturns apendingpayment;compreturns none.) - The browser confirms the card payment with Stripe.
- Stripe calls
POST /payments/webhook; onpayment_intent.succeededthe payment is markedpaid,paid_atis stamped, and the linked lesson/enrolment isconfirmed. - On transition to
paid,ReceiptMailerassigns areceipt_number, emails the student a receipt, and stampsreceipt_sent_at. - For an e-transfer, the studio admin later calls
PATCH /payments/{id}to mark itpaid, 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 withtaxRatebyUnsupervised\Schedular\ShortcodeRegistrar
Tests
tests/Unit/ShortcodeRegistrarTest.php(pricing helper registration + localizedtaxRate)tests/Unit/Payment/PaymentRepositoryTest.phptests/Unit/Payment/PaymentTest.phptests/Unit/Payment/StripeGatewayTest.phptests/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.