From f4abbb2de14cb3d685d1e62766dae82f922509c1 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:11:36 +0000 Subject: [PATCH] feat: refactor billing to strategy implemention (#27828) * factory and statergie * chore: use correct method of DI * feat: add onchagne * add logic to HWM stat * add webhook resolver methods to each statergy * move seat tracking + webhooks over to own statergy * Move to factory base approach * move logic to correct class * rename create -> createByTeamId * fix: remove debug `true ||` overrides from IS_STRIPE_ENABLED and IS_TEAM_BILLING_ENABLED Remove accidentally committed debug overrides that short-circuited IS_STRIPE_ENABLED and IS_TEAM_BILLING_ENABLED to always be true, bypassing Stripe credential checks. This would break self-hosted instances without Stripe configured. Identified by cubic (https://cubic.dev) Co-Authored-By: unknown <> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/web/app/api/cron/downgradeUsers/route.ts | 2 +- .../billing/api/webhook/_invoice.paid.team.ts | 17 +- .../webhook/_invoice.payment_failed.test.ts | 64 +++- .../api/webhook/_invoice.payment_failed.ts | 32 +- .../_invoice.payment_succeeded.test.ts | 45 ++- .../api/webhook/_invoice.payment_succeeded.ts | 29 +- .../billing/api/webhook/_invoice.upcoming.ts | 28 +- .../ee/billing/di/containers/Billing.ts | 8 +- .../di/modules/BillingPeriodRepository.ts | 23 ++ .../di/modules/BillingPeriodService.module.ts | 27 ++ .../SeatBillingStrategyFactory.module.ts | 37 +++ .../di/modules/TeamBillingServiceFactory.ts | 5 +- packages/features/ee/billing/di/tokens.ts | 6 + packages/features/ee/billing/seed.ts | 134 ++++++++ .../HighWaterMarkStrategy.ts | 56 ++++ .../ISeatBillingStrategy.ts | 45 +++ .../ImmediateUpdateStrategy.ts | 17 ++ .../MonthlyProrationStrategy.ts | 55 ++++ .../SeatBillingStrategyFactory.ts | 82 +++++ .../SeatBillingStrategyFactory.test.ts | 191 ++++++++++++ .../__tests__/strategies.test.ts | 286 ++++++++++++++++++ .../service/teams/ITeamBillingService.ts | 8 +- .../service/teams/StubTeamBillingService.ts | 3 +- .../service/teams/TeamBillingFactory.test.ts | 9 +- .../service/teams/TeamBillingService.test.ts | 74 +++-- .../service/teams/TeamBillingService.ts | 38 +-- .../teams/TeamBillingServiceFactory.ts | 3 + .../teams/internal-team-billing.test.ts | 69 ++--- .../services/teamService.integration-test.ts | 2 +- .../ee/teams/services/teamService.test.ts | 2 +- .../features/ee/teams/services/teamService.ts | 4 +- packages/lib/constants.ts | 151 ++++++--- .../organizations/bulkDeleteUsers.handler.ts | 2 +- .../inviteMember/inviteMember.handler.ts | 2 +- 34 files changed, 1314 insertions(+), 242 deletions(-) create mode 100644 packages/features/ee/billing/di/modules/BillingPeriodRepository.ts create mode 100644 packages/features/ee/billing/di/modules/BillingPeriodService.module.ts create mode 100644 packages/features/ee/billing/di/modules/SeatBillingStrategyFactory.module.ts create mode 100644 packages/features/ee/billing/seed.ts create mode 100644 packages/features/ee/billing/service/seatBillingStrategy/HighWaterMarkStrategy.ts create mode 100644 packages/features/ee/billing/service/seatBillingStrategy/ISeatBillingStrategy.ts create mode 100644 packages/features/ee/billing/service/seatBillingStrategy/ImmediateUpdateStrategy.ts create mode 100644 packages/features/ee/billing/service/seatBillingStrategy/MonthlyProrationStrategy.ts create mode 100644 packages/features/ee/billing/service/seatBillingStrategy/SeatBillingStrategyFactory.ts create mode 100644 packages/features/ee/billing/service/seatBillingStrategy/__tests__/SeatBillingStrategyFactory.test.ts create mode 100644 packages/features/ee/billing/service/seatBillingStrategy/__tests__/strategies.test.ts diff --git a/apps/web/app/api/cron/downgradeUsers/route.ts b/apps/web/app/api/cron/downgradeUsers/route.ts index ae13dfa5e9..422c9d3ba2 100644 --- a/apps/web/app/api/cron/downgradeUsers/route.ts +++ b/apps/web/app/api/cron/downgradeUsers/route.ts @@ -44,7 +44,7 @@ async function postHandler(request: NextRequest) { const teamBillingFactory = getTeamBillingServiceFactory(); const teamsBilling = teamBillingFactory.initMany(teams); - const teamBillingPromises = teamsBilling.map((teamBilling) => teamBilling.updateQuantity()); + const teamBillingPromises = teamsBilling.map((teamBilling) => teamBilling.updateQuantity("sync")); await Promise.allSettled(teamBillingPromises); pageNumber++; diff --git a/packages/features/ee/billing/api/webhook/_invoice.paid.team.ts b/packages/features/ee/billing/api/webhook/_invoice.paid.team.ts index 4f519523d2..3ea7962ac1 100644 --- a/packages/features/ee/billing/api/webhook/_invoice.paid.team.ts +++ b/packages/features/ee/billing/api/webhook/_invoice.paid.team.ts @@ -1,7 +1,8 @@ +import { getSeatBillingStrategyFactory } from "@calcom/features/ee/billing/di/containers/Billing"; import logger from "@calcom/lib/logger"; import { z } from "zod"; + import type { SWHMap } from "./__handler"; -import { handleHwmResetAfterRenewal, validateInvoiceLinesForHwm } from "./hwm-webhook-utils"; const log = logger.getSubLogger({ prefix: ["invoice-paid-team"] }); @@ -35,13 +36,17 @@ const handler = async (data: SWHMap["invoice.paid"]["data"]) => { customerId: invoice.customer, }); - // Only handle renewal invoices for HWM reset if (invoice.billing_reason === "subscription_cycle") { - log.info(`Processing renewal invoice for team subscription ${subscriptionId}`); - const validation = validateInvoiceLinesForHwm(invoice.lines.data, subscriptionId, log); - if (validation.isValid) { - await handleHwmResetAfterRenewal(subscriptionId, validation.periodStart, log); + const periodStart = invoice.lines.data[0]?.period?.start; + if (!periodStart) { + log.warn(`Invoice has no period start for subscription ${subscriptionId}, skipping renewal handling`); + return { success: true }; } + + log.info(`Processing renewal invoice for team subscription ${subscriptionId}`); + const factory = getSeatBillingStrategyFactory(); + const strategy = await factory.createBySubscriptionId(subscriptionId); + await strategy.onRenewalPaid(subscriptionId, new Date(periodStart * 1000)); } return { success: true }; diff --git a/packages/features/ee/billing/api/webhook/_invoice.payment_failed.test.ts b/packages/features/ee/billing/api/webhook/_invoice.payment_failed.test.ts index 33ef977e38..9cc7ddb663 100644 --- a/packages/features/ee/billing/api/webhook/_invoice.payment_failed.test.ts +++ b/packages/features/ee/billing/api/webhook/_invoice.payment_failed.test.ts @@ -4,19 +4,15 @@ import { buildMonthlyProrationMetadata } from "../../lib/proration-utils"; import type { SWHMap } from "./__handler"; import handler from "./_invoice.payment_failed"; -const handleProrationPaymentFailure = vi.fn(); +const onPaymentFailed = vi.fn().mockResolvedValue({ handled: true }); +const createBySubscriptionId = vi.fn().mockResolvedValue({ onPaymentFailed }); const getPaymentIntentFailureReason = vi.fn().mockResolvedValue("card_declined"); vi.mock("@calcom/ee/billing/di/containers/Billing", () => ({ getBillingProviderService: () => ({ getPaymentIntentFailureReason, }), -})); - -vi.mock("../../service/proration/MonthlyProrationService", () => ({ - MonthlyProrationService: class { - handleProrationPaymentFailure = handleProrationPaymentFailure; - }, + getSeatBillingStrategyFactory: () => ({ createBySubscriptionId }), })); describe("invoice.payment_failed webhook", () => { @@ -27,6 +23,7 @@ describe("invoice.payment_failed webhook", () => { it("records proration failure with payment intent reason", async () => { const data = { object: { + subscription: "sub_123", payment_intent: "pi_123", status: "open", lines: { @@ -42,10 +39,53 @@ describe("invoice.payment_failed webhook", () => { const result = await handler(data); expect(getPaymentIntentFailureReason).toHaveBeenCalledWith("pi_123"); - expect(handleProrationPaymentFailure).toHaveBeenCalledWith({ - prorationId: "pr_123", - reason: "card_declined", - }); - expect(result).toEqual({ success: true }); + expect(createBySubscriptionId).toHaveBeenCalledWith("sub_123"); + expect(onPaymentFailed).toHaveBeenCalledWith( + { lines: data.object.lines }, + "card_declined" + ); + expect(result).toEqual({ success: true, handled: true }); + }); + + it("skips when no subscription on invoice", async () => { + const data = { + object: { + subscription: null, + payment_intent: "pi_123", + status: "open", + lines: { data: [] }, + }, + } as unknown as SWHMap["invoice.payment_failed"]["data"]; + + const result = await handler(data); + + expect(createBySubscriptionId).not.toHaveBeenCalled(); + expect(result).toEqual({ success: true, message: "not a subscription invoice" }); + }); + + it("uses invoice status as fallback when no payment intent", async () => { + const data = { + object: { + subscription: "sub_456", + payment_intent: null, + status: "open", + lines: { + data: [ + { + metadata: buildMonthlyProrationMetadata({ prorationId: "pr_456" }), + }, + ], + }, + }, + } as unknown as SWHMap["invoice.payment_failed"]["data"]; + + const result = await handler(data); + + expect(getPaymentIntentFailureReason).not.toHaveBeenCalled(); + expect(onPaymentFailed).toHaveBeenCalledWith( + { lines: data.object.lines }, + "open" + ); + expect(result).toEqual({ success: true, handled: true }); }); }); diff --git a/packages/features/ee/billing/api/webhook/_invoice.payment_failed.ts b/packages/features/ee/billing/api/webhook/_invoice.payment_failed.ts index f28eaad672..81b17d4169 100644 --- a/packages/features/ee/billing/api/webhook/_invoice.payment_failed.ts +++ b/packages/features/ee/billing/api/webhook/_invoice.payment_failed.ts @@ -1,8 +1,6 @@ -import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing"; +import { getBillingProviderService, getSeatBillingStrategyFactory } from "@calcom/ee/billing/di/containers/Billing"; import logger from "@calcom/lib/logger"; -import { findMonthlyProrationLineItem } from "../../lib/proration-utils"; -import { MonthlyProrationService } from "../../service/proration/MonthlyProrationService"; import type { SWHMap } from "./__handler"; const log = logger.getSubLogger({ prefix: ["invoice-payment-failed"] }); @@ -12,19 +10,14 @@ type Data = SWHMap["invoice.payment_failed"]["data"]; const handler = async (data: Data) => { const invoice = data.object; - const prorationLineItem = findMonthlyProrationLineItem(invoice.lines.data); + const subscriptionId = + typeof invoice.subscription === "string" ? invoice.subscription : invoice.subscription?.id; - if (!prorationLineItem) { - return { success: true, message: "no proration line items in invoice" }; + if (!subscriptionId) { + log.debug("Not a subscription invoice, skipping"); + return { success: true, message: "not a subscription invoice" }; } - const prorationId = prorationLineItem.metadata?.prorationId; - if (!prorationId) { - log.warn("proration line item missing prorationId metadata"); - return { success: false, message: "missing prorationId in metadata" }; - } - - const prorationService = new MonthlyProrationService(); let failureReason = invoice.status ?? "payment_failed"; const paymentIntentId = typeof invoice.payment_intent === "string" ? invoice.payment_intent : invoice.payment_intent?.id; @@ -35,14 +28,15 @@ const handler = async (data: Data) => { failureReason = paymentFailureReason ?? failureReason; } - await prorationService.handleProrationPaymentFailure({ - prorationId, - reason: failureReason, - }); + const factory = getSeatBillingStrategyFactory(); + const strategy = await factory.createBySubscriptionId(subscriptionId); + const { handled } = await strategy.onPaymentFailed({ lines: invoice.lines }, failureReason); - log.info(`proration ${prorationId} marked as failed`); + if (handled) { + log.info("Strategy handled payment failure", { subscriptionId, failureReason }); + } - return { success: true }; + return { success: true, handled }; }; export default handler; diff --git a/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.test.ts b/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.test.ts index 2ebc00da4e..dd100a0214 100644 --- a/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.test.ts +++ b/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.test.ts @@ -4,12 +4,11 @@ import { buildMonthlyProrationMetadata } from "../../lib/proration-utils"; import type { SWHMap } from "./__handler"; import handler from "./_invoice.payment_succeeded"; -const handleProrationPaymentSuccess = vi.fn(); +const onPaymentSucceeded = vi.fn().mockResolvedValue({ handled: true }); +const createBySubscriptionId = vi.fn().mockResolvedValue({ onPaymentSucceeded }); -vi.mock("../../service/proration/MonthlyProrationService", () => ({ - MonthlyProrationService: class { - handleProrationPaymentSuccess = handleProrationPaymentSuccess; - }, +vi.mock("@calcom/features/ee/billing/di/containers/Billing", () => ({ + getSeatBillingStrategyFactory: () => ({ createBySubscriptionId }), })); describe("invoice.payment_succeeded webhook", () => { @@ -20,6 +19,7 @@ describe("invoice.payment_succeeded webhook", () => { it("marks proration as charged when line item is present", async () => { const data = { object: { + subscription: "sub_123", lines: { data: [ { @@ -32,19 +32,21 @@ describe("invoice.payment_succeeded webhook", () => { const result = await handler(data); - expect(handleProrationPaymentSuccess).toHaveBeenCalledWith("pr_123"); - expect(result).toEqual({ success: true }); + expect(createBySubscriptionId).toHaveBeenCalledWith("sub_123"); + expect(onPaymentSucceeded).toHaveBeenCalledWith({ + lines: data.object.lines, + }); + expect(result).toEqual({ success: true, handled: true }); }); - it("skips when no proration line item exists", async () => { + it("skips when no subscription on invoice", async () => { const data = { object: { + subscription: null, lines: { data: [ { - metadata: { - type: "other", - }, + metadata: { type: "other" }, }, ], }, @@ -53,7 +55,24 @@ describe("invoice.payment_succeeded webhook", () => { const result = await handler(data); - expect(handleProrationPaymentSuccess).not.toHaveBeenCalled(); - expect(result).toEqual({ success: true, message: "no proration line items in invoice" }); + expect(createBySubscriptionId).not.toHaveBeenCalled(); + expect(result).toEqual({ success: true, message: "not a subscription invoice" }); + }); + + it("returns handled=false when strategy does not handle the invoice", async () => { + onPaymentSucceeded.mockResolvedValueOnce({ handled: false }); + + const data = { + object: { + subscription: "sub_456", + lines: { + data: [{ metadata: { type: "other" } }], + }, + }, + } as unknown as SWHMap["invoice.payment_succeeded"]["data"]; + + const result = await handler(data); + + expect(result).toEqual({ success: true, handled: false }); }); }); diff --git a/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.ts b/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.ts index d7a4b448ad..a39005e579 100644 --- a/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.ts +++ b/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.ts @@ -1,7 +1,6 @@ +import { getSeatBillingStrategyFactory } from "@calcom/features/ee/billing/di/containers/Billing"; import logger from "@calcom/lib/logger"; -import { findMonthlyProrationLineItem } from "../../lib/proration-utils"; -import { MonthlyProrationService } from "../../service/proration/MonthlyProrationService"; import type { SWHMap } from "./__handler"; const log = logger.getSubLogger({ prefix: ["invoice-payment-succeeded"] }); @@ -11,25 +10,23 @@ type Data = SWHMap["invoice.payment_succeeded"]["data"]; const handler = async (data: Data) => { const invoice = data.object; - const prorationLineItem = findMonthlyProrationLineItem(invoice.lines.data); + const subscriptionId = + typeof invoice.subscription === "string" ? invoice.subscription : invoice.subscription?.id; - if (!prorationLineItem) { - return { success: true, message: "no proration line items in invoice" }; + if (!subscriptionId) { + log.debug("Not a subscription invoice, skipping"); + return { success: true, message: "not a subscription invoice" }; } - const prorationId = prorationLineItem.metadata?.prorationId; - if (!prorationId) { - log.warn("proration line item missing prorationId metadata"); - return { success: false, message: "missing prorationId in metadata" }; + const factory = getSeatBillingStrategyFactory(); + const strategy = await factory.createBySubscriptionId(subscriptionId); + const { handled } = await strategy.onPaymentSucceeded({ lines: invoice.lines }); + + if (handled) { + log.info("Strategy handled payment succeeded", { subscriptionId }); } - const prorationService = new MonthlyProrationService(); - - await prorationService.handleProrationPaymentSuccess(prorationId); - - log.info(`proration ${prorationId} marked as charged`); - - return { success: true }; + return { success: true, handled }; }; export default handler; diff --git a/packages/features/ee/billing/api/webhook/_invoice.upcoming.ts b/packages/features/ee/billing/api/webhook/_invoice.upcoming.ts index 84dfb4e3fe..e6a55df369 100644 --- a/packages/features/ee/billing/api/webhook/_invoice.upcoming.ts +++ b/packages/features/ee/billing/api/webhook/_invoice.upcoming.ts @@ -1,5 +1,4 @@ -import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing"; -import { HighWaterMarkService } from "@calcom/features/ee/billing/service/highWaterMark/HighWaterMarkService"; +import { getSeatBillingStrategyFactory } from "@calcom/features/ee/billing/di/containers/Billing"; import logger from "@calcom/lib/logger"; import type { SWHMap } from "./__handler"; @@ -11,7 +10,6 @@ const log = logger.getSubLogger({ prefix: ["stripe-webhook-invoice-upcoming"] }) const handler = async (data: Data) => { const invoice = data.object; - // Only handle subscription invoices if (!invoice.subscription) { log.debug("Not a subscription invoice, skipping"); return { success: false, message: "Not a subscription invoice" }; @@ -26,30 +24,18 @@ const handler = async (data: Data) => { customerId: typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id, }); - const billingService = getBillingProviderService(); - const highWaterMarkService = new HighWaterMarkService({ - logger: log, - billingService, - }); - try { - const applied = await highWaterMarkService.applyHighWaterMarkToSubscription(subscriptionId); + const factory = getSeatBillingStrategyFactory(); + const strategy = await factory.createBySubscriptionId(subscriptionId); + const { applied } = await strategy.onInvoiceUpcoming(subscriptionId); if (applied) { - log.info("Successfully applied high water mark before renewal", { - subscriptionId, - }); - return { success: true, highWaterMarkApplied: true }; + log.info("Strategy applied invoice.upcoming handling", { subscriptionId }); } - log.debug("No high water mark update needed", { subscriptionId }); - return { success: true, highWaterMarkApplied: false }; + return { success: true, highWaterMarkApplied: applied }; } catch (error) { - log.error("Failed to apply high water mark", { - subscriptionId, - error, - }); - // Return success: false but don't throw - we don't want to fail the webhook + log.error("Failed to process invoice.upcoming", { subscriptionId, error }); return { success: false, error: String(error) }; } }; diff --git a/packages/features/ee/billing/di/containers/Billing.ts b/packages/features/ee/billing/di/containers/Billing.ts index 9d10438102..5a6b9d2c63 100644 --- a/packages/features/ee/billing/di/containers/Billing.ts +++ b/packages/features/ee/billing/di/containers/Billing.ts @@ -1,9 +1,10 @@ import { createContainer } from "@calcom/features/di/di"; - import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository"; import type { StripeBillingService } from "../../service/billingProvider/StripeBillingService"; +import type { SeatBillingStrategyFactory } from "../../service/seatBillingStrategy/SeatBillingStrategyFactory"; import type { TeamBillingServiceFactory } from "../../service/teams/TeamBillingServiceFactory"; import { billingProviderServiceModuleLoader } from "../modules/BillingProviderService"; +import { seatBillingStrategyFactoryModuleLoader } from "../modules/SeatBillingStrategyFactory.module"; import { teamBillingServiceFactoryModuleLoader } from "../modules/TeamBillingServiceFactory"; import { DI_TOKENS } from "../tokens"; @@ -12,6 +13,7 @@ const billingContainer = createContainer(); // Load all modules (dependencies are loaded recursively) teamBillingServiceFactoryModuleLoader.loadModule(billingContainer); billingProviderServiceModuleLoader.loadModule(billingContainer); +seatBillingStrategyFactoryModuleLoader.loadModule(billingContainer); export function getTeamBillingServiceFactory(): TeamBillingServiceFactory { return billingContainer.get(DI_TOKENS.TEAM_BILLING_SERVICE_FACTORY); @@ -24,3 +26,7 @@ export function getBillingProviderService(): StripeBillingService { export function getTeamBillingDataRepository(): ITeamBillingDataRepository { return billingContainer.get(DI_TOKENS.TEAM_BILLING_DATA_REPOSITORY); } + +export function getSeatBillingStrategyFactory(): SeatBillingStrategyFactory { + return billingContainer.get(DI_TOKENS.SEAT_BILLING_STRATEGY_FACTORY); +} diff --git a/packages/features/ee/billing/di/modules/BillingPeriodRepository.ts b/packages/features/ee/billing/di/modules/BillingPeriodRepository.ts new file mode 100644 index 0000000000..f0c1b9c94f --- /dev/null +++ b/packages/features/ee/billing/di/modules/BillingPeriodRepository.ts @@ -0,0 +1,23 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; +import { BillingPeriodRepository } from "@calcom/features/ee/billing/repository/billingPeriod/BillingPeriodRepository"; +import { DI_TOKENS } from "../tokens"; + +const thisModule = createModule(); +const token = DI_TOKENS.BILLING_PERIOD_REPOSITORY; +const moduleToken = DI_TOKENS.BILLING_PERIOD_REPOSITORY_MODULE; + +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: BillingPeriodRepository, + dep: prismaModuleLoader, +}); + +export const billingPeriodRepositoryModuleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { BillingPeriodRepository }; diff --git a/packages/features/ee/billing/di/modules/BillingPeriodService.module.ts b/packages/features/ee/billing/di/modules/BillingPeriodService.module.ts new file mode 100644 index 0000000000..f143422a1a --- /dev/null +++ b/packages/features/ee/billing/di/modules/BillingPeriodService.module.ts @@ -0,0 +1,27 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/FeaturesRepository"; +import { BillingPeriodService } from "@calcom/features/ee/billing/service/billingPeriod/BillingPeriodService"; +import { DI_TOKENS } from "../tokens"; +import { billingPeriodRepositoryModuleLoader } from "./BillingPeriodRepository"; + +const thisModule = createModule(); +const token = DI_TOKENS.BILLING_PERIOD_SERVICE; +const moduleToken = DI_TOKENS.BILLING_PERIOD_SERVICE_MODULE; + +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: BillingPeriodService, + depsMap: { + repository: billingPeriodRepositoryModuleLoader, + featuresRepository: featuresRepositoryModuleLoader, + }, +}); + +export const billingPeriodServiceModuleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { BillingPeriodService }; diff --git a/packages/features/ee/billing/di/modules/SeatBillingStrategyFactory.module.ts b/packages/features/ee/billing/di/modules/SeatBillingStrategyFactory.module.ts new file mode 100644 index 0000000000..3f6d185609 --- /dev/null +++ b/packages/features/ee/billing/di/modules/SeatBillingStrategyFactory.module.ts @@ -0,0 +1,37 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/FeaturesRepository"; +import { SeatBillingStrategyFactory } from "@calcom/features/ee/billing/service/seatBillingStrategy/SeatBillingStrategyFactory"; +import { DI_TOKENS } from "../tokens"; +import { billingPeriodServiceModuleLoader } from "./BillingPeriodService.module"; +import { billingProviderServiceModuleLoader } from "./BillingProviderService"; +import { highWaterMarkRepositoryModuleLoader } from "./HighWaterMarkRepository"; +import { highWaterMarkServiceModuleLoader } from "./HighWaterMarkService"; +import { monthlyProrationServiceModuleLoader } from "./MonthlyProrationService"; +import { teamBillingDataRepositoryModuleLoader } from "./TeamBillingDataRepositoryFactory"; + +const thisModule = createModule(); +const token = DI_TOKENS.SEAT_BILLING_STRATEGY_FACTORY; +const moduleToken = DI_TOKENS.SEAT_BILLING_STRATEGY_FACTORY_MODULE; + +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: SeatBillingStrategyFactory, + depsMap: { + billingPeriodService: billingPeriodServiceModuleLoader, + featuresRepository: featuresRepositoryModuleLoader, + billingProviderService: billingProviderServiceModuleLoader, + highWaterMarkRepository: highWaterMarkRepositoryModuleLoader, + highWaterMarkService: highWaterMarkServiceModuleLoader, + monthlyProrationService: monthlyProrationServiceModuleLoader, + teamBillingDataRepository: teamBillingDataRepositoryModuleLoader, + }, +}); + +export const seatBillingStrategyFactoryModuleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { SeatBillingStrategyFactory }; diff --git a/packages/features/ee/billing/di/modules/TeamBillingServiceFactory.ts b/packages/features/ee/billing/di/modules/TeamBillingServiceFactory.ts index bd0be0d618..b01edcfef5 100644 --- a/packages/features/ee/billing/di/modules/TeamBillingServiceFactory.ts +++ b/packages/features/ee/billing/di/modules/TeamBillingServiceFactory.ts @@ -1,10 +1,10 @@ -import { createModule, ModuleLoader, bindModuleToClassOnToken } from "@calcom/features/di/di"; - +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; import { TeamBillingServiceFactory } from "../../service/teams/TeamBillingServiceFactory"; import { DI_TOKENS } from "../tokens"; import { billingProviderServiceModuleLoader } from "./BillingProviderService"; import { billingRepositoryFactoryModuleLoader } from "./BillingRepositoryFactory"; import { isTeamBillingEnabledModuleLoader } from "./IsTeamBillingEnabled"; +import { seatBillingStrategyFactoryModuleLoader } from "./SeatBillingStrategyFactory.module"; import { teamBillingDataRepositoryModuleLoader } from "./TeamBillingDataRepositoryFactory"; const teamBillingServiceFactoryModule = createModule(); @@ -20,6 +20,7 @@ const loadModule = bindModuleToClassOnToken({ teamBillingDataRepository: teamBillingDataRepositoryModuleLoader, billingRepositoryFactory: billingRepositoryFactoryModuleLoader, isTeamBillingEnabled: isTeamBillingEnabledModuleLoader, + seatBillingStrategyFactory: seatBillingStrategyFactoryModuleLoader, }, }); diff --git a/packages/features/ee/billing/di/tokens.ts b/packages/features/ee/billing/di/tokens.ts index 1649768082..cdb57ff000 100644 --- a/packages/features/ee/billing/di/tokens.ts +++ b/packages/features/ee/billing/di/tokens.ts @@ -17,4 +17,10 @@ export const DI_TOKENS = { HIGH_WATER_MARK_REPOSITORY_MODULE: Symbol("HighWaterMarkRepositoryModule"), MONTHLY_PRORATION_TEAM_REPOSITORY: Symbol("MonthlyProrationTeamRepository"), MONTHLY_PRORATION_TEAM_REPOSITORY_MODULE: Symbol("MonthlyProrationTeamRepositoryModule"), + BILLING_PERIOD_REPOSITORY: Symbol("BillingPeriodRepository"), + BILLING_PERIOD_REPOSITORY_MODULE: Symbol("BillingPeriodRepositoryModule"), + BILLING_PERIOD_SERVICE: Symbol("BillingPeriodService"), + BILLING_PERIOD_SERVICE_MODULE: Symbol("BillingPeriodServiceModule"), + SEAT_BILLING_STRATEGY_FACTORY: Symbol("SeatBillingStrategyFactory"), + SEAT_BILLING_STRATEGY_FACTORY_MODULE: Symbol("SeatBillingStrategyFactoryModule"), }; diff --git a/packages/features/ee/billing/seed.ts b/packages/features/ee/billing/seed.ts new file mode 100644 index 0000000000..a9939dedb9 --- /dev/null +++ b/packages/features/ee/billing/seed.ts @@ -0,0 +1,134 @@ +#!/usr/bin/env npx tsx +/** + * Interactive seed script launcher for billing test data. + * + * Usage: + * npx tsx packages/features/ee/billing/seed.ts + * + * Non-interactive (CI-friendly): + * npx tsx packages/features/ee/billing/seed.ts --hwm + * npx tsx packages/features/ee/billing/seed.ts --proration + * npx tsx packages/features/ee/billing/seed.ts --all + * npx tsx packages/features/ee/billing/seed.ts --cleanup + * + * Flags (combinable with above): + * --skip-stripe Skip Stripe API calls (use fake IDs) + * --cleanup Clean up before seeding + */ + +import { spawn } from "node:child_process"; +import * as readline from "node:readline"; + +const HWM_SCRIPT = "packages/features/ee/billing/service/highWaterMark/seed-hwm-test.ts"; +const PRORATION_SCRIPT = "packages/features/ee/billing/service/dueInvoice/seed-proration-test.ts"; + +const passthrough = process.argv.filter((a) => a === "--skip-stripe"); + +function run(script: string, extraArgs: string[] = []): Promise { + const args = ["tsx", script, ...passthrough, ...extraArgs]; + return new Promise((resolve) => { + const child = spawn("npx", args, { stdio: "inherit", shell: true }); + child.on("close", (code) => resolve(code ?? 1)); + }); +} + +async function seedHwm(cleanup: boolean) { + console.log("\n--- Seeding High Water Mark test data ---\n"); + const extra = cleanup ? ["--cleanup"] : []; + return run(HWM_SCRIPT, extra); +} + +async function seedProration(cleanup: boolean) { + console.log("\n--- Seeding Proration test data ---\n"); + const extra = cleanup ? ["--cleanup"] : []; + return run(PRORATION_SCRIPT, extra); +} + +async function seedAll(cleanup: boolean) { + let code = await seedHwm(cleanup); + if (code !== 0) return code; + code = await seedProration(cleanup); + return code; +} + +async function cleanupAll() { + console.log("\n--- Cleaning up all billing test data ---\n"); + let code = await run(HWM_SCRIPT, ["--cleanup", "--skip-stripe"]); + if (code !== 0) return code; + code = await run(PRORATION_SCRIPT, ["--cleanup", "--skip-stripe"]); + return code; +} + +function prompt(question: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +async function interactive() { + console.log("=== Billing Test Data Seeder ===\n"); + console.log(" 1) Seed HWM (High Water Mark) test data"); + console.log(" 2) Seed Proration test data"); + console.log(" 3) Seed all"); + console.log(" 4) Cleanup all test data"); + console.log(" q) Quit\n"); + + const choice = await prompt("Choose [1-4, q]: "); + + if (choice === "q" || choice === "") { + console.log("Bye."); + return; + } + + let cleanup = false; + if (["1", "2", "3"].includes(choice)) { + const ans = await prompt("Run cleanup before seeding? [y/N]: "); + cleanup = ans.toLowerCase() === "y"; + } + + let code = 0; + switch (choice) { + case "1": + code = await seedHwm(cleanup); + break; + case "2": + code = await seedProration(cleanup); + break; + case "3": + code = await seedAll(cleanup); + break; + case "4": + code = await cleanupAll(); + break; + default: + console.log(`Unknown option: ${choice}`); + code = 1; + } + + process.exit(code); +} + +async function main() { + const args = process.argv.slice(2).filter((a) => !a.startsWith("--skip-stripe")); + + if (args.includes("--hwm")) { + process.exit(await seedHwm(args.includes("--cleanup"))); + } + if (args.includes("--proration")) { + process.exit(await seedProration(args.includes("--cleanup"))); + } + if (args.includes("--all")) { + process.exit(await seedAll(args.includes("--cleanup"))); + } + if (args.includes("--cleanup") && !args.includes("--hwm") && !args.includes("--proration") && !args.includes("--all")) { + process.exit(await cleanupAll()); + } + + await interactive(); +} + +main(); diff --git a/packages/features/ee/billing/service/seatBillingStrategy/HighWaterMarkStrategy.ts b/packages/features/ee/billing/service/seatBillingStrategy/HighWaterMarkStrategy.ts new file mode 100644 index 0000000000..4a40f86402 --- /dev/null +++ b/packages/features/ee/billing/service/seatBillingStrategy/HighWaterMarkStrategy.ts @@ -0,0 +1,56 @@ +import logger from "@calcom/lib/logger"; + +import type { HighWaterMarkService } from "../highWaterMark/HighWaterMarkService"; +import type { HighWaterMarkRepository } from "../../repository/highWaterMark/HighWaterMarkRepository"; +import { BaseSeatBillingStrategy } from "./ISeatBillingStrategy"; +import type { SeatChangeContext } from "./ISeatBillingStrategy"; + +const log = logger.getSubLogger({ prefix: ["HighWaterMarkStrategy"] }); + +export interface IHighWaterMarkStrategyDeps { + highWaterMarkRepository: HighWaterMarkRepository; + highWaterMarkService: HighWaterMarkService; +} + +export class HighWaterMarkStrategy extends BaseSeatBillingStrategy { + constructor(private readonly deps: IHighWaterMarkStrategyDeps) { + super(); + } + + async onSeatChange(context: SeatChangeContext): Promise { + if (context.changeType !== "addition") return; + + const billing = await this.deps.highWaterMarkRepository.getByTeamId(context.teamId); + if (!billing) return; + + const periodStart = billing.highWaterMarkPeriodStart || billing.subscriptionStart; + if (!periodStart) return; + + const result = await this.deps.highWaterMarkRepository.updateIfHigher({ + teamId: context.teamId, + isOrganization: billing.isOrganization, + newSeatCount: context.membershipCount, + periodStart, + }); + + if (result.updated) { + log.info(`High water mark updated for team ${context.teamId}`, { + previousHighWaterMark: result.previousHighWaterMark, + newHighWaterMark: context.membershipCount, + }); + } + } + + override async onInvoiceUpcoming(subscriptionId: string): Promise<{ applied: boolean }> { + const applied = await this.deps.highWaterMarkService.applyHighWaterMarkToSubscription(subscriptionId); + return { applied }; + } + + override async onRenewalPaid(subscriptionId: string, periodStart: Date): Promise<{ reset: boolean }> { + const reset = await this.deps.highWaterMarkService.resetSubscriptionAfterRenewal({ + subscriptionId, + newPeriodStart: periodStart, + }); + return { reset }; + } +} diff --git a/packages/features/ee/billing/service/seatBillingStrategy/ISeatBillingStrategy.ts b/packages/features/ee/billing/service/seatBillingStrategy/ISeatBillingStrategy.ts new file mode 100644 index 0000000000..09b5be5472 --- /dev/null +++ b/packages/features/ee/billing/service/seatBillingStrategy/ISeatBillingStrategy.ts @@ -0,0 +1,45 @@ +export type SeatChangeType = "addition" | "removal" | "sync"; + +export interface SeatChangeContext { + teamId: number; + subscriptionId: string; + subscriptionItemId: string; + membershipCount: number; + changeType: SeatChangeType; +} + +export interface StripeInvoiceData { + lines: { + data: Array<{ + metadata?: Record | null; + }>; + }; +} + +export interface ISeatBillingStrategy { + onSeatChange(context: SeatChangeContext): Promise; + onInvoiceUpcoming(subscriptionId: string): Promise<{ applied: boolean }>; + onRenewalPaid(subscriptionId: string, periodStart: Date): Promise<{ reset: boolean }>; + onPaymentSucceeded(invoice: StripeInvoiceData): Promise<{ handled: boolean }>; + onPaymentFailed(invoice: StripeInvoiceData, reason: string): Promise<{ handled: boolean }>; +} + +export abstract class BaseSeatBillingStrategy implements ISeatBillingStrategy { + abstract onSeatChange(context: SeatChangeContext): Promise; + + async onInvoiceUpcoming(_subscriptionId: string): Promise<{ applied: boolean }> { + return { applied: false }; + } + + async onRenewalPaid(_subscriptionId: string, _periodStart: Date): Promise<{ reset: boolean }> { + return { reset: false }; + } + + async onPaymentSucceeded(_invoice: StripeInvoiceData): Promise<{ handled: boolean }> { + return { handled: false }; + } + + async onPaymentFailed(_invoice: StripeInvoiceData, _reason: string): Promise<{ handled: boolean }> { + return { handled: false }; + } +} diff --git a/packages/features/ee/billing/service/seatBillingStrategy/ImmediateUpdateStrategy.ts b/packages/features/ee/billing/service/seatBillingStrategy/ImmediateUpdateStrategy.ts new file mode 100644 index 0000000000..126fbcf477 --- /dev/null +++ b/packages/features/ee/billing/service/seatBillingStrategy/ImmediateUpdateStrategy.ts @@ -0,0 +1,17 @@ +import type { IBillingProviderService } from "../billingProvider/IBillingProviderService"; +import { BaseSeatBillingStrategy } from "./ISeatBillingStrategy"; +import type { SeatChangeContext } from "./ISeatBillingStrategy"; + +export class ImmediateUpdateStrategy extends BaseSeatBillingStrategy { + constructor(private readonly billingProviderService: IBillingProviderService) { + super(); + } + + async onSeatChange(context: SeatChangeContext): Promise { + await this.billingProviderService.handleSubscriptionUpdate({ + subscriptionId: context.subscriptionId, + subscriptionItemId: context.subscriptionItemId, + membershipCount: context.membershipCount, + }); + } +} diff --git a/packages/features/ee/billing/service/seatBillingStrategy/MonthlyProrationStrategy.ts b/packages/features/ee/billing/service/seatBillingStrategy/MonthlyProrationStrategy.ts new file mode 100644 index 0000000000..877e66d4d3 --- /dev/null +++ b/packages/features/ee/billing/service/seatBillingStrategy/MonthlyProrationStrategy.ts @@ -0,0 +1,55 @@ +import logger from "@calcom/lib/logger"; + +import { findMonthlyProrationLineItem } from "../../lib/proration-utils"; +import type { MonthlyProrationService } from "../proration/MonthlyProrationService"; +import { BaseSeatBillingStrategy } from "./ISeatBillingStrategy"; +import type { SeatChangeContext, StripeInvoiceData } from "./ISeatBillingStrategy"; + +const log = logger.getSubLogger({ prefix: ["MonthlyProrationStrategy"] }); + +export interface IMonthlyProrationStrategyDeps { + monthlyProrationService: MonthlyProrationService; +} + +export class MonthlyProrationStrategy extends BaseSeatBillingStrategy { + constructor(private readonly deps: IMonthlyProrationStrategyDeps) { + super(); + } + + async onSeatChange(_context: SeatChangeContext): Promise { + // No immediate Stripe update -- proration is calculated and invoiced on a monthly cycle + } + + override async onPaymentSucceeded(invoice: StripeInvoiceData): Promise<{ handled: boolean }> { + const prorationLineItem = findMonthlyProrationLineItem(invoice.lines.data); + if (!prorationLineItem) return { handled: false }; + + const prorationId = prorationLineItem.metadata?.prorationId; + if (!prorationId) { + log.warn("proration line item missing prorationId metadata"); + return { handled: false }; + } + + await this.deps.monthlyProrationService.handleProrationPaymentSuccess(prorationId); + log.info(`proration ${prorationId} marked as charged`); + return { handled: true }; + } + + override async onPaymentFailed(invoice: StripeInvoiceData, reason: string): Promise<{ handled: boolean }> { + const prorationLineItem = findMonthlyProrationLineItem(invoice.lines.data); + if (!prorationLineItem) return { handled: false }; + + const prorationId = prorationLineItem.metadata?.prorationId; + if (!prorationId) { + log.warn("proration line item missing prorationId metadata"); + return { handled: false }; + } + + await this.deps.monthlyProrationService.handleProrationPaymentFailure({ + prorationId, + reason, + }); + log.info(`proration ${prorationId} marked as failed`); + return { handled: true }; + } +} diff --git a/packages/features/ee/billing/service/seatBillingStrategy/SeatBillingStrategyFactory.ts b/packages/features/ee/billing/service/seatBillingStrategy/SeatBillingStrategyFactory.ts new file mode 100644 index 0000000000..7a42872fff --- /dev/null +++ b/packages/features/ee/billing/service/seatBillingStrategy/SeatBillingStrategyFactory.ts @@ -0,0 +1,82 @@ +import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface"; +import logger from "@calcom/lib/logger"; + +import type { HighWaterMarkRepository } from "../../repository/highWaterMark/HighWaterMarkRepository"; +import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository"; +import type { BillingPeriodService } from "../billingPeriod/BillingPeriodService"; +import type { IBillingProviderService } from "../billingProvider/IBillingProviderService"; +import type { HighWaterMarkService } from "../highWaterMark/HighWaterMarkService"; +import type { MonthlyProrationService } from "../proration/MonthlyProrationService"; +import { HighWaterMarkStrategy } from "./HighWaterMarkStrategy"; +import { ImmediateUpdateStrategy } from "./ImmediateUpdateStrategy"; +import type { ISeatBillingStrategy } from "./ISeatBillingStrategy"; +import { MonthlyProrationStrategy } from "./MonthlyProrationStrategy"; + +const log = logger.getSubLogger({ prefix: ["SeatBillingStrategyFactory"] }); + +export interface ISeatBillingStrategyFactoryDeps { + billingPeriodService: BillingPeriodService; + featuresRepository: IFeaturesRepository; + billingProviderService: IBillingProviderService; + highWaterMarkRepository: HighWaterMarkRepository; + highWaterMarkService: HighWaterMarkService; + monthlyProrationService: MonthlyProrationService; + teamBillingDataRepository: ITeamBillingDataRepository; +} + +export class SeatBillingStrategyFactory { + private readonly prorationStrategy: ISeatBillingStrategy; + private readonly hwmStrategy: ISeatBillingStrategy; + private readonly fallback: ISeatBillingStrategy; + + constructor(private readonly deps: ISeatBillingStrategyFactoryDeps) { + this.fallback = new ImmediateUpdateStrategy(deps.billingProviderService); + this.prorationStrategy = new MonthlyProrationStrategy({ + monthlyProrationService: deps.monthlyProrationService, + }); + this.hwmStrategy = new HighWaterMarkStrategy({ + highWaterMarkRepository: deps.highWaterMarkRepository, + highWaterMarkService: deps.highWaterMarkService, + }); + } + + async createByTeamId(teamId: number): Promise { + const info = await this.deps.billingPeriodService.getBillingPeriodInfo( + teamId + ); + + if (!info.isInTrial && info.subscriptionStart) { + if (info.billingPeriod === "ANNUALLY") { + const enabled = + await this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally( + "monthly-proration" + ); + if (enabled) return this.prorationStrategy; + } + if (info.billingPeriod === "MONTHLY") { + const enabled = + await this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally( + "hwm-seating" + ); + if (enabled) return this.hwmStrategy; + } + } + + return this.fallback; + } + + async createBySubscriptionId( + subscriptionId: string + ): Promise { + const team = await this.deps.teamBillingDataRepository.findBySubscriptionId( + subscriptionId + ); + if (!team) { + log.warn( + `No team found for subscription ${subscriptionId}, using fallback strategy` + ); + return this.fallback; + } + return this.createByTeamId(team.id); + } +} diff --git a/packages/features/ee/billing/service/seatBillingStrategy/__tests__/SeatBillingStrategyFactory.test.ts b/packages/features/ee/billing/service/seatBillingStrategy/__tests__/SeatBillingStrategyFactory.test.ts new file mode 100644 index 0000000000..17270db639 --- /dev/null +++ b/packages/features/ee/billing/service/seatBillingStrategy/__tests__/SeatBillingStrategyFactory.test.ts @@ -0,0 +1,191 @@ +import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface"; +import { describe, expect, it, vi } from "vitest"; +import type { ITeamBillingDataRepository } from "../../../repository/teamBillingData/ITeamBillingDataRepository"; +import type { BillingPeriodInfo } from "../../billingPeriod/BillingPeriodService"; +import type { IBillingProviderService } from "../../billingProvider/IBillingProviderService"; +import { HighWaterMarkStrategy } from "../HighWaterMarkStrategy"; +import { ImmediateUpdateStrategy } from "../ImmediateUpdateStrategy"; +import { MonthlyProrationStrategy } from "../MonthlyProrationStrategy"; +import { SeatBillingStrategyFactory } from "../SeatBillingStrategyFactory"; + +function createMockBillingPeriodService(info: BillingPeriodInfo) { + return { getBillingPeriodInfo: vi.fn().mockResolvedValue(info) }; +} + +function createMockFeaturesRepository(enabledFlags: Record): IFeaturesRepository { + return { + checkIfFeatureIsEnabledGlobally: vi.fn(async (slug: string) => enabledFlags[slug] ?? false), + } as unknown as IFeaturesRepository; +} + +function createMockBillingProviderService(): IBillingProviderService { + return { handleSubscriptionUpdate: vi.fn() } as unknown as IBillingProviderService; +} + +function createMockHighWaterMarkRepository() { + return { + getByTeamId: vi.fn(), + updateIfHigher: vi.fn().mockResolvedValue({ updated: false, previousHighWaterMark: null }), + }; +} + +function createMockHighWaterMarkService() { + return { + applyHighWaterMarkToSubscription: vi.fn().mockResolvedValue(false), + resetSubscriptionAfterRenewal: vi.fn().mockResolvedValue(false), + }; +} + +function createMockMonthlyProrationService() { + return { + handleProrationPaymentSuccess: vi.fn(), + handleProrationPaymentFailure: vi.fn(), + }; +} + +function createMockTeamBillingDataRepository(): ITeamBillingDataRepository { + return { + find: vi.fn(), + findBySubscriptionId: vi.fn().mockResolvedValue(null), + findMany: vi.fn(), + }; +} + +const baseBillingInfo: BillingPeriodInfo = { + billingPeriod: null, + subscriptionStart: new Date("2025-01-01"), + subscriptionEnd: new Date("2026-01-01"), + trialEnd: null, + isInTrial: false, + pricePerSeat: 1500, + isOrganization: false, +}; + +function createFactory( + info: BillingPeriodInfo, + enabledFlags: Record, + overrides?: { teamBillingDataRepository?: ITeamBillingDataRepository } +): SeatBillingStrategyFactory { + return new SeatBillingStrategyFactory({ + billingPeriodService: createMockBillingPeriodService(info), + featuresRepository: createMockFeaturesRepository(enabledFlags), + billingProviderService: createMockBillingProviderService(), + highWaterMarkRepository: createMockHighWaterMarkRepository(), + highWaterMarkService: createMockHighWaterMarkService(), + monthlyProrationService: createMockMonthlyProrationService(), + teamBillingDataRepository: overrides?.teamBillingDataRepository ?? createMockTeamBillingDataRepository(), + } as never); +} + +describe("SeatBillingStrategyFactory", () => { + it("returns MonthlyProrationStrategy for annual plan with proration enabled", async () => { + const billingPeriodService = createMockBillingPeriodService({ + ...baseBillingInfo, + billingPeriod: "ANNUALLY", + }); + const factory = new SeatBillingStrategyFactory({ + billingPeriodService, + featuresRepository: createMockFeaturesRepository({ "monthly-proration": true }), + billingProviderService: createMockBillingProviderService(), + highWaterMarkRepository: createMockHighWaterMarkRepository(), + highWaterMarkService: createMockHighWaterMarkService(), + monthlyProrationService: createMockMonthlyProrationService(), + teamBillingDataRepository: createMockTeamBillingDataRepository(), + } as never); + const strategy = await factory.createByTeamId(1); + + expect(strategy).toBeInstanceOf(MonthlyProrationStrategy); + expect(billingPeriodService.getBillingPeriodInfo).toHaveBeenCalledWith(1); + }); + + it("returns HighWaterMarkStrategy for monthly plan with HWM enabled", async () => { + const factory = createFactory( + { ...baseBillingInfo, billingPeriod: "MONTHLY" }, + { "hwm-seating": true } + ); + const strategy = await factory.createByTeamId(1); + + expect(strategy).toBeInstanceOf(HighWaterMarkStrategy); + }); + + it("returns ImmediateUpdateStrategy for annual plan with proration disabled", async () => { + const factory = createFactory( + { ...baseBillingInfo, billingPeriod: "ANNUALLY" }, + { "monthly-proration": false } + ); + const strategy = await factory.createByTeamId(1); + + expect(strategy).toBeInstanceOf(ImmediateUpdateStrategy); + }); + + it("returns ImmediateUpdateStrategy for monthly plan with HWM disabled", async () => { + const factory = createFactory( + { ...baseBillingInfo, billingPeriod: "MONTHLY" }, + { "hwm-seating": false } + ); + const strategy = await factory.createByTeamId(1); + + expect(strategy).toBeInstanceOf(ImmediateUpdateStrategy); + }); + + it("returns ImmediateUpdateStrategy when team is in trial", async () => { + const factory = createFactory( + { ...baseBillingInfo, billingPeriod: "ANNUALLY", isInTrial: true, trialEnd: new Date("2026-06-01") }, + { "monthly-proration": true } + ); + const strategy = await factory.createByTeamId(1); + + expect(strategy).toBeInstanceOf(ImmediateUpdateStrategy); + }); + + it("returns ImmediateUpdateStrategy when subscriptionStart is null", async () => { + const factory = createFactory( + { ...baseBillingInfo, billingPeriod: "ANNUALLY", subscriptionStart: null }, + { "monthly-proration": true } + ); + const strategy = await factory.createByTeamId(1); + + expect(strategy).toBeInstanceOf(ImmediateUpdateStrategy); + }); + + it("returns ImmediateUpdateStrategy when billingPeriod is null", async () => { + const factory = createFactory( + { ...baseBillingInfo, billingPeriod: null }, + { "monthly-proration": true, "hwm-seating": true } + ); + const strategy = await factory.createByTeamId(1); + + expect(strategy).toBeInstanceOf(ImmediateUpdateStrategy); + }); + + it("createBySubscriptionId looks up team and delegates to create", async () => { + const teamBillingDataRepo = createMockTeamBillingDataRepository(); + vi.mocked(teamBillingDataRepo.findBySubscriptionId).mockResolvedValue({ + id: 42, + metadata: {}, + isOrganization: false, + parentId: null, + name: "Test Team", + }); + + const factory = createFactory( + { ...baseBillingInfo, billingPeriod: "MONTHLY" }, + { "hwm-seating": true }, + { teamBillingDataRepository: teamBillingDataRepo } + ); + const strategy = await factory.createBySubscriptionId("sub_abc"); + + expect(teamBillingDataRepo.findBySubscriptionId).toHaveBeenCalledWith("sub_abc"); + expect(strategy).toBeInstanceOf(HighWaterMarkStrategy); + }); + + it("createBySubscriptionId returns fallback when no team found", async () => { + const teamBillingDataRepo = createMockTeamBillingDataRepository(); + vi.mocked(teamBillingDataRepo.findBySubscriptionId).mockResolvedValue(null); + + const factory = createFactory(baseBillingInfo, {}, { teamBillingDataRepository: teamBillingDataRepo }); + const strategy = await factory.createBySubscriptionId("sub_unknown"); + + expect(strategy).toBeInstanceOf(ImmediateUpdateStrategy); + }); +}); diff --git a/packages/features/ee/billing/service/seatBillingStrategy/__tests__/strategies.test.ts b/packages/features/ee/billing/service/seatBillingStrategy/__tests__/strategies.test.ts new file mode 100644 index 0000000000..1762bbe847 --- /dev/null +++ b/packages/features/ee/billing/service/seatBillingStrategy/__tests__/strategies.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, it, vi } from "vitest"; +import type { IBillingProviderService } from "../../billingProvider/IBillingProviderService"; +import type { HighWaterMarkService } from "../../highWaterMark/HighWaterMarkService"; +import type { MonthlyProrationService } from "../../proration/MonthlyProrationService"; +import type { HighWaterMarkRepository } from "../../../repository/highWaterMark/HighWaterMarkRepository"; +import { HighWaterMarkStrategy } from "../HighWaterMarkStrategy"; +import { ImmediateUpdateStrategy } from "../ImmediateUpdateStrategy"; +import type { SeatChangeContext, StripeInvoiceData } from "../ISeatBillingStrategy"; +import { MonthlyProrationStrategy } from "../MonthlyProrationStrategy"; + +const mockContext: SeatChangeContext = { + teamId: 1, + subscriptionId: "sub_123", + subscriptionItemId: "si_456", + membershipCount: 10, + changeType: "addition", +}; + +function createMockBillingProviderService(): IBillingProviderService { + return { + handleSubscriptionUpdate: vi.fn(), + } as unknown as IBillingProviderService; +} + +function createMockHighWaterMarkRepository(): HighWaterMarkRepository { + return { + getByTeamId: vi.fn(), + updateIfHigher: vi.fn().mockResolvedValue({ updated: false, previousHighWaterMark: null }), + } as unknown as HighWaterMarkRepository; +} + +function createMockHighWaterMarkService(): HighWaterMarkService { + return { + applyHighWaterMarkToSubscription: vi.fn().mockResolvedValue(false), + resetSubscriptionAfterRenewal: vi.fn().mockResolvedValue(false), + } as unknown as HighWaterMarkService; +} + +function createMockMonthlyProrationService(): MonthlyProrationService { + return { + handleProrationPaymentSuccess: vi.fn(), + handleProrationPaymentFailure: vi.fn(), + } as unknown as MonthlyProrationService; +} + +describe("ImmediateUpdateStrategy", () => { + it("calls handleSubscriptionUpdate on seat change", async () => { + const billingProvider = createMockBillingProviderService(); + const strategy = new ImmediateUpdateStrategy(billingProvider); + + await strategy.onSeatChange(mockContext); + + expect(billingProvider.handleSubscriptionUpdate).toHaveBeenCalledWith({ + subscriptionId: "sub_123", + subscriptionItemId: "si_456", + membershipCount: 10, + }); + }); + + it("returns no-op for onInvoiceUpcoming", async () => { + const strategy = new ImmediateUpdateStrategy(createMockBillingProviderService()); + expect(await strategy.onInvoiceUpcoming("sub_123")).toEqual({ applied: false }); + }); + + it("returns no-op for onRenewalPaid", async () => { + const strategy = new ImmediateUpdateStrategy(createMockBillingProviderService()); + expect(await strategy.onRenewalPaid("sub_123", new Date())).toEqual({ reset: false }); + }); + + it("returns no-op for onPaymentSucceeded (inherited from base)", async () => { + const strategy = new ImmediateUpdateStrategy(createMockBillingProviderService()); + const invoice: StripeInvoiceData = { lines: { data: [] } }; + expect(await strategy.onPaymentSucceeded(invoice)).toEqual({ handled: false }); + }); + + it("returns no-op for onPaymentFailed (inherited from base)", async () => { + const strategy = new ImmediateUpdateStrategy(createMockBillingProviderService()); + const invoice: StripeInvoiceData = { lines: { data: [] } }; + expect(await strategy.onPaymentFailed(invoice, "card_declined")).toEqual({ handled: false }); + }); +}); + +describe("HighWaterMarkStrategy", () => { + function createStrategy() { + const hwmRepo = createMockHighWaterMarkRepository(); + const hwmService = createMockHighWaterMarkService(); + const strategy = new HighWaterMarkStrategy({ + highWaterMarkRepository: hwmRepo, + highWaterMarkService: hwmService, + }); + return { strategy, hwmRepo, hwmService }; + } + + it("updates high water mark on seat addition", async () => { + const { strategy, hwmRepo } = createStrategy(); + vi.mocked(hwmRepo.getByTeamId).mockResolvedValue({ + subscriptionStart: new Date("2025-01-01"), + highWaterMarkPeriodStart: new Date("2025-06-01"), + isOrganization: false, + }); + vi.mocked(hwmRepo.updateIfHigher).mockResolvedValue({ updated: true, previousHighWaterMark: 8 }); + + await strategy.onSeatChange(mockContext); + + expect(hwmRepo.getByTeamId).toHaveBeenCalledWith(1); + expect(hwmRepo.updateIfHigher).toHaveBeenCalledWith({ + teamId: 1, + isOrganization: false, + newSeatCount: 10, + periodStart: new Date("2025-06-01"), + }); + }); + + it("uses subscriptionStart when highWaterMarkPeriodStart is null", async () => { + const { strategy, hwmRepo } = createStrategy(); + vi.mocked(hwmRepo.getByTeamId).mockResolvedValue({ + subscriptionStart: new Date("2025-01-01"), + highWaterMarkPeriodStart: null, + isOrganization: false, + }); + + await strategy.onSeatChange(mockContext); + + expect(hwmRepo.updateIfHigher).toHaveBeenCalledWith( + expect.objectContaining({ periodStart: new Date("2025-01-01") }) + ); + }); + + it("skips HWM update on seat removal", async () => { + const { strategy, hwmRepo } = createStrategy(); + await strategy.onSeatChange({ ...mockContext, changeType: "removal" }); + + expect(hwmRepo.getByTeamId).not.toHaveBeenCalled(); + }); + + it("skips HWM update on sync", async () => { + const { strategy, hwmRepo } = createStrategy(); + await strategy.onSeatChange({ ...mockContext, changeType: "sync" }); + + expect(hwmRepo.getByTeamId).not.toHaveBeenCalled(); + }); + + it("skips HWM update when no billing record exists", async () => { + const { strategy, hwmRepo } = createStrategy(); + vi.mocked(hwmRepo.getByTeamId).mockResolvedValue(null); + + await strategy.onSeatChange(mockContext); + + expect(hwmRepo.updateIfHigher).not.toHaveBeenCalled(); + }); + + it("skips HWM update when no period start available", async () => { + const { strategy, hwmRepo } = createStrategy(); + vi.mocked(hwmRepo.getByTeamId).mockResolvedValue({ + subscriptionStart: null, + highWaterMarkPeriodStart: null, + isOrganization: false, + }); + + await strategy.onSeatChange(mockContext); + + expect(hwmRepo.updateIfHigher).not.toHaveBeenCalled(); + }); + + it("delegates onInvoiceUpcoming to HighWaterMarkService", async () => { + const { strategy, hwmService } = createStrategy(); + vi.mocked(hwmService.applyHighWaterMarkToSubscription).mockResolvedValue(true); + + const result = await strategy.onInvoiceUpcoming("sub_123"); + + expect(result).toEqual({ applied: true }); + expect(hwmService.applyHighWaterMarkToSubscription).toHaveBeenCalledWith("sub_123"); + }); + + it("delegates onRenewalPaid to HighWaterMarkService", async () => { + const { strategy, hwmService } = createStrategy(); + vi.mocked(hwmService.resetSubscriptionAfterRenewal).mockResolvedValue(true); + const periodStart = new Date("2025-07-01"); + + const result = await strategy.onRenewalPaid("sub_123", periodStart); + + expect(result).toEqual({ reset: true }); + expect(hwmService.resetSubscriptionAfterRenewal).toHaveBeenCalledWith({ + subscriptionId: "sub_123", + newPeriodStart: periodStart, + }); + }); + + it("returns no-op for onPaymentSucceeded (inherited from base)", async () => { + const { strategy } = createStrategy(); + const invoice: StripeInvoiceData = { lines: { data: [] } }; + expect(await strategy.onPaymentSucceeded(invoice)).toEqual({ handled: false }); + }); + + it("returns no-op for onPaymentFailed (inherited from base)", async () => { + const { strategy } = createStrategy(); + const invoice: StripeInvoiceData = { lines: { data: [] } }; + expect(await strategy.onPaymentFailed(invoice, "card_declined")).toEqual({ handled: false }); + }); +}); + +describe("MonthlyProrationStrategy", () => { + function createProrationStrategy() { + const prorationService = createMockMonthlyProrationService(); + const strategy = new MonthlyProrationStrategy({ + monthlyProrationService: prorationService, + }); + return { strategy, prorationService }; + } + + it("does not call any external service on seat change", async () => { + const { strategy } = createProrationStrategy(); + await expect(strategy.onSeatChange(mockContext)).resolves.toBeUndefined(); + }); + + it("returns no-op for onInvoiceUpcoming (inherited from base)", async () => { + const { strategy } = createProrationStrategy(); + expect(await strategy.onInvoiceUpcoming("sub_123")).toEqual({ applied: false }); + }); + + it("returns no-op for onRenewalPaid (inherited from base)", async () => { + const { strategy } = createProrationStrategy(); + expect(await strategy.onRenewalPaid("sub_123", new Date())).toEqual({ reset: false }); + }); + + it("handles payment succeeded for proration invoice", async () => { + const { strategy, prorationService } = createProrationStrategy(); + const invoice: StripeInvoiceData = { + lines: { data: [{ metadata: { type: "monthly_proration", prorationId: "pro_123" } }] }, + }; + + const result = await strategy.onPaymentSucceeded(invoice); + + expect(result).toEqual({ handled: true }); + expect(prorationService.handleProrationPaymentSuccess).toHaveBeenCalledWith("pro_123"); + }); + + it("returns not handled for non-proration invoice on payment succeeded", async () => { + const { strategy, prorationService } = createProrationStrategy(); + const invoice: StripeInvoiceData = { + lines: { data: [{ metadata: { type: "other" } }] }, + }; + + const result = await strategy.onPaymentSucceeded(invoice); + + expect(result).toEqual({ handled: false }); + expect(prorationService.handleProrationPaymentSuccess).not.toHaveBeenCalled(); + }); + + it("returns not handled when proration line item has no prorationId", async () => { + const { strategy, prorationService } = createProrationStrategy(); + const invoice: StripeInvoiceData = { + lines: { data: [{ metadata: { type: "monthly_proration" } }] }, + }; + + const result = await strategy.onPaymentSucceeded(invoice); + + expect(result).toEqual({ handled: false }); + expect(prorationService.handleProrationPaymentSuccess).not.toHaveBeenCalled(); + }); + + it("handles payment failed for proration invoice", async () => { + const { strategy, prorationService } = createProrationStrategy(); + const invoice: StripeInvoiceData = { + lines: { data: [{ metadata: { type: "monthly_proration", prorationId: "pro_456" } }] }, + }; + + const result = await strategy.onPaymentFailed(invoice, "card_declined"); + + expect(result).toEqual({ handled: true }); + expect(prorationService.handleProrationPaymentFailure).toHaveBeenCalledWith({ + prorationId: "pro_456", + reason: "card_declined", + }); + }); + + it("returns not handled for non-proration invoice on payment failed", async () => { + const { strategy, prorationService } = createProrationStrategy(); + const invoice: StripeInvoiceData = { lines: { data: [] } }; + + const result = await strategy.onPaymentFailed(invoice, "card_declined"); + + expect(result).toEqual({ handled: false }); + expect(prorationService.handleProrationPaymentFailure).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/features/ee/billing/service/teams/ITeamBillingService.ts b/packages/features/ee/billing/service/teams/ITeamBillingService.ts index 103d5cc853..5077a17638 100644 --- a/packages/features/ee/billing/service/teams/ITeamBillingService.ts +++ b/packages/features/ee/billing/service/teams/ITeamBillingService.ts @@ -1,9 +1,7 @@ import type { Team } from "@calcom/prisma/client"; -import { - SubscriptionStatus, - IBillingRepositoryCreateArgs, -} from "../../repository/billing/IBillingRepository"; +import type { IBillingRepositoryCreateArgs, SubscriptionStatus } from "../../repository/billing/IBillingRepository"; +import type { SeatChangeType } from "../seatBillingStrategy/ISeatBillingStrategy"; export type TeamBillingInput = Pick; export const TeamBillingPublishResponseStatus = { @@ -21,7 +19,7 @@ export interface ITeamBillingService { cancel(): Promise; publish(): Promise; downgrade(): Promise; - updateQuantity(): Promise; + updateQuantity(changeType: SeatChangeType): Promise; getSubscriptionStatus(): Promise; endTrial(): Promise; saveTeamBilling(args: IBillingRepositoryCreateArgs): Promise; diff --git a/packages/features/ee/billing/service/teams/StubTeamBillingService.ts b/packages/features/ee/billing/service/teams/StubTeamBillingService.ts index cb614a6e71..4cbbb4f1a5 100644 --- a/packages/features/ee/billing/service/teams/StubTeamBillingService.ts +++ b/packages/features/ee/billing/service/teams/StubTeamBillingService.ts @@ -1,3 +1,4 @@ +import type { SeatChangeType } from "../seatBillingStrategy/ISeatBillingStrategy"; import { TeamBillingPublishResponseStatus } from "./ITeamBillingService"; import type { ITeamBillingService, @@ -23,7 +24,7 @@ export class StubTeamBillingService implements ITeamBillingService { // Stub implementation - no-op } - async updateQuantity(): Promise { + async updateQuantity(_changeType: SeatChangeType): Promise { // Stub implementation - no-op } diff --git a/packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts b/packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts index aac1582bbb..8f75505a93 100644 --- a/packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts +++ b/packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - import type { IBillingRepository } from "../../repository/billing/IBillingRepository"; import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository"; import type { IBillingProviderService } from "../billingProvider/IBillingProviderService"; +import type { SeatBillingStrategyFactory } from "../seatBillingStrategy/SeatBillingStrategyFactory"; import { StubTeamBillingService } from "./StubTeamBillingService"; import { TeamBillingService } from "./TeamBillingService"; import { TeamBillingServiceFactory } from "./TeamBillingServiceFactory"; @@ -14,6 +14,7 @@ describe("TeamBilling", () => { let mockBillingProviderService: IBillingProviderService; let mockTeamBillingDataRepository: ITeamBillingDataRepository; let mockBillingRepository: IBillingRepository; + let mockSeatBillingStrategyFactory: SeatBillingStrategyFactory; let factory: TeamBillingServiceFactory; const createMockBillingProviderService = (): IBillingProviderService => ({ @@ -55,6 +56,7 @@ describe("TeamBilling", () => { mockBillingProviderService = createMockBillingProviderService(); mockTeamBillingDataRepository = createMockTeamBillingDataRepository(); mockBillingRepository = createMockBillingRepository(); + mockSeatBillingStrategyFactory = { createByTeamId: vi.fn() } as unknown as SeatBillingStrategyFactory; }); afterEach(() => { @@ -68,6 +70,7 @@ describe("TeamBilling", () => { teamBillingDataRepository: mockTeamBillingDataRepository, billingRepositoryFactory: () => mockBillingRepository, isTeamBillingEnabled: true, + seatBillingStrategyFactory: mockSeatBillingStrategyFactory, }); const result = factory.init(mockTeam); @@ -81,6 +84,7 @@ describe("TeamBilling", () => { teamBillingDataRepository: mockTeamBillingDataRepository, billingRepositoryFactory: () => mockBillingRepository, isTeamBillingEnabled: false, + seatBillingStrategyFactory: mockSeatBillingStrategyFactory, }); const result = factory.init(mockTeam); @@ -96,6 +100,7 @@ describe("TeamBilling", () => { teamBillingDataRepository: mockTeamBillingDataRepository, billingRepositoryFactory: () => mockBillingRepository, isTeamBillingEnabled: false, + seatBillingStrategyFactory: mockSeatBillingStrategyFactory, }); const result = factory.initMany(mockTeams); @@ -113,6 +118,7 @@ describe("TeamBilling", () => { teamBillingDataRepository: mockTeamBillingDataRepository, billingRepositoryFactory: () => mockBillingRepository, isTeamBillingEnabled: true, + seatBillingStrategyFactory: mockSeatBillingStrategyFactory, }); vi.mocked(mockTeamBillingDataRepository.find).mockResolvedValue(mockTeam); @@ -131,6 +137,7 @@ describe("TeamBilling", () => { teamBillingDataRepository: mockTeamBillingDataRepository, billingRepositoryFactory: () => mockBillingRepository, isTeamBillingEnabled: true, + seatBillingStrategyFactory: mockSeatBillingStrategyFactory, }); vi.mocked(mockTeamBillingDataRepository.findMany).mockResolvedValue([mockTeam, { ...mockTeam, id: 2 }]); diff --git a/packages/features/ee/billing/service/teams/TeamBillingService.test.ts b/packages/features/ee/billing/service/teams/TeamBillingService.test.ts index f214bb9420..41e66ef3a4 100644 --- a/packages/features/ee/billing/service/teams/TeamBillingService.test.ts +++ b/packages/features/ee/billing/service/teams/TeamBillingService.test.ts @@ -1,11 +1,13 @@ +import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock"; import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments"; import { WEBAPP_URL } from "@calcom/lib/constants"; -import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { IBillingRepository } from "../../repository/billing/IBillingRepository"; import { Plan, SubscriptionStatus } from "../../repository/billing/IBillingRepository"; import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository"; import type { IBillingProviderService } from "../billingProvider/IBillingProviderService"; +import type { ISeatBillingStrategy } from "../seatBillingStrategy/ISeatBillingStrategy"; +import type { SeatBillingStrategyFactory } from "../seatBillingStrategy/SeatBillingStrategyFactory"; import { TeamBillingPublishResponseStatus } from "./ITeamBillingService"; import { TeamBillingService } from "./TeamBillingService"; @@ -21,16 +23,6 @@ vi.mock("@calcom/features/ee/teams/lib/payments", () => ({ purchaseTeamOrOrgSubscription: vi.fn(), })); -const shouldApplyMonthlyProration = vi.fn().mockResolvedValue(false); -const shouldApplyHighWaterMark = vi.fn().mockResolvedValue(false); - -vi.mock("../billingPeriod/BillingPeriodService", () => ({ - BillingPeriodService: class { - shouldApplyMonthlyProration = shouldApplyMonthlyProration; - shouldApplyHighWaterMark = shouldApplyHighWaterMark; - }, -})); - const mockTeam = { id: 1, metadata: { @@ -77,16 +69,26 @@ const createMockBillingRepository = (): IBillingRepository => ({ create: vi.fn(), }); +function createMockStrategy(): ISeatBillingStrategy { + return { onSeatChange: vi.fn() } as unknown as ISeatBillingStrategy; +} + +function createMockFactory(strategy: ISeatBillingStrategy): SeatBillingStrategyFactory { + return { createByTeamId: vi.fn().mockResolvedValue(strategy) } as unknown as SeatBillingStrategyFactory; +} + describe("TeamBillingService", () => { let mockBillingProviderService: IBillingProviderService; let mockTeamBillingDataRepository: ITeamBillingDataRepository; let mockBillingRepository: IBillingRepository; + let defaultResolver: SeatBillingStrategyFactory; beforeEach(() => { vi.resetAllMocks(); mockBillingProviderService = createMockBillingProviderService(); mockTeamBillingDataRepository = createMockTeamBillingDataRepository(); mockBillingRepository = createMockBillingRepository(); + defaultResolver = createMockFactory(createMockStrategy()); }); afterEach(() => { @@ -100,6 +102,7 @@ describe("TeamBillingService", () => { billingProviderService: mockBillingProviderService, teamBillingDataRepository: mockTeamBillingDataRepository, billingRepository: mockBillingRepository, + seatBillingStrategyFactory: defaultResolver, }); await teamBillingService.cancel(); @@ -121,6 +124,7 @@ describe("TeamBillingService", () => { billingProviderService: mockBillingProviderService, teamBillingDataRepository: mockTeamBillingDataRepository, billingRepository: mockBillingRepository, + seatBillingStrategyFactory: defaultResolver, }); vi.mocked(mockBillingProviderService.checkoutSessionIsPaid).mockResolvedValue(false); @@ -148,6 +152,7 @@ describe("TeamBillingService", () => { billingProviderService: mockBillingProviderService, teamBillingDataRepository: mockTeamBillingDataRepository, billingRepository: mockBillingRepository, + seatBillingStrategyFactory: defaultResolver, }); const mockUrl = `${WEBAPP_URL}/api/teams/${mockTeam.id}/upgrade?session_id=cs_789`; @@ -168,16 +173,17 @@ describe("TeamBillingService", () => { }); describe("updateQuantity", () => { - it("should update the subscription quantity", async () => { - const mockTeamNotOrg = { - ...mockTeam, - isOrganization: false, - }; + it("should resolve and delegate to the seat billing strategy", async () => { + const strategy = createMockStrategy(); + const resolver = createMockFactory(strategy); + const mockTeamNotOrg = { ...mockTeam, isOrganization: false }; + const teamBillingService = new TeamBillingService({ team: mockTeamNotOrg, billingProviderService: mockBillingProviderService, teamBillingDataRepository: mockTeamBillingDataRepository, billingRepository: mockBillingRepository, + seatBillingStrategyFactory: resolver, }); prismaMock.membership.count.mockResolvedValue(10); @@ -186,40 +192,43 @@ describe("TeamBillingService", () => { paymentId: "cs_789", paymentRequired: false, }); - shouldApplyMonthlyProration.mockResolvedValue(false); - await teamBillingService.updateQuantity(); + await teamBillingService.updateQuantity("addition"); - expect(mockBillingProviderService.handleSubscriptionUpdate).toHaveBeenCalledWith({ + expect(resolver.createByTeamId).toHaveBeenCalledWith(mockTeamNotOrg.id); + expect(strategy.onSeatChange).toHaveBeenCalledWith({ + teamId: mockTeamNotOrg.id, subscriptionId: "sub_123", subscriptionItemId: "si_456", membershipCount: 10, + changeType: "addition", }); }); - it("should skip subscription updates when monthly proration applies", async () => { - const mockTeamNotOrg = { - ...mockTeam, - isOrganization: false, - }; + it("should pass correct membership count to strategy", async () => { + const strategy = createMockStrategy(); + const resolver = createMockFactory(strategy); + const teamBillingService = new TeamBillingService({ - team: mockTeamNotOrg, + team: mockTeam, billingProviderService: mockBillingProviderService, teamBillingDataRepository: mockTeamBillingDataRepository, billingRepository: mockBillingRepository, + seatBillingStrategyFactory: resolver, }); - prismaMock.membership.count.mockResolvedValue(10); + prismaMock.membership.count.mockResolvedValue(7); vi.spyOn(teamBillingService, "checkIfTeamPaymentRequired").mockResolvedValue({ url: "http://checkout.url", paymentId: "cs_789", paymentRequired: false, }); - shouldApplyMonthlyProration.mockResolvedValue(true); - await teamBillingService.updateQuantity(); + await teamBillingService.updateQuantity("removal"); - expect(mockBillingProviderService.handleSubscriptionUpdate).not.toHaveBeenCalled(); + expect(strategy.onSeatChange).toHaveBeenCalledWith( + expect.objectContaining({ membershipCount: 7, changeType: "removal" }) + ); }); }); @@ -237,6 +246,7 @@ describe("TeamBillingService", () => { billingProviderService: mockBillingProviderService, teamBillingDataRepository: mockTeamBillingDataRepository, billingRepository: mockBillingRepository, + seatBillingStrategyFactory: defaultResolver, }); const result = await teamBillingService.checkIfTeamPaymentRequired(); @@ -250,6 +260,7 @@ describe("TeamBillingService", () => { billingProviderService: mockBillingProviderService, teamBillingDataRepository: mockTeamBillingDataRepository, billingRepository: mockBillingRepository, + seatBillingStrategyFactory: defaultResolver, }); vi.mocked(mockBillingProviderService.checkoutSessionIsPaid).mockResolvedValue(false); @@ -265,6 +276,7 @@ describe("TeamBillingService", () => { billingProviderService: mockBillingProviderService, teamBillingDataRepository: mockTeamBillingDataRepository, billingRepository: mockBillingRepository, + seatBillingStrategyFactory: defaultResolver, }); vi.mocked(mockBillingProviderService.checkoutSessionIsPaid).mockResolvedValue(true); @@ -310,6 +322,7 @@ describe("TeamBillingService", () => { billingProviderService: mockBillingProviderService, teamBillingDataRepository: mockTeamBillingDataRepository, billingRepository: mockBillingRepository, + seatBillingStrategyFactory: defaultResolver, }); await teamBillingService.saveTeamBilling(mockBillingArgs); @@ -348,6 +361,7 @@ describe("TeamBillingService", () => { billingProviderService: mockBillingProviderService, teamBillingDataRepository: mockTeamBillingDataRepository, billingRepository: mockBillingRepository, + seatBillingStrategyFactory: defaultResolver, }); await teamBillingService.saveTeamBilling(mockBillingArgs); @@ -386,6 +400,7 @@ describe("TeamBillingService", () => { billingProviderService: mockBillingProviderService, teamBillingDataRepository: mockTeamBillingDataRepository, billingRepository: mockBillingRepository, + seatBillingStrategyFactory: defaultResolver, }); await teamBillingService.saveTeamBilling(mockBillingArgs); @@ -427,6 +442,7 @@ describe("TeamBillingService", () => { billingProviderService: mockBillingProviderService, teamBillingDataRepository: mockTeamBillingDataRepository, billingRepository: mockBillingRepository, + seatBillingStrategyFactory: defaultResolver, }); await expect(teamBillingService.saveTeamBilling(mockBillingArgs)).rejects.toThrow( diff --git a/packages/features/ee/billing/service/teams/TeamBillingService.ts b/packages/features/ee/billing/service/teams/TeamBillingService.ts index c6dc245d69..bb6979d459 100644 --- a/packages/features/ee/billing/service/teams/TeamBillingService.ts +++ b/packages/features/ee/billing/service/teams/TeamBillingService.ts @@ -9,15 +9,14 @@ import { prisma } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils"; import type { z } from "zod"; -import { updateSubscriptionQuantity } from "../../lib/subscription-updates"; -// import billing from "../.."; import type { IBillingRepository, IBillingRepositoryCreateArgs, } from "../../repository/billing/IBillingRepository"; import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository"; -import { BillingPeriodService } from "../billingPeriod/BillingPeriodService"; import type { IBillingProviderService } from "../billingProvider/IBillingProviderService"; +import type { SeatChangeType } from "../seatBillingStrategy/ISeatBillingStrategy"; +import type { SeatBillingStrategyFactory } from "../seatBillingStrategy/SeatBillingStrategyFactory"; import { type ITeamBillingService, type TeamBillingInput, @@ -35,22 +34,26 @@ export class TeamBillingService implements ITeamBillingService { private billingProviderService: IBillingProviderService; private billingRepository: IBillingRepository; private teamBillingDataRepository: ITeamBillingDataRepository; + private seatBillingStrategyFactory: SeatBillingStrategyFactory; constructor({ team, billingProviderService, teamBillingDataRepository, billingRepository, + seatBillingStrategyFactory, }: { team: TeamBillingInput; billingProviderService: IBillingProviderService; teamBillingDataRepository: ITeamBillingDataRepository; billingRepository: IBillingRepository; + seatBillingStrategyFactory: SeatBillingStrategyFactory; }) { this.team = team; this.billingProviderService = billingProviderService; this.teamBillingDataRepository = teamBillingDataRepository; this.billingRepository = billingRepository; + this.seatBillingStrategyFactory = seatBillingStrategyFactory; } set team(team: TeamBillingInput) { const metadata = teamPaymentMetadataSchema.parse(team.metadata || {}); @@ -142,7 +145,7 @@ export class TeamBillingService implements ITeamBillingService { this.logErrorFromUnknown(error); } } - async updateQuantity() { + async updateQuantity(changeType: SeatChangeType) { try { await this.getOrgIfNeeded(); const { id: teamId, metadata, isOrganization } = this.team; @@ -166,30 +169,15 @@ export class TeamBillingService implements ITeamBillingService { if (!subscriptionId) throw Error("missing subscriptionId"); if (!subscriptionItemId) throw Error("missing subscriptionItemId"); - const billingPeriodService = new BillingPeriodService(); - const shouldApplyMonthlyProration = await billingPeriodService.shouldApplyMonthlyProration(teamId); - if (shouldApplyMonthlyProration) { - log.info(`Skipping subscription update for team ${teamId} because monthly proration is enabled.`); - return; - } - - // Skip immediate subscription update for monthly billing with high water mark - // The subscription quantity will be updated before renewal via invoice.upcoming webhook - const shouldApplyHighWaterMark = await billingPeriodService.shouldApplyHighWaterMark(teamId); - if (shouldApplyHighWaterMark) { - log.info( - `Skipping subscription update for team ${teamId} because high water mark billing is enabled for monthly plans.` - ); - return; - } - - await updateSubscriptionQuantity({ - billingService: this.billingProviderService, + const strategy = await this.seatBillingStrategyFactory.createByTeamId(teamId); + await strategy.onSeatChange({ + teamId, subscriptionId, subscriptionItemId, - quantity: membershipCount, + membershipCount, + changeType, }); - log.info(`Updated subscription ${subscriptionId} for team ${teamId} to ${membershipCount} seats.`); + log.info(`Seat change processed for team ${teamId} (${membershipCount} seats).`); } catch (error) { this.logErrorFromUnknown(error); } diff --git a/packages/features/ee/billing/service/teams/TeamBillingServiceFactory.ts b/packages/features/ee/billing/service/teams/TeamBillingServiceFactory.ts index 7d310bb671..f0fb01d525 100644 --- a/packages/features/ee/billing/service/teams/TeamBillingServiceFactory.ts +++ b/packages/features/ee/billing/service/teams/TeamBillingServiceFactory.ts @@ -1,6 +1,7 @@ import type { IBillingRepository } from "../../repository/billing/IBillingRepository"; import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository"; import type { IBillingProviderService } from "../billingProvider/IBillingProviderService"; +import type { SeatBillingStrategyFactory } from "../seatBillingStrategy/SeatBillingStrategyFactory"; import type { ITeamBillingService, TeamBillingInput } from "./ITeamBillingService"; import { StubTeamBillingService } from "./StubTeamBillingService"; import { TeamBillingService } from "./TeamBillingService"; @@ -11,6 +12,7 @@ export interface ITeamBillingServiceFactoryDeps { teamBillingDataRepository: ITeamBillingDataRepository; billingRepositoryFactory: (isOrganization: boolean) => IBillingRepository; isTeamBillingEnabled: boolean; + seatBillingStrategyFactory: SeatBillingStrategyFactory; } export class TeamBillingServiceFactory { @@ -31,6 +33,7 @@ export class TeamBillingServiceFactory { billingProviderService: this.deps.billingProviderService, teamBillingDataRepository: this.deps.teamBillingDataRepository, billingRepository, + seatBillingStrategyFactory: this.deps.seatBillingStrategyFactory, }); } diff --git a/packages/features/ee/billing/teams/internal-team-billing.test.ts b/packages/features/ee/billing/teams/internal-team-billing.test.ts index c59b4addfd..45769af5aa 100644 --- a/packages/features/ee/billing/teams/internal-team-billing.test.ts +++ b/packages/features/ee/billing/teams/internal-team-billing.test.ts @@ -1,11 +1,13 @@ +import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock"; import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments"; import { WEBAPP_URL } from "@calcom/lib/constants"; -import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { IBillingRepository } from "../repository/billing/IBillingRepository"; import { Plan, SubscriptionStatus } from "../repository/billing/IBillingRepository"; import type { ITeamBillingDataRepository } from "../repository/teamBillingData/ITeamBillingDataRepository"; import type { IBillingProviderService } from "../service/billingProvider/IBillingProviderService"; +import type { ISeatBillingStrategy } from "../service/seatBillingStrategy/ISeatBillingStrategy"; +import type { SeatBillingStrategyFactory } from "../service/seatBillingStrategy/SeatBillingStrategyFactory"; import { TeamBillingPublishResponseStatus } from "../service/teams/ITeamBillingService"; import { TeamBillingService } from "../service/teams/TeamBillingService"; @@ -21,15 +23,14 @@ vi.mock("@calcom/features/ee/teams/lib/payments", () => ({ purchaseTeamOrOrgSubscription: vi.fn(), })); -const shouldApplyMonthlyProration = vi.fn().mockResolvedValue(false); -const shouldApplyHighWaterMark = vi.fn().mockResolvedValue(false); +function createMockStrategy(): ISeatBillingStrategy { + return { onSeatChange: vi.fn() } as unknown as ISeatBillingStrategy; +} + +function createMockFactory(strategy: ISeatBillingStrategy): SeatBillingStrategyFactory { + return { createByTeamId: vi.fn().mockResolvedValue(strategy) } as unknown as SeatBillingStrategyFactory; +} -vi.mock("../service/billingPeriod/BillingPeriodService", () => ({ - BillingPeriodService: class { - shouldApplyMonthlyProration = shouldApplyMonthlyProration; - shouldApplyHighWaterMark = shouldApplyHighWaterMark; - }, -})); const mockTeam = { id: 1, metadata: { @@ -46,6 +47,7 @@ describe("TeamBillingService", () => { let mockBillingProviderService: IBillingProviderService; let mockTeamBillingDataRepository: ITeamBillingDataRepository; let mockBillingRepository: IBillingRepository; + let defaultResolver: SeatBillingStrategyFactory; beforeEach(() => { vi.clearAllMocks(); @@ -85,11 +87,14 @@ describe("TeamBillingService", () => { create: vi.fn(), } as unknown as IBillingRepository; + defaultResolver = createMockFactory(createMockStrategy()); + teamBillingService = new TeamBillingService({ team: mockTeam, billingProviderService: mockBillingProviderService, teamBillingDataRepository: mockTeamBillingDataRepository, billingRepository: mockBillingRepository, + seatBillingStrategyFactory: defaultResolver, }); }); @@ -151,16 +156,17 @@ describe("TeamBillingService", () => { }); describe("updateQuantity", () => { - it("should update the subscription quantity", async () => { - const mockTeamNotOrg = { - ...mockTeam, - isOrganization: false, - }; + it("should resolve and delegate to the seat billing strategy", async () => { + const strategy = createMockStrategy(); + const resolver = createMockFactory(strategy); + const mockTeamNotOrg = { ...mockTeam, isOrganization: false }; + const teamBillingServiceNotOrg = new TeamBillingService({ team: mockTeamNotOrg, billingProviderService: mockBillingProviderService, teamBillingDataRepository: mockTeamBillingDataRepository, billingRepository: mockBillingRepository, + seatBillingStrategyFactory: resolver, }); prismaMock.membership.count.mockResolvedValue(10); vi.spyOn(teamBillingServiceNotOrg, "checkIfTeamPaymentRequired").mockResolvedValue({ @@ -168,40 +174,18 @@ describe("TeamBillingService", () => { paymentId: "cs_789", paymentRequired: false, }); - shouldApplyMonthlyProration.mockResolvedValue(false); - await teamBillingServiceNotOrg.updateQuantity(); + await teamBillingServiceNotOrg.updateQuantity("addition"); - expect(mockBillingProviderService.handleSubscriptionUpdate).toHaveBeenCalledWith({ + expect(resolver.createByTeamId).toHaveBeenCalledWith(mockTeamNotOrg.id); + expect(strategy.onSeatChange).toHaveBeenCalledWith({ + teamId: mockTeamNotOrg.id, subscriptionId: "sub_123", subscriptionItemId: "si_456", membershipCount: 10, + changeType: "addition", }); }); - - it("should skip subscription updates when monthly proration applies", async () => { - const mockTeamNotOrg = { - ...mockTeam, - isOrganization: false, - }; - const teamBillingServiceNotOrg = new TeamBillingService({ - team: mockTeamNotOrg, - billingProviderService: mockBillingProviderService, - teamBillingDataRepository: mockTeamBillingDataRepository, - billingRepository: mockBillingRepository, - }); - prismaMock.membership.count.mockResolvedValue(10); - vi.spyOn(teamBillingServiceNotOrg, "checkIfTeamPaymentRequired").mockResolvedValue({ - url: "http://checkout.url", - paymentId: "cs_789", - paymentRequired: false, - }); - shouldApplyMonthlyProration.mockResolvedValue(true); - - await teamBillingServiceNotOrg.updateQuantity(); - - expect(mockBillingProviderService.handleSubscriptionUpdate).not.toHaveBeenCalled(); - }); }); describe("checkIfTeamPaymentRequired", () => { @@ -218,6 +202,7 @@ describe("TeamBillingService", () => { billingProviderService: mockBillingProviderService, teamBillingDataRepository: mockTeamBillingDataRepository, billingRepository: mockBillingRepository, + seatBillingStrategyFactory: defaultResolver, }); const result = await teamBillingServiceNoPayment.checkIfTeamPaymentRequired(); @@ -232,6 +217,7 @@ describe("TeamBillingService", () => { billingProviderService: mockBillingProviderService, teamBillingDataRepository: mockTeamBillingDataRepository, billingRepository: mockBillingRepository, + seatBillingStrategyFactory: defaultResolver, }); const result = await teamBillingServiceWithPayment.checkIfTeamPaymentRequired(); @@ -246,6 +232,7 @@ describe("TeamBillingService", () => { billingProviderService: mockBillingProviderService, teamBillingDataRepository: mockTeamBillingDataRepository, billingRepository: mockBillingRepository, + seatBillingStrategyFactory: defaultResolver, }); const result = await teamBillingServicePaid.checkIfTeamPaymentRequired(); diff --git a/packages/features/ee/teams/services/teamService.integration-test.ts b/packages/features/ee/teams/services/teamService.integration-test.ts index 409fbbcdcf..4e7df20612 100644 --- a/packages/features/ee/teams/services/teamService.integration-test.ts +++ b/packages/features/ee/teams/services/teamService.integration-test.ts @@ -967,7 +967,7 @@ describe("TeamService.removeMembers Integration Tests", () => { const mockFactory = getTeamBillingServiceFactory(); expect(mockFactory.findAndInitMany).toHaveBeenCalledWith([regularTeamTestData.team.id]); const mockInstances = await mockFactory.findAndInitMany([regularTeamTestData.team.id]); - expect(mockInstances[0].updateQuantity).toHaveBeenCalled(); + expect(mockInstances[0].updateQuantity).toHaveBeenCalledWith("removal"); }); it("should throw error when membership doesn't exist", async () => { diff --git a/packages/features/ee/teams/services/teamService.test.ts b/packages/features/ee/teams/services/teamService.test.ts index 03f5849770..ca215b75da 100644 --- a/packages/features/ee/teams/services/teamService.test.ts +++ b/packages/features/ee/teams/services/teamService.test.ts @@ -115,7 +115,7 @@ describe("TeamService", () => { userId: 1, }, }); - expect(mockTeamBilling.updateQuantity).toHaveBeenCalled(); + expect(mockTeamBilling.updateQuantity).toHaveBeenCalledWith("addition"); expect(result).toBe("Test Team"); }); }); diff --git a/packages/features/ee/teams/services/teamService.ts b/packages/features/ee/teams/services/teamService.ts index dd2c9dc2d3..ed90af919d 100644 --- a/packages/features/ee/teams/services/teamService.ts +++ b/packages/features/ee/teams/services/teamService.ts @@ -172,7 +172,7 @@ export class TeamService { const teamBillingServiceFactory = getTeamBillingServiceFactory(); const teamBillingServices = await teamBillingServiceFactory.findAndInitMany(teamIds); const teamBillingPromises = teamBillingServices.map((teamBillingService) => - teamBillingService.updateQuantity() + teamBillingService.updateQuantity("removal") ); await Promise.allSettled(teamBillingPromises); } @@ -230,7 +230,7 @@ export class TeamService { const teamBillingServiceFactory = getTeamBillingServiceFactory(); const teamBillingService = await teamBillingServiceFactory.findAndInit(verificationToken.teamId); - await teamBillingService.updateQuantity(); + await teamBillingService.updateQuantity("addition"); return verificationToken.team.name; } diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 84856a4bfe..8c67cfa020 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -9,14 +9,23 @@ function ensureProtocol(url: string | undefined): string { return `https://${url}`; } -const VERCEL_URL = process.env.NEXT_PUBLIC_VERCEL_URL ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` : ""; -const RAILWAY_STATIC_URL = process.env.RAILWAY_STATIC_URL ? `https://${process.env.RAILWAY_STATIC_URL}` : ""; -const HEROKU_URL = process.env.HEROKU_APP_NAME ? `https://${process.env.HEROKU_APP_NAME}.herokuapp.com` : ""; -const RENDER_URL = process.env.RENDER_EXTERNAL_URL ? `https://${process.env.RENDER_EXTERNAL_URL}` : ""; +const VERCEL_URL = process.env.NEXT_PUBLIC_VERCEL_URL + ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` + : ""; +const RAILWAY_STATIC_URL = process.env.RAILWAY_STATIC_URL + ? `https://${process.env.RAILWAY_STATIC_URL}` + : ""; +const HEROKU_URL = process.env.HEROKU_APP_NAME + ? `https://${process.env.HEROKU_APP_NAME}.herokuapp.com` + : ""; +const RENDER_URL = process.env.RENDER_EXTERNAL_URL + ? `https://${process.env.RENDER_EXTERNAL_URL}` + : ""; export const CALCOM_ENV = process.env.CALCOM_ENV || process.env.NODE_ENV; export const IS_PRODUCTION = CALCOM_ENV === "production"; export const IS_PRODUCTION_BUILD = process.env.NODE_ENV === "production"; -export const ORGANIZER_EMAIL_EXEMPT_DOMAINS = process.env.ORGANIZER_EMAIL_EXEMPT_DOMAINS || ""; +export const ORGANIZER_EMAIL_EXEMPT_DOMAINS = + process.env.ORGANIZER_EMAIL_EXEMPT_DOMAINS || ""; const IS_DEV = CALCOM_ENV === "development"; export const SINGLE_ORG_SLUG = process.env.NEXT_PUBLIC_SINGLE_ORG_SLUG; /** https://app.cal.com */ @@ -30,16 +39,21 @@ export const WEBAPP_URL = // OAuth needs to have HTTPS(which is not generally setup locally) and a valid tld(*.local isn't a valid tld) // So for development purpose, we would stick to localhost only -export const WEBAPP_URL_FOR_OAUTH = IS_PRODUCTION || IS_DEV ? WEBAPP_URL : "http://localhost:3000"; +export const WEBAPP_URL_FOR_OAUTH = + IS_PRODUCTION || IS_DEV ? WEBAPP_URL : "http://localhost:3000"; /** @deprecated use `WEBAPP_URL` */ export const BASE_URL = WEBAPP_URL; -export const WEBSITE_URL = ensureProtocol(process.env.NEXT_PUBLIC_WEBSITE_URL) || "https://cal.com"; +export const WEBSITE_URL = + ensureProtocol(process.env.NEXT_PUBLIC_WEBSITE_URL) || "https://cal.com"; export const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME || "Cal.com"; -export const SUPPORT_MAIL_ADDRESS = process.env.NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS || "help@cal.com"; -export const COMPANY_NAME = process.env.NEXT_PUBLIC_COMPANY_NAME || "Cal.com, Inc."; +export const SUPPORT_MAIL_ADDRESS = + process.env.NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS || "help@cal.com"; +export const COMPANY_NAME = + process.env.NEXT_PUBLIC_COMPANY_NAME || "Cal.com, Inc."; export const SENDER_ID = process.env.NEXT_PUBLIC_SENDER_ID || "Cal"; -export const SENDER_NAME = process.env.NEXT_PUBLIC_SENDGRID_SENDER_NAME || "Cal.com"; +export const SENDER_NAME = + process.env.NEXT_PUBLIC_SENDGRID_SENDER_NAME || "Cal.com"; export const EMAIL_FROM_NAME = process.env.EMAIL_FROM_NAME || APP_NAME; // This is the URL from which all Cal Links and their assets are served. @@ -68,8 +82,11 @@ export const CONSOLE_URL = : `https://console.cal.com`; const CAL_DOMAINS = [".cal.com", ".cal.dev", ".cal.eu", ".cal.qa"]; const WEBAPP_HOSTNAME = new URL(WEBAPP_URL).hostname; -export const IS_SELF_HOSTED = !CAL_DOMAINS.some((domain) => WEBAPP_HOSTNAME.endsWith(domain)); -export const EMBED_LIB_URL = process.env.NEXT_PUBLIC_EMBED_LIB_URL || `${WEBAPP_URL}/embed/embed.js`; +export const IS_SELF_HOSTED = !CAL_DOMAINS.some((domain) => + WEBAPP_HOSTNAME.endsWith(domain) +); +export const EMBED_LIB_URL = + process.env.NEXT_PUBLIC_EMBED_LIB_URL || `${WEBAPP_URL}/embed/embed.js`; export const TRIAL_LIMIT_DAYS = 14; export const MAX_SEATS_PER_TIME_SLOT = 1000; @@ -79,16 +96,26 @@ export const MAX_EVENT_DURATION_MINUTES = 1440; /** Minimum duration allowed for an event in minutes */ export const MIN_EVENT_DURATION_MINUTES = 1; -export const HOSTED_CAL_FEATURES = process.env.NEXT_PUBLIC_HOSTED_CAL_FEATURES || !IS_SELF_HOSTED; +export const HOSTED_CAL_FEATURES = + process.env.NEXT_PUBLIC_HOSTED_CAL_FEATURES || !IS_SELF_HOSTED; export const PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS = - parseInt(process.env.NEXT_PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS ?? "", 10) || 30; + parseInt( + process.env.NEXT_PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS ?? "", + 10 + ) || 30; // Must be lower than PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS export const PUBLIC_QUERY_RESERVATION_STALE_TIME_SECONDS = - parseInt(process.env.NEXT_PUBLIC_QUERY_RESERVATION_STALE_TIME_SECONDS ?? "", 10) || 20; + parseInt( + process.env.NEXT_PUBLIC_QUERY_RESERVATION_STALE_TIME_SECONDS ?? "", + 10 + ) || 20; export const PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS = - parseInt(process.env.NEXT_PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS ?? "", 10) || 5 * 60; + parseInt( + process.env.NEXT_PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS ?? "", + 10 + ) || 5 * 60; export const PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM = process.env.NEXT_PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM === "1"; @@ -97,7 +124,8 @@ export const PUBLIC_QUICK_AVAILABILITY_ROLLOUT = parseInt(process.env.NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT ?? "", 10) || 0; /** @deprecated use `WEBAPP_URL` */ -export const NEXT_PUBLIC_BASE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL || `https://${process.env.VERCEL_URL}`; +export const NEXT_PUBLIC_BASE_URL = + process.env.NEXT_PUBLIC_WEBAPP_URL || `https://${process.env.VERCEL_URL}`; export const LOGO = "/calcom-logo-white-word.svg"; export const LOGO_DARK = "/cal-logo-word-black.svg"; export const LOGO_ICON = "/cal-com-icon-white.svg"; @@ -135,10 +163,15 @@ export const IS_TEAM_BILLING_ENABLED_CLIENT = export const FULL_NAME_LENGTH_MAX_LIMIT = 50; export const API_NAME_LENGTH_MAX_LIMIT = 80; export const MINUTES_TO_BOOK = process.env.NEXT_PUBLIC_MINUTES_TO_BOOK || "5"; -export const ENABLE_PROFILE_SWITCHER = process.env.NEXT_PUBLIC_ENABLE_PROFILE_SWITCHER === "1"; +export const ENABLE_PROFILE_SWITCHER = + process.env.NEXT_PUBLIC_ENABLE_PROFILE_SWITCHER === "1"; // Needed for orgs -export const ALLOWED_HOSTNAMES = JSON.parse(`[${process.env.ALLOWED_HOSTNAMES || ""}]`) as string[]; -export const RESERVED_SUBDOMAINS = JSON.parse(`[${process.env.RESERVED_SUBDOMAINS || ""}]`) as string[]; +export const ALLOWED_HOSTNAMES = JSON.parse( + `[${process.env.ALLOWED_HOSTNAMES || ""}]` +) as string[]; +export const RESERVED_SUBDOMAINS = JSON.parse( + `[${process.env.RESERVED_SUBDOMAINS || ""}]` +) as string[]; export const ORGANIZATION_SELF_SERVE_PRICE = parseFloat( process.env.NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE_NEW || "37" @@ -149,15 +182,19 @@ export const IS_MAILHOG_ENABLED = process.env.E2E_TEST_MAILHOG_ENABLED === "1"; export const CALCOM_VERSION = process.env.NEXT_PUBLIC_CALCOM_VERSION as string; export const APP_CREDENTIAL_SHARING_ENABLED = - !!process.env.CALCOM_CREDENTIAL_SYNC_SECRET && !!process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY; + !!process.env.CALCOM_CREDENTIAL_SYNC_SECRET && + !!process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY; export const CREDENTIAL_SYNC_SECRET = process.env.CALCOM_CREDENTIAL_SYNC_SECRET; export const CREDENTIAL_SYNC_SECRET_HEADER_NAME = - process.env.CALCOM_CREDENTIAL_SYNC_HEADER_NAME || "calcom-credential-sync-secret"; + process.env.CALCOM_CREDENTIAL_SYNC_HEADER_NAME || + "calcom-credential-sync-secret"; -export const CREDENTIAL_SYNC_ENDPOINT = process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT; +export const CREDENTIAL_SYNC_ENDPOINT = + process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT; // Service Account Encryption Key for encrypting/decrypting service account keys -export const SERVICE_ACCOUNT_ENCRYPTION_KEY = process.env.CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY; +export const SERVICE_ACCOUNT_ENCRYPTION_KEY = + process.env.CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY; export const DEFAULT_LIGHT_BRAND_COLOR = "#292929"; export const DEFAULT_DARK_BRAND_COLOR = "#fafafa"; @@ -173,7 +210,9 @@ export const MAX_NB_INVITES = 100; export const URL_PROTOCOL_REGEX = /(^\w+:|^)\/\//; -export const IS_VISUAL_REGRESSION_TESTING = Boolean(globalThis.window?.Meticulous?.isRunningAsTest); +export const IS_VISUAL_REGRESSION_TESTING = Boolean( + globalThis.window?.Meticulous?.isRunningAsTest +); export const BOOKER_NUMBER_OF_DAYS_TO_LOAD = parseInt( process.env.NEXT_PUBLIC_BOOKER_NUMBER_OF_DAYS_TO_LOAD ?? "0", @@ -181,15 +220,20 @@ export const BOOKER_NUMBER_OF_DAYS_TO_LOAD = parseInt( ); export const CLOUDFLARE_SITE_ID = process.env.NEXT_PUBLIC_CLOUDFLARE_SITEKEY; -export const CLOUDFLARE_USE_TURNSTILE_IN_BOOKER = process.env.NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER; -export const ORG_SELF_SERVE_ENABLED = process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED === "1"; +export const CLOUDFLARE_USE_TURNSTILE_IN_BOOKER = + process.env.NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER; +export const ORG_SELF_SERVE_ENABLED = + process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED === "1"; export const ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE = 0; export const ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE_HELPER_DIALOGUE = 1; -export const CALCOM_PRIVATE_API_ROUTE = process.env.CALCOM_PRIVATE_API_ROUTE || "https://goblin.cal.com"; +export const CALCOM_PRIVATE_API_ROUTE = + process.env.CALCOM_PRIVATE_API_ROUTE || "https://goblin.cal.com"; export const WEBSITE_PRIVACY_POLICY_URL = - process.env.NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL || "https://cal.com/privacy"; -export const WEBSITE_TERMS_URL = process.env.NEXT_PUBLIC_WEBSITE_TERMS_URL || "https://cal.com/terms"; + process.env.NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL || + "https://cal.com/privacy"; +export const WEBSITE_TERMS_URL = + process.env.NEXT_PUBLIC_WEBSITE_TERMS_URL || "https://cal.com/terms"; export const LINGO_DOT_DEV_API_KEY = process.env.LINGO_DOT_DEV_API_KEY; /** @@ -214,36 +258,55 @@ export const RECORDING_IN_PROGRESS_ICON = IS_PRODUCTION ? `${WEBAPP_URL}/stop-recording.svg` : `https://app.cal.com/stop-recording.svg`; -export const SCOPE_USERINFO_PROFILE = "https://www.googleapis.com/auth/userinfo.profile"; -export const SCOPE_USERINFO_EMAIL = "https://www.googleapis.com/auth/userinfo.email"; -export const GOOGLE_OAUTH_SCOPES = [SCOPE_USERINFO_PROFILE, SCOPE_USERINFO_EMAIL]; +export const SCOPE_USERINFO_PROFILE = + "https://www.googleapis.com/auth/userinfo.profile"; +export const SCOPE_USERINFO_EMAIL = + "https://www.googleapis.com/auth/userinfo.email"; +export const GOOGLE_OAUTH_SCOPES = [ + SCOPE_USERINFO_PROFILE, + SCOPE_USERINFO_EMAIL, +]; export const GOOGLE_CALENDAR_SCOPES = [ "https://www.googleapis.com/auth/calendar.events", "https://www.googleapis.com/auth/calendar.readonly", ]; -export const DIRECTORY_IDS_TO_LOG = process.env.DIRECTORY_IDS_TO_LOG?.split(",") || []; -export const SCANNING_WORKFLOW_STEPS = !!(!IS_SELF_HOSTED && process.env.IFFY_API_KEY); +export const DIRECTORY_IDS_TO_LOG = + process.env.DIRECTORY_IDS_TO_LOG?.split(",") || []; +export const SCANNING_WORKFLOW_STEPS = !!( + !IS_SELF_HOSTED && process.env.IFFY_API_KEY +); // Cloudflare URL Scanner - checks URLs for malicious content in workflows and event types export const URL_SCANNING_ENABLED = - !!process.env.CLOUDFLARE_URL_SCANNER_API_TOKEN && !!process.env.CLOUDFLARE_ACCOUNT_ID; + !!process.env.CLOUDFLARE_URL_SCANNER_API_TOKEN && + !!process.env.CLOUDFLARE_ACCOUNT_ID; export const IS_DUB_REFERRALS_ENABLED = - !!process.env.NEXT_PUBLIC_DUB_PROGRAM_ID && process.env.NEXT_PUBLIC_DUB_PROGRAM_ID !== ""; + !!process.env.NEXT_PUBLIC_DUB_PROGRAM_ID && + process.env.NEXT_PUBLIC_DUB_PROGRAM_ID !== ""; -export const CAL_VIDEO_MEETING_LINK_FOR_TESTING = process.env.CAL_VIDEO_MEETING_LINK_FOR_TESTING; +export const CAL_VIDEO_MEETING_LINK_FOR_TESTING = + process.env.CAL_VIDEO_MEETING_LINK_FOR_TESTING; export const IS_SMS_CREDITS_ENABLED = - !!process.env.NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID || !!process.env.NEXT_PUBLIC_IS_E2E; -export const DATABASE_CHUNK_SIZE = parseInt(process.env.DATABASE_CHUNK_SIZE || "25", 10); + !!process.env.NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID || + !!process.env.NEXT_PUBLIC_IS_E2E; +export const DATABASE_CHUNK_SIZE = parseInt( + process.env.DATABASE_CHUNK_SIZE || "25", + 10 +); export const NEXTJS_CACHE_TTL = 3600; // 1 hour export const DEFAULT_GROUP_ID = "default_group_id"; -const _rawCalAiPrice = process.env.NEXT_PUBLIC_CAL_AI_PHONE_NUMBER_MONTHLY_PRICE; +const _rawCalAiPrice = + process.env.NEXT_PUBLIC_CAL_AI_PHONE_NUMBER_MONTHLY_PRICE; export const CAL_AI_PHONE_NUMBER_MONTHLY_PRICE = (() => { - const parsed = _rawCalAiPrice && _rawCalAiPrice.trim() !== "" ? Number(_rawCalAiPrice) : NaN; + const parsed = + _rawCalAiPrice && _rawCalAiPrice.trim() !== "" + ? Number(_rawCalAiPrice) + : NaN; return Number.isFinite(parsed) ? parsed : 5; })(); @@ -275,4 +338,6 @@ export const ORG_TRIAL_DAYS = process.env.STRIPE_ORG_TRIAL_DAYS export const IS_API_V2_E2E = process.env.IS_E2E === "true"; export const ENABLE_ASYNC_TASKER = - process.env.ENABLE_ASYNC_TASKER === "true" && !process.env.NEXT_PUBLIC_IS_E2E && !IS_API_V2_E2E; + process.env.ENABLE_ASYNC_TASKER === "true" && + !process.env.NEXT_PUBLIC_IS_E2E && + !IS_API_V2_E2E; diff --git a/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts b/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts index 27668fbeaf..da0d993e1e 100644 --- a/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts @@ -154,7 +154,7 @@ export async function bulkDeleteUsersHandler({ ctx, input }: BulkDeleteUsersHand const teamBillingServiceFactory = getTeamBillingServiceFactory(); const teamBillingService = await teamBillingServiceFactory.findAndInit(currentUserOrgId); - await teamBillingService.updateQuantity(); + await teamBillingService.updateQuantity("removal"); return { success: true, diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts index 67509ef632..560312dfc0 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts @@ -232,7 +232,7 @@ export const inviteMembersWithNoInviterPermissionCheck = async ( const teamBillingServiceFactory = getTeamBillingServiceFactory(); const teamBillingService = teamBillingServiceFactory.init(team); - await teamBillingService.updateQuantity(); + await teamBillingService.updateQuantity("addition"); return { // TODO: Better rename it to invitations only maybe?