diff --git a/changelog/woopmnt-5356-review-if-any-woopayments-meta-need-to-be-excluded-from-subs b/changelog/woopmnt-5356-review-if-any-woopayments-meta-need-to-be-excluded-from-subs new file mode 100644 index 00000000000..38f533f937b --- /dev/null +++ b/changelog/woopmnt-5356-review-if-any-woopayments-meta-need-to-be-excluded-from-subs @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Attempt to ensure no incorrect data for subscription orders. diff --git a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php index ca9449c071c..dcef4bef0cf 100644 --- a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php +++ b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php @@ -28,6 +28,37 @@ trait WC_Payment_Gateway_WCPay_Subscriptions_Trait { use WC_Payments_Subscriptions_Utilities; + /** + * Stores the payment method meta table name + * + * @var string + */ + private static $payment_method_meta_table = 'wc_order_tokens'; + + /** + * Stores the payment method meta key name + * + * @var string + */ + private static $payment_method_meta_key = 'token'; + + /** + * Stores a flag to indicate if the subscription integration hooks have been attached. + * + * The callbacks attached as part of maybe_init_subscriptions() only need to be attached once to avoid duplication. + * + * @var bool False by default, true once the callbacks have been attached. + */ + private static $has_attached_integration_hooks = false; + + /** + * Used to temporary keep the state of the order_pay value on the Pay for order page with the SCA authorization flow. + * For more details, see remove_order_pay_var and restore_order_pay_var hooks. + * + * @var string|int + */ + private $order_pay_var; + /** * Retrieve payment token from a subscription or order. * @@ -81,37 +112,6 @@ abstract protected function get_user_formatted_tokens_array( $user_id ); */ abstract protected function prepare_payment_information( $order ); - /** - * Stores the payment method meta table name - * - * @var string - */ - private static $payment_method_meta_table = 'wc_order_tokens'; - - /** - * Stores the payment method meta key name - * - * @var string - */ - private static $payment_method_meta_key = 'token'; - - /** - * Stores a flag to indicate if the subscription integration hooks have been attached. - * - * The callbacks attached as part of maybe_init_subscriptions() only need to be attached once to avoid duplication. - * - * @var bool False by default, true once the callbacks have been attached. - */ - private static $has_attached_integration_hooks = false; - - /** - * Used to temporary keep the state of the order_pay value on the Pay for order page with the SCA authorization flow. - * For more details, see remove_order_pay_var and restore_order_pay_var hooks. - * - * @var string|int - */ - private $order_pay_var; - /** * Initialize subscription support and hooks. */ @@ -226,6 +226,9 @@ public function maybe_init_subscriptions_hooks() { // 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 ); + + // Exclude WooPayments meta keys from being copied from parent orders to subscriptions. + add_filter( 'wcs_copy_payment_meta_to_order', [ $this, 'exclude_wcpay_meta_from_subscription_copy' ], 10, 3 ); } /** @@ -323,27 +326,6 @@ public function change_no_available_methods_message() { return wpautop( __( "Almost there!\n\nYour order has already been created, the only thing that still needs to be done is for you to authorize the payment with your bank.", 'woocommerce-payments' ) ); } - /** - * 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. * @@ -421,25 +403,6 @@ public function update_failing_payment_method( $subscription, $renewal_order ) { $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. * @@ -828,7 +791,9 @@ public function maybe_schedule_subscription_order_tracking( $order_id, $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')"; + $excluded_meta_keys = $this->get_excluded_meta_keys_for_subscription_copying(); + $excluded_meta_keys_string = "'" . implode( "', '", $excluded_meta_keys ) . "'"; + $order_meta_query .= " AND `meta_key` NOT IN ({$excluded_meta_keys_string})"; return $order_meta_query; } @@ -841,7 +806,12 @@ public function update_renewal_meta_data( $order_meta_query, $to_order, $from_or * @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'] ); + $excluded_meta_keys = $this->get_excluded_meta_keys_for_subscription_copying(); + + foreach ( $excluded_meta_keys as $meta_key ) { + unset( $order_data[ $meta_key ] ); + } + return $order_data; } @@ -903,29 +873,6 @@ public function update_subscription_token( $updated, $subscription, $new_token ) 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. @@ -1051,4 +998,154 @@ public function get_mandate_param_for_renewal_order( WC_Order $renewal_order ): return $mandate; } + + /** + * Exclude WooPayments meta keys from being copied from parent orders to subscriptions. + * + * This filter is applied when WooCommerce Subscriptions copies payment meta from parent orders + * to subscriptions, preventing stale intent data and other WooPayments-specific metadata + * from being copied. + * + * @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 exclude_wcpay_meta_from_subscription_copy( $payment_meta, $order, $subscription ) { + if ( $this->id !== $order->get_payment_method() || $this->id !== $subscription->get_payment_method() ) { + return $payment_meta; + } + + if ( ! is_array( $payment_meta ) ) { + return $payment_meta; + } + + $excluded_meta_keys = $this->get_excluded_meta_keys_for_subscription_copying(); + + // Remove excluded meta keys from the payment meta array. + foreach ( $excluded_meta_keys as $meta_key ) { + unset( $payment_meta[ $meta_key ] ); + } + + return $payment_meta; + } + + /** + * 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; + } + + /** + * 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(), + ], + ], + ]; + } + + /** + * 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 the list of WooPayments meta keys that should be excluded from subscription data copying. + * + * This prevents stale intent data and other WooPayments-specific metadata from being copied + * from parent orders to subscriptions, which can cause issues with 3DS failures and re-attempts. + * + * @return array Array of meta keys to exclude from copying. + */ + private function get_excluded_meta_keys_for_subscription_copying() { + return [ + // Order tracking and completion. + '_new_order_tracking_complete', + + // Intent-related meta keys (prevent stale intent data). + '_intent_id', + '_intention_status', + '_wcpay_intent_currency', + + // Payment method and charge data (prevent stale payment data). + '_payment_method_id', + '_charge_id', + '_charge_risk_level', + '_wcpay_payment_method_details', + '_wcpay_payment_transaction_id', + + // Customer and fraud data (should be fresh for each order). + '_stripe_customer_id', + '_wcpay_fraud_meta_box_type', + '_wcpay_fraud_outcome_status', + + // Refund data (not relevant for subscriptions). + '_wcpay_refund_id', + '_wcpay_refund_transaction_id', + '_wcpay_refund_status', + + // Transaction fees (calculated per transaction). + '_wcpay_transaction_fee', + + // Mode and environment data. + '_wcpay_mode', + + // Multibanco-specific data (payment method specific). + '_wcpay_multibanco_entity', + '_wcpay_multibanco_reference', + '_wcpay_multibanco_expiry', + '_wcpay_multibanco_url', + + // Mandate data (should be fresh for each subscription). + '_stripe_mandate_id', + + // Multi-currency data (should be calculated fresh). + '_wcpay_multi_currency_order_exchange_rate', + '_wcpay_multi_currency_order_default_currency', + ]; + } } diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions-trait.php b/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions-trait.php index b888bf80cab..73aaa3ebc1d 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions-trait.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions-trait.php @@ -105,4 +105,309 @@ public function test_maybe_init_subscriptions_with_stripe_billing_enabled() { delete_option( '_wcpay_feature_stripe_billing' ); } + + /** + * Test update_renewal_meta_data method excludes WooPayments meta keys. + */ + public function test_update_renewal_meta_data_excludes_wcpay_meta_keys() { + $original_query = 'SELECT * FROM wp_postmeta WHERE post_id = 123'; + $to_order = 456; + $from_order = 789; + + $result = $this->mock_wcpay_subscriptions_trait->update_renewal_meta_data( $original_query, $to_order, $from_order ); + + // Check that the query contains NOT IN clause. + $this->assertStringContainsString( 'NOT IN', $result ); + + // Check that specific WooPayments meta keys are excluded. + $excluded_keys = [ + '_intent_id', + '_intention_status', + '_wcpay_intent_currency', + '_payment_method_id', + '_charge_id', + '_wcpay_payment_method_details', + '_stripe_customer_id', + '_wcpay_mode', + '_stripe_mandate_id', + ]; + + foreach ( $excluded_keys as $key ) { + $this->assertStringContainsString( $key, $result, "Meta key {$key} should be excluded from SQL query" ); + } + + // Check that the original query is preserved. + $this->assertStringContainsString( $original_query, $result ); + } + + /** + * Test remove_data_renewal_order method removes WooPayments meta keys. + */ + public function test_remove_data_renewal_order_removes_wcpay_meta_keys() { + $order_data = [ + // WooPayments meta keys that should be removed. + '_intent_id' => 'pi_1234567890', + '_intention_status' => 'requires_action', + '_wcpay_intent_currency' => 'USD', + '_payment_method_id' => 'pm_1234567890', + '_charge_id' => 'ch_1234567890', + '_wcpay_payment_method_details' => '{"card":{"brand":"visa"}}', + '_stripe_customer_id' => 'cus_1234567890', + '_wcpay_fraud_meta_box_type' => 'review', + '_wcpay_fraud_outcome_status' => 'review', + '_wcpay_refund_id' => 're_1234567890', + '_wcpay_transaction_fee' => '2.50', + '_wcpay_mode' => 'test', + '_wcpay_multibanco_entity' => '12345', + '_stripe_mandate_id' => 'mandate_1234567890', + '_wcpay_multi_currency_order_exchange_rate' => '1.25', + '_wcpay_multi_currency_order_default_currency' => 'EUR', + '_new_order_tracking_complete' => '1', + // Non-WooPayments meta keys that should be preserved. + '_billing_first_name' => 'John', + '_billing_last_name' => 'Doe', + '_order_total' => '29.99', + '_order_currency' => 'USD', + ]; + + $result = $this->mock_wcpay_subscriptions_trait->remove_data_renewal_order( $order_data ); + + // Check that WooPayments meta keys are removed. + $wcpay_meta_keys = [ + '_intent_id', + '_intention_status', + '_wcpay_intent_currency', + '_payment_method_id', + '_charge_id', + '_wcpay_payment_method_details', + '_stripe_customer_id', + '_wcpay_fraud_meta_box_type', + '_wcpay_fraud_outcome_status', + '_wcpay_refund_id', + '_wcpay_transaction_fee', + '_wcpay_mode', + '_wcpay_multibanco_entity', + '_stripe_mandate_id', + '_wcpay_multi_currency_order_exchange_rate', + '_wcpay_multi_currency_order_default_currency', + '_new_order_tracking_complete', + ]; + + foreach ( $wcpay_meta_keys as $key ) { + $this->assertArrayNotHasKey( $key, $result, "WooPayments meta key {$key} should be removed" ); + } + + // Check that non-WooPayments meta keys are preserved. + $preserved_keys = [ + '_billing_first_name', + '_billing_last_name', + '_order_total', + '_order_currency', + ]; + + foreach ( $preserved_keys as $key ) { + $this->assertArrayHasKey( $key, $result, "Non-WooPayments meta key {$key} should be preserved" ); + $this->assertEquals( $order_data[ $key ], $result[ $key ], "Value for {$key} should be preserved" ); + } + } + + /** + * Test remove_data_renewal_order method with empty array. + */ + public function test_remove_data_renewal_order_with_empty_array() { + $order_data = []; + $result = $this->mock_wcpay_subscriptions_trait->remove_data_renewal_order( $order_data ); + + $this->assertIsArray( $result ); + $this->assertEmpty( $result ); + } + + /** + * Test exclude_wcpay_meta_from_subscription_copy method with WooPayments payment method. + */ + public function test_exclude_wcpay_meta_from_subscription_copy_with_wcpay_payment_method() { + // Mock order and subscription with WooPayments payment method. + $order = $this->createMock( WC_Order::class ); + $subscription = $this->createMock( WC_Subscription::class ); + + $order->method( 'get_payment_method' )->willReturn( 'woocommerce_payments' ); + $subscription->method( 'get_payment_method' )->willReturn( 'woocommerce_payments' ); + + $payment_meta = [ + // WooPayments meta keys that should be removed. + '_intent_id' => 'pi_1234567890', + '_wcpay_payment_method_details' => '{"card":{"brand":"visa"}}', + '_stripe_customer_id' => 'cus_1234567890', + '_wcpay_mode' => 'test', + // Non-WooPayments meta keys that should be preserved. + '_billing_first_name' => 'John', + '_billing_last_name' => 'Doe', + ]; + + $result = $this->mock_wcpay_subscriptions_trait->exclude_wcpay_meta_from_subscription_copy( $payment_meta, $order, $subscription ); + + // Check that WooPayments meta keys are removed. + $wcpay_meta_keys = [ + '_intent_id', + '_wcpay_payment_method_details', + '_stripe_customer_id', + '_wcpay_mode', + ]; + + foreach ( $wcpay_meta_keys as $key ) { + $this->assertArrayNotHasKey( $key, $result, "WooPayments meta key {$key} should be removed" ); + } + + // Check that non-WooPayments meta keys are preserved. + $preserved_keys = [ + '_billing_first_name', + '_billing_last_name', + ]; + + foreach ( $preserved_keys as $key ) { + $this->assertArrayHasKey( $key, $result, "Non-WooPayments meta key {$key} should be preserved" ); + $this->assertEquals( $payment_meta[ $key ], $result[ $key ], "Value for {$key} should be preserved" ); + } + } + + /** + * Test exclude_wcpay_meta_from_subscription_copy method with non-WooPayments payment method. + */ + public function test_exclude_wcpay_meta_from_subscription_copy_with_non_wcpay_payment_method() { + // Mock order and subscription with non-WooPayments payment method. + $order = $this->createMock( WC_Order::class ); + $subscription = $this->createMock( WC_Subscription::class ); + + $order->method( 'get_payment_method' )->willReturn( 'paypal' ); + $subscription->method( 'get_payment_method' )->willReturn( 'paypal' ); + + $payment_meta = [ + '_intent_id' => 'pi_1234567890', + '_wcpay_payment_method_details' => '{"card":{"brand":"visa"}}', + '_billing_first_name' => 'John', + ]; + + $result = $this->mock_wcpay_subscriptions_trait->exclude_wcpay_meta_from_subscription_copy( $payment_meta, $order, $subscription ); + + // Should return the original payment meta unchanged. + $this->assertEquals( $payment_meta, $result ); + } + + /** + * Test exclude_wcpay_meta_from_subscription_copy method with mixed payment methods. + */ + public function test_exclude_wcpay_meta_from_subscription_copy_with_mixed_payment_methods() { + // Mock order and subscription with different payment methods. + $order = $this->createMock( WC_Order::class ); + $subscription = $this->createMock( WC_Subscription::class ); + + $order->method( 'get_payment_method' )->willReturn( 'woocommerce_payments' ); + $subscription->method( 'get_payment_method' )->willReturn( 'paypal' ); + + $payment_meta = [ + '_intent_id' => 'pi_1234567890', + '_wcpay_payment_method_details' => '{"card":{"brand":"visa"}}', + '_billing_first_name' => 'John', + ]; + + $result = $this->mock_wcpay_subscriptions_trait->exclude_wcpay_meta_from_subscription_copy( $payment_meta, $order, $subscription ); + + // Should return the original payment meta unchanged. + $this->assertEquals( $payment_meta, $result ); + } + + /** + * Test exclude_wcpay_meta_from_subscription_copy method with non-array payment meta. + */ + public function test_exclude_wcpay_meta_from_subscription_copy_with_non_array_payment_meta() { + $order = $this->createMock( WC_Order::class ); + $subscription = $this->createMock( WC_Subscription::class ); + + $order->method( 'get_payment_method' )->willReturn( 'woocommerce_payments' ); + $subscription->method( 'get_payment_method' )->willReturn( 'woocommerce_payments' ); + + $payment_meta = 'not_an_array'; + + $result = $this->mock_wcpay_subscriptions_trait->exclude_wcpay_meta_from_subscription_copy( $payment_meta, $order, $subscription ); + + // Should return the original payment meta unchanged. + $this->assertEquals( $payment_meta, $result ); + } + + /** + * Test exclude_wcpay_meta_from_subscription_copy method with null payment meta. + */ + public function test_exclude_wcpay_meta_from_subscription_copy_with_null_payment_meta() { + $order = $this->createMock( WC_Order::class ); + $subscription = $this->createMock( WC_Subscription::class ); + + $order->method( 'get_payment_method' )->willReturn( 'woocommerce_payments' ); + $subscription->method( 'get_payment_method' )->willReturn( 'woocommerce_payments' ); + + $payment_meta = null; + + $result = $this->mock_wcpay_subscriptions_trait->exclude_wcpay_meta_from_subscription_copy( $payment_meta, $order, $subscription ); + + // Should return the original payment meta unchanged. + $this->assertEquals( $payment_meta, $result ); + } + + /** + * Test get_excluded_meta_keys_for_subscription_copying method returns expected keys. + */ + public function test_get_excluded_meta_keys_for_subscription_copying_returns_expected_keys() { + // Use reflection to access the private method. + $reflection = new ReflectionClass( $this->mock_wcpay_subscriptions_trait ); + $method = $reflection->getMethod( 'get_excluded_meta_keys_for_subscription_copying' ); + $method->setAccessible( true ); + + $excluded_keys = $method->invoke( $this->mock_wcpay_subscriptions_trait ); + + // Check that it returns an array. + $this->assertIsArray( $excluded_keys ); + + // Check that it contains expected WooPayments meta keys. + $expected_keys = [ + '_new_order_tracking_complete', + '_intent_id', + '_intention_status', + '_wcpay_intent_currency', + '_payment_method_id', + '_charge_id', + '_charge_risk_level', + '_wcpay_payment_method_details', + '_wcpay_payment_transaction_id', + '_stripe_customer_id', + '_wcpay_fraud_meta_box_type', + '_wcpay_fraud_outcome_status', + '_wcpay_refund_id', + '_wcpay_refund_transaction_id', + '_wcpay_refund_status', + '_wcpay_transaction_fee', + '_wcpay_mode', + '_wcpay_multibanco_entity', + '_wcpay_multibanco_reference', + '_wcpay_multibanco_expiry', + '_wcpay_multibanco_url', + '_stripe_mandate_id', + '_wcpay_multi_currency_order_exchange_rate', + '_wcpay_multi_currency_order_default_currency', + ]; + + foreach ( $expected_keys as $key ) { + $this->assertContains( $key, $excluded_keys, "Expected meta key {$key} should be in excluded keys list" ); + } + + // Check that it doesn't contain non-WooPayments meta keys. + $non_wcpay_keys = [ + '_billing_first_name', + '_billing_last_name', + '_order_total', + '_order_currency', + ]; + + foreach ( $non_wcpay_keys as $key ) { + $this->assertNotContains( $key, $excluded_keys, "Non-WooPayments meta key {$key} should not be in excluded keys list" ); + } + } }