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"