feat(cart): add needs_input state

Because:

- We want to track a cart state where the cart waits on a user input

This commit:

- Adds the needs_input state

Closes FXA-10542
This commit is contained in:
julianpoyourow 2024-11-05 23:38:13 +00:00
Родитель 7da1197d19
Коммит 81b8bbff0d
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: EA0570ABC73D47D3
14 изменённых файлов: 97 добавлений и 100 удалений

Просмотреть файл

@ -226,6 +226,27 @@ describe('CartManager', () => {
}); });
}); });
describe('setNeedsInputcart', () => {
it('succeeds', async () => {
await directUpdate(db, { state: CartState.PROCESSING }, testCart.id);
testCart = await cartManager.fetchCartById(testCart.id);
await cartManager.setNeedsInputCart(testCart.id);
const cart = await cartManager.fetchCartById(testCart.id);
expect(cart.state).toEqual(CartState.NEEDS_INPUT);
});
it('fails - invalid state', async () => {
try {
await cartManager.setNeedsInputCart(testCart.id);
fail('Error in finishCart');
} catch (error) {
expect(error).toBeInstanceOf(CartInvalidStateForActionError);
}
});
});
describe('finishErrorCart', () => { describe('finishErrorCart', () => {
it('succeeds', async () => { it('succeeds', async () => {
const items = FinishErrorCartFactory(); const items = FinishErrorCartFactory();

Просмотреть файл

@ -38,7 +38,8 @@ const ACTIONS_VALID_STATE = {
finishErrorCart: [CartState.START, CartState.PROCESSING], finishErrorCart: [CartState.START, CartState.PROCESSING],
deleteCart: [CartState.START, CartState.PROCESSING], deleteCart: [CartState.START, CartState.PROCESSING],
restartCart: [CartState.START, CartState.PROCESSING, CartState.FAIL], restartCart: [CartState.START, CartState.PROCESSING, CartState.FAIL],
setProcessingCart: [CartState.START], setProcessingCart: [CartState.START, CartState.NEEDS_INPUT],
setNeedsInputCart: [CartState.PROCESSING],
}; };
// Type guard to check if action is valid key in ACTIONS_VALID_STATE // Type guard to check if action is valid key in ACTIONS_VALID_STATE
@ -181,6 +182,21 @@ export class CartManager {
} }
} }
public async setNeedsInputCart(cartId: string) {
const cart = await this.fetchCartById(cartId);
this.checkActionForValidCartState(cart, 'setNeedsInputCart');
try {
await updateCart(this.db, Buffer.from(cartId, 'hex'), cart.version, {
state: CartState.NEEDS_INPUT,
});
} catch (error) {
const cause = error instanceof CartNotUpdatedError ? undefined : error;
throw new CartNotUpdatedError(cartId, cause);
}
}
public async setProcessingCart(cartId: string) { public async setProcessingCart(cartId: string) {
const cart = await this.fetchCartById(cartId); const cart = await this.fetchCartById(cartId);

Просмотреть файл

@ -38,7 +38,6 @@ import {
StripeResponseFactory, StripeResponseFactory,
MockStripeConfigProvider, MockStripeConfigProvider,
AccountCustomerManager, AccountCustomerManager,
StripePaymentIntentFactory,
StripeSubscriptionFactory, StripeSubscriptionFactory,
StripePaymentMethodFactory, StripePaymentMethodFactory,
} from '@fxa/payments/stripe'; } from '@fxa/payments/stripe';
@ -344,16 +343,12 @@ describe('CartService', () => {
it('accepts payment with stripe', async () => { it('accepts payment with stripe', async () => {
const mockCart = ResultCartFactory(); const mockCart = ResultCartFactory();
const mockPaymentMethodId = faker.string.uuid(); const mockPaymentMethodId = faker.string.uuid();
const mockPaymentIntent = StripePaymentIntentFactory({
payment_method: mockPaymentMethodId,
status: 'succeeded',
});
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
jest jest
.spyOn(checkoutService, 'payWithStripe') .spyOn(cartManager, 'fetchAndValidateCartVersion')
.mockResolvedValue(mockPaymentIntent.status); .mockResolvedValue(mockCart);
jest.spyOn(cartManager, 'finishCart').mockResolvedValue(); jest.spyOn(cartManager, 'setProcessingCart').mockResolvedValue();
jest.spyOn(checkoutService, 'payWithStripe').mockResolvedValue();
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue(); jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();
await cartService.checkoutCartWithStripe( await cartService.checkoutCartWithStripe(
@ -368,27 +363,20 @@ describe('CartService', () => {
mockPaymentMethodId, mockPaymentMethodId,
mockCustomerData mockCustomerData
); );
expect(cartManager.finishCart).toHaveBeenCalledWith(
mockCart.id,
mockCart.version,
{}
);
expect(cartManager.finishErrorCart).not.toHaveBeenCalled(); expect(cartManager.finishErrorCart).not.toHaveBeenCalled();
}); });
it('calls cartManager.finishErrorCart when error occurs during checkout', async () => { it('calls cartManager.finishErrorCart when error occurs during checkout', async () => {
const mockCart = ResultCartFactory(); const mockCart = ResultCartFactory();
const mockPaymentMethodId = faker.string.uuid(); const mockPaymentMethodId = faker.string.uuid();
const mockPaymentIntent = StripePaymentIntentFactory({
payment_method: mockPaymentMethodId,
status: 'succeeded',
});
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart); jest
.spyOn(cartManager, 'fetchAndValidateCartVersion')
.mockResolvedValue(mockCart);
jest.spyOn(cartManager, 'setProcessingCart').mockResolvedValue();
jest jest
.spyOn(checkoutService, 'payWithStripe') .spyOn(checkoutService, 'payWithStripe')
.mockResolvedValue(mockPaymentIntent.status); .mockRejectedValue(new Error('test'));
jest.spyOn(cartManager, 'finishCart').mockRejectedValue(undefined);
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue(); jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();
await cartService.checkoutCartWithStripe( await cartService.checkoutCartWithStripe(
@ -426,7 +414,7 @@ describe('CartService', () => {
); );
expect(checkoutService.payWithPaypal).toHaveBeenCalledWith( expect(checkoutService.payWithPaypal).toHaveBeenCalledWith(
{ ...mockCart, version: mockCart.version + 1 }, mockCart,
mockCustomerData, mockCustomerData,
mockToken mockToken
); );

Просмотреть файл

@ -212,32 +212,32 @@ export class CartService {
confirmationTokenId: string, confirmationTokenId: string,
customerData: CheckoutCustomerData customerData: CheckoutCustomerData
) { ) {
let updatedCart: ResultCart | null = null;
try { try {
//Ensure that the cart version matches the value passed in from FE //Ensure that the cart version matches the value passed in from FE
const cart = await this.cartManager.fetchAndValidateCartVersion( await this.cartManager.fetchAndValidateCartVersion(cartId, version);
await this.cartManager.setProcessingCart(cartId);
// Ensure we have a positive lock on the processing cart
updatedCart = await this.cartManager.fetchAndValidateCartVersion(
cartId, cartId,
version version + 1
); );
const status = await this.checkoutService.payWithStripe(
cart,
confirmationTokenId,
customerData
);
const updatedCart = await this.cartManager.fetchCartById(cartId);
if (status === 'succeeded') {
// multiple threads is causing this to be called on an already-successful cart
// this then throws an error, and has us trying to finish the cart while its in an error state
await this.cartManager.finishCart(cartId, updatedCart.version, {});
}
} catch (e) { } catch (e) {
// TODO: Handle errors and provide an associated reason for failure throw new CartStateProcessingError(cartId, e);
await this.cartManager.finishErrorCart(cartId, {
errorReasonId: CartErrorReasonId.Unknown,
});
} }
// Intentionally left out of try/catch block to so that the rest of the logic
// is non-blocking and can be handled asynchronously.
this.checkoutService
.payWithStripe(updatedCart, confirmationTokenId, customerData)
.catch(async () => {
// TODO: Handle errors and provide an associated reason for failure
await this.cartManager.finishErrorCart(cartId, {
errorReasonId: CartErrorReasonId.Unknown,
});
});
} }
async checkoutCartWithPaypal( async checkoutCartWithPaypal(
@ -249,16 +249,15 @@ export class CartService {
let updatedCart: ResultCart | null = null; let updatedCart: ResultCart | null = null;
try { try {
//Ensure that the cart version matches the value passed in from FE //Ensure that the cart version matches the value passed in from FE
const cart = await this.cartManager.fetchAndValidateCartVersion( await this.cartManager.fetchAndValidateCartVersion(cartId, version);
cartId,
version
);
await this.cartManager.setProcessingCart(cartId); await this.cartManager.setProcessingCart(cartId);
updatedCart = {
...cart, // Ensure we have a positive lock on the processing cart
version: cart.version + 1, updatedCart = await this.cartManager.fetchAndValidateCartVersion(
}; cartId,
version + 1
);
} catch (e) { } catch (e) {
throw new CartStateProcessingError(cartId, e); throw new CartStateProcessingError(cartId, e);
} }
@ -364,13 +363,6 @@ export class CartService {
} }
} }
/**
* Update a cart to be in the processing state
*/
async setCartProcessing(cartId: string): Promise<void> {
await this.cartManager.setProcessingCart(cartId);
}
/** /**
* Update a cart in the database by ID or with an existing cart reference * Update a cart in the database by ID or with an existing cart reference
*/ */

Просмотреть файл

@ -306,7 +306,6 @@ export class CheckoutService {
} }
await this.postPaySteps(cart, updatedVersion, subscription, uid); await this.postPaySteps(cart, updatedVersion, subscription, uid);
} }
return status;
} }
async payWithPaypal( async payWithPaypal(

Просмотреть файл

@ -10,7 +10,6 @@ import {
CheckoutCartWithStripeActionArgs, CheckoutCartWithStripeActionArgs,
CheckoutCartWithStripeActionCustomerData, CheckoutCartWithStripeActionCustomerData,
} from '../nestapp/validators/CheckoutCartWithStripeActionArgs'; } from '../nestapp/validators/CheckoutCartWithStripeActionArgs';
import { SetCartProcessingActionArgs } from '../nestapp/validators/SetCartProcessingActionArgs';
export const checkoutCartWithStripe = async ( export const checkoutCartWithStripe = async (
cartId: string, cartId: string,
@ -18,22 +17,12 @@ export const checkoutCartWithStripe = async (
confirmationTokenId: string, confirmationTokenId: string,
customerData: CheckoutCartWithStripeActionCustomerData customerData: CheckoutCartWithStripeActionCustomerData
) => { ) => {
await getApp() getApp().getActionsService().checkoutCartWithStripe(
.getActionsService() plainToClass(CheckoutCartWithStripeActionArgs, {
.setCartProcessing( cartId,
plainToClass(SetCartProcessingActionArgs, { cartId, version }) version,
); customerData,
confirmationTokenId,
const updatedVersion = version + 1; })
);
getApp()
.getActionsService()
.checkoutCartWithStripe(
plainToClass(CheckoutCartWithStripeActionArgs, {
cartId,
version: updatedVersion,
customerData,
confirmationTokenId,
})
);
}; };

Просмотреть файл

@ -20,7 +20,6 @@ import { SetupCartActionArgs } from './validators/SetupCartActionArgs';
import { UpdateCartActionArgs } from './validators/UpdateCartActionArgs'; import { UpdateCartActionArgs } from './validators/UpdateCartActionArgs';
import { RecordEmitterEventArgs } from './validators/RecordEmitterEvent'; import { RecordEmitterEventArgs } from './validators/RecordEmitterEvent';
import { PaymentsEmitterService } from '../emitter/emitter.service'; import { PaymentsEmitterService } from '../emitter/emitter.service';
import { SetCartProcessingActionArgs } from './validators/SetCartProcessingActionArgs';
import { FinalizeProcessingCartActionArgs } from './validators/finalizeProcessingCartActionArgs'; import { FinalizeProcessingCartActionArgs } from './validators/finalizeProcessingCartActionArgs';
import { PollCartActionArgs } from './validators/pollCartActionArgs'; import { PollCartActionArgs } from './validators/pollCartActionArgs';
@ -105,12 +104,6 @@ export class NextJSActionsService {
await this.cartService.finalizeProcessingCart(args.cartId); await this.cartService.finalizeProcessingCart(args.cartId);
} }
async setCartProcessing(args: SetCartProcessingActionArgs) {
await new Validator().validateOrReject(args);
await this.cartService.setCartProcessing(args.cartId);
}
async getPayPalCheckoutToken(args: GetPayPalCheckoutTokenArgs) { async getPayPalCheckoutToken(args: GetPayPalCheckoutTokenArgs) {
await new Validator().validateOrReject(args); await new Validator().validateOrReject(args);

Просмотреть файл

@ -1,13 +0,0 @@
/* 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 { IsNumber, IsString } from 'class-validator';
export class SetCartProcessingActionArgs {
@IsString()
cartId!: string;
@IsNumber()
version!: number;
}

Просмотреть файл

@ -7,6 +7,7 @@ import { SupportedPages } from './types';
export const cartStateToPageMap = { export const cartStateToPageMap = {
[CartState.START]: SupportedPages.START, [CartState.START]: SupportedPages.START,
[CartState.PROCESSING]: SupportedPages.PROCESSING, [CartState.PROCESSING]: SupportedPages.PROCESSING,
[CartState.NEEDS_INPUT]: SupportedPages.PROCESSING,
[CartState.SUCCESS]: SupportedPages.SUCCESS, [CartState.SUCCESS]: SupportedPages.SUCCESS,
[CartState.FAIL]: SupportedPages.ERROR, [CartState.FAIL]: SupportedPages.ERROR,
}; };

Просмотреть файл

@ -26,6 +26,7 @@ export enum CartState {
START = 'start', START = 'start',
PROCESSING = 'processing', PROCESSING = 'processing',
SUCCESS = 'success', SUCCESS = 'success',
NEEDS_INPUT = 'needs_input',
FAIL = 'fail', FAIL = 'fail',
} }

Просмотреть файл

@ -1,7 +1,7 @@
CREATE TABLE `carts` ( CREATE TABLE `carts` (
`id` binary(16) NOT NULL, `id` binary(16) NOT NULL,
`uid` binary(16) DEFAULT NULL, `uid` binary(16) DEFAULT NULL,
`state` enum('start','processing','success','fail') COLLATE utf8mb4_bin NOT NULL, `state` enum('start','processing','success','fail','needs_input') COLLATE utf8mb4_bin NOT NULL,
`errorReasonId` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL, `errorReasonId` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL,
`offeringConfigId` varchar(255) COLLATE utf8mb4_bin NOT NULL, `offeringConfigId` varchar(255) COLLATE utf8mb4_bin NOT NULL,
`interval` varchar(255) COLLATE utf8mb4_bin NOT NULL, `interval` varchar(255) COLLATE utf8mb4_bin NOT NULL,

Просмотреть файл

@ -0,0 +1,5 @@
-- Add `needs_input` to the `carts` `state` enum.
ALTER TABLE carts
MODIFY state ENUM('start', 'processing', 'success', 'fail', 'needs_input');
UPDATE dbMetadata SET value = '158' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -0,0 +1,5 @@
-- Add `needs_input` to the `carts` `state` enum.
-- ALTER TABLE carts
-- MODIFY state ENUM('start', 'processing', 'success', 'fail');
-- UPDATE dbMetadata SET value = '157' WHERE name = 'schema-patch-level';

Просмотреть файл

@ -1,3 +1,3 @@
{ {
"level": 157 "level": 158
} }