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
This commit is contained in:
martmull
2024-03-12 18:10:27 +01:00
committed by GitHub
parent 4476f5215b
commit 62d414ee66
23 changed files with 292 additions and 247 deletions

View File

@@ -1,9 +1,10 @@
import { Route, Routes } from 'react-router-dom'; 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 { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { DefaultLayout } from '@/ui/layout/page/DefaultLayout'; import { DefaultLayout } from '@/ui/layout/page/DefaultLayout';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { DefaultPageTitle } from '~/DefaultPageTitle'; import { DefaultPageTitle } from '~/DefaultPageTitle';
import { CommandMenuEffect } from '~/effect-components/CommandMenuEffect'; import { CommandMenuEffect } from '~/effect-components/CommandMenuEffect';
import { GotoHotkeysEffect } from '~/effect-components/GotoHotkeysEffect'; import { GotoHotkeysEffect } from '~/effect-components/GotoHotkeysEffect';
@@ -12,7 +13,6 @@ import { CreateProfile } from '~/pages/auth/CreateProfile';
import { CreateWorkspace } from '~/pages/auth/CreateWorkspace'; import { CreateWorkspace } from '~/pages/auth/CreateWorkspace';
import { PasswordReset } from '~/pages/auth/PasswordReset'; import { PasswordReset } from '~/pages/auth/PasswordReset';
import { PaymentSuccess } from '~/pages/auth/PaymentSuccess.tsx'; import { PaymentSuccess } from '~/pages/auth/PaymentSuccess.tsx';
import { PlanRequired } from '~/pages/auth/PlanRequired';
import { SignInUp } from '~/pages/auth/SignInUp'; import { SignInUp } from '~/pages/auth/SignInUp';
import { VerifyEffect } from '~/pages/auth/VerifyEffect'; import { VerifyEffect } from '~/pages/auth/VerifyEffect';
import { DefaultHomePage } from '~/pages/DefaultHomePage'; import { DefaultHomePage } from '~/pages/DefaultHomePage';
@@ -47,7 +47,7 @@ import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMemb
import { Tasks } from '~/pages/tasks/Tasks'; import { Tasks } from '~/pages/tasks/Tasks';
export const App = () => { export const App = () => {
const isSelfBillingEnabled = useIsFeatureEnabled('IS_SELF_BILLING_ENABLED'); const billing = useRecoilValue(billingState());
return ( return (
<> <>
@@ -63,12 +63,7 @@ export const App = () => {
<Route path={AppPath.ResetPassword} element={<PasswordReset />} /> <Route path={AppPath.ResetPassword} element={<PasswordReset />} />
<Route path={AppPath.CreateWorkspace} element={<CreateWorkspace />} /> <Route path={AppPath.CreateWorkspace} element={<CreateWorkspace />} />
<Route path={AppPath.CreateProfile} element={<CreateProfile />} /> <Route path={AppPath.CreateProfile} element={<CreateProfile />} />
<Route <Route path={AppPath.PlanRequired} element={<ChooseYourPlan />} />
path={AppPath.PlanRequired}
element={
isSelfBillingEnabled ? <ChooseYourPlan /> : <PlanRequired />
}
/>
<Route <Route
path={AppPath.PlanRequiredSuccess} path={AppPath.PlanRequiredSuccess}
element={<PaymentSuccess />} element={<PaymentSuccess />}
@@ -115,10 +110,12 @@ export const App = () => {
path={SettingsPath.AccountsEmailsInboxSettings} path={SettingsPath.AccountsEmailsInboxSettings}
element={<SettingsAccountsEmailsInboxSettings />} element={<SettingsAccountsEmailsInboxSettings />}
/> />
{billing?.isBillingEnabled && (
<Route <Route
path={SettingsPath.Billing} path={SettingsPath.Billing}
element={<SettingsBilling />} element={<SettingsBilling />}
/> />
)}
<Route <Route
path={SettingsPath.WorkspaceMembersPage} path={SettingsPath.WorkspaceMembersPage}
element={<SettingsWorkspaceMembers />} element={<SettingsWorkspaceMembers />}

View File

@@ -93,7 +93,10 @@ export const PageChangeEffect = () => {
[OnboardingStatus.Unpaid, OnboardingStatus.Canceled].includes( [OnboardingStatus.Unpaid, OnboardingStatus.Canceled].includes(
onboardingStatus, onboardingStatus,
) && ) &&
!isMatchingLocation(SettingsPath.Billing) !(
isMatchingLocation(AppPath.SettingsCatchAll) ||
isMatchingLocation(AppPath.PlanRequired)
)
) { ) {
navigate( navigate(
`${AppPath.SettingsCatchAll.replace('/*', '')}/${SettingsPath.Billing}`, `${AppPath.SettingsCatchAll.replace('/*', '')}/${SettingsPath.Billing}`,
@@ -110,7 +113,8 @@ export const PageChangeEffect = () => {
) { ) {
navigate(AppPath.CreateProfile); navigate(AppPath.CreateProfile);
} else if ( } else if (
onboardingStatus === OnboardingStatus.Completed && (onboardingStatus === OnboardingStatus.Completed ||
onboardingStatus === OnboardingStatus.CompletedWithoutSubscription) &&
isMatchingOnboardingRoute isMatchingOnboardingRoute
) { ) {
navigate(AppPath.Index); navigate(AppPath.Index);

View File

@@ -65,6 +65,28 @@ export type Billing = {
isBillingEnabled: Scalars['Boolean']; isBillingEnabled: Scalars['Boolean'];
}; };
export type BillingSubscription = {
__typename?: 'BillingSubscription';
id: Scalars['ID'];
status: Scalars['String'];
};
export type BillingSubscriptionFilter = {
and?: InputMaybe<Array<BillingSubscriptionFilter>>;
id?: InputMaybe<IdFilterComparison>;
or?: InputMaybe<Array<BillingSubscriptionFilter>>;
};
export type BillingSubscriptionSort = {
direction: SortDirection;
field: BillingSubscriptionSortFields;
nulls?: InputMaybe<SortNulls>;
};
export enum BillingSubscriptionSortFields {
Id = 'id'
}
export type BooleanFieldComparison = { export type BooleanFieldComparison = {
is?: InputMaybe<Scalars['Boolean']>; is?: InputMaybe<Scalars['Boolean']>;
isNot?: InputMaybe<Scalars['Boolean']>; isNot?: InputMaybe<Scalars['Boolean']>;
@@ -631,7 +653,9 @@ export type Workspace = {
__typename?: 'Workspace'; __typename?: 'Workspace';
activationStatus: Scalars['String']; activationStatus: Scalars['String'];
allowImpersonation: Scalars['Boolean']; allowImpersonation: Scalars['Boolean'];
billingSubscriptions?: Maybe<Array<BillingSubscription>>;
createdAt: Scalars['DateTime']; createdAt: Scalars['DateTime'];
currentBillingSubscription?: Maybe<BillingSubscription>;
deletedAt?: Maybe<Scalars['DateTime']>; deletedAt?: Maybe<Scalars['DateTime']>;
displayName?: Maybe<Scalars['String']>; displayName?: Maybe<Scalars['String']>;
domainName?: Maybe<Scalars['String']>; domainName?: Maybe<Scalars['String']>;
@@ -644,6 +668,12 @@ export type Workspace = {
}; };
export type WorkspaceBillingSubscriptionsArgs = {
filter?: BillingSubscriptionFilter;
sorting?: Array<BillingSubscriptionSort>;
};
export type WorkspaceFeatureFlagsArgs = { export type WorkspaceFeatureFlagsArgs = {
filter?: FeatureFlagFilter; filter?: FeatureFlagFilter;
sorting?: Array<FeatureFlagSort>; sorting?: Array<FeatureFlagSort>;
@@ -942,7 +972,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf
export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; 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<{ export type ActivateWorkspaceMutationVariables = Exact<{
input: ActivateWorkspaceInput; input: ActivateWorkspaceInput;
@@ -1917,6 +1947,9 @@ export const GetCurrentUserDocument = gql`
value value
workspaceId workspaceId
} }
currentBillingSubscription {
status
}
} }
workspaces { workspaces {
workspace { workspace {

View File

@@ -21,6 +21,9 @@ const currentWorkspace = {
activationStatus: 'active', activationStatus: 'active',
id: '1', id: '1',
allowImpersonation: true, allowImpersonation: true,
currentBillingSubscription: {
status: 'trialing',
},
}; };
const currentWorkspaceMember = { const currentWorkspaceMember = {
id: '1', id: '1',
@@ -240,4 +243,35 @@ describe('useOnboardingStatus', () => {
expect(result.current.onboardingStatus).toBe('unpaid'); 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',
);
});
}); });

View File

@@ -11,6 +11,7 @@ export type CurrentWorkspace = Pick<
| 'featureFlags' | 'featureFlags'
| 'subscriptionStatus' | 'subscriptionStatus'
| 'activationStatus' | 'activationStatus'
| 'currentBillingSubscription'
>; >;
export const currentWorkspaceState = createState<CurrentWorkspace | null>({ export const currentWorkspaceState = createState<CurrentWorkspace | null>({

View File

@@ -10,6 +10,7 @@ export enum OnboardingStatus {
OngoingWorkspaceActivation = 'ongoing_workspace_activation', OngoingWorkspaceActivation = 'ongoing_workspace_activation',
OngoingProfileCreation = 'ongoing_profile_creation', OngoingProfileCreation = 'ongoing_profile_creation',
Completed = 'completed', Completed = 'completed',
CompletedWithoutSubscription = 'completed_without_subscription',
} }
export const getOnboardingStatus = ({ export const getOnboardingStatus = ({
@@ -75,5 +76,12 @@ export const getOnboardingStatus = ({
return OnboardingStatus.Unpaid; return OnboardingStatus.Unpaid;
} }
if (
isBillingEnabled === true &&
!currentWorkspace.currentBillingSubscription
) {
return OnboardingStatus.CompletedWithoutSubscription;
}
return OnboardingStatus.Completed; return OnboardingStatus.Completed;
}; };

View File

@@ -1,7 +1,9 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { useAuth } from '@/auth/hooks/useAuth'; import { useAuth } from '@/auth/hooks/useAuth';
import { billingState } from '@/client-config/states/billingState.ts';
import { SettingsNavigationDrawerItem } from '@/settings/components/SettingsNavigationDrawerItem'; import { SettingsNavigationDrawerItem } from '@/settings/components/SettingsNavigationDrawerItem';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
@@ -35,7 +37,7 @@ export const SettingsNavigationDrawerItems = () => {
}, [signOut, navigate]); }, [signOut, navigate]);
const isCalendarEnabled = useIsFeatureEnabled('IS_CALENDAR_ENABLED'); const isCalendarEnabled = useIsFeatureEnabled('IS_CALENDAR_ENABLED');
const isSelfBillingEnabled = useIsFeatureEnabled('IS_SELF_BILLING_ENABLED'); const billing = useRecoilValue(billingState());
return ( return (
<> <>
@@ -88,12 +90,13 @@ export const SettingsNavigationDrawerItems = () => {
path={SettingsPath.WorkspaceMembersPage} path={SettingsPath.WorkspaceMembersPage}
Icon={IconUsers} Icon={IconUsers}
/> />
{billing?.isBillingEnabled && (
<SettingsNavigationDrawerItem <SettingsNavigationDrawerItem
label="Billing" label="Billing"
path={SettingsPath.Billing} path={SettingsPath.Billing}
Icon={IconCurrencyDollar} Icon={IconCurrencyDollar}
soon={!isSelfBillingEnabled}
/> />
)}
<SettingsNavigationDrawerItem <SettingsNavigationDrawerItem
label="Data model" label="Data model"
path={SettingsPath.Objects} path={SettingsPath.Objects}

View File

@@ -83,7 +83,10 @@ export const DefaultLayout = ({ children }: DefaultLayoutProps) => {
OnboardingStatus.OngoingProfileCreation, OnboardingStatus.OngoingProfileCreation,
OnboardingStatus.OngoingWorkspaceActivation, OnboardingStatus.OngoingWorkspaceActivation,
].includes(onboardingStatus)) || ].includes(onboardingStatus)) ||
isMatchingLocation(AppPath.ResetPassword) isMatchingLocation(AppPath.ResetPassword) ||
(isMatchingLocation(AppPath.PlanRequired) &&
(OnboardingStatus.CompletedWithoutSubscription ||
OnboardingStatus.Canceled))
); );
}, [isMatchingLocation, onboardingStatus]); }, [isMatchingLocation, onboardingStatus]);

View File

@@ -35,6 +35,9 @@ export const GET_CURRENT_USER = gql`
value value
workspaceId workspaceId
} }
currentBillingSubscription {
status
}
} }
workspaces { workspaces {
workspace { workspace {

View File

@@ -1,5 +1,4 @@
export type FeatureFlagKey = export type FeatureFlagKey =
| 'IS_BLOCKLIST_ENABLED' | 'IS_BLOCKLIST_ENABLED'
| 'IS_CALENDAR_ENABLED' | 'IS_CALENDAR_ENABLED'
| 'IS_QUICK_ACTIONS_ENABLED' | 'IS_QUICK_ACTIONS_ENABLED';
| 'IS_SELF_BILLING_ENABLED';

View File

@@ -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 (
<>
<AnimatedEaseIn>
<Logo />
</AnimatedEaseIn>
<Title>Plan required</Title>
<SubTitle>
Please select a subscription plan before proceeding to sign in.
</SubTitle>
<StyledButtonContainer>
<MainButton
title="Get started"
onClick={handleButtonClick}
width={200}
/>
</StyledButtonContainer>
</>
);
};

View File

@@ -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<PageDecoratorArgs> = {
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<typeof PlanRequired>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByRole('button', { name: 'Get started' });
},
};

View File

@@ -1,15 +1,13 @@
import React from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus.ts'; import { useOnboardingStatus } from '@/auth/hooks/useOnboardingStatus.ts';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState.ts';
import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus.ts'; import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus.ts';
import { SettingsBillingCoverImage } from '@/billing/components/SettingsBillingCoverImage.tsx'; import { SettingsBillingCoverImage } from '@/billing/components/SettingsBillingCoverImage.tsx';
import { supportChatState } from '@/client-config/states/supportChatState.ts';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SupportChat } from '@/support/components/SupportChat.tsx'; import { SupportChat } from '@/support/components/SupportChat.tsx';
import { AppPath } from '@/types/AppPath.ts';
import { IconCreditCard, IconCurrencyDollar } from '@/ui/display/icon'; import { IconCreditCard, IconCurrencyDollar } from '@/ui/display/icon';
import { Info } from '@/ui/display/info/components/Info.tsx'; import { Info } from '@/ui/display/info/components/Info.tsx';
import { H1Title } from '@/ui/display/typography/components/H1Title.tsx'; import { H1Title } from '@/ui/display/typography/components/H1Title.tsx';
@@ -29,9 +27,8 @@ const StyledInvisibleChat = styled.div`
`; `;
export const SettingsBilling = () => { export const SettingsBilling = () => {
const navigate = useNavigate();
const onboardingStatus = useOnboardingStatus(); const onboardingStatus = useOnboardingStatus();
const supportChat = useRecoilValue(supportChatState());
const currentWorkspace = useRecoilValue(currentWorkspaceState());
const { data, loading } = useBillingPortalSessionQuery({ const { data, loading } = useBillingPortalSessionQuery({
variables: { variables: {
returnUrlPath: '/settings/billing', returnUrlPath: '/settings/billing',
@@ -45,22 +42,17 @@ export const SettingsBilling = () => {
const displaySubscriptionCanceledInfo = const displaySubscriptionCanceledInfo =
onboardingStatus === OnboardingStatus.Canceled; onboardingStatus === OnboardingStatus.Canceled;
const displaySubscribeInfo =
onboardingStatus === OnboardingStatus.CompletedWithoutSubscription;
const openBillingPortal = () => { const openBillingPortal = () => {
if (isDefined(data)) { if (isDefined(data)) {
window.location.replace(data.billingPortalSession.url); window.location.replace(data.billingPortalSession.url);
} }
}; };
const openChat = () => { const redirectToSubscribePage = () => {
if (isNonEmptyString(supportChat.supportDriver)) { navigate(AppPath.PlanRequired);
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';
}
}; };
return ( return (
@@ -68,14 +60,6 @@ export const SettingsBilling = () => {
<SettingsPageContainer> <SettingsPageContainer>
<StyledH1Title title="Billing" /> <StyledH1Title title="Billing" />
<SettingsBillingCoverImage /> <SettingsBillingCoverImage />
{displaySubscriptionCanceledInfo && (
<Info
text={'Subscription canceled. Please contact us to start a new one'}
buttonTitle={'Contact Us'}
accent={'danger'}
onClick={openChat}
/>
)}
{displayPaymentFailInfo && ( {displayPaymentFailInfo && (
<Info <Info
text={'Last payment failed. Please update your billing details.'} text={'Last payment failed. Please update your billing details.'}
@@ -84,6 +68,23 @@ export const SettingsBilling = () => {
onClick={openBillingPortal} onClick={openBillingPortal}
/> />
)} )}
{displaySubscriptionCanceledInfo && (
<Info
text={'Subscription canceled. Please start a new one'}
buttonTitle={'Subscribe'}
accent={'danger'}
onClick={redirectToSubscribePage}
/>
)}
{displaySubscribeInfo && (
<Info
text={'Your workspace does not have an active subscription'}
buttonTitle={'Subscribe'}
accent={'danger'}
onClick={redirectToSubscribePage}
/>
)}
{!displaySubscribeInfo && (
<Section> <Section>
<H2Title <H2Title
title="Manage your subscription" title="Manage your subscription"
@@ -97,6 +98,7 @@ export const SettingsBilling = () => {
disabled={loading} disabled={loading}
/> />
</Section> </Section>
)}
</SettingsPageContainer> </SettingsPageContainer>
<StyledInvisibleChat> <StyledInvisibleChat>
<SupportChat /> <SupportChat />

View File

@@ -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 { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subscription-item.entity';
import { Workspace } from 'src/core/workspace/workspace.entity'; import { Workspace } from 'src/core/workspace/workspace.entity';
import { BillingResolver } from 'src/core/billing/billing.resolver'; 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 { BillingWorkspaceMemberListener } from 'src/core/billing/listeners/billing-workspace-member.listener';
import { UserWorkspaceModule } from 'src/core/user-workspace/user-workspace.module';
@Module({ @Module({
imports: [ imports: [
StripeModule, StripeModule,
UserWorkspaceModule,
TypeOrmModule.forFeature( TypeOrmModule.forFeature(
[ [BillingSubscription, BillingSubscriptionItem, Workspace],
BillingSubscription,
BillingSubscriptionItem,
Workspace,
FeatureFlagEntity,
],
'core', 'core',
), ),
], ],

View File

@@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import Stripe from 'stripe'; import Stripe from 'stripe';
import { Repository } from 'typeorm'; import { Not, Repository } from 'typeorm';
import { EnvironmentService } from 'src/integrations/environment/environment.service'; import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { StripeService } from 'src/core/billing/stripe/stripe.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 { ProductPriceEntity } from 'src/core/billing/dto/product-price.entity';
import { User } from 'src/core/user/user.entity'; import { User } from 'src/core/user/user.entity';
import { assert } from 'src/utils/assert'; import { assert } from 'src/utils/assert';
import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.service';
export enum AvailableProduct { export enum AvailableProduct {
BasePlan = 'base-plan', BasePlan = 'base-plan',
@@ -29,6 +30,7 @@ export class BillingService {
protected readonly logger = new Logger(BillingService.name); protected readonly logger = new Logger(BillingService.name);
constructor( constructor(
private readonly stripeService: StripeService, private readonly stripeService: StripeService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
@InjectRepository(BillingSubscription, 'core') @InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>, private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
@@ -76,24 +78,38 @@ export class BillingService {
return Object.values(result).sort((a, b) => a.unitAmount - b.unitAmount); return Object.values(result).sort((a, b) => a.unitAmount - b.unitAmount);
} }
async getBillingSubscription(criteria: { async getCurrentBillingSubscription(criteria: {
workspaceId?: string; workspaceId?: string;
stripeCustomerId?: string; stripeCustomerId?: string;
}) { }) {
return await this.billingSubscriptionRepository.findOneOrFail({ const notCanceledSubscriptions =
where: criteria, await this.billingSubscriptionRepository.find({
where: { ...criteria, status: Not('canceled') },
relations: ['billingSubscriptionItems'], relations: ['billingSubscriptionItems'],
}); });
assert(
notCanceledSubscriptions.length <= 1,
`More than on not canceled subscription for workspace ${criteria.workspaceId}`,
);
return notCanceledSubscriptions?.[0];
} }
async getBillingSubscriptionItem( async getBillingSubscriptionItem(
workspaceId: string, workspaceId: string,
stripeProductId = this.environmentService.getBillingStripeBasePlanProductId(), stripeProductId = this.environmentService.getBillingStripeBasePlanProductId(),
) { ) {
const billingSubscription = await this.getBillingSubscription({ const billingSubscription = await this.getCurrentBillingSubscription({
workspaceId, workspaceId,
}); });
if (!billingSubscription) {
throw new Error(
`Cannot find billingSubscriptionItem for product ${stripeProductId} for workspace ${workspaceId}`,
);
}
const billingSubscriptionItem = const billingSubscriptionItem =
billingSubscription.billingSubscriptionItems.filter( billingSubscription.billingSubscriptionItems.filter(
(billingSubscriptionItem) => (billingSubscriptionItem) =>
@@ -143,11 +159,27 @@ export class BillingService {
? frontBaseUrl + successUrlPath ? frontBaseUrl + successUrlPath
: frontBaseUrl; : 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( const session = await this.stripeService.createCheckoutSession(
user, user,
priceId, priceId,
quantity,
successUrl, successUrl,
frontBaseUrl, frontBaseUrl,
stripeCustomerId,
); );
assert(session.url, 'Error: missing checkout.session.url'); assert(session.url, 'Error: missing checkout.session.url');
@@ -170,26 +202,23 @@ export class BillingService {
} }
async handleUnpaidInvoices(data: Stripe.SetupIntentSucceededEvent.Data) { async handleUnpaidInvoices(data: Stripe.SetupIntentSucceededEvent.Data) {
try { const billingSubscription = await this.getCurrentBillingSubscription({
const billingSubscription = await this.getBillingSubscription({
stripeCustomerId: data.object.customer as string, stripeCustomerId: data.object.customer as string,
}); });
if (billingSubscription.status === 'unpaid') { if (billingSubscription?.status === 'unpaid') {
await this.stripeService.collectLastInvoice( await this.stripeService.collectLastInvoice(
billingSubscription.stripeSubscriptionId, billingSubscription.stripeSubscriptionId,
); );
} }
} catch (err) {
return;
}
} }
async upsertBillingSubscription( async upsertBillingSubscription(
workspaceId: string, workspaceId: string,
data: data:
| Stripe.CustomerSubscriptionUpdatedEvent.Data | Stripe.CustomerSubscriptionUpdatedEvent.Data
| Stripe.CustomerSubscriptionCreatedEvent.Data, | Stripe.CustomerSubscriptionCreatedEvent.Data
| Stripe.CustomerSubscriptionDeletedEvent.Data,
) { ) {
await this.billingSubscriptionRepository.upsert( await this.billingSubscriptionRepository.upsert(
{ {
@@ -199,7 +228,7 @@ export class BillingService {
status: data.object.status, status: data.object.status,
}, },
{ {
conflictPaths: ['workspaceId'], conflictPaths: ['stripeSubscriptionId'],
skipUpdateIfNoValuesChanged: true, skipUpdateIfNoValuesChanged: true,
}, },
); );
@@ -208,10 +237,14 @@ export class BillingService {
subscriptionStatus: data.object.status, subscriptionStatus: data.object.status,
}); });
const billingSubscription = await this.getBillingSubscription({ const billingSubscription = await this.getCurrentBillingSubscription({
workspaceId, workspaceId,
}); });
if (!billingSubscription) {
return;
}
await this.billingSubscriptionItemRepository.upsert( await this.billingSubscriptionItemRepository.upsert(
data.object.items.data.map((item) => { data.object.items.data.map((item) => {
return { return {

View File

@@ -1,20 +1,25 @@
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { import {
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
JoinColumn, JoinColumn,
ManyToOne,
OneToMany, OneToMany,
OneToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import Stripe from 'stripe'; import Stripe from 'stripe';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { Workspace } from 'src/core/workspace/workspace.entity'; import { Workspace } from 'src/core/workspace/workspace.entity';
import { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subscription-item.entity'; import { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subscription-item.entity';
@Entity({ name: 'billingSubscription', schema: 'core' }) @Entity({ name: 'billingSubscription', schema: 'core' })
@ObjectType('BillingSubscription')
export class BillingSubscription { export class BillingSubscription {
@IDField(() => ID)
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@@ -27,7 +32,7 @@ export class BillingSubscription {
@UpdateDateColumn({ type: 'timestamp with time zone' }) @UpdateDateColumn({ type: 'timestamp with time zone' })
updatedAt: Date; updatedAt: Date;
@OneToOne(() => Workspace, (workspace) => workspace.billingSubscription, { @ManyToOne(() => Workspace, (workspace) => workspace.billingSubscriptions, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@JoinColumn() @JoinColumn()
@@ -36,12 +41,13 @@ export class BillingSubscription {
@Column({ nullable: false, type: 'uuid' }) @Column({ nullable: false, type: 'uuid' })
workspaceId: string; workspaceId: string;
@Column({ unique: true, nullable: false }) @Column({ nullable: false })
stripeCustomerId: string; stripeCustomerId: string;
@Column({ unique: true, nullable: false }) @Column({ unique: true, nullable: false })
stripeSubscriptionId: string; stripeSubscriptionId: string;
@Field()
@Column({ nullable: false }) @Column({ nullable: false })
status: Stripe.Subscription.Status; status: Stripe.Subscription.Status;

View File

@@ -1,16 +1,9 @@
import { Injectable, Logger } from '@nestjs/common'; 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 { MessageQueueJob } from 'src/integrations/message-queue/interfaces/message-queue-job.interface';
import { BillingService } from 'src/core/billing/billing.service'; import { BillingService } from 'src/core/billing/billing.service';
import { UserWorkspaceService } from 'src/core/user-workspace/user-workspace.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'; import { StripeService } from 'src/core/billing/stripe/stripe.service';
export type UpdateSubscriptionJobData = { workspaceId: string }; export type UpdateSubscriptionJobData = { workspaceId: string };
@Injectable() @Injectable()
@@ -22,21 +15,9 @@ export class UpdateSubscriptionJob
private readonly billingService: BillingService, private readonly billingService: BillingService,
private readonly userWorkspaceService: UserWorkspaceService, private readonly userWorkspaceService: UserWorkspaceService,
private readonly stripeService: StripeService, private readonly stripeService: StripeService,
@InjectRepository(FeatureFlagEntity, 'core')
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
) {} ) {}
async handle(data: UpdateSubscriptionJobData): Promise<void> { async handle(data: UpdateSubscriptionJobData): Promise<void> {
const isSelfBillingEnabled = await this.featureFlagRepository.findOneBy({
workspaceId: data.workspaceId,
key: FeatureFlagKeys.IsSelfBillingEnabled,
value: true,
});
if (!isSelfBillingEnabled) {
return;
}
const workspaceMembersCount = const workspaceMembersCount =
await this.userWorkspaceService.getWorkspaceMemberCount(data.workspaceId); await this.userWorkspaceService.getWorkspaceMemberCount(data.workspaceId);
@@ -44,6 +25,7 @@ export class UpdateSubscriptionJob
return; return;
} }
try {
const billingSubscriptionItem = const billingSubscriptionItem =
await this.billingService.getBillingSubscriptionItem(data.workspaceId); await this.billingService.getBillingSubscriptionItem(data.workspaceId);
@@ -55,5 +37,10 @@ export class UpdateSubscriptionJob
this.logger.log( this.logger.log(
`Updating workspace ${data.workspaceId} subscription quantity to ${workspaceMembersCount} members`, `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}`,
);
}
} }
} }

View File

@@ -1,15 +1,8 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { Repository } from 'typeorm';
import { MessageQueue } from 'src/integrations/message-queue/message-queue.constants'; import { MessageQueue } from 'src/integrations/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/integrations/message-queue/services/message-queue.service'; 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 { 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 { WorkspaceMemberObjectMetadata } from 'src/workspace/workspace-sync-metadata/standard-objects/workspace-member.object-metadata';
import { import {
@@ -22,8 +15,6 @@ export class BillingWorkspaceMemberListener {
constructor( constructor(
@Inject(MessageQueue.billingQueue) @Inject(MessageQueue.billingQueue)
private readonly messageQueueService: MessageQueueService, private readonly messageQueueService: MessageQueueService,
@InjectRepository(FeatureFlagEntity, 'core')
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
) {} ) {}
@OnEvent('workspaceMember.created') @OnEvent('workspaceMember.created')
@@ -31,17 +22,6 @@ export class BillingWorkspaceMemberListener {
async handleCreateOrDeleteEvent( async handleCreateOrDeleteEvent(
payload: ObjectRecordCreateEvent<WorkspaceMemberObjectMetadata>, payload: ObjectRecordCreateEvent<WorkspaceMemberObjectMetadata>,
) { ) {
const isSelfBillingFeatureFlag = await this.featureFlagRepository.findOneBy(
{
key: FeatureFlagKeys.IsSelfBillingEnabled,
value: true,
workspaceId: payload.workspaceId,
},
);
if (!isSelfBillingFeatureFlag) {
return;
}
await this.messageQueueService.add<UpdateSubscriptionJobData>( await this.messageQueueService.add<UpdateSubscriptionJobData>(
UpdateSubscriptionJob.name, UpdateSubscriptionJob.name,
{ workspaceId: payload.workspaceId }, { workspaceId: payload.workspaceId },

View File

@@ -55,14 +55,16 @@ export class StripeService {
async createCheckoutSession( async createCheckoutSession(
user: User, user: User,
priceId: string, priceId: string,
quantity: number,
successUrl?: string, successUrl?: string,
cancelUrl?: string, cancelUrl?: string,
stripeCustomerId?: string,
): Promise<Stripe.Checkout.Session> { ): Promise<Stripe.Checkout.Session> {
return await this.stripe.checkout.sessions.create({ return await this.stripe.checkout.sessions.create({
line_items: [ line_items: [
{ {
price: priceId, price: priceId,
quantity: 1, quantity,
}, },
], ],
mode: 'subscription', mode: 'subscription',
@@ -75,7 +77,9 @@ export class StripeService {
}, },
automatic_tax: { enabled: true }, automatic_tax: { enabled: true },
tax_id_collection: { 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, success_url: successUrl,
cancel_url: cancelUrl, cancel_url: cancelUrl,
}); });

View File

@@ -16,7 +16,6 @@ import { Workspace } from 'src/core/workspace/workspace.entity';
export enum FeatureFlagKeys { export enum FeatureFlagKeys {
IsBlocklistEnabled = 'IS_BLOCKLIST_ENABLED', IsBlocklistEnabled = 'IS_BLOCKLIST_ENABLED',
IsCalendarEnabled = 'IS_CALENDAR_ENABLED', IsCalendarEnabled = 'IS_CALENDAR_ENABLED',
IsSelfBillingEnabled = 'IS_SELF_BILLING_ENABLED',
} }
@Entity({ name: 'featureFlag', schema: 'core' }) @Entity({ name: 'featureFlag', schema: 'core' })

View File

@@ -6,7 +6,6 @@ import {
CreateDateColumn, CreateDateColumn,
Entity, Entity,
OneToMany, OneToMany,
OneToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
@@ -20,6 +19,9 @@ import { UserWorkspace } from 'src/core/user-workspace/user-workspace.entity';
@Entity({ name: 'workspace', schema: 'core' }) @Entity({ name: 'workspace', schema: 'core' })
@ObjectType('Workspace') @ObjectType('Workspace')
@UnPagedRelation('featureFlags', () => FeatureFlagEntity, { nullable: true }) @UnPagedRelation('featureFlags', () => FeatureFlagEntity, { nullable: true })
@UnPagedRelation('billingSubscriptions', () => BillingSubscription, {
nullable: true,
})
export class Workspace { export class Workspace {
@IDField(() => ID) @IDField(() => ID)
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
@@ -72,12 +74,15 @@ export class Workspace {
@Column({ default: 'incomplete' }) @Column({ default: 'incomplete' })
subscriptionStatus: Stripe.Subscription.Status; subscriptionStatus: Stripe.Subscription.Status;
@Field({ nullable: true })
currentBillingSubscription: BillingSubscription;
@Field() @Field()
activationStatus: 'active' | 'inactive'; activationStatus: 'active' | 'inactive';
@OneToOne( @OneToMany(
() => BillingSubscription, () => BillingSubscription,
(billingSubscription) => billingSubscription.workspace, (billingSubscription) => billingSubscription.workspace,
) )
billingSubscription: BillingSubscription; billingSubscriptions: BillingSubscription[];
} }

View File

@@ -22,6 +22,8 @@ import { EnvironmentService } from 'src/integrations/environment/environment.ser
import { User } from 'src/core/user/user.entity'; import { User } from 'src/core/user/user.entity';
import { AuthUser } from 'src/decorators/auth/auth-user.decorator'; import { AuthUser } from 'src/decorators/auth/auth-user.decorator';
import { ActivateWorkspaceInput } from 'src/core/workspace/dtos/activate-workspace-input'; 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'; import { Workspace } from './workspace.entity';
@@ -34,6 +36,7 @@ export class WorkspaceResolver {
private readonly workspaceService: WorkspaceService, private readonly workspaceService: WorkspaceService,
private readonly fileUploadService: FileUploadService, private readonly fileUploadService: FileUploadService,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
private readonly billingService: BillingService,
) {} ) {}
@Query(() => Workspace) @Query(() => Workspace)
@@ -108,4 +111,13 @@ export class WorkspaceResolver {
return 'inactive'; return 'inactive';
} }
@ResolveField(() => BillingSubscription)
async currentBillingSubscription(
@Parent() workspace: Workspace,
): Promise<BillingSubscription | null> {
return this.billingService.getCurrentBillingSubscription({
workspaceId: workspace.id,
});
}
} }

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UpdateBillingSubscription1709914564361
implements MigrationInterface
{
name = 'UpdateBillingSubscription1709914564361';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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`,
);
}
}