Skip to content

Commit

Permalink
Refacto to introduce billing plan instead
Browse files Browse the repository at this point in the history
  • Loading branch information
FelixMalfait committed Dec 20, 2024
1 parent d24680a commit 3db9401
Show file tree
Hide file tree
Showing 22 changed files with 409 additions and 116 deletions.
17 changes: 12 additions & 5 deletions packages/twenty-front/src/generated/graphql.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -536,8 +543,8 @@ export type MutationChallengeArgs = {


export type MutationCheckoutSessionArgs = {
plan?: BillingPlanKey;
recurringInterval: SubscriptionInterval;
requirePaymentMethod?: InputMaybe<Scalars['Boolean']>;
successUrlPath?: InputMaybe<Scalars['String']>;
};

Expand Down Expand Up @@ -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;
}>;


Expand Down Expand Up @@ -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
}
Expand All @@ -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'
* },
* });
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -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);
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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) ||
Expand All @@ -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;
Expand All @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -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 />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
38 changes: 38 additions & 0 deletions packages/twenty-front/src/modules/billing/hooks/useBillingPlan.ts
Original file line number Diff line number Diff line change
@@ -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;
};
21 changes: 0 additions & 21 deletions packages/twenty-front/src/modules/billing/hooks/useFreePass.ts

This file was deleted.

This file was deleted.

5 changes: 5 additions & 0 deletions packages/twenty-front/src/modules/billing/types/billing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export enum BillingPlanKey {
FREE = 'FREE',
PRO = 'PRO',
ENTERPRISE = 'ENTERPRISE',
}
2 changes: 1 addition & 1 deletion packages/twenty-front/src/modules/types/AppPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '/',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const useShowAuthModal = () => {

if (
isMatchingLocation(AppPath.PlanRequired) ||
isMatchingLocation(AppPath.FreePassCheckout)
isMatchingLocation(AppPath.PlanCheckout)
) {
return (
(onboardingStatus === OnboardingStatus.Completed &&
Expand Down
Loading

0 comments on commit 3db9401

Please sign in to comment.