Completes the deferred half of payments: real credit-card processing on top of the existing ledger/e-transfer/comp foundation. - StripeGateway wraps stripe/stripe-php: creates idempotent PaymentIntents (amount in cents, registration ids in metadata) and verifies webhook signatures. Stripe calls sit behind protected seams for unit testing. - PaymentService::createIntent resolves the client-side step for a new registration (card → client secret; e-transfer → display data; comp → none) with caller-ownership enforcement. - PaymentService::handleWebhook finalises a payment exactly once on payment_intent.succeeded (mark paid → confirm → receipt) and marks it failed on payment_intent.payment_failed. - PaymentEndpoint: POST /payments/intent (book_lesson) and public, signature-verified POST /payments/webhook. - PaymentRepository: setStripeIntentId / findByStripeIntentId. - StudioSettings: us_stripe_webhook_secret option, with the webhook URL and required events surfaced on the settings page. - Front end: shared payment.js mounts Stripe Payment Elements and confirms the card (or shows e-transfer instructions); Stripe.js enqueued only when configured. Wired into booking and group-class flows. Tests: new StripeGatewayTest; PaymentService card-intent + webhook cases; repository coverage. composer test/lint/cs all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8.0 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 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) |
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 |
Payment Flow
- During registration the front-end calls
POST /payments/intent, which 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.
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
Tests
tests/Unit/Payment/PaymentRepositoryTest.phptests/Unit/Payment/PaymentTest.phptests/Unit/Payment/StripeGatewayTest.phptests/Unit/Payment/ReceiptMailerTest.php