Skip to content

Commit 0d5892c

Browse files
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
1 parent 866e34d commit 0d5892c

File tree

10 files changed

+361
-11
lines changed

10 files changed

+361
-11
lines changed

libs/payments/management/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ export * from './lib/subscriptionManagement.error';
77
export * from './lib/subscriptionManagement.service';
88
export * from './lib/types';
99
export * from './lib/churn-intervention.service';
10+
export * from './lib/throwStripeUpdatePaymentFailedError';
11+
export * from './lib/manage-payment-method.error';
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { BaseError } from '@fxa/shared/error';
6+
7+
export class ManagePaymentMethodError extends BaseError {
8+
public readonly errorCode: string;
9+
10+
constructor(message: string, info: Record<string, any>, errorCode: string) {
11+
super(errorCode, { info: {message, ...info} });
12+
this.name = 'ManagePaymentMethodError';
13+
this.errorCode = errorCode;
14+
}
15+
}
16+
17+
export class ManagePaymentMethodIntentFailedGenericError extends ManagePaymentMethodError {
18+
constructor(errorCode: string) {
19+
super('ManagePaymentMethod Intent payment method failed with general error', {}, errorCode);
20+
this.name = 'ManagePaymentMethodIntentPaymentFailedGenericError';
21+
}
22+
}
23+
24+
export class ManagePaymentMethodIntentFailedHandledError extends ManagePaymentMethodError {
25+
constructor(message: string, info: Record<string, any>, errorCode: string) {
26+
super(message, info, errorCode);
27+
this.name = 'ManagePaymentMethodIntentFailedHandledError';
28+
}
29+
}
30+
31+
export class ManagePaymentMethodIntentCardDeclinedError extends ManagePaymentMethodIntentFailedHandledError {
32+
constructor(errorCode: string) {
33+
super('ManagePaymentMethod Intent payment method card declined', {}, errorCode);
34+
this.name = 'ManagePaymentMethodIntentCardDeclinedError';
35+
}
36+
}
37+
38+
export class ManagePaymentMethodIntentCardExpiredError extends ManagePaymentMethodIntentFailedHandledError {
39+
constructor(errorCode: string) {
40+
super('ManagePaymentMethod Intent payment method card expired', {}, errorCode);
41+
this.name = 'ManagePaymentMethodIntentCardExpiredError';
42+
}
43+
}
44+
45+
export class ManagePaymentMethodIntentTryAgainError extends ManagePaymentMethodIntentFailedHandledError {
46+
constructor(errorCode: string) {
47+
super('ManagePaymentMethod Intent failed with an error where customers can try again.', {}, errorCode);
48+
this.name = 'ManagePaymentMethodIntentTryAgainError';
49+
}
50+
}
51+
52+
export class ManagePaymentMethodIntentGetInTouchError extends ManagePaymentMethodIntentFailedHandledError {
53+
constructor(errorCode: string) {
54+
super(
55+
'ManagePaymentMethod Intent failed with an error requiring customers to get in touch with the payment issuer.',
56+
{},
57+
errorCode
58+
);
59+
this.name = 'ManagePaymentMethodIntentGetInTouchError';
60+
}
61+
}
62+
63+
export class ManagePaymentMethodIntentInsufficientFundsError extends ManagePaymentMethodIntentFailedHandledError {
64+
constructor(errorCode: string) {
65+
super(
66+
'ManagePaymentMethod Intent payment method card has insufficient funds',
67+
{errorCode},
68+
errorCode
69+
);
70+
this.name = 'ManagePaymentMethodIntentInsufficientFundsError';
71+
}
72+
}

