mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-22 11:31:39 +03:00
Refacto to introduce billing plan instead
This commit is contained in:
parent
d24680a6ba
commit
3db94013bd
@ -121,6 +121,13 @@ export type Billing = {
|
||||
isBillingEnabled: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
/** The different billing plans available */
|
||||
export enum BillingPlanKey {
|
||||
Enterprise = 'ENTERPRISE',
|
||||
Free = 'FREE',
|
||||
Pro = 'PRO'
|
||||
}
|
||||
|
||||
export type BillingSubscription = {
|
||||
__typename?: 'BillingSubscription';
|
||||
id: Scalars['UUID'];
|
||||
@ -536,8 +543,8 @@ export type MutationChallengeArgs = {
|
||||
|
||||
|
||||
export type MutationCheckoutSessionArgs = {
|
||||
plan?: BillingPlanKey;
|
||||
recurringInterval: SubscriptionInterval;
|
||||
requirePaymentMethod?: InputMaybe<Scalars['Boolean']>;
|
||||
successUrlPath?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
@ -1953,7 +1960,7 @@ export type BillingPortalSessionQuery = { __typename?: 'Query', billingPortalSes
|
||||
export type CheckoutSessionMutationVariables = Exact<{
|
||||
recurringInterval: SubscriptionInterval;
|
||||
successUrlPath?: InputMaybe<Scalars['String']>;
|
||||
requirePaymentMethod?: InputMaybe<Scalars['Boolean']>;
|
||||
plan: BillingPlanKey;
|
||||
}>;
|
||||
|
||||
|
||||
@ -3244,11 +3251,11 @@ export type BillingPortalSessionQueryHookResult = ReturnType<typeof useBillingPo
|
||||
export type BillingPortalSessionLazyQueryHookResult = ReturnType<typeof useBillingPortalSessionLazyQuery>;
|
||||
export type BillingPortalSessionQueryResult = Apollo.QueryResult<BillingPortalSessionQuery, BillingPortalSessionQueryVariables>;
|
||||
export const CheckoutSessionDocument = gql`
|
||||
mutation CheckoutSession($recurringInterval: SubscriptionInterval!, $successUrlPath: String, $requirePaymentMethod: Boolean) {
|
||||
mutation CheckoutSession($recurringInterval: SubscriptionInterval!, $successUrlPath: String, $plan: BillingPlanKey!) {
|
||||
checkoutSession(
|
||||
recurringInterval: $recurringInterval
|
||||
successUrlPath: $successUrlPath
|
||||
requirePaymentMethod: $requirePaymentMethod
|
||||
plan: $plan
|
||||
) {
|
||||
url
|
||||
}
|
||||
@ -3271,7 +3278,7 @@ export type CheckoutSessionMutationFn = Apollo.MutationFunction<CheckoutSessionM
|
||||
* variables: {
|
||||
* recurringInterval: // value for 'recurringInterval'
|
||||
* successUrlPath: // value for 'successUrlPath'
|
||||
* requirePaymentMethod: // value for 'requirePaymentMethod'
|
||||
* plan: // value for 'plan'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
|
@ -1,12 +1,11 @@
|
||||
import { useIsLogged } from '@/auth/hooks/useIsLogged';
|
||||
import { useFreePass } from '@/billing/hooks/useFreePass';
|
||||
import { useBillingPlan } from '@/billing/hooks/useBillingPlan';
|
||||
import { BillingPlanKey } from '@/billing/types/billing';
|
||||
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
|
||||
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
|
||||
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
|
||||
import { OnboardingStatus, SubscriptionStatus } from '~/generated/graphql';
|
||||
|
||||
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
|
||||
import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation';
|
||||
import { UNTESTED_APP_PATHS } from '~/testing/constants/UntestedAppPaths';
|
||||
@ -39,9 +38,9 @@ const setupMockIsLogged = (isLogged: boolean) => {
|
||||
jest.mocked(useIsLogged).mockReturnValueOnce(isLogged);
|
||||
};
|
||||
|
||||
jest.mock('@/billing/hooks/useFreePass');
|
||||
const setupMockFreePass = (freePass: boolean) => {
|
||||
jest.mocked(useFreePass).mockReturnValueOnce(freePass);
|
||||
jest.mock('@/billing/hooks/useBillingPlan');
|
||||
const setupMockBillingPlan = (plan: BillingPlanKey) => {
|
||||
jest.mocked(useBillingPlan).mockReturnValueOnce(plan);
|
||||
};
|
||||
|
||||
const defaultHomePagePath = '/objects/companies';
|
||||
@ -152,16 +151,16 @@ const testCases = [
|
||||
{ loc: AppPath.PlanRequired, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.PlanRequired, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.FreePassCheckout, isLoggedIn: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: undefined },
|
||||
{ loc: AppPath.FreePassCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' },
|
||||
{ loc: AppPath.FreePassCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' },
|
||||
{ loc: AppPath.FreePassCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.PastDue, onboardingStatus: OnboardingStatus.Completed, res: defaultHomePagePath },
|
||||
{ loc: AppPath.FreePassCheckout, isLoggedIn: false, subscriptionStatus: undefined, onboardingStatus: undefined, res: AppPath.SignInUp },
|
||||
{ loc: AppPath.FreePassCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.WorkspaceActivation, res: AppPath.CreateWorkspace },
|
||||
{ loc: AppPath.FreePassCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.ProfileCreation, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.FreePassCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.SyncEmail, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.FreePassCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.FreePassCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: defaultHomePagePath },
|
||||
{ loc: AppPath.PlanCheckout, isLoggedIn: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: undefined },
|
||||
{ loc: AppPath.PlanCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' },
|
||||
{ loc: AppPath.PlanCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' },
|
||||
{ loc: AppPath.PlanCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.PastDue, onboardingStatus: OnboardingStatus.Completed, res: defaultHomePagePath },
|
||||
{ loc: AppPath.PlanCheckout, isLoggedIn: false, subscriptionStatus: undefined, onboardingStatus: undefined, res: AppPath.SignInUp },
|
||||
{ loc: AppPath.PlanCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.WorkspaceActivation, res: AppPath.CreateWorkspace },
|
||||
{ loc: AppPath.PlanCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.ProfileCreation, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.PlanCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.SyncEmail, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.PlanCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.PlanCheckout, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.PlanRequiredSuccess, isLoggedIn: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: AppPath.PlanRequired },
|
||||
{ loc: AppPath.PlanRequiredSuccess, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' },
|
||||
@ -292,7 +291,7 @@ describe('usePageChangeEffectNavigateLocation', () => {
|
||||
setupMockOnboardingStatus(testCase.onboardingStatus);
|
||||
setupMockSubscriptionStatus(testCase.subscriptionStatus);
|
||||
setupMockIsLogged(testCase.isLoggedIn);
|
||||
setupMockFreePass(false);
|
||||
setupMockBillingPlan(BillingPlanKey.PRO);
|
||||
expect(usePageChangeEffectNavigateLocation()).toEqual(testCase.res);
|
||||
});
|
||||
});
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { useIsLogged } from '@/auth/hooks/useIsLogged';
|
||||
import { useFreePass } from '@/billing/hooks/useFreePass';
|
||||
import { useBillingPlan } from '@/billing/hooks/useBillingPlan';
|
||||
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
|
||||
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
@ -14,7 +14,7 @@ export const usePageChangeEffectNavigateLocation = () => {
|
||||
const onboardingStatus = useOnboardingStatus();
|
||||
const subscriptionStatus = useSubscriptionStatus();
|
||||
const { defaultHomePagePath } = useDefaultHomePagePath();
|
||||
const freePass = useFreePass();
|
||||
const plan = useBillingPlan();
|
||||
|
||||
const isMatchingOpenRoute =
|
||||
isMatchingLocation(AppPath.Invite) ||
|
||||
@ -33,7 +33,7 @@ export const usePageChangeEffectNavigateLocation = () => {
|
||||
isMatchingLocation(AppPath.InviteTeam) ||
|
||||
isMatchingLocation(AppPath.PlanRequired) ||
|
||||
isMatchingLocation(AppPath.PlanRequiredSuccess) ||
|
||||
isMatchingLocation(AppPath.FreePassCheckout);
|
||||
isMatchingLocation(AppPath.PlanCheckout);
|
||||
|
||||
if (isMatchingOpenRoute) {
|
||||
return;
|
||||
@ -46,9 +46,9 @@ export const usePageChangeEffectNavigateLocation = () => {
|
||||
if (
|
||||
onboardingStatus === OnboardingStatus.PlanRequired &&
|
||||
!isMatchingLocation(AppPath.PlanRequired) &&
|
||||
!isMatchingLocation(AppPath.FreePassCheckout)
|
||||
!isMatchingLocation(AppPath.PlanCheckout)
|
||||
) {
|
||||
return freePass ? AppPath.FreePassCheckout : AppPath.PlanRequired;
|
||||
return plan ? AppPath.PlanCheckout : AppPath.PlanRequired;
|
||||
}
|
||||
|
||||
if (
|
||||
|
@ -6,9 +6,9 @@ import { AppPath } from '@/types/AppPath';
|
||||
import { BlankLayout } from '@/ui/layout/page/components/BlankLayout';
|
||||
import { DefaultLayout } from '@/ui/layout/page/components/DefaultLayout';
|
||||
import {
|
||||
createBrowserRouter,
|
||||
createRoutesFromElements,
|
||||
Route,
|
||||
createBrowserRouter,
|
||||
createRoutesFromElements,
|
||||
Route,
|
||||
} from 'react-router-dom';
|
||||
import { Authorize } from '~/pages/auth/Authorize';
|
||||
import { Invite } from '~/pages/auth/Invite';
|
||||
@ -20,9 +20,9 @@ import { RecordShowPage } from '~/pages/object-record/RecordShowPage';
|
||||
import { ChooseYourPlan } from '~/pages/onboarding/ChooseYourPlan';
|
||||
import { CreateProfile } from '~/pages/onboarding/CreateProfile';
|
||||
import { CreateWorkspace } from '~/pages/onboarding/CreateWorkspace';
|
||||
import { FreePassCheckoutEffect } from '~/pages/onboarding/FreePassCheckoutEffect';
|
||||
import { InviteTeam } from '~/pages/onboarding/InviteTeam';
|
||||
import { PaymentSuccess } from '~/pages/onboarding/PaymentSuccess';
|
||||
import { PlanCheckoutEffect } from '~/pages/onboarding/PlanCheckoutEffect';
|
||||
import { SyncEmails } from '~/pages/onboarding/SyncEmails';
|
||||
export const useCreateAppRouter = (
|
||||
isBillingEnabled?: boolean,
|
||||
@ -49,10 +49,7 @@ export const useCreateAppRouter = (
|
||||
<Route path={AppPath.SyncEmails} element={<SyncEmails />} />
|
||||
<Route path={AppPath.InviteTeam} element={<InviteTeam />} />
|
||||
<Route path={AppPath.PlanRequired} element={<ChooseYourPlan />} />
|
||||
<Route
|
||||
path={AppPath.FreePassCheckout}
|
||||
element={<FreePassCheckoutEffect />}
|
||||
/>
|
||||
<Route path={AppPath.PlanCheckout} element={<PlanCheckoutEffect />} />
|
||||
<Route
|
||||
path={AppPath.PlanRequiredSuccess}
|
||||
element={<PaymentSuccess />}
|
||||
|
@ -4,12 +4,12 @@ export const CHECKOUT_SESSION = gql`
|
||||
mutation CheckoutSession(
|
||||
$recurringInterval: SubscriptionInterval!
|
||||
$successUrlPath: String
|
||||
$requirePaymentMethod: Boolean
|
||||
$plan: BillingPlanKey!
|
||||
) {
|
||||
checkoutSession(
|
||||
recurringInterval: $recurringInterval
|
||||
successUrlPath: $successUrlPath
|
||||
requirePaymentMethod: $requirePaymentMethod
|
||||
plan: $plan
|
||||
) {
|
||||
url
|
||||
}
|
||||
|
@ -0,0 +1,72 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
|
||||
import { useBillingPlan } from '@/billing/hooks/useBillingPlan';
|
||||
import { BillingPlanKey } from '@/billing/types/billing';
|
||||
|
||||
const Wrapper = ({ children, initialUrl = '' }: any) => (
|
||||
<MemoryRouter initialEntries={[initialUrl]}>
|
||||
<RecoilRoot>{children}</RecoilRoot>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
describe('useBillingPlan', () => {
|
||||
it('should return FREE as default plan', () => {
|
||||
const { result } = renderHook(() => useBillingPlan(), {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
|
||||
expect(result.current).toBe(BillingPlanKey.FREE);
|
||||
});
|
||||
|
||||
it('should set plan from URL parameter - FREE', () => {
|
||||
const { result } = renderHook(() => useBillingPlan(), {
|
||||
wrapper: ({ children }) => (
|
||||
<Wrapper initialUrl="?plan=free">{children}</Wrapper>
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(BillingPlanKey.FREE);
|
||||
});
|
||||
|
||||
it('should set plan from URL parameter - PRO', () => {
|
||||
const { result } = renderHook(() => useBillingPlan(), {
|
||||
wrapper: ({ children }) => (
|
||||
<Wrapper initialUrl="?plan=pro">{children}</Wrapper>
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(BillingPlanKey.PRO);
|
||||
});
|
||||
|
||||
it('should set plan from URL parameter - ENTERPRISE', () => {
|
||||
const { result } = renderHook(() => useBillingPlan(), {
|
||||
wrapper: ({ children }) => (
|
||||
<Wrapper initialUrl="?plan=enterprise">{children}</Wrapper>
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(BillingPlanKey.ENTERPRISE);
|
||||
});
|
||||
|
||||
it('should ignore invalid plan from URL parameter', () => {
|
||||
const { result } = renderHook(() => useBillingPlan(), {
|
||||
wrapper: ({ children }) => (
|
||||
<Wrapper initialUrl="?plan=invalid">{children}</Wrapper>
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(BillingPlanKey.FREE);
|
||||
});
|
||||
|
||||
it('should handle URL without plan parameter', () => {
|
||||
const { result } = renderHook(() => useBillingPlan(), {
|
||||
wrapper: ({ children }) => (
|
||||
<Wrapper initialUrl="?other=param">{children}</Wrapper>
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(BillingPlanKey.FREE);
|
||||
});
|
||||
});
|
@ -0,0 +1,38 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { atom, useRecoilState } from 'recoil';
|
||||
|
||||
import { BillingPlanKey } from '@/billing/types/billing';
|
||||
|
||||
const billingPlanState = atom<BillingPlanKey | null>({
|
||||
key: 'billingPlanState',
|
||||
default: null,
|
||||
});
|
||||
|
||||
export const useBillingPlan = () => {
|
||||
const { search } = useLocation();
|
||||
const [billingPlan, setBillingPlan] = useRecoilState(billingPlanState);
|
||||
|
||||
const hasFreePassParameter =
|
||||
search.includes('freepass') ||
|
||||
search.includes('freePass') ||
|
||||
search.includes('free-pass') ||
|
||||
search.includes('Free-pass') ||
|
||||
search.includes('FreePass');
|
||||
|
||||
if (hasFreePassParameter) {
|
||||
setBillingPlan(BillingPlanKey.FREE);
|
||||
return billingPlan;
|
||||
}
|
||||
|
||||
const planFromUrl = search.match(/[?&]plan=([^&]+)/)?.[1]?.toUpperCase();
|
||||
|
||||
if (
|
||||
planFromUrl !== null &&
|
||||
planFromUrl !== undefined &&
|
||||
Object.values(BillingPlanKey).includes(planFromUrl as BillingPlanKey)
|
||||
) {
|
||||
setBillingPlan(planFromUrl as BillingPlanKey);
|
||||
}
|
||||
|
||||
return billingPlan;
|
||||
};
|
@ -1,21 +0,0 @@
|
||||
import { freePassState } from '@/billing/states/freePassState';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
export const useFreePass = () => {
|
||||
const { search } = useLocation();
|
||||
const [freePass, setFreePass] = useRecoilState(freePassState);
|
||||
|
||||
const hasFreePassParameter =
|
||||
search.includes('freepass') ||
|
||||
search.includes('freePass') ||
|
||||
search.includes('free-pass') ||
|
||||
search.includes('Free-pass') ||
|
||||
search.includes('FreePass');
|
||||
|
||||
if (hasFreePassParameter) {
|
||||
setFreePass(true);
|
||||
}
|
||||
|
||||
return freePass;
|
||||
};
|
@ -1,6 +0,0 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const freePassState = atom<boolean>({
|
||||
key: 'freePassState',
|
||||
default: false,
|
||||
});
|
@ -0,0 +1,5 @@
|
||||
export enum BillingPlanKey {
|
||||
FREE = 'FREE',
|
||||
PRO = 'PRO',
|
||||
ENTERPRISE = 'ENTERPRISE',
|
||||
}
|
@ -12,7 +12,7 @@ export enum AppPath {
|
||||
InviteTeam = '/invite-team',
|
||||
PlanRequired = '/plan-required',
|
||||
PlanRequiredSuccess = '/plan-required/payment-success',
|
||||
FreePassCheckout = '/free-pass',
|
||||
PlanCheckout = '/plan-checkout',
|
||||
|
||||
// Onboarded
|
||||
Index = '/',
|
||||
|
@ -155,16 +155,16 @@ const testCases = [
|
||||
{ loc: AppPath.PlanRequired, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: true },
|
||||
{ loc: AppPath.PlanRequired, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: false },
|
||||
|
||||
{ loc: AppPath.FreePassCheckout, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: true },
|
||||
{ loc: AppPath.FreePassCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: true },
|
||||
{ loc: AppPath.FreePassCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: false },
|
||||
{ loc: AppPath.FreePassCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.PastDue, onboardingStatus: OnboardingStatus.Completed, res: false },
|
||||
{ loc: AppPath.FreePassCheckout, isLogged: false, subscriptionStatus: undefined, onboardingStatus: undefined, res: true },
|
||||
{ loc: AppPath.FreePassCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.WorkspaceActivation, res: true },
|
||||
{ loc: AppPath.FreePassCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.ProfileCreation, res: true },
|
||||
{ loc: AppPath.FreePassCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.SyncEmail, res: true },
|
||||
{ loc: AppPath.FreePassCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: true },
|
||||
{ loc: AppPath.FreePassCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: false },
|
||||
{ loc: AppPath.PlanCheckout, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: true },
|
||||
{ loc: AppPath.PlanCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: true },
|
||||
{ loc: AppPath.PlanCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: false },
|
||||
{ loc: AppPath.PlanCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.PastDue, onboardingStatus: OnboardingStatus.Completed, res: false },
|
||||
{ loc: AppPath.PlanCheckout, isLogged: false, subscriptionStatus: undefined, onboardingStatus: undefined, res: true },
|
||||
{ loc: AppPath.PlanCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.WorkspaceActivation, res: true },
|
||||
{ loc: AppPath.PlanCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.ProfileCreation, res: true },
|
||||
{ loc: AppPath.PlanCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.SyncEmail, res: true },
|
||||
{ loc: AppPath.PlanCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: true },
|
||||
{ loc: AppPath.PlanCheckout, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: false },
|
||||
|
||||
{ loc: AppPath.PlanRequiredSuccess, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: true },
|
||||
{ loc: AppPath.PlanRequiredSuccess, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: false },
|
||||
|
@ -46,7 +46,7 @@ export const useShowAuthModal = () => {
|
||||
|
||||
if (
|
||||
isMatchingLocation(AppPath.PlanRequired) ||
|
||||
isMatchingLocation(AppPath.FreePassCheckout)
|
||||
isMatchingLocation(AppPath.PlanCheckout)
|
||||
) {
|
||||
return (
|
||||
(onboardingStatus === OnboardingStatus.Completed &&
|
||||
|
@ -1,14 +1,16 @@
|
||||
import { useBillingPlan } from '@/billing/hooks/useBillingPlan';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import {
|
||||
SubscriptionInterval,
|
||||
useCheckoutSessionMutation,
|
||||
SubscriptionInterval,
|
||||
useCheckoutSessionMutation,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
export const FreePassCheckoutEffect = () => {
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
const [checkoutSession] = useCheckoutSessionMutation();
|
||||
const plan = useBillingPlan();
|
||||
|
||||
const createCheckoutSession = async () => {
|
||||
try {
|
||||
@ -16,7 +18,7 @@ export const FreePassCheckoutEffect = () => {
|
||||
variables: {
|
||||
recurringInterval: SubscriptionInterval.Month,
|
||||
successUrlPath: AppPath.PlanRequiredSuccess,
|
||||
requirePaymentMethod: false,
|
||||
plan,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -0,0 +1,46 @@
|
||||
import { useBillingPlan } from '@/billing/hooks/useBillingPlan';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import {
|
||||
SubscriptionInterval,
|
||||
useCheckoutSessionMutation,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
export const PlanCheckoutEffect = () => {
|
||||
const { enqueueSnackBar } = useSnackBar();
|
||||
const [checkoutSession] = useCheckoutSessionMutation();
|
||||
const plan = useBillingPlan();
|
||||
|
||||
const createCheckoutSession = async () => {
|
||||
try {
|
||||
const { data } = await checkoutSession({
|
||||
variables: {
|
||||
recurringInterval: SubscriptionInterval.Month,
|
||||
successUrlPath: AppPath.PlanRequiredSuccess,
|
||||
plan,
|
||||
},
|
||||
});
|
||||
|
||||
if (!data?.checkoutSession.url) {
|
||||
enqueueSnackBar(
|
||||
'Checkout session error. Please retry or contact Twenty team',
|
||||
{
|
||||
variant: SnackBarVariant.Error,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.replace(data.checkoutSession.url);
|
||||
} catch (error) {
|
||||
enqueueSnackBar('Error creating checkout session', {
|
||||
variant: SnackBarVariant.Error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
createCheckoutSession();
|
||||
|
||||
return <></>;
|
||||
};
|
@ -0,0 +1,144 @@
|
||||
import { MockedProvider } from '@apollo/client/testing';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
|
||||
import { CHECKOUT_SESSION } from '@/billing/graphql/checkoutSession';
|
||||
import { BillingPlanKey } from '@/billing/types/billing';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { SubscriptionInterval } from '~/generated/graphql';
|
||||
import { PlanCheckoutEffect } from '../PlanCheckoutEffect';
|
||||
|
||||
const mockEnqueueSnackBar = jest.fn();
|
||||
jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({
|
||||
useSnackBar: () => ({
|
||||
enqueueSnackBar: mockEnqueueSnackBar,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockBillingPlan = jest.fn();
|
||||
jest.mock('@/billing/hooks/useBillingPlan', () => ({
|
||||
useBillingPlan: () => mockBillingPlan(),
|
||||
}));
|
||||
|
||||
const mockReplace = jest.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
replace: mockReplace,
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
describe('PlanCheckoutEffect', () => {
|
||||
const mockCheckoutUrl = 'https://checkout.stripe.com/test';
|
||||
|
||||
const successMock = {
|
||||
request: {
|
||||
query: CHECKOUT_SESSION,
|
||||
variables: {
|
||||
recurringInterval: SubscriptionInterval.Month,
|
||||
successUrlPath: AppPath.PlanRequiredSuccess,
|
||||
plan: BillingPlanKey.PRO,
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
checkoutSession: {
|
||||
url: mockCheckoutUrl,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const errorMock = {
|
||||
request: {
|
||||
query: CHECKOUT_SESSION,
|
||||
variables: {
|
||||
recurringInterval: SubscriptionInterval.Month,
|
||||
successUrlPath: AppPath.PlanRequiredSuccess,
|
||||
plan: BillingPlanKey.PRO,
|
||||
},
|
||||
},
|
||||
error: new Error('Checkout session error'),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockBillingPlan.mockReturnValue(BillingPlanKey.PRO);
|
||||
mockEnqueueSnackBar.mockClear();
|
||||
mockReplace.mockClear();
|
||||
});
|
||||
|
||||
it('should redirect to checkout URL on successful session creation', async () => {
|
||||
render(
|
||||
<MockedProvider mocks={[successMock]} addTypename={false}>
|
||||
<BrowserRouter>
|
||||
<RecoilRoot>
|
||||
<PlanCheckoutEffect />
|
||||
</RecoilRoot>
|
||||
</BrowserRouter>
|
||||
</MockedProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith(mockCheckoutUrl);
|
||||
expect(mockEnqueueSnackBar).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show error snackbar when checkout session creation fails', async () => {
|
||||
render(
|
||||
<MockedProvider mocks={[errorMock]} addTypename={false}>
|
||||
<BrowserRouter>
|
||||
<RecoilRoot>
|
||||
<PlanCheckoutEffect />
|
||||
</RecoilRoot>
|
||||
</BrowserRouter>
|
||||
</MockedProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(mockReplace).not.toHaveBeenCalled();
|
||||
expect(mockEnqueueSnackBar).toHaveBeenCalledWith(
|
||||
'Error creating checkout session',
|
||||
{ variant: 'error' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error snackbar when checkout URL is missing', async () => {
|
||||
const noUrlMock = {
|
||||
...successMock,
|
||||
result: {
|
||||
data: {
|
||||
checkoutSession: {
|
||||
url: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<MockedProvider mocks={[noUrlMock]} addTypename={false}>
|
||||
<BrowserRouter>
|
||||
<RecoilRoot>
|
||||
<PlanCheckoutEffect />
|
||||
</RecoilRoot>
|
||||
</BrowserRouter>
|
||||
</MockedProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(mockReplace).not.toHaveBeenCalled();
|
||||
expect(mockEnqueueSnackBar).toHaveBeenCalledWith(
|
||||
'Checkout session error. Please retry or contact Twenty team',
|
||||
{ variant: 'error' },
|
||||
);
|
||||
});
|
||||
});
|
@ -56,11 +56,7 @@ export class BillingResolver {
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@AuthUser() user: User,
|
||||
@Args()
|
||||
{
|
||||
recurringInterval,
|
||||
successUrlPath,
|
||||
requirePaymentMethod,
|
||||
}: CheckoutSessionInput,
|
||||
{ recurringInterval, successUrlPath, plan }: CheckoutSessionInput,
|
||||
) {
|
||||
const productPrice = await this.stripeService.getStripePrice(
|
||||
AvailableProduct.BasePlan,
|
||||
@ -79,7 +75,7 @@ export class BillingResolver {
|
||||
workspace,
|
||||
productPrice.stripePriceId,
|
||||
successUrlPath,
|
||||
requirePaymentMethod,
|
||||
plan,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||
|
||||
@ArgsType()
|
||||
@ -12,13 +13,13 @@ export class CheckoutSessionInput {
|
||||
@IsNotEmpty()
|
||||
recurringInterval: Stripe.Price.Recurring.Interval;
|
||||
|
||||
@Field(() => String)
|
||||
@Field(() => BillingPlanKey, { defaultValue: BillingPlanKey.PRO })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
successUrlPath: string;
|
||||
|
||||
@Field(() => Boolean, { defaultValue: true })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
requirePaymentMethod: boolean;
|
||||
plan?: BillingPlanKey;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
successUrlPath?: string;
|
||||
}
|
||||
|
@ -1,4 +1,12 @@
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
export enum BillingPlanKey {
|
||||
BASE_PLAN = 'BASE_PLAN',
|
||||
PRO_PLAN = 'PRO_PLAN',
|
||||
FREE = 'FREE',
|
||||
PRO = 'PRO',
|
||||
ENTERPRISE = 'ENTERPRISE',
|
||||
}
|
||||
|
||||
registerEnumType(BillingPlanKey, {
|
||||
name: 'BillingPlanKey',
|
||||
description: 'The different billing plans available',
|
||||
});
|
||||
|
@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
@ -30,34 +31,41 @@ export class BillingPortalWorkspaceService {
|
||||
workspace: Workspace,
|
||||
priceId: string,
|
||||
successUrlPath?: string,
|
||||
requirePaymentMethod?: boolean,
|
||||
plan?: BillingPlanKey,
|
||||
cancelUrl?: string,
|
||||
stripeCustomerId?: string,
|
||||
): Promise<string> {
|
||||
const frontBaseUrl = this.domainManagerService.getBaseUrl();
|
||||
const cancelUrl = frontBaseUrl.toString();
|
||||
if (!cancelUrl) {
|
||||
cancelUrl = this.domainManagerService.getBaseUrl().toString();
|
||||
}
|
||||
|
||||
if (successUrlPath) {
|
||||
const frontBaseUrl = this.domainManagerService.getBaseUrl();
|
||||
|
||||
frontBaseUrl.pathname = successUrlPath;
|
||||
successUrlPath = frontBaseUrl.toString();
|
||||
}
|
||||
const successUrl = frontBaseUrl.toString();
|
||||
|
||||
const quantity = await this.userWorkspaceRepository.countBy({
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
const stripeCustomerId = (
|
||||
await this.billingSubscriptionRepository.findOneBy({
|
||||
workspaceId: user.defaultWorkspaceId,
|
||||
})
|
||||
)?.stripeCustomerId;
|
||||
if (!stripeCustomerId) {
|
||||
stripeCustomerId = (
|
||||
await this.billingSubscriptionRepository.findOneBy({
|
||||
workspaceId: user.defaultWorkspaceId,
|
||||
})
|
||||
)?.stripeCustomerId;
|
||||
}
|
||||
|
||||
const session = await this.stripeService.createCheckoutSession(
|
||||
user,
|
||||
priceId,
|
||||
quantity,
|
||||
successUrl,
|
||||
successUrlPath,
|
||||
cancelUrl,
|
||||
stripeCustomerId,
|
||||
requirePaymentMethod,
|
||||
plan,
|
||||
);
|
||||
|
||||
assert(session.url, 'Error: missing checkout.session.url');
|
||||
|
@ -51,14 +51,7 @@ export class BillingWebhookProductService {
|
||||
}
|
||||
|
||||
isValidBillingPlanKey(planKey?: string) {
|
||||
switch (planKey) {
|
||||
case BillingPlanKey.BASE_PLAN:
|
||||
return true;
|
||||
case BillingPlanKey.PRO_PLAN:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return Object.values(BillingPlanKey).includes(planKey as BillingPlanKey);
|
||||
}
|
||||
|
||||
isValidPriceUsageBased(priceUsageBased?: string) {
|
||||
|
@ -5,6 +5,7 @@ import Stripe from 'stripe';
|
||||
import { ProductPriceEntity } from 'src/engine/core-modules/billing/dto/product-price.entity';
|
||||
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
|
||||
import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum';
|
||||
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
@ -89,8 +90,10 @@ export class StripeService {
|
||||
successUrl?: string,
|
||||
cancelUrl?: string,
|
||||
stripeCustomerId?: string,
|
||||
requirePaymentMethod?: boolean,
|
||||
plan: BillingPlanKey = BillingPlanKey.FREE,
|
||||
): Promise<Stripe.Checkout.Session> {
|
||||
const requirePaymentMethod = plan !== BillingPlanKey.FREE;
|
||||
|
||||
return await this.stripe.checkout.sessions.create({
|
||||
line_items: [
|
||||
{
|
||||
@ -102,13 +105,14 @@ export class StripeService {
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
workspaceId: user.defaultWorkspaceId,
|
||||
plan: plan,
|
||||
},
|
||||
trial_period_days: this.environmentService.get(
|
||||
'BILLING_FREE_TRIAL_DURATION_IN_DAYS',
|
||||
),
|
||||
},
|
||||
automatic_tax: { enabled: !!requirePaymentMethod }, // For now we correlate collecting tax info with collecting the payment method
|
||||
tax_id_collection: { enabled: !!requirePaymentMethod }, // TBC what we should do in the future.
|
||||
automatic_tax: { enabled: requirePaymentMethod },
|
||||
tax_id_collection: { enabled: requirePaymentMethod },
|
||||
customer: stripeCustomerId,
|
||||
customer_update: stripeCustomerId ? { name: 'auto' } : undefined,
|
||||
customer_email: stripeCustomerId ? undefined : user.email,
|
||||
|
Loading…
Reference in New Issue
Block a user