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

16 KiB
Raw Permalink Blame History

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 overrideus_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 monthScheduledBillingRunner::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_appliedPayment::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.