Offerings can now bill weekly (a pending payment 24h before each lesson) or monthly (one payment on the 1st for that month's lessons), alongside one-time and full-term. Applies to both private lessons and group classes. - Offering: new `weekly`/`monthly` billing modes + `isScheduledBilling()` - Booking/enrolment defer payment for scheduled modes; a single lesson booked after its due date has passed (e.g. an add-on in an already-billed month) is charged at booking instead - ScheduledBillingRunner: daily WP-Cron scan generates due payments across four cases (private/group × weekly/monthly), deduped via lesson.payment_id and payments.period_key - PaymentDueMailer: one consolidated itemised email per student per scan - Notice batch: payments emailed together share a reference; the admin Payments queue groups them with a lump-sum total for e-transfer reconciliation - Cancellation never voids a scheduled payment (Payment::isScheduled()) - Schema: us_payments gains due_date, period_key, notice_batch; USC_VERSION 1.2.0 composer test, composer lint, composer cs all pass. Co-Authored-By: Claude Opus 4.8 <[email protected]>
9.6 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.
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