From 991dd84c464df101559dabcdb39410272ff46948 Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Wed, 30 Aug 2023 13:16:26 +1000 Subject: [PATCH 01/49] Refactor isAwaitingResponse check to wrap entire Note the final return statement will return the non-awaiting-response UI --- .../payment-details/dispute-details/index.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/client/payment-details/dispute-details/index.tsx b/client/payment-details/dispute-details/index.tsx index 6872a10ed11..312f74a5e81 100644 --- a/client/payment-details/dispute-details/index.tsx +++ b/client/payment-details/dispute-details/index.tsx @@ -23,21 +23,25 @@ const DisputeDetails: React.FC< DisputeDetailsProps > = ( { dispute } ) => { const dueBy = moment.unix( dispute.evidence_details?.due_by ?? 0 ); const countdownDays = Math.floor( dueBy.diff( now, 'days', true ) ); - return ( -
- - - { isAwaitingResponse( dispute.status ) && - countdownDays >= 0 && ( + if ( isAwaitingResponse( dispute.status ) ) { + return ( +
+ + + { countdownDays >= 0 && ( ) } - - -
- ); +
+
+
+
+ ); + } + + return null; }; export default DisputeDetails; From 46c3f311f623d82f5b5bd66904c2f6b7564fc9fd Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Wed, 30 Aug 2023 13:48:05 +1000 Subject: [PATCH 02/49] Add dispute actions init --- .../dispute-details/dispute-actions.tsx | 57 +++++++++++++++++++ .../payment-details/dispute-details/index.tsx | 2 + 2 files changed, 59 insertions(+) create mode 100644 client/payment-details/dispute-details/dispute-actions.tsx diff --git a/client/payment-details/dispute-details/dispute-actions.tsx b/client/payment-details/dispute-details/dispute-actions.tsx new file mode 100644 index 00000000000..ab0ba048df6 --- /dev/null +++ b/client/payment-details/dispute-details/dispute-actions.tsx @@ -0,0 +1,57 @@ +/** @format **/ + +/** + * External dependencies + */ +import React from 'react'; +import { __ } from '@wordpress/i18n'; +import { Button, Flex } from '@wordpress/components'; +import { getHistory } from '@woocommerce/navigation'; + +/** + * Internal dependencies + */ +import { getAdminUrl } from 'wcpay/utils'; +import type { Dispute } from 'wcpay/types/disputes'; + +interface Props { + /** + * The dispute ID. + */ + dispute: Dispute; +} +const DisputeActions: React.FC< Props > = ( { dispute } ) => { + return ( + + + + + + ); +}; + +export default DisputeActions; diff --git a/client/payment-details/dispute-details/index.tsx b/client/payment-details/dispute-details/index.tsx index 312f74a5e81..e985f7a8492 100644 --- a/client/payment-details/dispute-details/index.tsx +++ b/client/payment-details/dispute-details/index.tsx @@ -12,6 +12,7 @@ import type { Dispute } from 'wcpay/types/disputes'; import { Card, CardBody } from '@wordpress/components'; import './style.scss'; import DisputeNotice from './dispute-notice'; +import DisputeActions from './dispute-actions'; import { isAwaitingResponse } from 'wcpay/disputes/utils'; interface DisputeDetailsProps { @@ -35,6 +36,7 @@ const DisputeDetails: React.FC< DisputeDetailsProps > = ( { dispute } ) => { /> ) }
+
From 59190f22da03db83436519d93ee75186fb18c6bb Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Wed, 30 Aug 2023 14:02:03 +1000 Subject: [PATCH 03/49] Add Dispute > EvidenceDetails type comments to improve devex --- client/types/disputes.d.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/client/types/disputes.d.ts b/client/types/disputes.d.ts index 2a5c5e45a76..5598d3d49ef 100644 --- a/client/types/disputes.d.ts +++ b/client/types/disputes.d.ts @@ -11,8 +11,21 @@ interface Evidence { } interface EvidenceDetails { + /** + * Whether evidence has been staged for this dispute. + */ has_evidence: boolean; + /** + * Date by which evidence must be submitted in order to successfully challenge dispute. + */ due_by: number; + /** + * Whether the last evidence submission was submitted past the due date. Defaults to false if no evidence submissions have occurred. If true, then delivery of the latest evidence is not guaranteed. + */ + past_due: boolean; + /** + * The number of times evidence has been submitted. Typically, the merchant may only submit evidence once. + */ submission_count: number; } From b8ef1be8c498e84647a45fbe262b6358a09ffc1a Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Wed, 30 Aug 2023 14:02:40 +1000 Subject: [PATCH 04/49] Show different challenge button label if has staged evidence --- client/payment-details/dispute-details/dispute-actions.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/payment-details/dispute-details/dispute-actions.tsx b/client/payment-details/dispute-details/dispute-actions.tsx index ab0ba048df6..1257e57c4f5 100644 --- a/client/payment-details/dispute-details/dispute-actions.tsx +++ b/client/payment-details/dispute-details/dispute-actions.tsx @@ -21,6 +21,7 @@ interface Props { dispute: Dispute; } const DisputeActions: React.FC< Props > = ( { dispute } ) => { + const hasStagedEvidence = dispute.evidence_details?.has_evidence; return ( + + { isModalOpen && ( + +

+ + { __( + 'Before proceeding, please take note of the following:', + 'woocommerce-payments' + ) } + +

+

+ { __( + 'Accepting the dispute marks it as Lost. The disputed amount will be returned to the cardholder, with a $15 dispute fee deducted from your account', + 'woocommerce-payments' + ) } +

+

+ { __( + 'Accepting the dispute is final and cannot be undone.', + 'woocommerce-payments' + ) } +

+ + + + +
+ ) }
); }; From a85fea3e136400e1ddee350daf4a422d58314f22 Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Wed, 30 Aug 2023 16:26:00 +1000 Subject: [PATCH 09/49] Improve isLoading state for accepting disputes and exceptions --- client/data/disputes/actions.js | 11 +++++++---- .../dispute-details/dispute-actions.tsx | 6 +----- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/client/data/disputes/actions.js b/client/data/disputes/actions.js index 2a1798c5da0..77f75482f6a 100644 --- a/client/data/disputes/actions.js +++ b/client/data/disputes/actions.js @@ -108,14 +108,14 @@ export function* acceptTransactionDetailsDispute( dispute ) { yield updateDispute( updatedDispute ); - yield controls.dispatch( STORE_NAME, 'finishResolution', 'getDispute', [ - id, - ] ); - // 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( @@ -139,5 +139,8 @@ export function* acceptTransactionDetailsDispute( dispute ) { ); 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/payment-details/dispute-details/dispute-actions.tsx b/client/payment-details/dispute-details/dispute-actions.tsx index 452e4faf6a3..06bc8c557e8 100644 --- a/client/payment-details/dispute-details/dispute-actions.tsx +++ b/client/payment-details/dispute-details/dispute-actions.tsx @@ -27,11 +27,7 @@ export const useDisputeAccept = ( const { isResolving } = select( STORE_NAME ); return { - isLoading: - isResolving( 'getDispute', [ dispute.id ] ) || - isResolving( 'getPaymentIntent', [ - dispute.payment_intent, - ] ), + isLoading: isResolving( 'getDispute', [ dispute.id ] ), }; }, [ dispute.id ] From 3f7168d24ad7e5c1b9ddad5b9d830285fe158bd3 Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 31 Aug 2023 13:16:17 +1000 Subject: [PATCH 10/49] Update accept dispute modal styles --- .../dispute-details/dispute-actions.tsx | 57 ++++++++++++++----- .../dispute-details/style.scss | 22 +++++++ 2 files changed, 64 insertions(+), 15 deletions(-) diff --git a/client/payment-details/dispute-details/dispute-actions.tsx b/client/payment-details/dispute-details/dispute-actions.tsx index 06bc8c557e8..89c0fc73962 100644 --- a/client/payment-details/dispute-details/dispute-actions.tsx +++ b/client/payment-details/dispute-details/dispute-actions.tsx @@ -5,7 +5,8 @@ */ import React, { useState } from 'react'; import { __ } from '@wordpress/i18n'; -import { Button, Flex, Modal } from '@wordpress/components'; +import { Button, Flex, FlexItem, Icon, Modal } from '@wordpress/components'; +import { backup, lock } from '@wordpress/icons'; import { useDispatch, useSelect } from '@wordpress/data'; import { getHistory } from '@woocommerce/navigation'; @@ -99,7 +100,11 @@ const DisputeActions: React.FC< Props > = ( { dispute } ) => { { isModalOpen && ( - +

{ __( @@ -108,19 +113,41 @@ const DisputeActions: React.FC< Props > = ( { dispute } ) => { ) }

-

- { __( - 'Accepting the dispute marks it as Lost. The disputed amount will be returned to the cardholder, with a $15 dispute fee deducted from your account', - 'woocommerce-payments' - ) } -

-

- { __( - 'Accepting the dispute is final and cannot be undone.', - 'woocommerce-payments' - ) } -

- + + + + + + { __( + 'Accepting the dispute marks it as Lost. The disputed amount will be returned to the cardholder, with a $15 dispute fee deducted from your account', + 'woocommerce-payments' + ) } + + + + + + + + { __( + '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 c9676e19bc2..a36fd3d3319 100644 --- a/client/payment-details/dispute-details/style.scss +++ b/client/payment-details/dispute-details/style.scss @@ -9,3 +9,25 @@ padding: $grid-unit-20; } } + +.transaction-details-dispute-accept-modal { + max-width: 600px; + + .components-modal__content { + padding-top: $grid-unit-30; + } + + .components-flex-item { + flex-shrink: 0; + } + + &__icon { + flex-shrink: 0; + padding: 6px; + margin-right: $grid-unit-10; + } + + &__actions { + margin-top: $grid-unit-30; + } +} From 347198a27ddb90ca62ad34fab5538c9b6a18bcd3 Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 31 Aug 2023 13:54:57 +1000 Subject: [PATCH 11/49] Add getDisputeFee util function --- client/disputes/utils.ts | 13 +++++++++++++ client/types/balance-transactions.d.ts | 1 + 2 files changed, 14 insertions(+) diff --git a/client/disputes/utils.ts b/client/disputes/utils.ts index 95f59b190d9..a6a87a61659 100644 --- a/client/disputes/utils.ts +++ b/client/disputes/utils.ts @@ -13,6 +13,7 @@ import type { DisputeStatus, EvidenceDetails, } from 'wcpay/types/disputes'; +import type { BalanceTransaction } from 'wcpay/types/balance-transactions'; import { disputeAwaitingResponseStatuses } from 'wcpay/disputes/filters/config'; interface IsDueWithinProps { @@ -62,3 +63,15 @@ export const isInquiry = ( dispute: Dispute | CachedDispute ): boolean => { // Inquiry dispute statuses are one of `warning_needs_response`, `warning_under_review` or `warning_closed`. return dispute.status.startsWith( 'warning' ); }; + +/** + * Returns the dispute fee balance transaction for a dispute if it exists. + */ +export const getDisputeFee = ( + dispute: Dispute +): BalanceTransaction | undefined => { + const disputeFee = dispute.balance_transactions.find( + ( transaction ) => transaction.reporting_category === 'dispute' + ); + return disputeFee; +}; diff --git a/client/types/balance-transactions.d.ts b/client/types/balance-transactions.d.ts index e14e65cd2e5..dccb7535902 100644 --- a/client/types/balance-transactions.d.ts +++ b/client/types/balance-transactions.d.ts @@ -2,4 +2,5 @@ export interface BalanceTransaction { currency: string; amount: number; fee: number; + reporting_category?: 'dispute' | 'dispute_reversal' | string; } From 484b60df6ac60dc8fd9141877a26b29b983912fa Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 31 Aug 2023 13:56:23 +1000 Subject: [PATCH 12/49] Populate dynamic content for accept dispute modal --- .../dispute-details/dispute-actions.tsx | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/client/payment-details/dispute-details/dispute-actions.tsx b/client/payment-details/dispute-details/dispute-actions.tsx index 89c0fc73962..7192a3ceea5 100644 --- a/client/payment-details/dispute-details/dispute-actions.tsx +++ b/client/payment-details/dispute-details/dispute-actions.tsx @@ -4,18 +4,21 @@ * External dependencies */ import React, { useState } from 'react'; -import { __ } from '@wordpress/i18n'; -import { Button, Flex, FlexItem, Icon, Modal } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; import { backup, lock } from '@wordpress/icons'; import { useDispatch, useSelect } from '@wordpress/data'; +import { createInterpolateElement } from '@wordpress/element'; +import { Button, Flex, FlexItem, Icon, Modal } from '@wordpress/components'; import { getHistory } from '@woocommerce/navigation'; /** * Internal dependencies */ -import { getAdminUrl } from 'wcpay/utils'; import type { Dispute } from 'wcpay/types/disputes'; +import { getAdminUrl } from 'wcpay/utils'; import { STORE_NAME } from 'wcpay/data/constants'; +import { getDisputeFee } from 'wcpay/disputes/utils'; +import { formatCurrency } from 'wcpay/utils/currency'; export const useDisputeAccept = ( dispute: Dispute @@ -42,10 +45,12 @@ interface Props { dispute: Dispute; } const DisputeActions: React.FC< Props > = ( { dispute } ) => { - const hasStagedEvidence = dispute.evidence_details?.has_evidence; const { doAccept, isLoading } = useDisputeAccept( dispute ); const [ isModalOpen, setModalOpen ] = useState( false ); + const hasStagedEvidence = dispute.evidence_details?.has_evidence; + const disputeFee = getDisputeFee( dispute ); + const onClose = () => { setModalOpen( false ); }; @@ -122,9 +127,23 @@ const DisputeActions: React.FC< Props > = ( { dispute } ) => { /> - { __( - 'Accepting the dispute marks it as Lost. The disputed amount will be returned to the cardholder, with a $15 dispute fee deducted from your account', - 'woocommerce-payments' + { createInterpolateElement( + sprintf( + /* translators: %s: dispute fee */ + + __( + '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: , + } ) } From 83474feb3ffd17d8740dc2fe094e920e38b46648 Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 31 Aug 2023 13:57:26 +1000 Subject: [PATCH 13/49] Add changelog entry --- changelog/fix-6926-transaction-dispute-details-actions | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog/fix-6926-transaction-dispute-details-actions 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 + + From b64d04b0edf2587cc4f9e29854aabffe5519e3af Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 31 Aug 2023 14:02:16 +1000 Subject: [PATCH 14/49] Fix modal text content overflow --- .../dispute-details/dispute-actions.tsx | 16 ++++------------ .../payment-details/dispute-details/style.scss | 4 ---- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/client/payment-details/dispute-details/dispute-actions.tsx b/client/payment-details/dispute-details/dispute-actions.tsx index 7192a3ceea5..e2451a5a247 100644 --- a/client/payment-details/dispute-details/dispute-actions.tsx +++ b/client/payment-details/dispute-details/dispute-actions.tsx @@ -119,12 +119,8 @@ const DisputeActions: React.FC< Props > = ( { dispute } ) => {

- - + + { createInterpolateElement( @@ -148,12 +144,8 @@ const DisputeActions: React.FC< Props > = ( { dispute } ) => { - - + + { __( diff --git a/client/payment-details/dispute-details/style.scss b/client/payment-details/dispute-details/style.scss index a36fd3d3319..90512e32b1a 100644 --- a/client/payment-details/dispute-details/style.scss +++ b/client/payment-details/dispute-details/style.scss @@ -17,10 +17,6 @@ padding-top: $grid-unit-30; } - .components-flex-item { - flex-shrink: 0; - } - &__icon { flex-shrink: 0; padding: 6px; From 9ea2586cb2941f1292cef4639ab307a971aa2f5f Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 31 Aug 2023 14:55:41 +1000 Subject: [PATCH 15/49] =?UTF-8?q?Update=20Dispute=20=E2=86=92=20Charge=20i?= =?UTF-8?q?nterface=20to=20accept=20`string`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Dispute is nested within a charge, it will be charge_id rather than charge object --- client/types/disputes.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/types/disputes.d.ts b/client/types/disputes.d.ts index 6a31605fc30..1d12b63773f 100644 --- a/client/types/disputes.d.ts +++ b/client/types/disputes.d.ts @@ -64,7 +64,7 @@ export interface Dispute { evidence: Evidence; fileSize?: Record< string, number >; reason: DisputeReason; - charge: Charge; + charge: Charge | string; amount: number; currency: string; created: number; From 004aaf6245dc1df3685c41465546a1d67d667a39 Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 31 Aug 2023 14:56:48 +1000 Subject: [PATCH 16/49] Move useDisputeAccept hook to `data/disputes/hooks` --- client/data/disputes/hooks.ts | 21 ++++++++++++++++ .../dispute-details/dispute-actions.tsx | 24 +------------------ 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/client/data/disputes/hooks.ts b/client/data/disputes/hooks.ts index c4b7eb6f505..2a26616d127 100644 --- a/client/data/disputes/hooks.ts +++ b/client/data/disputes/hooks.ts @@ -43,6 +43,27 @@ export const useDispute = ( return { dispute, isLoading, doAccept }; }; +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-actions.tsx b/client/payment-details/dispute-details/dispute-actions.tsx index e2451a5a247..96775835a8a 100644 --- a/client/payment-details/dispute-details/dispute-actions.tsx +++ b/client/payment-details/dispute-details/dispute-actions.tsx @@ -6,7 +6,6 @@ import React, { useState } from 'react'; import { __, sprintf } from '@wordpress/i18n'; import { backup, lock } from '@wordpress/icons'; -import { useDispatch, useSelect } from '@wordpress/data'; import { createInterpolateElement } from '@wordpress/element'; import { Button, Flex, FlexItem, Icon, Modal } from '@wordpress/components'; import { getHistory } from '@woocommerce/navigation'; @@ -16,31 +15,10 @@ import { getHistory } from '@woocommerce/navigation'; */ import type { Dispute } from 'wcpay/types/disputes'; import { getAdminUrl } from 'wcpay/utils'; -import { STORE_NAME } from 'wcpay/data/constants'; +import { useDisputeAccept } from 'wcpay/data'; import { getDisputeFee } from 'wcpay/disputes/utils'; import { formatCurrency } from 'wcpay/utils/currency'; -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 }; -}; - interface Props { dispute: Dispute; } From 341d83d967010e32f4a5bf065f7256afd7452af7 Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 31 Aug 2023 14:57:31 +1000 Subject: [PATCH 17/49] Improve translators note with `` element description --- client/payment-details/dispute-details/dispute-actions.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/payment-details/dispute-details/dispute-actions.tsx b/client/payment-details/dispute-details/dispute-actions.tsx index 96775835a8a..07b197699f3 100644 --- a/client/payment-details/dispute-details/dispute-actions.tsx +++ b/client/payment-details/dispute-details/dispute-actions.tsx @@ -103,8 +103,7 @@ const DisputeActions: React.FC< Props > = ( { dispute } ) => { { createInterpolateElement( sprintf( - /* translators: %s: dispute fee */ - + /* 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' From da679bc23dd9df6516c281fa1ba668d2bf8275eb Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 31 Aug 2023 14:57:59 +1000 Subject: [PATCH 18/49] Add DisputeDetails tests --- .../dispute-details/test/index.test.tsx | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 client/payment-details/dispute-details/test/index.test.tsx diff --git a/client/payment-details/dispute-details/test/index.test.tsx b/client/payment-details/dispute-details/test/index.test.tsx new file mode 100644 index 00000000000..2c725b31148 --- /dev/null +++ b/client/payment-details/dispute-details/test/index.test.tsx @@ -0,0 +1,179 @@ +/** @format */ +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +/** + * Internal dependencies + */ +import type { Dispute } from 'wcpay/types/disputes'; +import { useDisputeAccept } from 'wcpay/data'; +import DisputeDetails from '..'; + +declare const global: { + wcSettings: { + locale: { + siteLocale: string; + }; + }; + wcpaySettings: { + isSubscriptionsActive: boolean; + zeroDecimalCurrencies: string[]; + currencyData: Record< string, any >; + connect: { + country: string; + }; + featureFlags: { + isAuthAndCaptureEnabled: boolean; + }; + }; +}; + +global.wcpaySettings = { + isSubscriptionsActive: false, + zeroDecimalCurrencies: [], + connect: { + country: 'US', + }, + featureFlags: { + isAuthAndCaptureEnabled: true, + }, + currencyData: { + US: { + code: 'USD', + symbol: '$', + symbolPosition: 'left', + thousandSeparator: ',', + decimalSeparator: '.', + precision: 2, + }, + }, +}; + +const charge = { + id: 'ch_38jdHA39KKA', + /* Stripe data comes in seconds, instead of the default Date milliseconds */ + created: Date.parse( 'Sep 19, 2019, 5:24 pm' ) / 1000, + amount: 2000, + amount_refunded: 0, + application_fee_amount: 70, + disputed: false, + dispute: { + id: 'dp_1', + amount: 6800, + charge: 'ch_38jdHA39KKA', + order: null, + balance_transactions: [ + { + amount: -2000, + currency: 'usd', + fee: 1500, + reporting_category: 'dispute', + }, + ], + created: 1693453017, + currency: 'usd', + evidence: { + billing_address: '123 test address', + customer_email_address: 'test@email.com', + customer_name: 'Test customer', + shipping_address: '123 test address', + }, + evidence_details: { + due_by: 1694303999, + has_evidence: false, + past_due: false, + submission_count: 0, + }, + // issuer_evidence: null, + metadata: [], + payment_intent: 'pi_1', + reason: 'fraudulent', + status: 'needs_response', + } as Dispute, + currency: 'usd', + type: 'charge', + status: 'succeeded', + paid: true, + captured: true, + balance_transaction: { + amount: 2000, + currency: 'usd', + fee: 70, + }, + refunds: { + data: [], + }, + payment_method_details: { + card: { + brand: 'visa', + last4: '4242', + }, + type: 'card', + }, +}; + +// mock the useDisputeAccept hook +jest.mock( 'wcpay/data', () => ( { + useDisputeAccept: jest.fn( () => ( { + doAccept: jest.fn(), + isLoading: false, + } ) ), +} ) ); +const mockUseDisputeAccept = useDisputeAccept as jest.MockedFunction< + typeof useDisputeAccept +>; +const mockDoAccept = jest.fn(); + +describe( 'DisputeDetails', () => { + beforeEach( () => { + jest.clearAllMocks(); + + mockUseDisputeAccept.mockReset(); + mockUseDisputeAccept.mockReturnValue( { + doAccept: mockDoAccept, + isLoading: false, + } ); + } ); + + test( 'correctly renders dispute details', () => { + render( ); + + screen.getByText( + /The cardholder claims this is an unauthorized transaction/, + { ignore: '.a11y-speak-region' } + ); + screen.getByRole( 'button', { + name: /Challenge dispute/, + } ); + screen.getByRole( 'button', { + name: /Accept dispute/, + } ); + } ); + + test( 'correctly renders the accept dispute modal and accepts', () => { + render( ); + + const openModalButton = screen.getByRole( 'button', { + name: /Accept dispute/, + } ); + + // Open the modal + openModalButton.click(); + + screen.getByText( /Accept the dispute?/ ); + screen.getByRole( 'button', { + name: /Cancel/, + } ); + const acceptButton = screen.getByRole( 'button', { + name: /Accept dispute/, + } ); + + // Accept the dispute + acceptButton.click(); + + expect( mockDoAccept ).toHaveBeenCalledTimes( 1 ); + } ); +} ); From 62cd852ec65c6276b112d49004b9b40b33c69842 Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 31 Aug 2023 15:07:11 +1000 Subject: [PATCH 19/49] Add test for clicking challenge button --- .../dispute-details/test/index.test.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/client/payment-details/dispute-details/test/index.test.tsx b/client/payment-details/dispute-details/test/index.test.tsx index 2c725b31148..4e667bdd111 100644 --- a/client/payment-details/dispute-details/test/index.test.tsx +++ b/client/payment-details/dispute-details/test/index.test.tsx @@ -127,6 +127,14 @@ const mockUseDisputeAccept = useDisputeAccept as jest.MockedFunction< >; const mockDoAccept = jest.fn(); +// mock the history push function +const mockHistoryPush = jest.fn(); +jest.mock( '@woocommerce/navigation', () => ( { + getHistory: () => ( { + push: mockHistoryPush, + } ), +} ) ); + describe( 'DisputeDetails', () => { beforeEach( () => { jest.clearAllMocks(); @@ -176,4 +184,18 @@ describe( 'DisputeDetails', () => { expect( mockDoAccept ).toHaveBeenCalledTimes( 1 ); } ); + + test( 'correctly navigates to the challenge screen', () => { + render( ); + + const challengeButton = screen.getByRole( 'button', { + name: /Challenge dispute/, + } ); + challengeButton.click(); + + expect( mockHistoryPush ).toHaveBeenNthCalledWith( + 1, + expect.stringContaining( `challenge&id=${ charge.dispute.id }` ) + ); + } ); } ); From 8a349b64a5e0cfaa93a29bc4755ab7edb12cc720 Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 31 Aug 2023 15:10:23 +1000 Subject: [PATCH 20/49] Remove extraneous div --- client/payment-details/dispute-details/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/client/payment-details/dispute-details/index.tsx b/client/payment-details/dispute-details/index.tsx index e985f7a8492..f25a56811bc 100644 --- a/client/payment-details/dispute-details/index.tsx +++ b/client/payment-details/dispute-details/index.tsx @@ -35,7 +35,6 @@ const DisputeDetails: React.FC< DisputeDetailsProps > = ( { dispute } ) => { urgent={ countdownDays <= 2 } /> ) } -
From 3545ae9698b3022ec29cfef4e35d19f1049523d3 Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 31 Aug 2023 16:49:54 +1000 Subject: [PATCH 21/49] Add test for dispute with staged evidence --- .../dispute-details/test/index.test.tsx | 153 +++++++++++------- 1 file changed, 96 insertions(+), 57 deletions(-) diff --git a/client/payment-details/dispute-details/test/index.test.tsx b/client/payment-details/dispute-details/test/index.test.tsx index 4e667bdd111..134f6d0a6c0 100644 --- a/client/payment-details/dispute-details/test/index.test.tsx +++ b/client/payment-details/dispute-details/test/index.test.tsx @@ -9,6 +9,7 @@ import React from 'react'; * Internal dependencies */ import type { Dispute } from 'wcpay/types/disputes'; +import type { Charge } from 'wcpay/types/charges'; import { useDisputeAccept } from 'wcpay/data'; import DisputeDetails from '..'; @@ -52,68 +53,83 @@ global.wcpaySettings = { }, }; -const charge = { - id: 'ch_38jdHA39KKA', - /* Stripe data comes in seconds, instead of the default Date milliseconds */ - created: Date.parse( 'Sep 19, 2019, 5:24 pm' ) / 1000, - amount: 2000, - amount_refunded: 0, - application_fee_amount: 70, - disputed: false, - dispute: { - id: 'dp_1', - amount: 6800, - charge: 'ch_38jdHA39KKA', - order: null, - balance_transactions: [ - { - amount: -2000, - currency: 'usd', - fee: 1500, - reporting_category: 'dispute', +interface ChargeWithDisputeRequired extends Charge { + dispute: Dispute; +} + +const getBaseCharge = (): ChargeWithDisputeRequired => + ( { + id: 'ch_38jdHA39KKA', + /* Stripe data comes in seconds, instead of the default Date milliseconds */ + created: Date.parse( 'Sep 19, 2019, 5:24 pm' ) / 1000, + amount: 2000, + amount_refunded: 0, + application_fee_amount: 70, + disputed: true, + dispute: { + id: 'dp_1', + amount: 6800, + charge: 'ch_38jdHA39KKA', + order: null, + balance_transactions: [ + { + amount: -2000, + currency: 'usd', + fee: 1500, + reporting_category: 'dispute', + }, + ], + created: 1693453017, + currency: 'usd', + evidence: { + billing_address: '123 test address', + customer_email_address: 'test@email.com', + customer_name: 'Test customer', + shipping_address: '123 test address', }, - ], - created: 1693453017, + evidence_details: { + due_by: 1694303999, + has_evidence: false, + past_due: false, + submission_count: 0, + }, + // issuer_evidence: null, + metadata: [], + payment_intent: 'pi_1', + reason: 'fraudulent', + status: 'needs_response', + } as Dispute, currency: 'usd', - evidence: { - billing_address: '123 test address', - customer_email_address: 'test@email.com', - customer_name: 'Test customer', - shipping_address: '123 test address', + type: 'charge', + status: 'succeeded', + paid: true, + captured: true, + balance_transaction: { + amount: 2000, + currency: 'usd', + fee: 70, }, - evidence_details: { - due_by: 1694303999, - has_evidence: false, - past_due: false, - submission_count: 0, + refunds: { + data: [], }, - // issuer_evidence: null, - metadata: [], - payment_intent: 'pi_1', - reason: 'fraudulent', - status: 'needs_response', - } as Dispute, - currency: 'usd', - type: 'charge', - status: 'succeeded', - paid: true, - captured: true, - balance_transaction: { - amount: 2000, - currency: 'usd', - fee: 70, - }, - refunds: { - data: [], - }, - payment_method_details: { - card: { - brand: 'visa', - last4: '4242', + order: { + number: 45981, + url: 'https://somerandomorderurl.com/?edit_order=45981', }, - type: 'card', - }, -}; + billing_details: { + name: 'Customer name', + }, + payment_method_details: { + card: { + brand: 'visa', + last4: '4242', + }, + type: 'card', + }, + outcome: { + risk_level: 'normal', + }, + } as any ); // mock the useDisputeAccept hook jest.mock( 'wcpay/data', () => ( { @@ -147,6 +163,7 @@ describe( 'DisputeDetails', () => { } ); test( 'correctly renders dispute details', () => { + const charge = getBaseCharge(); render( ); screen.getByText( @@ -161,7 +178,28 @@ describe( 'DisputeDetails', () => { } ); } ); + test( 'correctly renders dispute details for a dispute with staged evidence', () => { + const charge = getBaseCharge(); + charge.dispute.evidence_details = { + has_evidence: true, + due_by: 1694303999, + past_due: false, + submission_count: 0, + }; + + render( ); + + screen.getByText( + /The cardholder claims this is an unauthorized transaction/, + { ignore: '.a11y-speak-region' } + ); + screen.getByRole( 'button', { + name: /Continue with challenge/, + } ); + } ); + test( 'correctly renders the accept dispute modal and accepts', () => { + const charge = getBaseCharge(); render( ); const openModalButton = screen.getByRole( 'button', { @@ -186,6 +224,7 @@ describe( 'DisputeDetails', () => { } ); test( 'correctly navigates to the challenge screen', () => { + const charge = getBaseCharge(); render( ); const challengeButton = screen.getByRole( 'button', { From 2fb3cfb70f1599067e600cb12fb5e0c1423a9bc9 Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 31 Aug 2023 16:56:30 +1000 Subject: [PATCH 22/49] Add dispute action tracks events --- .../dispute-details/dispute-actions.tsx | 23 +++++++------------ client/tracks/index.js | 3 +++ 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/client/payment-details/dispute-details/dispute-actions.tsx b/client/payment-details/dispute-details/dispute-actions.tsx index 07b197699f3..58b8496755b 100644 --- a/client/payment-details/dispute-details/dispute-actions.tsx +++ b/client/payment-details/dispute-details/dispute-actions.tsx @@ -14,6 +14,7 @@ import { getHistory } from '@woocommerce/navigation'; * Internal dependencies */ import type { Dispute } from 'wcpay/types/disputes'; +import wcpayTracks from 'tracks'; import { getAdminUrl } from 'wcpay/utils'; import { useDisputeAccept } from 'wcpay/data'; import { getDisputeFee } from 'wcpay/disputes/utils'; @@ -34,11 +35,7 @@ const DisputeActions: React.FC< Props > = ( { dispute } ) => { }; const onSubmit = () => { - // TODO: Tracks event - // wcpayTracks.recordEvent( - // 'wcpay_dispute_challenge_clicked', - // {} - // ); + wcpayTracks.recordEvent( wcpayTracks.events.DISPUTE_ACCEPT_CLICK ); setModalOpen( false ); doAccept(); }; @@ -49,11 +46,9 @@ const DisputeActions: React.FC< Props > = ( { dispute } ) => { variant="primary" disabled={ isLoading } onClick={ () => { - // TODO: Tracks event - // wcpayTracks.recordEvent( - // 'wcpay_dispute_challenge_clicked', - // {} - // ); + wcpayTracks.recordEvent( + wcpayTracks.events.DISPUTE_CHALLENGE_CLICK + ); const challengeUrl = getAdminUrl( { page: 'wc-admin', path: '/payments/disputes/challenge', @@ -71,11 +66,9 @@ const DisputeActions: React.FC< Props > = ( { dispute } ) => { variant="tertiary" disabled={ isLoading } onClick={ () => { - // TODO: Tracks event - // wcpayTracks.recordEvent( - // 'wcpay_dispute_challenge_clicked', - // {} - // ); + wcpayTracks.recordEvent( + wcpayTracks.events.DISPUTE_ACCEPT_MODAL_VIEW + ); setModalOpen( true ); } } > diff --git a/client/tracks/index.js b/client/tracks/index.js index 3c750ee2784..dc0bd1f51ea 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: From c355a77893bdab816796a073e82659f014f683ad Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 31 Aug 2023 17:19:24 +1000 Subject: [PATCH 23/49] Add missing full-stop to accept dispute modal text --- client/payment-details/dispute-details/dispute-actions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/payment-details/dispute-details/dispute-actions.tsx b/client/payment-details/dispute-details/dispute-actions.tsx index 58b8496755b..2bf89b4d336 100644 --- a/client/payment-details/dispute-details/dispute-actions.tsx +++ b/client/payment-details/dispute-details/dispute-actions.tsx @@ -98,7 +98,7 @@ const DisputeActions: React.FC< Props > = ( { dispute } ) => { 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', + '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 && From 2f59597439397d9a3631ee59f0d0f5107ff56922 Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 31 Aug 2023 19:20:03 +1000 Subject: [PATCH 24/49] Add currency rendering test for accept dispute modal --- .../dispute-details/test/index.test.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/client/payment-details/dispute-details/test/index.test.tsx b/client/payment-details/dispute-details/test/index.test.tsx index 134f6d0a6c0..f8c8be47f4e 100644 --- a/client/payment-details/dispute-details/test/index.test.tsx +++ b/client/payment-details/dispute-details/test/index.test.tsx @@ -200,6 +200,8 @@ describe( 'DisputeDetails', () => { test( 'correctly renders the accept dispute modal and accepts', () => { const charge = getBaseCharge(); + charge.dispute.status = 'needs_response'; + render( ); const openModalButton = screen.getByRole( 'button', { @@ -209,7 +211,13 @@ describe( 'DisputeDetails', () => { // Open the modal openModalButton.click(); - screen.getByText( /Accept the dispute?/ ); + screen.getByRole( 'heading', { + name: /Accept the dispute?/, + } ); + screen.getByText( /\$15.00 dispute fee/, { + ignore: '.a11y-speak-region', + } ); + screen.getByRole( 'button', { name: /Cancel/, } ); From 0d0616d25c4c8272a5f8903de1cbce6bc0678c79 Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 31 Aug 2023 19:22:27 +1000 Subject: [PATCH 25/49] Add test for Inquiry accept dispute modal --- .../dispute-details/test/index.test.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/client/payment-details/dispute-details/test/index.test.tsx b/client/payment-details/dispute-details/test/index.test.tsx index f8c8be47f4e..480b2a46549 100644 --- a/client/payment-details/dispute-details/test/index.test.tsx +++ b/client/payment-details/dispute-details/test/index.test.tsx @@ -198,6 +198,42 @@ describe( 'DisputeDetails', () => { } ); } ); + test( 'correctly renders the accept dispute (inquiry) modal and accepts', () => { + const charge = getBaseCharge(); + charge.dispute.status = 'warning_needs_response'; + // Inquiry disputes are not expected to have balance transactions + // as the dispute fee has not been charged yet. + charge.dispute.balance_transactions = []; + + render( ); + + 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( mockDoAccept ).toHaveBeenCalledTimes( 1 ); + } ); + test( 'correctly renders the accept dispute modal and accepts', () => { const charge = getBaseCharge(); charge.dispute.status = 'needs_response'; From 9091394d078c007a0c537606a2a2ef80c010cbf3 Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 7 Sep 2023 15:24:44 +1000 Subject: [PATCH 26/49] Fix formatting after merge conflict resolution --- client/payment-details/dispute-details/index.tsx | 3 +-- client/payment-details/dispute-details/test/index.test.tsx | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/client/payment-details/dispute-details/index.tsx b/client/payment-details/dispute-details/index.tsx index 0a2ae661637..8af760449d7 100644 --- a/client/payment-details/dispute-details/index.tsx +++ b/client/payment-details/dispute-details/index.tsx @@ -29,14 +29,13 @@ const DisputeDetails: React.FC< DisputeDetailsProps > = ( { dispute } ) => { const countdownDays = Math.floor( dueBy.diff( now, 'days', true ) ); const hasStagedEvidence = dispute.evidence_details?.has_evidence; - if ( isAwaitingResponse( dispute.status ) ) { return (
{ countdownDays >= 0 && ( - <> + <> { /The cardholder claims this is an unauthorized transaction/, { ignore: '.a11y-speak-region' } ); - - // Render the staged evidence message + // Render the staged evidence message screen.getByText( /You initiated a dispute a challenge to this dispute/, { ignore: '.a11y-speak-region' } From 1165d6743182d322c47e3721378f3184acf95208 Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Mon, 11 Sep 2023 16:22:23 +1000 Subject: [PATCH 27/49] Fix duplicate import after merge conflict resolution --- client/payment-details/dispute-details/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/payment-details/dispute-details/index.tsx b/client/payment-details/dispute-details/index.tsx index 92a3a339ac9..54ea85adaf1 100644 --- a/client/payment-details/dispute-details/index.tsx +++ b/client/payment-details/dispute-details/index.tsx @@ -13,10 +13,9 @@ import { edit } from '@wordpress/icons'; * Internal dependencies */ import type { Dispute } from 'wcpay/types/disputes'; -import DisputeNotice from './dispute-notice'; -import DisputeActions from './dispute-actions'; import { isAwaitingResponse } from 'wcpay/disputes/utils'; import DisputeNotice from './dispute-notice'; +import DisputeActions from './dispute-actions'; import DisputeSummaryRow from './dispute-summary-row'; import InlineNotice from 'components/inline-notice'; import './style.scss'; From 47a932407e883d27b173311ed670010d721e07d8 Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 14 Sep 2023 12:42:32 +1000 Subject: [PATCH 28/49] Don't render dispute actions for inquiries --- .../payment-details/dispute-details/index.tsx | 6 ++- .../dispute-details/test/index.test.tsx | 38 +------------------ 2 files changed, 5 insertions(+), 39 deletions(-) diff --git a/client/payment-details/dispute-details/index.tsx b/client/payment-details/dispute-details/index.tsx index 54ea85adaf1..0d62ba0d797 100644 --- a/client/payment-details/dispute-details/index.tsx +++ b/client/payment-details/dispute-details/index.tsx @@ -13,7 +13,7 @@ import { edit } from '@wordpress/icons'; * Internal dependencies */ import type { Dispute } from 'wcpay/types/disputes'; -import { isAwaitingResponse } from 'wcpay/disputes/utils'; +import { isAwaitingResponse, isInquiry } from 'wcpay/disputes/utils'; import DisputeNotice from './dispute-notice'; import DisputeActions from './dispute-actions'; import DisputeSummaryRow from './dispute-summary-row'; @@ -58,7 +58,9 @@ const DisputeDetails: React.FC< DisputeDetailsProps > = ( { dispute } ) => { /> ) } - + { ! isInquiry( dispute ) && ( + + ) }
diff --git a/client/payment-details/dispute-details/test/index.test.tsx b/client/payment-details/dispute-details/test/index.test.tsx index bd28c0deee8..8b79f9eec24 100644 --- a/client/payment-details/dispute-details/test/index.test.tsx +++ b/client/payment-details/dispute-details/test/index.test.tsx @@ -244,42 +244,6 @@ describe( 'DisputeDetails', () => { } ); } ); - test( 'correctly renders the accept dispute (inquiry) modal and accepts', () => { - const charge = getBaseCharge(); - charge.dispute.status = 'warning_needs_response'; - // Inquiry disputes are not expected to have balance transactions - // as the dispute fee has not been charged yet. - charge.dispute.balance_transactions = []; - - render( ); - - 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( mockDoAccept ).toHaveBeenCalledTimes( 1 ); - } ); - test( 'correctly renders the accept dispute modal and accepts', () => { const charge = getBaseCharge(); charge.dispute.status = 'needs_response'; @@ -313,7 +277,7 @@ describe( 'DisputeDetails', () => { expect( mockDoAccept ).toHaveBeenCalledTimes( 1 ); } ); - test( 'correctly navigates to the challenge screen', () => { + test( 'correctly navigates to the challenge screen when challenge button clicked', () => { const charge = getBaseCharge(); render( ); From cae2394f432e034f116bbd551c64e87ec15ed7c8 Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 14 Sep 2023 12:47:32 +1000 Subject: [PATCH 29/49] Add more info to acceptTransactionDetailsDispute comment --- client/data/disputes/actions.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/client/data/disputes/actions.js b/client/data/disputes/actions.js index 77f75482f6a..7e099b440f3 100644 --- a/client/data/disputes/actions.js +++ b/client/data/disputes/actions.js @@ -91,8 +91,13 @@ export function* acceptDispute( id ) { } // This function handles the dispute acceptance flow from the Transaction Details screen. -// It will become the default acceptDispute function once the feature flag -// '_wcpay_feature_dispute_on_transaction_page' is enabled by default. +// 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; From db85448c8ba16df497070953a557066b39d081d7 Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 14 Sep 2023 12:50:58 +1000 Subject: [PATCH 30/49] =?UTF-8?q?Rename=20onSubmit=20=E2=86=92=20onAccept?= =?UTF-8?q?=20to=20name=20intent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/payment-details/dispute-details/dispute-actions.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/payment-details/dispute-details/dispute-actions.tsx b/client/payment-details/dispute-details/dispute-actions.tsx index 2bf89b4d336..fe000018397 100644 --- a/client/payment-details/dispute-details/dispute-actions.tsx +++ b/client/payment-details/dispute-details/dispute-actions.tsx @@ -34,7 +34,7 @@ const DisputeActions: React.FC< Props > = ( { dispute } ) => { setModalOpen( false ); }; - const onSubmit = () => { + const onAccept = () => { wcpayTracks.recordEvent( wcpayTracks.events.DISPUTE_ACCEPT_CLICK ); setModalOpen( false ); doAccept(); @@ -132,7 +132,7 @@ const DisputeActions: React.FC< Props > = ( { dispute } ) => { -
From 81738f0aa478c0834c0548687b8703a9be9079b7 Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 14 Sep 2023 12:53:01 +1000 Subject: [PATCH 31/49] Add dispute_status prop to tracks events --- .../dispute-details/dispute-actions.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/client/payment-details/dispute-details/dispute-actions.tsx b/client/payment-details/dispute-details/dispute-actions.tsx index fe000018397..cdfc7e95ee9 100644 --- a/client/payment-details/dispute-details/dispute-actions.tsx +++ b/client/payment-details/dispute-details/dispute-actions.tsx @@ -35,7 +35,9 @@ const DisputeActions: React.FC< Props > = ( { dispute } ) => { }; const onAccept = () => { - wcpayTracks.recordEvent( wcpayTracks.events.DISPUTE_ACCEPT_CLICK ); + wcpayTracks.recordEvent( wcpayTracks.events.DISPUTE_ACCEPT_CLICK, { + dispute_status: dispute.status, + } ); setModalOpen( false ); doAccept(); }; @@ -47,7 +49,10 @@ const DisputeActions: React.FC< Props > = ( { dispute } ) => { disabled={ isLoading } onClick={ () => { wcpayTracks.recordEvent( - wcpayTracks.events.DISPUTE_CHALLENGE_CLICK + wcpayTracks.events.DISPUTE_CHALLENGE_CLICK, + { + dispute_status: dispute.status, + } ); const challengeUrl = getAdminUrl( { page: 'wc-admin', @@ -67,7 +72,10 @@ const DisputeActions: React.FC< Props > = ( { dispute } ) => { disabled={ isLoading } onClick={ () => { wcpayTracks.recordEvent( - wcpayTracks.events.DISPUTE_ACCEPT_MODAL_VIEW + wcpayTracks.events.DISPUTE_ACCEPT_MODAL_VIEW, + { + dispute_status: dispute.status, + } ); setModalOpen( true ); } } From c072af51870e9b3527c38651809e255ab76fda3d Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 14 Sep 2023 13:50:16 +1000 Subject: [PATCH 32/49] Update dispute hook comments --- client/data/disputes/hooks.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/client/data/disputes/hooks.ts b/client/data/disputes/hooks.ts index 2a26616d127..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,10 @@ 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 ): { From 85490fc95cfc2ca0f413af1998b995f54748cc3f Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 14 Sep 2023 14:26:26 +1000 Subject: [PATCH 33/49] Cleanup tests and remove flaky test --- .../dispute-details/test/index.test.tsx | 39 +------------------ 1 file changed, 2 insertions(+), 37 deletions(-) diff --git a/client/payment-details/dispute-details/test/index.test.tsx b/client/payment-details/dispute-details/test/index.test.tsx index 8b79f9eec24..ca2dec78663 100644 --- a/client/payment-details/dispute-details/test/index.test.tsx +++ b/client/payment-details/dispute-details/test/index.test.tsx @@ -10,7 +10,6 @@ import React from 'react'; */ import type { Dispute } from 'wcpay/types/disputes'; import type { Charge } from 'wcpay/types/charges'; -import { useDisputeAccept } from 'wcpay/data'; import DisputeDetails from '..'; declare const global: { @@ -132,24 +131,13 @@ const getBaseCharge = (): ChargeWithDisputeRequired => } as any ); // mock the useDisputeAccept hook +const mockDoAccept = jest.fn(); jest.mock( 'wcpay/data', () => ( { useDisputeAccept: jest.fn( () => ( { - doAccept: jest.fn(), + doAccept: mockDoAccept, isLoading: false, } ) ), } ) ); -const mockUseDisputeAccept = useDisputeAccept as jest.MockedFunction< - typeof useDisputeAccept ->; -const mockDoAccept = jest.fn(); - -// mock the history push function -const mockHistoryPush = jest.fn(); -jest.mock( '@woocommerce/navigation', () => ( { - getHistory: () => ( { - push: mockHistoryPush, - } ), -} ) ); describe( 'DisputeDetails', () => { beforeEach( () => { @@ -157,14 +145,6 @@ describe( 'DisputeDetails', () => { Date.now = jest.fn( () => new Date( '2023-09-08T12:33:37.000Z' ).getTime() ); - - jest.clearAllMocks(); - - mockUseDisputeAccept.mockReset(); - mockUseDisputeAccept.mockReturnValue( { - doAccept: mockDoAccept, - isLoading: false, - } ); } ); afterEach( () => { @@ -276,19 +256,4 @@ describe( 'DisputeDetails', () => { expect( mockDoAccept ).toHaveBeenCalledTimes( 1 ); } ); - - test( 'correctly navigates to the challenge screen when challenge button clicked', () => { - const charge = getBaseCharge(); - render( ); - - const challengeButton = screen.getByRole( 'button', { - name: /Challenge dispute/, - } ); - challengeButton.click(); - - expect( mockHistoryPush ).toHaveBeenNthCalledWith( - 1, - expect.stringContaining( `challenge&id=${ charge.dispute.id }` ) - ); - } ); } ); From cfb37094db206e0640c89f8866064e7f6e7fd4e1 Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Thu, 14 Sep 2023 14:29:16 +1000 Subject: [PATCH 34/49] Move challenge click handler to func improve code clarity --- .../dispute-details/dispute-actions.tsx | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/client/payment-details/dispute-details/dispute-actions.tsx b/client/payment-details/dispute-details/dispute-actions.tsx index cdfc7e95ee9..1cb8157fb86 100644 --- a/client/payment-details/dispute-details/dispute-actions.tsx +++ b/client/payment-details/dispute-details/dispute-actions.tsx @@ -42,25 +42,24 @@ const DisputeActions: React.FC< Props > = ( { dispute } ) => { doAccept(); }; + const onChallenge = () => { + wcpayTracks.recordEvent( wcpayTracks.events.DISPUTE_CHALLENGE_CLICK, { + dispute_status: dispute.status, + } ); + const challengeUrl = getAdminUrl( { + page: 'wc-admin', + path: '/payments/disputes/challenge', + id: dispute.id, + } ); + getHistory().push( challengeUrl ); + }; + return ( + + + + + ); +}; + +const DisputeWonFooter: React.FC< { + dispute: Dispute; +} > = ( { dispute } ) => { + const closedDateFormatted = dispute?.metadata.__dispute_closed_at + ? dateI18n( + 'M j, Y', + moment + .unix( + parseInt( dispute.metadata.__dispute_closed_at, 10 ) + ) + .toISOString() + ) + : '-'; + + return ( + + + + { createInterpolateElement( + sprintf( + /* Translators: %s - formatted date, - link to documentation page */ + __( + 'Good news! You won this dispute on %s. The disputed amount and the dispute fee have been credited back to your account. Learn more about preventing disputes.', + 'woocommerce-payments' + ), + closedDateFormatted + ), + { + a: ( + // eslint-disable-next-line jsx-a11y/anchor-has-content -- Link content is provided by createInterpolateElement + + ), + } + ) } + + + + + + +
+ + ); +}; + +const DisputeLostFooter: React.FC< { + dispute: Dispute; +} > = ( { dispute } ) => { + const isSubmitted = !! dispute?.metadata.__evidence_submitted_at; + const isAccepted = dispute?.metadata.__closed_by_merchant === '1'; + const disputeFeeFormatted = getDisputeFeeFormatted( dispute ) ?? '-'; + + const closedDateFormatted = dispute?.metadata.__dispute_closed_at + ? dateI18n( + 'M j, Y', + moment + .unix( + parseInt( dispute.metadata.__dispute_closed_at, 10 ) + ) + .toISOString() + ) + : '-'; + + let messagePrefix = sprintf( + /* Translators: %1$s - formatted date */ + __( + 'This dispute was lost on %1$s due to non-response.', + 'woocommerce-payments' + ), + closedDateFormatted + ); + + if ( isAccepted ) { + messagePrefix = sprintf( + /* Translators: %1$s - formatted date */ + __( + 'This dispute was accepted and lost on %1$s.', + 'woocommerce-payments' + ), + closedDateFormatted + ); + } + + if ( isSubmitted ) { + messagePrefix = sprintf( + /* Translators: %1$s - formatted date */ + __( 'This dispute was lost on %1$s.', 'woocommerce-payments' ), + closedDateFormatted + ); + } + + return ( + + + + { messagePrefix }{ ' ' } + { createInterpolateElement( + sprintf( + /* Translators: %1$s – the formatted dispute fee amount, - link to documentation page */ + __( + 'The %1$s fee has been deducted from your account, and the disputed amount returned to the cardholder. Learn more about preventing disputes.', + 'woocommerce-payments' + ), + disputeFeeFormatted + ), + { + a: ( + // eslint-disable-next-line jsx-a11y/anchor-has-content -- Link content is provided by createInterpolateElement + + ), + } + ) } + + + { isSubmitted && ( + + + + + + ) } + + + ); +}; + +const DisputeResolutionFooter: React.FC< { + dispute: Dispute; +} > = ( { dispute } ) => { + if ( dispute.status === 'under_review' ) { + return ; + } + if ( dispute.status === 'won' ) { + return ; + } + if ( dispute.status === 'lost' ) { + return ; + } + + return null; +}; + +export default DisputeResolutionFooter; diff --git a/client/payment-details/dispute-details/style.scss b/client/payment-details/dispute-details/style.scss index 3cf8b06d9bb..5346591176e 100644 --- a/client/payment-details/dispute-details/style.scss +++ b/client/payment-details/dispute-details/style.scss @@ -67,6 +67,29 @@ } } +.transaction-details-dispute-footer { + background-color: #f2f4f5; + + &__actions { + flex-shrink: 0; + } + + &--primary { + background-color: $wp-blue-0; + } + + @media screen and ( max-width: $break-small ) { + .components-flex { + flex-direction: column; + align-items: flex-start; + } + + .components-flex-item { + margin: 10px 0; + } + } +} + .dispute-evidence { // Override WordPress core PanelBody boxy styles. Ours is more inline content. &.components-panel__body { diff --git a/client/payment-details/dispute-details/test/index.test.tsx b/client/payment-details/dispute-details/test/index.test.tsx deleted file mode 100644 index b6bf07b4630..00000000000 --- a/client/payment-details/dispute-details/test/index.test.tsx +++ /dev/null @@ -1,259 +0,0 @@ -/** @format */ -/** - * External dependencies - */ -import { render, screen } from '@testing-library/react'; -import React from 'react'; - -/** - * Internal dependencies - */ -import type { Dispute } from 'wcpay/types/disputes'; -import type { Charge } from 'wcpay/types/charges'; -import DisputeDetails from '..'; - -declare const global: { - wcSettings: { - locale: { - siteLocale: string; - }; - }; - wcpaySettings: { - isSubscriptionsActive: boolean; - zeroDecimalCurrencies: string[]; - currencyData: Record< string, any >; - connect: { - country: string; - }; - featureFlags: { - isAuthAndCaptureEnabled: boolean; - }; - }; -}; - -global.wcpaySettings = { - isSubscriptionsActive: false, - zeroDecimalCurrencies: [], - connect: { - country: 'US', - }, - featureFlags: { - isAuthAndCaptureEnabled: true, - }, - currencyData: { - US: { - code: 'USD', - symbol: '$', - symbolPosition: 'left', - thousandSeparator: ',', - decimalSeparator: '.', - precision: 2, - }, - }, -}; - -interface ChargeWithDisputeRequired extends Charge { - dispute: Dispute; -} - -const getBaseCharge = (): ChargeWithDisputeRequired => - ( { - id: 'ch_38jdHA39KKA', - /* Stripe data comes in seconds, instead of the default Date milliseconds */ - created: Date.parse( 'Sep 19, 2019, 5:24 pm' ) / 1000, - amount: 2000, - amount_refunded: 0, - application_fee_amount: 70, - disputed: true, - dispute: { - id: 'dp_1', - amount: 6800, - charge: 'ch_38jdHA39KKA', - order: null, - balance_transactions: [ - { - amount: -2000, - currency: 'usd', - fee: 1500, - reporting_category: 'dispute', - }, - ], - created: 1693453017, - currency: 'usd', - evidence: { - billing_address: '123 test address', - customer_email_address: 'test@email.com', - customer_name: 'Test customer', - shipping_address: '123 test address', - }, - issuer_evidence: null, - evidence_details: { - due_by: 1694303999, - has_evidence: false, - past_due: false, - submission_count: 0, - }, - // issuer_evidence: null, - metadata: [], - payment_intent: 'pi_1', - reason: 'fraudulent', - status: 'needs_response', - } as Dispute, - currency: 'usd', - type: 'charge', - status: 'succeeded', - paid: true, - captured: true, - balance_transaction: { - amount: 2000, - currency: 'usd', - fee: 70, - }, - refunds: { - data: [], - }, - order: { - number: 45981, - url: 'https://somerandomorderurl.com/?edit_order=45981', - }, - billing_details: { - name: 'Customer name', - }, - payment_method_details: { - card: { - brand: 'visa', - last4: '4242', - }, - type: 'card', - }, - outcome: { - risk_level: 'normal', - }, - } as any ); - -// mock the useDisputeAccept hook -const mockDoAccept = jest.fn(); -jest.mock( 'wcpay/data', () => ( { - useDisputeAccept: jest.fn( () => ( { - doAccept: mockDoAccept, - isLoading: false, - } ) ), -} ) ); - -describe( 'DisputeDetails', () => { - beforeEach( () => { - // mock Date.now that moment library uses to get current date for testing purposes - Date.now = jest.fn( () => - new Date( '2023-09-08T12:33:37.000Z' ).getTime() - ); - } ); - - afterEach( () => { - // roll it back - Date.now = () => new Date().getTime(); - } ); - - test( 'correctly renders dispute details', () => { - const charge = getBaseCharge(); - render( ); - - // Expect this warning to be logged to the console - expect( console ).toHaveWarnedWith( - 'List with items prop is deprecated is deprecated and will be removed in version 9.0.0. Note: See ExperimentalList / ExperimentalListItem for the new API that will replace this component in future versions.' - ); - - // Dispute Notice - screen.getByText( - /The cardholder claims this is an unauthorized transaction/, - { ignore: '.a11y-speak-region' } - ); - - screen.getByRole( 'button', { - name: /Challenge dispute/, - } ); - screen.getByRole( 'button', { - name: /Accept dispute/, - } ); - - // Don't render the staged evidence message - expect( - screen.queryByText( - /You initiated a dispute a challenge to this dispute/, - { ignore: '.a11y-speak-region' } - ) - ).toBeNull(); - - // Dispute Summary Row - expect( - screen.getByText( /Dispute Amount/i ).nextSibling - ).toHaveTextContent( /\$68.00/ ); - expect( - screen.getByText( /Disputed On/i ).nextSibling - ).toHaveTextContent( /Aug 30, 2023/ ); - expect( screen.getByText( /Reason/i ).nextSibling ).toHaveTextContent( - /Transaction unauthorized/ - ); - expect( - screen.getByText( /Respond By/i ).nextSibling - ).toHaveTextContent( /Sep 9, 2023/ ); - } ); - - test( 'correctly renders dispute details for a dispute with staged evidence', () => { - const charge = getBaseCharge(); - charge.dispute.evidence_details = { - has_evidence: true, - due_by: 1694303999, - past_due: false, - submission_count: 0, - }; - - render( ); - - screen.getByText( - /The cardholder claims this is an unauthorized transaction/, - { ignore: '.a11y-speak-region' } - ); - - // Render the staged evidence message - 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.dispute.status = 'needs_response'; - - render( ); - - 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( mockDoAccept ).toHaveBeenCalledTimes( 1 ); - } ); -} ); diff --git a/client/payment-details/summary/index.tsx b/client/payment-details/summary/index.tsx index e75f69f2caf..591a1987f6a 100644 --- a/client/payment-details/summary/index.tsx +++ b/client/payment-details/summary/index.tsx @@ -37,7 +37,9 @@ import WCPaySettingsContext from '../../settings/wcpay-settings-context'; import { FraudOutcome } from '../../types/fraud-outcome'; import CancelAuthorizationButton from '../../components/cancel-authorization-button'; import { PaymentIntent } from '../../types/payment-intents'; -import DisputeDetails from '../dispute-details'; +import DisputeAwaitingResponseDetails from '../dispute-details/dispute-awaiting-response-details'; +import DisputeResolutionFooter from '../dispute-details/dispute-resolution-footer'; +import { isAwaitingResponse } from 'wcpay/disputes/utils'; declare const window: any; @@ -375,9 +377,19 @@ const PaymentDetailsSummary: React.FC< PaymentDetailsSummaryProps > = ( { /> + { isDisputeOnTransactionPageEnabled && charge.dispute && ( - + <> + { isAwaitingResponse( charge.dispute.status ) ? ( + + ) : ( + + ) } + ) } + { isAuthAndCaptureEnabled && authorization && ! authorization.captured && ( diff --git a/client/payment-details/summary/test/__snapshots__/index.tsx.snap b/client/payment-details/summary/test/__snapshots__/index.tsx.snap index 9dd86ba7c81..e93b1417903 100644 --- a/client/payment-details/summary/test/__snapshots__/index.tsx.snap +++ b/client/payment-details/summary/test/__snapshots__/index.tsx.snap @@ -265,7 +265,7 @@ exports[`PaymentDetailsSummary capture notification and fraud buttons renders ca this charge within the next 7 days @@ -582,7 +582,7 @@ exports[`PaymentDetailsSummary capture notification and fraud buttons renders th this charge within the next 7 days diff --git a/client/payment-details/summary/test/index.tsx b/client/payment-details/summary/test/index.tsx index 79c4eb47c7c..24fbde20ed7 100755 --- a/client/payment-details/summary/test/index.tsx +++ b/client/payment-details/summary/test/index.tsx @@ -6,11 +6,13 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import moment from 'moment'; import '@wordpress/jest-console'; + /** * Internal dependencies */ +import type { Charge } from 'wcpay/types/charges'; +import type { Dispute } from 'wcpay/types/disputes'; import PaymentDetailsSummary from '../'; -import { Charge } from 'wcpay/types/charges'; import { useAuthorization } from 'wcpay/data'; import { paymentIntentMock } from 'wcpay/data/payment-intents/test/hooks'; @@ -28,15 +30,22 @@ declare const global: { country: string; }; featureFlags: { + isDisputeOnTransactionPageEnabled: boolean; isAuthAndCaptureEnabled: boolean; }; }; }; +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< @@ -85,6 +94,41 @@ const getBaseCharge = (): Charge => }, } as any ); +const getBaseDispute = (): Dispute => + ( { + id: 'dp_1', + amount: 2000, + charge: 'ch_38jdHA39KKA', + order: null, + balance_transactions: [ + { + amount: -1500, + currency: 'usd', + fee: 1500, + reporting_category: 'dispute', + }, + ], + created: 1693453017, + currency: 'usd', + evidence: { + billing_address: '123 test address', + customer_email_address: 'test@email.com', + customer_name: 'Test customer', + shipping_address: '123 test address', + }, + evidence_details: { + due_by: 1694303999, + has_evidence: false, + past_due: false, + submission_count: 0, + }, + issuer_evidence: null, + metadata: {}, + payment_intent: 'pi_1', + reason: 'fraudulent', + status: 'needs_response', + } as Dispute ); + const getBaseMetadata = () => ( { platform: 'ios', reader_id: 'APPLEBUILTINSIMULATOR-1', @@ -120,6 +164,7 @@ describe( 'PaymentDetailsSummary', () => { }, featureFlags: { isAuthAndCaptureEnabled: true, + isDisputeOnTransactionPageEnabled: false, }, currencyData: { US: { @@ -132,6 +177,15 @@ describe( 'PaymentDetailsSummary', () => { }, }, }; + + // mock Date.now that moment library uses to get current date for testing purposes + Date.now = jest.fn( () => + new Date( '2023-09-08T12:33:37.000Z' ).getTime() + ); + } ); + + afterEach( () => { + Date.now = () => new Date().getTime(); } ); test( 'correctly renders a charge', () => { @@ -174,16 +228,8 @@ describe( 'PaymentDetailsSummary', () => { test( 'renders the information of a disputed charge', () => { const charge = getBaseCharge(); charge.disputed = true; - charge.dispute = { - amount: 1500, - status: 'under_review', - balance_transactions: [ - { - amount: -1500, - fee: 1500, - } as any, - ], - } as any; + charge.dispute = getBaseDispute(); + charge.dispute.status = 'under_review'; expect( renderCharge( charge ) ).toMatchSnapshot(); } ); @@ -308,4 +354,191 @@ describe( 'PaymentDetailsSummary', () => { expect( container ).toMatchSnapshot(); } ); } ); + + describe( 'with feature flag isDisputeOnTransactionPageEnabled', () => { + beforeEach( () => { + global.wcpaySettings.featureFlags.isDisputeOnTransactionPageEnabled = true; + } ); + + afterEach( () => { + global.wcpaySettings.featureFlags.isDisputeOnTransactionPageEnabled = false; + } ); + + test( 'renders the information of a disputed charge', () => { + const charge = getBaseCharge(); + charge.disputed = true; + charge.dispute = getBaseDispute(); + charge.dispute.status = 'needs_response'; + + renderCharge( charge ); + + // Dispute Notice + screen.getByText( + /The cardholder claims this is an unauthorized transaction/, + { ignore: '.a11y-speak-region' } + ); + + // Don't render the staged evidence message + expect( + screen.queryByText( + /You initiated a challenge to this dispute/, + { ignore: '.a11y-speak-region' } + ) + ).toBeNull(); + + // Dispute Summary Row + expect( + screen.getByText( /Dispute Amount/i ).nextSibling + ).toHaveTextContent( /\$20.00/ ); + expect( + screen.getByText( /Disputed On/i ).nextSibling + ).toHaveTextContent( /Aug 30, 2023/ ); + expect( + screen.getByText( /Reason/i ).nextSibling + ).toHaveTextContent( /Transaction unauthorized/ ); + 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', () => { + const charge = getBaseCharge(); + charge.disputed = true; + charge.dispute = getBaseDispute(); + charge.dispute.status = 'needs_response'; + charge.dispute.evidence_details = { + has_evidence: true, + due_by: 1694303999, + past_due: false, + submission_count: 0, + }; + + renderCharge( charge ); + + screen.getByText( + /The cardholder claims this is an unauthorized transaction/, + { ignore: '.a11y-speak-region' } + ); + + // Render the staged evidence message + 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( 'correctly renders dispute details for "won" disputes', () => { + const charge = getBaseCharge(); + charge.disputed = true; + charge.dispute = getBaseDispute(); + charge.dispute.status = 'won'; + charge.dispute.metadata.__evidence_submitted_at = '1693400000'; + renderCharge( charge ); + + screen.getByText( /You won this dispute on/i, { + ignore: '.a11y-speak-region', + } ); + screen.getByRole( 'button', { name: /View dispute details/i } ); + } ); + + test( 'correctly renders dispute details for "under_review" disputes', () => { + const charge = getBaseCharge(); + charge.disputed = true; + charge.dispute = getBaseDispute(); + charge.dispute.status = 'under_review'; + charge.dispute.metadata.__evidence_submitted_at = '1693400000'; + + renderCharge( charge ); + + screen.getByText( /reviewing the case/i, { + ignore: '.a11y-speak-region', + } ); + screen.getByRole( 'button', { name: /View submitted evidence/i } ); + } ); + + test( 'correctly renders dispute details for "accepted" disputes', () => { + const charge = getBaseCharge(); + charge.disputed = true; + charge.dispute = getBaseDispute(); + charge.dispute.status = 'lost'; + charge.dispute.metadata.__closed_by_merchant = '1'; + charge.dispute.metadata.__dispute_closed_at = '1693453017'; + + renderCharge( charge ); + + screen.getByText( /This dispute was accepted/i, { + ignore: '.a11y-speak-region', + } ); + // Check for the correct fee amount + screen.getByText( /\$15.00 fee/i, { + ignore: '.a11y-speak-region', + } ); + } ); + + test( 'correctly renders dispute details for "lost" disputes', () => { + const charge = getBaseCharge(); + charge.disputed = true; + charge.dispute = getBaseDispute(); + charge.dispute.status = 'lost'; + charge.dispute.metadata.__evidence_submitted_at = '1693400000'; + charge.dispute.metadata.__dispute_closed_at = '1693453017'; + + renderCharge( charge ); + + screen.getByText( /This dispute was lost/i, { + ignore: '.a11y-speak-region', + } ); + // Check for the correct fee amount + screen.getByText( /\$15.00 fee/i, { + ignore: '.a11y-speak-region', + } ); + screen.getByRole( 'button', { name: /View dispute details/i } ); + } ); + } ); } ); diff --git a/client/tracks/index.js b/client/tracks/index.js index dc0bd1f51ea..4006c197e6e 100644 --- a/client/tracks/index.js +++ b/client/tracks/index.js @@ -80,6 +80,8 @@ const events = { OVERVIEW_DEPOSITS_CHANGE_SCHEDULE_CLICK: 'wcpay_overview_deposits_change_schedule_click', OVERVIEW_TASK_CLICK: 'wcpay_overview_task_click', + PAYMENT_DETAILS_VIEW_DISPUTE_EVIDENCE_BUTTON_CLICK: + 'wcpay_payment_details_view_dispute_evidence_button_click', SETTINGS_DEPOSITS_MANAGE_IN_STRIPE_CLICK: 'wcpay_settings_deposits_manage_in_stripe_click', MULTI_CURRENCY_ENABLED_CURRENCIES_UPDATED: diff --git a/client/types/disputes.d.ts b/client/types/disputes.d.ts index 8e15bc7201b..4ce74d40b3a 100644 --- a/client/types/disputes.d.ts +++ b/client/types/disputes.d.ts @@ -77,7 +77,22 @@ export interface Dispute { status: DisputeStatus; id: string; evidence_details?: EvidenceDetails; - metadata: Record< string, any >; + metadata: { + /* eslint-disable @typescript-eslint/naming-convention -- required to allow underscores in keys */ + /** + * '1' if the dispute was closed/accepted by the merchant, '0' if the dispute was closed by Stripe. + */ + __closed_by_merchant?: '1' | '0'; + /** + * Unix timestamp of when the dispute was closed. + */ + __dispute_closed_at?: string; + /** + * Unix timestamp of when dispute evidence was submitted. + */ + __evidence_submitted_at?: string; + /* eslint-enable @typescript-eslint/naming-convention */ + }; order: null | OrderDetails; evidence: Evidence; issuer_evidence: IssuerEvidence | null; From 395a1110603467e8308defa0c072c1dba5395bde Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Tue, 19 Sep 2023 13:02:01 +1000 Subject: [PATCH 37/49] Move DisputeActions logic and markup into parent component --- .../dispute-details/dispute-actions.tsx | 152 --------------- .../dispute-awaiting-response-details.tsx | 173 +++++++++++++++++- 2 files changed, 166 insertions(+), 159 deletions(-) delete mode 100644 client/payment-details/dispute-details/dispute-actions.tsx diff --git a/client/payment-details/dispute-details/dispute-actions.tsx b/client/payment-details/dispute-details/dispute-actions.tsx deleted file mode 100644 index 1cb8157fb86..00000000000 --- a/client/payment-details/dispute-details/dispute-actions.tsx +++ /dev/null @@ -1,152 +0,0 @@ -/** @format **/ - -/** - * External dependencies - */ -import React, { useState } from 'react'; -import { __, sprintf } from '@wordpress/i18n'; -import { backup, lock } from '@wordpress/icons'; -import { createInterpolateElement } from '@wordpress/element'; -import { Button, Flex, FlexItem, Icon, Modal } from '@wordpress/components'; -import { getHistory } from '@woocommerce/navigation'; - -/** - * Internal dependencies - */ -import type { Dispute } from 'wcpay/types/disputes'; -import wcpayTracks from 'tracks'; -import { getAdminUrl } from 'wcpay/utils'; -import { useDisputeAccept } from 'wcpay/data'; -import { getDisputeFee } from 'wcpay/disputes/utils'; -import { formatCurrency } from 'wcpay/utils/currency'; - -interface Props { - dispute: Dispute; -} -const DisputeActions: React.FC< Props > = ( { dispute } ) => { - const { doAccept, isLoading } = useDisputeAccept( dispute ); - const [ isModalOpen, setModalOpen ] = useState( false ); - - const hasStagedEvidence = dispute.evidence_details?.has_evidence; - const disputeFee = getDisputeFee( dispute ); - - const onClose = () => { - setModalOpen( false ); - }; - - const onAccept = () => { - wcpayTracks.recordEvent( wcpayTracks.events.DISPUTE_ACCEPT_CLICK, { - dispute_status: dispute.status, - } ); - setModalOpen( false ); - doAccept(); - }; - - const onChallenge = () => { - wcpayTracks.recordEvent( wcpayTracks.events.DISPUTE_CHALLENGE_CLICK, { - dispute_status: dispute.status, - } ); - const challengeUrl = getAdminUrl( { - page: 'wc-admin', - path: '/payments/disputes/challenge', - id: dispute.id, - } ); - getHistory().push( challengeUrl ); - }; - - return ( - - - - - - { 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' - ) } - - - - - - - -
- ) } -
- ); -}; - -export default DisputeActions; 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 715dc3f6755..5b96b3dfeea 100644 --- a/client/payment-details/dispute-details/dispute-awaiting-response-details.tsx +++ b/client/payment-details/dispute-details/dispute-awaiting-response-details.tsx @@ -3,20 +3,37 @@ /** * 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 { getHistory } from '@woocommerce/navigation'; +import { + Button, + Card, + CardBody, + Flex, + FlexItem, + Icon, + Modal, +} from '@wordpress/components'; /** * Internal dependencies */ import type { Dispute } from 'wcpay/types/disputes'; -import { isAwaitingResponse, isInquiry } 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 DisputeActions from './dispute-actions'; import DisputeSummaryRow from './dispute-summary-row'; import InlineNotice from 'components/inline-notice'; import './style.scss'; @@ -26,10 +43,38 @@ 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 onModalClose = () => { + setModalOpen( false ); + }; + + const onAccept = () => { + wcpayTracks.recordEvent( wcpayTracks.events.DISPUTE_ACCEPT_CLICK, { + dispute_status: dispute.status, + } ); + setModalOpen( false ); + doAccept(); + }; + + const onChallenge = () => { + wcpayTracks.recordEvent( wcpayTracks.events.DISPUTE_CHALLENGE_CLICK, { + dispute_status: dispute.status, + } ); + const challengeUrl = getAdminUrl( { + page: 'wc-admin', + path: '/payments/disputes/challenge', + id: dispute.id, + } ); + getHistory().push( challengeUrl ); + }; return (
@@ -62,8 +107,122 @@ const DisputeAwaitingResponseDetails: React.FC< Props > = ( { dispute } ) => { /> ) } + + { /* Dispute Actions */ } { ! isInquiry( dispute ) && ( - + + + + + + { 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' + ) } + + + + + + + +
+ ) } +
) } From 49158aa0cb4fe8c01cfb177387805ad5a29fea7d Mon Sep 17 00:00:00 2001 From: Eric Jinks <3147296+Jinksi@users.noreply.github.com> Date: Tue, 19 Sep 2023 13:11:30 +1000 Subject: [PATCH 38/49] Allow native browser navigation for challenge button --- .../dispute-awaiting-response-details.tsx | 63 +++++++++++-------- 1 file changed, 36 insertions(+), 27 deletions(-) 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 5b96b3dfeea..06e69a61748 100644 --- a/client/payment-details/dispute-details/dispute-awaiting-response-details.tsx +++ b/client/payment-details/dispute-details/dispute-awaiting-response-details.tsx @@ -8,7 +8,7 @@ import moment from 'moment'; import { __, sprintf } from '@wordpress/i18n'; import { backup, edit, lock } from '@wordpress/icons'; import { createInterpolateElement } from '@wordpress/element'; -import { getHistory } from '@woocommerce/navigation'; +import { Link } from '@woocommerce/components'; import { Button, Card, @@ -64,18 +64,6 @@ const DisputeAwaitingResponseDetails: React.FC< Props > = ( { dispute } ) => { doAccept(); }; - const onChallenge = () => { - wcpayTracks.recordEvent( wcpayTracks.events.DISPUTE_CHALLENGE_CLICK, { - dispute_status: dispute.status, - } ); - const challengeUrl = getAdminUrl( { - page: 'wc-admin', - path: '/payments/disputes/challenge', - id: dispute.id, - } ); - getHistory().push( challengeUrl ); - }; - return (
@@ -111,21 +99,42 @@ const DisputeAwaitingResponseDetails: React.FC< Props > = ( { dispute } ) => { { /* Dispute Actions */ } { ! isInquiry( dispute ) && ( - + + +