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
7 changes: 7 additions & 0 deletions libs/payments/cart/src/lib/cart.error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,13 @@ export class CartProcessingConflictError extends CartError {
}
}

export class ConcurrentCartCheckoutError extends CartError {
constructor(cartId: string, cause?: Error) {
super('Concurrent checkout detected for cart', { cartId }, cause);
this.name = 'ConcurrentCartCheckoutError';
}
}

export class CartStateProcessingError extends CartError {
constructor(message: string, cartId: string, cause: Error) {
super(message, { cartId }, cause);
Expand Down
45 changes: 45 additions & 0 deletions libs/payments/cart/src/lib/cart.manager.in.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
UpdateProcessingCartFactory,
} from './cart.factories';
import { CartManager } from './cart.manager';
import { setCartProcessing } from './cart.repository';
import { ResultCart } from './cart.types';
import { type StatsD } from '@fxa/shared/metrics/statsd';
import { LoggerService } from '@nestjs/common';
Expand Down Expand Up @@ -378,6 +379,8 @@ describe('CartManager', () => {
const STALE_PROCESSING_UID = '77777777777777777777777777777777';
const CONCURRENT_UID = '88888888888888888888888888888888';
const TERMINAL_SIBLING_UID = '99999999999999999999999999999999';
const SELF_CONCURRENT_UID = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
const SELF_PROCESSING_UID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';

async function seedAccount(db: AccountDatabase, uidHex: string) {
await db
Expand Down Expand Up @@ -556,6 +559,48 @@ describe('CartManager', () => {
).length;
expect(processingCount).toEqual(1);
});

it('fails when the same cart is already processing', async () => {
await seedAccount(db, SELF_PROCESSING_UID);
const cart = await cartManager.createCart(
SetupCartFactory({ uid: SELF_PROCESSING_UID })
);

await directUpdate(
db,
{ state: CartState.PROCESSING, updatedAt: Date.now() },
cart.id
);

await expect(
setCartProcessing(
db,
Buffer.from(cart.id, 'hex'),
Buffer.from(SELF_PROCESSING_UID, 'hex'),
cart.version
)
).rejects.toBeInstanceOf(CartProcessingConflictError);
});

it('allows only one of two concurrent checkouts for the same cart to enter processing', async () => {
await seedAccount(db, SELF_CONCURRENT_UID);
const cart = await cartManager.createCart(
SetupCartFactory({ uid: SELF_CONCURRENT_UID })
);

const results = await Promise.allSettled([
cartManager.setProcessingCart(cart.id),
cartManager.setProcessingCart(cart.id),
]);

const fulfillments = results.filter((r) => r.status === 'fulfilled');
const rejections = results.filter((r) => r.status === 'rejected');
expect(fulfillments).toHaveLength(1);
expect(rejections).toHaveLength(1);

const updated = await cartManager.fetchCartById(cart.id);
expect(updated.state).toEqual(CartState.PROCESSING);
});
});

describe('finishErrorCart', () => {
Expand Down
15 changes: 15 additions & 0 deletions libs/payments/cart/src/lib/cart.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,21 @@ export async function setCartProcessing(
conflictingCart.id.toString('hex')
);
}

const cartIsProcessing = carts.find(
(cart) =>
cart.id.equals(cartId) &&
cart.state === CartState.PROCESSING &&
cart.updatedAt > now - CART_PROCESSING_STALE_TIMEOUT_MS
);

if (cartIsProcessing) {
throw new CartProcessingConflictError(
cartId.toString('hex'),
uid.toString('hex'),
cartId.toString('hex')
);
}
}

const updatedRows = await trx
Expand Down
186 changes: 183 additions & 3 deletions libs/payments/cart/src/lib/cart.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,11 @@ import {
CartSetupInvalidPromoCodeError,
CartRestartInvalidPromoCodeError,
SetupCartAccountNotFoundError,
ConcurrentCartCheckoutError,
CartProcessingConflictError,
UpdatePayPalProcessingCartError,
} from './cart.error';
import { CART_PROCESSING_STALE_TIMEOUT_MS } from './cart.repository';
import { CurrencyManager } from '@fxa/payments/currency';
import {
LocationConfig,
Expand Down Expand Up @@ -1400,9 +1404,10 @@ describe('CartService', () => {
const mockRequestArgs = CommonMetricsFactory();

it('accepts payment with Paypal', async () => {
const mockCart = ResultCartFactory();
const mockCart = ResultCartFactory({ state: CartState.START });
const mockToken = faker.string.uuid();

jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
jest
.spyOn(cartManager, 'fetchAndValidateCartVersion')
.mockResolvedValue(mockCart);
Expand Down Expand Up @@ -1430,9 +1435,10 @@ describe('CartService', () => {
});

it('reject with CartStateProcessingError if cart could not be set to processing', async () => {
const mockCart = ResultCartFactory();
const mockCart = ResultCartFactory({ state: CartState.START });
const mockToken = faker.string.uuid();

jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
jest
.spyOn(cartManager, 'fetchAndValidateCartVersion')
.mockRejectedValue(new Error('test'));
Expand All @@ -1453,6 +1459,137 @@ describe('CartService', () => {
expect(checkoutService.payWithPaypal).not.toHaveBeenCalled();
expect(cartManager.finishErrorCart).toHaveBeenCalled();
});

it('rejects with ConcurrentCartCheckoutError when the cart is already actively processing', async () => {
const mockCart = ResultCartFactory({
state: CartState.PROCESSING,
updatedAt: Date.now(),
});
const mockToken = faker.string.uuid();

jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
const setProcessingSpy = jest
.spyOn(cartManager, 'setProcessingCart')
.mockResolvedValue();
jest.spyOn(checkoutService, 'payWithPaypal').mockResolvedValue();
const finishErrorSpy = jest
.spyOn(cartManager, 'finishErrorCart')
.mockResolvedValue();

await expect(
cartService.checkoutCartWithPaypal(
mockCart.id,
mockCart.version,
mockAttributionData,
mockRequestArgs,
mockCart.uid,
mockToken
)
).rejects.toBeInstanceOf(ConcurrentCartCheckoutError);

expect(setProcessingSpy).not.toHaveBeenCalled();
expect(checkoutService.payWithPaypal).not.toHaveBeenCalled();
expect(finishErrorSpy).not.toHaveBeenCalled();
});

it('rejects with ConcurrentCartCheckoutError without finalizing when the claim hits a CartProcessingConflictError', async () => {
const mockCart = ResultCartFactory({ state: CartState.START });
const mockToken = faker.string.uuid();

jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
jest
.spyOn(cartManager, 'fetchAndValidateCartVersion')
.mockResolvedValue(mockCart);
jest
.spyOn(cartManager, 'setProcessingCart')
.mockRejectedValue(
new CartProcessingConflictError(
mockCart.id,
mockCart.uid ?? faker.string.uuid(),
faker.string.uuid()
)
);
jest.spyOn(checkoutService, 'payWithPaypal').mockResolvedValue();
const finishErrorSpy = jest
.spyOn(cartManager, 'finishErrorCart')
.mockResolvedValue();

await expect(
cartService.checkoutCartWithPaypal(
mockCart.id,
mockCart.version,
mockAttributionData,
mockRequestArgs,
mockCart.uid,
mockToken
)
).rejects.toBeInstanceOf(ConcurrentCartCheckoutError);

expect(finishErrorSpy).not.toHaveBeenCalled();
});

it('rejects with ConcurrentCartCheckoutError without finalizing when the claim fails but the cart is still processing', async () => {
const startCart = ResultCartFactory({ state: CartState.START });
const processingCart = ResultCartFactory({
id: startCart.id,
state: CartState.PROCESSING,
updatedAt: Date.now(),
});
const mockToken = faker.string.uuid();

jest
.spyOn(cartManager, 'fetchCartById')
.mockResolvedValueOnce(startCart)
.mockResolvedValue(processingCart);
jest
.spyOn(cartManager, 'fetchAndValidateCartVersion')
.mockRejectedValue(new CartVersionMismatchError(startCart.id));
jest.spyOn(checkoutService, 'payWithPaypal').mockResolvedValue();
const finishErrorSpy = jest
.spyOn(cartManager, 'finishErrorCart')
.mockResolvedValue();

await expect(
cartService.checkoutCartWithPaypal(
startCart.id,
startCart.version,
mockAttributionData,
mockRequestArgs,
startCart.uid,
mockToken
)
).rejects.toBeInstanceOf(ConcurrentCartCheckoutError);

expect(finishErrorSpy).not.toHaveBeenCalled();
});

it('finalizes with UpdatePayPalProcessingCartError when the claim fails and the cart is not processing', async () => {
const mockCart = ResultCartFactory({ state: CartState.START });
const mockToken = faker.string.uuid();

jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
jest
.spyOn(cartManager, 'fetchAndValidateCartVersion')
.mockRejectedValue(new CartVersionMismatchError(mockCart.id));
jest.spyOn(checkoutService, 'payWithPaypal').mockResolvedValue();
const finishErrorSpy = jest
.spyOn(cartManager, 'finishErrorCart')
.mockResolvedValue();

await expect(
cartService.checkoutCartWithPaypal(
mockCart.id,
mockCart.version,
mockAttributionData,
mockRequestArgs,
mockCart.uid,
mockToken
)
).rejects.toBeInstanceOf(UpdatePayPalProcessingCartError);

expect(checkoutService.payWithPaypal).not.toHaveBeenCalled();
expect(finishErrorSpy).toHaveBeenCalled();
});
});

describe('finalizeProcessingCart', () => {
Expand Down Expand Up @@ -1529,9 +1666,10 @@ describe('CartService', () => {

describe('finalizeCartWithError', () => {
it('calls cartManager.finishErrorCart', async () => {
const mockCart = ResultCartFactory();
const mockCart = ResultCartFactory({ state: CartState.START });
const mockErrorCart = FinishErrorCartFactory();

jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();

await cartService.finalizeCartWithError(
Expand All @@ -1544,6 +1682,48 @@ describe('CartService', () => {
});
});

it('does not finalize a cart that is actively processing', async () => {
const mockCart = ResultCartFactory({
state: CartState.PROCESSING,
updatedAt: Date.now(),
});
const mockErrorCart = FinishErrorCartFactory();

jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
const finishErrorSpy = jest
.spyOn(cartManager, 'finishErrorCart')
.mockResolvedValue();

await cartService.finalizeCartWithError(
mockCart.id,
mockErrorCart.errorReasonId
);

expect(finishErrorSpy).not.toHaveBeenCalled();
});

it('finalizes a cart whose processing has exceeded the stale timeout', async () => {
const mockCart = ResultCartFactory({
state: CartState.PROCESSING,
updatedAt: Date.now() - CART_PROCESSING_STALE_TIMEOUT_MS - 1000,
});
const mockErrorCart = FinishErrorCartFactory();

jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
const finishErrorSpy = jest
.spyOn(cartManager, 'finishErrorCart')
.mockResolvedValue();

await cartService.finalizeCartWithError(
mockCart.id,
mockErrorCart.errorReasonId
);

expect(finishErrorSpy).toHaveBeenCalledWith(mockCart.id, {
errorReasonId: mockErrorCart.errorReasonId,
});
});

it('should swallow error if cart already in fail state', async () => {
const mockCart = ResultCartFactory({
state: CartState.FAIL,
Expand Down
Loading