diff --git a/assets/js/gateways/stripe.js b/assets/js/gateways/stripe.js index f9df3690..79a36229 100644 --- a/assets/js/gateways/stripe.js +++ b/assets/js/gateways/stripe.js @@ -1,290 +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; - -const stripeElements = function(publicKey) { - - _stripe = Stripe(publicKey); - - const elements = _stripe.elements(); - - card = elements.create('card', { - hidePostalCode: true, - }); - - wp.hooks.addFilter('wu_before_form_submitted', 'nextpress/wp-ultimo', function(promises, checkout, gateway) { - - const cardEl = document.getElementById('card-element'); - - if (gateway === 'stripe' && checkout.order.totals.total > 0 && cardEl && cardEl.offsetParent) { - - 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; - - 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); - - } // end if; - - }); - - wp.hooks.addAction('wu_on_form_updated', 'nextpress/wp-ultimo', function(form) { - - if (form.gateway === 'stripe') { - - try { - - card.mount('#card-element'); - - wu_stripe_update_styles(card, '#field-payment_template'); - - /* - * 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'); - - } catch (error) { - - // Silence - - } // end try; - - } else { - - form.set_prevent_submission(false); - - try { - - card.unmount('#card-element'); - - } catch (error) { - - // Silence is golden - - } // end try; - - } // end if; - - }); - - // 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'); - - }); +let paymentElement; +let elements; +let currentElementsMode = null; +let currentElementsAmount = null; +/** + * Initialize Stripe and set up Payment Element. + * + * @param {string} publicKey Stripe publishable key. + */ +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); + } + }); }; -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() { +/** + * Initialize form data before checkout loads. + */ +wp.hooks.addFilter('wu_before_form_init', 'nextpress/wp-ultimo', function (data) { - stripeElement = stripeElements(wu_stripe.pk_key); + data.add_new_card = wu_stripe.add_new_card; + data.payment_method = wu_stripe.payment_method; + return data; }); /** - * Copy styles from an existing element to the Stripe Card Element. - * - * @param {Object} cardElement Stripe card element. - * @param {string} selector Selector to copy styles from. - * - * @since 3.3 + * Initialize Stripe when checkout loads. */ -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; - - } +wp.hooks.addAction('wu_checkout_loaded', 'nextpress/wp-ultimo', function () { - const inputStyles = window.getComputedStyle(inputField); + stripeElements(wu_stripe.pk_key); - 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'), - }, - }, - }); - -} - -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; - - wu_checkout_form.resubmit(); - - }, _handle_error); - - } catch(e) {} // end if; - -} // end if; +}); /** - * After registration has been processed, handle card payments. + * 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 593291b6..72a4229c 100644 --- a/inc/gateways/class-base-stripe-gateway.php +++ b/inc/gateways/class-base-stripe-gateway.php @@ -81,6 +81,38 @@ 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 = ''; + + /** + * Authentication mode: 'direct' or 'oauth'. + * + * @since 2.x.x + * @var string + */ + protected $authentication_mode = 'direct'; + /** * Holds the Stripe client instance. * @@ -92,15 +124,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 +197,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 +208,41 @@ 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", ''); + } } if ($this->secret_key && Stripe\Stripe::getApiKey() !== $this->secret_key) { @@ -181,6 +252,313 @@ 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 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 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. + * + * @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 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'])); + $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. * @@ -2514,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')), ] ); @@ -2982,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 04218a3a..8beea90c 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,16 +742,13 @@ public function fields(): string {