From 62d414ee666f0849eb89ba044970b88925560dbe Mon Sep 17 00:00:00 2001 From: martmull Date: Tue, 12 Mar 2024 18:10:27 +0100 Subject: [PATCH] 40 remove self billing feature flag (#4379) * Define quantity at checkout * Remove billing submenu when not isBillingEnabled * Remove feature flag * Log warning when missing subscription active workspace add or remove member * Display subscribe cta for free usage of twenty * Authorize all settings when subscription canceled or unpaid * Display subscribe cta for workspace with canceled subscription * Replace OneToOne by OneToMany * Add a currentBillingSubscriptionField * Handle multiple subscriptions by workspace * Fix redirection * Fix test * Fix billingState --- packages/twenty-front/src/App.tsx | 23 +++--- .../effect-components/PageChangeEffect.tsx | 8 +- .../twenty-front/src/generated/graphql.tsx | 35 ++++++++- .../__test__/useOnboardingStatus.test.ts | 34 +++++++++ .../auth/states/currentWorkspaceState.ts | 1 + .../modules/auth/utils/getOnboardingStatus.ts | 8 ++ .../SettingsNavigationDrawerItems.tsx | 17 +++-- .../modules/ui/layout/page/DefaultLayout.tsx | 5 +- .../users/graphql/queries/getCurrentUser.ts | 3 + .../modules/workspace/types/FeatureFlagKey.ts | 3 +- .../src/pages/auth/PlanRequired.tsx | 47 ------------ .../auth/__stories__/PlanRequired.stories.tsx | 54 ------------- .../src/pages/settings/SettingsBilling.tsx | 72 +++++++++--------- .../src/core/billing/billing.module.ts | 10 +-- .../src/core/billing/billing.service.ts | 75 +++++++++++++------ .../entities/billing-subscription.entity.ts | 12 ++- .../billing/jobs/update-subscription.job.ts | 43 ++++------- .../billing-workspace-member.listener.ts | 20 ----- .../src/core/billing/stripe/stripe.service.ts | 8 +- .../core/feature-flag/feature-flag.entity.ts | 1 - .../src/core/workspace/workspace.entity.ts | 11 ++- .../src/core/workspace/workspace.resolver.ts | 12 +++ ...1709914564361-updateBillingSubscription.ts | 37 +++++++++ 23 files changed, 292 insertions(+), 247 deletions(-) delete mode 100644 packages/twenty-front/src/pages/auth/PlanRequired.tsx delete mode 100644 packages/twenty-front/src/pages/auth/__stories__/PlanRequired.stories.tsx create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/1709914564361-updateBillingSubscription.ts 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`, + ); + } +}