libs/payments/management/src/lib/subscriptionManagement.service.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ import {
2323
AccountCustomerManager,
2424
AccountCustomerNotFoundError,
2525
} from '@fxa/payments/stripe';
26+
import {
27+
throwStripeUpdatePaymentFailedError
28+
} from './throwStripeUpdatePaymentFailedError';
2629
import type {
2730
ResultAccountCustomer,
2831
StripeCustomer,
@@ -60,6 +63,14 @@ import {
6063
CreateBillingAgreementCurrencyNotFound,
6164
CreateBillingAgreementPaypalSubscriptionNotFound,
6265
} from './subscriptionManagement.error';
66+
import {
67+
ManagePaymentMethodIntentCardDeclinedError,
68+
ManagePaymentMethodIntentCardExpiredError,
69+
ManagePaymentMethodIntentFailedGenericError,
70+
ManagePaymentMethodIntentGetInTouchError,
71+
ManagePaymentMethodIntentTryAgainError,
72+
ManagePaymentMethodIntentInsufficientFundsError,
73+
} from './manage-payment-method.error';
6374
import { NotifierService } from '@fxa/shared/notifier';
6475
import { ProfileClient } from '@fxa/profile/client';
6576
import {
@@ -834,7 +845,16 @@ export class SubscriptionManagementService {
834845
await this.customerChanged(uid);
835846
}
836847

837-
@SanitizeExceptions()
848+
@SanitizeExceptions({
849+
allowlist: [
850+
ManagePaymentMethodIntentCardDeclinedError,
851+
ManagePaymentMethodIntentCardExpiredError,
852+
ManagePaymentMethodIntentFailedGenericError,
853+
ManagePaymentMethodIntentGetInTouchError,
854+
ManagePaymentMethodIntentTryAgainError,
855+
ManagePaymentMethodIntentInsufficientFundsError,
856+
],
857+
})
838858
async updateStripePaymentDetails(uid: string, confirmationTokenId: string) {
839859
const accountCustomer =
840860
await this.accountCustomerManager.getAccountCustomerByUid(uid);
@@ -843,10 +863,22 @@ export class SubscriptionManagementService {
843863
throw new UpdateAccountCustomerMissingStripeId(uid);
844864
}
845865

846-
const setupIntent = await this.setupIntentManager.createAndConfirm(
847-
accountCustomer.stripeCustomerId,
848-
confirmationTokenId
849-
);
866+
let setupIntent;
867+
try {
868+
setupIntent = await this.setupIntentManager.createAndConfirm(
869+
accountCustomer.stripeCustomerId,
870+
confirmationTokenId
871+
);
872+
} catch (error) {
873+
const setupIntentError = error?.setup_intent;
874+
if (setupIntentError?.status === 'requires_payment_method') {
875+
const code = setupIntentError.last_setup_error?.code;
876+
const declineCode = setupIntentError.last_setup_error?.decline_code;
877+
throwStripeUpdatePaymentFailedError(code, declineCode);
878+
}
879+
throw error;
880+
}
881+
850882
this.statsd.increment(
851883
'sub_management_update_stripe_payment_setupintent_status',
852884
{ status: setupIntent.status }
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import {
6+
ManagePaymentMethodIntentCardDeclinedError,
7+
ManagePaymentMethodIntentCardExpiredError,
8+
ManagePaymentMethodIntentFailedGenericError,
9+
ManagePaymentMethodIntentGetInTouchError,
10+
ManagePaymentMethodIntentTryAgainError,
11+
ManagePaymentMethodIntentInsufficientFundsError,
12+
} from './manage-payment-method.error';
13+
import {
14+
throwStripeUpdatePaymentFailedError
15+
} from './throwStripeUpdatePaymentFailedError';
16+
17+
describe('throwStripeUpdatePaymentFailedError', () => {
18+
test.each([
19+
['approve_with_id', ManagePaymentMethodIntentTryAgainError],
20+
['issuer_not_available', ManagePaymentMethodIntentTryAgainError],
21+
['reenter_transaction', ManagePaymentMethodIntentTryAgainError],
22+
['insufficient_funds', ManagePaymentMethodIntentInsufficientFundsError],
23+
['call_issuer', ManagePaymentMethodIntentGetInTouchError],
24+
['card_not_supported', ManagePaymentMethodIntentGetInTouchError],
25+
['card_velocity_exceeded', ManagePaymentMethodIntentGetInTouchError],
26+
['do_not_honor', ManagePaymentMethodIntentGetInTouchError],
27+
['fraudulent', ManagePaymentMethodIntentGetInTouchError],
28+
['generic_decline', ManagePaymentMethodIntentGetInTouchError],
29+
['invalid_account', ManagePaymentMethodIntentGetInTouchError],
30+
['lost_card', ManagePaymentMethodIntentGetInTouchError],
31+
['merchant_blacklist', ManagePaymentMethodIntentGetInTouchError],
32+
['new_account_information_available', ManagePaymentMethodIntentGetInTouchError],
33+
['no_action_take', ManagePaymentMethodIntentGetInTouchError],
34+
['not_permitted', ManagePaymentMethodIntentGetInTouchError],
35+
['pickup_card', ManagePaymentMethodIntentGetInTouchError],
36+
['restricted_card', ManagePaymentMethodIntentGetInTouchError],
37+
['revocation_of_all_authorizations', ManagePaymentMethodIntentGetInTouchError],
38+
['revocation_of_authorization', ManagePaymentMethodIntentGetInTouchError],
39+
['security_violation', ManagePaymentMethodIntentGetInTouchError],
40+
['service_not_allowed', ManagePaymentMethodIntentGetInTouchError],
41+
['stolen_card', ManagePaymentMethodIntentGetInTouchError],
42+
['stop_payment_order', ManagePaymentMethodIntentGetInTouchError],
43+
['transaction_not_allowed', ManagePaymentMethodIntentGetInTouchError],
44+
['unexpected_code', ManagePaymentMethodIntentCardDeclinedError],
45+
])(
46+
'throws correct error for card_declined with decline_code=%s',
47+
(declineCode, ExpectedError) => {
48+
expect(() =>
49+
throwStripeUpdatePaymentFailedError('card_declined', declineCode)
50+
).toThrow(ExpectedError);
51+
}
52+
);
53+
54+
it('throws ManagePaymentMethodIntentCardDeclinedError for incorrect_cvc', () => {
55+
expect(() =>
56+
throwStripeUpdatePaymentFailedError('incorrect_cvc', undefined)
57+
).toThrow(ManagePaymentMethodIntentCardDeclinedError);
58+
});
59+
60+
it('throws ManagePaymentMethodIntentCardExpiredError for expired_card', () => {
61+
expect(() =>
62+
throwStripeUpdatePaymentFailedError('expired_card', undefined)
63+
).toThrow(ManagePaymentMethodIntentCardExpiredError);
64+
});
65+
66+
test.each([
67+
'payment_intent_authentication_failure',
68+
'setup_intent_authentication_failure',
69+
'processing_error',
70+
])('throws ManagePaymentMethodIntentTryAgainError for %s', (errorCode) => {
71+
expect(() =>
72+
throwStripeUpdatePaymentFailedError(errorCode as any, undefined)
73+
).toThrow(ManagePaymentMethodIntentTryAgainError);
74+
});
75+
76+
it('throws ManagePaymentMethodIntentFailedGenericError for undefined error code', () => {
77+
expect(() =>
78+
throwStripeUpdatePaymentFailedError(undefined, undefined)
79+
).toThrow(ManagePaymentMethodIntentFailedGenericError);
80+
});
81+
82+
it('throws ManagePaymentMethodIntentFailedGenericError for unknown error code', () => {
83+
expect(() =>
84+
throwStripeUpdatePaymentFailedError('unknown_code' as any, undefined)
85+
).toThrow(ManagePaymentMethodIntentFailedGenericError);
86+
});
87+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { Stripe } from 'stripe';
6+
import {
7+
ManagePaymentMethodIntentCardDeclinedError,
8+
ManagePaymentMethodIntentCardExpiredError,
9+
ManagePaymentMethodIntentFailedGenericError,
10+
ManagePaymentMethodIntentGetInTouchError,
11+
ManagePaymentMethodIntentTryAgainError,
12+
ManagePaymentMethodIntentInsufficientFundsError,
13+
} from './manage-payment-method.error';
14+
15+
export function throwStripeUpdatePaymentFailedError(
16+
errorCode:
17+
| Stripe.PaymentIntent.LastPaymentError.Code
18+
| Stripe.SetupIntent.LastSetupError.Code
19+
| undefined,
20+
declineCode: string | undefined,
21+
) {
22+
switch (errorCode) {
23+
case 'payment_intent_payment_attempt_failed':
24+
case 'payment_method_provider_decline':
25+
case 'card_declined': {
26+
switch (declineCode) {
27+
case 'approve_with_id':
28+
case 'issuer_not_available':
29+
case 'reenter_transaction':
30+
throw new ManagePaymentMethodIntentTryAgainError('intent_failed_try_again');
31+
case 'insufficient_funds':
32+
throw new ManagePaymentMethodIntentInsufficientFundsError('intent_failed_insufficient_funds');
33+
case 'call_issuer':
34+
case 'card_not_supported':
35+
case 'card_velocity_exceeded':
36+
case 'do_not_honor':
37+
case 'fraudulent':
38+
case 'generic_decline':
39+
case 'invalid_account':
40+
case 'lost_card':
41+
case 'merchant_blacklist':
42+
case 'new_account_information_available':
43+
case 'no_action_take':
44+
case 'not_permitted':
45+
case 'pickup_card':
46+
case 'restricted_card':
47+
case 'revocation_of_all_authorizations':
48+
case 'revocation_of_authorization':
49+
case 'security_violation':
50+
case 'service_not_allowed':
51+
case 'stolen_card':
52+
case 'stop_payment_order':
53+
case 'transaction_not_allowed':
54+
throw new ManagePaymentMethodIntentGetInTouchError('intent_failed_get_in_touch');
55+
default:
56+
throw new ManagePaymentMethodIntentCardDeclinedError('intent_failed_card_declined');
57+
}
58+
}
59+
case 'incorrect_cvc':
60+
throw new ManagePaymentMethodIntentCardDeclinedError('intent_failed_card_declined');
61+
case 'expired_card':
62+
throw new ManagePaymentMethodIntentCardExpiredError('intent_failed_card_expired');
63+
case 'payment_intent_authentication_failure':
64+
case 'setup_intent_authentication_failure':
65+
case 'processing_error':
66+
throw new ManagePaymentMethodIntentTryAgainError('intent_failed_try_again');
67+
default:
68+
throw new ManagePaymentMethodIntentFailedGenericError('intent_failed_generic');
69+
}
70+
}

libs/payments/ui/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,4 @@ export * from './lib/utils/types';
3636
export * from './lib/utils/get-cart';
3737
export * from './lib/utils/buildRedirectUrl';
3838
export * from './lib/utils/getCardIcon';
39+
export * from './lib/utils/getManagePaymentMethodErrorFtlInfo';

libs/payments/ui/src/lib/client/components/PaymentMethodManagement/index.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import {
88
useElements,
99
useStripe,
1010
} from '@stripe/react-stripe-js';
11-
import { BaseButton, ButtonVariant } from '@fxa/payments/ui';
11+
import {
12+
BaseButton,
13+
ButtonVariant,
14+
getManagePaymentMethodErrorFtlInfo,
15+
} from '@fxa/payments/ui';
1216
import * as Form from '@radix-ui/react-form';
1317
import Image from 'next/image';
1418
import spinnerWhiteImage from '@fxa/shared/assets/images/spinnerwhite.svg';
@@ -151,10 +155,21 @@ export function PaymentMethodManagement({
151155
throw confirmationTokenError;
152156
}
153157

154-
const response = await updateStripePaymentDetails(
155-
uid ?? '',
156-
confirmationToken.id
157-
);
158+
let response;
159+
try {
160+
response = await updateStripePaymentDetails(
161+
uid ?? '',
162+
confirmationToken.id
163+
);
164+
} catch (error) {
165+
const errorReason = getManagePaymentMethodErrorFtlInfo(error.message);
166+
setError(l10n.getString(
167+
errorReason.messageFtl,
168+
{},
169+
errorReason.message
170+
));
171+
return;
172+
}
158173

159174
if (response.status === 'requires_action' && response.clientSecret) {
160175
await handleNextAction(response.clientSecret);

libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ import { CurrencyManager } from '@fxa/payments/currency';
1717
import {
1818
SubscriptionManagementService,
1919
ChurnInterventionService,
20+
ManagePaymentMethodIntentCardDeclinedError,
21+
ManagePaymentMethodIntentCardExpiredError,
22+
ManagePaymentMethodIntentFailedGenericError,
23+
ManagePaymentMethodIntentGetInTouchError,
24+
ManagePaymentMethodIntentTryAgainError,
25+
ManagePaymentMethodIntentInsufficientFundsError,
2026
} from '@fxa/payments/management';
2127
import {
2228
CheckoutTokenManager,
@@ -885,7 +891,16 @@ export class NextJSActionsService {
885891
);
886892
}
887893

888-
@SanitizeExceptions()
894+
@SanitizeExceptions({
895+
allowlist: [
896+
ManagePaymentMethodIntentCardDeclinedError,
897+
ManagePaymentMethodIntentCardExpiredError,
898+
ManagePaymentMethodIntentFailedGenericError,
899+
ManagePaymentMethodIntentGetInTouchError,
900+
ManagePaymentMethodIntentTryAgainError,
901+
ManagePaymentMethodIntentInsufficientFundsError,
902+
],
903+
})
889904
@NextIOValidator(
890905
UpdateStripePaymentDetailsArgs,
891906
UpdateStripePaymentDetailsResult

libs/payments/ui/src/lib/utils/en.ftl

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,13 @@ stay-subscribed-error-not-current-subscriber = This discount is only available t
5353
stay-subscribed-error-still-active = Your { $productTitle } subscription is still active.
5454
stay-subscribed-error-general = There was an issue with renewing your subscription.
5555
56+
## Manage Payment Method Error Messages
57+
58+
manage-payment-method-intent-error-card-declined = Your transaction could not be processed. Please verify your credit card information and try again.
59+
manage-payment-method-intent-error-expired-card-error = It looks like your credit card has expired. Try another card.
60+
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.
61+
manage-payment-method-intent-error-get-in-touch = Hmm. There was a problem authorizing your payment. Get in touch with your card issuer.
62+
manage-payment-method-intent-error-insufficient-funds = It looks like your card has insufficient funds. Try another card.
63+
manage-payment-method-intent-error-generic = An unexpected error has occurred while processing your payment, please try again.
64+
5665
##

0 commit comments

Comments
 (0)