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']>;
};
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<Scalars['String']>;
};
@ -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<Scalars['String']>;
};
export type QueryCheckUserExistsArgs = {
email: Scalars['String'];
};
@ -500,6 +501,11 @@ export type Sentry = {
dsn?: Maybe<Scalars['String']>;
};
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<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<{
product: Scalars['String'];
@ -1582,40 +1588,43 @@ export function useValidatePasswordResetTokenLazyQuery(baseOptions?: Apollo.Lazy
export type ValidatePasswordResetTokenQueryHookResult = ReturnType<typeof useValidatePasswordResetTokenQuery>;
export type ValidatePasswordResetTokenLazyQueryHookResult = ReturnType<typeof useValidatePasswordResetTokenLazyQuery>;
export type ValidatePasswordResetTokenQueryResult = Apollo.QueryResult<ValidatePasswordResetTokenQuery, ValidatePasswordResetTokenQueryVariables>;
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<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.
* 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<CheckoutMutation, CheckoutMutationVariables>) {
export function useCheckoutSessionMutation(baseOptions?: Apollo.MutationHookOptions<CheckoutSessionMutation, CheckoutSessionMutationVariables>) {
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 CheckoutMutationResult = Apollo.MutationResult<CheckoutMutation>;
export type CheckoutMutationOptions = Apollo.BaseMutationOptions<CheckoutMutation, CheckoutMutationVariables>;
export type CheckoutSessionMutationHookResult = ReturnType<typeof useCheckoutSessionMutation>;
export type CheckoutSessionMutationResult = Apollo.MutationResult<CheckoutSessionMutation>;
export type CheckoutSessionMutationOptions = Apollo.BaseMutationOptions<CheckoutSessionMutation, CheckoutSessionMutationVariables>;
export const GetProductPricesDocument = gql`
query GetProductPrices($product: String!) {
getProductPrices(product: $product) {

View File

@ -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
) {

View File

@ -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 (

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 { 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,

View File

@ -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<string> {
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) {

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';
@ArgsType()
export class CheckoutInput {
export class CheckoutSessionInput {
@Field(() => String)
@IsString()
@IsNotEmpty()

View File

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

View File

@ -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<Stripe.BillingPortal.Session> {
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<Stripe.Checkout.Session> {
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;
}
}