Skip to content

Commit 691d6a5

Browse files
committed
fix(payments-cart): validate account customer prior to Stripe customer creation
Because: - We've seen that if a user has two tabs open with a cart and proceeds with both, they'll create a duplicate Stripe customer and receive the error "AccountCustomerNotCreatedError: AccountCustomer not created: Duplicate entry...". This commit: - Narrows the race condition, but does not completely eliminate it. We should be fine as it stands now, given the window is very small and the impact is only that a duplicate Stripe customer will be created for the user, but the second cart will still fail anyway due to the unique PK for account customers if this timing were to ever happen. Closes PAY-3541
1 parent 2584a57 commit 691d6a5

3 files changed

Lines changed: 53 additions & 4 deletions

File tree

libs/payments/cart/src/lib/checkout.error.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ export class InvalidIntentStateError extends CheckoutError {
4747
}
4848
}
4949

50+
export class AccountCustomerAlreadyExistsError extends CheckoutError {
51+
constructor(uid: string) {
52+
super('account customer already exists for uid', { uid });
53+
this.name = 'AccountCustomerAlreadyExistsError';
54+
}
55+
}
56+
5057
export class SubmitNeedsInputFailedError extends CheckoutError {
5158
constructor(cartId: string) {
5259
super('payment failed while submitting user needs_input', { cartId });

libs/payments/cart/src/lib/checkout.service.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import {
6565
ResultAccountCustomerFactory,
6666
MockStripeConfigProvider,
6767
AccountCustomerManager,
68+
AccountCustomerNotFoundError,
6869
StripeConfirmationTokenFactory,
6970
StripeSetupIntentFactory,
7071
} from '@fxa/payments/stripe';
@@ -99,6 +100,7 @@ import {
99100
CartNoTaxAddressError,
100101
CartUidMismatchError,
101102
} from './cart.error';
103+
import { AccountCustomerAlreadyExistsError } from './checkout.error';
102104
import { CheckoutService } from './checkout.service';
103105
import { PrePayStepsResultFactory } from './checkout.factories';
104106
import { AccountManager } from '@fxa/shared/account/account';
@@ -502,6 +504,11 @@ describe('CheckoutService', () => {
502504
})
503505
);
504506

507+
jest
508+
.spyOn(accountCustomerManager, 'getAccountCustomerByUid')
509+
.mockRejectedValue(
510+
new AccountCustomerNotFoundError(uid, new Error('not found'))
511+
);
505512
jest
506513
.spyOn(promotionCodeManager, 'assertValidPromotionCodeNameForPrice')
507514
.mockRejectedValue(
@@ -528,10 +535,36 @@ describe('CheckoutService', () => {
528535
})
529536
);
530537

538+
jest
539+
.spyOn(accountCustomerManager, 'getAccountCustomerByUid')
540+
.mockRejectedValue(
541+
new AccountCustomerNotFoundError(uid, new Error('not found'))
542+
);
543+
531544
await expect(
532545
checkoutService.prePaySteps(mockCart, mockCart.uid)
533546
).rejects.toBeInstanceOf(CartTotalMismatchError);
534547
});
548+
549+
it('throws account customer already exists error', async () => {
550+
const mockCart = StripeResponseFactory(
551+
ResultCartFactory({
552+
uid: uid,
553+
couponCode: faker.string.uuid(),
554+
stripeCustomerId: null,
555+
eligibilityStatus: CartEligibilityStatus.CREATE,
556+
amount: mockInvoicePreview.subtotal,
557+
})
558+
);
559+
560+
jest
561+
.spyOn(accountCustomerManager, 'getAccountCustomerByUid')
562+
.mockResolvedValue(mockAccountCustomer);
563+
564+
await expect(
565+
checkoutService.prePaySteps(mockCart, mockCart.uid)
566+
).rejects.toBeInstanceOf(AccountCustomerAlreadyExistsError);
567+
});
535568
});
536569
});
537570

libs/payments/cart/src/lib/checkout.service.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
StripePromotionCode,
4242
type StripePaymentIntent,
4343
type StripeSetupIntent,
44+
AccountCustomerNotFoundError,
4445
} from '@fxa/payments/stripe';
4546
import { AccountManager } from '@fxa/shared/account/account';
4647
import { ProfileClient } from '@fxa/profile/client';
@@ -80,6 +81,7 @@ import {
8081
PayWithStripeNullCurrencyError,
8182
UpgradeSubscriptionNullCurrencyError,
8283
UnexpectedSubscriptionStatusForTrialError,
84+
AccountCustomerAlreadyExistsError,
8385
} from './checkout.error';
8486
import { isPaymentIntentId } from './util/isPaymentIntentId';
8587
import { isPaymentIntent } from './util/isPaymentIntent';
@@ -175,6 +177,16 @@ export class CheckoutService {
175177
let stripeCustomerId = cart.stripeCustomerId;
176178
let customer: StripeCustomer;
177179
if (!stripeCustomerId) {
180+
try {
181+
await this.accountCustomerManager.getAccountCustomerByUid(uid);
182+
183+
throw new AccountCustomerAlreadyExistsError(uid);
184+
} catch(error) {
185+
if (!(error instanceof AccountCustomerNotFoundError)) {
186+
throw error;
187+
}
188+
}
189+
178190
customer = await this.customerManager.create({
179191
uid,
180192
email,
@@ -196,15 +208,12 @@ export class CheckoutService {
196208
});
197209
}
198210

199-
if (uid && !cart.stripeCustomerId) {
211+
if (!cart.stripeCustomerId) {
200212
await this.accountCustomerManager.createAccountCustomer({
201213
uid,
202214
stripeCustomerId,
203215
});
204-
}
205216

206-
// Cart only needs to be updated if we created a customer
207-
if (!cart.uid || !cart.stripeCustomerId) {
208217
await this.cartManager.updateFreshCart(cart.id, cart.version, {
209218
uid,
210219
stripeCustomerId,

0 commit comments

Comments
 (0)