Skip to content

Commit

Permalink
38 add billing webhook endpoint (#4158)
Browse files Browse the repository at this point in the history
* 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

* Add BILLING_STRIPE_WEBHOOK_SECRET env variable

* WIP: add webhook endpoint

* Fix body parser

* Create Billing Subscription on payment success

* Set subscriptionStatus active on webhook

* Add useful log

---------

Co-authored-by: Charles Bochet <[email protected]>
  • Loading branch information
martmull and charlesBochet authored Feb 24, 2024
1 parent c96e210 commit 05c2060
Show file tree
Hide file tree
Showing 7 changed files with 159 additions and 30 deletions.
54 changes: 52 additions & 2 deletions packages/twenty-server/src/core/billing/billing.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import {
Controller,
Get,
Param,
Headers,
Req,
RawBodyRequest,
Logger,
Post,
Res,
UseGuards,
Expand All @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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<Request>,
@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');
}
}
}
12 changes: 11 additions & 1 deletion packages/twenty-server/src/core/billing/billing.module.ts
Original file line number Diff line number Diff line change
@@ -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],
})
Expand Down
47 changes: 47 additions & 0 deletions packages/twenty-server/src/core/billing/billing.service.ts
Original file line number Diff line number Diff line change
@@ -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<Stripe.Price.Recurring.Interval, Stripe.Price>
Expand All @@ -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<BillingSubscription>,
@InjectRepository(BillingSubscriptionItem, 'core')
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {}

getProductStripeId(product: AvailableProduct) {
Expand Down Expand Up @@ -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',
});
}
}
16 changes: 15 additions & 1 deletion packages/twenty-server/src/core/billing/stripe/stripe.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@ export class EnvironmentService {
return this.configService.get<string>('BILLING_PLAN_REQUIRED_LINK') ?? '';
}

getBillingStripeBasePlanProductId(): string {
return (
this.configService.get<string>('BILLING_STRIPE_BASE_PLAN_PRODUCT_ID') ??
''
);
}

getBillingStripeApiKey(): string {
return this.configService.get<string>('BILLING_STRIPE_API_KEY') ?? '';
}

getBillingStripeWebhookSecret(): string {
return (
this.configService.get<string>('BILLING_STRIPE_WEBHOOK_SECRET') ?? ''
);
}

isTelemetryEnabled(): boolean {
return this.configService.get<boolean>('TELEMETRY_ENABLED') ?? true;
}
Expand Down Expand Up @@ -305,15 +322,4 @@ export class EnvironmentService {
this.configService.get<number>('MUTATION_MAXIMUM_RECORD_AFFECTED') ?? 100
);
}

getBillingStripeBasePlanProductId(): string {
return (
this.configService.get<string>('BILLING_STRIPE_BASE_PLAN_PRODUCT_ID') ??
''
);
}

getStripeApiKey(): string {
return this.configService.get<string>('STRIPE_API_KEY') ?? '';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
18 changes: 8 additions & 10 deletions packages/twenty-server/src/main.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<NestExpressApplication>(AppModule, {
cors: true,
bufferLogs: process.env.LOGGER_IS_BUFFER_ENABLED === 'true',
rawBody: true,
});
const logger = app.get(LoggerService);

Expand All @@ -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(
Expand Down

0 comments on commit 05c2060

Please sign in to comment.