diff --git a/packages/twenty-server/src/core/billing/billing.controller.ts b/packages/twenty-server/src/core/billing/billing.controller.ts index 3ef61b684..5a1a8e016 100644 --- a/packages/twenty-server/src/core/billing/billing.controller.ts +++ b/packages/twenty-server/src/core/billing/billing.controller.ts @@ -3,6 +3,10 @@ import { Controller, Get, Param, + Headers, + Req, + RawBodyRequest, + Logger, Post, Res, UseGuards, @@ -15,6 +19,7 @@ import { BillingService, PriceData, RecurringInterval, + WebhookEvent, } from 'src/core/billing/billing.service'; import { StripeService } from 'src/core/billing/stripe/stripe.service'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; @@ -24,6 +29,8 @@ import { JwtAuthGuard } from 'src/guards/jwt.auth.guard'; @Controller('billing') export class BillingController { + protected readonly logger = new Logger(BillingController.name); + constructor( private readonly stripeService: StripeService, private readonly billingService: BillingService, @@ -97,8 +104,10 @@ export class BillingController { }, ], mode: 'subscription', - metadata: { - workspaceId: user.defaultWorkspace.id, + subscription_data: { + metadata: { + workspaceId: user.defaultWorkspace.id, + }, }, customer_email: user.email, success_url: frontBaseUrl, @@ -110,7 +119,48 @@ export class BillingController { 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, + @Req() req: RawBodyRequest, + @Res() res: Response, + ) { + if (!req.rawBody) { + res.status(400).send('Missing raw body'); + + return; + } + const event = this.stripeService.constructEventFromPayload( + signature, + req.rawBody, + ); + + if (event.type === WebhookEvent.CUSTOMER_SUBSCRIPTION_UPDATED) { + if (event.data.object.status !== 'active') { + res.status(402).send('Payment did not succeeded'); + + return; + } + + const workspaceId = event.data.object.metadata?.workspaceId; + + if (!workspaceId) { + res.status(404).send('Missing workspaceId in webhook event metadata'); + + return; + } + + await this.billingService.createBillingSubscription( + workspaceId, + event.data, + ); + + res.status(200).send('Subscription successfully updated'); + } + } } diff --git a/packages/twenty-server/src/core/billing/billing.module.ts b/packages/twenty-server/src/core/billing/billing.module.ts index 9a70d9d7f..6eb489cbe 100644 --- a/packages/twenty-server/src/core/billing/billing.module.ts +++ b/packages/twenty-server/src/core/billing/billing.module.ts @@ -1,12 +1,22 @@ 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'; @Module({ - imports: [StripeModule], + imports: [ + StripeModule, + TypeOrmModule.forFeature( + [BillingSubscription, BillingSubscriptionItem, Workspace], + 'core', + ), + ], controllers: [BillingController], providers: [EnvironmentModule, BillingService], }) diff --git a/packages/twenty-server/src/core/billing/billing.service.ts b/packages/twenty-server/src/core/billing/billing.service.ts index 357ba90ca..e43335914 100644 --- a/packages/twenty-server/src/core/billing/billing.service.ts +++ b/packages/twenty-server/src/core/billing/billing.service.ts @@ -1,9 +1,14 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; import Stripe from 'stripe'; +import { Repository } from 'typeorm'; import { EnvironmentService } from 'src/integrations/environment/environment.service'; 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'; export type PriceData = Partial< Record @@ -15,11 +20,22 @@ export enum RecurringInterval { MONTH = 'month', YEAR = 'year', } + +export enum WebhookEvent { + CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated', +} + @Injectable() export class BillingService { constructor( private readonly stripeService: StripeService, private readonly environmentService: EnvironmentService, + @InjectRepository(BillingSubscription, 'core') + private readonly billingSubscriptionRepository: Repository, + @InjectRepository(BillingSubscriptionItem, 'core') + private readonly billingSubscriptionItemRepository: Repository, + @InjectRepository(Workspace, 'core') + private readonly workspaceRepository: Repository, ) {} getProductStripeId(product: AvailableProduct) { @@ -55,4 +71,35 @@ export class BillingService { return result; } + + async createBillingSubscription( + workspaceId: string, + data: Stripe.CustomerSubscriptionUpdatedEvent.Data, + ) { + const billingSubscription = this.billingSubscriptionRepository.create({ + workspaceId: workspaceId, + stripeCustomerId: data.object.customer as string, + stripeSubscriptionId: data.object.id, + status: data.object.status, + }); + + await this.billingSubscriptionRepository.save(billingSubscription); + + for (const item of data.object.items.data) { + const billingSubscriptionItem = + this.billingSubscriptionItemRepository.create({ + billingSubscriptionId: billingSubscription.id, + stripeProductId: item.price.product as string, + stripePriceId: item.price.id, + quantity: item.quantity, + }); + + await this.billingSubscriptionItemRepository.save( + billingSubscriptionItem, + ); + } + await this.workspaceRepository.update(workspaceId, { + subscriptionStatus: 'active', + }); + } } diff --git a/packages/twenty-server/src/core/billing/stripe/stripe.service.ts b/packages/twenty-server/src/core/billing/stripe/stripe.service.ts index 52d0e138f..38bb9b23a 100644 --- a/packages/twenty-server/src/core/billing/stripe/stripe.service.ts +++ b/packages/twenty-server/src/core/billing/stripe/stripe.service.ts @@ -9,6 +9,20 @@ export class StripeService { public readonly stripe: Stripe; constructor(private readonly environmentService: EnvironmentService) { - this.stripe = new Stripe(this.environmentService.getStripeApiKey(), {}); + this.stripe = new Stripe( + this.environmentService.getBillingStripeApiKey(), + {}, + ); + } + + constructEventFromPayload(signature: string, payload: Buffer) { + const webhookSecret = + this.environmentService.getBillingStripeWebhookSecret(); + + return this.stripe.webhooks.constructEvent( + payload, + signature, + webhookSecret, + ); } } diff --git a/packages/twenty-server/src/integrations/environment/environment.service.ts b/packages/twenty-server/src/integrations/environment/environment.service.ts index b8c4f676e..2465a07b3 100644 --- a/packages/twenty-server/src/integrations/environment/environment.service.ts +++ b/packages/twenty-server/src/integrations/environment/environment.service.ts @@ -32,6 +32,23 @@ export class EnvironmentService { return this.configService.get('BILLING_PLAN_REQUIRED_LINK') ?? ''; } + getBillingStripeBasePlanProductId(): string { + return ( + this.configService.get('BILLING_STRIPE_BASE_PLAN_PRODUCT_ID') ?? + '' + ); + } + + getBillingStripeApiKey(): string { + return this.configService.get('BILLING_STRIPE_API_KEY') ?? ''; + } + + getBillingStripeWebhookSecret(): string { + return ( + this.configService.get('BILLING_STRIPE_WEBHOOK_SECRET') ?? '' + ); + } + isTelemetryEnabled(): boolean { return this.configService.get('TELEMETRY_ENABLED') ?? true; } @@ -305,15 +322,4 @@ export class EnvironmentService { this.configService.get('MUTATION_MAXIMUM_RECORD_AFFECTED') ?? 100 ); } - - getBillingStripeBasePlanProductId(): string { - return ( - this.configService.get('BILLING_STRIPE_BASE_PLAN_PRODUCT_ID') ?? - '' - ); - } - - getStripeApiKey(): string { - return this.configService.get('STRIPE_API_KEY') ?? ''; - } } diff --git a/packages/twenty-server/src/integrations/environment/environment.validation.ts b/packages/twenty-server/src/integrations/environment/environment.validation.ts index 483511727..bd21b8193 100644 --- a/packages/twenty-server/src/integrations/environment/environment.validation.ts +++ b/packages/twenty-server/src/integrations/environment/environment.validation.ts @@ -44,17 +44,21 @@ export class EnvironmentVariables { @IsBoolean() IS_BILLING_ENABLED?: boolean; - @IsOptional() @IsString() - BILLING_URL?: string; + @ValidateIf((env) => env.IS_BILLING_ENABLED === true) + BILLING_PLAN_REQUIRED_LINK?: string; - @IsOptional() @IsString() + @ValidateIf((env) => env.IS_BILLING_ENABLED === true) BILLING_STRIPE_BASE_PLAN_PRODUCT_ID?: string; - @IsOptional() @IsString() - STRIPE_API_KEY?: string; + @ValidateIf((env) => env.IS_BILLING_ENABLED === true) + BILLING_STRIPE_API_KEY?: string; + + @IsString() + @ValidateIf((env) => env.IS_BILLING_ENABLED === true) + BILLING_STRIPE_WEBHOOK_SECRET?: string; @CastToBoolean() @IsOptional() diff --git a/packages/twenty-server/src/main.ts b/packages/twenty-server/src/main.ts index c0c8a0369..8bba5ce2b 100644 --- a/packages/twenty-server/src/main.ts +++ b/packages/twenty-server/src/main.ts @@ -1,7 +1,7 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; +import { NestExpressApplication } from '@nestjs/platform-express'; -import * as bodyParser from 'body-parser'; import { graphqlUploadExpress } from 'graphql-upload'; import bytes from 'bytes'; import { useContainer } from 'class-validator'; @@ -14,9 +14,10 @@ import { LoggerService } from './integrations/logger/logger.service'; import { EnvironmentService } from './integrations/environment/environment.service'; const bootstrap = async () => { - const app = await NestFactory.create(AppModule, { + const app = await NestFactory.create(AppModule, { cors: true, bufferLogs: process.env.LOGGER_IS_BUFFER_ENABLED === 'true', + rawBody: true, }); const logger = app.get(LoggerService); @@ -32,14 +33,11 @@ const bootstrap = async () => { transform: true, }), ); - - app.use(bodyParser.json({ limit: settings.storage.maxFileSize })); - app.use( - bodyParser.urlencoded({ - limit: settings.storage.maxFileSize, - extended: true, - }), - ); + app.useBodyParser('json', { limit: settings.storage.maxFileSize }); + app.useBodyParser('urlencoded', { + limit: settings.storage.maxFileSize, + extended: true, + }); // Graphql file upload app.use(