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.
---------
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,
+ ]);
};