From 8edce4cb4c55d637f4f4a4ee31855b4af77708ea Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 07:37:25 +0000 Subject: [PATCH 1/8] feat: add BillingCacheService with 1-hour TTL for team subscription data - Create BillingCacheService following CalendarsCacheService pattern - Use teamId-based cache keys with 1-hour TTL (3,600,000 ms) - Integrate caching into getBillingData method in BillingService - Add cache invalidation to all webhook handlers: - handleStripeSubscriptionDeleted - handleStripePaymentSuccess - handleStripePaymentFailed - handleStripePaymentPastDue - handleStripeCheckoutEvents - Add cache invalidation to cancelTeamSubscription method - Add RedisModule import to billing module - Add BillingCacheService to billing module providers - Add findTeamByPlatformBillingId method to OrganizationsRepository for cache invalidation Co-Authored-By: morgan@cal.com --- .../v2/src/modules/billing/billing.module.ts | 4 ++ .../billing/services/billing-cache.service.ts | 31 +++++++++++ .../billing/services/billing.service.ts | 51 +++++++++++++++++-- .../index/organizations.repository.ts | 10 ++++ 4 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 apps/api/v2/src/modules/billing/services/billing-cache.service.ts diff --git a/apps/api/v2/src/modules/billing/billing.module.ts b/apps/api/v2/src/modules/billing/billing.module.ts index 7cf287b617d087..e54c77a152ed5b 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 { BillingCacheService } from "@/modules/billing/services/billing-cache.service"; 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,7 @@ import { Module } from "@nestjs/common"; providers: [ BillingConfigService, BillingService, + BillingCacheService, BillingRepository, BillingProcessor, ManagedOrganizationsBillingService, diff --git a/apps/api/v2/src/modules/billing/services/billing-cache.service.ts b/apps/api/v2/src/modules/billing/services/billing-cache.service.ts new file mode 100644 index 00000000000000..e5621a0d769437 --- /dev/null +++ b/apps/api/v2/src/modules/billing/services/billing-cache.service.ts @@ -0,0 +1,31 @@ +import { RedisService } from "@/modules/redis/redis.service"; +import { Injectable } from "@nestjs/common"; + +export const REDIS_BILLING_CACHE_KEY = (teamId: number) => `apiv2:team:${teamId}:billing`; +export const BILLING_CACHE_TTL_MS = 3_600_000; // 1 hour + +type BillingData = { + team: any; + status: "valid" | "no_subscription" | "no_billing"; + plan: string; +}; + +@Injectable() +export class BillingCacheService { + constructor(private readonly redisService: RedisService) {} + + async deleteBillingCache(teamId: number) { + await this.redisService.del(REDIS_BILLING_CACHE_KEY(teamId)); + } + + async getBillingCache(teamId: number) { + const cachedResult = await this.redisService.get(REDIS_BILLING_CACHE_KEY(teamId)); + return cachedResult; + } + + async setBillingCache(teamId: number, billingData: BillingData): Promise { + await this.redisService.set(REDIS_BILLING_CACHE_KEY(teamId), billingData, { + ttl: BILLING_CACHE_TTL_MS, + }); + } +} 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..8c4f18337b7412 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 { BillingCacheService } from "@/modules/billing/services/billing-cache.service"; import { BillingConfigService } from "@/modules/billing/services/billing.config.service"; import { PlatformPlan } from "@/modules/billing/types"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; @@ -36,22 +37,37 @@ export class BillingService implements OnModuleDestroy { private readonly usersRepository: UsersRepository, private readonly oAuthClientRepository: OAuthClientRepository, private readonly bookingsRepository: BookingsRepository_2024_08_13, + private readonly billingCacheService: BillingCacheService, @InjectQueue(BILLING_QUEUE) private readonly billingQueue: Queue ) { this.webAppUrl = this.configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com"; } async getBillingData(teamId: number) { + const cachedBillingData = await this.billingCacheService.getBillingCache(teamId); + if (cachedBillingData) { + return cachedBillingData; + } + const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId); + let billingData; + if (teamWithBilling?.platformBilling) { if (!teamWithBilling?.platformBilling.subscriptionId) { - return { team: teamWithBilling, status: "no_subscription", plan: "none" }; + billingData = { team: teamWithBilling, status: "no_subscription" as const, plan: "none" }; + } else { + billingData = { + 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" }; + billingData = { team: teamWithBilling, status: "no_billing" as const, plan: "none" }; } + + await this.billingCacheService.setBillingCache(teamId, billingData); + return billingData; } async createTeamBilling(teamId: number) { @@ -168,6 +184,7 @@ export class BillingService implements OnModuleDestroy { const currentBilling = await this.billingRepository.getBillingForTeam(Number.parseInt(teamId)); if (currentBilling?.subscriptionId === subscription.id) { await this.billingRepository.deleteBilling(currentBilling.id); + await this.billingCacheService.deleteBillingCache(Number.parseInt(teamId)); this.logger.log(`Stripe Subscription deleted`, { customerId: currentBilling.customerId, subscriptionId: currentBilling.subscriptionId, @@ -207,6 +224,13 @@ export class BillingService implements OnModuleDestroy { const customerId = this.getCustomerIdFromInvoice(invoice); if (subscriptionId && customerId) { await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, false); + const teamBilling = await this.billingRepository.getBillingForTeamBySubscriptionId(subscriptionId); + if (teamBilling) { + const team = await this.teamsRepository.findTeamByPlatformBillingId(teamBilling.id); + if (team?.id) { + await this.billingCacheService.deleteBillingCache(team.id); + } + } } } @@ -216,6 +240,13 @@ export class BillingService implements OnModuleDestroy { const customerId = this.getCustomerIdFromInvoice(invoice); if (subscriptionId && customerId) { await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, true); + const teamBilling = await this.billingRepository.getBillingForTeamBySubscriptionId(subscriptionId); + if (teamBilling) { + const team = await this.teamsRepository.findTeamByPlatformBillingId(teamBilling.id); + if (team?.id) { + await this.billingCacheService.deleteBillingCache(team.id); + } + } } } @@ -236,6 +267,14 @@ export class BillingService implements OnModuleDestroy { if (existingUserSubscription.status === "active") { await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, false); } + + const teamBilling = await this.billingRepository.getBillingForTeamBySubscriptionId(subscriptionId); + if (teamBilling) { + const team = await this.teamsRepository.findTeamByPlatformBillingId(teamBilling.id); + if (team?.id) { + await this.billingCacheService.deleteBillingCache(team.id); + } + } } if (!subscriptionId || !customerId) { @@ -279,6 +318,8 @@ export class BillingService implements OnModuleDestroy { await this.updateStripeSubscriptionForTeam(teamId, plan as PlatformPlan); } + await this.billingCacheService.deleteBillingCache(teamId); + return; } @@ -459,6 +500,8 @@ export class BillingService implements OnModuleDestroy { await this.stripeService .getStripe() .subscriptions.cancel(teamWithBilling?.platformBilling?.subscriptionId); + + await this.billingCacheService.deleteBillingCache(teamId); } catch (error) { this.logger.log(error, "error while cancelling team subscription in stripe"); throw new BadRequestException("Failed to cancel team subscription"); 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, + }, + }, + }); + } } From 877c8242018748b2cfc5c51d78b95bd4d72e7747 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 08:16:09 +0000 Subject: [PATCH 2/8] refactor: implement BillingServiceCachingProxy pattern - Extract IBillingService interface with all public methods - Create BillingServiceCachingProxy that implements caching logic - Remove all caching logic from original BillingService - Simplify cache invalidation using billing.id = team.id - Update module to use proxy with proper dependency injection - Update controller to inject proxy interface - Remove unused BillingService import from controller This follows the proxy pattern requested in PR feedback, separating caching concerns from core billing logic for better maintainability. Co-Authored-By: morgan@cal.com --- .../v2/src/modules/billing/billing.module.ts | 5 + .../billing/controllers/billing.controller.ts | 5 +- .../interfaces/billing-service.interface.ts | 23 ++++ .../services/billing-service-caching-proxy.ts | 122 ++++++++++++++++++ .../billing/services/billing.service.ts | 53 ++------ 5 files changed, 161 insertions(+), 47 deletions(-) create mode 100644 apps/api/v2/src/modules/billing/interfaces/billing-service.interface.ts create mode 100644 apps/api/v2/src/modules/billing/services/billing-service-caching-proxy.ts diff --git a/apps/api/v2/src/modules/billing/billing.module.ts b/apps/api/v2/src/modules/billing/billing.module.ts index e54c77a152ed5b..ef9364ed9c87ab 100644 --- a/apps/api/v2/src/modules/billing/billing.module.ts +++ b/apps/api/v2/src/modules/billing/billing.module.ts @@ -3,6 +3,7 @@ import { BillingProcessor } from "@/modules/billing/billing.processor"; import { BillingRepository } from "@/modules/billing/billing.repository"; import { BillingController } from "@/modules/billing/controllers/billing.controller"; import { BillingCacheService } from "@/modules/billing/services/billing-cache.service"; +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"; @@ -36,6 +37,10 @@ import { Module } from "@nestjs/common"; BillingConfigService, BillingService, BillingCacheService, + { + provide: "IBillingService", + useClass: BillingServiceCachingProxy, + }, BillingRepository, BillingProcessor, ManagedOrganizationsBillingService, 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..6e41e480eb4da2 --- /dev/null +++ b/apps/api/v2/src/modules/billing/interfaces/billing-service.interface.ts @@ -0,0 +1,23 @@ +import { PlatformPlan } from "@/modules/billing/types"; +import Stripe from "stripe"; + +export interface IBillingService { + getBillingData(teamId: number): Promise<{ + team: any; + status: "valid" | "no_subscription" | "no_billing"; + plan: string; + }>; + 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: any; +} 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..ac9a941ab70e60 --- /dev/null +++ b/apps/api/v2/src/modules/billing/services/billing-service-caching-proxy.ts @@ -0,0 +1,122 @@ +import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface"; +import { BillingCacheService } from "@/modules/billing/services/billing-cache.service"; +import { BillingService } from "@/modules/billing/services/billing.service"; +import { PlatformPlan } from "@/modules/billing/types"; +import { Injectable } from "@nestjs/common"; +import Stripe from "stripe"; + +@Injectable() +export class BillingServiceCachingProxy implements IBillingService { + constructor( + private readonly billingService: BillingService, + private readonly billingCacheService: BillingCacheService + ) {} + + async getBillingData(teamId: number) { + const cachedBillingData = await this.billingCacheService.getBillingCache(teamId); + if (cachedBillingData) { + return cachedBillingData; + } + + const billingData = await this.billingService.getBillingData(teamId); + await this.billingCacheService.setBillingCache(teamId, billingData); + return billingData; + } + + 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.billingCacheService.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.billingCacheService.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.billingCacheService.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.billingCacheService.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.billingCacheService.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.billingCacheService.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 8c4f18337b7412..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,7 +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 { BillingCacheService } from "@/modules/billing/services/billing-cache.service"; +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"; @@ -24,53 +24,43 @@ 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, private readonly oAuthClientRepository: OAuthClientRepository, private readonly bookingsRepository: BookingsRepository_2024_08_13, - private readonly billingCacheService: BillingCacheService, @InjectQueue(BILLING_QUEUE) private readonly billingQueue: Queue ) { this.webAppUrl = this.configService.get("app.baseUrl", { infer: true }) ?? "https://app.cal.com"; } async getBillingData(teamId: number) { - const cachedBillingData = await this.billingCacheService.getBillingCache(teamId); - if (cachedBillingData) { - return cachedBillingData; - } - const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId); - let billingData; if (teamWithBilling?.platformBilling) { if (!teamWithBilling?.platformBilling.subscriptionId) { - billingData = { team: teamWithBilling, status: "no_subscription" as const, plan: "none" }; + return { team: teamWithBilling, status: "no_subscription" as const, plan: "none" }; } else { - billingData = { + return { team: teamWithBilling, status: "valid" as const, plan: teamWithBilling.platformBilling.plan, }; } } else { - billingData = { team: teamWithBilling, status: "no_billing" as const, plan: "none" }; + return { team: teamWithBilling, status: "no_billing" as const, plan: "none" }; } - - await this.billingCacheService.setBillingCache(teamId, billingData); - return billingData; } - async createTeamBilling(teamId: number) { + async createTeamBilling(teamId: number): Promise { const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId); let customerId = teamWithBilling?.platformBilling?.customerId; @@ -83,7 +73,7 @@ export class BillingService implements OnModuleDestroy { }); } - return customerId; + return customerId!; } async redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string) { @@ -184,7 +174,6 @@ export class BillingService implements OnModuleDestroy { const currentBilling = await this.billingRepository.getBillingForTeam(Number.parseInt(teamId)); if (currentBilling?.subscriptionId === subscription.id) { await this.billingRepository.deleteBilling(currentBilling.id); - await this.billingCacheService.deleteBillingCache(Number.parseInt(teamId)); this.logger.log(`Stripe Subscription deleted`, { customerId: currentBilling.customerId, subscriptionId: currentBilling.subscriptionId, @@ -224,13 +213,6 @@ export class BillingService implements OnModuleDestroy { const customerId = this.getCustomerIdFromInvoice(invoice); if (subscriptionId && customerId) { await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, false); - const teamBilling = await this.billingRepository.getBillingForTeamBySubscriptionId(subscriptionId); - if (teamBilling) { - const team = await this.teamsRepository.findTeamByPlatformBillingId(teamBilling.id); - if (team?.id) { - await this.billingCacheService.deleteBillingCache(team.id); - } - } } } @@ -240,13 +222,6 @@ export class BillingService implements OnModuleDestroy { const customerId = this.getCustomerIdFromInvoice(invoice); if (subscriptionId && customerId) { await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, true); - const teamBilling = await this.billingRepository.getBillingForTeamBySubscriptionId(subscriptionId); - if (teamBilling) { - const team = await this.teamsRepository.findTeamByPlatformBillingId(teamBilling.id); - if (team?.id) { - await this.billingCacheService.deleteBillingCache(team.id); - } - } } } @@ -267,14 +242,6 @@ export class BillingService implements OnModuleDestroy { if (existingUserSubscription.status === "active") { await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, false); } - - const teamBilling = await this.billingRepository.getBillingForTeamBySubscriptionId(subscriptionId); - if (teamBilling) { - const team = await this.teamsRepository.findTeamByPlatformBillingId(teamBilling.id); - if (team?.id) { - await this.billingCacheService.deleteBillingCache(team.id); - } - } } if (!subscriptionId || !customerId) { @@ -318,8 +285,6 @@ export class BillingService implements OnModuleDestroy { await this.updateStripeSubscriptionForTeam(teamId, plan as PlatformPlan); } - await this.billingCacheService.deleteBillingCache(teamId); - return; } @@ -500,8 +465,6 @@ export class BillingService implements OnModuleDestroy { await this.stripeService .getStripe() .subscriptions.cancel(teamWithBilling?.platformBilling?.subscriptionId); - - await this.billingCacheService.deleteBillingCache(teamId); } catch (error) { this.logger.log(error, "error while cancelling team subscription in stripe"); throw new BadRequestException("Failed to cancel team subscription"); From 890554d96b6f8a0e6a4ac0df233efc5609b7dc85 Mon Sep 17 00:00:00 2001 From: "cal.com" Date: Fri, 19 Sep 2025 11:51:58 +0300 Subject: [PATCH 3/8] chore: add e2e for billing check --- .../billing.controller.e2e-spec.ts | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) 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..d0317d312d998b 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 as CheckPlatformBillingResponseDto; + expect(data?.plan).toEqual(undefined); + }); + }); + it("/billing/webhook (POST) should set billing free plan for org", () => { jest.spyOn(StripeService.prototype, "getStripe").mockImplementation( () => @@ -119,6 +132,14 @@ 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); + }); + it("/billing/webhook (POST) failed payment should set billing free plan to overdue", () => { jest.spyOn(StripeService.prototype, "getStripe").mockImplementation( () => From e77930e0b0e427bf20e281aa64543722531004c9 Mon Sep 17 00:00:00 2001 From: Benny Joo Date: Fri, 19 Sep 2025 04:05:53 -0400 Subject: [PATCH 4/8] chore: eslint rule for blocking importing features from appstore, lib, prisma (#23832) * eslint rule * improve * fix * improve msg --- .eslintrc.js | 91 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 83 insertions(+), 8 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 43c0cd83593ea1..6d6b21761b4e8d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,4 +1,3 @@ -// This configuration only applies to the package manager root. /** @type {import("eslint").Linter.Config} */ module.exports = { extends: ["./packages/config/eslint-preset.js"], @@ -7,29 +6,105 @@ module.exports = { "import/no-cycle": ["warn", { maxDepth: Infinity }], }, overrides: [ + // WARN: features must not be imported by app-store or lib { - files: ["packages/lib/**/*.{ts,tsx,js,jsx}", "packages/prisma/**/*.{ts,tsx,js,jsx}"], + files: ["packages/app-store/**/*.{ts,tsx,js,jsx}", "packages/lib/**/*.{ts,tsx,js,jsx}"], rules: { "no-restricted-imports": [ "warn", { - paths: ["@calcom/app-store"], - patterns: ["@calcom/app-store/*"], + patterns: [ + { + group: [ + // Catch all relative paths into features + "**/features", + "**/features/*", + // Catch all alias imports + "@calcom/features", + "@calcom/features/*", + ], + message: "Avoid importing @calcom/features from @calcom/app-store or @calcom/lib.", + }, + ], }, ], }, }, + // WARN: lib must not import app-store or features + { + files: ["packages/lib/**/*.{ts,tsx,js,jsx}"], + rules: { + "no-restricted-imports": [ + "warn", + { + patterns: [ + { + group: [ + // Catch all relative paths into app-store + "**/app-store", + "**/app-store/*", + // Catch all relative paths into features + "**/features", + "**/features/*", + // Catch alias imports + "@calcom/app-store", + "@calcom/app-store/*", + "@calcom/features", + "@calcom/features/*", + ], + message: "@calcom/lib should not import @calcom/app-store or @calcom/features.", + }, + ], + }, + ], + }, + }, + // ERROR: app-store must not import trpc { files: ["packages/app-store/**/*.{ts,tsx,js,jsx}"], rules: { - "@typescript-eslint/no-restricted-imports": [ + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: [ + // Catch all relative paths into trpc + "**/trpc", + "**/trpc/*", + // Catch alias imports + "@calcom/trpc", + "@calcom/trpc/*", + "@trpc", + "@trpc/*", + ], + message: + "@calcom/app-store must not import trpc. Move UI to apps/web/components/apps or introduce an API boundary.", + }, + ], + }, + ], + }, + }, + + // ERROR: prisma must not import `features` package + { + files: ["packages/prisma/**/*.{ts,tsx,js,jsx}"], + rules: { + "no-restricted-imports": [ "error", { patterns: [ { - group: ["@calcom/trpc/*", "@trpc/*"], - message: "tRPC imports are blocked in packages/app-store. Move UI to apps/web/components/apps or introduce an API boundary.", - allowTypeImports: false, + group: [ + // Catch all relative paths into features + "**/features", + "**/features/*", + // Catch all alias imports + "@calcom/features", + "@calcom/features/*", + ], + message: "Avoid importing @calcom/features from @calcom/prisma.", }, ], }, From aa9d05ee961bfc6a0db30bb4e5221dead87fd17e Mon Sep 17 00:00:00 2001 From: "cal.com" Date: Fri, 19 Sep 2025 12:04:20 +0300 Subject: [PATCH 5/8] chore: fix any types set by devin --- .../billing/interfaces/billing-service.interface.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 index 6e41e480eb4da2..6429c5ffcfa0da 100644 --- a/apps/api/v2/src/modules/billing/interfaces/billing-service.interface.ts +++ b/apps/api/v2/src/modules/billing/interfaces/billing-service.interface.ts @@ -1,9 +1,12 @@ 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 interface IBillingService { getBillingData(teamId: number): Promise<{ - team: any; + team: (Team & { platformBilling: PlatformBilling | null }) | null; status: "valid" | "no_subscription" | "no_billing"; plan: string; }>; @@ -19,5 +22,5 @@ export interface IBillingService { handleStripeSubscriptionForActiveManagedUsers(event: Stripe.Event): Promise; getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null; getCustomerIdFromInvoice(invoice: Stripe.Invoice): string | null; - stripeService: any; + stripeService: StripeService; } From 08482b3127649d30254306378e6d22a35fd35327 Mon Sep 17 00:00:00 2001 From: "cal.com" Date: Fri, 19 Sep 2025 15:57:31 +0300 Subject: [PATCH 6/8] fix: add mising expect in test --- .../billing/controllers/billing.controller.e2e-spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 d0317d312d998b..c8a48642e88710 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 @@ -136,8 +136,11 @@ describe("Platform Billing Controller (e2e)", () => { it("/billing/webhook (GET) should get billing plan for org", () => { return request(app.getHttpServer()) .get(`/v2/billing/${organization.id}/check`) - - .expect(200); + .expect(200) + .then(async (res) => { + const data = res.body as CheckPlatformBillingResponseDto; + expect(data?.plan).toEqual("FREE"); + }); }); it("/billing/webhook (POST) failed payment should set billing free plan to overdue", () => { From 1c9c7f673d1a5fd37aa0f8d608ac20e04f34ea60 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 19:40:44 +0000 Subject: [PATCH 7/8] refactor: move cache methods into BillingServiceCachingProxy - Remove BillingCacheService abstraction as suggested by @keithwillcode - Move cache methods directly into proxy as private methods - Update proxy to inject RedisService directly - Move BillingData type to interface for better type safety - Remove BillingCacheService from module providers - Delete unused billing-cache.service.ts file This simplifies the architecture by removing unnecessary abstraction and follows standard caching proxy patterns. Co-Authored-By: morgan@cal.com --- .../v2/src/modules/billing/billing.module.ts | 2 - .../interfaces/billing-service.interface.ts | 13 +++--- .../billing/services/billing-cache.service.ts | 31 ------------- .../services/billing-service-caching-proxy.ts | 43 +++++++++++++------ 4 files changed, 36 insertions(+), 53 deletions(-) delete mode 100644 apps/api/v2/src/modules/billing/services/billing-cache.service.ts diff --git a/apps/api/v2/src/modules/billing/billing.module.ts b/apps/api/v2/src/modules/billing/billing.module.ts index ef9364ed9c87ab..8dc8c2213cbea7 100644 --- a/apps/api/v2/src/modules/billing/billing.module.ts +++ b/apps/api/v2/src/modules/billing/billing.module.ts @@ -2,7 +2,6 @@ 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 { BillingCacheService } from "@/modules/billing/services/billing-cache.service"; 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"; @@ -36,7 +35,6 @@ import { Module } from "@nestjs/common"; providers: [ BillingConfigService, BillingService, - BillingCacheService, { provide: "IBillingService", useClass: BillingServiceCachingProxy, 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 index 6429c5ffcfa0da..776e191476fd56 100644 --- a/apps/api/v2/src/modules/billing/interfaces/billing-service.interface.ts +++ b/apps/api/v2/src/modules/billing/interfaces/billing-service.interface.ts @@ -1,15 +1,16 @@ 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<{ - team: (Team & { platformBilling: PlatformBilling | null }) | null; - status: "valid" | "no_subscription" | "no_billing"; - plan: string; - }>; + getBillingData(teamId: number): Promise; createTeamBilling(teamId: number): Promise; redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string): Promise; updateSubscriptionForTeam(teamId: number, plan: PlatformPlan): Promise; diff --git a/apps/api/v2/src/modules/billing/services/billing-cache.service.ts b/apps/api/v2/src/modules/billing/services/billing-cache.service.ts deleted file mode 100644 index e5621a0d769437..00000000000000 --- a/apps/api/v2/src/modules/billing/services/billing-cache.service.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { RedisService } from "@/modules/redis/redis.service"; -import { Injectable } from "@nestjs/common"; - -export const REDIS_BILLING_CACHE_KEY = (teamId: number) => `apiv2:team:${teamId}:billing`; -export const BILLING_CACHE_TTL_MS = 3_600_000; // 1 hour - -type BillingData = { - team: any; - status: "valid" | "no_subscription" | "no_billing"; - plan: string; -}; - -@Injectable() -export class BillingCacheService { - constructor(private readonly redisService: RedisService) {} - - async deleteBillingCache(teamId: number) { - await this.redisService.del(REDIS_BILLING_CACHE_KEY(teamId)); - } - - async getBillingCache(teamId: number) { - const cachedResult = await this.redisService.get(REDIS_BILLING_CACHE_KEY(teamId)); - return cachedResult; - } - - async setBillingCache(teamId: number, billingData: BillingData): Promise { - await this.redisService.set(REDIS_BILLING_CACHE_KEY(teamId), billingData, { - ttl: BILLING_CACHE_TTL_MS, - }); - } -} 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 index ac9a941ab70e60..7e45eff371f383 100644 --- 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 @@ -1,28 +1,43 @@ -import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface"; -import { BillingCacheService } from "@/modules/billing/services/billing-cache.service"; +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 billingCacheService: BillingCacheService - ) {} + constructor(private readonly billingService: BillingService, private readonly redisService: RedisService) {} async getBillingData(teamId: number) { - const cachedBillingData = await this.billingCacheService.getBillingCache(teamId); + const cachedBillingData = await this.getBillingCache(teamId); if (cachedBillingData) { return cachedBillingData; } const billingData = await this.billingService.getBillingData(teamId); - await this.billingCacheService.setBillingCache(teamId, billingData); + 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); } @@ -41,7 +56,7 @@ export class BillingServiceCachingProxy implements IBillingService { async cancelTeamSubscription(teamId: number): Promise { await this.billingService.cancelTeamSubscription(teamId); - await this.billingCacheService.deleteBillingCache(teamId); + await this.deleteBillingCache(teamId); } async handleStripeSubscriptionDeleted(event: Stripe.Event): Promise { @@ -49,7 +64,7 @@ export class BillingServiceCachingProxy implements IBillingService { const subscription = event.data.object as Stripe.Subscription; const teamId = subscription?.metadata?.teamId; if (teamId) { - await this.billingCacheService.deleteBillingCache(Number.parseInt(teamId)); + await this.deleteBillingCache(Number.parseInt(teamId)); } } @@ -62,7 +77,7 @@ export class BillingServiceCachingProxy implements IBillingService { subscriptionId ); if (teamBilling?.id) { - await this.billingCacheService.deleteBillingCache(teamBilling.id); + await this.deleteBillingCache(teamBilling.id); } } } @@ -76,7 +91,7 @@ export class BillingServiceCachingProxy implements IBillingService { subscriptionId ); if (teamBilling?.id) { - await this.billingCacheService.deleteBillingCache(teamBilling.id); + await this.deleteBillingCache(teamBilling.id); } } } @@ -90,7 +105,7 @@ export class BillingServiceCachingProxy implements IBillingService { subscriptionId ); if (teamBilling?.id) { - await this.billingCacheService.deleteBillingCache(teamBilling.id); + await this.deleteBillingCache(teamBilling.id); } } } @@ -100,7 +115,7 @@ export class BillingServiceCachingProxy implements IBillingService { const checkoutSession = event.data.object as Stripe.Checkout.Session; const teamId = checkoutSession.metadata?.teamId; if (teamId) { - await this.billingCacheService.deleteBillingCache(Number.parseInt(teamId)); + await this.deleteBillingCache(Number.parseInt(teamId)); } } From c92d16947da80ea71671784b3633ad2e9b8c2060 Mon Sep 17 00:00:00 2001 From: "cal.com" Date: Mon, 22 Sep 2025 10:47:20 +0300 Subject: [PATCH 8/8] fix: test and legacy starter --- .../billing/controllers/billing.controller.e2e-spec.ts | 6 +++--- .../test/fixtures/repository/billing.repository.fixture.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) 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 c8a48642e88710..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 @@ -95,8 +95,8 @@ describe("Platform Billing Controller (e2e)", () => { .expect(200) .then(async (res) => { - const data = res.body as CheckPlatformBillingResponseDto; - expect(data?.plan).toEqual(undefined); + const data = res.body.data as CheckPlatformBillingResponseDto; + expect(data?.plan).toEqual("FREE"); }); }); @@ -138,7 +138,7 @@ describe("Platform Billing Controller (e2e)", () => { .get(`/v2/billing/${organization.id}/check`) .expect(200) .then(async (res) => { - const data = res.body as CheckPlatformBillingResponseDto; + const data = res.body.data as CheckPlatformBillingResponseDto; expect(data?.plan).toEqual("FREE"); }); }); 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", }, }); }