46 add stripe product endpoint (#4133)
* 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
This commit is contained in:
@ -1,6 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ProductPriceController } from 'src/core/billing/controllers/product-price.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({
|
@Module({
|
||||||
imports: [],
|
imports: [StripeModule],
|
||||||
|
controllers: [ProductPriceController],
|
||||||
|
providers: [EnvironmentModule, BillingService],
|
||||||
})
|
})
|
||||||
export class BillingModule {}
|
export class BillingModule {}
|
||||||
|
|||||||
43
packages/twenty-server/src/core/billing/billing.service.ts
Normal file
43
packages/twenty-server/src/core/billing/billing.service.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
|
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||||
|
|
||||||
|
export type PriceData = Partial<
|
||||||
|
Record<Stripe.Price.Recurring.Interval, Stripe.Price>
|
||||||
|
>;
|
||||||
|
export enum AvailableProduct {
|
||||||
|
BasePlan = 'base-plan',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BillingService {
|
||||||
|
constructor(private readonly environmentService: EnvironmentService) {}
|
||||||
|
|
||||||
|
getProductStripeId(product: AvailableProduct) {
|
||||||
|
if (product === AvailableProduct.BasePlan) {
|
||||||
|
return this.environmentService.getBillingStripeBasePlanProductId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatProductPrices(prices: Stripe.Price[]) {
|
||||||
|
const result: PriceData = {};
|
||||||
|
|
||||||
|
prices.forEach((item) => {
|
||||||
|
const recurringInterval = item.recurring?.interval;
|
||||||
|
|
||||||
|
if (!recurringInterval) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!result[recurringInterval] ||
|
||||||
|
item.created > (result[recurringInterval]?.created || 0)
|
||||||
|
) {
|
||||||
|
result[recurringInterval] = item;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
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<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const productPrices = await this.stripeService.stripe.prices.search({
|
||||||
|
query: `product: '${stripeProductId}'`,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(this.billingService.formatProductPrices(productPrices.data));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { StripeService } from 'src/core/billing/stripe/stripe.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [StripeService],
|
||||||
|
exports: [StripeService],
|
||||||
|
})
|
||||||
|
export class StripeModule {}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
|
import { EnvironmentService } from 'src/integrations/environment/environment.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StripeService {
|
||||||
|
public readonly stripe: Stripe;
|
||||||
|
|
||||||
|
constructor(private readonly environmentService: EnvironmentService) {
|
||||||
|
this.stripe = new Stripe(this.environmentService.getStripeApiKey(), {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -305,4 +305,15 @@ 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') ?? '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,6 +48,14 @@ export class EnvironmentVariables {
|
|||||||
@IsString()
|
@IsString()
|
||||||
BILLING_URL?: string;
|
BILLING_URL?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
BILLING_STRIPE_BASE_PLAN_PRODUCT_ID?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
STRIPE_API_KEY?: string;
|
||||||
|
|
||||||
@CastToBoolean()
|
@CastToBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@ -83,21 +91,25 @@ export class EnvironmentVariables {
|
|||||||
// Json Web Token
|
// Json Web Token
|
||||||
@IsString()
|
@IsString()
|
||||||
ACCESS_TOKEN_SECRET: string;
|
ACCESS_TOKEN_SECRET: string;
|
||||||
|
|
||||||
@IsDuration()
|
@IsDuration()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
ACCESS_TOKEN_EXPIRES_IN: string;
|
ACCESS_TOKEN_EXPIRES_IN: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
REFRESH_TOKEN_SECRET: string;
|
REFRESH_TOKEN_SECRET: string;
|
||||||
|
|
||||||
@IsDuration()
|
@IsDuration()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
REFRESH_TOKEN_EXPIRES_IN: string;
|
REFRESH_TOKEN_EXPIRES_IN: string;
|
||||||
|
|
||||||
@IsDuration()
|
@IsDuration()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
REFRESH_TOKEN_COOL_DOWN: string;
|
REFRESH_TOKEN_COOL_DOWN: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
LOGIN_TOKEN_SECRET: string;
|
LOGIN_TOKEN_SECRET: string;
|
||||||
|
|
||||||
@IsDuration()
|
@IsDuration()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
LOGIN_TOKEN_EXPIRES_IN: string;
|
LOGIN_TOKEN_EXPIRES_IN: string;
|
||||||
@ -205,7 +217,6 @@ export class EnvironmentVariables {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
MUTATION_MAXIMUM_RECORD_AFFECTED: number;
|
MUTATION_MAXIMUM_RECORD_AFFECTED: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const validate = (config: Record<string, unknown>) => {
|
export const validate = (config: Record<string, unknown>) => {
|
||||||
const validatedConfig = plainToClass(EnvironmentVariables, config);
|
const validatedConfig = plainToClass(EnvironmentVariables, config);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user