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
+72
View File
@@ -21,6 +21,78 @@ class PaymentEndpoint {
],
]
);
register_rest_route(
$route_namespace,
'/payments/intent',
[
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'createIntent' ],
'permission_callback' => [ $this, 'canBook' ],
'args' => [
'registration_type' => [
'type' => 'string',
'required' => true,
'enum' => [ Payment::REG_LESSON, Payment::REG_ENROLLMENT ],
],
'registration_id' => [
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
],
],
],
]
);
register_rest_route(
$route_namespace,
'/payments/webhook',
[
[
'methods' => \WP_REST_Server::CREATABLE,
'callback' => [ $this, 'webhook' ],
'permission_callback' => '__return_true',
],
]
);
}
/**
* Create the client-side payment step for a registration the student just made
* (Stripe client secret for card; display data for e-transfer/comp).
*/
public function createIntent( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$type = (string) $request->get_param( 'registration_type' );
$registrationId = absint( $request->get_param( 'registration_id' ) );
$result = $this->service->createIntent( $type, $registrationId, get_current_user_id() );
if ( null === $result ) {
return new \WP_Error( 'intent_failed', __( 'Could not start payment for this registration.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
return new \WP_REST_Response( $result, 200 );
}
/**
* Stripe-to-server webhook. The body is verified against the signing secret
* before any ledger change; an invalid signature is rejected with 400 so Stripe
* retries are not silently accepted.
*/
public function webhook( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error {
$payload = $request->get_body();
$signature = (string) $request->get_header( 'stripe_signature' );
if ( ! $this->service->handleWebhook( $payload, $signature ) ) {
return new \WP_Error( 'invalid_signature', __( 'Invalid webhook signature.', 'unsupervised-schedular' ), [ 'status' => 400 ] );
}
return new \WP_REST_Response( [ 'received' => true ], 200 );
}
public function canBook(): bool {
return is_user_logged_in() && current_user_can( RoleManager::CAP_BOOK_LESSON );
}
/**
+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,
+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 );
+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 );
}
}
+12
View File
@@ -9,6 +9,7 @@ class StudioSettings {
public const OPT_PUBLISHABLE = 'us_stripe_publishable_key';
public const OPT_SECRET = 'us_stripe_secret_key';
public const OPT_WEBHOOK_SECRET = 'us_stripe_webhook_secret';
public const OPT_MODE = 'us_stripe_mode';
public const OPT_CURRENCY = 'us_currency';
public const OPT_ETRANSFER_EMAIL = 'us_etransfer_email';
@@ -22,6 +23,14 @@ class StudioSettings {
return (string) get_option( self::OPT_SECRET, '' );
}
/**
* The Stripe webhook signing secret (`whsec_…`) used to verify that incoming
* webhook requests genuinely came from Stripe. Empty until configured.
*/
public function webhookSecret(): string {
return (string) get_option( self::OPT_WEBHOOK_SECRET, '' );
}
public function mode(): string {
return 'live' === get_option( self::OPT_MODE, 'test' ) ? 'live' : 'test';
}
@@ -66,6 +75,8 @@ class StudioSettings {
$publishableKey = $this->publishableKey();
$secretKey = $this->secretKey();
$webhookSecret = $this->webhookSecret();
$webhookUrl = rest_url( 'us-scheduler/v1/payments/webhook' );
$mode = $this->mode();
$currency = $this->currency();
$etransferEmail = $this->etransferEmail();
@@ -81,6 +92,7 @@ class StudioSettings {
$mode = sanitize_key( wp_unslash( $_POST['mode'] ?? 'test' ) );
update_option( self::OPT_PUBLISHABLE, sanitize_text_field( wp_unslash( $_POST['publishable_key'] ?? '' ) ) );
update_option( self::OPT_SECRET, sanitize_text_field( wp_unslash( $_POST['secret_key'] ?? '' ) ) );
update_option( self::OPT_WEBHOOK_SECRET, sanitize_text_field( wp_unslash( $_POST['webhook_secret'] ?? '' ) ) );
update_option( self::OPT_MODE, 'live' === $mode ? 'live' : 'test' );
update_option( self::OPT_CURRENCY, strtoupper( sanitize_text_field( wp_unslash( $_POST['currency'] ?? 'CAD' ) ) ) );
update_option( self::OPT_ETRANSFER_EMAIL, sanitize_email( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) );
+3 -1
View File
@@ -13,6 +13,7 @@ use Unsupervised\Schedular\Payment\BillingMethodResolver;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Payment\PaymentService;
use Unsupervised\Schedular\Payment\ReceiptMailer;
use Unsupervised\Schedular\Payment\StripeGateway;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\PolicyRepository;
@@ -44,7 +45,8 @@ class Plugin {
$paymentRepo = new PaymentRepository( $wpdb );
$settings = new StudioSettings();
$resolver = new BillingMethodResolver( $settings );
$paymentService = new PaymentService( $paymentRepo, $resolver, new ReceiptMailer(), $bookings, $enrollments, $settings );
$stripe = new StripeGateway( $settings );
$paymentService = new PaymentService( $paymentRepo, $resolver, new ReceiptMailer(), $bookings, $enrollments, $settings, $stripe );
( new RoleManager() )->register();
( new AdminMenu( $availability, $bookings, $offerings, $questions, $policies, $policyVersions, $policyService, $invites, $enrollments, $settings, $paymentRepo, $paymentService, $resolver ) )->register();
+23 -6
View File
@@ -8,6 +8,7 @@ use Unsupervised\Schedular\Auth\LoginPage;
use Unsupervised\Schedular\Auth\RegistrationPage;
use Unsupervised\Schedular\Booking\BookingPage;
use Unsupervised\Schedular\GroupClass\GroupClassPage;
use Unsupervised\Schedular\Payment\StudioSettings;
use Unsupervised\Schedular\Policy\AcceptanceRepository;
use Unsupervised\Schedular\Policy\PolicyRepository;
use Unsupervised\Schedular\Policy\PolicyVersionRepository;
@@ -43,15 +44,31 @@ class ShortcodeRegistrar {
public function enqueueAssets(): void {
wp_register_style( 'us-scheduler', USC_PLUGIN_URL . 'assets/css/frontend.css', [], USC_VERSION );
$settings = new StudioSettings();
// Stripe.js (loaded from Stripe's CDN per their terms) only when card billing
// is available; the payment helper degrades to e-transfer messaging without it.
$paymentDeps = [];
if ( $settings->isStripeConfigured() ) {
// Stripe pins the version in the URL path (/v3/) and forbids self-hosting,
// so no query-string version applies here.
wp_register_script( 'us-scheduler-stripe', 'https://js.stripe.com/v3/', [], null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion
$paymentDeps[] = 'us-scheduler-stripe';
}
wp_register_script( 'us-scheduler-payment', USC_PLUGIN_URL . 'assets/js/payment.js', $paymentDeps, USC_VERSION, true );
$data = [
'restUrl' => rest_url( 'us-scheduler/v1/' ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'restUrl' => rest_url( 'us-scheduler/v1/' ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'stripeKey' => $settings->publishableKey(),
];
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [], USC_VERSION, true );
wp_localize_script( 'us-scheduler', 'usScheduler', $data );
// Attach the shared config to the payment helper so it is defined before the
// booking/group scripts (which depend on it) run.
wp_localize_script( 'us-scheduler-payment', 'usScheduler', $data );
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [], USC_VERSION, true );
wp_localize_script( 'us-scheduler-group', 'usScheduler', $data );
wp_register_script( 'us-scheduler', USC_PLUGIN_URL . 'assets/js/booking.js', [ 'us-scheduler-payment' ], USC_VERSION, true );
wp_register_script( 'us-scheduler-group', USC_PLUGIN_URL . 'assets/js/group-classes.js', [ 'us-scheduler-payment' ], USC_VERSION, true );
}
}