зеркало из https://github.com/mozilla/fxa.git
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:
Родитель
7da1197d19
Коммит
81b8bbff0d
|
@ -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', () => {
|
||||
it('succeeds', async () => {
|
||||
const items = FinishErrorCartFactory();
|
||||
|
|
|
@ -38,7 +38,8 @@ const ACTIONS_VALID_STATE = {
|
|||
finishErrorCart: [CartState.START, CartState.PROCESSING],
|
||||
deleteCart: [CartState.START, CartState.PROCESSING],
|
||||
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
|
||||
|
@ -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) {
|
||||
const cart = await this.fetchCartById(cartId);
|
||||
|
||||
|
|
|
@ -38,7 +38,6 @@ import {
|
|||
StripeResponseFactory,
|
||||
MockStripeConfigProvider,
|
||||
AccountCustomerManager,
|
||||
StripePaymentIntentFactory,
|
||||
StripeSubscriptionFactory,
|
||||
StripePaymentMethodFactory,
|
||||
} from '@fxa/payments/stripe';
|
||||
|
@ -344,16 +343,12 @@ describe('CartService', () => {
|
|||
it('accepts payment with stripe', async () => {
|
||||
const mockCart = ResultCartFactory();
|
||||
const mockPaymentMethodId = faker.string.uuid();
|
||||
const mockPaymentIntent = StripePaymentIntentFactory({
|
||||
payment_method: mockPaymentMethodId,
|
||||
status: 'succeeded',
|
||||
});
|
||||
|
||||
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
|
||||
jest
|
||||
.spyOn(checkoutService, 'payWithStripe')
|
||||
.mockResolvedValue(mockPaymentIntent.status);
|
||||
jest.spyOn(cartManager, 'finishCart').mockResolvedValue();
|
||||
.spyOn(cartManager, 'fetchAndValidateCartVersion')
|
||||
.mockResolvedValue(mockCart);
|
||||
jest.spyOn(cartManager, 'setProcessingCart').mockResolvedValue();
|
||||
jest.spyOn(checkoutService, 'payWithStripe').mockResolvedValue();
|
||||
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();
|
||||
|
||||
await cartService.checkoutCartWithStripe(
|
||||
|
@ -368,27 +363,20 @@ describe('CartService', () => {
|
|||
mockPaymentMethodId,
|
||||
mockCustomerData
|
||||
);
|
||||
expect(cartManager.finishCart).toHaveBeenCalledWith(
|
||||
mockCart.id,
|
||||
mockCart.version,
|
||||
{}
|
||||
);
|
||||
expect(cartManager.finishErrorCart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls cartManager.finishErrorCart when error occurs during checkout', async () => {
|
||||
const mockCart = ResultCartFactory();
|
||||
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
|
||||
.spyOn(checkoutService, 'payWithStripe')
|
||||
.mockResolvedValue(mockPaymentIntent.status);
|
||||
jest.spyOn(cartManager, 'finishCart').mockRejectedValue(undefined);
|
||||
.mockRejectedValue(new Error('test'));
|
||||
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();
|
||||
|
||||
await cartService.checkoutCartWithStripe(
|
||||
|
@ -426,7 +414,7 @@ describe('CartService', () => {
|
|||
);
|
||||
|
||||
expect(checkoutService.payWithPaypal).toHaveBeenCalledWith(
|
||||
{ ...mockCart, version: mockCart.version + 1 },
|
||||
mockCart,
|
||||
mockCustomerData,
|
||||
mockToken
|
||||
);
|
||||
|
|
|
@ -212,32 +212,32 @@ export class CartService {
|
|||
confirmationTokenId: string,
|
||||
customerData: CheckoutCustomerData
|
||||
) {
|
||||
let updatedCart: ResultCart | null = null;
|
||||
try {
|
||||
//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,
|
||||
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) {
|
||||
// TODO: Handle errors and provide an associated reason for failure
|
||||
await this.cartManager.finishErrorCart(cartId, {
|
||||
errorReasonId: CartErrorReasonId.Unknown,
|
||||
});
|
||||
throw new CartStateProcessingError(cartId, e);
|
||||
}
|
||||
|
||||
// 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(
|
||||
|
@ -249,16 +249,15 @@ export class CartService {
|
|||
let updatedCart: ResultCart | null = null;
|
||||
try {
|
||||
//Ensure that the cart version matches the value passed in from FE
|
||||
const cart = await this.cartManager.fetchAndValidateCartVersion(
|
||||
cartId,
|
||||
version
|
||||
);
|
||||
await this.cartManager.fetchAndValidateCartVersion(cartId, version);
|
||||
|
||||
await this.cartManager.setProcessingCart(cartId);
|
||||
updatedCart = {
|
||||
...cart,
|
||||
version: cart.version + 1,
|
||||
};
|
||||
|
||||
// Ensure we have a positive lock on the processing cart
|
||||
updatedCart = await this.cartManager.fetchAndValidateCartVersion(
|
||||
cartId,
|
||||
version + 1
|
||||
);
|
||||
} catch (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
|
||||
*/
|
||||
|
|
|
@ -306,7 +306,6 @@ export class CheckoutService {
|
|||
}
|
||||
await this.postPaySteps(cart, updatedVersion, subscription, uid);
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
async payWithPaypal(
|
||||
|
|
|
@ -10,7 +10,6 @@ import {
|
|||
CheckoutCartWithStripeActionArgs,
|
||||
CheckoutCartWithStripeActionCustomerData,
|
||||
} from '../nestapp/validators/CheckoutCartWithStripeActionArgs';
|
||||
import { SetCartProcessingActionArgs } from '../nestapp/validators/SetCartProcessingActionArgs';
|
||||
|
||||
export const checkoutCartWithStripe = async (
|
||||
cartId: string,
|
||||
|
@ -18,22 +17,12 @@ export const checkoutCartWithStripe = async (
|
|||
confirmationTokenId: string,
|
||||
customerData: CheckoutCartWithStripeActionCustomerData
|
||||
) => {
|
||||
await getApp()
|
||||
.getActionsService()
|
||||
.setCartProcessing(
|
||||
plainToClass(SetCartProcessingActionArgs, { cartId, version })
|
||||
);
|
||||
|
||||
const updatedVersion = version + 1;
|
||||
|
||||
getApp()
|
||||
.getActionsService()
|
||||
.checkoutCartWithStripe(
|
||||
plainToClass(CheckoutCartWithStripeActionArgs, {
|
||||
cartId,
|
||||
version: updatedVersion,
|
||||
customerData,
|
||||
confirmationTokenId,
|
||||
})
|
||||
);
|
||||
getApp().getActionsService().checkoutCartWithStripe(
|
||||
plainToClass(CheckoutCartWithStripeActionArgs, {
|
||||
cartId,
|
||||
version,
|
||||
customerData,
|
||||
confirmationTokenId,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
|
@ -20,7 +20,6 @@ import { SetupCartActionArgs } from './validators/SetupCartActionArgs';
|
|||
import { UpdateCartActionArgs } from './validators/UpdateCartActionArgs';
|
||||
import { RecordEmitterEventArgs } from './validators/RecordEmitterEvent';
|
||||
import { PaymentsEmitterService } from '../emitter/emitter.service';
|
||||
import { SetCartProcessingActionArgs } from './validators/SetCartProcessingActionArgs';
|
||||
import { FinalizeProcessingCartActionArgs } from './validators/finalizeProcessingCartActionArgs';
|
||||
import { PollCartActionArgs } from './validators/pollCartActionArgs';
|
||||
|
||||
|
@ -105,12 +104,6 @@ export class NextJSActionsService {
|
|||
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) {
|
||||
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 = {
|
||||
[CartState.START]: SupportedPages.START,
|
||||
[CartState.PROCESSING]: SupportedPages.PROCESSING,
|
||||
[CartState.NEEDS_INPUT]: SupportedPages.PROCESSING,
|
||||
[CartState.SUCCESS]: SupportedPages.SUCCESS,
|
||||
[CartState.FAIL]: SupportedPages.ERROR,
|
||||
};
|
||||
|
|
|
@ -26,6 +26,7 @@ export enum CartState {
|
|||
START = 'start',
|
||||
PROCESSING = 'processing',
|
||||
SUCCESS = 'success',
|
||||
NEEDS_INPUT = 'needs_input',
|
||||
FAIL = 'fail',
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
CREATE TABLE `carts` (
|
||||
`id` binary(16) NOT 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,
|
||||
`offeringConfigId` 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
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче