Add live Stripe card charges (PaymentIntent + Elements + webhook)
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 <[email protected]>
This commit is contained in:
2026-06-08 15:51:37 -03:00
co-authored by Claude Opus 4.8
parent 2aa0d5ad5d
commit 925a4b79ba
16 changed files with 762 additions and 22 deletions
+25
View File
@@ -38,6 +38,31 @@ class PaymentRepository {
return $this->db->insert_id;
}
/**
* Attach the Stripe PaymentIntent id created for a card payment so the webhook
* can later reconcile the charge back to this row.
*/
public function setStripeIntentId( int $id, string $intentId ): bool {
return false !== $this->db->update(
$this->table,
[ 'stripe_payment_intent_id' => $intentId ],
[ 'id' => $id ],
[ '%s' ],
[ '%d' ]
);
}
public function findByStripeIntentId( string $intentId ): ?Payment {
$row = $this->db->get_row(
$this->db->prepare(
"SELECT * FROM {$this->table} WHERE stripe_payment_intent_id = %s ORDER BY id DESC LIMIT 1",
$intentId
)
);
return $row ? Payment::fromRow( $row ) : null;
}
public function updateEtransferEmail( int $id, ?string $email ): bool {
return false !== $this->db->update(
$this->table,