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:
elizabeth-ilina 2025-12-16 19:44:16 -05:00
parent 866e34d928
commit 0d5892cfe7
No known key found for this signature in database
GPG Key ID: 6611DC28AC3DCFAB
10 changed files with 361 additions and 11 deletions

View File

@ -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';

View File

@ -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';
}
}

View File

@ -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 }

View File

@ -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);
});
});

View File

@ -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');
}
}

View File

@ -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';

View File

@ -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);

View File

@ -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

View File

@ -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.
##

View File

@ -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',
};
}
}