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
+79
View File
@@ -20,6 +20,7 @@ class PaymentService {
private BookingRepository $bookings,
private EnrollmentRepository $enrollments,
private StudioSettings $settings,
private StripeGateway $stripe,
) {}
/**
@@ -88,6 +89,84 @@ class PaymentService {
return true;
}
/**
* Resolve the client-side payment step for a freshly created registration.
* For a card payment a Stripe PaymentIntent is created (or replayed
* idempotently) and its client secret returned so the browser can confirm the
* card; e-transfer returns the destination and amount to display; comp/paid
* needs no further action. Returns null when the registration has no payment,
* the caller does not own it, or Stripe could not create the intent.
*
* @return array<string, mixed>|null
*/
public function createIntent( string $type, int $registrationId, int $studentId ): ?array {
$payment = $this->payments->findByRegistration( $type, $registrationId );
if ( null === $payment || null === $payment->id || $payment->studentId !== $studentId ) {
return null;
}
$base = [
'payment_id' => $payment->id,
'method' => $payment->method,
'status' => $payment->status,
'amount' => $payment->total(),
'currency' => $payment->currency,
];
// Comp (already paid) or anything else settled needs no client action.
if ( $payment->isPaid() || Payment::METHOD_CARD !== $payment->method ) {
if ( Payment::METHOD_ETRANSFER === $payment->method ) {
$base['etransfer_email'] = $payment->etransferEmail;
}
return $base;
}
$intent = $this->stripe->createIntent( $payment );
if ( null === $intent ) {
return null;
}
$this->payments->setStripeIntentId( $payment->id, (string) $intent->id );
$base['client_secret'] = (string) $intent->client_secret;
$base['publishable_key'] = $this->settings->publishableKey();
return $base;
}
/**
* Process a verified Stripe webhook. Returns false only when the signature
* fails verification (so the endpoint can reply 400); a true result means the
* event was authentic and has been acknowledged, whether or not it matched a
* ledger row. A succeeded intent finalises the matching payment exactly once;
* a failed intent marks an unpaid payment `failed`.
*/
public function handleWebhook( string $payload, string $signatureHeader ): bool {
$event = $this->stripe->verifyWebhook( $payload, $signatureHeader );
if ( null === $event ) {
return false;
}
$intent = $event->data->object ?? null;
if ( ! $intent instanceof \Stripe\PaymentIntent ) {
return true;
}
$payment = $this->payments->findByStripeIntentId( (string) $intent->id );
if ( null === $payment || null === $payment->id ) {
return true;
}
if ( 'payment_intent.succeeded' === $event->type && ! $payment->isPaid() ) {
$this->finalizePaid( $payment->id, $payment->registrationType, $payment->registrationId, $payment->studentId );
} elseif ( 'payment_intent.payment_failed' === $event->type && ! $payment->isPaid() ) {
$this->payments->updateStatus( $payment->id, Payment::STATUS_FAILED );
}
return true;
}
private function finalizePaid( int $paymentId, string $type, int $registrationId, int $studentId ): void {
$this->payments->markPaid( $paymentId, 'USC-' . $paymentId );
$this->confirmRegistration( $type, $registrationId );