From 925a4b79ba591ffe6249b1efd57643a2b33cbcdb Mon Sep 17 00:00:00 2001 From: James Griffin Date: Mon, 8 Jun 2026 15:51:37 -0300 Subject: [PATCH] Add live Stripe card charges (PaymentIntent + Elements + webhook) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- assets/js/booking.js | 12 +- assets/js/group-classes.js | 12 +- assets/js/payment.js | 121 ++++++++++++++++++ composer.json | 3 +- docs/features/payments.md | 12 +- src/Payment/PaymentEndpoint.php | 72 +++++++++++ src/Payment/PaymentRepository.php | 25 ++++ src/Payment/PaymentService.php | 79 ++++++++++++ src/Payment/StripeGateway.php | 116 +++++++++++++++++ src/Payment/StudioSettings.php | 12 ++ src/Plugin.php | 4 +- src/ShortcodeRegistrar.php | 29 ++++- templates/admin/settings.php | 13 ++ tests/Unit/Payment/PaymentRepositoryTest.php | 36 ++++++ tests/Unit/Payment/PaymentServiceTest.php | 127 ++++++++++++++++++- tests/Unit/Payment/StripeGatewayTest.php | 111 ++++++++++++++++ 16 files changed, 762 insertions(+), 22 deletions(-) create mode 100644 assets/js/payment.js create mode 100644 src/Payment/StripeGateway.php create mode 100644 tests/Unit/Payment/StripeGatewayTest.php diff --git a/assets/js/booking.js b/assets/js/booking.js index b458604..aeb9303 100644 --- a/assets/js/booking.js +++ b/assets/js/booking.js @@ -179,13 +179,17 @@ accepted_policy_version_ids: accepted, }), }) - .then(() => { - slotList.style.display = 'none'; - confirm.style.display = 'block'; - }) + .then((res) => window.usPayment.collect('lesson', (res.ids || [])[0], slotList)) + .then((result) => showConfirmation(window.usPayment.message(result))) .catch((err) => showError(err.message)); } + function showConfirmation(message) { + confirm.textContent = message; + slotList.style.display = 'none'; + confirm.style.display = 'block'; + } + function loadSlots() { clearError(); slotList.style.display = 'block'; diff --git a/assets/js/group-classes.js b/assets/js/group-classes.js index 0b9af64..8618498 100644 --- a/assets/js/group-classes.js +++ b/assets/js/group-classes.js @@ -142,13 +142,17 @@ accepted_policy_version_ids: accepted, }), }) - .then(() => { - list.style.display = 'none'; - confirm.style.display = 'block'; - }) + .then((res) => window.usPayment.collect('enrollment', res.id, list)) + .then((result) => showConfirmation(window.usPayment.message(result))) .catch((err) => showError(err.message)); } + function showConfirmation(message) { + confirm.textContent = message; + list.style.display = 'none'; + confirm.style.display = 'block'; + } + function loadClasses() { clearError(); list.style.display = 'block'; diff --git a/assets/js/payment.js b/assets/js/payment.js new file mode 100644 index 0000000..d664828 --- /dev/null +++ b/assets/js/payment.js @@ -0,0 +1,121 @@ +/* global usScheduler, Stripe */ +(function () { + 'use strict'; + + const { restUrl, nonce, stripeKey } = usScheduler; + + function apiFetch(path, options = {}) { + return fetch(restUrl + path, { + ...options, + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': nonce, + ...(options.headers || {}), + }, + }).then(async (res) => { + const data = await res.json(); + if (!res.ok) throw new Error(data.message || 'Request failed'); + return data; + }); + } + + function escHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function money(amount, currency) { + return `${Number(amount).toFixed(2)} ${escHtml(currency || '')}`.trim(); + } + + // Render Stripe's Payment Element into mountEl and resolve once the card has + // been confirmed. The server is told the result out-of-band via webhook, so a + // successful confirm here just means "the charge is on its way". + function confirmCard(intent, mountEl) { + return new Promise((resolve, reject) => { + if (typeof Stripe === 'undefined') { + reject(new Error('Card payment is unavailable right now. Please try again later.')); + return; + } + + const stripe = Stripe(intent.publishable_key || stripeKey); + const elements = stripe.elements({ clientSecret: intent.client_secret }); + const paymentEl = elements.create('payment'); + + mountEl.innerHTML = ` +
+

Payment

+

Amount due: ${money(intent.amount, intent.currency)}

+
+ +

+
`; + paymentEl.mount('#us-card-element'); + + const payBtn = mountEl.querySelector('#us-pay-btn'); + const cardError = mountEl.querySelector('#us-card-error'); + + payBtn.addEventListener('click', () => { + payBtn.disabled = true; + cardError.style.display = 'none'; + stripe.confirmPayment({ elements, redirect: 'if_required' }) + .then((result) => { + if (result.error) { + cardError.textContent = result.error.message; + cardError.style.display = 'block'; + payBtn.disabled = false; + return; + } + resolve(intent); + }) + .catch((err) => { + cardError.textContent = err.message; + cardError.style.display = 'block'; + payBtn.disabled = false; + }); + }); + }); + } + + // Public helper used by the booking and group-class flows. Given a freshly + // created registration, drive its payment step and resolve with the intent + // result (method card/etransfer/comp) so the caller can show a message. + window.usPayment = { + collect(type, registrationId, mountEl) { + if (!registrationId) { + return Promise.resolve(null); + } + + return apiFetch('payments/intent', { + method: 'POST', + body: JSON.stringify({ registration_type: type, registration_id: registrationId }), + }).then((intent) => { + if (intent.method === 'card' && intent.client_secret) { + return confirmCard(intent, mountEl); + } + return intent; + }); + }, + + // Human-readable confirmation copy for a completed payment step. + message(result) { + if (!result) { + return 'Your registration is confirmed.'; + } + if (result.method === 'etransfer') { + const where = result.etransfer_email + ? ` to ${escHtml(result.etransfer_email)}` + : ''; + return `Please send an e-transfer of ${money(result.amount, result.currency)}${where}. ` + + 'Your spot is reserved and will be confirmed once payment is received.'; + } + if (result.method === 'card') { + return 'Thank you — your payment is processing. Your registration will be confirmed shortly.'; + } + return 'Your registration is confirmed.'; + }, + }; +}()); diff --git a/composer.json b/composer.json index 6611d02..6f891cc 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,8 @@ "type": "wordpress-plugin", "license": "GPL-2.0-or-later", "require": { - "php": ">=8.1" + "php": ">=8.1", + "stripe/stripe-php": "^17.0" }, "require-dev": { "phpunit/phpunit": "^10.5", diff --git a/docs/features/payments.md b/docs/features/payments.md index 68a79bb..7f069a7 100644 --- a/docs/features/payments.md +++ b/docs/features/payments.md @@ -11,12 +11,13 @@ numbered receipt is emailed automatically when a payment is marked paid. > **Implemented:** the payment ledger, studio settings, method resolution > (e-transfer default with no Stripe; card default when configured; per-student -> override), the e-transfer/comp flow with admin confirmation + receipts, and +> override), the e-transfer/comp flow with admin confirmation + receipts, > integration into booking/enrolment (a registration's `payment_id` is linked; -> comp auto-confirms; e-transfer stays pending until confirmed). -> **Deferred to a follow-up:** the live Stripe card charge (PaymentIntent + -> Stripe.js Elements + webhook + `stripe/stripe-php`). Until then a `card` -> payment is created `pending` and can be confirmed like an e-transfer. +> comp auto-confirms; e-transfer stays pending until confirmed), and the **live +> Stripe card charge** — a PaymentIntent created on `POST /payments/intent`, +> confirmed in the browser with Stripe.js Payment Elements, and finalised by the +> `POST /payments/webhook` handler (signature-verified) on +> `payment_intent.succeeded`. Uses the `stripe/stripe-php` SDK. ## Stripe Configuration Stripe credentials live in WordPress options, managed on the **Studio Settings** @@ -26,6 +27,7 @@ page (`manage_billing`, studio admin only): |------------------------------|----------------------------------------| | `us_stripe_publishable_key` | Stripe publishable key | | `us_stripe_secret_key` | Stripe secret key | +| `us_stripe_webhook_secret` | Webhook signing secret (`whsec_…`) | | `us_stripe_mode` | `test` or `live` | | `us_currency` | Default ISO 4217 currency, e.g. `CAD` | | `us_etransfer_email` | Studio-default e-transfer destination | diff --git a/src/Payment/PaymentEndpoint.php b/src/Payment/PaymentEndpoint.php index f8579dc..caef53d 100644 --- a/src/Payment/PaymentEndpoint.php +++ b/src/Payment/PaymentEndpoint.php @@ -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 ); } /** diff --git a/src/Payment/PaymentRepository.php b/src/Payment/PaymentRepository.php index 6642636..2b81e01 100644 --- a/src/Payment/PaymentRepository.php +++ b/src/Payment/PaymentRepository.php @@ -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, diff --git a/src/Payment/PaymentService.php b/src/Payment/PaymentService.php index f1afdf8..3a7766e 100644 --- a/src/Payment/PaymentService.php +++ b/src/Payment/PaymentService.php @@ -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|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 ); diff --git a/src/Payment/StripeGateway.php b/src/Payment/StripeGateway.php new file mode 100644 index 0000000..e139855 --- /dev/null +++ b/src/Payment/StripeGateway.php @@ -0,0 +1,116 @@ +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 $params + * @param array $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 ); + } +} diff --git a/src/Payment/StudioSettings.php b/src/Payment/StudioSettings.php index 4dabbc4..7cb8b90 100644 --- a/src/Payment/StudioSettings.php +++ b/src/Payment/StudioSettings.php @@ -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'] ?? '' ) ) ); diff --git a/src/Plugin.php b/src/Plugin.php index 7ac8790..bbe3cef 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -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(); diff --git a/src/ShortcodeRegistrar.php b/src/ShortcodeRegistrar.php index 7cb5da4..74942fb 100644 --- a/src/ShortcodeRegistrar.php +++ b/src/ShortcodeRegistrar.php @@ -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 ); } } diff --git a/templates/admin/settings.php b/templates/admin/settings.php index b46ba30..427bc5d 100644 --- a/templates/admin/settings.php +++ b/templates/admin/settings.php @@ -8,6 +8,8 @@ if (! defined('ABSPATH')) { /** * @var string $publishableKey * @var string $secretKey + * @var string $webhookSecret + * @var string $webhookUrl * @var string $mode * @var string $currency * @var string $etransferEmail @@ -41,6 +43,17 @@ if (! defined('ABSPATH')) { + + + + +

+
+
+ +

+ + diff --git a/tests/Unit/Payment/PaymentRepositoryTest.php b/tests/Unit/Payment/PaymentRepositoryTest.php index 3a7e169..362a07e 100644 --- a/tests/Unit/Payment/PaymentRepositoryTest.php +++ b/tests/Unit/Payment/PaymentRepositoryTest.php @@ -98,6 +98,42 @@ class PaymentRepositoryTest extends TestCase self::assertCount(0, $this->repo->findPaidBetween('2026-06-01 00:00:00', '2026-07-01 00:00:00')); } + public function testSetStripeIntentIdUpdatesRow(): void + { + $this->db->shouldReceive('update') + ->once() + ->with( + 'wp_us_payments', + ['stripe_payment_intent_id' => 'pi_123'], + ['id' => 50], + ['%s'], + ['%d'] + ) + ->andReturn(1); + + self::assertTrue($this->repo->setStripeIntentId(50, 'pi_123')); + } + + public function testFindByStripeIntentIdReturnsPayment(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with(Mockery::pattern('/stripe_payment_intent_id = %s/'), 'pi_123') + ->andReturn('SELECT ...'); + + $this->db->shouldReceive('get_row')->andReturn($this->row()); + + self::assertInstanceOf(Payment::class, $this->repo->findByStripeIntentId('pi_123')); + } + + public function testFindByStripeIntentIdReturnsNullWhenMissing(): void + { + $this->db->shouldReceive('prepare')->andReturn('SELECT ...'); + $this->db->shouldReceive('get_row')->andReturn(null); + + self::assertNull($this->repo->findByStripeIntentId('pi_missing')); + } + public function testFindByRegistrationReturnsPayment(): void { $this->db->shouldReceive('prepare') diff --git a/tests/Unit/Payment/PaymentServiceTest.php b/tests/Unit/Payment/PaymentServiceTest.php index 779f892..d1f2f99 100644 --- a/tests/Unit/Payment/PaymentServiceTest.php +++ b/tests/Unit/Payment/PaymentServiceTest.php @@ -13,6 +13,7 @@ use Unsupervised\Schedular\Payment\Payment; 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\Tests\Unit\TestCase; @@ -24,6 +25,7 @@ class PaymentServiceTest extends TestCase private BookingRepository $bookings; private EnrollmentRepository $enrollments; private StudioSettings $settings; + private StripeGateway $stripe; private PaymentService $service; protected function setUp(): void @@ -36,6 +38,7 @@ class PaymentServiceTest extends TestCase $this->bookings = Mockery::mock(BookingRepository::class); $this->enrollments = Mockery::mock(EnrollmentRepository::class); $this->settings = Mockery::mock(StudioSettings::class); + $this->stripe = Mockery::mock(StripeGateway::class); $this->settings->shouldReceive('etransferEmail')->andReturn(''); $this->settings->shouldReceive('hstRate')->andReturn(0.0)->byDefault(); @@ -45,7 +48,8 @@ class PaymentServiceTest extends TestCase $this->mailer, $this->bookings, $this->enrollments, - $this->settings + $this->settings, + $this->stripe ); Functions\when('get_userdata')->justReturn(false); @@ -166,4 +170,125 @@ class PaymentServiceTest extends TestCase // Already paid → no markPaid/confirm calls. self::assertTrue($this->service->markPaid(80)); } + + public function testCreateIntentForCardReturnsClientSecret(): void + { + $this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 12) + ->andReturn($this->payment(Payment::METHOD_CARD, Payment::STATUS_PENDING, 90)); + + $intent = \Stripe\PaymentIntent::constructFrom(['id' => 'pi_abc', 'client_secret' => 'pi_abc_secret']); + $this->stripe->shouldReceive('createIntent')->once() + ->with(Mockery::on(static fn (Payment $p): bool => $p->id === 90)) + ->andReturn($intent); + $this->payments->shouldReceive('setStripeIntentId')->once()->with(90, 'pi_abc')->andReturn(true); + $this->settings->shouldReceive('publishableKey')->andReturn('pk_test_123'); + + $result = $this->service->createIntent(Payment::REG_LESSON, 12, 5); + + self::assertSame('card', $result['method']); + self::assertSame('pi_abc_secret', $result['client_secret']); + self::assertSame('pk_test_123', $result['publishable_key']); + } + + public function testCreateIntentForEtransferReturnsDisplayDataWithoutStripe(): void + { + $payment = new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_ETRANSFER, Payment::STATUS_PENDING, etransferEmail: 'pay@studio.test', id: 91); + $this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 12)->andReturn($payment); + + $this->stripe->shouldNotReceive('createIntent'); + + $result = $this->service->createIntent(Payment::REG_LESSON, 12, 5); + + self::assertSame('etransfer', $result['method']); + self::assertSame('pay@studio.test', $result['etransfer_email']); + self::assertArrayNotHasKey('client_secret', $result); + } + + public function testCreateIntentReturnsNullWhenNotOwner(): void + { + $this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 12) + ->andReturn($this->payment(Payment::METHOD_CARD, Payment::STATUS_PENDING, 90)); + + // Student 999 does not own payment whose studentId is 5. + self::assertNull($this->service->createIntent(Payment::REG_LESSON, 12, 999)); + } + + public function testCreateIntentReturnsNullWhenNoPayment(): void + { + $this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 12)->andReturn(null); + + self::assertNull($this->service->createIntent(Payment::REG_LESSON, 12, 5)); + } + + public function testCreateIntentReturnsNullWhenStripeFails(): void + { + $this->payments->shouldReceive('findByRegistration')->with(Payment::REG_LESSON, 12) + ->andReturn($this->payment(Payment::METHOD_CARD, Payment::STATUS_PENDING, 90)); + $this->stripe->shouldReceive('createIntent')->once()->andReturn(null); + + self::assertNull($this->service->createIntent(Payment::REG_LESSON, 12, 5)); + } + + public function testHandleWebhookInvalidSignatureReturnsFalse(): void + { + $this->stripe->shouldReceive('verifyWebhook')->with('{}', 'bad-sig')->andReturn(null); + + self::assertFalse($this->service->handleWebhook('{}', 'bad-sig')); + } + + public function testHandleWebhookSucceededFinalizesPayment(): void + { + $event = $this->intentEvent('payment_intent.succeeded', 'pi_ok'); + $this->stripe->shouldReceive('verifyWebhook')->andReturn($event); + + $this->payments->shouldReceive('findByStripeIntentId')->with('pi_ok') + ->andReturn($this->payment(Payment::METHOD_CARD, Payment::STATUS_PENDING, 90)); + + $this->payments->shouldReceive('markPaid')->once()->with(90, 'USC-90')->andReturn(true); + $this->bookings->shouldReceive('updateStatus')->once()->with(12, Lesson::STATUS_CONFIRMED)->andReturn(true); + $this->payments->shouldReceive('findById')->with(90)->andReturn($this->payment(Payment::METHOD_CARD, Payment::STATUS_PAID, 90)); + $this->mailer->shouldReceive('send')->andReturn(false); + + self::assertTrue($this->service->handleWebhook('{}', 'sig')); + } + + public function testHandleWebhookSucceededIdempotentWhenAlreadyPaid(): void + { + $event = $this->intentEvent('payment_intent.succeeded', 'pi_ok'); + $this->stripe->shouldReceive('verifyWebhook')->andReturn($event); + + $this->payments->shouldReceive('findByStripeIntentId')->with('pi_ok') + ->andReturn($this->payment(Payment::METHOD_CARD, Payment::STATUS_PAID, 90)); + + // Already paid → no markPaid/confirm. + self::assertTrue($this->service->handleWebhook('{}', 'sig')); + } + + public function testHandleWebhookFailedMarksFailed(): void + { + $event = $this->intentEvent('payment_intent.payment_failed', 'pi_bad'); + $this->stripe->shouldReceive('verifyWebhook')->andReturn($event); + + $this->payments->shouldReceive('findByStripeIntentId')->with('pi_bad') + ->andReturn($this->payment(Payment::METHOD_CARD, Payment::STATUS_PENDING, 90)); + $this->payments->shouldReceive('updateStatus')->once()->with(90, Payment::STATUS_FAILED)->andReturn(true); + + self::assertTrue($this->service->handleWebhook('{}', 'sig')); + } + + public function testHandleWebhookAcknowledgesUnknownIntent(): void + { + $event = $this->intentEvent('payment_intent.succeeded', 'pi_unknown'); + $this->stripe->shouldReceive('verifyWebhook')->andReturn($event); + $this->payments->shouldReceive('findByStripeIntentId')->with('pi_unknown')->andReturn(null); + + self::assertTrue($this->service->handleWebhook('{}', 'sig')); + } + + private function intentEvent(string $type, string $intentId): \Stripe\Event + { + $intent = \Stripe\PaymentIntent::constructFrom(['id' => $intentId, 'object' => 'payment_intent']); + + return \Stripe\Event::constructFrom(['type' => $type, 'data' => ['object' => $intent]]); + } } diff --git a/tests/Unit/Payment/StripeGatewayTest.php b/tests/Unit/Payment/StripeGatewayTest.php new file mode 100644 index 0000000..d908bfa --- /dev/null +++ b/tests/Unit/Payment/StripeGatewayTest.php @@ -0,0 +1,111 @@ +settings = Mockery::mock(StudioSettings::class); + } + + private function partialGateway(): StripeGateway + { + return Mockery::mock(StripeGateway::class, [$this->settings]) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + } + + private function payment(): Payment + { + return new Payment(5, 3, Payment::REG_LESSON, 12, 35.00, 'CAD', Payment::METHOD_CARD, Payment::STATUS_PENDING, id: 90); + } + + public function testCreateIntentReturnsNullWhenNotConfigured(): void + { + $this->settings->shouldReceive('isStripeConfigured')->andReturn(false); + + $gateway = new StripeGateway($this->settings); + + self::assertNull($gateway->createIntent($this->payment())); + } + + public function testCreateIntentSendsAmountInCentsAndReturnsIntent(): void + { + $this->settings->shouldReceive('isStripeConfigured')->andReturn(true); + + $intent = \Stripe\PaymentIntent::constructFrom(['id' => 'pi_1', 'client_secret' => 'cs_1']); + $gateway = $this->partialGateway(); + $gateway->shouldReceive('paymentIntentsCreate') + ->once() + ->with( + Mockery::on(static fn (array $p): bool => $p['amount'] === 3500 + && $p['currency'] === 'cad' + && $p['metadata']['payment_id'] === '90'), + Mockery::on(static fn (array $o): bool => $o['idempotency_key'] === 'usc-payment-90') + ) + ->andReturn($intent); + + self::assertSame('pi_1', $gateway->createIntent($this->payment())->id); + } + + public function testCreateIntentReturnsNullOnStripeError(): void + { + $this->settings->shouldReceive('isStripeConfigured')->andReturn(true); + + $gateway = $this->partialGateway(); + $gateway->shouldReceive('paymentIntentsCreate')->once()->andThrow(new \RuntimeException('declined')); + + self::assertNull($gateway->createIntent($this->payment())); + } + + public function testVerifyWebhookReturnsNullWithoutSecret(): void + { + $this->settings->shouldReceive('webhookSecret')->andReturn(''); + + $gateway = new StripeGateway($this->settings); + + self::assertNull($gateway->verifyWebhook('{}', 'sig')); + } + + public function testVerifyWebhookReturnsNullWithoutSignatureHeader(): void + { + $this->settings->shouldReceive('webhookSecret')->andReturn('whsec_123'); + + $gateway = new StripeGateway($this->settings); + + self::assertNull($gateway->verifyWebhook('{}', '')); + } + + public function testVerifyWebhookReturnsNullOnInvalidSignature(): void + { + $this->settings->shouldReceive('webhookSecret')->andReturn('whsec_123'); + + $gateway = $this->partialGateway(); + $gateway->shouldReceive('constructEvent')->once()->andThrow(new \RuntimeException('bad signature')); + + self::assertNull($gateway->verifyWebhook('{}', 'sig')); + } + + public function testVerifyWebhookReturnsEventOnSuccess(): void + { + $this->settings->shouldReceive('webhookSecret')->andReturn('whsec_123'); + + $event = \Stripe\Event::constructFrom(['type' => 'payment_intent.succeeded']); + $gateway = $this->partialGateway(); + $gateway->shouldReceive('constructEvent')->once()->with('{payload}', 'sig', 'whsec_123')->andReturn($event); + + self::assertSame('payment_intent.succeeded', $gateway->verifyWebhook('{payload}', 'sig')->type); + } +}