Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PayPal Express Flow #2551

Merged
merged 14 commits into from
Feb 21, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions packages/lib/src/components/PayPal/Paypal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,31 @@ describe('Paypal', () => {
const paypal = new Paypal({});
expect(paypal.data).toEqual({
clientStateDataIndicator: true,
paymentMethod: { subtype: 'sdk', type: 'paypal', checkoutAttemptId: 'do-not-track' }
paymentMethod: { subtype: 'sdk', type: 'paypal', userAction: 'pay', checkoutAttemptId: 'do-not-track' }
});
});

test('should return subtype express if isExpress flag is set', () => {
const paypal = new Paypal({ isExpress: true });
expect(paypal.data).toEqual({
clientStateDataIndicator: true,
paymentMethod: { subtype: 'express', type: 'paypal', checkoutAttemptId: 'do-not-track' }
paymentMethod: { subtype: 'express', type: 'paypal', userAction: 'pay', checkoutAttemptId: 'do-not-track' }
});
});

test('should return userAction=pay as default', () => {
const paypal = new Paypal({});
expect(paypal.data).toEqual({
clientStateDataIndicator: true,
paymentMethod: { subtype: 'sdk', type: 'paypal', userAction: 'pay', checkoutAttemptId: 'do-not-track' }
});
});

test('should return userAction=continue if set', () => {
const paypal = new Paypal({ isExpress: true, userAction: 'continue' });
expect(paypal.data).toEqual({
clientStateDataIndicator: true,
paymentMethod: { subtype: 'express', type: 'paypal', userAction: 'continue', checkoutAttemptId: 'do-not-track' }
});
});

