Skip to content

Commit

Permalink
Disputes: Allow merchant to respond to inquiries from transaction det…
Browse files Browse the repository at this point in the history
…ail page (#7298)

Co-authored-by: Rua Haszard <[email protected]>
Co-authored-by: Eric Jinks <[email protected]>
  • Loading branch information
3 people authored Oct 4, 2023
1 parent c001813 commit e1b3b62
Show file tree
Hide file tree
Showing 5 changed files with 215 additions and 57 deletions.
5 changes: 5 additions & 0 deletions changelog/add-7193-dispute-cta-for-inquries
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Significance: patch
Type: dev
Comment: Add CTA for Inquiries, behind a feature flag.


Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
import React, { useState } from 'react';
import moment from 'moment';
import { __, sprintf } from '@wordpress/i18n';
import { backup, edit, lock } from '@wordpress/icons';
import { backup, edit, lock, arrowRight } from '@wordpress/icons';
import { useDispatch } from '@wordpress/data';
import { createInterpolateElement } from '@wordpress/element';
import { Link } from '@woocommerce/components';
import {
Expand Down Expand Up @@ -39,12 +40,121 @@ interface Props {
dispute: Dispute;
customer: ChargeBillingDetails | null;
chargeCreated: number;
orderUrl: string | undefined;
}

/**
* The lines of text to display in the modal to confirm acceptance / refunding of the dispute / inquiry.
*/
interface ModalLineItem {
icon: JSX.Element;
description: string | JSX.Element;
}

interface AcceptDisputeProps {
/**
* The label for the button that opens the modal.
*/
acceptButtonLabel: string;
/**
* The event to track when the button that opens the modal is clicked.
*/
acceptButtonTracksEvent: string;
/**
* The title of the modal.
*/
modalTitle: string;
/**
* The lines of text to display in the modal.
*/
modalLines: ModalLineItem[];
/**
* The label for the primary button in the modal to Accept / Refund the dispute / inquiry.
*/
modalButtonLabel: string;
/**
* The event to track when the primary button in the modal is clicked.
*/
modalButtonTracksEvent: string;
}

/**
* Disputes and Inquiries have different text for buttons and the modal.
* They also have different icons and tracks events. This function returns the correct props.
*
* @param dispute
*/
function getAcceptDisputeProps( dispute: Dispute ): AcceptDisputeProps {
if ( isInquiry( dispute ) ) {
return {
acceptButtonLabel: __( 'Issue refund', 'woocommerce-payments' ),
acceptButtonTracksEvent:
wcpayTracks.events.DISPUTE_INQUIRY_REFUND_MODAL_VIEW,
modalTitle: __( 'Issue a refund?', 'woocommerce-payments' ),
modalLines: [
{
icon: <Icon icon={ backup } size={ 24 } />,
description: __(
'Issuing a refund will close the inquiry, returning the amount in question back to the cardholder. No additional fees apply.',
'woocommerce-payments'
),
},
{
icon: <Icon icon={ arrowRight } size={ 24 } />,
description: __(
'You will be taken to the order, where you must complete the refund process manually.',
'woocommerce-payments'
),
},
],
modalButtonLabel: __(
'View order to issue refund',
'woocommerce-payments'
),
modalButtonTracksEvent:
wcpayTracks.events.DISPUTE_INQUIRY_REFUND_CLICK,
};
}

return {
acceptButtonLabel: __( 'Accept dispute', 'woocommerce-payments' ),
acceptButtonTracksEvent: wcpayTracks.events.DISPUTE_ACCEPT_MODAL_VIEW,
modalTitle: __( 'Accept the dispute?', 'woocommerce-payments' ),
modalLines: [
{
icon: <Icon icon={ backup } size={ 24 } />,
description: createInterpolateElement(
sprintf(
/* translators: %s: dispute fee, <em>: emphasis HTML element. */
__(
'Accepting the dispute marks it as <em>Lost</em>. The disputed amount will be returned to the cardholder, with a %s dispute fee deducted from your account.',
'woocommerce-payments'
),
getDisputeFeeFormatted( dispute, true ) ?? '-'
),
{
em: <em />,
}
),
},
{
icon: <Icon icon={ lock } size={ 24 } />,
description: __(
'This action is final and cannot be undone.',
'woocommerce-payments'
),
},
],
modalButtonLabel: __( 'Accept dispute', 'woocommerce-payments' ),
modalButtonTracksEvent: wcpayTracks.events.DISPUTE_ACCEPT_CLICK,
};
}

const DisputeAwaitingResponseDetails: React.FC< Props > = ( {
dispute,
customer,
chargeCreated,
orderUrl,
} ) => {
const { doAccept, isLoading } = useDisputeAccept( dispute );
const [ isModalOpen, setModalOpen ] = useState( false );
Expand All @@ -53,13 +163,34 @@ const DisputeAwaitingResponseDetails: React.FC< Props > = ( {
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 { createErrorNotice } = useDispatch( 'core/notices' );
// This is a temporary restriction and can be removed once steps and actions for inquiries are implemented.
const showDisputeStepsAndActions = ! isInquiry( dispute );
const showDisputeSteps = ! isInquiry( dispute );

const onModalClose = () => {
setModalOpen( false );
};

const viewOrder = () => {
if ( orderUrl ) {
window.location.href = orderUrl;
return;
}

createErrorNotice(
__(
'Unable to view order. Order not found.',
'woocommerce-payments'
)
);
};

const disputeAcceptAction = getAcceptDisputeProps( dispute );

const challengeButtonDefaultText = isInquiry( dispute )
? __( 'Submit evidence', 'woocommerce-payments' )
: __( 'Challenge dispute', 'woocommerce-payments' );

return (
<div className="transaction-details-dispute-details-wrapper">
<Card>
Expand All @@ -80,7 +211,7 @@ const DisputeAwaitingResponseDetails: React.FC< Props > = ( {
dispute={ dispute }
daysRemaining={ countdownDays }
/>
{ showDisputeStepsAndActions && (
{ showDisputeSteps && (
<DisputeSteps
dispute={ dispute }
customer={ customer }
Expand All @@ -93,7 +224,7 @@ const DisputeAwaitingResponseDetails: React.FC< Props > = ( {
/>

{ /* Dispute Actions */ }
{ showDisputeStepsAndActions && (
{
<div className="transaction-details-dispute-details-body__actions">
<Link
href={
Expand Down Expand Up @@ -126,10 +257,7 @@ const DisputeAwaitingResponseDetails: React.FC< Props > = ( {
'Continue with challenge',
'woocommerce-payments'
)
: __(
'Challenge dispute',
'woocommerce-payments'
) }
: challengeButtonDefaultText }
</Button>
</Link>

Expand All @@ -138,24 +266,21 @@ const DisputeAwaitingResponseDetails: React.FC< Props > = ( {
disabled={ isLoading }
onClick={ () => {
wcpayTracks.recordEvent(
wcpayTracks.events
.DISPUTE_ACCEPT_MODAL_VIEW,
disputeAcceptAction.acceptButtonTracksEvent,
{
dispute_status: dispute.status,
}
);
setModalOpen( true );
} }
>
{ __(
'Accept dispute',
'woocommerce-payments'
) }
{ disputeAcceptAction.acceptButtonLabel }
</Button>

{ /** Accept dispute modal */ }
{ isModalOpen && (
<Modal
title="Accept the dispute?"
title={ disputeAcceptAction.modalTitle }
onRequestClose={ onModalClose }
className="transaction-details-dispute-accept-modal"
>
Expand All @@ -167,40 +292,19 @@ const DisputeAwaitingResponseDetails: React.FC< Props > = ( {
) }
</strong>
</p>
<Flex justify="start">
<FlexItem className="transaction-details-dispute-accept-modal__icon">
<Icon icon={ backup } size={ 24 } />
</FlexItem>
<FlexItem>
{ createInterpolateElement(
sprintf(
/* translators: %s: dispute fee, <em>: emphasis HTML element. */
__(
'Accepting the dispute marks it as <em>Lost</em>. The disputed amount will be returned to the cardholder, with a %s dispute fee deducted from your account.',
'woocommerce-payments'
),
getDisputeFeeFormatted(
dispute,
true
) ?? '-'
),
{
em: <em />,
}
) }
</FlexItem>
</Flex>
<Flex justify="start">
<FlexItem className="transaction-details-dispute-accept-modal__icon">
<Icon icon={ lock } size={ 24 } />
</FlexItem>
<FlexItem>
{ __(
'Accepting the dispute is final and cannot be undone.',
'woocommerce-payments'
) }
</FlexItem>
</Flex>

{ disputeAcceptAction.modalLines.map(
( line, key ) => (
<Flex justify="start" key={ key }>
<FlexItem className="transaction-details-dispute-accept-modal__icon">
{ line.icon }
</FlexItem>
<FlexItem>
{ line.description }
</FlexItem>
</Flex>
)
) }

<Flex
className="transaction-details-dispute-accept-modal__actions"
Expand All @@ -219,27 +323,33 @@ const DisputeAwaitingResponseDetails: React.FC< Props > = ( {
variant="primary"
onClick={ () => {
wcpayTracks.recordEvent(
wcpayTracks.events
.DISPUTE_ACCEPT_CLICK,
disputeAcceptAction.modalButtonTracksEvent,
{
dispute_status:
dispute.status,
}
);
setModalOpen( false );
doAccept();
/**
* Handle the primary modal action.
* If it's an inquiry, redirect to the order page; otherwise, continue with the default dispute acceptance.
*/
if ( isInquiry( dispute ) ) {
viewOrder();
} else {
doAccept();
}
} }
>
{ __(
'Accept dispute',
'woocommerce-payments'
) }
{
disputeAcceptAction.modalButtonLabel
}
</Button>
</Flex>
</Modal>
) }
</div>
) }
}
</CardBody>
</Card>
</div>
Expand Down
1 change: 1 addition & 0 deletions client/payment-details/summary/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,7 @@ const PaymentDetailsSummary: React.FC< PaymentDetailsSummaryProps > = ( {
dispute={ charge.dispute }
customer={ charge.billing_details }
chargeCreated={ charge.created }
orderUrl={ charge.order?.url }
/>
) : (
<DisputeResolutionFooter dispute={ charge.dispute } />
Expand Down
39 changes: 39 additions & 0 deletions client/payment-details/summary/test/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,22 @@ jest.mock( 'wcpay/data', () => ( {
} ) ),
} ) );

jest.mock( '@wordpress/data', () => ( {
createRegistryControl: jest.fn(),
dispatch: jest.fn( () => ( {
setIsMatching: jest.fn(),
onLoad: jest.fn(),
} ) ),
registerStore: jest.fn(),
select: jest.fn(),
useDispatch: jest.fn( () => ( {
createErrorNotice: jest.fn(),
} ) ),
useSelect: jest.fn( () => ( { getNotices: jest.fn() } ) ),
withDispatch: jest.fn( () => jest.fn() ),
withSelect: jest.fn( () => jest.fn() ),
} ) );

const mockUseAuthorization = useAuthorization as jest.MockedFunction<
typeof useAuthorization
>;
Expand Down Expand Up @@ -792,6 +808,29 @@ describe( 'PaymentDetailsSummary', () => {
).toBeNull();
} );

test( 'correctly renders dispute details for "warning_needs_response" inquiry disputes', () => {
const charge = getBaseCharge();
charge.disputed = true;
charge.dispute = getBaseDispute();
charge.dispute.status = 'warning_needs_response';

renderCharge( charge );

// Dispute Notice
screen.getByText(
/The cardholder claims this is an unauthorized transaction/,
{ ignore: '.a11y-speak-region' }
);

// Actions
screen.getByRole( 'button', {
name: /Submit evidence/i,
} );
screen.getByRole( 'button', {
name: /Issue refund/i,
} );
} );

test( 'correctly renders dispute details for "warning_under_review" inquiry disputes', () => {
const charge = getBaseCharge();
charge.disputed = true;
Expand Down
3 changes: 3 additions & 0 deletions client/tracks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ const events = {
DISPUTE_CHALLENGE_CLICK: 'wcpay_dispute_challenge_click',
DISPUTE_ACCEPT_CLICK: 'wcpay_dispute_accept_click',
DISPUTE_ACCEPT_MODAL_VIEW: 'wcpay_dispute_accept_modal_view',
DISPUTE_INQUIRY_REFUND_CLICK: 'wcpay_dispute_inquiry_refund_click',
DISPUTE_INQUIRY_REFUND_MODAL_VIEW:
'wcpay_dispute_inquiry_refund_modal_view',
ORDER_DISPUTE_NOTICE_BUTTON_CLICK:
'wcpay_order_dispute_notice_action_click',
OVERVIEW_BALANCES_CURRENCY_CLICK:
Expand Down

0 comments on commit e1b3b62

Please sign in to comment.