From 6bade12b81fee338338da5b107c4c4f4e2f756bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ismael=20Marti=CC=81n=20Alabarce?= Date: Wed, 29 Sep 2021 16:13:47 +0200 Subject: [PATCH 01/22] Initial add intent mandate implementaiton Due to the new additional factor authentication for all recurring transactions from Indian credit cards, we need to include a new parameter on every subscription. --- includes/class-wc-payment-gateway-wcpay.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index bec0830174d..0c0b14107a5 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -953,6 +953,12 @@ public function process_payment_for_order( $cart, $payment_information, $additio ]; } + // Add card mandate options parameters to the order payment intent if needed. + $additional_api_parameters = array_merge( + $additional_api_parameters, + $this->maybe_add_mandate_to_order_payment( $order ) + ); + if ( $payment_needed ) { $converted_amount = WC_Payments_Utils::prepare_amount( $amount, $order->get_currency() ); $currency = strtolower( $order->get_currency() ); From 2dc71684aa10e8364596802581f18dcf472cf1af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ismael=20Marti=CC=81n=20Alabarce?= Date: Fri, 1 Oct 2021 15:27:34 +0200 Subject: [PATCH 02/22] Include mandate id if exists --- .../trait-wc-payment-gateway-wcpay-subscriptions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4731df2278f..98872cb5c4f 100644 --- a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php +++ b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php @@ -272,7 +272,7 @@ public function scheduled_subscription_payment( $amount, $renewal_order ) { try { $payment_information = new Payment_Information( '', $renewal_order, Payment_Type::RECURRING(), $token, Payment_Initiated_By::MERCHANT() ); - $this->process_payment_for_order( null, $payment_information ); + $this->process_payment_for_order( null, $payment_information, $additional_api_parameters ); } catch ( API_Exception $e ) { Logger::error( 'Error processing subscription renewal: ' . $e->getMessage() ); // TODO: Update to use Order_Service->mark_payment_failed. From 3ddb5ff871822112c73de7ccc045eb4333e04efe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ismael=20Marti=CC=81n=20Alabarce?= Date: Tue, 5 Oct 2021 11:29:59 +0200 Subject: [PATCH 03/22] Handle multiple subscriptions on a single order --- .../trait-wc-payment-gateway-wcpay-subscriptions.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 98872cb5c4f..c2c358611e2 100644 --- a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php +++ b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php @@ -281,7 +281,7 @@ public function scheduled_subscription_payment( $amount, $renewal_order ) { if ( ! empty( $payment_information ) ) { $note = sprintf( WC_Payments_Utils::esc_interpolated_html( - /* translators: %1: the failed payment amount, %2: error message */ + /* 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' @@ -787,6 +787,16 @@ private function is_wcpay_subscription_renewal_order( WC_Order $renewal_order ) if ( WC_Payments_Subscription_Service::is_wcpay_subscription( $subscription ) ) { return true; } + + // 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['payment_method_options']['card']['mandate_options']['amount_type'] = 'maximum'; + $result['payment_method_options']['card']['mandate_options']['interval'] = 'sporadic'; + unset( $result['payment_method_options']['card']['mandate_options']['interval_count'] ); + } } return false; From 37dc4683296850127f284c0da96f69c3b09c7bff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ismael=20Marti=CC=81n=20Alabarce?= Date: Tue, 5 Oct 2021 15:37:34 +0200 Subject: [PATCH 04/22] Add order note about payment authorisation by customer --- includes/class-wc-payment-gateway-wcpay.php | 3 +++ ...wc-payment-gateway-wcpay-subscriptions.php | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index 0c0b14107a5..a58158c746d 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -1174,6 +1174,9 @@ public function process_payment_for_order( $cart, $payment_information, $additio $this->attach_exchange_info_to_order( $order, $charge_id ); $this->update_order_status_from_intent( $order, $intent_id, $status, $charge_id ); + // Add order note when next action type is card_await_notification. + $this->maybe_add_card_await_notification_note( $order, $next_action ); + if ( isset( $response ) ) { return $response; } 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 c2c358611e2..e72d8e50b55 100644 --- a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php +++ b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php @@ -801,4 +801,29 @@ private function is_wcpay_subscription_renewal_order( WC_Order $renewal_order ) return false; } + + /** + * Add an order note if the renew intent next action 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 $next_action Next action intent content from Stripe's response. + * @return void + */ + public function maybe_add_card_await_notification_note( WC_Order $order, array $next_action ) { + if ( isset( $next_action['type'] ) && 'card_await_notification' === $next_action['type'] ) { + $charge_attempt_at = $next_action['card_await_notification']['charge_attempt_at']; + $attempt_date = wp_date( get_option( 'date_format', 'F j, Y' ), $charge_attempt_at, wp_timezone() ); + $attempt_time = wp_date( get_option( 'time_format', 'g:i a' ), $charge_attempt_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 the pre-debit notification sent to them by their card issuing bank before %1$s at %2$s, when the charge will be attempted.', 'woocommerce-payments' ), + $attempt_date, + $attempt_time + ); + + $order->add_order_note( $note ); + } + } } From a70792db7563bc28d67c32c52eec31d2db3c3587 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ismael=20Marti=CC=81n=20Alabarce?= Date: Wed, 6 Oct 2021 12:30:54 +0200 Subject: [PATCH 05/22] Add a comma to the order note text --- .../trait-wc-payment-gateway-wcpay-subscriptions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e72d8e50b55..d41345656d9 100644 --- a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php +++ b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php @@ -818,7 +818,7 @@ public function maybe_add_card_await_notification_note( WC_Order $order, array $ $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 the pre-debit notification sent to them by their card issuing bank before %1$s at %2$s, when the charge will be attempted.', 'woocommerce-payments' ), + __( 'The customer must authorize this payment via the pre-debit notification sent to them by their card issuing bank, before %1$s at %2$s, when the charge will be attempted.', 'woocommerce-payments' ), $attempt_date, $attempt_time ); From c1986cf1322ee3eba4ffbf6637109ca3ef30dc95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ismael=20Marti=CC=81n=20Alabarce?= Date: Thu, 7 Oct 2021 10:20:29 +0200 Subject: [PATCH 06/22] Fix current tests Add mocks, and required methods and properties to helpers --- .../helpers/class-wc-helper-subscription.php | 7 ++++ ...wc-payment-gateway-wcpay-payment-types.php | 35 +++++++++++++++---- ...ay-wcpay-subscriptions-process-payment.php | 27 +++++++------- 3 files changed, 47 insertions(+), 22 deletions(-) diff --git a/tests/unit/helpers/class-wc-helper-subscription.php b/tests/unit/helpers/class-wc-helper-subscription.php index 60eed6f43ce..84c49f72538 100644 --- a/tests/unit/helpers/class-wc-helper-subscription.php +++ b/tests/unit/helpers/class-wc-helper-subscription.php @@ -92,6 +92,13 @@ class WC_Subscription extends WC_Mock_WC_Data { */ public $currency = 'USD'; + /** + * Created timestamp + * + * @var int + */ + public $date_created; + /** * Helper variable for mocking the subscription's billing period. * diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay-payment-types.php b/tests/unit/test-class-wc-payment-gateway-wcpay-payment-types.php index 443b12dadba..cb6bd878f62 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay-payment-types.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay-payment-types.php @@ -162,9 +162,26 @@ function ( $order ) use ( $value ) { ); } + private function mock_wcs_get_subscriptions_for_order( $value ) { + WC_Subscriptions::set_wcs_get_subscriptions_for_order( + function ( $order ) use ( $value ) { + return $value; + } + ); + } + + private function mock_wcs_get_subscriptions_for_renewal_order( $value ) { + WC_Subscriptions::set_wcs_get_subscriptions_for_renewal_order( + function ( $order ) use ( $value ) { + return $value; + } + ); + } + public function test_single_payment() { $order = WC_Helper_Order::create_order(); $this->mock_wcs_order_contains_subscription( false ); + $this->mock_wcs_get_subscriptions_for_order( [] ); $intent = WC_Helper_Intention::create_intention(); $this->mock_api_client @@ -200,8 +217,11 @@ function( $metadata ) use ( $order ) { } public function test_initial_subscription_payment() { - $order = WC_Helper_Order::create_order(); + $order = WC_Helper_Order::create_order(); + $subscription = new WC_Subscription(); + $subscription->set_parent( $order ); $this->mock_wcs_order_contains_subscription( true ); + $this->mock_wcs_get_subscriptions_for_order( [ $subscription ] ); $intent = WC_Helper_Intention::create_intention(); $this->mock_api_client @@ -230,13 +250,14 @@ function( $metadata ) use ( $order ) { } public function test_renewal_subscription_payment() { - $order = WC_Helper_Order::create_order(); + $order = WC_Helper_Order::create_order(); + $subscription = new WC_Subscription(); + $subscription->set_parent( $order ); + $this->mock_wcs_order_contains_subscription( true ); - WC_Subscriptions::set_wcs_get_subscriptions_for_order( - function( $parent_order ) use ( $order ) { - return $order; - } - ); + $this->mock_wcs_get_subscriptions_for_order( [ $subscription ] ); + $this->mock_wcs_get_subscriptions_for_renewal_order( [] ); + $order->add_payment_token( $this->token ); $mock_subscription = new WC_Subscription(); diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions-process-payment.php b/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions-process-payment.php index 51a9cbeb73c..6b95e64aa33 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions-process-payment.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions-process-payment.php @@ -166,11 +166,11 @@ public function set_up() { } public function test_new_card_subscription() { - $order = WC_Helper_Order::create_order( self::USER_ID ); + $order = WC_Helper_Order::create_order( self::USER_ID ); + $subscriptions = [ new WC_Subscription() ]; + $subscriptions[0]->set_parent( $order ); $this->mock_wcs_order_contains_subscription( true ); - - $subscriptions = [ WC_Helper_Order::create_order( self::USER_ID ) ]; $this->mock_wcs_get_subscriptions_for_order( $subscriptions ); $this->mock_api_client @@ -202,11 +202,11 @@ public function test_new_card_subscription() { } public function test_new_card_zero_dollar_subscription() { - $order = WC_Helper_Order::create_order( self::USER_ID, 0 ); + $order = WC_Helper_Order::create_order( self::USER_ID, 0 ); + $subscriptions = [ new WC_Subscription() ]; + $subscriptions[0]->set_parent( $order ); $this->mock_wcs_order_contains_subscription( true ); - - $subscriptions = [ WC_Helper_Order::create_order( self::USER_ID ) ]; $this->mock_wcs_get_subscriptions_for_order( $subscriptions ); $this->mock_api_client @@ -238,12 +238,9 @@ public function test_new_card_zero_dollar_subscription() { } public function test_new_card_is_added_before_status_update() { - $order = WC_Helper_Order::create_order( self::USER_ID, 0 ); - - $this->mock_wcs_order_contains_subscription( true ); - - $subscriptions = [ WC_Helper_Order::create_order( self::USER_ID ) ]; - $this->mock_wcs_get_subscriptions_for_order( $subscriptions ); + $order = WC_Helper_Order::create_order( self::USER_ID, 0 ); + $subscriptions = [ new WC_Subscription() ]; + $subscriptions[0]->set_parent( $order ); $this->mock_api_client ->expects( $this->once() ) @@ -270,7 +267,9 @@ public function test_new_card_is_added_before_status_update() { } public function test_saved_card_subscription() { - $order = WC_Helper_Order::create_order( self::USER_ID ); + $order = WC_Helper_Order::create_order( self::USER_ID ); + $subscriptions = [ new WC_Subscription() ]; + $subscriptions[0]->set_parent( $order ); $_POST = [ 'payment_method' => WC_Payment_Gateway_WCPay::GATEWAY_ID, @@ -278,8 +277,6 @@ public function test_saved_card_subscription() { ]; $this->mock_wcs_order_contains_subscription( true ); - - $subscriptions = [ WC_Helper_Order::create_order( self::USER_ID ) ]; $this->mock_wcs_get_subscriptions_for_order( $subscriptions ); $this->mock_api_client From d16af9c4bc57f0898ec4ad0c06e1a25d4763f3f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ismael=20Marti=CC=81n=20Alabarce?= Date: Thu, 7 Oct 2021 12:07:32 +0200 Subject: [PATCH 07/22] Fix param should be optional --- .../trait-wc-payment-gateway-wcpay-subscriptions.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 d41345656d9..7700e4ab94b 100644 --- a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php +++ b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php @@ -807,10 +807,10 @@ private function is_wcpay_subscription_renewal_order( WC_Order $renewal_order ) * 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 $next_action Next action intent content from Stripe's response. + * @param ?array $next_action Next action intent content from Stripe's response. * @return void */ - public function maybe_add_card_await_notification_note( WC_Order $order, array $next_action ) { + public function maybe_add_card_await_notification_note( WC_Order $order, array $next_action = null ) { if ( isset( $next_action['type'] ) && 'card_await_notification' === $next_action['type'] ) { $charge_attempt_at = $next_action['card_await_notification']['charge_attempt_at']; $attempt_date = wp_date( get_option( 'date_format', 'F j, Y' ), $charge_attempt_at, wp_timezone() ); From 4a3f1b1815a8c9ea5907d4c902ba790c77eccc03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ismael=20Marti=CC=81n=20Alabarce?= Date: Fri, 8 Oct 2021 11:50:58 +0200 Subject: [PATCH 08/22] Add new tests --- .../test-class-wc-rest-payments-webhook.php | 34 ++++++- ...wc-payment-gateway-wcpay-subscriptions.php | 98 ++++++++++++++----- 2 files changed, 108 insertions(+), 24 deletions(-) diff --git a/tests/unit/admin/test-class-wc-rest-payments-webhook.php b/tests/unit/admin/test-class-wc-rest-payments-webhook.php index bdb309b22f3..243b19f8ad8 100644 --- a/tests/unit/admin/test-class-wc-rest-payments-webhook.php +++ b/tests/unit/admin/test-class-wc-rest-payments-webhook.php @@ -44,8 +44,8 @@ public function set_up() { /** @var WC_Payments_API_Client|MockObject $mock_api_client */ $mock_api_client = $this->getMockBuilder( WC_Payments_API_Client::class ) - ->disableOriginalConstructor() - ->getMock(); + ->disableOriginalConstructor() + ->getMock(); $this->mock_webhook_processing_service = $this->createMock( WC_Payments_Webhook_Processing_Service::class ); @@ -122,4 +122,34 @@ public function test_handle_webhook_returns_error_message() { $this->assertSame( 500, $response->get_status() ); $this->assertSame( [ 'result' => 'error' ], $response_data ); } + + /** + * Tests that a payment_intent.succeeded will save mandate if it's received. + */ + public function test_payment_intent_succeeded_save_mandate() { + $this->request_body['type'] = 'payment_intent.succeeded'; + $this->request_body['data']['object'] = [ + 'id' => 'pi_123123123123123', + ]; + + $this->request_body['data']['object']['charges']['data'][0]['payment_method_details']['card']['mandate'] = 'mandate_id'; + + $this->request->set_body( wp_json_encode( $this->request_body ) ); + + $mock_order = $this->createMock( WC_Order::class ); + + $mock_order + ->expects( $this->once() ) + ->method( 'update_meta_data' ) + ->with( '_stripe_mandate_id', 'mandate_id' ) + ->willReturn( true ); + + $this->mock_db_wrapper + ->expects( $this->once() ) + ->method( 'order_from_intent_id' ) + ->with( 'pi_123123123123123' ) + ->willReturn( $mock_order ); + + $this->controller->handle_webhook( $this->request ); + } } diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions.php b/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions.php index 7d0fb7bde5e..9f99022fe4c 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions.php @@ -81,22 +81,22 @@ public function set_up() { wp_set_current_user( self::USER_ID ); $this->mock_api_client = $this->getMockBuilder( 'WC_Payments_API_Client' ) - ->disableOriginalConstructor() - ->getMock(); + ->disableOriginalConstructor() + ->getMock(); $this->mock_wcpay_account = $this->createMock( WC_Payments_Account::class ); $this->mock_customer_service = $this->getMockBuilder( 'WC_Payments_Customer_Service' ) - ->disableOriginalConstructor() - ->getMock(); + ->disableOriginalConstructor() + ->getMock(); $this->mock_token_service = $this->getMockBuilder( 'WC_Payments_Token_Service' ) - ->disableOriginalConstructor() - ->getMock(); + ->disableOriginalConstructor() + ->getMock(); $this->mock_action_scheduler_service = $this->getMockBuilder( 'WC_Payments_Action_Scheduler_Service' ) - ->disableOriginalConstructor() - ->getMock(); + ->disableOriginalConstructor() + ->getMock(); $this->mock_session_rate_limiter = $this->getMockBuilder( Session_Rate_Limiter::class ) ->disableOriginalConstructor() @@ -221,10 +221,10 @@ function ( $id ) use ( $mock_subscription ) { ); $this->mock_customer_service - ->expects( $this->once() ) - ->method( 'get_customer_id_by_user_id' ) - ->with( self::USER_ID ) - ->willReturn( self::CUSTOMER_ID ); + ->expects( $this->once() ) + ->method( 'get_customer_id_by_user_id' ) + ->with( self::USER_ID ) + ->willReturn( self::CUSTOMER_ID ); $this->mock_api_client ->expects( $this->once() ) @@ -241,8 +241,8 @@ public function test_scheduled_subscription_payment_fails_when_token_is_missing( $renewal_order = WC_Helper_Order::create_order( self::USER_ID ); $this->mock_customer_service - ->expects( $this->never() ) - ->method( 'get_customer_id_by_user_id' ); + ->expects( $this->never() ) + ->method( 'get_customer_id_by_user_id' ); $this->wcpay_gateway->scheduled_subscription_payment( $renewal_order->get_total(), $renewal_order ); @@ -257,8 +257,8 @@ public function test_scheduled_subscription_payment_fails_when_token_is_invalid( $token->delete(); $this->mock_customer_service - ->expects( $this->never() ) - ->method( 'get_customer_id_by_user_id' ); + ->expects( $this->never() ) + ->method( 'get_customer_id_by_user_id' ); $this->wcpay_gateway->scheduled_subscription_payment( $renewal_order->get_total(), $renewal_order ); @@ -280,9 +280,9 @@ function ( $id ) use ( $mock_subscription ) { ); $this->mock_customer_service - ->expects( $this->once() ) - ->method( 'get_customer_id_by_user_id' ) - ->willThrowException( new API_Exception( 'Error', 'error', 500 ) ); + ->expects( $this->once() ) + ->method( 'get_customer_id_by_user_id' ) + ->willThrowException( new API_Exception( 'Error', 'error', 500 ) ); $this->wcpay_gateway->scheduled_subscription_payment( $renewal_order->get_total(), $renewal_order ); @@ -305,9 +305,9 @@ function ( $id ) use ( $mock_subscription ) { ); $this->mock_customer_service - ->expects( $this->once() ) - ->method( 'get_customer_id_by_user_id' ) - ->willThrowException( new API_Exception( 'Error', 'error', 500 ) ); + ->expects( $this->once() ) + ->method( 'get_customer_id_by_user_id' ) + ->willThrowException( new API_Exception( 'Error', 'error', 500 ) ); $this->wcpay_gateway->scheduled_subscription_payment( $renewal_order->get_total(), $renewal_order ); @@ -324,6 +324,52 @@ function ( $id ) use ( $mock_subscription ) { $this->assertStringContainsString( wc_price( $renewal_order->get_total(), [ 'currency' => 'EUR' ] ), $latest_wcpay_note->content ); } + public function test_scheduled_subscription_payment_adds_mandate() { + $subscription = WC_Helper_Order::create_order( self::USER_ID ); + $subscription->add_meta_data( '_stripe_mandate_id', 'mandate_id' ); + $subscription->save_meta_data(); + + $renew_order = WC_Helper_Order::create_order( self::USER_ID ); + $renew_order->set_parent_id( $subscription->get_id() ); + + $token = WC_Helper_Token::create_token( self::PAYMENT_METHOD_ID, self::USER_ID ); + $renew_order->add_payment_token( $token ); + + $this->mock_wcs_get_subscriptions_for_renewal_order( [ $renew_order ] ); + + $this->mock_api_client + ->expects( $this->once() ) + ->method( 'create_and_confirm_intention' ) + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + // additional_parameters. + $this->equalTo( [ 'mandate' => 'mandate_id' ] ) + ) + ->willReturn( + new WC_Payments_API_Intention( + self::PAYMENT_INTENT_ID, + 1500, + 'usd', + 'cus_12345', + 'pm_12345', + new DateTime(), + 'succeeded', + self::CHARGE_ID, + '' + ) + ); + + $this->wcpay_gateway->scheduled_subscription_payment( $renew_order->get_total(), $renew_order ); + } + public function test_subscription_payment_method_filter_bypass_other_payment_methods() { $subscription = WC_Helper_Order::create_order( self::USER_ID ); $payment_method_to_display = $this->wcpay_gateway->maybe_render_subscription_payment_method( 'Via Crypto Currency', $subscription ); @@ -752,4 +798,12 @@ function ( $order ) use ( $return_value ) { } ); } + + private function mock_wcs_get_subscriptions_for_renewal_order( $value ) { + WC_Subscriptions::set_wcs_get_subscriptions_for_renewal_order( + function ( $order ) use ( $value ) { + return $value; + } + ); + } } From 171a964db7705477aebb3526f4ed69f4b5fad763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ismael=20Marti=CC=81n=20Alabarce?= Date: Mon, 11 Oct 2021 11:59:04 +0200 Subject: [PATCH 09/22] Update renew note about customer authorization --- .../trait-wc-payment-gateway-wcpay-subscriptions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7700e4ab94b..493f82b397d 100644 --- a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php +++ b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php @@ -818,7 +818,7 @@ public function maybe_add_card_await_notification_note( WC_Order $order, array $ $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 the pre-debit notification sent to them by their card issuing bank, before %1$s at %2$s, when the charge will be attempted.', 'woocommerce-payments' ), + __( '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 ); From 53bd7ea8da81f0e8445cb85743fa4b4c47b2126d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ismael=20Marti=CC=81n=20Alabarce?= Date: Mon, 11 Oct 2021 13:11:03 +0200 Subject: [PATCH 10/22] Handle order with other products and signup fee --- ...it-wc-payment-gateway-wcpay-subscriptions.php | 16 ++++++++++++++++ ...ss-wc-payment-gateway-wcpay-subscriptions.php | 6 ++++++ 2 files changed, 22 insertions(+) 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 493f82b397d..86b48ca47ea 100644 --- a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php +++ b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php @@ -788,6 +788,22 @@ private function is_wcpay_subscription_renewal_order( WC_Order $renewal_order ) return true; } + // Get total by adding only subscriptions and get rid of any other product or fee. + $subs_amount = 0; + foreach ( $subscriptions as $sub ) { + $subs_amount += $sub->get_total(); + } + + $result['setup_future_usage'] = 'off_session'; + $result['payment_method_options']['card']['mandate_options'] = [ + 'reference' => $order->get_id(), + 'amount' => WC_Payments_Utils::prepare_amount( $subs_amount, $order->get_currency() ), + 'amount_type' => 'fixed', + 'start_date' => $subscription->get_time( 'date_created' ), + 'interval' => $subscription->get_billing_period(), + 'interval_count' => $subscription->get_billing_interval(), + ]; + // 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. diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions.php b/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions.php index 9f99022fe4c..efcb7d04fd8 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions.php @@ -115,6 +115,12 @@ public function set_up() { ); } + public static function tearDownAfterClass() { + WC_Subscriptions::set_wcs_get_subscriptions_for_order( null ); + WC_Subscriptions::set_wcs_is_subscription( null ); + WC_Subscriptions::set_wcs_get_subscriptions_for_renewal_order( null ); + } + public function test_add_token_to_order_should_add_token_to_subscriptions() { $original_order = WC_Helper_Order::create_order( self::USER_ID ); $subscriptions = [ From 5aca7ff312ae50f659e48f65f39b22186fbe257d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ismael=20Marti=CC=81n=20Alabarce?= Date: Mon, 18 Oct 2021 12:39:40 +0200 Subject: [PATCH 11/22] Handle UPE new card subscription too With UPE we use another process_payment method where we need to also call the add mandate function. --- includes/payment-methods/class-upe-payment-gateway.php | 1 + includes/wc-payment-api/class-wc-payments-api-client.php | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/includes/payment-methods/class-upe-payment-gateway.php b/includes/payment-methods/class-upe-payment-gateway.php index 2bae214605a..11f4d0a7978 100644 --- a/includes/payment-methods/class-upe-payment-gateway.php +++ b/includes/payment-methods/class-upe-payment-gateway.php @@ -442,6 +442,7 @@ public function process_payment( $order_id ) { 'fraud_prevention_enabled' ); } + $additional_api_parameters = $this->maybe_add_mandate_to_order_payment( $order ); if ( $this->failed_transaction_rate_limiter->is_limited() ) { // Throwing an exception instead of adding an error notice diff --git a/includes/wc-payment-api/class-wc-payments-api-client.php b/includes/wc-payment-api/class-wc-payments-api-client.php index ce8ce49f3ed..a27d9e5017c 100644 --- a/includes/wc-payment-api/class-wc-payments-api-client.php +++ b/includes/wc-payment-api/class-wc-payments-api-client.php @@ -304,6 +304,7 @@ public function create_intention( * @param array $level3 - Level 3 data. * @param string $selected_upe_payment_type - The name of the selected UPE payment type or empty string. * @param ?string $payment_country - The payment two-letter iso country code or null. + * @param array $additional_parameters - An array of any additional request parameters. * * @return WC_Payments_API_Intention * @throws API_Exception - Exception thrown on intention creation failure. @@ -317,7 +318,8 @@ public function update_intention( $metadata = [], $level3 = [], $selected_upe_payment_type = '', - $payment_country = null + $payment_country = null, + $additional_parameters = [] ) { // 'receipt_email' is set to prevent Stripe from sending receipts (when intent is created outside WCPay). $request = [ @@ -329,6 +331,8 @@ public function update_intention( 'description' => $this->get_intent_description( $metadata['order_number'] ?? 0 ), ]; + $request = array_merge( $request, $additional_parameters ); + if ( '' !== $selected_upe_payment_type ) { // Only update the payment_method_types if we have a reference to the payment type the customer selected. $request['payment_method_types'] = [ $selected_upe_payment_type ]; From 3291b8859d12f2ec1080b7c724a2892f1526b472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ismael=20Marti=CC=81n=20Alabarce?= Date: Tue, 25 Oct 2022 15:38:40 +0200 Subject: [PATCH 12/22] Rebase conflicts --- includes/class-wc-payment-gateway-wcpay.php | 1 - ...wc-payments-webhook-processing-service.php | 6 +++ ...wc-payment-gateway-wcpay-subscriptions.php | 39 ++++++++++++++++++- .../class-upe-payment-gateway.php | 6 ++- .../class-wc-payments-api-client.php | 6 ++- 5 files changed, 53 insertions(+), 5 deletions(-) diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index a58158c746d..13666073edc 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -1174,7 +1174,6 @@ public function process_payment_for_order( $cart, $payment_information, $additio $this->attach_exchange_info_to_order( $order, $charge_id ); $this->update_order_status_from_intent( $order, $intent_id, $status, $charge_id ); - // Add order note when next action type is card_await_notification. $this->maybe_add_card_await_notification_note( $order, $next_action ); if ( isset( $response ) ) { diff --git a/includes/class-wc-payments-webhook-processing-service.php b/includes/class-wc-payments-webhook-processing-service.php index be84a68171f..7559f0569c0 100644 --- a/includes/class-wc-payments-webhook-processing-service.php +++ b/includes/class-wc-payments-webhook-processing-service.php @@ -385,6 +385,12 @@ private function process_webhook_payment_intent_succeeded( $event_body ) { WC_Payments_Utils::ORDER_INTENT_CURRENCY_META_KEY => $currency, ]; + // Save mandate id, necessary for some subscription renewals. + $mandate_id = $event_data['object']['charges']['data'][0]['payment_method_details']['card']['mandate'] ?? null; + if ( $mandate_id ) { + $meta_data_to_update['_stripe_mandate_id'] = $mandate_id; + } + foreach ( $meta_data_to_update as $key => $value ) { // Override existing meta data with incoming values, if present. if ( $value ) { 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 86b48ca47ea..a06556f2853 100644 --- a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php +++ b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php @@ -270,6 +270,19 @@ public function scheduled_subscription_payment( $amount, $renewal_order ) { return; } + $additional_api_parameters = []; + + // Include mandate from parent subscription order if exists. + $renewals = wcs_get_subscriptions_for_renewal_order( $renewal_order->get_id() ); + if ( $renewals ) { + $parent_order_id = reset( $renewals )->get_parent_id(); + $parent_order = wc_get_order( $parent_order_id ); + $mandate = $parent_order->get_meta( '_stripe_mandate_id', true ); + if ( ! empty( $mandate ) ) { + $additional_api_parameters['mandate'] = $mandate; + } + } + try { $payment_information = new Payment_Information( '', $renewal_order, Payment_Type::RECURRING(), $token, Payment_Initiated_By::MERCHANT() ); $this->process_payment_for_order( null, $payment_information, $additional_api_parameters ); @@ -787,6 +800,29 @@ private function is_wcpay_subscription_renewal_order( WC_Order $renewal_order ) if ( WC_Payments_Subscription_Service::is_wcpay_subscription( $subscription ) ) { return true; } + } + + return false; + } + + /** + * Add card mandate options parameters to the order payment intent if needed. + * Only required for the subscription creation for card 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 maybe_add_mandate_to_order_payment( WC_Order $order ): array { + $result = []; + + if ( $this->is_subscriptions_enabled() ) { + $subscriptions = wcs_get_subscriptions_for_order( $order->get_id() ); + $subscription = reset( $subscriptions ); + + if ( ! $subscription ) { + return $result; + } // Get total by adding only subscriptions and get rid of any other product or fee. $subs_amount = 0; @@ -802,6 +838,7 @@ private function is_wcpay_subscription_renewal_order( WC_Order $renewal_order ) '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: @@ -815,7 +852,7 @@ private function is_wcpay_subscription_renewal_order( WC_Order $renewal_order ) } } - return false; + return $result; } /** diff --git a/includes/payment-methods/class-upe-payment-gateway.php b/includes/payment-methods/class-upe-payment-gateway.php index 11f4d0a7978..73564392db9 100644 --- a/includes/payment-methods/class-upe-payment-gateway.php +++ b/includes/payment-methods/class-upe-payment-gateway.php @@ -442,7 +442,6 @@ public function process_payment( $order_id ) { 'fraud_prevention_enabled' ); } - $additional_api_parameters = $this->maybe_add_mandate_to_order_payment( $order ); if ( $this->failed_transaction_rate_limiter->is_limited() ) { // Throwing an exception instead of adding an error notice @@ -457,6 +456,8 @@ public function process_payment( $order_id ) { throw new Exception( WC_Payments_Utils::get_filtered_error_message( $exception ) ); } + $additional_api_parameters = $this->maybe_add_mandate_to_order_payment( $order ); + try { $updated_payment_intent = $this->payments_api_client->update_intention( $payment_intent_id, @@ -467,7 +468,8 @@ public function process_payment( $order_id ) { $this->get_metadata_from_order( $order, $payment_type ), $this->get_level3_data_from_order( $order ), $selected_upe_payment_type, - $payment_country + $payment_country, + $additional_api_parameters ); } catch ( Amount_Too_Small_Exception $e ) { // This code would only be reached if the cache has already expired. diff --git a/includes/wc-payment-api/class-wc-payments-api-client.php b/includes/wc-payment-api/class-wc-payments-api-client.php index a27d9e5017c..5f3abe3d74c 100644 --- a/includes/wc-payment-api/class-wc-payments-api-client.php +++ b/includes/wc-payment-api/class-wc-payments-api-client.php @@ -263,6 +263,7 @@ public function create_and_confirm_intention( * @param string $capture_method - optional capture method (either `automatic` or `manual`). * @param array $metadata - A list of intent metadata. * @param string|null $customer_id - Customer id for intent. + * @param array $additional_parameters - An array of any additional request parameters, particularly for additional payment methods. * * @return WC_Payments_API_Intention * @throws API_Exception - Exception thrown on intention creation failure. @@ -274,7 +275,8 @@ public function create_intention( $order_number, $capture_method = 'automatic', array $metadata = [], - $customer_id = null + $customer_id = null, + $additional_parameters = [] ) { $request = []; $request['amount'] = $amount; @@ -287,6 +289,8 @@ public function create_intention( $request['customer'] = $customer_id; } + $request = array_merge( $request, $additional_parameters ); + $response_array = $this->request( $request, self::INTENTIONS_API, self::POST ); return $this->deserialize_intention_object_from_array( $response_array ); From b2229ac3b90eeddbd069c80db07b906fb77441ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ismael=20Marti=CC=81n=20Alabarce?= Date: Wed, 2 Nov 2022 10:24:31 +0100 Subject: [PATCH 13/22] Update tests --- .../test-class-wc-rest-payments-webhook.php | 34 +---- ...wc-payment-gateway-wcpay-payment-types.php | 16 +-- ...wc-payment-gateway-wcpay-subscriptions.php | 120 ++++++++++-------- ...wc-payments-webhook-processing-service.php | 87 ++++++++++++- 4 files changed, 152 insertions(+), 105 deletions(-) diff --git a/tests/unit/admin/test-class-wc-rest-payments-webhook.php b/tests/unit/admin/test-class-wc-rest-payments-webhook.php index 243b19f8ad8..bdb309b22f3 100644 --- a/tests/unit/admin/test-class-wc-rest-payments-webhook.php +++ b/tests/unit/admin/test-class-wc-rest-payments-webhook.php @@ -44,8 +44,8 @@ public function set_up() { /** @var WC_Payments_API_Client|MockObject $mock_api_client */ $mock_api_client = $this->getMockBuilder( WC_Payments_API_Client::class ) - ->disableOriginalConstructor() - ->getMock(); + ->disableOriginalConstructor() + ->getMock(); $this->mock_webhook_processing_service = $this->createMock( WC_Payments_Webhook_Processing_Service::class ); @@ -122,34 +122,4 @@ public function test_handle_webhook_returns_error_message() { $this->assertSame( 500, $response->get_status() ); $this->assertSame( [ 'result' => 'error' ], $response_data ); } - - /** - * Tests that a payment_intent.succeeded will save mandate if it's received. - */ - public function test_payment_intent_succeeded_save_mandate() { - $this->request_body['type'] = 'payment_intent.succeeded'; - $this->request_body['data']['object'] = [ - 'id' => 'pi_123123123123123', - ]; - - $this->request_body['data']['object']['charges']['data'][0]['payment_method_details']['card']['mandate'] = 'mandate_id'; - - $this->request->set_body( wp_json_encode( $this->request_body ) ); - - $mock_order = $this->createMock( WC_Order::class ); - - $mock_order - ->expects( $this->once() ) - ->method( 'update_meta_data' ) - ->with( '_stripe_mandate_id', 'mandate_id' ) - ->willReturn( true ); - - $this->mock_db_wrapper - ->expects( $this->once() ) - ->method( 'order_from_intent_id' ) - ->with( 'pi_123123123123123' ) - ->willReturn( $mock_order ); - - $this->controller->handle_webhook( $this->request ); - } } diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay-payment-types.php b/tests/unit/test-class-wc-payment-gateway-wcpay-payment-types.php index cb6bd878f62..cc9299b26ee 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay-payment-types.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay-payment-types.php @@ -250,24 +250,16 @@ function( $metadata ) use ( $order ) { } public function test_renewal_subscription_payment() { - $order = WC_Helper_Order::create_order(); - $subscription = new WC_Subscription(); - $subscription->set_parent( $order ); + $order = WC_Helper_Order::create_order(); + $mock_subscription = new WC_Subscription(); + $mock_subscription->set_parent( $order ); $this->mock_wcs_order_contains_subscription( true ); - $this->mock_wcs_get_subscriptions_for_order( [ $subscription ] ); + $this->mock_wcs_get_subscriptions_for_order( [ $mock_subscription ] ); $this->mock_wcs_get_subscriptions_for_renewal_order( [] ); $order->add_payment_token( $this->token ); - $mock_subscription = new WC_Subscription(); - - WC_Subscriptions::set_wcs_get_subscriptions_for_renewal_order( - function ( $id ) use ( $mock_subscription ) { - return [ '1' => $mock_subscription ]; - } - ); - $intent = WC_Helper_Intention::create_intention(); $this->mock_api_client ->expects( $this->once() ) diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions.php b/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions.php index efcb7d04fd8..dabdc13c2d3 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions.php @@ -81,22 +81,22 @@ public function set_up() { wp_set_current_user( self::USER_ID ); $this->mock_api_client = $this->getMockBuilder( 'WC_Payments_API_Client' ) - ->disableOriginalConstructor() - ->getMock(); + ->disableOriginalConstructor() + ->getMock(); $this->mock_wcpay_account = $this->createMock( WC_Payments_Account::class ); $this->mock_customer_service = $this->getMockBuilder( 'WC_Payments_Customer_Service' ) - ->disableOriginalConstructor() - ->getMock(); + ->disableOriginalConstructor() + ->getMock(); $this->mock_token_service = $this->getMockBuilder( 'WC_Payments_Token_Service' ) - ->disableOriginalConstructor() - ->getMock(); + ->disableOriginalConstructor() + ->getMock(); $this->mock_action_scheduler_service = $this->getMockBuilder( 'WC_Payments_Action_Scheduler_Service' ) - ->disableOriginalConstructor() - ->getMock(); + ->disableOriginalConstructor() + ->getMock(); $this->mock_session_rate_limiter = $this->getMockBuilder( Session_Rate_Limiter::class ) ->disableOriginalConstructor() @@ -115,7 +115,7 @@ public function set_up() { ); } - public static function tearDownAfterClass() { + public static function tear_down_after_class() { WC_Subscriptions::set_wcs_get_subscriptions_for_order( null ); WC_Subscriptions::set_wcs_is_subscription( null ); WC_Subscriptions::set_wcs_get_subscriptions_for_renewal_order( null ); @@ -219,23 +219,31 @@ public function test_scheduled_subscription_payment() { $renewal_order->add_payment_token( $token ); $mock_subscription = new WC_Subscription(); + $renewal_order->set_parent_id( $mock_subscription->get_id() ); - WC_Subscriptions::set_wcs_get_subscriptions_for_renewal_order( - function ( $id ) use ( $mock_subscription ) { - return [ '1' => $mock_subscription ]; - } - ); + $this->mock_wcs_get_subscriptions_for_renewal_order( [ '1' => $mock_subscription ] ); $this->mock_customer_service - ->expects( $this->once() ) - ->method( 'get_customer_id_by_user_id' ) - ->with( self::USER_ID ) - ->willReturn( self::CUSTOMER_ID ); + ->expects( $this->once() ) + ->method( 'get_customer_id_by_user_id' ) + ->with( self::USER_ID ) + ->willReturn( self::CUSTOMER_ID ); $this->mock_api_client ->expects( $this->once() ) ->method( 'create_and_confirm_intention' ) - ->with( $this->anything(), $this->anything(), self::PAYMENT_METHOD_ID, self::CUSTOMER_ID, $this->anything(), false, false, $this->anything(), $this->anything(), true ) + ->with( + $this->anything(), + $this->anything(), + self::PAYMENT_METHOD_ID, + self::CUSTOMER_ID, + $this->anything(), + false, + false, + $this->anything(), + $this->anything(), + true + ) ->willReturn( WC_Helper_Intention::create_intention() ); $this->wcpay_gateway->scheduled_subscription_payment( $renewal_order->get_total(), $renewal_order ); @@ -247,8 +255,8 @@ public function test_scheduled_subscription_payment_fails_when_token_is_missing( $renewal_order = WC_Helper_Order::create_order( self::USER_ID ); $this->mock_customer_service - ->expects( $this->never() ) - ->method( 'get_customer_id_by_user_id' ); + ->expects( $this->never() ) + ->method( 'get_customer_id_by_user_id' ); $this->wcpay_gateway->scheduled_subscription_payment( $renewal_order->get_total(), $renewal_order ); @@ -263,8 +271,8 @@ public function test_scheduled_subscription_payment_fails_when_token_is_invalid( $token->delete(); $this->mock_customer_service - ->expects( $this->never() ) - ->method( 'get_customer_id_by_user_id' ); + ->expects( $this->never() ) + ->method( 'get_customer_id_by_user_id' ); $this->wcpay_gateway->scheduled_subscription_payment( $renewal_order->get_total(), $renewal_order ); @@ -286,9 +294,9 @@ function ( $id ) use ( $mock_subscription ) { ); $this->mock_customer_service - ->expects( $this->once() ) - ->method( 'get_customer_id_by_user_id' ) - ->willThrowException( new API_Exception( 'Error', 'error', 500 ) ); + ->expects( $this->once() ) + ->method( 'get_customer_id_by_user_id' ) + ->willThrowException( new API_Exception( 'Error', 'error', 500 ) ); $this->wcpay_gateway->scheduled_subscription_payment( $renewal_order->get_total(), $renewal_order ); @@ -311,9 +319,9 @@ function ( $id ) use ( $mock_subscription ) { ); $this->mock_customer_service - ->expects( $this->once() ) - ->method( 'get_customer_id_by_user_id' ) - ->willThrowException( new API_Exception( 'Error', 'error', 500 ) ); + ->expects( $this->once() ) + ->method( 'get_customer_id_by_user_id' ) + ->willThrowException( new API_Exception( 'Error', 'error', 500 ) ); $this->wcpay_gateway->scheduled_subscription_payment( $renewal_order->get_total(), $renewal_order ); @@ -344,34 +352,34 @@ public function test_scheduled_subscription_payment_adds_mandate() { $this->mock_wcs_get_subscriptions_for_renewal_order( [ $renew_order ] ); $this->mock_api_client - ->expects( $this->once() ) - ->method( 'create_and_confirm_intention' ) - ->with( - $this->anything(), - $this->anything(), - $this->anything(), - $this->anything(), - $this->anything(), - $this->anything(), - $this->anything(), - $this->anything(), - $this->anything(), - // additional_parameters. - $this->equalTo( [ 'mandate' => 'mandate_id' ] ) - ) - ->willReturn( - new WC_Payments_API_Intention( - self::PAYMENT_INTENT_ID, - 1500, - 'usd', - 'cus_12345', - 'pm_12345', - new DateTime(), - 'succeeded', - self::CHARGE_ID, - '' + ->expects( $this->once() ) + ->method( 'create_and_confirm_intention' ) + ->with( + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything(), + // additional_parameters. + $this->equalTo( [ 'mandate' => 'mandate_id' ] ) ) - ); + ->willReturn( + new WC_Payments_API_Intention( + self::PAYMENT_INTENT_ID, + 1500, + 'usd', + 'cus_12345', + 'pm_12345', + new DateTime(), + 'succeeded', + self::CHARGE_ID, + '' + ) + ); $this->wcpay_gateway->scheduled_subscription_payment( $renew_order->get_total(), $renew_order ); } diff --git a/tests/unit/test-class-wc-payments-webhook-processing-service.php b/tests/unit/test-class-wc-payments-webhook-processing-service.php index 0af388a431a..70054e0884f 100644 --- a/tests/unit/test-class-wc-payments-webhook-processing-service.php +++ b/tests/unit/test-class-wc-payments-webhook-processing-service.php @@ -87,8 +87,8 @@ public function set_up() { /** @var WC_Payments_API_Client|MockObject $mock_api_client */ $mock_api_client = $this->getMockBuilder( WC_Payments_API_Client::class ) - ->disableOriginalConstructor() - ->getMock(); + ->disableOriginalConstructor() + ->getMock(); $mock_wcpay_account = $this->createMock( WC_Payments_Account::class ); @@ -97,9 +97,9 @@ public function set_up() { ); $this->mock_db_wrapper = $this->getMockBuilder( WC_Payments_DB::class ) - ->disableOriginalConstructor() - ->setMethods( [ 'order_from_charge_id', 'order_from_intent_id', 'order_from_order_id' ] ) - ->getMock(); + ->disableOriginalConstructor() + ->setMethods( [ 'order_from_charge_id', 'order_from_intent_id', 'order_from_order_id' ] ) + ->getMock(); $this->mock_remote_note_service = $this->createMock( WC_Payments_Remote_Note_Service::class ); @@ -814,6 +814,83 @@ public function test_payment_intent_successful_and_send_card_reader_receipt() { $this->webhook_processing_service->process( $this->event_body ); } + + /** + * Tests that a payment_intent.succeeded will save mandate if it's received. + */ + public function test_payment_intent_succeeded_save_mandate() { + $this->event_body['type'] = 'payment_intent.succeeded'; + $this->event_body['data']['object'] = [ + 'id' => $id = 'pi_123123123123123', // payment_intent's ID. + 'object' => 'payment_intent', + 'amount' => 1500, + 'charges' => [ + 'data' => [ + [ + 'id' => $charge_id = 'py_123123123123123', + 'payment_method' => $payment_method_id = 'pm_foo', + 'payment_method_details' => [ + 'card' => [ + 'mandate' => $mandate_id = 'mandate_123123123', + ], + ], + ], + ], + ], + 'currency' => $currency = 'eur', + 'status' => $intent_status = 'succeeded', + ]; + + $mock_order = $this->createMock( WC_Order::class ); + + $mock_order + ->expects( $this->exactly( 6 ) ) + ->method( 'update_meta_data' ) + ->withConsecutive( + [ '_intent_id', $id ], + [ '_charge_id', $charge_id ], + [ '_payment_method_id', $payment_method_id ], + [ WC_Payments_Utils::ORDER_INTENT_CURRENCY_META_KEY, $currency ], + [ '_stripe_mandate_id', $mandate_id ], + [ '_intention_status', $intent_status ], + ); + + $mock_order + ->expects( $this->exactly( 2 ) ) + ->method( 'save' ); + + $mock_order + ->expects( $this->exactly( 2 ) ) + ->method( 'has_status' ) + ->with( [ 'processing', 'completed' ] ) + ->willReturn( false ); + + $mock_order + ->expects( $this->once() ) + ->method( 'payment_complete' ); + + $this->mock_db_wrapper + ->expects( $this->once() ) + ->method( 'order_from_intent_id' ) + ->with( 'pi_123123123123123' ) + ->willReturn( $mock_order ); + + $mock_order + ->method( 'get_data_store' ) + ->willReturn( new \WC_Mock_WC_Data_Store() ); + + $this->mock_receipt_service + ->expects( $this->never() ) + ->method( 'send_customer_ipp_receipt_email' ); + + $this->mock_wcpay_gateway + ->expects( $this->never() ) + ->method( 'get_option' ); + + // Run the test. + $this->webhook_processing_service->process( $this->event_body ); + } + /** * Tests that a payment_intent.succeeded event will complete the order. */ From 6e1a634794a276b851ff4f7ac4f62faf335b3f8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ismael=20Marti=CC=81n=20Alabarce?= Date: Thu, 3 Nov 2022 11:52:37 +0100 Subject: [PATCH 14/22] Minor refactor and tests update Rename `maybe_add_*` methods to `get_*` as they weren't actually adding anything. --- includes/class-wc-payment-gateway-wcpay.php | 2 +- ...wc-payment-gateway-wcpay-subscriptions.php | 66 ++++++++++++------- .../class-upe-payment-gateway.php | 2 +- .../class-wc-payments-api-client.php | 2 +- ...wc-payment-gateway-wcpay-subscriptions.php | 57 ++++++++-------- ...wc-payments-webhook-processing-service.php | 2 +- 6 files changed, 76 insertions(+), 55 deletions(-) diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index 13666073edc..fb8bd64e175 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -956,7 +956,7 @@ public function process_payment_for_order( $cart, $payment_information, $additio // Add card mandate options parameters to the order payment intent if needed. $additional_api_parameters = array_merge( $additional_api_parameters, - $this->maybe_add_mandate_to_order_payment( $order ) + $this->get_mandate_params_for_order( $order ) ); if ( $payment_needed ) { 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 a06556f2853..7a676021db5 100644 --- a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php +++ b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php @@ -270,18 +270,8 @@ public function scheduled_subscription_payment( $amount, $renewal_order ) { return; } - $additional_api_parameters = []; - - // Include mandate from parent subscription order if exists. - $renewals = wcs_get_subscriptions_for_renewal_order( $renewal_order->get_id() ); - if ( $renewals ) { - $parent_order_id = reset( $renewals )->get_parent_id(); - $parent_order = wc_get_order( $parent_order_id ); - $mandate = $parent_order->get_meta( '_stripe_mandate_id', true ); - if ( ! empty( $mandate ) ) { - $additional_api_parameters['mandate'] = $mandate; - } - } + // Add mandate param to the order payment if needed. + $additional_api_parameters = $this->get_mandate_param_for_renewal_order( $renewal_order ); try { $payment_information = new Payment_Information( '', $renewal_order, Payment_Type::RECURRING(), $token, Payment_Initiated_By::MERCHANT() ); @@ -806,14 +796,14 @@ private function is_wcpay_subscription_renewal_order( WC_Order $renewal_order ) } /** - * Add card mandate options parameters to the order payment intent if needed. - * Only required for the subscription creation for card issued in India. + * 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 maybe_add_mandate_to_order_payment( WC_Order $order ): array { + public function get_mandate_params_for_order( WC_Order $order ): array { $result = []; if ( $this->is_subscriptions_enabled() ) { @@ -832,13 +822,13 @@ public function maybe_add_mandate_to_order_payment( WC_Order $order ): array { $result['setup_future_usage'] = 'off_session'; $result['payment_method_options']['card']['mandate_options'] = [ - 'reference' => $order->get_id(), - 'amount' => WC_Payments_Utils::prepare_amount( $subs_amount, $order->get_currency() ), - 'amount_type' => 'fixed', - 'start_date' => $subscription->get_time( 'date_created' ), - 'interval' => $subscription->get_billing_period(), - 'interval_count' => $subscription->get_billing_interval(), - 'supported_types' => ['india'], + 'reference' => $order->get_id(), + 'amount' => WC_Payments_Utils::prepare_amount( $subs_amount, $order->get_currency() ), + '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: @@ -878,5 +868,37 @@ public function maybe_add_card_await_notification_note( WC_Order $order, array $ $order->add_order_note( $note ); } + + } + + /** + * Add 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 array Param to be included or empty array. + */ + public function get_mandate_param_for_renewal_order( WC_Order $renewal_order ): array { + $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' => $mandate ]; } } diff --git a/includes/payment-methods/class-upe-payment-gateway.php b/includes/payment-methods/class-upe-payment-gateway.php index 73564392db9..3a153e5a28c 100644 --- a/includes/payment-methods/class-upe-payment-gateway.php +++ b/includes/payment-methods/class-upe-payment-gateway.php @@ -456,7 +456,7 @@ public function process_payment( $order_id ) { throw new Exception( WC_Payments_Utils::get_filtered_error_message( $exception ) ); } - $additional_api_parameters = $this->maybe_add_mandate_to_order_payment( $order ); + $additional_api_parameters = $this->get_mandate_params_for_order( $order ); try { $updated_payment_intent = $this->payments_api_client->update_intention( diff --git a/includes/wc-payment-api/class-wc-payments-api-client.php b/includes/wc-payment-api/class-wc-payments-api-client.php index 5f3abe3d74c..3da56e85d54 100644 --- a/includes/wc-payment-api/class-wc-payments-api-client.php +++ b/includes/wc-payment-api/class-wc-payments-api-client.php @@ -289,7 +289,7 @@ public function create_intention( $request['customer'] = $customer_id; } - $request = array_merge( $request, $additional_parameters ); + $request = array_merge( $request, $additional_parameters ); $response_array = $this->request( $request, self::INTENTIONS_API, self::POST ); diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions.php b/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions.php index dabdc13c2d3..5ebfd96d7ef 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay-subscriptions.php @@ -219,7 +219,6 @@ public function test_scheduled_subscription_payment() { $renewal_order->add_payment_token( $token ); $mock_subscription = new WC_Subscription(); - $renewal_order->set_parent_id( $mock_subscription->get_id() ); $this->mock_wcs_get_subscriptions_for_renewal_order( [ '1' => $mock_subscription ] ); @@ -339,17 +338,24 @@ function ( $id ) use ( $mock_subscription ) { } public function test_scheduled_subscription_payment_adds_mandate() { - $subscription = WC_Helper_Order::create_order( self::USER_ID ); - $subscription->add_meta_data( '_stripe_mandate_id', 'mandate_id' ); - $subscription->save_meta_data(); + $renewal_order = WC_Helper_Order::create_order( self::USER_ID ); + $token = WC_Helper_Token::create_token( self::PAYMENT_METHOD_ID, self::USER_ID ); + $renewal_order->add_payment_token( $token ); - $renew_order = WC_Helper_Order::create_order( self::USER_ID ); - $renew_order->set_parent_id( $subscription->get_id() ); + $subscription_order = WC_Helper_Order::create_order(); + $subscription_order->add_meta_data( '_stripe_mandate_id', 'mandate_id' ); + $subscription_order->save_meta_data(); - $token = WC_Helper_Token::create_token( self::PAYMENT_METHOD_ID, self::USER_ID ); - $renew_order->add_payment_token( $token ); + $mock_subscription = new WC_Subscription(); + $mock_subscription->set_parent( $subscription_order ); + + $this->mock_wcs_get_subscriptions_for_renewal_order( [ '1' => $mock_subscription ] ); - $this->mock_wcs_get_subscriptions_for_renewal_order( [ $renew_order ] ); + $this->mock_customer_service + ->expects( $this->once() ) + ->method( 'get_customer_id_by_user_id' ) + ->with( self::USER_ID ) + ->willReturn( self::CUSTOMER_ID ); $this->mock_api_client ->expects( $this->once() ) @@ -357,31 +363,24 @@ public function test_scheduled_subscription_payment_adds_mandate() { ->with( $this->anything(), $this->anything(), + self::PAYMENT_METHOD_ID, + self::CUSTOMER_ID, $this->anything(), + false, + false, $this->anything(), $this->anything(), - $this->anything(), - $this->anything(), - $this->anything(), - $this->anything(), - // additional_parameters. - $this->equalTo( [ 'mandate' => 'mandate_id' ] ) - ) - ->willReturn( - new WC_Payments_API_Intention( - self::PAYMENT_INTENT_ID, - 1500, - 'usd', - 'cus_12345', - 'pm_12345', - new DateTime(), - 'succeeded', - self::CHARGE_ID, - '' + true, + $this->equalTo( + [ + 'mandate' => 'mandate_id', + 'is_platform_payment_method' => false, + ] ) - ); + ) + ->willReturn( WC_Helper_Intention::create_intention() ); - $this->wcpay_gateway->scheduled_subscription_payment( $renew_order->get_total(), $renew_order ); + $this->wcpay_gateway->scheduled_subscription_payment( $renewal_order->get_total(), $renewal_order ); } public function test_subscription_payment_method_filter_bypass_other_payment_methods() { diff --git a/tests/unit/test-class-wc-payments-webhook-processing-service.php b/tests/unit/test-class-wc-payments-webhook-processing-service.php index 70054e0884f..ac3306c15ba 100644 --- a/tests/unit/test-class-wc-payments-webhook-processing-service.php +++ b/tests/unit/test-class-wc-payments-webhook-processing-service.php @@ -852,7 +852,7 @@ public function test_payment_intent_succeeded_save_mandate() { [ '_payment_method_id', $payment_method_id ], [ WC_Payments_Utils::ORDER_INTENT_CURRENCY_META_KEY, $currency ], [ '_stripe_mandate_id', $mandate_id ], - [ '_intention_status', $intent_status ], + [ '_intention_status', $intent_status ] ); $mock_order From 7977d1e7dffc5151ea67a1320fa2bc8cad83e1d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ismael=20Marti=CC=81n=20Alabarce?= Date: Fri, 4 Nov 2022 09:46:00 +0100 Subject: [PATCH 15/22] Fix tests fro WP 6.1 --- ...wc-payments-webhook-processing-service.php | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/tests/unit/test-class-wc-payments-webhook-processing-service.php b/tests/unit/test-class-wc-payments-webhook-processing-service.php index ac3306c15ba..5c589be9121 100644 --- a/tests/unit/test-class-wc-payments-webhook-processing-service.php +++ b/tests/unit/test-class-wc-payments-webhook-processing-service.php @@ -814,11 +814,10 @@ public function test_payment_intent_successful_and_send_card_reader_receipt() { $this->webhook_processing_service->process( $this->event_body ); } - /** - * Tests that a payment_intent.succeeded will save mandate if it's received. + * Tests that a payment_intent.succeeded event will save mandate. */ - public function test_payment_intent_succeeded_save_mandate() { + public function test_payment_intent_successful_and_save_mandate() { $this->event_body['type'] = 'payment_intent.succeeded'; $this->event_body['data']['object'] = [ 'id' => $id = 'pi_123123123123123', // payment_intent's ID. @@ -841,9 +840,7 @@ public function test_payment_intent_succeeded_save_mandate() { 'status' => $intent_status = 'succeeded', ]; - $mock_order = $this->createMock( WC_Order::class ); - - $mock_order + $this->mock_order ->expects( $this->exactly( 6 ) ) ->method( 'update_meta_data' ) ->withConsecutive( @@ -855,17 +852,17 @@ public function test_payment_intent_succeeded_save_mandate() { [ '_intention_status', $intent_status ] ); - $mock_order + $this->mock_order ->expects( $this->exactly( 2 ) ) ->method( 'save' ); - $mock_order + $this->mock_order ->expects( $this->exactly( 2 ) ) ->method( 'has_status' ) ->with( [ 'processing', 'completed' ] ) ->willReturn( false ); - $mock_order + $this->mock_order ->expects( $this->once() ) ->method( 'payment_complete' ); @@ -873,9 +870,9 @@ public function test_payment_intent_succeeded_save_mandate() { ->expects( $this->once() ) ->method( 'order_from_intent_id' ) ->with( 'pi_123123123123123' ) - ->willReturn( $mock_order ); + ->willReturn( $this->mock_order ); - $mock_order + $this->mock_order ->method( 'get_data_store' ) ->willReturn( new \WC_Mock_WC_Data_Store() ); From 023e6b796e422720ea82e79f361471bf1783cd0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ismael=20Marti=CC=81n=20Alabarce?= Date: Fri, 4 Nov 2022 10:56:57 +0100 Subject: [PATCH 16/22] Add changelog entry --- changelog/fix-2935-recurring-transactions-indian-ccs | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog/fix-2935-recurring-transactions-indian-ccs diff --git a/changelog/fix-2935-recurring-transactions-indian-ccs b/changelog/fix-2935-recurring-transactions-indian-ccs new file mode 100644 index 00000000000..1c660f70130 --- /dev/null +++ b/changelog/fix-2935-recurring-transactions-indian-ccs @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Recurring payments for cards issued by Indian banks. From 7edbf442a548321985404b549f2afa31628cf4fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ismael=20Marti=CC=81n=20Alabarce?= Date: Thu, 17 Nov 2022 12:17:31 +0100 Subject: [PATCH 17/22] Update notification note to meet new Stripe requriements We need to use `processing` prop rather than `next_action` updating the intention class accordingly. --- includes/class-wc-payment-gateway-wcpay.php | 4 +++- ...ait-wc-payment-gateway-wcpay-subscriptions.php | 13 +++++++------ .../class-wc-payments-api-client.php | 4 +++- .../models/class-wc-payments-api-intention.php | 15 ++++++++++++++- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index fb8bd64e175..4ea016ee138 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -1029,6 +1029,7 @@ public function process_payment_for_order( $cart, $payment_information, $additio $client_secret = $intent->get_client_secret(); $currency = $intent->get_currency(); $next_action = $intent->get_next_action(); + $processing = $intent->get_processing(); // We update the payment method ID server side when it's necessary to clone payment methods, // for example when saving a payment method to a platform customer account. When this happens // we need to make sure the payment method on the order matches the one on the merchant account @@ -1093,6 +1094,7 @@ public function process_payment_for_order( $cart, $payment_information, $additio $client_secret = $intent['client_secret']; $currency = $order->get_currency(); $next_action = $intent['next_action']; + $processing = []; } if ( ! empty( $intent ) ) { @@ -1174,7 +1176,7 @@ public function process_payment_for_order( $cart, $payment_information, $additio $this->attach_exchange_info_to_order( $order, $charge_id ); $this->update_order_status_from_intent( $order, $intent_id, $status, $charge_id ); - $this->maybe_add_card_await_notification_note( $order, $next_action ); + $this->maybe_add_customer_notification_note( $order, $processing ); if ( isset( $response ) ) { return $response; 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 7a676021db5..d38fd185639 100644 --- a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php +++ b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php @@ -850,14 +850,15 @@ public function get_mandate_params_for_order( WC_Order $order ): array { * 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 $next_action Next action intent content from Stripe's response. + * @param array $processing Processing state from Stripe's intent response. * @return void */ - public function maybe_add_card_await_notification_note( WC_Order $order, array $next_action = null ) { - if ( isset( $next_action['type'] ) && 'card_await_notification' === $next_action['type'] ) { - $charge_attempt_at = $next_action['card_await_notification']['charge_attempt_at']; - $attempt_date = wp_date( get_option( 'date_format', 'F j, Y' ), $charge_attempt_at, wp_timezone() ); - $attempt_time = wp_date( get_option( 'time_format', 'g:i a' ), $charge_attempt_at, wp_timezone() ); + 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' */ diff --git a/includes/wc-payment-api/class-wc-payments-api-client.php b/includes/wc-payment-api/class-wc-payments-api-client.php index 3da56e85d54..6773ba614d6 100644 --- a/includes/wc-payment-api/class-wc-payments-api-client.php +++ b/includes/wc-payment-api/class-wc-payments-api-client.php @@ -2455,6 +2455,7 @@ private function deserialize_intention_object_from_array( array $intention_array $metadata = ! empty( $intention_array['metadata'] ) ? $intention_array['metadata'] : []; $customer = $intention_array['customer'] ?? $charge_array['customer'] ?? null; $payment_method = $intention_array['payment_method'] ?? $intention_array['source'] ?? null; + $processing = $intention_array['processing'] ?? []; $charge = ! empty( $charge_array ) ? self::deserialize_charge_object_from_array( $charge_array ) : null; @@ -2470,7 +2471,8 @@ private function deserialize_intention_object_from_array( array $intention_array $charge, $next_action, $last_payment_error, - $metadata + $metadata, + $processing ); return $intent; diff --git a/includes/wc-payment-api/models/class-wc-payments-api-intention.php b/includes/wc-payment-api/models/class-wc-payments-api-intention.php index 6ae57a04324..2d45d7034e5 100644 --- a/includes/wc-payment-api/models/class-wc-payments-api-intention.php +++ b/includes/wc-payment-api/models/class-wc-payments-api-intention.php @@ -112,6 +112,7 @@ class WC_Payments_API_Intention implements \JsonSerializable { * @param array $next_action - An array containing information for next action to take. * @param array $last_payment_error - An array containing details of any errors. * @param array $metadata - An array containing additional metadata of associated charge or order. + * @param array $processing - An array containing details of the processing state of the payment. */ public function __construct( $id, @@ -125,7 +126,8 @@ public function __construct( $charge = null, $next_action = [], $last_payment_error = [], - $metadata = [] + $metadata = [], + $processing = [] ) { $this->id = $id; $this->amount = $amount; @@ -139,6 +141,7 @@ public function __construct( $this->payment_method_id = $payment_method_id; $this->charge = $charge; $this->metadata = $metadata; + $this->processing = $processing; } /** @@ -249,6 +252,15 @@ public function get_metadata() { return $this->metadata; } + /** + * Returns the processing state of this intention + * + * @return array + */ + public function get_processing() { + return $this->processing; + } + /** * Defines which data will be serialized to JSON */ @@ -262,6 +274,7 @@ public function jsonSerialize(): array { 'customer' => $this->get_customer_id(), 'metadata' => $this->get_metadata(), 'payment_method' => $this->get_payment_method_id(), + 'processing' => $this->get_processing(), 'status' => $this->get_status(), ]; } From 155efb398c25e39d6085daaf29b1fac7953204d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ismael=20Marti=CC=81n=20Alabarce?= Date: Thu, 17 Nov 2022 12:50:07 +0100 Subject: [PATCH 18/22] Update `process_webhook_payment_intent_failed` to support intent with no charges. Using `last_payment_error` as the source of details. And ensure we return the Stripe error message when there is no error code match. --- ...wc-payments-webhook-processing-service.php | 60 ++++++------ ...wc-payments-webhook-processing-service.php | 97 +++++++++++++++++-- 2 files changed, 118 insertions(+), 39 deletions(-) diff --git a/includes/class-wc-payments-webhook-processing-service.php b/includes/class-wc-payments-webhook-processing-service.php index 7559f0569c0..3a931a4158d 100644 --- a/includes/class-wc-payments-webhook-processing-service.php +++ b/includes/class-wc-payments-webhook-processing-service.php @@ -324,10 +324,13 @@ private function process_webhook_expired_authorization( $event_body ) { */ private function process_webhook_payment_intent_failed( $event_body ) { // Check to make sure we should process this according to the payment method. - $charges_data = $event_body['data']['object']['charges']['data'][0] ?? null; - $payment_method_type = $charges_data['payment_method_details']['type'] ?? null; + $charge_id = $event_body['data']['object']['charges']['data'][0]['id'] ?? ''; + $last_payment_error = $event_body['data']['object']['last_payment_error'] ?? null; + $payment_method = $last_payment_error['payment_method'] ?? null; + $payment_method_type = $payment_method['type'] ?? null; $actionable_methods = [ + Payment_Method::CARD, Payment_Method::US_BANK_ACCOUNT, Payment_Method::BECS, ]; @@ -338,7 +341,7 @@ private function process_webhook_payment_intent_failed( $event_body ) { // Get the order and make sure it is an order and the payment methods match. $order = $this->get_order_from_event_body_intent_id( $event_body ); - $payment_method_id = $charges_data['payment_method'] ?? null; + $payment_method_id = $payment_method['id'] ?? null; if ( ! $order || empty( $payment_method_id ) @@ -350,9 +353,8 @@ private function process_webhook_payment_intent_failed( $event_body ) { $event_object = $this->read_webhook_property( $event_data, 'object' ); $intent_id = $this->read_webhook_property( $event_object, 'id' ); $intent_status = $this->read_webhook_property( $event_object, 'status' ); - $charge_id = $this->read_webhook_property( $charges_data, 'id' ); - $this->order_service->mark_payment_failed( $order, $intent_id, $intent_status, $charge_id, $this->get_failure_message_from_event( $event_body ) ); } + $this->order_service->mark_payment_failed( $order, $intent_id, $intent_status, $charge_id, $this->get_failure_message_from_error( $last_payment_error ) ); } /** * Process webhook for a successful payment intent. @@ -627,45 +629,43 @@ private function get_order_from_event_body_intent_id( $event_body ) { } /** - * Gets the proper failure message from the code in the event. + * Gets the proper failure message from the code in the error. + * Error codes from https://stripe.com/docs/error-codes. * - * @param array $event_body The event that triggered the webhook. + * @param array $error The last payment error from the payment failed event. * * @return string The failure message. */ - private function get_failure_message_from_event( $event_body ):string { - // Get the failure code from the event body. - $event_data = $this->read_webhook_property( $event_body, 'data' ); - $event_object = $this->read_webhook_property( $event_data, 'object' ); - $event_charges = $this->read_webhook_property( $event_object, 'charges' ); - $charges_data = $this->read_webhook_property( $event_charges, 'data' ); - $failure_code = $charges_data[0]['failure_code'] ?? ''; + private function get_failure_message_from_error( $error ):string { + $code = $error['code'] ?? ''; + $decline_code = $error['decline_code'] ?? ''; + $message = $error['message'] ?? ''; - switch ( $failure_code ) { + switch ( $code ) { case 'account_closed': - $failure_message = __( "The customer's bank account has been closed.", 'woocommerce-payments' ); - break; + return __( "The customer's bank account has been closed.", 'woocommerce-payments' ); case 'debit_not_authorized': - $failure_message = __( 'The customer has notified their bank that this payment was unauthorized.', 'woocommerce-payments' ); - break; + return __( 'The customer has notified their bank that this payment was unauthorized.', 'woocommerce-payments' ); case 'insufficient_funds': - $failure_message = __( "The customer's account has insufficient funds to cover this payment.", 'woocommerce-payments' ); - break; + return __( "The customer's account has insufficient funds to cover this payment.", 'woocommerce-payments' ); case 'no_account': - $failure_message = __( "The customer's bank account could not be located.", 'woocommerce-payments' ); - break; + return __( "The customer's bank account could not be located.", 'woocommerce-payments' ); case 'payment_method_microdeposit_failed': - $failure_message = __( 'Microdeposit transfers failed. Please check the account, institution and transit numbers.', 'woocommerce-payments' ); - break; + return __( 'Microdeposit transfers failed. Please check the account, institution and transit numbers.', 'woocommerce-payments' ); case 'payment_method_microdeposit_verification_attempts_exceeded': - $failure_message = __( 'You have exceeded the number of allowed verification attempts.', 'woocommerce-payments' ); + return __( 'You have exceeded the number of allowed verification attempts.', 'woocommerce-payments' ); + case 'card_declined': + switch ( $decline_code ) { + case 'debit_notification_undelivered': + return __( "The customer's bank could not send pre-debit notification for the payment.", 'woocommerce-payments' ); + case 'transaction_not_approved': + return __( 'For recurring payment greater than mandate amount or INR 15000, payment was not approved by the card holder.', 'woocommerce-payments' ); + } break; default: - $failure_message = __( 'The payment was not able to be processed.', 'woocommerce-payments' ); - break; + // translators: %s Stripe error message. + return sprintf( __( 'With the following message: %s', 'woocommerce-payments' ), $message ); } - - return $failure_message; } } diff --git a/tests/unit/test-class-wc-payments-webhook-processing-service.php b/tests/unit/test-class-wc-payments-webhook-processing-service.php index 5c589be9121..1a8399e6927 100644 --- a/tests/unit/test-class-wc-payments-webhook-processing-service.php +++ b/tests/unit/test-class-wc-payments-webhook-processing-service.php @@ -889,15 +889,15 @@ public function test_payment_intent_successful_and_save_mandate() { } /** - * Tests that a payment_intent.succeeded event will complete the order. + * Tests that a payment_intent.payment_failed event set order status to failed and adds a respective order note. */ public function test_payment_intent_fails_and_fails_order() { $this->event_body['type'] = 'payment_intent.payment_failed'; $this->event_body['data']['object'] = [ - 'id' => 'pi_123123123123123', // Payment_intent's ID. - 'object' => 'payment_intent', - 'amount' => 1500, - 'charges' => [ + 'id' => 'pi_123123123123123', // Payment_intent's ID. + 'object' => 'payment_intent', + 'amount' => 1500, + 'charges' => [ 'data' => [ [ 'id' => 'py_123123123123123', @@ -908,8 +908,15 @@ public function test_payment_intent_fails_and_fails_order() { ], ], ], - 'currency' => 'usd', - 'status' => 'requires_payment_method', + 'last_payment_error' => [ + 'message' => 'error message', + 'payment_method' => [ + 'id' => 'pm_123123123123123', + 'type' => 'us_bank_account', + ], + ], + 'currency' => 'usd', + 'status' => 'requires_payment_method', ]; $this->mock_order @@ -938,8 +945,80 @@ public function test_payment_intent_fails_and_fails_order() { ->expects( $this->once() ) ->method( 'add_order_note' ) ->with( - $this->matchesRegularExpression( - '/The payment was not able to be processed/' + $this->stringContains( + 'With the following message: error message' + ) + ); + + $this->mock_order + ->expects( $this->once() ) + ->method( 'update_status' ) + ->with( 'failed' ); + + $this->mock_order + ->method( 'get_data_store' ) + ->willReturn( new \WC_Mock_WC_Data_Store() ); + + $this->mock_db_wrapper + ->expects( $this->once() ) + ->method( 'order_from_intent_id' ) + ->with( 'pi_123123123123123' ) + ->willReturn( $this->mock_order ); + + // Run the test. + $this->webhook_processing_service->process( $this->event_body ); + } + + /** + * Tests that a payment_intent.payment_failed event without charges set order status to failed and adds a respective order note. + */ + public function test_payment_intent_without_charges_fails_and_fails_order() { + $this->event_body['type'] = 'payment_intent.payment_failed'; + $this->event_body['data']['object'] = [ + 'id' => 'pi_123123123123123', // Payment_intent's ID. + 'object' => 'payment_intent', + 'amount' => 1500, + 'charges' => [], + 'last_payment_error' => [ + 'code' => 'card_declined', + 'decline_code' => 'debit_notification_undelivered', + 'payment_method' => [ + 'id' => 'pm_123123123123123', + 'type' => 'us_bank_account', + ], + ], + 'currency' => 'usd', + 'status' => 'requires_payment_method', + ]; + + $this->mock_order + ->expects( $this->exactly( 2 ) ) + ->method( 'get_meta' ) + ->withConsecutive( + [ '_payment_method_id' ], + [ '_intention_status' ] + ) + ->willReturnOnConsecutiveCalls( + 'pm_123123123123123', + false + ); + + $this->mock_order + ->expects( $this->exactly( 3 ) ) + ->method( 'has_status' ) + ->withConsecutive( + [ [ 'failed' ] ], + [ [ 'processing', 'completed' ] ], + [ [ 'processing', 'completed' ] ] + ) + ->willReturn( false ); + + $this->mock_order + ->expects( $this->once() ) + ->method( 'add_order_note' ) + ->with( + $this->stringContains( + "The customer's bank could not send pre-debit notification for the payment" ) ); From 823fb03473cd6646dd42ddba3458d7dc84a89b36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ismael=20Marti=CC=81n=20Alabarce?= Date: Fri, 18 Nov 2022 10:12:48 +0100 Subject: [PATCH 19/22] Fix psalm errors --- includes/class-wc-payments-webhook-processing-service.php | 8 +++----- .../models/class-wc-payments-api-intention.php | 7 +++++++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/includes/class-wc-payments-webhook-processing-service.php b/includes/class-wc-payments-webhook-processing-service.php index 3a931a4158d..f338ec7f3d0 100644 --- a/includes/class-wc-payments-webhook-processing-service.php +++ b/includes/class-wc-payments-webhook-processing-service.php @@ -661,11 +661,9 @@ private function get_failure_message_from_error( $error ):string { case 'transaction_not_approved': return __( 'For recurring payment greater than mandate amount or INR 15000, payment was not approved by the card holder.', 'woocommerce-payments' ); } - break; - - default: - // translators: %s Stripe error message. - return sprintf( __( 'With the following message: %s', 'woocommerce-payments' ), $message ); } + + // translators: %s Stripe error message. + return sprintf( __( 'With the following message: %s', 'woocommerce-payments' ), $message ); } } diff --git a/includes/wc-payment-api/models/class-wc-payments-api-intention.php b/includes/wc-payment-api/models/class-wc-payments-api-intention.php index 2d45d7034e5..4b8bdbd09c2 100644 --- a/includes/wc-payment-api/models/class-wc-payments-api-intention.php +++ b/includes/wc-payment-api/models/class-wc-payments-api-intention.php @@ -97,6 +97,13 @@ class WC_Payments_API_Intention implements \JsonSerializable { */ private $metadata; + /** + * The details on the state of the payment. + * + * @var array + */ + private $processing; + /** * WC_Payments_API_Intention constructor. * From d26264a205f503b5691c07e263302117c7f35380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ismael=20Marti=CC=81n=20Alabarce?= Date: Mon, 21 Nov 2022 11:46:16 +0100 Subject: [PATCH 20/22] Minor comment update --- .../trait-wc-payment-gateway-wcpay-subscriptions.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 d38fd185639..7d79bc4e7fe 100644 --- a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php +++ b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php @@ -846,7 +846,7 @@ public function get_mandate_params_for_order( WC_Order $order ): array { } /** - * Add an order note if the renew intent next action requires the merchant to authenticate the payment. + * 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. @@ -873,7 +873,7 @@ public function maybe_add_customer_notification_note( WC_Order $order, array $pr } /** - * Add mandate ID parameter to renewal payment if exists. + * 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 * From d220c6d5e7a19e1bbe752d9ccb12662b14a56f4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ismael=20Marti=CC=81n=20Alabarce?= Date: Wed, 23 Nov 2022 10:32:17 +0100 Subject: [PATCH 21/22] Use early return and align docblock params --- ...wc-payment-gateway-wcpay-subscriptions.php | 63 ++++++++++--------- .../class-wc-payments-api-client.php | 14 ++--- 2 files changed, 39 insertions(+), 38 deletions(-) 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 7d79bc4e7fe..08111627101 100644 --- a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php +++ b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php @@ -806,40 +806,41 @@ private function is_wcpay_subscription_renewal_order( WC_Order $renewal_order ) public function get_mandate_params_for_order( WC_Order $order ): array { $result = []; - if ( $this->is_subscriptions_enabled() ) { - $subscriptions = wcs_get_subscriptions_for_order( $order->get_id() ); - $subscription = reset( $subscriptions ); + if ( ! $this->is_subscriptions_enabled() ) { + return $result; + } + $subscriptions = wcs_get_subscriptions_for_order( $order->get_id() ); + $subscription = reset( $subscriptions ); - if ( ! $subscription ) { - return $result; - } + if ( ! $subscription ) { + return $result; + } - // Get total by adding only subscriptions and get rid of any other product or fee. - $subs_amount = 0; - foreach ( $subscriptions as $sub ) { - $subs_amount += $sub->get_total(); - } + // Get total by adding only subscriptions and get rid of any other product or fee. + $subs_amount = 0; + foreach ( $subscriptions as $sub ) { + $subs_amount += $sub->get_total(); + } - $result['setup_future_usage'] = 'off_session'; - $result['payment_method_options']['card']['mandate_options'] = [ - 'reference' => $order->get_id(), - 'amount' => WC_Payments_Utils::prepare_amount( $subs_amount, $order->get_currency() ), - '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['payment_method_options']['card']['mandate_options']['amount_type'] = 'maximum'; - $result['payment_method_options']['card']['mandate_options']['interval'] = 'sporadic'; - unset( $result['payment_method_options']['card']['mandate_options']['interval_count'] ); - } + $result['setup_future_usage'] = 'off_session'; + $result['payment_method_options']['card']['mandate_options'] = [ + 'reference' => $order->get_id(), + 'amount' => WC_Payments_Utils::prepare_amount( $subs_amount, $order->get_currency() ), + '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['payment_method_options']['card']['mandate_options']['amount_type'] = 'maximum'; + $result['payment_method_options']['card']['mandate_options']['interval'] = 'sporadic'; + unset( $result['payment_method_options']['card']['mandate_options']['interval_count'] ); } return $result; diff --git a/includes/wc-payment-api/class-wc-payments-api-client.php b/includes/wc-payment-api/class-wc-payments-api-client.php index 6773ba614d6..edd13539024 100644 --- a/includes/wc-payment-api/class-wc-payments-api-client.php +++ b/includes/wc-payment-api/class-wc-payments-api-client.php @@ -256,13 +256,13 @@ public function create_and_confirm_intention( /** * Create an intention, without confirming it. * - * @param int $amount - Amount to charge. - * @param string $currency_code - Currency to charge in. - * @param array $payment_methods - Payment methods to include. - * @param string $order_number - The order number. - * @param string $capture_method - optional capture method (either `automatic` or `manual`). - * @param array $metadata - A list of intent metadata. - * @param string|null $customer_id - Customer id for intent. + * @param int $amount - Amount to charge. + * @param string $currency_code - Currency to charge in. + * @param array $payment_methods - Payment methods to include. + * @param string $order_number - The order number. + * @param string $capture_method - optional capture method (either `automatic` or `manual`). + * @param array $metadata - A list of intent metadata. + * @param string|null $customer_id - Customer id for intent. * @param array $additional_parameters - An array of any additional request parameters, particularly for additional payment methods. * * @return WC_Payments_API_Intention From 2777b248964e08a9e9fe6da1a6db15d0bdb133e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ismael=20Marti=CC=81n=20Alabarce?= Date: Tue, 29 Nov 2022 11:15:39 +0100 Subject: [PATCH 22/22] Revert unneeded change There is no need for $additional_parameters in create_intention --- .../class-wc-payments-api-client.php | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/includes/wc-payment-api/class-wc-payments-api-client.php b/includes/wc-payment-api/class-wc-payments-api-client.php index dab833ce57d..e5abafc602b 100644 --- a/includes/wc-payment-api/class-wc-payments-api-client.php +++ b/includes/wc-payment-api/class-wc-payments-api-client.php @@ -258,14 +258,13 @@ public function create_and_confirm_intention( /** * Create an intention, without confirming it. * - * @param int $amount - Amount to charge. - * @param string $currency_code - Currency to charge in. - * @param array $payment_methods - Payment methods to include. - * @param string $order_number - The order number. - * @param string $capture_method - optional capture method (either `automatic` or `manual`). - * @param array $metadata - A list of intent metadata. - * @param string|null $customer_id - Customer id for intent. - * @param array $additional_parameters - An array of any additional request parameters, particularly for additional payment methods. + * @param int $amount - Amount to charge. + * @param string $currency_code - Currency to charge in. + * @param array $payment_methods - Payment methods to include. + * @param string $order_number - The order number. + * @param string $capture_method - optional capture method (either `automatic` or `manual`). + * @param array $metadata - A list of intent metadata. + * @param string|null $customer_id - Customer id for intent. * * @return WC_Payments_API_Intention * @throws API_Exception - Exception thrown on intention creation failure. @@ -277,8 +276,7 @@ public function create_intention( $order_number, $capture_method = 'automatic', array $metadata = [], - $customer_id = null, - $additional_parameters = [] + $customer_id = null ) { $fingerprint = isset( $metadata['fingerprint'] ) ? $metadata['fingerprint'] : ''; unset( $metadata['fingerprint'] ); @@ -294,8 +292,6 @@ public function create_intention( $request['customer'] = $customer_id; } - $request = array_merge( $request, $additional_parameters ); - $response_array = $this->request( $request, self::INTENTIONS_API, self::POST ); return $this->deserialize_intention_object_from_array( $response_array );