Skip to content

Commit

Permalink
Handle payment_failed payment intent webhooks. (#3628)
Browse files Browse the repository at this point in the history
* Handle payment_failed payment intent webhooks.

* Add order locking back.

* Update method name to be more clear.

* Refactor a bit due to other changes.

* Fix psalm error.

* add tests

* add tests

* Update changelog and readme.
  • Loading branch information
jessepearson authored Jan 14, 2022
1 parent 56e6602 commit d619c4d
Show file tree
Hide file tree
Showing 4 changed files with 164 additions and 47 deletions.
1 change: 1 addition & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* Fix - Flag emoji rendering in currency switcher block widget
* Fix - Error when saved Google Pay payment method does not have billing address name
* Update - Update Payment Element from beta version to release version.
* Add - Add handling for payment_failed webhooks.

= 3.5.0 - 2021-12-29 =
* Fix - Error when renewing subscriptions with saved payment methods disabled.
Expand Down
142 changes: 113 additions & 29 deletions includes/admin/class-wc-rest-payments-webhook-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ public function handle_webhook( $request ) {
$note = $this->read_rest_property( $body, 'data' );
$this->remote_note_service->put_note( $note );
break;
case 'payment_intent.payment_failed':
$this->process_webhook_payment_intent_failed( $body );
break;
case 'payment_intent.succeeded':
$this->process_webhook_payment_intent_succeeded( $body );
break;
Expand Down Expand Up @@ -258,7 +261,23 @@ private function process_webhook_expired_authorization( $event_body ) {
}

/**
* Process webhook for a successul payment intent.
* Process webhook for a failed payment intent.
*
* @param array $event_body The event that triggered the webhook.
*
* @throws Rest_Request_Exception Required parameters not found.
* @throws Invalid_Payment_Method_Exception When unable to resolve charge ID to order.
*/
private function process_webhook_payment_intent_failed( $event_body ) {
$order = $this->get_order_from_event_body_intent_id( $event_body );

if ( $order && ! $order->has_status( [ 'failed' ] ) ) {
$order->update_status( 'failed', $this->get_failure_message_from_event( $event_body ) );
}
}

/**
* Process webhook for a successful payment intent.
*
* @param array $event_body The event that triggered the webhook.
*
Expand All @@ -269,34 +288,7 @@ private function process_webhook_payment_intent_succeeded( $event_body ) {
$event_data = $this->read_rest_property( $event_body, 'data' );
$event_object = $this->read_rest_property( $event_data, 'object' );
$intent_id = $this->read_rest_property( $event_object, 'id' );

// Look up the order related to this charge.
$order = $this->wcpay_db->order_from_intent_id( $intent_id );

if ( ! $order ) {
// Retrieving order with order_id in case intent_id was not properly set.
Logger::debug( 'intent_id not found, using order_id to retrieve order' );
$metadata = $this->read_rest_property( $event_object, 'metadata' );

if ( isset( $metadata['order_id'] ) ) {
$order_id = $metadata['order_id'];
$order = $this->wcpay_db->order_from_order_id( $order_id );
} elseif ( ! empty( $event_object['invoice'] ) ) {
// If the payment intent contains an invoice it is a WCPay Subscription-related intent and will be handled by the `invoice.paid` event.
return;
}
}

if ( ! $order ) {
throw new Invalid_Payment_Method_Exception(
sprintf(
/* translators: %1: charge ID */
__( 'Could not find order via intent ID: %1$s', 'woocommerce-payments' ),
$intent_id
),
'order_not_found'
);
}
$order = $this->get_order_from_event_body_intent_id( $event_body );

WC_Payments_Utils::mark_payment_completed( $order, $intent_id );
}
Expand Down Expand Up @@ -463,4 +455,96 @@ private function read_rest_property( $array, $key ) {
}
return $array[ $key ];
}

/**
* Gets the order related to the event intent id.
*
* @param array $event_body The event that triggered the webhook.
*
* @throws Rest_Request_Exception Required parameters not found.
* @throws Invalid_Payment_Method_Exception When unable to resolve charge ID to order.
*
* @return boolean|WC_Order|WC_Order_Refund
*/
private function get_order_from_event_body_intent_id( $event_body ) {
$event_data = $this->read_rest_property( $event_body, 'data' );
$event_object = $this->read_rest_property( $event_data, 'object' );
$intent_id = $this->read_rest_property( $event_object, 'id' );

// Look up the order related to this charge.
$order = $this->wcpay_db->order_from_intent_id( $intent_id );

if ( ! $order ) {
// Retrieving order with order_id in case intent_id was not properly set.
Logger::debug( 'intent_id not found, using order_id to retrieve order' );
$metadata = $this->read_rest_property( $event_object, 'metadata' );

if ( isset( $metadata['order_id'] ) ) {
$order_id = $metadata['order_id'];
$order = $this->wcpay_db->order_from_order_id( $order_id );
} elseif ( ! empty( $event_object['invoice'] ) ) {
// If the payment intent contains an invoice it is a WCPay Subscription-related intent and will be handled by the `invoice.paid` event.
return false;
}
}

if ( ! $order ) {
throw new Invalid_Payment_Method_Exception(
sprintf(
/* translators: %1: charge ID */
__( 'Could not find order via intent ID: %1$s', 'woocommerce-payments' ),
$intent_id
),
'order_not_found'
);
}

// Get an updated set of order properties to avoid race conditions when the server sends the paid webhook before we've finished processing the original payment request.
$order->get_data_store()->read( $order );

return $order;
}

