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 9 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
3 changes: 2 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 && nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/src/main",
"start:prod": "SKIP_DOCS_GENERATION=1 node dist/src/main",
"test": "yarn dev:build && jest",
"test:watch": "yarn dev:build && jest --watch",
"test:cov": "yarn dev:build && jest --coverage",
Expand Down Expand Up @@ -56,6 +56,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
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
14 changes: 12 additions & 2 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/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,6 +155,8 @@ 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,
Expand Down Expand Up @@ -198,6 +202,9 @@ 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,
Expand All @@ -220,6 +227,9 @@ export class BookingsController {
const instantMeeting = await handleInstantMeeting(
await this.createNextApiBookingRequest(req, oAuthClientId)
);

void (await this.billingService.increaseUsageByClientId(oAuthClientId!));

return {
status: SUCCESS_STATUS,
data: instantMeeting,
Expand Down
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
4 changes: 3 additions & 1 deletion apps/api/v2/src/main.ts
Expand Up @@ -23,7 +23,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) {
Expand Down
16 changes: 16 additions & 0 deletions apps/api/v2/src/modules/billing/billing.module.ts
@@ -0,0 +1,16 @@
import { BillingRepository } from "@/modules/billing/billing.repository";
import { BillingService } from "@/modules/billing/billing.service";
import { BillingController } from "@/modules/billing/controllers/billing.controller";
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: [BillingService, BillingRepository],
exports: [BillingService, BillingRepository],
controllers: [BillingController],
})
export class BillingModule {}
43 changes: 43 additions & 0 deletions apps/api/v2/src/modules/billing/billing.repository.ts
@@ -0,0 +1,43 @@
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, subscription?: string) {
return this.dbWrite.prisma.platformBilling.update({
where: {
id: teamId,
},
data: {
billingCycleStart: billingStart,
billingCycleEnd: billingEnd,
subscriptionId: subscription,
},
});
}
}
126 changes: 126 additions & 0 deletions apps/api/v2/src/modules/billing/billing.service.ts
@@ -0,0 +1,126 @@
import { AppConfig } from "@/config/type";
import { BillingRepository } from "@/modules/billing/billing.repository";
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 plansToPriceId: Map<PlatformPlan, string>;
private readonly webAppUrl: string;

constructor(
private readonly teamsRepository: OrganizationsRepository,
public readonly stripeService: StripeService,
private readonly billingRepository: BillingRepository,
private readonly configService: ConfigService<AppConfig>
) {
this.webAppUrl = configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com";
this.plansToPriceId = new Map<PlatformPlan, string>();
// for (const plan in Object.keys(PlatformPlan)) {
exception marked this conversation as resolved.
Show resolved Hide resolved
// const planId = configService.get<string>(`billing.${plan}`) ?? "";
// this.plansToPriceId.set(plan as PlatformPlan, planId);
// }
}

async getBillingData(teamId: number) {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
if (teamWithBilling?.platformBilling) {
if (!teamWithBilling?.platformBilling.subscriptionId) {
return { team: teamWithBilling, status: "no_subscription" };
}

return { team: teamWithBilling, status: "valid" };
} else {
return { team: teamWithBilling, status: "no_billing" };
}
}

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.plansToPriceId.get(plan),
},
],
success_url: `${this.webAppUrl}/settings/platform/oauth-clients`,
cancel_url: `${this.webAppUrl}/settings/platform/oauth-clients`,
mode: "subscription",
metadata: {
teamId: teamId.toString(),
},
subscription_data: {
metadata: {
teamId: teamId.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) {
const billingCycleStart = DateTime.now().get("day");
const billingCycleEnd = DateTime.now().plus({ month: 1 }).get("day");

return this.billingRepository.updateTeamBilling(
teamId,
billingCycleStart,
billingCycleEnd,
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.billingRepository.findTeamIdFromClientId(clientId);
if (!team.id) return Promise.resolve(); // noop resolution.

return this.increaseUsageForTeam(team?.id);
}
}