Skip to content

Commit cb16c6a

Browse files
authored
Correctly handle IPP failed orders (#3689)
* Correctly handle IPP orders * Revert unnecessary changes * Changelog and readme entries * Renaming webhook processing method * Adding specific unit tests * Improve unit tests
1 parent 8ddd5ad commit cb16c6a

File tree

5 files changed

+222
-20
lines changed

5 files changed

+222
-20
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 - Correctly handles IPP failed payments webhook calls by extracting the order ID from the payment intent metadata.
45
* Fix - Fix ECE crash in classic cart and checkout pages for non-English language sites.
56
* Fix - Correctly handles UK postcodes redacted by Apple Pay.
67
* Tweak - Avoid re-sending Processing Order customer email when merchant wins dispute.

includes/class-wc-stripe-webhook-handler.php

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -925,7 +925,7 @@ public function get_partial_amount_to_charge( $notification ) {
925925
*
926926
* @param stdClass $notification The webhook notification from Stripe.
927927
*/
928-
public function process_payment_intent_success( $notification ) {
928+
public function process_payment_intent( $notification ) {
929929
$intent = $notification->data->object;
930930
$order = $this->get_order_from_intent( $intent );
931931

@@ -1001,7 +1001,7 @@ public function process_payment_intent_success( $notification ) {
10011001
break;
10021002
}
10031003

1004-
$error_message = $intent->last_payment_error ? $intent->last_payment_error->message : '';
1004+
$error_message = $intent->last_payment_error->message ?? '';
10051005

10061006
/* translators: 1) The error message that was received from Stripe. */
10071007
$message = sprintf( __( 'Stripe SCA authentication failed. Reason: %s', 'woocommerce-gateway-stripe' ), $error_message );
@@ -1226,7 +1226,7 @@ public function process_webhook( $request_body ) {
12261226
case 'payment_intent.payment_failed':
12271227
case 'payment_intent.amount_capturable_updated':
12281228
case 'payment_intent.requires_action':
1229-
$this->process_payment_intent_success( $notification );
1229+
$this->process_payment_intent( $notification );
12301230
break;
12311231

12321232
case 'setup_intent.succeeded':
@@ -1244,29 +1244,44 @@ public function process_webhook( $request_body ) {
12441244
*/
12451245
private function get_order_from_intent( $intent ) {
12461246
// Attempt to get the order from the intent metadata.
1247-
if ( isset( $intent->metadata->signature ) ) {
1248-
$signature = wc_clean( $intent->metadata->signature );
1249-
$data = explode( ':', $signature );
1247+
if ( isset( $intent->metadata ) ) {
1248+
// Try to retrieve from the signature
1249+
if ( isset( $intent->metadata->signature ) ) {
1250+
$signature = wc_clean( $intent->metadata->signature );
1251+
$data = explode( ':', $signature );
12501252

1251-
// Verify we received the order ID and signature (hash).
1252-
$order = isset( $data[0], $data[1] ) ? wc_get_order( absint( $data[0] ) ) : false;
1253+
// Verify we received the order ID and signature (hash).
1254+
$order = isset( $data[0], $data[1] ) ? wc_get_order( absint( $data[0] ) ) : false;
12531255

1254-
if ( $order ) {
1255-
$intent_id = WC_Stripe_Helper::get_intent_id_from_order( $order );
1256+
if ( $order ) {
1257+
$intent_id = WC_Stripe_Helper::get_intent_id_from_order( $order );
12561258

1257-
// Return the order if the intent ID matches.
1258-
if ( $intent->id === $intent_id ) {
1259-
return $order;
1260-
}
1259+
// Return the order if the intent ID matches.
1260+
if ( $intent->id === $intent_id ) {
1261+
return $order;
1262+
}
12611263

1262-
/**
1263-
* If the order has no intent ID stored, we may have failed to store it during the initial payment request.
1264-
* Confirm that the signature matches the order, otherwise fall back to finding the order via the intent ID.
1265-
*/
1266-
if ( empty( $intent_id ) && $this->get_order_signature( $order ) === $signature ) {
1267-
return $order;
1264+
/**
1265+
* If the order has no intent ID stored, we may have failed to store it during the initial payment request.
1266+
* Confirm that the signature matches the order, otherwise fall back to finding the order via the intent ID.
1267+
*/
1268+
if ( empty( $intent_id ) && $this->get_order_signature( $order ) === $signature ) {
1269+
return $order;
1270+
}
12681271
}
12691272
}
1273+
1274+
// Try to retrieve from the metadata order ID.
1275+
if ( isset( $intent->metadata->order_id ) ) {
1276+
return wc_get_order( absint( $intent->metadata->order_id ) );
1277+
}
1278+
}
1279+
1280+
// Try to retrieve from the charges array.
1281+
if ( ! empty( $intent->charges ) ) {
1282+
$charge = $intent->charges[0] ?? [];
1283+
$order_id = $charge['metadata']['order_id'] ?? null;
1284+
return wc_get_order( $order_id );
12701285
}
12711286

12721287
// Fall back to finding the order via the intent ID.

includes/constants/class-wc-stripe-payment-methods.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class WC_Stripe_Payment_Methods {
2323
const SEPA_DEBIT = 'sepa_debit';
2424
const SOFORT = 'sofort';
2525
const WECHAT_PAY = 'wechat_pay';
26+
const CARD_PRESENT = 'card_present';
2627

2728
/**
2829
* Payment methods that are considered as voucher payment methods.

readme.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ If you get stuck, you can ask for help in the [Plugin Forum](https://wordpress.o
111111
== Changelog ==
112112

113113
= 9.1.0 - xxxx-xx-xx =
114+
* Fix - Correctly handles IPP failed payments webhook calls by extracting the order ID from the payment intent metadata.
114115
* Fix - Fix ECE crash in classic cart and checkout pages for non-English language sites.
115116
* Fix - Correctly handles UK postcodes redacted by Apple Pay.
116117
* Tweak - Avoid re-sending Processing Order customer email when merchant wins dispute.

tests/phpunit/test-wc-stripe-webhook-handler.php

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,4 +380,188 @@ public function provide_test_process_webhook_dispute() {
380380
],
381381
];
382382
}
383+
384+
/**
385+
* Test for `process_payment_intent`.
386+
*
387+
* @param string $event_type The event type.
388+
* @param string $order_status The order status.
389+
* @param bool $order_locked Whether the order is locked.
390+
* @param string $payment_type The payment method.
391+
* @param bool $order_status_final Whether the order status is final.
392+
* @param string $expected_status The expected order status.
393+
* @param string $expected_note The expected order note.
394+
* @param int $expected_process_payment_calls The expected number of calls to process_payment.
395+
* @param int $expected_process_payment_intent_incomplete_calls The expected number of calls to process_payment_intent_incomplete.
396+
* @return void
397+
* @dataProvider provide_test_process_payment_intent
398+
* @throws WC_Data_Exception When order status is invalid.
399+
*/
400+
public function test_process_payment_intent(
401+
$event_type,
402+
$order_status,
403+
$order_locked,
404+
$payment_type,
405+
$order_status_final,
406+
$expected_status,
407+
$expected_note,
408+
$expected_process_payment_calls,
409+
$expected_process_payment_intent_incomplete_calls
410+
) {
411+
$mock_action_process_payment = new MockAction();
412+
add_action(
413+
'wc_gateway_stripe_process_payment',
414+
[ &$mock_action_process_payment, 'action' ]
415+
);
416+
417+
$mock_action_process_payment_intent_incomplete = new MockAction();
418+
add_action(
419+
'wc_gateway_stripe_process_payment_intent_incomplete',
420+
[ &$mock_action_process_payment_intent_incomplete, 'action' ]
421+
);
422+
423+
$order = WC_Helper_Order::create_order();
424+
$order->set_status( $order_status );
425+
if ( $order_locked ) {
426+
$order->update_meta_data( '_stripe_lock_payment', ( time() + MINUTE_IN_SECONDS ) );
427+
}
428+
if ( $order_status_final ) {
429+
$order->update_meta_data( '_stripe_status_final', true );
430+
}
431+
$order->update_meta_data( '_stripe_upe_payment_type', $payment_type );
432+
$order->update_meta_data( '_stripe_upe_waiting_for_redirect', true );
433+
$order->save_meta_data();
434+
$order->save();
435+
436+
$notification = (object) [
437+
'type' => $event_type,
438+
'data' => (object) [
439+
'object' => (object) [
440+
'id' => 'pi_mock',
441+
'metadata' => (object) [
442+
'order_id' => $order->get_id(),
443+
],
444+
'last_payment_error' => (object) [
445+
'message' => 'Your card was declined. You can call your bank for details.',
446+
],
447+
],
448+
],
449+
];
450+
451+
$this->mock_webhook_handler->process_payment_intent( $notification );
452+
453+
$final_order = wc_get_order( $order->get_id() );
454+
455+
$this->assertSame( $expected_status, $final_order->get_status() );
456+
if ( ! empty( $expected_note ) ) {
457+
$notes = wc_get_order_notes(
458+
[
459+
'order_id' => $final_order->get_id(),
460+
'limit' => 1,
461+
]
462+
);
463+
$this->assertMatchesRegularExpression( $expected_note, $notes[0]->content );
464+
}
465+
466+
$this->assertEquals( $expected_process_payment_calls, $mock_action_process_payment->get_call_count() );
467+
$this->assertEquals( $expected_process_payment_intent_incomplete_calls, $mock_action_process_payment_intent_incomplete->get_call_count() );
468+
}
469+
470+
/**
471+
* Provider for `test_process_payment_intent`.
472+
*
473+
* @return array
474+
*/
475+
public function provide_test_process_payment_intent() {
476+
return [
477+
'invalid status' => [
478+
'event type' => 'payment_intent.succeeded',
479+
'order status' => 'cancelled',
480+
'order locked' => false,
481+
'payment type' => WC_Stripe_Payment_Methods::CARD,
482+
'order status final' => false,
483+
'expected status' => 'cancelled',
484+
'expected note' => '',
485+
'expected process payment calls' => 0,
486+
'expected process payment intent incomplete calls' => 0,
487+
],
488+
'order is locked' => [
489+
'event type' => 'payment_intent.succeeded',
490+
'order status' => 'pending',
491+
'order locked' => true,
492+
'payment type' => WC_Stripe_Payment_Methods::CARD,
493+
'order status final' => false,
494+
'expected status' => 'pending',
495+
'expected note' => '',
496+
'expected process payment calls' => 0,
497+
'expected process payment intent incomplete calls' => 0,
498+
],
499+
'success, payment_intent.requires_action, voucher payment' => [
500+
'event type' => 'payment_intent.requires_action',
501+
'order status' => 'pending',
502+
'order locked' => false,
503+
'payment type' => WC_Stripe_Payment_Methods::BOLETO,
504+
'order status final' => false,
505+
'expected status' => 'on-hold',
506+
'expected note' => '/Awaiting payment. Order status changed from Pending payment to On hold./',
507+
'expected process payment calls' => 0,
508+
'expected process payment intent incomplete calls' => 0,
509+
],
510+
'success, payment_intent.succeeded, voucher payment' => [
511+
'event type' => 'payment_intent.succeeded',
512+
'order status' => 'pending',
513+
'order locked' => false,
514+
'payment type' => WC_Stripe_Payment_Methods::BOLETO,
515+
'order status final' => false,
516+
'expected status' => 'pending',
517+
'expected note' => '',
518+
'expected process payment calls' => 1,
519+
'expected process payment intent incomplete calls' => 0,
520+
],
521+
'success, payment_intent.amount_capturable_updated, async payment, awaiting action' => [
522+
'event type' => 'payment_intent.amount_capturable_updated',
523+
'order status' => 'pending',
524+
'order locked' => false,
525+
'payment type' => WC_Stripe_Payment_Methods::CARD,
526+
'order status final' => false,
527+
'expected status' => 'pending',
528+
'expected note' => '',
529+
'expected process payment calls' => 0,
530+
'expected process payment intent incomplete calls' => 1,
531+
],
532+
'success, payment_intent.payment_failed, voucher payment' => [
533+
'event type' => 'payment_intent.payment_failed',
534+
'order status' => 'pending',
535+
'order locked' => false,
536+
'payment type' => WC_Stripe_Payment_Methods::BOLETO,
537+
'order status final' => false,
538+
'expected status' => 'failed',
539+
'expected note' => '/Payment not completed in time Order status changed from Pending payment to Failed./',
540+
'expected process payment calls' => 0,
541+
'expected process payment intent incomplete calls' => 0,
542+
],
543+
'success, payment_intent.payment_failed, IPP' => [
544+
'event type' => 'payment_intent.payment_failed',
545+
'order status' => 'pending',
546+
'order locked' => false,
547+
'payment type' => WC_Stripe_Payment_Methods::CARD_PRESENT,
548+
'order status final' => false,
549+
'expected status' => 'failed',
550+
'expected note' => '/Stripe SCA authentication failed. Reason: Your card was declined. You can call your bank for details. Order status changed from Pending payment to Failed./',
551+
'expected process payment calls' => 0,
552+
'expected process payment intent incomplete calls' => 0,
553+
],
554+
'success, payment_intent.payment_failed, IPP, status final' => [
555+
'event type' => 'payment_intent.payment_failed',
556+
'order status' => 'pending',
557+
'order locked' => false,
558+
'payment type' => WC_Stripe_Payment_Methods::CARD_PRESENT,
559+
'order status final' => true,
560+
'expected status' => 'pending',
561+
'expected note' => '/Stripe SCA authentication failed. Reason: Your card was declined. You can call your bank for details./',
562+
'expected process payment calls' => 0,
563+
'expected process payment intent incomplete calls' => 0,
564+
],
565+
];
566+
}
383567
}

0 commit comments

Comments
 (0)