Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Billing Service #14756

Merged
merged 22 commits into from Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/api/v2/package.json
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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"
Expand Down
24 changes: 18 additions & 6 deletions apps/api/v2/src/app.module.ts
@@ -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";
Expand Down Expand Up @@ -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({
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stripe requires a Buffer/String for the Webhooks to be built. Since NestJS by default parses everything as JSON, this was not letting the event through. That's why this change was required.

path: "/api/v2/billing/webhook",
method: RequestMethod.POST,
})
.apply(JsonBodyMiddleware)
.forRoutes("*")
.apply(AppLoggerMiddleware)
.forRoutes("*")
.apply(RewriterMiddleware)
.forRoutes("/");
}
}
7 changes: 7 additions & 0 deletions apps/api/v2/src/config/app.ts
Expand Up @@ -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"),
},
};
};

Expand Down
8 changes: 8 additions & 0 deletions apps/api/v2/src/config/stripe.config.json
@@ -0,0 +1,8 @@
{
"plans": {
"ESSENTIALS": "",
"STARTER": "",
"ENTERPRISE": "",
"SCALE": ""
}
}
7 changes: 7 additions & 0 deletions apps/api/v2/src/config/type.ts
Expand Up @@ -15,4 +15,11 @@ export type AppConfig = {
next: {
authSecret: string;
};
stripe: {
apiKey: string;
webhookSecret: string;
};
app: {
baseUrl: string;
};
};
3 changes: 2 additions & 1 deletion apps/api/v2/src/ee/bookings/bookings.module.ts
@@ -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";
Expand All @@ -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],
})
Expand Down
54 changes: 32 additions & 22 deletions apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts
Expand Up @@ -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";
Expand Down Expand Up @@ -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("/")
Expand Down Expand Up @@ -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.");
}
Expand All @@ -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.");
Expand All @@ -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.");
}
Expand All @@ -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) {
Expand All @@ -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);
Expand All @@ -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);
}
if (err instanceof Error) {
const error = err as Error;
throw new InternalServerErrorException(error?.message ?? errMsg);
}

throw new InternalServerErrorException(errMsg);
throw new InternalServerErrorException(errMsg);
}
}
3 changes: 3 additions & 0 deletions apps/api/v2/src/env.ts
Expand Up @@ -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] => {
Expand Down
5 changes: 4 additions & 1 deletion apps/api/v2/src/main.ts
Expand Up @@ -16,14 +16,17 @@ 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");

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) {
Expand Down
10 changes: 10 additions & 0 deletions apps/api/v2/src/middleware/body/json.body.middleware.ts
@@ -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);
}
}
10 changes: 10 additions & 0 deletions apps/api/v2/src/middleware/body/raw.body.middleware.ts
@@ -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);
}
}
17 changes: 17 additions & 0 deletions apps/api/v2/src/modules/billing/billing.module.ts
@@ -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 {}
51 changes: 51 additions & 0 deletions apps/api/v2/src/modules/billing/billing.repository.ts
@@ -0,0 +1,51 @@
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 findTeamIdFromClientId(clientId: string) {
return this.dbRead.prisma.team.findFirstOrThrow({
where: {
platformOAuthClient: {
some: {
id: clientId,
},
},
},
select: {
id: true,
},
});
}
exception marked this conversation as resolved.
Show resolved Hide resolved

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(),
},
});
}
}