From 3db94013bda81700ae109c500775de73dedee8cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Fri, 20 Dec 2024 18:55:14 +0100 Subject: [PATCH] Refacto to introduce billing plan instead --- .../twenty-front/src/generated/graphql.tsx | 17 ++- ...sePageChangeEffectNavigateLocation.test.ts | 33 ++-- .../usePageChangeEffectNavigateLocation.ts | 10 +- .../modules/app/hooks/useCreateAppRouter.tsx | 13 +- .../billing/graphql/checkoutSession.ts | 4 +- .../hooks/__tests__/useBillingPlan.test.tsx | 72 +++++++++ .../modules/billing/hooks/useBillingPlan.ts | 38 +++++ .../src/modules/billing/hooks/useFreePass.ts | 21 --- .../modules/billing/states/freePassState.ts | 6 - .../src/modules/billing/types/billing.ts | 5 + .../twenty-front/src/modules/types/AppPath.ts | 2 +- .../hooks/__tests__/useShowAuthModal.test.tsx | 20 +-- .../ui/layout/hooks/useShowAuthModal.ts | 2 +- .../onboarding/FreePassCheckoutEffect.tsx | 8 +- .../pages/onboarding/PlanCheckoutEffect.tsx | 46 ++++++ .../__tests__/PlanCheckoutEffect.test.tsx | 144 ++++++++++++++++++ .../core-modules/billing/billing.resolver.ts | 8 +- .../billing/dto/checkout-session.input.ts | 15 +- .../billing/enums/billing-plan-key.enum.ts | 12 +- .../billing-portal.workspace-service.ts | 30 ++-- .../billing-webhook-product.service.ts | 9 +- .../billing/stripe/stripe.service.ts | 10 +- 22 files changed, 409 insertions(+), 116 deletions(-) create mode 100644 packages/twenty-front/src/modules/billing/hooks/__tests__/useBillingPlan.test.tsx create mode 100644 packages/twenty-front/src/modules/billing/hooks/useBillingPlan.ts delete mode 100644 packages/twenty-front/src/modules/billing/hooks/useFreePass.ts delete mode 100644 packages/twenty-front/src/modules/billing/states/freePassState.ts create mode 100644 packages/twenty-front/src/modules/billing/types/billing.ts create mode 100644 packages/twenty-front/src/pages/onboarding/PlanCheckoutEffect.tsx create mode 100644 packages/twenty-front/src/pages/onboarding/__tests__/PlanCheckoutEffect.test.tsx diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 07b23ec6c280..9493fb184dde 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -121,6 +121,13 @@ export type Billing = { isBillingEnabled: Scalars['Boolean']; }; +/** The different billing plans available */ +export enum BillingPlanKey { + Enterprise = 'ENTERPRISE', + Free = 'FREE', + Pro = 'PRO' +} + export type BillingSubscription = { __typename?: 'BillingSubscription'; id: Scalars['UUID']; @@ -536,8 +543,8 @@ export type MutationChallengeArgs = { export type MutationCheckoutSessionArgs = { + plan?: BillingPlanKey; recurringInterval: SubscriptionInterval; - requirePaymentMethod?: InputMaybe; successUrlPath?: InputMaybe; }; @@ -1953,7 +1960,7 @@ export type BillingPortalSessionQuery = { __typename?: 'Query', billingPortalSes export type CheckoutSessionMutationVariables = Exact<{ recurringInterval: SubscriptionInterval; successUrlPath?: InputMaybe; - requirePaymentMethod?: InputMaybe; + plan: BillingPlanKey; }>; @@ -3244,11 +3251,11 @@ export type BillingPortalSessionQueryHookResult = ReturnType; export type BillingPortalSessionQueryResult = Apollo.QueryResult; export const CheckoutSessionDocument = gql` - mutation CheckoutSession($recurringInterval: SubscriptionInterval!, $successUrlPath: String, $requirePaymentMethod: Boolean) { + mutation CheckoutSession($recurringInterval: SubscriptionInterval!, $successUrlPath: String, $plan: BillingPlanKey!) { checkoutSession( recurringInterval: $recurringInterval successUrlPath: $successUrlPath - requirePaymentMethod: $requirePaymentMethod + plan: $plan ) { url } @@ -3271,7 +3278,7 @@ export type CheckoutSessionMutationFn = Apollo.MutationFunction { jest.mocked(useIsLogged).mockReturnValueOnce(isLogged); }; -jest.mock('@/billing/hooks/useFreePass'); -const setupMockFreePass = (freePass: boolean) => { - jest.mocked(useFreePass).mockReturnValueOnce(freePass); +jest.mock('@/billing/hooks/useBillingPlan'); +const setupMockBillingPlan = (plan: BillingPlanKey) => { + jest.mocked(useBillingPlan).mockReturnValueOnce(plan); }; const defaultHomePagePath = '/objects/companies'; @@ -152,16 +151,16 @@ const testCases = [ { loc: AppPath.PlanRequired, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: AppPath.InviteTeam }, { loc: AppPath.PlanRequired, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: defaultHomePagePath }, - { loc: AppPath.FreePassCheckout, isLoggedIn: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: undefined }, - { loc: AppPath.FreePassCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' }, - { loc: AppPath.FreePassCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' }, - { loc: AppPath.FreePassCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.PastDue, onboardingStatus: OnboardingStatus.Completed, res: defaultHomePagePath }, - { loc: AppPath.FreePassCheckout, isLoggedIn: false, subscriptionStatus: undefined, onboardingStatus: undefined, res: AppPath.SignInUp }, - { loc: AppPath.FreePassCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.WorkspaceActivation, res: AppPath.CreateWorkspace }, - { loc: AppPath.FreePassCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.ProfileCreation, res: AppPath.CreateProfile }, - { loc: AppPath.FreePassCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.SyncEmail, res: AppPath.SyncEmails }, - { loc: AppPath.FreePassCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: AppPath.InviteTeam }, - { loc: AppPath.FreePassCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: defaultHomePagePath }, + { loc: AppPath.PlanCheckout, isLoggedIn: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: undefined }, + { loc: AppPath.PlanCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' }, + { loc: AppPath.PlanCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' }, + { loc: AppPath.PlanCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.PastDue, onboardingStatus: OnboardingStatus.Completed, res: defaultHomePagePath }, + { loc: AppPath.PlanCheckout, isLoggedIn: false, subscriptionStatus: undefined, onboardingStatus: undefined, res: AppPath.SignInUp }, + { loc: AppPath.PlanCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.WorkspaceActivation, res: AppPath.CreateWorkspace }, + { loc: AppPath.PlanCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.ProfileCreation, res: AppPath.CreateProfile }, + { loc: AppPath.PlanCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.SyncEmail, res: AppPath.SyncEmails }, + { loc: AppPath.PlanCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: AppPath.InviteTeam }, + { loc: AppPath.PlanCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: defaultHomePagePath }, { loc: AppPath.PlanRequiredSuccess, isLoggedIn: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: AppPath.PlanRequired }, { loc: AppPath.PlanRequiredSuccess, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' }, @@ -292,7 +291,7 @@ describe('usePageChangeEffectNavigateLocation', () => { setupMockOnboardingStatus(testCase.onboardingStatus); setupMockSubscriptionStatus(testCase.subscriptionStatus); setupMockIsLogged(testCase.isLoggedIn); - setupMockFreePass(false); + setupMockBillingPlan(BillingPlanKey.PRO); expect(usePageChangeEffectNavigateLocation()).toEqual(testCase.res); }); }); diff --git a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts index a7fe87586388..0cbdbac87721 100644 --- a/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts +++ b/packages/twenty-front/src/hooks/usePageChangeEffectNavigateLocation.ts @@ -1,5 +1,5 @@ import { useIsLogged } from '@/auth/hooks/useIsLogged'; -import { useFreePass } from '@/billing/hooks/useFreePass'; +import { useBillingPlan } from '@/billing/hooks/useBillingPlan'; import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath'; import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus'; import { AppPath } from '@/types/AppPath'; @@ -14,7 +14,7 @@ export const usePageChangeEffectNavigateLocation = () => { const onboardingStatus = useOnboardingStatus(); const subscriptionStatus = useSubscriptionStatus(); const { defaultHomePagePath } = useDefaultHomePagePath(); - const freePass = useFreePass(); + const plan = useBillingPlan(); const isMatchingOpenRoute = isMatchingLocation(AppPath.Invite) || @@ -33,7 +33,7 @@ export const usePageChangeEffectNavigateLocation = () => { isMatchingLocation(AppPath.InviteTeam) || isMatchingLocation(AppPath.PlanRequired) || isMatchingLocation(AppPath.PlanRequiredSuccess) || - isMatchingLocation(AppPath.FreePassCheckout); + isMatchingLocation(AppPath.PlanCheckout); if (isMatchingOpenRoute) { return; @@ -46,9 +46,9 @@ export const usePageChangeEffectNavigateLocation = () => { if ( onboardingStatus === OnboardingStatus.PlanRequired && !isMatchingLocation(AppPath.PlanRequired) && - !isMatchingLocation(AppPath.FreePassCheckout) + !isMatchingLocation(AppPath.PlanCheckout) ) { - return freePass ? AppPath.FreePassCheckout : AppPath.PlanRequired; + return plan ? AppPath.PlanCheckout : AppPath.PlanRequired; } if ( diff --git a/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx b/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx index 9eed332b671c..80ccc34bc486 100644 --- a/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx +++ b/packages/twenty-front/src/modules/app/hooks/useCreateAppRouter.tsx @@ -6,9 +6,9 @@ import { AppPath } from '@/types/AppPath'; import { BlankLayout } from '@/ui/layout/page/components/BlankLayout'; import { DefaultLayout } from '@/ui/layout/page/components/DefaultLayout'; import { - createBrowserRouter, - createRoutesFromElements, - Route, + createBrowserRouter, + createRoutesFromElements, + Route, } from 'react-router-dom'; import { Authorize } from '~/pages/auth/Authorize'; import { Invite } from '~/pages/auth/Invite'; @@ -20,9 +20,9 @@ import { RecordShowPage } from '~/pages/object-record/RecordShowPage'; import { ChooseYourPlan } from '~/pages/onboarding/ChooseYourPlan'; import { CreateProfile } from '~/pages/onboarding/CreateProfile'; import { CreateWorkspace } from '~/pages/onboarding/CreateWorkspace'; -import { FreePassCheckoutEffect } from '~/pages/onboarding/FreePassCheckoutEffect'; import { InviteTeam } from '~/pages/onboarding/InviteTeam'; import { PaymentSuccess } from '~/pages/onboarding/PaymentSuccess'; +import { PlanCheckoutEffect } from '~/pages/onboarding/PlanCheckoutEffect'; import { SyncEmails } from '~/pages/onboarding/SyncEmails'; export const useCreateAppRouter = ( isBillingEnabled?: boolean, @@ -49,10 +49,7 @@ export const useCreateAppRouter = ( } /> } /> } /> - } - /> + } /> } diff --git a/packages/twenty-front/src/modules/billing/graphql/checkoutSession.ts b/packages/twenty-front/src/modules/billing/graphql/checkoutSession.ts index 904136d93556..5df109524f87 100644 --- a/packages/twenty-front/src/modules/billing/graphql/checkoutSession.ts +++ b/packages/twenty-front/src/modules/billing/graphql/checkoutSession.ts @@ -4,12 +4,12 @@ export const CHECKOUT_SESSION = gql` mutation CheckoutSession( $recurringInterval: SubscriptionInterval! $successUrlPath: String - $requirePaymentMethod: Boolean + $plan: BillingPlanKey! ) { checkoutSession( recurringInterval: $recurringInterval successUrlPath: $successUrlPath - requirePaymentMethod: $requirePaymentMethod + plan: $plan ) { url } diff --git a/packages/twenty-front/src/modules/billing/hooks/__tests__/useBillingPlan.test.tsx b/packages/twenty-front/src/modules/billing/hooks/__tests__/useBillingPlan.test.tsx new file mode 100644 index 000000000000..7060fc9d1cf8 --- /dev/null +++ b/packages/twenty-front/src/modules/billing/hooks/__tests__/useBillingPlan.test.tsx @@ -0,0 +1,72 @@ +import { renderHook } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { RecoilRoot } from 'recoil'; + +import { useBillingPlan } from '@/billing/hooks/useBillingPlan'; +import { BillingPlanKey } from '@/billing/types/billing'; + +const Wrapper = ({ children, initialUrl = '' }: any) => ( + + {children} + +); + +describe('useBillingPlan', () => { + it('should return FREE as default plan', () => { + const { result } = renderHook(() => useBillingPlan(), { + wrapper: Wrapper, + }); + + expect(result.current).toBe(BillingPlanKey.FREE); + }); + + it('should set plan from URL parameter - FREE', () => { + const { result } = renderHook(() => useBillingPlan(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(result.current).toBe(BillingPlanKey.FREE); + }); + + it('should set plan from URL parameter - PRO', () => { + const { result } = renderHook(() => useBillingPlan(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(result.current).toBe(BillingPlanKey.PRO); + }); + + it('should set plan from URL parameter - ENTERPRISE', () => { + const { result } = renderHook(() => useBillingPlan(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(result.current).toBe(BillingPlanKey.ENTERPRISE); + }); + + it('should ignore invalid plan from URL parameter', () => { + const { result } = renderHook(() => useBillingPlan(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(result.current).toBe(BillingPlanKey.FREE); + }); + + it('should handle URL without plan parameter', () => { + const { result } = renderHook(() => useBillingPlan(), { + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(result.current).toBe(BillingPlanKey.FREE); + }); +}); diff --git a/packages/twenty-front/src/modules/billing/hooks/useBillingPlan.ts b/packages/twenty-front/src/modules/billing/hooks/useBillingPlan.ts new file mode 100644 index 000000000000..a05dd0c4e7f0 --- /dev/null +++ b/packages/twenty-front/src/modules/billing/hooks/useBillingPlan.ts @@ -0,0 +1,38 @@ +import { useLocation } from 'react-router-dom'; +import { atom, useRecoilState } from 'recoil'; + +import { BillingPlanKey } from '@/billing/types/billing'; + +const billingPlanState = atom({ + key: 'billingPlanState', + default: null, +}); + +export const useBillingPlan = () => { + const { search } = useLocation(); + const [billingPlan, setBillingPlan] = useRecoilState(billingPlanState); + + const hasFreePassParameter = + search.includes('freepass') || + search.includes('freePass') || + search.includes('free-pass') || + search.includes('Free-pass') || + search.includes('FreePass'); + + if (hasFreePassParameter) { + setBillingPlan(BillingPlanKey.FREE); + return billingPlan; + } + + const planFromUrl = search.match(/[?&]plan=([^&]+)/)?.[1]?.toUpperCase(); + + if ( + planFromUrl !== null && + planFromUrl !== undefined && + Object.values(BillingPlanKey).includes(planFromUrl as BillingPlanKey) + ) { + setBillingPlan(planFromUrl as BillingPlanKey); + } + + return billingPlan; +}; diff --git a/packages/twenty-front/src/modules/billing/hooks/useFreePass.ts b/packages/twenty-front/src/modules/billing/hooks/useFreePass.ts deleted file mode 100644 index 0ce711f8bc33..000000000000 --- a/packages/twenty-front/src/modules/billing/hooks/useFreePass.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { freePassState } from '@/billing/states/freePassState'; -import { useLocation } from 'react-router-dom'; -import { useRecoilState } from 'recoil'; - -export const useFreePass = () => { - const { search } = useLocation(); - const [freePass, setFreePass] = useRecoilState(freePassState); - - const hasFreePassParameter = - search.includes('freepass') || - search.includes('freePass') || - search.includes('free-pass') || - search.includes('Free-pass') || - search.includes('FreePass'); - - if (hasFreePassParameter) { - setFreePass(true); - } - - return freePass; -}; diff --git a/packages/twenty-front/src/modules/billing/states/freePassState.ts b/packages/twenty-front/src/modules/billing/states/freePassState.ts deleted file mode 100644 index e9f3a8ca331b..000000000000 --- a/packages/twenty-front/src/modules/billing/states/freePassState.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { atom } from 'recoil'; - -export const freePassState = atom({ - key: 'freePassState', - default: false, -}); diff --git a/packages/twenty-front/src/modules/billing/types/billing.ts b/packages/twenty-front/src/modules/billing/types/billing.ts new file mode 100644 index 000000000000..c998820b4568 --- /dev/null +++ b/packages/twenty-front/src/modules/billing/types/billing.ts @@ -0,0 +1,5 @@ +export enum BillingPlanKey { + FREE = 'FREE', + PRO = 'PRO', + ENTERPRISE = 'ENTERPRISE', +} diff --git a/packages/twenty-front/src/modules/types/AppPath.ts b/packages/twenty-front/src/modules/types/AppPath.ts index df8768378635..73c851a4aa56 100644 --- a/packages/twenty-front/src/modules/types/AppPath.ts +++ b/packages/twenty-front/src/modules/types/AppPath.ts @@ -12,7 +12,7 @@ export enum AppPath { InviteTeam = '/invite-team', PlanRequired = '/plan-required', PlanRequiredSuccess = '/plan-required/payment-success', - FreePassCheckout = '/free-pass', + PlanCheckout = '/plan-checkout', // Onboarded Index = '/', diff --git a/packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx b/packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx index ec46b179456f..38b1eeeaf99e 100644 --- a/packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx +++ b/packages/twenty-front/src/modules/ui/layout/hooks/__tests__/useShowAuthModal.test.tsx @@ -155,16 +155,16 @@ const testCases = [ { loc: AppPath.PlanRequired, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: true }, { loc: AppPath.PlanRequired, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: false }, - { loc: AppPath.FreePassCheckout, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: true }, - { loc: AppPath.FreePassCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: true }, - { loc: AppPath.FreePassCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: false }, - { loc: AppPath.FreePassCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.PastDue, onboardingStatus: OnboardingStatus.Completed, res: false }, - { loc: AppPath.FreePassCheckout, isLogged: false, subscriptionStatus: undefined, onboardingStatus: undefined, res: true }, - { loc: AppPath.FreePassCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.WorkspaceActivation, res: true }, - { loc: AppPath.FreePassCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.ProfileCreation, res: true }, - { loc: AppPath.FreePassCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.SyncEmail, res: true }, - { loc: AppPath.FreePassCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: true }, - { loc: AppPath.FreePassCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: false }, + { loc: AppPath.PlanCheckout, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: true }, + { loc: AppPath.PlanCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: true }, + { loc: AppPath.PlanCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: false }, + { loc: AppPath.PlanCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.PastDue, onboardingStatus: OnboardingStatus.Completed, res: false }, + { loc: AppPath.PlanCheckout, isLogged: false, subscriptionStatus: undefined, onboardingStatus: undefined, res: true }, + { loc: AppPath.PlanCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.WorkspaceActivation, res: true }, + { loc: AppPath.PlanCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.ProfileCreation, res: true }, + { loc: AppPath.PlanCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.SyncEmail, res: true }, + { loc: AppPath.PlanCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: true }, + { loc: AppPath.PlanCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: false }, { loc: AppPath.PlanRequiredSuccess, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: true }, { loc: AppPath.PlanRequiredSuccess, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: false }, diff --git a/packages/twenty-front/src/modules/ui/layout/hooks/useShowAuthModal.ts b/packages/twenty-front/src/modules/ui/layout/hooks/useShowAuthModal.ts index 6cafc16ec0b8..6834135f64b9 100644 --- a/packages/twenty-front/src/modules/ui/layout/hooks/useShowAuthModal.ts +++ b/packages/twenty-front/src/modules/ui/layout/hooks/useShowAuthModal.ts @@ -46,7 +46,7 @@ export const useShowAuthModal = () => { if ( isMatchingLocation(AppPath.PlanRequired) || - isMatchingLocation(AppPath.FreePassCheckout) + isMatchingLocation(AppPath.PlanCheckout) ) { return ( (onboardingStatus === OnboardingStatus.Completed && diff --git a/packages/twenty-front/src/pages/onboarding/FreePassCheckoutEffect.tsx b/packages/twenty-front/src/pages/onboarding/FreePassCheckoutEffect.tsx index 93617801f18a..8a96c4609486 100644 --- a/packages/twenty-front/src/pages/onboarding/FreePassCheckoutEffect.tsx +++ b/packages/twenty-front/src/pages/onboarding/FreePassCheckoutEffect.tsx @@ -1,14 +1,16 @@ +import { useBillingPlan } from '@/billing/hooks/useBillingPlan'; import { AppPath } from '@/types/AppPath'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { - SubscriptionInterval, - useCheckoutSessionMutation, + SubscriptionInterval, + useCheckoutSessionMutation, } from '~/generated/graphql'; export const FreePassCheckoutEffect = () => { const { enqueueSnackBar } = useSnackBar(); const [checkoutSession] = useCheckoutSessionMutation(); + const plan = useBillingPlan(); const createCheckoutSession = async () => { try { @@ -16,7 +18,7 @@ export const FreePassCheckoutEffect = () => { variables: { recurringInterval: SubscriptionInterval.Month, successUrlPath: AppPath.PlanRequiredSuccess, - requirePaymentMethod: false, + plan, }, }); diff --git a/packages/twenty-front/src/pages/onboarding/PlanCheckoutEffect.tsx b/packages/twenty-front/src/pages/onboarding/PlanCheckoutEffect.tsx new file mode 100644 index 000000000000..e0ef371925cb --- /dev/null +++ b/packages/twenty-front/src/pages/onboarding/PlanCheckoutEffect.tsx @@ -0,0 +1,46 @@ +import { useBillingPlan } from '@/billing/hooks/useBillingPlan'; +import { AppPath } from '@/types/AppPath'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { + SubscriptionInterval, + useCheckoutSessionMutation, +} from '~/generated/graphql'; + +export const PlanCheckoutEffect = () => { + const { enqueueSnackBar } = useSnackBar(); + const [checkoutSession] = useCheckoutSessionMutation(); + const plan = useBillingPlan(); + + const createCheckoutSession = async () => { + try { + const { data } = await checkoutSession({ + variables: { + recurringInterval: SubscriptionInterval.Month, + successUrlPath: AppPath.PlanRequiredSuccess, + plan, + }, + }); + + if (!data?.checkoutSession.url) { + enqueueSnackBar( + 'Checkout session error. Please retry or contact Twenty team', + { + variant: SnackBarVariant.Error, + }, + ); + return; + } + + window.location.replace(data.checkoutSession.url); + } catch (error) { + enqueueSnackBar('Error creating checkout session', { + variant: SnackBarVariant.Error, + }); + } + }; + + createCheckoutSession(); + + return <>; +}; diff --git a/packages/twenty-front/src/pages/onboarding/__tests__/PlanCheckoutEffect.test.tsx b/packages/twenty-front/src/pages/onboarding/__tests__/PlanCheckoutEffect.test.tsx new file mode 100644 index 000000000000..501e3cef34c9 --- /dev/null +++ b/packages/twenty-front/src/pages/onboarding/__tests__/PlanCheckoutEffect.test.tsx @@ -0,0 +1,144 @@ +import { MockedProvider } from '@apollo/client/testing'; +import { act, render } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import { RecoilRoot } from 'recoil'; + +import { CHECKOUT_SESSION } from '@/billing/graphql/checkoutSession'; +import { BillingPlanKey } from '@/billing/types/billing'; +import { AppPath } from '@/types/AppPath'; +import { SubscriptionInterval } from '~/generated/graphql'; +import { PlanCheckoutEffect } from '../PlanCheckoutEffect'; + +const mockEnqueueSnackBar = jest.fn(); +jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({ + useSnackBar: () => ({ + enqueueSnackBar: mockEnqueueSnackBar, + }), +})); + +const mockBillingPlan = jest.fn(); +jest.mock('@/billing/hooks/useBillingPlan', () => ({ + useBillingPlan: () => mockBillingPlan(), +})); + +const mockReplace = jest.fn(); +Object.defineProperty(window, 'location', { + value: { + replace: mockReplace, + }, + writable: true, +}); + +describe('PlanCheckoutEffect', () => { + const mockCheckoutUrl = 'https://checkout.stripe.com/test'; + + const successMock = { + request: { + query: CHECKOUT_SESSION, + variables: { + recurringInterval: SubscriptionInterval.Month, + successUrlPath: AppPath.PlanRequiredSuccess, + plan: BillingPlanKey.PRO, + }, + }, + result: { + data: { + checkoutSession: { + url: mockCheckoutUrl, + }, + }, + }, + }; + + const errorMock = { + request: { + query: CHECKOUT_SESSION, + variables: { + recurringInterval: SubscriptionInterval.Month, + successUrlPath: AppPath.PlanRequiredSuccess, + plan: BillingPlanKey.PRO, + }, + }, + error: new Error('Checkout session error'), + }; + + beforeEach(() => { + mockBillingPlan.mockReturnValue(BillingPlanKey.PRO); + mockEnqueueSnackBar.mockClear(); + mockReplace.mockClear(); + }); + + it('should redirect to checkout URL on successful session creation', async () => { + render( + + + + + + + , + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(mockReplace).toHaveBeenCalledWith(mockCheckoutUrl); + expect(mockEnqueueSnackBar).not.toHaveBeenCalled(); + }); + + it('should show error snackbar when checkout session creation fails', async () => { + render( + + + + + + + , + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(mockReplace).not.toHaveBeenCalled(); + expect(mockEnqueueSnackBar).toHaveBeenCalledWith( + 'Error creating checkout session', + { variant: 'error' }, + ); + }); + + it('should show error snackbar when checkout URL is missing', async () => { + const noUrlMock = { + ...successMock, + result: { + data: { + checkoutSession: { + url: null, + }, + }, + }, + }; + + render( + + + + + + + , + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(mockReplace).not.toHaveBeenCalled(); + expect(mockEnqueueSnackBar).toHaveBeenCalledWith( + 'Checkout session error. Please retry or contact Twenty team', + { variant: 'error' }, + ); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts index f92645e13829..d8b238e4abae 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts @@ -56,11 +56,7 @@ export class BillingResolver { @AuthWorkspace() workspace: Workspace, @AuthUser() user: User, @Args() - { - recurringInterval, - successUrlPath, - requirePaymentMethod, - }: CheckoutSessionInput, + { recurringInterval, successUrlPath, plan }: CheckoutSessionInput, ) { const productPrice = await this.stripeService.getStripePrice( AvailableProduct.BasePlan, @@ -79,7 +75,7 @@ export class BillingResolver { workspace, productPrice.stripePriceId, successUrlPath, - requirePaymentMethod, + plan, ), }; } diff --git a/packages/twenty-server/src/engine/core-modules/billing/dto/checkout-session.input.ts b/packages/twenty-server/src/engine/core-modules/billing/dto/checkout-session.input.ts index 6134224f374d..30a141789593 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/dto/checkout-session.input.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/dto/checkout-session.input.ts @@ -1,8 +1,9 @@ import { ArgsType, Field } from '@nestjs/graphql'; -import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; import Stripe from 'stripe'; +import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; @ArgsType() @@ -12,13 +13,13 @@ export class CheckoutSessionInput { @IsNotEmpty() recurringInterval: Stripe.Price.Recurring.Interval; - @Field(() => String) + @Field(() => BillingPlanKey, { defaultValue: BillingPlanKey.PRO }) @IsString() - @IsNotEmpty() - successUrlPath: string; + @IsOptional() + plan?: BillingPlanKey; - @Field(() => Boolean, { defaultValue: true }) - @IsBoolean() + @Field(() => String, { nullable: true }) + @IsString() @IsOptional() - requirePaymentMethod: boolean; + successUrlPath?: string; } diff --git a/packages/twenty-server/src/engine/core-modules/billing/enums/billing-plan-key.enum.ts b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-plan-key.enum.ts index 2f8aa1eb128d..71fc74bbd54e 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/enums/billing-plan-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-plan-key.enum.ts @@ -1,4 +1,12 @@ +import { registerEnumType } from '@nestjs/graphql'; + export enum BillingPlanKey { - BASE_PLAN = 'BASE_PLAN', - PRO_PLAN = 'PRO_PLAN', + FREE = 'FREE', + PRO = 'PRO', + ENTERPRISE = 'ENTERPRISE', } + +registerEnumType(BillingPlanKey, { + name: 'BillingPlanKey', + description: 'The different billing plans available', +}); diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts index c653d69a795b..dd3e2a70233d 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts @@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; +import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; @@ -30,34 +31,41 @@ export class BillingPortalWorkspaceService { workspace: Workspace, priceId: string, successUrlPath?: string, - requirePaymentMethod?: boolean, + plan?: BillingPlanKey, + cancelUrl?: string, + stripeCustomerId?: string, ): Promise { - const frontBaseUrl = this.domainManagerService.getBaseUrl(); - const cancelUrl = frontBaseUrl.toString(); + if (!cancelUrl) { + cancelUrl = this.domainManagerService.getBaseUrl().toString(); + } if (successUrlPath) { + const frontBaseUrl = this.domainManagerService.getBaseUrl(); + frontBaseUrl.pathname = successUrlPath; + successUrlPath = frontBaseUrl.toString(); } - const successUrl = frontBaseUrl.toString(); const quantity = await this.userWorkspaceRepository.countBy({ workspaceId: workspace.id, }); - const stripeCustomerId = ( - await this.billingSubscriptionRepository.findOneBy({ - workspaceId: user.defaultWorkspaceId, - }) - )?.stripeCustomerId; + if (!stripeCustomerId) { + stripeCustomerId = ( + await this.billingSubscriptionRepository.findOneBy({ + workspaceId: user.defaultWorkspaceId, + }) + )?.stripeCustomerId; + } const session = await this.stripeService.createCheckoutSession( user, priceId, quantity, - successUrl, + successUrlPath, cancelUrl, stripeCustomerId, - requirePaymentMethod, + plan, ); assert(session.url, 'Error: missing checkout.session.url'); diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-product.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-product.service.ts index 1c9fcf859046..ec274e051797 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-product.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook-product.service.ts @@ -51,14 +51,7 @@ export class BillingWebhookProductService { } isValidBillingPlanKey(planKey?: string) { - switch (planKey) { - case BillingPlanKey.BASE_PLAN: - return true; - case BillingPlanKey.PRO_PLAN: - return true; - default: - return false; - } + return Object.values(BillingPlanKey).includes(planKey as BillingPlanKey); } isValidPriceUsageBased(priceUsageBased?: string) { diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts index f243aa40fabc..33fc71aaabd4 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts @@ -5,6 +5,7 @@ import Stripe from 'stripe'; import { ProductPriceEntity } from 'src/engine/core-modules/billing/dto/product-price.entity'; import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum'; +import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { User } from 'src/engine/core-modules/user/user.entity'; @@ -89,8 +90,10 @@ export class StripeService { successUrl?: string, cancelUrl?: string, stripeCustomerId?: string, - requirePaymentMethod?: boolean, + plan: BillingPlanKey = BillingPlanKey.FREE, ): Promise { + const requirePaymentMethod = plan !== BillingPlanKey.FREE; + return await this.stripe.checkout.sessions.create({ line_items: [ { @@ -102,13 +105,14 @@ export class StripeService { subscription_data: { metadata: { workspaceId: user.defaultWorkspaceId, + plan: plan, }, trial_period_days: this.environmentService.get( 'BILLING_FREE_TRIAL_DURATION_IN_DAYS', ), }, - automatic_tax: { enabled: !!requirePaymentMethod }, // For now we correlate collecting tax info with collecting the payment method - tax_id_collection: { enabled: !!requirePaymentMethod }, // TBC what we should do in the future. + automatic_tax: { enabled: requirePaymentMethod }, + tax_id_collection: { enabled: requirePaymentMethod }, customer: stripeCustomerId, customer_update: stripeCustomerId ? { name: 'auto' } : undefined, customer_email: stripeCustomerId ? undefined : user.email,