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 <[email protected]>
This commit is contained in:
@@ -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 );
|
||||
|
||||
Reference in New Issue
Block a user