47 add stripe checkout endpoint (#4147)
* Add self billing feature flag * Add two core tables for billing * Remove useless imports * Remove graphql decorators * Rename subscriptionProduct table * WIP: Add stripe config * Add controller to get product prices * Add billing service * Remove unecessary package * Simplify stripe service * Code review returns * Use nestjs param * Rename subscription to basePlan * Rename env variable * Add checkout endpoint * Remove resolver * Merge controllers * Fix security issue * Handle missing url error * Add workspaceId in checkout metadata
This commit is contained in:
116
packages/twenty-server/src/core/billing/billing.controller.ts
Normal file
116
packages/twenty-server/src/core/billing/billing.controller.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Res,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
import { Response } from 'express';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AvailableProduct,
|
||||||
|
BillingService,
|
||||||
|
PriceData,
|
||||||
|
RecurringInterval,
|
||||||
|
} 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 {
|
||||||
|
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',
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect(303, session.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,13 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ProductPriceController } from 'src/core/billing/controllers/product-price.controller';
|
import { BillingController } from 'src/core/billing/billing.controller';
|
||||||
import { EnvironmentModule } from 'src/integrations/environment/environment.module';
|
import { EnvironmentModule } from 'src/integrations/environment/environment.module';
|
||||||
import { BillingService } from 'src/core/billing/billing.service';
|
import { BillingService } from 'src/core/billing/billing.service';
|
||||||
import { StripeModule } from 'src/core/billing/stripe/stripe.module';
|
import { StripeModule } from 'src/core/billing/stripe/stripe.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [StripeModule],
|
imports: [StripeModule],
|
||||||
controllers: [ProductPriceController],
|
controllers: [BillingController],
|
||||||
providers: [EnvironmentModule, BillingService],
|
providers: [EnvironmentModule, BillingService],
|
||||||
})
|
})
|
||||||
export class BillingModule {}
|
export class BillingModule {}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||||
|
import { StripeService } from 'src/core/billing/stripe/stripe.service';
|
||||||
|
|
||||||
export type PriceData = Partial<
|
export type PriceData = Partial<
|
||||||
Record<Stripe.Price.Recurring.Interval, Stripe.Price>
|
Record<Stripe.Price.Recurring.Interval, Stripe.Price>
|
||||||
@ -10,10 +11,16 @@ export type PriceData = Partial<
|
|||||||
export enum AvailableProduct {
|
export enum AvailableProduct {
|
||||||
BasePlan = 'base-plan',
|
BasePlan = 'base-plan',
|
||||||
}
|
}
|
||||||
|
export enum RecurringInterval {
|
||||||
|
MONTH = 'month',
|
||||||
|
YEAR = 'year',
|
||||||
|
}
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BillingService {
|
export class BillingService {
|
||||||
constructor(private readonly environmentService: EnvironmentService) {}
|
constructor(
|
||||||
|
private readonly stripeService: StripeService,
|
||||||
|
private readonly environmentService: EnvironmentService,
|
||||||
|
) {}
|
||||||
|
|
||||||
getProductStripeId(product: AvailableProduct) {
|
getProductStripeId(product: AvailableProduct) {
|
||||||
if (product === AvailableProduct.BasePlan) {
|
if (product === AvailableProduct.BasePlan) {
|
||||||
@ -21,6 +28,14 @@ export class BillingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getProductPrices(stripeProductId: string) {
|
||||||
|
const productPrices = await this.stripeService.stripe.prices.search({
|
||||||
|
query: `product: '${stripeProductId}'`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.formatProductPrices(productPrices.data);
|
||||||
|
}
|
||||||
|
|
||||||
formatProductPrices(prices: Stripe.Price[]) {
|
formatProductPrices(prices: Stripe.Price[]) {
|
||||||
const result: PriceData = {};
|
const result: PriceData = {};
|
||||||
|
|
||||||
|
|||||||
@ -1,46 +0,0 @@
|
|||||||
import { Controller, Get, Param, Res } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { Response } from 'express';
|
|
||||||
|
|
||||||
import {
|
|
||||||
AvailableProduct,
|
|
||||||
BillingService,
|
|
||||||
PriceData,
|
|
||||||
} from 'src/core/billing/billing.service';
|
|
||||||
import { StripeService } from 'src/core/billing/stripe/stripe.service';
|
|
||||||
|
|
||||||
@Controller('billing/product-prices')
|
|
||||||
export class ProductPriceController {
|
|
||||||
constructor(
|
|
||||||
private readonly stripeService: StripeService,
|
|
||||||
private readonly billingService: BillingService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get(':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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const productPrices = await this.stripeService.stripe.prices.search({
|
|
||||||
query: `product: '${stripeProductId}'`,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(this.billingService.formatProductPrices(productPrices.data));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user