mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-23 03:51:36 +03:00
Add freePass for cloud
This commit is contained in:
parent
7375ab8d71
commit
85a08a4f4b
@ -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)
|
||||
|
@ -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'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
|
@ -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 (
|
||||
|
@ -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 />}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const freePassState = atom<boolean>({
|
||||
key: 'freePassState',
|
||||
default: false,
|
||||
});
|
@ -12,6 +12,7 @@ export enum AppPath {
|
||||
InviteTeam = '/invite-team',
|
||||
PlanRequired = '/plan-required',
|
||||
PlanRequiredSuccess = '/plan-required/payment-success',
|
||||
FreePassCheckout = '/free-pass',
|
||||
|
||||
// Onboarded
|
||||
Index = '/',
|
||||
|
@ -127,6 +127,7 @@ export const ChooseYourPlan = () => {
|
||||
variables: {
|
||||
recurringInterval: planSelected,
|
||||
successUrlPath: AppPath.PlanRequiredSuccess,
|
||||
requirePaymentMethod: true,
|
||||
},
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
|
@ -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;
|
||||
};
|
5
packages/twenty-front/src/types/AppPath.ts
Normal file
5
packages/twenty-front/src/types/AppPath.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export enum AppPath {
|
||||
// ... existing paths ...
|
||||
FreePassCheckout = '/free-pass-checkout',
|
||||
// ... rest of the paths ...
|
||||
}
|
@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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');
|
||||
|
@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user