From 823d4eaeedf4d5dce6e3242a51d72bba29421159 Mon Sep 17 00:00:00 2001 From: Zvonimir Maglica Date: Mon, 27 Jan 2025 16:24:55 +0100 Subject: [PATCH] Added better handling when terminal payment fails (#10168) --- ...dle-repeated_card-present-payment-failures | 4 ++ includes/class-wc-payments-order-service.php | 69 +++++++++++++++++++ ...wc-payments-webhook-processing-service.php | 7 +- ...wc-payments-webhook-processing-service.php | 51 +++++++++++++- 4 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 changelog/fix-10023-handle-repeated_card-present-payment-failures diff --git a/changelog/fix-10023-handle-repeated_card-present-payment-failures b/changelog/fix-10023-handle-repeated_card-present-payment-failures new file mode 100644 index 00000000000..3160ae4173f --- /dev/null +++ b/changelog/fix-10023-handle-repeated_card-present-payment-failures @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Added timestamp to the order note when terminal payment fails. diff --git a/includes/class-wc-payments-order-service.php b/includes/class-wc-payments-order-service.php index eb74cde866f..0db9728a724 100644 --- a/includes/class-wc-payments-order-service.php +++ b/includes/class-wc-payments-order-service.php @@ -420,6 +420,41 @@ public function mark_terminal_payment_completed( $order, $intent_id, $intent_sta $this->complete_order_processing( $order, $intent_status ); } + + /** + * Mark terminal payment failed function. + * + * @param WC_Order $order Order object. + * @param string $intent_id The ID of the intent associated with this order. + * @param string $intent_status The status of the intent related to this order. + * @param string $charge_id The charge ID related to the intent/order. + * @param string $message Optional message to add to the failed note. + * + * @return void + */ + public function mark_terminal_payment_failed( $order, string $intent_id, string $intent_status, string $charge_id, string $message ) { + if ( ! $this->order_prepared_for_processing( $order, $intent_id ) ) { + return; + } + + $order_status_before_update = $order->get_status(); + $this->update_order_status( $order, Order_Status::FAILED ); + + $note = $this->generate_terminal_payment_failure_note( $intent_id, $charge_id, $message, $this->get_order_amount( $order ) ); + if ( $this->order_note_exists( $order, $note ) ) { + $this->complete_order_processing( $order ); + return; + } + + $order->add_order_note( $note ); + $this->complete_order_processing( $order, $intent_status ); + // Trigger the failed order status hook to send notifications etc only if the order status was not already failed to avoid duplicate notifications. + if ( Order_Status::FAILED === $order_status_before_update ) { + do_action( 'woocommerce_order_status_pending_to_failed_notification', $order->get_id(), $order ); + do_action( 'woocommerce_order_status_failed_notification', $order->get_id(), $order ); + } + } + /** * Check if a note content has already existed in the order. * @@ -1431,6 +1466,40 @@ private function generate_payment_failure_note( $intent_id, $charge_id, $message return $note; } + /** + * Get content for the failure order note and additional message, if included. + * + * @param string $intent_id The ID of the intent associated with this order. + * @param string $charge_id The charge ID related to the intent/order. + * @param string $message Optional message to add to the note. + * @param string $formatted_amount The formatted order total. + * + * @return string Note content. + */ + private function generate_terminal_payment_failure_note( $intent_id, $charge_id, $message, $formatted_amount ) { + // Add charge_id to the transaction URL instead of intent_id for uniqueness. + $transaction_url = WC_Payments_Utils::compose_transaction_url( '', $charge_id ); + + $note = sprintf( + WC_Payments_Utils::esc_interpolated_html( + /* translators: %1: the authorized amount, %2: WooPayments, %3: transaction ID of the payment, %4: timestamp */ + __( 'A terminal payment of %1$s failed using %2$s (%3$s)', 'woocommerce-payments' ), + [ + 'strong' => '', + 'a' => ! empty( $transaction_url ) ? '' : '', + ] + ), + $formatted_amount, + 'WooPayments', + $intent_id ?? $charge_id + ); + + if ( ! empty( $message ) ) { + $note .= ' ' . $message; + } + + return $note; + } /** * Generates the payment authorized order note. diff --git a/includes/class-wc-payments-webhook-processing-service.php b/includes/class-wc-payments-webhook-processing-service.php index d9b0333c765..d93044e6d6b 100644 --- a/includes/class-wc-payments-webhook-processing-service.php +++ b/includes/class-wc-payments-webhook-processing-service.php @@ -439,8 +439,11 @@ 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' ); - - $this->order_service->mark_payment_failed( $order, $intent_id, $intent_status, $charge_id, $this->get_failure_message_from_error( $last_payment_error ) ); + if ( Payment_Method::CARD_PRESENT === $payment_method_type ) { + $this->order_service->mark_terminal_payment_failed( $order, $intent_id, $intent_status, $charge_id, $this->get_failure_message_from_error( $last_payment_error ) ); + } else { + $this->order_service->mark_payment_failed( $order, $intent_id, $intent_status, $charge_id, $this->get_failure_message_from_error( $last_payment_error ) ); + } } /** 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 e628c533848..6c195574221 100644 --- a/tests/unit/test-class-wc-payments-webhook-processing-service.php +++ b/tests/unit/test-class-wc-payments-webhook-processing-service.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\MockObject\MockObject; use WCPay\Constants\Order_Status; use WCPay\Constants\Intent_Status; +use WCPay\Constants\Payment_Method; use WCPay\Database_Cache; use WCPay\Exceptions\Invalid_Payment_Method_Exception; use WCPay\Exceptions\Invalid_Webhook_Data_Exception; @@ -97,7 +98,7 @@ public function set_up() { $this->order_service = $this->getMockBuilder( 'WC_Payments_Order_Service' ) ->setConstructorArgs( [ $this->createMock( WC_Payments_API_Client::class ) ] ) - ->setMethods( [ 'get_wcpay_refund_id_for_order', 'add_note_and_metadata_for_refund', 'create_refund_for_order' ] ) + ->setMethods( [ 'get_wcpay_refund_id_for_order', 'add_note_and_metadata_for_refund', 'create_refund_for_order', 'mark_terminal_payment_failed' ] ) ->getMock(); $this->mock_db_wrapper = $this->getMockBuilder( WC_Payments_DB::class ) @@ -1813,4 +1814,52 @@ public function test_process_throws_exception_when_refund_found_for_successful_i $this->webhook_processing_service->process( $this->event_body ); } + + public function test_payment_intent_failed_handles_terminal_payment() { + $this->event_body = [ + 'type' => 'payment_intent.payment_failed', + 'livemode' => true, + 'data' => [ + 'object' => [ + 'id' => 'pi_123', + 'status' => 'requires_payment_method', + 'created' => 1706745600, + 'charges' => [ + 'data' => [ + [ + 'id' => 'ch_123', + 'created' => 1706745600, + 'payment_method_details' => [ 'type' => 'card_present' ], + ], + ], + ], + 'last_payment_error' => [ + 'message' => 'Card declined', + 'payment_method' => [ + 'id' => 'pm_123', + 'type' => Payment_Method::CARD_PRESENT, + ], + ], + ], + ], + ]; + + $this->mock_db_wrapper + ->expects( $this->once() ) + ->method( 'order_from_intent_id' ) + ->willReturn( $this->mock_order ); + + $this->order_service + ->expects( $this->once() ) + ->method( 'mark_terminal_payment_failed' ) + ->with( + $this->mock_order, + 'pi_123', + 'requires_payment_method', + 'ch_123', + 'With the following message: Card declined' + ); + + $this->webhook_processing_service->process( $this->event_body ); + } }