A weekly booking on a per-lesson (one_time) priced offering was creating its single upfront payment for one week's price while reserving up to 12 weeks, and settling that payment confirmed only the anchor lesson, leaving the rest of the series pending forever. - BookingEndpoint now charges price x claimed occurrences for one_time billing; a full_term price is still charged once since it covers the term. - PaymentService::confirmRegistration resolves the anchor lesson's series and confirms every non-cancelled row via the new BookingRepository::updateStatusForSeries(). Closes #79 Co-Authored-By: Claude Fable 5 <[email protected]>
8.3 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) |
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— 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.
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