зеркало из 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', () => {
|
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
|
||||||
}
|
}
|
||||||
|
|
Загрузка…
Ссылка в новой задаче