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:
@@ -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>
|
||||
|
||||
+73
@@ -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();
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -301,7 +301,7 @@ export class ClientConfig {
|
||||
@Field(() => ApiConfig)
|
||||
api: ApiConfig;
|
||||
|
||||
onboarding: OnboardingConfig;
|
||||
onboarding: OnboardingConfig | null;
|
||||
|
||||
@Field(() => Boolean)
|
||||
canManageFeatureFlags: boolean;
|
||||
|
||||
+12
@@ -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')
|
||||
|
||||
+28
-25
@@ -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',
|
||||
|
||||
+55
-8
@@ -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', () => {
|
||||
|
||||
+5
-4
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user