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
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:
@@ -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 );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user