Skip to content

Commit

Permalink
Added better handling when terminal payment fails (#10168)
Browse files Browse the repository at this point in the history
  • Loading branch information
zmaglica authored Jan 27, 2025
1 parent 50d4bd0 commit 823d4ea
Show file tree
Hide file tree
Showing 4 changed files with 128 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: fix

Added timestamp to the order note when terminal payment fails.
69 changes: 69 additions & 0 deletions includes/class-wc-payments-order-service.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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 <strong>failed</strong> using %2$s (<a>%3$s</a>)', 'woocommerce-payments' ),
[
'strong' => '<strong>',
'a' => ! empty( $transaction_url ) ? '<a href="' . $transaction_url . '" target="_blank" rel="noopener noreferrer">' : '<code>',
]
),
$formatted_amount,
'WooPayments',
$intent_id ?? $charge_id
);

if ( ! empty( $message ) ) {
$note .= ' ' . $message;
}

return $note;
}

/**
* Generates the payment authorized order note.
Expand Down
7 changes: 5 additions & 2 deletions includes/class-wc-payments-webhook-processing-service.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) );
}
}

/**
Expand Down
51 changes: 50 additions & 1 deletion tests/unit/test-class-wc-payments-webhook-processing-service.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 )
Expand Down Expand Up @@ -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: <code>Card declined</code>'
);

$this->webhook_processing_service->process( $this->event_body );
}
}

0 comments on commit 823d4ea

Please sign in to comment.