feat: Billing Service (#14756)

* chore: Billing Service

* chore: others

* chore: don't type

* chore: typo

* chore: use proper wh secret

* chore: updates

* chore: lockfile

* chore: docs and comment out

* chore: updates and include plan

* chore: config

* chore: bring back alias

* chore: update lockfile

* chore: refactor

* chore: default none plan in db

* chore: read from env vars

* chore: feedback

* chore: remove json config

* chore: proper type
This commit is contained in:
Erik
2024-04-30 20:02:04 +00:00
committed by GitHub
parent a51024e915
commit 1cfd2f7053
38 changed files with 664 additions and 133 deletions
+10 -1
View File
@@ -10,4 +10,13 @@ JWT_SECRET=
SENTRY_DSN=
# KEEP THIS EMPTY, DISABLE SENTRY CLIENT INSIDE OF LIBRARIES USED BY APIv2
NEXT_PUBLIC_SENTRY_DSN=
NEXT_PUBLIC_SENTRY_DSN=
# Stripe Billing
STRIPE_PRICE_ID_STARTER=
STRIPE_PRICE_ID_ESSENTIALS=
STRIPE_PRICE_ID_ENTERPRISE=
STRIPE_API_KEY=
STRIPE_WEBHOOK_SECRET=
WEB_APP_URL=http://localhost:3000/
+3 -1
View File
@@ -13,7 +13,7 @@
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build && yarn workspace @calcom/platform-libraries build",
"dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && yarn start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node ./dist/apps/api/v2/src/main.js",
"start:prod": "SKIP_DOCS_GENERATION=1 node ./dist/apps/api/v2/src/main.js",
"test": "yarn dev:build && jest",
"test:watch": "yarn dev:build && jest --watch",
"test:cov": "yarn dev:build && jest --coverage",
@@ -40,6 +40,7 @@
"@nestjs/throttler": "^5.1.2",
"@sentry/node": "^7.86.0",
"@sentry/tracing": "^7.86.0",
"body-parser": "^1.20.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
@@ -56,6 +57,7 @@
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"stripe": "^15.3.0",
"uuid": "^8.3.2",
"winston": "^3.11.0",
"zod": "^3.22.4"
+18 -6
View File
@@ -1,16 +1,18 @@
import { AppLoggerMiddleware } from "@/app.logger.middleware";
import { RewriterMiddleware } from "@/app.rewrites.middleware";
import appConfig from "@/config/app";
import { AppLoggerMiddleware } from "@/middleware/app.logger.middleware";
import { RewriterMiddleware } from "@/middleware/app.rewrites.middleware";
import { JsonBodyMiddleware } from "@/middleware/body/json.body.middleware";
import { RawBodyMiddleware } from "@/middleware/body/raw.body.middleware";
import { AuthModule } from "@/modules/auth/auth.module";
import { EndpointsModule } from "@/modules/endpoints.module";
import { JwtModule } from "@/modules/jwt/jwt.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { RedisService } from "@/modules/redis/redis.service";
import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { RouterModule } from "@nestjs/core";
import { ThrottlerModule, seconds } from "@nestjs/throttler";
import { seconds, ThrottlerModule } from "@nestjs/throttler";
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
import { AppController } from "./app.controller";
@@ -53,7 +55,17 @@ import { AppController } from "./app.controller";
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(AppLoggerMiddleware).forRoutes("*");
consumer.apply(RewriterMiddleware).forRoutes("/");
consumer
.apply(RawBodyMiddleware)
.forRoutes({
path: "/api/v2/billing/webhook",
method: RequestMethod.POST,
})
.apply(JsonBodyMiddleware)
.forRoutes("*")
.apply(AppLoggerMiddleware)
.forRoutes("*")
.apply(RewriterMiddleware)
.forRoutes("/");
}
}
+7
View File
@@ -24,6 +24,13 @@ const loadConfig = (): AppConfig => {
next: {
authSecret: getEnv("NEXTAUTH_SECRET"),
},
stripe: {
apiKey: getEnv("STRIPE_API_KEY"),
webhookSecret: getEnv("STRIPE_WEBHOOK_SECRET"),
},
app: {
baseUrl: getEnv("WEB_APP_URL", "https://app.cal.com"),
},
};
};
+7
View File
@@ -15,4 +15,11 @@ export type AppConfig = {
next: {
authSecret: string;
};
stripe: {
apiKey: string;
webhookSecret: string;
};
app: {
baseUrl: string;
};
};
@@ -1,4 +1,5 @@
import { BookingsController } from "@/ee/bookings/controllers/bookings.controller";
import { BillingModule } from "@/modules/billing/billing.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
@@ -8,7 +9,7 @@ import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, RedisModule, TokensModule],
imports: [PrismaModule, RedisModule, TokensModule, BillingModule],
providers: [TokensRepository, OAuthFlowService, OAuthClientRepository],
controllers: [BookingsController],
})
@@ -6,6 +6,7 @@ import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { BillingService } from "@/modules/billing/services/billing.service";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
@@ -77,12 +78,13 @@ const DEFAULT_PLATFORM_PARAMS = {
@UseGuards(PermissionsGuard)
@DocsTags("Bookings")
export class BookingsController {
private readonly logger = new Logger("ee bookings controller");
private readonly logger = new Logger("BookingsController");
constructor(
private readonly oAuthFlowService: OAuthFlowService,
private readonly prismaReadService: PrismaReadService,
private readonly oAuthClientRepository: OAuthClientRepository
private readonly oAuthClientRepository: OAuthClientRepository,
private readonly billingService: BillingService
) {}
@Get("/")
@@ -153,12 +155,14 @@ export class BookingsController {
const booking = await handleNewBooking(
await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl)
);
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
return {
status: SUCCESS_STATUS,
data: booking,
};
} catch (err) {
handleBookingErrors(err);
this.handleBookingErrors(err);
}
throw new InternalServerErrorException("Could not create booking.");
}
@@ -179,7 +183,7 @@ export class BookingsController {
status: SUCCESS_STATUS,
};
} catch (err) {
handleBookingErrors(err);
this.handleBookingErrors(err);
}
} else {
throw new NotFoundException("Booking ID is required.");
@@ -198,12 +202,15 @@ export class BookingsController {
const createdBookings: BookingResponse[] = await handleNewRecurringBooking(
await this.createNextApiBookingRequest(req, oAuthClientId)
);
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
return {
status: SUCCESS_STATUS,
data: createdBookings,
};
} catch (err) {
handleBookingErrors(err, "recurring");
this.handleBookingErrors(err, "recurring");
}
throw new InternalServerErrorException("Could not create recurring booking.");
}
@@ -220,17 +227,20 @@ export class BookingsController {
const instantMeeting = await handleInstantMeeting(
await this.createNextApiBookingRequest(req, oAuthClientId)
);
void (await this.billingService.increaseUsageByClientId(oAuthClientId!));
return {
status: SUCCESS_STATUS,
data: instantMeeting,
};
} catch (err) {
handleBookingErrors(err, "instant");
this.handleBookingErrors(err, "instant");
}
throw new InternalServerErrorException("Could not create instant booking.");
}
async getOwnerId(req: Request): Promise<number | undefined> {
private async getOwnerId(req: Request): Promise<number | undefined> {
try {
const accessToken = req.get("Authorization")?.replace("Bearer ", "");
if (accessToken) {
@@ -241,7 +251,7 @@ export class BookingsController {
}
}
async getOAuthClientsParams(req: BookingRequest, clientId: string): Promise<OAuthRequestParams> {
private async getOAuthClientsParams(clientId: string): Promise<OAuthRequestParams> {
const res = DEFAULT_PLATFORM_PARAMS;
try {
const client = await this.oAuthClientRepository.getOAuthClient(clientId);
@@ -260,32 +270,32 @@ export class BookingsController {
}
}
async createNextApiBookingRequest(
private async createNextApiBookingRequest(
req: BookingRequest,
oAuthClientId?: string,
platformBookingLocation?: string
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
const userId = (await this.getOwnerId(req)) ?? -1;
const oAuthParams = oAuthClientId
? await this.getOAuthClientsParams(req, oAuthClientId)
? await this.getOAuthClientsParams(oAuthClientId)
: DEFAULT_PLATFORM_PARAMS;
Object.assign(req, { userId, ...oAuthParams, platformBookingLocation });
req.body = { ...req.body, noEmail: !oAuthParams.arePlatformEmailsEnabled };
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
}
}
function handleBookingErrors(err: Error | HttpError | unknown, type?: "recurring" | `instant`): void {
const errMsg = `Error while creating ${type ? type + " " : ""}booking.`;
if (err instanceof HttpError) {
const httpError = err as HttpError;
throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500);
private handleBookingErrors(err: Error | HttpError | unknown, type?: "recurring" | `instant`): void {
const errMsg = `Error while creating ${type ? type + " " : ""}booking.`;
if (err instanceof HttpError) {
const httpError = err as HttpError;
throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500);
}
if (err instanceof Error) {
const error = err as Error;
throw new InternalServerErrorException(error?.message ?? errMsg);
}
throw new InternalServerErrorException(errMsg);
}
if (err instanceof Error) {
const error = err as Error;
throw new InternalServerErrorException(error?.message ?? errMsg);
}
throw new InternalServerErrorException(errMsg);
}
+3
View File
@@ -12,6 +12,9 @@ export type Environment = {
SENTRY_DSN: string;
LOG_LEVEL: keyof typeof logLevels;
REDIS_URL: string;
STRIPE_API_KEY: string;
STRIPE_WEBHOOK_SECRET: string;
WEB_APP_URL: string;
};
export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environment[K]): Environment[K] => {
+4 -1
View File
@@ -16,6 +16,7 @@ import { loggerConfig } from "./lib/logger";
const run = async () => {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: WinstonModule.createLogger(loggerConfig()),
bodyParser: false,
});
const logger = new Logger("App");
@@ -23,7 +24,9 @@ const run = async () => {
try {
bootstrap(app);
const port = app.get(ConfigService<AppConfig, true>).get("api.port", { infer: true });
generateSwagger(app);
if (process.env.SKIP_DOCS_GENERATION !== "1") {
void generateSwagger(app);
}
await app.listen(port);
logger.log(`Application started on port: ${port}`);
} catch (error) {
@@ -0,0 +1,10 @@
import { Injectable, NestMiddleware } from "@nestjs/common";
import * as bodyParser from "body-parser";
import type { Request, Response } from "express";
@Injectable()
export class JsonBodyMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: () => any) {
bodyParser.json()(req, res, next);
}
}
@@ -0,0 +1,10 @@
import { Injectable, NestMiddleware } from "@nestjs/common";
import * as bodyParser from "body-parser";
import type { Request, Response } from "express";
@Injectable()
export class RawBodyMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: () => any) {
bodyParser.raw({ type: "*/*" })(req, res, next);
}
}
@@ -0,0 +1,17 @@
import { BillingRepository } from "@/modules/billing/billing.repository";
import { BillingController } from "@/modules/billing/controllers/billing.controller";
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
import { BillingService } from "@/modules/billing/services/billing.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, StripeModule, MembershipsModule, OrganizationsModule],
providers: [BillingConfigService, BillingService, BillingRepository],
exports: [BillingService, BillingRepository],
controllers: [BillingController],
})
export class BillingModule {}
@@ -0,0 +1,36 @@
import { PlatformPlan } from "@/modules/billing/types";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class BillingRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
getBillingForTeam = (teamId: number) =>
this.dbRead.prisma.platformBilling.findUnique({
where: {
id: teamId,
},
});
async updateTeamBilling(
teamId: number,
billingStart: number,
billingEnd: number,
plan: PlatformPlan,
subscription?: string
) {
return this.dbWrite.prisma.platformBilling.update({
where: {
id: teamId,
},
data: {
billingCycleStart: billingStart,
billingCycleEnd: billingEnd,
subscriptionId: subscription,
plan: plan.toString(),
},
});
}
}
@@ -0,0 +1,126 @@
import { AppConfig } from "@/config/type";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input";
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
import { SubscribeTeamToBillingResponseDto } from "@/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto";
import { BillingService } from "@/modules/billing/services/billing.service";
import { PlatformPlan } from "@/modules/billing/types";
import {
BadRequestException,
Body,
Controller,
Get,
Param,
Post,
Req,
UseGuards,
Headers,
HttpCode,
HttpStatus,
Logger,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiExcludeController } from "@nestjs/swagger";
import { Request } from "express";
import { Stripe } from "stripe";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
path: "/billing",
version: "2",
})
@ApiExcludeController(true)
export class BillingController {
private readonly stripeWhSecret: string;
private logger = new Logger("Billing Controller");
constructor(
private readonly billingService: BillingService,
private readonly configService: ConfigService<AppConfig>
) {
this.stripeWhSecret = configService.get("stripe.webhookSecret", { infer: true }) ?? "";
}
@Get("/:teamId/check")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@Roles(["OWNER", "ADMIN"])
async checkTeamBilling(
@Param("teamId") teamId: number
): Promise<ApiResponse<CheckPlatformBillingResponseDto>> {
const { status, plan } = await this.billingService.getBillingData(teamId);
return {
status: "success",
data: {
valid: status === "valid",
plan,
},
};
}
@Post("/:teamId/subscribe")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@Roles(["OWNER", "ADMIN"])
async subscribeTeamToStripe(
@Param("teamId") teamId: number,
@Body() input: SubscribeToPlanInput
): Promise<ApiResponse<SubscribeTeamToBillingResponseDto | undefined>> {
const { status } = await this.billingService.getBillingData(teamId);
if (status === "valid") {
throw new BadRequestException("This team is already subscribed to a plan.");
}
const { action, url } = await this.billingService.createSubscriptionForTeam(teamId, input.plan);
if (action === "redirect") {
return {
status: "success",
data: {
action: "redirect",
url,
},
};
}
return {
status: "success",
};
}
@Post("/webhook")
@HttpCode(HttpStatus.OK)
async stripeWebhook(
@Req() request: Request,
@Headers("stripe-signature") stripeSignature: string
): Promise<ApiResponse> {
const event = await this.billingService.stripeService.stripe.webhooks.constructEventAsync(
request.body,
stripeSignature,
this.stripeWhSecret
);
if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated") {
const subscription = event.data.object as Stripe.Subscription;
const teamId = Number.parseInt(subscription.metadata.teamId);
const plan = subscription.metadata.plan;
if (!plan || !teamId) {
throw new Error("Invalid webhook received.");
}
await this.billingService.setSubscriptionForTeam(
teamId,
subscription,
PlatformPlan[plan as keyof typeof PlatformPlan]
);
return {
status: "success",
};
}
throw new BadRequestException(`Unhandled event type ${event.type}`);
}
}
@@ -0,0 +1,7 @@
import { PlatformPlan } from "@/modules/billing/types";
import { IsEnum } from "class-validator";
export class SubscribeToPlanInput {
@IsEnum(PlatformPlan)
plan!: PlatformPlan;
}
@@ -0,0 +1,5 @@
export class CheckPlatformBillingResponseDto {
valid!: boolean;
plan?: string;
}
@@ -0,0 +1,4 @@
export class SubscribeTeamToBillingResponseDto {
url?: string;
action?: "redirect";
}
@@ -0,0 +1,23 @@
import { PlatformPlan } from "@/modules/billing/types";
import { Injectable } from "@nestjs/common";
@Injectable()
export class BillingConfigService {
private readonly config: Map<PlatformPlan, string>;
constructor() {
this.config = new Map<PlatformPlan, string>();
const planKeys = Object.keys(PlatformPlan).filter((key) => isNaN(Number(key)));
for (const key of planKeys) {
this.config.set(
PlatformPlan[key as keyof typeof PlatformPlan],
process.env[`STRIPE_PRICE_ID_${key}`] ?? ""
);
}
}
get(plan: PlatformPlan): string | undefined {
return this.config.get(plan);
}
}
@@ -0,0 +1,126 @@
import { AppConfig } from "@/config/type";
import { BillingRepository } from "@/modules/billing/billing.repository";
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
import { PlatformPlan } from "@/modules/billing/types";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { StripeService } from "@/modules/stripe/stripe.service";
import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { DateTime } from "luxon";
import Stripe from "stripe";
@Injectable()
export class BillingService {
private logger = new Logger("BillingService");
private readonly webAppUrl: string;
constructor(
private readonly teamsRepository: OrganizationsRepository,
public readonly stripeService: StripeService,
private readonly billingRepository: BillingRepository,
private readonly configService: ConfigService<AppConfig>,
private readonly billingConfigService: BillingConfigService
) {
this.webAppUrl = configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com";
}
async getBillingData(teamId: number) {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
if (teamWithBilling?.platformBilling) {
if (!teamWithBilling?.platformBilling.subscriptionId) {
return { team: teamWithBilling, status: "no_subscription", plan: "none" };
}
return { team: teamWithBilling, status: "valid", plan: teamWithBilling.platformBilling.plan };
} else {
return { team: teamWithBilling, status: "no_billing", plan: "none" };
}
}
async createSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
let brandNewBilling = false;
let customerId = teamWithBilling?.platformBilling?.customerId;
if (!teamWithBilling?.platformBilling) {
brandNewBilling = true;
customerId = await this.teamsRepository.createNewBillingRelation(teamId);
this.logger.log("Team had no Stripe Customer ID, created one for them.", {
id: teamId,
stripeId: customerId,
});
}
if (brandNewBilling || !teamWithBilling?.platformBilling?.subscriptionId) {
const { url } = await this.stripeService.stripe.checkout.sessions.create({
customer: customerId,
line_items: [
{
price: this.billingConfigService.get(plan),
quantity: 1,
},
],
success_url: `${this.webAppUrl}/settings/platform/oauth-clients`,
cancel_url: `${this.webAppUrl}/settings/platform/oauth-clients`,
mode: "subscription",
metadata: {
teamId: teamId.toString(),
plan: plan.toString(),
},
subscription_data: {
metadata: {
teamId: teamId.toString(),
plan: plan.toString(),
},
},
});
if (!url) throw new InternalServerErrorException("Failed to create Stripe session.");
return { action: "redirect", url };
}
return { action: "none" };
}
async setSubscriptionForTeam(teamId: number, subscription: Stripe.Subscription, plan: PlatformPlan) {
const billingCycleStart = DateTime.now().get("day");
const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day");
return this.billingRepository.updateTeamBilling(
teamId,
billingCycleStart,
billingCycleEnd,
plan,
subscription.id
);
}
async increaseUsageForTeam(teamId: number) {
// TODO - if we support multiple subscription items per team, we may need to track which plan they're
// subscribed to so we can do one less query.
const billingSubscription = await this.billingRepository.getBillingForTeam(teamId);
if (!billingSubscription || !billingSubscription?.subscriptionId) {
throw new Error(`Failed to increase usage for team ${teamId}`);
}
const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve(
billingSubscription.subscriptionId
);
const items = stripeSubscription.items.data[0]; // first (and only) subscription item.
await this.stripeService.stripe.subscriptionItems.createUsageRecord(items.id, {
action: "increment",
quantity: 1,
timestamp: "now",
});
}
async increaseUsageByClientId(clientId: string) {
const team = await this.teamsRepository.findTeamIdFromClientId(clientId);
if (!team.id) return Promise.resolve(); // noop resolution.
return this.increaseUsageForTeam(team?.id);
}
}
+5
View File
@@ -0,0 +1,5 @@
export enum PlatformPlan {
STARTER = "STARTER",
ESSENTIALS = "ESSENTIALS",
ENTERPRISE = "ENTERPRISE",
}
+2 -1
View File
@@ -1,11 +1,12 @@
import { PlatformEndpointsModule } from "@/ee/platform-endpoints-module";
import { BillingModule } from "@/modules/billing/billing.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { TimezoneModule } from "@/modules/timezones/timezones.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common";
@Module({
imports: [OAuthClientModule, PlatformEndpointsModule, TimezoneModule],
imports: [OAuthClientModule, BillingModule, PlatformEndpointsModule, TimezoneModule],
})
export class EndpointsModule implements NestModule {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -8,6 +8,7 @@ import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/o
import { GetOAuthClientsResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto";
import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oauth-client.input";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Body,
@@ -22,6 +23,7 @@ import {
Logger,
UseGuards,
NotFoundException,
BadRequestException,
} from "@nestjs/common";
import {
ApiTags as DocsTags,
@@ -47,7 +49,10 @@ Second, make sure that the logged in user has organizationId set to pass the Org
export class OAuthClientsController {
private readonly logger = new Logger("OAuthClientController");
constructor(private readonly oauthClientRepository: OAuthClientRepository) {}
constructor(
private readonly oauthClientRepository: OAuthClientRepository,
private readonly teamsRepository: OrganizationsRepository
) {}
@Post("/")
@HttpCode(HttpStatus.CREATED)
@@ -65,6 +70,12 @@ export class OAuthClientsController {
this.logger.log(
`For organisation ${organizationId} creating OAuth Client with data: ${JSON.stringify(body)}`
);
const organization = await this.teamsRepository.findByIdIncludeBilling(organizationId);
if (!organization?.platformBilling || !organization?.platformBilling?.subscriptionId) {
throw new BadRequestException("Team is not subscribed, cannot create an OAuth Client.");
}
const { id, secret } = await this.oauthClientRepository.createOAuthClient(organizationId, body);
return {
status: SUCCESS_STATUS,
@@ -2,7 +2,7 @@ import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsIn, ValidateNested, IsNotEmptyObject, IsString } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { SUCCESS_STATUS, ERROR_STATUS, REDIRECT_STATUS } from "@calcom/platform-constants";
class DataDto {
@ApiProperty({
@@ -34,3 +34,9 @@ export class CreateOAuthClientResponseDto {
@Type(() => DataDto)
data!: DataDto;
}
export class CreateOauthClientRedirect {
status!: typeof REDIRECT_STATUS;
url!: string;
}
@@ -1,6 +1,7 @@
import { EventTypesModule } from "@/ee/event-types/event-types.module";
import { SchedulesModule } from "@/ee/schedules/schedules.module";
import { AuthModule } from "@/modules/auth/auth.module";
import { BillingModule } from "@/modules/billing/billing.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthClientUsersController } from "@/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller";
import { OAuthClientsController } from "@/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller";
@@ -12,6 +13,7 @@ import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.se
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersModule } from "@/modules/users/users.module";
@@ -28,6 +30,8 @@ import { Global, Module } from "@nestjs/common";
MembershipsModule,
EventTypesModule,
OrganizationsModule,
StripeModule,
BillingModule,
SchedulesModule,
],
providers: [
@@ -59,7 +59,7 @@ export class OAuthClientRepository {
}
async getOAuthClientWithRefreshSecret(clientId: string, clientSecret: string, refreshToken: string) {
return await this.dbRead.prisma.platformOAuthClient.findFirst({
return this.dbRead.prisma.platformOAuthClient.findFirst({
where: {
id: clientId,
secret: clientSecret,
@@ -7,8 +7,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, Injectable } from "@nestjs/common";
import { User } from "@prisma/client";
import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries";
import { slugify } from "@calcom/platform-libraries";
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries";
@Injectable()
export class OAuthClientUsersService {
@@ -100,8 +99,6 @@ export class OAuthClientUsersService {
getOAuthUserEmail(oAuthClientId: string, userEmail: string) {
const [username, emailDomain] = userEmail.split("@");
const email = `${username}+${oAuthClientId}@${emailDomain}`;
return email;
return `${username}+${oAuthClientId}@${emailDomain}`;
}
}
@@ -1,11 +1,12 @@
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
imports: [PrismaModule, StripeModule],
providers: [OrganizationsRepository, OrganizationsService],
exports: [OrganizationsService],
exports: [OrganizationsService, OrganizationsRepository],
})
export class OrganizationsModule {}
@@ -1,9 +1,16 @@
import { PlatformPlan } from "@/modules/billing/types";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { StripeService } from "@/modules/stripe/stripe.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class OrganizationsRepository {
constructor(private readonly dbRead: PrismaReadService) {}
constructor(
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService,
private readonly stripeService: StripeService
) {}
async findById(organizationId: number) {
return this.dbRead.prisma.team.findUnique({
@@ -12,4 +19,54 @@ export class OrganizationsRepository {
},
});
}
async findByIdIncludeBilling(orgId: number) {
return this.dbRead.prisma.team.findUnique({
where: {
id: orgId,
},
include: {
platformBilling: true,
},
});
}
async createNewBillingRelation(orgId: number, plan?: PlatformPlan) {
const { id } = await this.stripeService.stripe.customers.create({
metadata: {
createdBy: "oauth_client_no_csid", // mark in case this is needed in the future.
},
});
await this.dbWrite.prisma.team.update({
where: {
id: orgId,
},
data: {
platformBilling: {
create: {
customerId: id,
plan: plan ? plan.toString() : "none",
},
},
},
});
return id;
}
async findTeamIdFromClientId(clientId: string) {
return this.dbRead.prisma.team.findFirstOrThrow({
where: {
platformOAuthClient: {
some: {
id: clientId,
},
},
},
select: {
id: true,
},
});
}
}
@@ -0,0 +1,10 @@
import { StripeService } from "@/modules/stripe/stripe.service";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
@Module({
imports: [ConfigModule],
exports: [StripeService],
providers: [StripeService],
})
export class StripeModule {}
@@ -0,0 +1,15 @@
import { AppConfig } from "@/config/type";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import Stripe from "stripe";
@Injectable()
export class StripeService {
public stripe: Stripe;
constructor(configService: ConfigService<AppConfig>) {
this.stripe = new Stripe(configService.get("stripe.apiKey", { infer: true }) ?? "", {
apiVersion: "2020-08-27",
});
}
}
@@ -19,7 +19,7 @@ export const useCreateOAuthClient = (
},
}
) => {
const mutation = useMutation<
return useMutation<
ApiResponse<{ clientId: string; clientSecret: string }>,
unknown,
CreateOAuthClientInput
@@ -42,8 +42,6 @@ export const useCreateOAuthClient = (
onError?.();
},
});
return mutation;
};
export const useUpdateOAuthClient = (
+1
View File
@@ -9,6 +9,7 @@ export const V2_ENDPOINTS = {
export const SUCCESS_STATUS = "success";
export const ERROR_STATUS = "error";
export const REDIRECT_STATUS = "redirect";
// Client Errors (4xx)
export const BAD_REQUEST = "BAD_REQUEST";
export const UNAUTHORIZED = "UNAUTHORIZED";
+9 -1
View File
@@ -2,7 +2,12 @@ import { Transform } from "class-transformer";
import { IsNumber, Min, Max, IsOptional } from "class-validator";
import type { Response as BaseResponse } from "express";
import type { ERROR_STATUS, SUCCESS_STATUS, API_ERROR_CODES } from "@calcom/platform-constants";
import type {
ERROR_STATUS,
SUCCESS_STATUS,
API_ERROR_CODES,
REDIRECT_STATUS,
} from "@calcom/platform-constants";
export type ApiSuccessResponse<T> = { status: typeof SUCCESS_STATUS; data: T };
export type ApiSuccessResponseWithoutData = { status: typeof SUCCESS_STATUS };
@@ -23,6 +28,7 @@ export type ApiErrorResponse = {
};
export type ApiRedirectResponseType = {
status: typeof REDIRECT_STATUS;
url: string;
};
@@ -32,6 +38,8 @@ export type ApiResponse<T = undefined> = T extends undefined
? ApiSuccessResponseWithoutData | ApiErrorResponse
: ApiSuccessResponse<T> | ApiErrorResponse;
export type ApiResponseMaybeRedirect<T = undefined> = ApiResponse<T> | ApiRedirectResponseType;
export class Pagination {
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "PlatformBilling" (
"id" INTEGER NOT NULL,
"customerId" TEXT NOT NULL,
"plan" TEXT NOT NULL DEFAULT 'none',
"subscriptionId" TEXT,
"billingCycleStart" INTEGER,
"billingCycleEnd" INTEGER,
"overdue" BOOLEAN DEFAULT false,
CONSTRAINT "PlatformBilling_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PlatformBilling_id_key" ON "PlatformBilling"("id");
-- CreateIndex
CREATE UNIQUE INDEX "PlatformBilling_customerId_key" ON "PlatformBilling"("customerId");
-- AddForeignKey
ALTER TABLE "PlatformBilling" ADD CONSTRAINT "PlatformBilling_id_fkey" FOREIGN KEY ("id") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+15
View File
@@ -400,6 +400,7 @@ model Team {
isPlatform Boolean @default(false)
platformOAuthClient PlatformOAuthClient[]
smsLockState SMSLockState @default(UNLOCKED)
platformBilling PlatformBilling?
@@unique([slug, parentId])
@@index([parentId])
@@ -1357,3 +1358,17 @@ enum SMSLockState {
UNLOCKED
REVIEW_NEEDED
}
model PlatformBilling {
id Int @id @unique // team id
customerId String @unique
subscriptionId String?
plan String
billingCycleStart Int?
billingCycleEnd Int?
overdue Boolean? @default(false)
team Team @relation(fields: [id], references: [id], onDelete: Cascade)
}
+47 -84
View File
@@ -290,25 +290,6 @@ __metadata:
languageName: node
linkType: hard
"@auth/core@npm:^0.1.4":
version: 0.1.4
resolution: "@auth/core@npm:0.1.4"
dependencies:
"@panva/hkdf": 1.0.2
cookie: 0.5.0
jose: 4.11.1
oauth4webapi: 2.0.5
preact: 10.11.3
preact-render-to-string: 5.2.3
peerDependencies:
nodemailer: 6.8.0
peerDependenciesMeta:
nodemailer:
optional: true
checksum: 64854404ea1883e0deb5535b34bed95cd43fc85094aeaf4f15a79e14045020eb944f844defe857edfc8528a0a024be89cbb2a3069dedef0e9217a74ca6c3eb79
languageName: node
linkType: hard
"@aw-web-design/x-default-browser@npm:1.4.126":
version: 1.4.126
resolution: "@aw-web-design/x-default-browser@npm:1.4.126"
@@ -3891,7 +3872,7 @@ __metadata:
"@nestjs/schematics": ^10.0.0
"@nestjs/swagger": ^7.3.0
"@nestjs/testing": ^10.0.0
"@nestjs/throttler": ^5.1.1
"@nestjs/throttler": ^5.1.2
"@sentry/node": ^7.86.0
"@sentry/tracing": ^7.86.0
"@types/cookie-parser": ^1.4.6
@@ -3902,6 +3883,7 @@ __metadata:
"@types/node": ^20.3.1
"@types/passport-jwt": ^3.0.13
"@types/supertest": ^2.0.12
body-parser: ^1.20.2
class-transformer: ^0.5.1
class-validator: ^0.14.0
cookie-parser: ^1.4.6
@@ -3921,6 +3903,7 @@ __metadata:
reflect-metadata: ^0.1.13
rxjs: ^7.8.1
source-map-support: ^0.5.21
stripe: ^15.3.0
supertest: ^6.3.3
ts-jest: ^29.1.1
ts-loader: ^9.4.3
@@ -4055,38 +4038,12 @@ __metadata:
languageName: unknown
linkType: soft
"@calcom/auth@workspace:apps/auth":
"@calcom/autocheckin@workspace:packages/app-store/autocheckin":
version: 0.0.0-use.local
resolution: "@calcom/auth@workspace:apps/auth"
resolution: "@calcom/autocheckin@workspace:packages/app-store/autocheckin"
dependencies:
"@auth/core": ^0.1.4
"@calcom/app-store": "*"
"@calcom/app-store-cli": "*"
"@calcom/config": "*"
"@calcom/core": "*"
"@calcom/dayjs": "*"
"@calcom/embed-core": "workspace:*"
"@calcom/embed-react": "workspace:*"
"@calcom/embed-snippet": "workspace:*"
"@calcom/features": "*"
"@calcom/lib": "*"
"@calcom/prisma": "*"
"@calcom/trpc": "*"
"@calcom/tsconfig": "*"
"@calcom/types": "*"
"@calcom/ui": "*"
"@types/node": 16.9.1
"@types/react": 18.0.26
"@types/react-dom": ^18.0.9
eslint: ^8.34.0
eslint-config-next: ^13.2.1
next: ^13.5.4
next-auth: ^4.22.1
postcss: ^8.4.18
react: ^18.2.0
react-dom: ^18.2.0
tailwindcss: ^3.3.3
typescript: ^4.9.4
languageName: unknown
linkType: soft
@@ -4152,6 +4109,15 @@ __metadata:
languageName: unknown
linkType: soft
"@calcom/clic@workspace:packages/app-store/clic":
version: 0.0.0-use.local
resolution: "@calcom/clic@workspace:packages/app-store/clic"
dependencies:
"@calcom/lib": "*"
"@calcom/types": "*"
languageName: unknown
linkType: soft
"@calcom/closecom@workspace:packages/app-store/closecom":
version: 0.0.0-use.local
resolution: "@calcom/closecom@workspace:packages/app-store/closecom"
@@ -5195,12 +5161,14 @@ __metadata:
class-variance-authority: ^0.4.0
cmdk: ^0.2.0
cmk: ^0.1.1
date-fns: ^3.6.0
downshift: ^6.1.9
lucide-react: ^0.364.0
next: ^13.5.4
next-seo: ^6.0.0
react: ^18.2.0
react-colorful: ^5.6.0
react-day-picker: ^8.10.1
react-feather: ^2.0.10
react-hook-form: ^7.43.3
react-select: ^5.7.0
@@ -9488,7 +9456,7 @@ __metadata:
languageName: node
linkType: hard
"@nestjs/throttler@npm:^5.1.1":
"@nestjs/throttler@npm:^5.1.2":
version: 5.1.2
resolution: "@nestjs/throttler@npm:5.1.2"
peerDependencies:
@@ -10374,13 +10342,6 @@ __metadata:
languageName: node
linkType: hard
"@panva/hkdf@npm:1.0.2":
version: 1.0.2
resolution: "@panva/hkdf@npm:1.0.2"
checksum: 75183b4d5ea816ef516dcea70985c610683579a9e2ac540c2d59b9a3ed27eedaff830a43a1c43c1683556a457c92ac66e09109ee995ab173090e4042c4c4bb03
languageName: node
linkType: hard
"@panva/hkdf@npm:^1.0.2":
version: 1.0.4
resolution: "@panva/hkdf@npm:1.0.4"
@@ -19705,7 +19666,7 @@ __metadata:
languageName: node
linkType: hard
"body-parser@npm:1.20.2":
"body-parser@npm:1.20.2, body-parser@npm:^1.20.2":
version: 1.20.2
resolution: "body-parser@npm:1.20.2"
dependencies:
@@ -22766,6 +22727,13 @@ __metadata:
languageName: node
linkType: hard
"date-fns@npm:^3.6.0":
version: 3.6.0
resolution: "date-fns@npm:3.6.0"
checksum: 0daa1e9a436cf99f9f2ae9232b55e11f3dd46132bee10987164f3eebd29f245b2e066d7d7db40782627411ecf18551d8f4c9fcdf2226e48bb66545407d448ab7
languageName: node
linkType: hard
"datocms-listen@npm:^0.1.9":
version: 0.1.15
resolution: "datocms-listen@npm:0.1.15"
@@ -31146,13 +31114,6 @@ __metadata:
languageName: node
linkType: hard
"jose@npm:4.11.1":
version: 4.11.1
resolution: "jose@npm:4.11.1"
checksum: cd15cba258d0fd20f6168631ce2e94fda8442df80e43c1033c523915cecdf390a1cc8efe0eab0c2d65935ca973d791c668aea80724d2aa9c2879d4e70f3081d7
languageName: node
linkType: hard
"jose@npm:5.2.1":
version: 5.2.1
resolution: "jose@npm:5.2.1"
@@ -36152,13 +36113,6 @@ __metadata:
languageName: node
linkType: hard
"oauth4webapi@npm:2.0.5":
version: 2.0.5
resolution: "oauth4webapi@npm:2.0.5"
checksum: 32d0cb7b1cca42d51dfb88075ca2d69fe33172a807e8ea50e317d17cab3bc80588ab8ebcb7eb4600c371a70af4674595b4b341daf6f3a655f1efa1ab715bb6c9
languageName: node
linkType: hard
"oauth@npm:^0.9.15":
version: 0.9.15
resolution: "oauth@npm:0.9.15"
@@ -38333,17 +38287,6 @@ __metadata:
languageName: node
linkType: hard
"preact-render-to-string@npm:5.2.3":
version: 5.2.3
resolution: "preact-render-to-string@npm:5.2.3"
dependencies:
pretty-format: ^3.8.0
peerDependencies:
preact: ">=10"
checksum: 6e46288d8956adde35b9fe3a21aecd9dea29751b40f0f155dea62f3896f27cb8614d457b32f48d33909d2da81135afcca6c55077520feacd7d15164d1371fb44
languageName: node
linkType: hard
"preact-render-to-string@npm:^5.1.19":
version: 5.2.6
resolution: "preact-render-to-string@npm:5.2.6"
@@ -38355,7 +38298,7 @@ __metadata:
languageName: node
linkType: hard
"preact@npm:10.11.3, preact@npm:^10.6.3":
"preact@npm:^10.6.3":
version: 10.11.3
resolution: "preact@npm:10.11.3"
checksum: 9387115aa0581e8226309e6456e9856f17dfc0e3d3e63f774de80f3d462a882ba7c60914c05942cb51d51e23e120dcfe904b8d392d46f29ad15802941fe7a367
@@ -39393,6 +39336,16 @@ __metadata:
languageName: node
linkType: hard
"react-day-picker@npm:^8.10.1":
version: 8.10.1
resolution: "react-day-picker@npm:8.10.1"
peerDependencies:
date-fns: ^2.28.0 || ^3.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
checksum: daf7fb5aad1e355df9149e954fafc2cd40ceb90de799db4f36ef86d24729aaecfdf022fa9e63f24d093d81dc2beb60f65ae796741dff0f7305a2af80b333fb4c
languageName: node
linkType: hard
"react-debounce-input@npm:=3.2.4":
version: 3.2.4
resolution: "react-debounce-input@npm:3.2.4"
@@ -43652,6 +43605,16 @@ __metadata:
languageName: node
linkType: hard
"stripe@npm:^15.3.0":
version: 15.4.0
resolution: "stripe@npm:15.4.0"
dependencies:
"@types/node": ">=8.1.0"
qs: ^6.11.0
checksum: 0302b740ef76084b8cc0fbec14f80c864101a0b04b1d85ecd120d921f3d665a512230a905be1388f777d9677850ffcf066c8cce37935e2f8f3d0d3d5d29d7a4b
languageName: node
linkType: hard
"stripe@npm:^9.16.0":
version: 9.16.0
resolution: "stripe@npm:9.16.0"
@@ -48180,4 +48143,4 @@ __metadata:
resolution: "zwitch@npm:2.0.4"
checksum: f22ec5fc2d5f02c423c93d35cdfa83573a3a3bd98c66b927c368ea4d0e7252a500df2a90a6b45522be536a96a73404393c958e945fdba95e6832c200791702b6
languageName: node
linkType: hard
linkType: hard