Skip to content

Commit

Permalink
47 add stripe checkout endpoint (#4147)
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
  • Loading branch information
martmull authored Feb 24, 2024
1 parent c434d1e commit c96e210
Show file tree
Hide file tree
Showing 4 changed files with 135 additions and 50 deletions.
116 changes: 116 additions & 0 deletions packages/twenty-server/src/core/billing/billing.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
4 changes: 2 additions & 2 deletions packages/twenty-server/src/core/billing/billing.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
19 changes: 17 additions & 2 deletions packages/twenty-server/src/core/billing/billing.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,39 @@ 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<Stripe.Price.Recurring.Interval, Stripe.Price>
>;
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) {
return this.environmentService.getBillingStripeBasePlanProductId();
}
}

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 = {};

Expand Down

This file was deleted.

0 comments on commit c96e210

Please sign in to comment.