Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/api/v2/src/modules/billing/billing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings
import { BillingProcessor } from "@/modules/billing/billing.processor";
import { BillingRepository } from "@/modules/billing/billing.repository";
import { BillingController } from "@/modules/billing/controllers/billing.controller";
import { BillingServiceCachingProxy } from "@/modules/billing/services/billing-service-caching-proxy";
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
import { BillingService } from "@/modules/billing/services/billing.service";
import { ManagedOrganizationsBillingService } from "@/modules/billing/services/managed-organizations.billing.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
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 { UsersModule } from "@/modules/users/users.module";
import { BullModule } from "@nestjs/bull";
Expand All @@ -18,6 +20,7 @@ import { Module } from "@nestjs/common";
imports: [
PrismaModule,
StripeModule,
RedisModule,
MembershipsModule,
OrganizationsModule,
BullModule.registerQueue({
Expand All @@ -32,6 +35,10 @@ import { Module } from "@nestjs/common";
providers: [
BillingConfigService,
BillingService,
{
provide: "IBillingService",
useClass: BillingServiceCachingProxy,
},
BillingRepository,
BillingProcessor,
ManagedOrganizationsBillingService,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeService } from "@/modules/stripe/stripe.service";
import { TokensModule } from "@/modules/tokens/tokens.module";
Expand All @@ -16,7 +17,7 @@ import { OrganizationRepositoryFixture } from "test/fixtures/repository/organiza
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { withNextAuth } from "test/utils/withNextAuth";

import type { Team, PlatformBilling } from "@calcom/prisma/client";

Expand All @@ -31,9 +32,8 @@ describe("Platform Billing Controller (e2e)", () => {
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
let organization: Team;

beforeAll(async () => {
const moduleRef = await withApiAuth(
const moduleRef = await withNextAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
Expand All @@ -44,8 +44,10 @@ describe("Platform Billing Controller (e2e)", () => {
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);

organization = await organizationsRepositoryFixture.create({
name: `billing-organization-${randomString()}`,
isPlatform: true,
});

user = await userRepositoryFixture.create({
Expand Down Expand Up @@ -87,6 +89,17 @@ describe("Platform Billing Controller (e2e)", () => {
await app.close();
});

it("/billing/webhook (GET) should not get billing plan for org since it's not set yet", () => {
return request(app.getHttpServer())
.get(`/v2/billing/${organization.id}/check`)

.expect(200)
.then(async (res) => {
const data = res.body.data as CheckPlatformBillingResponseDto;
expect(data?.plan).toEqual("FREE");
});
});

it("/billing/webhook (POST) should set billing free plan for org", () => {
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
() =>
Expand Down Expand Up @@ -119,6 +132,17 @@ describe("Platform Billing Controller (e2e)", () => {
expect(billing?.plan).toEqual("FREE");
});
});

it("/billing/webhook (GET) should get billing plan for org", () => {
return request(app.getHttpServer())
.get(`/v2/billing/${organization.id}/check`)
.expect(200)
.then(async (res) => {
const data = res.body.data as CheckPlatformBillingResponseDto;
expect(data?.plan).toEqual("FREE");
});
});

it("/billing/webhook (POST) failed payment should set billing free plan to overdue", () => {
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles
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 { IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
import { StripeService } from "@/modules/stripe/stripe.service";
import {
Body,
Expand All @@ -19,6 +19,7 @@ import {
Headers,
HttpCode,
HttpStatus,
Inject,
Logger,
Delete,
ParseIntPipe,
Expand All @@ -40,7 +41,7 @@ export class BillingController {
private logger = new Logger("Billing Controller");

constructor(
private readonly billingService: BillingService,
@Inject("IBillingService") private readonly billingService: IBillingService,
public readonly stripeService: StripeService,
private readonly configService: ConfigService<AppConfig>
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { PlatformPlan } from "@/modules/billing/types";
import type { StripeService } from "@/modules/stripe/stripe.service";
import Stripe from "stripe";
import { PlatformBilling, Team } from "@calcom/prisma/client";

export type BillingData = {
team: (Team & { platformBilling: PlatformBilling | null }) | null;
status: "valid" | "no_subscription" | "no_billing";
plan: string;
};

export interface IBillingService {
getBillingData(teamId: number): Promise<BillingData>;
createTeamBilling(teamId: number): Promise<string>;
redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string): Promise<string>;
updateSubscriptionForTeam(teamId: number, plan: PlatformPlan): Promise<string>;
cancelTeamSubscription(teamId: number): Promise<void>;
handleStripeSubscriptionDeleted(event: Stripe.Event): Promise<void>;
handleStripePaymentSuccess(event: Stripe.Event): Promise<void>;
handleStripePaymentFailed(event: Stripe.Event): Promise<void>;
handleStripePaymentPastDue(event: Stripe.Event): Promise<void>;
handleStripeCheckoutEvents(event: Stripe.Event): Promise<void>;
handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event): Promise<void>;
getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null;
getCustomerIdFromInvoice(invoice: Stripe.Invoice): string | null;
stripeService: StripeService;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { IBillingService, BillingData } from "@/modules/billing/interfaces/billing-service.interface";
import { BillingService } from "@/modules/billing/services/billing.service";
import { PlatformPlan } from "@/modules/billing/types";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
import Stripe from "stripe";

export const REDIS_BILLING_CACHE_KEY = (teamId: number) => `apiv2:team:${teamId}:billing`;
export const BILLING_CACHE_TTL_MS = 3_600_000; // 1 hour

@Injectable()
export class BillingServiceCachingProxy implements IBillingService {
constructor(private readonly billingService: BillingService, private readonly redisService: RedisService) {}

async getBillingData(teamId: number) {
const cachedBillingData = await this.getBillingCache(teamId);
if (cachedBillingData) {
return cachedBillingData;
}

const billingData = await this.billingService.getBillingData(teamId);
await this.setBillingCache(teamId, billingData);
return billingData;
}

private async deleteBillingCache(teamId: number) {
await this.redisService.del(REDIS_BILLING_CACHE_KEY(teamId));
}

private async getBillingCache(teamId: number) {
const cachedResult = await this.redisService.get<BillingData>(REDIS_BILLING_CACHE_KEY(teamId));
return cachedResult;
}

private async setBillingCache(teamId: number, billingData: BillingData): Promise<void> {
await this.redisService.set<BillingData>(REDIS_BILLING_CACHE_KEY(teamId), billingData, {
ttl: BILLING_CACHE_TTL_MS,
});
}

async createTeamBilling(teamId: number): Promise<string> {
return this.billingService.createTeamBilling(teamId);
}

async redirectToSubscribeCheckout(
teamId: number,
plan: PlatformPlan,
customerId?: string
): Promise<string> {
return this.billingService.redirectToSubscribeCheckout(teamId, plan, customerId);
}

async updateSubscriptionForTeam(teamId: number, plan: PlatformPlan): Promise<string> {
return this.billingService.updateSubscriptionForTeam(teamId, plan);
}

async cancelTeamSubscription(teamId: number): Promise<void> {
await this.billingService.cancelTeamSubscription(teamId);
await this.deleteBillingCache(teamId);
}

async handleStripeSubscriptionDeleted(event: Stripe.Event): Promise<void> {
await this.billingService.handleStripeSubscriptionDeleted(event);
const subscription = event.data.object as Stripe.Subscription;
const teamId = subscription?.metadata?.teamId;
if (teamId) {
await this.deleteBillingCache(Number.parseInt(teamId));
}
}

async handleStripePaymentSuccess(event: Stripe.Event): Promise<void> {
await this.billingService.handleStripePaymentSuccess(event);
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
if (subscriptionId) {
const teamBilling = await this.billingService.billingRepository.getBillingForTeamBySubscriptionId(
subscriptionId
);
if (teamBilling?.id) {
await this.deleteBillingCache(teamBilling.id);
}
}
}

async handleStripePaymentFailed(event: Stripe.Event): Promise<void> {
await this.billingService.handleStripePaymentFailed(event);
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
if (subscriptionId) {
const teamBilling = await this.billingService.billingRepository.getBillingForTeamBySubscriptionId(
subscriptionId
);
if (teamBilling?.id) {
await this.deleteBillingCache(teamBilling.id);
}
}
}

async handleStripePaymentPastDue(event: Stripe.Event): Promise<void> {
await this.billingService.handleStripePaymentPastDue(event);
const subscription = event.data.object as Stripe.Subscription;
const subscriptionId = subscription.id;
if (subscriptionId) {
const teamBilling = await this.billingService.billingRepository.getBillingForTeamBySubscriptionId(
subscriptionId
);
if (teamBilling?.id) {
await this.deleteBillingCache(teamBilling.id);
}
}
}

async handleStripeCheckoutEvents(event: Stripe.Event): Promise<void> {
await this.billingService.handleStripeCheckoutEvents(event);
const checkoutSession = event.data.object as Stripe.Checkout.Session;
const teamId = checkoutSession.metadata?.teamId;
if (teamId) {
await this.deleteBillingCache(Number.parseInt(teamId));
}
}

async handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event): Promise<void> {
return this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
}

getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null {
return this.billingService.getSubscriptionIdFromInvoice(invoice);
}

getCustomerIdFromInvoice(invoice: Stripe.Invoice): string | null {
return this.billingService.getCustomerIdFromInvoice(invoice);
}

get stripeService() {
return this.billingService.stripeService;
}
}
22 changes: 14 additions & 8 deletions apps/api/v2/src/modules/billing/services/billing.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AppConfig } from "@/config/type";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
import { BillingRepository } from "@/modules/billing/billing.repository";
import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
import { BillingConfigService } from "@/modules/billing/services/billing.config.service";
import { PlatformPlan } from "@/modules/billing/types";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
Expand All @@ -23,14 +24,14 @@ import { DateTime } from "luxon";
import Stripe from "stripe";

@Injectable()
export class BillingService implements OnModuleDestroy {
export class BillingService implements IBillingService, OnModuleDestroy {
private logger = new Logger("BillingService");
private readonly webAppUrl: string;

constructor(
private readonly teamsRepository: OrganizationsRepository,
public readonly stripeService: StripeService,
private readonly billingRepository: BillingRepository,
public readonly billingRepository: BillingRepository,
private readonly configService: ConfigService<AppConfig>,
private readonly billingConfigService: BillingConfigService,
private readonly usersRepository: UsersRepository,
Expand All @@ -43,18 +44,23 @@ export class BillingService implements OnModuleDestroy {

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: "no_subscription" as const, plan: "none" };
} else {
return {
team: teamWithBilling,
status: "valid" as const,
plan: teamWithBilling.platformBilling.plan,
};
}

return { team: teamWithBilling, status: "valid", plan: teamWithBilling.platformBilling.plan };
} else {
return { team: teamWithBilling, status: "no_billing", plan: "none" };
return { team: teamWithBilling, status: "no_billing" as const, plan: "none" };
}
}

async createTeamBilling(teamId: number) {
async createTeamBilling(teamId: number): Promise<string> {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
let customerId = teamWithBilling?.platformBilling?.customerId;

Expand All @@ -67,7 +73,7 @@ export class BillingService implements OnModuleDestroy {
});
}

return customerId;
return customerId!;
}

async redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,14 @@ export class OrganizationsRepository {
},
});
}

async findTeamByPlatformBillingId(billingId: number) {
return this.dbRead.prisma.team.findFirst({
where: {
platformBilling: {
id: billingId,
},
},
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export class PlatformBillingRepositoryFixture {
id: orgId,
customerId: `cus_123_${randomString}`,
subscriptionId: `sub_123_${randomString}`,
plan: plan || "STARTER",
plan: plan || "FREE",
},
});
}
Expand Down
Loading