Skip to content

Commit 060fa87

Browse files
wjrosadiegocurbelo
andauthored
Fix creation of duplicate tokens (#3635)
* Fix creation of duplicate tokens * Fix creation of duplicate tokens * Fix duplicate tokens bug when updateing existing ones * Changelog and readme entries * Removing duplicate method * Reverting unnecessary change * Fix duplicate tokens for the block checkout * Readme and changelog updates * Readme and changelog updates * Not updating tokens if the new order is a subscription * Block existing token update if any subscription is using it * Replacing token update block with new method * Removing subscription limitation * Comparing fingerprint instead of card details + new fingerprint trait + moving tokens to a new folder * Fix CC token class overitte * Adding specific unit tests * Adding specific unit tests * Removing unnecessary trait usage * Fix tests * Adding specific unit tests * Adding specific unit tests * Fix tests * New trait to simplify duplicate comparison logic * Renaming trait + including it * Specific unit tests * Renaming the search method to get * Transforming trait into interface * Fix tests --------- Co-authored-by: Diego Curbelo <[email protected]>
1 parent 0a93ce6 commit 060fa87

20 files changed

+641
-76
lines changed

changelog.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
*** Changelog ***
22

33
= 9.1.0 - xxxx-xx-xx =
4+
* Fix - Prevents duplicated credit cards to be added to the customer's account through the My Account page, the shortcode checkout and the block checkout.
45
* Fix - Return to the correct page when redirect-based payment method fails.
56
* Fix - Show default recipient for Payment Authentication Requested email.
67
* Fix - Correctly handles IPP failed payments webhook calls by extracting the order ID from the payment intent metadata.

includes/class-wc-stripe-customer.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -391,28 +391,31 @@ public function add_source( $source_id ) {
391391
$wc_token->set_token( $response->id );
392392
$wc_token->set_gateway_id( 'stripe_sepa' );
393393
$wc_token->set_last4( $response->sepa_debit->last4 );
394+
$wc_token->set_fingerprint( $response->sepa_debit->fingerprint );
394395
break;
395396
default:
396397
if ( WC_Stripe_Helper::is_card_payment_method( $response ) ) {
397-
$wc_token = new WC_Payment_Token_CC();
398+
$wc_token = new WC_Stripe_Payment_Token_CC();
398399
$wc_token->set_token( $response->id );
399400
$wc_token->set_gateway_id( 'stripe' );
400401
$wc_token->set_card_type( strtolower( $response->card->brand ) );
401402
$wc_token->set_last4( $response->card->last4 );
402403
$wc_token->set_expiry_month( $response->card->exp_month );
403404
$wc_token->set_expiry_year( $response->card->exp_year );
405+
$wc_token->set_fingerprint( $response->card->fingerprint );
404406
}
405407
break;
406408
}
407409
} else {
408410
// Legacy.
409-
$wc_token = new WC_Payment_Token_CC();
411+
$wc_token = new WC_Stripe_Payment_Token_CC();
410412
$wc_token->set_token( $response->id );
411413
$wc_token->set_gateway_id( 'stripe' );
412414
$wc_token->set_card_type( strtolower( $response->brand ) );
413415
$wc_token->set_last4( $response->last4 );
414416
$wc_token->set_expiry_month( $response->exp_month );
415417
$wc_token->set_expiry_year( $response->exp_year );
418+
$wc_token->set_fingerprint( $response->fingerprint );
416419
}
417420

