From 5bafaa09946a5d38324f12be887a90c08327035f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?=
<71827178+bosiraphael@users.noreply.github.com>
Date: Tue, 4 Aug 2026 10:31:24 +0200
Subject: [PATCH] Hide onboarding credits when billing is disabled (#23717)
Onboarding advertises free credits (the header pill and the green "Earn
+N free credits" tags) even when `IS_BILLING_ENABLED` is false,
promising a reward that can never be granted: `creditWorkspaceBalance`
already no-ops when billing is off.
The server now omits the `onboarding` credit-rewards block from the
client config when billing is disabled, which hides every reward tag on
its own since they all render behind a defined-config guard. The header
pill gets an explicit gate.
Also stops treating onboarding invites as reward-eligible when billing
is off, so they are minted as plain invitation tokens and the 10-invite
`ONBOARDING_INVITE_TEAM_MAX_INVITES` cap no longer applies to
self-hosted instances.
---
.../client-config/hooks/useClientConfig.ts | 2 +-
.../client-config/types/ClientConfig.ts | 2 +-
.../components/OnboardingStepLayout.tsx | 10 ++-
.../__tests__/OnboardingStepLayout.test.tsx | 73 +++++++++++++++++++
.../client-config/client-config.entity.ts | 2 +-
.../services/client-config.service.spec.ts | 12 +++
.../services/client-config.service.ts | 53 +++++++-------
.../workspace-invitation.service.spec.ts | 63 ++++++++++++++--
.../services/workspace-invitation.service.ts | 9 ++-
9 files changed, 184 insertions(+), 42 deletions(-)
create mode 100644 packages/twenty-front/src/modules/onboarding/components/__tests__/OnboardingStepLayout.test.tsx
diff --git a/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts b/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts
index bf55e537a3..b560a342b2 100644
--- a/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts
+++ b/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts
@@ -193,7 +193,7 @@ export const useClientConfig = (): UseClientConfigResult => {
});
setApiConfig(clientConfig?.api);
- setOnboardingConfig(clientConfig?.onboarding);
+ setOnboardingConfig(clientConfig?.onboarding ?? null);
setDomainConfiguration({
defaultSubdomain: clientConfig?.defaultSubdomain,
frontDomain: clientConfig?.frontDomain,
diff --git a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts
index 86f6415819..c7dc6eb7da 100644
--- a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts
+++ b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts
@@ -40,7 +40,7 @@ export type ClientConfig = {
isClickHouseConfigured: boolean;
isWorkspaceSchemaDDLLocked: boolean;
isOnboardingAiChatEnabled: boolean;
- onboarding: OnboardingConfig;
+ onboarding: OnboardingConfig | null;
publicFeatureFlags: Array;
sentry: Sentry;
signInPrefilled: boolean;
diff --git a/packages/twenty-front/src/modules/onboarding/components/OnboardingStepLayout.tsx b/packages/twenty-front/src/modules/onboarding/components/OnboardingStepLayout.tsx
index f25d766825..de7e9bbb25 100644
--- a/packages/twenty-front/src/modules/onboarding/components/OnboardingStepLayout.tsx
+++ b/packages/twenty-front/src/modules/onboarding/components/OnboardingStepLayout.tsx
@@ -1,13 +1,19 @@
+import { onboardingConfigState } from '@/client-config/states/onboardingConfigState';
import { OnboardingLayout } from '@/onboarding/components/OnboardingLayout';
import { OnboardingTransitionOutlet } from '@/onboarding/components/OnboardingTransitionOutlet';
import { PrefetchPlanRequiredStepEffect } from '@/onboarding/effect-components/PrefetchPlanRequiredStepEffect';
import { useOnboardingFreeCreditsTotal } from '@/onboarding/hooks/useOnboardingFreeCreditsTotal';
+import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
+import { isDefined } from 'twenty-shared/utils';
export const OnboardingStepLayout = () => {
- const freeCredits = useOnboardingFreeCreditsTotal();
+ const onboardingConfig = useAtomStateValue(onboardingConfigState);
+ const freeCreditsTotal = useOnboardingFreeCreditsTotal();
return (
-
+
diff --git a/packages/twenty-front/src/modules/onboarding/components/__tests__/OnboardingStepLayout.test.tsx b/packages/twenty-front/src/modules/onboarding/components/__tests__/OnboardingStepLayout.test.tsx
new file mode 100644
index 0000000000..e4ee2dc2b3
--- /dev/null
+++ b/packages/twenty-front/src/modules/onboarding/components/__tests__/OnboardingStepLayout.test.tsx
@@ -0,0 +1,73 @@
+import { i18n } from '@lingui/core';
+import { I18nProvider } from '@lingui/react';
+import { render, screen } from '@testing-library/react';
+import { Provider as JotaiProvider } from 'jotai';
+import { type ReactNode } from 'react';
+import { SOURCE_LOCALE } from 'twenty-shared/translations';
+
+import { onboardingConfigState } from '@/client-config/states/onboardingConfigState';
+import { type OnboardingConfig } from '@/client-config/types/OnboardingConfig';
+import { OnboardingStepLayout } from '@/onboarding/components/OnboardingStepLayout';
+import { onboardingFreeCreditsState } from '@/onboarding/states/onboardingFreeCreditsState';
+import {
+ jotaiStore,
+ resetJotaiStore,
+} from '@/ui/utilities/state/jotai/jotaiStore';
+import { messages } from '~/locales/generated/en';
+
+jest.mock(
+ '@/onboarding/effect-components/PrefetchPlanRequiredStepEffect',
+ () => ({
+ PrefetchPlanRequiredStepEffect: () => null,
+ }),
+);
+
+jest.mock('@/onboarding/components/OnboardingTransitionOutlet', () => ({
+ OnboardingTransitionOutlet: () => null,
+}));
+
+i18n.load({ [SOURCE_LOCALE]: messages });
+i18n.activate(SOURCE_LOCALE);
+
+const onboardingConfig: OnboardingConfig = {
+ importContactsCreditsReward: 2,
+ inviteTeamMaxCreditsReward: 9,
+ inviteTeamCreditsRewardPerUser: 3,
+ upgradeCreditsReward: 5,
+ installAppsCreditsRewardPerApp: 1,
+};
+
+const Wrapper = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+);
+
+describe('OnboardingStepLayout', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ resetJotaiStore();
+ jotaiStore.set(onboardingFreeCreditsState.atom, {
+ importContacts: 2,
+ inviteTeam: 3,
+ installApps: 1,
+ });
+ });
+
+ it('should display the free credits pill when credits rewards are configured', () => {
+ jotaiStore.set(onboardingConfigState.atom, onboardingConfig);
+
+ render(, { wrapper: Wrapper });
+
+ expect(screen.getByText('free credits')).toBeInTheDocument();
+ expect(screen.getByText('6')).toBeInTheDocument();
+ });
+
+ it('should hide the free credits pill when credits rewards are not configured', () => {
+ jotaiStore.set(onboardingConfigState.atom, null);
+
+ render(, { wrapper: Wrapper });
+
+ expect(screen.queryByText('free credits')).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts
index 0fd72b96da..7e5bbf9387 100644
--- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts
+++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts
@@ -301,7 +301,7 @@ export class ClientConfig {
@Field(() => ApiConfig)
api: ApiConfig;
- onboarding: OnboardingConfig;
+ onboarding: OnboardingConfig | null;
@Field(() => Boolean)
canManageFeatureFlags: boolean;
diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts
index 38dce08ec4..f859ee2ce4 100644
--- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts
@@ -197,6 +197,18 @@ describe('ClientConfigService', () => {
});
});
+ it('should not return onboarding credits rewards when billing is disabled', async () => {
+ jest
+ .spyOn(twentyConfigService, 'get')
+ .mockImplementation((key: string) =>
+ key === 'IS_BILLING_ENABLED' ? false : undefined,
+ );
+
+ const result = await service.getClientConfig();
+
+ expect(result.onboarding).toBeNull();
+ });
+
it('should advertise cookie sessions when the flag is on', async () => {
jest
.spyOn(twentyConfigService, 'get')
diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts
index 288471d4a9..5a25a42fa4 100644
--- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts
@@ -52,6 +52,8 @@ export class ClientConfigService {
this.twentyConfigService.get('EMAILING_DOMAIN_DRIVER') ===
EmailingDomainDriver.LOG;
+ const isBillingEnabled = this.twentyConfigService.get('IS_BILLING_ENABLED');
+
const availableModels =
this.aiModelRegistryService.getAdminFilteredModels();
const recommendedModelIds =
@@ -160,7 +162,7 @@ export class ClientConfigService {
const clientConfig: ClientConfig = {
appVersion: this.twentyConfigService.get('APP_VERSION'),
billing: {
- isBillingEnabled: this.twentyConfigService.get('IS_BILLING_ENABLED'),
+ isBillingEnabled,
billingUrl: this.twentyConfigService.get('BILLING_PLAN_REQUIRED_LINK'),
stripePublishableKey: this.twentyConfigService.get(
'BILLING_STRIPE_PUBLISHABLE_KEY',
@@ -220,36 +222,37 @@ export class ClientConfigService {
'MUTATION_MAXIMUM_AFFECTED_RECORDS',
),
},
- onboarding: {
- importContactsCreditsReward: toDisplayCredits(
- this.twentyConfigService.get(
- 'ONBOARDING_IMPORT_CONTACTS_CREDITS_REWARD',
- ),
- ),
- inviteTeamCreditsRewardPerUser: toDisplayCredits(
- this.twentyConfigService.get(
- 'ONBOARDING_INVITE_TEAM_CREDITS_REWARD_PER_USER',
- ),
- ),
- upgradeCreditsReward: toDisplayCredits(
- this.twentyConfigService.get(
- 'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITH_CREDIT_CARD',
- ),
- ),
- installAppsCreditsRewardPerApp: toDisplayCredits(
- this.twentyConfigService.get(
- 'ONBOARDING_INSTALL_APPS_CREDITS_REWARD_PER_APP',
- ),
- ),
- },
+ onboarding: isBillingEnabled
+ ? {
+ importContactsCreditsReward: toDisplayCredits(
+ this.twentyConfigService.get(
+ 'ONBOARDING_IMPORT_CONTACTS_CREDITS_REWARD',
+ ),
+ ),
+ inviteTeamCreditsRewardPerUser: toDisplayCredits(
+ this.twentyConfigService.get(
+ 'ONBOARDING_INVITE_TEAM_CREDITS_REWARD_PER_USER',
+ ),
+ ),
+ upgradeCreditsReward: toDisplayCredits(
+ this.twentyConfigService.get(
+ 'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITH_CREDIT_CARD',
+ ),
+ ),
+ installAppsCreditsRewardPerApp: toDisplayCredits(
+ this.twentyConfigService.get(
+ 'ONBOARDING_INSTALL_APPS_CREDITS_REWARD_PER_APP',
+ ),
+ ),
+ }
+ : null,
isAttachmentPreviewEnabled: this.twentyConfigService.get(
'IS_ATTACHMENT_PREVIEW_ENABLED',
),
analyticsEnabled: this.twentyConfigService.get('ANALYTICS_ENABLED'),
canManageFeatureFlags:
this.twentyConfigService.get('NODE_ENV') ===
- NodeEnvironment.DEVELOPMENT ||
- this.twentyConfigService.get('IS_BILLING_ENABLED'),
+ NodeEnvironment.DEVELOPMENT || isBillingEnabled,
publicFeatureFlags: PUBLIC_FEATURE_FLAGS,
isCookieSessionEnabled: this.twentyConfigService.get(
'AUTH_COOKIE_SESSIONS_ENABLED',
diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts
index f214662fd3..a52db5c067 100644
--- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts
@@ -253,13 +253,12 @@ describe('WorkspaceInvitationService', () => {
jest
.spyOn(onboardingService, 'isOnboardingInviteTeamPending')
.mockResolvedValue(true);
- jest
- .spyOn(twentyConfigService, 'get')
- .mockImplementation((key: any) =>
- key === 'ONBOARDING_INVITE_TEAM_MAX_INVITES'
- ? 10
- : 'http://localhost:3000',
- );
+ jest.spyOn(twentyConfigService, 'get').mockImplementation((key: any) => {
+ if (key === 'IS_BILLING_ENABLED') return true;
+ if (key === 'ONBOARDING_INVITE_TEAM_MAX_INVITES') return 10;
+
+ return 'http://localhost:3000';
+ });
jest.spyOn(appTokenRepository, 'count').mockResolvedValue(0);
jest.spyOn(emailService, 'send').mockResolvedValue({} as any);
@@ -301,7 +300,9 @@ describe('WorkspaceInvitationService', () => {
.mockResolvedValue(false);
jest
.spyOn(twentyConfigService, 'get')
- .mockReturnValue('http://localhost:3000');
+ .mockImplementation((key: any) =>
+ key === 'IS_BILLING_ENABLED' ? true : 'http://localhost:3000',
+ );
jest.spyOn(emailService, 'send').mockResolvedValue({} as any);
await service.sendInvitations(
@@ -320,6 +321,52 @@ describe('WorkspaceInvitationService', () => {
onboardingService.isOnboardingInviteTeamPending,
).toHaveBeenCalledWith({ workspaceId: workspace.id });
});
+
+ it('should downgrade to a regular invitation when billing is disabled', async () => {
+ const workspace = {
+ id: 'workspace-id',
+ inviteHash: 'invite-hash',
+ displayName: 'Test Workspace',
+ } as WorkspaceEntity;
+ const sender = {
+ userEmail: 'sender@example.com',
+ name: { firstName: 'Sender' },
+ locale: 'en',
+ };
+
+ const createWorkspaceInvitationSpy = jest
+ .spyOn(service, 'createWorkspaceInvitation')
+ .mockResolvedValue({
+ context: { email: 'test1@example.com' },
+ value: 'token-value',
+ type: AppTokenType.InvitationToken,
+ } as AppTokenEntity);
+ jest
+ .spyOn(onboardingService, 'isOnboardingInviteTeamPending')
+ .mockResolvedValue(true);
+ jest
+ .spyOn(twentyConfigService, 'get')
+ .mockImplementation((key: any) =>
+ key === 'IS_BILLING_ENABLED' ? false : 'http://localhost:3000',
+ );
+ const countSpy = jest.spyOn(appTokenRepository, 'count');
+
+ jest.spyOn(emailService, 'send').mockResolvedValue({} as any);
+
+ await service.sendInvitations(
+ ['test1@example.com'],
+ workspace,
+ sender as WorkspaceMemberWorkspaceEntity,
+ );
+
+ expect(createWorkspaceInvitationSpy).toHaveBeenCalledWith(
+ 'test1@example.com',
+ workspace,
+ undefined,
+ false,
+ );
+ expect(countSpy).not.toHaveBeenCalled();
+ });
});
describe('resendWorkspaceInvitation', () => {
diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts
index 398a8da829..1f7389f2de 100644
--- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts
@@ -282,10 +282,11 @@ export class WorkspaceInvitationService {
}
const isOnboardingInviteReward =
- isOnboardingInviteRewardOverride ??
- (await this.onboardingService.isOnboardingInviteTeamPending({
- workspaceId: workspace.id,
- }));
+ this.twentyConfigService.get('IS_BILLING_ENABLED') &&
+ (isOnboardingInviteRewardOverride ??
+ (await this.onboardingService.isOnboardingInviteTeamPending({
+ workspaceId: workspace.id,
+ })));
if (isOnboardingInviteReward) {
await this.throwIfOnboardingInvitationLimitReached(