add payment method page. */ class WC_Payments_Token_Service { const REUSABLE_GATEWAYS_BY_PAYMENT_METHOD = [ Payment_Method::CARD => WC_Payment_Gateway_WCPay::GATEWAY_ID, Payment_Method::SEPA => WC_Payment_Gateway_WCPay::GATEWAY_ID . '_' . Payment_Method::SEPA, Payment_Method::LINK => WC_Payment_Gateway_WCPay::GATEWAY_ID, Payment_Method::AMAZON_PAY => WC_Payment_Gateway_WCPay::GATEWAY_ID . '_' . Payment_Method::AMAZON_PAY, ]; const CACHED_PAYMENT_METHODS_META_KEY = '_wcpay_payment_methods'; /** * Client for making requests to the WooCommerce Payments API * * @var WC_Payments_API_Client */ private $payments_api_client; /** * WC_Payments_Customer instance for working with customer information * * @var WC_Payments_Customer_Service */ private $customer_service; /** * WC_Payments_Token_Service constructor. * * @param WC_Payments_API_Client $payments_api_client Payments API client. * @param WC_Payments_Customer_Service $customer_service Customer class instance. */ public function __construct( WC_Payments_API_Client $payments_api_client, WC_Payments_Customer_Service $customer_service ) { $this->payments_api_client = $payments_api_client; $this->customer_service = $customer_service; } /** * Initializes hooks. */ public function init_hooks() { add_action( 'woocommerce_payment_token_deleted', [ $this, 'woocommerce_payment_token_deleted' ], 10, 2 ); add_action( 'woocommerce_payment_token_set_default', [ $this, 'woocommerce_payment_token_set_default' ], 10, 2 ); add_filter( 'woocommerce_get_customer_payment_tokens', [ $this, 'woocommerce_get_customer_payment_tokens' ], 10, 3 ); add_filter( 'woocommerce_payment_methods_list_item', [ $this, 'get_account_saved_payment_methods_list_item_sepa' ], 10, 2 ); add_filter( 'woocommerce_payment_methods_list_item', [ $this, 'get_account_saved_payment_methods_list_item_link' ], 10, 2 ); add_filter( 'woocommerce_payment_methods_list_item', [ $this, 'get_account_saved_payment_methods_list_item_wallet' ], 10, 2 ); add_filter( 'woocommerce_payment_methods_list_item', [ $this, 'get_account_saved_payment_methods_list_item_amazon_pay' ], 10, 2 ); add_filter( 'woocommerce_get_credit_card_type_label', [ $this, 'normalize_sepa_label' ] ); add_filter( 'woocommerce_get_credit_card_type_label', [ $this, 'normalize_stripe_link_label' ] ); } /** * Creates and add a token to a user, based on the payment_method object * * @param array $payment_method Payment method to be added. * @param WP_User $user User to attach payment method to. * @return WC_Payment_Token The WC object for the payment token. */ public function add_token_to_user( $payment_method, $user ) { // Clear cached payment methods. $this->clear_cached_payment_methods_for_user( $user->ID ); switch ( $payment_method['type'] ) { case Payment_Method::SEPA: $token = new WC_Payment_Token_WCPay_SEPA(); $gateway_id = WC_Payment_Gateway_WCPay::GATEWAY_ID . '_' . Payment_Method::SEPA; $token->set_gateway_id( $gateway_id ); $token->set_last4( $payment_method[ Payment_Method::SEPA ]['last4'] ); break; case Payment_Method::LINK: $token = new WC_Payment_Token_WCPay_Link(); $gateway_id = WC_Payment_Gateway_WCPay::GATEWAY_ID; $token->set_gateway_id( $gateway_id ); $token->set_email( $payment_method[ Payment_Method::LINK ]['email'] ); break; case Payment_Method::AMAZON_PAY: $token = new WC_Payment_Token_WCPay_Amazon_Pay(); $gateway_id = WC_Payment_Gateway_WCPay::GATEWAY_ID . '_' . Payment_Method::AMAZON_PAY; $token->set_gateway_id( $gateway_id ); // Amazon Pay stores email in billing_details. The token class handles redaction. if ( ! empty( $payment_method['billing_details']['email'] ) ) { $token->set_email( $payment_method['billing_details']['email'] ); } break; case Payment_Method::CARD_PRESENT: $token = new WC_Payment_Token_CC(); $token->set_gateway_id( WC_Payment_Gateway_WCPay::GATEWAY_ID ); $token->set_expiry_month( $payment_method[ Payment_Method::CARD_PRESENT ]['exp_month'] ); $token->set_expiry_year( $payment_method[ Payment_Method::CARD_PRESENT ]['exp_year'] ); $token->set_card_type( strtolower( $payment_method[ Payment_Method::CARD_PRESENT ]['brand'] ) ); $token->set_last4( $payment_method[ Payment_Method::CARD_PRESENT ]['last4'] ); break; default: $token = new WC_Payment_Token_CC(); $token->set_gateway_id( WC_Payment_Gateway_WCPay::GATEWAY_ID ); $token->set_expiry_month( $payment_method[ Payment_Method::CARD ]['exp_month'] ); $token->set_expiry_year( $payment_method[ Payment_Method::CARD ]['exp_year'] ); $token->set_card_type( strtolower( $payment_method[ Payment_Method::CARD ]['display_brand'] ?? $payment_method[ Payment_Method::CARD ]['networks']['preferred'] ?? $payment_method[ Payment_Method::CARD ]['brand'] ) ); $token->set_last4( $payment_method[ Payment_Method::CARD ]['last4'] ); if ( ! empty( $payment_method[ Payment_Method::CARD ]['wallet']['type'] ) ) { $token->add_meta_data( '_wcpay_wallet_type', $payment_method[ Payment_Method::CARD ]['wallet']['type'], true ); } } $token->set_token( $payment_method['id'] ); $token->set_user_id( $user->ID ); $token->save(); return $token; } /** * Adds a payment method to a user. * * @param string $payment_method_id Payment method to be added. * @param WP_User $user User to attach payment method to. * @return WC_Payment_Token_CC The newly created token. */ public function add_payment_method_to_user( $payment_method_id, $user ) { $payment_method_object = $this->payments_api_client->get_payment_method( $payment_method_id ); return $this->add_token_to_user( $payment_method_object, $user ); } /** * Returns boolean value if payment method type matches relevant payment gateway. * * @param string $payment_method_type Stripe payment method type ID. * @param string $gateway_id WC payment gateway ID. * @return bool True, if payment method type matches gateway, false if otherwise. */ public function is_valid_payment_method_type_for_gateway( $payment_method_type, $gateway_id ) { return self::REUSABLE_GATEWAYS_BY_PAYMENT_METHOD[ $payment_method_type ] === $gateway_id; } /** * Clear payment methods cache for a user. * * @param int $user_id WC user ID. */ public function clear_cached_payment_methods_for_user( $user_id ) { if ( WC_Payments::is_network_saved_cards_enabled() ) { return; // No need to do anything, payment methods will never be cached in this case. } delete_user_meta( $user_id, self::CACHED_PAYMENT_METHODS_META_KEY ); } /** * Clear all cached payment methods. * Used when account data is updated and all payment method caches need to be cleared. */ public function clear_all_cached_payment_methods() { global $wpdb; if ( WC_Payments::is_network_saved_cards_enabled() ) { return; // No need to do anything, payment methods will never be cached in this case. } // Tap straight into the database and delete the meta key for all users. $wpdb->query( $wpdb->prepare( "DELETE FROM $wpdb->usermeta WHERE meta_key = %s", self::CACHED_PAYMENT_METHODS_META_KEY ) ); /** * Legacy: Payment methods were stored in the database cache with the `wcpay_pm_` prefix. * When cleaning up cached payment methods, we need to flush the database from old cached data as well. * * This method gets called for account updates. Even though those are rare, they should be a * good opportunity to clean up old cached data. */ $options = $wpdb->get_results( "SELECT option_name FROM $wpdb->options WHERE option_name LIKE 'wcpay_pm_%'" ); foreach ( $options as $option ) { delete_option( $option->option_name ); } } /** * Gets saved tokens from API if they don't already exist in WooCommerce. * * @param array $tokens Array of tokens. * @param string $user_id WC user ID. * @param string $gateway_id WC gateway ID. * @return array */ public function woocommerce_get_customer_payment_tokens( $tokens, $user_id, $gateway_id ) { if ( ( ! empty( $gateway_id ) && ! in_array( $gateway_id, self::REUSABLE_GATEWAYS_BY_PAYMENT_METHOD, true ) ) || ! is_user_logged_in() ) { return $tokens; } if ( count( $tokens ) >= get_option( 'posts_per_page' ) ) { // The tokens data store is not paginated and only the first "post_per_page" (defaults to 10) tokens are retrieved. // Having 10 saved credit cards is considered an unsupported edge case, new ones that have been stored in Stripe won't be added. return $tokens; } try { $customer_id = $this->customer_service->get_customer_id_by_user_id( $user_id ); if ( null === $customer_id ) { return $tokens; } $stored_tokens = []; foreach ( $tokens as $token ) { if ( in_array( $token->get_gateway_id(), self::REUSABLE_GATEWAYS_BY_PAYMENT_METHOD, true ) ) { $stored_tokens[ $token->get_token() ] = $token; } } $payment_methods = $this->get_payment_methods_from_stripe( $user_id, $customer_id, $gateway_id ); } catch ( Exception $e ) { Logger::error( 'Failed to fetch payment methods for customer.' . $e ); return $tokens; } // Prevent unnecessary recursion, WC_Payment_Token::save() ends up calling 'woocommerce_get_customer_payment_tokens' in some cases. remove_action( 'woocommerce_get_customer_payment_tokens', [ $this, 'woocommerce_get_customer_payment_tokens' ], 10, 3 ); foreach ( $payment_methods as $payment_method ) { if ( ! isset( $payment_method['type'] ) ) { continue; } if ( ! isset( $stored_tokens[ $payment_method['id'] ] ) && ( $this->is_valid_payment_method_type_for_gateway( $payment_method['type'], $gateway_id ) || empty( $gateway_id ) ) ) { $token = $this->add_token_to_user( $payment_method, get_user_by( 'id', $user_id ) ); $tokens[ $token->get_id() ] = $token; } else { unset( $stored_tokens[ $payment_method['id'] ] ); } } add_action( 'woocommerce_get_customer_payment_tokens', [ $this, 'woocommerce_get_customer_payment_tokens' ], 10, 3 ); // Remove the payment methods that no longer exist in Stripe's side. remove_action( 'woocommerce_payment_token_deleted', [ $this, 'woocommerce_payment_token_deleted' ], 10, 2 ); foreach ( $stored_tokens as $token ) { unset( $tokens[ $token->get_id() ] ); $token->delete(); } add_action( 'woocommerce_payment_token_deleted', [ $this, 'woocommerce_payment_token_deleted' ], 10, 2 ); return $tokens; } /** * Gets payment methods from Stripe. * * @param string $user_id WP user ID. * @param string $customer_id WC customer ID. * @param string $gateway_id WC gateway ID. * @return array Payment methods. */ private function get_payment_methods_from_stripe( $user_id, $customer_id, $gateway_id ) { // Prepare all payment method types that are to be retrieved, based on the gateway. $types_to_retrieve = $this->get_retrievable_payment_method_types( $gateway_id ); // Load cached data, verify it is for the same customer ID. Bust if they do not match. $cache = get_user_meta( $user_id, self::CACHED_PAYMENT_METHODS_META_KEY, true ); if ( ! is_array( $cache ) || ! isset( $cache['customer_id'] ) || $cache['customer_id'] !== $customer_id ) { $cache = [ 'customer_id' => $customer_id, ]; } $payment_methods = []; // Check whether all retrievable payment method types are cached. // Combine with existing data in case there are cached PMs for other gateway IDs. foreach ( $types_to_retrieve as $type ) { if ( isset( $cache[ 'payment_method_' . $type ] ) ) { $payment_methods = array_merge( $payment_methods, $cache[ 'payment_method_' . $type ] ); unset( $types_to_retrieve[ array_search( $type, $types_to_retrieve, true ) ] ); } } if ( empty( $types_to_retrieve ) ) { return $payment_methods; } foreach ( $types_to_retrieve as $type ) { $type_methods = $this->customer_service->get_payment_methods_for_customer( $customer_id, $type ); // Add to cache. $cache[ 'payment_method_' . $type ] = $type_methods; // Add to the list that will be returned. $payment_methods = array_merge( $payment_methods, $type_methods ); } update_user_meta( $user_id, self::CACHED_PAYMENT_METHODS_META_KEY, $cache ); return $payment_methods; } /** * Retrieves the payment method types for which tokens should be retrieved. * * This function determines the appropriate payment method types based on the provided gateway ID. * - If a gateway ID is provided, it retrieves the payment methods specific to that gateway to prevent duplication of saved tokens under incorrect payment methods during checkout. * - If no gateway ID is provided, it retrieves the default payment methods to fetch all saved tokens, e.g., for the Blocks checkout or My Account page. * * @param string|null $gateway_id The optional ID of the gateway. * @return array The list of retrievable payment method types. */ private function get_retrievable_payment_method_types( $gateway_id = null ) { if ( empty( $gateway_id ) ) { return $this->get_all_retrievable_payment_types(); } else { return $this->get_gateway_specific_retrievable_payment_types( $gateway_id ); } } /** * Returns all the enabled retrievable payment method types. * * @return array Enabled retrievable payment method types. */ private function get_all_retrievable_payment_types() { $types = [ Payment_Method::CARD ]; if ( $this->is_payment_method_enabled( Payment_Method::SEPA ) ) { $types[] = Payment_Method::SEPA; } if ( $this->is_payment_method_enabled( Payment_Method::LINK ) ) { $types[] = Payment_Method::LINK; } $amazon_pay = WC_Payments::get_payment_gateway_by_id( \WCPay\PaymentMethods\Configs\Definitions\AmazonPayDefinition::get_id() ); if ( $amazon_pay && $amazon_pay->is_enabled() ) { $types[] = Payment_Method::AMAZON_PAY; } return $types; } /** * Returns retrievable payment method types for a given gateway. * * @param string $gateway_id The ID of the gateway. * @return array Retrievable payment method types for the specified gateway. */ private function get_gateway_specific_retrievable_payment_types( $gateway_id ) { $types = []; foreach ( self::REUSABLE_GATEWAYS_BY_PAYMENT_METHOD as $payment_method => $gateway ) { if ( $gateway !== $gateway_id ) { continue; } // Stripe Link is part of the card gateway, so we need to check separately if Link is enabled. if ( Payment_Method::LINK === $payment_method && ! $this->is_payment_method_enabled( Payment_Method::LINK ) ) { continue; } $types[] = $payment_method; } return $types; } /** * Checks if a payment method is enabled. * * @param string $payment_method The payment method to check. * @return bool True if the payment method is enabled, false otherwise. */ private function is_payment_method_enabled( $payment_method ) { return in_array( $payment_method, WC_Payments::get_gateway()->get_upe_enabled_payment_method_ids(), true ); } /** * Delete token from Stripe. * * @param string $token_id Token ID. * @param WC_Payment_Token $token Token object. * * @throws Exception */ public function woocommerce_payment_token_deleted( $token_id, $token ) { // If it's not reusable payment method, we don't need to perform any additional checks. if ( ! in_array( $token->get_gateway_id(), self::REUSABLE_GATEWAYS_BY_PAYMENT_METHOD, true ) ) { return; } // First check if it's live mode. // Second check if it's admin. // Third check if it's not production environment. // When all conditions are met, we don't want to delete the payment method from Stripe. // This is to avoid detaching the payment method from the live stripe account on non production environments. if ( WC_Payments::mode()->is_live() && is_admin() && 'production' !== wp_get_environment_type() ) { return; } try { $this->payments_api_client->detach_payment_method( $token->get_token() ); // Clear cached payment methods. $this->clear_cached_payment_methods_for_user( $token->get_user_id() ); } catch ( Exception $e ) { Logger::log( 'Error detaching payment method:' . $e->getMessage() ); } } /** * Set as default in Stripe. * * @param string $token_id Token ID. * @param WC_Payment_Token $token Token object. */ public function woocommerce_payment_token_set_default( $token_id, $token ) { if ( in_array( $token->get_gateway_id(), self::REUSABLE_GATEWAYS_BY_PAYMENT_METHOD, true ) ) { $customer_id = $this->customer_service->get_customer_id_by_user_id( $token->get_user_id() ); if ( $customer_id ) { $this->customer_service->set_default_payment_method_for_customer( $customer_id, $token->get_token() ); // Clear cached payment methods. $this->clear_cached_payment_methods_for_user( $token->get_user_id() ); } } } /** * Controls the output for SEPA on the my account page. * * @param array $item Individual list item from woocommerce_saved_payment_methods_list. * @param WC_Payment_Token|WC_Payment_Token_WCPay_SEPA $payment_token The payment token associated with this method entry. * @return array Filtered item */ public function get_account_saved_payment_methods_list_item_sepa( $item, $payment_token ) { if ( WC_Payment_Token_WCPay_SEPA::TYPE === strtolower( $payment_token->get_type() ) ) { $item['method']['last4'] = $payment_token->get_last4(); $item['method']['brand'] = esc_html__( 'SEPA IBAN', 'woocommerce-payments' ); } return $item; } /** * Controls the output for Stripe Link on the My account page. * * @param array $item Individual list item from woocommerce_saved_payment_methods_list. * @param WC_Payment_Token|WC_Payment_Token_WCPay_Link $payment_token The payment token associated with this method entry. * @return array Filtered item */ public function get_account_saved_payment_methods_list_item_link( $item, $payment_token ) { if ( WC_Payment_Token_WCPay_Link::TYPE === strtolower( $payment_token->get_type() ) ) { $item['method']['last4'] = $payment_token->get_redacted_email(); $item['method']['brand'] = esc_html__( 'Stripe Link email', 'woocommerce-payments' ); } return $item; } /** * Controls the output for Wallet tokens on the My account page. * * @param array $item Individual list item from woocommerce_saved_payment_methods_list. * @param WC_Payment_Token|WC_Payment_Token_WCPay_Link $payment_token The payment token associated with this method entry. * @return array Filtered item */ public function get_account_saved_payment_methods_list_item_wallet( $item, $payment_token ) { if ( 'cc' !== strtolower( $payment_token->get_type() ) ) { return $item; } $wallet_type = $payment_token->get_meta( '_wcpay_wallet_type', true ); if ( empty( $wallet_type ) ) { return $item; } // Google Pay and Apple Pay are separate payment methods, so we can retrieve them from the registered payment methods class. $payment_method = WC_Payments::get_payment_method_by_id( $wallet_type ); if ( ! $payment_method || ! method_exists( $payment_method, 'get_title' ) ) { return $item; } $original_brand = $item['method']['brand'] ?? ''; $item['method']['brand'] = sprintf( /* translators: 1: wallet name, 2: card brand */ _x( '%1$s %2$s', 'Payment token with wallet', 'woocommerce-payments' ), $payment_method->get_title(), $original_brand ); return $item; } /** * Controls the output for Amazon Pay tokens on the My Account page. * * @param array $item Individual list item from woocommerce_saved_payment_methods_list. * @param WC_Payment_Token|WC_Payment_Token_WCPay_Amazon_Pay $payment_token The payment token associated with this method entry. * @return array Filtered item. */ public function get_account_saved_payment_methods_list_item_amazon_pay( $item, $payment_token ) { if ( WC_Payment_Token_WCPay_Amazon_Pay::TYPE === strtolower( $payment_token->get_type() ) ) { $item['method']['last4'] = $payment_token->get_email(); $item['method']['brand'] = esc_html__( 'Amazon Pay', 'woocommerce-payments' ); } return $item; } /** * Normalizes the SEPA IBAN label on My Account page. * * @param string $label Token label. * @return string $label Capitalized SEPA IBAN label. */ public function normalize_sepa_label( $label ) { if ( 'sepa iban' === strtolower( $label ) ) { return __( 'SEPA IBAN', 'woocommerce-payments' ); } return $label; } /** * Normalizes the Stripe Link label on My Account page. * * @param string $label Token label. * @return string $label Capitalized SEPA IBAN label. */ public function normalize_stripe_link_label( $label ) { if ( 'stripe link email' === strtolower( $label ) ) { return 'Stripe Link email'; } return $label; } }