зеркало из https://github.com/mozilla/fxa.git
Refactor cart polling logic
This commit is contained in:
Родитель
e482c1b550
Коммит
d30b430ad5
|
@ -3,24 +3,39 @@
|
|||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import {
|
||||
PaymentStateHandler,
|
||||
CheckoutParams,
|
||||
LoadingSpinner,
|
||||
PollingSection,
|
||||
StripeWrapper,
|
||||
} from '@fxa/payments/ui';
|
||||
import { getApp } from '@fxa/payments/ui/server';
|
||||
import { getApp, SupportedPages } from '@fxa/payments/ui/server';
|
||||
import { headers } from 'next/headers';
|
||||
import { DEFAULT_LOCALE } from '@fxa/shared/l10n';
|
||||
import { getCartOrRedirectAction } from '@fxa/payments/ui/actions';
|
||||
|
||||
export default function ProcessingPage({ params }: { params: CheckoutParams }) {
|
||||
export default async function ProcessingPage({
|
||||
params,
|
||||
}: {
|
||||
params: CheckoutParams;
|
||||
}) {
|
||||
const locale = headers().get('accept-language') || DEFAULT_LOCALE;
|
||||
const l10n = getApp().getL10n(locale);
|
||||
const cart = await getCartOrRedirectAction(
|
||||
params.cartId,
|
||||
SupportedPages.PROCESSING
|
||||
);
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col text-center text-sm"
|
||||
data-testid="payment-processing"
|
||||
>
|
||||
<LoadingSpinner className="w-10 h-10" />
|
||||
<PollingSection cartId={params.cartId} />
|
||||
<StripeWrapper
|
||||
amount={cart.amount}
|
||||
currency={cart.currency?.toLowerCase()}
|
||||
>
|
||||
<PaymentStateHandler cartId={params.cartId} />
|
||||
</StripeWrapper>
|
||||
{l10n.getString(
|
||||
'next-payment-processing-message',
|
||||
`Please wait while we process your payment…`
|
||||
|
|
|
@ -35,7 +35,11 @@ import type { AccountDatabase } from '@fxa/shared/db/mysql/account';
|
|||
const ACTIONS_VALID_STATE = {
|
||||
updateFreshCart: [CartState.START, CartState.PROCESSING],
|
||||
finishCart: [CartState.PROCESSING],
|
||||
finishErrorCart: [CartState.START, CartState.PROCESSING],
|
||||
finishErrorCart: [
|
||||
CartState.START,
|
||||
CartState.PROCESSING,
|
||||
CartState.NEEDS_INPUT,
|
||||
],
|
||||
deleteCart: [CartState.START, CartState.PROCESSING],
|
||||
restartCart: [CartState.START, CartState.PROCESSING, CartState.FAIL],
|
||||
setProcessingCart: [CartState.START, CartState.NEEDS_INPUT],
|
||||
|
|
|
@ -92,6 +92,7 @@ import {
|
|||
} from './cart.error';
|
||||
import { CurrencyManager } from '@fxa/payments/currency';
|
||||
import { MockCurrencyConfigProvider } from 'libs/payments/currency/src/lib/currency.config';
|
||||
import { NeedsInputType } from './cart.types';
|
||||
|
||||
describe('CartService', () => {
|
||||
let accountManager: AccountManager;
|
||||
|
@ -108,7 +109,6 @@ describe('CartService', () => {
|
|||
let productConfigurationManager: ProductConfigurationManager;
|
||||
let subscriptionManager: SubscriptionManager;
|
||||
let paymentMethodManager: PaymentMethodManager;
|
||||
let stripeClient: StripeClient;
|
||||
|
||||
const mockLogger = {
|
||||
error: jest.fn(),
|
||||
|
@ -176,7 +176,6 @@ describe('CartService', () => {
|
|||
productConfigurationManager = moduleRef.get(ProductConfigurationManager);
|
||||
subscriptionManager = moduleRef.get(SubscriptionManager);
|
||||
paymentMethodManager = moduleRef.get(PaymentMethodManager);
|
||||
stripeClient = moduleRef.get(StripeClient);
|
||||
});
|
||||
|
||||
describe('setupCart', () => {
|
||||
|
@ -354,6 +353,7 @@ describe('CartService', () => {
|
|||
jest.spyOn(cartManager, 'setProcessingCart').mockResolvedValue();
|
||||
jest.spyOn(checkoutService, 'payWithStripe').mockResolvedValue();
|
||||
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();
|
||||
jest.spyOn(cartService, 'orchestrateCheckoutProcess').mockResolvedValue();
|
||||
|
||||
await cartService.checkoutCartWithStripe(
|
||||
mockCart.id,
|
||||
|
@ -368,6 +368,7 @@ describe('CartService', () => {
|
|||
mockCustomerData
|
||||
);
|
||||
expect(cartManager.finishErrorCart).not.toHaveBeenCalled();
|
||||
expect(cartService.orchestrateCheckoutProcess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls cartManager.finishErrorCart when error occurs during checkout', async () => {
|
||||
|
@ -382,6 +383,7 @@ describe('CartService', () => {
|
|||
.spyOn(checkoutService, 'payWithStripe')
|
||||
.mockRejectedValue(new Error('test'));
|
||||
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();
|
||||
jest.spyOn(cartService, 'orchestrateCheckoutProcess').mockResolvedValue();
|
||||
|
||||
await cartService.checkoutCartWithStripe(
|
||||
mockCart.id,
|
||||
|
@ -393,6 +395,7 @@ describe('CartService', () => {
|
|||
expect(cartManager.finishErrorCart).toHaveBeenCalledWith(mockCart.id, {
|
||||
errorReasonId: CartErrorReasonId.Unknown,
|
||||
});
|
||||
expect(cartService.orchestrateCheckoutProcess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -409,6 +412,7 @@ describe('CartService', () => {
|
|||
jest.spyOn(cartManager, 'setProcessingCart').mockResolvedValue();
|
||||
jest.spyOn(checkoutService, 'payWithPaypal').mockResolvedValue();
|
||||
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();
|
||||
jest.spyOn(cartService, 'orchestrateCheckoutProcess').mockResolvedValue();
|
||||
|
||||
await cartService.checkoutCartWithPaypal(
|
||||
mockCart.id,
|
||||
|
@ -423,6 +427,7 @@ describe('CartService', () => {
|
|||
mockToken
|
||||
);
|
||||
expect(cartManager.finishErrorCart).not.toHaveBeenCalled();
|
||||
expect(cartService.orchestrateCheckoutProcess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls cartManager.finishErrorCart when error occurs during checkout', async () => {
|
||||
|
@ -437,6 +442,7 @@ describe('CartService', () => {
|
|||
.spyOn(checkoutService, 'payWithPaypal')
|
||||
.mockRejectedValue(new Error('test'));
|
||||
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();
|
||||
jest.spyOn(cartService, 'orchestrateCheckoutProcess').mockResolvedValue();
|
||||
|
||||
await cartService.checkoutCartWithPaypal(
|
||||
mockCart.id,
|
||||
|
@ -448,6 +454,7 @@ describe('CartService', () => {
|
|||
expect(cartManager.finishErrorCart).toHaveBeenCalledWith(mockCart.id, {
|
||||
errorReasonId: CartErrorReasonId.Unknown,
|
||||
});
|
||||
expect(cartService.orchestrateCheckoutProcess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reject with CartStateProcessingError if cart could not be set to processing', async () => {
|
||||
|
@ -474,107 +481,6 @@ describe('CartService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('pollCart', () => {
|
||||
it('returns cartState if cart is in failed state', async () => {
|
||||
const mockart = ResultCartFactory({ state: CartState.FAIL });
|
||||
|
||||
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockart);
|
||||
|
||||
const result = await cartService.pollCart(mockart.id);
|
||||
|
||||
expect(result).toEqual({ cartState: mockart.state });
|
||||
});
|
||||
|
||||
it('returns cartState if cart is in success state', async () => {
|
||||
const mockart = ResultCartFactory({ state: CartState.SUCCESS });
|
||||
|
||||
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockart);
|
||||
|
||||
const result = await cartService.pollCart(mockart.id);
|
||||
|
||||
expect(result).toEqual({ cartState: mockart.state });
|
||||
});
|
||||
|
||||
it('calls invoiceManager.processPayPalNonZeroInvoice for send_invoice subscriptions', async () => {
|
||||
const mockSubscriptionId = faker.string.uuid();
|
||||
const mockInvoiceId = faker.string.uuid();
|
||||
const mockCart = ResultCartFactory({
|
||||
state: CartState.PROCESSING,
|
||||
stripeSubscriptionId: mockSubscriptionId,
|
||||
});
|
||||
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
|
||||
const mockInvoice = StripeResponseFactory(
|
||||
StripeInvoiceFactory({
|
||||
id: mockInvoiceId,
|
||||
currency: 'usd',
|
||||
amount_due: 1000,
|
||||
tax: 100,
|
||||
})
|
||||
);
|
||||
const mockSubscription = StripeResponseFactory(
|
||||
StripeSubscriptionFactory({
|
||||
id: mockSubscriptionId,
|
||||
latest_invoice: mockInvoiceId,
|
||||
collection_method: 'send_invoice',
|
||||
})
|
||||
);
|
||||
|
||||
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'retrieve')
|
||||
.mockResolvedValue(mockSubscription);
|
||||
jest
|
||||
.spyOn(stripeClient, 'customersRetrieve')
|
||||
.mockResolvedValue(mockCustomer);
|
||||
jest.spyOn(invoiceManager, 'retrieve').mockResolvedValue(mockInvoice);
|
||||
jest
|
||||
.spyOn(invoiceManager, 'processPayPalNonZeroInvoice')
|
||||
.mockResolvedValue(mockInvoice);
|
||||
|
||||
const result = await cartService.pollCart(mockCart.id);
|
||||
|
||||
expect(invoiceManager.processPayPalNonZeroInvoice).toHaveBeenCalledWith(
|
||||
mockCustomer,
|
||||
mockInvoice
|
||||
);
|
||||
|
||||
expect(result).toEqual({ cartState: CartState.PROCESSING });
|
||||
});
|
||||
|
||||
it('calls subscriptionManager.processStripeSubscription for stripe subscriptions', async () => {
|
||||
const mockSubscriptionId = faker.string.uuid();
|
||||
const mockCart = ResultCartFactory({
|
||||
state: CartState.PROCESSING,
|
||||
stripeSubscriptionId: mockSubscriptionId,
|
||||
});
|
||||
const mockSubscription = StripeResponseFactory(
|
||||
StripeSubscriptionFactory({
|
||||
id: mockSubscriptionId,
|
||||
collection_method: 'charge_automatically',
|
||||
})
|
||||
);
|
||||
const mockPaymentIntent = StripeResponseFactory(
|
||||
StripePaymentIntentFactory({ status: 'processing' })
|
||||
);
|
||||
|
||||
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'retrieve')
|
||||
.mockResolvedValue(mockSubscription);
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'processStripeSubscription')
|
||||
.mockResolvedValue(mockPaymentIntent);
|
||||
|
||||
const result = await cartService.pollCart(mockCart.id);
|
||||
|
||||
expect(
|
||||
subscriptionManager.processStripeSubscription
|
||||
).toHaveBeenCalledWith(mockSubscription);
|
||||
|
||||
expect(result).toEqual({ cartState: CartState.PROCESSING });
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalizeCartWithError', () => {
|
||||
it('calls cartManager.finishErrorCart', async () => {
|
||||
const mockCart = ResultCartFactory();
|
||||
|
@ -1040,4 +946,507 @@ describe('CartService', () => {
|
|||
expect(result).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelCartCheckout', () => {
|
||||
it('calls finalizeCartWithError', async () => {
|
||||
const mockCart = ResultCartFactory({ stripeSubscriptionId: null });
|
||||
|
||||
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
|
||||
jest.spyOn(cartService, 'finalizeCartWithError').mockResolvedValue();
|
||||
jest.spyOn(subscriptionManager, 'cancel');
|
||||
|
||||
await cartService.cancelCartCheckout(mockCart.id);
|
||||
|
||||
expect(cartService.finalizeCartWithError).toHaveBeenCalledWith(
|
||||
mockCart.id,
|
||||
CartErrorReasonId.Unknown
|
||||
);
|
||||
expect(subscriptionManager.cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
it('cancels the subscription if cart has a subscription', async () => {
|
||||
const mockSubscription = StripeResponseFactory(
|
||||
StripeSubscriptionFactory()
|
||||
);
|
||||
const mockCart = ResultCartFactory({
|
||||
stripeSubscriptionId: mockSubscription.id,
|
||||
});
|
||||
|
||||
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
|
||||
jest.spyOn(cartService, 'finalizeCartWithError').mockResolvedValue();
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'cancel')
|
||||
.mockResolvedValue(mockSubscription);
|
||||
|
||||
await cartService.cancelCartCheckout(mockCart.id);
|
||||
|
||||
expect(cartService.finalizeCartWithError).toHaveBeenCalledWith(
|
||||
mockCart.id,
|
||||
CartErrorReasonId.Unknown
|
||||
);
|
||||
expect(subscriptionManager.cancel).toHaveBeenCalledWith(
|
||||
mockSubscription.id
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNeedsInput', () => {
|
||||
it('returns StripeHandleNextActionResponse for requires_action payment intents', async () => {
|
||||
const mockCart = ResultCartFactory({ state: CartState.NEEDS_INPUT });
|
||||
const mockSubscription = StripeResponseFactory(
|
||||
StripeSubscriptionFactory()
|
||||
);
|
||||
const mockPaymentIntent = StripeResponseFactory(
|
||||
StripePaymentIntentFactory({ status: 'requires_action' })
|
||||
);
|
||||
|
||||
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'retrieve')
|
||||
.mockResolvedValue(mockSubscription);
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'processStripeSubscription')
|
||||
.mockResolvedValue(mockPaymentIntent);
|
||||
|
||||
const result = await cartService.getNeedsInput(mockCart.id);
|
||||
expect(result).toEqual({
|
||||
inputType: NeedsInputType.StripeHandleNextAction,
|
||||
data: { clientSecret: mockPaymentIntent.client_secret },
|
||||
});
|
||||
});
|
||||
it('returns NoInputNeededResponse for non requires_action payment intents', async () => {
|
||||
const mockCart = ResultCartFactory({ state: CartState.NEEDS_INPUT });
|
||||
const mockSubscription = StripeResponseFactory(
|
||||
StripeSubscriptionFactory()
|
||||
);
|
||||
const mockPaymentIntent = StripeResponseFactory(
|
||||
StripePaymentIntentFactory({ status: 'processing' })
|
||||
);
|
||||
|
||||
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'retrieve')
|
||||
.mockResolvedValue(mockSubscription);
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'processStripeSubscription')
|
||||
.mockResolvedValue(mockPaymentIntent);
|
||||
|
||||
const result = await cartService.getNeedsInput(mockCart.id);
|
||||
expect(result).toEqual({
|
||||
inputType: NeedsInputType.NotRequired,
|
||||
});
|
||||
});
|
||||
it('throws an error if the cart is not in the NEEDS_INPUT state', async () => {
|
||||
const mockCart = ResultCartFactory({ state: CartState.SUCCESS });
|
||||
const mockSubscription = StripeResponseFactory(
|
||||
StripeSubscriptionFactory()
|
||||
);
|
||||
const mockPaymentIntent = StripeResponseFactory(
|
||||
StripePaymentIntentFactory({ status: 'processing' })
|
||||
);
|
||||
|
||||
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'retrieve')
|
||||
.mockResolvedValue(mockSubscription);
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'processStripeSubscription')
|
||||
.mockResolvedValue(mockPaymentIntent);
|
||||
|
||||
await expect(() =>
|
||||
cartService.getNeedsInput(mockCart.id)
|
||||
).rejects.toThrowError(CartInvalidStateForActionError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('submitNeedsInput', () => {
|
||||
it('changes the cart state and calls orchestrateCheckoutProcess', async () => {
|
||||
const mockCart = ResultCartFactory({ state: CartState.NEEDS_INPUT });
|
||||
|
||||
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
|
||||
jest.spyOn(cartManager, 'setProcessingCart').mockResolvedValue();
|
||||
jest.spyOn(cartService, 'orchestrateCheckoutProcess').mockResolvedValue();
|
||||
|
||||
await cartService.submitNeedsInput(mockCart.id);
|
||||
|
||||
expect(cartManager.setProcessingCart).toHaveBeenCalledWith(mockCart.id);
|
||||
expect(cartService.orchestrateCheckoutProcess).toHaveBeenCalledWith(
|
||||
mockCart.id
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error if the cart is not in the NEEDS_INPUT state', async () => {
|
||||
const mockCart = ResultCartFactory({ state: CartState.SUCCESS });
|
||||
|
||||
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
|
||||
|
||||
await expect(() =>
|
||||
cartService.submitNeedsInput(mockCart.id)
|
||||
).rejects.toThrowError(CartInvalidStateForActionError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('orchestrateCheckoutProcess', () => {
|
||||
const mockDelay = (_: number) => new Promise<void>((resolve) => resolve());
|
||||
it('Does nothing for carts with the SUCCESS state', async () => {
|
||||
const mockCart = ResultCartFactory({ state: CartState.SUCCESS });
|
||||
|
||||
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
|
||||
jest.spyOn(subscriptionManager, 'retrieve');
|
||||
|
||||
await cartService.orchestrateCheckoutProcess(mockCart.id, mockDelay);
|
||||
expect(cartManager.fetchCartById).toHaveBeenCalledWith(mockCart.id);
|
||||
expect(subscriptionManager.retrieve).not.toHaveBeenCalled();
|
||||
});
|
||||
it('Does nothing for carts with the FAIL state', async () => {
|
||||
const mockCart = ResultCartFactory({ state: CartState.FAIL });
|
||||
|
||||
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
|
||||
jest.spyOn(subscriptionManager, 'retrieve');
|
||||
|
||||
await cartService.orchestrateCheckoutProcess(mockCart.id, mockDelay);
|
||||
expect(cartManager.fetchCartById).toHaveBeenCalledWith(mockCart.id);
|
||||
expect(subscriptionManager.retrieve).not.toHaveBeenCalled();
|
||||
});
|
||||
it('processes paypal subscriptions that require re-processing', async () => {
|
||||
const mockCart = ResultCartFactory({ state: CartState.PROCESSING });
|
||||
const mockInvoice = StripeResponseFactory(StripeInvoiceFactory());
|
||||
const mockSubscription = StripeResponseFactory(
|
||||
StripeSubscriptionFactory({
|
||||
collection_method: 'send_invoice',
|
||||
latest_invoice: mockInvoice.id,
|
||||
})
|
||||
);
|
||||
const mockPaymentMethod = StripeResponseFactory(
|
||||
StripePaymentMethodFactory()
|
||||
);
|
||||
const processingPaymentIntent = StripeResponseFactory(
|
||||
StripePaymentIntentFactory({
|
||||
status: 'processing',
|
||||
payment_method: mockPaymentMethod.id,
|
||||
})
|
||||
);
|
||||
const completedPaymentIntent = StripeResponseFactory(
|
||||
StripePaymentIntentFactory({
|
||||
...processingPaymentIntent,
|
||||
status: 'succeeded',
|
||||
})
|
||||
);
|
||||
const mockCustomer = StripeResponseFactory(
|
||||
StripeCustomerFactory({
|
||||
invoice_settings: {
|
||||
custom_fields: null,
|
||||
default_payment_method: mockPaymentMethod.id,
|
||||
footer: null,
|
||||
rendering_options: null,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'retrieve')
|
||||
.mockResolvedValue(mockSubscription);
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'getLatestPaymentIntent')
|
||||
.mockResolvedValueOnce(processingPaymentIntent);
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'getLatestPaymentIntent')
|
||||
.mockResolvedValueOnce(completedPaymentIntent);
|
||||
jest.spyOn(invoiceManager, 'retrieve').mockResolvedValue(mockInvoice);
|
||||
jest.spyOn(invoiceManager, 'processPayPalInvoice').mockResolvedValue();
|
||||
jest.spyOn(customerManager, 'retrieve').mockResolvedValue(mockCustomer);
|
||||
jest.spyOn(customerManager, 'update').mockResolvedValue(mockCustomer);
|
||||
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
|
||||
jest.spyOn(cartManager, 'setNeedsInputCart').mockResolvedValue();
|
||||
jest.spyOn(cartManager, 'setProcessingCart').mockResolvedValue();
|
||||
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();
|
||||
jest.spyOn(cartService, 'finalizeProcessingCart').mockResolvedValue();
|
||||
|
||||
await cartService.orchestrateCheckoutProcess(mockCart.id, mockDelay);
|
||||
expect(cartManager.fetchCartById).toHaveBeenCalledWith(mockCart.id);
|
||||
expect(subscriptionManager.retrieve).toHaveBeenCalledWith(
|
||||
mockCart.stripeSubscriptionId
|
||||
);
|
||||
expect(subscriptionManager.getLatestPaymentIntent).toHaveBeenCalledWith(
|
||||
mockSubscription
|
||||
);
|
||||
expect(subscriptionManager.getLatestPaymentIntent).toHaveBeenCalledTimes(
|
||||
2
|
||||
);
|
||||
expect(invoiceManager.retrieve).toHaveBeenCalledWith(
|
||||
mockSubscription.latest_invoice
|
||||
);
|
||||
expect(invoiceManager.processPayPalInvoice).toHaveBeenCalledWith(
|
||||
mockInvoice
|
||||
);
|
||||
expect(cartManager.setNeedsInputCart).not.toHaveBeenCalled();
|
||||
expect(cartManager.setProcessingCart).not.toHaveBeenCalled();
|
||||
expect(cartManager.finishErrorCart).not.toHaveBeenCalled();
|
||||
expect(customerManager.retrieve).toHaveBeenCalledWith(
|
||||
mockCart.stripeCustomerId
|
||||
);
|
||||
expect(customerManager.update).toHaveBeenCalledWith(mockCustomer.id, {
|
||||
invoice_settings: { default_payment_method: mockPaymentMethod.id },
|
||||
});
|
||||
expect(cartService.finalizeProcessingCart).toHaveBeenCalledWith(
|
||||
mockCart.id
|
||||
);
|
||||
});
|
||||
it('processes stripe subscriptions that do not need further input from the user', async () => {
|
||||
const mockCart = ResultCartFactory({ state: CartState.PROCESSING });
|
||||
const mockInvoice = StripeResponseFactory(StripeInvoiceFactory());
|
||||
const mockSubscription = StripeResponseFactory(
|
||||
StripeSubscriptionFactory({
|
||||
latest_invoice: mockInvoice.id,
|
||||
})
|
||||
);
|
||||
const mockPaymentMethod = StripeResponseFactory(
|
||||
StripePaymentMethodFactory()
|
||||
);
|
||||
const mockPaymentIntent = StripeResponseFactory(
|
||||
StripePaymentIntentFactory({
|
||||
status: 'succeeded',
|
||||
payment_method: mockPaymentMethod.id,
|
||||
})
|
||||
);
|
||||
const mockCustomer = StripeResponseFactory(
|
||||
StripeCustomerFactory({
|
||||
invoice_settings: {
|
||||
custom_fields: null,
|
||||
default_payment_method: mockPaymentMethod.id,
|
||||
footer: null,
|
||||
rendering_options: null,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'retrieve')
|
||||
.mockResolvedValue(mockSubscription);
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'getLatestPaymentIntent')
|
||||
.mockResolvedValue(mockPaymentIntent);
|
||||
jest.spyOn(invoiceManager, 'retrieve').mockResolvedValue(mockInvoice);
|
||||
jest.spyOn(invoiceManager, 'processPayPalInvoice').mockResolvedValue();
|
||||
jest.spyOn(customerManager, 'retrieve').mockResolvedValue(mockCustomer);
|
||||
jest.spyOn(customerManager, 'update').mockResolvedValue(mockCustomer);
|
||||
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
|
||||
jest.spyOn(cartManager, 'setNeedsInputCart').mockResolvedValue();
|
||||
jest.spyOn(cartManager, 'setProcessingCart').mockResolvedValue();
|
||||
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();
|
||||
jest.spyOn(cartService, 'finalizeProcessingCart').mockResolvedValue();
|
||||
|
||||
await cartService.orchestrateCheckoutProcess(mockCart.id, mockDelay);
|
||||
expect(cartManager.fetchCartById).toHaveBeenCalledWith(mockCart.id);
|
||||
expect(subscriptionManager.retrieve).toHaveBeenCalledWith(
|
||||
mockCart.stripeSubscriptionId
|
||||
);
|
||||
expect(subscriptionManager.getLatestPaymentIntent).toHaveBeenCalledWith(
|
||||
mockSubscription
|
||||
);
|
||||
expect(invoiceManager.retrieve).not.toHaveBeenCalled();
|
||||
expect(invoiceManager.processPayPalInvoice).not.toHaveBeenCalled();
|
||||
expect(cartManager.setNeedsInputCart).not.toHaveBeenCalled();
|
||||
expect(cartManager.setProcessingCart).not.toHaveBeenCalled();
|
||||
expect(cartManager.finishErrorCart).not.toHaveBeenCalled();
|
||||
expect(customerManager.retrieve).toHaveBeenCalledWith(
|
||||
mockCart.stripeCustomerId
|
||||
);
|
||||
expect(customerManager.update).toHaveBeenCalledWith(mockCustomer.id, {
|
||||
invoice_settings: { default_payment_method: mockPaymentMethod.id },
|
||||
});
|
||||
expect(cartService.finalizeProcessingCart).toHaveBeenCalledWith(
|
||||
mockCart.id
|
||||
);
|
||||
});
|
||||
it('processes stripe subscriptions that need further input from the user', async () => {
|
||||
const mockCart = ResultCartFactory({ state: CartState.PROCESSING });
|
||||
const mockInvoice = StripeResponseFactory(StripeInvoiceFactory());
|
||||
const mockSubscription = StripeResponseFactory(
|
||||
StripeSubscriptionFactory({
|
||||
latest_invoice: mockInvoice.id,
|
||||
})
|
||||
);
|
||||
const mockPaymentMethod = StripeResponseFactory(
|
||||
StripePaymentMethodFactory()
|
||||
);
|
||||
const mockPaymentIntent = StripeResponseFactory(
|
||||
StripePaymentIntentFactory({
|
||||
status: 'requires_action',
|
||||
payment_method: mockPaymentMethod.id,
|
||||
})
|
||||
);
|
||||
const mockCustomer = StripeResponseFactory(
|
||||
StripeCustomerFactory({
|
||||
invoice_settings: {
|
||||
custom_fields: null,
|
||||
default_payment_method: mockPaymentMethod.id,
|
||||
footer: null,
|
||||
rendering_options: null,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'retrieve')
|
||||
.mockResolvedValue(mockSubscription);
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'getLatestPaymentIntent')
|
||||
.mockResolvedValue(mockPaymentIntent);
|
||||
jest.spyOn(invoiceManager, 'retrieve').mockResolvedValue(mockInvoice);
|
||||
jest.spyOn(invoiceManager, 'processPayPalInvoice').mockResolvedValue();
|
||||
jest.spyOn(customerManager, 'retrieve').mockResolvedValue(mockCustomer);
|
||||
jest.spyOn(customerManager, 'update').mockResolvedValue(mockCustomer);
|
||||
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
|
||||
jest.spyOn(cartManager, 'setNeedsInputCart').mockResolvedValue();
|
||||
jest.spyOn(cartManager, 'setProcessingCart').mockResolvedValue();
|
||||
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();
|
||||
jest.spyOn(cartService, 'finalizeProcessingCart').mockResolvedValue();
|
||||
|
||||
await cartService.orchestrateCheckoutProcess(mockCart.id, mockDelay);
|
||||
expect(cartManager.fetchCartById).toHaveBeenCalledWith(mockCart.id);
|
||||
expect(subscriptionManager.retrieve).toHaveBeenCalledWith(
|
||||
mockCart.stripeSubscriptionId
|
||||
);
|
||||
expect(subscriptionManager.getLatestPaymentIntent).toHaveBeenCalledWith(
|
||||
mockSubscription
|
||||
);
|
||||
expect(invoiceManager.retrieve).not.toHaveBeenCalled();
|
||||
expect(invoiceManager.processPayPalInvoice).not.toHaveBeenCalled();
|
||||
|
||||
expect(cartManager.setNeedsInputCart).toHaveBeenCalledWith(mockCart.id);
|
||||
expect(cartManager.setProcessingCart).not.toHaveBeenCalled();
|
||||
expect(cartManager.finishErrorCart).not.toHaveBeenCalled();
|
||||
expect(customerManager.retrieve).not.toHaveBeenCalled();
|
||||
expect(customerManager.update).not.toHaveBeenCalled();
|
||||
expect(cartService.finalizeProcessingCart).not.toHaveBeenCalled();
|
||||
});
|
||||
it('times out for payment intents that do not proceed', async () => {
|
||||
const mockCart = ResultCartFactory({ state: CartState.PROCESSING });
|
||||
const mockInvoice = StripeResponseFactory(StripeInvoiceFactory());
|
||||
const mockSubscription = StripeResponseFactory(
|
||||
StripeSubscriptionFactory({
|
||||
latest_invoice: mockInvoice.id,
|
||||
})
|
||||
);
|
||||
const mockPaymentMethod = StripeResponseFactory(
|
||||
StripePaymentMethodFactory()
|
||||
);
|
||||
const mockPaymentIntent = StripeResponseFactory(
|
||||
StripePaymentIntentFactory({
|
||||
status: 'processing',
|
||||
payment_method: mockPaymentMethod.id,
|
||||
})
|
||||
);
|
||||
const mockCustomer = StripeResponseFactory(
|
||||
StripeCustomerFactory({
|
||||
invoice_settings: {
|
||||
custom_fields: null,
|
||||
default_payment_method: mockPaymentMethod.id,
|
||||
footer: null,
|
||||
rendering_options: null,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'retrieve')
|
||||
.mockResolvedValue(mockSubscription);
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'getLatestPaymentIntent')
|
||||
.mockResolvedValue(mockPaymentIntent);
|
||||
jest.spyOn(invoiceManager, 'retrieve').mockResolvedValue(mockInvoice);
|
||||
jest.spyOn(invoiceManager, 'processPayPalInvoice').mockResolvedValue();
|
||||
jest.spyOn(customerManager, 'retrieve').mockResolvedValue(mockCustomer);
|
||||
jest.spyOn(customerManager, 'update').mockResolvedValue(mockCustomer);
|
||||
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
|
||||
jest.spyOn(cartManager, 'setNeedsInputCart').mockResolvedValue();
|
||||
jest.spyOn(cartManager, 'setProcessingCart').mockResolvedValue();
|
||||
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();
|
||||
jest.spyOn(cartService, 'finalizeProcessingCart').mockResolvedValue();
|
||||
|
||||
await cartService.orchestrateCheckoutProcess(mockCart.id, mockDelay, 200);
|
||||
expect(cartManager.fetchCartById).toHaveBeenCalledWith(mockCart.id);
|
||||
expect(subscriptionManager.retrieve).toHaveBeenCalledWith(
|
||||
mockCart.stripeSubscriptionId
|
||||
);
|
||||
expect(subscriptionManager.getLatestPaymentIntent).toHaveBeenCalledWith(
|
||||
mockSubscription
|
||||
);
|
||||
expect(invoiceManager.retrieve).not.toHaveBeenCalled();
|
||||
expect(invoiceManager.processPayPalInvoice).not.toHaveBeenCalled();
|
||||
|
||||
expect(cartManager.setNeedsInputCart).not.toHaveBeenCalled();
|
||||
expect(cartManager.setProcessingCart).not.toHaveBeenCalled();
|
||||
expect(cartManager.finishErrorCart).toHaveBeenCalledWith(mockCart.id, {
|
||||
errorReasonId: CartErrorReasonId.Unknown,
|
||||
});
|
||||
expect(customerManager.retrieve).not.toHaveBeenCalled();
|
||||
expect(customerManager.update).not.toHaveBeenCalled();
|
||||
expect(cartService.finalizeProcessingCart).not.toHaveBeenCalled();
|
||||
});
|
||||
it('cancels the cart for failed payment intents', async () => {
|
||||
const mockCart = ResultCartFactory({ state: CartState.PROCESSING });
|
||||
const mockInvoice = StripeResponseFactory(StripeInvoiceFactory());
|
||||
const mockSubscription = StripeResponseFactory(
|
||||
StripeSubscriptionFactory({
|
||||
latest_invoice: mockInvoice.id,
|
||||
})
|
||||
);
|
||||
const mockPaymentMethod = StripeResponseFactory(
|
||||
StripePaymentMethodFactory()
|
||||
);
|
||||
const mockPaymentIntent = StripeResponseFactory(
|
||||
StripePaymentIntentFactory({
|
||||
status: 'canceled',
|
||||
payment_method: mockPaymentMethod.id,
|
||||
})
|
||||
);
|
||||
const mockCustomer = StripeResponseFactory(
|
||||
StripeCustomerFactory({
|
||||
invoice_settings: {
|
||||
custom_fields: null,
|
||||
default_payment_method: mockPaymentMethod.id,
|
||||
footer: null,
|
||||
rendering_options: null,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'retrieve')
|
||||
.mockResolvedValue(mockSubscription);
|
||||
jest
|
||||
.spyOn(subscriptionManager, 'getLatestPaymentIntent')
|
||||
.mockResolvedValue(mockPaymentIntent);
|
||||
jest.spyOn(invoiceManager, 'retrieve').mockResolvedValue(mockInvoice);
|
||||
jest.spyOn(invoiceManager, 'processPayPalInvoice').mockResolvedValue();
|
||||
jest.spyOn(customerManager, 'retrieve').mockResolvedValue(mockCustomer);
|
||||
jest.spyOn(customerManager, 'update').mockResolvedValue(mockCustomer);
|
||||
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
|
||||
jest.spyOn(cartManager, 'setNeedsInputCart').mockResolvedValue();
|
||||
jest.spyOn(cartManager, 'setProcessingCart').mockResolvedValue();
|
||||
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();
|
||||
jest.spyOn(cartService, 'finalizeProcessingCart').mockResolvedValue();
|
||||
|
||||
await cartService.orchestrateCheckoutProcess(mockCart.id, mockDelay, 1);
|
||||
expect(cartManager.fetchCartById).toHaveBeenCalledWith(mockCart.id);
|
||||
expect(subscriptionManager.retrieve).toHaveBeenCalledWith(
|
||||
mockCart.stripeSubscriptionId
|
||||
);
|
||||
expect(subscriptionManager.getLatestPaymentIntent).toHaveBeenCalledWith(
|
||||
mockSubscription
|
||||
);
|
||||
expect(invoiceManager.retrieve).not.toHaveBeenCalled();
|
||||
expect(invoiceManager.processPayPalInvoice).not.toHaveBeenCalled();
|
||||
|
||||
expect(cartManager.setNeedsInputCart).not.toHaveBeenCalled();
|
||||
expect(cartManager.setProcessingCart).not.toHaveBeenCalled();
|
||||
expect(cartManager.finishErrorCart).toHaveBeenCalledWith(mockCart.id, {
|
||||
errorReasonId: CartErrorReasonId.Unknown,
|
||||
});
|
||||
expect(customerManager.retrieve).not.toHaveBeenCalled();
|
||||
expect(customerManager.update).not.toHaveBeenCalled();
|
||||
expect(cartService.finalizeProcessingCart).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -27,9 +27,12 @@ import { GeoDBManager } from '@fxa/shared/geodb';
|
|||
import { CartManager } from './cart.manager';
|
||||
import {
|
||||
CheckoutCustomerData,
|
||||
GetNeedsInputResponse,
|
||||
NeedsInputType,
|
||||
NoInputNeededResponse,
|
||||
PaymentInfo,
|
||||
PollCartResponse,
|
||||
ResultCart,
|
||||
StripeHandleNextActionResponse,
|
||||
SuccessCart,
|
||||
UpdateCart,
|
||||
WithContextCart,
|
||||
|
@ -238,6 +241,8 @@ export class CartService {
|
|||
errorReasonId: CartErrorReasonId.Unknown,
|
||||
});
|
||||
});
|
||||
|
||||
this.orchestrateCheckoutProcess(cartId);
|
||||
}
|
||||
|
||||
async checkoutCartWithPaypal(
|
||||
|
@ -269,60 +274,19 @@ export class CartService {
|
|||
errorReasonId: CartErrorReasonId.Unknown,
|
||||
});
|
||||
});
|
||||
|
||||
this.orchestrateCheckoutProcess(cartId);
|
||||
}
|
||||
|
||||
/**
|
||||
* return the cart state, and the stripe client secret if the cart has a
|
||||
* stripe paymentIntent with `requires_action` actions for the client to handle
|
||||
*/
|
||||
async pollCart(cartId: string): Promise<PollCartResponse> {
|
||||
async cancelCartCheckout(cartId: string) {
|
||||
const cart = await this.cartManager.fetchCartById(cartId);
|
||||
|
||||
// respect cart state set elsewhere
|
||||
if (cart.state === CartState.FAIL || cart.state === CartState.SUCCESS) {
|
||||
return { cartState: cart.state };
|
||||
const promises: Promise<any>[] = [
|
||||
this.finalizeCartWithError(cartId, CartErrorReasonId.Unknown),
|
||||
];
|
||||
if (cart.stripeSubscriptionId) {
|
||||
promises.push(this.subscriptionManager.cancel(cart.stripeSubscriptionId));
|
||||
}
|
||||
|
||||
if (!cart.stripeSubscriptionId) {
|
||||
return { cartState: cart.state };
|
||||
}
|
||||
|
||||
const subscription = await this.subscriptionManager.retrieve(
|
||||
cart.stripeSubscriptionId
|
||||
);
|
||||
if (!subscription) {
|
||||
return { cartState: cart.state };
|
||||
}
|
||||
|
||||
// PayPal payment method collection
|
||||
if (subscription.collection_method === 'send_invoice') {
|
||||
if (!cart.stripeCustomerId) {
|
||||
throw new CartError('Invalid stripe customer id on cart', {
|
||||
cartId,
|
||||
});
|
||||
}
|
||||
if (subscription.latest_invoice) {
|
||||
const invoice = await this.invoiceManager.retrieve(
|
||||
subscription.latest_invoice
|
||||
);
|
||||
await this.invoiceManager.processPayPalInvoice(invoice);
|
||||
}
|
||||
|
||||
return { cartState: cart.state };
|
||||
}
|
||||
|
||||
// Stripe payment method collection
|
||||
const paymentIntent =
|
||||
await this.subscriptionManager.processStripeSubscription(subscription);
|
||||
|
||||
if (paymentIntent.status === 'requires_action') {
|
||||
return {
|
||||
cartState: cart.state,
|
||||
stripeClientSecret: paymentIntent.client_secret ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return { cartState: cart.state };
|
||||
await Promise.all([promises]);
|
||||
}
|
||||
|
||||
async finalizeProcessingCart(cartId: string): Promise<void> {
|
||||
|
@ -509,4 +473,165 @@ export class CartService {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async orchestrateCheckoutProcess(
|
||||
cartId: string,
|
||||
delayFn?: (ms: number) => Promise<void>,
|
||||
timeoutMs = 120000
|
||||
) {
|
||||
if (!delayFn) {
|
||||
delayFn = (ms: number) => new Promise((res) => setTimeout(res, ms));
|
||||
}
|
||||
|
||||
let msDelay = 500;
|
||||
const startTime = Date.now();
|
||||
let isPolling = true;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
if (!isPolling) return;
|
||||
|
||||
const cart = await this.cartManager.fetchCartById(cartId);
|
||||
if (cart.state === CartState.FAIL || cart.state === CartState.SUCCESS) {
|
||||
return;
|
||||
}
|
||||
if (!cart.stripeSubscriptionId) {
|
||||
throw new CartSubscriptionNotFoundError(cartId);
|
||||
}
|
||||
const subscription = await this.subscriptionManager.retrieve(
|
||||
cart.stripeSubscriptionId
|
||||
);
|
||||
if (!subscription) {
|
||||
throw new CartSubscriptionNotFoundError(cartId);
|
||||
}
|
||||
const paymentIntent =
|
||||
await this.subscriptionManager.getLatestPaymentIntent(subscription);
|
||||
if (!paymentIntent) {
|
||||
throw new CartError(
|
||||
`PaymentIntent not found for subscription ${subscription.id}`,
|
||||
{ cartId }
|
||||
);
|
||||
}
|
||||
|
||||
// Process and retry PayPal payment
|
||||
if (
|
||||
subscription.collection_method === 'send_invoice' &&
|
||||
subscription.latest_invoice
|
||||
) {
|
||||
const invoice = await this.invoiceManager.retrieve(
|
||||
subscription.latest_invoice
|
||||
);
|
||||
await this.invoiceManager.processPayPalInvoice(invoice);
|
||||
}
|
||||
|
||||
// Update cart state
|
||||
switch (paymentIntent.status) {
|
||||
case 'requires_action':
|
||||
await this.cartManager.setNeedsInputCart(cartId);
|
||||
return;
|
||||
case 'succeeded':
|
||||
const customer = cart.stripeCustomerId
|
||||
? await this.customerManager.retrieve(cart.stripeCustomerId)
|
||||
: undefined;
|
||||
if (customer && paymentIntent?.payment_method) {
|
||||
await this.customerManager.update(customer.id, {
|
||||
invoice_settings: {
|
||||
default_payment_method: paymentIntent.payment_method,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
throw new CartError(
|
||||
'Failed to update customer default payment method',
|
||||
{ cartId }
|
||||
);
|
||||
}
|
||||
await this.finalizeProcessingCart(cartId);
|
||||
return;
|
||||
case 'requires_capture':
|
||||
case 'requires_confirmation':
|
||||
case 'processing':
|
||||
msDelay = msDelay * 2;
|
||||
if (cart.state !== CartState.PROCESSING) {
|
||||
await this.cartManager.setProcessingCart(cartId);
|
||||
}
|
||||
break;
|
||||
case 'canceled':
|
||||
case 'requires_payment_method':
|
||||
default:
|
||||
await this.cartManager.finishErrorCart(cartId, {
|
||||
errorReasonId: CartErrorReasonId.Unknown,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// Allow unhandled errors to retry
|
||||
console.error(error);
|
||||
msDelay = msDelay * 2;
|
||||
}
|
||||
|
||||
// Time out after 2 minutes of cart being in the processing state
|
||||
if (Date.now() - startTime > timeoutMs) {
|
||||
isPolling = false;
|
||||
await this.cartManager.finishErrorCart(cartId, {
|
||||
errorReasonId: CartErrorReasonId.Unknown,
|
||||
});
|
||||
} else {
|
||||
await delayFn(msDelay);
|
||||
await poll();
|
||||
}
|
||||
};
|
||||
|
||||
await poll();
|
||||
}
|
||||
|
||||
async getNeedsInput(cartId: string): Promise<GetNeedsInputResponse> {
|
||||
const cart = await this.cartManager.fetchCartById(cartId);
|
||||
|
||||
if (cart.state !== CartState.NEEDS_INPUT) {
|
||||
throw new CartInvalidStateForActionError(
|
||||
cartId,
|
||||
cart.state,
|
||||
'getNeedsInput'
|
||||
);
|
||||
}
|
||||
|
||||
if (!cart.stripeSubscriptionId) {
|
||||
throw new CartSubscriptionNotFoundError(cartId);
|
||||
}
|
||||
|
||||
const subscription = await this.subscriptionManager.retrieve(
|
||||
cart.stripeSubscriptionId
|
||||
);
|
||||
if (!subscription) {
|
||||
throw new CartSubscriptionNotFoundError(cartId);
|
||||
}
|
||||
|
||||
const paymentIntent =
|
||||
await this.subscriptionManager.processStripeSubscription(subscription);
|
||||
|
||||
if (paymentIntent.status === 'requires_action') {
|
||||
return {
|
||||
inputType: NeedsInputType.StripeHandleNextAction,
|
||||
data: { clientSecret: paymentIntent.client_secret },
|
||||
} as StripeHandleNextActionResponse;
|
||||
}
|
||||
|
||||
return { inputType: NeedsInputType.NotRequired } as NoInputNeededResponse;
|
||||
}
|
||||
|
||||
async submitNeedsInput(cartId: string) {
|
||||
const cart = await this.cartManager.fetchCartById(cartId);
|
||||
|
||||
if (cart.state !== CartState.NEEDS_INPUT) {
|
||||
throw new CartInvalidStateForActionError(
|
||||
cartId,
|
||||
cart.state,
|
||||
'submitNeedsInput'
|
||||
);
|
||||
}
|
||||
|
||||
await this.cartManager.setProcessingCart(cartId);
|
||||
|
||||
this.orchestrateCheckoutProcess(cartId);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -106,7 +106,24 @@ export type CartEligibilityDetails = {
|
|||
errorReasonId?: CartErrorReasonId;
|
||||
};
|
||||
|
||||
export type PollCartResponse = {
|
||||
cartState: CartState;
|
||||
stripeClientSecret?: string;
|
||||
export enum NeedsInputType {
|
||||
StripeHandleNextAction = 'stripeHandleNextAction',
|
||||
NotRequired = 'notRequired',
|
||||
}
|
||||
type StripeHandleNextActionData = {
|
||||
clientSecret: string;
|
||||
};
|
||||
|
||||
export type GetNeedsInputResponse = {
|
||||
inputType: NeedsInputType;
|
||||
data: StripeHandleNextActionData;
|
||||
};
|
||||
|
||||
export interface StripeHandleNextActionResponse extends GetNeedsInputResponse {
|
||||
inputType: NeedsInputType.StripeHandleNextAction;
|
||||
data: StripeHandleNextActionData;
|
||||
}
|
||||
|
||||
export interface NoInputNeededResponse extends GetNeedsInputResponse {
|
||||
inputType: NeedsInputType.NotRequired;
|
||||
}
|
||||
|
|
|
@ -48,4 +48,22 @@ describe('PaymentIntentManager', () => {
|
|||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retrieve', () => {
|
||||
it('should retrieve a payment intent', async () => {
|
||||
const mockPaymentIntent = StripePaymentIntentFactory();
|
||||
const mockResponse = StripeResponseFactory(mockPaymentIntent);
|
||||
|
||||
jest
|
||||
.spyOn(stripeClient, 'paymentIntentRetrieve')
|
||||
.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await paymentIntentManager.retrieve(mockPaymentIntent.id);
|
||||
|
||||
expect(stripeClient.paymentIntentRetrieve).toHaveBeenCalledWith(
|
||||
mockPaymentIntent.id
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -16,4 +16,8 @@ export class PaymentIntentManager {
|
|||
) {
|
||||
return this.stripeClient.paymentIntentConfirm(paymentIntentId, params);
|
||||
}
|
||||
|
||||
async retrieve(paymentIntentId: string) {
|
||||
return this.stripeClient.paymentIntentRetrieve(paymentIntentId);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,18 +8,16 @@ export * from './lib/client/components/BaseButton';
|
|||
export * from './lib/client/components/CheckoutForm';
|
||||
export * from './lib/client/components/CheckoutCheckbox';
|
||||
export * from './lib/client/components/CouponForm';
|
||||
export * from './lib/client/components/PaymentStateHandler';
|
||||
export * from './lib/client/components/PaymentSection';
|
||||
export * from './lib/client/components/PurchaseDetails';
|
||||
export * from './lib/client/components/SignInForm';
|
||||
export * from './lib/client/components/SubmitButton';
|
||||
export * from './lib/client/components/LoadingSpinner';
|
||||
export * from './lib/client/components/MetricsWrapper';
|
||||
export * from './lib/client/components/CartPoller';
|
||||
export * from './lib/client/components/PollingSection';
|
||||
export * from './lib/client/components/StripeWrapper';
|
||||
export * from './lib/client/providers/Providers';
|
||||
export * from './lib/utils/helpers';
|
||||
export * from './lib/utils/types';
|
||||
export * from './lib/utils/get-cart';
|
||||
export * from './lib/utils/poll-cart';
|
||||
export * from './lib/utils/buildRedirectUrl';
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
/* 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/. */
|
||||
|
||||
'use server';
|
||||
|
||||
import { plainToClass } from 'class-transformer';
|
||||
import { getApp } from '../nestapp/app';
|
||||
import { CancelCartCheckoutActionArgs } from '../nestapp/validators/CancelCartCheckoutActionArgs';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { SupportedPages } from '../utils/types';
|
||||
|
||||
export const cancelCartCheckoutAction = async (cartId: string) => {
|
||||
await getApp().getActionsService().cancelCartCheckout(
|
||||
plainToClass(CancelCartCheckoutActionArgs, {
|
||||
cartId,
|
||||
})
|
||||
);
|
||||
redirect(SupportedPages.ERROR);
|
||||
};
|
|
@ -0,0 +1,17 @@
|
|||
/* 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/. */
|
||||
|
||||
'use server';
|
||||
|
||||
import { getApp } from '../nestapp/app';
|
||||
import { plainToClass } from 'class-transformer';
|
||||
import { GetNeedsInputActionArgs } from '../nestapp/validators/GetNeedsInputActionArgs';
|
||||
|
||||
export const getNeedsInputAction = async (cartId: string) => {
|
||||
const inputNeeded = getApp()
|
||||
.getActionsService()
|
||||
.getNeedsInput(plainToClass(GetNeedsInputActionArgs, { cartId }));
|
||||
|
||||
return inputNeeded;
|
||||
};
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
export { checkoutCartWithPaypal } from './checkoutCartWithPaypal';
|
||||
export { checkoutCartWithStripe } from './checkoutCartWithStripe';
|
||||
export { cancelCartCheckoutAction } from './cancelCartCheckout';
|
||||
export { fetchCMSData } from './fetchCMSData';
|
||||
export { getCartAction } from './getCart';
|
||||
export { getCartOrRedirectAction } from './getCartOrRedirect';
|
||||
|
@ -13,6 +14,7 @@ export { recordEmitterEventAction } from './recordEmitterEvent';
|
|||
export { restartCartAction } from './restartCart';
|
||||
export { setupCartAction } from './setupCart';
|
||||
export { updateCartAction } from './updateCart';
|
||||
export { pollCartAction } from './pollCart';
|
||||
export { finalizeCartWithError } from './finalizeCartWithError';
|
||||
export { finalizeProcessingCartAction } from './finalizeProcessingCart';
|
||||
export { getNeedsInputAction } from './getNeedsInput';
|
||||
export { submitNeedsInputAction } from './submitNeedsInput';
|
||||
|
|
|
@ -4,16 +4,12 @@
|
|||
|
||||
'use server';
|
||||
|
||||
import { plainToClass } from 'class-transformer';
|
||||
import { getApp } from '../nestapp/app';
|
||||
import { PollCartActionArgs } from '../nestapp/validators/pollCartActionArgs';
|
||||
import { plainToClass } from 'class-transformer';
|
||||
import { SubmitNeedsInputActionArgs } from '../nestapp/validators/SubmitNeedsInputActionArgs';
|
||||
|
||||
export const pollCartAction = async (cartId: string) => {
|
||||
const cart = await getApp().getActionsService().pollCart(
|
||||
plainToClass(PollCartActionArgs, {
|
||||
cartId,
|
||||
})
|
||||
);
|
||||
|
||||
return cart;
|
||||
export const submitNeedsInputAction = async (cartId: string) => {
|
||||
return getApp()
|
||||
.getActionsService()
|
||||
.submitNeedsInput(plainToClass(SubmitNeedsInputActionArgs, { cartId }));
|
||||
};
|
|
@ -1,61 +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/. */
|
||||
|
||||
'use client';
|
||||
|
||||
import { CheckoutParams, pollCart, SupportedPages } from '@fxa/payments/ui';
|
||||
import {
|
||||
finalizeCartWithError,
|
||||
getCartAction,
|
||||
getCartOrRedirectAction,
|
||||
} from '@fxa/payments/ui/actions';
|
||||
import { CartErrorReasonId } from '@fxa/shared/db/mysql/account/kysely-types';
|
||||
import { useEffect } from 'react';
|
||||
import { useStripe } from '@stripe/react-stripe-js';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
|
||||
|
||||
export function CartPoller() {
|
||||
const stripe = useStripe();
|
||||
const checkoutParams: CheckoutParams = useParams();
|
||||
|
||||
useEffect(() => {
|
||||
let retries = 0;
|
||||
let isPolling = true;
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!isPolling) return;
|
||||
|
||||
try {
|
||||
retries = await pollCart(checkoutParams, retries, stripe);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
retries += 1;
|
||||
}
|
||||
|
||||
if (retries > 5) {
|
||||
isPolling = false;
|
||||
const cart = await getCartAction(checkoutParams.cartId);
|
||||
await finalizeCartWithError(cart.id, CartErrorReasonId.BASIC_ERROR);
|
||||
await getCartOrRedirectAction(
|
||||
checkoutParams.cartId,
|
||||
SupportedPages.PROCESSING
|
||||
);
|
||||
} else {
|
||||
await delay(Math.pow(10, retries));
|
||||
fetchData();
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
|
||||
return () => {
|
||||
// Cleanup to stop polling if the component unmounts
|
||||
isPolling = false;
|
||||
};
|
||||
}, [stripe]);
|
||||
|
||||
return <></>;
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
/* 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/. */
|
||||
|
||||
'use client';
|
||||
|
||||
import { SupportedPages } from '@fxa/payments/ui';
|
||||
import {
|
||||
cancelCartCheckoutAction,
|
||||
getCartOrRedirectAction,
|
||||
getNeedsInputAction,
|
||||
submitNeedsInputAction,
|
||||
} from '@fxa/payments/ui/actions';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useStripe } from '@stripe/react-stripe-js';
|
||||
import type { WithContextCart } from '@fxa/payments/cart';
|
||||
|
||||
const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
|
||||
|
||||
export function PaymentStateHandler({ cartId }: { cartId: string }) {
|
||||
const stripe = useStripe();
|
||||
|
||||
const [cart, setCart] = useState<WithContextCart | null>(null);
|
||||
const [collectingInput, setCollectingInput] = useState(false);
|
||||
useEffect(() => {
|
||||
let msDelay = 500;
|
||||
let startTime = Date.now();
|
||||
let isPolling = true;
|
||||
|
||||
const poll = async () => {
|
||||
if (!isPolling) return;
|
||||
|
||||
const cart = await getCartOrRedirectAction(
|
||||
cartId,
|
||||
SupportedPages.PROCESSING
|
||||
);
|
||||
setCart(cart);
|
||||
console.log('cart.state:', cart.state);
|
||||
|
||||
if (cart.state === 'needs_input') {
|
||||
// poll once per second while in the needs_input state, and reset the timeout
|
||||
startTime = Date.now();
|
||||
msDelay = 1000;
|
||||
} else {
|
||||
msDelay = msDelay * 2;
|
||||
}
|
||||
|
||||
// Time out after 2 minutes of cart being in the processing state
|
||||
if (Date.now() - startTime > 120000) {
|
||||
isPolling = false;
|
||||
await cancelCartCheckoutAction(cartId);
|
||||
} else {
|
||||
await delay(msDelay);
|
||||
poll();
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
|
||||
return () => {
|
||||
// Cleanup to stop polling if the component unmounts
|
||||
isPolling = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle the needs_input state
|
||||
useEffect(() => {
|
||||
console.log('checking if we need to handleNextAction');
|
||||
if (cart?.state !== 'needs_input' || !stripe || collectingInput) {
|
||||
return;
|
||||
}
|
||||
console.log('calling handleNextAction');
|
||||
const handleNextAction = async () => {
|
||||
setCollectingInput(true);
|
||||
|
||||
const inputRequest = await getNeedsInputAction(cartId);
|
||||
switch (inputRequest.inputType) {
|
||||
case 'stripeHandleNextAction':
|
||||
const { error, paymentIntent } = await stripe.handleNextAction({
|
||||
clientSecret: inputRequest.data.clientSecret,
|
||||
});
|
||||
|
||||
// return to polling if the paymentIntent is out of the requires_action state. Cancel checkout on failed payment intents.
|
||||
if (
|
||||
!error &&
|
||||
(paymentIntent?.status === 'succeeded' ||
|
||||
paymentIntent?.status === 'processing')
|
||||
) {
|
||||
console.log('submitting needs input');
|
||||
await submitNeedsInputAction(cartId);
|
||||
} else if (paymentIntent?.status === 'requires_action') {
|
||||
console.log('paymentIntent requires action');
|
||||
handleNextAction();
|
||||
} else {
|
||||
console.log('paymentIntent failed');
|
||||
await cancelCartCheckoutAction(cartId);
|
||||
}
|
||||
break;
|
||||
case 'notRequired':
|
||||
break;
|
||||
}
|
||||
setCollectingInput(false);
|
||||
};
|
||||
handleNextAction();
|
||||
}, [stripe, cart?.state]);
|
||||
|
||||
return <>{}</>;
|
||||
}
|
|
@ -1,31 +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/. */
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { WithContextCart } from '@fxa/payments/cart';
|
||||
import { getCartOrRedirectAction } from '@fxa/payments/ui/actions';
|
||||
import { SupportedPages } from '@fxa/payments/ui';
|
||||
import { StripeWrapper } from '../StripeWrapper';
|
||||
import { CartPoller } from '../CartPoller';
|
||||
|
||||
export const PollingSection = ({ cartId }: { cartId: string }) => {
|
||||
const [cart, setCart] = useState<WithContextCart | null>(null);
|
||||
useEffect(() => {
|
||||
getCartOrRedirectAction(cartId, SupportedPages.PROCESSING).then((cart) => {
|
||||
setCart(cart);
|
||||
});
|
||||
}, []);
|
||||
if (cart && cart.currency) {
|
||||
return (
|
||||
<StripeWrapper
|
||||
amount={cart.amount}
|
||||
currency={cart.currency.toLowerCase()}
|
||||
>
|
||||
<CartPoller />
|
||||
</StripeWrapper>
|
||||
);
|
||||
} else return <></>;
|
||||
};
|
|
@ -10,7 +10,7 @@ import { ConfigContext } from '../providers/ConfigProvider';
|
|||
|
||||
interface StripeWrapperProps {
|
||||
amount: number;
|
||||
currency: string;
|
||||
currency?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
|
|
@ -21,7 +21,9 @@ import { UpdateCartActionArgs } from './validators/UpdateCartActionArgs';
|
|||
import { RecordEmitterEventArgs } from './validators/RecordEmitterEvent';
|
||||
import { PaymentsEmitterService } from '../emitter/emitter.service';
|
||||
import { FinalizeProcessingCartActionArgs } from './validators/finalizeProcessingCartActionArgs';
|
||||
import { PollCartActionArgs } from './validators/pollCartActionArgs';
|
||||
import { CancelCartCheckoutActionArgs } from './validators/CancelCartCheckoutActionArgs';
|
||||
import { SubmitNeedsInputActionArgs } from './validators/SubmitNeedsInputActionArgs';
|
||||
import { GetNeedsInputActionArgs } from './validators/GetNeedsInputActionArgs';
|
||||
|
||||
/**
|
||||
* ANY AND ALL methods exposed via this service should be considered publicly accessible and callable with any arguments.
|
||||
|
@ -81,14 +83,6 @@ export class NextJSActionsService {
|
|||
return cart;
|
||||
}
|
||||
|
||||
async pollCart(args: PollCartActionArgs) {
|
||||
await new Validator().validateOrReject(args);
|
||||
|
||||
const cart = await this.cartService.pollCart(args.cartId);
|
||||
|
||||
return cart;
|
||||
}
|
||||
|
||||
async finalizeCartWithError(args: FinalizeCartWithErrorArgs) {
|
||||
await new Validator().validateOrReject(args);
|
||||
|
||||
|
@ -134,6 +128,12 @@ export class NextJSActionsService {
|
|||
);
|
||||
}
|
||||
|
||||
async cancelCartCheckout(args: CancelCartCheckoutActionArgs) {
|
||||
await new Validator().validateOrReject(args);
|
||||
|
||||
await this.cartService.cancelCartCheckout(args.cartId);
|
||||
}
|
||||
|
||||
async fetchCMSData(args: FetchCMSDataArgs) {
|
||||
await new Validator().validateOrReject(args);
|
||||
|
||||
|
@ -172,4 +172,15 @@ export class NextJSActionsService {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getNeedsInput(args: GetNeedsInputActionArgs) {
|
||||
await new Validator().validateOrReject(args);
|
||||
|
||||
return await this.cartService.getNeedsInput(args.cartId);
|
||||
}
|
||||
async submitNeedsInput(args: SubmitNeedsInputActionArgs) {
|
||||
await new Validator().validateOrReject(args);
|
||||
|
||||
return await this.cartService.submitNeedsInput(args.cartId);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
/* 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 { IsString } from 'class-validator';
|
||||
|
||||
export class CancelCartCheckoutActionArgs {
|
||||
@IsString()
|
||||
cartId!: string;
|
||||
}
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class PollCartActionArgs {
|
||||
export class GetNeedsInputActionArgs {
|
||||
@IsString()
|
||||
cartId!: string;
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
/* 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 { IsString } from 'class-validator';
|
||||
|
||||
export class SubmitNeedsInputActionArgs {
|
||||
@IsString()
|
||||
cartId!: string;
|
||||
}
|
|
@ -1,67 +0,0 @@
|
|||
import { SupportedPages } from './types';
|
||||
import type { Stripe } from '@stripe/stripe-js';
|
||||
import {
|
||||
pollCartAction,
|
||||
finalizeProcessingCartAction,
|
||||
finalizeCartWithError,
|
||||
getCartOrRedirectAction,
|
||||
} from '@fxa/payments/ui/actions';
|
||||
import { CartErrorReasonId } from '@fxa/shared/db/mysql/account/kysely-types';
|
||||
|
||||
export const pollCart = async (
|
||||
checkoutParams: {
|
||||
cartId: string;
|
||||
locale: string;
|
||||
interval: string;
|
||||
offeringId: string;
|
||||
},
|
||||
retries = 0,
|
||||
stripeClient: Stripe | null
|
||||
): Promise<number> => {
|
||||
const pollCartResponse = await pollCartAction(checkoutParams.cartId);
|
||||
|
||||
if (pollCartResponse.cartState !== 'processing') {
|
||||
await getCartOrRedirectAction(
|
||||
checkoutParams.cartId,
|
||||
SupportedPages.PROCESSING
|
||||
);
|
||||
} else if (
|
||||
pollCartResponse.cartState === 'processing' &&
|
||||
pollCartResponse.stripeClientSecret
|
||||
) {
|
||||
// Handle next action and restart the polling process
|
||||
if (!stripeClient) {
|
||||
return retries + 1;
|
||||
}
|
||||
const { error, paymentIntent } = await stripeClient.handleNextAction({
|
||||
clientSecret: pollCartResponse.stripeClientSecret,
|
||||
});
|
||||
if (error || !paymentIntent) {
|
||||
await finalizeCartWithError(
|
||||
checkoutParams.cartId,
|
||||
CartErrorReasonId.BASIC_ERROR
|
||||
);
|
||||
} else {
|
||||
if (paymentIntent.status === 'succeeded') {
|
||||
await finalizeProcessingCartAction(checkoutParams.cartId);
|
||||
} else if (
|
||||
paymentIntent.status === 'canceled' ||
|
||||
paymentIntent.status === 'requires_payment_method'
|
||||
) {
|
||||
await finalizeCartWithError(
|
||||
checkoutParams.cartId,
|
||||
CartErrorReasonId.BASIC_ERROR
|
||||
);
|
||||
} else {
|
||||
// TODO: handle other paymentIntent statuses. For now, retry
|
||||
retries += 1;
|
||||
}
|
||||
getCartOrRedirectAction(checkoutParams.cartId, SupportedPages.PROCESSING);
|
||||
return retries;
|
||||
}
|
||||
}
|
||||
|
||||
getCartOrRedirectAction(checkoutParams.cartId, SupportedPages.PROCESSING),
|
||||
(retries += 1);
|
||||
return retries;
|
||||
};
|
Загрузка…
Ссылка в новой задаче