Skip to content

Commit

Permalink
Handle failed refunds (#411)
Browse files Browse the repository at this point in the history
* Extract DB access from Stripe API client class

This logic is required by other parts of the code base, so extracting
it out allows it to be shared. The unit tests have been left as is for
now, but this also allows for database access to be mocked more easily.

* Add REST endpoint for handling webhooks from WP.com

The initial implementation just handles refund updated events and adds
an order note in the event of a failure.

* Turn off Squiz.Commenting.FunctionCommentThrowTag.WrongNumber PHPCS rule

This rule is generating some false positives. We can try turning it on
again if it gets fixed in a later PHPCS version. Alternatively we could
start relying on a static code analyser for more robust checks on how
we're using exceptions.
  • Loading branch information
jrodger authored Apr 22, 2020
1 parent a4b6d06 commit 6e19910
Show file tree
Hide file tree
Showing 9 changed files with 532 additions and 40 deletions.
166 changes: 166 additions & 0 deletions includes/admin/class-wc-rest-payments-webhook-controller.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<?php
/**
* Class WC_REST_Payments_Webhook_Controller
*
* @package WooCommerce\Payments\Admin
*/

use WCPay\Exceptions\WC_Payments_Rest_Request_Exception;
use WCPay\Logger;

defined( 'ABSPATH' ) || exit;

/**
* REST controller for webhooks.
*/
class WC_REST_Payments_Webhook_Controller extends WC_Payments_REST_Controller {

/**
* Result codes for returning to the WCPay server API. They don't have any special meaning, but can will be logged
* and are therefore useful when debugging how we reacted to a webhook.
*/
const RESULT_SUCCESS = 'success';
const RESULT_BAD_REQUEST = 'bad_request';
const RESULT_ERROR = 'error';

/**
* Endpoint path.
*
* @var string
*/
protected $rest_base = 'payments/webhook';

/**
* DB wrapper.
*
* @var WC_Payments_DB
*/
private $wcpay_db;

/**
* WC_REST_Payments_Webhook_Controller constructor.
*
* @param WC_Payments_API_Client $api_client WC_Payments_API_Client instance.
* @param WC_Payments_DB $wcpay_db WC_Payments_DB instance.
*/
public function __construct( WC_Payments_API_Client $api_client, WC_Payments_DB $wcpay_db ) {
parent::__construct( $api_client );
$this->wcpay_db = $wcpay_db;
}

/**
* Configure REST API routes.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'handle_webhook' ),
'permission_callback' => array( $this, 'check_permission' ),
)
);
}

/**
* Retrieve transactions to respond with via API.
*
* @param WP_REST_Request $request Full data about the request.
*
* @return WP_REST_Response
*/
public function handle_webhook( $request ) {
$body = $request->get_json_params();

try {
// Extract information about the webhook event.
$event_type = $this->read_rest_property( $body, 'type' );
$event_data = $this->read_rest_property( $body, 'data' );
$event_object = $this->read_rest_property( $event_data, 'object' );

switch ( $event_type ) {
case 'charge.refund.updated':
$this->process_webhook_refund_updated( $event_object );
break;
}
} catch ( WC_Payments_Rest_Request_Exception $e ) {
Logger::error( $e );
return new WP_REST_Response( array( 'result' => self::RESULT_BAD_REQUEST ), 400 );
} catch ( Exception $e ) {
Logger::error( $e );
return new WP_REST_Response( array( 'result' => self::RESULT_ERROR ), 500 );
}

return new WP_REST_Response( array( 'result' => self::RESULT_SUCCESS ) );
}

/**
* Process webhook refund updated.
*
* @param array $event_object The event that triggered the webhook.
*
* @throws WC_Payments_Rest_Request_Exception Required parameters not found.
* @throws Exception Unable to resolve charge ID to order.
*/
private function process_webhook_refund_updated( $event_object ) {
// First, check the reason for the update. We're only interesting in a status of failed.
$status = $this->read_rest_property( $event_object, 'status' );
if ( 'failed' !== $status ) {
return;
}

// Fetch the details of the failed refund so that we can find the associated order and write a note.
$charge_id = $this->read_rest_property( $event_object, 'charge' );
$refund_id = $this->read_rest_property( $event_object, 'id' );
$amount = $this->read_rest_property( $event_object, 'amount' );

// Look up the order related to this charge.
$order = $this->wcpay_db->order_from_charge_id( $charge_id );
if ( ! $order ) {
throw new Exception(
sprintf(
/* translators: %1: charge ID */
__( 'Could not find order via charge ID: %1$s', 'woocommerce-payments' ),
$charge_id
)
);
}

$note = sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1: the refund amount, %2: ID of the refund */
__( 'A refund of %1$s was <strong>unsuccessful</strong> using WooCommerce Payments (<code>%2$s</code>).', 'woocommerce-payments' ),
array(
'strong' => '<strong>',
'code' => '<code>',
)
),
wc_price( $amount / 100 ),
$refund_id
);
$order->add_order_note( $note );
}

