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.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23717?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Raphaël Bosi
2026-08-04 10:31:24 +02:00
committed by GitHub
parent 6f6da14cc8
commit 5bafaa0994
9 changed files with 184 additions and 42 deletions
@@ -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,
@@ -40,7 +40,7 @@ export type ClientConfig = {
isClickHouseConfigured: boolean;
isWorkspaceSchemaDDLLocked: boolean;
isOnboardingAiChatEnabled: boolean;
onboarding: OnboardingConfig;
onboarding: OnboardingConfig | null;
publicFeatureFlags: Array<PublicFeatureFlag>;
sentry: Sentry;
signInPrefilled: boolean;
@@ -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 (
<OnboardingLayout freeCredits={freeCredits}>
<OnboardingLayout
freeCredits={isDefined(onboardingConfig) ? freeCreditsTotal : undefined}
>
<PrefetchPlanRequiredStepEffect />
<OnboardingTransitionOutlet />
</OnboardingLayout>
@@ -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 }) => (
<JotaiProvider store={jotaiStore}>
<I18nProvider i18n={i18n}>{children}</I18nProvider>
</JotaiProvider>
);
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(<OnboardingStepLayout />, { 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(<OnboardingStepLayout />, { wrapper: Wrapper });
expect(screen.queryByText('free credits')).not.toBeInTheDocument();
});
});
@@ -301,7 +301,7 @@ export class ClientConfig {
@Field(() => ApiConfig)
api: ApiConfig;
onboarding: OnboardingConfig;
onboarding: OnboardingConfig | null;
@Field(() => Boolean)
canManageFeatureFlags: boolean;
@@ -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')
@@ -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',
@@ -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', () => {
@@ -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(