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 {