418421
$wc_token->set_user_id( $this->get_user_id() );

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

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1474,7 +1474,7 @@ public function is_payment_needed( $order_id = null ) {
14741474

14751475
// Check if the cart contains a pre-order product. Ignore the cart if we're on the Pay for Order page.
14761476
if ( $this->is_pre_order_item_in_cart() && ! $is_pay_for_order_page ) {
1477-
$pre_order_product = $this->get_pre_order_product_from_cart();
1477+
$pre_order_product = $this->get_pre_order_product_from_cart();
14781478

14791479
// Only one pre-order product is allowed per cart,
14801480
// so we can return if it's charged upfront.
@@ -1802,7 +1802,7 @@ private function is_setup_intent_success_creation_redirection() {
18021802
* @param string $setup_intent_id ID of the setup intent.
18031803
* @param WP_User $user User to add token to.
18041804
*
1805-
* @return WC_Payment_Token_CC|WC_Payment_Token_WCPay_SEPA The added token.
1805+
* @return WC_Payment_Token The added token.
18061806
*
18071807
* @since 5.8.0
18081808
* @version 5.8.0
@@ -2216,8 +2216,16 @@ protected function handle_saving_payment_method( WC_Order $order, $payment_metho
22162216
$payment_method_instance = $this->payment_methods[ $payment_method_type ];
22172217
}
22182218

2219-
// Create a payment token for the user in the store.
2220-
$payment_method_instance->create_payment_token_for_user( $user->ID, $payment_method_object );
2219+
// Searches for an existing duplicate token to update.
2220+
$found_token = WC_Stripe_Payment_Tokens::get_duplicate_token( $payment_method_object, $customer->get_user_id(), $this->id );
2221+
2222+
if ( $found_token ) {
2223+
// Update the token with the new payment method ID.
2224+
$payment_method_instance->update_payment_token( $found_token, $payment_method_object->id );
2225+
} else {
2226+
// Create a payment token for the user in the store.
2227+
$payment_method_instance->create_payment_token_for_user( $user->ID, $payment_method_object );
2228+
}
22212229

22222230
// Add the payment method information to the order.
22232231
$prepared_payment_method_object = $this->prepare_payment_method( $payment_method_object );
@@ -2363,7 +2371,16 @@ public function add_payment_method() {
23632371
$customer = new WC_Stripe_Customer( $user->ID );
23642372
$customer->clear_cache();
23652373

2366-
$token = $payment_method->create_payment_token_for_user( $user->ID, $payment_method_object );
2374+
// Check if a token with the same payment method details exist. If so, just updates the payment method ID and return.
2375+
$found_token = WC_Stripe_Payment_Tokens::get_duplicate_token( $payment_method_object, $user->ID, $this->id );
2376+
2377+
// If we have a token found, update it and return.
2378+
if ( $found_token ) {
2379+
$token = $payment_method->update_payment_token( $found_token, $payment_method_object->id );
2380+
} else {
2381+
// Create a new token if not.
2382+
$token = $payment_method->create_payment_token_for_user( $user->ID, $payment_method_object );
2383+
}
23672384

23682385
if ( ! is_a( $token, 'WC_Payment_Token' ) ) {
23692386
throw new WC_Stripe_Exception( sprintf( 'New payment token is not an instance of WC_Payment_Token. Token: %s.', print_r( $token, true ) ) );

includes/payment-methods/class-wc-stripe-upe-payment-method-cc.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,17 +65,18 @@ public function get_retrievable_type() {
6565
* @param string $user_id WP_User ID
6666
* @param object $payment_method Stripe payment method object
6767
*
68-
* @return WC_Payment_Token_CC
68+
* @return WC_Stripe_Payment_Token_CC
6969
*/
7070
public function create_payment_token_for_user( $user_id, $payment_method ) {
71-
$token = new WC_Payment_Token_CC();
71+
$token = new WC_Stripe_Payment_Token_CC();
7272
$token->set_expiry_month( $payment_method->card->exp_month );
7373
$token->set_expiry_year( $payment_method->card->exp_year );
7474
$token->set_card_type( strtolower( $payment_method->card->display_brand ?? $payment_method->card->networks->preferred ?? $payment_method->card->brand ) );
7575
$token->set_last4( $payment_method->card->last4 );
7676
$token->set_gateway_id( WC_Stripe_UPE_Payment_Gateway::ID );
7777
$token->set_token( $payment_method->id );
7878
$token->set_user_id( $user_id );
79+
$token->set_fingerprint( $payment_method->card->fingerprint );
7980
$token->save();
8081
return $token;
8182
}

includes/payment-methods/class-wc-stripe-upe-payment-method.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,20 @@ public function create_payment_token_for_user( $user_id, $payment_method ) {
374374
$token->set_token( $payment_method->id );
375375
$token->set_payment_method_type( $this->get_id() );
376376
$token->set_user_id( $user_id );
377+
$token->set_fingerprint( $payment_method->sepa_debit->fingerprint );
378+
$token->save();
379+
return $token;
380+
}
381+
382+
/**
383+
* Updates a payment token.
384+
*
385+
* @param WC_Payment_Token $token The token to update.
386+
* @param string $payment_method_id The new payment method ID.
387+
* @return WC_Payment_Token
388+
*/
389+
public function update_payment_token( $token, $payment_method_id ) {
390+
$token->set_token( $payment_method_id );
377391
$token->save();
378392
return $token;
379393
}

includes/class-wc-stripe-cash-app-pay-token.php renamed to includes/payment-tokens/class-wc-stripe-cash-app-payment-token.php

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@
1313
// Exit if accessed directly
1414
defined( 'ABSPATH' ) || exit;
1515

16-
class WC_Payment_Token_CashApp extends WC_Payment_Token {
17-
16+
class WC_Payment_Token_CashApp extends WC_Payment_Token implements WC_Stripe_Payment_Method_Comparison_Interface {
1817
/**
1918
* Token Type.
2019
*
@@ -62,6 +61,20 @@ public function get_cashtag() {
6261
return $this->get_prop( 'cashtag' );
6362
}
6463

64+
/**
65+
* Checks if the payment method token is equal a provided payment method.
66+
*
67+
* @inheritDoc
68+
*/
69+
public function is_equal_payment_method( $payment_method ): bool {
70+
if ( WC_Stripe_Payment_Methods::CASHAPP_PAY === $this->get_type()
71+
&& ( $payment_method->cashapp->cashtag ?? null ) === $this->get_cashtag() ) {
72+
return true;
73+
}
74+
75+
return false;
76+
}
77+
6578
/**
6679
* Returns this token's hook prefix.
6780
*
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
/**
3+
* WooCommerce Stripe Credit Card Payment Token
4+
*
5+
* Representation of a payment token for Credit Card.
6+
*
7+
* @package WooCommerce_Stripe
8+
* @since 9.9.0
9+
*/
10+
11+
// phpcs:disable WordPress.Files.FileName
12+
13+
// Exit if accessed directly
14+
defined( 'ABSPATH' ) || exit;
15+
16+
class WC_Stripe_Payment_Token_CC extends WC_Payment_Token_CC implements WC_Stripe_Payment_Method_Comparison_Interface {
17+
18+
use WC_Stripe_Fingerprint_Trait;
19+
20+
/**
21+
* Constructor.
22+
*
23+
* @inheritDoc
24+
*/
25+
public function __construct( $token = '' ) {
26+
// Add fingerprint to extra data to be persisted.
27+
$this->extra_data['fingerprint'] = '';
28+
29+
parent::__construct( $token );
30+
}
31+
32+
/**
33+
* Checks if the payment method token is equal a provided payment method.
34+
*
35+
* @inheritDoc
36+
*/
37+
public function is_equal_payment_method( $payment_method ): bool {
38+
if ( WC_Stripe_Payment_Methods::CARD === $payment_method->type
39+
&& ( $payment_method->card->fingerprint ?? null ) === $this->get_fingerprint() ) {
40+
return true;
41+
}
42+
43+
return false;
44+
}
45+
}

includes/class-wc-stripe-link-payment-token.php renamed to includes/payment-tokens/class-wc-stripe-link-payment-token.php

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@
1313
*
1414
* @class WC_Payment_Token_Link
1515
*/
16-
class WC_Payment_Token_Link extends WC_Payment_Token {
17-
16+
class WC_Payment_Token_Link extends WC_Payment_Token implements WC_Stripe_Payment_Method_Comparison_Interface {
1817
/**
1918
* Stores payment type.
2019
*
@@ -92,4 +91,18 @@ public function get_email( $context = 'view' ) {
9291
public function set_email( $email ) {
9392
$this->set_prop( 'email', $email );
9493
}
94+
95+
/**
96+
* Checks if the payment method token is equal a provided payment method.
97+
*
98+
* @inheritDoc
99+
*/
100+
public function is_equal_payment_method( $payment_method ): bool {
101+
if ( WC_Stripe_Payment_Methods::LINK === $payment_method->type
102+
&& ( $payment_method->link->email ?? null ) === $this->get_email() ) {
103+
return true;
104+
}
105+
106+
return false;
107+
}
95108
}

includes/class-wc-stripe-payment-tokens.php renamed to includes/payment-tokens/class-wc-stripe-payment-tokens.php

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public function __construct() {
4242
add_filter( 'woocommerce_get_customer_payment_tokens', [ $this, 'woocommerce_get_customer_payment_tokens' ], 10, 3 );
4343
add_filter( 'woocommerce_payment_methods_list_item', [ $this, 'get_account_saved_payment_methods_list_item' ], 10, 2 );
4444
add_filter( 'woocommerce_get_credit_card_type_label', [ $this, 'normalize_sepa_label' ] );
45+
add_filter( 'woocommerce_payment_token_class', [ $this, 'woocommerce_payment_token_class' ], 10, 2 );
4546
add_action( 'woocommerce_payment_token_deleted', [ $this, 'woocommerce_payment_token_deleted' ], 10, 2 );
4647
add_action( 'woocommerce_payment_token_set_default', [ $this, 'woocommerce_payment_token_set_default' ] );
4748
}
@@ -173,7 +174,7 @@ public function woocommerce_get_customer_payment_tokens_legacy( $tokens, $custom
173174
foreach ( $stripe_sources as $source ) {
174175
if ( isset( $source->type ) && WC_Stripe_Payment_Methods::CARD === $source->type ) {
175176
if ( ! isset( $stored_tokens[ $source->id ] ) ) {
176-
$token = new WC_Payment_Token_CC();
177+
$token = new WC_Stripe_Payment_Token_CC();
177178
$token->set_token( $source->id );
178179
$token->set_gateway_id( WC_Gateway_Stripe::ID );
179180

@@ -184,6 +185,7 @@ public function woocommerce_get_customer_payment_tokens_legacy( $tokens, $custom
184185
$token->set_expiry_year( $source->card->exp_year );
185186
}
186187

188+
$token->set_fingerprint( $source->fingerprint );
187189
$token->set_user_id( $customer_id );
188190
$token->save();
189191
$tokens[ $token->get_id() ] = $token;
@@ -192,14 +194,15 @@ public function woocommerce_get_customer_payment_tokens_legacy( $tokens, $custom
192194
}
193195
} else {
194196
if ( ! isset( $stored_tokens[ $source->id ] ) && WC_Stripe_Payment_Methods::CARD === $source->object ) {
195-
$token = new WC_Payment_Token_CC();
197+
$token = new WC_Stripe_Payment_Token_CC();
196198
$token->set_token( $source->id );
197199
$token->set_gateway_id( WC_Gateway_Stripe::ID );
198200
$token->set_card_type( strtolower( $source->brand ) );
199201
$token->set_last4( $source->last4 );
200202
$token->set_expiry_month( $source->exp_month );
201203
$token->set_expiry_year( $source->exp_year );
202204
$token->set_user_id( $customer_id );
205+
$token->set_fingerprint( $source->fingerprint );
203206
$token->save();
204207
$tokens[ $token->get_id() ] = $token;
205208
} else {
@@ -221,6 +224,7 @@ public function woocommerce_get_customer_payment_tokens_legacy( $tokens, $custom
221224
$token->set_gateway_id( WC_Gateway_Stripe_Sepa::ID );
222225
$token->set_last4( $source->sepa_debit->last4 );
223226
$token->set_user_id( $customer_id );
227+
$token->set_fingerprint( $source->fingerprint );
224228
$token->save();
225229
$tokens[ $token->get_id() ] = $token;
226230
} else {
@@ -492,9 +496,9 @@ private function is_valid_payment_method_type_for_gateway( $payment_method_type,
492496
/**
493497
* Creates and add a token to an user, based on the PaymentMethod object.
494498
*
495-
* @param array $payment_method Payment method to be added.
496-
* @param WC_Stripe_Customer $user WC_Stripe_Customer we're processing the tokens for.
497-
* @return WC_Payment_Token_CC|WC_Payment_Token_Link|WC_Payment_Token_SEPA The WC object for the payment token.
499+
* @param object $payment_method Payment method to be added.
500+
* @param WC_Stripe_Customer $customer WC_Stripe_Customer we're processing the tokens for.
501+
* @return WC_Payment_Token The WC object for the payment token.
498502
*/
499503
private function add_token_to_user( $payment_method, WC_Stripe_Customer $customer ) {
500504
// Clear cached payment methods.
@@ -503,13 +507,22 @@ private function add_token_to_user( $payment_method, WC_Stripe_Customer $custome
503507
$payment_method_type = $this->get_original_payment_method_type( $payment_method );
504508
$gateway_id = self::UPE_REUSABLE_GATEWAYS_BY_PAYMENT_METHOD[ $payment_method_type ];
505509

510+
$found_token = $this->get_duplicate_token( $payment_method, $customer->get_user_id(), $gateway_id );
511+
if ( $found_token ) {
512+
// Update the token with the new payment method ID.
513+
$found_token->set_token( $payment_method->id );
514+
$found_token->save();
515+
return $found_token;
516+
}
517+
506518
switch ( $payment_method_type ) {
507519
case WC_Stripe_UPE_Payment_Method_CC::STRIPE_ID:
508-
$token = new WC_Payment_Token_CC();
520+
$token = new WC_Stripe_Payment_Token_CC();
509521
$token->set_expiry_month( $payment_method->card->exp_month );
510522
$token->set_expiry_year( $payment_method->card->exp_year );
511523
$token->set_card_type( strtolower( $payment_method->card->display_brand ?? $payment_method->card->networks->preferred ?? $payment_method->card->brand ) );
512524
$token->set_last4( $payment_method->card->last4 );
525+
$token->set_fingerprint( $payment_method->card->fingerprint );
513526
break;
514527

515528
case WC_Stripe_UPE_Payment_Method_Link::STRIPE_ID:
@@ -528,6 +541,7 @@ private function add_token_to_user( $payment_method, WC_Stripe_Customer $custome
528541
$token = new WC_Payment_Token_SEPA();
529542
$token->set_last4( $payment_method->sepa_debit->last4 );
530543
$token->set_payment_method_type( $payment_method_type );
544+
$token->set_fingerprint( $payment_method->sepa_debit->fingerprint );
531545
}
532546

533547
$token->set_gateway_id( $gateway_id );
@@ -647,6 +661,50 @@ public function is_valid_payment_method_id( $payment_method_id, $payment_method_
647661
return 0 === strpos( $payment_method_id, 'src_' ) && WC_Stripe_Payment_Methods::CARD === $payment_method_type;
648662
}
649663

664+
/**
665+
* Searches for a duplicate token in the user's saved payment methods and returns it.
666+
*
667+
* @param $payment_method stdClass The payment method object.
668+
* @param $user_id int The user ID.
669+
* @param $gateway_id string The gateway ID.
670+
* @return WC_Payment_Token|null
671+
*/
672+
public static function get_duplicate_token( $payment_method, $user_id, $gateway_id ) {
673+
// Using the base method instead of `WC_Payment_Tokens::get_customer_tokens` to avoid recursive calls to hooked filters and actions
674+
$tokens = WC_Payment_Tokens::get_tokens(
675+
[
676+
'user_id' => $user_id,
677+
'gateway_id' => $gateway_id,
678+
'limit' => 100,
679+
]
680+
);
681+
foreach ( $tokens as $token ) {
682+
/**
683+
* Token object.
684+
*
685+
* @var WC_Payment_Token_CashApp|WC_Stripe_Payment_Token_CC|WC_Payment_Token_Link|WC_Payment_Token_SEPA $token
686+
*/
687+
if ( $token->is_equal_payment_method( $payment_method ) ) {
688+
return $token;
689+
}
690+
}
691+
return null;
692+
}
693+
694+
/**
695+
* Filters the payment token class to override the credit card class with the extension's version.
696+
*
697+
* @param string $class Payment token class.
698+
* @param string $type Token type.
699+
* @return string
700+
*/
701+
public function woocommerce_payment_token_class( $class, $type ) {
702+
if ( WC_Payment_Token_CC::class === $class ) {
703+
return WC_Stripe_Payment_Token_CC::class;
704+
}
705+
return $class;
706+
}
707+
650708
/**
651709
* Controls the output for SEPA on the my account page.
652710
*

0 commit comments

Comments
 (0)