diff --git a/apps/api/v2/src/modules/billing/billing.module.ts b/apps/api/v2/src/modules/billing/billing.module.ts index 7cf287b617d087..8dc8c2213cbea7 100644 --- a/apps/api/v2/src/modules/billing/billing.module.ts +++ b/apps/api/v2/src/modules/billing/billing.module.ts @@ -2,6 +2,7 @@ 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"; @@ -9,6 +10,7 @@ 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"; @@ -18,6 +20,7 @@ import { Module } from "@nestjs/common"; imports: [ PrismaModule, StripeModule, + RedisModule, MembershipsModule, OrganizationsModule, BullModule.registerQueue({ @@ -32,6 +35,10 @@ import { Module } from "@nestjs/common"; providers: [ BillingConfigService, BillingService, + { + provide: "IBillingService", + useClass: BillingServiceCachingProxy, + }, BillingRepository, BillingProcessor, ManagedOrganizationsBillingService, diff --git a/apps/api/v2/src/modules/billing/controllers/billing.controller.e2e-spec.ts b/apps/api/v2/src/modules/billing/controllers/billing.controller.e2e-spec.ts index 12573cc344eb67..ee4960ad851bab 100644 --- a/apps/api/v2/src/modules/billing/controllers/billing.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/billing/controllers/billing.controller.e2e-spec.ts @@ -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"; @@ -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"; @@ -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], @@ -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({ @@ -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( () => @@ -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( () => diff --git a/apps/api/v2/src/modules/billing/controllers/billing.controller.ts b/apps/api/v2/src/modules/billing/controllers/billing.controller.ts index 783e39e5675293..572d29f666fb18 100644 --- a/apps/api/v2/src/modules/billing/controllers/billing.controller.ts +++ b/apps/api/v2/src/modules/billing/controllers/billing.controller.ts @@ -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, @@ -19,6 +19,7 @@ import { Headers, HttpCode, HttpStatus, + Inject, Logger, Delete, ParseIntPipe, @@ -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 ) { diff --git a/apps/api/v2/src/modules/billing/interfaces/billing-service.interface.ts b/apps/api/v2/src/modules/billing/interfaces/billing-service.interface.ts new file mode 100644 index 00000000000000..776e191476fd56 --- /dev/null +++ b/apps/api/v2/src/modules/billing/interfaces/billing-service.interface.ts @@ -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; + createTeamBilling(teamId: number): Promise; + redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string): Promise; + updateSubscriptionForTeam(teamId: number, plan: PlatformPlan): Promise; + cancelTeamSubscription(teamId: number): Promise; + handleStripeSubscriptionDeleted(event: Stripe.Event): Promise; + handleStripePaymentSuccess(event: Stripe.Event): Promise; + handleStripePaymentFailed(event: Stripe.Event): Promise; + handleStripePaymentPastDue(event: Stripe.Event): Promise; + handleStripeCheckoutEvents(event: Stripe.Event): Promise; + handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event): Promise; + getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null; + getCustomerIdFromInvoice(invoice: Stripe.Invoice): string | null; + stripeService: StripeService; +} diff --git a/apps/api/v2/src/modules/billing/services/billing-service-caching-proxy.ts b/apps/api/v2/src/modules/billing/services/billing-service-caching-proxy.ts new file mode 100644 index 00000000000000..7e45eff371f383 --- /dev/null +++ b/apps/api/v2/src/modules/billing/services/billing-service-caching-proxy.ts @@ -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(REDIS_BILLING_CACHE_KEY(teamId)); + return cachedResult; + } + + private async setBillingCache(teamId: number, billingData: BillingData): Promise { + await this.redisService.set(REDIS_BILLING_CACHE_KEY(teamId), billingData, { + ttl: BILLING_CACHE_TTL_MS, + }); + } + + async createTeamBilling(teamId: number): Promise { + return this.billingService.createTeamBilling(teamId); + } + + async redirectToSubscribeCheckout( + teamId: number, + plan: PlatformPlan, + customerId?: string + ): Promise { + return this.billingService.redirectToSubscribeCheckout(teamId, plan, customerId); + } + + async updateSubscriptionForTeam(teamId: number, plan: PlatformPlan): Promise { + return this.billingService.updateSubscriptionForTeam(teamId, plan); + } + + async cancelTeamSubscription(teamId: number): Promise { + await this.billingService.cancelTeamSubscription(teamId); + await this.deleteBillingCache(teamId); + } + + async handleStripeSubscriptionDeleted(event: Stripe.Event): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/api/v2/src/modules/billing/services/billing.service.ts b/apps/api/v2/src/modules/billing/services/billing.service.ts index 477883ef1409aa..0d5ffcf3163f24 100644 --- a/apps/api/v2/src/modules/billing/services/billing.service.ts +++ b/apps/api/v2/src/modules/billing/services/billing.service.ts @@ -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"; @@ -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, private readonly billingConfigService: BillingConfigService, private readonly usersRepository: UsersRepository, @@ -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 { const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId); let customerId = teamWithBilling?.platformBilling?.customerId; @@ -67,7 +73,7 @@ export class BillingService implements OnModuleDestroy { }); } - return customerId; + return customerId!; } async redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string) { diff --git a/apps/api/v2/src/modules/organizations/index/organizations.repository.ts b/apps/api/v2/src/modules/organizations/index/organizations.repository.ts index 5c66a4dabc6160..00035ca40f5af3 100644 --- a/apps/api/v2/src/modules/organizations/index/organizations.repository.ts +++ b/apps/api/v2/src/modules/organizations/index/organizations.repository.ts @@ -174,4 +174,14 @@ export class OrganizationsRepository { }, }); } + + async findTeamByPlatformBillingId(billingId: number) { + return this.dbRead.prisma.team.findFirst({ + where: { + platformBilling: { + id: billingId, + }, + }, + }); + } } diff --git a/apps/api/v2/test/fixtures/repository/billing.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/billing.repository.fixture.ts index e53709519cf3e4..16517887db625d 100644 --- a/apps/api/v2/test/fixtures/repository/billing.repository.fixture.ts +++ b/apps/api/v2/test/fixtures/repository/billing.repository.fixture.ts @@ -18,7 +18,7 @@ export class PlatformBillingRepositoryFixture { id: orgId, customerId: `cus_123_${randomString}`, subscriptionId: `sub_123_${randomString}`, - plan: plan || "STARTER", + plan: plan || "FREE", }, }); }