confirmed to belong * to a different card does the request fall through to mint a fresh * mandate_options payload for the card actually being charged. */ $renewals = wcs_get_subscriptions_for_renewal_order( $order ); $subscription = ( 1 === count( $renewals ) ) ? reset( $renewals ) : false; $current_source = ( $subscription instanceof \WC_Subscription ) ? Helper::get_meta( $subscription, '_fkwcs_source_id' ) : ''; $mandate_candidates = array( $order->get_meta( '_stripe_mandate_id', true ) ); if ( $subscription instanceof \WC_Subscription ) { $mandate_candidates[] = $subscription->get_meta( '_stripe_mandate_id', true ); $parent_order = $subscription->get_parent(); if ( $parent_order instanceof \WC_Order ) { $mandate_candidates[] = $parent_order->get_meta( '_stripe_mandate_id', true ); } } $indeterminate_candidate = ''; $has_mandate_history = false; foreach ( $mandate_candidates as $candidate ) { if ( empty( $candidate ) ) { continue; } // A stored mandate means this subscription is on the India e-mandate // lifecycle - only then may the no-match fall-through below engage. $has_mandate_history = true; /** * When the current source can't be determined (legacy data) trust the * stored mandate to avoid regressing original-card renewals; otherwise * the mandate must belong to the card actually being charged. */ if ( empty( $current_source ) ) { $data['mandate'] = $candidate; return $data; } $belongs = $this->mandate_belongs_to_source( $candidate, $current_source ); if ( true === $belongs ) { $data['mandate'] = $candidate; return $data; } /** * Indeterminate verdict (Stripe lookup failed): remember the first such * candidate so a transient API failure never drops a possibly-valid * mandate, but keep scanning for a confirmed match first. */ if ( null === $belongs && empty( $indeterminate_candidate ) ) { $indeterminate_candidate = $candidate; } } if ( ! empty( $indeterminate_candidate ) ) { Helper::log( 'Renewal order ' . $order->get_id() . ': mandate ownership indeterminate (Stripe lookup failed), attaching stored mandate ' . $indeterminate_candidate ); $data['mandate'] = $indeterminate_candidate; return $data; } /** * No stored mandate at all: this subscription never had an India * e-mandate (the overwhelmingly common, non-India case). Leave the * renewal request exactly as before - do NOT fall through, so renewals * outside the India mandate lifecycle are byte-identical to legacy. */ if ( ! $has_mandate_history ) { return $data; } /** * India e-mandate lifecycle only: every stored candidate is confirmed to * belong to a different card (the customer changed the subscription card) * - fall through to the mandate_options builder below so a fresh mandate * is minted for the card actually being charged. */ Helper::log( 'Renewal order ' . $order->get_id() . ': stored mandate(s) belong to a different card than source ' . $current_source . ', building fresh mandate_options.' ); } } $subscriptions = wcs_get_subscriptions_for_order( $order ); /** * The change-payment-method SetupIntent passes the subscription itself rather * than a parent order, so resolve it directly to still apply India mandate * options to the SetupIntent for the new card. */ if ( empty( $subscriptions ) && function_exists( 'wcs_is_subscription' ) && wcs_is_subscription( $order ) ) { $subscriptions = array( $order ); } /** * Card renewal fall-through: a renewal order is neither a parent nor a switch * order, so resolve its subscriptions directly to build fresh mandate_options * when no stored mandate matched the current card. Card only - non-card * gateways keep the legacy renewal behavior untouched (AC7). */ if ( empty( $subscriptions ) && 'card' === $this->payment_method_types && 0 < did_action( 'woocommerce_scheduled_subscription_payment_' . $this->id ) && function_exists( 'wcs_get_subscriptions_for_renewal_order' ) ) { $subscriptions = wcs_get_subscriptions_for_renewal_order( $order ); } /** * return from here because creating mandate is not required at all */ if ( 0 === count( $subscriptions ) ) { return $data; } $sub_amount = 0; foreach ( $subscriptions as $sub ) { $sub_amount += Helper::get_stripe_amount( $sub->get_total() ); } /** * Avoid creating mandate when zero amount,if zero it will throw an API error later on */ if ( 0 === $sub_amount ) { return $data; } $sub = reset( $subscriptions ); if ( 1 === count( $subscriptions ) ) { $data['payment_method_options']['card']['mandate_options']['amount_type'] = 'fixed'; $data['payment_method_options']['card']['mandate_options']['interval'] = $sub->get_billing_period(); $data['payment_method_options']['card']['mandate_options']['interval_count'] = $sub->get_billing_interval(); } else { // If there are multiple subscriptions the amount_type becomes 'maximum' so we can charge anything // less than the order total, and the interval is sporadic so we don't have to follow a set interval. $data['payment_method_options']['card']['mandate_options']['amount_type'] = 'maximum'; $data['payment_method_options']['card']['mandate_options']['interval'] = 'sporadic'; } /** * Set other common params */ $data['payment_method_options']['card']['mandate_options']['amount'] = $sub_amount; $data['payment_method_options']['card']['mandate_options']['reference'] = $order->get_id(); $data['payment_method_options']['card']['mandate_options']['start_date'] = $sub->get_time( 'start' ); $data['payment_method_options']['card']['mandate_options']['supported_types'] = array( 'india' ); if ( true === $is_setup_intent ) { $data['payment_method_options']['card']['mandate_options']['currency'] = strtolower( $order->get_currency() ); } return $data; } /** * Check for processing card reason * * Only valid for mandates for Indian 3DS regulations. * * @param \StdClass $payment_intent the Payment Intent to be evaluated. * * @return bool true if payment intent must be authorized off session, false otherwise. */ public function maybe_check_for_auth( $payment_intent ) { return ! empty( $payment_intent->status ) && 'processing' === $payment_intent->status && ! empty( $payment_intent->processing->card->customer_notification->completes_at ); } /** * Force update the payment method for a subscription. * * This method updates the payment method for a given subscription to the current instance's payment method. * * @param \WC_Subscription||\WC_Order $subscription The subscription object to update. * * @return \WC_Subscription The updated subscription object. */ public function force_update_payment_method( $subscription ) { try { remove_filter( 'woocommerce_subscription_get_payment_method', array( Stripe::get_instance(), 'change_payment_method' ), 99 ); $subscription->set_payment_method( $this->id ); $subscription->save(); remove_filter( 'woocommerce_subscription_get_payment_method', array( Stripe::get_instance(), 'change_payment_method' ), 99 ); return $subscription; } catch ( \Exception $e ) { return $subscription; } } /** * @param int $wordpress_user_id * @param string $token * @param string $type 'id' Or 'token' * * @return void */ public function update_subscriptions_payment_method( $wordpress_user_id, $token, $type = 'id' ) { // Maybe mark the token ID as default $default_token = false; if ( $type === 'id' ) { $default_token = \WC_Payment_Tokens::get( $token ); } else { global $wpdb; $token_exists = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}woocommerce_payment_tokens where token =%s", $token ), ARRAY_A ); if ( ! empty( $token_exists ) ) { $default_token = \WC_Payment_Tokens::get( $token_exists[0]['token_id'] ); } } if ( ! $default_token ) { Helper::log( 'Default token not found for user ID: ' . $wordpress_user_id ); return; } \WC_Payment_Tokens::set_users_default( $wordpress_user_id, intval( $default_token->get_id() ) ); Helper::log( 'Set default token for user ID: ' . $wordpress_user_id . ' to token ID: ' . $default_token->get_id() ); if ( class_exists( '\WCS_Payment_Tokens' ) ) { $tokens = \WCS_Payment_Tokens::get_customer_tokens( $wordpress_user_id, $this->id ); unset( $tokens[ $default_token->get_id() ] ); foreach ( $tokens as $old_token ) { foreach ( \WCS_Payment_Tokens::get_subscriptions_from_token( $old_token ) as $subscription ) { if ( ! empty( $subscription ) && \WCS_Payment_Tokens::update_subscription_token( $subscription, $default_token, $old_token ) ) { // translators: 1: previous token, 2: new token. $subscription->add_order_note( sprintf( _x( 'Payment method meta updated after customer changed their default token and opted to update their subscriptions. Payment meta changed from %1$s to %2$s', 'used in subscription note', 'funnelkit-stripe-woo-payment-gateway' ), $old_token->get_token(), $default_token->get_token() ) ); Helper::log( 'Updated subscription ID: ' . $subscription->get_id() . ' with new token ID: ' . $default_token->get_id() ); } } } } } /** * Keeping the filters attached for the @hook woocommerce_scheduled_subscription_payment * For all the unforeseen edge cases * * @return void */ public function attach_hooks_to_update_payment_method() { add_filter( 'woocommerce_order_get_payment_method', array( Stripe::get_instance(), 'change_payment_method' ), 99, 2 ); add_filter( 'woocommerce_subscription_get_payment_method', array( Stripe::get_instance(), 'change_payment_method' ), 99, 2 ); } /** * Try alternative payment methods when current payment method fails. * * @param \WC_Order $renewal_order The renewal order. * @param object $prepared_source The prepared source object. * @param object $error The error object from the failed payment attempt. * * @return string|false The payment method ID to try next, or false if no alternatives available. */ private function try_alternative_payment_method( $renewal_order, $prepared_source, $error ) { if ( empty( $prepared_source->customer ) ) { return false; } try { // Get list of payment methods already tried for this order $tried_payment_methods = Helper::get_meta( $renewal_order, '_fkwcs_tried_payment_methods' ); if ( empty( $tried_payment_methods ) || ! is_array( $tried_payment_methods ) ) { $tried_payment_methods = array(); } // Add current payment method to tried list // Use a special marker for empty source (default customer source) $current_payment_method = ! empty( $prepared_source->source ) ? $prepared_source->source : '__default__'; if ( ! in_array( $current_payment_method, $tried_payment_methods, true ) ) { $tried_payment_methods[] = $current_payment_method; } // Maximum 4 total attempts: 1 original + 3 alternatives // If we've already tried 4 methods (including the original), stop if ( count( $tried_payment_methods ) >= 4 ) { Helper::log( "Info: Maximum payment method retry limit reached (4 attempts) for order {$renewal_order->get_id()}" ); $renewal_order->update_meta_data( '_fkwcs_tried_payment_methods', $tried_payment_methods ); $renewal_order->save_meta_data(); return false; } // Get payment method type (default to 'card') $payment_type = isset( $this->payment_method_types ) ? $this->payment_method_types : 'card'; // Fetch all payment methods for the customer $all_payment_methods = $this->get_payment_methods( $prepared_source->customer, $payment_type ); if ( empty( $all_payment_methods ) || ! is_array( $all_payment_methods ) ) { // No payment methods available, mark current as tried and return false $renewal_order->update_meta_data( '_fkwcs_tried_payment_methods', $tried_payment_methods ); $renewal_order->save_meta_data(); return false; } // Find the next untried payment method (limit to 3 alternatives after the original) $next_payment_method = false; $max_alternatives = 3; // Maximum 3 alternative payment methods foreach ( $all_payment_methods as $payment_method ) { $payment_method_id = isset( $payment_method->id ) ? $payment_method->id : ''; if ( ! empty( $payment_method_id ) && ! in_array( $payment_method_id, $tried_payment_methods, true ) ) { $next_payment_method = $payment_method_id; // Stop after finding first untried method break; } } // If no specific payment method found and default hasn't been tried, try default if ( false === $next_payment_method && ! in_array( '__default__', $tried_payment_methods, true ) ) { $next_payment_method = ''; } // Check if we've exceeded the maximum alternative attempts (3) // Count only alternatives (excluding the original method) // After adding current method above, count alternatives = total - 1 $alternative_attempts = count( $tried_payment_methods ) - 1; // Subtract 1 for the original method if ( $alternative_attempts >= $max_alternatives ) { Helper::log( "Info: Maximum alternative payment method retry limit reached (3 alternatives) for order {$renewal_order->get_id()}" ); $renewal_order->update_meta_data( '_fkwcs_tried_payment_methods', $tried_payment_methods ); $renewal_order->save_meta_data(); return false; } // If we found a next payment method, mark it as tried and return it if ( false !== $next_payment_method ) { $marker = '' === $next_payment_method ? '__default__' : $next_payment_method; $tried_payment_methods[] = $marker; $renewal_order->update_meta_data( '_fkwcs_tried_payment_methods', $tried_payment_methods ); $renewal_order->save_meta_data(); $log_method = '' === $next_payment_method ? 'default customer source' : $next_payment_method; Helper::log( "Info: Found alternative payment method {$log_method} for order {$renewal_order->get_id()}. Total tried: " . count( $tried_payment_methods ) . ' (max 4: 1 original + 3 alternatives)' ); return $next_payment_method; } // All payment methods have been tried Helper::log( "Info: All payment methods exhausted for order {$renewal_order->get_id()}. Total tried: " . count( $tried_payment_methods ) ); return false; } catch ( \Exception $e ) { // Log the error but don't break the payment flow Helper::log( "Error: Failed to fetch alternative payment methods for order {$renewal_order->get_id()}. Error: " . $e->getMessage(), 'warning' ); // Return false to allow parent method to continue with normal error handling return false; } } /** * Update subscription payment method when payment succeeds with an alternative payment method. * * @param \WC_Order $renewal_order The renewal order. * @param string $payment_method_id The payment method ID that succeeded. * * @return void */ private function update_subscription_payment_method_on_success( $renewal_order, $payment_method_id ) { if ( empty( $payment_method_id ) ) { return; } // Save the successful payment method ID to renewal order meta // This ensures the renewal order reflects the actual payment method used $renewal_order->update_meta_data( '_fkwcs_source_id', $payment_method_id ); $renewal_order->save_meta_data(); Helper::log( "Info: Saved payment method {$payment_method_id} to renewal order {$renewal_order->get_id()} meta" ); $subscriptions = wcs_get_subscriptions_for_renewal_order( $renewal_order->get_id() ); if ( empty( $subscriptions ) ) { return; } // Get the original payment method from subscription meta foreach ( $subscriptions as $subscription ) { $original_source_id = Helper::get_meta( $subscription, '_fkwcs_source_id' ); // Only update if the payment method used is different from what's stored if ( $original_source_id !== $payment_method_id ) { $subscription->update_meta_data( '_fkwcs_source_id', $payment_method_id ); $subscription->save(); Helper::log( "Info: Updated subscription {$subscription->get_id()} payment method from {$original_source_id} to {$payment_method_id}" ); } // Always keep the mandate in step with the renewal order's captured mandate. $this->sync_subscription_mandate_from_order( $subscription, $renewal_order ); } } /** * Retrieve payment method object from Stripe API. * * @param string $payment_method_id The payment method ID to retrieve. * * @return object|false The payment method object on success, false on failure. */ private function retrieve_payment_method_object( $payment_method_id ) { if ( empty( $payment_method_id ) ) { return false; } $client = $this->get_client(); if ( is_null( $client ) ) { Helper::log( __FUNCTION__ . ': Stripe Client not setup', 'warning' ); return false; } $response = $client->payment_methods( 'retrieve', array( $payment_method_id ) ); return $this->handle_client_response( $response, false ); } /** * Resolve the India e-mandate id that belongs to a given PaymentMethod. * * The mandate is recovered from the change-payment-method SetupIntent whose id * is stored on the subscription at creation time (see Ajax::create_intent()). A * Stripe PaymentMethod object carries no mandate, so the SetupIntent is the only * object that exposes it. Guarded and silent-failing. * * @since 1.14.1 * * @param \WC_Order|\WC_Subscription $subscription The subscription holding `_fkwcs_setup_intent`. * @param string $source_id The current PaymentMethod id to match. * * @return string|false The mandate id bound to the source, or false when not resolvable. */ private function get_mandate_id_for_source( $subscription, $source_id ) { try { if ( ! is_a( $subscription, 'WC_Order' ) || empty( $source_id ) ) { return false; } $intent_id = $subscription->get_meta( '_fkwcs_setup_intent', true ); // The change-PM flow stores a bare id, the $0-checkout flow stores an array. if ( is_array( $intent_id ) ) { $intent_id = isset( $intent_id['id'] ) ? $intent_id['id'] : ''; } if ( empty( $intent_id ) ) { return false; } $client = $this->get_client(); if ( is_null( $client ) ) { return false; } $response = $client->setup_intents( 'retrieve', array( $intent_id ) ); if ( empty( $response['success'] ) ) { return false; } $intent = $response['data']; // Only trust the mandate when the SetupIntent is confirmed against this card. if ( isset( $intent->object ) && 'setup_intent' === $intent->object && isset( $intent->payment_method ) && $intent->payment_method === $source_id ) { return ( isset( $intent->mandate ) && ! empty( $intent->mandate ) ) ? $intent->mandate : false; } return false; } catch ( \Throwable $e ) { Helper::log( 'get_mandate_id_for_source failed: ' . $e->getMessage(), 'warning' ); return false; } } /** * Check whether a Stripe Mandate is bound to the given PaymentMethod. * * Used on the renewal hot path to ensure the mandate attached to the renewal * PaymentIntent belongs to the card actually being charged. Definitive verdicts * are cached per request; an API/client failure is indeterminate (null) so the * caller can tell "confirmed mismatch" apart from "could not verify" - a * transient Stripe failure must never be treated as a confirmed non-match. * * @since 1.14.1 * * @param string $mandate_id The Stripe mandate id to inspect. * @param string $source_id The PaymentMethod id the mandate must belong to. * * @return bool|null True when the mandate's payment_method matches the source, * false on a confirmed mismatch, null when the lookup failed. */ private function mandate_belongs_to_source( $mandate_id, $source_id ) { static $cache = array(); if ( empty( $mandate_id ) || empty( $source_id ) ) { return false; } $cache_key = $mandate_id . '|' . $source_id; if ( isset( $cache[ $cache_key ] ) ) { return $cache[ $cache_key ]; } $result = null; try { $client = $this->get_client(); if ( ! is_null( $client ) ) { $response = $client->mandates( 'retrieve', array( $mandate_id ) ); if ( ! empty( $response['success'] ) ) { $mandate = $response['data']; $result = ( isset( $mandate->payment_method ) && $mandate->payment_method === $source_id ); } else { Helper::log( 'mandate_belongs_to_source: lookup of ' . $mandate_id . ' failed, verdict indeterminate.', 'warning' ); } } } catch ( \Throwable $e ) { Helper::log( 'mandate_belongs_to_source failed: ' . $e->getMessage(), 'warning' ); $result = null; } // Cache only definitive verdicts so a transient failure can be retried. if ( null !== $result ) { $cache[ $cache_key ] = $result; } return $result; } /** * Keep a subscription's India e-mandate consistent with its current source. * * Clears any stale `_stripe_mandate_id` first (so a capture failure can never * leave a mandate bound to a different card) then, when the new card's mandate * can be recovered, persists it on the subscription where the renewal reads it. * Card/India context only; guarded and silent-failing. * * @since 1.14.1 * * @param \WC_Order|\WC_Subscription $subscription The subscription to update. * @param string $source_id The current PaymentMethod id. * * @return void */ private function sync_subscription_mandate( $subscription, $source_id ) { try { if ( ! is_a( $subscription, 'WC_Order' ) || 'card' !== $this->payment_method_types ) { return; } // Clear the stale mandate first, mirroring the delete_renewal_meta() idiom. $subscription->delete_meta_data( '_stripe_mandate_id' ); $mandate_id = $this->get_mandate_id_for_source( $subscription, $source_id ); if ( isset( $mandate_id ) && ! empty( $mandate_id ) ) { $subscription->update_meta_data( '_stripe_mandate_id', $mandate_id ); Helper::log( 'Stored India mandate ' . $mandate_id . ' on subscription ' . $subscription->get_id() . ' for source ' . $source_id ); } $subscription->save_meta_data(); } catch ( \Throwable $e ) { Helper::log( 'sync_subscription_mandate failed: ' . $e->getMessage(), 'warning' ); } } /** * Copy the India e-mandate captured on a source order onto the subscription. * * Unlike sync_subscription_mandate() (used on the change-PM flow, where the * mandate only exists on the SetupIntent), the renewal / checkout paths already * have the fresh mandate persisted on the order that just paid - so read it from * meta instead of calling Stripe. Card/India context only; guarded + silent. * * @since 1.14.1 * * @param \WC_Order|\WC_Subscription $subscription The subscription to update. * @param \WC_Order $source_order The order carrying `_stripe_mandate_id`. * * @return void */ private function sync_subscription_mandate_from_order( $subscription, $source_order ) { try { if ( ! is_a( $subscription, 'WC_Order' ) || ! is_a( $source_order, 'WC_Order' ) || 'card' !== $this->payment_method_types ) { return; } $mandate_id = $source_order->get_meta( '_stripe_mandate_id', true ); if ( isset( $mandate_id ) && ! empty( $mandate_id ) ) { $subscription->update_meta_data( '_stripe_mandate_id', $mandate_id ); $subscription->save_meta_data(); Helper::log( 'Synced mandate ' . $mandate_id . ' from order ' . $source_order->get_id() . ' to subscription ' . $subscription->get_id() ); } } catch ( \Throwable $e ) { Helper::log( 'sync_subscription_mandate_from_order failed: ' . $e->getMessage(), 'warning' ); } } }