diff --git a/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/error/page.tsx b/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/error/page.tsx
index 0d73205099c..acb196f215c 100644
--- a/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/error/page.tsx
+++ b/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/error/page.tsx
@@ -9,7 +9,11 @@ import Link from 'next/link';
import { DEFAULT_LOCALE } from '@fxa/shared/l10n';
import errorIcon from '@fxa/shared/assets/images/error.svg';
-import { SupportedPages, getApp } from '@fxa/payments/ui/server';
+import {
+ getApp,
+ CheckoutParams,
+ SupportedPages,
+} from '@fxa/payments/ui/server';
import {
getCartOrRedirectAction,
recordEmitterEventAction,
@@ -40,13 +44,6 @@ const getErrorReason = (reason: CartErrorReasonId | null) => {
}
};
-interface CheckoutParams {
- cartId: string;
- locale: string;
- interval: string;
- offeringId: string;
-}
-
export default async function CheckoutError({
params,
searchParams,
diff --git a/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/layout.tsx b/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/layout.tsx
index b10b239289d..9a97672d5c7 100644
--- a/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/layout.tsx
+++ b/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/layout.tsx
@@ -10,6 +10,7 @@ import {
PriceInterval,
SubscriptionTitle,
TermsAndPrivacy,
+ CheckoutParams,
} from '@fxa/payments/ui/server';
import { DEFAULT_LOCALE } from '@fxa/shared/l10n';
import { config } from 'apps/payments/next/config';
@@ -22,13 +23,6 @@ export const metadata = {
description: 'Mozilla accounts',
};
-export interface CheckoutParams {
- cartId: string;
- locale: string;
- interval: string;
- offeringId: string;
-}
-
export interface CheckoutSearchParams {
experiment?: string;
promotion_code?: string;
diff --git a/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/processing/page.tsx b/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/processing/page.tsx
index d568859a689..f015ba560bf 100644
--- a/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/processing/page.tsx
+++ b/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/processing/page.tsx
@@ -2,24 +2,28 @@
* 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 { headers } from 'next/headers';
-import { LoadingSpinner } from '@fxa/payments/ui';
+import {
+ CheckoutParams,
+ LoadingSpinner,
+ PollingSection,
+} from '@fxa/payments/ui';
import { getApp } from '@fxa/payments/ui/server';
+import { headers } from 'next/headers';
import { DEFAULT_LOCALE } from '@fxa/shared/l10n';
-export default async function ProcessingPage() {
+export default function ProcessingPage({ params }: { params: CheckoutParams }) {
const locale = headers().get('accept-language') || DEFAULT_LOCALE;
const l10n = getApp().getL10n(locale);
-
return (
+
{l10n.getString(
- 'payment-processing-message',
- 'Please wait while we process your payment…'
+ 'next-payment-processing-message',
+ `Please wait while we process your payment…`
)}
);
diff --git a/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/start/page.tsx b/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/start/page.tsx
index 546dbe1b686..7c40d1d9475 100644
--- a/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/start/page.tsx
+++ b/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/start/page.tsx
@@ -5,7 +5,7 @@
import { revalidatePath } from 'next/cache';
import { headers } from 'next/headers';
import { BaseButton, ButtonVariant, PaymentSection } from '@fxa/payments/ui';
-import { getApp, SupportedPages } from '@fxa/payments/ui/server';
+import { getApp, SupportedPages, CheckoutParams, } from '@fxa/payments/ui/server';
import { getCartOrRedirectAction } from '@fxa/payments/ui/actions';
import { DEFAULT_LOCALE } from '@fxa/shared/l10n';
import {
@@ -13,7 +13,6 @@ import {
getCMSContent,
} from 'apps/payments/next/app/_lib/apiClient';
import { auth, signIn } from 'apps/payments/next/auth';
-import { CheckoutParams } from '../layout';
export const dynamic = 'force-dynamic';
diff --git a/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/success/page.tsx b/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/success/page.tsx
index 724dba6df38..2b429286503 100644
--- a/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/success/page.tsx
+++ b/apps/payments/next/app/[locale]/[offeringId]/checkout/[interval]/[cartId]/success/page.tsx
@@ -16,6 +16,7 @@ import {
getCartOrRedirectAction,
recordEmitterEventAction,
} from '@fxa/payments/ui/actions';
+import { CheckoutParams } from '@fxa/payments/ui/server';
export const dynamic = 'force-dynamic';
@@ -41,13 +42,6 @@ const ConfirmationDetail = ({
);
};
-interface CheckoutParams {
- cartId: string;
- locale: string;
- interval: string;
- offeringId: string;
-}
-
export default async function CheckoutSuccess({
params,
searchParams,
diff --git a/apps/payments/next/app/[locale]/error.tsx b/apps/payments/next/app/[locale]/error.tsx
index 4d948adaba5..99abba89073 100644
--- a/apps/payments/next/app/[locale]/error.tsx
+++ b/apps/payments/next/app/[locale]/error.tsx
@@ -7,10 +7,9 @@
import { useEffect, useState } from 'react';
import * as Sentry from '@sentry/nextjs';
import { useRouter, useParams } from 'next/navigation';
-import { CheckoutParams } from './[offeringId]/checkout/[interval]/[cartId]/layout';
import Image from 'next/image';
import errorIcon from '@fxa/shared/assets/images/error.svg';
-import { LoadingSpinner } from '@fxa/payments/ui';
+import { CheckoutParams, LoadingSpinner } from '@fxa/payments/ui';
import Link from 'next/link';
import { Localized } from '@fluent/react';
import { restartCartAction, getCartAction } from '@fxa/payments/ui/actions';
diff --git a/libs/payments/cart/src/lib/cart.error.ts b/libs/payments/cart/src/lib/cart.error.ts
index 860bb0ea4ea..7ee19759155 100644
--- a/libs/payments/cart/src/lib/cart.error.ts
+++ b/libs/payments/cart/src/lib/cart.error.ts
@@ -133,3 +133,11 @@ export class CartInvalidCurrencyError extends CartError {
});
}
}
+
+export class CartSubscriptionNotFoundError extends CartError {
+ constructor(cartId: string) {
+ super('Cart subscription not found', {
+ cartId,
+ });
+ }
+}
diff --git a/libs/payments/cart/src/lib/cart.manager.ts b/libs/payments/cart/src/lib/cart.manager.ts
index 04f0043a169..401d1f86679 100644
--- a/libs/payments/cart/src/lib/cart.manager.ts
+++ b/libs/payments/cart/src/lib/cart.manager.ts
@@ -33,11 +33,12 @@ import type { AccountDatabase } from '@fxa/shared/db/mysql/account';
// For an action to be executed, the cart state needs to be in one of
// valid states listed in the array of CartStates below
const ACTIONS_VALID_STATE = {
- updateFreshCart: [CartState.START],
+ updateFreshCart: [CartState.START, CartState.PROCESSING],
finishCart: [CartState.PROCESSING],
finishErrorCart: [CartState.START, CartState.PROCESSING],
deleteCart: [CartState.START, CartState.PROCESSING],
restartCart: [CartState.START, CartState.PROCESSING, CartState.FAIL],
+ setProcessingCart: [CartState.START],
};
// Type guard to check if action is valid key in ACTIONS_VALID_STATE
@@ -180,6 +181,21 @@ export class CartManager {
}
}
+ public async setProcessingCart(cartId: string) {
+ const cart = await this.fetchCartById(cartId);
+
+ this.checkActionForValidCartState(cart, 'setProcessingCart');
+
+ try {
+ await updateCart(this.db, Buffer.from(cartId, 'hex'), cart.version, {
+ state: CartState.PROCESSING,
+ });
+ } catch (error) {
+ const cause = error instanceof CartNotUpdatedError ? undefined : error;
+ throw new CartNotUpdatedError(cartId, cause);
+ }
+ }
+
public async deleteCart(cart: ResultCart) {
this.checkActionForValidCartState(cart, 'deleteCart');
diff --git a/libs/payments/cart/src/lib/cart.service.spec.ts b/libs/payments/cart/src/lib/cart.service.spec.ts
index f46b13a2810..f5c54b4b823 100644
--- a/libs/payments/cart/src/lib/cart.service.spec.ts
+++ b/libs/payments/cart/src/lib/cart.service.spec.ts
@@ -37,6 +37,7 @@ import {
StripeResponseFactory,
MockStripeConfigProvider,
AccountCustomerManager,
+ StripePaymentIntentFactory,
} from '@fxa/payments/stripe';
import {
MockProfileClientConfigProvider,
@@ -214,6 +215,7 @@ describe('CartService', () => {
.spyOn(currencyManager, 'getCurrencyForCountry')
.mockReturnValue(mockResolvedCurrency);
jest.spyOn(cartManager, 'createCart').mockResolvedValue(mockResultCart);
+ jest.spyOn(accountManager, 'getAccounts').mockResolvedValue([]);
const result = await cartService.setupCart(args);
@@ -330,9 +332,15 @@ describe('CartService', () => {
it('accepts payment with stripe', async () => {
const mockCart = ResultCartFactory();
const mockPaymentMethodId = faker.string.uuid();
+ const mockPaymentIntent = StripePaymentIntentFactory({
+ payment_method: mockPaymentMethodId,
+ status: 'succeeded',
+ });
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
- jest.spyOn(checkoutService, 'payWithStripe').mockResolvedValue();
+ jest
+ .spyOn(checkoutService, 'payWithStripe')
+ .mockResolvedValue(mockPaymentIntent);
jest.spyOn(cartManager, 'finishCart').mockResolvedValue();
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();
@@ -359,9 +367,15 @@ describe('CartService', () => {
it('calls cartManager.finishErrorCart when error occurs during checkout', async () => {
const mockCart = ResultCartFactory();
const mockPaymentMethodId = faker.string.uuid();
+ const mockPaymentIntent = StripePaymentIntentFactory({
+ payment_method: mockPaymentMethodId,
+ status: 'succeeded',
+ });
jest.spyOn(cartManager, 'fetchCartById').mockResolvedValue(mockCart);
- jest.spyOn(checkoutService, 'payWithStripe').mockResolvedValue();
+ jest
+ .spyOn(checkoutService, 'payWithStripe')
+ .mockResolvedValue(mockPaymentIntent);
jest.spyOn(cartManager, 'finishCart').mockRejectedValue(undefined);
jest.spyOn(cartManager, 'finishErrorCart').mockResolvedValue();
diff --git a/libs/payments/cart/src/lib/cart.service.ts b/libs/payments/cart/src/lib/cart.service.ts
index 0b765d6d83a..7d555f15dfa 100644
--- a/libs/payments/cart/src/lib/cart.service.ts
+++ b/libs/payments/cart/src/lib/cart.service.ts
@@ -9,6 +9,7 @@ import {
InvoiceManager,
SubplatInterval,
PromotionCodeManager,
+ SubscriptionManager,
} from '@fxa/payments/customer';
import { EligibilityService } from '@fxa/payments/eligibility';
import {
@@ -24,6 +25,7 @@ import { GeoDBManager } from '@fxa/shared/geodb';
import { CartManager } from './cart.manager';
import {
CheckoutCustomerData,
+ PollCartResponse,
ResultCart,
UpdateCart,
WithContextCart,
@@ -31,8 +33,10 @@ import {
import { handleEligibilityStatusMap } from './cart.utils';
import { CheckoutService } from './checkout.service';
import {
+ CartError,
CartInvalidCurrencyError,
CartInvalidPromoCodeError,
+ CartSubscriptionNotFoundError,
} from './cart.error';
import { AccountManager } from '@fxa/shared/account/account';
@@ -49,7 +53,8 @@ export class CartService {
private eligibilityService: EligibilityService,
private geodbManager: GeoDBManager,
private invoiceManager: InvoiceManager,
- private productConfigurationManager: ProductConfigurationManager
+ private productConfigurationManager: ProductConfigurationManager,
+ private subscriptionManager: SubscriptionManager
) {}
/**
@@ -94,6 +99,12 @@ export class CartService {
args.interval
);
+ const fxaAccounts = args.uid
+ ? await this.accountManager.getAccounts([args.uid])
+ : undefined;
+ const fxaAccount =
+ fxaAccounts && fxaAccounts.length > 0 ? fxaAccounts[0] : undefined;
+
const [upcomingInvoice, eligibility] = await Promise.all([
this.invoiceManager.preview({
priceId: price.id,
@@ -136,6 +147,7 @@ export class CartService {
offeringConfigId: args.offeringConfigId,
amount: upcomingInvoice.subtotal,
uid: args.uid,
+ email: fxaAccount?.email || undefined,
stripeCustomerId: accountCustomer?.stripeCustomerId || undefined,
experiment: args.experiment,
taxAddress,
@@ -194,13 +206,17 @@ export class CartService {
try {
const cart = await this.cartManager.fetchCartById(cartId);
- await this.checkoutService.payWithStripe(
+ const paymentIntent = await this.checkoutService.payWithStripe(
cart,
paymentMethodId,
customerData
);
- await this.cartManager.finishCart(cartId, version, {});
+ const updatedCart = await this.cartManager.fetchCartById(cartId);
+
+ if (paymentIntent.status === 'succeeded') {
+ await this.cartManager.finishCart(cartId, updatedCart.version, {});
+ }
} catch (e) {
// TODO: Handle errors and provide an associated reason for failure
await this.cartManager.finishErrorCart(cartId, {
@@ -218,7 +234,7 @@ export class CartService {
try {
const cart = await this.cartManager.fetchCartById(cartId);
- this.checkoutService.payWithPaypal(cart, customerData, token);
+ await this.checkoutService.payWithPaypal(cart, customerData, token);
await this.cartManager.finishCart(cartId, version, {});
} catch (e) {
@@ -229,6 +245,70 @@ export class CartService {
}
}
+ /**
+ * 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 {
+ 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 };
+ }
+
+ 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') {
+ 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 };
+ }
+
+ async finalizeProcessingCart(cartId: string): Promise {
+ const cart = await this.cartManager.fetchCartById(cartId);
+
+ if (!cart.uid) {
+ throw new CartError('Cart must have a uid to finalize', { cartId });
+ }
+
+ if (!cart.stripeSubscriptionId) {
+ throw new CartSubscriptionNotFoundError(cartId);
+ }
+ const subscription = await this.subscriptionManager.retrieve(
+ cart.stripeSubscriptionId
+ );
+ if (!subscription) {
+ throw new CartSubscriptionNotFoundError(cartId);
+ }
+ await Promise.all([
+ this.checkoutService.postPaySteps(cart, subscription, cart.uid),
+ await this.cartManager.finishCart(cart.id, cart.version, {}),
+ ]);
+ }
+
/**
* Update a cart in the database by ID or with an existing cart reference
* **Note**: This method is currently a placeholder. The arguments will likely change, and the internal implementation is far from complete.
@@ -252,6 +332,13 @@ export class CartService {
}
}
+ /**
+ * Update a cart to be in the processing state
+ */
+ async setCartProcessing(cartId: string): Promise {
+ await this.cartManager.setProcessingCart(cartId);
+ }
+
/**
* Update a cart in the database by ID or with an existing cart reference
*/
diff --git a/libs/payments/cart/src/lib/cart.types.ts b/libs/payments/cart/src/lib/cart.types.ts
index 0ea178d607d..5c79dc11743 100644
--- a/libs/payments/cart/src/lib/cart.types.ts
+++ b/libs/payments/cart/src/lib/cart.types.ts
@@ -76,6 +76,7 @@ export type UpdateCart = {
couponCode?: string;
email?: string;
stripeCustomerId?: string;
+ stripeSubscriptionId?: string;
};
export type CartEligibilityDetails = {
@@ -83,3 +84,8 @@ export type CartEligibilityDetails = {
state: CartState;
errorReasonId?: CartErrorReasonId;
};
+
+export type PollCartResponse = {
+ cartState: CartState;
+ stripeClientSecret?: string;
+};
diff --git a/libs/payments/cart/src/lib/checkout.service.spec.ts b/libs/payments/cart/src/lib/checkout.service.spec.ts
index 2b68eb6205e..3f401bbb321 100644
--- a/libs/payments/cart/src/lib/checkout.service.spec.ts
+++ b/libs/payments/cart/src/lib/checkout.service.spec.ts
@@ -474,7 +474,7 @@ describe('CheckoutService', () => {
StripeSubscriptionFactory({
metadata: {
[STRIPE_CUSTOMER_METADATA.SubscriptionPromotionCode]:
- mockCart.couponCode,
+ mockCart.couponCode as string,
},
})
);
@@ -549,6 +549,7 @@ describe('CheckoutService', () => {
.spyOn(subscriptionManager, 'cancel')
.mockResolvedValue(mockSubscription);
jest.spyOn(checkoutService, 'postPaySteps').mockResolvedValue();
+ jest.spyOn(cartManager, 'updateFreshCart').mockResolvedValue();
});
describe('success', () => {
diff --git a/libs/payments/cart/src/lib/checkout.service.ts b/libs/payments/cart/src/lib/checkout.service.ts
index 847400a4fec..cec6554ce9c 100644
--- a/libs/payments/cart/src/lib/checkout.service.ts
+++ b/libs/payments/cart/src/lib/checkout.service.ts
@@ -26,6 +26,7 @@ import {
StripeSubscription,
StripeCustomer,
StripePromotionCode,
+ StripePaymentIntent,
} from '@fxa/payments/stripe';
import { ProfileClient } from '@fxa/profile/client';
import { AccountManager } from '@fxa/shared/account/account';
@@ -81,6 +82,7 @@ export class CheckoutService {
async prePaySteps(cart: ResultCart, customerData: CheckoutCustomerData) {
const taxAddress = cart.taxAddress as any as TaxAddress;
+ let version = cart.version;
if (!cart.email) {
throw new CartEmailNotFoundError(cart.id);
@@ -133,6 +135,7 @@ export class CheckoutService {
uid: uid,
stripeCustomerId: stripeCustomerId,
});
+ version += 1;
}
// validate customer is eligible for product via eligibility service
@@ -204,6 +207,7 @@ export class CheckoutService {
email: cart.email,
enableAutomaticTax,
promotionCode,
+ version,
price,
};
}
@@ -237,9 +241,15 @@ export class CheckoutService {
cart: ResultCart,
paymentMethodId: string,
customerData: CheckoutCustomerData
- ) {
- const { uid, customer, enableAutomaticTax, promotionCode, price } =
- await this.prePaySteps(cart, customerData);
+ ): Promise {
+ const {
+ uid,
+ customer,
+ enableAutomaticTax,
+ promotionCode,
+ version: updatedVersion,
+ price,
+ } = await this.prePaySteps(cart, customerData);
await this.paymentMethodManager.attach(paymentMethodId, {
customer: customer.id,
@@ -278,6 +288,10 @@ export class CheckoutService {
}
);
+ await this.cartManager.updateFreshCart(cart.id, updatedVersion, {
+ stripeSubscriptionId: subscription.id,
+ });
+
const paymentIntent = await this.subscriptionManager.getLatestPaymentIntent(
subscription
);
@@ -305,7 +319,10 @@ export class CheckoutService {
);
}
- await this.postPaySteps(cart, subscription, uid);
+ if (paymentIntent.status === 'succeeded') {
+ await this.postPaySteps(cart, subscription, uid);
+ }
+ return paymentIntent;
}
async payWithPaypal(
diff --git a/libs/payments/customer/src/lib/error.ts b/libs/payments/customer/src/lib/error.ts
index 6b9556de383..11b9bea2680 100644
--- a/libs/payments/customer/src/lib/error.ts
+++ b/libs/payments/customer/src/lib/error.ts
@@ -80,3 +80,15 @@ export class StripeNoMinimumChargeAmountAvailableError extends PaymentsCustomerE
super('Currency does not have a minimum charge amount available.');
}
}
+
+export class PaymentIntentNotFoundError extends PaymentsCustomerError {
+ constructor() {
+ super('Payment intent not found');
+ }
+}
+
+export class InvalidPaymentIntentError extends PaymentsCustomerError {
+ constructor() {
+ super('Invalid payment intent');
+ }
+}
diff --git a/libs/payments/customer/src/lib/subscription.manager.ts b/libs/payments/customer/src/lib/subscription.manager.ts
index 7c8ccca3400..76d79650a3f 100644
--- a/libs/payments/customer/src/lib/subscription.manager.ts
+++ b/libs/payments/customer/src/lib/subscription.manager.ts
@@ -5,9 +5,14 @@
import { Injectable } from '@nestjs/common';
import { Stripe } from 'stripe';
-import { StripeClient, StripeSubscription } from '@fxa/payments/stripe';
+import {
+ StripeClient,
+ StripePaymentIntent,
+ StripeSubscription,
+} from '@fxa/payments/stripe';
import { ACTIVE_SUBSCRIPTION_STATUSES } from '@fxa/payments/stripe';
import { STRIPE_CUSTOMER_METADATA } from './types';
+import { InvalidPaymentIntentError, PaymentIntentNotFoundError } from './error';
@Injectable()
export class SubscriptionManager {
@@ -100,4 +105,25 @@ export class SubscriptionManager {
return paymentIntent;
}
+
+ async processStripeSubscription(
+ subscription: StripeSubscription
+ ): Promise {
+ const paymentIntent = await this.getLatestPaymentIntent(subscription);
+
+ if (!paymentIntent) {
+ throw new PaymentIntentNotFoundError();
+ }
+
+ if (paymentIntent.last_payment_error) {
+ await this.cancel(subscription.id);
+ throw new InvalidPaymentIntentError();
+ }
+
+ if (paymentIntent.status === 'requires_confirmation') {
+ await this.stripeClient.paymentIntentConfirm(paymentIntent.id);
+ }
+
+ return paymentIntent;
+ }
}
diff --git a/libs/payments/stripe/src/lib/stripe.client.ts b/libs/payments/stripe/src/lib/stripe.client.ts
index 536550cace2..9a52a3e0495 100644
--- a/libs/payments/stripe/src/lib/stripe.client.ts
+++ b/libs/payments/stripe/src/lib/stripe.client.ts
@@ -222,4 +222,26 @@ export class StripeClient {
});
return result as StripeResponse;
}
+
+ async paymentIntentConfirm(
+ paymentIntentId: string,
+ params?: Stripe.PaymentIntentConfirmParams
+ ) {
+ const result = await this.stripe.paymentIntents.confirm(paymentIntentId, {
+ ...params,
+ expand: undefined,
+ });
+ return result as StripeResponse;
+ }
+
+ async paymentIntentCancel(
+ paymentIntentId: string,
+ params?: Stripe.PaymentIntentCancelParams
+ ) {
+ const result = await this.stripe.paymentIntents.cancel(paymentIntentId, {
+ ...params,
+ expand: undefined,
+ });
+ return result as StripeResponse;
+ }
}
diff --git a/libs/payments/ui/src/index.ts b/libs/payments/ui/src/index.ts
index f2de33f69d0..4521086eb69 100644
--- a/libs/payments/ui/src/index.ts
+++ b/libs/payments/ui/src/index.ts
@@ -13,6 +13,11 @@ export * from './lib/client/components/PurchaseDetails';
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';
diff --git a/libs/payments/ui/src/lib/actions/checkoutCartWithStripe.ts b/libs/payments/ui/src/lib/actions/checkoutCartWithStripe.ts
index e369c4e56e5..ab586b02140 100644
--- a/libs/payments/ui/src/lib/actions/checkoutCartWithStripe.ts
+++ b/libs/payments/ui/src/lib/actions/checkoutCartWithStripe.ts
@@ -10,6 +10,7 @@ import {
CheckoutCartWithStripeActionArgs,
CheckoutCartWithStripeActionCustomerData,
} from '../nestapp/validators/CheckoutCartWithStripeActionArgs';
+import { SetCartProcessingActionArgs } from '../nestapp/validators/SetCartProcessingActionArgs';
export const checkoutCartWithStripe = async (
cartId: string,
@@ -17,12 +18,22 @@ export const checkoutCartWithStripe = async (
paymentMethodId: string,
customerData: CheckoutCartWithStripeActionCustomerData
) => {
- await getApp().getActionsService().checkoutCartWithStripe(
- plainToClass(CheckoutCartWithStripeActionArgs, {
- cartId,
- version,
- customerData,
- paymentMethodId,
- })
- );
+ await getApp()
+ .getActionsService()
+ .setCartProcessing(
+ plainToClass(SetCartProcessingActionArgs, { cartId, version })
+ );
+
+ const updatedVersion = version + 1;
+
+ getApp()
+ .getActionsService()
+ .checkoutCartWithStripe(
+ plainToClass(CheckoutCartWithStripeActionArgs, {
+ cartId,
+ version: updatedVersion,
+ customerData,
+ paymentMethodId,
+ })
+ );
};
diff --git a/libs/payments/ui/src/lib/actions/finalizeCartWithError.ts b/libs/payments/ui/src/lib/actions/finalizeCartWithError.ts
new file mode 100644
index 00000000000..e8004c7779f
--- /dev/null
+++ b/libs/payments/ui/src/lib/actions/finalizeCartWithError.ts
@@ -0,0 +1,22 @@
+/* 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 { FinalizeCartWithErrorArgs } from '../nestapp/validators/FinalizeCartWithErrorArgs';
+import { CartErrorReasonId } from '@fxa/shared/db/mysql/account/kysely-types';
+
+export const finalizeCartWithError = async (
+ cartId: string,
+ errorReasonId: CartErrorReasonId
+) => {
+ return await getApp().getActionsService().finalizeCartWithError(
+ plainToClass(FinalizeCartWithErrorArgs, {
+ cartId,
+ errorReasonId,
+ })
+ );
+};
diff --git a/libs/payments/ui/src/lib/actions/finalizeProcessingCart.ts b/libs/payments/ui/src/lib/actions/finalizeProcessingCart.ts
new file mode 100644
index 00000000000..42d0863538e
--- /dev/null
+++ b/libs/payments/ui/src/lib/actions/finalizeProcessingCart.ts
@@ -0,0 +1,19 @@
+/* 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 { GetCartActionArgs } from '../nestapp/validators/GetCartActionArgs';
+
+export const finalizeProcessingCartAction = async (cartId: string) => {
+ const cart = await getApp().getActionsService().finalizeProcessingCart(
+ plainToClass(GetCartActionArgs, {
+ cartId,
+ })
+ );
+
+ return cart;
+};
diff --git a/libs/payments/ui/src/lib/actions/index.ts b/libs/payments/ui/src/lib/actions/index.ts
index 1a03bcfce48..fcc0d9efb73 100644
--- a/libs/payments/ui/src/lib/actions/index.ts
+++ b/libs/payments/ui/src/lib/actions/index.ts
@@ -13,3 +13,6 @@ 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';
diff --git a/libs/payments/ui/src/lib/actions/pollCart.ts b/libs/payments/ui/src/lib/actions/pollCart.ts
new file mode 100644
index 00000000000..682190d4d25
--- /dev/null
+++ b/libs/payments/ui/src/lib/actions/pollCart.ts
@@ -0,0 +1,19 @@
+/* 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 { PollCartActionArgs } from '../nestapp/validators/pollCartActionArgs';
+
+export const pollCartAction = async (cartId: string) => {
+ const cart = await getApp().getActionsService().pollCart(
+ plainToClass(PollCartActionArgs, {
+ cartId,
+ })
+ );
+
+ return cart;
+};
diff --git a/libs/payments/ui/src/lib/client/components/CartPoller/index.tsx b/libs/payments/ui/src/lib/client/components/CartPoller/index.tsx
new file mode 100644
index 00000000000..b98b31817f7
--- /dev/null
+++ b/libs/payments/ui/src/lib/client/components/CartPoller/index.tsx
@@ -0,0 +1,66 @@
+/* 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,
+ getCartOrRedirectAction,
+ retries,
+ stripe
+ );
+ } catch (error) {
+ console.error(error);
+ throw error;
+ }
+
+ 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 <>>;
+}
diff --git a/libs/payments/ui/src/lib/client/components/CheckoutForm/index.tsx b/libs/payments/ui/src/lib/client/components/CheckoutForm/index.tsx
index a1c44b77957..d84f6d75516 100644
--- a/libs/payments/ui/src/lib/client/components/CheckoutForm/index.tsx
+++ b/libs/payments/ui/src/lib/client/components/CheckoutForm/index.tsx
@@ -158,26 +158,7 @@ export function CheckoutForm({
displayName: fullName,
});
- // TODO - To be added in M3B - Redirect customer to '/processing' page
- router.push('./start');
- // TODO - To be moved in M3B - Confirm Payment on '/processing' page
- // Confirm the Intent using the details collected by the Payment Element
- //const { error } = await stripe.confirmPayment({
- // clientSecret,
- // confirmParams: {
- // return_url: successRedirectUrl,
- // },
- //});
- //
- //if (error) {
- // // This point is only reached if there's an immediate error when confirming the Intent.
- // // Show the error to your customer (for example, "payment details incomplete").
- // await handleStripeErrorAction(cart.id, cart.version, error);
- //} else {
- // // Your customer is redirected to your `return_url`. For some payment
- // // methods like iDEAL, your customer is redirected to an intermediate
- // // site first to authorize the payment, then redirected to the `return_url`.
- //}
+ router.push('./processing');
};
const nonStripeFieldsComplete = !!fullName;
diff --git a/libs/payments/ui/src/lib/client/components/PollingSection/index.tsx b/libs/payments/ui/src/lib/client/components/PollingSection/index.tsx
new file mode 100644
index 00000000000..ea11e986473
--- /dev/null
+++ b/libs/payments/ui/src/lib/client/components/PollingSection/index.tsx
@@ -0,0 +1,31 @@
+/* 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(null);
+ useEffect(() => {
+ getCartOrRedirectAction(cartId, SupportedPages.PROCESSING).then((cart) => {
+ setCart(cart);
+ });
+ }, []);
+ if (cart && cart.currency) {
+ return (
+
+
+
+ );
+ } else return <>>;
+};
diff --git a/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts b/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts
index b742f778631..fd76c8485e6 100644
--- a/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts
+++ b/libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts
@@ -20,6 +20,9 @@ import { SetupCartActionArgs } from './validators/SetupCartActionArgs';
import { UpdateCartActionArgs } from './validators/UpdateCartActionArgs';
import { RecordEmitterEventArgs } from './validators/RecordEmitterEvent';
import { PaymentsEmitterService } from '../emitter/emitter.service';
+import { SetCartProcessingActionArgs } from './validators/SetCartProcessingActionArgs';
+import { FinalizeProcessingCartActionArgs } from './validators/finalizeProcessingCartActionArgs';
+import { PollCartActionArgs } from './validators/pollCartActionArgs';
/**
* ANY AND ALL methods exposed via this service should be considered publicly accessible and callable with any arguments.
@@ -71,6 +74,14 @@ 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);
@@ -80,6 +91,18 @@ export class NextJSActionsService {
);
}
+ async finalizeProcessingCart(args: FinalizeProcessingCartActionArgs) {
+ await new Validator().validateOrReject(args);
+
+ await this.cartService.finalizeProcessingCart(args.cartId);
+ }
+
+ async setCartProcessing(args: SetCartProcessingActionArgs) {
+ await new Validator().validateOrReject(args);
+
+ await this.cartService.setCartProcessing(args.cartId);
+ }
+
async getPayPalCheckoutToken(args: GetPayPalCheckoutTokenArgs) {
await new Validator().validateOrReject(args);
diff --git a/libs/payments/ui/src/lib/nestapp/validators/FinalizeCartWithErrorArgs.ts b/libs/payments/ui/src/lib/nestapp/validators/FinalizeCartWithErrorArgs.ts
index 4f9dd422f9e..37252ad80c4 100644
--- a/libs/payments/ui/src/lib/nestapp/validators/FinalizeCartWithErrorArgs.ts
+++ b/libs/payments/ui/src/lib/nestapp/validators/FinalizeCartWithErrorArgs.ts
@@ -2,13 +2,13 @@
* 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, ValidateNested } from 'class-validator';
+import { IsEnum, IsString } from 'class-validator';
import { CartErrorReasonId } from '@fxa/shared/db/mysql/account';
export class FinalizeCartWithErrorArgs {
@IsString()
cartId!: string;
- @ValidateNested()
+ @IsEnum(CartErrorReasonId)
errorReasonId!: CartErrorReasonId;
}
diff --git a/libs/payments/ui/src/lib/nestapp/validators/SetCartProcessingActionArgs.ts b/libs/payments/ui/src/lib/nestapp/validators/SetCartProcessingActionArgs.ts
new file mode 100644
index 00000000000..5465876a899
--- /dev/null
+++ b/libs/payments/ui/src/lib/nestapp/validators/SetCartProcessingActionArgs.ts
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { IsNumber, IsString } from 'class-validator';
+
+export class SetCartProcessingActionArgs {
+ @IsString()
+ cartId!: string;
+
+ @IsNumber()
+ version!: number;
+}
diff --git a/libs/payments/ui/src/lib/nestapp/validators/finalizeProcessingCartActionArgs.ts b/libs/payments/ui/src/lib/nestapp/validators/finalizeProcessingCartActionArgs.ts
new file mode 100644
index 00000000000..94234b7ab83
--- /dev/null
+++ b/libs/payments/ui/src/lib/nestapp/validators/finalizeProcessingCartActionArgs.ts
@@ -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 FinalizeProcessingCartActionArgs {
+ @IsString()
+ cartId!: string;
+}
diff --git a/libs/payments/ui/src/lib/nestapp/validators/pollCartActionArgs.ts b/libs/payments/ui/src/lib/nestapp/validators/pollCartActionArgs.ts
new file mode 100644
index 00000000000..bc8831a1b4c
--- /dev/null
+++ b/libs/payments/ui/src/lib/nestapp/validators/pollCartActionArgs.ts
@@ -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 PollCartActionArgs {
+ @IsString()
+ cartId!: string;
+}
diff --git a/libs/payments/ui/src/lib/utils/get-cart.ts b/libs/payments/ui/src/lib/utils/get-cart.ts
index dcd5e5d9ea3..e86a10909d5 100644
--- a/libs/payments/ui/src/lib/utils/get-cart.ts
+++ b/libs/payments/ui/src/lib/utils/get-cart.ts
@@ -1,7 +1,7 @@
/* 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 { CartState } from '@fxa/shared/db/mysql/account';
+import { CartState } from '@fxa/shared/db/mysql/account/kysely-types';
import { SupportedPages } from './types';
export const cartStateToPageMap = {
diff --git a/libs/payments/ui/src/lib/utils/poll-cart.ts b/libs/payments/ui/src/lib/utils/poll-cart.ts
new file mode 100644
index 00000000000..01daa3aad18
--- /dev/null
+++ b/libs/payments/ui/src/lib/utils/poll-cart.ts
@@ -0,0 +1,64 @@
+import { SupportedPages } from './types';
+import type { Stripe } from '@stripe/stripe-js';
+import {
+ pollCartAction,
+ finalizeProcessingCartAction,
+ finalizeCartWithError,
+} 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;
+ },
+ validatePageCb: (cartId: string, page: SupportedPages) => Promise,
+ retries = 0,
+ stripeClient: Stripe | null
+): Promise => {
+ const pollCartResponse = await pollCartAction(checkoutParams.cartId);
+
+ if (pollCartResponse.cartState !== 'processing') {
+ await validatePageCb(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;
+ }
+ validatePageCb(checkoutParams.cartId, SupportedPages.PROCESSING);
+ return retries;
+ }
+ }
+
+ validatePageCb(checkoutParams.cartId, SupportedPages.PROCESSING),
+ (retries += 1);
+ return retries;
+};
diff --git a/libs/payments/ui/src/lib/utils/types.ts b/libs/payments/ui/src/lib/utils/types.ts
index 28fe0eeb97a..a2e6613a748 100644
--- a/libs/payments/ui/src/lib/utils/types.ts
+++ b/libs/payments/ui/src/lib/utils/types.ts
@@ -8,3 +8,10 @@ export enum SupportedPages {
SUCCESS = 'success',
ERROR = 'error',
}
+
+export type CheckoutParams = {
+ cartId: string;
+ locale: string;
+ interval: string;
+ offeringId: string;
+};
diff --git a/tsconfig.base.json b/tsconfig.base.json
index f7efb490b3f..e077d1a5a9d 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -48,6 +48,9 @@
"@fxa/shared/db/mysql/account": [
"libs/shared/db/mysql/account/src/index.ts"
],
+ "@fxa/shared/db/mysql/account/*": [
+ "libs/shared/db/mysql/account/src/lib/*"
+ ],
"@fxa/shared/db/mysql/core": ["libs/shared/db/mysql/core/src/index.ts"],
"@fxa/shared/db/type-cacheable": [
"libs/shared/db/type-cacheable/src/index.ts"