mirror of
https://github.com/lingble/twenty.git
synced 2025-11-01 05:07:56 +00:00
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:
@@ -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 />}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>({
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ export const GET_CURRENT_USER = gql`
|
|||||||
value
|
value
|
||||||
workspaceId
|
workspaceId
|
||||||
}
|
}
|
||||||
|
currentBillingSubscription {
|
||||||
|
status
|
||||||
|
}
|
||||||
}
|
}
|
||||||
workspaces {
|
workspaces {
|
||||||
workspace {
|
workspace {
|
||||||
|
|||||||
@@ -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';
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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' });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user