Skip to content

Commit

Permalink
feat: Billing Service (#14756)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
exception committed Apr 30, 2024
1 parent a51024e commit 1cfd2f7
Show file tree
Hide file tree
Showing 38 changed files with 663 additions and 132 deletions.
11 changes: 10 additions & 1 deletion apps/api/v2/.env.example
Expand Up @@ -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/
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({
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
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
File renamed without changes.
File renamed without changes.
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 {}
36 changes: 36 additions & 0 deletions apps/api/v2/src/modules/billing/billing.repository.ts
@@ -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 comments on commit 1cfd2f7

Please sign in to comment.