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. Review in cubic --- .../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(