Refacto to introduce billing plan instead

This commit is contained in:
Félix Malfait 2024-12-20 18:55:14 +01:00
parent d24680a6ba
commit 3db94013bd
22 changed files with 410 additions and 117 deletions

View File

@ -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'
* },
* });
*/

View File

@ -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);
});
});

View File

@ -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 (

View File

@ -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 />}

View File

@ -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
}

View File

@ -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);
});
});

View File

@ -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;
};

View File

@ -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;
};

View File

@ -1,6 +0,0 @@
import { atom } from 'recoil';
export const freePassState = atom<boolean>({
key: 'freePassState',
default: false,
});

View File

@ -0,0 +1,5 @@
export enum BillingPlanKey {
FREE = 'FREE',
PRO = 'PRO',
ENTERPRISE = 'ENTERPRISE',
}

View File

@ -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 = '/',

View File

@ -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 },

View File

@ -46,7 +46,7 @@ export const useShowAuthModal = () => {
if (
isMatchingLocation(AppPath.PlanRequired) ||
isMatchingLocation(AppPath.FreePassCheckout)
isMatchingLocation(AppPath.PlanCheckout)
) {
return (
(onboardingStatus === OnboardingStatus.Completed &&

View File

@ -1,3 +1,4 @@
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';
@ -9,6 +10,7 @@ import {
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,
},
});

View File

@ -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 <></>;
};

View File

@ -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' },
);
});
});

View File

@ -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,
),
};
}

View File

@ -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;
}

View File

@ -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',
});

View File

@ -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 = (
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');

View File

@ -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) {

View File

@ -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,