42 add billing portal endpoint (#4315)

* Add create billing portal session endpoint

* Rename checkout to checkoutSession

* Code review returns
This commit is contained in:
martmull 2024-03-05 15:28:45 +01:00 committed by GitHub
parent 1f00af286b
commit 28a093d495
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 118 additions and 49 deletions

View File

@ -70,11 +70,6 @@ export type BooleanFieldComparison = {
isNot?: InputMaybe<Scalars['Boolean']>; isNot?: InputMaybe<Scalars['Boolean']>;
}; };
export type CheckoutEntity = {
__typename?: 'CheckoutEntity';
url: Scalars['String'];
};
export type ClientConfig = { export type ClientConfig = {
__typename?: 'ClientConfig'; __typename?: 'ClientConfig';
authProviders: AuthProviders; authProviders: AuthProviders;
@ -226,7 +221,7 @@ export type Mutation = {
__typename?: 'Mutation'; __typename?: 'Mutation';
activateWorkspace: Workspace; activateWorkspace: Workspace;
challenge: LoginToken; challenge: LoginToken;
checkout: CheckoutEntity; checkoutSession: SessionEntity;
createEvent: Analytics; createEvent: Analytics;
createOneObject: Object; createOneObject: Object;
createOneRefreshToken: RefreshToken; createOneRefreshToken: RefreshToken;
@ -262,7 +257,7 @@ export type MutationChallengeArgs = {
}; };
export type MutationCheckoutArgs = { export type MutationCheckoutSessionArgs = {
recurringInterval: Scalars['String']; recurringInterval: Scalars['String'];
successUrlPath?: InputMaybe<Scalars['String']>; successUrlPath?: InputMaybe<Scalars['String']>;
}; };
@ -397,6 +392,7 @@ export type ProductPricesEntity = {
export type Query = { export type Query = {
__typename?: 'Query'; __typename?: 'Query';
billingPortalSession: SessionEntity;
checkUserExists: UserExists; checkUserExists: UserExists;
checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid; checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid;
clientConfig: ClientConfig; clientConfig: ClientConfig;
@ -412,6 +408,11 @@ export type Query = {
}; };
export type QueryBillingPortalSessionArgs = {
returnUrlPath?: InputMaybe<Scalars['String']>;
};
export type QueryCheckUserExistsArgs = { export type QueryCheckUserExistsArgs = {
email: Scalars['String']; email: Scalars['String'];
}; };
@ -500,6 +501,11 @@ export type Sentry = {
dsn?: Maybe<Scalars['String']>; dsn?: Maybe<Scalars['String']>;
}; };
export type SessionEntity = {
__typename?: 'SessionEntity';
url: Scalars['String'];
};
/** Sort Directions */ /** Sort Directions */
export enum SortDirection { export enum SortDirection {
Asc = 'ASC', Asc = 'ASC',
@ -883,13 +889,13 @@ export type ValidatePasswordResetTokenQueryVariables = Exact<{
export type ValidatePasswordResetTokenQuery = { __typename?: 'Query', validatePasswordResetToken: { __typename?: 'ValidatePasswordResetToken', id: string, email: string } }; export type ValidatePasswordResetTokenQuery = { __typename?: 'Query', validatePasswordResetToken: { __typename?: 'ValidatePasswordResetToken', id: string, email: string } };
export type CheckoutMutationVariables = Exact<{ export type CheckoutSessionMutationVariables = Exact<{
recurringInterval: Scalars['String']; recurringInterval: Scalars['String'];
successUrlPath?: InputMaybe<Scalars['String']>; successUrlPath?: InputMaybe<Scalars['String']>;
}>; }>;
export type CheckoutMutation = { __typename?: 'Mutation', checkout: { __typename?: 'CheckoutEntity', url: string } }; export type CheckoutSessionMutation = { __typename?: 'Mutation', checkoutSession: { __typename?: 'SessionEntity', url: string } };
export type GetProductPricesQueryVariables = Exact<{ export type GetProductPricesQueryVariables = Exact<{
product: Scalars['String']; product: Scalars['String'];
@ -1582,40 +1588,43 @@ export function useValidatePasswordResetTokenLazyQuery(baseOptions?: Apollo.Lazy
export type ValidatePasswordResetTokenQueryHookResult = ReturnType<typeof useValidatePasswordResetTokenQuery>; export type ValidatePasswordResetTokenQueryHookResult = ReturnType<typeof useValidatePasswordResetTokenQuery>;
export type ValidatePasswordResetTokenLazyQueryHookResult = ReturnType<typeof useValidatePasswordResetTokenLazyQuery>; export type ValidatePasswordResetTokenLazyQueryHookResult = ReturnType<typeof useValidatePasswordResetTokenLazyQuery>;
export type ValidatePasswordResetTokenQueryResult = Apollo.QueryResult<ValidatePasswordResetTokenQuery, ValidatePasswordResetTokenQueryVariables>; export type ValidatePasswordResetTokenQueryResult = Apollo.QueryResult<ValidatePasswordResetTokenQuery, ValidatePasswordResetTokenQueryVariables>;
export const CheckoutDocument = gql` export const CheckoutSessionDocument = gql`
mutation Checkout($recurringInterval: String!, $successUrlPath: String) { mutation CheckoutSession($recurringInterval: String!, $successUrlPath: String) {
checkout(recurringInterval: $recurringInterval, successUrlPath: $successUrlPath) { checkoutSession(
recurringInterval: $recurringInterval
successUrlPath: $successUrlPath
) {
url url
} }
} }
`; `;
export type CheckoutMutationFn = Apollo.MutationFunction<CheckoutMutation, CheckoutMutationVariables>; export type CheckoutSessionMutationFn = Apollo.MutationFunction<CheckoutSessionMutation, CheckoutSessionMutationVariables>;
/** /**
* __useCheckoutMutation__ * __useCheckoutSessionMutation__
* *
* To run a mutation, you first call `useCheckoutMutation` within a React component and pass it any options that fit your needs. * 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, `useCheckoutMutation` returns a tuple that includes: * When your component renders, `useCheckoutSessionMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation * - 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 * - 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; * @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 * @example
* const [checkoutMutation, { data, loading, error }] = useCheckoutMutation({ * const [checkoutSessionMutation, { data, loading, error }] = useCheckoutSessionMutation({
* variables: { * variables: {
* recurringInterval: // value for 'recurringInterval' * recurringInterval: // value for 'recurringInterval'
* successUrlPath: // value for 'successUrlPath' * successUrlPath: // value for 'successUrlPath'
* }, * },
* }); * });
*/ */
export function useCheckoutMutation(baseOptions?: Apollo.MutationHookOptions<CheckoutMutation, CheckoutMutationVariables>) { export function useCheckoutSessionMutation(baseOptions?: Apollo.MutationHookOptions<CheckoutSessionMutation, CheckoutSessionMutationVariables>) {
const options = {...defaultOptions, ...baseOptions} const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CheckoutMutation, CheckoutMutationVariables>(CheckoutDocument, options); return Apollo.useMutation<CheckoutSessionMutation, CheckoutSessionMutationVariables>(CheckoutSessionDocument, options);
} }
export type CheckoutMutationHookResult = ReturnType<typeof useCheckoutMutation>; export type CheckoutSessionMutationHookResult = ReturnType<typeof useCheckoutSessionMutation>;
export type CheckoutMutationResult = Apollo.MutationResult<CheckoutMutation>; export type CheckoutSessionMutationResult = Apollo.MutationResult<CheckoutSessionMutation>;
export type CheckoutMutationOptions = Apollo.BaseMutationOptions<CheckoutMutation, CheckoutMutationVariables>; export type CheckoutSessionMutationOptions = Apollo.BaseMutationOptions<CheckoutSessionMutation, CheckoutSessionMutationVariables>;
export const GetProductPricesDocument = gql` export const GetProductPricesDocument = gql`
query GetProductPrices($product: String!) { query GetProductPrices($product: String!) {
getProductPrices(product: $product) { getProductPrices(product: $product) {

View File

@ -1,8 +1,11 @@
import { gql } from '@apollo/client'; import { gql } from '@apollo/client';
export const CHECKOUT = gql` export const CHECKOUT_SESSION = gql`
mutation Checkout($recurringInterval: String!, $successUrlPath: String) { mutation CheckoutSession(
checkout( $recurringInterval: String!
$successUrlPath: String
) {
checkoutSession(
recurringInterval: $recurringInterval recurringInterval: $recurringInterval
successUrlPath: $successUrlPath successUrlPath: $successUrlPath
) { ) {

View File

@ -14,7 +14,7 @@ import { MainButton } from '@/ui/input/button/components/MainButton.tsx';
import { CardPicker } from '@/ui/input/components/CardPicker.tsx'; import { CardPicker } from '@/ui/input/components/CardPicker.tsx';
import { import {
ProductPriceEntity, ProductPriceEntity,
useCheckoutMutation, useCheckoutSessionMutation,
useGetProductPricesQuery, useGetProductPricesQuery,
} from '~/generated/graphql.tsx'; } from '~/generated/graphql.tsx';
@ -53,7 +53,7 @@ export const ChooseYourPlan = () => {
variables: { product: 'base-plan' }, variables: { product: 'base-plan' },
}); });
const [checkout] = useCheckoutMutation(); const [checkoutSession] = useCheckoutSessionMutation();
const handlePlanChange = (type?: string) => { const handlePlanChange = (type?: string) => {
return () => { return () => {
@ -81,14 +81,14 @@ export const ChooseYourPlan = () => {
const handleButtonClick = async () => { const handleButtonClick = async () => {
setIsSubmitting(true); setIsSubmitting(true);
const { data } = await checkout({ const { data } = await checkoutSession({
variables: { variables: {
recurringInterval: planSelected, recurringInterval: planSelected,
successUrlPath: AppPath.PlanRequiredSuccess, successUrlPath: AppPath.PlanRequiredSuccess,
}, },
}); });
setIsSubmitting(false); setIsSubmitting(false);
if (!data?.checkout.url) { if (!data?.checkoutSession.url) {
enqueueSnackBar( enqueueSnackBar(
'Checkout session error. Please retry or contact Twenty team', 'Checkout session error. Please retry or contact Twenty team',
{ {
@ -97,7 +97,7 @@ export const ChooseYourPlan = () => {
); );
return; return;
} }
window.location.replace(data.checkout.url); window.location.replace(data.checkoutSession.url);
}; };
return ( return (

View File

@ -11,8 +11,9 @@ import { ProductPricesEntity } from 'src/core/billing/dto/product-prices.entity'
import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
import { AuthUser } from 'src/decorators/auth/auth-user.decorator'; import { AuthUser } from 'src/decorators/auth/auth-user.decorator';
import { User } from 'src/core/user/user.entity'; import { User } from 'src/core/user/user.entity';
import { CheckoutInput } from 'src/core/billing/dto/checkout.input'; import { CheckoutSessionInput } from 'src/core/billing/dto/checkout-session.input';
import { CheckoutEntity } from 'src/core/billing/dto/checkout.entity'; import { SessionEntity } from 'src/core/billing/dto/session.entity';
import { BillingSessionInput } from 'src/core/billing/dto/billing-session.input';
@Resolver() @Resolver()
export class BillingResolver { export class BillingResolver {
@ -38,11 +39,25 @@ export class BillingResolver {
}; };
} }
@Mutation(() => CheckoutEntity) @Query(() => SessionEntity)
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
async checkout( async billingPortalSession(
@AuthUser() user: User, @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( const stripeProductId = this.billingService.getProductStripeId(
AvailableProduct.BasePlan, AvailableProduct.BasePlan,
@ -66,7 +81,7 @@ export class BillingResolver {
); );
return { return {
url: await this.billingService.checkout( url: await this.billingService.computeCheckoutSessionURL(
user, user,
stripePriceId, stripePriceId,
successUrlPath, successUrlPath,

View File

@ -11,6 +11,7 @@ import { BillingSubscriptionItem } from 'src/core/billing/entities/billing-subsc
import { Workspace } from 'src/core/workspace/workspace.entity'; import { Workspace } from 'src/core/workspace/workspace.entity';
import { ProductPriceEntity } from 'src/core/billing/dto/product-price.entity'; import { ProductPriceEntity } from 'src/core/billing/dto/product-price.entity';
import { User } from 'src/core/user/user.entity'; import { User } from 'src/core/user/user.entity';
import { assert } from 'src/utils/assert';
export enum AvailableProduct { export enum AvailableProduct {
BasePlan = 'base-plan', BasePlan = 'base-plan',
@ -101,18 +102,45 @@ export class BillingService {
return billingSubscriptionItem; 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<string> {
const frontBaseUrl = this.environmentService.getFrontBaseUrl(); const frontBaseUrl = this.environmentService.getFrontBaseUrl();
const successUrl = successUrlPath const successUrl = successUrlPath
? frontBaseUrl + successUrlPath ? frontBaseUrl + successUrlPath
: frontBaseUrl; : frontBaseUrl;
return await this.stripeService.createCheckoutSession( const session = await this.stripeService.createCheckoutSession(
user, user,
priceId, priceId,
successUrl, successUrl,
frontBaseUrl, frontBaseUrl,
); );
assert(session.url, 'Error: missing checkout.session.url');
return session.url;
} }
async deleteSubscription(workspaceId: string) { async deleteSubscription(workspaceId: string) {

View File

@ -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;
}

View File

@ -4,7 +4,7 @@ import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import Stripe from 'stripe'; import Stripe from 'stripe';
@ArgsType() @ArgsType()
export class CheckoutInput { export class CheckoutSessionInput {
@Field(() => String) @Field(() => String)
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()

View File

@ -1,7 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql'; import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType() @ObjectType()
export class CheckoutEntity { export class SessionEntity {
@Field(() => String) @Field(() => String)
url: string; url: string;
} }

View File

@ -4,7 +4,6 @@ import Stripe from 'stripe';
import { EnvironmentService } from 'src/integrations/environment/environment.service'; import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { User } from 'src/core/user/user.entity'; import { User } from 'src/core/user/user.entity';
import { assert } from 'src/utils/assert';
@Injectable() @Injectable()
export class StripeService { export class StripeService {
@ -43,13 +42,23 @@ export class StripeService {
await this.stripe.subscriptions.cancel(stripeSubscriptionId); await this.stripe.subscriptions.cancel(stripeSubscriptionId);
} }
async createBillingPortalSession(
stripeCustomerId: string,
returnUrlPath?: string,
): Promise<Stripe.BillingPortal.Session> {
return await this.stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: returnUrlPath ?? this.environmentService.getFrontBaseUrl(),
});
}
async createCheckoutSession( async createCheckoutSession(
user: User, user: User,
priceId: string, priceId: string,
successUrl?: string, successUrl?: string,
cancelUrl?: string, cancelUrl?: string,
) { ): Promise<Stripe.Checkout.Session> {
const session = await this.stripe.checkout.sessions.create({ return await this.stripe.checkout.sessions.create({
line_items: [ line_items: [
{ {
price: priceId, price: priceId,
@ -70,11 +79,5 @@ export class StripeService {
success_url: successUrl, success_url: successUrl,
cancel_url: cancelUrl, cancel_url: cancelUrl,
}); });
assert(session.url, 'Error: missing checkout.session.url');
this.logger.log(`Stripe Checkout Session Url Redirection: ${session.url}`);
return session.url;
} }
} }