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

125 lines
8.0 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 override**`us_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`