Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions packages/payment-integration-api/src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export {
export { default as OrderFinalizationNotCompletedError } from './order-finalization-not-completed-error';
export { default as OrderFinalizationNotRequiredError } from './order-finalization-not-required-error';
export { default as PaymentArgumentInvalidError } from './payment-argument-invalid-error';
export { default as PaymentMethodBankDeclinedAuthenticationError } from './payment-method-bank-declined-authentication-error';
export { default as PaymentMethodCancelledError } from './payment-method-cancelled-error';
export { default as PaymentMethodClientUnavailableError } from './payment-method-client-unavailable-error';
export { default as PaymentMethodFailedError } from './payment-method-failed-error';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import PaymentMethodBankDeclinedAuthenticationError from './payment-method-bank-declined-authentication-error';

describe('PaymentMethodBankDeclinedAuthenticationError', () => {
it('returns error name', () => {
const error = new PaymentMethodBankDeclinedAuthenticationError();

expect(error.name).toBe('PaymentMethodBankDeclinedAuthenticationError');
});

it('returns error type', () => {
const error = new PaymentMethodBankDeclinedAuthenticationError();

expect(error.type).toBe('bank_declined_authentication');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import StandardError from './standard-error';

/**
* This error should be thrown when a shopper's bank declines 3DS
* authentication (e.g.: a PayPal `liabilityShift` of `NO` or `UNKNOWN`).
* The decline is issuer-side, so retrying with the same card in the same
* session will not succeed and the shopper should use a different card.
*/
export default class PaymentMethodBankDeclinedAuthenticationError extends StandardError {
constructor() {
super('Your bank declined authentication, please use a different card.');

this.name = 'PaymentMethodBankDeclinedAuthenticationError';
this.type = 'bank_declined_authentication';
}
}
1 change: 1 addition & 0 deletions packages/payment-integration-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export {
PaymentExecuteError,
PaymentInvalidFormError,
PaymentInvalidFormErrorDetails,
PaymentMethodBankDeclinedAuthenticationError,
PaymentMethodCancelledError,
PaymentMethodClientUnavailableError,
PaymentMethodInvalidError,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
PaymentIntegrationService,
PaymentInvalidFormError,
PaymentMethod,
PaymentMethodBankDeclinedAuthenticationError,
PaymentMethodFailedError,
} from '@bigcommerce/checkout-sdk/payment-integration-api';
import {
getBillingAddress,
Expand Down Expand Up @@ -497,6 +499,88 @@ describe('PayPalCommerceCreditCardsPaymentStrategy', () => {
}
});

it.each([LiabilityShiftEnum.No, LiabilityShiftEnum.Unknown])(
'throws an actionable bank-declined error when liabilityShift is %s',
Comment thread
bc-nick marked this conversation as resolved.
async (liabilityShift) => {
let onApproveCallback: PaypalCardFieldsConfig['onApprove'] | undefined;

jest.spyOn(paypalSdk, 'CardFields').mockImplementation(
(options: PaypalCardFieldsConfig) => {
onApproveCallback = options.onApprove;

return Promise.resolve(cardFieldsInstanceMock);
},
);

await strategy.initialize(initializationOptions);

expect(() =>
onApproveCallback?.({
orderID: hostedFormOrderId,
liabilityShift,
}),
).toThrow(new PaymentMethodBankDeclinedAuthenticationError());
},
);

it('surfaces the actionable bank-declined error from submitHostedForm', async () => {
// Reproduce the real PayPal SDK behaviour: `submit()` invokes
// `onApprove` with a bank decline, swallows the error it throws and
// rejects with its own error. The strategy must still surface the
// actionable bank-declined error rather than the generic one.
jest.spyOn(paypalSdk, 'CardFields').mockImplementation(
(options: PaypalCardFieldsConfig) => {
return Promise.resolve({
...cardFieldsInstanceMock,
submit: jest.fn(() => {
try {
options.onApprove?.({
orderID: hostedFormOrderId,
liabilityShift: LiabilityShiftEnum.No,
});
} catch (_) {
// swallowed by the PayPal SDK
}

return Promise.reject(new Error('sdk error'));
}),
});
},
);

await strategy.initialize(initializationOptions);

await expect(
strategy.execute({
payment: {
methodId: 'paypalcommercecreditcards',
paymentData: {},
},
}),
).rejects.toThrow(new PaymentMethodBankDeclinedAuthenticationError());
});

it('falls back to the generic error when submitHostedForm fails for other reasons', async () => {
jest.spyOn(cardFieldsInstanceMock, 'submit').mockRejectedValueOnce(
new Error('network'),
);

await strategy.initialize(initializationOptions);

await expect(
strategy.execute({
payment: {
methodId: 'paypalcommercecreditcards',
paymentData: {},
},
}),
).rejects.toThrow(
new PaymentMethodFailedError(
'Failed authentication. Please try to authorize again.',
),
);
});

it('submits payment with vaulted(stored) instrument', async () => {
jest.spyOn(paypalSdk, 'CardFields').mockImplementation(
(options: PaypalCardFieldsConfig) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
PaymentIntegrationService,
PaymentInvalidFormError,
PaymentInvalidFormErrorDetails,
PaymentMethodBankDeclinedAuthenticationError,
PaymentMethodFailedError,
PaymentRequestOptions,
PaymentStrategy,
Expand Down Expand Up @@ -71,6 +72,7 @@ export default class PayPalCommerceCreditCardsPaymentStrategy implements Payment
private hostedFormOptions?: HostedFormOptions;
private returnedOrderId?: string;
private returnedVaultedToken?: string;
private isBankDeclinedAuthentication = false;

constructor(
private paymentIntegrationService: PaymentIntegrationService,
Expand Down Expand Up @@ -234,7 +236,12 @@ export default class PayPalCommerceCreditCardsPaymentStrategy implements Payment
liabilityShift === LiabilityShiftEnum.No ||
liabilityShift === LiabilityShiftEnum.Unknown
) {
throw new Error();
// The PayPal SDK swallows errors thrown here and rejects
// `submit()` with its own error, so we record the decline
// and surface the actionable error from `submitHostedForm`.
this.isBankDeclinedAuthentication = true;
Comment thread
bc-nick marked this conversation as resolved.

throw new PaymentMethodBankDeclinedAuthenticationError();
}

return this.handleApprove({ orderID, vaultSetupToken });
Expand Down Expand Up @@ -462,13 +469,19 @@ export default class PayPalCommerceCreditCardsPaymentStrategy implements Payment
},
};

this.isBankDeclinedAuthentication = false;

try {
if (this.isCreditCardVaultedForm) {
await cardFields.submit();
} else {
await cardFields.submit(submitConfig);
}
} catch (_) {
if (this.isBankDeclinedAuthentication) {
throw new PaymentMethodBankDeclinedAuthenticationError();
}

throw new PaymentMethodFailedError(
'Failed authentication. Please try to authorize again.',
);
Expand Down