diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 0d0f0bc22..70e7f0593 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -11,7 +11,7 @@ on:
jobs:
cypress:
runs-on: ubuntu-latest
- timeout-minutes: 30 # Increased timeout for setup wizard
+ timeout-minutes: 45 # Increased for setup wizard + Stripe test clock advancement
strategy:
matrix:
php: ["8.1", "8.2"] # Reduced PHP versions for faster CI
@@ -56,9 +56,22 @@ jobs:
- name: Install Composer dependencies
run: composer install
- - name: Set PHP version for wp-env
+ - name: Configure wp-env for CI
run: |
- echo "{\"config\": {\"phpVersion\": \"${{ matrix.php }}\"}}" > .wp-env.override.json
+ node -e "
+ const fs = require('fs');
+ const cfg = JSON.parse(fs.readFileSync('.wp-env.json', 'utf8'));
+ // Remove addons and mappings that don't exist in CI
+ for (const env of Object.values(cfg.env || {})) {
+ env.plugins = (env.plugins || []).filter(p => !p.includes('addons/'));
+ env.mappings = {};
+ // Remove SUNRISE config since sunrise.php mapping is gone
+ if (env.config) delete env.config.SUNRISE;
+ }
+ cfg.config = { ...(cfg.config || {}), phpVersion: '${{ matrix.php }}' };
+ fs.writeFileSync('.wp-env.json', JSON.stringify(cfg, null, 2));
+ "
+ cat .wp-env.json
- name: Start WordPress Test Environment
run: npm run env:start:test
@@ -167,6 +180,52 @@ jobs:
echo "✅ All checkout tests passed!"
+ - name: Run Stripe Tests (After Setup)
+ id: stripe-tests
+ env:
+ STRIPE_TEST_PK_KEY: ${{ secrets.STRIPE_TEST_PK_KEY }}
+ STRIPE_TEST_SK_KEY: ${{ secrets.STRIPE_TEST_SK_KEY }}
+ run: |
+ if [ -z "$STRIPE_TEST_SK_KEY" ]; then
+ echo "⏭️ Skipping Stripe tests: STRIPE_TEST_SK_KEY secret not configured"
+ exit 0
+ fi
+
+ set +e
+ echo "=== Starting Stripe Tests ==="
+
+ STRIPE_TESTS=(
+ "tests/e2e/cypress/integration/030-stripe-checkout-flow.spec.js"
+ "tests/e2e/cypress/integration/040-stripe-renewal-flow.spec.js"
+ )
+
+ TOTAL_FAILURES=0
+
+ for TEST_SPEC in "${STRIPE_TESTS[@]}"; do
+ echo "Running: $TEST_SPEC"
+
+ npx cypress run \
+ --config-file cypress.config.test.js \
+ --spec "$TEST_SPEC" \
+ --browser ${{ matrix.browser }}
+
+ CYPRESS_EXIT_CODE=$?
+
+ if [ $CYPRESS_EXIT_CODE -eq 0 ]; then
+ echo "✅ $TEST_SPEC passed"
+ else
+ echo "❌ $TEST_SPEC failed with exit code $CYPRESS_EXIT_CODE"
+ TOTAL_FAILURES=$((TOTAL_FAILURES + 1))
+ fi
+ done
+
+ if [ $TOTAL_FAILURES -gt 0 ]; then
+ echo "❌ $TOTAL_FAILURES Stripe test(s) failed"
+ exit 1
+ fi
+
+ echo "✅ All Stripe tests passed!"
+
- name: Fix permissions for Cypress output
if: always()
run: sudo chown -R $USER:$USER tests/e2e/cypress
diff --git a/.gitignore b/.gitignore
index 2135dca2c..72e804653 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,4 +18,7 @@ ultimate-multisite.zip
# Code coverage
coverage.xml
-coverage-html/
\ No newline at end of file
+coverage-html/
+
+# Local test scripts with secrets
+run-tests.sh
\ No newline at end of file
diff --git a/assets/js/gateways/stripe.js b/assets/js/gateways/stripe.js
index f9df36907..79a362292 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/cypress.config.js b/cypress.config.js
index 1eb3bc2f4..776d75d1f 100644
--- a/cypress.config.js
+++ b/cypress.config.js
@@ -9,6 +9,7 @@ module.exports = defineConfig({
screenshotsFolder: "tests/e2e/cypress/screenshots",
videosFolder: "tests/e2e/cypress/videos",
video: true,
+ chromeWebSecurity: false,
retries: {
runMode: 1,
openMode: 0,
@@ -21,7 +22,12 @@ module.exports = defineConfig({
requestTimeout: 30000,
responseTimeout: 30000,
pageLoadTimeout: 60000,
- setupNodeEvents(on, config) {},
+ setupNodeEvents(on, config) {
+ // Forward Stripe test keys from process.env to Cypress.env
+ config.env.STRIPE_TEST_PK_KEY = process.env.STRIPE_TEST_PK_KEY || "";
+ config.env.STRIPE_TEST_SK_KEY = process.env.STRIPE_TEST_SK_KEY || "";
+ return config;
+ },
},
env: {
MAILPIT_URL: "http://localhost:8025",
diff --git a/cypress.config.test.js b/cypress.config.test.js
index 7256bf3a8..f84fa9cc4 100644
--- a/cypress.config.test.js
+++ b/cypress.config.test.js
@@ -50,6 +50,10 @@ module.exports = defineConfig({
return launchOptions;
});
+ // Forward Stripe test keys from process.env to Cypress.env
+ config.env.STRIPE_TEST_PK_KEY = process.env.STRIPE_TEST_PK_KEY || '';
+ config.env.STRIPE_TEST_SK_KEY = process.env.STRIPE_TEST_SK_KEY || '';
+
return config;
}
}
diff --git a/inc/admin-pages/class-wizard-admin-page.php b/inc/admin-pages/class-wizard-admin-page.php
index 342287334..81e91f05b 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 b316e0ed7..851d133c5 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']);
$use_custom_login = wu_get_setting('enable_custom_login_page', false);
@@ -676,4 +681,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/checkout/signup-fields/class-signup-field-billing-address.php b/inc/checkout/signup-fields/class-signup-field-billing-address.php
index 8f2d9d9aa..853fe65b1 100644
--- a/inc/checkout/signup-fields/class-signup-field-billing-address.php
+++ b/inc/checkout/signup-fields/class-signup-field-billing-address.php
@@ -270,6 +270,19 @@ public function to_fields_array($attributes) {
$field['wrapper_classes'] = trim(wu_get_isset($field, 'wrapper_classes', '') . ' ' . $attributes['element_classes']);
$field['wrapper_html_attr']['v-show'] = 'order.should_collect_payment';
$field['wrapper_html_attr']['v-cloak'] = 1;
+
+ /*
+ * 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) {
+ $field['wrapper_html_attr'][':style'] = "{ display: gateway && gateway.startsWith('stripe') ? 'none' : '' }";
+ }
}
uasort($fields, 'wu_sort_by_order');
diff --git a/inc/gateways/class-base-stripe-gateway.php b/inc/gateways/class-base-stripe-gateway.php
index 593291b6f..72a4229cd 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-checkout-gateway.php b/inc/gateways/class-stripe-checkout-gateway.php
index 0d08bef9d..ce857bf10 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()
+ )
+ );
+ }
+ }
}
diff --git a/inc/gateways/class-stripe-gateway.php b/inc/gateways/class-stripe-gateway.php
index 04218a3a2..c3284a6a9 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,
],
]
);
@@ -537,7 +592,7 @@ public function process_checkout($payment, $membership, $customer, $cart, $type)
$payment_intent = $this->get_stripe_client()->setupIntents->retrieve($payment_intent_id);
} else {
- $payment_intent = $this->get_stripe_client()->paymentIntents->retrieve($payment_intent_id);
+ $payment_intent = $this->get_stripe_client()->paymentIntents->retrieve($payment_intent_id, ['expand' => ['latest_charge']]);
}
/*
@@ -581,7 +636,7 @@ public function process_checkout($payment, $membership, $customer, $cart, $type)
*/
$payment_method = $this->save_payment_method($payment_intent, $s_customer);
- $payment_completed = $is_setup_intent || (! empty($payment_intent->charges->data[0]['id']) && 'succeeded' === $payment_intent->charges->data[0]['status']);
+ $payment_completed = $is_setup_intent || ('succeeded' === $payment_intent->status && ! empty($payment_intent->latest_charge));
$subscription = false;
@@ -597,7 +652,8 @@ public function process_checkout($payment, $membership, $customer, $cart, $type)
}
if ($payment_completed) {
- $payment_id = $is_setup_intent ? $payment_intent->id : sanitize_text_field($payment_intent->charges->data[0]['id']);
+ $charge_id = is_object($payment_intent->latest_charge) ? $payment_intent->latest_charge->id : $payment_intent->latest_charge;
+ $payment_id = $is_setup_intent ? $payment_intent->id : sanitize_text_field($charge_id);
$payment->set_status(Payment_Status::COMPLETED);
$payment->set_gateway($this->get_id());
@@ -687,16 +743,13 @@ public function fields(): string {
-
-
-
-
-
@@ -796,4 +849,53 @@ 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
+ return sprintf(
+ '
',
+ esc_html__('Connect your Stripe account with one click.', 'ultimate-multisite'),
+ 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/inc/helpers/validation-rules/class-state.php b/inc/helpers/validation-rules/class-state.php
index 4495f65cd..134c374f6 100644
--- a/inc/helpers/validation-rules/class-state.php
+++ b/inc/helpers/validation-rules/class-state.php
@@ -42,12 +42,16 @@ public function check($state) : bool { // phpcs:ignore
$country = $this->parameter('country') ?? wu_request('billing_country');
if ($country && $state) {
- $state = strtoupper((string) $state);
+ $state_upper = strtoupper((string) $state);
- $allowed_states = array_keys(wu_get_country_states(strtoupper((string) $country), false));
+ $states = wu_get_country_states(strtoupper((string) $country), false);
- if (! empty($allowed_states)) {
- $check = in_array($state, $allowed_states, true);
+ if (! empty($states)) {
+ $allowed_codes = array_keys($states);
+
+ // Accept state codes (e.g. "BW") or full state names (e.g. "Baden-Württemberg")
+ $check = in_array($state_upper, $allowed_codes, true)
+ || in_array((string) $state, $states, true);
}
}
diff --git a/inc/invoices/class-invoice.php b/inc/invoices/class-invoice.php
index ebcd23f5d..3c256c8e6 100644
--- a/inc/invoices/class-invoice.php
+++ b/inc/invoices/class-invoice.php
@@ -173,6 +173,29 @@ public function render() {
return wu_get_template_contents('invoice/template', $atts);
}
+ /**
+ * Returns the PDF content as a string.
+ *
+ * @since 2.3.0
+ * @return string
+ */
+ public function get_content(): string {
+
+ wu_setup_memory_limit_trap();
+
+ wu_try_unlimited_server_limits();
+
+ $this->pdf_setup();
+
+ if ( ! defined('WU_GENERATING_PDF')) {
+ define('WU_GENERATING_PDF', true);
+ }
+
+ $this->printer->WriteHTML($this->render());
+
+ return $this->printer->Output('', Destination::STRING_RETURN);
+ }
+
/**
* Handles the PDF generation.
*
diff --git a/inc/managers/class-email-manager.php b/inc/managers/class-email-manager.php
index 857d7d8f1..d9599c1fc 100644
--- a/inc/managers/class-email-manager.php
+++ b/inc/managers/class-email-manager.php
@@ -161,10 +161,14 @@ public function send_system_email($slug, $payload): void {
/*
* Add the invoice attachment, if need be.
*/
- if (wu_get_isset($payload, 'payment_invoice_url') && wu_get_setting('attach_invoice_pdf', true)) {
- $file_name = 'invoice-' . $payload['payment_reference_code'] . '.pdf';
+ if (wu_get_isset($payload, 'payment_id') && wu_get_setting('attach_invoice_pdf', true)) {
+ $invoice_payment = wu_get_payment($payload['payment_id']);
- $this->attach_file_by_url($payload['payment_invoice_url'], $file_name, $args['subject']);
+ if ($invoice_payment) {
+ $file_name = 'invoice-' . $invoice_payment->get_hash() . '.pdf';
+
+ $this->attach_invoice_pdf($invoice_payment, $file_name, $args['subject']);
+ }
}
$when_to_send = $email->get_when_to_send();
@@ -178,43 +182,28 @@ public function send_system_email($slug, $payload): void {
}
/**
- * Attach a file by a URL
+ * Attach an invoice PDF generated directly from the payment model.
*
- * @since 2.0.0
+ * @since 2.3.0
*
- * @param string $file_url The URL of the file to attach.
- * @param string $file_name The name to save the file with.
- * @param string $email_subject The email subject, to avoid attaching a file to the wrong email.
+ * @param \WP_Ultimo\Models\Payment $payment The payment to generate the invoice for.
+ * @param string $file_name The name to save the file with.
+ * @param string $email_subject The email subject, to avoid attaching a file to the wrong email.
* @return void
*/
- public function attach_file_by_url($file_url, $file_name, $email_subject = ''): void {
+ public function attach_invoice_pdf($payment, string $file_name, string $email_subject = ''): void {
add_action(
'phpmailer_init',
- function ($mail) use ($file_url, $file_name, $email_subject) {
-
- if ($email_subject && $mail->Subject !== $email_subject) { // phpcs:ignore
-
- return;
- }
-
- $response = wp_remote_get(
- $file_url,
- [
- 'timeout' => 50,
- ]
- );
+ function ($mail) use ($payment, $file_name, $email_subject) {
- if (is_wp_error($response)) {
+ if ($email_subject && $mail->Subject !== $email_subject) { // phpcs:ignore
return;
}
- $file = wp_remote_retrieve_body($response);
+ $invoice = new \WP_Ultimo\Invoices\Invoice($payment);
- /*
- * Use the default PHPMailer APIs to attach the file.
- */
- $mail->addStringAttachment($file, $file_name);
+ $mail->addStringAttachment($invoice->get_content(), $file_name);
}
);
}
diff --git a/inc/managers/class-gateway-manager.php b/inc/managers/class-gateway-manager.php
index ab199a5d0..885fc6e62 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);
}
/**
@@ -527,7 +543,7 @@ public function install_hooks($class_name): void {
add_action("wu_{$gateway_id}_process_webhooks", [$gateway, 'process_webhooks']);
- add_action("wu_{$gateway_id}_remote_payment_url", [$gateway, 'get_payment_url_on_gateway']);
+ add_action("wu_{$gateway_id}_remote_payment_url", [$gateway, 'get_payment_url_on_gateway']); // @phpstan-ignore-line Used as filter via apply_filters.
add_action("wu_{$gateway_id}_remote_subscription_url", [$gateway, 'get_subscription_url_on_gateway']);
@@ -540,7 +556,7 @@ public function install_hooks($class_name): void {
'wu_checkout_gateway_fields',
function () use ($gateway) {
- $field_content = call_user_func([$gateway, 'fields']);
+ $field_content = call_user_func([$gateway, 'fields']); // @phpstan-ignore-line Subclass implementations return string.
ob_start();
@@ -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 = wu_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 = wu_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 = wu_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()));
+ }
}
diff --git a/inc/managers/class-payment-manager.php b/inc/managers/class-payment-manager.php
index ac2861a2a..5b9ea4e41 100644
--- a/inc/managers/class-payment-manager.php
+++ b/inc/managers/class-payment-manager.php
@@ -290,20 +290,28 @@ public function render_pending_payments(): void {
*/
public function invoice_viewer(): void {
- if (wu_request('action') === 'invoice' && wu_request('reference') && wu_request('key')) {
- /*
- * Validates nonce.
- */
- if ( ! wp_verify_nonce(wu_request('key'), 'see_invoice')) {
- wp_die(esc_html__('You do not have permissions to access this file.', 'ultimate-multisite'));
- }
-
+ if (wu_request('action') === 'invoice' && wu_request('reference')) {
$payment = wu_get_payment_by_hash(wu_request('reference'));
if ( ! $payment) {
wp_die(esc_html__('This invoice does not exist.', 'ultimate-multisite'));
}
+ /*
+ * Validates access: must be a network admin or the customer who owns this payment.
+ */
+ $has_access = current_user_can('manage_network');
+
+ if ( ! $has_access) {
+ $current_customer = wu_get_current_customer();
+
+ $has_access = $current_customer && $current_customer->get_id() === $payment->get_customer_id();
+ }
+
+ if ( ! $has_access) {
+ wp_die(esc_html__('You do not have permissions to access this file.', 'ultimate-multisite'));
+ }
+
$invoice = new Invoice($payment);
/*
diff --git a/inc/models/class-payment.php b/inc/models/class-payment.php
index a1eb8c725..84d5562c8 100644
--- a/inc/models/class-payment.php
+++ b/inc/models/class-payment.php
@@ -826,7 +826,6 @@ public function get_invoice_url() {
$url_atts = [
'action' => 'invoice',
'reference' => $this->get_hash(),
- 'key' => wp_create_nonce('see_invoice'),
];
return add_query_arg($url_atts, get_site_url(wu_get_main_site_id()));
diff --git a/tests/WP_Ultimo/Gateways/Stripe_Gateway_Process_Checkout_Test.php b/tests/WP_Ultimo/Gateways/Stripe_Gateway_Process_Checkout_Test.php
index dd97c4dc2..2ff9cd5e3 100644
--- a/tests/WP_Ultimo/Gateways/Stripe_Gateway_Process_Checkout_Test.php
+++ b/tests/WP_Ultimo/Gateways/Stripe_Gateway_Process_Checkout_Test.php
@@ -107,6 +107,7 @@ public function setUp(): void {
'status' => 'succeeded',
'customer' => 'cus_123',
'payment_method' => 'pm_123',
+ 'latest_charge' => 'ch_123',
'charges' => [
'object' => 'list',
'data' => [
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 000000000..c219a1b68
--- /dev/null
+++ b/tests/WP_Ultimo/Gateways/Stripe_OAuth_E2E_Test.php
@@ -0,0 +1,143 @@
+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 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();
+
+ wu_save_setting('stripe_sandbox_mode', 1);
+
+ // 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 3: 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 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));
+
+ // 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');
+
+ $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 000000000..e12534491
--- /dev/null
+++ b/tests/WP_Ultimo/Gateways/Stripe_OAuth_Test.php
@@ -0,0 +1,298 @@
+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 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);
+ }
+}
diff --git a/tests/WP_Ultimo/Helpers/Validation_Rules/State_Test.php b/tests/WP_Ultimo/Helpers/Validation_Rules/State_Test.php
new file mode 100644
index 000000000..2e9373e15
--- /dev/null
+++ b/tests/WP_Ultimo/Helpers/Validation_Rules/State_Test.php
@@ -0,0 +1,321 @@
+rule = new State();
+ }
+
+ /**
+ * Test state code is accepted for Germany.
+ */
+ public function test_german_state_code_passes() {
+ $_REQUEST['billing_country'] = 'DE';
+
+ $this->assertTrue($this->rule->check('BW'));
+ }
+
+ /**
+ * Test lowercase state code is accepted for Germany.
+ */
+ public function test_german_state_code_lowercase_passes() {
+ $_REQUEST['billing_country'] = 'DE';
+
+ $this->assertTrue($this->rule->check('bw'));
+ }
+
+ /**
+ * Test full state name is accepted for Germany.
+ */
+ public function test_german_state_name_passes() {
+ $_REQUEST['billing_country'] = 'DE';
+
+ $this->assertTrue($this->rule->check('Baden-Württemberg'));
+ }
+
+ /**
+ * Test all German state codes are valid.
+ *
+ * @dataProvider german_state_codes_provider
+ *
+ * @param string $code The state code to test.
+ */
+ public function test_all_german_state_codes_pass(string $code) {
+ $_REQUEST['billing_country'] = 'DE';
+
+ $this->assertTrue($this->rule->check($code));
+ }
+
+ /**
+ * Data provider for German state codes.
+ *
+ * @return array
+ */
+ public function german_state_codes_provider(): array {
+ return [
+ 'Baden-Württemberg' => ['BW'],
+ 'Bavaria' => ['BY'],
+ 'Berlin' => ['BE'],
+ 'Brandenburg' => ['BB'],
+ 'Bremen' => ['HB'],
+ 'Hamburg' => ['HH'],
+ 'Hesse' => ['HE'],
+ 'Lower Saxony' => ['NI'],
+ 'Mecklenburg-Vorpommern' => ['MV'],
+ 'North Rhine-Westphalia' => ['NW'],
+ 'Rhineland-Palatinate' => ['RP'],
+ 'Saarland' => ['SL'],
+ 'Saxony' => ['SN'],
+ 'Saxony-Anhalt' => ['ST'],
+ 'Schleswig-Holstein' => ['SH'],
+ 'Thuringia' => ['TH'],
+ ];
+ }
+
+ /**
+ * Test all German state names are valid.
+ *
+ * @dataProvider german_state_names_provider
+ *
+ * @param string $name The state name to test.
+ */
+ public function test_all_german_state_names_pass(string $name) {
+ $_REQUEST['billing_country'] = 'DE';
+
+ $this->assertTrue($this->rule->check($name));
+ }
+
+ /**
+ * Data provider for German state names.
+ *
+ * @return array
+ */
+ public function german_state_names_provider(): array {
+ return [
+ 'Baden-Württemberg' => ['Baden-Württemberg'],
+ 'Bavaria' => ['Bavaria'],
+ 'Berlin' => ['Berlin'],
+ 'Brandenburg' => ['Brandenburg'],
+ 'Bremen' => ['Bremen'],
+ 'Hamburg' => ['Hamburg'],
+ 'Hesse' => ['Hesse'],
+ 'Lower Saxony' => ['Lower Saxony'],
+ 'Mecklenburg-Vorpommern' => ['Mecklenburg-Vorpommern'],
+ 'North Rhine-Westphalia' => ['North Rhine-Westphalia'],
+ 'Rhineland-Palatinate' => ['Rhineland-Palatinate'],
+ 'Saarland' => ['Saarland'],
+ 'Saxony' => ['Saxony'],
+ 'Saxony-Anhalt' => ['Saxony-Anhalt'],
+ 'Schleswig-Holstein' => ['Schleswig-Holstein'],
+ 'Thuringia' => ['Thuringia'],
+ ];
+ }
+
+ /**
+ * Test invalid state code is rejected.
+ */
+ public function test_invalid_state_code_rejected() {
+ $_REQUEST['billing_country'] = 'DE';
+
+ $this->assertFalse($this->rule->check('XX'));
+ }
+
+ /**
+ * Test invalid state name is rejected.
+ */
+ public function test_invalid_state_name_rejected() {
+ $_REQUEST['billing_country'] = 'DE';
+
+ $this->assertFalse($this->rule->check('Atlantis'));
+ }
+
+ /**
+ * Test US state code passes.
+ */
+ public function test_us_state_code_passes() {
+ $_REQUEST['billing_country'] = 'US';
+
+ $this->assertTrue($this->rule->check('CA'));
+ }
+
+ /**
+ * Test US state name passes.
+ */
+ public function test_us_state_name_passes() {
+ $_REQUEST['billing_country'] = 'US';
+
+ $this->assertTrue($this->rule->check('California'));
+ }
+
+ /**
+ * Test empty state value passes (state not required by default).
+ */
+ public function test_empty_state_passes() {
+ $_REQUEST['billing_country'] = 'DE';
+
+ $this->assertTrue($this->rule->check(''));
+ }
+
+ /**
+ * Test null state value passes.
+ */
+ public function test_null_state_passes() {
+ $_REQUEST['billing_country'] = 'DE';
+
+ $this->assertTrue($this->rule->check(null));
+ }
+
+ /**
+ * Test validation passes when country has no states defined.
+ */
+ public function test_country_without_states_passes_any_value() {
+ $_REQUEST['billing_country'] = 'XX';
+
+ $this->assertTrue($this->rule->check('anything'));
+ }
+
+ /**
+ * Test validation passes when no country is set.
+ */
+ public function test_no_country_passes() {
+ unset($_REQUEST['billing_country']);
+
+ $this->assertTrue($this->rule->check('BW'));
+ }
+
+ /**
+ * Test lowercase country code works.
+ */
+ public function test_lowercase_country_code_works() {
+ $_REQUEST['billing_country'] = 'de';
+
+ $this->assertTrue($this->rule->check('BW'));
+ }
+
+ /**
+ * Test German state from wrong country fails.
+ */
+ public function test_german_state_code_fails_for_us() {
+ $_REQUEST['billing_country'] = 'US';
+
+ $this->assertFalse($this->rule->check('BW'));
+ }
+
+ /**
+ * Test US state fails for Germany.
+ */
+ public function test_us_state_code_fails_for_germany() {
+ $_REQUEST['billing_country'] = 'DE';
+
+ $this->assertFalse($this->rule->check('CA'));
+ }
+
+ /**
+ * Test unicode state name with special characters.
+ */
+ public function test_unicode_state_name_passes() {
+ $_REQUEST['billing_country'] = 'DE';
+
+ // Baden-Württemberg has ü character
+ $this->assertTrue($this->rule->check('Baden-Württemberg'));
+ }
+
+ /**
+ * Test state validation via the Validator class integration.
+ */
+ public function test_state_validation_via_validator() {
+ $_REQUEST['billing_country'] = 'DE';
+
+ $validator = new \WP_Ultimo\Helpers\Validator();
+
+ $data = [
+ 'billing_country' => 'DE',
+ 'billing_state' => 'BW',
+ ];
+
+ $rules = [
+ 'billing_state' => 'state',
+ ];
+
+ $result = $validator->validate($data, $rules);
+
+ $this->assertFalse($result->fails());
+ }
+
+ /**
+ * Test state name validation via the Validator class integration.
+ */
+ public function test_state_name_validation_via_validator() {
+ $_REQUEST['billing_country'] = 'DE';
+
+ $validator = new \WP_Ultimo\Helpers\Validator();
+
+ $data = [
+ 'billing_country' => 'DE',
+ 'billing_state' => 'Baden-Württemberg',
+ ];
+
+ $rules = [
+ 'billing_state' => 'state',
+ ];
+
+ $result = $validator->validate($data, $rules);
+
+ $this->assertFalse($result->fails());
+ }
+
+ /**
+ * Test invalid state fails validation via the Validator class.
+ */
+ public function test_invalid_state_fails_via_validator() {
+ $_REQUEST['billing_country'] = 'DE';
+
+ $validator = new \WP_Ultimo\Helpers\Validator();
+
+ $data = [
+ 'billing_country' => 'DE',
+ 'billing_state' => 'InvalidState',
+ ];
+
+ $rules = [
+ 'billing_state' => 'state',
+ ];
+
+ $result = $validator->validate($data, $rules);
+
+ $this->assertTrue($result->fails());
+ }
+
+ /**
+ * Clean up.
+ */
+ public function tearDown(): void {
+ unset($_REQUEST['billing_country']);
+
+ parent::tearDown();
+ }
+}
diff --git a/tests/WP_Ultimo/Invoices/Invoice_Test.php b/tests/WP_Ultimo/Invoices/Invoice_Test.php
new file mode 100644
index 000000000..6e4a9c44a
--- /dev/null
+++ b/tests/WP_Ultimo/Invoices/Invoice_Test.php
@@ -0,0 +1,735 @@
+ $username,
+ 'email' => $username . '@example.com',
+ 'password' => 'password123',
+ ]
+ );
+
+ if (is_wp_error($customer)) {
+ // Fallback: get any existing customer
+ $customers = \WP_Ultimo\Models\Customer::query(['number' => 1]);
+ $customer = $customers[0] ?? wu_create_customer([
+ 'username' => 'inv_fallback_' . wp_rand(),
+ 'email' => 'inv_fallback_' . wp_rand() . '@example.com',
+ 'password' => 'password123',
+ ]);
+ }
+
+ self::$customer = $customer;
+ }
+
+ /**
+ * Create a payment with line items for testing.
+ *
+ * @param array $overrides Payment attribute overrides.
+ * @return Payment
+ */
+ private function create_test_payment(array $overrides = []): Payment {
+
+ $payment = new Payment();
+ $payment->set_customer_id(self::$customer->get_id());
+ $payment->set_currency($overrides['currency'] ?? 'USD');
+ $payment->set_subtotal($overrides['subtotal'] ?? 100.00);
+ $payment->set_total($overrides['total'] ?? 110.00);
+ $payment->set_tax_total($overrides['tax_total'] ?? 10.00);
+ $payment->set_status($overrides['status'] ?? Payment_Status::COMPLETED);
+ $payment->set_gateway($overrides['gateway'] ?? 'manual');
+
+ $line_items = $overrides['line_items'] ?? [
+ new Line_Item(
+ [
+ 'type' => 'product',
+ 'hash' => 'test_plan',
+ 'title' => 'Test Plan',
+ 'description' => 'Monthly hosting plan',
+ 'unit_price' => 100.00,
+ 'quantity' => 1,
+ 'taxable' => true,
+ 'tax_rate' => 10.00,
+ ]
+ ),
+ ];
+
+ $payment->set_line_items($line_items);
+
+ $payment->save();
+
+ return $payment;
+ }
+
+ /**
+ * Test Invoice class can be instantiated with a payment.
+ */
+ public function test_invoice_instantiation() {
+
+ $payment = $this->create_test_payment();
+ $invoice = new Invoice($payment);
+
+ $this->assertInstanceOf(Invoice::class, $invoice);
+ $this->assertSame($payment, $invoice->get_payment());
+ }
+
+ /**
+ * Test Invoice has default attributes.
+ */
+ public function test_invoice_has_default_attributes() {
+
+ $payment = $this->create_test_payment();
+ $invoice = new Invoice($payment);
+ $attributes = $invoice->get_attributes();
+
+ $this->assertArrayHasKey('company_name', $attributes);
+ $this->assertArrayHasKey('company_address', $attributes);
+ $this->assertArrayHasKey('primary_color', $attributes);
+ $this->assertArrayHasKey('font', $attributes);
+ $this->assertArrayHasKey('logo_url', $attributes);
+ $this->assertArrayHasKey('use_custom_logo', $attributes);
+ $this->assertArrayHasKey('custom_logo', $attributes);
+ $this->assertArrayHasKey('footer_message', $attributes);
+ $this->assertArrayHasKey('paid_tag_text', $attributes);
+ }
+
+ /**
+ * Test Invoice default font is DejaVuSansCondensed.
+ */
+ public function test_invoice_default_font() {
+
+ $payment = $this->create_test_payment();
+ $invoice = new Invoice($payment);
+
+ $this->assertEquals('DejaVuSansCondensed', $invoice->font);
+ }
+
+ /**
+ * Test Invoice default primary color.
+ */
+ public function test_invoice_default_primary_color() {
+
+ $payment = $this->create_test_payment();
+ $invoice = new Invoice($payment);
+
+ $this->assertEquals('#675645', $invoice->primary_color);
+ }
+
+ /**
+ * Test custom attributes override defaults.
+ */
+ public function test_custom_attributes_override_defaults() {
+
+ $payment = $this->create_test_payment();
+ $invoice = new Invoice($payment, [
+ 'company_name' => 'Test Company',
+ 'primary_color' => '#ff0000',
+ 'font' => 'DejaVuSerifCondensed',
+ ]);
+
+ $this->assertEquals('Test Company', $invoice->company_name);
+ $this->assertEquals('#ff0000', $invoice->primary_color);
+ $this->assertEquals('DejaVuSerifCondensed', $invoice->font);
+ }
+
+ /**
+ * Test magic getter returns empty string for unknown attributes.
+ */
+ public function test_magic_getter_returns_empty_for_unknown() {
+
+ $payment = $this->create_test_payment();
+ $invoice = new Invoice($payment);
+
+ $this->assertEquals('', $invoice->nonexistent_attribute);
+ }
+
+ /**
+ * Test Invoice render method generates HTML.
+ */
+ public function test_invoice_render_generates_html() {
+
+ $payment = $this->create_test_payment();
+ $invoice = new Invoice($payment);
+
+ $html = $invoice->render();
+
+ $this->assertIsString($html);
+ $this->assertNotEmpty($html);
+ }
+
+ /**
+ * Test rendered HTML contains invoice structure.
+ */
+ public function test_render_contains_invoice_structure() {
+
+ $payment = $this->create_test_payment();
+ $invoice = new Invoice($payment, ['company_name' => 'Acme Corp']);
+
+ $html = $invoice->render();
+
+ $this->assertStringContainsString('invoice-box', $html);
+ $this->assertStringContainsString('Acme Corp', $html);
+ }
+
+ /**
+ * Test rendered HTML contains line item data.
+ */
+ public function test_render_contains_line_items() {
+
+ $payment = $this->create_test_payment();
+ $invoice = new Invoice($payment);
+
+ $html = $invoice->render();
+
+ $this->assertStringContainsString('Test Plan', $html);
+ }
+
+ /**
+ * Test rendered HTML contains payment total.
+ */
+ public function test_render_contains_payment_total() {
+
+ $payment = $this->create_test_payment(['total' => 110.00, 'currency' => 'USD']);
+ $invoice = new Invoice($payment);
+
+ $html = $invoice->render();
+
+ $this->assertStringContainsString('110', $html);
+ }
+
+ /**
+ * Test render with multiple line items.
+ */
+ public function test_render_with_multiple_line_items() {
+
+ $line_items = [
+ new Line_Item([
+ 'type' => 'product',
+ 'hash' => 'plan_1',
+ 'title' => 'Basic Plan',
+ 'unit_price' => 50.00,
+ 'quantity' => 1,
+ 'taxable' => true,
+ 'tax_rate' => 10.00,
+ ]),
+ new Line_Item([
+ 'type' => 'fee',
+ 'hash' => 'setup_fee',
+ 'title' => 'Setup Fee',
+ 'unit_price' => 25.00,
+ 'quantity' => 1,
+ 'taxable' => false,
+ 'tax_rate' => 0,
+ ]),
+ ];
+
+ $payment = $this->create_test_payment(['line_items' => $line_items]);
+ $invoice = new Invoice($payment);
+
+ $html = $invoice->render();
+
+ $this->assertStringContainsString('Basic Plan', $html);
+ $this->assertStringContainsString('Setup Fee', $html);
+ }
+
+ /**
+ * Test render with completed payment shows payment method section.
+ */
+ public function test_render_completed_payment_shows_payment_method() {
+
+ $payment = $this->create_test_payment(['status' => Payment_Status::COMPLETED]);
+ $invoice = new Invoice($payment);
+
+ $html = $invoice->render();
+
+ // Completed payments show "Payment Method" heading
+ $this->assertStringContainsString('Payment Method', $html);
+ }
+
+ /**
+ * Test render with pending payment does not show payment method section.
+ */
+ public function test_render_pending_payment_hides_payment_method() {
+
+ $payment = $this->create_test_payment(['status' => Payment_Status::PENDING]);
+ $invoice = new Invoice($payment);
+
+ $html = $invoice->render();
+
+ // Pending payments are payable, so no payment method section
+ $this->assertStringNotContainsString('Payment Method', $html);
+ }
+
+ /**
+ * Test Invoice PDF generation does not fatal error.
+ */
+ public function test_pdf_generation_does_not_fatal() {
+
+ $payment = $this->create_test_payment();
+ $invoice = new Invoice($payment);
+
+ $folder = Invoice::get_folder();
+ $file_name = 'test-invoice-' . time() . '.pdf';
+
+ $invoice->save_file($file_name);
+
+ $file_path = $folder . $file_name;
+
+ $this->assertFileExists($file_path);
+
+ // Clean up
+ if (file_exists($file_path)) {
+ unlink($file_path);
+ }
+ }
+
+ /**
+ * Test generated PDF file is non-empty.
+ */
+ public function test_generated_pdf_has_content() {
+
+ $payment = $this->create_test_payment();
+ $invoice = new Invoice($payment);
+
+ $folder = Invoice::get_folder();
+ $file_name = 'test-invoice-content-' . time() . '.pdf';
+
+ $invoice->save_file($file_name);
+
+ $file_path = $folder . $file_name;
+
+ $this->assertGreaterThan(0, filesize($file_path));
+
+ // Clean up
+ if (file_exists($file_path)) {
+ unlink($file_path);
+ }
+ }
+
+ /**
+ * Test generated PDF file starts with PDF header.
+ */
+ public function test_generated_pdf_has_valid_header() {
+
+ $payment = $this->create_test_payment();
+ $invoice = new Invoice($payment);
+
+ $folder = Invoice::get_folder();
+ $file_name = 'test-invoice-header-' . time() . '.pdf';
+
+ $invoice->save_file($file_name);
+
+ $file_path = $folder . $file_name;
+
+ $header = file_get_contents($file_path, false, null, 0, 5);
+ $this->assertEquals('%PDF-', $header);
+
+ // Clean up
+ if (file_exists($file_path)) {
+ unlink($file_path);
+ }
+ }
+
+ /**
+ * Test PDF generation with EUR currency.
+ */
+ public function test_pdf_generation_with_eur_currency() {
+
+ $payment = $this->create_test_payment([
+ 'currency' => 'EUR',
+ 'total' => 58.31,
+ 'subtotal' => 49.00,
+ 'tax_total' => 9.31,
+ ]);
+ $invoice = new Invoice($payment);
+
+ $folder = Invoice::get_folder();
+ $file_name = 'test-invoice-eur-' . time() . '.pdf';
+
+ $invoice->save_file($file_name);
+
+ $file_path = $folder . $file_name;
+ $this->assertFileExists($file_path);
+ $this->assertGreaterThan(0, filesize($file_path));
+
+ // Clean up
+ if (file_exists($file_path)) {
+ unlink($file_path);
+ }
+ }
+
+ /**
+ * Test PDF generation with zero-amount payment.
+ */
+ public function test_pdf_generation_with_zero_amount() {
+
+ $line_items = [
+ new Line_Item([
+ 'type' => 'product',
+ 'hash' => 'free_plan',
+ 'title' => 'Free Plan',
+ 'unit_price' => 0,
+ 'quantity' => 1,
+ 'taxable' => false,
+ 'tax_rate' => 0,
+ ]),
+ ];
+
+ $payment = $this->create_test_payment([
+ 'total' => 0,
+ 'subtotal' => 0,
+ 'tax_total' => 0,
+ 'line_items' => $line_items,
+ ]);
+ $invoice = new Invoice($payment);
+
+ $folder = Invoice::get_folder();
+ $file_name = 'test-invoice-free-' . time() . '.pdf';
+
+ $invoice->save_file($file_name);
+
+ $file_path = $folder . $file_name;
+ $this->assertFileExists($file_path);
+
+ // Clean up
+ if (file_exists($file_path)) {
+ unlink($file_path);
+ }
+ }
+
+ /**
+ * Test PDF generation with custom company info.
+ */
+ public function test_pdf_generation_with_custom_company_info() {
+
+ $payment = $this->create_test_payment();
+ $invoice = new Invoice($payment, [
+ 'company_name' => 'HogaCloud GmbH',
+ 'company_address' => "Musterstraße 123\n69168 Wiesloch\nDeutschland",
+ 'primary_color' => '#003366',
+ ]);
+
+ $html = $invoice->render();
+
+ $this->assertStringContainsString('HogaCloud GmbH', $html);
+ $this->assertStringContainsString('69168 Wiesloch', $html);
+ }
+
+ /**
+ * Test PDF generation with footer message.
+ */
+ public function test_pdf_generation_with_footer() {
+
+ $payment = $this->create_test_payment();
+ $invoice = new Invoice($payment, [
+ 'footer_message' => 'Thank you for your business!',
+ ]);
+
+ $folder = Invoice::get_folder();
+ $file_name = 'test-invoice-footer-' . time() . '.pdf';
+
+ $invoice->save_file($file_name);
+
+ $file_path = $folder . $file_name;
+ $this->assertFileExists($file_path);
+
+ // Clean up
+ if (file_exists($file_path)) {
+ unlink($file_path);
+ }
+ }
+
+ /**
+ * Test Invoice settings save and retrieve.
+ */
+ public function test_settings_save_and_retrieve() {
+
+ $settings = [
+ 'company_name' => 'Test Corp',
+ 'primary_color' => '#123456',
+ 'font' => 'FreeMono',
+ ];
+
+ Invoice::save_settings($settings);
+
+ $saved = Invoice::get_settings();
+
+ $this->assertEquals('Test Corp', $saved['company_name']);
+ $this->assertEquals('#123456', $saved['primary_color']);
+ $this->assertEquals('FreeMono', $saved['font']);
+ }
+
+ /**
+ * Test save_settings filters out unknown keys.
+ */
+ public function test_save_settings_filters_unknown_keys() {
+
+ $settings = [
+ 'company_name' => 'Test Corp',
+ 'evil_key' => 'should be filtered',
+ ];
+
+ Invoice::save_settings($settings);
+
+ $saved = Invoice::get_settings();
+
+ $this->assertArrayNotHasKey('evil_key', $saved);
+ }
+
+ /**
+ * Test Invoice get_folder creates directory.
+ */
+ public function test_get_folder_creates_directory() {
+
+ $folder = Invoice::get_folder();
+
+ $this->assertNotEmpty($folder);
+ $this->assertDirectoryExists($folder);
+ }
+
+ /**
+ * Test set_payment and get_payment.
+ */
+ public function test_set_and_get_payment() {
+
+ $payment1 = $this->create_test_payment();
+ $payment2 = $this->create_test_payment(['total' => 200.00]);
+
+ $invoice = new Invoice($payment1);
+
+ $this->assertSame($payment1, $invoice->get_payment());
+
+ $invoice->set_payment($payment2);
+
+ $this->assertSame($payment2, $invoice->get_payment());
+ }
+
+ /**
+ * Test set_attributes and get_attributes.
+ */
+ public function test_set_and_get_attributes() {
+
+ $payment = $this->create_test_payment();
+ $invoice = new Invoice($payment);
+
+ $new_atts = [
+ 'company_name' => 'Updated Corp',
+ 'primary_color' => '#000000',
+ ];
+
+ $invoice->set_attributes($new_atts);
+
+ $attributes = $invoice->get_attributes();
+
+ $this->assertEquals('Updated Corp', $attributes['company_name']);
+ $this->assertEquals('#000000', $attributes['primary_color']);
+ // Default values should still be present
+ $this->assertArrayHasKey('font', $attributes);
+ }
+
+ /**
+ * Test rendering with payment that has no line items.
+ */
+ public function test_render_with_no_line_items() {
+
+ $payment = $this->create_test_payment(['line_items' => []]);
+ $invoice = new Invoice($payment);
+
+ $html = $invoice->render();
+
+ $this->assertIsString($html);
+ $this->assertStringContainsString('invoice-box', $html);
+ }
+
+ /**
+ * Test rendering with payment that has no membership.
+ */
+ public function test_render_with_no_membership() {
+
+ $payment = $this->create_test_payment();
+ // Payment is not associated with a membership
+ $invoice = new Invoice($payment);
+
+ $html = $invoice->render();
+
+ $this->assertIsString($html);
+ $this->assertStringContainsString('invoice-box', $html);
+ }
+
+ /**
+ * Test rendering with special characters in company name.
+ */
+ public function test_render_with_special_characters() {
+
+ $payment = $this->create_test_payment();
+ $invoice = new Invoice($payment, [
+ 'company_name' => 'Müller & Söhne GmbH',
+ 'company_address' => "Königstraße 42\nBerlin",
+ ]);
+
+ $html = $invoice->render();
+
+ $this->assertStringContainsString('Müller', $html);
+ $this->assertStringContainsString('Söhne', $html);
+ }
+
+ /**
+ * Test PDF generation with special characters does not crash MPDF.
+ */
+ public function test_pdf_with_special_characters() {
+
+ $line_items = [
+ new Line_Item([
+ 'type' => 'product',
+ 'hash' => 'special_plan',
+ 'title' => 'Ünïcödé Plän — Special Édition',
+ 'description' => 'Lörem ïpsum dölor sït ämet',
+ 'unit_price' => 49.00,
+ 'quantity' => 1,
+ 'taxable' => true,
+ 'tax_rate' => 19.00,
+ ]),
+ ];
+
+ $payment = $this->create_test_payment([
+ 'currency' => 'EUR',
+ 'line_items' => $line_items,
+ ]);
+
+ $invoice = new Invoice($payment, [
+ 'company_name' => 'Ünïcödé Tëst GmbH',
+ 'company_address' => "Königstraße 42\n10117 Berlin\nDeutschland",
+ ]);
+
+ $folder = Invoice::get_folder();
+ $file_name = 'test-invoice-unicode-' . time() . '.pdf';
+
+ $invoice->save_file($file_name);
+
+ $file_path = $folder . $file_name;
+ $this->assertFileExists($file_path);
+
+ $header = file_get_contents($file_path, false, null, 0, 5);
+ $this->assertEquals('%PDF-', $header);
+
+ // Clean up
+ if (file_exists($file_path)) {
+ unlink($file_path);
+ }
+ }
+
+ /**
+ * Test Invoice render contains Bill To section.
+ */
+ public function test_render_contains_bill_to_section() {
+
+ $payment = $this->create_test_payment();
+ $invoice = new Invoice($payment);
+
+ $html = $invoice->render();
+
+ $this->assertStringContainsString('Bill to', $html);
+ }
+
+ /**
+ * Test Invoice render contains Invoice number.
+ */
+ public function test_render_contains_invoice_number() {
+
+ $payment = $this->create_test_payment();
+ $invoice = new Invoice($payment);
+
+ $html = $invoice->render();
+
+ $this->assertStringContainsString('Invoice #', $html);
+ }
+
+ /**
+ * Test render includes tax information.
+ */
+ public function test_render_includes_tax_column() {
+
+ $payment = $this->create_test_payment();
+ $invoice = new Invoice($payment);
+
+ $html = $invoice->render();
+
+ $this->assertStringContainsString('Tax', $html);
+ }
+
+ /**
+ * Test render includes discount column.
+ */
+ public function test_render_includes_discount_column() {
+
+ $payment = $this->create_test_payment();
+ $invoice = new Invoice($payment);
+
+ $html = $invoice->render();
+
+ $this->assertStringContainsString('Discount', $html);
+ }
+
+ /**
+ * Test render with tax-inclusive line item shows note.
+ */
+ public function test_render_with_tax_inclusive_shows_note() {
+
+ $line_items = [
+ new Line_Item([
+ 'type' => 'product',
+ 'hash' => 'inclusive_plan',
+ 'title' => 'Tax Inclusive Plan',
+ 'unit_price' => 119.00,
+ 'quantity' => 1,
+ 'taxable' => true,
+ 'tax_rate' => 19.00,
+ 'tax_inclusive' => true,
+ ]),
+ ];
+
+ $payment = $this->create_test_payment(['line_items' => $line_items]);
+ $invoice = new Invoice($payment);
+
+ $html = $invoice->render();
+
+ $this->assertStringContainsString('Tax included in price', $html);
+ }
+}
diff --git a/tests/WP_Ultimo/Managers/Payment_Manager_Test.php b/tests/WP_Ultimo/Managers/Payment_Manager_Test.php
index 5eec503dc..d56ec44e1 100644
--- a/tests/WP_Ultimo/Managers/Payment_Manager_Test.php
+++ b/tests/WP_Ultimo/Managers/Payment_Manager_Test.php
@@ -4,36 +4,60 @@
use WP_Ultimo\Models\Payment;
use WP_Ultimo\Models\Customer;
+use WP_Ultimo\Database\Memberships\Membership_Status;
use WP_Ultimo\Database\Payments\Payment_Status;
use WP_Ultimo\Invoices\Invoice;
use WP_UnitTestCase;
class Payment_Manager_Test extends WP_UnitTestCase {
- private static $customer;
- private static $payment;
+ private static Customer $customer;
+ private static Payment $payment;
private Payment_Manager $payment_manager;
public static function set_up_before_class() {
parent::set_up_before_class();
- // Create a simple payment object for testing
- // We'll use minimal setup to avoid complex dependencies
- self::$payment = new Payment();
- self::$payment->set_customer_id(1);
- self::$payment->set_membership_id(1);
- self::$payment->set_currency('USD');
- self::$payment->set_subtotal(100.00);
- self::$payment->set_total(100.00);
- self::$payment->set_status(Payment_Status::COMPLETED);
- self::$payment->set_gateway('manual');
-
- // Save the payment and generate a hash
- $saved = self::$payment->save();
- if (! $saved) {
- // If save fails, just set a fake hash for testing
- self::$payment->set_hash('test_payment_hash_' . uniqid());
- }
+ self::$customer = wu_create_customer(
+ [
+ 'username' => 'invoicetest',
+ 'email' => 'invoicetest@example.com',
+ 'password' => 'password123',
+ ]
+ );
+
+ $product = wu_create_product(
+ [
+ 'name' => 'Test Plan',
+ 'slug' => 'test-plan-' . wp_generate_uuid4(),
+ 'pricing_type' => 'paid',
+ 'amount' => 100,
+ 'currency' => 'USD',
+ 'recurring' => false,
+ 'type' => 'plan',
+ ]
+ );
+
+ $membership = wu_create_membership(
+ [
+ 'customer_id' => self::$customer->get_id(),
+ 'plan_id' => $product->get_id(),
+ 'status' => Membership_Status::ACTIVE,
+ ]
+ );
+
+ self::$payment = wu_create_payment(
+ [
+ 'customer_id' => self::$customer->get_id(),
+ 'membership_id' => $membership->get_id(),
+ 'product_id' => $product->get_id(),
+ 'currency' => 'USD',
+ 'subtotal' => 100.00,
+ 'total' => 100.00,
+ 'status' => Payment_Status::COMPLETED,
+ 'gateway' => 'manual',
+ ]
+ );
}
public function set_up() {
@@ -42,194 +66,101 @@ public function set_up() {
}
/**
- * Test invoice_viewer method with valid parameters and correct nonce.
- * Since creating a valid payment with proper hash is complex in the test environment,
- * we'll test that the method correctly validates the nonce and fails appropriately.
+ * Test invoice_viewer method with non-existent payment reference.
*/
- public function test_invoice_viewer_with_valid_parameters(): void {
- // Use a test hash that won't be found in the database
- $payment_hash = 'test_payment_hash_12345';
- $nonce = wp_create_nonce('see_invoice');
-
- // Mock the request parameters
+ public function test_invoice_viewer_with_nonexistent_payment(): void {
$_REQUEST['action'] = 'invoice';
- $_REQUEST['reference'] = $payment_hash;
- $_REQUEST['key'] = $nonce;
+ $_REQUEST['reference'] = 'nonexistent_hash';
$reflection = new \ReflectionClass($this->payment_manager);
$method = $reflection->getMethod('invoice_viewer');
- // Only call setAccessible() on PHP < 8.1 where it's needed
if (PHP_VERSION_ID < 80100) {
$method->setAccessible(true);
}
- // The method should pass nonce validation but fail on payment lookup
- // This confirms that our nonce validation logic is working correctly
$this->expectException(\WPDieException::class);
$this->expectExceptionMessage('This invoice does not exist.');
$method->invoke($this->payment_manager);
- // Clean up request parameters
- unset($_REQUEST['action'], $_REQUEST['reference'], $_REQUEST['key']);
+ unset($_REQUEST['action'], $_REQUEST['reference']);
}
/**
- * Test invoice_viewer method with invalid nonce.
+ * Test invoice_viewer denies access to unauthorized users.
*/
- public function test_invoice_viewer_with_invalid_nonce(): void {
- $payment_hash = self::$payment->get_hash();
- $invalid_nonce = 'invalid_nonce';
-
- // Mock the request parameters
+ public function test_invoice_viewer_with_unauthorized_user(): void {
$_REQUEST['action'] = 'invoice';
- $_REQUEST['reference'] = $payment_hash;
- $_REQUEST['key'] = $invalid_nonce;
-
- $reflection = new \ReflectionClass($this->payment_manager);
- $method = $reflection->getMethod('invoice_viewer');
-
- // Only call setAccessible() on PHP < 8.1 where it's needed
- if (PHP_VERSION_ID < 80100) {
- $method->setAccessible(true);
- }
-
- // Expect wp_die to be called with permission error
- $this->expectException(\WPDieException::class);
- $this->expectExceptionMessage('You do not have permissions to access this file.');
-
- $method->invoke($this->payment_manager);
-
- // Clean up request parameters
- unset($_REQUEST['action'], $_REQUEST['reference'], $_REQUEST['key']);
- }
-
- /**
- * Test invoice_viewer method with non-existent payment reference.
- */
- public function test_invoice_viewer_with_nonexistent_payment(): void {
- $invalid_hash = 'nonexistent_hash';
- $nonce = wp_create_nonce('see_invoice');
+ $_REQUEST['reference'] = self::$payment->get_hash();
- // Mock the request parameters
- $_REQUEST['action'] = 'invoice';
- $_REQUEST['reference'] = $invalid_hash;
- $_REQUEST['key'] = $nonce;
+ // Switch to a non-admin user with no customer record.
+ $user_id = self::factory()->user->create(['role' => 'subscriber']);
+ wp_set_current_user($user_id);
$reflection = new \ReflectionClass($this->payment_manager);
$method = $reflection->getMethod('invoice_viewer');
- // Only call setAccessible() on PHP < 8.1 where it's needed
if (PHP_VERSION_ID < 80100) {
$method->setAccessible(true);
}
- // Expect wp_die to be called with invoice not found error
$this->expectException(\WPDieException::class);
- $this->expectExceptionMessage('This invoice does not exist.');
+ $this->expectExceptionMessage('You do not have permissions to access this file.');
$method->invoke($this->payment_manager);
- // Clean up request parameters
- unset($_REQUEST['action'], $_REQUEST['reference'], $_REQUEST['key']);
+ unset($_REQUEST['action'], $_REQUEST['reference']);
}
/**
* Test invoice_viewer method with missing action parameter.
*/
public function test_invoice_viewer_with_missing_action(): void {
- // Don't set action parameter
$_REQUEST['reference'] = self::$payment->get_hash();
- $_REQUEST['key'] = wp_create_nonce('see_invoice');
$reflection = new \ReflectionClass($this->payment_manager);
$method = $reflection->getMethod('invoice_viewer');
- // Only call setAccessible() on PHP < 8.1 where it's needed
if (PHP_VERSION_ID < 80100) {
$method->setAccessible(true);
}
- // Method should return early without doing anything
ob_start();
$method->invoke($this->payment_manager);
$output = ob_get_clean();
$this->assertEmpty($output, 'Method should return early when action parameter is missing');
- // Clean up request parameters
- unset($_REQUEST['reference'], $_REQUEST['key']);
+ unset($_REQUEST['reference']);
}
/**
* Test invoice_viewer method with missing reference parameter.
*/
public function test_invoice_viewer_with_missing_reference(): void {
- // Set action but not reference
$_REQUEST['action'] = 'invoice';
- $_REQUEST['key'] = wp_create_nonce('see_invoice');
$reflection = new \ReflectionClass($this->payment_manager);
$method = $reflection->getMethod('invoice_viewer');
- // Only call setAccessible() on PHP < 8.1 where it's needed
if (PHP_VERSION_ID < 80100) {
$method->setAccessible(true);
}
- // Method should return early without doing anything
ob_start();
$method->invoke($this->payment_manager);
$output = ob_get_clean();
$this->assertEmpty($output, 'Method should return early when reference parameter is missing');
- // Clean up request parameters
- unset($_REQUEST['action'], $_REQUEST['key']);
- }
-
- /**
- * Test invoice_viewer method with missing key parameter.
- */
- public function test_invoice_viewer_with_missing_key(): void {
- // Set action and reference but not key
- $_REQUEST['action'] = 'invoice';
- $_REQUEST['reference'] = self::$payment->get_hash();
-
- $reflection = new \ReflectionClass($this->payment_manager);
- $method = $reflection->getMethod('invoice_viewer');
-
- // Only call setAccessible() on PHP < 8.1 where it's needed
- if (PHP_VERSION_ID < 80100) {
- $method->setAccessible(true);
- }
-
- // Method should return early without doing anything
- ob_start();
- $method->invoke($this->payment_manager);
- $output = ob_get_clean();
-
- $this->assertEmpty($output, 'Method should return early when key parameter is missing');
-
- // Clean up request parameters
- unset($_REQUEST['action'], $_REQUEST['reference']);
+ unset($_REQUEST['action']);
}
public static function tear_down_after_class() {
- global $wpdb;
-
- // Clean up test data
- if (self::$payment) {
- self::$payment->delete();
- }
- if (self::$customer) {
- self::$customer->delete();
- }
- // Clean up database tables
- $wpdb->query("TRUNCATE TABLE {$wpdb->prefix}wu_payments");
- $wpdb->query("TRUNCATE TABLE {$wpdb->prefix}wu_customers");
+ self::$payment->delete();
+ self::$customer->delete();
parent::tear_down_after_class();
}
diff --git a/tests/WP_Ultimo/Models/Payment_Test.php b/tests/WP_Ultimo/Models/Payment_Test.php
index 04bedda7f..f3128795d 100644
--- a/tests/WP_Ultimo/Models/Payment_Test.php
+++ b/tests/WP_Ultimo/Models/Payment_Test.php
@@ -1356,7 +1356,6 @@ public function test_invoice_url_contains_reference_and_key(): void {
$this->assertIsString($url);
$this->assertStringContainsString('action=invoice', $url);
$this->assertStringContainsString('reference=', $url);
- $this->assertStringContainsString('key=', $url);
}
/**
diff --git a/tests/e2e/cypress/fixtures/advance-stripe-test-clock.php b/tests/e2e/cypress/fixtures/advance-stripe-test-clock.php
new file mode 100644
index 000000000..a7d65e7ac
--- /dev/null
+++ b/tests/e2e/cypress/fixtures/advance-stripe-test-clock.php
@@ -0,0 +1,51 @@
+
+ */
+
+$sk_key = isset($args[0]) ? $args[0] : '';
+$clock_id = isset($args[1]) ? $args[1] : '';
+$target_timestamp = isset($args[2]) ? (int) $args[2] : 0;
+
+if (empty($sk_key) || empty($clock_id) || empty($target_timestamp)) {
+ echo wp_json_encode(['error' => 'Missing arguments. Expected: sk_key, clock_id, target_timestamp']);
+ return;
+}
+
+try {
+ $stripe = new \Stripe\StripeClient($sk_key);
+
+ // Advance the test clock
+ $stripe->testHelpers->testClocks->advance($clock_id, [
+ 'frozen_time' => $target_timestamp,
+ ]);
+
+ // Poll until clock status is "ready" (max 60s)
+ $max_attempts = 30;
+ $status = 'advancing';
+
+ for ($i = 0; $i < $max_attempts; $i++) {
+ sleep(2);
+
+ $clock = $stripe->testHelpers->testClocks->retrieve($clock_id);
+ $status = $clock->status;
+
+ if ($status === 'ready') {
+ break;
+ }
+ }
+
+ echo wp_json_encode([
+ 'success' => $status === 'ready',
+ 'status' => $status,
+ 'frozen_time' => $clock->frozen_time ?? null,
+ 'clock_id' => $clock_id,
+ ]);
+} catch (\Exception $e) {
+ echo wp_json_encode([
+ 'error' => $e->getMessage(),
+ 'code' => $e->getCode(),
+ ]);
+}
diff --git a/tests/e2e/cypress/fixtures/cleanup-stripe-test-clock.php b/tests/e2e/cypress/fixtures/cleanup-stripe-test-clock.php
new file mode 100644
index 000000000..112157714
--- /dev/null
+++ b/tests/e2e/cypress/fixtures/cleanup-stripe-test-clock.php
@@ -0,0 +1,31 @@
+
+ */
+
+$sk_key = isset($args[0]) ? $args[0] : '';
+$clock_id = isset($args[1]) ? $args[1] : '';
+
+if (empty($sk_key) || empty($clock_id)) {
+ echo wp_json_encode(['error' => 'Missing arguments. Expected: sk_key, clock_id']);
+ return;
+}
+
+try {
+ $stripe = new \Stripe\StripeClient($sk_key);
+ $stripe->testHelpers->testClocks->delete($clock_id);
+
+ echo wp_json_encode([
+ 'success' => true,
+ 'clock_id' => $clock_id,
+ 'deleted' => true,
+ ]);
+} catch (\Exception $e) {
+ echo wp_json_encode([
+ 'success' => false,
+ 'error' => $e->getMessage(),
+ 'clock_id' => $clock_id,
+ ]);
+}
diff --git a/tests/e2e/cypress/fixtures/process-stripe-renewal.php b/tests/e2e/cypress/fixtures/process-stripe-renewal.php
new file mode 100644
index 000000000..f62e51b52
--- /dev/null
+++ b/tests/e2e/cypress/fixtures/process-stripe-renewal.php
@@ -0,0 +1,144 @@
+
+ */
+
+$sk_key = isset($args[0]) ? $args[0] : '';
+$subscription_id = isset($args[1]) ? $args[1] : '';
+$membership_id = isset($args[2]) ? (int) $args[2] : 0;
+
+if (empty($sk_key) || empty($subscription_id) || empty($membership_id)) {
+ echo wp_json_encode(['error' => 'Missing arguments. Expected: sk_key, subscription_id, membership_id']);
+ return;
+}
+
+try {
+ $stripe = new \Stripe\StripeClient($sk_key);
+
+ // 1. Retrieve subscription to get current period info
+ $subscription = $stripe->subscriptions->retrieve($subscription_id);
+
+ // 2. List invoices for this subscription and find the renewal invoice
+ $invoices = $stripe->invoices->all([
+ 'subscription' => $subscription_id,
+ 'limit' => 10,
+ ]);
+
+ $renewal_invoice = null;
+
+ foreach ($invoices->data as $invoice) {
+ if ($invoice->billing_reason === 'subscription_cycle') {
+ $renewal_invoice = $invoice;
+ break;
+ }
+ }
+
+ if (! $renewal_invoice) {
+ echo wp_json_encode([
+ 'error' => 'No renewal invoice found.',
+ 'invoice_count' => count($invoices->data),
+ 'billing_reasons' => array_map(function($inv) {
+ return $inv->billing_reason;
+ }, $invoices->data),
+ ]);
+ return;
+ }
+
+ // 3. If the renewal invoice is still open (test clock timing), pay it explicitly
+ if ($renewal_invoice->status === 'open' || $renewal_invoice->status === 'draft') {
+ if ($renewal_invoice->status === 'draft') {
+ $renewal_invoice = $stripe->invoices->finalizeInvoice($renewal_invoice->id);
+ }
+
+ $renewal_invoice = $stripe->invoices->pay($renewal_invoice->id);
+ }
+
+ // 4. Get charge/payment identifier from the paid renewal invoice
+ // Test clock invoices don't link charge/payment_intent, so fall back to invoice ID
+ $charge_id = $renewal_invoice->charge ?? $renewal_invoice->payment_intent ?? $renewal_invoice->id;
+
+ // 5. Calculate expiration using the same formula as the webhook handler
+ // (class-base-stripe-gateway.php lines 2546-2567)
+ $end_timestamp = null;
+
+ foreach ($subscription->items->data as $item) {
+ $end_timestamp = $item->current_period_end;
+ break;
+ }
+
+ if (! $end_timestamp) {
+ $end_timestamp = $subscription->current_period_end;
+ }
+
+ $renewal_date = new \DateTime();
+ $renewal_date->setTimestamp($end_timestamp);
+ $renewal_date->setTime(23, 59, 59);
+
+ $stripe_estimated_charge_timestamp = $end_timestamp + (2 * HOUR_IN_SECONDS);
+
+ if ($stripe_estimated_charge_timestamp > $renewal_date->getTimestamp()) {
+ $renewal_date->setTimestamp($stripe_estimated_charge_timestamp);
+ }
+
+ $expiration = $renewal_date->format('Y-m-d H:i:s');
+
+ // 6. Get membership and customer
+ $membership = wu_get_membership($membership_id);
+
+ if (! $membership) {
+ echo wp_json_encode(['error' => 'Membership not found: ' . $membership_id]);
+ return;
+ }
+
+ $customer_id = $membership->get_customer_id();
+
+ // 7. Get invoice total (convert from Stripe cents)
+ $currency_multiplier = function_exists('wu_stripe_get_currency_multiplier')
+ ? wu_stripe_get_currency_multiplier()
+ : 100;
+
+ $total = $renewal_invoice->amount_paid / $currency_multiplier;
+
+ // 8. Create renewal payment
+ $payment = wu_create_payment([
+ 'customer_id' => $customer_id,
+ 'membership_id' => $membership_id,
+ 'status' => 'completed',
+ 'gateway' => 'stripe',
+ 'gateway_payment_id' => (string) $charge_id,
+ 'subtotal' => $total,
+ 'total' => $total,
+ 'currency' => strtoupper($renewal_invoice->currency),
+ ]);
+
+ if (is_wp_error($payment)) {
+ echo wp_json_encode(['error' => 'Failed to create payment: ' . $payment->get_error_message()]);
+ return;
+ }
+
+ // 9. Renew membership (replicate webhook handler logic)
+ $membership->add_to_times_billed(1);
+ $membership->renew($membership->is_recurring(), 'active', $expiration);
+
+ echo wp_json_encode([
+ 'success' => true,
+ 'renewal_invoice_id' => $renewal_invoice->id,
+ 'charge_id' => $charge_id,
+ 'renewal_payment_id' => $payment->get_id(),
+ 'renewal_total' => $total,
+ 'new_expiration' => $expiration,
+ 'new_times_billed' => $membership->get_times_billed(),
+ 'membership_status' => $membership->get_status(),
+ 'current_period_end' => $end_timestamp,
+ ]);
+} catch (\Exception $e) {
+ echo wp_json_encode([
+ 'error' => $e->getMessage(),
+ 'code' => $e->getCode(),
+ ]);
+}
diff --git a/tests/e2e/cypress/fixtures/setup-stripe-gateway.php b/tests/e2e/cypress/fixtures/setup-stripe-gateway.php
new file mode 100644
index 000000000..c3c340a0d
--- /dev/null
+++ b/tests/e2e/cypress/fixtures/setup-stripe-gateway.php
@@ -0,0 +1,42 @@
+
+ */
+
+$pk_key = isset($args[0]) ? $args[0] : '';
+$sk_key = isset($args[1]) ? $args[1] : '';
+
+if (empty($pk_key) || empty($sk_key)) {
+ echo wp_json_encode(['error' => 'Missing Stripe test keys. Pass pk_key and sk_key as arguments.']);
+ return;
+}
+
+// Enable sandbox mode
+wu_save_setting('stripe_sandbox_mode', true);
+
+// Set test keys
+wu_save_setting('stripe_test_pk_key', $pk_key);
+wu_save_setting('stripe_test_sk_key', $sk_key);
+
+// Show direct keys (not OAuth)
+wu_save_setting('stripe_show_direct_keys', true);
+
+// Add stripe to active gateways while keeping existing ones
+$active_gateways = (array) wu_get_setting('active_gateways', []);
+
+if (!in_array('stripe', $active_gateways, true)) {
+ $active_gateways[] = 'stripe';
+}
+
+wu_save_setting('active_gateways', $active_gateways);
+
+echo wp_json_encode(
+ [
+ 'success' => true,
+ 'active_gateways' => wu_get_setting('active_gateways', []),
+ 'sandbox_mode' => wu_get_setting('stripe_sandbox_mode'),
+ 'pk_key_set' => !empty(wu_get_setting('stripe_test_pk_key')),
+ 'sk_key_set' => !empty(wu_get_setting('stripe_test_sk_key')),
+ ]
+);
diff --git a/tests/e2e/cypress/fixtures/setup-stripe-renewal-test.php b/tests/e2e/cypress/fixtures/setup-stripe-renewal-test.php
new file mode 100644
index 000000000..dabcdbabd
--- /dev/null
+++ b/tests/e2e/cypress/fixtures/setup-stripe-renewal-test.php
@@ -0,0 +1,190 @@
+
+ */
+
+$sk_key = isset($args[0]) ? $args[0] : '';
+
+if (empty($sk_key)) {
+ echo wp_json_encode(['error' => 'Missing Stripe secret key.']);
+ return;
+}
+
+try {
+ $stripe = new \Stripe\StripeClient($sk_key);
+
+ // 1. Create Test Clock frozen at now
+ $now = time();
+ $test_clock = $stripe->testHelpers->testClocks->create(['frozen_time' => $now]);
+
+ // 2. Create Stripe Customer attached to test clock
+ $s_customer = $stripe->customers->create([
+ 'name' => 'UM Renewal Test ' . $now,
+ 'email' => 'renewal-test-' . $now . '@test.com',
+ 'test_clock' => $test_clock->id,
+ ]);
+
+ // 3. Create Stripe Product + Price ($29.99/month)
+ $s_product = $stripe->products->create([
+ 'name' => 'UM Renewal Test Plan ' . $now,
+ ]);
+
+ $s_price = $stripe->prices->create([
+ 'product' => $s_product->id,
+ 'unit_amount' => 2999,
+ 'currency' => 'usd',
+ 'recurring' => ['interval' => 'month'],
+ ]);
+
+ // 4. Create PaymentMethod and attach to customer
+ $pm = $stripe->paymentMethods->create([
+ 'type' => 'card',
+ 'card' => ['token' => 'tok_visa'],
+ ]);
+
+ $stripe->paymentMethods->attach($pm->id, ['customer' => $s_customer->id]);
+
+ $stripe->customers->update($s_customer->id, [
+ 'invoice_settings' => ['default_payment_method' => $pm->id],
+ ]);
+
+ // 5. Create Subscription
+ $subscription = $stripe->subscriptions->create([
+ 'customer' => $s_customer->id,
+ 'items' => [['price' => $s_price->id]],
+ 'default_payment_method' => $pm->id,
+ ]);
+
+ $current_period_end = $subscription->items->data[0]->current_period_end ?? $subscription->current_period_end;
+
+ // 6. Create local UM records
+
+ // Create a WP user for the customer
+ $username = 'renewaltest' . $now;
+ $email = 'renewal-test-' . $now . '@test.com';
+ $user_id = wpmu_create_user($username, 'TestPassword123!', $email);
+
+ if (! $user_id) {
+ echo wp_json_encode(['error' => 'Failed to create WP user.']);
+ return;
+ }
+
+ // Get or create the test product (reuse existing "Test Plan" or create one)
+ $products = WP_Ultimo\Models\Product::query([
+ 'number' => 1,
+ 'search' => 'Test Plan',
+ ]);
+
+ if (! empty($products)) {
+ $product = $products[0];
+ } else {
+ $product = new WP_Ultimo\Models\Product();
+ $product->set_name('Test Plan');
+ $product->set_slug('test-plan-renewal');
+ $product->set_amount(29.99);
+ $product->set_duration(1);
+ $product->set_duration_unit('month');
+ $product->set_type('plan');
+ $product->set_active(true);
+ $product->save();
+ }
+
+ // Create UM Customer
+ $customer = wu_create_customer([
+ 'user_id' => $user_id,
+ 'email' => $email,
+ 'username' => $username,
+ ]);
+
+ if (is_wp_error($customer)) {
+ echo wp_json_encode(['error' => 'Failed to create customer: ' . $customer->get_error_message()]);
+ return;
+ }
+
+ // Calculate expiration from Stripe's period end (same formula as webhook handler)
+ $renewal_date = new \DateTime();
+ $renewal_date->setTimestamp($current_period_end);
+ $renewal_date->setTime(23, 59, 59);
+
+ $stripe_estimated_charge = $current_period_end + (2 * HOUR_IN_SECONDS);
+
+ if ($stripe_estimated_charge > $renewal_date->getTimestamp()) {
+ $renewal_date->setTimestamp($stripe_estimated_charge);
+ }
+
+ $expiration = $renewal_date->format('Y-m-d H:i:s');
+
+ // Create UM Membership
+ $membership = wu_create_membership([
+ 'customer_id' => $customer->get_id(),
+ 'plan_id' => $product->get_id(),
+ 'status' => 'active',
+ 'gateway' => 'stripe',
+ 'gateway_customer_id' => $s_customer->id,
+ 'gateway_subscription_id' => $subscription->id,
+ 'amount' => 29.99,
+ 'recurring' => true,
+ 'auto_renew' => true,
+ 'duration' => 1,
+ 'duration_unit' => 'month',
+ 'times_billed' => 1,
+ 'date_expiration' => $expiration,
+ 'currency' => 'USD',
+ ]);
+
+ if (is_wp_error($membership)) {
+ echo wp_json_encode(['error' => 'Failed to create membership: ' . $membership->get_error_message()]);
+ return;
+ }
+
+ // Create initial Payment (completed)
+ $initial_invoice = $subscription->latest_invoice;
+
+ if (is_string($initial_invoice)) {
+ $invoice_obj = $stripe->invoices->retrieve($initial_invoice);
+ $gateway_pay_id = $invoice_obj->charge ?? $invoice_obj->payment_intent ?? $initial_invoice;
+ } else {
+ $gateway_pay_id = $initial_invoice->charge ?? $initial_invoice->payment_intent ?? '';
+ }
+
+ $payment = wu_create_payment([
+ 'customer_id' => $customer->get_id(),
+ 'membership_id' => $membership->get_id(),
+ 'status' => 'completed',
+ 'gateway' => 'stripe',
+ 'gateway_payment_id' => is_object($gateway_pay_id) ? $gateway_pay_id->id : (string) $gateway_pay_id,
+ 'subtotal' => 29.99,
+ 'total' => 29.99,
+ 'currency' => 'USD',
+ ]);
+
+ if (is_wp_error($payment)) {
+ echo wp_json_encode(['error' => 'Failed to create payment: ' . $payment->get_error_message()]);
+ return;
+ }
+
+ echo wp_json_encode([
+ 'success' => true,
+ 'test_clock_id' => $test_clock->id,
+ 'stripe_customer_id' => $s_customer->id,
+ 'subscription_id' => $subscription->id,
+ 'stripe_product_id' => $s_product->id,
+ 'stripe_price_id' => $s_price->id,
+ 'current_period_end' => $current_period_end,
+ 'um_customer_id' => $customer->get_id(),
+ 'um_membership_id' => $membership->get_id(),
+ 'um_payment_id' => $payment->get_id(),
+ 'initial_times_billed' => $membership->get_times_billed(),
+ 'expiration' => $expiration,
+ ]);
+} catch (\Exception $e) {
+ echo wp_json_encode([
+ 'error' => $e->getMessage(),
+ 'code' => $e->getCode(),
+ ]);
+}
diff --git a/tests/e2e/cypress/fixtures/verify-stripe-checkout-results.php b/tests/e2e/cypress/fixtures/verify-stripe-checkout-results.php
new file mode 100644
index 000000000..cd9c25e24
--- /dev/null
+++ b/tests/e2e/cypress/fixtures/verify-stripe-checkout-results.php
@@ -0,0 +1,49 @@
+ 1,
+ 'orderby' => 'id',
+ 'order' => 'DESC',
+ ]
+);
+$um_payment_status = $payments ? $payments[0]->get_status() : 'no-payments';
+$um_payment_gateway = $payments ? $payments[0]->get_gateway() : 'none';
+$um_payment_total = $payments ? (float) $payments[0]->get_total() : 0;
+$gateway_payment_id = $payments ? $payments[0]->get_gateway_payment_id() : '';
+
+// UM membership (most recent)
+$memberships = WP_Ultimo\Models\Membership::query(
+ [
+ 'number' => 1,
+ 'orderby' => 'id',
+ 'order' => 'DESC',
+ ]
+);
+$um_membership_status = $memberships ? $memberships[0]->get_status() : 'no-memberships';
+$gateway_customer_id = $memberships ? $memberships[0]->get_gateway_customer_id() : '';
+$gateway_subscription_id = $memberships ? $memberships[0]->get_gateway_subscription_id() : '';
+
+// UM sites
+$sites = WP_Ultimo\Models\Site::query(['type__in' => ['customer_owned']]);
+$um_site_count = count($sites);
+$um_site_type = $sites ? $sites[0]->get_type() : 'no-sites';
+
+echo wp_json_encode(
+ [
+ 'um_payment_status' => $um_payment_status,
+ 'um_payment_gateway' => $um_payment_gateway,
+ 'um_payment_total' => $um_payment_total,
+ 'um_membership_status' => $um_membership_status,
+ 'um_site_count' => $um_site_count,
+ 'um_site_type' => $um_site_type,
+ 'gateway_payment_id' => $gateway_payment_id,
+ 'gateway_customer_id' => $gateway_customer_id,
+ 'gateway_subscription_id' => $gateway_subscription_id,
+ ]
+);
diff --git a/tests/e2e/cypress/fixtures/verify-stripe-renewal-results.php b/tests/e2e/cypress/fixtures/verify-stripe-renewal-results.php
new file mode 100644
index 000000000..9a409bc55
--- /dev/null
+++ b/tests/e2e/cypress/fixtures/verify-stripe-renewal-results.php
@@ -0,0 +1,54 @@
+
+ */
+
+$membership_id = isset($args[0]) ? (int) $args[0] : 0;
+
+if (empty($membership_id)) {
+ echo wp_json_encode(['error' => 'Missing membership_id argument.']);
+ return;
+}
+
+$membership = wu_get_membership($membership_id);
+
+if (! $membership) {
+ echo wp_json_encode(['error' => 'Membership not found: ' . $membership_id]);
+ return;
+}
+
+// Get all payments for this membership, ordered by ID ascending
+$payments = WP_Ultimo\Models\Payment::query([
+ 'membership_id' => $membership_id,
+ 'orderby' => 'id',
+ 'order' => 'ASC',
+ 'number' => 10,
+]);
+
+$payment_details = [];
+
+if ($payments) {
+ foreach ($payments as $p) {
+ $payment_details[] = [
+ 'id' => $p->get_id(),
+ 'status' => $p->get_status(),
+ 'total' => (float) $p->get_total(),
+ 'gateway' => $p->get_gateway(),
+ 'gateway_payment_id' => $p->get_gateway_payment_id(),
+ ];
+ }
+}
+
+echo wp_json_encode([
+ 'membership_id' => $membership->get_id(),
+ 'membership_status' => $membership->get_status(),
+ 'membership_expiration' => $membership->get_date_expiration(),
+ 'times_billed' => $membership->get_times_billed(),
+ 'gateway' => $membership->get_gateway(),
+ 'recurring' => $membership->is_recurring(),
+ 'auto_renew' => $membership->should_auto_renew(),
+ 'payment_count' => count($payment_details),
+ 'payments' => $payment_details,
+]);
diff --git a/tests/e2e/cypress/integration/030-stripe-checkout-flow.spec.js b/tests/e2e/cypress/integration/030-stripe-checkout-flow.spec.js
new file mode 100644
index 000000000..a1800fa3f
--- /dev/null
+++ b/tests/e2e/cypress/integration/030-stripe-checkout-flow.spec.js
@@ -0,0 +1,137 @@
+describe("Stripe Gateway Checkout Flow", () => {
+ const timestamp = Date.now();
+ const customerData = {
+ username: `stripecust${timestamp}`,
+ email: `stripecust${timestamp}@test.com`,
+ password: "TestPassword123!",
+ };
+ const siteData = {
+ title: "Stripe Test Site",
+ path: `stripesite${timestamp}`,
+ };
+
+ before(() => {
+ const pkKey = Cypress.env("STRIPE_TEST_PK_KEY");
+ const skKey = Cypress.env("STRIPE_TEST_SK_KEY");
+
+ if (!pkKey || !skKey) {
+ throw new Error(
+ "Skipping Stripe tests: STRIPE_TEST_PK_KEY and STRIPE_TEST_SK_KEY env vars are required"
+ );
+ }
+
+ cy.loginByForm(
+ Cypress.env("admin").username,
+ Cypress.env("admin").password
+ );
+
+ // Enable Stripe gateway with test keys
+ cy.exec(
+ `npx wp-env run tests-cli wp eval-file /var/www/html/wp-content/plugins/ultimate-multisite/tests/e2e/cypress/fixtures/setup-stripe-gateway.php '${pkKey}' '${skKey}'`,
+ { timeout: 60000 }
+ ).then((result) => {
+ const data = JSON.parse(result.stdout.trim());
+ cy.log(`Stripe setup: ${JSON.stringify(data)}`);
+ expect(data.success).to.equal(true);
+ });
+ });
+
+ it("Should complete checkout with Stripe payment", {
+ retries: 0,
+ }, () => {
+ cy.clearCookies();
+ cy.visit("/register", { failOnStatusCode: false });
+
+ // Wait for checkout form to render
+ cy.get("#field-email_address", { timeout: 30000 }).should(
+ "be.visible"
+ );
+ cy.wait(3000);
+
+ // Select the plan (first non-trial plan)
+ cy.get('#wrapper-field-pricing_table label[id^="wu-product-"]', {
+ timeout: 15000,
+ })
+ .contains("Test Plan")
+ .click();
+
+ cy.wait(3000);
+
+ // Fill account details
+ cy.get("#field-email_address").clear().type(customerData.email);
+ cy.get("#field-username").should("be.visible").clear().type(customerData.username);
+ cy.get("#field-password").should("be.visible").clear().type(customerData.password);
+
+ cy.get("body").then(($body) => {
+ if ($body.find("#field-password_conf").length > 0) {
+ cy.get("#field-password_conf").clear().type(customerData.password);
+ }
+ });
+
+ // Fill site details
+ cy.get("#field-site_title").should("be.visible").clear().type(siteData.title);
+ cy.get("#field-site_url").should("be.visible").clear().type(siteData.path);
+
+ // Select Stripe gateway
+ cy.get('input[type="radio"][name="gateway"][value="stripe"]').check({ force: true });
+
+ // Set billing address via Vue model (fields are hidden when Stripe is selected,
+ // but values are still sent to server and passed to Stripe's confirmPayment)
+ cy.window().then((win) => {
+ if (win.wu_checkout_form) {
+ win.wu_checkout_form.country = "US";
+ }
+ });
+ // Also set the zip code DOM value for form serialization
+ cy.get("body").then(($body) => {
+ if ($body.find("#field-billing_zip_code").length > 0) {
+ $body.find("#field-billing_zip_code").val("94105");
+ } else if ($body.find('[name="billing_address[billing_zip_code]"]').length > 0) {
+ $body.find('[name="billing_address[billing_zip_code]"]').val("94105");
+ }
+ });
+
+ // Wait for Stripe Payment Element iframe to load
+ cy.get("#payment-element iframe", { timeout: 30000 }).should("exist");
+ cy.wait(2000); // Give Payment Element time to fully render
+
+ // Fill Stripe card details inside the iframe
+ cy.fillStripeCard();
+
+ // Submit the checkout form
+ cy.get(
+ '#wrapper-field-checkout button[type="submit"], button.wu-checkout-submit, #field-checkout, button[type="submit"]',
+ { timeout: 10000 }
+ )
+ .filter(":visible")
+ .last()
+ .click();
+
+ // Stripe adds network roundtrips, so use a longer timeout
+ cy.url({ timeout: 90000 }).should("include", "status=done");
+ });
+
+ it("Should verify Stripe payment state via WP-CLI", () => {
+ cy.wpCliFile(
+ "tests/e2e/cypress/fixtures/verify-stripe-checkout-results.php"
+ ).then((result) => {
+ const data = JSON.parse(result.stdout.trim());
+ cy.log(`Stripe results: ${JSON.stringify(data)}`);
+
+ // Payment should be completed with Stripe gateway
+ expect(data.um_payment_status).to.equal("completed");
+ expect(data.um_payment_gateway).to.equal("stripe");
+ expect(data.um_payment_total).to.be.greaterThan(0);
+
+ // Membership should be active
+ expect(data.um_membership_status).to.equal("active");
+
+ // Site should exist
+ expect(data.um_site_type).to.equal("customer_owned");
+
+ // Stripe IDs should be populated
+ expect(data.gateway_payment_id).to.not.be.empty;
+ expect(data.gateway_customer_id).to.not.be.empty;
+ });
+ });
+});
diff --git a/tests/e2e/cypress/integration/040-stripe-renewal-flow.spec.js b/tests/e2e/cypress/integration/040-stripe-renewal-flow.spec.js
new file mode 100644
index 000000000..16b951e56
--- /dev/null
+++ b/tests/e2e/cypress/integration/040-stripe-renewal-flow.spec.js
@@ -0,0 +1,162 @@
+/**
+ * Stripe Subscription Renewal Flow
+ *
+ * Tests that subscription renewals work correctly using Stripe Test Clocks.
+ * Creates a subscription server-side, advances the test clock past the
+ * billing cycle, then processes the renewal and verifies the results.
+ *
+ * Requires STRIPE_TEST_PK_KEY and STRIPE_TEST_SK_KEY environment variables.
+ */
+describe("Stripe Subscription Renewal Flow", () => {
+ const CONTAINER_FIXTURES =
+ "/var/www/html/wp-content/plugins/ultimate-multisite/tests/e2e/cypress/fixtures";
+
+ let skKey;
+ let setupData = {};
+
+ before(() => {
+ const pkKey = Cypress.env("STRIPE_TEST_PK_KEY");
+ skKey = Cypress.env("STRIPE_TEST_SK_KEY");
+
+ if (!pkKey || !skKey) {
+ throw new Error(
+ "Skipping Stripe renewal tests: STRIPE_TEST_PK_KEY and STRIPE_TEST_SK_KEY env vars are required"
+ );
+ }
+
+ // Ensure Stripe gateway is configured
+ cy.exec(
+ `npx wp-env run tests-cli wp eval-file ${CONTAINER_FIXTURES}/setup-stripe-gateway.php '${pkKey}' '${skKey}'`,
+ { timeout: 60000 }
+ ).then((result) => {
+ const data = JSON.parse(result.stdout.trim());
+ expect(data.success).to.equal(true);
+ });
+ });
+
+ it(
+ "Should create subscription with Test Clock",
+ { retries: 0 },
+ () => {
+ cy.exec(
+ `npx wp-env run tests-cli wp eval-file ${CONTAINER_FIXTURES}/setup-stripe-renewal-test.php '${skKey}'`,
+ { timeout: 120000 }
+ ).then((result) => {
+ const data = JSON.parse(result.stdout.trim());
+ cy.log(`Setup result: ${JSON.stringify(data)}`);
+
+ expect(data.success).to.equal(true);
+ expect(data.test_clock_id).to.match(/^clock_/);
+ expect(data.subscription_id).to.match(/^sub_/);
+ expect(data.initial_times_billed).to.equal(1);
+
+ // Verify current_period_end is roughly 1 month from now
+ const nowSec = Math.floor(Date.now() / 1000);
+ const periodEnd = data.current_period_end;
+ const diffDays = (periodEnd - nowSec) / 86400;
+ expect(diffDays).to.be.within(25, 35);
+
+ // Store data for subsequent tests
+ setupData = data;
+ });
+ }
+ );
+
+ it(
+ "Should advance clock past renewal date",
+ { retries: 0 },
+ () => {
+ expect(setupData.test_clock_id).to.exist;
+ expect(setupData.current_period_end).to.exist;
+
+ // Advance to 1 day past the billing period end
+ const targetTimestamp = setupData.current_period_end + 86400;
+
+ cy.exec(
+ `npx wp-env run tests-cli wp eval-file ${CONTAINER_FIXTURES}/advance-stripe-test-clock.php '${skKey}' '${setupData.test_clock_id}' '${targetTimestamp}'`,
+ { timeout: 180000 }
+ ).then((result) => {
+ const data = JSON.parse(result.stdout.trim());
+ cy.log(`Advance result: ${JSON.stringify(data)}`);
+
+ expect(data.success).to.equal(true);
+ expect(data.status).to.equal("ready");
+ });
+ }
+ );
+
+ it(
+ "Should process renewal",
+ { retries: 0 },
+ () => {
+ expect(setupData.subscription_id).to.exist;
+ expect(setupData.um_membership_id).to.exist;
+
+ cy.exec(
+ `npx wp-env run tests-cli wp eval-file ${CONTAINER_FIXTURES}/process-stripe-renewal.php '${skKey}' '${setupData.subscription_id}' '${setupData.um_membership_id}'`,
+ { timeout: 120000 }
+ ).then((result) => {
+ const data = JSON.parse(result.stdout.trim());
+ cy.log(`Renewal result: ${JSON.stringify(data)}`);
+
+ expect(data.success).to.equal(true);
+ expect(data.renewal_invoice_id).to.not.be.empty;
+ expect(data.new_times_billed).to.equal(2);
+ expect(data.membership_status).to.equal("active");
+
+ // Verify the new expiration is roughly 1 month after the old one
+ const newPeriodEnd = data.current_period_end;
+ const oldPeriodEnd = setupData.current_period_end;
+ const diffDays =
+ (newPeriodEnd - oldPeriodEnd) / 86400;
+ expect(diffDays).to.be.within(25, 35);
+ });
+ }
+ );
+
+ it("Should verify renewal state", () => {
+ expect(setupData.um_membership_id).to.exist;
+
+ cy.exec(
+ `npx wp-env run tests-cli wp eval-file ${CONTAINER_FIXTURES}/verify-stripe-renewal-results.php '${setupData.um_membership_id}'`,
+ { timeout: 60000 }
+ ).then((result) => {
+ const data = JSON.parse(result.stdout.trim());
+ cy.log(`Verify result: ${JSON.stringify(data)}`);
+
+ // Should have 2 payments total (initial + renewal)
+ expect(data.payment_count).to.equal(2);
+
+ // Initial payment
+ expect(data.payments[0].status).to.equal("completed");
+ expect(data.payments[0].gateway).to.equal("stripe");
+
+ // Renewal payment
+ expect(data.payments[1].status).to.equal("completed");
+ expect(data.payments[1].gateway).to.equal("stripe");
+ expect(data.payments[1].gateway_payment_id).to.not.be.empty;
+
+ // Membership should be active with correct state
+ expect(data.membership_status).to.equal("active");
+ expect(data.times_billed).to.equal(2);
+ expect(data.gateway).to.equal("stripe");
+ expect(data.recurring).to.equal(true);
+ });
+ });
+
+ after(() => {
+ if (setupData.test_clock_id) {
+ cy.exec(
+ `npx wp-env run tests-cli wp eval-file ${CONTAINER_FIXTURES}/cleanup-stripe-test-clock.php '${skKey}' '${setupData.test_clock_id}'`,
+ { timeout: 60000, failOnNonZeroExit: false }
+ ).then((result) => {
+ try {
+ const data = JSON.parse(result.stdout.trim());
+ cy.log(`Cleanup result: ${JSON.stringify(data)}`);
+ } catch (e) {
+ cy.log("Cleanup output: " + result.stdout);
+ }
+ });
+ }
+ });
+});
diff --git a/tests/e2e/cypress/support/commands/index.js b/tests/e2e/cypress/support/commands/index.js
index 8cf5f5508..46b4f7e24 100644
--- a/tests/e2e/cypress/support/commands/index.js
+++ b/tests/e2e/cypress/support/commands/index.js
@@ -70,3 +70,81 @@ Cypress.Commands.add("blockAutosaves", () => {
}
}).as("adminAjax");
});
+
+/**
+ * Fill Stripe Payment Element card details inside its iframe.
+ * Uses test card 4242424242424242, exp 12/30, CVC 123.
+ * Must use explicit delay since Stripe's input masking needs time per keystroke
+ * (the global type override sets delay: 0 which breaks Stripe inputs).
+ */
+Cypress.Commands.add("fillStripeCard", () => {
+ // Helper to get an iframe body
+ const getIframeBody = (selector) => {
+ return cy
+ .get(selector, { timeout: 30000 })
+ .its("0.contentDocument.body")
+ .should("not.be.empty")
+ .then(cy.wrap);
+ };
+
+ // Stripe Payment Element can render as a single iframe or multiple iframes.
+ // Check which layout we have.
+ cy.get("#payment-element").then(($el) => {
+ const iframes = $el.find("iframe");
+
+ if (iframes.length >= 3) {
+ // Multi-iframe layout: separate iframes for number, expiry, cvc
+ getIframeBody("#payment-element iframe:eq(0)").within(() => {
+ cy.get('input[name="number"], input[name="cardnumber"]')
+ .first()
+ .type("4242424242424242", { delay: 50 });
+ });
+
+ getIframeBody("#payment-element iframe:eq(1)").within(() => {
+ cy.get('input[name="expiry"], input[name="exp-date"]')
+ .first()
+ .type("1230", { delay: 50 });
+ });
+
+ getIframeBody("#payment-element iframe:eq(2)").within(() => {
+ cy.get('input[name="cvc"]')
+ .first()
+ .type("123", { delay: 50 });
+ });
+
+ // Postal code may be in a 4th iframe or within one of the above
+ cy.get("#payment-element").then(($el2) => {
+ if ($el2.find("iframe").length > 3) {
+ getIframeBody("#payment-element iframe:eq(3)").within(() => {
+ cy.get('input[name="postalCode"], input[name="postal"]')
+ .first()
+ .type("94105", { delay: 50 });
+ });
+ }
+ });
+ } else {
+ // Single iframe layout (most common with Payment Element)
+ getIframeBody("#payment-element iframe").within(() => {
+ cy.get('input[name="number"], input[name="cardnumber"]')
+ .first()
+ .type("4242424242424242", { delay: 50 });
+
+ cy.get('input[name="expiry"], input[name="exp-date"]')
+ .first()
+ .type("1230", { delay: 50 });
+
+ cy.get('input[name="cvc"]')
+ .first()
+ .type("123", { delay: 50 });
+
+ // Fill postal/ZIP code if present in the Payment Element
+ cy.root().then(($body) => {
+ const postalInput = $body.find('input[name="postalCode"], input[name="postal"]');
+ if (postalInput.length > 0) {
+ cy.wrap(postalInput.first()).type("94105", { delay: 50 });
+ }
+ });
+ });
+ }
+ });
+});