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