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
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>
125 lines
8.0 KiB
Markdown
125 lines
8.0 KiB
Markdown
# 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`
|