Cancelling a lesson that was already paid for now credits the student that money instead of leaving it as a manual refund, and the daily scheduled-billing scan applies any available credit against their due charges before emailing the notice. - New us_credits ledger + us_payments.credit_applied column (Payment::netDue). - PaymentService::creditForCancelledLesson issues a per-lesson share of the covering payment's total; wired into all three cancel paths (student self-cancel, instructor status update, admin student-detail cancel). - PaymentService::applyCredits draws credit down FIFO across a run's charges, marking a fully-covered charge paid-by-credit; the notice shows the credit applied and reduced total, and the admin queue shows net due. - Student detail page shows a student's credit balance and history. Ships as part of the unreleased 1.2.0 (same release as scheduled billing). Tests: composer test (585), composer lint, composer cs all pass. Co-Authored-By: Claude Opus 4.8 <[email protected]>
9.9 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 |
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
Tests
tests/Unit/Payment/PaymentRepositoryTest.phptests/Unit/Payment/PaymentTest.phptests/Unit/Payment/StripeGatewayTest.phptests/Unit/Payment/ReceiptMailerTest.php