/**
* Safely get a value from the REST request body array.
*
* @param array $array Array to read from.
* @param string $key ID to fetch on.
*
* @return string|array
* @throws WC_Payments_Rest_Request_Exception Thrown if ID not set.
*/
private function read_rest_property( $array, $key ) {
if ( ! isset( $array[ $key ] ) ) {
throw new WC_Payments_Rest_Request_Exception(
sprintf(
/* translators: %1: ID being fetched */
__( '%1$s not found in array', 'woocommerce-payments' ),
$key
)
);
}
return $array[ $key ];
}
}
49 changes: 49 additions & 0 deletions includes/class-wc-payments-db.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php
/**
* WC_Payments_DB class
*
* @package WooCommerce\Payments
*/

defined( 'ABSPATH' ) || exit;

/**
* Wrapper class for accessing the database.
*/
class WC_Payments_DB {
/**
* Retrieve an order from the DB using a corresponding Stripe charge ID.
*
* @param string $charge_id Charge ID corresponding to an order ID.
*
* @return boolean|WC_Order|WC_Order_Refund
*/
public function order_from_charge_id( $charge_id ) {
$order_id = $this->order_id_from_charge_id( $charge_id );

if ( $order_id ) {
return wc_get_order( $order_id );
}
return false;
}

/**
* Retrieve an order ID from the DB using a corresponding Stripe charge ID.
*
* @param string $charge_id Charge ID corresponding to an order ID.
*
* @return null|string
*/
private function order_id_from_charge_id( $charge_id ) {
global $wpdb;

// The order ID is saved to DB in `WC_Payment_Gateway_WCPay::process_payment()`.
$order_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT DISTINCT ID FROM $wpdb->posts as posts LEFT JOIN $wpdb->postmeta as meta ON posts.ID = meta.post_id WHERE meta.meta_value = %s AND meta.meta_key = '_charge_id'",
$charge_id
)
);
return $order_id;
}
}
18 changes: 17 additions & 1 deletion includes/class-wc-payments.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ class WC_Payments {
*/
private static $api_client;

/**
* Instance of WC_Payments_DB.
*
* @var WC_Payments_DB
*/
private static $db_helper;

/**
* Instance of WC_Payments_Account, created in init function.
*
Expand Down Expand Up @@ -63,6 +70,9 @@ public static function init() {

add_filter( 'plugin_action_links_' . plugin_basename( WCPAY_PLUGIN_FILE ), array( __CLASS__, 'add_plugin_links' ) );

include_once dirname( __FILE__ ) . '/class-wc-payments-db.php';
self::$db_helper = new WC_Payments_DB();

self::$api_client = self::create_api_client();

include_once dirname( __FILE__ ) . '/class-wc-payments-account.php';
Expand Down Expand Up @@ -431,7 +441,8 @@ public static function create_api_client() {

$payments_api_client = new WC_Payments_API_Client(
'WooCommerce Payments/' . WCPAY_VERSION_NUMBER,
new WC_Payments_Http()
new WC_Payments_Http(),
self::$db_helper
);

return $payments_api_client;
Expand All @@ -441,6 +452,7 @@ public static function create_api_client() {
* Initialize the REST API controllers.
*/
public static function init_rest_api() {
include_once WCPAY_ABSPATH . 'includes/exceptions/class-wc-payments-rest-request-exception.php';
include_once WCPAY_ABSPATH . 'includes/admin/class-wc-payments-rest-controller.php';

include_once WCPAY_ABSPATH . 'includes/admin/class-wc-rest-payments-deposits-controller.php';
Expand All @@ -462,6 +474,10 @@ public static function init_rest_api() {
include_once WCPAY_ABSPATH . 'includes/admin/class-wc-rest-payments-timeline-controller.php';
$timeline_controller = new WC_REST_Payments_Timeline_Controller( self::$api_client );
$timeline_controller->register_routes();

include_once WCPAY_ABSPATH . 'includes/admin/class-wc-rest-payments-webhook-controller.php';
$webhook_controller = new WC_REST_Payments_Webhook_Controller( self::$api_client, self::$db_helper );
$webhook_controller->register_routes();
}

/**
Expand Down
17 changes: 17 additions & 0 deletions includes/exceptions/class-wc-payments-rest-request-exception.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php
/**
* Class WC_Payments_Rest_Exception
*
* @package WooCommerce\Payments
*/

namespace WCPay\Exceptions;

use Exception;

defined( 'ABSPATH' ) || exit;

/**
* Exception for throwing errors in REST API controllers (e.g. issues with missing parameters in requests).
*/
class WC_Payments_Rest_Request_Exception extends Exception {}
49 changes: 11 additions & 38 deletions includes/wc-payment-api/class-wc-payments-api-client.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,24 @@ class WC_Payments_API_Client {
*/
private $http_client;

/**
* DB access wrapper.
*
* @var WC_Payments_DB
*/
private $wcpay_db;

/**
* WC_Payments_API_Client constructor.
*
* @param string $user_agent - User agent string to report in requests.
* @param WC_Payments_Http $http_client - Used to send HTTP requests.
* @param WC_Payments_DB $wcpay_db - DB access wrapper.
*/
public function __construct( $user_agent, $http_client ) {
public function __construct( $user_agent, $http_client, $wcpay_db ) {
$this->user_agent = $user_agent;
$this->http_client = $http_client;
$this->wcpay_db = $wcpay_db;
}

/**
Expand Down Expand Up @@ -189,42 +198,6 @@ public function cancel_intention( $intention_id ) {
return $this->deserialize_intention_object_from_array( $response_array );
}

/**
* Retrive an order ID from the DB using a corresponding Stripe charge ID.
*
* @param string $charge_id Charge ID corresponding to an order ID.
*
* @return null|string
*/
private function order_id_from_charge_id( $charge_id ) {
global $wpdb;

// The order ID is saved to DB in `WC_Payment_Gateway_WCPay::process_payment()`.
$order_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT DISTINCT ID FROM $wpdb->posts as posts LEFT JOIN $wpdb->postmeta as meta ON posts.ID = meta.post_id WHERE meta.meta_value = %s AND meta.meta_key = '_charge_id'",
$charge_id
)
);
return $order_id;
}

/**
* Retrieve an order from the DB using a corresponding Stripe charge ID.
*
* @param string $charge_id Charge ID corresponding to an order ID.
*
* @return boolean|WC_Order|WC_Order_Refund
*/
private function order_from_charge_id( $charge_id ) {
$order_id = $this->order_id_from_charge_id( $charge_id );

if ( $order_id ) {
return wc_get_order( $order_id );
}
return false;
}

/**
* List deposits
*
Expand Down Expand Up @@ -667,7 +640,7 @@ protected function extract_response_body( $response ) {
* @return array new object with order information.
*/
private function add_order_info_to_object( $charge_id, $object ) {
$order = $this->order_from_charge_id( $charge_id );
$order = $this->wcpay_db->order_from_charge_id( $charge_id );

// Add order information to the `$transaction`.
// If the order couldn't be retrieved, return an empty order.
Expand Down
3 changes: 3 additions & 0 deletions phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
<!-- Rules -->
<rule ref="WooCommerce-Core" >
<exclude name="Generic.Commenting.Todo.TaskFound"/>

<!-- This rule is currently generating some false positives, it would be worth retrying after PHPCS upgrades -->
<exclude name="Squiz.Commenting.FunctionCommentThrowTag.WrongNumber"/>
</rule>

<rule ref="WordPress.WP.I18n">
Expand Down
Loading

0 comments on commit 6e19910

Please sign in to comment.