From 15568bfc7c400df885e0ceeb9f401fdb80f7c34a Mon Sep 17 00:00:00 2001 From: David Stone Date: Sun, 14 Dec 2025 23:48:34 -0700 Subject: [PATCH 1/5] Integrate Stripe Connect proxy server for secure OAuth flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace direct OAuth implementation with secure proxy server architecture to keep platform credentials safe and never expose them in distributed code. ## Security Improvements: ✅ **No credentials in distributed code** - Platform Client ID and Secret Keys never leave the proxy server at ultimatemultisite.com ✅ **Encrypted communication** - OAuth codes encrypted during transmission ✅ **Centralized control** - Rotate credentials without updating plugin ✅ **Usage tracking** - Proxy database tracks all connected sites ## Changes to Base Stripe Gateway (inc/gateways/class-base-stripe-gateway.php): ### New Methods: - `get_proxy_url()` - Returns proxy server URL (filterable) - `get_business_data()` - Provides site info for Stripe form prefill ### Updated Methods: - `get_connect_authorization_url()` - Now calls proxy /oauth/init endpoint instead of directly constructing Stripe OAuth URL - `handle_oauth_callbacks()` - Handles encrypted codes from proxy (looks for wcs_stripe_code and wcs_stripe_state parameters) - `exchange_code_for_keys()` - Calls proxy /oauth/keys endpoint to exchange encrypted code for API keys (replaces process_oauth_callback) - `handle_disconnect()` - Notifies proxy server when disconnecting ### Removed Methods: - `get_platform_client_id()` - No longer needed (credentials on proxy) - `get_platform_secret_key()` - No longer needed (credentials on proxy) ### Security Fixes: - Sanitize all $_GET input variables (wcs_stripe_code, _wpnonce) ## Changes to Stripe Gateway (inc/gateways/class-stripe-gateway.php): - Remove client ID check (proxy handles this now) - Auto-fixed code style issues ## Test Updates: - Updated `test_oauth_authorization_url_generation()` to mock proxy response - Updated `test_oauth_authorization_url_requires_client_id()` to test proxy failure - All tests passing: 19 tests, 53 assertions ✅ ## Architecture Flow: 1. User clicks "Connect with Stripe" 2. Plugin calls https://ultimatemultisite.com/wp-json/stripe-connect/v1/oauth/init 3. Proxy returns OAuth URL with encrypted state 4. User authorizes on Stripe 5. Stripe redirects to proxy /oauth/redirect endpoint 6. Proxy encrypts code and redirects back to site 7. Plugin calls proxy /oauth/keys to exchange encrypted code 8. Proxy decrypts code, exchanges with Stripe, returns tokens 9. Plugin saves tokens locally ## Proxy Server Repository: https://github.com/superdav42/stripe-connect-proxy ## Deployment Notes: The proxy server plugin must be deployed to ultimatemultisite.com and configured with platform credentials before OAuth connections will work. See INTEGRATION.md in the proxy repository for deployment instructions. ## Backwards Compatibility: Fully backwards compatible with existing direct API key mode. Sites that have already connected via OAuth will continue to work. New OAuth connections will automatically use the proxy server. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- assets/js/gateways/stripe.js | 273 +++++++++--- inc/gateways/class-base-stripe-gateway.php | 408 +++++++++++++++++- inc/gateways/class-stripe-gateway.php | 129 +++++- .../Gateways/Stripe_OAuth_E2E_Test.php | 209 +++++++++ .../WP_Ultimo/Gateways/Stripe_OAuth_Test.php | 348 +++++++++++++++ 5 files changed, 1296 insertions(+), 71 deletions(-) create mode 100644 tests/WP_Ultimo/Gateways/Stripe_OAuth_E2E_Test.php create mode 100644 tests/WP_Ultimo/Gateways/Stripe_OAuth_Test.php diff --git a/assets/js/gateways/stripe.js b/assets/js/gateways/stripe.js index f9df3690..fbf74969 100644 --- a/assets/js/gateways/stripe.js +++ b/assets/js/gateways/stripe.js @@ -3,115 +3,190 @@ let _stripe; let stripeElement; let card; +let paymentElement; +let elements; +let usePaymentElement = false; const stripeElements = function(publicKey) { _stripe = Stripe(publicKey); - const elements = _stripe.elements(); - - card = elements.create('card', { - hidePostalCode: true, - }); + // Payment Element will be initialized dynamically after preflight response + // We'll detect which element to use based on what's available in the DOM wp.hooks.addFilter('wu_before_form_submitted', 'nextpress/wp-ultimo', function(promises, checkout, gateway) { const cardEl = document.getElementById('card-element'); + const paymentEl = document.getElementById('payment-element'); - if (gateway === 'stripe' && checkout.order.totals.total > 0 && cardEl && cardEl.offsetParent) { + if (gateway === 'stripe' && checkout.order.totals.total > 0) { - promises.push(new Promise( async (resolve, reject) => { + // If using Card Element (legacy/fallback) + if (cardEl && cardEl.offsetParent && !usePaymentElement) { - try { + promises.push(new Promise( async (resolve, reject) => { - const paymentMethod = await _stripe.createPaymentMethod({type: 'card', card}); + try { - if (paymentMethod.error) { - - reject(paymentMethod.error); - - } // end if; + const paymentMethod = await _stripe.createPaymentMethod({type: 'card', card}); - } catch(err) { + if (paymentMethod.error) { - } // end try; + reject(paymentMethod.error); + + } // end if; + + } catch(err) { + + } // end try; - resolve(); + resolve(); - })); + })); + + } // end if; } // end if; return promises; - + }); wp.hooks.addAction('wu_on_form_success', 'nextpress/wp-ultimo', function(checkout, results) { if (checkout.gateway === 'stripe' && (checkout.order.totals.total > 0 || checkout.order.totals.recurring.total > 0)) { - checkout.set_prevent_submission(false); - - handlePayment(checkout, results, card); + // Check if we received a client_secret (required for Payment Element) + if (results.gateway.data.stripe_client_secret) { + + const paymentEl = document.getElementById('payment-element'); + const cardEl = document.getElementById('card-element'); + + // Initialize Payment Element with client_secret (preferred method) + if (paymentEl) { + + usePaymentElement = true; + + // Create elements instance with client_secret for dynamic mode + elements = _stripe.elements({ + clientSecret: results.gateway.data.stripe_client_secret, + appearance: { + theme: 'stripe', + }, + }); + + // Create and mount Payment Element + paymentElement = elements.create('payment'); + paymentElement.mount('#payment-element'); + + // Apply custom styles + wu_stripe_update_payment_element_styles(paymentElement, '#field-payment_template'); + + checkout.set_prevent_submission(false); + + // Handle payment with Payment Element + handlePaymentElement(checkout, results, elements); + + } else if (cardEl && card) { + + // Fallback to Card Element (legacy) + usePaymentElement = false; + checkout.set_prevent_submission(false); + handlePayment(checkout, results, card); + + } // end if; + + } else { + + // No client_secret, use legacy Card Element flow + checkout.set_prevent_submission(false); + + if (card) { + handlePayment(checkout, results, card); + } + + } // end if; } // end if; }); - + wp.hooks.addAction('wu_on_form_updated', 'nextpress/wp-ultimo', function(form) { if (form.gateway === 'stripe') { - try { + const cardEl = document.getElementById('card-element'); + const paymentEl = document.getElementById('payment-element'); - card.mount('#card-element'); + // Only mount Card Element if Payment Element is not available + if (cardEl && !paymentEl && !usePaymentElement) { - wu_stripe_update_styles(card, '#field-payment_template'); + try { - /* - * Prevents the from from submitting while Stripe is - * creating a payment source. - */ - form.set_prevent_submission(form.order && form.order.should_collect_payment && form.payment_method === 'add-new'); + // Initialize Card Element for legacy support + if (!card) { + const cardElements = _stripe.elements(); + card = cardElements.create('card', { + hidePostalCode: true, + }); - } catch (error) { + // Element focus ring + card.on('focus', function() { + const el = document.getElementById('card-element'); + el.classList.add('focused'); + }); - // Silence + card.on('blur', function() { + const el = document.getElementById('card-element'); + el.classList.remove('focused'); + }); + } - } // end try; + card.mount('#card-element'); - } else { + wu_stripe_update_styles(card, '#field-payment_template'); - form.set_prevent_submission(false); + /* + * Prevents the form from submitting while Stripe is + * creating a payment source. + */ + form.set_prevent_submission(form.order && form.order.should_collect_payment && form.payment_method === 'add-new'); - try { + } catch (error) { - card.unmount('#card-element'); + // Silence - } catch (error) { + } // end try; - // Silence is golden + } else { - } // end try; + // Payment Element will be initialized in wu_on_form_success + // Set prevent submission for Payment Element + if (paymentEl) { + form.set_prevent_submission(form.order && form.order.should_collect_payment && form.payment_method === 'add-new'); + } else { + form.set_prevent_submission(false); + } - } // end if; + } // end if; - }); + } else { - // Element focus ring - card.on('focus', function() { + form.set_prevent_submission(false); - const el = document.getElementById('card-element'); + try { - el.classList.add('focused'); + if (card) { + card.unmount('#card-element'); + } - }); + } catch (error) { - card.on('blur', function() { + // Silence is golden - const el = document.getElementById('card-element'); + } // end try; - el.classList.remove('focused'); + } // end if; }); @@ -214,6 +289,45 @@ function wu_stripe_update_styles(cardElement, selector) { } +/** + * Update styles for Payment Element. + * + * @param {Object} paymentElement Stripe payment element. + * @param {string} selector Selector to copy styles from. + * + * @since 2.x.x + */ +function wu_stripe_update_payment_element_styles(paymentElement, selector) { + + if (undefined === typeof selector) { + selector = '#field-payment_template'; + } + + const inputField = document.querySelector(selector); + + if (null === inputField) { + return; + } + + const inputStyles = window.getComputedStyle(inputField); + + // Payment Element uses appearance API instead of style object + paymentElement.update({ + appearance: { + theme: 'stripe', + variables: { + colorPrimary: inputStyles.getPropertyValue('border-color'), + colorBackground: inputStyles.getPropertyValue('background-color'), + colorText: inputStyles.getPropertyValue('color'), + fontFamily: inputStyles.getPropertyValue('font-family'), + fontSize: inputStyles.getPropertyValue('font-size'), + borderRadius: inputStyles.getPropertyValue('border-radius'), + }, + }, + }); + +} + function wu_stripe_handle_intent(handler, client_secret, args) { const _handle_error = function (e) { @@ -223,7 +337,7 @@ function wu_stripe_handle_intent(handler, client_secret, args) { if (e.error) { wu_checkout_form.errors.push(e.error); - + } // end if; } // end _handle_error; @@ -231,7 +345,7 @@ function wu_stripe_handle_intent(handler, client_secret, args) { try { _stripe[handler](client_secret, args).then(function(results) { - + if (results.error) { _handle_error(results); @@ -249,7 +363,58 @@ function wu_stripe_handle_intent(handler, client_secret, args) { } // end if; /** - * After registration has been processed, handle card payments. + * Handle payment with Payment Element (modern method). + * + * @param {Object} form Checkout form object. + * @param {Object} response Server response with client_secret. + * @param {Object} elements Stripe Elements instance. + * + * @since 2.x.x + */ +function handlePaymentElement(form, response, elements) { + + const clientSecret = response.gateway.data.stripe_client_secret; + + if (!clientSecret) { + return; + } + + _stripe.confirmPayment({ + elements: elements, + confirmParams: { + return_url: window.location.href, + payment_method_data: { + billing_details: { + name: response.customer.display_name, + email: response.customer.user_email, + address: { + country: response.customer.billing_address_data.billing_country, + postal_code: response.customer.billing_address_data.billing_zip_code, + }, + }, + }, + }, + redirect: 'if_required', // Stay on page if redirect not needed + }).then(function(result) { + + if (result.error) { + + wu_checkout_form.unblock(); + wu_checkout_form.errors.push(result.error); + + } else { + + // Payment succeeded - resubmit form to complete checkout + wu_checkout_form.resubmit(); + + } + + }); + +} + +/** + * After registration has been processed, handle card payments (legacy Card Element). * * @param form * @param response diff --git a/inc/gateways/class-base-stripe-gateway.php b/inc/gateways/class-base-stripe-gateway.php index 593291b6..81d00d85 100644 --- a/inc/gateways/class-base-stripe-gateway.php +++ b/inc/gateways/class-base-stripe-gateway.php @@ -81,6 +81,54 @@ class Base_Stripe_Gateway extends Base_Gateway { */ protected $test_mode; + /** + * If Stripe Connect is enabled (OAuth mode). + * + * @since 2.x.x + * @var bool + */ + protected $is_connect_enabled = false; + + /** + * OAuth access token from Stripe Connect. + * + * @since 2.x.x + * @var string + */ + protected $oauth_access_token = ''; + + /** + * Stripe Connect account ID. + * + * @since 2.x.x + * @var string + */ + protected $oauth_account_id = ''; + + /** + * Platform client ID for OAuth. + * + * @since 2.x.x + * @var string + */ + protected $platform_client_id = ''; + + /** + * Application fee percentage for Stripe Connect. + * + * @since 2.x.x + * @var float + */ + protected $application_fee_percentage = 0.0; + + /** + * Authentication mode: 'direct' or 'oauth'. + * + * @since 2.x.x + * @var string + */ + protected $authentication_mode = 'direct'; + /** * Holds the Stripe client instance. * @@ -92,15 +140,23 @@ class Base_Stripe_Gateway extends Base_Gateway { /** * Gets or creates the Stripe client instance. * + * For OAuth/Connect mode, sets the Stripe-Account header to direct API calls + * to the connected account. + * * @return StripeClient */ protected function get_stripe_client(): StripeClient { if (! isset($this->stripe_client)) { - $this->stripe_client = new StripeClient( - [ - 'api_key' => $this->secret_key, - ] - ); + $client_config = [ + 'api_key' => $this->secret_key, + ]; + + // Set Stripe-Account header for Connect mode + if ($this->is_using_oauth() && ! empty($this->oauth_account_id)) { + $client_config['stripe_account'] = $this->oauth_account_id; + } + + $this->stripe_client = new StripeClient($client_config); } return $this->stripe_client; @@ -157,6 +213,8 @@ public function init(): void { /** * Setup api keys for stripe. * + * Supports dual authentication: OAuth (preferred) and direct API keys (fallback). + * * @since 2.0.7 * * @param string $id The gateway stripe id. @@ -166,12 +224,46 @@ public function setup_api_keys($id = false): void { $id = $id ?: wu_replace_dashes($this->get_id()); + // Check OAuth tokens first (preferred method) if ($this->test_mode) { - $this->publishable_key = wu_get_setting("{$id}_test_pk_key", ''); - $this->secret_key = wu_get_setting("{$id}_test_sk_key", ''); + $oauth_token = wu_get_setting("{$id}_test_access_token", ''); + + if (! empty($oauth_token)) { + // Use OAuth mode + $this->authentication_mode = 'oauth'; + $this->oauth_access_token = $oauth_token; + $this->publishable_key = wu_get_setting("{$id}_test_publishable_key", ''); + $this->secret_key = $oauth_token; + $this->oauth_account_id = wu_get_setting("{$id}_test_account_id", ''); + $this->is_connect_enabled = true; + } else { + // Fallback to direct API keys + $this->authentication_mode = 'direct'; + $this->publishable_key = wu_get_setting("{$id}_test_pk_key", ''); + $this->secret_key = wu_get_setting("{$id}_test_sk_key", ''); + } } else { - $this->publishable_key = wu_get_setting("{$id}_live_pk_key", ''); - $this->secret_key = wu_get_setting("{$id}_live_sk_key", ''); + $oauth_token = wu_get_setting("{$id}_live_access_token", ''); + + if (! empty($oauth_token)) { + // Use OAuth mode + $this->authentication_mode = 'oauth'; + $this->oauth_access_token = $oauth_token; + $this->publishable_key = wu_get_setting("{$id}_live_publishable_key", ''); + $this->secret_key = $oauth_token; + $this->oauth_account_id = wu_get_setting("{$id}_live_account_id", ''); + $this->is_connect_enabled = true; + } else { + // Fallback to direct API keys + $this->authentication_mode = 'direct'; + $this->publishable_key = wu_get_setting("{$id}_live_pk_key", ''); + $this->secret_key = wu_get_setting("{$id}_live_sk_key", ''); + } + } + + // Load application fee if using OAuth + if ($this->is_using_oauth()) { + $this->application_fee_percentage = $this->get_application_fee_percentage(); } if ($this->secret_key && Stripe\Stripe::getApiKey() !== $this->secret_key) { @@ -181,6 +273,297 @@ public function setup_api_keys($id = false): void { } } + /** + * Returns the current authentication mode. + * + * @since 2.x.x + * @return string 'oauth' or 'direct' + */ + public function get_authentication_mode(): string { + return $this->authentication_mode; + } + + /** + * Checks if using OAuth authentication. + * + * @since 2.x.x + * @return bool + */ + public function is_using_oauth(): bool { + return 'oauth' === $this->authentication_mode && $this->is_connect_enabled; + } + + /** + * Get application fee percentage. + * + * This is the percentage Ultimate Multisite receives from transactions. + * Can be defined as constant WU_STRIPE_APPLICATION_FEE or filtered. + * + * @since 2.x.x + * @return float + */ + protected function get_application_fee_percentage(): float { + /** + * Filter the application fee percentage for Stripe Connect. + * + * @param float $percentage Application fee percentage (0-100). + */ + return (float) apply_filters('wu_stripe_application_fee_percentage', defined('WU_STRIPE_APPLICATION_FEE') ? WU_STRIPE_APPLICATION_FEE : 0); + } + + /** + * Get Stripe Connect proxy server URL. + * + * The proxy server handles OAuth flow and keeps platform credentials secure. + * Platform credentials are never exposed in the distributed plugin code. + * + * @since 2.x.x + * @return string + */ + protected function get_proxy_url(): string { + /** + * Filter the Stripe Connect proxy URL. + * + * @param string $url Proxy server URL. + */ + return apply_filters( + 'wu_stripe_connect_proxy_url', + 'https://ultimatemultisite.com/wp-json/stripe-connect/v1' + ); + } + + /** + * Get business data for prefilling Stripe Connect form. + * + * @since 2.x.x + * @return array + */ + protected function get_business_data(): array { + return [ + 'url' => get_site_url(), + 'business_name' => get_bloginfo('name'), + 'country' => 'US', // Could be made dynamic based on site settings + ]; + } + + /** + * Generate Stripe Connect OAuth authorization URL via proxy server. + * + * @since 2.x.x + * @param string $state CSRF protection state parameter (unused, kept for compatibility). + * @return string + */ + public function get_connect_authorization_url(string $state = ''): string { + $proxy_url = $this->get_proxy_url(); + $return_url = admin_url('admin.php?page=wu-settings&tab=payment-gateways'); + + // Call proxy to initialize OAuth + $response = wp_remote_post( + $proxy_url . '/oauth/init', + [ + 'body' => wp_json_encode( + [ + 'returnUrl' => $return_url, + 'businessData' => $this->get_business_data(), + 'testMode' => $this->test_mode, + ] + ), + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'timeout' => 30, + ] + ); + + if (is_wp_error($response)) { + return ''; + } + + $data = json_decode(wp_remote_retrieve_body($response), true); + + if (empty($data['oauthUrl'])) { + return ''; + } + + // Store state for verification + update_option('wu_stripe_oauth_state', $data['state'], false); + + return $data['oauthUrl']; + } + + /** + * Get disconnect URL. + * + * @since 2.x.x + * @return string + */ + protected function get_disconnect_url(): string { + return add_query_arg( + [ + 'page' => 'wu-settings', + 'tab' => 'payment-gateways', + 'stripe_disconnect' => '1', + '_wpnonce' => wp_create_nonce('stripe_disconnect'), + ], + admin_url('admin.php') + ); + } + + /** + * Handle OAuth callbacks and disconnects. + * + * @since 2.x.x + * @return void + */ + public function handle_oauth_callbacks(): void { + // Handle OAuth callback from proxy (encrypted code) + if (isset($_GET['wcs_stripe_code'], $_GET['wcs_stripe_state']) && isset($_GET['page']) && 'wu-settings' === $_GET['page']) { + $encrypted_code = sanitize_text_field(wp_unslash($_GET['wcs_stripe_code'])); + $state = sanitize_text_field(wp_unslash($_GET['wcs_stripe_state'])); + + // Verify CSRF state + $expected_state = get_option('wu_stripe_oauth_state'); + + if ($expected_state && $expected_state === $state) { + $this->exchange_code_for_keys($encrypted_code); + } + } + + // Handle disconnect + if (isset($_GET['stripe_disconnect'], $_GET['_wpnonce']) && isset($_GET['page']) && 'wu-settings' === $_GET['page']) { + if (wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'])), 'stripe_disconnect')) { + $this->handle_disconnect(); + } + } + } + + /** + * Exchange encrypted code for API keys via proxy. + * + * @since 2.x.x + * @param string $encrypted_code Encrypted authorization code from proxy. + * @return void + */ + protected function exchange_code_for_keys(string $encrypted_code): void { + $proxy_url = $this->get_proxy_url(); + + // Call proxy to exchange code for keys + $response = wp_remote_post( + $proxy_url . '/oauth/keys', + [ + 'body' => wp_json_encode( + [ + 'code' => $encrypted_code, + 'testMode' => $this->test_mode, + ] + ), + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'timeout' => 30, + ] + ); + + if (is_wp_error($response)) { + wp_die(esc_html__('Failed to connect to proxy server', 'ultimate-multisite')); + } + + $status_code = wp_remote_retrieve_response_code($response); + $body = wp_remote_retrieve_body($response); + + if (200 !== $status_code) { + wp_die(esc_html__('Failed to obtain access token', 'ultimate-multisite')); + } + + $data = json_decode($body, true); + + if (empty($data['accessToken']) || empty($data['accountId'])) { + wp_die(esc_html__('Invalid response from proxy', 'ultimate-multisite')); + } + + // Delete state after successful exchange + delete_option('wu_stripe_oauth_state'); + + $id = wu_replace_dashes($this->get_id()); + + // Save tokens + if ($this->test_mode) { + wu_save_setting("{$id}_test_access_token", $data['secretKey']); + wu_save_setting("{$id}_test_refresh_token", $data['refreshToken'] ?? ''); + wu_save_setting("{$id}_test_account_id", $data['accountId']); + wu_save_setting("{$id}_test_publishable_key", $data['publishableKey']); + } else { + wu_save_setting("{$id}_live_access_token", $data['secretKey']); + wu_save_setting("{$id}_live_refresh_token", $data['refreshToken'] ?? ''); + wu_save_setting("{$id}_live_account_id", $data['accountId']); + wu_save_setting("{$id}_live_publishable_key", $data['publishableKey']); + } + + // Redirect back to settings + $redirect_url = add_query_arg( + [ + 'page' => 'wu-settings', + 'tab' => 'payment-gateways', + 'stripe_connected' => '1', + ], + admin_url('admin.php') + ); + + wp_safe_redirect($redirect_url); + exit; + } + + /** + * Handle disconnect request. + * + * @since 2.x.x + * @return void + */ + protected function handle_disconnect(): void { + $id = wu_replace_dashes($this->get_id()); + + // Optionally notify proxy of disconnect + $proxy_url = $this->get_proxy_url(); + wp_remote_post( + $proxy_url . '/deauthorize', + [ + 'body' => wp_json_encode( + [ + 'siteUrl' => get_site_url(), + 'testMode' => $this->test_mode, + ] + ), + 'headers' => ['Content-Type' => 'application/json'], + 'timeout' => 10, + 'blocking' => false, // Don't wait for response + ] + ); + + // Clear OAuth tokens for both test and live + wu_save_setting("{$id}_test_access_token", ''); + wu_save_setting("{$id}_test_refresh_token", ''); + wu_save_setting("{$id}_test_account_id", ''); + wu_save_setting("{$id}_test_publishable_key", ''); + + wu_save_setting("{$id}_live_access_token", ''); + wu_save_setting("{$id}_live_refresh_token", ''); + wu_save_setting("{$id}_live_account_id", ''); + wu_save_setting("{$id}_live_publishable_key", ''); + + // Redirect back to settings + $redirect_url = add_query_arg( + [ + 'page' => 'wu-settings', + 'tab' => 'payment-gateways', + 'stripe_disconnected' => '1', + ], + admin_url('admin.php') + ); + + wp_safe_redirect($redirect_url); + exit; + } + /** * Adds additional hooks. * @@ -1107,6 +1490,13 @@ protected function create_recurring_payment($membership, $cart, $payment_method, } } + /* + * Add application fee if using OAuth Connect mode. + */ + if ($this->is_using_oauth() && $this->application_fee_percentage > 0) { + $sub_args['application_fee_percent'] = $this->application_fee_percentage; + } + /* * Filters the Stripe subscription arguments. */ diff --git a/inc/gateways/class-stripe-gateway.php b/inc/gateways/class-stripe-gateway.php index 04218a3a..d720c813 100644 --- a/inc/gateways/class-stripe-gateway.php +++ b/inc/gateways/class-stripe-gateway.php @@ -45,6 +45,9 @@ public function hooks(): void { parent::hooks(); + // Handle OAuth callbacks and disconnects + add_action('admin_init', [$this, 'handle_oauth_callbacks']); + add_filter( 'wu_customer_payment_methods', function ($fields, $customer): array { @@ -98,6 +101,54 @@ public function settings(): void { ] ); + // OAuth Connect Section + wu_register_settings_field( + 'payment-gateways', + 'stripe_auth_header', + [ + 'title' => __('Stripe Authentication', 'ultimate-multisite'), + 'desc' => __('Choose how to authenticate with Stripe. OAuth is recommended for easier setup and platform fees.', 'ultimate-multisite'), + 'type' => 'header', + 'show_as_submenu' => false, + 'require' => [ + 'active_gateways' => 'stripe', + ], + ] + ); + + // OAuth Connection Status/Button + wu_register_settings_field( + 'payment-gateways', + 'stripe_oauth_connection', + [ + 'title' => __('Stripe Connect (Recommended)', 'ultimate-multisite'), + 'desc' => __('Connect your Stripe account securely with one click. This provides easier setup and automatic configuration.', 'ultimate-multisite'), + 'type' => 'html', + 'content' => $this->get_oauth_connection_html(), + 'require' => [ + 'active_gateways' => 'stripe', + ], + ] + ); + + // Advanced: Show Direct API Keys Toggle + wu_register_settings_field( + 'payment-gateways', + 'stripe_show_direct_keys', + [ + 'title' => __('Use Direct API Keys (Advanced)', 'ultimate-multisite'), + 'desc' => __('Toggle to manually enter API keys instead of using OAuth. Use this for backwards compatibility or advanced configurations.', 'ultimate-multisite'), + 'type' => 'toggle', + 'default' => 0, + 'html_attr' => [ + 'v-model' => 'stripe_show_direct_keys', + ], + 'require' => [ + 'active_gateways' => 'stripe', + ], + ] + ); + wu_register_settings_field( 'payment-gateways', 'stripe_sandbox_mode', @@ -129,8 +180,9 @@ public function settings(): void { 'default' => '', 'capability' => 'manage_api_keys', 'require' => [ - 'active_gateways' => 'stripe', - 'stripe_sandbox_mode' => 1, + 'active_gateways' => 'stripe', + 'stripe_sandbox_mode' => 1, + 'stripe_show_direct_keys' => 1, ], ] ); @@ -149,8 +201,9 @@ public function settings(): void { 'default' => '', 'capability' => 'manage_api_keys', 'require' => [ - 'active_gateways' => 'stripe', - 'stripe_sandbox_mode' => 1, + 'active_gateways' => 'stripe', + 'stripe_sandbox_mode' => 1, + 'stripe_show_direct_keys' => 1, ], ] ); @@ -169,8 +222,9 @@ public function settings(): void { 'default' => '', 'capability' => 'manage_api_keys', 'require' => [ - 'active_gateways' => 'stripe', - 'stripe_sandbox_mode' => 0, + 'active_gateways' => 'stripe', + 'stripe_sandbox_mode' => 0, + 'stripe_show_direct_keys' => 1, ], ] ); @@ -189,8 +243,9 @@ public function settings(): void { 'default' => '', 'capability' => 'manage_api_keys', 'require' => [ - 'active_gateways' => 'stripe', - 'stripe_sandbox_mode' => 0, + 'active_gateways' => 'stripe', + 'stripe_sandbox_mode' => 0, + 'stripe_show_direct_keys' => 1, ], ] ); @@ -687,6 +742,12 @@ public function fields(): string {
+ +
+ +
+ +
@@ -796,4 +857,56 @@ public function get_user_saved_payment_methods() { return []; } } + + /** + * Get OAuth connection status HTML. + * + * Displays either the connected status with account ID and disconnect button, + * or a "Connect with Stripe" button for new connections. + * + * @since 2.x.x + * @return string HTML content for the OAuth connection status + */ + protected function get_oauth_connection_html(): string { + $is_oauth = $this->is_using_oauth(); + $account_id = $this->oauth_account_id; + + if ($is_oauth && ! empty($account_id)) { + // Connected state + return sprintf( + '
+
+ + %s +
+

%s %s

+ %s +
', + esc_html__('Connected via Stripe Connect', 'ultimate-multisite'), + esc_html__('Account ID:', 'ultimate-multisite'), + esc_html($account_id), + esc_url($this->get_disconnect_url()), + esc_html__('Disconnect', 'ultimate-multisite') + ); + } + + // Disconnected state - show connect button + $state = wp_generate_password(32, false); + update_option('wu_stripe_oauth_state', $state, false); + + return sprintf( + '
+

%s

+ + + %s + +

%s

+
', + esc_html__('Connect your Stripe account with one click.', 'ultimate-multisite'), + esc_url($this->get_connect_authorization_url($state)), + esc_html__('Connect with Stripe', 'ultimate-multisite'), + esc_html__('You will be redirected to Stripe to securely authorize the connection.', 'ultimate-multisite') + ); + } } diff --git a/tests/WP_Ultimo/Gateways/Stripe_OAuth_E2E_Test.php b/tests/WP_Ultimo/Gateways/Stripe_OAuth_E2E_Test.php new file mode 100644 index 00000000..19b37790 --- /dev/null +++ b/tests/WP_Ultimo/Gateways/Stripe_OAuth_E2E_Test.php @@ -0,0 +1,209 @@ +clear_all_stripe_settings(); + } + + /** + * Test that OAuth tokens are correctly saved from simulated callback. + */ + public function test_oauth_tokens_saved_correctly() { + // Manually simulate what the OAuth callback does + wu_save_setting('stripe_test_access_token', 'sk_test_oauth_abc123'); + wu_save_setting('stripe_test_account_id', 'acct_test_xyz789'); + wu_save_setting('stripe_test_publishable_key', 'pk_test_oauth_abc123'); + wu_save_setting('stripe_test_refresh_token', 'rt_test_refresh_abc123'); + wu_save_setting('stripe_sandbox_mode', 1); + + // Initialize gateway + $gateway = new Stripe_Gateway(); + $gateway->init(); + + // Verify OAuth mode is detected + $this->assertTrue($gateway->is_using_oauth()); + $this->assertEquals('oauth', $gateway->get_authentication_mode()); + + // Verify account ID is loaded + $reflection = new \ReflectionClass($gateway); + $property = $reflection->getProperty('oauth_account_id'); + $property->setAccessible(true); + $this->assertEquals('acct_test_xyz789', $property->getValue($gateway)); + } + + /** + * Test that application fee percentage is loaded when using OAuth. + */ + public function test_application_fee_loaded_with_oauth() { + // Mock application fee via filter + add_filter('wu_stripe_application_fee_percentage', function() { + return 3.5; + }); + + // Setup OAuth mode + wu_save_setting('stripe_test_access_token', 'sk_test_oauth_token_123'); + wu_save_setting('stripe_test_account_id', 'acct_test123'); + wu_save_setting('stripe_test_publishable_key', 'pk_test_oauth_123'); + wu_save_setting('stripe_sandbox_mode', 1); + + $gateway = new Stripe_Gateway(); + $gateway->init(); + + // Verify OAuth mode + $this->assertTrue($gateway->is_using_oauth()); + + // Verify application fee is loaded + $reflection = new \ReflectionClass($gateway); + $property = $reflection->getProperty('application_fee_percentage'); + $property->setAccessible(true); + + $this->assertEquals(3.5, $property->getValue($gateway)); + } + + /** + * Test that application fee is zero when using direct API keys. + */ + public function test_application_fee_zero_with_direct_keys() { + // Setup direct mode (no OAuth tokens) + wu_save_setting('stripe_test_pk_key', 'pk_test_direct_123'); + wu_save_setting('stripe_test_sk_key', 'sk_test_direct_123'); + wu_save_setting('stripe_application_fee_percentage', 3.5); // Should be ignored + wu_save_setting('stripe_sandbox_mode', 1); + + $gateway = new Stripe_Gateway(); + $gateway->init(); + + // Verify NOT using OAuth + $this->assertFalse($gateway->is_using_oauth()); + + // Verify application fee is zero (not loaded in direct mode) + $reflection = new \ReflectionClass($gateway); + $property = $reflection->getProperty('application_fee_percentage'); + $property->setAccessible(true); + + $this->assertEquals(0, $property->getValue($gateway)); + } + + /** + * Test that Stripe client is configured with account header in OAuth mode. + */ + public function test_stripe_client_has_account_header_in_oauth_mode() { + // Setup OAuth mode + wu_save_setting('stripe_test_access_token', 'sk_test_oauth_token_123'); + wu_save_setting('stripe_test_account_id', 'acct_oauth_123'); + wu_save_setting('stripe_test_publishable_key', 'pk_test_oauth_123'); + wu_save_setting('stripe_sandbox_mode', 1); + + $gateway = new Stripe_Gateway(); + $gateway->init(); + + $this->assertTrue($gateway->is_using_oauth()); + + // Access oauth_account_id via reflection + $reflection = new \ReflectionClass($gateway); + $property = $reflection->getProperty('oauth_account_id'); + $property->setAccessible(true); + + // Verify account ID is set + $this->assertEquals('acct_oauth_123', $property->getValue($gateway)); + } + + /** + * Test the complete OAuth setup flow. + */ + public function test_complete_oauth_flow_simulation() { + // Step 1: Start with no configuration + $this->clear_all_stripe_settings(); + + // Step 2: Platform credentials configured via filter (simulating wp-config.php constants) + add_filter('wu_stripe_platform_client_id', function() { + return 'ca_platform_test_123'; + }); + add_filter('wu_stripe_application_fee_percentage', function() { + return 2.5; + }); + + wu_save_setting('stripe_sandbox_mode', 1); + + // Step 3: User clicks "Connect with Stripe" and OAuth completes + // (Simulating what happens after successful OAuth callback) + wu_save_setting('stripe_test_access_token', 'sk_test_connected_abc'); + wu_save_setting('stripe_test_account_id', 'acct_connected_xyz'); + wu_save_setting('stripe_test_publishable_key', 'pk_test_connected_abc'); + wu_save_setting('stripe_test_refresh_token', 'rt_test_refresh_abc'); + + // Step 4: Gateway initializes and detects OAuth + $gateway = new Stripe_Gateway(); + $gateway->init(); + + // Verify OAuth mode + $this->assertTrue($gateway->is_using_oauth()); + $this->assertEquals('oauth', $gateway->get_authentication_mode()); + + // Verify application fee is loaded + $reflection = new \ReflectionClass($gateway); + $fee_property = $reflection->getProperty('application_fee_percentage'); + $fee_property->setAccessible(true); + $this->assertEquals(2.5, $fee_property->getValue($gateway)); + + // Verify account ID is loaded + $account_property = $reflection->getProperty('oauth_account_id'); + $account_property->setAccessible(true); + $this->assertEquals('acct_connected_xyz', $account_property->getValue($gateway)); + + // Step 5: Verify direct keys would still work if OAuth disconnected + wu_save_setting('stripe_test_access_token', ''); // Clear OAuth + wu_save_setting('stripe_test_pk_key', 'pk_test_direct_fallback'); + wu_save_setting('stripe_test_sk_key', 'sk_test_direct_fallback'); + + $gateway2 = new Stripe_Gateway(); + $gateway2->init(); + + // Should fall back to direct mode + $this->assertFalse($gateway2->is_using_oauth()); + $this->assertEquals('direct', $gateway2->get_authentication_mode()); + } + + /** + * Clear all Stripe settings. + */ + private function clear_all_stripe_settings() { + wu_save_setting('stripe_test_access_token', ''); + wu_save_setting('stripe_test_account_id', ''); + wu_save_setting('stripe_test_publishable_key', ''); + wu_save_setting('stripe_test_refresh_token', ''); + wu_save_setting('stripe_live_access_token', ''); + wu_save_setting('stripe_live_account_id', ''); + wu_save_setting('stripe_live_publishable_key', ''); + wu_save_setting('stripe_live_refresh_token', ''); + wu_save_setting('stripe_test_pk_key', ''); + wu_save_setting('stripe_test_sk_key', ''); + wu_save_setting('stripe_live_pk_key', ''); + wu_save_setting('stripe_live_sk_key', ''); + // Note: Platform credentials are now configured via constants/filters, not settings + } +} diff --git a/tests/WP_Ultimo/Gateways/Stripe_OAuth_Test.php b/tests/WP_Ultimo/Gateways/Stripe_OAuth_Test.php new file mode 100644 index 00000000..dac72c49 --- /dev/null +++ b/tests/WP_Ultimo/Gateways/Stripe_OAuth_Test.php @@ -0,0 +1,348 @@ +gateway = new Stripe_Gateway(); + } + + /** + * Test authentication mode detection - OAuth mode. + */ + public function test_oauth_mode_detection() { + // Set OAuth token + wu_save_setting('stripe_test_access_token', 'sk_test_oauth_token_123'); + wu_save_setting('stripe_test_account_id', 'acct_123'); + wu_save_setting('stripe_test_publishable_key', 'pk_test_oauth_123'); + wu_save_setting('stripe_sandbox_mode', 1); + + $gateway = new Stripe_Gateway(); + $gateway->init(); + + $this->assertEquals('oauth', $gateway->get_authentication_mode()); + $this->assertTrue($gateway->is_using_oauth()); + } + + /** + * Test authentication mode detection - direct mode. + */ + public function test_direct_mode_detection() { + // Only set direct API keys + wu_save_setting('stripe_test_pk_key', 'pk_test_direct_123'); + wu_save_setting('stripe_test_sk_key', 'sk_test_direct_123'); + wu_save_setting('stripe_sandbox_mode', 1); + + $gateway = new Stripe_Gateway(); + $gateway->init(); + + $this->assertEquals('direct', $gateway->get_authentication_mode()); + $this->assertFalse($gateway->is_using_oauth()); + } + + /** + * Test OAuth takes precedence over direct API keys. + */ + public function test_oauth_precedence_over_direct() { + // Set both OAuth and direct keys + wu_save_setting('stripe_test_access_token', 'sk_test_oauth_token_123'); + wu_save_setting('stripe_test_account_id', 'acct_123'); + wu_save_setting('stripe_test_publishable_key', 'pk_test_oauth_123'); + wu_save_setting('stripe_test_pk_key', 'pk_test_direct_123'); + wu_save_setting('stripe_test_sk_key', 'sk_test_direct_123'); + wu_save_setting('stripe_sandbox_mode', 1); + + $gateway = new Stripe_Gateway(); + $gateway->init(); + + // OAuth should take precedence + $this->assertEquals('oauth', $gateway->get_authentication_mode()); + $this->assertTrue($gateway->is_using_oauth()); + } + + /** + * Test application fee percentage is loaded for OAuth. + */ + public function test_application_fee_loaded_for_oauth() { + // Mock application fee via filter (simulating wp-config.php constant) + add_filter('wu_stripe_application_fee_percentage', function() { + return 2.5; + }); + + wu_save_setting('stripe_test_access_token', 'sk_test_oauth_token_123'); + wu_save_setting('stripe_test_account_id', 'acct_123'); + wu_save_setting('stripe_test_publishable_key', 'pk_test_oauth_123'); + wu_save_setting('stripe_sandbox_mode', 1); + + $gateway = new Stripe_Gateway(); + $gateway->init(); + + $this->assertTrue($gateway->is_using_oauth()); + + // Use reflection to check protected property + $reflection = new \ReflectionClass($gateway); + $property = $reflection->getProperty('application_fee_percentage'); + $property->setAccessible(true); + + $this->assertEquals(2.5, $property->getValue($gateway)); + } + + /** + * Test application fee not loaded for direct mode. + */ + public function test_application_fee_not_loaded_for_direct() { + wu_save_setting('stripe_test_pk_key', 'pk_test_direct_123'); + wu_save_setting('stripe_test_sk_key', 'sk_test_direct_123'); + wu_save_setting('stripe_application_fee_percentage', 2.5); + wu_save_setting('stripe_sandbox_mode', 1); + + $gateway = new Stripe_Gateway(); + $gateway->init(); + + $this->assertFalse($gateway->is_using_oauth()); + + // Use reflection to check protected property + $reflection = new \ReflectionClass($gateway); + $property = $reflection->getProperty('application_fee_percentage'); + $property->setAccessible(true); + + $this->assertEquals(0, $property->getValue($gateway)); + } + + /** + * Test OAuth authorization URL generation via proxy. + */ + public function test_oauth_authorization_url_generation() { + // Mock proxy response + add_filter('pre_http_request', function($preempt, $args, $url) { + if (strpos($url, '/oauth/init') !== false) { + return [ + 'response' => ['code' => 200], + 'body' => wp_json_encode([ + 'oauthUrl' => 'https://connect.stripe.com/oauth/authorize?client_id=ca_test123&state=encrypted_state&scope=read_write', + 'state' => 'test_state_123', + ]), + ]; + } + return $preempt; + }, 10, 3); + + $gateway = new Stripe_Gateway(); + $url = $gateway->get_connect_authorization_url(''); + + $this->assertStringContainsString('connect.stripe.com/oauth/authorize', $url); + $this->assertStringContainsString('client_id=ca_test123', $url); + $this->assertStringContainsString('scope=read_write', $url); + + // Verify state was stored + $this->assertEquals('test_state_123', get_option('wu_stripe_oauth_state')); + } + + /** + * Test OAuth authorization URL returns empty on proxy failure. + */ + public function test_oauth_authorization_url_requires_client_id() { + // Mock proxy returning error or invalid response + add_filter('pre_http_request', function($preempt, $args, $url) { + if (strpos($url, '/oauth/init') !== false) { + return new \WP_Error('http_request_failed', 'Connection failed'); + } + return $preempt; + }, 10, 3); + + $gateway = new Stripe_Gateway(); + $url = $gateway->get_connect_authorization_url(''); + + $this->assertEmpty($url); + } + + /** + * Test backwards compatibility with existing API keys. + */ + public function test_backwards_compatibility_with_existing_keys() { + // Simulate existing installation with direct API keys + wu_save_setting('stripe_test_pk_key', 'pk_test_existing_123'); + wu_save_setting('stripe_test_sk_key', 'sk_test_existing_123'); + wu_save_setting('stripe_sandbox_mode', 1); + + $gateway = new Stripe_Gateway(); + $gateway->init(); + + // Should work in direct mode + $this->assertEquals('direct', $gateway->get_authentication_mode()); + $this->assertFalse($gateway->is_using_oauth()); + + // Verify API keys are loaded + $reflection = new \ReflectionClass($gateway); + $secret_property = $reflection->getProperty('secret_key'); + $secret_property->setAccessible(true); + + $this->assertEquals('sk_test_existing_123', $secret_property->getValue($gateway)); + } + + /** + * Test OAuth account ID is loaded in OAuth mode. + */ + public function test_oauth_account_id_loaded() { + wu_save_setting('stripe_test_access_token', 'sk_test_oauth_token_123'); + wu_save_setting('stripe_test_account_id', 'acct_test123'); + wu_save_setting('stripe_test_publishable_key', 'pk_test_oauth_123'); + wu_save_setting('stripe_sandbox_mode', 1); + + $gateway = new Stripe_Gateway(); + $gateway->init(); + + $this->assertTrue($gateway->is_using_oauth()); + + // Verify account ID is loaded + $reflection = new \ReflectionClass($gateway); + $property = $reflection->getProperty('oauth_account_id'); + $property->setAccessible(true); + + $this->assertEquals('acct_test123', $property->getValue($gateway)); + } + + /** + * Test OAuth account ID is not loaded in direct mode. + */ + public function test_oauth_account_id_not_loaded_for_direct() { + wu_save_setting('stripe_test_pk_key', 'pk_test_direct_123'); + wu_save_setting('stripe_test_sk_key', 'sk_test_direct_123'); + wu_save_setting('stripe_sandbox_mode', 1); + + $gateway = new Stripe_Gateway(); + $gateway->init(); + + $this->assertFalse($gateway->is_using_oauth()); + + // Verify account ID is empty + $reflection = new \ReflectionClass($gateway); + $property = $reflection->getProperty('oauth_account_id'); + $property->setAccessible(true); + + $this->assertEmpty($property->getValue($gateway)); + } + + /** + * Test disconnect settings are cleared manually (without redirect). + */ + public function test_disconnect_settings_cleared() { + $id = 'stripe'; + + // Set OAuth tokens for both test and live mode + wu_save_setting("{$id}_test_access_token", 'sk_test_oauth_token_123'); + wu_save_setting("{$id}_test_account_id", 'acct_test123'); + wu_save_setting("{$id}_test_publishable_key", 'pk_test_oauth_123'); + wu_save_setting("{$id}_test_refresh_token", 'rt_test_123'); + wu_save_setting("{$id}_live_access_token", 'sk_live_oauth_token_123'); + wu_save_setting("{$id}_live_account_id", 'acct_live123'); + wu_save_setting("{$id}_live_publishable_key", 'pk_live_oauth_123'); + wu_save_setting("{$id}_live_refresh_token", 'rt_live_123'); + + // Manually clear settings (simulating disconnect without the redirect) + wu_save_setting("{$id}_test_access_token", ''); + wu_save_setting("{$id}_test_refresh_token", ''); + wu_save_setting("{$id}_test_account_id", ''); + wu_save_setting("{$id}_test_publishable_key", ''); + wu_save_setting("{$id}_live_access_token", ''); + wu_save_setting("{$id}_live_refresh_token", ''); + wu_save_setting("{$id}_live_account_id", ''); + wu_save_setting("{$id}_live_publishable_key", ''); + + // Verify all OAuth tokens are cleared + $this->assertEmpty(wu_get_setting("{$id}_test_access_token", '')); + $this->assertEmpty(wu_get_setting("{$id}_test_account_id", '')); + $this->assertEmpty(wu_get_setting("{$id}_test_publishable_key", '')); + $this->assertEmpty(wu_get_setting("{$id}_test_refresh_token", '')); + $this->assertEmpty(wu_get_setting("{$id}_live_access_token", '')); + $this->assertEmpty(wu_get_setting("{$id}_live_account_id", '')); + $this->assertEmpty(wu_get_setting("{$id}_live_publishable_key", '')); + $this->assertEmpty(wu_get_setting("{$id}_live_refresh_token", '')); + } + + /** + * Test direct API keys are independent from OAuth tokens. + */ + public function test_direct_keys_independent() { + // Set both OAuth tokens and direct keys + wu_save_setting('stripe_test_access_token', 'sk_test_oauth_token_123'); + wu_save_setting('stripe_test_pk_key', 'pk_test_direct_123'); + wu_save_setting('stripe_test_sk_key', 'sk_test_direct_123'); + + // Clearing OAuth tokens shouldn't affect direct keys + wu_save_setting('stripe_test_access_token', ''); + + // Verify direct API keys are still present + $this->assertEquals('pk_test_direct_123', wu_get_setting('stripe_test_pk_key')); + $this->assertEquals('sk_test_direct_123', wu_get_setting('stripe_test_sk_key')); + } + + /** + * Test live mode OAuth detection. + */ + public function test_live_mode_oauth_detection() { + // Set live mode OAuth tokens + wu_save_setting('stripe_live_access_token', 'sk_live_oauth_token_123'); + wu_save_setting('stripe_live_account_id', 'acct_live123'); + wu_save_setting('stripe_live_publishable_key', 'pk_live_oauth_123'); + wu_save_setting('stripe_sandbox_mode', 0); // Live mode + + $gateway = new Stripe_Gateway(); + $gateway->init(); + + $this->assertEquals('oauth', $gateway->get_authentication_mode()); + $this->assertTrue($gateway->is_using_oauth()); + } + + /** + * Test disconnect URL has proper nonce. + */ + public function test_disconnect_url_has_nonce() { + $gateway = new Stripe_Gateway(); + + $reflection = new \ReflectionClass($gateway); + $method = $reflection->getMethod('get_disconnect_url'); + $method->setAccessible(true); + + $url = $method->invoke($gateway); + + $this->assertStringContainsString('stripe_disconnect=1', $url); + $this->assertStringContainsString('_wpnonce=', $url); + $this->assertStringContainsString('page=wu-settings', $url); + $this->assertStringContainsString('tab=payment-gateways', $url); + } +} From bd87731168503bafaf65030ce584619571e199f9 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 15 Dec 2025 11:53:37 -0700 Subject: [PATCH 2/5] Optimize Stripe OAuth performance and remove application fees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Performance Improvements: - Implement lazy loading for OAuth initialization - Move proxy HTTP request from page load to button click - Add get_oauth_init_url() method for deferred OAuth flow - Update handle_oauth_callbacks() to process init requests Application Fee Removal: - Remove application_fee_percentage property and methods - Remove fee application from subscription creation - Clean up OAuth tests to remove fee-related assertions - Remove 4 application fee tests from test suite Technical Details: - OAuth URL generation now happens only when user clicks "Connect with Stripe" - State generation moved to init handler for better security - All 15 OAuth tests passing (44 assertions) This eliminates unnecessary HTTP requests on every settings page load and simplifies the codebase by removing unused application fee logic. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- inc/gateways/class-base-stripe-gateway.php | 72 +++++++++---------- inc/gateways/class-stripe-gateway.php | 5 +- .../Gateways/Stripe_OAuth_E2E_Test.php | 63 +--------------- .../WP_Ultimo/Gateways/Stripe_OAuth_Test.php | 50 ------------- 4 files changed, 36 insertions(+), 154 deletions(-) diff --git a/inc/gateways/class-base-stripe-gateway.php b/inc/gateways/class-base-stripe-gateway.php index 81d00d85..cc64b3b0 100644 --- a/inc/gateways/class-base-stripe-gateway.php +++ b/inc/gateways/class-base-stripe-gateway.php @@ -113,14 +113,6 @@ class Base_Stripe_Gateway extends Base_Gateway { */ protected $platform_client_id = ''; - /** - * Application fee percentage for Stripe Connect. - * - * @since 2.x.x - * @var float - */ - protected $application_fee_percentage = 0.0; - /** * Authentication mode: 'direct' or 'oauth'. * @@ -261,11 +253,6 @@ public function setup_api_keys($id = false): void { } } - // Load application fee if using OAuth - if ($this->is_using_oauth()) { - $this->application_fee_percentage = $this->get_application_fee_percentage(); - } - if ($this->secret_key && Stripe\Stripe::getApiKey() !== $this->secret_key) { Stripe\Stripe::setApiKey($this->secret_key); @@ -293,24 +280,6 @@ public function is_using_oauth(): bool { return 'oauth' === $this->authentication_mode && $this->is_connect_enabled; } - /** - * Get application fee percentage. - * - * This is the percentage Ultimate Multisite receives from transactions. - * Can be defined as constant WU_STRIPE_APPLICATION_FEE or filtered. - * - * @since 2.x.x - * @return float - */ - protected function get_application_fee_percentage(): float { - /** - * Filter the application fee percentage for Stripe Connect. - * - * @param float $percentage Application fee percentage (0-100). - */ - return (float) apply_filters('wu_stripe_application_fee_percentage', defined('WU_STRIPE_APPLICATION_FEE') ? WU_STRIPE_APPLICATION_FEE : 0); - } - /** * Get Stripe Connect proxy server URL. * @@ -391,6 +360,27 @@ public function get_connect_authorization_url(string $state = ''): string { return $data['oauthUrl']; } + /** + * Get OAuth init URL (triggers OAuth flow when clicked). + * + * This returns a local URL that will initiate the OAuth flow only when clicked, + * avoiding unnecessary HTTP requests to the proxy on every page load. + * + * @since 2.x.x + * @return string + */ + protected function get_oauth_init_url(): string { + return add_query_arg( + [ + 'page' => 'wu-settings', + 'tab' => 'payment-gateways', + 'stripe_oauth_init' => '1', + '_wpnonce' => wp_create_nonce('stripe_oauth_init'), + ], + admin_url('admin.php') + ); + } + /** * Get disconnect URL. * @@ -416,6 +406,19 @@ protected function get_disconnect_url(): string { * @return void */ public function handle_oauth_callbacks(): void { + // Handle OAuth init (user clicked Connect button) + if (isset($_GET['stripe_oauth_init'], $_GET['_wpnonce']) && isset($_GET['page']) && 'wu-settings' === $_GET['page']) { + if (wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'])), 'stripe_oauth_init')) { + // Now make the proxy call and redirect to OAuth URL + $oauth_url = $this->get_connect_authorization_url(); + + if (! empty($oauth_url)) { + wp_safe_redirect($oauth_url); + exit; + } + } + } + // Handle OAuth callback from proxy (encrypted code) if (isset($_GET['wcs_stripe_code'], $_GET['wcs_stripe_state']) && isset($_GET['page']) && 'wu-settings' === $_GET['page']) { $encrypted_code = sanitize_text_field(wp_unslash($_GET['wcs_stripe_code'])); @@ -1490,13 +1493,6 @@ protected function create_recurring_payment($membership, $cart, $payment_method, } } - /* - * Add application fee if using OAuth Connect mode. - */ - if ($this->is_using_oauth() && $this->application_fee_percentage > 0) { - $sub_args['application_fee_percent'] = $this->application_fee_percentage; - } - /* * Filters the Stripe subscription arguments. */ diff --git a/inc/gateways/class-stripe-gateway.php b/inc/gateways/class-stripe-gateway.php index d720c813..b2a5183e 100644 --- a/inc/gateways/class-stripe-gateway.php +++ b/inc/gateways/class-stripe-gateway.php @@ -891,9 +891,6 @@ protected function get_oauth_connection_html(): string { } // Disconnected state - show connect button - $state = wp_generate_password(32, false); - update_option('wu_stripe_oauth_state', $state, false); - return sprintf( '

%s

@@ -904,7 +901,7 @@ protected function get_oauth_connection_html(): string {

%s

', esc_html__('Connect your Stripe account with one click.', 'ultimate-multisite'), - esc_url($this->get_connect_authorization_url($state)), + esc_url($this->get_oauth_init_url()), esc_html__('Connect with Stripe', 'ultimate-multisite'), esc_html__('You will be redirected to Stripe to securely authorize the connection.', 'ultimate-multisite') ); diff --git a/tests/WP_Ultimo/Gateways/Stripe_OAuth_E2E_Test.php b/tests/WP_Ultimo/Gateways/Stripe_OAuth_E2E_Test.php index 19b37790..90245034 100644 --- a/tests/WP_Ultimo/Gateways/Stripe_OAuth_E2E_Test.php +++ b/tests/WP_Ultimo/Gateways/Stripe_OAuth_E2E_Test.php @@ -55,59 +55,6 @@ public function test_oauth_tokens_saved_correctly() { $this->assertEquals('acct_test_xyz789', $property->getValue($gateway)); } - /** - * Test that application fee percentage is loaded when using OAuth. - */ - public function test_application_fee_loaded_with_oauth() { - // Mock application fee via filter - add_filter('wu_stripe_application_fee_percentage', function() { - return 3.5; - }); - - // Setup OAuth mode - wu_save_setting('stripe_test_access_token', 'sk_test_oauth_token_123'); - wu_save_setting('stripe_test_account_id', 'acct_test123'); - wu_save_setting('stripe_test_publishable_key', 'pk_test_oauth_123'); - wu_save_setting('stripe_sandbox_mode', 1); - - $gateway = new Stripe_Gateway(); - $gateway->init(); - - // Verify OAuth mode - $this->assertTrue($gateway->is_using_oauth()); - - // Verify application fee is loaded - $reflection = new \ReflectionClass($gateway); - $property = $reflection->getProperty('application_fee_percentage'); - $property->setAccessible(true); - - $this->assertEquals(3.5, $property->getValue($gateway)); - } - - /** - * Test that application fee is zero when using direct API keys. - */ - public function test_application_fee_zero_with_direct_keys() { - // Setup direct mode (no OAuth tokens) - wu_save_setting('stripe_test_pk_key', 'pk_test_direct_123'); - wu_save_setting('stripe_test_sk_key', 'sk_test_direct_123'); - wu_save_setting('stripe_application_fee_percentage', 3.5); // Should be ignored - wu_save_setting('stripe_sandbox_mode', 1); - - $gateway = new Stripe_Gateway(); - $gateway->init(); - - // Verify NOT using OAuth - $this->assertFalse($gateway->is_using_oauth()); - - // Verify application fee is zero (not loaded in direct mode) - $reflection = new \ReflectionClass($gateway); - $property = $reflection->getProperty('application_fee_percentage'); - $property->setAccessible(true); - - $this->assertEquals(0, $property->getValue($gateway)); - } - /** * Test that Stripe client is configured with account header in OAuth mode. */ @@ -143,9 +90,6 @@ public function test_complete_oauth_flow_simulation() { add_filter('wu_stripe_platform_client_id', function() { return 'ca_platform_test_123'; }); - add_filter('wu_stripe_application_fee_percentage', function() { - return 2.5; - }); wu_save_setting('stripe_sandbox_mode', 1); @@ -164,13 +108,8 @@ public function test_complete_oauth_flow_simulation() { $this->assertTrue($gateway->is_using_oauth()); $this->assertEquals('oauth', $gateway->get_authentication_mode()); - // Verify application fee is loaded - $reflection = new \ReflectionClass($gateway); - $fee_property = $reflection->getProperty('application_fee_percentage'); - $fee_property->setAccessible(true); - $this->assertEquals(2.5, $fee_property->getValue($gateway)); - // Verify account ID is loaded + $reflection = new \ReflectionClass($gateway); $account_property = $reflection->getProperty('oauth_account_id'); $account_property->setAccessible(true); $this->assertEquals('acct_connected_xyz', $account_property->getValue($gateway)); diff --git a/tests/WP_Ultimo/Gateways/Stripe_OAuth_Test.php b/tests/WP_Ultimo/Gateways/Stripe_OAuth_Test.php index dac72c49..e1253449 100644 --- a/tests/WP_Ultimo/Gateways/Stripe_OAuth_Test.php +++ b/tests/WP_Ultimo/Gateways/Stripe_OAuth_Test.php @@ -35,7 +35,6 @@ public function setUp(): void { wu_save_setting('stripe_test_publishable_key', ''); wu_save_setting('stripe_test_pk_key', ''); wu_save_setting('stripe_test_sk_key', ''); - wu_save_setting('stripe_application_fee_percentage', 0); wu_save_setting('stripe_sandbox_mode', 1); $this->gateway = new Stripe_Gateway(); @@ -94,55 +93,6 @@ public function test_oauth_precedence_over_direct() { $this->assertTrue($gateway->is_using_oauth()); } - /** - * Test application fee percentage is loaded for OAuth. - */ - public function test_application_fee_loaded_for_oauth() { - // Mock application fee via filter (simulating wp-config.php constant) - add_filter('wu_stripe_application_fee_percentage', function() { - return 2.5; - }); - - wu_save_setting('stripe_test_access_token', 'sk_test_oauth_token_123'); - wu_save_setting('stripe_test_account_id', 'acct_123'); - wu_save_setting('stripe_test_publishable_key', 'pk_test_oauth_123'); - wu_save_setting('stripe_sandbox_mode', 1); - - $gateway = new Stripe_Gateway(); - $gateway->init(); - - $this->assertTrue($gateway->is_using_oauth()); - - // Use reflection to check protected property - $reflection = new \ReflectionClass($gateway); - $property = $reflection->getProperty('application_fee_percentage'); - $property->setAccessible(true); - - $this->assertEquals(2.5, $property->getValue($gateway)); - } - - /** - * Test application fee not loaded for direct mode. - */ - public function test_application_fee_not_loaded_for_direct() { - wu_save_setting('stripe_test_pk_key', 'pk_test_direct_123'); - wu_save_setting('stripe_test_sk_key', 'sk_test_direct_123'); - wu_save_setting('stripe_application_fee_percentage', 2.5); - wu_save_setting('stripe_sandbox_mode', 1); - - $gateway = new Stripe_Gateway(); - $gateway->init(); - - $this->assertFalse($gateway->is_using_oauth()); - - // Use reflection to check protected property - $reflection = new \ReflectionClass($gateway); - $property = $reflection->getProperty('application_fee_percentage'); - $property->setAccessible(true); - - $this->assertEquals(0, $property->getValue($gateway)); - } - /** * Test OAuth authorization URL generation via proxy. */ From c431f91448d0be0c7c908443109298db2809fff6 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 15 Dec 2025 12:00:31 -0700 Subject: [PATCH 3/5] Remove platform_client_id - now handled by proxy server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove platform_client_id property from Base_Stripe_Gateway - Remove platform_client_id filter usage from OAuth E2E test - Update test step numbering for clarity - All 15 OAuth tests passing (44 assertions) Platform credentials are kept secure on the proxy server and never exposed in the distributed plugin code. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- inc/gateways/class-base-stripe-gateway.php | 14 +++----------- tests/WP_Ultimo/Gateways/Stripe_OAuth_E2E_Test.php | 11 +++-------- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/inc/gateways/class-base-stripe-gateway.php b/inc/gateways/class-base-stripe-gateway.php index cc64b3b0..68d8f23d 100644 --- a/inc/gateways/class-base-stripe-gateway.php +++ b/inc/gateways/class-base-stripe-gateway.php @@ -105,14 +105,6 @@ class Base_Stripe_Gateway extends Base_Gateway { */ protected $oauth_account_id = ''; - /** - * Platform client ID for OAuth. - * - * @since 2.x.x - * @var string - */ - protected $platform_client_id = ''; - /** * Authentication mode: 'direct' or 'oauth'. * @@ -372,10 +364,10 @@ public function get_connect_authorization_url(string $state = ''): string { protected function get_oauth_init_url(): string { return add_query_arg( [ - 'page' => 'wu-settings', - 'tab' => 'payment-gateways', + 'page' => 'wu-settings', + 'tab' => 'payment-gateways', 'stripe_oauth_init' => '1', - '_wpnonce' => wp_create_nonce('stripe_oauth_init'), + '_wpnonce' => wp_create_nonce('stripe_oauth_init'), ], admin_url('admin.php') ); diff --git a/tests/WP_Ultimo/Gateways/Stripe_OAuth_E2E_Test.php b/tests/WP_Ultimo/Gateways/Stripe_OAuth_E2E_Test.php index 90245034..c219a1b6 100644 --- a/tests/WP_Ultimo/Gateways/Stripe_OAuth_E2E_Test.php +++ b/tests/WP_Ultimo/Gateways/Stripe_OAuth_E2E_Test.php @@ -86,21 +86,16 @@ public function test_complete_oauth_flow_simulation() { // Step 1: Start with no configuration $this->clear_all_stripe_settings(); - // Step 2: Platform credentials configured via filter (simulating wp-config.php constants) - add_filter('wu_stripe_platform_client_id', function() { - return 'ca_platform_test_123'; - }); - wu_save_setting('stripe_sandbox_mode', 1); - // Step 3: User clicks "Connect with Stripe" and OAuth completes + // Step 2: User clicks "Connect with Stripe" and OAuth completes // (Simulating what happens after successful OAuth callback) wu_save_setting('stripe_test_access_token', 'sk_test_connected_abc'); wu_save_setting('stripe_test_account_id', 'acct_connected_xyz'); wu_save_setting('stripe_test_publishable_key', 'pk_test_connected_abc'); wu_save_setting('stripe_test_refresh_token', 'rt_test_refresh_abc'); - // Step 4: Gateway initializes and detects OAuth + // Step 3: Gateway initializes and detects OAuth $gateway = new Stripe_Gateway(); $gateway->init(); @@ -114,7 +109,7 @@ public function test_complete_oauth_flow_simulation() { $account_property->setAccessible(true); $this->assertEquals('acct_connected_xyz', $account_property->getValue($gateway)); - // Step 5: Verify direct keys would still work if OAuth disconnected + // Step 4: Verify direct keys would still work if OAuth disconnected wu_save_setting('stripe_test_access_token', ''); // Clear OAuth wu_save_setting('stripe_test_pk_key', 'pk_test_direct_fallback'); wu_save_setting('stripe_test_sk_key', 'sk_test_direct_fallback'); From 44f23c3bf89903f176519821dfe2db1c56418fdf Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 30 Dec 2025 19:20:41 -0700 Subject: [PATCH 4/5] fix stripe --- assets/js/gateways/stripe.js | 738 ++++++++------------ inc/admin-pages/class-wizard-admin-page.php | 11 +- inc/checkout/class-checkout-pages.php | 110 +++ inc/gateways/class-base-stripe-gateway.php | 141 ++++ inc/gateways/class-stripe-gateway.php | 13 +- inc/managers/class-gateway-manager.php | 205 ++++++ 6 files changed, 775 insertions(+), 443 deletions(-) diff --git a/assets/js/gateways/stripe.js b/assets/js/gateways/stripe.js index fbf74969..79a36229 100644 --- a/assets/js/gateways/stripe.js +++ b/assets/js/gateways/stripe.js @@ -1,455 +1,331 @@ +/** + * Stripe Gateway - Payment Element Only. + * + * Uses Stripe Payment Element with deferred intent mode for immediate rendering + * without requiring a client_secret upfront. + */ /* eslint-disable */ /* global wu_stripe, Stripe */ let _stripe; -let stripeElement; -let card; let paymentElement; let elements; -let usePaymentElement = false; - -const stripeElements = function(publicKey) { - - _stripe = Stripe(publicKey); - - // Payment Element will be initialized dynamically after preflight response - // We'll detect which element to use based on what's available in the DOM - - wp.hooks.addFilter('wu_before_form_submitted', 'nextpress/wp-ultimo', function(promises, checkout, gateway) { - - const cardEl = document.getElementById('card-element'); - const paymentEl = document.getElementById('payment-element'); - - if (gateway === 'stripe' && checkout.order.totals.total > 0) { - - // If using Card Element (legacy/fallback) - if (cardEl && cardEl.offsetParent && !usePaymentElement) { - - promises.push(new Promise( async (resolve, reject) => { - - try { - - const paymentMethod = await _stripe.createPaymentMethod({type: 'card', card}); - - if (paymentMethod.error) { - - reject(paymentMethod.error); - - } // end if; - - } catch(err) { - - } // end try; - - resolve(); - - })); - - } // end if; - - } // end if; - - return promises; - - }); - - wp.hooks.addAction('wu_on_form_success', 'nextpress/wp-ultimo', function(checkout, results) { - - if (checkout.gateway === 'stripe' && (checkout.order.totals.total > 0 || checkout.order.totals.recurring.total > 0)) { - - // Check if we received a client_secret (required for Payment Element) - if (results.gateway.data.stripe_client_secret) { - - const paymentEl = document.getElementById('payment-element'); - const cardEl = document.getElementById('card-element'); - - // Initialize Payment Element with client_secret (preferred method) - if (paymentEl) { - - usePaymentElement = true; - - // Create elements instance with client_secret for dynamic mode - elements = _stripe.elements({ - clientSecret: results.gateway.data.stripe_client_secret, - appearance: { - theme: 'stripe', - }, - }); - - // Create and mount Payment Element - paymentElement = elements.create('payment'); - paymentElement.mount('#payment-element'); - - // Apply custom styles - wu_stripe_update_payment_element_styles(paymentElement, '#field-payment_template'); - - checkout.set_prevent_submission(false); - - // Handle payment with Payment Element - handlePaymentElement(checkout, results, elements); - - } else if (cardEl && card) { - - // Fallback to Card Element (legacy) - usePaymentElement = false; - checkout.set_prevent_submission(false); - handlePayment(checkout, results, card); - - } // end if; - - } else { - - // No client_secret, use legacy Card Element flow - checkout.set_prevent_submission(false); - - if (card) { - handlePayment(checkout, results, card); - } - - } // end if; - - } // end if; - - }); - - wp.hooks.addAction('wu_on_form_updated', 'nextpress/wp-ultimo', function(form) { - - if (form.gateway === 'stripe') { - - const cardEl = document.getElementById('card-element'); - const paymentEl = document.getElementById('payment-element'); - - // Only mount Card Element if Payment Element is not available - if (cardEl && !paymentEl && !usePaymentElement) { - - try { - - // Initialize Card Element for legacy support - if (!card) { - const cardElements = _stripe.elements(); - card = cardElements.create('card', { - hidePostalCode: true, - }); - - // Element focus ring - card.on('focus', function() { - const el = document.getElementById('card-element'); - el.classList.add('focused'); - }); - - card.on('blur', function() { - const el = document.getElementById('card-element'); - el.classList.remove('focused'); - }); - } - - card.mount('#card-element'); - - wu_stripe_update_styles(card, '#field-payment_template'); - - /* - * Prevents the form from submitting while Stripe is - * creating a payment source. - */ - form.set_prevent_submission(form.order && form.order.should_collect_payment && form.payment_method === 'add-new'); - - } catch (error) { - - // Silence - - } // end try; - - } else { - - // Payment Element will be initialized in wu_on_form_success - // Set prevent submission for Payment Element - if (paymentEl) { - form.set_prevent_submission(form.order && form.order.should_collect_payment && form.payment_method === 'add-new'); - } else { - form.set_prevent_submission(false); - } - - } // end if; - - } else { - - form.set_prevent_submission(false); - - try { - - if (card) { - card.unmount('#card-element'); - } - - } catch (error) { - - // Silence is golden - - } // end try; - - } // end if; - - }); - -}; - -wp.hooks.addFilter('wu_before_form_init', 'nextpress/wp-ultimo', function(data) { - - data.add_new_card = wu_stripe.add_new_card; - - data.payment_method = wu_stripe.payment_method; - - return data; - -}); - -wp.hooks.addAction('wu_checkout_loaded', 'nextpress/wp-ultimo', function() { - - stripeElement = stripeElements(wu_stripe.pk_key); - -}); +let currentElementsMode = null; +let currentElementsAmount = null; /** - * Copy styles from an existing element to the Stripe Card Element. + * Initialize Stripe and set up Payment Element. * - * @param {Object} cardElement Stripe card element. - * @param {string} selector Selector to copy styles from. - * - * @since 3.3 + * @param {string} publicKey Stripe publishable key. */ -function wu_stripe_update_styles(cardElement, selector) { - - if (undefined === typeof selector) { - - selector = '#field-payment_template'; - - } - - const inputField = document.querySelector(selector); - - if (null === inputField) { - - return; - - } - - if (document.getElementById('wu-stripe-styles')) { - - return; - - } - - const inputStyles = window.getComputedStyle(inputField); - - const styleTag = document.createElement('style'); - - styleTag.innerHTML = '.StripeElement {' + - 'background-color:' + inputStyles.getPropertyValue('background-color') + ';' + - 'border-top-color:' + inputStyles.getPropertyValue('border-top-color') + ';' + - 'border-right-color:' + inputStyles.getPropertyValue('border-right-color') + ';' + - 'border-bottom-color:' + inputStyles.getPropertyValue('border-bottom-color') + ';' + - 'border-left-color:' + inputStyles.getPropertyValue('border-left-color') + ';' + - 'border-top-width:' + inputStyles.getPropertyValue('border-top-width') + ';' + - 'border-right-width:' + inputStyles.getPropertyValue('border-right-width') + ';' + - 'border-bottom-width:' + inputStyles.getPropertyValue('border-bottom-width') + ';' + - 'border-left-width:' + inputStyles.getPropertyValue('border-left-width') + ';' + - 'border-top-style:' + inputStyles.getPropertyValue('border-top-style') + ';' + - 'border-right-style:' + inputStyles.getPropertyValue('border-right-style') + ';' + - 'border-bottom-style:' + inputStyles.getPropertyValue('border-bottom-style') + ';' + - 'border-left-style:' + inputStyles.getPropertyValue('border-left-style') + ';' + - 'border-top-left-radius:' + inputStyles.getPropertyValue('border-top-left-radius') + ';' + - 'border-top-right-radius:' + inputStyles.getPropertyValue('border-top-right-radius') + ';' + - 'border-bottom-left-radius:' + inputStyles.getPropertyValue('border-bottom-left-radius') + ';' + - 'border-bottom-right-radius:' + inputStyles.getPropertyValue('border-bottom-right-radius') + ';' + - 'padding-top:' + inputStyles.getPropertyValue('padding-top') + ';' + - 'padding-right:' + inputStyles.getPropertyValue('padding-right') + ';' + - 'padding-bottom:' + inputStyles.getPropertyValue('padding-bottom') + ';' + - 'padding-left:' + inputStyles.getPropertyValue('padding-left') + ';' + - 'line-height:' + inputStyles.getPropertyValue('height') + ';' + - 'height:' + inputStyles.getPropertyValue('height') + ';' + - `display: flex; - flex-direction: column; - justify-content: center;` + - '}'; - - styleTag.id = 'wu-stripe-styles'; - - document.body.appendChild(styleTag); - - cardElement.update({ - style: { - base: { - color: inputStyles.getPropertyValue('color'), - fontFamily: inputStyles.getPropertyValue('font-family'), - fontSize: inputStyles.getPropertyValue('font-size'), - fontWeight: inputStyles.getPropertyValue('font-weight'), - fontSmoothing: inputStyles.getPropertyValue('-webkit-font-smoothing'), - }, - }, - }); - -} +const stripeElements = function (publicKey) { + + _stripe = Stripe(publicKey); + + /** + * Filter to validate payment before form submission. + */ + wp.hooks.addFilter( + 'wu_before_form_submitted', + 'nextpress/wp-ultimo', + function (promises, checkout, gateway) { + + if (gateway === 'stripe' && checkout.order.totals.total > 0) { + + const paymentEl = document.getElementById('payment-element'); + + if (paymentEl && elements) { + promises.push( + new Promise(async (resolve, reject) => { + try { + // Validate the Payment Element before submission + const { error } = await elements.submit(); + + if (error) { + reject(error); + } else { + resolve(); + } + } catch (err) { + reject(err); + } + }) + ); + } + } + + return promises; + } + ); + + /** + * Handle successful form submission - confirm payment with client_secret. + */ + wp.hooks.addAction( + 'wu_on_form_success', + 'nextpress/wp-ultimo', + function (checkout, results) { + + if (checkout.gateway !== 'stripe') { + return; + } + + if (checkout.order.totals.total <= 0 && checkout.order.totals.recurring.total <= 0) { + return; + } + + // Check if we received a client_secret from the server + if (!results.gateway.data.stripe_client_secret) { + checkout.set_prevent_submission(false); + return; + } + + const clientSecret = results.gateway.data.stripe_client_secret; + const intentType = results.gateway.data.stripe_intent_type; + + checkout.set_prevent_submission(false); + + // Determine the confirmation method based on intent type + const confirmMethod = intentType === 'payment_intent' + ? 'confirmPayment' + : 'confirmSetup'; + + const confirmParams = { + elements: elements, + confirmParams: { + return_url: window.location.href, + payment_method_data: { + billing_details: { + name: results.customer.display_name, + email: results.customer.user_email, + address: { + country: results.customer.billing_address_data.billing_country, + postal_code: results.customer.billing_address_data.billing_zip_code, + }, + }, + }, + }, + redirect: 'if_required', + }; + + // Add clientSecret for confirmation + confirmParams.clientSecret = clientSecret; + + _stripe[confirmMethod](confirmParams).then(function (result) { + + if (result.error) { + wu_checkout_form.unblock(); + wu_checkout_form.errors.push(result.error); + } else { + // Payment succeeded - resubmit form to complete checkout + wu_checkout_form.resubmit(); + } + + }); + } + ); + + /** + * Initialize Payment Element on form update. + */ + wp.hooks.addAction('wu_on_form_updated', 'nextpress/wp-ultimo', function (form) { + + if (form.gateway !== 'stripe') { + form.set_prevent_submission(false); + + // Destroy elements if switching away from Stripe + if (paymentElement) { + try { + paymentElement.unmount(); + } catch (error) { + // Silence + } + paymentElement = null; + elements = null; + currentElementsMode = null; + currentElementsAmount = null; + } + + return; + } + + const paymentEl = document.getElementById('payment-element'); + + if (!paymentEl) { + form.set_prevent_submission(false); + return; + } + + // Determine the correct mode based on order total + // Use 'payment' mode when there's an immediate charge, 'setup' for trials/$0 + const orderTotal = form.order ? form.order.totals.total : 0; + const hasImmediateCharge = orderTotal > 0; + const requiredMode = hasImmediateCharge ? 'payment' : 'setup'; + + // Convert amount to cents for Stripe (integer) + const amountInCents = hasImmediateCharge ? Math.round(orderTotal * 100) : null; + + // Check if we need to reinitialize (mode or amount changed) + const needsReinit = !elements || + !paymentElement || + currentElementsMode !== requiredMode || + (hasImmediateCharge && currentElementsAmount !== amountInCents); + + if (!needsReinit) { + // Already initialized with correct mode, just update prevent submission state + form.set_prevent_submission( + form.order && + form.order.should_collect_payment && + form.payment_method === 'add-new' + ); + return; + } + + // Cleanup existing elements if reinitializing + if (paymentElement) { + try { + paymentElement.unmount(); + } catch (error) { + // Silence + } + paymentElement = null; + elements = null; + } + + try { + // Build elements options based on mode + const elementsOptions = { + currency: wu_stripe.currency || 'usd', + appearance: { + theme: 'stripe', + }, + }; + + if (hasImmediateCharge) { + // Payment mode - for immediate charges + elementsOptions.mode = 'payment'; + elementsOptions.amount = amountInCents; + // Match server-side PaymentIntent setup_future_usage for saving cards + elementsOptions.setupFutureUsage = 'off_session'; + } else { + // Setup mode - for trials or $0 orders + elementsOptions.mode = 'setup'; + } + + elements = _stripe.elements(elementsOptions); + + // Store current mode and amount for comparison + currentElementsMode = requiredMode; + currentElementsAmount = amountInCents; + + // Create and mount Payment Element + paymentElement = elements.create('payment', { + layout: 'tabs', + }); + + paymentElement.mount('#payment-element'); + + // Apply custom styles to match the checkout form + wu_stripe_update_payment_element_styles('#field-payment_template'); + + // Handle Payment Element errors + paymentElement.on('change', function (event) { + const errorEl = document.getElementById('payment-errors'); + + if (errorEl) { + if (event.error) { + errorEl.textContent = event.error.message; + errorEl.classList.add('wu-text-red-600', 'wu-text-sm', 'wu-mt-2'); + } else { + errorEl.textContent = ''; + } + } + }); + + // Set prevent submission until payment element is ready + form.set_prevent_submission( + form.order && + form.order.should_collect_payment && + form.payment_method === 'add-new' + ); + + } catch (error) { + // Log error but don't break the form + console.error('Stripe Payment Element initialization error:', error); + form.set_prevent_submission(false); + } + }); +}; /** - * Update styles for Payment Element. - * - * @param {Object} paymentElement Stripe payment element. - * @param {string} selector Selector to copy styles from. - * - * @since 2.x.x + * Initialize form data before checkout loads. */ -function wu_stripe_update_payment_element_styles(paymentElement, selector) { - - if (undefined === typeof selector) { - selector = '#field-payment_template'; - } - - const inputField = document.querySelector(selector); - - if (null === inputField) { - return; - } - - const inputStyles = window.getComputedStyle(inputField); - - // Payment Element uses appearance API instead of style object - paymentElement.update({ - appearance: { - theme: 'stripe', - variables: { - colorPrimary: inputStyles.getPropertyValue('border-color'), - colorBackground: inputStyles.getPropertyValue('background-color'), - colorText: inputStyles.getPropertyValue('color'), - fontFamily: inputStyles.getPropertyValue('font-family'), - fontSize: inputStyles.getPropertyValue('font-size'), - borderRadius: inputStyles.getPropertyValue('border-radius'), - }, - }, - }); - -} - -function wu_stripe_handle_intent(handler, client_secret, args) { - - const _handle_error = function (e) { - - wu_checkout_form.unblock(); - - if (e.error) { - - wu_checkout_form.errors.push(e.error); - - } // end if; - - } // end _handle_error; - - try { - - _stripe[handler](client_secret, args).then(function(results) { - - if (results.error) { - - _handle_error(results); - - return; - - } // end if; +wp.hooks.addFilter('wu_before_form_init', 'nextpress/wp-ultimo', function (data) { - wu_checkout_form.resubmit(); + data.add_new_card = wu_stripe.add_new_card; + data.payment_method = wu_stripe.payment_method; - }, _handle_error); - - } catch(e) {} // end if; - -} // end if; + return data; +}); /** - * Handle payment with Payment Element (modern method). - * - * @param {Object} form Checkout form object. - * @param {Object} response Server response with client_secret. - * @param {Object} elements Stripe Elements instance. - * - * @since 2.x.x + * Initialize Stripe when checkout loads. */ -function handlePaymentElement(form, response, elements) { - - const clientSecret = response.gateway.data.stripe_client_secret; - - if (!clientSecret) { - return; - } - - _stripe.confirmPayment({ - elements: elements, - confirmParams: { - return_url: window.location.href, - payment_method_data: { - billing_details: { - name: response.customer.display_name, - email: response.customer.user_email, - address: { - country: response.customer.billing_address_data.billing_country, - postal_code: response.customer.billing_address_data.billing_zip_code, - }, - }, - }, - }, - redirect: 'if_required', // Stay on page if redirect not needed - }).then(function(result) { - - if (result.error) { +wp.hooks.addAction('wu_checkout_loaded', 'nextpress/wp-ultimo', function () { - wu_checkout_form.unblock(); - wu_checkout_form.errors.push(result.error); + stripeElements(wu_stripe.pk_key); - } else { - - // Payment succeeded - resubmit form to complete checkout - wu_checkout_form.resubmit(); - - } - - }); - -} +}); /** - * After registration has been processed, handle card payments (legacy Card Element). + * Update styles for Payment Element to match the checkout form. * - * @param form - * @param response - * @param card + * @param {string} selector Selector to copy styles from. */ -function handlePayment(form, response, card) { - - // Trigger error if we don't have a client secret. - if (! response.gateway.data.stripe_client_secret) { - - return; - - } // end if; - - const handler = 'payment_intent' === response.gateway.data.stripe_intent_type ? 'confirmCardPayment' : 'confirmCardSetup'; - - const args = { - payment_method: form.payment_method !== 'add-new' ? form.payment_method : { - card, - billing_details: { - name: response.customer.display_name, - email: response.customer.user_email, - address: { - country: response.customer.billing_address_data.billing_country, - postal_code: response.customer.billing_address_data.billing_zip_code, - }, - }, - }, - }; - - /** - * Handle payment intent / setup intent. - */ - wu_stripe_handle_intent( - handler, response.gateway.data.stripe_client_secret, args - ); - +function wu_stripe_update_payment_element_styles(selector) { + + if ('undefined' === typeof selector) { + selector = '#field-payment_template'; + } + + const inputField = document.querySelector(selector); + + if (null === inputField) { + return; + } + + const inputStyles = window.getComputedStyle(inputField); + + // Add custom CSS for Payment Element container + if (!document.getElementById('wu-stripe-payment-element-styles')) { + const styleTag = document.createElement('style'); + styleTag.id = 'wu-stripe-payment-element-styles'; + styleTag.innerHTML = ` + #payment-element { + background-color: ${inputStyles.getPropertyValue('background-color')}; + border-radius: ${inputStyles.getPropertyValue('border-radius')}; + padding: ${inputStyles.getPropertyValue('padding')}; + } + `; + document.body.appendChild(styleTag); + } + + // Update elements appearance if possible + if (elements) { + try { + elements.update({ + appearance: { + theme: 'stripe', + variables: { + colorPrimary: inputStyles.getPropertyValue('border-color') || '#0570de', + colorBackground: inputStyles.getPropertyValue('background-color') || '#ffffff', + colorText: inputStyles.getPropertyValue('color') || '#30313d', + fontFamily: inputStyles.getPropertyValue('font-family') || 'system-ui, sans-serif', + borderRadius: inputStyles.getPropertyValue('border-radius') || '4px', + }, + }, + }); + } catch (error) { + // Appearance update not supported, that's fine + } + } } diff --git a/inc/admin-pages/class-wizard-admin-page.php b/inc/admin-pages/class-wizard-admin-page.php index 34228733..81e91f05 100644 --- a/inc/admin-pages/class-wizard-admin-page.php +++ b/inc/admin-pages/class-wizard-admin-page.php @@ -205,13 +205,22 @@ public function output() { 'labels' => $this->get_labels(), 'sections' => $this->get_sections(), 'current_section' => $this->get_current_section(), - 'classes' => 'wu-w-full wu-mx-auto sm:wu-w-11/12 xl:wu-w-8/12 wu-mt-8 sm:wu-max-w-screen-lg', + 'classes' => $this->get_classes(), 'clickable_navigation' => $this->clickable_navigation, 'form_id' => $this->form_id, ] ); } + /** + * Return the classes used in the main wrapper. + * + * @return string + */ + protected function get_classes() { + return 'wu-w-full wu-mx-auto sm:wu-w-11/12 xl:wu-w-8/12 wu-mt-8 sm:wu-max-w-screen-lg'; + } + /** * Returns the first section of the signup process * diff --git a/inc/checkout/class-checkout-pages.php b/inc/checkout/class-checkout-pages.php index 3c902aa6..5bc767b7 100644 --- a/inc/checkout/class-checkout-pages.php +++ b/inc/checkout/class-checkout-pages.php @@ -35,6 +35,11 @@ public function init(): void { add_shortcode('wu_confirmation', [$this, 'render_confirmation_page']); + /* + * Enqueue payment status polling script on thank you page. + */ + add_action('wp_enqueue_scripts', [$this, 'maybe_enqueue_payment_status_poll']); + add_filter('lostpassword_redirect', [$this, 'filter_lost_password_redirect']); if (is_main_site()) { @@ -204,6 +209,8 @@ public function get_error_message($error_code, $username = '') { 'password_reset_mismatch' => __('Error: The passwords do not match.'), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain 'invalidkey' => __('Error: Your password reset link appears to be invalid. Please request a new link below.'), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain 'expiredkey' => __('Error: Your password reset link has expired. Please request a new link below.'), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain + 'invalid_key' => __('Error: Your password reset link appears to be invalid. Please request a new link below.'), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain + 'expired_key' => __('Error: Your password reset link has expired. Please request a new link below.'), // phpcs:ignore WordPress.WP.I18n.MissingArgDomain ]; /** @@ -665,4 +672,107 @@ public function render_confirmation_page($atts, $content = null) { // phpcs:igno ] ); } + + /** + * Maybe enqueue payment status polling script on thank you page. + * + * This script polls the server to check if a pending payment has been completed, + * providing a fallback mechanism when webhooks are delayed or not working. + * + * @since 2.x.x + * @return void + */ + public function maybe_enqueue_payment_status_poll(): void { + + // Only on thank you page (payment hash and status=done in URL) + $payment_hash = wu_request('payment'); + $status = wu_request('status'); + + if (empty($payment_hash) || 'done' !== $status || 'none' === $payment_hash) { + return; + } + + $payment = wu_get_payment_by_hash($payment_hash); + + if (! $payment) { + return; + } + + // Only poll for pending Stripe payments + $gateway_id = $payment->get_gateway(); + + if (empty($gateway_id)) { + $membership = $payment->get_membership(); + $gateway_id = $membership ? $membership->get_gateway() : ''; + } + + // Only poll for Stripe payments that are still pending + $is_stripe_payment = in_array($gateway_id, ['stripe', 'stripe-checkout'], true); + $is_pending = $payment->get_status() === \WP_Ultimo\Database\Payments\Payment_Status::PENDING; + + if (! $is_stripe_payment) { + return; + } + + wp_register_script( + 'wu-payment-status-poll', + wu_get_asset('payment-status-poll.js', 'js'), + ['jquery'], + wu_get_version(), + true + ); + + wp_localize_script( + 'wu-payment-status-poll', + 'wu_payment_poll', + [ + 'payment_hash' => $payment_hash, + 'ajax_url' => admin_url('admin-ajax.php'), + 'poll_interval' => 3000, // 3 seconds + 'max_attempts' => 20, // 60 seconds total + 'should_poll' => $is_pending, + 'status_selector' => '.wu-payment-status', + 'success_redirect' => '', + 'messages' => [ + 'completed' => __('Payment confirmed! Refreshing page...', 'ultimate-multisite'), + 'pending' => __('Verifying your payment with Stripe...', 'ultimate-multisite'), + 'timeout' => __('Payment verification is taking longer than expected. Your payment may still be processing. Please refresh the page or contact support if you believe payment was made.', 'ultimate-multisite'), + 'error' => __('Error checking payment status. Retrying...', 'ultimate-multisite'), + 'checking' => __('Checking payment status...', 'ultimate-multisite'), + ], + ] + ); + + wp_enqueue_script('wu-payment-status-poll'); + + // Add inline CSS for the status messages + wp_add_inline_style( + 'wu-checkout', + ' + .wu-payment-status { + padding: 12px 16px; + border-radius: 6px; + margin-bottom: 16px; + font-weight: 500; + } + .wu-payment-status-pending, + .wu-payment-status-checking { + background-color: #fef3cd; + color: #856404; + border: 1px solid #ffc107; + } + .wu-payment-status-completed { + background-color: #d4edda; + color: #155724; + border: 1px solid #28a745; + } + .wu-payment-status-timeout, + .wu-payment-status-error { + background-color: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; + } + ' + ); + } } diff --git a/inc/gateways/class-base-stripe-gateway.php b/inc/gateways/class-base-stripe-gateway.php index 68d8f23d..72a4229c 100644 --- a/inc/gateways/class-base-stripe-gateway.php +++ b/inc/gateways/class-base-stripe-gateway.php @@ -2892,6 +2892,7 @@ public function register_scripts(): void { 'request_billing_address' => $this->request_billing_address, 'add_new_card' => empty($saved_cards), 'payment_method' => empty($saved_cards) ? 'add-new' : current(array_keys($saved_cards)), + 'currency' => strtolower((string) wu_get_setting('currency_symbol', 'USD')), ] ); @@ -3360,4 +3361,144 @@ public function get_customer_url_on_gateway($gateway_customer_id): string { return sprintf('https://dashboard.stripe.com%s/customers/%s', $route, $gateway_customer_id); } + + /** + * Verify and complete a pending payment by checking Stripe directly. + * + * This is a fallback mechanism when webhooks are not working correctly. + * It checks the payment intent status on Stripe and completes the payment locally if successful. + * + * @since 2.x.x + * + * @param int $payment_id The local payment ID to verify. + * @return array{success: bool, message: string, status?: string} + */ + public function verify_and_complete_payment(int $payment_id): array { + + $payment = wu_get_payment($payment_id); + + if (! $payment) { + return [ + 'success' => false, + 'message' => __('Payment not found.', 'ultimate-multisite'), + ]; + } + + // Already completed - nothing to do + if ($payment->get_status() === Payment_Status::COMPLETED) { + return [ + 'success' => true, + 'message' => __('Payment already completed.', 'ultimate-multisite'), + 'status' => 'completed', + ]; + } + + // Only process pending payments + if ($payment->get_status() !== Payment_Status::PENDING) { + return [ + 'success' => false, + 'message' => __('Payment is not in pending status.', 'ultimate-multisite'), + 'status' => $payment->get_status(), + ]; + } + + // Get the payment intent ID from payment meta + $payment_intent_id = $payment->get_meta('stripe_payment_intent_id'); + + if (empty($payment_intent_id)) { + return [ + 'success' => false, + 'message' => __('No Stripe payment intent found for this payment.', 'ultimate-multisite'), + ]; + } + + try { + $this->setup_api_keys(); + + // Determine intent type and retrieve it + if (str_starts_with((string) $payment_intent_id, 'seti_')) { + $intent = $this->get_stripe_client()->setupIntents->retrieve($payment_intent_id); + $is_setup_intent = true; + $is_succeeded = 'succeeded' === $intent->status; + } else { + $intent = $this->get_stripe_client()->paymentIntents->retrieve($payment_intent_id); + $is_setup_intent = false; + $is_succeeded = 'succeeded' === $intent->status; + } + + if (! $is_succeeded) { + return [ + 'success' => false, + 'message' => sprintf( + // translators: %s is the intent status from Stripe. + __('Payment intent status is: %s', 'ultimate-multisite'), + $intent->status + ), + 'status' => 'pending', + ]; + } + + // Payment succeeded on Stripe - complete it locally + $gateway_payment_id = $is_setup_intent + ? $intent->id + : ($intent->latest_charge ?? $intent->id); + + $payment->set_status(Payment_Status::COMPLETED); + $payment->set_gateway($this->get_id()); + $payment->set_gateway_payment_id($gateway_payment_id); + $payment->save(); + + // Trigger payment processed + $membership = $payment->get_membership(); + + if ($membership) { + $this->trigger_payment_processed($payment, $membership); + } + + wu_log_add('stripe', sprintf('Payment %d completed via fallback verification (intent: %s)', $payment_id, $payment_intent_id)); + + return [ + 'success' => true, + 'message' => __('Payment verified and completed successfully.', 'ultimate-multisite'), + 'status' => 'completed', + ]; + } catch (\Throwable $e) { + wu_log_add('stripe', sprintf('Payment verification failed for payment %d: %s', $payment_id, $e->getMessage()), LogLevel::ERROR); + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } + } + + /** + * Schedule a fallback payment verification job. + * + * This schedules an Action Scheduler job to verify the payment status + * if webhooks don't complete the payment in time. + * + * @since 2.x.x + * + * @param int $payment_id The payment ID to verify. + * @param int $delay_seconds How many seconds to wait before checking (default: 30). + * @return int|false The scheduled action ID or false on failure. + */ + public function schedule_payment_verification(int $payment_id, int $delay_seconds = 30) { + + $hook = 'wu_verify_stripe_payment'; + $args = [ + 'payment_id' => $payment_id, + 'gateway_id' => $this->get_id(), + ]; + + // Check if already scheduled + if (wu_next_scheduled_action($hook, $args)) { + return false; + } + + $timestamp = time() + $delay_seconds; + + return wu_schedule_single_action($timestamp, $hook, $args, 'wu-stripe-verification'); + } } diff --git a/inc/gateways/class-stripe-gateway.php b/inc/gateways/class-stripe-gateway.php index b2a5183e..8beea90c 100644 --- a/inc/gateways/class-stripe-gateway.php +++ b/inc/gateways/class-stripe-gateway.php @@ -742,22 +742,13 @@ public function fields(): string {
- +
- -
- -
- -
- -
- - +
diff --git a/inc/managers/class-gateway-manager.php b/inc/managers/class-gateway-manager.php index ab199a5d..61244408 100644 --- a/inc/managers/class-gateway-manager.php +++ b/inc/managers/class-gateway-manager.php @@ -112,6 +112,22 @@ function () { * Waits for webhook signals and deal with them. */ add_action('admin_init', [$this, 'maybe_process_v1_webhooks'], 21); + + /* + * AJAX endpoint for payment status polling (fallback for webhooks). + */ + add_action('wp_ajax_wu_check_payment_status', [$this, 'ajax_check_payment_status']); + add_action('wp_ajax_nopriv_wu_check_payment_status', [$this, 'ajax_check_payment_status']); + + /* + * Action Scheduler handler for payment verification fallback. + */ + add_action('wu_verify_stripe_payment', [$this, 'handle_scheduled_payment_verification']); + + /* + * Schedule payment verification after checkout. + */ + add_action('wu_checkout_done', [$this, 'maybe_schedule_payment_verification'], 10, 5); } /** @@ -567,4 +583,193 @@ public function get_auto_renewable_gateways() { return (array) $this->auto_renewable_gateways; } + + /** + * AJAX handler for checking payment status. + * + * This is used by the thank you page to poll for payment completion + * when webhooks might be delayed or not working. + * + * @since 2.x.x + * @return void + */ + public function ajax_check_payment_status(): void { + + $payment_hash = wu_request('payment_hash'); + + if (empty($payment_hash)) { + wp_send_json_error(['message' => __('Payment hash is required.', 'ultimate-multisite')]); + } + + $payment = wu_get_payment_by_hash($payment_hash); + + if (! $payment) { + wp_send_json_error(['message' => __('Payment not found.', 'ultimate-multisite')]); + } + + // If already completed, return success + if ($payment->get_status() === \WP_Ultimo\Database\Payments\Payment_Status::COMPLETED) { + wp_send_json_success( + [ + 'status' => 'completed', + 'message' => __('Payment completed.', 'ultimate-multisite'), + ] + ); + } + + // Only try to verify Stripe payments + $gateway_id = $payment->get_gateway(); + + if (empty($gateway_id)) { + // Check membership gateway as fallback + $membership = $payment->get_membership(); + $gateway_id = $membership ? $membership->get_gateway() : ''; + } + + if (! in_array($gateway_id, ['stripe', 'stripe-checkout'], true)) { + wp_send_json_success( + [ + 'status' => $payment->get_status(), + 'message' => __('Non-Stripe payment, cannot verify.', 'ultimate-multisite'), + ] + ); + } + + // Get the gateway instance and verify + $gateway = $this->get_gateway($gateway_id); + + if (! $gateway || ! method_exists($gateway, 'verify_and_complete_payment')) { + wp_send_json_success( + [ + 'status' => $payment->get_status(), + 'message' => __('Gateway does not support verification.', 'ultimate-multisite'), + ] + ); + } + + $result = $gateway->verify_and_complete_payment($payment->get_id()); + + if ($result['success']) { + wp_send_json_success( + [ + 'status' => $result['status'] ?? 'completed', + 'message' => $result['message'], + ] + ); + } else { + wp_send_json_success( + [ + 'status' => $result['status'] ?? 'pending', + 'message' => $result['message'], + ] + ); + } + } + + /** + * Handle scheduled payment verification from Action Scheduler. + * + * @since 2.x.x + * + * @param int $payment_id The payment ID to verify. + * @param string $gateway_id The gateway ID. + * @return void + */ + public function handle_scheduled_payment_verification($payment_id, $gateway_id = ''): void { + + // Support both old (single arg) and new (array) formats + if (is_array($payment_id)) { + $gateway_id = $payment_id['gateway_id'] ?? ''; + $payment_id = $payment_id['payment_id'] ?? 0; + } + + if (empty($payment_id)) { + wu_log_add('stripe', 'Scheduled payment verification: No payment ID provided', LogLevel::WARNING); + return; + } + + $payment = wu_get_payment($payment_id); + + if (! $payment) { + wu_log_add('stripe', sprintf('Scheduled payment verification: Payment %d not found', $payment_id), LogLevel::WARNING); + return; + } + + // Already completed - nothing to do + if ($payment->get_status() === \WP_Ultimo\Database\Payments\Payment_Status::COMPLETED) { + wu_log_add('stripe', sprintf('Scheduled payment verification: Payment %d already completed', $payment_id)); + return; + } + + // Determine gateway if not provided + if (empty($gateway_id)) { + $gateway_id = $payment->get_gateway(); + + if (empty($gateway_id)) { + $membership = $payment->get_membership(); + $gateway_id = $membership ? $membership->get_gateway() : ''; + } + } + + if (! in_array($gateway_id, ['stripe', 'stripe-checkout'], true)) { + wu_log_add('stripe', sprintf('Scheduled payment verification: Payment %d is not a Stripe payment', $payment_id)); + return; + } + + $gateway = $this->get_gateway($gateway_id); + + if (! $gateway || ! method_exists($gateway, 'verify_and_complete_payment')) { + wu_log_add('stripe', sprintf('Scheduled payment verification: Gateway %s not found or does not support verification', $gateway_id), LogLevel::WARNING); + return; + } + + $result = $gateway->verify_and_complete_payment($payment_id); + + wu_log_add( + 'stripe', + sprintf( + 'Scheduled payment verification for payment %d: %s - %s', + $payment_id, + $result['success'] ? 'SUCCESS' : 'PENDING', + $result['message'] + ) + ); + } + + /** + * Schedule payment verification after checkout for Stripe payments. + * + * @since 2.x.x + * + * @param \WP_Ultimo\Models\Payment $payment The payment object. + * @param \WP_Ultimo\Models\Membership $membership The membership object. + * @param \WP_Ultimo\Models\Customer $customer The customer object. + * @param \WP_Ultimo\Checkout\Cart $cart The cart object. + * @param string $type The checkout type. + * @return void + */ + public function maybe_schedule_payment_verification($payment, $membership, $customer, $cart, $type): void { + + // Only schedule for pending payments with Stripe + if (! $payment || $payment->get_status() === \WP_Ultimo\Database\Payments\Payment_Status::COMPLETED) { + return; + } + + $gateway_id = $membership ? $membership->get_gateway() : ''; + + if (! in_array($gateway_id, ['stripe', 'stripe-checkout'], true)) { + return; + } + + $gateway = $this->get_gateway($gateway_id); + + if (! $gateway || ! method_exists($gateway, 'schedule_payment_verification')) { + return; + } + + // Schedule verification in 30 seconds + $gateway->schedule_payment_verification($payment->get_id(), 30); + + wu_log_add('stripe', sprintf('Scheduled payment verification for payment %d in 30 seconds', $payment->get_id())); + } } From f980d7c53fdf83d9b68e6d12705528f3c6735d09 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 1 Jan 2026 20:55:55 -0700 Subject: [PATCH 5/5] Hide redundant billing address fields when Stripe gateway is selected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the billing address field is configured to show only ZIP and Country (zip_and_country mode), these fields are now automatically hidden when any Stripe gateway is active. Both Stripe Payment Element and Stripe Checkout collect Country and ZIP in their own UI, making separate fields redundant. Changes: - Add :style binding to billing address field wrappers that hides them when gateway starts with 'stripe' - Use Vue :style binding instead of v-show for better compatibility with server-rendered in-DOM templates - Add sync_billing_address_to_stripe() method to pre-fill billing address in Stripe Checkout modal 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../class-signup-field-billing-address.php | 19 ++++++ .../class-stripe-checkout-gateway.php | 63 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/inc/checkout/signup-fields/class-signup-field-billing-address.php b/inc/checkout/signup-fields/class-signup-field-billing-address.php index f8c223cc..96f12135 100644 --- a/inc/checkout/signup-fields/class-signup-field-billing-address.php +++ b/inc/checkout/signup-fields/class-signup-field-billing-address.php @@ -268,6 +268,25 @@ public function to_fields_array($attributes) { foreach ($fields as &$field) { $field['wrapper_classes'] = trim(wu_get_isset($field, 'wrapper_classes', '') . ' ' . $attributes['element_classes']); + + /* + * When zip_and_country is enabled (showing only ZIP + country), + * hide the billing address fields when any Stripe gateway is selected. + * Both Stripe Payment Element and Stripe Checkout collect Country and ZIP, + * making these fields redundant. + * + * Using :style binding instead of v-show for better Vue compatibility + * with server-rendered in-DOM templates. + */ + if ($zip_only) { + // Ensure wrapper_html_attr array exists + if ( ! isset($field['wrapper_html_attr'])) { + $field['wrapper_html_attr'] = []; + } + + // Use :style binding to hide element when any Stripe gateway is selected + $field['wrapper_html_attr'][':style'] = "{ display: gateway && gateway.startsWith('stripe') ? 'none' : '' }"; + } } uasort($fields, 'wu_sort_by_order'); diff --git a/inc/gateways/class-stripe-checkout-gateway.php b/inc/gateways/class-stripe-checkout-gateway.php index 0d08bef9..ce857bf1 100644 --- a/inc/gateways/class-stripe-checkout-gateway.php +++ b/inc/gateways/class-stripe-checkout-gateway.php @@ -221,6 +221,12 @@ public function run_preflight() { */ $s_customer = $this->get_or_create_customer($this->customer->get_id()); + /* + * Update the Stripe customer with the current billing address. + * This ensures the address is pre-filled in Stripe Checkout. + */ + $this->sync_billing_address_to_stripe($s_customer->id); + /* * Stripe Checkout allows for tons of different payment methods. * These include: @@ -493,4 +499,61 @@ public function get_user_saved_payment_methods() { return []; } } + + /** + * Syncs the customer's billing address to the Stripe customer object. + * + * This ensures that Stripe Checkout has the latest billing address + * pre-filled when the checkout modal opens. + * + * @since 2.3.0 + * + * @param string $stripe_customer_id The Stripe customer ID. + * @return void + */ + protected function sync_billing_address_to_stripe(string $stripe_customer_id): void { + + $billing_address = $this->customer->get_billing_address(); + + /* + * Only update if we have billing address data. + */ + if (empty($billing_address->billing_country) && empty($billing_address->billing_zip_code)) { + return; + } + + try { + $stripe_address = $this->convert_to_stripe_address($billing_address); + + $update_data = [ + 'address' => $stripe_address, + ]; + + /* + * Also update name and email if available. + */ + if ($this->customer->get_display_name()) { + $update_data['name'] = $this->customer->get_display_name(); + } + + if ($this->customer->get_email_address()) { + $update_data['email'] = $this->customer->get_email_address(); + } + + $this->get_stripe_client()->customers->update($stripe_customer_id, $update_data); + } catch (\Throwable $exception) { + /* + * Log the error but don't fail the checkout. + * Stripe Checkout will still collect the address. + */ + wu_log_add( + 'stripe-checkout', + sprintf( + 'Failed to sync billing address to Stripe customer %s: %s', + $stripe_customer_id, + $exception->getMessage() + ) + ); + } + } }