get_reusable_wcpay_gateway_ids(), true ); } /** * Get a descriptive payment method title from a token. * * For credit cards, returns "{title} ending in {last4}". * For Amazon Pay, returns "{title} ({redacted_email})". * For Stripe Link, returns "{title} ({redacted_email})". * For other tokens, returns the default title. * * @param WC_Payment_Token|null $token The payment token. * @param string $default The default title to return if token cannot be processed. * @return string The payment method title with identifying details. */ private function get_payment_method_title_from_token( $token, $default ) { if ( ! $token ) { return $default; } if ( $token instanceof WC_Payment_Token_CC ) { $last4 = $token->get_last4(); // Avoid duplication if the title already contains the last4. if ( ! empty( $last4 ) && false === strpos( $default, $last4 ) ) { // Use the specific card brand (e.g. "Visa") when available instead of $default, // which may refer to a different payment method type (e.g. "Link" from a previous subscription payment). $card_type = $token->get_card_type(); $title = ! empty( $card_type ) ? wc_get_credit_card_type_label( $card_type ) : $default; // translators: 1: payment method likely credit card, 2: last 4 digit. return sprintf( __( '%1$s ending in %2$s', 'woocommerce-payments' ), $title, $last4 ); } } if ( $token instanceof WC_Payment_Token_WCPay_Amazon_Pay ) { $email = $token->get_email(); // Avoid duplication if the title already contains the email. if ( ! empty( $email ) && false === strpos( $default, $email ) ) { // translators: 1: payment method (Amazon Pay), 2: redacted customer email. return sprintf( __( '%1$s (%2$s)', 'woocommerce-payments' ), $default, $email ); } } if ( $token instanceof WC_Payment_Token_WCPay_Link ) { $email = $token->get_redacted_email(); // Avoid duplication if the title already contains the email. if ( ! empty( $email ) && false === strpos( $default, $email ) ) { // Link uses the card gateway, so $default is "Card". Use "Stripe Link" instead. // translators: 1: payment method (Stripe Link), 2: redacted customer email. return sprintf( __( '%1$s (%2$s)', 'woocommerce-payments' ), __( 'Stripe Link', 'woocommerce-payments' ), $email ); } } return $default; } /** * Check if a subscription or order belongs to this specific gateway or a related WCPay gateway * that this gateway should handle (e.g., card gateway handling Amazon Pay display). * * @param WC_Order|WC_Subscription $order The order or subscription to check. * @return bool True if this gateway should handle the order. */ private function should_handle_order( $order ) { $payment_method = $order->get_payment_method(); // Direct match - order uses this gateway. if ( $payment_method === $this->id ) { return true; } // Subscriptions' hooks are only registered to the base card gateway. // The main gateway should be used for all reusable payment methods. if ( in_array( $payment_method, $this->get_reusable_wcpay_gateway_ids(), true ) ) { return true; } return false; } /** * Initialize subscription support and hooks. */ public function maybe_init_subscriptions() { if ( ! $this->is_subscriptions_enabled() ) { return; } /* * Base set of subscription features to add. * The WCPay payment gateway supports these features * for both WCPay Subscriptions and WooCommerce Subscriptions. */ $payment_gateway_features = [ 'multiple_subscriptions', 'subscription_cancellation', 'subscription_payment_method_change_admin', 'subscription_payment_method_change_customer', 'subscription_payment_method_change', 'subscription_reactivation', 'subscription_suspension', 'subscriptions', ]; if ( ! WC_Payments_Features::should_use_stripe_billing() ) { /* * Subscription amount & date changes are only supported * when Stripe Billing is not in use. */ $payment_gateway_features = array_merge( $payment_gateway_features, [ 'subscription_amount_changes', 'subscription_date_changes', ] ); } else { /* * The gateway_scheduled_payments feature is only supported * for WCPay Subscriptions. */ $payment_gateway_features[] = 'gateway_scheduled_payments'; } $this->supports = array_merge( $this->supports, $payment_gateway_features ); } /** * Initializes this trait's WP hooks. * * Generic hooks are registered once by the base gateway. * Gateway-specific hooks (for renewals) are registered by each reusable gateway. * Non-reusable gateways (iDEAL, Bancontact, etc.) do not register subscription hooks. * * @return void */ public function maybe_init_subscriptions_hooks() { if ( ! $this->is_subscriptions_enabled() ) { return; } if ( self::$has_attached_integration_hooks ) { return; } // Only the base gateway registers hooks to avoid duplication. if ( WC_Payment_Gateway_WCPay::GATEWAY_ID !== $this->id ) { return; } self::$has_attached_integration_hooks = true; add_filter( 'woocommerce_email_classes', [ $this, 'add_emails' ], 20 ); // Switch Amazon Pay ECE subscriptions to the correct gateway (priority 10, before manual renewal check). add_action( 'woocommerce_checkout_subscription_created', [ $this, 'maybe_switch_subscription_to_amazon_pay_gateway' ], 10, 1 ); // Force non-reusable payment methods to manual renewal (priority 11, after gateway switch). add_action( 'woocommerce_checkout_subscription_created', [ $this, 'maybe_force_subscription_to_manual' ], 11, 1 ); // Register gateway-specific hooks for all reusable gateways. foreach ( $this->get_reusable_wcpay_gateway_ids() as $gateway_id ) { add_action( 'woocommerce_scheduled_subscription_payment_' . $gateway_id, [ $this, 'scheduled_subscription_payment' ], 10, 2 ); add_action( 'woocommerce_subscription_failing_payment_method_updated_' . $gateway_id, [ $this, 'update_failing_payment_method' ], 10, 2 ); } add_filter( 'wc_payments_display_save_payment_method_checkbox', [ $this, 'display_save_payment_method_checkbox' ], 10 ); // Display the payment method used for a subscription in the "My Subscriptions" table. add_filter( 'woocommerce_my_subscriptions_payment_method', [ $this, 'maybe_render_subscription_payment_method' ], 10, 2 ); // Override the payment method display for Link subscriptions in all contexts (admin + customer). // The gateway title is "Card" for Link subscriptions because Link uses the card gateway. add_filter( 'woocommerce_subscription_payment_method_to_display', [ $this, 'maybe_override_link_subscription_payment_method_display' ], 10, 2 ); // Hide "Change payment" button for manual subscriptions with non-reusable payment methods. add_filter( 'wcs_view_subscription_actions', [ $this, 'maybe_hide_change_payment_for_manual_subscriptions' ], 10, 2 ); // Hide "Auto-renew" toggle for manual subscriptions with non-reusable payment methods. add_filter( 'user_has_cap', [ $this, 'maybe_hide_auto_renew_toggle_for_manual_subscriptions' ], 100, 3 ); // Used to filter out unwanted metadata on new renewal orders. if ( ! class_exists( 'WC_Subscriptions_Data_Copier' ) ) { add_filter( 'wcs_renewal_order_meta_query', [ $this, 'update_renewal_meta_data' ], 10, 3 ); } else { add_filter( 'wc_subscriptions_renewal_order_data', [ $this, 'remove_data_renewal_order' ], 10, 3 ); } // Allow store managers to manually set Stripe as the payment method on a subscription. add_filter( 'woocommerce_subscription_payment_meta', [ $this, 'add_subscription_payment_meta' ], 10, 2 ); add_filter( 'woocommerce_subscription_validate_payment_meta', [ $this, 'validate_subscription_payment_meta' ], 10, 3 ); add_action( 'wcs_save_other_payment_meta', [ $this, 'save_meta_in_order_tokens' ], 10, 4 ); // To make sure payment meta is copied from subscription to order. add_filter( 'wcs_copy_payment_meta_to_order', [ $this, 'append_payment_meta' ], 10, 3 ); add_filter( 'woocommerce_subscription_note_old_payment_method_title', [ $this, 'get_specific_old_payment_method_title' ], 10, 3 ); add_filter( 'woocommerce_subscription_note_new_payment_method_title', [ $this, 'get_specific_new_payment_method_title' ], 10, 3 ); add_action( 'woocommerce_admin_order_data_after_billing_address', [ $this, 'add_payment_method_select_to_subscription_edit' ] ); // Update subscriptions token when user sets a default payment method. add_filter( 'woocommerce_subscriptions_update_subscription_token', [ $this, 'update_subscription_token' ], 10, 3 ); add_filter( 'woocommerce_subscriptions_update_payment_via_pay_shortcode', [ $this, 'update_payment_method_for_subscriptions' ], 10, 3 ); // AJAX handler for fetching payment tokens when customer changes. add_action( 'wp_ajax_wcpay_get_user_payment_tokens', [ $this, 'ajax_get_user_payment_tokens' ] ); } /** * Stops WC Subscriptions from updating the payment method for subscriptions. * * @param bool $update_payment_method Whether to update the payment method. * @param string $new_payment_method The new payment method. * @param WC_Subscription $subscription The subscription. * @return bool */ public function update_payment_method_for_subscriptions( $update_payment_method, $new_payment_method, $subscription ) { // Skip if the change payment method request was not made yet. if ( ! isset( $_POST['_wcsnonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['_wcsnonce'] ), 'wcs_change_payment_method' ) ) { return $update_payment_method; } // Avoid interfering with use-cases not related to updating payment method for subscriptions. if ( ! $this->is_changing_payment_method_for_subscription() ) { return $update_payment_method; } // Avoid interfering with other payment gateways' operations. if ( $new_payment_method !== $this->id ) { return $update_payment_method; } // If the payment method is a saved payment method, we don't need to stop WC Subscriptions from updating it. if ( ( isset( $_POST[ 'wc-' . $new_payment_method . '-payment-token' ] ) && 'new' !== $_POST[ 'wc-' . $new_payment_method . '-payment-token' ] ) ) { return $update_payment_method; } return false; } /** * Prepares the payment information object. * * @param Payment_Information $payment_information The payment information from parent gateway. * @param int $order_id The order ID whose payment will be processed. * @return Payment_Information An object, which describes the payment. */ protected function maybe_prepare_subscription_payment_information( $payment_information, $order_id ) { if ( ! $this->is_payment_recurring( $order_id ) ) { return $payment_information; } // Subs-specific behavior starts here. $payment_information->set_payment_type( Payment_Type::RECURRING() ); // The payment method is always saved for subscriptions. $payment_information->must_save_payment_method_to_store(); $payment_information->set_is_changing_payment_method_for_subscription( $this->is_changing_payment_method_for_subscription() ); return $payment_information; } /** * Process a scheduled subscription payment. * * @param float $amount The amount to charge. * @param WC_Order $renewal_order A WC_Order object created to record the renewal payment. */ public function scheduled_subscription_payment( $amount, $renewal_order ) { // Exit early if the order belongs to a WCPay Subscription. The payment will be processed by the subscription via webhooks. if ( $this->is_wcpay_subscription_renewal_order( $renewal_order ) ) { return; } $token = $this->get_payment_token( $renewal_order ); if ( is_null( $token ) && ! WC_Payments::is_network_saved_cards_enabled() ) { $renewal_order->add_order_note( 'Subscription renewal failed: No saved payment method found.' ); Logger::error( 'There is no saved payment token for order #' . $renewal_order->get_id() ); // TODO: Update to use Order_Service->mark_payment_failed. $renewal_order->update_status( 'failed' ); return; } $customer_id = $this->order_service->get_customer_id_for_order( $renewal_order ); try { $payment_information = new Payment_Information( '', $renewal_order, Payment_Type::RECURRING(), $token, Payment_Initiated_By::MERCHANT(), null, null, '', $this->get_payment_method_to_use_for_intent(), $customer_id ); $this->process_payment_for_order( null, $payment_information, true ); } catch ( API_Exception $e ) { Logger::error( 'Error processing subscription renewal: ' . $e->getMessage() ); // TODO: Update to use Order_Service->mark_payment_failed. $renewal_order->update_status( 'failed' ); if ( ! empty( $payment_information ) ) { $error_details = esc_html( rtrim( $e->getMessage(), '.' ) ); if ( $e instanceof API_Merchant_Exception ) { $error_details = $error_details . '. ' . esc_html( rtrim( $e->get_merchant_message(), '.' ) ); } $note = sprintf( WC_Payments_Utils::esc_interpolated_html( /* translators: %1: the failed payment amount, %2: error message */ __( 'A payment of %1$s failed to complete with the following message: %2$s.', 'woocommerce-payments' ), [ 'strong' => '', 'code' => '', ] ), WC_Payments_Explicit_Price_Formatter::get_explicit_price( wc_price( $amount, [ 'currency' => WC_Payments_Utils::get_order_intent_currency( $renewal_order ) ] ), $renewal_order ), $error_details ); $renewal_order->add_order_note( $note ); } } } /** * Adds the payment token from a failed renewal order to the provided subscription. * * @param WC_Subscription $subscription The subscription to be updated. * @param WC_Order $renewal_order The failed renewal order. */ public function update_failing_payment_method( $subscription, $renewal_order ) { $renewal_token = $this->get_payment_token( $renewal_order ); if ( is_null( $renewal_token ) ) { $renewal_order->add_order_note( 'Unable to update subscription payment method: No valid payment token or method found.' ); Logger::error( 'Failing subscription could not be updated: there is no saved payment token for order #' . $renewal_order->get_id() ); return; } $this->add_token_to_order( $subscription, $renewal_token ); } /** * Return the payment meta data for this payment gateway. * * @param WC_Subscription $subscription The subscription order. * @return array */ private function get_payment_meta( $subscription ) { $active_token = $this->get_payment_token( $subscription ); return [ self::$payment_method_meta_table => [ self::$payment_method_meta_key => [ 'label' => __( 'Saved payment method', 'woocommerce-payments' ), 'value' => empty( $active_token ) ? '' : (string) $active_token->get_id(), ], ], ]; } /** * Append payment meta if order and subscription are using WCPay as payment method and if passed payment meta is an array. * * @param array $payment_meta Associative array of meta data required for automatic payments. * @param WC_Order $order The subscription's related order. * @param WC_Subscription $subscription The subscription order. * @return array */ public function append_payment_meta( $payment_meta, $order, $subscription ) { // Check if both order and subscription should be handled by this gateway. if ( ! $this->should_handle_order( $order ) || ! $this->should_handle_order( $subscription ) ) { return $payment_meta; } if ( ! is_array( $payment_meta ) ) { return $payment_meta; } return array_merge( $payment_meta, $this->get_payment_meta( $subscription ) ); } /** * Include the payment meta data required to process automatic recurring payments so that store managers can * manually set up automatic recurring payments for a customer via the Edit Subscriptions screen in 2.0+. * * @param array $payment_meta Associative array of meta data required for automatic payments. * @param WC_Subscription $subscription The subscription order. * @return array */ public function add_subscription_payment_meta( $payment_meta, $subscription ) { // Add payment meta for all reusable WCPay gateways (card, Amazon Pay). foreach ( $this->get_reusable_wcpay_gateway_ids() as $gateway_id ) { $payment_meta[ $gateway_id ] = $this->get_payment_meta( $subscription ); // Display select element on newer Subscriptions versions. add_action( sprintf( 'woocommerce_subscription_payment_meta_input_%s_%s_%s', $gateway_id, self::$payment_method_meta_table, self::$payment_method_meta_key ), [ $this, 'render_custom_payment_meta_input' ], 10, 3 ); } return $payment_meta; } /** * Validate the payment meta data required to process automatic recurring payments so that store managers can * manually set up automatic recurring payments for a customer via the Edit Subscriptions screen in 2.0+. * * @param string $payment_gateway_id The ID of the payment gateway to validate. * @param array $payment_meta Associative array of meta data required for automatic payments. * @param WC_Subscription $subscription The subscription order. * * @throws Invalid_Payment_Method_Exception When $payment_meta is not valid. */ public function validate_subscription_payment_meta( $payment_gateway_id, $payment_meta, $subscription ) { // Validate for all reusable WCPay gateways (card, Amazon Pay). if ( ! $this->is_reusable_wcpay_gateway( $payment_gateway_id ) ) { return; } if ( empty( $payment_meta[ self::$payment_method_meta_table ][ self::$payment_method_meta_key ]['value'] ) ) { throw new Invalid_Payment_Method_Exception( __( 'A customer saved payment method was not selected for this order.', 'woocommerce-payments' ), 'payment_method_not_selected' ); } $token = WC_Payment_Tokens::get( $payment_meta[ self::$payment_method_meta_table ][ self::$payment_method_meta_key ]['value'] ); if ( empty( $token ) ) { throw new Invalid_Payment_Method_Exception( __( 'The saved payment method selected is invalid or does not exist.', 'woocommerce-payments' ), 'payment_method_token_not_found' ); } if ( $subscription->get_user_id() !== $token->get_user_id() ) { throw new Invalid_Payment_Method_Exception( __( 'The saved payment method selected does not belong to this order\'s customer.', 'woocommerce-payments' ), 'payment_method_token_not_owned' ); } } /** * Saves the payment token to the order. * * @param WC_Order $order The order. * @param WC_Payment_Token $token The token to save. */ public function maybe_add_token_to_subscription_order( $order, $token ) { if ( $this->is_subscriptions_enabled() ) { $subscriptions = wcs_get_subscriptions_for_order( $order->get_id() ); if ( is_array( $subscriptions ) ) { foreach ( $subscriptions as $subscription ) { $payment_token = $this->get_payment_token( $subscription ); if ( is_null( $payment_token ) || $token->get_id() !== $payment_token->get_id() ) { $subscription->add_payment_token( $token ); } } } } } /** * Save subscriptions payment_method metadata to the order tokens when its type is wc_order_tokens. * * @param WC_Subscription $subscription The subscription to be updated. * @param string $table Where to store and retrieve the metadata. * @param string $meta_key Meta key to be updated. * @param string $meta_value Meta value to be updated. */ public function save_meta_in_order_tokens( $subscription, $table, $meta_key, $meta_value ) { if ( self::$payment_method_meta_table !== $table || self::$payment_method_meta_key !== $meta_key ) { return; } $token = WC_Payment_Tokens::get( $meta_value ); if ( empty( $token ) ) { return; } $this->add_token_to_order( $subscription, $token ); } /** * Loads the subscription edit page script with user cards to hijack the payment method input and * transform it into a select element. * * @param WC_Order $order The WC Order. */ public function add_payment_method_select_to_subscription_edit( $order ) { // Do not load the script if the order is not a subscription. if ( ! wcs_is_subscription( $order ) ) { return; } WC_Payments::register_script_with_dependencies( 'WCPAY_SUBSCRIPTION_EDIT_PAGE', 'dist/subscription-edit-page' ); wp_set_script_translations( 'WCPAY_SUBSCRIPTION_EDIT_PAGE', 'woocommerce-payments' ); wp_enqueue_script( 'WCPAY_SUBSCRIPTION_EDIT_PAGE' ); } /** * Override the payment method display for Link subscriptions in all contexts. * * Stripe Link uses the card gateway (woocommerce_payments), so the gateway title is "Card". * This replaces it with the Link token's display name (e.g. "Stripe Link (r***@r***.com)"). * * @param string $payment_method_to_display Default payment method to display. * @param WC_Subscription $subscription Subscription object. * * @return string Payment method string to display. */ public function maybe_override_link_subscription_payment_method_display( $payment_method_to_display, $subscription ) { try { if ( ! $this->should_handle_order( $subscription ) ) { return $payment_method_to_display; } $token = $this->get_payment_token( $subscription ); if ( $token instanceof \WC_Payment_Token_WCPay_Link ) { return $token->get_display_name(); } return $payment_method_to_display; } catch ( \Exception $e ) { return $payment_method_to_display; } } /** * Render the payment method used for a subscription in My Account pages * * @param string $payment_method_to_display Default payment method to display. * @param WC_Subscription $subscription Subscription object. * * @return string Payment method string to display in UI. */ public function maybe_render_subscription_payment_method( $payment_method_to_display, $subscription ) { try { // Use should_handle_order() to check if this gateway should render the payment method. // This allows the base gateway to render payment methods for Amazon Pay subscriptions too. if ( ! $this->should_handle_order( $subscription ) ) { return $payment_method_to_display; } $token = $this->get_payment_token( $subscription ); if ( is_null( $token ) ) { Logger::info( 'There is no saved payment token for subscription #' . $subscription->get_id() ); } else { $payment_method_to_display = $token->get_display_name(); } return $payment_method_to_display; } catch ( \Exception $e ) { Logger::error( 'Failed to get payment method for subscription #' . $subscription->get_id() . ' ' . $e ); return $payment_method_to_display; } } /** * Hide "Change payment" button for manual subscriptions with non-reusable payment methods. * These subscriptions use the "Renew now" flow where customers choose a payment method at renewal time. * * @param array $actions The subscription actions. * @param WC_Subscription $subscription The subscription object. * @return array The modified actions array. */ public function maybe_hide_change_payment_for_manual_subscriptions( $actions, $subscription ) { // Only process manual subscriptions with non-reusable payment methods. $original_payment_method_id = $subscription->get_meta( '_wcpay_original_payment_method_id', true ); if ( $subscription->is_manual() && ! empty( $original_payment_method_id ) ) { // Remove the "Change payment" action since they'll choose payment method during renewal. unset( $actions['change_payment_method'] ); } return $actions; } /** * Hide "Auto renew" toggle for manual subscriptions with non-reusable payment methods. * * @param array $allcaps List of user capabilities. * @param array $caps Which capabilities are being checked. * @param array $args Arguments, in our case user ID and subscription ID. * @return array */ public function maybe_hide_auto_renew_toggle_for_manual_subscriptions( $allcaps, $caps, $args ) { if ( ! isset( $caps[0] ) || 'toggle_shop_subscription_auto_renewal' !== $caps[0] ) { // Do not interfere with other capabilities. return $allcaps; } if ( ! isset( $args[2] ) ) { return $allcaps; } $subscription = wcs_get_subscription( $args[2] ); if ( ! $subscription ) { return $allcaps; } // Only process manual subscriptions with non-reusable payment methods. $original_payment_method_id = $subscription->get_meta( '_wcpay_original_payment_method_id', true ); if ( $subscription->is_manual() && ! empty( $original_payment_method_id ) ) { // Remove the capability as this subscription won't work with automatic renewals. unset( $allcaps['toggle_shop_subscription_auto_renewal'] ); } return $allcaps; } /** * AJAX handler to fetch payment tokens for a user. * * @return void */ public function ajax_get_user_payment_tokens() { check_ajax_referer( 'wcpay-subscription-edit', 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( [ 'message' => __( 'You do not have permission to perform this action.', 'woocommerce-payments' ) ], 403 ); return; } $user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0; $gateway_id = isset( $_POST['gateway_id'] ) ? sanitize_text_field( wp_unslash( $_POST['gateway_id'] ) ) : null; if ( $user_id <= 0 ) { wp_send_json_success( [ 'tokens' => [] ] ); return; } // Verify user exists. $user = get_user_by( 'id', $user_id ); if ( ! $user ) { wp_send_json_error( [ 'message' => __( 'Invalid user ID.', 'woocommerce-payments' ) ], 400 ); return; } // Validating the gateway_id - only allow reusable WCPay gateways. if ( null !== $gateway_id && ! $this->is_reusable_wcpay_gateway( $gateway_id ) ) { $gateway_id = null; // Fall back to the default card gateway. } $tokens = $this->get_user_formatted_tokens_array( $user_id, $gateway_id ); wp_send_json_success( [ 'tokens' => $tokens ] ); } /** * Outputs a select element to be used for the Subscriptions payment meta token selection. * * @param WC_Subscription $subscription The subscription object. * @param string $field_id The field_id to add to the select element. * @param string $field_value The field_value to be selected by default. */ public function render_custom_payment_meta_input( $subscription, $field_id, $field_value ) { // Make sure that we are either working with integers or null. $field_value = ctype_digit( $field_value ) ? absint( $field_value ) : ( is_int( $field_value ) ? $field_value : null ); $user_id = $subscription->get_user_id(); // Extract the gateway ID from the field_id which follows the pattern: // _payment_method_meta[{gateway_id}][{table}][{key}] // This ensures we show tokens for the gateway being rendered, not the subscription's current gateway. $token_gateway_id = WC_Payment_Gateway_WCPay::GATEWAY_ID; // Default to card. if ( preg_match( '/\[([^\]]+)\]/', $field_id, $matches ) ) { $extracted_gateway_id = $matches[1]; if ( $this->is_reusable_wcpay_gateway( $extracted_gateway_id ) ) { $token_gateway_id = $extracted_gateway_id; } } $disabled = false; $selected = null; $options = []; $prepared_data = [ 'value' => $field_value, 'userId' => $user_id, 'tokens' => [], 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'wcpay-subscription-edit' ), 'gatewayId' => $token_gateway_id, ]; if ( $user_id > 0 ) { $tokens = $this->get_user_formatted_tokens_array( $user_id, $token_gateway_id ); foreach ( $tokens as $token ) { $options[ $token['tokenId'] ] = $token['displayName']; if ( $field_value === $token['tokenId'] || ( ! $field_value && $token['isDefault'] ) ) { $selected = $token['tokenId']; } } $prepared_data['tokens'] = $tokens; if ( empty( $options ) ) { $options[0] = __( 'No payment methods found for customer', 'woocommerce-payments' ); $disabled = true; } } else { $options[0] = __( 'Please select a customer first', 'woocommerce-payments' ); $selected = 0; $disabled = true; } ?> is_changing_payment_method_for_subscription() ) { $token_ids = $subscription->get_payment_tokens(); // since old payment must be the second to last saved payment... if ( count( $token_ids ) < 2 ) { return $old_payment_method_title; } $second_to_last_token_id = $token_ids[ count( $token_ids ) - 2 ]; $token = WC_Payment_Tokens::get( $second_to_last_token_id ); return $this->get_payment_method_title_from_token( $token, $old_payment_method_title ); } else { $last_order_id = $subscription->get_last_order(); if ( ! $last_order_id ) { return $old_payment_method_title; } $last_order = wc_get_order( $last_order_id ); $token_ids = $last_order->get_payment_tokens(); // since old payment must be the second to last saved payment... if ( count( $token_ids ) < 2 ) { return $old_payment_method_title; } $second_to_last_token_id = $token_ids[ count( $token_ids ) - 2 ]; $token = WC_Payment_Tokens::get( $second_to_last_token_id ); return $this->get_payment_method_title_from_token( $token, $old_payment_method_title ); } } /** * Add specific data like last 4 digit of wcpay payment gateway * * @param string $new_payment_method_title Payment method title, eg: Credit card. * @param string $new_payment_method Payment gateway id. * @param WC_Subscription $subscription The subscription order. * @return string */ public function get_specific_new_payment_method_title( $new_payment_method_title, $new_payment_method, $subscription ) { // make sure payment method is wcpay's (including split gateways like Amazon Pay). if ( 0 !== strpos( $new_payment_method, WC_Payment_Gateway_WCPay::GATEWAY_ID ) ) { return $new_payment_method_title; } if ( $this->is_changing_payment_method_for_subscription() ) { $order = $subscription; } else { $last_order_id = $subscription->get_last_order(); if ( ! $last_order_id ) { return $new_payment_method_title; } $order = wc_get_order( $last_order_id ); } try { $payment_information = $this->prepare_payment_information( $order ); } catch ( Exception $e ) { return $new_payment_method_title; } if ( $payment_information->is_using_saved_payment_method() ) { $token = $payment_information->get_payment_token(); return $this->get_payment_method_title_from_token( $token, $new_payment_method_title ); } else { try { $payment_method_id = $payment_information->get_payment_method(); $payment_method = $this->payments_api_client->get_payment_method( $payment_method_id ); if ( ! empty( $payment_method['card']['last4'] ) ) { // translators: 1: payment method likely credit card, 2: last 4 digit. return sprintf( __( '%1$s ending in %2$s', 'woocommerce-payments' ), $new_payment_method_title, $payment_method['card']['last4'] ); } if ( ! empty( $payment_method['amazon_pay']['email'] ) ) { // translators: 1: payment method (Amazon Pay), 2: redacted customer email. return sprintf( __( '%1$s (%2$s)', 'woocommerce-payments' ), $new_payment_method_title, $payment_method['amazon_pay']['email'] ); } if ( ! empty( $payment_method['link']['email'] ) ) { // Link uses the card gateway, so $new_payment_method_title is "Card". Use "Stripe Link" instead. // translators: 1: payment method (Stripe Link), 2: customer email. return sprintf( __( '%1$s (%2$s)', 'woocommerce-payments' ), __( 'Stripe Link', 'woocommerce-payments' ), $payment_method['link']['email'] ); } } catch ( Exception $e ) { Logger::error( $e ); } } return $new_payment_method_title; } /** * When an order is created/updated, we want to add an ActionScheduler job to send this data to * the payment server. * * @param int $order_id The ID of the order that has been created. * @param WC_Order|null $order The order that has been created. * * @throws Order_Not_Found_Exception */ public function maybe_schedule_subscription_order_tracking( $order_id, $order = null ) { if ( ! $this->is_subscriptions_enabled() ) { return; } $save_meta_data = false; if ( is_null( $order ) ) { $order = wc_get_order( $order_id ); } $payment_token = $this->get_payment_token( $order ); // If we can't get the payment token for this order, then we check if we already have a payment token // set in the order metadata. If we don't, then we try and get the parent order's token from the metadata. if ( is_null( $payment_token ) ) { if ( empty( $this->order_service->get_payment_method_id_for_order( $order ) ) ) { $parent_order = wc_get_order( $order->get_parent_id() ); if ( $parent_order ) { $parent_payment_method_id = $this->order_service->get_payment_method_id_for_order( $parent_order ); } // If there is no parent order, or the parent order doesn't have the metadata set, then we cannot track this order. if ( empty( $parent_order ) || empty( $parent_payment_method_id ) ) { return; } $this->order_service->set_payment_method_id_for_order( $order, $parent_payment_method_id ); $save_meta_data = true; } } elseif ( $this->order_service->get_payment_method_id_for_order( $order ) !== $payment_token->get_token() ) { // If the payment token stored in the metadata already doesn't reflect the latest token, update it. $this->order_service->set_payment_method_id_for_order( $order, $payment_token->get_token() ); $save_meta_data = true; } // If the stripe customer ID metadata isn't set for this order, try and get this data from the metadata of the parent order. if ( empty( $this->order_service->get_customer_id_for_order( $order ) ) ) { $parent_order = wc_get_order( $order->get_parent_id() ); if ( $parent_order ) { $parent_customer_id = $this->order_service->get_customer_id_for_order( $parent_order ); } if ( ! empty( $parent_order ) && ! empty( $parent_customer_id ) ) { $this->order_service->set_customer_id_for_order( $order, $parent_customer_id ); $save_meta_data = true; } } // If we need to, save our changes to the metadata for this order. if ( $save_meta_data ) { $order->save_meta_data(); } } /** * Action called when a renewal order is created, allowing us to strip metadata that we do not * want it to inherit from the parent order. * * @param string $order_meta_query The metadata query (a valid SQL query). * @param int $to_order The renewal order. * @param int $from_order The source (parent) order. * * @return string */ public function update_renewal_meta_data( $order_meta_query, $to_order, $from_order ) { $order_meta_query .= " AND `meta_key` NOT IN ('_new_order_tracking_complete')"; return $order_meta_query; } /** * Removes the data that we don't need to copy to renewal orders. * * @param array $order_data Renewal order data. * * @return array The renewal order data with the data we don't want copied removed */ public function remove_data_renewal_order( $order_data ) { unset( $order_data['_new_order_tracking_complete'] ); return $order_data; } /** * Adds the failed SCA auth email to WooCommerce. * * @param WC_Email[] $email_classes All existing emails. * @return WC_Email[] */ public function add_emails( $email_classes ) { include_once __DIR__ . '/class-wc-payments-email-failed-renewal-authentication.php'; include_once __DIR__ . '/class-wc-payments-email-failed-authentication-retry.php'; $email_classes['WC_Payments_Email_Failed_Renewal_Authentication'] = new WC_Payments_Email_Failed_Renewal_Authentication( $email_classes ); $email_classes['WC_Payments_Email_Failed_Authentication_Retry'] = new WC_Payments_Email_Failed_Authentication_Retry(); return $email_classes; } /** * Update the specified subscription's payment token with a new token. * * @param bool $updated Whether the token was updated. * @param WC_Subscription $subscription The subscription whose payment token need to be updated. * @param WC_Payment_Token $new_token The new payment token to be used for the specified subscription. * * @return bool Whether this function updates the token or not. */ public function update_subscription_token( $updated, $subscription, $new_token ) { $token_gateway_id = $new_token->get_gateway_id(); // Check if the token belongs to a reusable WCPay gateway. // Only the base gateway processes this hook, so we handle all reusable gateway tokens. if ( WC_Payment_Gateway_WCPay::GATEWAY_ID !== $this->id || ! $this->is_reusable_wcpay_gateway( $token_gateway_id ) ) { return $updated; } // Set the subscription payment method to match the token's gateway. $subscription->set_payment_method( $token_gateway_id ); $subscription->update_meta_data( '_payment_method_id', $new_token->get_token() ); $subscription->add_payment_token( $new_token ); $subscription->save(); return true; } /** * Checks if a renewal order is linked to a WCPay subscription. * * @param WC_Order $renewal_order The renewal order to check. * * @return bool True if the renewal order is linked to a renewal order. Otherwise false. */ private function is_wcpay_subscription_renewal_order( WC_Order $renewal_order ) { // Renewal orders copy metadata from the parent subscription, so we can first check if it has the `_wcpay_subscription_id` meta. if ( ! class_exists( 'WC_Payments_Subscription_Service' ) || ! $renewal_order->meta_exists( WC_Payments_Subscription_Service::SUBSCRIPTION_ID_META_KEY ) ) { return false; } // Confirm the renewal order is linked to a subscription which is a WCPay Subscription. foreach ( wcs_get_subscriptions_for_renewal_order( $renewal_order ) as $subscription ) { if ( WC_Payments_Subscription_Service::is_wcpay_subscription( $subscription ) ) { return true; } } return false; } /** * Get card mandate parameters for the order payment intent if needed. * Only required for subscriptions creation for cards issued in India. * More details https://wp.me/pc4etw-ky * * @param WC_Order $order The subscription order. * @return array Params to be included or empty array. */ public function get_mandate_params_for_order( WC_Order $order ): array { $result = []; if ( ! $this->is_subscriptions_enabled() ) { return $result; } $subscriptions = wcs_get_subscriptions_for_order( $order->get_id() ); $subscription = reset( $subscriptions ); if ( ! $subscription ) { return $result; } // TEMP Fix – Stripe validates mandate params for cards not // issued by Indian banks. Apply them only for INR as Indian banks // only support it for now. $currency = $order->get_currency(); if ( 'INR' !== $currency ) { return $result; } // Get total by adding only subscriptions and get rid of any other product or fee. $subs_amount = 0.0; foreach ( $subscriptions as $sub ) { $subs_amount += $sub->get_total(); } $amount = WC_Payments_Utils::prepare_amount( $subs_amount, $order->get_currency() ); // TEMP Fix – Prevent stale free subscription data to throw // an error due amount < 1. if ( 0 === $amount ) { return $result; } $result['setup_future_usage'] = 'off_session'; $result['payment_method_options']['card']['mandate_options'] = [ 'reference' => $order->get_id(), 'amount' => $amount, 'amount_type' => 'fixed', 'start_date' => $subscription->get_time( 'date_created' ), 'interval' => $subscription->get_billing_period(), 'interval_count' => $subscription->get_billing_interval(), 'supported_types' => [ 'india' ], ]; // Multiple subscriptions per order needs: // - Set amount type to maximum, to allow renews of any amount under the order total. // - Set interval to sporadic, to not follow any specific interval. // - Unset interval count, because it doesn't apply anymore. if ( 1 < count( $subscriptions ) ) { $result['card']['mandate_options']['amount_type'] = 'maximum'; $result['card']['mandate_options']['interval'] = 'sporadic'; if ( isset( $result['card']['mandate_options']['interval_count'] ) ) { unset( $result['card']['mandate_options']['interval_count'] ); } } return $result; } /** * Add an order note if the renew intent customer notification requires the merchant to authenticate the payment. * The note includes the charge attempt date and let the merchant know the need of an off-session step by the customer. * * @param WC_Order $order The renew order. * @param array $processing Processing state from Stripe's intent response. * @return void */ public function maybe_add_customer_notification_note( WC_Order $order, array $processing = [] ) { $approval_requested = $processing['card']['customer_notification']['approval_requested'] ?? false; $completes_at = $processing['card']['customer_notification']['completes_at'] ?? null; if ( $approval_requested && $completes_at ) { $attempt_date = wp_date( get_option( 'date_format', 'F j, Y' ), $completes_at, wp_timezone() ); $attempt_time = wp_date( get_option( 'time_format', 'g:i a' ), $completes_at, wp_timezone() ); $note = sprintf( /* translators: 1) date in date_format or 'F j, Y'; 2) time in time_format or 'g:i a' */ __( 'The customer must authorize this payment via a notification sent to them by the bank which issued their card. The authorization must be completed before %1$s at %2$s, when the charge will be attempted.', 'woocommerce-payments' ), $attempt_date, $attempt_time ); $order->add_order_note( $note ); } } /** * Get mandate ID parameter to renewal payment if exists. * Only required for subscriptions renewals for cards issued in India. * More details https://wp.me/pc4etw-ky * * @param WC_Order $renewal_order The subscription renewal order. * @return string Param to be included or empty array. */ public function get_mandate_param_for_renewal_order( WC_Order $renewal_order ): string { $subscriptions = wcs_get_subscriptions_for_renewal_order( $renewal_order->get_id() ); $subscription = reset( $subscriptions ); if ( ! $subscription ) { return ''; } $parent_order = wc_get_order( $subscription->get_parent_id() ); if ( ! $parent_order ) { return ''; } $mandate = $parent_order->get_meta( '_stripe_mandate_id', true ); if ( empty( $mandate ) ) { return ''; } return $mandate; } /** * Switch subscription to Amazon Pay gateway when created via Express Checkout. * * ECE payments are initially processed by the base gateway, but Amazon Pay subscriptions * need to use the split Amazon Pay gateway for proper renewal handling. * * This runs at priority 9, before maybe_force_subscription_to_manual (priority 10). * * @param WC_Subscription $subscription The subscription being created. */ public function maybe_switch_subscription_to_amazon_pay_gateway( $subscription ) { // Only process subscriptions using the base WCPay gateway. $payment_method_id = $subscription->get_payment_method(); if ( WC_Payment_Gateway_WCPay::GATEWAY_ID !== $payment_method_id ) { return; } // Check if this is an Amazon Pay Express Checkout payment. $parent_order = $subscription->get_parent(); if ( ! $parent_order ) { return; } // technically, `$express_checkout_type` could also be `google_pay` or `apple_pay`. // But those are card methods, processed through `woocommerce_payments`, not through `woocommerce_payments_google_pay`. $express_checkout_type = $parent_order->get_meta( '_wcpay_express_checkout_payment_method' ); if ( AmazonPayDefinition::get_id() !== $express_checkout_type ) { return; } // Switch to the Amazon Pay split gateway. $amazon_pay_gateway_id = WC_Payment_Gateway_WCPay::GATEWAY_ID . '_' . AmazonPayDefinition::get_id(); $subscription->set_payment_method( $amazon_pay_gateway_id ); $subscription->save(); } /** * Force subscription to manual renewal if non-reusable payment method was used. * * This runs at priority 10, after maybe_switch_subscription_to_amazon_pay_gateway (priority 9). * * @param WC_Subscription $subscription The subscription being created. */ public function maybe_force_subscription_to_manual( $subscription ) { // Only process WCPay subscriptions (including split UPE gateways like woocommerce_payments_ideal). $payment_method_id = $subscription->get_payment_method(); if ( 0 !== strpos( $payment_method_id, WC_Payment_Gateway_WCPay::GATEWAY_ID ) ) { return; } // Check if this is a reusable payment method (card, Amazon Pay). // Reusable payment methods can be charged for subscription renewals, so no action needed. if ( $this->is_reusable_wcpay_gateway( $payment_method_id ) ) { return; } // This is a split UPE gateway (non-reusable payment method). // Extract the payment method type from the gateway ID (e.g., "ideal" from "woocommerce_payments_ideal"). $payment_method_type = str_replace( WC_Payment_Gateway_WCPay::GATEWAY_ID . '_', '', $payment_method_id ); // Store the original payment method ID for reference. $subscription->update_meta_data( '_wcpay_original_payment_method_id', $payment_method_id ); // Set to manual renewal (keep the original split payment method ID). $subscription->set_requires_manual_renewal( true ); $subscription->save(); // Add order note confirming the subscription was set to manual. $subscription->add_order_note( sprintf( /* translators: %s: payment method type */ __( 'Subscription set to manual renewal because %s is a non-reusable payment method.', 'woocommerce-payments' ), $payment_method_type ) ); } /** * When a customer changes the payment method for a subscription order, updates the * payment method via WC_Subscriptions_Change_Payment_Gateway and adds a notice. * * @param WC_Order $order The order whose payment method was changed. */ public function maybe_update_subscription_payment_method( WC_Order $order ) { if ( ! class_exists( 'WC_Subscriptions_Change_Payment_Gateway' ) ) { return; } $payment_token = $this->get_payment_token( $order ); WC_Subscriptions_Change_Payment_Gateway::update_payment_method( $order, $payment_token->get_gateway_id() ); $notice = __( 'Payment method updated.', 'woocommerce-payments' ); if ( WC_Subscriptions_Change_Payment_Gateway::will_subscription_update_all_payment_methods( $order ) && WC_Subscriptions_Change_Payment_Gateway::update_all_payment_methods_from_subscription( $order, $payment_token->get_gateway_id() ) ) { $notice = __( 'Payment method updated for all your current subscriptions.', 'woocommerce-payments' ); } wc_add_notice( $notice ); } }