From c96e210ef18d127411bc0f61f03ebdf59830a494 Mon Sep 17 00:00:00 2001 From: martmull Date: Sat, 24 Feb 2024 17:19:51 +0100 Subject: [PATCH] 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 --- .../src/core/billing/billing.controller.ts | 116 ++++++++++++++++++ .../src/core/billing/billing.module.ts | 4 +- .../src/core/billing/billing.service.ts | 19 ++- .../controllers/product-price.controller.ts | 46 ------- 4 files changed, 135 insertions(+), 50 deletions(-) create mode 100644 packages/twenty-server/src/core/billing/billing.controller.ts delete mode 100644 packages/twenty-server/src/core/billing/controllers/product-price.controller.ts diff --git a/packages/twenty-server/src/core/billing/billing.controller.ts b/packages/twenty-server/src/core/billing/billing.controller.ts new file mode 100644 index 000000000000..3ef61b684751 --- /dev/null +++ b/packages/twenty-server/src/core/billing/billing.controller.ts @@ -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, + ) { + 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); + } +} diff --git a/packages/twenty-server/src/core/billing/billing.module.ts b/packages/twenty-server/src/core/billing/billing.module.ts index ed7ba9299bde..9a70d9d7f466 100644 --- a/packages/twenty-server/src/core/billing/billing.module.ts +++ b/packages/twenty-server/src/core/billing/billing.module.ts @@ -1,13 +1,13 @@ 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 { BillingService } from 'src/core/billing/billing.service'; import { StripeModule } from 'src/core/billing/stripe/stripe.module'; @Module({ imports: [StripeModule], - controllers: [ProductPriceController], + controllers: [BillingController], providers: [EnvironmentModule, BillingService], }) export class BillingModule {} diff --git a/packages/twenty-server/src/core/billing/billing.service.ts b/packages/twenty-server/src/core/billing/billing.service.ts index fa25e7a9eba5..357ba90caa47 100644 --- a/packages/twenty-server/src/core/billing/billing.service.ts +++ b/packages/twenty-server/src/core/billing/billing.service.ts @@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common'; import Stripe from 'stripe'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; +import { StripeService } from 'src/core/billing/stripe/stripe.service'; export type PriceData = Partial< Record @@ -10,10 +11,16 @@ export type PriceData = Partial< export enum AvailableProduct { BasePlan = 'base-plan', } - +export enum RecurringInterval { + MONTH = 'month', + YEAR = 'year', +} @Injectable() export class BillingService { - constructor(private readonly environmentService: EnvironmentService) {} + constructor( + private readonly stripeService: StripeService, + private readonly environmentService: EnvironmentService, + ) {} getProductStripeId(product: AvailableProduct) { 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[]) { const result: PriceData = {}; diff --git a/packages/twenty-server/src/core/billing/controllers/product-price.controller.ts b/packages/twenty-server/src/core/billing/controllers/product-price.controller.ts deleted file mode 100644 index 5ee8cb754fbc..000000000000 --- a/packages/twenty-server/src/core/billing/controllers/product-price.controller.ts +++ /dev/null @@ -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, - ) { - 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)); - } -}