Expand Down
52 changes: 48 additions & 4 deletions packages/lib/src/components/PayPal/Paypal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import { createShopperDetails } from './utils/create-shopper-details';
class PaypalElement extends UIElement<PayPalElementProps> {
public static type = 'paypal';
public static subtype = 'sdk';
private paymentData = null;

public paymentData: string = null;

private resolve = null;
private reject = null;

Expand All @@ -22,17 +24,21 @@ class PaypalElement extends UIElement<PayPalElementProps> {
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleOnShippingAddressChange = this.handleOnShippingAddressChange.bind(this);
this.handleOnShippingOptionsChange = this.handleOnShippingOptionsChange.bind(this);
}

formatProps(props: PayPalElementProps): PayPalElementProps {
const { merchantId, intent: intentFromConfig } = props.configuration;
const isZeroAuth = props.amount?.value === 0;

const intent: Intent = isZeroAuth ? 'tokenize' : props.intent || intentFromConfig;
const vault = intent === 'tokenize' || props.vault;

const displayContinueToReviewPageButton = props.userAction === 'continue';

return {
...props,
commit: displayContinueToReviewPageButton ? false : props.commit,
sponglord marked this conversation as resolved.
Show resolved Hide resolved
vault,
configuration: {
intent,
Expand All @@ -45,15 +51,25 @@ class PaypalElement extends UIElement<PayPalElementProps> {
this.handleError(new AdyenCheckoutError('IMPLEMENTATION_ERROR', ERRORS.SUBMIT_NOT_SUPPORTED));
};

/**
* Updates the paymentData value. It must be used in the PayPal Express flow, when patching the amount
* @param paymentData - Payment data value
*/
public updatePaymentData(paymentData: string): void {
if (!paymentData) console.warn('PayPal - Updating payment data with an invalid value');
this.paymentData = paymentData;
}

/**
* Formats the component data output
*/
protected formatData() {
const { isExpress } = this.props;
const { isExpress, userAction } = this.props;

return {
paymentMethod: {
type: PaypalElement.type,
userAction,
subtype: isExpress ? 'express' : PaypalElement.subtype
}
};
Expand Down Expand Up @@ -131,16 +147,44 @@ class PaypalElement extends UIElement<PayPalElementProps> {
});
}

/**
* If the merchant provides the 'onShippingAddressChange' callback, then this method is used as a wrapper to it, in order
* to expose to the merchant the 'component' instance. The merchant needs the 'component' in order to manipulate the
* paymentData
*
* @param data - PayPal data
* @param actions - PayPal actions.
*/
private handleOnShippingAddressChange(data, actions): Promise<void> {
return this.props.onShippingAddressChange(data, actions, this);
}

/**
* If the merchant provides the 'onShippingOptionsChange' callback, then this method is used as a wrapper to it, in order
* to expose to the merchant the 'component' instance. The merchant needs the 'component' in order to manipulate the
* paymentData
*
* @param data - PayPal data
* @param actions - PayPal actions.
*/
private handleOnShippingOptionsChange(data, actions): Promise<void> {
return this.props.onShippingOptionsChange(data, actions, this);
}

render() {
if (!this.props.showPayButton) return null;

const { onShippingAddressChange, onShippingOptionsChange, ...rest } = this.props;

return (
<CoreProvider i18n={this.props.i18n} loadingContext={this.props.loadingContext} resources={this.resources}>
<PaypalComponent
ref={ref => {
this.componentRef = ref;
}}
{...this.props}
{...rest}
{...(onShippingAddressChange && { onShippingAddressChange: this.handleOnShippingAddressChange })}
{...(onShippingOptionsChange && { onShippingOptionsChange: this.handleOnShippingOptionsChange })}
onCancel={this.handleCancel}
onChange={this.setState}
onApprove={this.handleOnApprove}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,75 @@
import { h } from 'preact';
import { mount } from 'enzyme';
import { render } from '@testing-library/preact';
import PaypalButtons from './PaypalButtons';

const isEligible = jest.fn(() => true);
const render = jest.fn(() => Promise.resolve());
const paypalIsEligibleMock = jest.fn(() => true);
const paypalRenderMock = jest.fn(() => Promise.resolve());

const paypalRefMock = {
FUNDING: {
PAYPAL: 'paypal',
CREDIT: 'credit',
PAYLATER: 'paylater'
},
Buttons: jest.fn(() => ({ isEligible, render }))
Buttons: jest.fn(() => ({ isEligible: paypalIsEligibleMock, render: paypalRenderMock }))
};

describe('PaypalButtons', () => {
const getWrapper = (props?: object) => mount(<PaypalButtons {...props} paypalRef={paypalRefMock} />);
const renderComponent = () => render(<PaypalButtons isProcessingPayment={false} onApprove={jest.fn()} paypalRef={paypalRefMock} />);

test('Calls to paypalRef.Buttons', async () => {
describe('PaypalButtons', () => {
afterEach(() => {
jest.clearAllMocks();
getWrapper();
});

test('should call paypalRef.Buttons for each funding source', async () => {
renderComponent();
expect(paypalRefMock.Buttons).toHaveBeenCalledTimes(4);
});

test('Calls to paypalRef.Buttons().render', async () => {
jest.clearAllMocks();
getWrapper();
expect(paypalRefMock.Buttons().render).toHaveBeenCalledTimes(4);
test('should call paypalRef.Buttons().render for each funding source', async () => {
renderComponent();
expect(paypalRenderMock).toHaveBeenCalledTimes(4);
});

test('should pass onShippingAddressChange and onShippingOptionsChange callbacks to PayPal button', () => {
const onShippingOptionsChange = jest.fn();
const onShippingAddressChange = jest.fn();
const onApprove = jest.fn();
const createOrder = jest.fn();
const onClick = jest.fn();
const onError = jest.fn();
const onInit = jest.fn();
const style = {};

render(
<PaypalButtons
configuration={{ intent: 'authorize', merchantId: 'xxxx' }}
paypalRef={paypalRefMock}
isProcessingPayment={false}
onApprove={onApprove}
onShippingAddressChange={onShippingAddressChange}
onShippingOptionsChange={onShippingOptionsChange}
onSubmit={createOrder}
onClick={onClick}
onError={onError}
onInit={onInit}
blockPayPalCreditButton
blockPayPalPayLaterButton
blockPayPalVenmoButton
/>
);

expect(paypalRenderMock).toHaveBeenCalledTimes(1);
expect(paypalRefMock.Buttons).toHaveBeenCalledWith({
onShippingAddressChange,
onShippingOptionsChange,
onApprove,
createOrder,
onClick,
onError,
onInit,
style,
fundingSource: 'paypal'
});
});
});
19 changes: 17 additions & 2 deletions packages/lib/src/components/PayPal/components/PaypalButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export default function PaypalButtons({
onCancel,
onError,
onShippingChange,
onShippingAddressChange,
onShippingOptionsChange,
onSubmit,
isProcessingPayment,
paypalRef,
Expand All @@ -30,7 +32,9 @@ export default function PaypalButtons({
const configuration = {
...(isTokenize && { createBillingAgreement: onSubmit }),
...(!isTokenize && { createOrder: onSubmit }),
...(!isTokenize && fundingSource !== 'venmo' && { onShippingChange }),
...(!isTokenize && fundingSource !== 'venmo' && onShippingChange && { onShippingChange }),
...(!isTokenize && fundingSource !== 'venmo' && onShippingAddressChange && { onShippingAddressChange }),
...(!isTokenize && fundingSource !== 'venmo' && onShippingOptionsChange && { onShippingOptionsChange }),
fundingSource,
style: getStyle(fundingSource, style),
onInit,
Expand All @@ -47,6 +51,14 @@ export default function PaypalButtons({
}
};

useEffect(() => {
if (onShippingChange && onShippingAddressChange) {
console.warn(
'PayPal - "onShippingChange" and "onShippingAddressChange" are defined. It is recommended to only use "onShippingAddressChange", as "onShippingChange" is getting deprecated'
);
}
}, [onShippingChange, onShippingAddressChange]);

useEffect(() => {
const { PAYPAL, CREDIT, PAYLATER, VENMO } = paypalRef.FUNDING;
createButton(PAYPAL, paypalButtonRef);
Expand All @@ -56,6 +68,8 @@ export default function PaypalButtons({
if (!props.blockPayPalVenmoButton) createButton(VENMO, venmoButtonRef);
}, []);

const isProcessingPaymentWithoutReviewPage = props.commit === true;

return (
<div className={classnames('adyen-checkout__paypal__buttons', { 'adyen-checkout__paypal-processing': isProcessingPayment })}>
<div className="adyen-checkout__paypal__button adyen-checkout__paypal__button--paypal" ref={paypalButtonRef} />
Expand All @@ -66,7 +80,8 @@ export default function PaypalButtons({
{isProcessingPayment && (
<div className="adyen-checkout__paypal">
<div className="adyen-checkout__paypal__status adyen-checkout__paypal__status--processing">
<Spinner size="medium" inline /> {i18n.get('paypal.processingPayment')}
<Spinner size="medium" inline />
{isProcessingPaymentWithoutReviewPage && i18n.get('paypal.processingPayment')}
</div>
</div>
)}
Expand Down
5 changes: 3 additions & 2 deletions packages/lib/src/components/PayPal/defaultProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const defaultProps: PayPalElementProps = {
status: 'loading',
showPayButton: true,

userAction: 'pay',

// Config
/**
* @see {@link https://developer.paypal.com/docs/checkout/reference/customize-sdk/#merchant-id}
Expand Down Expand Up @@ -66,8 +68,7 @@ const defaultProps: PayPalElementProps = {
onInit: () => {},
onClick: () => {},
onCancel: () => {},
onError: () => {},
onShippingChange: () => {}
onError: () => {}
};

export default defaultProps;
43 changes: 42 additions & 1 deletion packages/lib/src/components/PayPal/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { PaymentAmount, PaymentMethod, ShopperDetails } from '../../types';
import UIElement from '../UIElement';
import { UIElementProps } from '../types';
import { SUPPORTED_LOCALES } from './config';
import PaypalElement from './Paypal';

// eslint-disable-next-line @typescript-eslint/no-unused-vars
declare global {
Expand Down Expand Up @@ -147,9 +148,22 @@ interface PayPalCommonProps {

/**
* @see {@link https://developer.paypal.com/docs/business/javascript-sdk/javascript-sdk-reference/#onshippingchange}
* @deprecated - Use 'onShippingAddressChange' instead, as described in the PayPal docs
*/
onShippingChange?: (data, actions) => void;

/**
* While the buyer is on the PayPal site, you can update their shopping cart to reflect the shipping address they chose on PayPal
* @see {@link https://developer.paypal.com/sdk/js/reference/#onshippingaddresschange}
*/
onShippingAddressChange?: (data: any, actions: { reject: (reason?: string) => Promise<void> }) => Promise<void>;

/**
* While the buyer is on the PayPal site, you can update their shopping cart to reflect the shipping options they chose on PayPal
* @see {@link https://developer.paypal.com/sdk/js/reference/#onshippingoptionschange}
*/
onShippingOptionsChange?: (data: any, actions: { reject: (reason?: string) => Promise<void> }) => Promise<void>;

/**
* Identifies if the payment is Express.
* @defaultValue false
Expand All @@ -168,12 +182,39 @@ export interface PayPalConfig {
intent?: Intent;
}

export interface PayPalElementProps extends PayPalCommonProps, UIElementProps {
export interface PayPalElementProps extends Omit<PayPalCommonProps, 'onShippingAddressChange' | 'onShippingOptionsChange'>, UIElementProps {
onSubmit?: (state: any, element: UIElement) => void;
onComplete?: (state, element?: UIElement) => void;
onAdditionalDetails?: (state: any, element: UIElement) => void;
onCancel?: (state: any, element: UIElement) => void;
onError?: (state: any, element?: UIElement) => void;

/**
* While the buyer is on the PayPal site, you can update their shopping cart to reflect the shipping address they chose on PayPal
* @see {@link https://developer.paypal.com/sdk/js/reference/#onshippingaddresschange}
*
* @param data - PayPal data object
* @param actions - Used to reject the address change in case the address is invalid
* @param component - Adyen instance of its PayPal implementation. It must be used to manipulate the 'paymentData' in order to apply the amount patch correctly
*/
onShippingAddressChange?: (data: any, actions: { reject: (reason?: string) => Promise<void> }, component: PaypalElement) => Promise<void>;

/**
* This callback is triggered any time the user selects a new shipping option.
* @see {@link https://developer.paypal.com/sdk/js/reference/#onshippingoptionschange}
*
* @param data - An PayPal object containing the payer’s selected shipping option
* @param actions - Used to indicates to PayPal that you will not support the shipping method selected by the buyer
* @param component - Adyen instance of its PayPal implementation. It must be used to manipulate the 'paymentData' in order to apply the amount patch correctly
*/
onShippingOptionsChange?: (data: any, actions: { reject: (reason?: string) => Promise<void> }, component: PaypalElement) => Promise<void>;

/**
* If set to 'continue' , the button inside the lightbox will display the 'Continue' button
* @default pay
*/
userAction?: 'continue' | 'pay';

onShopperDetails?(shopperDetails: ShopperDetails, rawData: any, actions: { resolve: () => void; reject: () => void }): void;
paymentMethods?: PaymentMethod[];
showPayButton?: boolean;
Expand Down
Loading
Loading