diff --git a/packages/twenty-front/src/modules/onboarding/components/OnboardingStepLayout.tsx b/packages/twenty-front/src/modules/onboarding/components/OnboardingStepLayout.tsx index 1e18008ef6..91c52396f1 100644 --- a/packages/twenty-front/src/modules/onboarding/components/OnboardingStepLayout.tsx +++ b/packages/twenty-front/src/modules/onboarding/components/OnboardingStepLayout.tsx @@ -1,10 +1,13 @@ import { OnboardingLayout } from '@/onboarding/components/OnboardingLayout'; import { OnboardingTransitionOutlet } from '@/onboarding/components/OnboardingTransitionOutlet'; import { useOnboardingFreeCreditsTotal } from '@/onboarding/hooks/useOnboardingFreeCreditsTotal'; +import { usePreloadStripeForPlanRequiredStep } from '@/onboarding/hooks/usePreloadStripeForPlanRequiredStep'; export const OnboardingStepLayout = () => { const freeCredits = useOnboardingFreeCreditsTotal(); + usePreloadStripeForPlanRequiredStep(); + return ( diff --git a/packages/twenty-front/src/modules/onboarding/hooks/__tests__/usePreloadStripeForPlanRequiredStep.test.ts b/packages/twenty-front/src/modules/onboarding/hooks/__tests__/usePreloadStripeForPlanRequiredStep.test.ts new file mode 100644 index 0000000000..9d2364ad69 --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/hooks/__tests__/usePreloadStripeForPlanRequiredStep.test.ts @@ -0,0 +1,101 @@ +import { act, renderHook } from '@testing-library/react'; +import { Provider as JotaiProvider } from 'jotai'; +import { createElement } from 'react'; + +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { billingState } from '@/client-config/states/billingState'; +import { usePreloadStripeForPlanRequiredStep } from '@/onboarding/hooks/usePreloadStripeForPlanRequiredStep'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; +import { + jotaiStore, + resetJotaiStore, +} from '@/ui/utilities/state/jotai/jotaiStore'; + +import { loadStripe } from '@stripe/stripe-js/pure'; +import { mockCurrentWorkspace } from '~/testing/mock-data/users'; + +jest.mock('@stripe/stripe-js/pure', () => ({ + loadStripe: jest.fn().mockResolvedValue(null), +})); + +const loadStripeMock = jest.mocked(loadStripe); + +const Wrapper = ({ children }: { children: React.ReactNode }) => + createElement(JotaiProvider, { store: jotaiStore }, children); + +type RenderHooksOptions = { + isBillingEnabled?: boolean; + withSubscription?: boolean; + stripePublishableKey?: string; +}; + +const renderHooks = ({ + isBillingEnabled = true, + withSubscription = false, + stripePublishableKey, +}: RenderHooksOptions = {}) => { + const { result } = renderHook( + () => { + const setCurrentWorkspace = useSetAtomState(currentWorkspaceState); + const setBilling = useSetAtomState(billingState); + usePreloadStripeForPlanRequiredStep(); + return { setCurrentWorkspace, setBilling }; + }, + { + wrapper: Wrapper, + }, + ); + + act(() => { + result.current.setCurrentWorkspace({ + ...mockCurrentWorkspace, + billingSubscriptions: withSubscription + ? mockCurrentWorkspace.billingSubscriptions + : [], + }); + result.current.setBilling({ + __typename: 'Billing', + isBillingEnabled, + trialPeriods: [], + stripePublishableKey, + }); + }); +}; + +describe('usePreloadStripeForPlanRequiredStep', () => { + beforeEach(() => { + resetJotaiStore(); + jest.clearAllMocks(); + }); + + it('should preload Stripe when billing is enabled and the workspace has no subscription', () => { + renderHooks({ stripePublishableKey: 'pk_test_preload' }); + + expect(loadStripeMock).toHaveBeenCalledTimes(1); + expect(loadStripeMock).toHaveBeenCalledWith('pk_test_preload'); + }); + + it('should not preload Stripe when billing is disabled', () => { + renderHooks({ + isBillingEnabled: false, + stripePublishableKey: 'pk_test_billing_disabled', + }); + + expect(loadStripeMock).not.toHaveBeenCalled(); + }); + + it('should not preload Stripe when the workspace already has a subscription', () => { + renderHooks({ + withSubscription: true, + stripePublishableKey: 'pk_test_with_subscription', + }); + + expect(loadStripeMock).not.toHaveBeenCalled(); + }); + + it('should not preload Stripe when the publishable key is missing', () => { + renderHooks(); + + expect(loadStripeMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-front/src/modules/onboarding/hooks/usePreloadStripeForPlanRequiredStep.ts b/packages/twenty-front/src/modules/onboarding/hooks/usePreloadStripeForPlanRequiredStep.ts new file mode 100644 index 0000000000..a73dec27ff --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/hooks/usePreloadStripeForPlanRequiredStep.ts @@ -0,0 +1,26 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { useEffect } from 'react'; + +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { billingState } from '@/client-config/states/billingState'; +import { getIsPlanRequired } from '@/onboarding/utils/getIsPlanRequired'; +import { getStripePromise } from '@/settings/billing/utils/getStripePromise'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; + +export const usePreloadStripeForPlanRequiredStep = () => { + const billing = useAtomStateValue(billingState); + const currentWorkspace = useAtomStateValue(currentWorkspaceState); + + const isPlanRequired = getIsPlanRequired({ + isBillingEnabled: billing?.isBillingEnabled ?? false, + currentWorkspace, + }); + + const publishableKey = billing?.stripePublishableKey; + + useEffect(() => { + if (isPlanRequired && isNonEmptyString(publishableKey)) { + void getStripePromise(publishableKey); + } + }, [isPlanRequired, publishableKey]); +}; diff --git a/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts b/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts index 385534b65e..0f96a39383 100644 --- a/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts +++ b/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts @@ -9,6 +9,7 @@ import { currentWorkspaceState, } from '@/auth/states/currentWorkspaceState'; import { billingState } from '@/client-config/states/billingState'; +import { getIsPlanRequired } from '@/onboarding/utils/getIsPlanRequired'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { useCallback } from 'react'; @@ -26,9 +27,10 @@ const getNextOnboardingStatus = ({ currentWorkspace, isBillingEnabled, }: GetNextOnboardingStatusArgs) => { - const isPlanRequired = - isBillingEnabled && - (currentWorkspace?.billingSubscriptions?.length ?? 0) === 0; + const isPlanRequired = getIsPlanRequired({ + isBillingEnabled, + currentWorkspace, + }); if (currentUser?.onboardingStatus === OnboardingStatus.WORKSPACE_ACTIVATION) { return OnboardingStatus.SYNC_EMAIL; diff --git a/packages/twenty-front/src/modules/onboarding/utils/getIsPlanRequired.ts b/packages/twenty-front/src/modules/onboarding/utils/getIsPlanRequired.ts new file mode 100644 index 0000000000..0e7e653117 --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/utils/getIsPlanRequired.ts @@ -0,0 +1,11 @@ +import { type CurrentWorkspace } from '@/auth/states/currentWorkspaceState'; + +export const getIsPlanRequired = ({ + isBillingEnabled, + currentWorkspace, +}: { + isBillingEnabled: boolean; + currentWorkspace: Pick | null; +}) => + isBillingEnabled && + (currentWorkspace?.billingSubscriptions?.length ?? 0) === 0; diff --git a/packages/twenty-front/src/modules/settings/billing/hooks/useStripePromise.ts b/packages/twenty-front/src/modules/settings/billing/hooks/useStripePromise.ts index e38be9a163..2af348948e 100644 --- a/packages/twenty-front/src/modules/settings/billing/hooks/useStripePromise.ts +++ b/packages/twenty-front/src/modules/settings/billing/hooks/useStripePromise.ts @@ -1,30 +1,14 @@ import { billingState } from '@/client-config/states/billingState'; +import { getStripePromise } from '@/settings/billing/utils/getStripePromise'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { isNonEmptyString } from '@sniptt/guards'; import { type Stripe } from '@stripe/stripe-js'; -import { loadStripe } from '@stripe/stripe-js/pure'; -import { isDefined } from 'twenty-shared/utils'; - -const stripePromiseByKey = new Map>(); - -const getStripePromise = (publishableKey: string): Promise => { - const existingPromise = stripePromiseByKey.get(publishableKey); - - if (isDefined(existingPromise)) { - return existingPromise; - } - - const stripePromise = loadStripe(publishableKey); - - stripePromiseByKey.set(publishableKey, stripePromise); - - return stripePromise; -}; export const useStripePromise = (): Promise | null => { const billing = useAtomStateValue(billingState); const publishableKey = billing?.stripePublishableKey; - return isDefined(publishableKey) && publishableKey !== '' + return isNonEmptyString(publishableKey) ? getStripePromise(publishableKey) : null; }; diff --git a/packages/twenty-front/src/modules/settings/billing/utils/__tests__/getStripePromise.test.ts b/packages/twenty-front/src/modules/settings/billing/utils/__tests__/getStripePromise.test.ts new file mode 100644 index 0000000000..083bd8aefc --- /dev/null +++ b/packages/twenty-front/src/modules/settings/billing/utils/__tests__/getStripePromise.test.ts @@ -0,0 +1,42 @@ +import { loadStripe } from '@stripe/stripe-js/pure'; + +import { getStripePromise } from '@/settings/billing/utils/getStripePromise'; + +jest.mock('@stripe/stripe-js/pure', () => ({ + loadStripe: jest.fn(), +})); + +const loadStripeMock = jest.mocked(loadStripe); + +describe('getStripePromise', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should load Stripe once per publishable key', async () => { + loadStripeMock.mockResolvedValue(null); + + const firstPromise = getStripePromise('pk_test_dedup'); + const secondPromise = getStripePromise('pk_test_dedup'); + + expect(secondPromise).toBe(firstPromise); + expect(loadStripeMock).toHaveBeenCalledTimes(1); + + await expect(firstPromise).resolves.toBeNull(); + }); + + it('should retry loading Stripe after a failed load', async () => { + loadStripeMock + .mockRejectedValueOnce(new Error('Failed to load Stripe.js')) + .mockResolvedValueOnce(null); + + await expect(getStripePromise('pk_test_retry')).rejects.toThrow( + 'Failed to load Stripe.js', + ); + + const retriedPromise = getStripePromise('pk_test_retry'); + + expect(loadStripeMock).toHaveBeenCalledTimes(2); + await expect(retriedPromise).resolves.toBeNull(); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/billing/utils/getStripePromise.ts b/packages/twenty-front/src/modules/settings/billing/utils/getStripePromise.ts new file mode 100644 index 0000000000..b47e612bea --- /dev/null +++ b/packages/twenty-front/src/modules/settings/billing/utils/getStripePromise.ts @@ -0,0 +1,26 @@ +import { type Stripe } from '@stripe/stripe-js'; +import { loadStripe } from '@stripe/stripe-js/pure'; +import { isDefined } from 'twenty-shared/utils'; + +const stripePromiseByKey = new Map>(); + +export const getStripePromise = ( + publishableKey: string, +): Promise => { + const existingPromise = stripePromiseByKey.get(publishableKey); + + if (isDefined(existingPromise)) { + return existingPromise; + } + + const stripePromise = loadStripe(publishableKey); + + stripePromiseByKey.set(publishableKey, stripePromise); + + // Drop failed loads so a later call can retry + stripePromise.catch(() => { + stripePromiseByKey.delete(publishableKey); + }); + + return stripePromise; +};