Skip to content

Commit 71f5db5

Browse files
authored
Check intent attached to order before payment (#5346)
* Add `check_payment_intent_attached_to_order_succeeded` to both the main gateway and UPE classes. * Handle the redirection for UPE flow in the front-end (JS code) * Add info text to the thank-you page
1 parent 6cade72 commit 71f5db5

File tree

9 files changed

+405
-22
lines changed

9 files changed

+405
-22
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: patch
2+
Type: update
3+
4+
Check the status of previously initiated payments and mark orders as processing instead of initiating a new payment.

client/checkout/api/index.js

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -705,17 +705,28 @@ export default class WCPayAPI {
705705
}
706706

707707
/**
708-
* If another order has the same cart content and was paid, redirect to its thank-you page.
708+
* Redirect to the order-received page for duplicate payments.
709709
*
710710
* @param {Object} response Response data to check if doing the redirect.
711711
* @return {boolean} Returns true if doing the redirection.
712712
*/
713-
handlePreviousOrderPaid( response ) {
714-
let didRedirection = false;
715-
if ( response.wcpay_upe_paid_for_previous_order && response.redirect ) {
716-
window.location = response.redirect;
717-
didRedirection = true;
713+
handleDuplicatePayments( {
714+
wcpay_upe_paid_for_previous_order: previouslyPaid,
715+
wcpay_upe_previous_successful_intent: previousSuccessfulIntent,
716+
redirect,
717+
} ) {
718+
if ( redirect ) {
719+
// Another order has the same cart content and was paid.
720+
if ( previouslyPaid ) {
721+
return ( window.location = redirect );
722+
}
723+
724+
// Another intent has the equivalent successful status for the order.
725+
if ( previousSuccessfulIntent ) {
726+
return ( window.location = redirect );
727+
}
718728
}
719-
return didRedirection;
729+
730+
return false;
720731
}
721732
}

client/checkout/blocks/upe-fields.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ const WCPayUPEFields = ( {
272272
onCheckoutAfterProcessingWithSuccess(
273273
( { orderId, processingResponse: { paymentDetails } } ) => {
274274
async function updateIntent() {
275-
if ( api.handlePreviousOrderPaid( paymentDetails ) ) {
275+
if ( api.handleDuplicatePayments( paymentDetails ) ) {
276276
return;
277277
}
278278

client/checkout/classic/upe.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,7 @@ jQuery( function ( $ ) {
462462
);
463463

464464
if ( updateResponse.data ) {
465-
if ( api.handlePreviousOrderPaid( updateResponse.data ) ) {
465+
if ( api.handleDuplicatePayments( updateResponse.data ) ) {
466466
return;
467467
}
468468
}
@@ -542,7 +542,7 @@ jQuery( function ( $ ) {
542542
fingerprint ? fingerprint : ''
543543
);
544544

545-
if ( api.handlePreviousOrderPaid( response ) ) {
545+
if ( api.handleDuplicatePayments( response ) ) {
546546
return;
547547
}
548548

includes/class-wc-payment-gateway-wcpay.php

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ class WC_Payment_Gateway_WCPay extends WC_Payment_Gateway_CC {
9696
*/
9797
const FLAG_PREVIOUS_ORDER_PAID = 'wcpay_paid_for_previous_order';
9898

99+
/**
100+
* Flag to indicate that a previous intention attached to the order was successful.
101+
*/
102+
const FLAG_PREVIOUS_SUCCESSFUL_INTENT = 'wcpay_previous_successful_intent';
103+
99104
/**
100105
* Client for making requests to the WooCommerce Payments API
101106
*
@@ -709,12 +714,17 @@ public function process_payment( $order_id ) {
709714

710715
UPE_Payment_Gateway::remove_upe_payment_intent_from_session();
711716

712-
$check_response = $this->check_against_session_processing_order( $order );
713-
if ( is_array( $check_response ) ) {
714-
return $check_response;
717+
$check_session_order = $this->check_against_session_processing_order( $order );
718+
if ( is_array( $check_session_order ) ) {
719+
return $check_session_order;
715720
}
716721
$this->maybe_update_session_processing_order( $order_id );
717722

723+
$check_existing_intention = $this->check_payment_intent_attached_to_order_succeeded( $order );
724+
if ( is_array( $check_existing_intention ) ) {
725+
return $check_existing_intention;
726+
}
727+
718728
$payment_information = $this->prepare_payment_information( $order );
719729
return $this->process_payment_for_order( WC()->cart, $payment_information );
720730
} catch ( Exception $e ) {
@@ -1924,6 +1934,56 @@ protected function get_account_branding_icon( $default_value = '' ): string {
19241934
return $default_value;
19251935
}
19261936

1937+
/**
1938+
* Checks if the attached payment intent was successful for the current order.
1939+
*
1940+
* @param WC_Order $order Current order to check.
1941+
*
1942+
* @return array|void A successful response in case the attached intent was successful, null if none.
1943+
*/
1944+
protected function check_payment_intent_attached_to_order_succeeded( WC_Order $order ) {
1945+
$intent_id = (string) $order->get_meta( '_intent_id', true );
1946+
if ( empty( $intent_id ) ) {
1947+
return;
1948+
}
1949+
1950+
// We only care about payment intent.
1951+
$is_payment_intent = 'pi_' === substr( $intent_id, 0, 3 );
1952+
if ( ! $is_payment_intent ) {
1953+
return;
1954+
}
1955+
1956+
try {
1957+
$intent = $this->payments_api_client->get_intent( $intent_id );
1958+
$intent_status = $intent->get_status();
1959+
} catch ( Exception $e ) {
1960+
Logger::error( 'Failed to fetch attached payment intent: ' . $e );
1961+
return;
1962+
};
1963+
1964+
if ( ! in_array( $intent_status, self::SUCCESSFUL_INTENT_STATUS, true ) ) {
1965+
return;
1966+
}
1967+
1968+
$intent_meta_order_id_raw = $intent->get_metadata()['order_id'] ?? '';
1969+
$intent_meta_order_id = is_numeric( $intent_meta_order_id_raw ) ? intval( $intent_meta_order_id_raw ) : 0;
1970+
if ( $intent_meta_order_id !== $order->get_id() ) {
1971+
return;
1972+
}
1973+
1974+
$charge = $intent->get_charge();
1975+
$charge_id = $charge ? $charge->get_id() : null;
1976+
$this->update_order_status_from_intent( $order, $intent_id, $intent_status, $charge_id );
1977+
1978+
$return_url = $this->get_return_url( $order );
1979+
$return_url = add_query_arg( self::FLAG_PREVIOUS_SUCCESSFUL_INTENT, 'yes', $return_url );
1980+
return [
1981+
'result' => 'success',
1982+
'redirect' => $return_url,
1983+
'wcpay_upe_previous_successful_intent' => 'yes', // This flag is needed for UPE flow.
1984+
];
1985+
}
1986+
19271987
/**
19281988
* Checks if the current order has the same content with the session processing order, which was already paid (ex. by a webhook).
19291989
*

includes/class-wc-payments-order-success-page.php

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public function __construct() {
1717
add_filter( 'woocommerce_thankyou_order_received_text', [ $this, 'show_woopay_thankyou_notice' ], 10, 2 );
1818
add_action( 'woocommerce_thankyou_woocommerce_payments', [ $this, 'show_woopay_payment_method_name' ] );
1919
add_filter( 'woocommerce_thankyou_order_received_text', [ $this, 'add_notice_previous_paid_order' ], 11 );
20+
add_filter( 'woocommerce_thankyou_order_received_text', [ $this, 'add_notice_previous_successful_intent' ], 11 );
2021
add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_scripts' ] );
2122
}
2223

@@ -70,13 +71,13 @@ public function show_woopay_payment_method_name( $order_id ) {
7071
}
7172

7273
/**
73-
* Add the notice to the thank you take in case a recent order with the same content has already paid.
74+
* Add the notice to the thank you page in case a recent order with the same content has already paid.
7475
*
7576
* @param string $text the default thank you text.
7677
*
7778
* @return string
7879
*/
79-
public function add_notice_previous_paid_order( $text ) {
80+
public function add_notice_previous_paid_order( string $text ) {
8081
if ( isset( $_GET[ WC_Payment_Gateway_WCPay::FLAG_PREVIOUS_ORDER_PAID ] ) ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended
8182
$text .= sprintf(
8283
'<div class="woocommerce-info">%s</div>',
@@ -86,6 +87,25 @@ public function add_notice_previous_paid_order( $text ) {
8687

8788
return $text;
8889
}
90+
91+
/**
92+
* Add the notice to the thank you page in case an existing intention was successful for the order.
93+
*
94+
* @param string $text the default thank you text.
95+
*
96+
* @return string
97+
*/
98+
public function add_notice_previous_successful_intent( string $text ) {
99+
if ( isset( $_GET[ WC_Payment_Gateway_WCPay::FLAG_PREVIOUS_SUCCESSFUL_INTENT ] ) ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended
100+
$text .= sprintf(
101+
'<div class="woocommerce-info">%s</div>',
102+
esc_attr__( 'We prevented multiple payments for the same order. If this was a mistake and you wish to try again, please create a new order.', 'woocommerce-payments' )
103+
);
104+
}
105+
106+
return $text;
107+
}
108+
89109
/**
90110
* Enqueue style to the order success page
91111
*/

includes/payment-methods/class-upe-payment-gateway.php

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -207,12 +207,17 @@ public function update_payment_intent( $payment_intent_id = '', $order_id = null
207207
return;
208208
}
209209

210-
$check_response = $this->check_against_session_processing_order( $order );
211-
if ( is_array( $check_response ) ) {
212-
return $check_response;
210+
$check_session_order = $this->check_against_session_processing_order( $order );
211+
if ( is_array( $check_session_order ) ) {
212+
return $check_session_order;
213213
}
214214
$this->maybe_update_session_processing_order( $order_id );
215215

216+
$check_existing_intention = $this->check_payment_intent_attached_to_order_succeeded( $order );
217+
if ( is_array( $check_existing_intention ) ) {
218+
return $check_existing_intention;
219+
}
220+
216221
$amount = $order->get_total();
217222
$currency = $order->get_currency();
218223

@@ -480,12 +485,17 @@ public function process_payment( $order_id ) {
480485
throw new Exception( WC_Payments_Utils::get_filtered_error_message( $exception ) );
481486
}
482487

483-
$check_response = $this->check_against_session_processing_order( $order );
484-
if ( is_array( $check_response ) ) {
485-
return $check_response;
488+
$check_session_order = $this->check_against_session_processing_order( $order );
489+
if ( is_array( $check_session_order ) ) {
490+
return $check_session_order;
486491
}
487492
$this->maybe_update_session_processing_order( $order_id );
488493

494+
$check_existing_intention = $this->check_payment_intent_attached_to_order_succeeded( $order );
495+
if ( is_array( $check_existing_intention ) ) {
496+
return $check_existing_intention;
497+
}
498+
489499
$additional_api_parameters = $this->get_mandate_params_for_order( $order );
490500

491501
try {

tests/unit/payment-methods/test-class-upe-payment-gateway.php

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,6 +943,147 @@ public function provider_process_payment_check_session_and_continue_processing()
943943
];
944944
}
945945

946+
/**
947+
* @dataProvider provider_check_payment_intent_attached_to_order_succeeded_with_invalid_intent_id_continue_process_payment
948+
* @param ?string $invalid_intent_id An invalid payment intent ID. If no intent id is set, this can be null.
949+
*/
950+
public function test_upe_check_payment_intent_attached_to_order_succeeded_with_invalid_intent_id_continue_process_payment( $invalid_intent_id ) {
951+
$_POST['wc_payment_intent_id'] = 'pi_mock';
952+
953+
// Arrange order.
954+
$order = WC_Helper_Order::create_order();
955+
$order->update_meta_data( '_intent_id', $invalid_intent_id );
956+
$order->save();
957+
958+
$order_id = $order->get_id();
959+
960+
// Assert: get_intent is not called.
961+
$this->mock_api_client
962+
->expects( $this->never() )
963+
->method( 'get_intent' );
964+
965+
// Assert: the payment process continues.
966+
$this->mock_api_client
967+
->expects( $this->once() )
968+
->method( 'update_intention' )
969+
->willReturn( WC_Helper_Intention::create_intention() );
970+
971+
// Act: process the order.
972+
$this->mock_upe_gateway->process_payment( $order_id );
973+
}
974+
975+
public function provider_check_payment_intent_attached_to_order_succeeded_with_invalid_intent_id_continue_process_payment(): array {
976+
return [
977+
'No intent_id is attached' => [ null ],
978+
'A setup intent is attached' => [ 'seti_possible_for_a_subscription_id' ],
979+
];
980+
}
981+
982+
/**
983+
* The attached PaymentIntent has invalid info (status or order_id) with the order, so payment_process continues.
984+
*
985+
* @dataProvider provider_check_payment_intent_attached_to_order_succeeded_with_invalid_data_continue_process_payment
986+
* @param string $attached_intent_id Attached intent ID to the order.
987+
* @param string $attached_intent_status Attached intent status.
988+
* @param bool $same_order_id True when the intent meta order_id is exactly the current processing order_id. False otherwise.
989+
*/
990+
public function test_upe_check_payment_intent_attached_to_order_succeeded_with_invalid_data_continue_process_payment(
991+
string $attached_intent_id,
992+
string $attached_intent_status,
993+
bool $same_order_id
994+
) {
995+
$_POST['wc_payment_intent_id'] = 'pi_mock';
996+
// Arrange order.
997+
$order = WC_Helper_Order::create_order();
998+
$order->update_meta_data( '_intent_id', $attached_intent_id );
999+
$order->save();
1000+
1001+
$order_id = $order->get_id();
1002+
1003+
// Arrange mock get_intent.
1004+
$meta_order_id = $same_order_id ? $order_id : $order_id - 1;
1005+
$attached_intent = WC_Helper_Intention::create_intention(
1006+
[
1007+
'id' => $attached_intent_id,
1008+
'status' => $attached_intent_status,
1009+
'metadata' => [ 'order_id' => $meta_order_id ],
1010+
]
1011+
);
1012+
$this->mock_api_client
1013+
->expects( $this->once() )
1014+
->method( 'get_intent' )
1015+
->with( $attached_intent_id )
1016+
->willReturn( $attached_intent );
1017+
1018+
// Assert: the payment process continues.
1019+
$this->mock_api_client
1020+
->expects( $this->once() )
1021+
->method( 'update_intention' )
1022+
->willReturn( WC_Helper_Intention::create_intention() );
1023+
1024+
// Act: process the order.
1025+
$this->mock_upe_gateway->process_payment( $order_id );
1026+
}
1027+
1028+
public function provider_check_payment_intent_attached_to_order_succeeded_with_invalid_data_continue_process_payment(): array {
1029+
return [
1030+
'Attached PaymentIntent with non-success status - same order_id' => [ 'pi_attached_intent_id', 'requires_action', true ],
1031+
'Attached PaymentIntent - non-success status - different order_id' => [ 'pi_attached_intent_id', 'requires_action', false ],
1032+
'Attached PaymentIntent - success status - different order_id' => [ 'pi_attached_intent_id', 'succeeded', false ],
1033+
];
1034+
}
1035+
1036+
/**
1037+
* @dataProvider provider_check_payment_intent_attached_to_order_succeeded_return_redirection
1038+
*/
1039+
public function test_upe_check_payment_intent_attached_to_order_succeeded_return_redirection( string $intent_successful_status ) {
1040+
$_POST['wc_payment_intent_id'] = 'pi_mock';
1041+
$attached_intent_id = 'pi_attached_intent_id';
1042+
1043+
// Arrange order.
1044+
$order = WC_Helper_Order::create_order();
1045+
$order->update_meta_data( '_intent_id', $attached_intent_id );
1046+
$order->save();
1047+
$order_id = $order->get_id();
1048+
1049+
// Arrange mock get_intention.
1050+
$attached_intent = WC_Helper_Intention::create_intention(
1051+
[
1052+
'id' => $attached_intent_id,
1053+
'status' => $intent_successful_status,
1054+
'metadata' => [ 'order_id' => $order_id ],
1055+
]
1056+
);
1057+
1058+
$this->mock_api_client
1059+
->expects( $this->once() )
1060+
->method( 'get_intent' )
1061+
->with( $attached_intent_id )
1062+
->willReturn( $attached_intent );
1063+
1064+
// Assert: no more call to the server to update the intention.
1065+
$this->mock_api_client
1066+
->expects( $this->never() )
1067+
->method( 'update_intention' );
1068+
1069+
// Act: process the order but redirect to the order.
1070+
$result = $this->mock_upe_gateway->process_payment( $order_id );
1071+
1072+
// Assert: the result of check_intent_attached_to_order_succeeded.
1073+
$this->assertSame( 'yes', $result['wcpay_upe_previous_successful_intent'] );
1074+
$this->assertSame( 'success', $result['result'] );
1075+
$this->assertStringContainsString( $this->mock_upe_gateway->get_return_url( $order ), $result['redirect'] );
1076+
}
1077+
1078+
public function provider_check_payment_intent_attached_to_order_succeeded_return_redirection(): array {
1079+
$ret = [];
1080+
foreach ( WC_Payment_Gateway_WCPay::SUCCESSFUL_INTENT_STATUS as $status ) {
1081+
$ret[ 'Intent status ' . $status ] = [ $status ];
1082+
}
1083+
1084+
return $ret;
1085+
}
1086+
9461087
public function test_process_redirect_payment_intent_processing() {
9471088
$order = WC_Helper_Order::create_order();
9481089
$order_id = $order->get_id();

0 commit comments

Comments
 (0)