diff --git a/client/order/index.js b/client/order/index.js new file mode 100644 index 00000000000..1d306c0df9b --- /dev/null +++ b/client/order/index.js @@ -0,0 +1,33 @@ +/* global jQuery */ + +/** + * Internal dependencies + */ +import { getConfig } from 'utils/order'; + +jQuery( function ( $ ) { + const disableManualRefunds = getConfig( 'disableManualRefunds' ) ?? false; + const manualRefundsTip = getConfig( 'manualRefundsTip' ) ?? ''; + + $( '#woocommerce-order-items' ).on( + 'click', + 'button.refund-items', + function () { + const $manualRefundButton = $( '.do-manual-refund' ); + + if ( disableManualRefunds ) { + $manualRefundButton.hide(); + } else { + // Adjust the messaging on the manual refund button. + $manualRefundButton + .attr( { + // Tips are readable through $.data(), but jQuery.tipTip use the title attribute to generate + // the tooltip. + title: manualRefundsTip, + } ) + // Regenerate the tipTip tooltip. + .tipTip(); + } + } + ); +} ); diff --git a/client/utils/order.js b/client/utils/order.js new file mode 100644 index 00000000000..d821a7e6d06 --- /dev/null +++ b/client/utils/order.js @@ -0,0 +1,16 @@ +/* global wcpay_order_config, wc */ + +/** + * Retrieves a configuration value. + * + * @param {string} name The name of the config parameter. + * @return {*} The value of the parameter of null. + */ +export const getConfig = ( name ) => { + // Config for the Edit Order screen. + const config = + wcpay_order_config ?? // eslint-disable-line camelcase + wc.wcSettings.getSetting( 'woocommerce_payments_data' ); + + return config?.[ name ]; +}; diff --git a/includes/admin/class-wc-payments-admin.php b/includes/admin/class-wc-payments-admin.php index 8c45a10bc94..4ec576f163d 100644 --- a/includes/admin/class-wc-payments-admin.php +++ b/includes/admin/class-wc-payments-admin.php @@ -302,6 +302,14 @@ public function register_payments_scripts() { WC_Payments::get_file_version( 'dist/tos.css' ) ); + wp_register_script( + 'WCPAY_ADMIN_ORDER_ACTIONS', + plugins_url( 'dist/order.js', WCPAY_PLUGIN_FILE ), + [ 'jquery-tiptip' ], + WC_Payments::get_file_version( 'dist/order.js' ), + true + ); + $settings_script_src_url = plugins_url( 'dist/settings.js', WCPAY_PLUGIN_FILE ); $settings_script_asset_path = WCPAY_ABSPATH . 'dist/settings.asset.php'; $settings_script_asset = file_exists( $settings_script_asset_path ) ? require_once $settings_script_asset_path : [ 'dependencies' => [] ]; @@ -414,6 +422,23 @@ public function enqueue_payments_scripts() { wp_enqueue_script( 'WCPAY_PAYMENT_GATEWAYS_PAGE' ); wp_enqueue_style( 'WCPAY_PAYMENT_GATEWAYS_PAGE' ); } + + $screen = get_current_screen(); + if ( 'shop_order' === $screen->id ) { + $order = wc_get_order(); + + if ( WC_Payment_Gateway_WCPay::GATEWAY_ID === $order->get_payment_method() ) { + wp_localize_script( + 'WCPAY_ADMIN_ORDER_ACTIONS', + 'wcpay_order_config', + [ + 'disableManualRefunds' => ! $this->wcpay_gateway->has_refund_failed( $order ), + 'manualRefundsTip' => __( 'Refunding manually requires reimbursing your customer offline via cash, check, etc. The refund amounts entered here will only be used to balance your analytics.', 'woocommerce-payments' ), + ] + ); + wp_enqueue_script( 'WCPAY_ADMIN_ORDER_ACTIONS' ); + } + } } /** diff --git a/includes/admin/class-wc-rest-payments-webhook-controller.php b/includes/admin/class-wc-rest-payments-webhook-controller.php index 38e3dfcf4fc..18265e99601 100644 --- a/includes/admin/class-wc-rest-payments-webhook-controller.php +++ b/includes/admin/class-wc-rest-payments-webhook-controller.php @@ -198,6 +198,8 @@ private function process_webhook_refund_updated( $event_body ) { $refund_id ); $order->add_order_note( $note ); + $order->update_meta_data( '_wcpay_refund_status', 'failed' ); + $order->save(); } /** diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index 6f51b7cd505..00f6ff6c778 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -409,7 +409,7 @@ public function needs_setup() { * @return bool */ public static function is_current_page_settings() { - return count( self::$settings_url_params ) === count( array_intersect_assoc( $_GET, self::$settings_url_params ) ); // phpcs:disable WordPress.Security.NonceVerification.Recommended + return count( self::$settings_url_params ) === count( array_intersect_assoc( $_GET, self::$settings_url_params ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended } /** @@ -504,11 +504,11 @@ public function output_payments_settings_screen() { global $hide_save_button; $hide_save_button = true; - if ( ! empty( $_GET['method'] ) ) : + if ( ! empty( $_GET['method'] ) ) : // phpcs:ignore WordPress.Security.NonceVerification.Recommended ?>
@@ -596,7 +596,7 @@ public function save_payment_method_checkbox( $force_checked = false ) { * @return array|null An array with customer data or nothing. */ public function get_prepared_customer_data() { - if ( ! isset( $_GET['pay_for_order'] ) && ! is_add_payment_method_page() ) { + if ( ! isset( $_GET['pay_for_order'] ) && ! is_add_payment_method_page() ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return null; } @@ -605,7 +605,7 @@ public function get_prepared_customer_data() { $firstname = ''; $lastname = ''; - if ( isset( $_GET['pay_for_order'] ) && 'true' === $_GET['pay_for_order'] ) { + if ( isset( $_GET['pay_for_order'] ) && 'true' === $_GET['pay_for_order'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $order_id = absint( $wp->query_vars['order-pay'] ); $order = wc_get_order( $order_id ); @@ -1166,6 +1166,8 @@ public function process_refund( $order_id, $amount = null, $reason = '' ) { Logger::log( $note ); $order->add_order_note( $note ); + $order->update_meta_data( '_wcpay_refund_status', 'failed' ); + $order->save(); Tracker::track_admin( 'wcpay_edit_order_refund_failure', [ 'reason' => $note ] ); return new WP_Error( 'wcpay_edit_order_refund_failure', $e->getMessage() ); @@ -1187,10 +1189,22 @@ public function process_refund( $order_id, $amount = null, $reason = '' ) { } $order->add_order_note( $note ); + $order->update_meta_data( '_wcpay_refund_status', 'successful' ); + $order->save(); return true; } + /** + * Checks whether a refund through the gateway has already failed. + * + * @param WC_Order $order The order to check. + * @return boolean + */ + public function has_refund_failed( $order ) { + return 'failed' === $order->get_meta( '_wcpay_refund_status', true ); + } + /** * Overrides the original method in woo's WC_Settings_API in order to conditionally render the enabled checkbox. * diff --git a/tests/unit/admin/test-class-wc-rest-payments-webhook.php b/tests/unit/admin/test-class-wc-rest-payments-webhook.php index bc68f572c4f..b5c6f747578 100644 --- a/tests/unit/admin/test-class-wc-rest-payments-webhook.php +++ b/tests/unit/admin/test-class-wc-rest-payments-webhook.php @@ -156,6 +156,79 @@ public function test_webhook_with_no_data_property() { $this->assertEquals( [ 'result' => 'bad_request' ], $response_data ); } + public function test_valid_failed_refund_webhook_sets_failed_meta() { + // Setup test request data. + $this->request_body['type'] = 'charge.refund.updated'; + $this->request_body['data']['object'] = [ + 'status' => 'failed', + 'charge' => 'test_charge_id', + 'id' => 'test_refund_id', + 'amount' => 999, + 'currency' => 'gbp', + ]; + + $this->request->set_body( wp_json_encode( $this->request_body ) ); + + $mock_order = $this->getMockBuilder( WC_Order::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'add_order_note', 'update_meta_data' ] ) + ->getMock(); + + $mock_order + ->expects( $this->once() ) + ->method( 'add_order_note' ) + ->with( + $this->matchesRegularExpression( + '~^A refund of ()?£9.99()? was unsuccessful using WooCommerce Payments \(test_refund_id\).$~' + ) + ); + + // The expects condition here is the real test; we expect that the 'update_meta_data' function + // is called with the appropriate values. + $mock_order + ->expects( $this->once() ) + ->method( 'update_meta_data' ) + ->with( '_wcpay_refund_status', 'failed' ); + + $this->mock_db_wrapper + ->expects( $this->once() ) + ->method( 'order_from_charge_id' ) + ->with( 'test_charge_id' ) + ->willReturn( $mock_order ); + + // Run the test. + $this->controller->handle_webhook( $this->request ); + } + + public function test_non_failed_refund_update_webhook_does_not_set_failed_meta() { + // Setup test request data. + $this->request_body['type'] = 'charge.refund.updated'; + $this->request_body['data']['object'] = [ + 'status' => 'success', + ]; + + $this->request->set_body( wp_json_encode( $this->request_body ) ); + + $mock_order = $this->getMockBuilder( WC_Order::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'update_meta_data' ] ) + ->getMock(); + + $this->mock_db_wrapper + ->expects( $this->never() ) + ->method( 'order_from_charge_id' ); + + // The expects condition here is the real test; we expect that the 'update_meta_data' function + // is never called to update the meta data. + $mock_order + ->expects( $this->never() ) + ->method( 'update_meta_data' ); + + // Run the test. + $this->controller->handle_webhook( $this->request ); + + } + /** * Test a valid failed refund update webhook. */ diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay.php b/tests/unit/test-class-wc-payment-gateway-wcpay.php index 1ab786339f9..e4e9aebf093 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay.php @@ -257,6 +257,66 @@ public function test_process_refund_on_uncaptured_payment() { $this->assertEquals( 'uncaptured-payment', $result->get_error_code() ); } + public function test_process_refund_success_does_not_set_refund_failed_meta() { + $intent_id = 'pi_xxxxxxxxxxxxx'; + $charge_id = 'ch_yyyyyyyyyyyyy'; + + $order = WC_Helper_Order::create_order(); + $order->update_meta_data( '_intent_id', $intent_id ); + $order->update_meta_data( '_charge_id', $charge_id ); + $order->save(); + + $this->mock_api_client->expects( $this->once() )->method( 'refund_charge' )->will( + $this->returnValue( + [ + 'id' => 're_123456789', + 'object' => 'refund', + 'amount' => 19.99, + 'balance_transaction' => 'txn_987654321', + 'charge' => 'ch_121212121212', + 'created' => 1610123467, + 'payment_intent' => 'pi_1234567890', + 'reason' => null, + 'receipt_number' => null, + 'source_transfer_reversal' => null, + 'status' => 'succeeded', + 'transfer_reversal' => null, + 'currency' => 'usd', + ] + ) + ); + + $this->wcpay_gateway->process_refund( $order->get_id(), 19.99 ); + + // Reload the order information to get the new meta. + $order = wc_get_order( $order->get_id() ); + $this->assertFalse( $this->wcpay_gateway->has_refund_failed( $order ) ); + } + + public function test_process_refund_failure_sets_refund_failed_meta() { + $intent_id = 'pi_xxxxxxxxxxxxx'; + $charge_id = 'ch_yyyyyyyyyyyyy'; + + $order = WC_Helper_Order::create_order(); + $order->update_meta_data( '_intent_id', $intent_id ); + $order->update_meta_data( '_charge_id', $charge_id ); + $order->update_status( 'processing' ); + $order->save(); + + $order_id = $order->get_id(); + + $this->mock_api_client + ->expects( $this->once() ) + ->method( 'refund_charge' ) + ->willThrowException( new \Exception( 'Test message' ) ); + + $this->wcpay_gateway->process_refund( $order_id, 19.99 ); + + // Reload the order information to get the new meta. + $order = wc_get_order( $order_id ); + $this->assertTrue( $this->wcpay_gateway->has_refund_failed( $order ) ); + } + public function test_process_refund_on_api_error() { $intent_id = 'pi_xxxxxxxxxxxxx'; $charge_id = 'ch_yyyyyyyyyyyyy'; diff --git a/webpack.config.js b/webpack.config.js index 8c1a22b945e..66156be29b4 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -20,6 +20,7 @@ const webpackConfig = { './client/additional-methods-setup/index.js', 'payment-gateways': './client/payment-gateways/index.js', 'multi-currency': './client/multi-currency/index.js', + order: './client/order/index.js', }, output: { filename: '[name].js',