mirror of
https://github.com/mozilla/fxa.git
synced 2025-12-28 07:03:55 +00:00
fix(payments-next): 500 Server Error and a Server Components render error when trying to set an expired Link card as a default payment method
Because: * still thinking about it This commit: * Closes #PAY-3423
This commit is contained in:
parent
866e34d928
commit
0d5892cfe7
@ -7,3 +7,5 @@ export * from './lib/subscriptionManagement.error';
|
||||
export * from './lib/subscriptionManagement.service';
|
||||
export * from './lib/types';
|
||||
export * from './lib/churn-intervention.service';
|
||||
export * from './lib/throwStripeUpdatePaymentFailedError';
|
||||
export * from './lib/manage-payment-method.error';
|
||||
|
||||
@ -0,0 +1,72 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { BaseError } from '@fxa/shared/error';
|
||||
|
||||
export class ManagePaymentMethodError extends BaseError {
|
||||
public readonly errorCode: string;
|
||||
|
||||
constructor(message: string, info: Record<string, any>, errorCode: string) {
|
||||
super(errorCode, { info: {message, ...info} });
|
||||
this.name = 'ManagePaymentMethodError';
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
}
|
||||
|
||||
export class ManagePaymentMethodIntentFailedGenericError extends ManagePaymentMethodError {
|
||||
constructor(errorCode: string) {
|
||||
super('ManagePaymentMethod Intent payment method failed with general error', {}, errorCode);
|
||||
this.name = 'ManagePaymentMethodIntentPaymentFailedGenericError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ManagePaymentMethodIntentFailedHandledError extends ManagePaymentMethodError {
|
||||
constructor(message: string, info: Record<string, any>, errorCode: string) {
|
||||
super(message, info, errorCode);
|
||||
this.name = 'ManagePaymentMethodIntentFailedHandledError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ManagePaymentMethodIntentCardDeclinedError extends ManagePaymentMethodIntentFailedHandledError {
|
||||
constructor(errorCode: string) {
|
||||
super('ManagePaymentMethod Intent payment method card declined', {}, errorCode);
|
||||
this.name = 'ManagePaymentMethodIntentCardDeclinedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ManagePaymentMethodIntentCardExpiredError extends ManagePaymentMethodIntentFailedHandledError {
|
||||
constructor(errorCode: string) {
|
||||
super('ManagePaymentMethod Intent payment method card expired', {}, errorCode);
|
||||
this.name = 'ManagePaymentMethodIntentCardExpiredError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ManagePaymentMethodIntentTryAgainError extends ManagePaymentMethodIntentFailedHandledError {
|
||||
constructor(errorCode: string) {
|
||||
super('ManagePaymentMethod Intent failed with an error where customers can try again.', {}, errorCode);
|
||||
this.name = 'ManagePaymentMethodIntentTryAgainError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ManagePaymentMethodIntentGetInTouchError extends ManagePaymentMethodIntentFailedHandledError {
|
||||
constructor(errorCode: string) {
|
||||
super(
|
||||
'ManagePaymentMethod Intent failed with an error requiring customers to get in touch with the payment issuer.',
|
||||
{},
|
||||
errorCode
|
||||
);
|
||||
this.name = 'ManagePaymentMethodIntentGetInTouchError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ManagePaymentMethodIntentInsufficientFundsError extends ManagePaymentMethodIntentFailedHandledError {
|
||||
constructor(errorCode: string) {
|
||||
super(
|
||||
'ManagePaymentMethod Intent payment method card has insufficient funds',
|
||||
{errorCode},
|
||||
errorCode
|
||||
);
|
||||
this.name = 'ManagePaymentMethodIntentInsufficientFundsError';
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,9 @@ import {
|
||||
AccountCustomerManager,
|
||||
AccountCustomerNotFoundError,
|
||||
} from '@fxa/payments/stripe';
|
||||
import {
|
||||
throwStripeUpdatePaymentFailedError
|
||||
} from './throwStripeUpdatePaymentFailedError';
|
||||
import type {
|
||||
ResultAccountCustomer,
|
||||
StripeCustomer,
|
||||
@ -60,6 +63,14 @@ import {
|
||||
CreateBillingAgreementCurrencyNotFound,
|
||||
CreateBillingAgreementPaypalSubscriptionNotFound,
|
||||
} from './subscriptionManagement.error';
|
||||
import {
|
||||
ManagePaymentMethodIntentCardDeclinedError,
|
||||
ManagePaymentMethodIntentCardExpiredError,
|
||||
ManagePaymentMethodIntentFailedGenericError,
|
||||
ManagePaymentMethodIntentGetInTouchError,
|
||||
ManagePaymentMethodIntentTryAgainError,
|
||||
ManagePaymentMethodIntentInsufficientFundsError,
|
||||
} from './manage-payment-method.error';
|
||||
import { NotifierService } from '@fxa/shared/notifier';
|
||||
import { ProfileClient } from '@fxa/profile/client';
|
||||
import {
|
||||
@ -834,7 +845,16 @@ export class SubscriptionManagementService {
|
||||
await this.customerChanged(uid);
|
||||
}
|
||||
|
||||
@SanitizeExceptions()
|
||||
@SanitizeExceptions({
|
||||
allowlist: [
|
||||
ManagePaymentMethodIntentCardDeclinedError,
|
||||
ManagePaymentMethodIntentCardExpiredError,
|
||||
ManagePaymentMethodIntentFailedGenericError,
|
||||
ManagePaymentMethodIntentGetInTouchError,
|
||||
ManagePaymentMethodIntentTryAgainError,
|
||||
ManagePaymentMethodIntentInsufficientFundsError,
|
||||
],
|
||||
})
|
||||
async updateStripePaymentDetails(uid: string, confirmationTokenId: string) {
|
||||
const accountCustomer =
|
||||
await this.accountCustomerManager.getAccountCustomerByUid(uid);
|
||||
@ -843,10 +863,22 @@ export class SubscriptionManagementService {
|
||||
throw new UpdateAccountCustomerMissingStripeId(uid);
|
||||
}
|
||||
|
||||
const setupIntent = await this.setupIntentManager.createAndConfirm(
|
||||
accountCustomer.stripeCustomerId,
|
||||
confirmationTokenId
|
||||
);
|
||||
let setupIntent;
|
||||
try {
|
||||
setupIntent = await this.setupIntentManager.createAndConfirm(
|
||||
accountCustomer.stripeCustomerId,
|
||||
confirmationTokenId
|
||||
);
|
||||
} catch (error) {
|
||||
const setupIntentError = error?.setup_intent;
|
||||
if (setupIntentError?.status === 'requires_payment_method') {
|
||||
const code = setupIntentError.last_setup_error?.code;
|
||||
const declineCode = setupIntentError.last_setup_error?.decline_code;
|
||||
throwStripeUpdatePaymentFailedError(code, declineCode);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.statsd.increment(
|
||||
'sub_management_update_stripe_payment_setupintent_status',
|
||||
{ status: setupIntent.status }
|
||||
|
||||
@ -0,0 +1,87 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import {
|
||||
ManagePaymentMethodIntentCardDeclinedError,
|
||||
ManagePaymentMethodIntentCardExpiredError,
|
||||
ManagePaymentMethodIntentFailedGenericError,
|
||||
ManagePaymentMethodIntentGetInTouchError,
|
||||
ManagePaymentMethodIntentTryAgainError,
|
||||
ManagePaymentMethodIntentInsufficientFundsError,
|
||||
} from './manage-payment-method.error';
|
||||
import {
|
||||
throwStripeUpdatePaymentFailedError
|
||||
} from './throwStripeUpdatePaymentFailedError';
|
||||
|
||||
describe('throwStripeUpdatePaymentFailedError', () => {
|
||||
test.each([
|
||||
['approve_with_id', ManagePaymentMethodIntentTryAgainError],
|
||||
['issuer_not_available', ManagePaymentMethodIntentTryAgainError],
|
||||
['reenter_transaction', ManagePaymentMethodIntentTryAgainError],
|
||||
['insufficient_funds', ManagePaymentMethodIntentInsufficientFundsError],
|
||||
['call_issuer', ManagePaymentMethodIntentGetInTouchError],
|
||||
['card_not_supported', ManagePaymentMethodIntentGetInTouchError],
|
||||
['card_velocity_exceeded', ManagePaymentMethodIntentGetInTouchError],
|
||||
['do_not_honor', ManagePaymentMethodIntentGetInTouchError],
|
||||
['fraudulent', ManagePaymentMethodIntentGetInTouchError],
|
||||
['generic_decline', ManagePaymentMethodIntentGetInTouchError],
|
||||
['invalid_account', ManagePaymentMethodIntentGetInTouchError],
|
||||
['lost_card', ManagePaymentMethodIntentGetInTouchError],
|
||||
['merchant_blacklist', ManagePaymentMethodIntentGetInTouchError],
|
||||
['new_account_information_available', ManagePaymentMethodIntentGetInTouchError],
|
||||
['no_action_take', ManagePaymentMethodIntentGetInTouchError],
|
||||
['not_permitted', ManagePaymentMethodIntentGetInTouchError],
|
||||
['pickup_card', ManagePaymentMethodIntentGetInTouchError],
|
||||
['restricted_card', ManagePaymentMethodIntentGetInTouchError],
|
||||
['revocation_of_all_authorizations', ManagePaymentMethodIntentGetInTouchError],
|
||||
['revocation_of_authorization', ManagePaymentMethodIntentGetInTouchError],
|
||||
['security_violation', ManagePaymentMethodIntentGetInTouchError],
|
||||
['service_not_allowed', ManagePaymentMethodIntentGetInTouchError],
|
||||
['stolen_card', ManagePaymentMethodIntentGetInTouchError],
|
||||
['stop_payment_order', ManagePaymentMethodIntentGetInTouchError],
|
||||
['transaction_not_allowed', ManagePaymentMethodIntentGetInTouchError],
|
||||
['unexpected_code', ManagePaymentMethodIntentCardDeclinedError],
|
||||
])(
|
||||
'throws correct error for card_declined with decline_code=%s',
|
||||
(declineCode, ExpectedError) => {
|
||||
expect(() =>
|
||||
throwStripeUpdatePaymentFailedError('card_declined', declineCode)
|
||||
).toThrow(ExpectedError);
|
||||
}
|
||||
);
|
||||
|
||||
it('throws ManagePaymentMethodIntentCardDeclinedError for incorrect_cvc', () => {
|
||||
expect(() =>
|
||||
throwStripeUpdatePaymentFailedError('incorrect_cvc', undefined)
|
||||
).toThrow(ManagePaymentMethodIntentCardDeclinedError);
|
||||
});
|
||||
|
||||
it('throws ManagePaymentMethodIntentCardExpiredError for expired_card', () => {
|
||||
expect(() =>
|
||||
throwStripeUpdatePaymentFailedError('expired_card', undefined)
|
||||
).toThrow(ManagePaymentMethodIntentCardExpiredError);
|
||||
});
|
||||
|
||||
test.each([
|
||||
'payment_intent_authentication_failure',
|
||||
'setup_intent_authentication_failure',
|
||||
'processing_error',
|
||||
])('throws ManagePaymentMethodIntentTryAgainError for %s', (errorCode) => {
|
||||
expect(() =>
|
||||
throwStripeUpdatePaymentFailedError(errorCode as any, undefined)
|
||||
).toThrow(ManagePaymentMethodIntentTryAgainError);
|
||||
});
|
||||
|
||||
it('throws ManagePaymentMethodIntentFailedGenericError for undefined error code', () => {
|
||||
expect(() =>
|
||||
throwStripeUpdatePaymentFailedError(undefined, undefined)
|
||||
).toThrow(ManagePaymentMethodIntentFailedGenericError);
|
||||
});
|
||||
|
||||
it('throws ManagePaymentMethodIntentFailedGenericError for unknown error code', () => {
|
||||
expect(() =>
|
||||
throwStripeUpdatePaymentFailedError('unknown_code' as any, undefined)
|
||||
).toThrow(ManagePaymentMethodIntentFailedGenericError);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,70 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Stripe } from 'stripe';
|
||||
import {
|
||||
ManagePaymentMethodIntentCardDeclinedError,
|
||||
ManagePaymentMethodIntentCardExpiredError,
|
||||
ManagePaymentMethodIntentFailedGenericError,
|
||||
ManagePaymentMethodIntentGetInTouchError,
|
||||
ManagePaymentMethodIntentTryAgainError,
|
||||
ManagePaymentMethodIntentInsufficientFundsError,
|
||||
} from './manage-payment-method.error';
|
||||
|
||||
export function throwStripeUpdatePaymentFailedError(
|
||||
errorCode:
|
||||
| Stripe.PaymentIntent.LastPaymentError.Code
|
||||
| Stripe.SetupIntent.LastSetupError.Code
|
||||
| undefined,
|
||||
declineCode: string | undefined,
|
||||
) {
|
||||
switch (errorCode) {
|
||||
case 'payment_intent_payment_attempt_failed':
|
||||
case 'payment_method_provider_decline':
|
||||
case 'card_declined': {
|
||||
switch (declineCode) {
|
||||
case 'approve_with_id':
|
||||
case 'issuer_not_available':
|
||||
case 'reenter_transaction':
|
||||
throw new ManagePaymentMethodIntentTryAgainError('intent_failed_try_again');
|
||||
case 'insufficient_funds':
|
||||
throw new ManagePaymentMethodIntentInsufficientFundsError('intent_failed_insufficient_funds');
|
||||
case 'call_issuer':
|
||||
case 'card_not_supported':
|
||||
case 'card_velocity_exceeded':
|
||||
case 'do_not_honor':
|
||||
case 'fraudulent':
|
||||
case 'generic_decline':
|
||||
case 'invalid_account':
|
||||
case 'lost_card':
|
||||
case 'merchant_blacklist':
|
||||
case 'new_account_information_available':
|
||||
case 'no_action_take':
|
||||
case 'not_permitted':
|
||||
case 'pickup_card':
|
||||
case 'restricted_card':
|
||||
case 'revocation_of_all_authorizations':
|
||||
case 'revocation_of_authorization':
|
||||
case 'security_violation':
|
||||
case 'service_not_allowed':
|
||||
case 'stolen_card':
|
||||
case 'stop_payment_order':
|
||||
case 'transaction_not_allowed':
|
||||
throw new ManagePaymentMethodIntentGetInTouchError('intent_failed_get_in_touch');
|
||||
default:
|
||||
throw new ManagePaymentMethodIntentCardDeclinedError('intent_failed_card_declined');
|
||||
}
|
||||
}
|
||||
case 'incorrect_cvc':
|
||||
throw new ManagePaymentMethodIntentCardDeclinedError('intent_failed_card_declined');
|
||||
case 'expired_card':
|
||||
throw new ManagePaymentMethodIntentCardExpiredError('intent_failed_card_expired');
|
||||
case 'payment_intent_authentication_failure':
|
||||
case 'setup_intent_authentication_failure':
|
||||
case 'processing_error':
|
||||
throw new ManagePaymentMethodIntentTryAgainError('intent_failed_try_again');
|
||||
default:
|
||||
throw new ManagePaymentMethodIntentFailedGenericError('intent_failed_generic');
|
||||
}
|
||||
}
|
||||
@ -36,3 +36,4 @@ export * from './lib/utils/types';
|
||||
export * from './lib/utils/get-cart';
|
||||
export * from './lib/utils/buildRedirectUrl';
|
||||
export * from './lib/utils/getCardIcon';
|
||||
export * from './lib/utils/getManagePaymentMethodErrorFtlInfo';
|
||||
|
||||
@ -8,7 +8,11 @@ import {
|
||||
useElements,
|
||||
useStripe,
|
||||
} from '@stripe/react-stripe-js';
|
||||
import { BaseButton, ButtonVariant } from '@fxa/payments/ui';
|
||||
import {
|
||||
BaseButton,
|
||||
ButtonVariant,
|
||||
getManagePaymentMethodErrorFtlInfo,
|
||||
} from '@fxa/payments/ui';
|
||||
import * as Form from '@radix-ui/react-form';
|
||||
import Image from 'next/image';
|
||||
import spinnerWhiteImage from '@fxa/shared/assets/images/spinnerwhite.svg';
|
||||
@ -151,10 +155,21 @@ export function PaymentMethodManagement({
|
||||
throw confirmationTokenError;
|
||||
}
|
||||
|
||||
const response = await updateStripePaymentDetails(
|
||||
uid ?? '',
|
||||
confirmationToken.id
|
||||
);
|
||||
let response;
|
||||
try {
|
||||
response = await updateStripePaymentDetails(
|
||||
uid ?? '',
|
||||
confirmationToken.id
|
||||
);
|
||||
} catch (error) {
|
||||
const errorReason = getManagePaymentMethodErrorFtlInfo(error.message);
|
||||
setError(l10n.getString(
|
||||
errorReason.messageFtl,
|
||||
{},
|
||||
errorReason.message
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 'requires_action' && response.clientSecret) {
|
||||
await handleNextAction(response.clientSecret);
|
||||
|
||||
@ -17,6 +17,12 @@ import { CurrencyManager } from '@fxa/payments/currency';
|
||||
import {
|
||||
SubscriptionManagementService,
|
||||
ChurnInterventionService,
|
||||
ManagePaymentMethodIntentCardDeclinedError,
|
||||
ManagePaymentMethodIntentCardExpiredError,
|
||||
ManagePaymentMethodIntentFailedGenericError,
|
||||
ManagePaymentMethodIntentGetInTouchError,
|
||||
ManagePaymentMethodIntentTryAgainError,
|
||||
ManagePaymentMethodIntentInsufficientFundsError,
|
||||
} from '@fxa/payments/management';
|
||||
import {
|
||||
CheckoutTokenManager,
|
||||
@ -885,7 +891,16 @@ export class NextJSActionsService {
|
||||
);
|
||||
}
|
||||
|
||||
@SanitizeExceptions()
|
||||
@SanitizeExceptions({
|
||||
allowlist: [
|
||||
ManagePaymentMethodIntentCardDeclinedError,
|
||||
ManagePaymentMethodIntentCardExpiredError,
|
||||
ManagePaymentMethodIntentFailedGenericError,
|
||||
ManagePaymentMethodIntentGetInTouchError,
|
||||
ManagePaymentMethodIntentTryAgainError,
|
||||
ManagePaymentMethodIntentInsufficientFundsError,
|
||||
],
|
||||
})
|
||||
@NextIOValidator(
|
||||
UpdateStripePaymentDetailsArgs,
|
||||
UpdateStripePaymentDetailsResult
|
||||
|
||||
@ -53,4 +53,13 @@ stay-subscribed-error-not-current-subscriber = This discount is only available t
|
||||
stay-subscribed-error-still-active = Your { $productTitle } subscription is still active.
|
||||
stay-subscribed-error-general = There was an issue with renewing your subscription.
|
||||
|
||||
## Manage Payment Method Error Messages
|
||||
|
||||
manage-payment-method-intent-error-card-declined = Your transaction could not be processed. Please verify your credit card information and try again.
|
||||
manage-payment-method-intent-error-expired-card-error = It looks like your credit card has expired. Try another card.
|
||||
manage-payment-method-intent-error-try-again = Hmm. There was a problem authorizing your payment. Try again or get in touch with your card issuer.
|
||||
manage-payment-method-intent-error-get-in-touch = Hmm. There was a problem authorizing your payment. Get in touch with your card issuer.
|
||||
manage-payment-method-intent-error-insufficient-funds = It looks like your card has insufficient funds. Try another card.
|
||||
manage-payment-method-intent-error-generic = An unexpected error has occurred while processing your payment, please try again.
|
||||
|
||||
##
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
export function getManagePaymentMethodErrorFtlInfo(
|
||||
errorCode: string,
|
||||
) {
|
||||
switch (errorCode) {
|
||||
case 'intent_failed_card_declined':
|
||||
return {
|
||||
message:
|
||||
'Your transaction could not be processed. Please verify your credit card information and try again.',
|
||||
messageFtl: 'manage-payment-method-intent-error-card-declined',
|
||||
};
|
||||
case 'intent_failed_card_expired':
|
||||
return {
|
||||
message:
|
||||
'It looks like your credit card has expired. Try another card.',
|
||||
messageFtl: 'manage-payment-method-intent-error-expired-card-error',
|
||||
};
|
||||
case 'intent_failed_try_again':
|
||||
return {
|
||||
message:
|
||||
'Hmm. There was a problem authorizing your payment. Try again or get in touch with your card issuer.',
|
||||
messageFtl: 'manage-payment-method-intent-error-try-again',
|
||||
};
|
||||
case 'intent_failed_get_in_touch':
|
||||
return {
|
||||
message:
|
||||
'Hmm. There was a problem authorizing your payment. Get in touch with your card issuer.',
|
||||
messageFtl: 'manage-payment-method-intent-error-get-in-touch',
|
||||
};
|
||||
case 'intent_failed_insufficient_funds':
|
||||
return {
|
||||
message:
|
||||
'It looks like your card has insufficient funds. Try another card.',
|
||||
messageFtl: 'manage-payment-method-intent-error-insufficient-funds',
|
||||
};
|
||||
case 'intent_failed_generic':
|
||||
default:
|
||||
return {
|
||||
message:
|
||||
'An unexpected error has occurred while processing your payment, please try again.',
|
||||
messageFtl: 'manage-payment-method-intent-error-generic',
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user