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() ) );