From 52d3735147019061bfec6b7cada8a428d53b2778 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:59:09 +0200 Subject: [PATCH] `[REQUIRED FOR 2.18 RELEASE]` Show the upgrade-plan step at the end of V1 onboarding (#22368) ## What On V1 onboarding the upgrade-plan step (`ChooseYourPlan`) only appeared later, once the user happened to create a record, instead of right after Invite team. ## Why The frontend advances the onboarding status optimistically in `getNextOnboardingStatus()` without refetching, and it never emitted `PLAN_REQUIRED`. So after Invite team the user was locally marked `COMPLETED` and dropped into the app; the backend's real `PLAN_REQUIRED` only surfaced on a later `GetCurrentUser` refetch. ## Fix Make `getNextOnboardingStatus()` billing-aware so it mirrors the backend: return `PLAN_REQUIRED` in the terminal branches when `isBillingEnabled && billingSubscriptions.length === 0` (using `billingSubscriptions` to match the backend's any-subscription check). The navigate hook already routes `PLAN_REQUIRED` to `/plan-required`, so no routing change is needed. Self-hosted and existing-subscription flows are unchanged. Review in cubic --------- Co-authored-by: prastoin --- .../useSetNextOnboardingStatus.test.ts | 105 ++++++++++++------ .../hooks/useSetNextOnboardingStatus.ts | 32 +++++- 2 files changed, 100 insertions(+), 37 deletions(-) diff --git a/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useSetNextOnboardingStatus.test.ts b/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useSetNextOnboardingStatus.test.ts index 77d5431ab1..604c0575ba 100644 --- a/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useSetNextOnboardingStatus.test.ts +++ b/packages/twenty-front/src/modules/onboarding/hooks/__tests__/useSetNextOnboardingStatus.test.ts @@ -1,11 +1,11 @@ import { act, renderHook } from '@testing-library/react'; import { createElement } from 'react'; import { Provider as JotaiProvider } from 'jotai'; -import { v4 } from 'uuid'; import { currentUserState } from '@/auth/states/currentUserState'; import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { billingState } from '@/client-config/states/billingState'; import { useSetNextOnboardingStatus } from '@/onboarding/hooks/useSetNextOnboardingStatus'; import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; @@ -14,10 +14,7 @@ import { resetJotaiStore, } from '@/ui/utilities/state/jotai/jotaiStore'; -import { - OnboardingStatus, - SubscriptionStatus, -} from '~/generated-metadata/graphql'; +import { OnboardingStatus } from '~/generated-metadata/graphql'; import { mockCurrentWorkspace, mockedUserData, @@ -26,10 +23,19 @@ import { const Wrapper = ({ children }: { children: React.ReactNode }) => createElement(JotaiProvider, { store: jotaiStore }, children); +type RenderHooksOptions = { + withSubscription?: boolean; + isBillingEnabled?: boolean; + withOneWorkspaceMember?: boolean; +}; + const renderHooks = ( onboardingStatus: OnboardingStatus, - withCurrentBillingSubscription: boolean, - withOneWorkspaceMember = true, + { + withSubscription = false, + isBillingEnabled = false, + withOneWorkspaceMember = true, + }: RenderHooksOptions = {}, ) => { const { result } = renderHook( () => { @@ -38,12 +44,14 @@ const renderHooks = ( currentUserWorkspaceState, ); const setCurrentWorkspace = useSetAtomState(currentWorkspaceState); + const setBilling = useSetAtomState(billingState); const setNextOnboardingStatus = useSetNextOnboardingStatus(); return { currentUser, setCurrentUser, setCurrentWorkspace, setCurrentUserWorkspace, + setBilling, setNextOnboardingStatus, }; }, @@ -56,16 +64,16 @@ const renderHooks = ( result.current.setCurrentUserWorkspace(mockedUserData.currentUserWorkspace); result.current.setCurrentWorkspace({ ...mockCurrentWorkspace, - currentBillingSubscription: withCurrentBillingSubscription - ? { - id: v4(), - status: SubscriptionStatus.Active, - metadata: {}, - phases: [], - } - : undefined, + billingSubscriptions: withSubscription + ? mockCurrentWorkspace.billingSubscriptions + : [], workspaceMembersCount: withOneWorkspaceMember ? 1 : 2, }); + result.current.setBilling({ + __typename: 'Billing', + isBillingEnabled, + trialPeriods: [], + }); }); act(() => { result.current.setNextOnboardingStatus(); @@ -81,45 +89,76 @@ describe('useSetNextOnboardingStatus', () => { it('should sync emails right after workspace activation', () => { const nextOnboardingStatus = renderHooks( OnboardingStatus.WORKSPACE_ACTIVATION, - false, - true, ); expect(nextOnboardingStatus).toEqual(OnboardingStatus.SYNC_EMAIL); }); it('should create profile after syncing emails', () => { - const nextOnboardingStatus = renderHooks( - OnboardingStatus.SYNC_EMAIL, - false, - true, - ); + const nextOnboardingStatus = renderHooks(OnboardingStatus.SYNC_EMAIL); expect(nextOnboardingStatus).toEqual(OnboardingStatus.PROFILE_CREATION); }); it('should invite the team right after profile creation', () => { - const nextOnboardingStatus = renderHooks( - OnboardingStatus.PROFILE_CREATION, - false, - true, - ); + const nextOnboardingStatus = renderHooks(OnboardingStatus.PROFILE_CREATION); expect(nextOnboardingStatus).toEqual(OnboardingStatus.INVITE_TEAM); }); it('should complete after profile creation when more than 1 workspaceMember exist', () => { const nextOnboardingStatus = renderHooks( OnboardingStatus.PROFILE_CREATION, - false, - false, + { + withOneWorkspaceMember: false, + }, ); expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED); }); - it('should set next onboarding status for Completed', () => { + it('should require a plan after profile creation when billing is enabled and the workspace has no subscription', () => { const nextOnboardingStatus = renderHooks( - OnboardingStatus.INVITE_TEAM, - true, - true, + OnboardingStatus.PROFILE_CREATION, + { + withOneWorkspaceMember: false, + isBillingEnabled: true, + withSubscription: false, + }, ); + expect(nextOnboardingStatus).toEqual(OnboardingStatus.PLAN_REQUIRED); + }); + + it('should complete after inviting the team when billing is disabled', () => { + const nextOnboardingStatus = renderHooks(OnboardingStatus.INVITE_TEAM); expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED); }); + + it('should complete after inviting the team when the workspace already has a subscription', () => { + const nextOnboardingStatus = renderHooks(OnboardingStatus.INVITE_TEAM, { + isBillingEnabled: true, + withSubscription: true, + }); + expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED); + }); + + it('should require a plan after inviting the team when billing is enabled and the workspace has no subscription', () => { + const nextOnboardingStatus = renderHooks(OnboardingStatus.INVITE_TEAM, { + isBillingEnabled: true, + withSubscription: false, + }); + expect(nextOnboardingStatus).toEqual(OnboardingStatus.PLAN_REQUIRED); + }); + + it('should complete after booking onboarding when the workspace already has a subscription', () => { + const nextOnboardingStatus = renderHooks(OnboardingStatus.BOOK_ONBOARDING, { + isBillingEnabled: true, + withSubscription: true, + }); + expect(nextOnboardingStatus).toEqual(OnboardingStatus.COMPLETED); + }); + + it('should require a plan after booking onboarding when billing is enabled and the workspace has no subscription', () => { + const nextOnboardingStatus = renderHooks(OnboardingStatus.BOOK_ONBOARDING, { + isBillingEnabled: true, + withSubscription: false, + }); + expect(nextOnboardingStatus).toEqual(OnboardingStatus.PLAN_REQUIRED); + }); }); diff --git a/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts b/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts index 0ea27cbaad..5da62ea45f 100644 --- a/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts +++ b/packages/twenty-front/src/modules/onboarding/hooks/useSetNextOnboardingStatus.ts @@ -8,6 +8,7 @@ import { type CurrentWorkspace, currentWorkspaceState, } from '@/auth/states/currentWorkspaceState'; +import { billingState } from '@/client-config/states/billingState'; import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; @@ -19,13 +20,19 @@ type GetNextOnboardingStatusArgs = { currentUser: CurrentUser | null; currentWorkspace: CurrentWorkspace | null; calendarBookingPageId: string | null; + isBillingEnabled: boolean; }; const getNextOnboardingStatus = ({ currentUser, currentWorkspace, calendarBookingPageId, + isBillingEnabled, }: GetNextOnboardingStatusArgs) => { + const isPlanRequired = + isBillingEnabled && + (currentWorkspace?.billingSubscriptions?.length ?? 0) === 0; + if (currentUser?.onboardingStatus === OnboardingStatus.WORKSPACE_ACTIVATION) { return OnboardingStatus.SYNC_EMAIL; } @@ -35,17 +42,25 @@ const getNextOnboardingStatus = ({ } if (currentUser?.onboardingStatus === OnboardingStatus.PROFILE_CREATION) { - return currentWorkspace?.workspaceMembersCount === 1 - ? OnboardingStatus.INVITE_TEAM + if (currentWorkspace?.workspaceMembersCount === 1) { + return OnboardingStatus.INVITE_TEAM; + } + return isPlanRequired + ? OnboardingStatus.PLAN_REQUIRED : OnboardingStatus.COMPLETED; } if (currentUser?.onboardingStatus === OnboardingStatus.INVITE_TEAM) { + if (isPlanRequired) { + return OnboardingStatus.PLAN_REQUIRED; + } return isDefined(calendarBookingPageId) ? OnboardingStatus.BOOK_ONBOARDING : OnboardingStatus.COMPLETED; } if (currentUser?.onboardingStatus === OnboardingStatus.BOOK_ONBOARDING) { - return OnboardingStatus.COMPLETED; + return isPlanRequired + ? OnboardingStatus.PLAN_REQUIRED + : OnboardingStatus.COMPLETED; } return OnboardingStatus.COMPLETED; }; @@ -55,12 +70,15 @@ export const useSetNextOnboardingStatus = () => { const currentUser = useAtomStateValue(currentUserState); const currentWorkspace = useAtomStateValue(currentWorkspaceState); const calendarBookingPageId = useAtomStateValue(calendarBookingPageIdState); + const billing = useAtomStateValue(billingState); + const isBillingEnabled = billing?.isBillingEnabled ?? false; return useCallback(() => { const nextOnboardingStatus = getNextOnboardingStatus({ currentUser, currentWorkspace, calendarBookingPageId, + isBillingEnabled, }); store.set(currentUserState.atom, (current) => { if (isDefined(current)) { @@ -71,5 +89,11 @@ export const useSetNextOnboardingStatus = () => { } return current; }); - }, [currentUser, currentWorkspace, calendarBookingPageId, store]); + }, [ + currentUser, + currentWorkspace, + calendarBookingPageId, + isBillingEnabled, + store, + ]); };