This commit is contained in:
Davey Alvarez 2024-11-13 16:03:49 -08:00
Родитель e482c1b550
Коммит d30b430ad5
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: A538D290868DCC59
21 изменённых файлов: 951 добавлений и 346 удалений

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

@ -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;
};