From 28a093d49592f42e3c3a0d57bcb783af6367961f Mon Sep 17 00:00:00 2001 From: martmull Date: Tue, 5 Mar 2024 15:28:45 +0100 Subject: [PATCH] 42 add billing portal endpoint (#4315) * Add create billing portal session endpoint * Rename checkout to checkoutSession * Code review returns --- .../twenty-front/src/generated/graphql.tsx | 53 +++++++++++-------- .../{checkout.ts => checkout-session.ts} | 9 ++-- .../src/pages/auth/ChooseYourPlan.tsx | 10 ++-- .../src/core/billing/billing.resolver.ts | 27 +++++++--- .../src/core/billing/billing.service.ts | 32 ++++++++++- .../core/billing/dto/billing-session.input.ts | 11 ++++ ...out.input.ts => checkout-session.input.ts} | 2 +- .../{checkout.entity.ts => session.entity.ts} | 2 +- .../src/core/billing/stripe/stripe.service.ts | 21 ++++---- 9 files changed, 118 insertions(+), 49 deletions(-) rename packages/twenty-front/src/modules/billing/graphql/{checkout.ts => checkout-session.ts} (50%) create mode 100644 packages/twenty-server/src/core/billing/dto/billing-session.input.ts rename packages/twenty-server/src/core/billing/dto/{checkout.input.ts => checkout-session.input.ts} (91%) rename packages/twenty-server/src/core/billing/dto/{checkout.entity.ts => session.entity.ts} (78%) diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index e698c1189..ef4db9f3f 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -70,11 +70,6 @@ export type BooleanFieldComparison = { isNot?: InputMaybe; }; -export type CheckoutEntity = { - __typename?: 'CheckoutEntity'; - url: Scalars['String']; -}; - export type ClientConfig = { __typename?: 'ClientConfig'; authProviders: AuthProviders; @@ -226,7 +221,7 @@ export type Mutation = { __typename?: 'Mutation'; activateWorkspace: Workspace; challenge: LoginToken; - checkout: CheckoutEntity; + checkoutSession: SessionEntity; createEvent: Analytics; createOneObject: Object; createOneRefreshToken: RefreshToken; @@ -262,7 +257,7 @@ export type MutationChallengeArgs = { }; -export type MutationCheckoutArgs = { +export type MutationCheckoutSessionArgs = { recurringInterval: Scalars['String']; successUrlPath?: InputMaybe; }; @@ -397,6 +392,7 @@ export type ProductPricesEntity = { export type Query = { __typename?: 'Query'; + billingPortalSession: SessionEntity; checkUserExists: UserExists; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid; clientConfig: ClientConfig; @@ -412,6 +408,11 @@ export type Query = { }; +export type QueryBillingPortalSessionArgs = { + returnUrlPath?: InputMaybe; +}; + + export type QueryCheckUserExistsArgs = { email: Scalars['String']; }; @@ -500,6 +501,11 @@ export type Sentry = { dsn?: Maybe; }; +export type SessionEntity = { + __typename?: 'SessionEntity'; + url: Scalars['String']; +}; + /** Sort Directions */ export enum SortDirection { Asc = 'ASC', @@ -883,13 +889,13 @@ export type ValidatePasswordResetTokenQueryVariables = Exact<{ export type ValidatePasswordResetTokenQuery = { __typename?: 'Query', validatePasswordResetToken: { __typename?: 'ValidatePasswordResetToken', id: string, email: string } }; -export type CheckoutMutationVariables = Exact<{ +export type CheckoutSessionMutationVariables = Exact<{ recurringInterval: Scalars['String']; successUrlPath?: InputMaybe; }>; -export type CheckoutMutation = { __typename?: 'Mutation', checkout: { __typename?: 'CheckoutEntity', url: string } }; +export type CheckoutSessionMutation = { __typename?: 'Mutation', checkoutSession: { __typename?: 'SessionEntity', url: string } }; export type GetProductPricesQueryVariables = Exact<{ product: Scalars['String']; @@ -1582,40 +1588,43 @@ 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) { +export const CheckoutSessionDocument = gql` + mutation CheckoutSession($recurringInterval: String!, $successUrlPath: String) { + checkoutSession( + recurringInterval: $recurringInterval + successUrlPath: $successUrlPath + ) { url } } `; -export type CheckoutMutationFn = Apollo.MutationFunction; +export type CheckoutSessionMutationFn = Apollo.MutationFunction; /** - * __useCheckoutMutation__ + * __useCheckoutSessionMutation__ * - * 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: + * To run a mutation, you first call `useCheckoutSessionMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCheckoutSessionMutation` 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({ + * const [checkoutSessionMutation, { data, loading, error }] = useCheckoutSessionMutation({ * variables: { * recurringInterval: // value for 'recurringInterval' * successUrlPath: // value for 'successUrlPath' * }, * }); */ -export function useCheckoutMutation(baseOptions?: Apollo.MutationHookOptions) { +export function useCheckoutSessionMutation(baseOptions?: Apollo.MutationHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(CheckoutDocument, options); + return Apollo.useMutation(CheckoutSessionDocument, options); } -export type CheckoutMutationHookResult = ReturnType; -export type CheckoutMutationResult = Apollo.MutationResult; -export type CheckoutMutationOptions = Apollo.BaseMutationOptions; +export type CheckoutSessionMutationHookResult = ReturnType; +export type CheckoutSessionMutationResult = Apollo.MutationResult; +export type CheckoutSessionMutationOptions = Apollo.BaseMutationOptions; export const GetProductPricesDocument = gql` query GetProductPrices($product: String!) { getProductPrices(product: $product) { diff --git a/packages/twenty-front/src/modules/billing/graphql/checkout.ts b/packages/twenty-front/src/modules/billing/graphql/checkout-session.ts similarity index 50% rename from packages/twenty-front/src/modules/billing/graphql/checkout.ts rename to packages/twenty-front/src/modules/billing/graphql/checkout-session.ts index e2742d107..bff619a82 100644 --- a/packages/twenty-front/src/modules/billing/graphql/checkout.ts +++ b/packages/twenty-front/src/modules/billing/graphql/checkout-session.ts @@ -1,8 +1,11 @@ import { gql } from '@apollo/client'; -export const CHECKOUT = gql` - mutation Checkout($recurringInterval: String!, $successUrlPath: String) { - checkout( +export const CHECKOUT_SESSION = gql` + mutation CheckoutSession( + $recurringInterval: String! + $successUrlPath: String + ) { + checkoutSession( recurringInterval: $recurringInterval successUrlPath: $successUrlPath ) { diff --git a/packages/twenty-front/src/pages/auth/ChooseYourPlan.tsx b/packages/twenty-front/src/pages/auth/ChooseYourPlan.tsx index f5a8afdca..3e5e0de66 100644 --- a/packages/twenty-front/src/pages/auth/ChooseYourPlan.tsx +++ b/packages/twenty-front/src/pages/auth/ChooseYourPlan.tsx @@ -14,7 +14,7 @@ import { MainButton } from '@/ui/input/button/components/MainButton.tsx'; import { CardPicker } from '@/ui/input/components/CardPicker.tsx'; import { ProductPriceEntity, - useCheckoutMutation, + useCheckoutSessionMutation, useGetProductPricesQuery, } from '~/generated/graphql.tsx'; @@ -53,7 +53,7 @@ export const ChooseYourPlan = () => { variables: { product: 'base-plan' }, }); - const [checkout] = useCheckoutMutation(); + const [checkoutSession] = useCheckoutSessionMutation(); const handlePlanChange = (type?: string) => { return () => { @@ -81,14 +81,14 @@ export const ChooseYourPlan = () => { const handleButtonClick = async () => { setIsSubmitting(true); - const { data } = await checkout({ + const { data } = await checkoutSession({ variables: { recurringInterval: planSelected, successUrlPath: AppPath.PlanRequiredSuccess, }, }); setIsSubmitting(false); - if (!data?.checkout.url) { + if (!data?.checkoutSession.url) { enqueueSnackBar( 'Checkout session error. Please retry or contact Twenty team', { @@ -97,7 +97,7 @@ export const ChooseYourPlan = () => { ); return; } - window.location.replace(data.checkout.url); + window.location.replace(data.checkoutSession.url); }; return ( diff --git a/packages/twenty-server/src/core/billing/billing.resolver.ts b/packages/twenty-server/src/core/billing/billing.resolver.ts index 7f8d0d90e..6507b4d6e 100644 --- a/packages/twenty-server/src/core/billing/billing.resolver.ts +++ b/packages/twenty-server/src/core/billing/billing.resolver.ts @@ -11,8 +11,9 @@ 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'; +import { CheckoutSessionInput } from 'src/core/billing/dto/checkout-session.input'; +import { SessionEntity } from 'src/core/billing/dto/session.entity'; +import { BillingSessionInput } from 'src/core/billing/dto/billing-session.input'; @Resolver() export class BillingResolver { @@ -38,11 +39,25 @@ export class BillingResolver { }; } - @Mutation(() => CheckoutEntity) + @Query(() => SessionEntity) @UseGuards(JwtAuthGuard) - async checkout( + async billingPortalSession( @AuthUser() user: User, - @Args() { recurringInterval, successUrlPath }: CheckoutInput, + @Args() { returnUrlPath }: BillingSessionInput, + ) { + return { + url: await this.billingService.computeBillingPortalSessionURL( + user.defaultWorkspaceId, + returnUrlPath, + ), + }; + } + + @Mutation(() => SessionEntity) + @UseGuards(JwtAuthGuard) + async checkoutSession( + @AuthUser() user: User, + @Args() { recurringInterval, successUrlPath }: CheckoutSessionInput, ) { const stripeProductId = this.billingService.getProductStripeId( AvailableProduct.BasePlan, @@ -66,7 +81,7 @@ export class BillingResolver { ); return { - url: await this.billingService.checkout( + url: await this.billingService.computeCheckoutSessionURL( 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 e888be1b1..cd7486ea7 100644 --- a/packages/twenty-server/src/core/billing/billing.service.ts +++ b/packages/twenty-server/src/core/billing/billing.service.ts @@ -11,6 +11,7 @@ import { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subsc 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 enum AvailableProduct { BasePlan = 'base-plan', @@ -101,18 +102,45 @@ export class BillingService { return billingSubscriptionItem; } - async checkout(user: User, priceId: string, successUrlPath?: string) { + async computeBillingPortalSessionURL( + workspaceId: string, + returnUrlPath?: string, + ) { + const billingSubscription = + await this.billingSubscriptionRepository.findOneOrFail({ + where: { workspaceId }, + }); + + const session = await this.stripeService.createBillingPortalSession( + billingSubscription.stripeCustomerId, + returnUrlPath, + ); + + assert(session.url, 'Error: missing billingPortal.session.url'); + + return session.url; + } + + async computeCheckoutSessionURL( + user: User, + priceId: string, + successUrlPath?: string, + ): Promise { const frontBaseUrl = this.environmentService.getFrontBaseUrl(); const successUrl = successUrlPath ? frontBaseUrl + successUrlPath : frontBaseUrl; - return await this.stripeService.createCheckoutSession( + const session = await this.stripeService.createCheckoutSession( user, priceId, successUrl, frontBaseUrl, ); + + assert(session.url, 'Error: missing checkout.session.url'); + + return session.url; } async deleteSubscription(workspaceId: string) { diff --git a/packages/twenty-server/src/core/billing/dto/billing-session.input.ts b/packages/twenty-server/src/core/billing/dto/billing-session.input.ts new file mode 100644 index 000000000..67a6c27b8 --- /dev/null +++ b/packages/twenty-server/src/core/billing/dto/billing-session.input.ts @@ -0,0 +1,11 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import { IsOptional, IsString } from 'class-validator'; + +@ArgsType() +export class BillingSessionInput { + @Field(() => String, { nullable: true }) + @IsString() + @IsOptional() + returnUrlPath?: string; +} diff --git a/packages/twenty-server/src/core/billing/dto/checkout.input.ts b/packages/twenty-server/src/core/billing/dto/checkout-session.input.ts similarity index 91% rename from packages/twenty-server/src/core/billing/dto/checkout.input.ts rename to packages/twenty-server/src/core/billing/dto/checkout-session.input.ts index e3858cb2c..778f0671e 100644 --- a/packages/twenty-server/src/core/billing/dto/checkout.input.ts +++ b/packages/twenty-server/src/core/billing/dto/checkout-session.input.ts @@ -4,7 +4,7 @@ import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; import Stripe from 'stripe'; @ArgsType() -export class CheckoutInput { +export class CheckoutSessionInput { @Field(() => String) @IsString() @IsNotEmpty() diff --git a/packages/twenty-server/src/core/billing/dto/checkout.entity.ts b/packages/twenty-server/src/core/billing/dto/session.entity.ts similarity index 78% rename from packages/twenty-server/src/core/billing/dto/checkout.entity.ts rename to packages/twenty-server/src/core/billing/dto/session.entity.ts index 83e50281d..95da4d422 100644 --- a/packages/twenty-server/src/core/billing/dto/checkout.entity.ts +++ b/packages/twenty-server/src/core/billing/dto/session.entity.ts @@ -1,7 +1,7 @@ import { Field, ObjectType } from '@nestjs/graphql'; @ObjectType() -export class CheckoutEntity { +export class SessionEntity { @Field(() => String) url: string; } diff --git a/packages/twenty-server/src/core/billing/stripe/stripe.service.ts b/packages/twenty-server/src/core/billing/stripe/stripe.service.ts index 74835770f..f7fbf24b2 100644 --- a/packages/twenty-server/src/core/billing/stripe/stripe.service.ts +++ b/packages/twenty-server/src/core/billing/stripe/stripe.service.ts @@ -4,7 +4,6 @@ import Stripe from 'stripe'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; import { User } from 'src/core/user/user.entity'; -import { assert } from 'src/utils/assert'; @Injectable() export class StripeService { @@ -43,13 +42,23 @@ export class StripeService { await this.stripe.subscriptions.cancel(stripeSubscriptionId); } + async createBillingPortalSession( + stripeCustomerId: string, + returnUrlPath?: string, + ): Promise { + return await this.stripe.billingPortal.sessions.create({ + customer: stripeCustomerId, + return_url: returnUrlPath ?? this.environmentService.getFrontBaseUrl(), + }); + } + async createCheckoutSession( user: User, priceId: string, successUrl?: string, cancelUrl?: string, - ) { - const session = await this.stripe.checkout.sessions.create({ + ): Promise { + return await this.stripe.checkout.sessions.create({ line_items: [ { price: priceId, @@ -70,11 +79,5 @@ export class StripeService { success_url: successUrl, cancel_url: cancelUrl, }); - - assert(session.url, 'Error: missing checkout.session.url'); - - this.logger.log(`Stripe Checkout Session Url Redirection: ${session.url}`); - - return session.url; } }