diff --git a/changelog/add-5605-cancel-authorizations-if-order-is-cancelled b/changelog/add-5605-cancel-authorizations-if-order-is-cancelled new file mode 100644 index 00000000000..879ed83d30f --- /dev/null +++ b/changelog/add-5605-cancel-authorizations-if-order-is-cancelled @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Confirmation when cancelling order with pending authorization. Automatic order changes submission if confirmed. diff --git a/changelog/fix-6806-authorizations-level-3-data-error-while-trying-to-capture-partial-amount b/changelog/fix-6806-authorizations-level-3-data-error-while-trying-to-capture-partial-amount new file mode 100644 index 00000000000..870c1de09f2 --- /dev/null +++ b/changelog/fix-6806-authorizations-level-3-data-error-while-trying-to-capture-partial-amount @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fixed a Level 3 error occurring during the capture of an authorization for amounts lower than the initial authorization amount. diff --git a/client/order/cancel-authorization-confirm-modal/index.tsx b/client/order/cancel-authorization-confirm-modal/index.tsx new file mode 100644 index 00000000000..779d7eb20f6 --- /dev/null +++ b/client/order/cancel-authorization-confirm-modal/index.tsx @@ -0,0 +1,134 @@ +/** @format */ +/** + * External dependencies + */ +import * as React from 'react'; +import { __ } from '@wordpress/i18n'; +import { Button } from '@wordpress/components'; +import interpolateComponents from '@automattic/interpolate-components'; +import { useState } from '@wordpress/element'; +/** + * Internal dependencies + */ +import ConfirmationModal from 'wcpay/components/confirmation-modal'; + +interface CancelAuthorizationConfirmationModalProps { + originalOrderStatus: string; +} + +const CancelAuthorizationConfirmationModal: React.FunctionComponent< CancelAuthorizationConfirmationModalProps > = ( { + originalOrderStatus, +} ) => { + const [ + isCancelAuthorizationConfirmationModalOpen, + setIsCancelAuthorizationConfirmationModalOpen, + ] = useState( true ); + + const closeModal = (): void => { + setIsCancelAuthorizationConfirmationModalOpen( false ); + }; + + const handleCancelOrder = (): void => { + const orderEditForm: HTMLFormElement | null = + document.querySelector( '#order_status' )?.closest( 'form' ) || + null; + if ( null !== orderEditForm ) { + orderEditForm.submit(); + } + }; + + const doNotCancel = (): void => { + const orderStatusElement: HTMLInputElement | null = document.querySelector( + '#order_status' + ); + if ( null !== orderStatusElement ) { + orderStatusElement.value = originalOrderStatus; + orderStatusElement.dispatchEvent( new Event( 'change' ) ); + } + closeModal(); + }; + + const cancelOrder = (): void => { + handleCancelOrder(); + closeModal(); + }; + + const buttonContent = ( + <> + + + + ); + + const confirmationMessage = interpolateComponents( { + mixedString: __( + 'This order has been {{authorizedNotCaptured/}} yet. Changing the status to ' + + 'Cancelled will also {{cancelAuthorization/}}. Do you want to continue?', + 'woocommerce-payments' + ), + components: { + authorizedNotCaptured: ( + + { __( + 'authorized but not captured', + 'woocommerce-payments' + ) } + + ), + cancelAuthorization: ( + + { __( 'cancel the authorization', 'woocommerce-payments' ) } + + ), + doNothingBold: ( + { __( 'Do Nothing', 'woocommerce-payments' ) } + ), + cancelOrderBold: ( + + { __( + 'Cancel order and authorization', + 'woocommerce-payments' + ) } + + ), + }, + } ); + + return ( + <> + { isCancelAuthorizationConfirmationModalOpen && ( + { + return false; + } } + > +

{ confirmationMessage }

+
+ ) } + + ); +}; + +export default CancelAuthorizationConfirmationModal; diff --git a/client/order/index.js b/client/order/index.js index 4c983955c10..4b2606caff0 100644 --- a/client/order/index.js +++ b/client/order/index.js @@ -12,6 +12,7 @@ import { getConfig } from 'utils/order'; import { isAwaitingResponse, isUnderReview } from 'wcpay/disputes/utils'; import RefundConfirmationModal from './refund-confirm-modal'; import CancelConfirmationModal from './cancel-confirm-modal'; +import CancelAuthorizationConfirmationModal from './cancel-authorization-confirm-modal'; import DisputedOrderNoticeHandler from 'wcpay/components/disputed-order-notice'; function disableWooOrderRefundButton( disputeStatus ) { @@ -95,6 +96,7 @@ jQuery( function ( $ ) { const canRefund = getConfig( 'canRefund' ); const refundAmount = getConfig( 'refundAmount' ); + const hasOpenAuthorization = getConfig( 'hasOpenAuthorization' ); if ( this.value === 'wc-refunded' && originalStatus !== 'wc-refunded' @@ -108,14 +110,27 @@ jQuery( function ( $ ) { this.value === 'wc-cancelled' && originalStatus !== 'wc-cancelled' ) { - if ( ! canRefund || refundAmount <= 0 ) { + // If order has an uncaptured authorization, confirm + // that merchant indeed wants to cancel both the order + // and the authorization. + if ( hasOpenAuthorization ) { + renderModal( + + ); return; } - renderModal( - - ); + // If it is possible to refund an order, double check that + // merchants indeed wants to cancel, or if they just want to + // refund. + if ( canRefund && refundAmount > 0 ) { + renderModal( + + ); + } } } ); diff --git a/client/payment-methods-map.tsx b/client/payment-methods-map.tsx index c7b6a795dbc..d0a17f282ea 100644 --- a/client/payment-methods-map.tsx +++ b/client/payment-methods-map.tsx @@ -1,7 +1,6 @@ /** * External dependencies */ -import React from 'react'; import { __ } from '@wordpress/i18n'; /** diff --git a/client/payment-methods/capability-request/index.tsx b/client/payment-methods/capability-request/index.tsx index 541a33ca2f7..4587c810784 100644 --- a/client/payment-methods/capability-request/index.tsx +++ b/client/payment-methods/capability-request/index.tsx @@ -2,7 +2,6 @@ * External dependencies */ import React from 'react'; -import { __ } from '@wordpress/i18n'; import CapabilityRequestList from './capability-request-map'; import CapabilityNotice from './capability-request-notice'; diff --git a/client/transactions/list/index.tsx b/client/transactions/list/index.tsx index 931712821a9..6573b44a29d 100644 --- a/client/transactions/list/index.tsx +++ b/client/transactions/list/index.tsx @@ -658,9 +658,7 @@ export const TransactionsList = ( window.confirm( confirmMessage ) ) { try { - const { - exported_transactions: exportedTransactions, - } = await apiFetch( { + await apiFetch( { path: getTransactionsCSV( { userEmail, dateAfter, diff --git a/includes/admin/class-wc-payments-admin.php b/includes/admin/class-wc-payments-admin.php index 0dc167c4aca..31934da5ebe 100644 --- a/includes/admin/class-wc-payments-admin.php +++ b/includes/admin/class-wc-payments-admin.php @@ -734,6 +734,7 @@ public function enqueue_payments_scripts() { 'refundedAmount' => $order->get_total_refunded(), 'canRefund' => $this->wcpay_gateway->can_refund_order( $order ), 'chargeId' => $this->order_service->get_charge_id_for_order( $order ), + 'hasOpenAuthorization' => $this->order_service->has_open_authorization( $order ), ] ); wp_localize_script( diff --git a/includes/admin/class-wc-rest-payments-orders-controller.php b/includes/admin/class-wc-rest-payments-orders-controller.php index 4040196c01e..a833fc9092f 100644 --- a/includes/admin/class-wc-rest-payments-orders-controller.php +++ b/includes/admin/class-wc-rest-payments-orders-controller.php @@ -289,7 +289,7 @@ public function capture_authorization( WP_REST_Request $request ) { $this->add_fraud_outcome_manual_entry( $order, 'approve' ); - $result = $this->gateway->capture_charge( $order, false, $intent_metadata ); + $result = $this->gateway->capture_charge( $order, true, $intent_metadata ); if ( Intent_Status::SUCCEEDED !== $result['status'] ) { return new WP_Error( diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index f3f81601e90..05604397ae2 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -451,6 +451,7 @@ public function init_hooks() { add_action( 'woocommerce_order_actions', [ $this, 'add_order_actions' ] ); add_action( 'woocommerce_order_action_capture_charge', [ $this, 'capture_charge' ] ); add_action( 'woocommerce_order_action_cancel_authorization', [ $this, 'cancel_authorization' ] ); + add_action( 'woocommerce_order_status_cancelled', [ $this, 'cancel_authorizations_on_order_cancel' ] ); add_action( 'wp_ajax_update_order_status', [ $this, 'update_order_status' ] ); add_action( 'wp_ajax_nopriv_update_order_status', [ $this, 'update_order_status' ] ); @@ -2863,6 +2864,52 @@ public function cancel_authorization( $order ) { ]; } + /** + * Cancels uncaptured authorizations on order cancel. + * + * @param int $order_id - Order ID. + */ + public function cancel_authorizations_on_order_cancel( $order_id ) { + $order = new WC_Order( $order_id ); + if ( null !== $order ) { + $intent_id = $this->order_service->get_intent_id_for_order( $order ); + if ( null !== $intent_id && '' !== $intent_id ) { + try { + $request = Get_Intention::create( $intent_id ); + $request->set_hook_args( $order ); + $intent = $request->send(); + $charge = $intent->get_charge(); + + /** + * Successful but not captured Charge is an authorization + * that needs to be cancelled. + */ + if ( null !== $charge + && false === $charge->is_captured() + && Intent_Status::SUCCEEDED === $charge->get_status() + && Intent_Status::REQUIRES_CAPTURE === $intent->get_status() + ) { + $request = Cancel_Intention::create( $intent_id ); + $request->set_hook_args( $order ); + $intent = $request->send(); + + $this->order_service->post_unique_capture_cancelled_note( $order ); + } + + $this->order_service->set_intention_status_for_order( $order, $intent->get_status() ); + $order->save(); + } catch ( \Exception $e ) { + $order->add_order_note( + WC_Payments_Utils::esc_interpolated_html( + __( 'Canceling authorization failed to complete.', 'woocommerce-payments' ), + [ 'strong' => '' ] + ) + ); + } + } + } + } + /** * Create the level 3 data array to send to Stripe when making a purchase. * diff --git a/includes/class-wc-payments-order-service.php b/includes/class-wc-payments-order-service.php index 11c51b11b30..44c5f22302f 100644 --- a/includes/class-wc-payments-order-service.php +++ b/includes/class-wc-payments-order-service.php @@ -557,6 +557,21 @@ public function get_intention_status_for_order( $order ) : string { return $order->get_meta( self::INTENTION_STATUS_META_KEY, true ); } + /** + * Checks if order has an open (uncaptured) authorization. + * + * @param mixed $order The order Id or order object. + * + * @return bool + * + * @throws Order_Not_Found_Exception + */ + public function has_open_authorization( $order ) : bool { + $order = $this->get_order( $order ); + return Intent_Status::REQUIRES_CAPTURE === $order->get_meta( self::INTENTION_STATUS_META_KEY, true ); + } + + /** * Set the payment metadata for customer id. * @@ -806,6 +821,21 @@ public function get_billing_data_from_order( WC_Order $order ): array { ]; } + /** + * Creates an "authorization cancelled" order note if not already present. + * + * @param WC_Order $order The order. + * @return boolean True if the note was added, false otherwise. + */ + public function post_unique_capture_cancelled_note( $order ) { + $note = $this->generate_capture_cancelled_note(); + if ( ! $this->order_note_exists( $order, $note ) ) { + $order->add_order_note( $note ); + return true; + } + return false; + } + /** * Updates an order to cancelled status, while adding a note with a link to the transaction. * @@ -815,8 +845,7 @@ public function get_billing_data_from_order( WC_Order $order ): array { * @return void */ private function mark_payment_capture_cancelled( $order, $intent_data ) { - $note = $this->generate_capture_cancelled_note(); - if ( $this->order_note_exists( $order, $note ) ) { + if ( false === $this->post_unique_capture_cancelled_note( $order ) ) { $this->complete_order_processing( $order ); return; } @@ -832,7 +861,6 @@ private function mark_payment_capture_cancelled( $order, $intent_data ) { } $this->update_order_status( $order, Order_Status::CANCELLED ); - $order->add_order_note( $note ); $this->complete_order_processing( $order, $intent_data['intent_status'] ); } diff --git a/tests/unit/helpers/class-wc-helper-intention.php b/tests/unit/helpers/class-wc-helper-intention.php index f47bbf39bf8..06130bb3b7e 100644 --- a/tests/unit/helpers/class-wc-helper-intention.php +++ b/tests/unit/helpers/class-wc-helper-intention.php @@ -34,6 +34,23 @@ public static function create_charge( $data = [] ) { 'funding' => 'credit', ], ], + 'payment_method' => 'pm_mock', + 'amount_captured' => 5000, + 'amount_refunded' => 0, + 'application_fee_amount' => 0, + 'balance_transaction' => 'txn_mock', + 'billing_details' => [], + 'currency' => 'usd', + 'dispute' => [], + 'disputed' => false, + 'order' => [], + 'outcome' => [], + 'paid' => true, + 'paydown' => [], + 'payment_intent' => 'pi_mock', + 'refunded' => false, + 'refunds' => [], + 'status' => 'succeeded', ] ); @@ -41,7 +58,24 @@ public static function create_charge( $data = [] ) { $charge_data['id'], $charge_data['amount'], $charge_data['created'], - $charge_data['payment_method_details'] + $charge_data['payment_method_details'], + $charge_data['payment_method'], + $charge_data['amount_captured'], + $charge_data['amount_refunded'], + $charge_data['application_fee_amount'], + $charge_data['balance_transaction'], + $charge_data['billing_details'], + $charge_data['currency'], + $charge_data['dispute'], + $charge_data['disputed'], + $charge_data['order'], + $charge_data['outcome'], + $charge_data['paid'], + $charge_data['paydown'], + $charge_data['payment_intent'], + $charge_data['refunded'], + $charge_data['refunds'], + $charge_data['status'] ); } @@ -49,10 +83,11 @@ public static function create_charge( $data = [] ) { * Create a payment intent. * * @param array $data Data to override defaults. + * @param bool $has_charge Whether or not the intention has a charge. * * @return WC_Payments_API_Payment_Intention */ - public static function create_intention( $data = [] ) { + public static function create_intention( $data = [], $has_charge = true ) { $intent_data = wp_parse_args( $data, [ @@ -74,6 +109,13 @@ public static function create_intention( $data = [] ) { ] ); + $charge_data = wp_parse_args( + $intent_data['charge'], + [ + 'payment_intent' => $intent_data['id'], + ] + ); + $intention = new WC_Payments_API_Payment_Intention( $intent_data['id'], $intent_data['amount'], @@ -83,7 +125,7 @@ public static function create_intention( $data = [] ) { $intent_data['created'], $intent_data['status'], $intent_data['client_secret'], - self::create_charge( $intent_data['charge'] ), + $has_charge ? self::create_charge( $charge_data ) : null, $intent_data['next_action'], $intent_data['last_payment_error'], $intent_data['metadata'], diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay.php b/tests/unit/test-class-wc-payment-gateway-wcpay.php index 258ebeefc2c..8efcc4ccb0e 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay.php @@ -939,6 +939,151 @@ public function test_capture_charge_without_level3() { $this->assertEquals( Order_Status::PROCESSING, $order->get_status() ); } + public function test_capture_cancelling_order_cancels_authorization() { + $intent_id = uniqid( 'pi_' ); + $charge_id = uniqid( 'ch_' ); + + $order = WC_Helper_Order::create_order(); + $order->set_transaction_id( $intent_id ); + $order->update_meta_data( '_intent_id', $intent_id ); + $order->update_meta_data( '_charge_id', $charge_id ); + $order->update_meta_data( '_intention_status', Intent_Status::REQUIRES_CAPTURE ); + $order->update_status( Order_Status::ON_HOLD ); + + $mock_intent = WC_Helper_Intention::create_intention( + [ + 'id' => $intent_id, + 'status' => Intent_Status::REQUIRES_CAPTURE, + 'charge' => [ + 'amount_captured' => 0, + 'status' => Intent_Status::SUCCEEDED, + 'id' => $charge_id, + ], + ] + ); + + $mock_canceled_intent = WC_Helper_Intention::create_intention( + [ + 'id' => $intent_id, + 'status' => Intent_Status::CANCELED, + 'charge' => [ + 'status' => Intent_Status::CANCELED, + 'id' => $charge_id, + ], + ] + ); + + $get_intent_request = $this->mock_wcpay_request( Get_Intention::class, 1, $intent_id ); + $get_intent_request->expects( $this->once() ) + ->method( 'format_response' ) + ->willReturn( $mock_intent ); + + $cancel_intent_request = $this->mock_wcpay_request( Cancel_Intention::class, 1, $intent_id ); + $cancel_intent_request->expects( $this->once() ) + ->method( 'format_response' ) + ->willReturn( $mock_canceled_intent ); + + $order->set_status( Order_Status::CANCELLED ); + $order->save(); + + $order = wc_get_order( $order->get_id() ); + + $this->assertSame( Intent_Status::CANCELED, $order->get_meta( '_intention_status', true ) ); + $this->assertSame( Order_Status::CANCELLED, $order->get_status() ); + } + + /** + * Test for various scenarios where we don't want to cancel existing + * payment intent. + * + * @dataProvider provider_capture_cancelling_order_does_not_cancel_captured_authorization + */ + public function test_capture_cancelling_order_does_not_cancel_captured_authorization( WC_Payments_API_Payment_Intention $intent ) { + $intent_id = $intent->get_id(); + $charge = $intent->get_charge(); + $charge_id = null !== $charge ? $charge->get_id() : null; + + $order = WC_Helper_Order::create_order(); + $order->set_transaction_id( $intent_id ); + $order->update_meta_data( '_intent_id', $intent_id ); + if ( null !== $charge_id ) { + $order->update_meta_data( '_charge_id', $charge_id ); + } + $order->update_meta_data( '_intention_status', $intent->get_status() ); + $order->update_status( Order_Status::PROCESSING ); + + $get_intent_request = $this->mock_wcpay_request( Get_Intention::class, 1, $intent_id ); + $get_intent_request->expects( $this->once() ) + ->method( 'format_response' ) + ->willReturn( $intent ); + + $this->mock_wcpay_request( Cancel_Intention::class, 0, $intent_id ); + + $order->set_status( Order_Status::CANCELLED ); + $order->save(); + + $order = wc_get_order( $order->get_id() ); + + $this->assertSame( $intent->get_status(), $order->get_meta( '_intention_status', true ), 'Intent status is not modified' ); + $this->assertSame( Order_Status::CANCELLED, $order->get_status(), 'Order should become cancelled' ); + } + + /** + * Provider for test_capture_cancelling_order_does_not_cancel_captured_authorization. + * + * @return array + */ + public function provider_capture_cancelling_order_does_not_cancel_captured_authorization() { + return [ + 'Captured intent' => [ + WC_Helper_Intention::create_intention( + [ + 'id' => uniqid( 'pi_' ), + 'status' => Intent_Status::SUCCEEDED, + 'charge' => [ + 'status' => Intent_Status::SUCCEEDED, + 'id' => uniqid( 'ch_' ), + ], + ] + ), + ], + 'Intent without charge' => [ + WC_Helper_Intention::create_intention( + [ + 'id' => uniqid( 'pi_' ), + 'status' => Intent_Status::SUCCEEDED, + ], + false + ), + ], + 'Canceled intent' => [ + WC_Helper_Intention::create_intention( + [ + 'id' => uniqid( 'pi_' ), + 'status' => Intent_Status::CANCELED, + 'charge' => [ + 'status' => Intent_Status::SUCCEEDED, + 'id' => uniqid( 'ch_' ), + ], + ] + ), + ], + 'Captured charge, intent out of sync' => [ + WC_Helper_Intention::create_intention( + [ + 'id' => uniqid( 'pi_' ), + 'status' => Intent_Status::REQUIRES_CAPTURE, + 'charge' => [ + 'status' => Intent_Status::SUCCEEDED, + 'id' => uniqid( 'ch_' ), + 'captured' => true, + ], + ] + ), + ], + ]; + } + public function test_cancel_authorization_handles_api_exception_when_canceling() { $intent_id = 'pi_mock'; $charge_id = 'ch_mock'; @@ -969,7 +1114,7 @@ public function test_cancel_authorization_handles_api_exception_when_canceling() ] )[0]; - $this->assertStringContainsString( 'cancelled', $note->content ); + $this->assertStringContainsString( 'cancelled', strtolower( $note->content ) ); $this->assertEquals( Order_Status::CANCELLED, $order->get_status() ); } diff --git a/tests/unit/test-class-wc-payments-order-service.php b/tests/unit/test-class-wc-payments-order-service.php index ec8863366b2..1107c94fcd5 100644 --- a/tests/unit/test-class-wc-payments-order-service.php +++ b/tests/unit/test-class-wc-payments-order-service.php @@ -776,8 +776,8 @@ public function test_mark_payment_capture_cancelled( $intent_args, $order_fraud_ // Assert: Check that the notes were updated. $notes = wc_get_order_notes( [ 'order_id' => $this->order->get_id() ] ); - $this->assertStringContainsString( 'Pending payment to ' . $wc_order_statuses['wc-cancelled'], $notes[1]->content ); - $this->assertStringContainsString( 'Payment authorization was successfully cancelled', $notes[0]->content ); + $this->assertStringContainsString( 'Pending payment to ' . $wc_order_statuses['wc-cancelled'], $notes[0]->content ); + $this->assertStringContainsString( 'Payment authorization was successfully cancelled', $notes[1]->content ); // Assert: Check that the order was unlocked. $this->assertFalse( get_transient( 'wcpay_processing_intent_' . $this->order->get_id() ) );