Files
thatguygriff 925a4b79ba
CI / No Debug Code (pull_request) Successful in 40s
CI / Tests (PHP 8.2) (pull_request) Successful in 48s
CI / Coding Standards (pull_request) Successful in 1m0s
CI / PHPStan (pull_request) Successful in 1m13s
CI / Tests (PHP 8.1) (pull_request) Successful in 2m9s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m8s
CI / Build Plugin Zip (pull_request) Has been skipped
Add live Stripe card charges (PaymentIntent + Elements + webhook)
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>
2026-06-08 15:51:37 -03:00

8.0 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 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

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:

  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)
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

  1. During registration the front-end calls POST /payments/intent, which 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.

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.php
  • tests/Unit/Payment/PaymentTest.php
  • tests/Unit/Payment/StripeGatewayTest.php
  • tests/Unit/Payment/ReceiptMailerTest.php