Skip to content

Commit

Permalink
Refund transaction from details page (#7742)
Browse files Browse the repository at this point in the history
Co-authored-by: Kārlis Janisels <[email protected]>
Co-authored-by: Vladimir Reznichenko <[email protected]>
Co-authored-by: Miguel Gasca <[email protected]>
  • Loading branch information
4 people authored Dec 21, 2023
1 parent ad7111c commit f73f0df
Show file tree
Hide file tree
Showing 23 changed files with 1,023 additions and 124 deletions.
4 changes: 4 additions & 0 deletions changelog/add-7248-refund-transaction-from-details-page
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: add

Add refund controls to transaction details view
63 changes: 63 additions & 0 deletions client/data/payment-intents/actions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
/** @format */

/**
* External dependencies
*/
import { apiFetch } from '@wordpress/data-controls';
import { controls } from '@wordpress/data';
import { __, sprintf } from '@wordpress/i18n';

/**
* Internal Dependencies
*/
Expand All @@ -10,6 +17,8 @@ import {
UpdateErrorForPaymentIntentAction,
UpdatePaymentIntentAction,
} from './types';
import { Charge } from 'wcpay/types/charges';
import { STORE_NAME } from 'wcpay/data/constants';

export function updatePaymentIntent(
id: string,
Expand All @@ -32,3 +41,57 @@ export function updateErrorForPaymentIntent(
error,
};
}

export function* refundCharge(
charge: Charge,
reason: string | null
): Generator {
const paymentIntentId = charge.payment_intent;
try {
yield apiFetch( {
path: `/wc/v3/payments/refund/`,
method: 'post',
data: {
charge_id: charge.id,
amount: charge.amount,
reason: reason,
order_id: charge?.order?.number,
},
} );

yield controls.dispatch(
STORE_NAME,
'invalidateResolutionForStoreSelector',
'getTimeline'
);

yield controls.dispatch(
STORE_NAME,
'invalidateResolutionForStoreSelector',
'getPaymentIntent'
);

yield controls.dispatch(
'core/notices',
'createSuccessNotice',
sprintf(
// translators: %s payment intent id
__( 'Refunded payment #%s.', 'woocommerce-payments' ),
paymentIntentId
)
);
} catch ( error ) {
yield controls.dispatch(
'core/notices',
'createErrorNotice',
sprintf(
// translators: %s payment intent id
__(
'There has been an error refunding the payment #%s. Please try again later.',
'woocommerce-payments'
),
paymentIntentId
)
);
}
}
20 changes: 17 additions & 3 deletions client/data/payment-intents/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,20 @@
/**
* External dependencies
*/
import { useSelect } from '@wordpress/data';
import { useDispatch, useSelect } from '@wordpress/data';
import { PaymentIntent } from '../../types/payment-intents';
import { getChargeData } from '../charges';
import { PaymentChargeDetailsResponse } from '../../payment-details/types';
import { STORE_NAME } from '../constants';
import { Charge } from 'wcpay/types/charges';

export const getIsChargeId = ( id: string ): boolean =>
-1 !== id.indexOf( 'ch_' ) || -1 !== id.indexOf( 'py_' );

export const usePaymentIntentWithChargeFallback = (
id: string
): PaymentChargeDetailsResponse =>
useSelect(
): PaymentChargeDetailsResponse => {
const { data, error, isLoading } = useSelect(
( select ) => {
const selectors = select( STORE_NAME );
const isChargeId = getIsChargeId( id );
Expand Down Expand Up @@ -52,3 +53,16 @@ export const usePaymentIntentWithChargeFallback = (
},
[ id ]
);

const { refundCharge } = useDispatch( STORE_NAME );

const doRefund = ( charge: Charge, reason: string | null ) =>
refundCharge( charge, reason );

return {
data,
error,
isLoading,
doRefund,
};
};
13 changes: 13 additions & 0 deletions client/data/payment-intents/test/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,16 @@ describe( 'Payment Intent hooks', () => {
( useSelect as jest.Mock ).mockImplementation(
( cb: ( callback: any ) => jest.Mock ) => cb( selectMock )
);

jest.spyOn(
// eslint-disable-next-line @typescript-eslint/no-var-requires
require( '@wordpress/data' ),
'useDispatch'
).mockReturnValue( () => {
return {
refundCharge: jest.fn(), // Mock the refundCharge function
};
} );
} );

describe( 'usePaymentIntentWithChargeFallback', () => {
Expand All @@ -133,6 +143,7 @@ describe( 'Payment Intent hooks', () => {

expect( result ).toEqual( {
data: paymentIntentMock.charge,
doRefund: expect.any( Function ),
error: {},
isLoading: false,
} );
Expand All @@ -158,6 +169,7 @@ describe( 'Payment Intent hooks', () => {

expect( result ).toEqual( {
data: paymentIntentMock,
doRefund: expect.any( Function ),
error: {},
isLoading: false,
} );
Expand All @@ -181,6 +193,7 @@ describe( 'Payment Intent hooks', () => {

expect( result ).toEqual( {
data: {},
doRefund: expect.any( Function ),
error: {},
isLoading: true,
} );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ exports[`Order details page should match the snapshot - Charge without payment i
</div>
</div>
</div>
<div
class="payment-details__refund-controls"
/>
</div>
<hr
aria-orientation="horizontal"
Expand Down
116 changes: 105 additions & 11 deletions client/payment-details/summary/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,26 @@
*/
import { __ } from '@wordpress/i18n';
import { dateI18n } from '@wordpress/date';
import { Card, CardBody, CardDivider, Flex } from '@wordpress/components';
import {
Card,
CardBody,
CardDivider,
Flex,
DropdownMenu,
MenuGroup,
MenuItem,
} from '@wordpress/components';
import { moreVertical } from '@wordpress/icons';
import moment from 'moment';
import React, { useContext } from 'react';
import React, { useContext, useState } from 'react';
import { createInterpolateElement } from '@wordpress/element';
import HelpOutlineIcon from 'gridicons/dist/help-outline';
import _ from 'lodash';

// This is a workaround for the position of the dropdown menu. At the same time underlines the need for a better solution.
import '../../../node_modules/@wordpress/components/src/dropdown-menu/style.scss';
import '../../../node_modules/@wordpress/components/src/popover/style.scss';

/**
* Internal dependencies.
*/
Expand Down Expand Up @@ -49,6 +62,7 @@ import MissingOrderNotice from 'wcpay/payment-details/summary/missing-order-noti
import DisputeAwaitingResponseDetails from '../dispute-details/dispute-awaiting-response-details';
import DisputeResolutionFooter from '../dispute-details/dispute-resolution-footer';
import ErrorBoundary from 'components/error-boundary';
import RefundModal from 'wcpay/payment-details/summary/refund-modal';
import CardNotice from 'wcpay/components/card-notice';

declare const window: any;
Expand Down Expand Up @@ -171,7 +185,7 @@ const PaymentDetailsSummary: React.FC< PaymentDetailsSummaryProps > = ( {
charge.currency && balance.currency !== charge.currency;

const {
featureFlags: { isAuthAndCaptureEnabled, isRefundControlsEnabled },
featureFlags: { isAuthAndCaptureEnabled },
} = useContext( WCPaySettingsContext );

// We should only fetch the authorization data if the payment is marked for manual capture and it is not already captured.
Expand Down Expand Up @@ -225,6 +239,7 @@ const PaymentDetailsSummary: React.FC< PaymentDetailsSummaryProps > = ( {
balance.currency
);

const [ isRefundModalOpen, setIsRefundModalOpen ] = useState( false );
return (
<Card>
<CardBody>
Expand Down Expand Up @@ -464,6 +479,71 @@ const PaymentDetailsSummary: React.FC< PaymentDetailsSummaryProps > = ( {
</div>
</div>
</div>
<div className="payment-details__refund-controls">
{ ! charge?.refunded && charge?.captured && (
<Loadable
isLoading={ isLoading }
placeholder={ moreVertical }
>
<DropdownMenu
icon={ moreVertical }
label={ __(
'Translation actions',
'woocommerce-payments'
) }
popoverProps={ {
position: 'bottom left',
} }
>
{ ( { onClose } ) => (
<MenuGroup>
<MenuItem
onClick={ () => {
setIsRefundModalOpen( true );
wcpayTracks.recordEvent(
'payments_transactions_details_refund_modal_open',
{
payment_intent_id:
charge.payment_intent,
}
);
onClose();
} }
>
{ __(
'Refund in full',
'woocommerce-payments'
) }
</MenuItem>
{ charge.order && (
<MenuItem
onClick={ () => {
wcpayTracks.recordEvent(
'payments_transactions_details_partial_refund',
{
payment_intent_id:
charge.payment_intent,
order_id:
charge.order
?.number,
}
);
window.location =
charge.order?.url;
} }
>
{ __(
'Partial refund',
'woocommerce-payments'
) }
</MenuItem>
) }
</MenuGroup>
) }
</DropdownMenu>
</Loadable>
) }
</div>
</CardBody>
<CardDivider />
<CardBody>
Expand Down Expand Up @@ -491,14 +571,28 @@ const PaymentDetailsSummary: React.FC< PaymentDetailsSummaryProps > = ( {
) }
</ErrorBoundary>
) }
{ isRefundControlsEnabled &&
! _.isEmpty( charge ) &&
! charge.order && (
<MissingOrderNotice
isLoading={ isLoading }
formattedAmount={ formattedAmount }
/>
) }
{ isRefundModalOpen && (
<RefundModal
charge={ charge }
formattedAmount={ formattedAmount }
onModalClose={ () => {
setIsRefundModalOpen( false );
wcpayTracks.recordEvent(
'payments_transactions_details_refund_modal_close',
{
payment_intent_id: charge.payment_intent,
}
);
} }
/>
) }
{ ! _.isEmpty( charge ) && ! charge.order && ! isLoading && (
<MissingOrderNotice
charge={ charge }
isLoading={ isLoading }
onButtonClick={ () => setIsRefundModalOpen( true ) }
/>
) }
{ isAuthAndCaptureEnabled &&
authorization &&
! authorization.captured && (
Expand Down
Loading

0 comments on commit f73f0df

Please sign in to comment.