diff --git a/packages/twenty-front/src/App.tsx b/packages/twenty-front/src/App.tsx
index dd0a149f8..81b665165 100644
--- a/packages/twenty-front/src/App.tsx
+++ b/packages/twenty-front/src/App.tsx
@@ -1,9 +1,10 @@
import { Route, Routes } from 'react-router-dom';
+import { useRecoilValue } from 'recoil';
+import { billingState } from '@/client-config/states/billingState.ts';
import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath';
import { DefaultLayout } from '@/ui/layout/page/DefaultLayout';
-import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { DefaultPageTitle } from '~/DefaultPageTitle';
import { CommandMenuEffect } from '~/effect-components/CommandMenuEffect';
import { GotoHotkeysEffect } from '~/effect-components/GotoHotkeysEffect';
@@ -12,7 +13,6 @@ import { CreateProfile } from '~/pages/auth/CreateProfile';
import { CreateWorkspace } from '~/pages/auth/CreateWorkspace';
import { PasswordReset } from '~/pages/auth/PasswordReset';
import { PaymentSuccess } from '~/pages/auth/PaymentSuccess.tsx';
-import { PlanRequired } from '~/pages/auth/PlanRequired';
import { SignInUp } from '~/pages/auth/SignInUp';
import { VerifyEffect } from '~/pages/auth/VerifyEffect';
import { DefaultHomePage } from '~/pages/DefaultHomePage';
@@ -47,7 +47,7 @@ import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMemb
import { Tasks } from '~/pages/tasks/Tasks';
export const App = () => {
- const isSelfBillingEnabled = useIsFeatureEnabled('IS_SELF_BILLING_ENABLED');
+ const billing = useRecoilValue(billingState());
return (
<>
@@ -63,12 +63,7 @@ export const App = () => {
} />
} />
} />
- :
- }
- />
+ } />
}
@@ -115,10 +110,12 @@ export const App = () => {
path={SettingsPath.AccountsEmailsInboxSettings}
element={}
/>
- }
- />
+ {billing?.isBillingEnabled && (
+ }
+ />
+ )}
}
diff --git a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx
index 998e7b291..65c57eefa 100644
--- a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx
+++ b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx
@@ -93,7 +93,10 @@ export const PageChangeEffect = () => {
[OnboardingStatus.Unpaid, OnboardingStatus.Canceled].includes(
onboardingStatus,
) &&
- !isMatchingLocation(SettingsPath.Billing)
+ !(
+ isMatchingLocation(AppPath.SettingsCatchAll) ||
+ isMatchingLocation(AppPath.PlanRequired)
+ )
) {
navigate(
`${AppPath.SettingsCatchAll.replace('/*', '')}/${SettingsPath.Billing}`,
@@ -110,7 +113,8 @@ export const PageChangeEffect = () => {
) {
navigate(AppPath.CreateProfile);
} else if (
- onboardingStatus === OnboardingStatus.Completed &&
+ (onboardingStatus === OnboardingStatus.Completed ||
+ onboardingStatus === OnboardingStatus.CompletedWithoutSubscription) &&
isMatchingOnboardingRoute
) {
navigate(AppPath.Index);
diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx
index b70eeddb6..c96373fe9 100644
--- a/packages/twenty-front/src/generated/graphql.tsx
+++ b/packages/twenty-front/src/generated/graphql.tsx
@@ -65,6 +65,28 @@ export type Billing = {
isBillingEnabled: Scalars['Boolean'];
};
+export type BillingSubscription = {
+ __typename?: 'BillingSubscription';
+ id: Scalars['ID'];
+ status: Scalars['String'];
+};
+
+export type BillingSubscriptionFilter = {
+ and?: InputMaybe>;
+ id?: InputMaybe;
+ or?: InputMaybe>;
+};
+
+export type BillingSubscriptionSort = {
+ direction: SortDirection;
+ field: BillingSubscriptionSortFields;
+ nulls?: InputMaybe;
+};
+
+export enum BillingSubscriptionSortFields {
+ Id = 'id'
+}
+
export type BooleanFieldComparison = {
is?: InputMaybe;
isNot?: InputMaybe;
@@ -631,7 +653,9 @@ export type Workspace = {
__typename?: 'Workspace';
activationStatus: Scalars['String'];
allowImpersonation: Scalars['Boolean'];
+ billingSubscriptions?: Maybe>;
createdAt: Scalars['DateTime'];
+ currentBillingSubscription?: Maybe;
deletedAt?: Maybe;
displayName?: Maybe;
domainName?: Maybe;
@@ -644,6 +668,12 @@ export type Workspace = {
};
+export type WorkspaceBillingSubscriptionsArgs = {
+ filter?: BillingSubscriptionFilter;
+ sorting?: Array;
+};
+
+
export type WorkspaceFeatureFlagsArgs = {
filter?: FeatureFlagFilter;
sorting?: Array;
@@ -942,7 +972,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf
export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
-export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, activationStatus: string, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: string, key: string, value: boolean, workspaceId: string }> | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null } | null }> } };
+export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, subscriptionStatus: string, activationStatus: string, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: string, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', status: string } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null } | null }> } };
export type ActivateWorkspaceMutationVariables = Exact<{
input: ActivateWorkspaceInput;
@@ -1917,6 +1947,9 @@ export const GetCurrentUserDocument = gql`
value
workspaceId
}
+ currentBillingSubscription {
+ status
+ }
}
workspaces {
workspace {
diff --git a/packages/twenty-front/src/modules/auth/hooks/__test__/useOnboardingStatus.test.ts b/packages/twenty-front/src/modules/auth/hooks/__test__/useOnboardingStatus.test.ts
index 51387e2c9..ec035d3c8 100644
--- a/packages/twenty-front/src/modules/auth/hooks/__test__/useOnboardingStatus.test.ts
+++ b/packages/twenty-front/src/modules/auth/hooks/__test__/useOnboardingStatus.test.ts
@@ -21,6 +21,9 @@ const currentWorkspace = {
activationStatus: 'active',
id: '1',
allowImpersonation: true,
+ currentBillingSubscription: {
+ status: 'trialing',
+ },
};
const currentWorkspaceMember = {
id: '1',
@@ -240,4 +243,35 @@ describe('useOnboardingStatus', () => {
expect(result.current.onboardingStatus).toBe('unpaid');
});
+
+ it('should return "completed_without_subscription"', async () => {
+ const { result } = renderHooks();
+ const {
+ setTokenPair,
+ setBilling,
+ setCurrentWorkspace,
+ setCurrentWorkspaceMember,
+ } = result.current;
+
+ act(() => {
+ setTokenPair(tokenPair);
+ setBilling(billing);
+ setCurrentWorkspace({
+ ...currentWorkspace,
+ subscriptionStatus: 'trialing',
+ currentBillingSubscription: null,
+ });
+ setCurrentWorkspaceMember({
+ ...currentWorkspaceMember,
+ name: {
+ firstName: 'John',
+ lastName: 'Doe',
+ },
+ });
+ });
+
+ expect(result.current.onboardingStatus).toBe(
+ 'completed_without_subscription',
+ );
+ });
});
diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts
index 5f9032e9d..3ad400f6f 100644
--- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts
+++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts
@@ -11,6 +11,7 @@ export type CurrentWorkspace = Pick<
| 'featureFlags'
| 'subscriptionStatus'
| 'activationStatus'
+ | 'currentBillingSubscription'
>;
export const currentWorkspaceState = createState({
diff --git a/packages/twenty-front/src/modules/auth/utils/getOnboardingStatus.ts b/packages/twenty-front/src/modules/auth/utils/getOnboardingStatus.ts
index e32e930d9..1f12af76b 100644
--- a/packages/twenty-front/src/modules/auth/utils/getOnboardingStatus.ts
+++ b/packages/twenty-front/src/modules/auth/utils/getOnboardingStatus.ts
@@ -10,6 +10,7 @@ export enum OnboardingStatus {
OngoingWorkspaceActivation = 'ongoing_workspace_activation',
OngoingProfileCreation = 'ongoing_profile_creation',
Completed = 'completed',
+ CompletedWithoutSubscription = 'completed_without_subscription',
}
export const getOnboardingStatus = ({
@@ -75,5 +76,12 @@ export const getOnboardingStatus = ({
return OnboardingStatus.Unpaid;
}
+ if (
+ isBillingEnabled === true &&
+ !currentWorkspace.currentBillingSubscription
+ ) {
+ return OnboardingStatus.CompletedWithoutSubscription;
+ }
+
return OnboardingStatus.Completed;
};
diff --git a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx
index 20923bd3c..0c9aa2f19 100644
--- a/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx
+++ b/packages/twenty-front/src/modules/settings/components/SettingsNavigationDrawerItems.tsx
@@ -1,7 +1,9 @@
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
+import { useRecoilValue } from 'recoil';
import { useAuth } from '@/auth/hooks/useAuth';
+import { billingState } from '@/client-config/states/billingState.ts';
import { SettingsNavigationDrawerItem } from '@/settings/components/SettingsNavigationDrawerItem';
import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath';
@@ -35,7 +37,7 @@ export const SettingsNavigationDrawerItems = () => {
}, [signOut, navigate]);
const isCalendarEnabled = useIsFeatureEnabled('IS_CALENDAR_ENABLED');
- const isSelfBillingEnabled = useIsFeatureEnabled('IS_SELF_BILLING_ENABLED');
+ const billing = useRecoilValue(billingState());
return (
<>
@@ -88,12 +90,13 @@ export const SettingsNavigationDrawerItems = () => {
path={SettingsPath.WorkspaceMembersPage}
Icon={IconUsers}
/>
-
+ {billing?.isBillingEnabled && (
+
+ )}
{
OnboardingStatus.OngoingProfileCreation,
OnboardingStatus.OngoingWorkspaceActivation,
].includes(onboardingStatus)) ||
- isMatchingLocation(AppPath.ResetPassword)
+ isMatchingLocation(AppPath.ResetPassword) ||
+ (isMatchingLocation(AppPath.PlanRequired) &&
+ (OnboardingStatus.CompletedWithoutSubscription ||
+ OnboardingStatus.Canceled))
);
}, [isMatchingLocation, onboardingStatus]);
diff --git a/packages/twenty-front/src/modules/users/graphql/queries/getCurrentUser.ts b/packages/twenty-front/src/modules/users/graphql/queries/getCurrentUser.ts
index 33dc3ad22..5568ae49c 100644
--- a/packages/twenty-front/src/modules/users/graphql/queries/getCurrentUser.ts
+++ b/packages/twenty-front/src/modules/users/graphql/queries/getCurrentUser.ts
@@ -35,6 +35,9 @@ export const GET_CURRENT_USER = gql`
value
workspaceId
}
+ currentBillingSubscription {
+ status
+ }
}
workspaces {
workspace {
diff --git a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts
index ad15dba8d..6b679b7d9 100644
--- a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts
+++ b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts
@@ -1,5 +1,4 @@
export type FeatureFlagKey =
| 'IS_BLOCKLIST_ENABLED'
| 'IS_CALENDAR_ENABLED'
- | 'IS_QUICK_ACTIONS_ENABLED'
- | 'IS_SELF_BILLING_ENABLED';
+ | 'IS_QUICK_ACTIONS_ENABLED';
diff --git a/packages/twenty-front/src/pages/auth/PlanRequired.tsx b/packages/twenty-front/src/pages/auth/PlanRequired.tsx
deleted file mode 100644
index 6cf1f55af..000000000
--- a/packages/twenty-front/src/pages/auth/PlanRequired.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import React from 'react';
-import styled from '@emotion/styled';
-import { useRecoilValue } from 'recoil';
-
-import { Logo } from '@/auth/components/Logo';
-import { SubTitle } from '@/auth/components/SubTitle';
-import { Title } from '@/auth/components/Title';
-import { billingState } from '@/client-config/states/billingState';
-import { PageHotkeyScope } from '@/types/PageHotkeyScope';
-import { MainButton } from '@/ui/input/button/components/MainButton.tsx';
-import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn';
-import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
-
-const StyledButtonContainer = styled.div`
- margin-top: ${({ theme }) => theme.spacing(8)};
-`;
-
-export const PlanRequired = () => {
- const billing = useRecoilValue(billingState());
-
- const handleButtonClick = () => {
- billing?.billingUrl && window.location.replace(billing.billingUrl);
- };
-
- useScopedHotkeys('enter', handleButtonClick, PageHotkeyScope.PlanRequired, [
- handleButtonClick,
- ]);
-
- return (
- <>
-
-
-
- Plan required
-
- Please select a subscription plan before proceeding to sign in.
-
-
-
-
- >
- );
-};
diff --git a/packages/twenty-front/src/pages/auth/__stories__/PlanRequired.stories.tsx b/packages/twenty-front/src/pages/auth/__stories__/PlanRequired.stories.tsx
deleted file mode 100644
index f325f824c..000000000
--- a/packages/twenty-front/src/pages/auth/__stories__/PlanRequired.stories.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import { getOperationName } from '@apollo/client/utilities';
-import { Meta, StoryObj } from '@storybook/react';
-import { within } from '@storybook/test';
-import { graphql, HttpResponse } from 'msw';
-
-import { AppPath } from '@/types/AppPath';
-import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
-import {
- PageDecorator,
- PageDecoratorArgs,
-} from '~/testing/decorators/PageDecorator';
-import { graphqlMocks } from '~/testing/graphqlMocks';
-import { mockedOnboardingUsersData } from '~/testing/mock-data/users';
-
-import { PlanRequired } from '../PlanRequired';
-
-const meta: Meta = {
- title: 'Pages/Auth/PlanRequired',
- component: PlanRequired,
- decorators: [PageDecorator],
- args: { routePath: AppPath.PlanRequired },
- parameters: {
- msw: {
- handlers: [
- graphql.query(getOperationName(GET_CURRENT_USER) ?? '', () => {
- return HttpResponse.json({
- data: {
- currentUser: {
- ...mockedOnboardingUsersData[0],
- defaultWorkspace: {
- ...mockedOnboardingUsersData[0].defaultWorkspace,
- subscriptionStatus: 'incomplete',
- },
- },
- },
- });
- }),
- graphqlMocks.handlers,
- ],
- },
- },
-};
-
-export default meta;
-
-export type Story = StoryObj;
-
-export const Default: Story = {
- play: async ({ canvasElement }) => {
- const canvas = within(canvasElement);
-
- await canvas.findByRole('button', { name: 'Get started' });
- },
-};
diff --git a/packages/twenty-front/src/pages/settings/SettingsBilling.tsx b/packages/twenty-front/src/pages/settings/SettingsBilling.tsx
index 0940e33d1..b4f332c67 100644
--- a/packages/twenty-front/src/pages/settings/SettingsBilling.tsx
+++ b/packages/twenty-front/src/pages/settings/SettingsBilling.tsx
@@ -1,15 +1,13 @@
import React from 'react';
+import { useNavigate } from 'react-router-dom';
import styled from '@emotion/styled';
-import { isNonEmptyString } from '@sniptt/guards';
-import { useRecoilValue } from 'recoil';
import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus.ts';
-import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState.ts';
import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus.ts';
import { SettingsBillingCoverImage } from '@/billing/components/SettingsBillingCoverImage.tsx';
-import { supportChatState } from '@/client-config/states/supportChatState.ts';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SupportChat } from '@/support/components/SupportChat.tsx';
+import { AppPath } from '@/types/AppPath.ts';
import { IconCreditCard, IconCurrencyDollar } from '@/ui/display/icon';
import { Info } from '@/ui/display/info/components/Info.tsx';
import { H1Title } from '@/ui/display/typography/components/H1Title.tsx';
@@ -29,9 +27,8 @@ const StyledInvisibleChat = styled.div`
`;
export const SettingsBilling = () => {
+ const navigate = useNavigate();
const onboardingStatus = useOnboardingStatus();
- const supportChat = useRecoilValue(supportChatState());
- const currentWorkspace = useRecoilValue(currentWorkspaceState());
const { data, loading } = useBillingPortalSessionQuery({
variables: {
returnUrlPath: '/settings/billing',
@@ -45,22 +42,17 @@ export const SettingsBilling = () => {
const displaySubscriptionCanceledInfo =
onboardingStatus === OnboardingStatus.Canceled;
+ const displaySubscribeInfo =
+ onboardingStatus === OnboardingStatus.CompletedWithoutSubscription;
+
const openBillingPortal = () => {
if (isDefined(data)) {
window.location.replace(data.billingPortalSession.url);
}
};
- const openChat = () => {
- if (isNonEmptyString(supportChat.supportDriver)) {
- window.FrontChat?.('show');
- } else {
- window.location.href =
- 'mailto:felix@twenty.com?' +
- `subject=Subscription Recovery for workspace ${currentWorkspace?.id}&` +
- 'body=Hey,%0D%0A%0D%0AMy subscription is canceled and I would like to subscribe a new one.' +
- 'Can you help me?%0D%0A%0D%0ACheers';
- }
+ const redirectToSubscribePage = () => {
+ navigate(AppPath.PlanRequired);
};
return (
@@ -68,14 +60,6 @@ export const SettingsBilling = () => {
- {displaySubscriptionCanceledInfo && (
-
- )}
{displayPaymentFailInfo && (
{
onClick={openBillingPortal}
/>
)}
-
+ )}
+ {!displaySubscribeInfo && (
+
+ )}
diff --git a/packages/twenty-server/src/core/billing/billing.module.ts b/packages/twenty-server/src/core/billing/billing.module.ts
index 25e63d966..a875613f0 100644
--- a/packages/twenty-server/src/core/billing/billing.module.ts
+++ b/packages/twenty-server/src/core/billing/billing.module.ts
@@ -8,19 +8,15 @@ import { BillingSubscription } from 'src/core/billing/entities/billing-subscript
import { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subscription-item.entity';
import { Workspace } from 'src/core/workspace/workspace.entity';
import { BillingResolver } from 'src/core/billing/billing.resolver';
-import { FeatureFlagEntity } from 'src/core/feature-flag/feature-flag.entity';
import { BillingWorkspaceMemberListener } from 'src/core/billing/listeners/billing-workspace-member.listener';
+import { UserWorkspaceModule } from 'src/core/user-workspace/user-workspace.module';
@Module({
imports: [
StripeModule,
+ UserWorkspaceModule,
TypeOrmModule.forFeature(
- [
- BillingSubscription,
- BillingSubscriptionItem,
- Workspace,
- FeatureFlagEntity,
- ],
+ [BillingSubscription, BillingSubscriptionItem, Workspace],
'core',
),
],
diff --git a/packages/twenty-server/src/core/billing/billing.service.ts b/packages/twenty-server/src/core/billing/billing.service.ts
index f17256c2f..2a5459082 100644
--- a/packages/twenty-server/src/core/billing/billing.service.ts
+++ b/packages/twenty-server/src/core/billing/billing.service.ts
@@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import Stripe from 'stripe';
-import { Repository } from 'typeorm';
+import { Not, Repository } from 'typeorm';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { StripeService } from 'src/core/billing/stripe/stripe.service';
@@ -12,6 +12,7 @@ import { Workspace } from 'src/core/workspace/workspace.entity';
import { ProductPriceEntity } from 'src/core/billing/dto/product-price.entity';
import { User } from 'src/core/user/user.entity';
import { assert } from 'src/utils/assert';
+import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service';
export enum AvailableProduct {
BasePlan = 'base-plan',
@@ -29,6 +30,7 @@ export class BillingService {
protected readonly logger = new Logger(BillingService.name);
constructor(
private readonly stripeService: StripeService,
+ private readonly userWorkspaceService: UserWorkspaceService,
private readonly environmentService: EnvironmentService,
@InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository,
@@ -76,24 +78,38 @@ export class BillingService {
return Object.values(result).sort((a, b) => a.unitAmount - b.unitAmount);
}
- async getBillingSubscription(criteria: {
+ async getCurrentBillingSubscription(criteria: {
workspaceId?: string;
stripeCustomerId?: string;
}) {
- return await this.billingSubscriptionRepository.findOneOrFail({
- where: criteria,
- relations: ['billingSubscriptionItems'],
- });
+ const notCanceledSubscriptions =
+ await this.billingSubscriptionRepository.find({
+ where: { ...criteria, status: Not('canceled') },
+ relations: ['billingSubscriptionItems'],
+ });
+
+ assert(
+ notCanceledSubscriptions.length <= 1,
+ `More than on not canceled subscription for workspace ${criteria.workspaceId}`,
+ );
+
+ return notCanceledSubscriptions?.[0];
}
async getBillingSubscriptionItem(
workspaceId: string,
stripeProductId = this.environmentService.getBillingStripeBasePlanProductId(),
) {
- const billingSubscription = await this.getBillingSubscription({
+ const billingSubscription = await this.getCurrentBillingSubscription({
workspaceId,
});
+ if (!billingSubscription) {
+ throw new Error(
+ `Cannot find billingSubscriptionItem for product ${stripeProductId} for workspace ${workspaceId}`,
+ );
+ }
+
const billingSubscriptionItem =
billingSubscription.billingSubscriptionItems.filter(
(billingSubscriptionItem) =>
@@ -143,11 +159,27 @@ export class BillingService {
? frontBaseUrl + successUrlPath
: frontBaseUrl;
+ let quantity = 1;
+
+ const stripeCustomerId = (
+ await this.billingSubscriptionRepository.findOneBy({
+ workspaceId: user.defaultWorkspaceId,
+ })
+ )?.stripeCustomerId;
+
+ try {
+ quantity = await this.userWorkspaceService.getWorkspaceMemberCount(
+ user.defaultWorkspaceId,
+ );
+ } catch (e) {}
+
const session = await this.stripeService.createCheckoutSession(
user,
priceId,
+ quantity,
successUrl,
frontBaseUrl,
+ stripeCustomerId,
);
assert(session.url, 'Error: missing checkout.session.url');
@@ -170,18 +202,14 @@ export class BillingService {
}
async handleUnpaidInvoices(data: Stripe.SetupIntentSucceededEvent.Data) {
- try {
- const billingSubscription = await this.getBillingSubscription({
- stripeCustomerId: data.object.customer as string,
- });
+ const billingSubscription = await this.getCurrentBillingSubscription({
+ stripeCustomerId: data.object.customer as string,
+ });
- if (billingSubscription.status === 'unpaid') {
- await this.stripeService.collectLastInvoice(
- billingSubscription.stripeSubscriptionId,
- );
- }
- } catch (err) {
- return;
+ if (billingSubscription?.status === 'unpaid') {
+ await this.stripeService.collectLastInvoice(
+ billingSubscription.stripeSubscriptionId,
+ );
}
}
@@ -189,7 +217,8 @@ export class BillingService {
workspaceId: string,
data:
| Stripe.CustomerSubscriptionUpdatedEvent.Data
- | Stripe.CustomerSubscriptionCreatedEvent.Data,
+ | Stripe.CustomerSubscriptionCreatedEvent.Data
+ | Stripe.CustomerSubscriptionDeletedEvent.Data,
) {
await this.billingSubscriptionRepository.upsert(
{
@@ -199,7 +228,7 @@ export class BillingService {
status: data.object.status,
},
{
- conflictPaths: ['workspaceId'],
+ conflictPaths: ['stripeSubscriptionId'],
skipUpdateIfNoValuesChanged: true,
},
);
@@ -208,10 +237,14 @@ export class BillingService {
subscriptionStatus: data.object.status,
});
- const billingSubscription = await this.getBillingSubscription({
+ const billingSubscription = await this.getCurrentBillingSubscription({
workspaceId,
});
+ if (!billingSubscription) {
+ return;
+ }
+
await this.billingSubscriptionItemRepository.upsert(
data.object.items.data.map((item) => {
return {
diff --git a/packages/twenty-server/src/core/billing/entities/billing-subscription.entity.ts b/packages/twenty-server/src/core/billing/entities/billing-subscription.entity.ts
index 704c23854..027af13b4 100644
--- a/packages/twenty-server/src/core/billing/entities/billing-subscription.entity.ts
+++ b/packages/twenty-server/src/core/billing/entities/billing-subscription.entity.ts
@@ -1,20 +1,25 @@
+import { Field, ID, ObjectType } from '@nestjs/graphql';
+
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
+ ManyToOne,
OneToMany,
- OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import Stripe from 'stripe';
+import { IDField } from '@ptc-org/nestjs-query-graphql';
import { Workspace } from 'src/core/workspace/workspace.entity';
import { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subscription-item.entity';
@Entity({ name: 'billingSubscription', schema: 'core' })
+@ObjectType('BillingSubscription')
export class BillingSubscription {
+ @IDField(() => ID)
@PrimaryGeneratedColumn('uuid')
id: string;
@@ -27,7 +32,7 @@ export class BillingSubscription {
@UpdateDateColumn({ type: 'timestamp with time zone' })
updatedAt: Date;
- @OneToOne(() => Workspace, (workspace) => workspace.billingSubscription, {
+ @ManyToOne(() => Workspace, (workspace) => workspace.billingSubscriptions, {
onDelete: 'CASCADE',
})
@JoinColumn()
@@ -36,12 +41,13 @@ export class BillingSubscription {
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
- @Column({ unique: true, nullable: false })
+ @Column({ nullable: false })
stripeCustomerId: string;
@Column({ unique: true, nullable: false })
stripeSubscriptionId: string;
+ @Field()
@Column({ nullable: false })
status: Stripe.Subscription.Status;
diff --git a/packages/twenty-server/src/core/billing/jobs/update-subscription.job.ts b/packages/twenty-server/src/core/billing/jobs/update-subscription.job.ts
index 876944a97..e0443f094 100644
--- a/packages/twenty-server/src/core/billing/jobs/update-subscription.job.ts
+++ b/packages/twenty-server/src/core/billing/jobs/update-subscription.job.ts
@@ -1,16 +1,9 @@
import { Injectable, Logger } from '@nestjs/common';
-import { InjectRepository } from '@nestjs/typeorm';
-
-import { Repository } from 'typeorm';
import { MessageQueueJob } from 'src/integrations/message-queue/interfaces/message-queue-job.interface';
import { BillingService } from 'src/core/billing/billing.service';
import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service';
-import {
- FeatureFlagEntity,
- FeatureFlagKeys,
-} from 'src/core/feature-flag/feature-flag.entity';
import { StripeService } from 'src/core/billing/stripe/stripe.service';
export type UpdateSubscriptionJobData = { workspaceId: string };
@Injectable()
@@ -22,21 +15,9 @@ export class UpdateSubscriptionJob
private readonly billingService: BillingService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly stripeService: StripeService,
- @InjectRepository(FeatureFlagEntity, 'core')
- private readonly featureFlagRepository: Repository,
) {}
async handle(data: UpdateSubscriptionJobData): Promise {
- const isSelfBillingEnabled = await this.featureFlagRepository.findOneBy({
- workspaceId: data.workspaceId,
- key: FeatureFlagKeys.IsSelfBillingEnabled,
- value: true,
- });
-
- if (!isSelfBillingEnabled) {
- return;
- }
-
const workspaceMembersCount =
await this.userWorkspaceService.getWorkspaceMemberCount(data.workspaceId);
@@ -44,16 +25,22 @@ export class UpdateSubscriptionJob
return;
}
- const billingSubscriptionItem =
- await this.billingService.getBillingSubscriptionItem(data.workspaceId);
+ try {
+ const billingSubscriptionItem =
+ await this.billingService.getBillingSubscriptionItem(data.workspaceId);
- await this.stripeService.updateSubscriptionItem(
- billingSubscriptionItem.stripeSubscriptionItemId,
- workspaceMembersCount,
- );
+ await this.stripeService.updateSubscriptionItem(
+ billingSubscriptionItem.stripeSubscriptionItemId,
+ workspaceMembersCount,
+ );
- this.logger.log(
- `Updating workspace ${data.workspaceId} subscription quantity to ${workspaceMembersCount} members`,
- );
+ this.logger.log(
+ `Updating workspace ${data.workspaceId} subscription quantity to ${workspaceMembersCount} members`,
+ );
+ } catch (e) {
+ this.logger.warn(
+ `Failed to update workspace ${data.workspaceId} subscription quantity to ${workspaceMembersCount} members. Error: ${e}`,
+ );
+ }
}
}
diff --git a/packages/twenty-server/src/core/billing/listeners/billing-workspace-member.listener.ts b/packages/twenty-server/src/core/billing/listeners/billing-workspace-member.listener.ts
index 1cb1db606..e70f34722 100644
--- a/packages/twenty-server/src/core/billing/listeners/billing-workspace-member.listener.ts
+++ b/packages/twenty-server/src/core/billing/listeners/billing-workspace-member.listener.ts
@@ -1,15 +1,8 @@
import { Inject, Injectable } from '@nestjs/common';
-import { InjectRepository } from '@nestjs/typeorm';
import { OnEvent } from '@nestjs/event-emitter';
-import { Repository } from 'typeorm';
-
import { MessageQueue } from 'src/integrations/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/integrations/message-queue/services/message-queue.service';
-import {
- FeatureFlagEntity,
- FeatureFlagKeys,
-} from 'src/core/feature-flag/feature-flag.entity';
import { ObjectRecordCreateEvent } from 'src/integrations/event-emitter/types/object-record-create.event';
import { WorkspaceMemberObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/workspace-member.object-metadata';
import {
@@ -22,8 +15,6 @@ export class BillingWorkspaceMemberListener {
constructor(
@Inject(MessageQueue.billingQueue)
private readonly messageQueueService: MessageQueueService,
- @InjectRepository(FeatureFlagEntity, 'core')
- private readonly featureFlagRepository: Repository,
) {}
@OnEvent('workspaceMember.created')
@@ -31,17 +22,6 @@ export class BillingWorkspaceMemberListener {
async handleCreateOrDeleteEvent(
payload: ObjectRecordCreateEvent,
) {
- const isSelfBillingFeatureFlag = await this.featureFlagRepository.findOneBy(
- {
- key: FeatureFlagKeys.IsSelfBillingEnabled,
- value: true,
- workspaceId: payload.workspaceId,
- },
- );
-
- if (!isSelfBillingFeatureFlag) {
- return;
- }
await this.messageQueueService.add(
UpdateSubscriptionJob.name,
{ workspaceId: payload.workspaceId },
diff --git a/packages/twenty-server/src/core/billing/stripe/stripe.service.ts b/packages/twenty-server/src/core/billing/stripe/stripe.service.ts
index f57515797..24afa8bfa 100644
--- a/packages/twenty-server/src/core/billing/stripe/stripe.service.ts
+++ b/packages/twenty-server/src/core/billing/stripe/stripe.service.ts
@@ -55,14 +55,16 @@ export class StripeService {
async createCheckoutSession(
user: User,
priceId: string,
+ quantity: number,
successUrl?: string,
cancelUrl?: string,
+ stripeCustomerId?: string,
): Promise {
return await this.stripe.checkout.sessions.create({
line_items: [
{
price: priceId,
- quantity: 1,
+ quantity,
},
],
mode: 'subscription',
@@ -75,7 +77,9 @@ export class StripeService {
},
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
- customer_email: user.email,
+ customer: stripeCustomerId,
+ customer_update: stripeCustomerId ? { name: 'auto' } : undefined,
+ customer_email: stripeCustomerId ? undefined : user.email,
success_url: successUrl,
cancel_url: cancelUrl,
});
diff --git a/packages/twenty-server/src/core/feature-flag/feature-flag.entity.ts b/packages/twenty-server/src/core/feature-flag/feature-flag.entity.ts
index ac0eeee18..1f39e6fc2 100644
--- a/packages/twenty-server/src/core/feature-flag/feature-flag.entity.ts
+++ b/packages/twenty-server/src/core/feature-flag/feature-flag.entity.ts
@@ -16,7 +16,6 @@ import { Workspace } from 'src/core/workspace/workspace.entity';
export enum FeatureFlagKeys {
IsBlocklistEnabled = 'IS_BLOCKLIST_ENABLED',
IsCalendarEnabled = 'IS_CALENDAR_ENABLED',
- IsSelfBillingEnabled = 'IS_SELF_BILLING_ENABLED',
}
@Entity({ name: 'featureFlag', schema: 'core' })
diff --git a/packages/twenty-server/src/core/workspace/workspace.entity.ts b/packages/twenty-server/src/core/workspace/workspace.entity.ts
index ca73019c5..bd8d8be4d 100644
--- a/packages/twenty-server/src/core/workspace/workspace.entity.ts
+++ b/packages/twenty-server/src/core/workspace/workspace.entity.ts
@@ -6,7 +6,6 @@ import {
CreateDateColumn,
Entity,
OneToMany,
- OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@@ -20,6 +19,9 @@ import { UserWorkspace } from 'src/core/user-workspace/user-workspace.entity';
@Entity({ name: 'workspace', schema: 'core' })
@ObjectType('Workspace')
@UnPagedRelation('featureFlags', () => FeatureFlagEntity, { nullable: true })
+@UnPagedRelation('billingSubscriptions', () => BillingSubscription, {
+ nullable: true,
+})
export class Workspace {
@IDField(() => ID)
@PrimaryGeneratedColumn('uuid')
@@ -72,12 +74,15 @@ export class Workspace {
@Column({ default: 'incomplete' })
subscriptionStatus: Stripe.Subscription.Status;
+ @Field({ nullable: true })
+ currentBillingSubscription: BillingSubscription;
+
@Field()
activationStatus: 'active' | 'inactive';
- @OneToOne(
+ @OneToMany(
() => BillingSubscription,
(billingSubscription) => billingSubscription.workspace,
)
- billingSubscription: BillingSubscription;
+ billingSubscriptions: BillingSubscription[];
}
diff --git a/packages/twenty-server/src/core/workspace/workspace.resolver.ts b/packages/twenty-server/src/core/workspace/workspace.resolver.ts
index 58085cbbc..6b398847b 100644
--- a/packages/twenty-server/src/core/workspace/workspace.resolver.ts
+++ b/packages/twenty-server/src/core/workspace/workspace.resolver.ts
@@ -22,6 +22,8 @@ import { EnvironmentService } from 'src/integrations/environment/environment.ser
import { User } from 'src/core/user/user.entity';
import { AuthUser } from 'src/decorators/auth/auth-user.decorator';
import { ActivateWorkspaceInput } from 'src/core/workspace/dtos/activate-workspace-input';
+import { BillingSubscription } from 'src/core/billing/entities/billing-subscription.entity';
+import { BillingService } from 'src/core/billing/billing.service';
import { Workspace } from './workspace.entity';
@@ -34,6 +36,7 @@ export class WorkspaceResolver {
private readonly workspaceService: WorkspaceService,
private readonly fileUploadService: FileUploadService,
private readonly environmentService: EnvironmentService,
+ private readonly billingService: BillingService,
) {}
@Query(() => Workspace)
@@ -108,4 +111,13 @@ export class WorkspaceResolver {
return 'inactive';
}
+
+ @ResolveField(() => BillingSubscription)
+ async currentBillingSubscription(
+ @Parent() workspace: Workspace,
+ ): Promise {
+ return this.billingService.getCurrentBillingSubscription({
+ workspaceId: workspace.id,
+ });
+ }
}
diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/1709914564361-updateBillingSubscription.ts b/packages/twenty-server/src/database/typeorm/core/migrations/1709914564361-updateBillingSubscription.ts
new file mode 100644
index 000000000..0505b688b
--- /dev/null
+++ b/packages/twenty-server/src/database/typeorm/core/migrations/1709914564361-updateBillingSubscription.ts
@@ -0,0 +1,37 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class UpdateBillingSubscription1709914564361
+ implements MigrationInterface
+{
+ name = 'UpdateBillingSubscription1709914564361';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "core"."billingSubscription" DROP CONSTRAINT "FK_4abfb70314c18da69e1bee1954d"`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "core"."billingSubscription" DROP CONSTRAINT "REL_4abfb70314c18da69e1bee1954"`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "core"."billingSubscription" DROP CONSTRAINT "UQ_9120b7586c3471463480b58d20a"`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "core"."billingSubscription" ADD CONSTRAINT "FK_4abfb70314c18da69e1bee1954d" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ `ALTER TABLE "core"."billingSubscription" DROP CONSTRAINT "FK_4abfb70314c18da69e1bee1954d"`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "core"."billingSubscription" ADD CONSTRAINT "UQ_9120b7586c3471463480b58d20a" UNIQUE ("stripeCustomerId")`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "core"."billingSubscription" ADD CONSTRAINT "REL_4abfb70314c18da69e1bee1954" UNIQUE ("workspaceId")`,
+ );
+ await queryRunner.query(
+ `ALTER TABLE "core"."billingSubscription" ADD CONSTRAINT "FK_4abfb70314c18da69e1bee1954d" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
+ );
+ }
+}