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 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 15:51:37 -03:00
parent 2aa0d5ad5d
commit 925a4b79ba
16 changed files with 762 additions and 22 deletions
+116
View File
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Payment;
use Stripe\Event;
use Stripe\PaymentIntent;
use Stripe\StripeClient;
use Stripe\Webhook;
/**
* Thin wrapper around the Stripe PHP SDK: creates PaymentIntents for card
* payments and verifies inbound webhook signatures. All Stripe-specific knowledge
* (amounts in cents, idempotency, signature checking) lives here so the rest of
* the payment domain stays gateway-agnostic.
*/
class StripeGateway {
private ?StripeClient $client;
/**
* Build the gateway. The Stripe client is created lazily from the configured
* secret key, or injected directly in tests.
*
* @param StripeClient|null $client Injectable for tests; built lazily when null.
*/
public function __construct( private StudioSettings $settings, ?StripeClient $client = null ) {
$this->client = $client;
}
public function isConfigured(): bool {
return $this->settings->isStripeConfigured();
}
/**
* Create (or, when one already exists, return) a PaymentIntent for the billed
* total of a payment. The payment id is stored in metadata so the webhook can
* reconcile the charge back to our ledger row. Returns null on any Stripe error
* so callers can fail gracefully.
*/
public function createIntent( Payment $payment ): ?PaymentIntent {
if ( ! $this->isConfigured() ) {
return null;
}
try {
return $this->paymentIntentsCreate(
[
'amount' => $this->toMinorUnits( $payment->total() ),
'currency' => strtolower( $payment->currency ),
'metadata' => [
'payment_id' => (string) $payment->id,
'registration_type' => $payment->registrationType,
'registration_id' => (string) $payment->registrationId,
'student_id' => (string) $payment->studentId,
],
'description' => sprintf( 'Lesson payment #%d', (int) $payment->id ),
],
[ 'idempotency_key' => 'usc-payment-' . $payment->id ]
);
} catch ( \Throwable $e ) {
return null;
}
}
/**
* Seam around the Stripe PaymentIntents create call so tests can stub the
* network request.
*
* @param array<string, mixed> $params
* @param array<string, mixed> $options
*/
protected function paymentIntentsCreate( array $params, array $options ): PaymentIntent {
return $this->client()->paymentIntents->create( $params, $options );
}
/**
* Verify a raw webhook payload against its signature header and return the
* decoded Stripe event, or null when verification fails or no signing secret is
* configured.
*/
public function verifyWebhook( string $payload, string $signatureHeader ): ?Event {
$secret = $this->settings->webhookSecret();
if ( '' === $secret || '' === $signatureHeader ) {
return null;
}
try {
return $this->constructEvent( $payload, $signatureHeader, $secret );
} catch ( \Throwable $e ) {
return null;
}
}
/**
* Seam around the static Stripe verifier so tests can stub signature checking.
*/
protected function constructEvent( string $payload, string $signatureHeader, string $secret ): Event {
return Webhook::constructEvent( $payload, $signatureHeader, $secret );
}
private function client(): StripeClient {
if ( null === $this->client ) {
$this->client = new StripeClient( $this->settings->secretKey() );
}
return $this->client;
}
/**
* Convert a dollar amount to the integer minor units (cents) Stripe expects.
*/
private function toMinorUnits( float $amount ): int {
return (int) round( $amount * 100 );
}
}