diff --git a/changelog/fix-6926-transaction-dispute-details-actions b/changelog/fix-6926-transaction-dispute-details-actions
new file mode 100644
index 00000000000..114fdfd9da0
--- /dev/null
+++ b/changelog/fix-6926-transaction-dispute-details-actions
@@ -0,0 +1,5 @@
+Significance: patch
+Type: add
+Comment: Behind a feature flag: add challenge and accept action buttons to Transaction Details screen
+
+
diff --git a/client/data/disputes/actions.js b/client/data/disputes/actions.js
index 2e9975e2cf9..7e099b440f3 100644
--- a/client/data/disputes/actions.js
+++ b/client/data/disputes/actions.js
@@ -14,6 +14,7 @@ import { NAMESPACE, STORE_NAME } from '../constants';
import TYPES from './action-types';
import wcpayTracks from 'tracks';
import { getAdminUrl } from 'wcpay/utils';
+import { getPaymentIntent } from '../payment-intents/resolvers';
export function updateDispute( data ) {
return {
@@ -88,3 +89,63 @@ export function* acceptDispute( id ) {
yield controls.dispatch( 'core/notices', 'createErrorNotice', message );
}
}
+
+// This function handles the dispute acceptance flow from the Transaction Details screen.
+// It differs from the `acceptDispute` function above in that it also fetches and updates
+// the payment intent associated with the dispute to reflect changes to the dispute
+// on the Transaction Details screen.
+//
+// Once the '_wcpay_feature_dispute_on_transaction_page' is enabled by default,
+// the `acceptDispute` function above can be removed and this function can be renamed
+// to `acceptDispute`.
+export function* acceptTransactionDetailsDispute( dispute ) {
+ const { id, payment_intent: paymentIntent } = dispute;
+
+ try {
+ yield controls.dispatch( STORE_NAME, 'startResolution', 'getDispute', [
+ id,
+ ] );
+
+ const updatedDispute = yield apiFetch( {
+ path: `${ NAMESPACE }/disputes/${ id }/close`,
+ method: 'post',
+ } );
+
+ yield updateDispute( updatedDispute );
+
+ // Fetch and update the payment intent associated with the dispute
+ // to reflect changes to the dispute on the Transaction Details screen.
+ yield getPaymentIntent( paymentIntent );
+
+ yield controls.dispatch( STORE_NAME, 'finishResolution', 'getDispute', [
+ id,
+ ] );
+
+ wcpayTracks.recordEvent( 'wcpay_dispute_accept_success' );
+ const message = updatedDispute.order
+ ? sprintf(
+ /* translators: #%s is an order number, e.g. 15 */
+ __(
+ 'You have accepted the dispute for order #%s.',
+ 'woocommerce-payments'
+ ),
+ updatedDispute.order.number
+ )
+ : __( 'You have accepted the dispute.', 'woocommerce-payments' );
+ yield controls.dispatch(
+ 'core/notices',
+ 'createSuccessNotice',
+ message
+ );
+ } catch ( e ) {
+ const message = __(
+ 'There has been an error accepting the dispute. Please try again later.',
+ 'woocommerce-payments'
+ );
+ wcpayTracks.recordEvent( 'wcpay_dispute_accept_failed' );
+ yield controls.dispatch( 'core/notices', 'createErrorNotice', message );
+ yield controls.dispatch( STORE_NAME, 'finishResolution', 'getDispute', [
+ id,
+ ] );
+ }
+}
diff --git a/client/data/disputes/hooks.ts b/client/data/disputes/hooks.ts
index c4b7eb6f505..256faf30152 100644
--- a/client/data/disputes/hooks.ts
+++ b/client/data/disputes/hooks.ts
@@ -18,6 +18,10 @@ import type {
import { STORE_NAME } from '../constants';
import { disputeAwaitingResponseStatuses } from 'wcpay/disputes/filters/config';
+/**
+ * Returns the dispute object, loading state, and accept function.
+ * Fetches the dispute object if it is not already cached.
+ */
export const useDispute = (
id: string
): {
@@ -43,6 +47,31 @@ export const useDispute = (
return { dispute, isLoading, doAccept };
};
+/**
+ * Returns the dispute accept function and loading state.
+ * Does not return or fetch the dispute object.
+ */
+export const useDisputeAccept = (
+ dispute: Dispute
+): {
+ doAccept: () => void;
+ isLoading: boolean;
+} => {
+ const { isLoading } = useSelect(
+ ( select ) => {
+ const { isResolving } = select( STORE_NAME );
+
+ return {
+ isLoading: isResolving( 'getDispute', [ dispute.id ] ),
+ };
+ },
+ [ dispute.id ]
+ );
+ const { acceptTransactionDetailsDispute } = useDispatch( STORE_NAME );
+ const doAccept = () => acceptTransactionDetailsDispute( dispute );
+ return { doAccept, isLoading };
+};
+
export const useDisputeEvidence = (): {
updateDispute: ( data: Dispute ) => void;
} => {
diff --git a/client/payment-details/dispute-details/dispute-awaiting-response-details.tsx b/client/payment-details/dispute-details/dispute-awaiting-response-details.tsx
index e661ad9d6b1..c313c5d83d6 100644
--- a/client/payment-details/dispute-details/dispute-awaiting-response-details.tsx
+++ b/client/payment-details/dispute-details/dispute-awaiting-response-details.tsx
@@ -3,17 +3,35 @@
/**
* External dependencies
*/
-import React from 'react';
+import React, { useState } from 'react';
import moment from 'moment';
-import { __ } from '@wordpress/i18n';
-import { Card, CardBody } from '@wordpress/components';
-import { edit } from '@wordpress/icons';
+import { __, sprintf } from '@wordpress/i18n';
+import { backup, edit, lock } from '@wordpress/icons';
+import { createInterpolateElement } from '@wordpress/element';
+import { Link } from '@woocommerce/components';
+import {
+ Button,
+ Card,
+ CardBody,
+ Flex,
+ FlexItem,
+ Icon,
+ Modal,
+} from '@wordpress/components';
/**
* Internal dependencies
*/
import type { Dispute } from 'wcpay/types/disputes';
-import { isAwaitingResponse } from 'wcpay/disputes/utils';
+import wcpayTracks from 'tracks';
+import { useDisputeAccept } from 'wcpay/data';
+import {
+ getDisputeFee,
+ isAwaitingResponse,
+ isInquiry,
+} from 'wcpay/disputes/utils';
+import { getAdminUrl } from 'wcpay/utils';
+import { formatCurrency } from 'wcpay/utils/currency';
import DisputeNotice from './dispute-notice';
import IssuerEvidenceList from './evidence-list';
import DisputeSummaryRow from './dispute-summary-row';
@@ -25,10 +43,19 @@ interface Props {
}
const DisputeAwaitingResponseDetails: React.FC< Props > = ( { dispute } ) => {
+ const { doAccept, isLoading } = useDisputeAccept( dispute );
+ const [ isModalOpen, setModalOpen ] = useState( false );
+
const now = moment();
const dueBy = moment.unix( dispute.evidence_details?.due_by ?? 0 );
const countdownDays = Math.floor( dueBy.diff( now, 'days', true ) );
const hasStagedEvidence = dispute.evidence_details?.has_evidence;
+ const disputeFee = getDisputeFee( dispute );
+ const showDisputeActions = ! isInquiry( dispute );
+
+ const onModalClose = () => {
+ setModalOpen( false );
+ };
return (
@@ -61,6 +88,156 @@ const DisputeAwaitingResponseDetails: React.FC< Props > = ( { dispute } ) => {
/>
>
) }
+
+ { /* Dispute Actions */ }
+ { showDisputeActions && (
+
+
+
+
+
+
+
+ { isModalOpen && (
+
+
+
+ { __(
+ 'Before proceeding, please take note of the following:',
+ 'woocommerce-payments'
+ ) }
+
+
+
+
+
+
+
+ { createInterpolateElement(
+ sprintf(
+ /* translators: %s: dispute fee, : emphasis HTML element. */
+ __(
+ 'Accepting the dispute marks it as Lost. The disputed amount will be returned to the cardholder, with a %s dispute fee deducted from your account.',
+ 'woocommerce-payments'
+ ),
+ disputeFee &&
+ formatCurrency(
+ disputeFee.fee,
+ disputeFee.currency
+ )
+ ),
+ {
+ em: ,
+ }
+ ) }
+
+
+
+
+
+
+
+ { __(
+ 'Accepting the dispute is final and cannot be undone.',
+ 'woocommerce-payments'
+ ) }
+
+
+
+
+
+
+
+
+ ) }
+
+ ) }
diff --git a/client/payment-details/dispute-details/style.scss b/client/payment-details/dispute-details/style.scss
index 9ffda2d35ec..4fe5a0c6be6 100644
--- a/client/payment-details/dispute-details/style.scss
+++ b/client/payment-details/dispute-details/style.scss
@@ -34,6 +34,21 @@
}
}
}
+
+ &__actions {
+ display: flex;
+ justify-content: start;
+ gap: $grid-unit-10;
+
+ @media screen and ( max-width: $break-small ) {
+ flex-direction: column;
+
+ .components-button {
+ width: 100%;
+ justify-content: center;
+ }
+ }
+ }
}
}
.dispute-reason-tooltip {
@@ -49,6 +64,24 @@
}
}
+.transaction-details-dispute-accept-modal {
+ max-width: 600px;
+
+ .components-modal__content {
+ padding-top: $grid-unit-30;
+ }
+
+ &__icon {
+ flex-shrink: 0;
+ padding: 6px;
+ margin-right: $grid-unit-10;
+ }
+
+ &__actions {
+ margin-top: $grid-unit-30;
+ }
+}
+
.transaction-details-dispute-footer {
background-color: #f2f4f5;
diff --git a/client/payment-details/summary/test/__snapshots__/index.tsx.snap b/client/payment-details/summary/test/__snapshots__/index.test.tsx.snap
similarity index 100%
rename from client/payment-details/summary/test/__snapshots__/index.tsx.snap
rename to client/payment-details/summary/test/__snapshots__/index.test.tsx.snap
diff --git a/client/payment-details/summary/test/index.tsx b/client/payment-details/summary/test/index.test.tsx
similarity index 82%
rename from client/payment-details/summary/test/index.tsx
rename to client/payment-details/summary/test/index.test.tsx
index 55be2ddcb4b..8817b5b1eca 100755
--- a/client/payment-details/summary/test/index.tsx
+++ b/client/payment-details/summary/test/index.test.tsx
@@ -36,10 +36,16 @@ declare const global: {
};
};
+const mockDisputeDoAccept = jest.fn();
+
jest.mock( 'wcpay/data', () => ( {
useAuthorization: jest.fn( () => ( {
authorization: null,
} ) ),
+ useDisputeAccept: jest.fn( () => ( {
+ doAccept: mockDisputeDoAccept,
+ isLoading: false,
+ } ) ),
} ) );
const mockUseAuthorization = useAuthorization as jest.MockedFunction<
@@ -393,6 +399,14 @@ describe( 'PaymentDetailsSummary', () => {
expect(
screen.getByText( /Respond By/i ).nextSibling
).toHaveTextContent( /Sep 9, 2023/ );
+
+ // Actions
+ screen.getByRole( 'button', {
+ name: /Challenge dispute/,
+ } );
+ screen.getByRole( 'button', {
+ name: /Accept dispute/,
+ } );
} );
test( 'correctly renders dispute details for a dispute with staged evidence', () => {
@@ -418,6 +432,65 @@ describe( 'PaymentDetailsSummary', () => {
screen.getByText( /You initiated a challenge to this dispute/, {
ignore: '.a11y-speak-region',
} );
+
+ screen.getByRole( 'button', {
+ name: /Continue with challenge/,
+ } );
+ } );
+
+ test( 'correctly renders the accept dispute modal and accepts', () => {
+ const charge = getBaseCharge();
+ charge.disputed = true;
+ charge.dispute = getBaseDispute();
+ charge.dispute.status = 'needs_response';
+
+ renderCharge( charge );
+
+ const openModalButton = screen.getByRole( 'button', {
+ name: /Accept dispute/,
+ } );
+
+ // Open the modal
+ openModalButton.click();
+
+ screen.getByRole( 'heading', {
+ name: /Accept the dispute?/,
+ } );
+ screen.getByText( /\$15.00 dispute fee/, {
+ ignore: '.a11y-speak-region',
+ } );
+
+ screen.getByRole( 'button', {
+ name: /Cancel/,
+ } );
+ const acceptButton = screen.getByRole( 'button', {
+ name: /Accept dispute/,
+ } );
+
+ // Accept the dispute
+ acceptButton.click();
+
+ expect( mockDisputeDoAccept ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ test( 'navigates to the dispute challenge screen when the challenge button is clicked', () => {
+ const charge = getBaseCharge();
+ charge.disputed = true;
+ charge.dispute = getBaseDispute();
+ charge.dispute.status = 'needs_response';
+ charge.dispute.id = 'dp_test123';
+
+ renderCharge( charge );
+
+ const challengeButton = screen.getByRole( 'button', {
+ name: /Challenge dispute/,
+ } );
+
+ challengeButton.click();
+
+ expect( window.location.href ).toContain(
+ `admin.php?page=wc-admin&path=%2Fpayments%2Fdisputes%2Fchallenge&id=${ charge.dispute.id }`
+ );
} );
test( 'correctly renders dispute details for "won" disputes', () => {
@@ -432,6 +505,18 @@ describe( 'PaymentDetailsSummary', () => {
ignore: '.a11y-speak-region',
} );
screen.getByRole( 'button', { name: /View dispute details/i } );
+
+ // No actions rendered
+ expect(
+ screen.queryByRole( 'button', {
+ name: /Challenge/i,
+ } )
+ ).toBeNull();
+ expect(
+ screen.queryByRole( 'button', {
+ name: /Accept/i,
+ } )
+ ).toBeNull();
} );
test( 'correctly renders dispute details for "under_review" disputes', () => {
@@ -447,6 +532,18 @@ describe( 'PaymentDetailsSummary', () => {
ignore: '.a11y-speak-region',
} );
screen.getByRole( 'button', { name: /View submitted evidence/i } );
+
+ // No actions rendered
+ expect(
+ screen.queryByRole( 'button', {
+ name: /Challenge/i,
+ } )
+ ).toBeNull();
+ expect(
+ screen.queryByRole( 'button', {
+ name: /Accept/i,
+ } )
+ ).toBeNull();
} );
test( 'correctly renders dispute details for "accepted" disputes', () => {
@@ -466,6 +563,18 @@ describe( 'PaymentDetailsSummary', () => {
screen.getByText( /\$15.00 fee/i, {
ignore: '.a11y-speak-region',
} );
+
+ // No actions rendered
+ expect(
+ screen.queryByRole( 'button', {
+ name: /Challenge/i,
+ } )
+ ).toBeNull();
+ expect(
+ screen.queryByRole( 'button', {
+ name: /Accept/i,
+ } )
+ ).toBeNull();
} );
test( 'correctly renders dispute details for "lost" disputes', () => {
@@ -486,6 +595,18 @@ describe( 'PaymentDetailsSummary', () => {
ignore: '.a11y-speak-region',
} );
screen.getByRole( 'button', { name: /View dispute details/i } );
+
+ // No actions rendered
+ expect(
+ screen.queryByRole( 'button', {
+ name: /Challenge/i,
+ } )
+ ).toBeNull();
+ expect(
+ screen.queryByRole( 'button', {
+ name: /Accept/i,
+ } )
+ ).toBeNull();
} );
} );
} );
diff --git a/client/tracks/index.js b/client/tracks/index.js
index 7c6012d4a75..4006c197e6e 100644
--- a/client/tracks/index.js
+++ b/client/tracks/index.js
@@ -68,6 +68,9 @@ const events = {
DEPOSITS_ROW_CLICK: 'wcpay_deposits_row_click',
DEPOSITS_DOWNLOAD_CSV_CLICK: 'wcpay_deposits_download',
DISPUTES_ROW_ACTION_CLICK: 'wcpay_disputes_row_action_click',
+ DISPUTE_CHALLENGE_CLICK: 'wcpay_dispute_challenge_click',
+ DISPUTE_ACCEPT_CLICK: 'wcpay_dispute_accept_click',
+ DISPUTE_ACCEPT_MODAL_VIEW: 'wcpay_dispute_accept_modal_view',
ORDER_DISPUTE_NOTICE_BUTTON_CLICK:
'wcpay_order_dispute_notice_action_click',
OVERVIEW_BALANCES_CURRENCY_CLICK:
diff --git a/client/types/disputes.d.ts b/client/types/disputes.d.ts
index 419689056bd..4ce74d40b3a 100644
--- a/client/types/disputes.d.ts
+++ b/client/types/disputes.d.ts
@@ -103,6 +103,7 @@ export interface Dispute {
currency: string;
created: number;
balance_transactions: BalanceTransaction[];
+ payment_intent: string;
}
export interface CachedDispute {