Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce FreePass for cloud #9146

Closed
wants to merge 22 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,7 @@ We felt the need for a CRM platform that empowers rather than constrains. We bel
<br>

# Demo
Go to <a href="https://demo.twenty.com/">demo.twenty.com</a> and login with the following credentials:

```
email: [email protected]
password: Applecar2025
```
Go to <a href="https://app.twenty.com/?freepass=true">app.twenty.com?freepass=true</a> (the freepass parameter will allow you to signup without a credit card)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: exposing the freepass parameter in the README makes it easily discoverable and could lead to abuse. Consider using a different distribution method for demo access


See also:
🚀 [Self-hosting](https://twenty.com/developers/section/self-hosting)
Expand Down
6 changes: 5 additions & 1 deletion packages/twenty-front/src/generated/graphql.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,7 @@ export type MutationChallengeArgs = {

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

Expand Down Expand Up @@ -1952,6 +1953,7 @@ export type BillingPortalSessionQuery = { __typename?: 'Query', billingPortalSes
export type CheckoutSessionMutationVariables = Exact<{
recurringInterval: SubscriptionInterval;
successUrlPath?: InputMaybe<Scalars['String']>;
requirePaymentMethod?: InputMaybe<Scalars['Boolean']>;
}>;


Expand Down Expand Up @@ -3242,10 +3244,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) {
mutation CheckoutSession($recurringInterval: SubscriptionInterval!, $successUrlPath: String, $requirePaymentMethod: Boolean) {
checkoutSession(
recurringInterval: $recurringInterval
successUrlPath: $successUrlPath
requirePaymentMethod: $requirePaymentMethod
) {
url
}
Expand All @@ -3268,6 +3271,7 @@ export type CheckoutSessionMutationFn = Apollo.MutationFunction<CheckoutSessionM
* variables: {
* recurringInterval: // value for 'recurringInterval'
* successUrlPath: // value for 'successUrlPath'
* requirePaymentMethod: // value for 'requirePaymentMethod'
* },
* });
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { useIsLogged } from '@/auth/hooks/useIsLogged';
import { freePassState } from '@/billing/states/freePassState';
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath';
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { useLocation } from 'react-router-dom';
import { useRecoilState } from 'recoil';
import { OnboardingStatus, SubscriptionStatus } from '~/generated/graphql';
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';

Expand All @@ -14,6 +17,24 @@ export const usePageChangeEffectNavigateLocation = () => {
const subscriptionStatus = useSubscriptionStatus();
const { defaultHomePagePath } = useDefaultHomePagePath();

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');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: use toLowerCase() and a single includes check instead of multiple case-sensitive checks


console.log('hasFreePassParameter', hasFreePassParameter);

if (hasFreePassParameter) {
console.log('setting free pass to true');
setFreePass(true);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: remove console.log statements before production deployment

}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: this state update in a render function could cause infinite re-renders - should be moved to useEffect


const isMatchingOpenRoute =
isMatchingLocation(AppPath.Invite) ||
isMatchingLocation(AppPath.ResetPassword);
Expand Down Expand Up @@ -44,7 +65,7 @@ export const usePageChangeEffectNavigateLocation = () => {
onboardingStatus === OnboardingStatus.PlanRequired &&
!isMatchingLocation(AppPath.PlanRequired)
) {
return AppPath.PlanRequired;
return freePass ? AppPath.FreePassCheckout : AppPath.PlanRequired;
}

if (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ 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 { SyncEmails } from '~/pages/onboarding/SyncEmails';
Expand Down Expand Up @@ -49,6 +50,10 @@ 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 />}
/>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: ensure FreePassCheckout route is protected and can't be accessed directly via URL to bypass normal signup flow

<Route
path={AppPath.PlanRequiredSuccess}
element={<PaymentSuccess />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ export const CHECKOUT_SESSION = gql`
mutation CheckoutSession(
$recurringInterval: SubscriptionInterval!
$successUrlPath: String
$requirePaymentMethod: Boolean
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Consider making this parameter non-nullable to enforce explicit handling of payment method requirements

) {
checkoutSession(
recurringInterval: $recurringInterval
successUrlPath: $successUrlPath
requirePaymentMethod: $requirePaymentMethod
) {
url
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { atom } from 'recoil';

export const freePassState = atom<boolean>({
key: 'freePassState',
default: false,
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Consider adding persistence to this state to prevent it from resetting on page refresh, which could disrupt the signup flow

1 change: 1 addition & 0 deletions packages/twenty-front/src/modules/types/AppPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export enum AppPath {
InviteTeam = '/invite-team',
PlanRequired = '/plan-required',
PlanRequiredSuccess = '/plan-required/payment-success',
FreePassCheckout = '/free-pass',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: This path should be grouped with other billing/payment related paths like PlanRequired and PlanRequiredSuccess above it for better code organization


// Onboarded
Index = '/',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export const ChooseYourPlan = () => {
variables: {
recurringInterval: planSelected,
successUrlPath: AppPath.PlanRequiredSuccess,
requirePaymentMethod: true,
},
});
setIsSubmitting(false);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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 { useEffect } from 'react';
import {
SubscriptionInterval,
useCheckoutSessionMutation,
} from '~/generated/graphql';

export const FreePassCheckoutEffect = () => {
const { enqueueSnackBar } = useSnackBar();
const [checkoutSession] = useCheckoutSessionMutation();

useEffect(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: useEffect runs on every mount without cleanup. Could cause multiple checkout sessions if component remounts

const createCheckoutSession = async () => {
try {
const { data } = await checkoutSession({
variables: {
recurringInterval: SubscriptionInterval.Month,
successUrlPath: AppPath.PlanRequiredSuccess,
requirePaymentMethod: false,
},
});

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();
}, [checkoutSession, enqueueSnackBar]);

return null;
};
5 changes: 5 additions & 0 deletions packages/twenty-front/src/types/AppPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export enum AppPath {
// ... existing paths ...
FreePassCheckout = '/free-pass-checkout',
// ... rest of the paths ...
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,12 @@ export class BillingResolver {
async checkoutSession(
@AuthWorkspace() workspace: Workspace,
@AuthUser() user: User,
@Args() { recurringInterval, successUrlPath }: CheckoutSessionInput,
@Args()
{
recurringInterval,
successUrlPath,
requirePaymentMethod,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: This parameter should be validated in the CheckoutSessionInput DTO to ensure it's a boolean and has appropriate default value. Currently there's no validation.

}: CheckoutSessionInput,
) {
const productPrice = await this.stripeService.getStripePrice(
AvailableProduct.BasePlan,
Expand All @@ -74,6 +79,7 @@ export class BillingResolver {
workspace,
productPrice.stripePriceId,
successUrlPath,
requirePaymentMethod,
),
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ArgsType, Field } from '@nestjs/graphql';

import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import Stripe from 'stripe';

import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
Expand All @@ -16,4 +16,9 @@ export class CheckoutSessionInput {
@IsString()
@IsOptional()
successUrlPath?: string;

@Field(() => Boolean, { nullable: true })
@IsBoolean()
@IsOptional()
requirePaymentMethod?: boolean;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: consider adding a default value of true for requirePaymentMethod to maintain existing behavior by default

}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export class BillingPortalWorkspaceService {
workspace: Workspace,
priceId: string,
successUrlPath?: string,
requirePaymentMethod?: boolean,
): Promise<string> {
const frontBaseUrl = this.domainManagerService.getBaseUrl();
const cancelUrl = frontBaseUrl.toString();
Expand All @@ -56,6 +57,7 @@ export class BillingPortalWorkspaceService {
successUrl,
cancelUrl,
stripeCustomerId,
requirePaymentMethod,
);

assert(session.url, 'Error: missing checkout.session.url');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export class StripeService {
successUrl?: string,
cancelUrl?: string,
stripeCustomerId?: string,
requirePaymentMethod?: boolean,
): Promise<Stripe.Checkout.Session> {
return await this.stripe.checkout.sessions.create({
line_items: [
Expand All @@ -106,13 +107,16 @@ export class StripeService {
'BILLING_FREE_TRIAL_DURATION_IN_DAYS',
),
},
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
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.
customer: stripeCustomerId,
customer_update: stripeCustomerId ? { name: 'auto' } : undefined,
customer_email: stripeCustomerId ? undefined : user.email,
success_url: successUrl,
cancel_url: cancelUrl,
payment_method_collection: requirePaymentMethod
? 'always'
: 'if_required',
});
}

Expand Down
Loading