Add freePass for cloud

This commit is contained in:
Félix Malfait 2024-12-19 14:52:28 +01:00
parent 7375ab8d71
commit 85a08a4f4b
14 changed files with 116 additions and 12 deletions

View File

@ -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: tim@apple.dev
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)
See also:
🚀 [Self-hosting](https://twenty.com/developers/section/self-hosting)

View File

@ -537,6 +537,7 @@ export type MutationChallengeArgs = {
export type MutationCheckoutSessionArgs = {
recurringInterval: SubscriptionInterval;
requirePaymentMethod?: InputMaybe<Scalars['Boolean']>;
successUrlPath?: InputMaybe<Scalars['String']>;
};
@ -1952,6 +1953,7 @@ export type BillingPortalSessionQuery = { __typename?: 'Query', billingPortalSes
export type CheckoutSessionMutationVariables = Exact<{
recurringInterval: SubscriptionInterval;
successUrlPath?: InputMaybe<Scalars['String']>;
requirePaymentMethod?: InputMaybe<Scalars['Boolean']>;
}>;
@ -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
}
@ -3268,6 +3271,7 @@ export type CheckoutSessionMutationFn = Apollo.MutationFunction<CheckoutSessionM
* variables: {
* recurringInterval: // value for 'recurringInterval'
* successUrlPath: // value for 'successUrlPath'
* requirePaymentMethod: // value for 'requirePaymentMethod'
* },
* });
*/

View File

@ -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';
@ -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');
console.log('hasFreePassParameter', hasFreePassParameter);
if (hasFreePassParameter) {
console.log('setting free pass to true');
setFreePass(true);
}
const isMatchingOpenRoute =
isMatchingLocation(AppPath.Invite) ||
isMatchingLocation(AppPath.ResetPassword);
@ -44,7 +65,7 @@ export const usePageChangeEffectNavigateLocation = () => {
onboardingStatus === OnboardingStatus.PlanRequired &&
!isMatchingLocation(AppPath.PlanRequired)
) {
return AppPath.PlanRequired;
return freePass ? AppPath.FreePassCheckout : AppPath.PlanRequired;
}
if (

View File

@ -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';
@ -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 />}
/>
<Route
path={AppPath.PlanRequiredSuccess}
element={<PaymentSuccess />}

View File

@ -4,10 +4,12 @@ export const CHECKOUT_SESSION = gql`
mutation CheckoutSession(
$recurringInterval: SubscriptionInterval!
$successUrlPath: String
$requirePaymentMethod: Boolean
) {
checkoutSession(
recurringInterval: $recurringInterval
successUrlPath: $successUrlPath
requirePaymentMethod: $requirePaymentMethod
) {
url
}

View File

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

View File

@ -12,6 +12,7 @@ export enum AppPath {
InviteTeam = '/invite-team',
PlanRequired = '/plan-required',
PlanRequiredSuccess = '/plan-required/payment-success',
FreePassCheckout = '/free-pass',
// Onboarded
Index = '/',

View File

@ -127,6 +127,7 @@ export const ChooseYourPlan = () => {
variables: {
recurringInterval: planSelected,
successUrlPath: AppPath.PlanRequiredSuccess,
requirePaymentMethod: true,
},
});
setIsSubmitting(false);

View File

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

View File

@ -0,0 +1,5 @@
export enum AppPath {
// ... existing paths ...
FreePassCheckout = '/free-pass-checkout',
// ... rest of the paths ...
}

View File

@ -55,7 +55,12 @@ export class BillingResolver {
async checkoutSession(
@AuthWorkspace() workspace: Workspace,
@AuthUser() user: User,
@Args() { recurringInterval, successUrlPath }: CheckoutSessionInput,
@Args()
{
recurringInterval,
successUrlPath,
requirePaymentMethod,
}: CheckoutSessionInput,
) {
const productPrice = await this.stripeService.getStripePrice(
AvailableProduct.BasePlan,
@ -74,6 +79,7 @@ export class BillingResolver {
workspace,
productPrice.stripePriceId,
successUrlPath,
requirePaymentMethod,
),
};
}

View File

@ -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';
@ -16,4 +16,9 @@ export class CheckoutSessionInput {
@IsString()
@IsOptional()
successUrlPath?: string;
@Field(() => Boolean, { nullable: true })
@IsBoolean()
@IsOptional()
requirePaymentMethod?: boolean;
}

View File

@ -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();
@ -56,6 +57,7 @@ export class BillingPortalWorkspaceService {
successUrl,
cancelUrl,
stripeCustomerId,
requirePaymentMethod,
);
assert(session.url, 'Error: missing checkout.session.url');

View File

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