38 add billing webhook endpoint (#4158)
* 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 <charles@twenty.com>
This commit is contained in:
@ -3,6 +3,10 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
|
Headers,
|
||||||
|
Req,
|
||||||
|
RawBodyRequest,
|
||||||
|
Logger,
|
||||||
Post,
|
Post,
|
||||||
Res,
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@ -15,6 +19,7 @@ import {
|
|||||||
BillingService,
|
BillingService,
|
||||||
PriceData,
|
PriceData,
|
||||||
RecurringInterval,
|
RecurringInterval,
|
||||||
|
WebhookEvent,
|
||||||
} from 'src/core/billing/billing.service';
|
} from 'src/core/billing/billing.service';
|
||||||
import { StripeService } from 'src/core/billing/stripe/stripe.service';
|
import { StripeService } from 'src/core/billing/stripe/stripe.service';
|
||||||
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||||
@ -24,6 +29,8 @@ import { JwtAuthGuard } from 'src/guards/jwt.auth.guard';
|
|||||||
|
|
||||||
@Controller('billing')
|
@Controller('billing')
|
||||||
export class BillingController {
|
export class BillingController {
|
||||||
|
protected readonly logger = new Logger(BillingController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly stripeService: StripeService,
|
private readonly stripeService: StripeService,
|
||||||
private readonly billingService: BillingService,
|
private readonly billingService: BillingService,
|
||||||
@ -97,8 +104,10 @@ export class BillingController {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
metadata: {
|
subscription_data: {
|
||||||
workspaceId: user.defaultWorkspace.id,
|
metadata: {
|
||||||
|
workspaceId: user.defaultWorkspace.id,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
customer_email: user.email,
|
customer_email: user.email,
|
||||||
success_url: frontBaseUrl,
|
success_url: frontBaseUrl,
|
||||||
@ -110,7 +119,48 @@ export class BillingController {
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.logger.log(`Stripe Checkout Session Url Redirection: ${session.url}`);
|
||||||
|
|
||||||
res.redirect(303, 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,22 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { BillingController } from 'src/core/billing/billing.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';
|
||||||
|
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({
|
@Module({
|
||||||
imports: [StripeModule],
|
imports: [
|
||||||
|
StripeModule,
|
||||||
|
TypeOrmModule.forFeature(
|
||||||
|
[BillingSubscription, BillingSubscriptionItem, Workspace],
|
||||||
|
'core',
|
||||||
|
),
|
||||||
|
],
|
||||||
controllers: [BillingController],
|
controllers: [BillingController],
|
||||||
providers: [EnvironmentModule, BillingService],
|
providers: [EnvironmentModule, BillingService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
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';
|
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<
|
export type PriceData = Partial<
|
||||||
Record<Stripe.Price.Recurring.Interval, Stripe.Price>
|
Record<Stripe.Price.Recurring.Interval, Stripe.Price>
|
||||||
@ -15,11 +20,22 @@ export enum RecurringInterval {
|
|||||||
MONTH = 'month',
|
MONTH = 'month',
|
||||||
YEAR = 'year',
|
YEAR = 'year',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum WebhookEvent {
|
||||||
|
CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated',
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BillingService {
|
export class BillingService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly stripeService: StripeService,
|
private readonly stripeService: StripeService,
|
||||||
private readonly environmentService: EnvironmentService,
|
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) {
|
getProductStripeId(product: AvailableProduct) {
|
||||||
@ -55,4 +71,35 @@ export class BillingService {
|
|||||||
|
|
||||||
return result;
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,20 @@ export class StripeService {
|
|||||||
public readonly stripe: Stripe;
|
public readonly stripe: Stripe;
|
||||||
|
|
||||||
constructor(private readonly environmentService: EnvironmentService) {
|
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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,23 @@ export class EnvironmentService {
|
|||||||
return this.configService.get<string>('BILLING_PLAN_REQUIRED_LINK') ?? '';
|
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 {
|
isTelemetryEnabled(): boolean {
|
||||||
return this.configService.get<boolean>('TELEMETRY_ENABLED') ?? true;
|
return this.configService.get<boolean>('TELEMETRY_ENABLED') ?? true;
|
||||||
}
|
}
|
||||||
@ -305,15 +322,4 @@ export class EnvironmentService {
|
|||||||
this.configService.get<number>('MUTATION_MAXIMUM_RECORD_AFFECTED') ?? 100
|
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') ?? '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,17 +44,21 @@ export class EnvironmentVariables {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
IS_BILLING_ENABLED?: boolean;
|
IS_BILLING_ENABLED?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
@IsString()
|
||||||
BILLING_URL?: string;
|
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
|
||||||
|
BILLING_PLAN_REQUIRED_LINK?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
|
||||||
BILLING_STRIPE_BASE_PLAN_PRODUCT_ID?: string;
|
BILLING_STRIPE_BASE_PLAN_PRODUCT_ID?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
@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()
|
@CastToBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
|
||||||
import * as bodyParser from 'body-parser';
|
|
||||||
import { graphqlUploadExpress } from 'graphql-upload';
|
import { graphqlUploadExpress } from 'graphql-upload';
|
||||||
import bytes from 'bytes';
|
import bytes from 'bytes';
|
||||||
import { useContainer } from 'class-validator';
|
import { useContainer } from 'class-validator';
|
||||||
@ -14,9 +14,10 @@ import { LoggerService } from './integrations/logger/logger.service';
|
|||||||
import { EnvironmentService } from './integrations/environment/environment.service';
|
import { EnvironmentService } from './integrations/environment/environment.service';
|
||||||
|
|
||||||
const bootstrap = async () => {
|
const bootstrap = async () => {
|
||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||||
cors: true,
|
cors: true,
|
||||||
bufferLogs: process.env.LOGGER_IS_BUFFER_ENABLED === 'true',
|
bufferLogs: process.env.LOGGER_IS_BUFFER_ENABLED === 'true',
|
||||||
|
rawBody: true,
|
||||||
});
|
});
|
||||||
const logger = app.get(LoggerService);
|
const logger = app.get(LoggerService);
|
||||||
|
|
||||||
@ -32,14 +33,11 @@ const bootstrap = async () => {
|
|||||||
transform: true,
|
transform: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
app.useBodyParser('json', { limit: settings.storage.maxFileSize });
|
||||||
app.use(bodyParser.json({ limit: settings.storage.maxFileSize }));
|
app.useBodyParser('urlencoded', {
|
||||||
app.use(
|
limit: settings.storage.maxFileSize,
|
||||||
bodyParser.urlencoded({
|
extended: true,
|
||||||
limit: settings.storage.maxFileSize,
|
});
|
||||||
extended: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Graphql file upload
|
// Graphql file upload
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
Reference in New Issue
Block a user