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
This commit is contained in:
@ -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<PriceData | { error: string }>,
|
||||
) {
|
||||
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,
|
||||
|
||||
@ -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 {}
|
||||
|
||||
76
packages/twenty-server/src/core/billing/billing.resolver.ts
Normal file
76
packages/twenty-server/src/core/billing/billing.resolver.ts
Normal file
@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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<Stripe.Price.Recurring.Interval, Stripe.Price>
|
||||
>;
|
||||
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<string, ProductPriceEntity> = {};
|
||||
|
||||
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(
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class CheckoutEntity {
|
||||
@Field(() => String)
|
||||
url: string;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
13
packages/twenty-server/src/core/billing/dto/product.input.ts
Normal file
13
packages/twenty-server/src/core/billing/dto/product.input.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -28,6 +28,9 @@ class Billing {
|
||||
|
||||
@Field(() => String)
|
||||
billingUrl: string;
|
||||
|
||||
@Field(() => Number, { nullable: true })
|
||||
billingFreeTrialDurationInDays: number | undefined;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -49,6 +49,12 @@ export class EnvironmentService {
|
||||
);
|
||||
}
|
||||
|
||||
getBillingFreeTrialDurationInDays(): number {
|
||||
return (
|
||||
this.configService.get<number>('BILLING_FREE_TRIAL_DURATION_IN_DAYS') ?? 7
|
||||
);
|
||||
}
|
||||
|
||||
isTelemetryEnabled(): boolean {
|
||||
return this.configService.get<boolean>('TELEMETRY_ENABLED') ?? true;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user