/**
* Gets the proper failure message from the code in the event.
*
* @param array $event_body The event that triggered the webhook.
*
* @return string The failure message.
*/
private function get_failure_message_from_event( $event_body ):string {
// Get the failure code from the event body.
$event_data = $this->read_rest_property( $event_body, 'data' );
$event_object = $this->read_rest_property( $event_data, 'object' );
$event_charges = $this->read_rest_property( $event_object, 'charges' );
$charges_data = $this->read_rest_property( $event_charges, 'data' );
$failure_code = $charges_data[0]['failure_code'] ?? '';

switch ( $failure_code ) {
case 'account_closed':
$failure_message = __( "The customer's bank account has been closed.", 'woocommerce-payments' );
break;
case 'debit_not_authorized':
$failure_message = __( 'The customer has notified their bank that this payment was unauthorized.', 'woocommerce-payments' );
break;
case 'insufficient_funds':
$failure_message = __( "The customer's account has insufficient funds to cover this payment.", 'woocommerce-payments' );
break;
case 'no_account':
$failure_message = __( "The customer's bank account could not be located.", 'woocommerce-payments' );
break;
case 'payment_method_microdeposit_failed':
$failure_message = __( 'Microdeposit transfers failed. Please check the account, institution and transit numbers.', 'woocommerce-payments' );
break;
case 'payment_method_microdeposit_verification_attempts_exceeded':
$failure_message = __( 'You have exceeded the number of allowed verification attempts.', 'woocommerce-payments' );
break;

default:
$failure_message = __( 'The payment was not able to be processed.', 'woocommerce-payments' );
break;
}

return $failure_message;
}
}
1 change: 1 addition & 0 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ Please note that our support for the checkout block is still experimental and th
* Fix - Flag emoji rendering in currency switcher block widget
* Fix - Error when saved Google Pay payment method does not have billing address name
* Update - Update Payment Element from beta version to release version.
* Add - Add handling for payment_failed webhooks.

= 3.5.0 - 2021-12-29 =
* Fix - Error when renewing subscriptions with saved payment methods disabled.
Expand Down
67 changes: 49 additions & 18 deletions tests/unit/admin/test-class-wc-rest-payments-webhook.php
Original file line number Diff line number Diff line change
Expand Up @@ -685,27 +685,58 @@ public function test_payment_intent_successful_when_retrying() {
}

/**
* Tests that an invoice upoming event creates invoice items for subscription.
* Tests that a payment_intent.succeeded event will complete the order.
*/
public function test_invoice_upcoming_webhook() {
// Stub.
$this->assertTrue( true );
}
public function test_payment_intent_fails_and_fails_order() {
$this->request_body['type'] = 'payment_intent.payment_failed';
$this->request_body['data']['object'] = [
'id' => 'pi_123123123123123', // payment_intent's ID.
'object' => 'payment_intent',
'amount' => 1500,
'charges' => [
'data' => [],
],
'currency' => 'usd',
];

/**
* Tests that an invoice paid event renews a subscription.
*/
public function test_invoice_paid_webhook() {
// Stub.
$this->assertTrue( true );
}
$this->request->set_body( wp_json_encode( $this->request_body ) );

/**
* Tests that an invoice payment failed event places a subscription on-hold.
*/
public function test_invoice_payment_failed_webhook() {
// Stub.
$this->assertTrue( true );
$mock_order = $this->createMock( WC_Order::class );

$mock_order
->expects( $this->once() )
->method( 'has_status' )
->with( [ 'failed' ] )
->willReturn( false );

$mock_order
->expects( $this->once() )
->method( 'update_status' )
->with(
'failed',
$this->matchesRegularExpression(
'/The payment was not able to be processed.*/'
)
);

$mock_order
->method( 'get_data_store' )
->willReturn( new \WC_Mock_WC_Data_Store() );

$this->mock_db_wrapper
->expects( $this->once() )
->method( 'order_from_intent_id' )
->with( 'pi_123123123123123' )
->willReturn( $mock_order );

// Run the test.
$response = $this->controller->handle_webhook( $this->request );

// Check the response.
$response_data = $response->get_data();

$this->assertEquals( 200, $response->get_status() );
$this->assertEquals( [ 'result' => 'success' ], $response_data );
}

/**
Expand Down

0 comments on commit d619c4d

Please sign in to comment.