From 9ca3dbeb70ac84f087cca86f9ab4b380e3d08bd6 Mon Sep 17 00:00:00 2001 From: martmull Date: Wed, 28 Feb 2024 19:51:04 +0100 Subject: [PATCH] 39 create subscription and success modale (#4208) * Init add choose your plan page component * Update price format * Add billing refund trial duration env variable * Add billing benefits * Add Button * Call checkout endpoint * Fix theme color * Add Payment success modale * Add loader to createWorkspace submit button * Fix lint * Fix dark mode * Code review returns * Use a resolver for front requests * Fix 'create workspace' loader at sign up * Fix 'create workspace' with enter key bug --- packages/twenty-front/src/App.tsx | 14 +- .../effect-components/PageChangeEffect.tsx | 3 +- .../twenty-front/src/generated/graphql.tsx | 125 +++++++++++++++- .../src/modules/auth/components/Modal.tsx | 2 +- .../src/modules/auth/components/Title.tsx | 16 ++- .../auth/sign-in-up/components/FooterNote.tsx | 1 + .../sign-in-up/components/SignInUpForm.tsx | 16 +-- .../components/SubscriptionBenefit.tsx | 36 +++++ .../billing/components/SubscriptionCard.tsx | 41 ++++++ .../components/SubscriptionCardPrice.tsx | 33 +++++ .../src/modules/billing/graphql/checkout.ts | 12 ++ .../billing/graphql/getProductPrices.ts | 14 ++ .../graphql/queries/getClientConfig.ts | 1 + .../twenty-front/src/modules/types/AppPath.ts | 1 + .../ui/input/button/components/MainButton.tsx | 11 +- .../__stories__/MainButton.stories.tsx | 4 + .../ui/input/components/CardPicker.tsx | 44 ++++++ .../modules/ui/layout/page/DefaultLayout.tsx | 1 - .../modules/workspace/types/FeatureFlagKey.ts | 3 +- .../src/pages/auth/ChooseYourPlan.tsx | 133 ++++++++++++++++++ .../src/pages/auth/CreateProfile.tsx | 2 +- .../src/pages/auth/CreateWorkspace.tsx | 19 +-- .../src/pages/auth/PaymentSuccess.tsx | 53 +++++++ .../src/pages/auth/PlanRequired.tsx | 10 +- .../src/testing/mock-data/config.ts | 1 + .../src/core/billing/billing.controller.ts | 104 +------------- .../src/core/billing/billing.module.ts | 4 +- .../src/core/billing/billing.resolver.ts | 76 ++++++++++ .../src/core/billing/billing.service.ts | 61 ++++++-- .../src/core/billing/dto/checkout.entity.ts | 7 + .../src/core/billing/dto/checkout.input.ts | 17 +++ .../core/billing/dto/product-price.entity.ts | 18 +++ .../core/billing/dto/product-prices.entity.ts | 12 ++ .../src/core/billing/dto/product.input.ts | 13 ++ .../client-config/client-config.entity.ts | 3 + .../client-config/client-config.resolver.ts | 2 + .../environment/environment.service.ts | 6 + .../environment/environment.validation.ts | 6 + 38 files changed, 761 insertions(+), 164 deletions(-) create mode 100644 packages/twenty-front/src/modules/billing/components/SubscriptionBenefit.tsx create mode 100644 packages/twenty-front/src/modules/billing/components/SubscriptionCard.tsx create mode 100644 packages/twenty-front/src/modules/billing/components/SubscriptionCardPrice.tsx create mode 100644 packages/twenty-front/src/modules/billing/graphql/checkout.ts create mode 100644 packages/twenty-front/src/modules/billing/graphql/getProductPrices.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/CardPicker.tsx create mode 100644 packages/twenty-front/src/pages/auth/ChooseYourPlan.tsx create mode 100644 packages/twenty-front/src/pages/auth/PaymentSuccess.tsx create mode 100644 packages/twenty-server/src/core/billing/billing.resolver.ts create mode 100644 packages/twenty-server/src/core/billing/dto/checkout.entity.ts create mode 100644 packages/twenty-server/src/core/billing/dto/checkout.input.ts create mode 100644 packages/twenty-server/src/core/billing/dto/product-price.entity.ts create mode 100644 packages/twenty-server/src/core/billing/dto/product-prices.entity.ts create mode 100644 packages/twenty-server/src/core/billing/dto/product.input.ts diff --git a/packages/twenty-front/src/App.tsx b/packages/twenty-front/src/App.tsx index c892a64025..1725a277c8 100644 --- a/packages/twenty-front/src/App.tsx +++ b/packages/twenty-front/src/App.tsx @@ -7,9 +7,11 @@ import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { DefaultPageTitle } from '~/DefaultPageTitle'; import { CommandMenuEffect } from '~/effect-components/CommandMenuEffect'; import { GotoHotkeysEffect } from '~/effect-components/GotoHotkeysEffect'; +import { ChooseYourPlan } from '~/pages/auth/ChooseYourPlan.tsx'; import { CreateProfile } from '~/pages/auth/CreateProfile'; import { CreateWorkspace } from '~/pages/auth/CreateWorkspace'; import { PasswordReset } from '~/pages/auth/PasswordReset'; +import { PaymentSuccess } from '~/pages/auth/PaymentSuccess.tsx'; import { PlanRequired } from '~/pages/auth/PlanRequired'; import { SignInUp } from '~/pages/auth/SignInUp'; import { VerifyEffect } from '~/pages/auth/VerifyEffect'; @@ -48,6 +50,7 @@ export const App = () => { const isNewRecordBoardEnabled = useIsFeatureEnabled( 'IS_NEW_RECORD_BOARD_ENABLED', ); + const isSelfBillingEnabled = useIsFeatureEnabled('IS_SELF_BILLING_ENABLED'); return ( <> @@ -63,7 +66,16 @@ export const App = () => { } /> } /> } /> - } /> + : + } + /> + } + /> } /> } /> } /> diff --git a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx index 0db11f40ff..b86a15c6d4 100644 --- a/packages/twenty-front/src/effect-components/PageChangeEffect.tsx +++ b/packages/twenty-front/src/effect-components/PageChangeEffect.tsx @@ -89,7 +89,8 @@ export const PageChangeEffect = () => { navigate(AppPath.PlanRequired); } else if ( onboardingStatus === OnboardingStatus.OngoingWorkspaceActivation && - !isMatchingLocation(AppPath.CreateWorkspace) + !isMatchingLocation(AppPath.CreateWorkspace) && + !isMatchingLocation(AppPath.PlanRequiredSuccess) ) { navigate(AppPath.CreateWorkspace); } else if ( diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 1973079ae0..2e092eaa2b 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -60,6 +60,7 @@ export type AuthTokens = { export type Billing = { __typename?: 'Billing'; + billingFreeTrialDurationInDays?: Maybe; billingUrl: Scalars['String']; isBillingEnabled: Scalars['Boolean']; }; @@ -69,6 +70,11 @@ export type BooleanFieldComparison = { isNot?: InputMaybe; }; +export type CheckoutEntity = { + __typename?: 'CheckoutEntity'; + url: Scalars['String']; +}; + export type ClientConfig = { __typename?: 'ClientConfig'; authProviders: AuthProviders; @@ -220,6 +226,7 @@ export type Mutation = { __typename?: 'Mutation'; activateWorkspace: Workspace; challenge: LoginToken; + checkout: CheckoutEntity; createEvent: Analytics; createOneObject: Object; createOneRefreshToken: RefreshToken; @@ -254,6 +261,12 @@ export type MutationChallengeArgs = { }; +export type MutationCheckoutArgs = { + recurringInterval: Scalars['String']; + successUrlPath?: InputMaybe; +}; + + export type MutationCreateEventArgs = { data: Scalars['JSON']; type: Scalars['String']; @@ -362,6 +375,20 @@ export type PageInfo = { startCursor?: Maybe; }; +export type ProductPriceEntity = { + __typename?: 'ProductPriceEntity'; + created: Scalars['Float']; + recurringInterval: Scalars['String']; + stripePriceId: Scalars['String']; + unitAmount: Scalars['Float']; +}; + +export type ProductPricesEntity = { + __typename?: 'ProductPricesEntity'; + productPrices: Array; + totalNumberOfPrices: Scalars['Int']; +}; + export type Query = { __typename?: 'Query'; checkUserExists: UserExists; @@ -370,6 +397,7 @@ export type Query = { currentUser: User; currentWorkspace: Workspace; findWorkspaceFromInviteHash: Workspace; + getProductPrices: ProductPricesEntity; getTimelineThreadsFromCompanyId: TimelineThreadsWithTotal; getTimelineThreadsFromPersonId: TimelineThreadsWithTotal; object: Object; @@ -393,6 +421,11 @@ export type QueryFindWorkspaceFromInviteHashArgs = { }; +export type QueryGetProductPricesArgs = { + product: Scalars['String']; +}; + + export type QueryGetTimelineThreadsFromCompanyIdArgs = { companyId: Scalars['ID']; page: Scalars['Int']; @@ -830,10 +863,25 @@ export type ValidatePasswordResetTokenQueryVariables = Exact<{ export type ValidatePasswordResetTokenQuery = { __typename?: 'Query', validatePasswordResetToken: { __typename?: 'ValidatePasswordResetToken', id: string, email: string } }; +export type CheckoutMutationVariables = Exact<{ + recurringInterval: Scalars['String']; + successUrlPath?: InputMaybe; +}>; + + +export type CheckoutMutation = { __typename?: 'Mutation', checkout: { __typename?: 'CheckoutEntity', url: string } }; + +export type GetProductPricesQueryVariables = Exact<{ + product: Scalars['String']; +}>; + + +export type GetProductPricesQuery = { __typename?: 'Query', getProductPrices: { __typename?: 'ProductPricesEntity', productPrices: Array<{ __typename?: 'ProductPriceEntity', created: number, recurringInterval: string, stripePriceId: string, unitAmount: number }> } }; + export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>; -export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl: string }, telemetry: { __typename?: 'Telemetry', enabled: boolean, anonymizationEnabled: boolean }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null } } }; +export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl: string, billingFreeTrialDurationInDays?: number | null }, telemetry: { __typename?: 'Telemetry', enabled: boolean, anonymizationEnabled: boolean }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null } } }; export type UploadFileMutationVariables = Exact<{ file: Scalars['Upload']; @@ -1514,6 +1562,80 @@ export function useValidatePasswordResetTokenLazyQuery(baseOptions?: Apollo.Lazy export type ValidatePasswordResetTokenQueryHookResult = ReturnType; export type ValidatePasswordResetTokenLazyQueryHookResult = ReturnType; export type ValidatePasswordResetTokenQueryResult = Apollo.QueryResult; +export const CheckoutDocument = gql` + mutation Checkout($recurringInterval: String!, $successUrlPath: String) { + checkout(recurringInterval: $recurringInterval, successUrlPath: $successUrlPath) { + url + } +} + `; +export type CheckoutMutationFn = Apollo.MutationFunction; + +/** + * __useCheckoutMutation__ + * + * To run a mutation, you first call `useCheckoutMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCheckoutMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [checkoutMutation, { data, loading, error }] = useCheckoutMutation({ + * variables: { + * recurringInterval: // value for 'recurringInterval' + * successUrlPath: // value for 'successUrlPath' + * }, + * }); + */ +export function useCheckoutMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CheckoutDocument, options); + } +export type CheckoutMutationHookResult = ReturnType; +export type CheckoutMutationResult = Apollo.MutationResult; +export type CheckoutMutationOptions = Apollo.BaseMutationOptions; +export const GetProductPricesDocument = gql` + query GetProductPrices($product: String!) { + getProductPrices(product: $product) { + productPrices { + created + recurringInterval + stripePriceId + unitAmount + } + } +} + `; + +/** + * __useGetProductPricesQuery__ + * + * To run a query within a React component, call `useGetProductPricesQuery` and pass it any options that fit your needs. + * When your component renders, `useGetProductPricesQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetProductPricesQuery({ + * variables: { + * product: // value for 'product' + * }, + * }); + */ +export function useGetProductPricesQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetProductPricesDocument, options); + } +export function useGetProductPricesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetProductPricesDocument, options); + } +export type GetProductPricesQueryHookResult = ReturnType; +export type GetProductPricesLazyQueryHookResult = ReturnType; +export type GetProductPricesQueryResult = Apollo.QueryResult; export const GetClientConfigDocument = gql` query GetClientConfig { clientConfig { @@ -1524,6 +1646,7 @@ export const GetClientConfigDocument = gql` billing { isBillingEnabled billingUrl + billingFreeTrialDurationInDays } signInPrefilled signUpDisabled diff --git a/packages/twenty-front/src/modules/auth/components/Modal.tsx b/packages/twenty-front/src/modules/auth/components/Modal.tsx index db7d03d86a..3a94292702 100644 --- a/packages/twenty-front/src/modules/auth/components/Modal.tsx +++ b/packages/twenty-front/src/modules/auth/components/Modal.tsx @@ -11,7 +11,7 @@ const StyledContent = styled(UIModal.Content)` type AuthModalProps = { children: React.ReactNode }; export const AuthModal = ({ children }: AuthModalProps) => ( - + {children} ); diff --git a/packages/twenty-front/src/modules/auth/components/Title.tsx b/packages/twenty-front/src/modules/auth/components/Title.tsx index 9ef55d43d3..4fe541db96 100644 --- a/packages/twenty-front/src/modules/auth/components/Title.tsx +++ b/packages/twenty-front/src/modules/auth/components/Title.tsx @@ -5,24 +5,30 @@ import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEase type TitleProps = React.PropsWithChildren & { animate?: boolean; + withMarginTop?: boolean; }; -const StyledTitle = styled.div` +const StyledTitle = styled.div>` color: ${({ theme }) => theme.font.color.primary}; font-size: ${({ theme }) => theme.font.size.xl}; font-weight: ${({ theme }) => theme.font.weight.semiBold}; margin-bottom: ${({ theme }) => theme.spacing(4)}; - margin-top: ${({ theme }) => theme.spacing(4)}; + margin-top: ${({ theme, withMarginTop }) => + withMarginTop ? theme.spacing(4) : 0}; `; -export const Title = ({ children, animate = false }: TitleProps) => { +export const Title = ({ + children, + animate = false, + withMarginTop = true, +}: TitleProps) => { if (animate) { return ( - + {children} ); } - return {children}; + return {children}; }; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/FooterNote.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/FooterNote.tsx index 8ef2ee7245..c9d5066d95 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/FooterNote.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/FooterNote.tsx @@ -8,6 +8,7 @@ const StyledContainer = styled.div` color: ${({ theme }) => theme.font.color.tertiary}; display: flex; font-size: ${({ theme }) => theme.font.size.sm}; + max-width: 280px; text-align: center; `; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx index a3307724a3..bc1997f2d4 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx @@ -30,10 +30,6 @@ const StyledContentContainer = styled.div` width: 200px; `; -const StyledFooterNote = styled(FooterNote)` - max-width: 280px; -`; - const StyledForm = styled.form` align-items: center; display: flex; @@ -89,12 +85,8 @@ export const SignInUpForm = () => { return 'Continue'; } - return signInUpMode === SignInUpMode.SignIn - ? 'Sign in' - : form.formState.isSubmitting - ? 'Creating workspace' - : 'Sign up'; - }, [signInUpMode, signInUpStep, form.formState.isSubmitting]); + return signInUpMode === SignInUpMode.SignIn ? 'Sign in' : 'Sign up'; + }, [signInUpMode, signInUpStep]); const title = useMemo(() => { if (signInUpMode === SignInUpMode.Invite) { @@ -242,10 +234,10 @@ export const SignInUpForm = () => { Forgot your password? ) : ( - + By using Twenty, you agree to the Terms of Service and Data Processing Agreement. - + )} ); diff --git a/packages/twenty-front/src/modules/billing/components/SubscriptionBenefit.tsx b/packages/twenty-front/src/modules/billing/components/SubscriptionBenefit.tsx new file mode 100644 index 0000000000..490d8f3047 --- /dev/null +++ b/packages/twenty-front/src/modules/billing/components/SubscriptionBenefit.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; + +import { IconCheck } from '@/ui/display/icon'; + +const StyledBenefitContainer = styled.div` + color: ${({ theme }) => theme.font.color.secondary}; + display: flex; + flex-direction: row; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledCheckContainer = styled.div` + align-items: center; + background-color: ${({ theme }) => theme.background.tertiary}; + border-radius: 50%; + display: flex; + height: 16px; + justify-content: center; + width: 16px; +`; +type SubscriptionBenefitProps = { + children: React.ReactNode; +}; +export const SubscriptionBenefit = ({ children }: SubscriptionBenefitProps) => { + const theme = useTheme(); + return ( + + + + + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/billing/components/SubscriptionCard.tsx b/packages/twenty-front/src/modules/billing/components/SubscriptionCard.tsx new file mode 100644 index 0000000000..fd323391c0 --- /dev/null +++ b/packages/twenty-front/src/modules/billing/components/SubscriptionCard.tsx @@ -0,0 +1,41 @@ +import styled from '@emotion/styled'; + +import { SubscriptionCardPrice } from '@/billing/components/SubscriptionCardPrice.tsx'; +import { capitalize } from '~/utils/string/capitalize.ts'; + +type SubscriptionCardProps = { + type?: string; + price: number; + info: string; +}; + +const StyledSubscriptionCardContainer = styled.div` + display: flex; + flex-direction: column; +`; + +const StyledTypeContainer = styled.div` + color: ${({ theme }) => theme.font.color.secondary}; + font-size: ${({ theme }) => theme.font.size.sm}; + display: flex; +`; + +const StyledInfoContainer = styled.div` + color: ${({ theme }) => theme.font.color.tertiary}; + font-size: ${({ theme }) => theme.font.size.sm}; + display: flex; +`; + +export const SubscriptionCard = ({ + type, + price, + info, +}: SubscriptionCardProps) => { + return ( + + {capitalize(type || '')} + + {info} + + ); +}; diff --git a/packages/twenty-front/src/modules/billing/components/SubscriptionCardPrice.tsx b/packages/twenty-front/src/modules/billing/components/SubscriptionCardPrice.tsx new file mode 100644 index 0000000000..8091c72ab3 --- /dev/null +++ b/packages/twenty-front/src/modules/billing/components/SubscriptionCardPrice.tsx @@ -0,0 +1,33 @@ +import styled from '@emotion/styled'; + +type SubscriptionCardPriceProps = { + price: number; +}; +const StyledSubscriptionCardPriceContainer = styled.div` + align-items: baseline; + display: flex; + gap: ${({ theme }) => theme.betweenSiblingsGap}; + margin: ${({ theme }) => theme.spacing(1)} 0 + ${({ theme }) => theme.spacing(2)}; +`; +const StyledPriceSpan = styled.span` + color: ${({ theme }) => theme.font.color.primary}; + font-size: ${({ theme }) => theme.font.size.xl}; + font-weight: ${({ theme }) => theme.font.weight.semiBold}; +`; +const StyledSeatSpan = styled.span` + color: ${({ theme }) => theme.font.color.light}; + font-size: ${({ theme }) => theme.font.size.md}; + font-weight: ${({ theme }) => theme.font.weight.medium}; +`; +export const SubscriptionCardPrice = ({ + price, +}: SubscriptionCardPriceProps) => { + return ( + + ${price} + / + seat + + ); +}; diff --git a/packages/twenty-front/src/modules/billing/graphql/checkout.ts b/packages/twenty-front/src/modules/billing/graphql/checkout.ts new file mode 100644 index 0000000000..e2742d1074 --- /dev/null +++ b/packages/twenty-front/src/modules/billing/graphql/checkout.ts @@ -0,0 +1,12 @@ +import { gql } from '@apollo/client'; + +export const CHECKOUT = gql` + mutation Checkout($recurringInterval: String!, $successUrlPath: String) { + checkout( + recurringInterval: $recurringInterval + successUrlPath: $successUrlPath + ) { + url + } + } +`; diff --git a/packages/twenty-front/src/modules/billing/graphql/getProductPrices.ts b/packages/twenty-front/src/modules/billing/graphql/getProductPrices.ts new file mode 100644 index 0000000000..353e75512d --- /dev/null +++ b/packages/twenty-front/src/modules/billing/graphql/getProductPrices.ts @@ -0,0 +1,14 @@ +import { gql } from '@apollo/client'; + +export const GET_PRODUCT_PRICES = gql` + query GetProductPrices($product: String!) { + getProductPrices(product: $product) { + productPrices { + created + recurringInterval + stripePriceId + unitAmount + } + } + } +`; diff --git a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts index 3f7eb573c8..aca84198a5 100644 --- a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts @@ -10,6 +10,7 @@ export const GET_CLIENT_CONFIG = gql` billing { isBillingEnabled billingUrl + billingFreeTrialDurationInDays } signInPrefilled signUpDisabled diff --git a/packages/twenty-front/src/modules/types/AppPath.ts b/packages/twenty-front/src/modules/types/AppPath.ts index e35563a1ef..e450d0fe62 100644 --- a/packages/twenty-front/src/modules/types/AppPath.ts +++ b/packages/twenty-front/src/modules/types/AppPath.ts @@ -10,6 +10,7 @@ export enum AppPath { CreateWorkspace = '/create/workspace', CreateProfile = '/create/profile', PlanRequired = '/plan-required', + PlanRequiredSuccess = '/plan-required/payment-success', // Onboarded Index = '/', diff --git a/packages/twenty-front/src/modules/ui/input/button/components/MainButton.tsx b/packages/twenty-front/src/modules/ui/input/button/components/MainButton.tsx index 4d75be9ae8..c872cef410 100644 --- a/packages/twenty-front/src/modules/ui/input/button/components/MainButton.tsx +++ b/packages/twenty-front/src/modules/ui/input/button/components/MainButton.tsx @@ -9,11 +9,14 @@ type Variant = 'primary' | 'secondary'; type Props = { title: string; fullWidth?: boolean; + width?: number; variant?: Variant; soon?: boolean; } & React.ComponentProps<'button'>; -const StyledButton = styled.button>` +const StyledButton = styled.button< + Pick +>` align-items: center; background: ${({ theme, variant, disabled }) => { if (disabled) { @@ -75,7 +78,8 @@ const StyledButton = styled.button>` justify-content: center; outline: none; padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(3)}; - width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')}; + width: ${({ fullWidth, width }) => + fullWidth ? '100%' : width ? `${width}px` : 'auto'}; ${({ theme, variant }) => { switch (variant) { case 'secondary': @@ -101,6 +105,7 @@ type MainButtonProps = Props & { export const MainButton = ({ Icon, title, + width, fullWidth = false, variant = 'primary', type, @@ -112,7 +117,7 @@ export const MainButton = ({ return ( {Icon && } {title} diff --git a/packages/twenty-front/src/modules/ui/input/button/components/__stories__/MainButton.stories.tsx b/packages/twenty-front/src/modules/ui/input/button/components/__stories__/MainButton.stories.tsx index 2ca12fa026..e5d8cb2bad 100644 --- a/packages/twenty-front/src/modules/ui/input/button/components/__stories__/MainButton.stories.tsx +++ b/packages/twenty-front/src/modules/ui/input/button/components/__stories__/MainButton.stories.tsx @@ -42,6 +42,10 @@ export const FullWidth: Story = { args: { fullWidth: true }, }; +export const Width: Story = { + args: { width: 200 }, +}; + export const Secondary: Story = { args: { title: 'A secondary Button', variant: 'secondary' }, }; diff --git a/packages/twenty-front/src/modules/ui/input/components/CardPicker.tsx b/packages/twenty-front/src/modules/ui/input/components/CardPicker.tsx new file mode 100644 index 0000000000..b0d42ad010 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/CardPicker.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import styled from '@emotion/styled'; + +import { Radio } from '@/ui/input/components/Radio.tsx'; +const StyledSubscriptionCardContainer = styled.button` + background-color: ${({ theme }) => theme.background.secondary}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-radius: ${({ theme }) => theme.border.radius.md}; + display: flex; + padding: ${({ theme }) => theme.spacing(4)} ${({ theme }) => theme.spacing(3)}; + position: relative; + width: 100%; + :hover { + cursor: pointer; + background: ${({ theme }) => theme.background.tertiary}; + } +`; + +const StyledRadioContainer = styled.div` + position: absolute; + right: ${({ theme }) => theme.spacing(2)}; + top: ${({ theme }) => theme.spacing(2)}; +`; + +type CardPickerProps = { + children: React.ReactNode; + handleChange?: () => void; + checked?: boolean; +}; + +export const CardPicker = ({ + children, + checked, + handleChange, +}: CardPickerProps) => { + return ( + + + + + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx b/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx index d3cb26c65c..3c872dd171 100644 --- a/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx +++ b/packages/twenty-front/src/modules/ui/layout/page/DefaultLayout.tsx @@ -74,7 +74,6 @@ export const DefaultLayout = ({ children }: DefaultLayoutProps) => { const theme = useTheme(); const widowsWidth = useScreenSize().width; const isMatchingLocation = useIsMatchingLocation(); - const showAuthModal = useMemo(() => { return ( (onboardingStatus && onboardingStatus !== OnboardingStatus.Completed) || diff --git a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts index 1a85d16de4..31e49745b2 100644 --- a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts +++ b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts @@ -3,4 +3,5 @@ export type FeatureFlagKey = | 'IS_CALENDAR_ENABLED' | 'IS_MESSAGING_ENABLED' | 'IS_NEW_RECORD_BOARD_ENABLED' - | 'IS_QUICK_ACTIONS_ENABLED'; + | 'IS_QUICK_ACTIONS_ENABLED' + | 'IS_SELF_BILLING_ENABLED'; diff --git a/packages/twenty-front/src/pages/auth/ChooseYourPlan.tsx b/packages/twenty-front/src/pages/auth/ChooseYourPlan.tsx new file mode 100644 index 0000000000..321e305aa7 --- /dev/null +++ b/packages/twenty-front/src/pages/auth/ChooseYourPlan.tsx @@ -0,0 +1,133 @@ +import React, { useState } from 'react'; +import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; + +import { SubTitle } from '@/auth/components/SubTitle.tsx'; +import { Title } from '@/auth/components/Title.tsx'; +import { SubscriptionBenefit } from '@/billing/components/SubscriptionBenefit.tsx'; +import { SubscriptionCard } from '@/billing/components/SubscriptionCard.tsx'; +import { billingState } from '@/client-config/states/billingState.ts'; +import { AppPath } from '@/types/AppPath.ts'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar.tsx'; +import { MainButton } from '@/ui/input/button/components/MainButton.tsx'; +import { CardPicker } from '@/ui/input/components/CardPicker.tsx'; +import { + ProductPriceEntity, + useCheckoutMutation, + useGetProductPricesQuery, +} from '~/generated/graphql.tsx'; + +const StyledChoosePlanContainer = styled.div` + display: flex; + flex-direction: row; + width: 100%; + margin: ${({ theme }) => theme.spacing(8)} 0 + ${({ theme }) => theme.spacing(2)}; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledBenefitsContainer = styled.div` + background-color: ${({ theme }) => theme.background.secondary}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-radius: ${({ theme }) => theme.border.radius.md}; + box-sizing: border-box; + display: flex; + flex-direction: column; + width: 100%; + gap: 16px; + padding: ${({ theme }) => theme.spacing(4)} ${({ theme }) => theme.spacing(3)}; + margin-bottom: ${({ theme }) => theme.spacing(8)}; +`; + +export const ChooseYourPlan = () => { + const billing = useRecoilValue(billingState); + + const [planSelected, setPlanSelected] = useState('month'); + + const { enqueueSnackBar } = useSnackBar(); + + const { data: prices } = useGetProductPricesQuery({ + variables: { product: 'base-plan' }, + }); + + const [checkout] = useCheckoutMutation(); + + const handlePlanChange = (type?: string) => { + return () => { + if (type && planSelected !== type) { + setPlanSelected(type); + } + }; + }; + + const computeInfo = ( + price: ProductPriceEntity, + prices: ProductPriceEntity[], + ): string => { + if (price.recurringInterval !== 'year') { + return 'Cancel anytime'; + } + const monthPrice = prices.filter( + (price) => price.recurringInterval === 'month', + )?.[0]; + if (monthPrice && monthPrice.unitAmount && price.unitAmount) { + return `Save $${(12 * monthPrice.unitAmount - price.unitAmount) / 100}`; + } + return 'Cancel anytime'; + }; + + const handleButtonClick = async () => { + const { data } = await checkout({ + variables: { + recurringInterval: planSelected, + successUrlPath: AppPath.PlanRequiredSuccess, + }, + }); + if (!data?.checkout.url) { + enqueueSnackBar( + 'Checkout session error. Please retry or contact Twenty team', + { + variant: 'error', + }, + ); + return; + } + window.location.replace(data.checkout.url); + }; + + return ( + prices?.getProductPrices?.productPrices && ( + <> + Choose your Plan + + Enjoy a {billing?.billingFreeTrialDurationInDays}-day free trial + + + {prices.getProductPrices.productPrices.map((price, index) => ( + + + + ))} + + + Full access + Unlimited contacts + Email integration + Custom objects + API & Webhooks + Frequent updates + And much more + + + + ) + ); +}; diff --git a/packages/twenty-front/src/pages/auth/CreateProfile.tsx b/packages/twenty-front/src/pages/auth/CreateProfile.tsx index 423d9e530f..edbaa99bcf 100644 --- a/packages/twenty-front/src/pages/auth/CreateProfile.tsx +++ b/packages/twenty-front/src/pages/auth/CreateProfile.tsx @@ -141,7 +141,7 @@ export const CreateProfile = () => { return ( <> - Create profile + Create profile How you'll be identified on the app. diff --git a/packages/twenty-front/src/pages/auth/CreateWorkspace.tsx b/packages/twenty-front/src/pages/auth/CreateWorkspace.tsx index 8d82f3c56f..ff605c5e3d 100644 --- a/packages/twenty-front/src/pages/auth/CreateWorkspace.tsx +++ b/packages/twenty-front/src/pages/auth/CreateWorkspace.tsx @@ -3,6 +3,7 @@ import { Controller, SubmitHandler, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import styled from '@emotion/styled'; import { zodResolver } from '@hookform/resolvers/zod'; +import { Key } from 'ts-key-enum'; import { z } from 'zod'; import { SubTitle } from '@/auth/components/SubTitle'; @@ -13,12 +14,11 @@ import { FIND_MANY_OBJECT_METADATA_ITEMS } from '@/object-metadata/graphql/queri import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient'; import { WorkspaceLogoUploader } from '@/settings/workspace/components/WorkspaceLogoUploader'; import { AppPath } from '@/types/AppPath'; -import { PageHotkeyScope } from '@/types/PageHotkeyScope'; import { H2Title } from '@/ui/display/typography/components/H2Title'; +import { Loader } from '@/ui/feedback/loader/components/Loader.tsx'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { MainButton } from '@/ui/input/button/components/MainButton'; import { TextInput } from '@/ui/input/components/TextInput'; -import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser'; import { useActivateWorkspaceMutation } from '~/generated/graphql'; @@ -57,7 +57,6 @@ export const CreateWorkspace = () => { control, handleSubmit, formState: { isValid, isSubmitting }, - getValues, } = useForm
({ mode: 'onChange', defaultValues: { @@ -99,28 +98,19 @@ export const CreateWorkspace = () => { ); const handleKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter') { + if (event.key === Key.Enter) { event.preventDefault(); handleSubmit(onSubmit)(); } }; - useScopedHotkeys( - 'enter', - () => { - onSubmit(getValues()); - }, - PageHotkeyScope.CreateWokspace, - [onSubmit], - ); - if (onboardingStatus !== OnboardingStatus.OngoingWorkspaceActivation) { return null; } return ( <> - Create your workspace + Create your workspace A shared environment where you will be able to manage your customer relations with your team. @@ -162,6 +152,7 @@ export const CreateWorkspace = () => { title="Continue" onClick={handleSubmit(onSubmit)} disabled={!isValid || isSubmitting} + Icon={() => isSubmitting && } fullWidth /> diff --git a/packages/twenty-front/src/pages/auth/PaymentSuccess.tsx b/packages/twenty-front/src/pages/auth/PaymentSuccess.tsx new file mode 100644 index 0000000000..48c65566f3 --- /dev/null +++ b/packages/twenty-front/src/pages/auth/PaymentSuccess.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; + +import { SubTitle } from '@/auth/components/SubTitle.tsx'; +import { Title } from '@/auth/components/Title.tsx'; +import { AppPath } from '@/types/AppPath.ts'; +import { IconCheck } from '@/ui/display/icon'; +import { MainButton } from '@/ui/input/button/components/MainButton.tsx'; +import { RGBA } from '@/ui/theme/constants/Rgba.ts'; +import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn.tsx'; + +const StyledCheckContainer = styled.div` + align-items: center; + display: flex; + justify-content: center; + border: 2px solid ${(props) => props.color}; + border-radius: ${({ theme }) => theme.border.radius.rounded}; + box-shadow: ${(props) => + props.color && `-4px 4px 0 -2px ${RGBA(props.color, 1)}`}; + height: 36px; + width: 36px; + margin-bottom: ${({ theme }) => theme.spacing(4)}; +`; + +const StyledButtonContainer = styled.div` + margin-top: ${({ theme }) => theme.spacing(8)}; +`; + +export const PaymentSuccess = () => { + const navigate = useNavigate(); + const theme = useTheme(); + const handleButtonClick = () => { + navigate(AppPath.CreateWorkspace); + }; + const color = + theme.name === 'light' ? theme.grayScale.gray90 : theme.grayScale.gray10; + return ( + <> + + + + + + All set! + Your account has been activated. + + + + + ); +}; diff --git a/packages/twenty-front/src/pages/auth/PlanRequired.tsx b/packages/twenty-front/src/pages/auth/PlanRequired.tsx index 0b93c6222b..8c8a18fb76 100644 --- a/packages/twenty-front/src/pages/auth/PlanRequired.tsx +++ b/packages/twenty-front/src/pages/auth/PlanRequired.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import styled from '@emotion/styled'; import { useRecoilValue } from 'recoil'; @@ -6,13 +7,12 @@ import { SubTitle } from '@/auth/components/SubTitle'; import { Title } from '@/auth/components/Title'; import { billingState } from '@/client-config/states/billingState'; import { PageHotkeyScope } from '@/types/PageHotkeyScope'; -import { MainButton } from '@/ui/input/button/components/MainButton'; +import { MainButton } from '@/ui/input/button/components/MainButton.tsx'; import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; const StyledButtonContainer = styled.div` margin-top: ${({ theme }) => theme.spacing(8)}; - width: 200px; `; export const PlanRequired = () => { @@ -36,7 +36,11 @@ export const PlanRequired = () => { Please select a subscription plan before proceeding to sign in. - + ); diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts index 16504ac90e..7b1db13645 100644 --- a/packages/twenty-front/src/testing/mock-data/config.ts +++ b/packages/twenty-front/src/testing/mock-data/config.ts @@ -23,6 +23,7 @@ export const mockedClientConfig = { billing: { isBillingEnabled: true, billingUrl: '', + billingFreeTrialDurationInDays: 10, __typename: 'Billing', }, __typename: 'ClientConfig', diff --git a/packages/twenty-server/src/core/billing/billing.controller.ts b/packages/twenty-server/src/core/billing/billing.controller.ts index 5a1a8e016d..c986deee13 100644 --- a/packages/twenty-server/src/core/billing/billing.controller.ts +++ b/packages/twenty-server/src/core/billing/billing.controller.ts @@ -1,31 +1,17 @@ import { - Body, Controller, - Get, - Param, Headers, Req, RawBodyRequest, Logger, Post, Res, - UseGuards, } from '@nestjs/common'; import { Response } from 'express'; -import { - AvailableProduct, - BillingService, - PriceData, - RecurringInterval, - WebhookEvent, -} from 'src/core/billing/billing.service'; +import { BillingService, WebhookEvent } from 'src/core/billing/billing.service'; import { StripeService } from 'src/core/billing/stripe/stripe.service'; -import { EnvironmentService } from 'src/integrations/environment/environment.service'; -import { AuthUser } from 'src/decorators/auth/auth-user.decorator'; -import { User } from 'src/core/user/user.entity'; -import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; @Controller('billing') export class BillingController { @@ -34,96 +20,8 @@ export class BillingController { constructor( private readonly stripeService: StripeService, private readonly billingService: BillingService, - private readonly environmentService: EnvironmentService, ) {} - @Get('/product-prices/:product') - async get( - @Param() params: { product: AvailableProduct }, - @Res() res: Response, - ) { - const stripeProductId = this.billingService.getProductStripeId( - params.product, - ); - - if (!stripeProductId) { - res.status(404).send({ - error: `Product '${ - params.product - }' not found, available products are ['${Object.values( - AvailableProduct, - ).join("','")}']`, - }); - - return; - } - - res.json(await this.billingService.getProductPrices(stripeProductId)); - } - - @UseGuards(JwtAuthGuard) - @Post('/checkout') - async post( - @AuthUser() user: User, - @Body() body: { recurringInterval: RecurringInterval }, - @Res() res: Response, - ) { - const productId = this.billingService.getProductStripeId( - AvailableProduct.BasePlan, - ); - - if (!productId) { - res - .status(404) - .send( - 'BasePlan productId not found, please check your BILLING_STRIPE_BASE_PLAN_PRODUCT_ID env variable', - ); - - return; - } - - const productPrices = await this.billingService.getProductPrices(productId); - const recurringInterval = body.recurringInterval; - const priceId = productPrices[recurringInterval]?.id; - - if (!priceId) { - res - .status(404) - .send( - `BasePlan priceId not found, please check body.recurringInterval and product '${AvailableProduct.BasePlan}' prices`, - ); - - return; - } - const frontBaseUrl = this.environmentService.getFrontBaseUrl(); - const session = await this.stripeService.stripe.checkout.sessions.create({ - line_items: [ - { - price: priceId, - quantity: 1, - }, - ], - mode: 'subscription', - subscription_data: { - metadata: { - workspaceId: user.defaultWorkspace.id, - }, - }, - customer_email: user.email, - success_url: frontBaseUrl, - cancel_url: frontBaseUrl, - }); - - if (!session.url) { - res.status(400).send('Error: missing checkout.session.url'); - - return; - } - this.logger.log(`Stripe Checkout Session Url Redirection: ${session.url}`); - - res.redirect(303, session.url); - } - @Post('/webhooks') async handleWebhooks( @Headers('stripe-signature') signature: string, diff --git a/packages/twenty-server/src/core/billing/billing.module.ts b/packages/twenty-server/src/core/billing/billing.module.ts index 6eb489cbe1..550f9252c3 100644 --- a/packages/twenty-server/src/core/billing/billing.module.ts +++ b/packages/twenty-server/src/core/billing/billing.module.ts @@ -2,12 +2,12 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BillingController } from 'src/core/billing/billing.controller'; -import { EnvironmentModule } from 'src/integrations/environment/environment.module'; import { BillingService } from 'src/core/billing/billing.service'; import { StripeModule } from 'src/core/billing/stripe/stripe.module'; import { BillingSubscription } from 'src/core/billing/entities/billing-subscription.entity'; import { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subscription-item.entity'; import { Workspace } from 'src/core/workspace/workspace.entity'; +import { BillingResolver } from 'src/core/billing/billing.resolver'; @Module({ imports: [ @@ -18,6 +18,6 @@ import { Workspace } from 'src/core/workspace/workspace.entity'; ), ], controllers: [BillingController], - providers: [EnvironmentModule, BillingService], + providers: [BillingService, BillingResolver], }) export class BillingModule {} diff --git a/packages/twenty-server/src/core/billing/billing.resolver.ts b/packages/twenty-server/src/core/billing/billing.resolver.ts new file mode 100644 index 0000000000..7f8d0d90ec --- /dev/null +++ b/packages/twenty-server/src/core/billing/billing.resolver.ts @@ -0,0 +1,76 @@ +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { UseGuards } from '@nestjs/common'; + +import { + AvailableProduct, + BillingService, +} from 'src/core/billing/billing.service'; +import { ProductInput } from 'src/core/billing/dto/product.input'; +import { assert } from 'src/utils/assert'; +import { ProductPricesEntity } from 'src/core/billing/dto/product-prices.entity'; +import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; +import { AuthUser } from 'src/decorators/auth/auth-user.decorator'; +import { User } from 'src/core/user/user.entity'; +import { CheckoutInput } from 'src/core/billing/dto/checkout.input'; +import { CheckoutEntity } from 'src/core/billing/dto/checkout.entity'; + +@Resolver() +export class BillingResolver { + constructor(private readonly billingService: BillingService) {} + + @Query(() => ProductPricesEntity) + async getProductPrices(@Args() { product }: ProductInput) { + const stripeProductId = this.billingService.getProductStripeId(product); + + assert( + stripeProductId, + `Product '${product}' not found, available products are ['${Object.values( + AvailableProduct, + ).join("','")}']`, + ); + + const productPrices = + await this.billingService.getProductPrices(stripeProductId); + + return { + totalNumberOfPrices: productPrices.length, + productPrices: productPrices, + }; + } + + @Mutation(() => CheckoutEntity) + @UseGuards(JwtAuthGuard) + async checkout( + @AuthUser() user: User, + @Args() { recurringInterval, successUrlPath }: CheckoutInput, + ) { + const stripeProductId = this.billingService.getProductStripeId( + AvailableProduct.BasePlan, + ); + + assert( + stripeProductId, + 'BasePlan productId not found, please check your BILLING_STRIPE_BASE_PLAN_PRODUCT_ID env variable', + ); + + const productPrices = + await this.billingService.getProductPrices(stripeProductId); + + const stripePriceId = productPrices.filter( + (price) => price.recurringInterval === recurringInterval, + )?.[0]?.stripePriceId; + + assert( + stripePriceId, + `BasePlan priceId not found, please check body.recurringInterval and product '${AvailableProduct.BasePlan}' prices`, + ); + + return { + url: await this.billingService.checkout( + user, + stripePriceId, + successUrlPath, + ), + }; + } +} diff --git a/packages/twenty-server/src/core/billing/billing.service.ts b/packages/twenty-server/src/core/billing/billing.service.ts index e43335914c..46e643dad1 100644 --- a/packages/twenty-server/src/core/billing/billing.service.ts +++ b/packages/twenty-server/src/core/billing/billing.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import Stripe from 'stripe'; @@ -9,17 +9,13 @@ import { StripeService } from 'src/core/billing/stripe/stripe.service'; import { BillingSubscription } from 'src/core/billing/entities/billing-subscription.entity'; import { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subscription-item.entity'; import { Workspace } from 'src/core/workspace/workspace.entity'; +import { ProductPriceEntity } from 'src/core/billing/dto/product-price.entity'; +import { User } from 'src/core/user/user.entity'; +import { assert } from 'src/utils/assert'; -export type PriceData = Partial< - Record ->; export enum AvailableProduct { BasePlan = 'base-plan', } -export enum RecurringInterval { - MONTH = 'month', - YEAR = 'year', -} export enum WebhookEvent { CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated', @@ -27,6 +23,7 @@ export enum WebhookEvent { @Injectable() export class BillingService { + protected readonly logger = new Logger(BillingService.name); constructor( private readonly stripeService: StripeService, private readonly environmentService: EnvironmentService, @@ -53,23 +50,57 @@ export class BillingService { } formatProductPrices(prices: Stripe.Price[]) { - const result: PriceData = {}; + const result: Record = {}; prices.forEach((item) => { - const recurringInterval = item.recurring?.interval; + const interval = item.recurring?.interval; - if (!recurringInterval) { + if (!interval || !item.unit_amount) { return; } if ( - !result[recurringInterval] || - item.created > (result[recurringInterval]?.created || 0) + !result[interval] || + item.created > (result[interval]?.created || 0) ) { - result[recurringInterval] = item; + result[interval] = { + unitAmount: item.unit_amount, + recurringInterval: interval, + created: item.created, + stripePriceId: item.id, + }; } }); - return result; + return Object.values(result).sort((a, b) => a.unitAmount - b.unitAmount); + } + + async checkout(user: User, priceId: string, successUrlPath?: string) { + const frontBaseUrl = this.environmentService.getFrontBaseUrl(); + const session = await this.stripeService.stripe.checkout.sessions.create({ + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + mode: 'subscription', + subscription_data: { + metadata: { + workspaceId: user.defaultWorkspace.id, + }, + }, + customer_email: user.email, + success_url: successUrlPath + ? frontBaseUrl + successUrlPath + : frontBaseUrl, + cancel_url: frontBaseUrl, + }); + + assert(session.url, 'Error: missing checkout.session.url'); + + this.logger.log(`Stripe Checkout Session Url Redirection: ${session.url}`); + + return session.url; } async createBillingSubscription( diff --git a/packages/twenty-server/src/core/billing/dto/checkout.entity.ts b/packages/twenty-server/src/core/billing/dto/checkout.entity.ts new file mode 100644 index 0000000000..83e50281d9 --- /dev/null +++ b/packages/twenty-server/src/core/billing/dto/checkout.entity.ts @@ -0,0 +1,7 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class CheckoutEntity { + @Field(() => String) + url: string; +} diff --git a/packages/twenty-server/src/core/billing/dto/checkout.input.ts b/packages/twenty-server/src/core/billing/dto/checkout.input.ts new file mode 100644 index 0000000000..e3858cb2cb --- /dev/null +++ b/packages/twenty-server/src/core/billing/dto/checkout.input.ts @@ -0,0 +1,17 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import Stripe from 'stripe'; + +@ArgsType() +export class CheckoutInput { + @Field(() => String) + @IsString() + @IsNotEmpty() + recurringInterval: Stripe.Price.Recurring.Interval; + + @Field(() => String, { nullable: true }) + @IsString() + @IsOptional() + successUrlPath?: string; +} diff --git a/packages/twenty-server/src/core/billing/dto/product-price.entity.ts b/packages/twenty-server/src/core/billing/dto/product-price.entity.ts new file mode 100644 index 0000000000..69fc80011f --- /dev/null +++ b/packages/twenty-server/src/core/billing/dto/product-price.entity.ts @@ -0,0 +1,18 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import Stripe from 'stripe'; + +@ObjectType() +export class ProductPriceEntity { + @Field(() => String) + recurringInterval: Stripe.Price.Recurring.Interval; + + @Field(() => Number) + unitAmount: number; + + @Field(() => Number) + created: number; + + @Field(() => String) + stripePriceId: string; +} diff --git a/packages/twenty-server/src/core/billing/dto/product-prices.entity.ts b/packages/twenty-server/src/core/billing/dto/product-prices.entity.ts new file mode 100644 index 0000000000..9b00588201 --- /dev/null +++ b/packages/twenty-server/src/core/billing/dto/product-prices.entity.ts @@ -0,0 +1,12 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql'; + +import { ProductPriceEntity } from 'src/core/billing/dto/product-price.entity'; + +@ObjectType() +export class ProductPricesEntity { + @Field(() => Int) + totalNumberOfPrices: number; + + @Field(() => [ProductPriceEntity]) + productPrices: ProductPriceEntity[]; +} diff --git a/packages/twenty-server/src/core/billing/dto/product.input.ts b/packages/twenty-server/src/core/billing/dto/product.input.ts new file mode 100644 index 0000000000..4b68eb1833 --- /dev/null +++ b/packages/twenty-server/src/core/billing/dto/product.input.ts @@ -0,0 +1,13 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import { IsNotEmpty, IsString } from 'class-validator'; + +import { AvailableProduct } from 'src/core/billing/billing.service'; + +@ArgsType() +export class ProductInput { + @Field(() => String) + @IsString() + @IsNotEmpty() + product: AvailableProduct; +} diff --git a/packages/twenty-server/src/core/client-config/client-config.entity.ts b/packages/twenty-server/src/core/client-config/client-config.entity.ts index a9c2d150b9..57a719acc9 100644 --- a/packages/twenty-server/src/core/client-config/client-config.entity.ts +++ b/packages/twenty-server/src/core/client-config/client-config.entity.ts @@ -28,6 +28,9 @@ class Billing { @Field(() => String) billingUrl: string; + + @Field(() => Number, { nullable: true }) + billingFreeTrialDurationInDays: number | undefined; } @ObjectType() diff --git a/packages/twenty-server/src/core/client-config/client-config.resolver.ts b/packages/twenty-server/src/core/client-config/client-config.resolver.ts index c75a8b6ecb..7ee9d292a7 100644 --- a/packages/twenty-server/src/core/client-config/client-config.resolver.ts +++ b/packages/twenty-server/src/core/client-config/client-config.resolver.ts @@ -24,6 +24,8 @@ export class ClientConfigResolver { billing: { isBillingEnabled: this.environmentService.isBillingEnabled(), billingUrl: this.environmentService.getBillingUrl(), + billingFreeTrialDurationInDays: + this.environmentService.getBillingFreeTrialDurationInDays(), }, signInPrefilled: this.environmentService.isSignInPrefilled(), signUpDisabled: this.environmentService.isSignUpDisabled(), diff --git a/packages/twenty-server/src/integrations/environment/environment.service.ts b/packages/twenty-server/src/integrations/environment/environment.service.ts index 2465a07b34..e2bce5cba9 100644 --- a/packages/twenty-server/src/integrations/environment/environment.service.ts +++ b/packages/twenty-server/src/integrations/environment/environment.service.ts @@ -49,6 +49,12 @@ export class EnvironmentService { ); } + getBillingFreeTrialDurationInDays(): number { + return ( + this.configService.get('BILLING_FREE_TRIAL_DURATION_IN_DAYS') ?? 7 + ); + } + isTelemetryEnabled(): boolean { return this.configService.get('TELEMETRY_ENABLED') ?? true; } diff --git a/packages/twenty-server/src/integrations/environment/environment.validation.ts b/packages/twenty-server/src/integrations/environment/environment.validation.ts index bd21b81934..e64df45c68 100644 --- a/packages/twenty-server/src/integrations/environment/environment.validation.ts +++ b/packages/twenty-server/src/integrations/environment/environment.validation.ts @@ -52,6 +52,12 @@ export class EnvironmentVariables { @ValidateIf((env) => env.IS_BILLING_ENABLED === true) BILLING_STRIPE_BASE_PLAN_PRODUCT_ID?: string; + @IsNumber() + @CastToPositiveNumber() + @IsOptional() + @ValidateIf((env) => env.IS_BILLING_ENABLED === true) + BILLING_FREE_TRIAL_DURATION_IN_DAYS?: number; + @IsString() @ValidateIf((env) => env.IS_BILLING_ENABLED === true) BILLING_STRIPE_API_KEY?: string;