From 7e4d9e26c696392f676a14ca4c14f4d076d25bb0 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Wed, 19 Nov 2025 10:21:22 -0500 Subject: [PATCH] refactor: implement DI in team billing service and team billing data repository factory (#24803) * Move TeamBillingRepositories * WIP refactor team internal billing service * Remove duplicate billing repository files * Remove logic check in repository for billing is enabled * Rename repository to `TeamBillingData` * Use repository factory in main service * Fix new import paths * Rename to * Ensure `IS_TEAM_BILLING_ENABLED` is of type boolean * Rename classes to TeamBillingService and TeamBillingServiceFactory * Implement DI in `BookingServiceFactory` * `TeamBillingService` use repository in `getOrgIfNeeded` * DI `isTeamBillingEnabled` to `TeamBillingServiceFactory` * Rename files for consistency * Return stub BillingRepository if billing is not enabled * Move Stripe billing service to service folder * Rename file * `StripeBillingService.getSubscriptionStatus` return `SubscriptionStatus` * Type fices in StripeBillingService * Type fix in `stubTeamBillingService` * DI the `BillingProviderService` into the `TeamBillingService` * Implement DI in `skipTeamTrials.handler` * Implement DI for team billing in `inviteMember.handler` * `skipTeamTrials.handler` use `team.isOrganization` * Implement DI for billing in `hasActiveTeamPlan.handler` * Type fixes * Implement DI in `bulkDeleteUsers.handler` * Implement `BillingProviderServiceFactory` in `updateProfile.handler` * Implment `BillingProviderServiceFactory` in `buyCredits.handler` * Fix import in `stripeCustomer.handler` * Add a constructor to `teamBillingServiceFactory` * Add DI to `PrismaTeamBillingRepository` * Add DI to `StripeBillingService` * Implement singleton in `BillingProviderServiceFactory` * Add DI folder and contents to billing folder * Use `getTeamBillingServiceFactory` in `inviteMember.handler` * Add `saveTeamBilling` method to `ITeamBillingService` * Implement DI in new team route * Implement DI in `teamService` * Implement DI in `OrganizationPaymentService` * Implement DI in `credit-service` * In `StripeBillingService` remove `static` from status methods * Implemnt DI in `_invoice.paid.org` * Refactor `hasActiveTeamPlan` to use `getTeamBillingFactory` * Refactor `skipTeamTrials` to use `getTeamBillingFactory` * Refactor `skipTeamTrials` to use `getTeamBillingServiceFactory` * `stripeCustomer.handler` to use `getBillingProviderService` * Remove old factories * Type fix * Remove unused factory * Refactor `updateProfile.handler` to use `getBillingProviderService` * Change name to `TeamBillingDataRepositoryFactory` * Type Prisma return in `prisma.module` * Type fix * Refactor `buyCredits.handler` to use `getBillingProviderService` * Refactor `credit-service` to use billing DI containers * Type fix * Add `getTeamBillingDataRepository` * Refactor `_invoice.paid.org` to use DI container * Refactor `_customer.subscription.deleted.team-plan` to use DI container * Refactor `calcomHandler` to use DI container * Refactor `getCustomerAndCheckoutSession` to use DI container * Refactor `verify-email` to use DI containers * Refactor `api/create/route` to use DI container * Refactor downgradeUsers to use DI container * Type fix * Clean up console.logs * Add await to `this.billingRepository.create` in `saveTeamBilling` Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * Fix type errors * Address comments * fix: update tests to work with new DI pattern - Update teamBillingService.test.ts to properly inject DI dependencies - Remove unused billingModule import and mock - Fix import naming in teamService.integration-test.ts (remove unused rename) - Fix import path for TeamBillingPublishResponseStatus All tests now properly mock IBillingProviderService, ITeamBillingDataRepository, and IBillingRepository instead of using the old BillingRepositoryFactory pattern. Co-Authored-By: joe@cal.com * fix: add compatibility layer and env setup for unit tests - Add STRIPE_PRIVATE_KEY dummy value to vitest.config.ts to prevent DI module errors - Fix import paths in credit-service.test.ts (StripeBillingService, TeamBillingService) - Create compatibility barrel at packages/features/ee/billing/teams/index.ts for test mocking Co-Authored-By: joe@cal.com * fix: update unit tests to mock DI container properly - Update teamService.test.ts to mock getTeamBillingServiceFactory() instead of TeamBilling.findAndInit - Update teamService.alternative.test.ts to mock DI container - Update credit-service.test.ts to mock getBillingProviderService() and use SubscriptionStatus enum values - Update OrganizationPaymentService.test.ts to mock DI container instead of direct StripeBillingService import - Remove all 'as any' type casting to comply with Cal.com coding standards - Fix unused variable warnings by prefixing with underscore All 53 tests now passing (16 + 1 + 30 + 6) Co-Authored-By: joe@cal.com * fix: update remaining unit tests to use DI pattern - Fix StripeBillingService.test.ts to inject mock Stripe client directly - Fix teamBillingFactory.test.ts to mock getTeamBillingServiceFactory() from DI container - Fix skipTeamTrials.test.ts to mock DI container and use SubscriptionStatus enum All 11 previously failing tests now pass (5 + 5 + 1) Co-Authored-By: joe@cal.com * Undo changes made to Prisma module * fix: address test-related PR comments - Fix OrganizationPaymentService.test.ts mock path from @calcom/ee to @calcom/features/ee - Refactor teamBillingFactory.test.ts to test real factory logic instead of mocking container - Remove duplicate teamBillingService.test..ts file with incorrect double-dot filename All three test files now pass successfully with proper DI patterns. Co-Authored-By: joe@cal.com * Address feedback * fix: update teamService integration test to mock new DI factory pattern Co-Authored-By: joe@cal.com * refactor: remove duplicate imports in credit-service.test.ts Co-Authored-By: joe@cal.com * Remove unused index file * `getBySubscriptionId` to return team or null * Address feedback * Merge fix * Refactor file names * fix: correct mockStripe variable name to stripeMock in StripeBillingService.test.ts Co-Authored-By: joe@cal.com * refactor: update internal-team-billing.test.ts to use new DI structure with TeamBillingService - Replace InternalTeamBilling with TeamBillingService - Use constructor injection with mock dependencies instead of factory pattern - Remove BillingRepositoryFactory mock and import - Update all test cases to use mockBillingProviderService, mockTeamBillingDataRepository, and mockBillingRepository - Simplify saveTeamBilling tests to focus on repository.create calls - All 11 tests now pass with the new DI structure Co-Authored-By: joe@cal.com * fix: update createWithPaymentIntent.handler.test.ts to mock DI container's getBillingProviderService - OrganizationPaymentService now uses getBillingProviderService() from DI container - Test was mocking @calcom/features/ee/payments/server/stripe directly, which no longer works - Added mock for @calcom/features/ee/billing/di/containers/Billing module - Mock returns fake billing provider that delegates to mockSharedStripe - Preserves all existing test assertions and helpers - Fixed lint error by prefixing unused lastCreatedSessionId with underscore - All 11 tests now pass (1 skipped as expected) Co-Authored-By: joe@cal.com --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com> --- apps/web/app/api/cron/downgradeUsers/route.ts | 6 +- apps/web/app/api/teams/api/create/route.ts | 15 +- apps/web/app/api/teams/create/route.ts | 16 +- apps/web/lib/pages/auth/verify-email.ts | 4 +- .../lib/getCustomerAndCheckoutSession.ts | 4 +- .../auth/signup/handlers/calcomHandler.ts | 4 +- packages/features/di/di.ts | 4 +- ...customer.subscription.deleted.team-plan.ts | 24 +- .../billing/api/webhook/_invoice.paid.org.ts | 14 +- .../ee/billing/credit-service.test.ts | 80 +++- .../features/ee/billing/credit-service.ts | 28 +- .../ee/billing/di/containers/Billing.ts | 26 ++ .../di/modules/BillingProviderService.ts | 20 + .../di/modules/BillingRepositoryFactory.ts | 43 ++ .../di/modules/IsTeamBillingEnabled.ts | 17 + .../ee/billing/di/modules/StripeClient.ts | 24 + .../TeamBillingDataRepositoryFactory.ts | 34 ++ .../di/modules/TeamBillingServiceFactory.ts | 29 ++ packages/features/ee/billing/di/tokens.ts | 12 + .../{ => billing}/IBillingRepository.ts | 0 .../PrismaOrganizationBillingRepository.ts | 0 .../PrismaTeamBillingRepository.ts | 0 .../billing/StubBillingRepository.ts | 16 + .../billingRepositoryFactory.test.ts | 39 -- .../repository/billingRepositoryFactory.ts | 13 - .../ITeamBillingDataRepository.ts} | 5 +- .../PrismaTeamBillingRepository.ts | 29 ++ .../StubTeamBillingRepository.ts | 17 + .../IBillingProviderService.ts} | 6 +- .../StripeBillingService.test.ts} | 62 ++- .../billingProvider/StripeBillingService.ts} | 39 +- .../teams/ITeamBillingService.ts} | 10 +- .../service/teams/StubTeamBillingService.ts | 39 ++ .../service/teams/TeamBillingFactory.test.ts | 134 ++++++ .../service/teams/TeamBillingService.test.ts | 409 ++++++++++++++++++ .../teams/TeamBillingService.ts} | 56 ++- .../teams/TeamBillingServiceFactory.ts | 53 +++ packages/features/ee/billing/teams/index.ts | 30 -- .../teams/internal-team-billing.test.ts | 249 ++++------- .../ee/billing/teams/stub-team-billing.ts | 28 -- .../teams/team-billing.repository.test.ts | 110 ----- .../billing/teams/team-billing.repository.ts | 34 -- .../ee/billing/teams/team-billing.test.ts | 83 ---- .../lib/OrganizationPaymentService.test.ts | 35 +- .../lib/OrganizationPaymentService.ts | 5 +- .../services/teamService.alternative.test.ts | 41 +- .../services/teamService.integration-test.ts | 23 +- .../ee/teams/services/teamService.test.ts | 20 +- .../features/ee/teams/services/teamService.ts | 33 +- packages/lib/constants.ts | 2 +- .../loggedInViewer/stripeCustomer.handler.ts | 4 +- .../viewer/credits/buyCredits.handler.ts | 4 +- .../viewer/me/updateProfile.handler.ts | 7 +- .../organizations/bulkDeleteUsers.handler.ts | 7 +- .../createWithPaymentIntent.handler.test.ts | 61 ++- .../viewer/teams/hasActiveTeamPlan.handler.ts | 15 +- .../inviteMember/inviteMember.handler.ts | 7 +- .../viewer/teams/skipTeamTrials.handler.ts | 8 +- .../viewer/teams/skipTeamTrials.test.ts | 30 +- turbo.json | 1 + vitest.config.ts | 1 + 61 files changed, 1416 insertions(+), 753 deletions(-) create mode 100644 packages/features/ee/billing/di/containers/Billing.ts create mode 100644 packages/features/ee/billing/di/modules/BillingProviderService.ts create mode 100644 packages/features/ee/billing/di/modules/BillingRepositoryFactory.ts create mode 100644 packages/features/ee/billing/di/modules/IsTeamBillingEnabled.ts create mode 100644 packages/features/ee/billing/di/modules/StripeClient.ts create mode 100644 packages/features/ee/billing/di/modules/TeamBillingDataRepositoryFactory.ts create mode 100644 packages/features/ee/billing/di/modules/TeamBillingServiceFactory.ts create mode 100644 packages/features/ee/billing/di/tokens.ts rename packages/features/ee/billing/repository/{ => billing}/IBillingRepository.ts (100%) rename packages/features/ee/billing/repository/{ => billing}/PrismaOrganizationBillingRepository.ts (100%) rename packages/features/ee/billing/repository/{ => billing}/PrismaTeamBillingRepository.ts (100%) create mode 100644 packages/features/ee/billing/repository/billing/StubBillingRepository.ts delete mode 100644 packages/features/ee/billing/repository/billingRepositoryFactory.test.ts delete mode 100644 packages/features/ee/billing/repository/billingRepositoryFactory.ts rename packages/features/ee/billing/{teams/team-billing.repository.interface.ts => repository/teamBillingData/ITeamBillingDataRepository.ts} (85%) create mode 100644 packages/features/ee/billing/repository/teamBillingData/PrismaTeamBillingRepository.ts create mode 100644 packages/features/ee/billing/repository/teamBillingData/StubTeamBillingRepository.ts rename packages/features/ee/billing/{billing-service.ts => service/billingProvider/IBillingProviderService.ts} (90%) rename packages/features/ee/billing/{stripe-billing-service.test.ts => service/billingProvider/StripeBillingService.test.ts} (61%) rename packages/features/ee/billing/{stripe-billing-service.ts => service/billingProvider/StripeBillingService.ts} (85%) rename packages/features/ee/billing/{teams/team-billing.ts => service/teams/ITeamBillingService.ts} (66%) create mode 100644 packages/features/ee/billing/service/teams/StubTeamBillingService.ts create mode 100644 packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts create mode 100644 packages/features/ee/billing/service/teams/TeamBillingService.test.ts rename packages/features/ee/billing/{teams/internal-team-billing.ts => service/teams/TeamBillingService.ts} (83%) create mode 100644 packages/features/ee/billing/service/teams/TeamBillingServiceFactory.ts delete mode 100644 packages/features/ee/billing/teams/index.ts delete mode 100644 packages/features/ee/billing/teams/stub-team-billing.ts delete mode 100644 packages/features/ee/billing/teams/team-billing.repository.test.ts delete mode 100644 packages/features/ee/billing/teams/team-billing.repository.ts delete mode 100644 packages/features/ee/billing/teams/team-billing.test.ts diff --git a/apps/web/app/api/cron/downgradeUsers/route.ts b/apps/web/app/api/cron/downgradeUsers/route.ts index 140b42c879..ae13dfa5e9 100644 --- a/apps/web/app/api/cron/downgradeUsers/route.ts +++ b/apps/web/app/api/cron/downgradeUsers/route.ts @@ -3,7 +3,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { z } from "zod"; -import { TeamBilling } from "@calcom/ee/billing/teams"; +import { getTeamBillingServiceFactory } from "@calcom/features/ee/billing/di/containers/Billing"; import prisma from "@calcom/prisma"; const querySchema = z.object({ @@ -17,7 +17,6 @@ async function postHandler(request: NextRequest) { return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); } - const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const pageSize = 90; // Adjust this value based on the total number of teams and the available processing time let { page: pageNumber } = querySchema.parse(Object.fromEntries(request.nextUrl.searchParams)); @@ -43,7 +42,8 @@ async function postHandler(request: NextRequest) { break; } - const teamsBilling = TeamBilling.initMany(teams); + const teamBillingFactory = getTeamBillingServiceFactory(); + const teamsBilling = teamBillingFactory.initMany(teams); const teamBillingPromises = teamsBilling.map((teamBilling) => teamBilling.updateQuantity()); await Promise.allSettled(teamBillingPromises); diff --git a/apps/web/app/api/teams/api/create/route.ts b/apps/web/app/api/teams/api/create/route.ts index 9699897de7..393ed8ed0d 100644 --- a/apps/web/app/api/teams/api/create/route.ts +++ b/apps/web/app/api/teams/api/create/route.ts @@ -4,9 +4,9 @@ import { NextResponse } from "next/server"; import type Stripe from "stripe"; import { z } from "zod"; -import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository"; -import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service"; -import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing"; +import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing"; +import { getTeamBillingServiceFactory } from "@calcom/ee/billing/di/containers/Billing"; +import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/billing/IBillingRepository"; import stripe from "@calcom/features/ee/payments/server/stripe"; import { HttpError } from "@calcom/lib/http-error"; import { prisma } from "@calcom/prisma"; @@ -58,11 +58,12 @@ async function handler(request: NextRequest) { }); if (checkoutSessionSubscription) { - const { subscriptionStart } = - StripeBillingService.extractSubscriptionDates(checkoutSessionSubscription); + const billingService = getBillingProviderService(); + const { subscriptionStart } = billingService.extractSubscriptionDates(checkoutSessionSubscription); - const internalBillingService = new InternalTeamBilling(finalizedTeam); - await internalBillingService.saveTeamBilling({ + const teamBillingServiceFactory = getTeamBillingServiceFactory(); + const teamBillingService = teamBillingServiceFactory.init(finalizedTeam); + await teamBillingService.saveTeamBilling({ teamId: finalizedTeam.id, subscriptionId: checkoutSessionSubscription.id, subscriptionItemId: checkoutSessionSubscription.items.data[0].id, diff --git a/apps/web/app/api/teams/create/route.ts b/apps/web/app/api/teams/create/route.ts index 09a3a231cb..04fe9399a1 100644 --- a/apps/web/app/api/teams/create/route.ts +++ b/apps/web/app/api/teams/create/route.ts @@ -4,9 +4,11 @@ import { NextResponse } from "next/server"; import type Stripe from "stripe"; import { z } from "zod"; -import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository"; -import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service"; -import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing"; +import { + getBillingProviderService, + getTeamBillingServiceFactory, +} from "@calcom/features/ee/billing/di/containers/Billing"; +import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/billing/IBillingRepository"; import stripe from "@calcom/features/ee/payments/server/stripe"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { HttpError } from "@calcom/lib/http-error"; @@ -91,9 +93,11 @@ async function getHandler(req: NextRequest) { }); if (checkoutSession && subscription) { - const { subscriptionStart } = StripeBillingService.extractSubscriptionDates(subscription); - const internalBillingService = new InternalTeamBilling(team); - await internalBillingService.saveTeamBilling({ + const billingProviderService = getBillingProviderService(); + const { subscriptionStart } = billingProviderService.extractSubscriptionDates(subscription); + const teamBillingServiceFactory = getTeamBillingServiceFactory(); + const teamBillingService = teamBillingServiceFactory.init(team); + await teamBillingService.saveTeamBilling({ teamId: team.id, subscriptionId: subscription.id, subscriptionItemId: subscription.items.data[0].id, diff --git a/apps/web/lib/pages/auth/verify-email.ts b/apps/web/lib/pages/auth/verify-email.ts index c6f8bcd012..b487e60ca6 100644 --- a/apps/web/lib/pages/auth/verify-email.ts +++ b/apps/web/lib/pages/auth/verify-email.ts @@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { z } from "zod"; import dayjs from "@calcom/dayjs"; -import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service"; +import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing"; import { getOrganizationRepository } from "@calcom/features/ee/organizations/di/OrganizationRepository.container"; import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service"; import { WEBAPP_URL } from "@calcom/lib/constants"; @@ -45,7 +45,6 @@ export async function moveUserToMatchingOrg({ email }: { email: string }) { export async function handler(req: NextApiRequest, res: NextApiResponse) { const { token } = verifySchema.parse(req.query); - const billingService = new StripeBillingService(); const foundToken = await prisma.verificationToken.findFirst({ where: { @@ -133,6 +132,7 @@ export async function handler(req: NextApiRequest, res: NextApiResponse) { }); if (IS_STRIPE_ENABLED && userMetadataParsed.stripeCustomerId) { + const billingService = getBillingProviderService(); await billingService.updateCustomer({ customerId: userMetadataParsed.stripeCustomerId, email: updatedEmail, diff --git a/packages/app-store/stripepayment/lib/getCustomerAndCheckoutSession.ts b/packages/app-store/stripepayment/lib/getCustomerAndCheckoutSession.ts index 81194473cd..cede5599c9 100644 --- a/packages/app-store/stripepayment/lib/getCustomerAndCheckoutSession.ts +++ b/packages/app-store/stripepayment/lib/getCustomerAndCheckoutSession.ts @@ -1,7 +1,7 @@ -import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service"; +import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing"; export async function getCustomerAndCheckoutSession(checkoutSessionId: string) { - const billingService = new StripeBillingService(); + const billingService = getBillingProviderService(); const checkoutSession = await billingService.getCheckoutSession(checkoutSessionId); const customerOrCustomerId = checkoutSession.customer; let customerId = null; diff --git a/packages/features/auth/signup/handlers/calcomHandler.ts b/packages/features/auth/signup/handlers/calcomHandler.ts index 76a95feb71..dc204f4a93 100644 --- a/packages/features/auth/signup/handlers/calcomHandler.ts +++ b/packages/features/auth/signup/handlers/calcomHandler.ts @@ -7,7 +7,7 @@ import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail"; import { createOrUpdateMemberships } from "@calcom/features/auth/signup/utils/createOrUpdateMemberships"; import { prefillAvatar } from "@calcom/features/auth/signup/utils/prefillAvatar"; import { validateAndGetCorrectedUsernameAndEmail } from "@calcom/features/auth/signup/utils/validateUsername"; -import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service"; +import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing"; import { sentrySpan } from "@calcom/features/watchlist/lib/telemetry"; import { checkIfEmailIsBlockedInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller"; import { hashPassword } from "@calcom/lib/auth/hashPassword"; @@ -44,7 +44,7 @@ const handler: CustomNextApiHandler = async (body, usernameStatus) => { }) .parse(body); - const billingService = new StripeBillingService(); + const billingService = getBillingProviderService(); const shouldLockByDefault = await checkIfEmailIsBlockedInWatchlistController({ email: _email, diff --git a/packages/features/di/di.ts b/packages/features/di/di.ts index bb02a13d06..5cee3a74e2 100644 --- a/packages/features/di/di.ts +++ b/packages/features/di/di.ts @@ -1,4 +1,4 @@ -import type { Container, Module } from "@evyweb/ioctopus"; +import type { Container, Module, ResolveFunction } from "@evyweb/ioctopus"; import { createContainer, createModule } from "@evyweb/ioctopus"; export type ModuleLoader = { token: string | symbol; loadModule: (container: Container) => void }; @@ -111,4 +111,4 @@ export function bindModuleToClassOnToken any>( } }; } -export { createContainer, createModule, type Container, type Module }; +export { createContainer, createModule, type Container, type Module, type ResolveFunction }; diff --git a/packages/features/ee/billing/api/webhook/_customer.subscription.deleted.team-plan.ts b/packages/features/ee/billing/api/webhook/_customer.subscription.deleted.team-plan.ts index 4370e0706e..f20a7ac1ae 100644 --- a/packages/features/ee/billing/api/webhook/_customer.subscription.deleted.team-plan.ts +++ b/packages/features/ee/billing/api/webhook/_customer.subscription.deleted.team-plan.ts @@ -1,6 +1,8 @@ import { z } from "zod"; -import { TeamBilling } from "../../teams"; +import logger from "@calcom/lib/logger"; + +import { getTeamBillingDataRepository, getTeamBillingServiceFactory } from "../../di/containers/Billing"; import type { SWHMap } from "./__handler"; const metadataSchema = z.object({ @@ -9,15 +11,25 @@ const metadataSchema = z.object({ const handler = async (data: SWHMap["customer.subscription.deleted"]["data"]) => { const subscription = data.object; + const teamBillingFactory = getTeamBillingServiceFactory(); + const log = logger.getSubLogger({ + prefix: [`[customer.subscription.deleted.team-plan]: subscriptionId: ${subscription.id}`], + }); + try { const { teamId } = metadataSchema.parse(subscription.metadata); - const teamBilling = await TeamBilling.findAndInit(teamId); - await teamBilling.downgrade(); + const teamBillingService = await teamBillingFactory.findAndInit(teamId); + await teamBillingService.downgrade(); return { success: true }; - } catch (error) { + } catch { + const teamBillingDataRepository = getTeamBillingDataRepository(); // If stripe metadata is missing teamId, we attempt to find by sub ID. - const team = await TeamBilling.repo.findBySubscriptionId(subscription.id); - const teamBilling = TeamBilling.init(team); + const team = await teamBillingDataRepository.findBySubscriptionId(subscription.id); + if (!team) { + log.warn("No team found with subscriptionId"); + return { success: false }; + } + const teamBilling = teamBillingFactory.init(team); await teamBilling.downgrade(); return { success: true }; } diff --git a/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts b/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts index 317bc027e5..a5783a3ada 100644 --- a/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts +++ b/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts @@ -1,8 +1,7 @@ import { z } from "zod"; -import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/IBillingRepository"; -import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service"; -import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing"; +import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing"; +import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/billing/IBillingRepository"; import { BillingEnabledOrgOnboardingService } from "@calcom/features/ee/organizations/lib/service/onboarding/BillingEnabledOrgOnboardingService"; import stripe from "@calcom/features/ee/payments/server/stripe"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; @@ -11,6 +10,7 @@ import { safeStringify } from "@calcom/lib/safeStringify"; import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding"; import { prisma } from "@calcom/prisma"; +import { getTeamBillingServiceFactory } from "../../di/containers/Billing"; import type { SWHMap } from "./__handler"; const invoicePaidSchema = z.object({ @@ -123,10 +123,12 @@ const handler = async (data: SWHMap["invoice.paid"]["data"]) => { // Get the Stripe subscription object const stripeSubscription = await stripe.subscriptions.retrieve(paymentSubscriptionId); - const { subscriptionStart } = StripeBillingService.extractSubscriptionDates(stripeSubscription); + const billingService = getBillingProviderService(); + const { subscriptionStart } = billingService.extractSubscriptionDates(stripeSubscription); - const internalTeamBillingService = new InternalTeamBilling(organization); - await internalTeamBillingService.saveTeamBilling({ + const teamBillingServiceFactory = getTeamBillingServiceFactory(); + const teamBillingService = teamBillingServiceFactory.init(organization); + await teamBillingService.saveTeamBilling({ teamId: organization.id, subscriptionId: paymentSubscriptionId, subscriptionItemId: paymentSubscriptionItemId, diff --git a/packages/features/ee/billing/credit-service.test.ts b/packages/features/ee/billing/credit-service.test.ts index b10ff0f493..7cecc6d236 100644 --- a/packages/features/ee/billing/credit-service.test.ts +++ b/packages/features/ee/billing/credit-service.test.ts @@ -9,7 +9,7 @@ import { CreditsRepository } from "@calcom/lib/server/repository/credits"; import { CreditType } from "@calcom/prisma/enums"; import { CreditService } from "./credit-service"; -import { InternalTeamBilling } from "./teams/internal-team-billing"; +import { SubscriptionStatus } from "./repository/billing/IBillingRepository"; const MOCK_TX = { team: { @@ -102,6 +102,12 @@ vi.mock("@calcom/lib/getOrgIdFromMemberOrTeamId", () => ({ default: vi.fn().mockResolvedValue(null), })); +vi.mock("@calcom/ee/billing/di/containers/Billing", () => ({ + getBillingProviderService: vi.fn(), + getTeamBillingServiceFactory: vi.fn(), + getTeamBillingDataRepository: vi.fn(), +})); + const creditService = new CreditService(); vi.spyOn(creditService, "_getAllCreditsForTeam").mockResolvedValue({ @@ -126,7 +132,7 @@ CreditsRepository.findCreditBalance.mockResolvedValueOnce({ describe("CreditService", () => { let creditService: CreditService; - beforeEach(() => { + beforeEach(async () => { vi.restoreAllMocks(); mockStripe.prices.retrieve.mockResolvedValue({ id: "price_123", unit_amount: 1000 }); @@ -135,6 +141,25 @@ describe("CreditService", () => { creditService = new CreditService(); vi.mocked(CreditsRepository.findCreditExpenseLogByExternalRef).mockResolvedValue(null); + + const { getBillingProviderService, getTeamBillingServiceFactory } = await import( + "@calcom/ee/billing/di/containers/Billing" + ); + + const mockBillingProviderService = { + getPrice: vi.fn().mockResolvedValue({ unit_amount: 1500 }), + }; + vi.mocked(getBillingProviderService).mockReturnValue(mockBillingProviderService); + + const mockTeamBillingService = { + getSubscriptionStatus: vi.fn().mockResolvedValue("active"), + }; + const mockTeamBillingServiceFactory = { + init: vi.fn().mockReturnValue(mockTeamBillingService), + findAndInit: vi.fn().mockResolvedValue(mockTeamBillingService), + findAndInitMany: vi.fn().mockResolvedValue([mockTeamBillingService]), + }; + vi.mocked(getTeamBillingServiceFactory).mockReturnValue(mockTeamBillingServiceFactory); }); describe("Team credits", () => { @@ -450,11 +475,14 @@ describe("CreditService", () => { vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as unknown as TeamRepository); const mockTeamBillingService = { - getSubscriptionStatus: vi.fn().mockResolvedValue("trialing"), + getSubscriptionStatus: vi.fn().mockResolvedValue(SubscriptionStatus.TRIALING), }; - vi.spyOn(InternalTeamBilling.prototype, "getSubscriptionStatus").mockImplementation( - mockTeamBillingService.getSubscriptionStatus - ); + const { getTeamBillingServiceFactory } = await import("@calcom/ee/billing/di/containers/Billing"); + vi.mocked(getTeamBillingServiceFactory).mockReturnValue({ + init: vi.fn().mockReturnValue(mockTeamBillingService), + findAndInit: vi.fn().mockResolvedValue(mockTeamBillingService), + findAndInitMany: vi.fn().mockResolvedValue([mockTeamBillingService]), + }); const result = await creditService.getMonthlyCredits(1); expect(result).toBe(0); @@ -471,13 +499,20 @@ describe("CreditService", () => { vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as unknown as TeamRepository); const mockTeamBillingService = { - getSubscriptionStatus: vi.fn().mockResolvedValue("active"), + getSubscriptionStatus: vi.fn().mockResolvedValue(SubscriptionStatus.ACTIVE), }; - vi.spyOn(InternalTeamBilling.prototype, "getSubscriptionStatus").mockImplementation( - mockTeamBillingService.getSubscriptionStatus + const mockBillingProviderService = { + getPrice: vi.fn().mockResolvedValue({ unit_amount: 1000 }), + }; + const { getBillingProviderService, getTeamBillingServiceFactory } = await import( + "@calcom/ee/billing/di/containers/Billing" ); - - mockStripe.prices.retrieve.mockResolvedValue({ id: "price_123", unit_amount: 1000 }); + vi.mocked(getBillingProviderService).mockReturnValue(mockBillingProviderService); + vi.mocked(getTeamBillingServiceFactory).mockReturnValue({ + init: vi.fn().mockReturnValue(mockTeamBillingService), + findAndInit: vi.fn().mockResolvedValue(mockTeamBillingService), + findAndInitMany: vi.fn().mockResolvedValue([mockTeamBillingService]), + }); const result = await creditService.getMonthlyCredits(1); expect(result).toBe(1500); // (3 members * 1000 price) / 2 @@ -495,18 +530,20 @@ describe("CreditService", () => { vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as unknown as TeamRepository); const mockTeamBillingService = { - getSubscriptionStatus: vi.fn().mockResolvedValue("active"), + getSubscriptionStatus: vi.fn().mockResolvedValue(SubscriptionStatus.ACTIVE), }; - vi.spyOn(InternalTeamBilling.prototype, "getSubscriptionStatus").mockImplementation( - mockTeamBillingService.getSubscriptionStatus - ); + const { getTeamBillingServiceFactory } = await import("@calcom/ee/billing/di/containers/Billing"); + vi.mocked(getTeamBillingServiceFactory).mockReturnValue({ + init: vi.fn().mockReturnValue(mockTeamBillingService), + findAndInit: vi.fn().mockResolvedValue(mockTeamBillingService), + findAndInitMany: vi.fn().mockResolvedValue([mockTeamBillingService]), + }); const result = await creditService.getMonthlyCredits(1); expect(result).toBe(3000); // 2 members * 1500 credits per seat }); it("should calculate credits for organizations with default 1000 credits per seat", async () => { - // Clear ORG_MONTHLY_CREDITS to test default behavior vi.stubEnv("ORG_MONTHLY_CREDITS", undefined); const mockTeamRepo = { findTeamWithMembers: vi.fn().mockResolvedValue({ @@ -518,11 +555,14 @@ describe("CreditService", () => { vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as unknown as TeamRepository); const mockTeamBillingService = { - getSubscriptionStatus: vi.fn().mockResolvedValue("active"), + getSubscriptionStatus: vi.fn().mockResolvedValue(SubscriptionStatus.ACTIVE), }; - vi.spyOn(InternalTeamBilling.prototype, "getSubscriptionStatus").mockImplementation( - mockTeamBillingService.getSubscriptionStatus - ); + const { getTeamBillingServiceFactory } = await import("@calcom/ee/billing/di/containers/Billing"); + vi.mocked(getTeamBillingServiceFactory).mockReturnValue({ + init: vi.fn().mockReturnValue(mockTeamBillingService), + findAndInit: vi.fn().mockResolvedValue(mockTeamBillingService), + findAndInitMany: vi.fn().mockResolvedValue([mockTeamBillingService]), + }); const result = await creditService.getMonthlyCredits(1); expect(result).toBe(3000); // 3 members * 1000 credits per seat (default) diff --git a/packages/features/ee/billing/credit-service.ts b/packages/features/ee/billing/credit-service.ts index c2237e0be4..864114c4e3 100644 --- a/packages/features/ee/billing/credit-service.ts +++ b/packages/features/ee/billing/credit-service.ts @@ -10,6 +10,9 @@ import { CreditsRepository } from "@calcom/lib/server/repository/credits"; import { prisma, type PrismaTransaction } from "@calcom/prisma"; import { CreditUsageType, CreditType } from "@calcom/prisma/enums"; +import { getBillingProviderService, getTeamBillingServiceFactory } from "./di/containers/Billing"; +import { SubscriptionStatus } from "./repository/billing/IBillingRepository"; + const log = logger.getSubLogger({ prefix: ["[CreditService]"] }); type LowCreditBalanceResultBase = { @@ -466,10 +469,9 @@ export class CreditService { const { totalMonthlyCredits } = await this._getAllCreditsForTeam({ teamId, tx }); warningLimit = totalMonthlyCredits * 0.2; } else if (userId) { - const { StripeBillingService } = await import("./stripe-billing-service"); - const billing = new StripeBillingService(); - const teamMonthlyPrice = await billing.getPrice(process.env.STRIPE_TEAM_MONTHLY_PRICE_ID || ""); - const pricePerSeat = teamMonthlyPrice?.unit_amount ?? 0; + const billingService = getBillingProviderService(); + const teamMonthlyPrice = await billingService.getPrice(process.env.STRIPE_TEAM_MONTHLY_PRICE_ID || ""); + const pricePerSeat = teamMonthlyPrice.unit_amount ?? 0; warningLimit = (pricePerSeat / 2) * 0.2; } @@ -586,9 +588,7 @@ export class CreditService { try { if (result.type === "LIMIT_REACHED") { - const { sendCreditBalanceLimitReachedEmails } = await import( - "@calcom/emails/billing-email-service" - ); + const { sendCreditBalanceLimitReachedEmails } = await import("@calcom/emails/billing-email-service"); const promises: Promise[] = [ sendCreditBalanceLimitReachedEmails({ @@ -656,11 +656,14 @@ export class CreditService { if (!team) return 0; - const { InternalTeamBilling } = await import("@calcom/features/ee/billing/teams/internal-team-billing"); - const teamBillingService = new InternalTeamBilling(team); + const teamBillingServiceFactory = getTeamBillingServiceFactory(); + const teamBillingService = teamBillingServiceFactory.init(team); const subscriptionStatus = await teamBillingService.getSubscriptionStatus(); - if (subscriptionStatus !== "active" && subscriptionStatus !== "past_due") { + if ( + subscriptionStatus !== SubscriptionStatus.ACTIVE && + subscriptionStatus !== SubscriptionStatus.PAST_DUE + ) { return 0; } @@ -672,8 +675,7 @@ export class CreditService { return activeMembers * creditsPerSeat; } - const { StripeBillingService } = await import("./stripe-billing-service"); - const billing = new StripeBillingService(); + const billingService = getBillingProviderService(); const priceId = process.env.STRIPE_TEAM_MONTHLY_PRICE_ID; if (!priceId) { @@ -681,7 +683,7 @@ export class CreditService { return 0; } - const monthlyPrice = await billing.getPrice(priceId); + const monthlyPrice = await billingService.getPrice(priceId); if (!monthlyPrice) { log.warn("Failed to retrieve monthly price", { teamId, priceId }); return 0; diff --git a/packages/features/ee/billing/di/containers/Billing.ts b/packages/features/ee/billing/di/containers/Billing.ts new file mode 100644 index 0000000000..9d10438102 --- /dev/null +++ b/packages/features/ee/billing/di/containers/Billing.ts @@ -0,0 +1,26 @@ +import { createContainer } from "@calcom/features/di/di"; + +import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository"; +import type { StripeBillingService } from "../../service/billingProvider/StripeBillingService"; +import type { TeamBillingServiceFactory } from "../../service/teams/TeamBillingServiceFactory"; +import { billingProviderServiceModuleLoader } from "../modules/BillingProviderService"; +import { teamBillingServiceFactoryModuleLoader } from "../modules/TeamBillingServiceFactory"; +import { DI_TOKENS } from "../tokens"; + +const billingContainer = createContainer(); + +// Load all modules (dependencies are loaded recursively) +teamBillingServiceFactoryModuleLoader.loadModule(billingContainer); +billingProviderServiceModuleLoader.loadModule(billingContainer); + +export function getTeamBillingServiceFactory(): TeamBillingServiceFactory { + return billingContainer.get(DI_TOKENS.TEAM_BILLING_SERVICE_FACTORY); +} + +export function getBillingProviderService(): StripeBillingService { + return billingContainer.get(DI_TOKENS.BILLING_PROVIDER_SERVICE); +} + +export function getTeamBillingDataRepository(): ITeamBillingDataRepository { + return billingContainer.get(DI_TOKENS.TEAM_BILLING_DATA_REPOSITORY); +} diff --git a/packages/features/ee/billing/di/modules/BillingProviderService.ts b/packages/features/ee/billing/di/modules/BillingProviderService.ts new file mode 100644 index 0000000000..cd02daddd5 --- /dev/null +++ b/packages/features/ee/billing/di/modules/BillingProviderService.ts @@ -0,0 +1,20 @@ +import { type Container, createModule, ModuleLoader } from "@calcom/features/di/di"; + +import { StripeBillingService } from "../../service/billingProvider/StripeBillingService"; +import { DI_TOKENS } from "../tokens"; +import { stripeClientModuleLoader } from "./StripeClient"; + +const billingProviderServiceModule = createModule(); +const token = DI_TOKENS.BILLING_PROVIDER_SERVICE; +billingProviderServiceModule.bind(token).toClass(StripeBillingService, [DI_TOKENS.STRIPE_CLIENT]); + +export const billingProviderServiceModuleLoader: ModuleLoader = { + token, + loadModule: (container: Container) => { + // Load dependency first + stripeClientModuleLoader.loadModule(container); + + // Then load this module + container.load(DI_TOKENS.BILLING_PROVIDER_SERVICE_MODULE, billingProviderServiceModule); + }, +}; diff --git a/packages/features/ee/billing/di/modules/BillingRepositoryFactory.ts b/packages/features/ee/billing/di/modules/BillingRepositoryFactory.ts new file mode 100644 index 0000000000..a8482317da --- /dev/null +++ b/packages/features/ee/billing/di/modules/BillingRepositoryFactory.ts @@ -0,0 +1,43 @@ +import { type Container, createModule, ModuleLoader, type ResolveFunction } from "@calcom/features/di/di"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; +import { DI_TOKENS as GLOBAL_DI_TOKENS } from "@calcom/features/di/tokens"; +import type { PrismaClient } from "@calcom/prisma"; + +import { IBillingRepository } from "../../repository/billing/IBillingRepository"; +import { PrismaOrganizationBillingRepository } from "../../repository/billing/PrismaOrganizationBillingRepository"; +import { PrismaTeamBillingRepository } from "../../repository/billing/PrismaTeamBillingRepository"; +import { StubBillingRepository } from "../../repository/billing/StubBillingRepository"; +import { DI_TOKENS } from "../tokens"; +import { isTeamBillingEnabledModuleLoader } from "./IsTeamBillingEnabled"; + +const billingRepositoryFactoryModule = createModule(); +const token = DI_TOKENS.BILLING_REPOSITORY_FACTORY; +billingRepositoryFactoryModule.bind(token).toFactory((resolve: ResolveFunction) => { + const isTeamBillingEnabled = resolve(DI_TOKENS.IS_TEAM_BILLING_ENABLED); + + return (isOrganization: boolean): IBillingRepository => { + if (!isTeamBillingEnabled) { + return new StubBillingRepository(); + } + + const prisma = resolve(GLOBAL_DI_TOKENS.PRISMA_CLIENT) as PrismaClient; + + if (isOrganization) { + return new PrismaOrganizationBillingRepository(prisma); + } + + return new PrismaTeamBillingRepository(prisma); + }; +}); + +export const billingRepositoryFactoryModuleLoader: ModuleLoader = { + token, + loadModule: (container: Container) => { + // Load dependencies first + prismaModuleLoader.loadModule(container); + isTeamBillingEnabledModuleLoader.loadModule(container); + + // Then load this module + container.load(DI_TOKENS.BILLING_REPOSITORY_FACTORY_MODULE, billingRepositoryFactoryModule); + }, +}; diff --git a/packages/features/ee/billing/di/modules/IsTeamBillingEnabled.ts b/packages/features/ee/billing/di/modules/IsTeamBillingEnabled.ts new file mode 100644 index 0000000000..5696a3643f --- /dev/null +++ b/packages/features/ee/billing/di/modules/IsTeamBillingEnabled.ts @@ -0,0 +1,17 @@ +import { type Container, createModule, ModuleLoader } from "@calcom/features/di/di"; +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; + +import { DI_TOKENS } from "../tokens"; + +const isTeamBillingEnabledModule = createModule(); +const token = DI_TOKENS.IS_TEAM_BILLING_ENABLED; +isTeamBillingEnabledModule.bind(token).toFactory(() => { + return IS_TEAM_BILLING_ENABLED; +}); + +export const isTeamBillingEnabledModuleLoader: ModuleLoader = { + token, + loadModule: function (container: Container) { + container.load(token, isTeamBillingEnabledModule); + }, +}; diff --git a/packages/features/ee/billing/di/modules/StripeClient.ts b/packages/features/ee/billing/di/modules/StripeClient.ts new file mode 100644 index 0000000000..41a70d454a --- /dev/null +++ b/packages/features/ee/billing/di/modules/StripeClient.ts @@ -0,0 +1,24 @@ +import Stripe from "stripe"; + +import { type Container, createModule, ModuleLoader } from "@calcom/features/di/di"; + +import { DI_TOKENS } from "../tokens"; + +export const stripeClientModule = createModule(); +const token = DI_TOKENS.STRIPE_CLIENT; +stripeClientModule.bind(token).toFactory(() => { + if (!process.env.STRIPE_PRIVATE_KEY) { + throw new Error("STRIPE_PRIVATE_KEY is not set"); + } + + return new Stripe(process.env.STRIPE_PRIVATE_KEY!, { + apiVersion: "2020-08-27", + }); +}); + +export const stripeClientModuleLoader: ModuleLoader = { + token, + loadModule: function (container: Container) { + container.load(token, stripeClientModule); + }, +}; diff --git a/packages/features/ee/billing/di/modules/TeamBillingDataRepositoryFactory.ts b/packages/features/ee/billing/di/modules/TeamBillingDataRepositoryFactory.ts new file mode 100644 index 0000000000..4ad0c39aa0 --- /dev/null +++ b/packages/features/ee/billing/di/modules/TeamBillingDataRepositoryFactory.ts @@ -0,0 +1,34 @@ +import { type Container, createModule, ModuleLoader, type ResolveFunction } from "@calcom/features/di/di"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; +import { DI_TOKENS as GLOBAL_DI_TOKENS } from "@calcom/features/di/tokens"; +import type { PrismaClient } from "@calcom/prisma"; + +import { PrismaTeamBillingDataRepository } from "../../repository/teamBillingData/PrismaTeamBillingRepository"; +import { StubTeamBillingDataRepository } from "../../repository/teamBillingData/StubTeamBillingRepository"; +import { DI_TOKENS } from "../tokens"; +import { isTeamBillingEnabledModuleLoader } from "./IsTeamBillingEnabled"; + +const teamBillingDataRepositoryFactoryModule = createModule(); +const token = DI_TOKENS.TEAM_BILLING_DATA_REPOSITORY; +teamBillingDataRepositoryFactoryModule.bind(token).toFactory((resolve: ResolveFunction) => { + const isTeamBillingEnabled = resolve(DI_TOKENS.IS_TEAM_BILLING_ENABLED); + + if (!isTeamBillingEnabled) { + return new StubTeamBillingDataRepository(); + } + + const prisma = resolve(GLOBAL_DI_TOKENS.PRISMA_CLIENT) as PrismaClient; + return new PrismaTeamBillingDataRepository(prisma); +}); + +export const teamBillingDataRepositoryModuleLoader: ModuleLoader = { + token, + loadModule: (container: Container) => { + // Load dependencies first + prismaModuleLoader.loadModule(container); + isTeamBillingEnabledModuleLoader.loadModule(container); + + // Then load this module + container.load(DI_TOKENS.TEAM_BILLING_DATA_REPOSITORY_MODULE, teamBillingDataRepositoryFactoryModule); + }, +}; diff --git a/packages/features/ee/billing/di/modules/TeamBillingServiceFactory.ts b/packages/features/ee/billing/di/modules/TeamBillingServiceFactory.ts new file mode 100644 index 0000000000..bd0be0d618 --- /dev/null +++ b/packages/features/ee/billing/di/modules/TeamBillingServiceFactory.ts @@ -0,0 +1,29 @@ +import { createModule, ModuleLoader, bindModuleToClassOnToken } 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 { teamBillingDataRepositoryModuleLoader } from "./TeamBillingDataRepositoryFactory"; + +const teamBillingServiceFactoryModule = createModule(); +const token = DI_TOKENS.TEAM_BILLING_SERVICE_FACTORY; +const moduleToken = DI_TOKENS.TEAM_BILLING_SERVICE_FACTORY_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: teamBillingServiceFactoryModule, + moduleToken, + token, + classs: TeamBillingServiceFactory, + depsMap: { + billingProviderService: billingProviderServiceModuleLoader, + teamBillingDataRepository: teamBillingDataRepositoryModuleLoader, + billingRepositoryFactory: billingRepositoryFactoryModuleLoader, + isTeamBillingEnabled: isTeamBillingEnabledModuleLoader, + }, +}); + +export const teamBillingServiceFactoryModuleLoader: ModuleLoader = { + token: DI_TOKENS.TEAM_BILLING_SERVICE_FACTORY, + loadModule, +}; diff --git a/packages/features/ee/billing/di/tokens.ts b/packages/features/ee/billing/di/tokens.ts new file mode 100644 index 0000000000..af8888284c --- /dev/null +++ b/packages/features/ee/billing/di/tokens.ts @@ -0,0 +1,12 @@ +export const DI_TOKENS = { + STRIPE_CLIENT: Symbol("StripeClient"), + IS_TEAM_BILLING_ENABLED: Symbol("IsTeamBillingEnabled"), + BILLING_PROVIDER_SERVICE: Symbol("BillingProviderService"), + BILLING_PROVIDER_SERVICE_MODULE: Symbol("BillingProviderServiceModule"), + BILLING_REPOSITORY_FACTORY: Symbol("BillingRepositoryFactory"), + BILLING_REPOSITORY_FACTORY_MODULE: Symbol("BillingRepositoryFactoryModule"), + TEAM_BILLING_DATA_REPOSITORY: Symbol("TeamBillingDataRepository"), + TEAM_BILLING_DATA_REPOSITORY_MODULE: Symbol("TeamBillingDataRepositoryModule"), + TEAM_BILLING_SERVICE_FACTORY: Symbol("TeamBillingServiceFactory"), + TEAM_BILLING_SERVICE_FACTORY_MODULE: Symbol("TeamBillingServiceFactoryModule"), +}; diff --git a/packages/features/ee/billing/repository/IBillingRepository.ts b/packages/features/ee/billing/repository/billing/IBillingRepository.ts similarity index 100% rename from packages/features/ee/billing/repository/IBillingRepository.ts rename to packages/features/ee/billing/repository/billing/IBillingRepository.ts diff --git a/packages/features/ee/billing/repository/PrismaOrganizationBillingRepository.ts b/packages/features/ee/billing/repository/billing/PrismaOrganizationBillingRepository.ts similarity index 100% rename from packages/features/ee/billing/repository/PrismaOrganizationBillingRepository.ts rename to packages/features/ee/billing/repository/billing/PrismaOrganizationBillingRepository.ts diff --git a/packages/features/ee/billing/repository/PrismaTeamBillingRepository.ts b/packages/features/ee/billing/repository/billing/PrismaTeamBillingRepository.ts similarity index 100% rename from packages/features/ee/billing/repository/PrismaTeamBillingRepository.ts rename to packages/features/ee/billing/repository/billing/PrismaTeamBillingRepository.ts diff --git a/packages/features/ee/billing/repository/billing/StubBillingRepository.ts b/packages/features/ee/billing/repository/billing/StubBillingRepository.ts new file mode 100644 index 0000000000..c77d4a280e --- /dev/null +++ b/packages/features/ee/billing/repository/billing/StubBillingRepository.ts @@ -0,0 +1,16 @@ +import type { BillingRecord, IBillingRepository, IBillingRepositoryCreateArgs } from "./IBillingRepository"; + +export class StubBillingRepository implements IBillingRepository { + async create(args: IBillingRepositoryCreateArgs): Promise { + // Stub implementation - returns a mock billing record without database interaction + return { + id: "stub-billing-id", + teamId: args.teamId, + subscriptionId: args.subscriptionId, + subscriptionItemId: args.subscriptionItemId, + customerId: args.customerId, + planName: args.planName, + status: args.status, + }; + } +} diff --git a/packages/features/ee/billing/repository/billingRepositoryFactory.test.ts b/packages/features/ee/billing/repository/billingRepositoryFactory.test.ts deleted file mode 100644 index b16251d049..0000000000 --- a/packages/features/ee/billing/repository/billingRepositoryFactory.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, it, expect } from "vitest"; - -import { PrismaOrganizationBillingRepository } from "./PrismaOrganizationBillingRepository"; -import { PrismaTeamBillingRepository } from "./PrismaTeamBillingRepository"; -import { BillingRepositoryFactory } from "./billingRepositoryFactory"; - -describe("BillingRepositoryFactory", () => { - describe("getRepository", () => { - it("should return PrismaOrganizationBillingRepository when isOrganization is true", () => { - const repository = BillingRepositoryFactory.getRepository(true); - - expect(repository).toBeInstanceOf(PrismaOrganizationBillingRepository); - }); - - it("should return PrismaTeamBillingRepository when isOrganization is false", () => { - const repository = BillingRepositoryFactory.getRepository(false); - - expect(repository).toBeInstanceOf(PrismaTeamBillingRepository); - }); - - it("should return same repository type for multiple calls with same parameter", () => { - const repository1 = BillingRepositoryFactory.getRepository(true); - const repository2 = BillingRepositoryFactory.getRepository(true); - - expect(repository1).toBeInstanceOf(PrismaOrganizationBillingRepository); - expect(repository2).toBeInstanceOf(PrismaOrganizationBillingRepository); - }); - - it("should return different repository types for different parameters", () => { - const orgRepository = BillingRepositoryFactory.getRepository(true); - const teamRepository = BillingRepositoryFactory.getRepository(false); - - expect(orgRepository).toBeInstanceOf(PrismaOrganizationBillingRepository); - expect(teamRepository).toBeInstanceOf(PrismaTeamBillingRepository); - expect(orgRepository).not.toBeInstanceOf(PrismaTeamBillingRepository); - expect(teamRepository).not.toBeInstanceOf(PrismaOrganizationBillingRepository); - }); - }); -}); diff --git a/packages/features/ee/billing/repository/billingRepositoryFactory.ts b/packages/features/ee/billing/repository/billingRepositoryFactory.ts deleted file mode 100644 index 94cdccbdf3..0000000000 --- a/packages/features/ee/billing/repository/billingRepositoryFactory.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { prisma } from "@calcom/prisma"; - -import { PrismaOrganizationBillingRepository } from "./PrismaOrganizationBillingRepository"; -import { PrismaTeamBillingRepository } from "./PrismaTeamBillingRepository"; - -export class BillingRepositoryFactory { - static getRepository(isOrganization: boolean) { - if (isOrganization) { - return new PrismaOrganizationBillingRepository(prisma); - } - return new PrismaTeamBillingRepository(prisma); - } -} diff --git a/packages/features/ee/billing/teams/team-billing.repository.interface.ts b/packages/features/ee/billing/repository/teamBillingData/ITeamBillingDataRepository.ts similarity index 85% rename from packages/features/ee/billing/teams/team-billing.repository.interface.ts rename to packages/features/ee/billing/repository/teamBillingData/ITeamBillingDataRepository.ts index 82a29a0009..836f10d569 100644 --- a/packages/features/ee/billing/teams/team-billing.repository.interface.ts +++ b/packages/features/ee/billing/repository/teamBillingData/ITeamBillingDataRepository.ts @@ -5,14 +5,15 @@ export const teamBillingSelect = { metadata: true, isOrganization: true, parentId: true, + name: true, } satisfies Prisma.TeamSelect; export type TeamBillingType = Prisma.TeamGetPayload<{ select: typeof teamBillingSelect; }>; -export interface ITeamBillingRepository { +export interface ITeamBillingDataRepository { find(teamId: number): Promise; - findBySubscriptionId(subscriptionId: string): Promise; + findBySubscriptionId(subscriptionId: string): Promise; findMany(teamIds: number[]): Promise; } diff --git a/packages/features/ee/billing/repository/teamBillingData/PrismaTeamBillingRepository.ts b/packages/features/ee/billing/repository/teamBillingData/PrismaTeamBillingRepository.ts new file mode 100644 index 0000000000..fadebc9607 --- /dev/null +++ b/packages/features/ee/billing/repository/teamBillingData/PrismaTeamBillingRepository.ts @@ -0,0 +1,29 @@ +import type { PrismaClient } from "@calcom/prisma"; + +import type { ITeamBillingDataRepository } from "./ITeamBillingDataRepository"; +import { teamBillingSelect } from "./ITeamBillingDataRepository"; + +export class PrismaTeamBillingDataRepository implements ITeamBillingDataRepository { + constructor(private prisma: PrismaClient) {} + + /** Fetch a single team with minimal data needed for billing */ + async find(teamId: number) { + return this.prisma.team.findUniqueOrThrow({ where: { id: teamId }, select: teamBillingSelect }); + } + /** Fetch a single team with minimal data needed for billing */ + async findBySubscriptionId(subscriptionId: string) { + return this.prisma.team.findFirst({ + where: { + metadata: { + path: ["subscriptionId"], + equals: subscriptionId, + }, + }, + select: teamBillingSelect, + }); + } + /** Fetch multiple teams with minimal data needed for billing */ + async findMany(teamIds: number[]) { + return this.prisma.team.findMany({ where: { id: { in: teamIds } }, select: teamBillingSelect }); + } +} diff --git a/packages/features/ee/billing/repository/teamBillingData/StubTeamBillingRepository.ts b/packages/features/ee/billing/repository/teamBillingData/StubTeamBillingRepository.ts new file mode 100644 index 0000000000..518c4e6cf9 --- /dev/null +++ b/packages/features/ee/billing/repository/teamBillingData/StubTeamBillingRepository.ts @@ -0,0 +1,17 @@ +import { ITeamBillingDataRepository, TeamBillingType } from "./ITeamBillingDataRepository"; + +export class StubTeamBillingDataRepository implements ITeamBillingDataRepository { + stubTeam = { id: -1, metadata: {}, isOrganization: true, parentId: -1, name: "" }; + + async find() { + return this.stubTeam; + } + + async findBySubscriptionId(): Promise { + return this.stubTeam; + } + + async findMany(): Promise { + return []; + } +} diff --git a/packages/features/ee/billing/billing-service.ts b/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts similarity index 90% rename from packages/features/ee/billing/billing-service.ts rename to packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts index a3730ad30d..6b2f0eb0ff 100644 --- a/packages/features/ee/billing/billing-service.ts +++ b/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts @@ -1,6 +1,8 @@ import type Stripe from "stripe"; -export interface BillingService { +import { SubscriptionStatus } from "../../repository/billing/IBillingRepository"; + +export interface IBillingProviderService { checkoutSessionIsPaid(paymentId: string): Promise; handleSubscriptionCancel(subscriptionId: string): Promise; handleSubscriptionCreation(subscriptionId: string): Promise; @@ -57,7 +59,7 @@ export interface BillingService { metadata?: Record; }): Promise<{ priceId: string }>; getPrice(priceId: string): Promise; - getSubscriptionStatus(subscriptionId: string): Promise; + getSubscriptionStatus(subscriptionId: string): Promise; getCheckoutSession(checkoutSessionId: string): Promise; getCustomer(customerId: string): Promise; diff --git a/packages/features/ee/billing/stripe-billing-service.test.ts b/packages/features/ee/billing/service/billingProvider/StripeBillingService.test.ts similarity index 61% rename from packages/features/ee/billing/stripe-billing-service.test.ts rename to packages/features/ee/billing/service/billingProvider/StripeBillingService.test.ts index 5d2f85630d..60acf3689c 100644 --- a/packages/features/ee/billing/stripe-billing-service.test.ts +++ b/packages/features/ee/billing/service/billingProvider/StripeBillingService.test.ts @@ -1,44 +1,32 @@ +import type Stripe from "stripe"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { StripeBillingService } from "./stripe-billing-service"; - -const mockStripe = vi.hoisted(() => ({ - subscriptions: { - cancel: vi.fn(), - retrieve: vi.fn(), - update: vi.fn(), - }, - checkout: { - sessions: { - retrieve: vi.fn(), - }, - }, - customers: { - create: vi.fn(), - }, - paymentIntents: { - create: vi.fn(), - }, - prices: { - retrieve: vi.fn(), - }, -})); - -vi.mock("@calcom/features/ee/payments/server/stripe", () => ({ - default: mockStripe, -})); +import { StripeBillingService } from "./StripeBillingService"; describe("StripeBillingService", () => { let stripeBillingService: StripeBillingService; + let stripeMock: Partial; beforeEach(() => { - stripeBillingService = new StripeBillingService(); + stripeMock = { + subscriptions: { + cancel: vi.fn(), + retrieve: vi.fn(), + update: vi.fn(), + } as Partial, + checkout: { + sessions: { + retrieve: vi.fn(), + } as Partial, + } as Partial, + }; + stripeBillingService = new StripeBillingService(stripeMock as Stripe); }); it("should cancel a subscription", async () => { const subscriptionId = "sub_123"; await stripeBillingService.handleSubscriptionCancel(subscriptionId); - expect(mockStripe.subscriptions.cancel).toHaveBeenCalledWith(subscriptionId); + expect(stripeMock.subscriptions.cancel).toHaveBeenCalledWith(subscriptionId); }); it("should update a subscription", async () => { @@ -47,14 +35,14 @@ describe("StripeBillingService", () => { subscriptionItemId: "item_123", membershipCount: 5, }; - mockStripe.subscriptions.retrieve.mockResolvedValue({ + stripeMock.subscriptions.retrieve.mockResolvedValue({ items: { data: [{ id: "item_123", quantity: 3 }], }, }); await stripeBillingService.handleSubscriptionUpdate(args); - expect(mockStripe.subscriptions.retrieve).toHaveBeenCalledWith(args.subscriptionId); - expect(mockStripe.subscriptions.update).toHaveBeenCalledWith(args.subscriptionId, { + expect(stripeMock.subscriptions.retrieve).toHaveBeenCalledWith(args.subscriptionId); + expect(stripeMock.subscriptions.update).toHaveBeenCalledWith(args.subscriptionId, { items: [{ quantity: args.membershipCount, id: args.subscriptionItemId }], }); }); @@ -65,7 +53,7 @@ describe("StripeBillingService", () => { subscriptionItemId: "item_123", membershipCount: 5, }; - mockStripe.subscriptions.retrieve.mockResolvedValue({ + stripeMock.subscriptions.retrieve.mockResolvedValue({ items: { data: [], }, @@ -77,21 +65,21 @@ describe("StripeBillingService", () => { it("should return true if checkout session is paid", async () => { const paymentId = "pay_123"; - mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + stripeMock.checkout.sessions.retrieve.mockResolvedValue({ payment_status: "paid", }); const result = await stripeBillingService.checkoutSessionIsPaid(paymentId); expect(result).toBe(true); - expect(mockStripe.checkout.sessions.retrieve).toHaveBeenCalledWith(paymentId); + expect(stripeMock.checkout.sessions.retrieve).toHaveBeenCalledWith(paymentId); }); it("should return false if checkout session is not paid", async () => { const paymentId = "pay_123"; - mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + stripeMock.checkout.sessions.retrieve.mockResolvedValue({ payment_status: "unpaid", }); const result = await stripeBillingService.checkoutSessionIsPaid(paymentId); expect(result).toBe(false); - expect(mockStripe.checkout.sessions.retrieve).toHaveBeenCalledWith(paymentId); + expect(stripeMock.checkout.sessions.retrieve).toHaveBeenCalledWith(paymentId); }); }); diff --git a/packages/features/ee/billing/stripe-billing-service.ts b/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts similarity index 85% rename from packages/features/ee/billing/stripe-billing-service.ts rename to packages/features/ee/billing/service/billingProvider/StripeBillingService.ts index 8fa55a328d..5c1d2e44e1 100644 --- a/packages/features/ee/billing/stripe-billing-service.ts +++ b/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts @@ -1,18 +1,14 @@ import type Stripe from "stripe"; -import stripe from "@calcom/features/ee/payments/server/stripe"; import logger from "@calcom/lib/logger"; -import type { BillingService } from "./billing-service"; -import { SubscriptionStatus } from "./repository/IBillingRepository"; +import { SubscriptionStatus } from "../../repository/billing/IBillingRepository"; +import type { IBillingProviderService } from "./IBillingProviderService"; -export class StripeBillingService implements BillingService { - private stripe: Stripe; - constructor() { - this.stripe = stripe; - } +export class StripeBillingService implements IBillingProviderService { + constructor(private stripe: Stripe) {} - async createCustomer(args: Parameters[0]) { + async createCustomer(args: Parameters[0]) { const { email, metadata } = args; const customer = await this.stripe.customers.create({ email, @@ -24,7 +20,7 @@ export class StripeBillingService implements BillingService { return { stripeCustomerId: customer.id }; } - async createPaymentIntent(args: Parameters[0]) { + async createPaymentIntent(args: Parameters[0]) { const { customerId, amount, metadata } = args; const paymentIntent = await this.stripe.paymentIntents.create({ customer: customerId, @@ -69,7 +65,9 @@ export class StripeBillingService implements BillingService { }; } - async createSubscriptionCheckout(args: Parameters[0]) { + async createSubscriptionCheckout( + args: Parameters[0] + ) { const { customerId, successUrl, @@ -110,7 +108,7 @@ export class StripeBillingService implements BillingService { }; } - async createPrice(args: Parameters[0]) { + async createPrice(args: Parameters[0]) { const { amount, currency, interval, productId, nickname, metadata } = args; const price = await this.stripe.prices.create({ @@ -137,7 +135,7 @@ export class StripeBillingService implements BillingService { await this.stripe.subscriptions.cancel(subscriptionId); } - async handleSubscriptionUpdate(args: Parameters[0]) { + async handleSubscriptionUpdate(args: Parameters[0]) { const { subscriptionId, subscriptionItemId, membershipCount } = args; const subscription = await this.stripe.subscriptions.retrieve(subscriptionId); const subscriptionQuantity = subscription.items.data.find( @@ -169,7 +167,10 @@ export class StripeBillingService implements BillingService { const subscription = await this.stripe.subscriptions.retrieve(subscriptionId); if (!subscription || !subscription.status) return null; - return subscription.status; + return this.mapStripeStatusToCalStatus({ + stripeStatus: subscription.status, + subscriptionId, + }); } async getCheckoutSession(checkoutSessionId: string) { @@ -187,7 +188,7 @@ export class StripeBillingService implements BillingService { return subscriptions.data; } - async updateCustomer(args: Parameters[0]) { + async updateCustomer(args: Parameters[0]) { const { customerId, email, userId } = args; const metadata: { email?: string; userId?: number } = {}; if (email) metadata.email = email; @@ -200,7 +201,7 @@ export class StripeBillingService implements BillingService { return price; } - static extractSubscriptionDates(subscription: { + extractSubscriptionDates(subscription: { start_date: number; trial_end?: number | null; cancel_at?: number | null; @@ -213,13 +214,13 @@ export class StripeBillingService implements BillingService { return { subscriptionStart, subscriptionTrialEnd, subscriptionEnd }; } - static mapStripeStatusToCalStatus = ({ + mapStripeStatusToCalStatus({ stripeStatus, subscriptionId, }: { stripeStatus: string; subscriptionId: string; - }) => { + }) { const log = logger.getSubLogger({ prefix: ["mapStripeStatusToCalStatus"] }); const statusMap: Record = { active: SubscriptionStatus.ACTIVE, @@ -239,5 +240,5 @@ export class StripeBillingService implements BillingService { } return status || SubscriptionStatus.ACTIVE; - }; + } } diff --git a/packages/features/ee/billing/teams/team-billing.ts b/packages/features/ee/billing/service/teams/ITeamBillingService.ts similarity index 66% rename from packages/features/ee/billing/teams/team-billing.ts rename to packages/features/ee/billing/service/teams/ITeamBillingService.ts index 39502ae970..103d5cc853 100644 --- a/packages/features/ee/billing/teams/team-billing.ts +++ b/packages/features/ee/billing/service/teams/ITeamBillingService.ts @@ -1,5 +1,10 @@ import type { Team } from "@calcom/prisma/client"; +import { + SubscriptionStatus, + IBillingRepositoryCreateArgs, +} from "../../repository/billing/IBillingRepository"; + export type TeamBillingInput = Pick; export const TeamBillingPublishResponseStatus = { REQUIRES_PAYMENT: "REQUIRES_PAYMENT", @@ -12,9 +17,12 @@ export type TeamBillingPublishResponse = { status: (typeof TeamBillingPublishResponseStatus)[keyof typeof TeamBillingPublishResponseStatus]; }; -export interface TeamBilling { +export interface ITeamBillingService { cancel(): Promise; publish(): Promise; downgrade(): Promise; updateQuantity(): 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 new file mode 100644 index 0000000000..cb614a6e71 --- /dev/null +++ b/packages/features/ee/billing/service/teams/StubTeamBillingService.ts @@ -0,0 +1,39 @@ +import { TeamBillingPublishResponseStatus } from "./ITeamBillingService"; +import type { + ITeamBillingService, + TeamBillingInput, + TeamBillingPublishResponse, +} from "./ITeamBillingService"; + +export class StubTeamBillingService implements ITeamBillingService { + constructor(private team: TeamBillingInput) {} + + async cancel(): Promise { + // Stub implementation - no-op + } + + async publish(): Promise { + return { + redirectUrl: null, + status: TeamBillingPublishResponseStatus.SUCCESS, + }; + } + + async downgrade(): Promise { + // Stub implementation - no-op + } + + async updateQuantity(): Promise { + // Stub implementation - no-op + } + + async getSubscriptionStatus() { + return null; + } + + async endTrial() { + return true; + } + + async saveTeamBilling() {} +} diff --git a/packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts b/packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts new file mode 100644 index 0000000000..8a7b7c596b --- /dev/null +++ b/packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts @@ -0,0 +1,134 @@ +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 { StubTeamBillingService } from "./StubTeamBillingService"; +import { TeamBillingService } from "./TeamBillingService"; +import { TeamBillingServiceFactory } from "./TeamBillingServiceFactory"; + +describe("TeamBilling", () => { + const mockTeam = { id: 1, metadata: null, isOrganization: true, parentId: null, name: "" }; + const mockTeams = [mockTeam, { id: 2, metadata: null, isOrganization: false, parentId: 1, name: "" }]; + + let mockBillingProviderService: IBillingProviderService; + let mockTeamBillingDataRepository: ITeamBillingDataRepository; + let mockBillingRepository: IBillingRepository; + let factory: TeamBillingServiceFactory; + + const createMockBillingProviderService = (): IBillingProviderService => ({ + handleSubscriptionCancel: vi.fn(), + handleSubscriptionUpdate: vi.fn(), + checkoutSessionIsPaid: vi.fn(), + getSubscriptionStatus: vi.fn(), + handleEndTrial: vi.fn(), + createCustomer: vi.fn(), + createPrice: vi.fn(), + getPrice: vi.fn(), + getCheckoutSession: vi.fn(), + createCheckoutSession: vi.fn(), + }); + + const createMockTeamBillingDataRepository = (): ITeamBillingDataRepository => ({ + find: vi.fn(), + findMany: vi.fn(), + }); + + const createMockBillingRepository = (): IBillingRepository => ({ + create: vi.fn(), + }); + + beforeEach(() => { + vi.resetAllMocks(); + mockBillingProviderService = createMockBillingProviderService(); + mockTeamBillingDataRepository = createMockTeamBillingDataRepository(); + mockBillingRepository = createMockBillingRepository(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe("init", () => { + it("should return TeamBillingService when team billing is enabled", () => { + factory = new TeamBillingServiceFactory({ + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepositoryFactory: () => mockBillingRepository, + isTeamBillingEnabled: true, + }); + + const result = factory.init(mockTeam); + + expect(result).toBeInstanceOf(TeamBillingService); + }); + + it("should return StubTeamBillingService when team billing is disabled", () => { + factory = new TeamBillingServiceFactory({ + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepositoryFactory: () => mockBillingRepository, + isTeamBillingEnabled: false, + }); + + const result = factory.init(mockTeam); + + expect(result).toBeInstanceOf(StubTeamBillingService); + }); + }); + + describe("initMany", () => { + it("should initialize multiple TeamBillingServices", () => { + factory = new TeamBillingServiceFactory({ + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepositoryFactory: () => mockBillingRepository, + isTeamBillingEnabled: false, + }); + + const result = factory.initMany(mockTeams); + + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(StubTeamBillingService); + expect(result[1]).toBeInstanceOf(StubTeamBillingService); + }); + }); + + describe("findAndInit", () => { + it("should find and initialize a single TeamBillingService", async () => { + factory = new TeamBillingServiceFactory({ + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepositoryFactory: () => mockBillingRepository, + isTeamBillingEnabled: true, + }); + + vi.mocked(mockTeamBillingDataRepository.find).mockResolvedValue(mockTeam); + + const result = await factory.findAndInit(1); + + expect(mockTeamBillingDataRepository.find).toHaveBeenCalledWith(1); + expect(result).toBeInstanceOf(TeamBillingService); + }); + }); + + describe("findAndInitMany", () => { + it("should find and initialize multiple team billings", async () => { + factory = new TeamBillingServiceFactory({ + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepositoryFactory: () => mockBillingRepository, + isTeamBillingEnabled: true, + }); + + vi.mocked(mockTeamBillingDataRepository.findMany).mockResolvedValue([mockTeam, { ...mockTeam, id: 2 }]); + + const result = await factory.findAndInitMany([1, 2]); + + expect(mockTeamBillingDataRepository.findMany).toHaveBeenCalledWith([1, 2]); + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(TeamBillingService); + expect(result[1]).toBeInstanceOf(TeamBillingService); + }); + }); +}); diff --git a/packages/features/ee/billing/service/teams/TeamBillingService.test.ts b/packages/features/ee/billing/service/teams/TeamBillingService.test.ts new file mode 100644 index 0000000000..b450d052c4 --- /dev/null +++ b/packages/features/ee/billing/service/teams/TeamBillingService.test.ts @@ -0,0 +1,409 @@ +import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock"; + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments"; +import { WEBAPP_URL } from "@calcom/lib/constants"; + +import type { IBillingRepository } from "../../repository/billing/IBillingRepository"; +import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository"; +import type { IBillingProviderService } from "../billingProvider/IBillingProviderService"; +import { TeamBillingPublishResponseStatus } from "./ITeamBillingService"; +import { TeamBillingService } from "./TeamBillingService"; + +vi.mock("@calcom/lib/constants", async () => { + const actual = await vi.importActual("@calcom/lib/constants"); + return { + ...actual, + WEBAPP_URL: "http://localhost:3000", + }; +}); + +vi.mock("@calcom/features/ee/teams/lib/payments", () => ({ + purchaseTeamOrOrgSubscription: vi.fn(), +})); + +const mockTeam = { + id: 1, + metadata: { + subscriptionId: "sub_123", + subscriptionItemId: "si_456", + paymentId: "cs_789", + }, + isOrganization: true, + parentId: null, +}; + +const createMockBillingProviderService = (): IBillingProviderService => ({ + handleSubscriptionCancel: vi.fn(), + handleSubscriptionUpdate: vi.fn(), + checkoutSessionIsPaid: vi.fn(), + getSubscriptionStatus: vi.fn(), + handleEndTrial: vi.fn(), + createCustomer: vi.fn(), + createPrice: vi.fn(), + getPrice: vi.fn(), + getCheckoutSession: vi.fn(), + createCheckoutSession: vi.fn(), +}); + +const createMockTeamBillingDataRepository = (): ITeamBillingDataRepository => ({ + find: vi.fn(), +}); + +const createMockBillingRepository = (): IBillingRepository => ({ + create: vi.fn(), +}); + +describe("TeamBillingService", () => { + let mockBillingProviderService: IBillingProviderService; + let mockTeamBillingDataRepository: ITeamBillingDataRepository; + let mockBillingRepository: IBillingRepository; + + beforeEach(() => { + vi.resetAllMocks(); + mockBillingProviderService = createMockBillingProviderService(); + mockTeamBillingDataRepository = createMockTeamBillingDataRepository(); + mockBillingRepository = createMockBillingRepository(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("cancel", () => { + it("should cancel the subscription and downgrade the team", async () => { + const teamBillingService = new TeamBillingService({ + team: mockTeam, + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepository: mockBillingRepository, + }); + + await teamBillingService.cancel(); + + expect(mockBillingProviderService.handleSubscriptionCancel).toHaveBeenCalledWith("sub_123"); + expect(prismaMock.team.update).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { + metadata: {}, + }, + }); + }); + }); + + describe("publish", () => { + it("should create a checkout session and update the team", async () => { + const teamBillingService = new TeamBillingService({ + team: mockTeam, + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepository: mockBillingRepository, + }); + + vi.mocked(mockBillingProviderService.checkoutSessionIsPaid).mockResolvedValue(false); + vi.mocked(purchaseTeamOrOrgSubscription).mockResolvedValue({ + url: "http://checkout.url", + }); + prismaMock.membership.count.mockResolvedValue(5); + prismaMock.membership.findFirstOrThrow.mockResolvedValue({ userId: 123 }); + + const result = await teamBillingService.publish(); + expect(result).toEqual({ + redirectUrl: "http://checkout.url", + status: TeamBillingPublishResponseStatus.REQUIRES_PAYMENT, + }); + + expect(prismaMock.membership.count).toHaveBeenCalledWith({ where: { teamId: 1 } }); + expect(prismaMock.membership.findFirstOrThrow).toHaveBeenCalledWith({ + where: { teamId: 1, role: "OWNER" }, + select: { userId: true }, + }); + }); + it("should return upgrade url if upgrade is required", async () => { + const teamBillingService = new TeamBillingService({ + team: mockTeam, + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepository: mockBillingRepository, + }); + + const mockUrl = `${WEBAPP_URL}/api/teams/${mockTeam.id}/upgrade?session_id=cs_789`; + vi.spyOn(teamBillingService, "checkIfTeamPaymentRequired").mockResolvedValue({ + url: mockUrl, + paymentId: "cs_789", + paymentRequired: false, + }); + + const result = await teamBillingService.publish(); + + expect(result).toEqual({ + redirectUrl: mockUrl, + status: TeamBillingPublishResponseStatus.REQUIRES_UPGRADE, + }); + expect(teamBillingService.checkIfTeamPaymentRequired).toHaveBeenCalled(); + }); + }); + + describe("updateQuantity", () => { + it("should update the subscription quantity", async () => { + const mockTeamNotOrg = { + ...mockTeam, + isOrganization: false, + }; + const teamBillingService = new TeamBillingService({ + team: mockTeamNotOrg, + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepository: mockBillingRepository, + }); + + prismaMock.membership.count.mockResolvedValue(10); + vi.spyOn(teamBillingService, "checkIfTeamPaymentRequired").mockResolvedValue({ + url: "http://checkout.url", + paymentId: "cs_789", + paymentRequired: false, + }); + + await teamBillingService.updateQuantity(); + + expect(mockBillingProviderService.handleSubscriptionUpdate).toHaveBeenCalledWith({ + subscriptionId: "sub_123", + subscriptionItemId: "si_456", + membershipCount: 10, + }); + }); + + it("should not update if membership count is less than minimum for organizations", async () => { + const teamBillingService = new TeamBillingService({ + team: mockTeam, + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepository: mockBillingRepository, + }); + + prismaMock.membership.count.mockResolvedValue(2); + vi.spyOn(teamBillingService, "checkIfTeamPaymentRequired").mockResolvedValue({ + url: "http://checkout.url", + paymentId: "cs_789", + paymentRequired: false, + }); + + await teamBillingService.updateQuantity(); + + expect(mockBillingProviderService.handleSubscriptionUpdate).not.toHaveBeenCalled(); + }); + }); + + describe("checkIfTeamPaymentRequired", () => { + it("should return payment required if no paymentId", async () => { + const teamWithoutPaymentId = { + ...mockTeam, + metadata: { + ...mockTeam.metadata, + paymentId: undefined, + }, + }; + const teamBillingService = new TeamBillingService({ + team: teamWithoutPaymentId, + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepository: mockBillingRepository, + }); + + const result = await teamBillingService.checkIfTeamPaymentRequired(); + + expect(result).toEqual({ url: null, paymentId: null, paymentRequired: true }); + }); + + it("should return payment required if checkout session is not paid", async () => { + const teamBillingService = new TeamBillingService({ + team: mockTeam, + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepository: mockBillingRepository, + }); + + vi.mocked(mockBillingProviderService.checkoutSessionIsPaid).mockResolvedValue(false); + + const result = await teamBillingService.checkIfTeamPaymentRequired(); + + expect(result).toEqual({ url: null, paymentId: "cs_789", paymentRequired: true }); + }); + + it("should return upgrade URL if checkout session is paid", async () => { + const teamBillingService = new TeamBillingService({ + team: mockTeam, + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepository: mockBillingRepository, + }); + + vi.mocked(mockBillingProviderService.checkoutSessionIsPaid).mockResolvedValue(true); + const result = await teamBillingService.checkIfTeamPaymentRequired(); + + expect(result).toEqual({ + url: `${WEBAPP_URL}/api/teams/1/upgrade?session_id=cs_789`, + paymentId: "cs_789", + paymentRequired: false, + }); + }); + }); + + describe("saveTeamBilling", () => { + it("should delegate to billing repository when team is an organization", async () => { + const mockOrgTeam = { + id: 1, + metadata: {}, + isOrganization: true, + parentId: null, + }; + + const mockBillingArgs = { + teamId: 1, + subscriptionId: "sub_org_123", + subscriptionItemId: "si_org_123", + customerId: "cus_org_123", + planName: "ORGANIZATION" as const, + status: "ACTIVE" as const, + }; + + const mockCreatedRecord = { + id: "billing_org_123", + ...mockBillingArgs, + createdAt: new Date(), + updatedAt: new Date(), + }; + + vi.mocked(mockBillingRepository.create).mockResolvedValue(mockCreatedRecord); + + const teamBillingService = new TeamBillingService({ + team: mockOrgTeam, + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepository: mockBillingRepository, + }); + + await teamBillingService.saveTeamBilling(mockBillingArgs); + + expect(mockBillingRepository.create).toHaveBeenCalledWith(mockBillingArgs); + }); + + it("should delegate to billing repository when team is not an organization", async () => { + const mockRegularTeam = { + id: 2, + metadata: {}, + isOrganization: false, + parentId: null, + }; + + const mockBillingArgs = { + teamId: 2, + subscriptionId: "sub_team_456", + subscriptionItemId: "si_team_456", + customerId: "cus_team_456", + planName: "TEAM" as const, + status: "ACTIVE" as const, + }; + + const mockCreatedRecord = { + id: "billing_team_456", + ...mockBillingArgs, + createdAt: new Date(), + updatedAt: new Date(), + }; + + vi.mocked(mockBillingRepository.create).mockResolvedValue(mockCreatedRecord); + + const teamBillingService = new TeamBillingService({ + team: mockRegularTeam, + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepository: mockBillingRepository, + }); + + await teamBillingService.saveTeamBilling(mockBillingArgs); + + expect(mockBillingRepository.create).toHaveBeenCalledWith(mockBillingArgs); + }); + + it("should pass all billing arguments correctly to repository", async () => { + const mockTeam = { + id: 3, + metadata: {}, + isOrganization: false, + parentId: null, + }; + + const mockBillingArgs = { + teamId: 3, + subscriptionId: "sub_detailed_789", + subscriptionItemId: "si_detailed_789", + customerId: "cus_detailed_789", + planName: "ENTERPRISE" as const, + status: "TRIALING" as const, + }; + + const mockCreatedRecord = { + id: "billing_detailed_789", + ...mockBillingArgs, + createdAt: new Date(), + updatedAt: new Date(), + }; + + vi.mocked(mockBillingRepository.create).mockResolvedValue(mockCreatedRecord); + + const teamBillingService = new TeamBillingService({ + team: mockTeam, + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepository: mockBillingRepository, + }); + + await teamBillingService.saveTeamBilling(mockBillingArgs); + + expect(mockBillingRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + teamId: 3, + subscriptionId: "sub_detailed_789", + subscriptionItemId: "si_detailed_789", + customerId: "cus_detailed_789", + planName: "ENTERPRISE", + status: "TRIALING", + }) + ); + }); + + it("should propagate repository errors", async () => { + const mockTeam = { + id: 4, + metadata: {}, + isOrganization: false, + parentId: null, + }; + + const mockBillingArgs = { + teamId: 4, + subscriptionId: "sub_error_999", + subscriptionItemId: "si_error_999", + customerId: "cus_error_999", + planName: "TEAM" as const, + status: "ACTIVE" as const, + }; + + const repositoryError = new Error("Database constraint violation"); + vi.mocked(mockBillingRepository.create).mockRejectedValue(repositoryError); + + const teamBillingService = new TeamBillingService({ + team: mockTeam, + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepository: mockBillingRepository, + }); + + await expect(teamBillingService.saveTeamBilling(mockBillingArgs)).rejects.toThrow( + "Database constraint violation" + ); + }); + }); +}); diff --git a/packages/features/ee/billing/teams/internal-team-billing.ts b/packages/features/ee/billing/service/teams/TeamBillingService.ts similarity index 83% rename from packages/features/ee/billing/teams/internal-team-billing.ts rename to packages/features/ee/billing/service/teams/TeamBillingService.ts index 02adde6ae3..80942bc033 100644 --- a/packages/features/ee/billing/teams/internal-team-billing.ts +++ b/packages/features/ee/billing/service/teams/TeamBillingService.ts @@ -11,25 +11,46 @@ import { prisma } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils"; -import { IBillingRepository, IBillingRepositoryCreateArgs } from "../repository/IBillingRepository"; -import { BillingRepositoryFactory } from "../repository/billingRepositoryFactory"; -import { StripeBillingService } from "../stripe-billing-service"; -import { TeamBillingPublishResponseStatus, type TeamBilling, type TeamBillingInput } from "./team-billing"; +// import billing from "../.."; +import type { + IBillingRepository, + IBillingRepositoryCreateArgs, +} from "../../repository/billing/IBillingRepository"; +import { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository"; +import type { IBillingProviderService } from "../billingProvider/IBillingProviderService"; +import { + TeamBillingPublishResponseStatus, + type ITeamBillingService, + type TeamBillingInput, +} from "./ITeamBillingService"; const log = logger.getSubLogger({ prefix: ["TeamBilling"] }); const teamPaymentMetadataSchema = teamMetadataStrictSchema.unwrap(); -export class InternalTeamBilling implements TeamBilling { +export class TeamBillingService implements ITeamBillingService { private _team!: Omit & { metadata: NonNullable>; }; + private billingProviderService: IBillingProviderService; private billingRepository: IBillingRepository; - private billingService: StripeBillingService; - constructor(team: TeamBillingInput) { + private teamBillingDataRepository: ITeamBillingDataRepository; + + constructor({ + team, + billingProviderService, + teamBillingDataRepository, + billingRepository, + }: { + team: TeamBillingInput; + billingProviderService: IBillingProviderService; + teamBillingDataRepository: ITeamBillingDataRepository; + billingRepository: IBillingRepository; + }) { this.team = team; - this.billingRepository = BillingRepositoryFactory.getRepository(team.isOrganization); - this.billingService = new StripeBillingService(); + this.billingProviderService = billingProviderService; + this.teamBillingDataRepository = teamBillingDataRepository; + this.billingRepository = billingRepository; } set team(team: TeamBillingInput) { const metadata = teamPaymentMetadataSchema.parse(team.metadata || {}); @@ -40,10 +61,7 @@ export class InternalTeamBilling implements TeamBilling { } private async getOrgIfNeeded() { if (!this.team.parentId) return; - const parentTeam = await prisma.team.findUniqueOrThrow({ - where: { id: this.team.parentId }, - select: { metadata: true, id: true, parentId: true, isOrganization: true }, - }); + const parentTeam = await this.teamBillingDataRepository.find(this.team.parentId); this.team = parentTeam; } private logErrorFromUnknown(error: unknown) { @@ -56,7 +74,7 @@ export class InternalTeamBilling implements TeamBilling { const { subscriptionId } = this.team.metadata; log.info(`Cancelling subscription ${subscriptionId} for team ${this.team.id}`); if (!subscriptionId) throw Error("missing subscriptionId"); - await this.billingService.handleSubscriptionCancel(subscriptionId); + await this.billingProviderService.handleSubscriptionCancel(subscriptionId); await this.downgrade(); log.info(`Cancelled subscription ${subscriptionId} for team ${this.team.id}`); } catch (error) { @@ -156,7 +174,7 @@ export class InternalTeamBilling implements TeamBilling { } if (!subscriptionId) throw Error("missing subscriptionId"); if (!subscriptionItemId) throw Error("missing subscriptionItemId"); - await this.billingService.handleSubscriptionUpdate({ + await this.billingProviderService.handleSubscriptionUpdate({ subscriptionId, subscriptionItemId, membershipCount, @@ -172,7 +190,7 @@ export class InternalTeamBilling implements TeamBilling { /** If there's no paymentId, we need to pay this team */ if (!paymentId) return { url: null, paymentId: null, paymentRequired: true }; /** If there's a pending session but it isn't paid, we need to pay this team */ - const checkoutSessionIsPaid = await this.billingService.checkoutSessionIsPaid(paymentId); + const checkoutSessionIsPaid = await this.billingProviderService.checkoutSessionIsPaid(paymentId); if (!checkoutSessionIsPaid) return { url: null, paymentId, paymentRequired: true }; /** If the session is already paid we return the upgrade URL so team is updated. */ return { @@ -185,7 +203,7 @@ export class InternalTeamBilling implements TeamBilling { async getSubscriptionStatus() { const { subscriptionId } = this.team.metadata; if (!subscriptionId) return null; - return await this.billingService.getSubscriptionStatus(subscriptionId); + return this.billingProviderService.getSubscriptionStatus(subscriptionId); } /** @@ -203,7 +221,7 @@ export class InternalTeamBilling implements TeamBilling { } // End the trial by converting to regular subscription - await this.billingService.handleEndTrial(subscriptionId); + await this.billingProviderService.handleEndTrial(subscriptionId); log.info(`Successfully ended trial for team ${this.team.id}`); return true; } catch (error) { @@ -212,6 +230,6 @@ export class InternalTeamBilling implements TeamBilling { } } async saveTeamBilling(args: IBillingRepositoryCreateArgs) { - await this.billingRepository.create(args); +await this.billingRepository.create(args); } } diff --git a/packages/features/ee/billing/service/teams/TeamBillingServiceFactory.ts b/packages/features/ee/billing/service/teams/TeamBillingServiceFactory.ts new file mode 100644 index 0000000000..7d310bb671 --- /dev/null +++ b/packages/features/ee/billing/service/teams/TeamBillingServiceFactory.ts @@ -0,0 +1,53 @@ +import type { IBillingRepository } from "../../repository/billing/IBillingRepository"; +import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository"; +import type { IBillingProviderService } from "../billingProvider/IBillingProviderService"; +import type { ITeamBillingService, TeamBillingInput } from "./ITeamBillingService"; +import { StubTeamBillingService } from "./StubTeamBillingService"; +import { TeamBillingService } from "./TeamBillingService"; + +// Export the interface for type safety in DI modules +export interface ITeamBillingServiceFactoryDeps { + billingProviderService: IBillingProviderService; + teamBillingDataRepository: ITeamBillingDataRepository; + billingRepositoryFactory: (isOrganization: boolean) => IBillingRepository; + isTeamBillingEnabled: boolean; +} + +export class TeamBillingServiceFactory { + // Store dependencies as single object (IOctopus pattern) + constructor(private readonly deps: ITeamBillingServiceFactoryDeps) {} + + /** Initialize a single team billing */ + init(team: TeamBillingInput): ITeamBillingService { + if (!this.deps.isTeamBillingEnabled) { + return new StubTeamBillingService(team); + } + + // Call the factory function with runtime context to get the correct repository + const billingRepository = this.deps.billingRepositoryFactory(team.isOrganization); + + return new TeamBillingService({ + team, + billingProviderService: this.deps.billingProviderService, + teamBillingDataRepository: this.deps.teamBillingDataRepository, + billingRepository, + }); + } + + /** Initialize multiple team billings at once for bulk operations */ + initMany(teams: TeamBillingInput[]) { + return teams.map((team) => this.init(team)); + } + + /** Fetch and initialize a single team billing in one go */ + async findAndInit(teamId: number) { + const team = await this.deps.teamBillingDataRepository.find(teamId); + return this.init(team); + } + + /** Fetch and initialize multiple team billings in one go */ + async findAndInitMany(teamIds: number[]) { + const teams = await this.deps.teamBillingDataRepository.findMany(teamIds); + return this.initMany(teams); + } +} diff --git a/packages/features/ee/billing/teams/index.ts b/packages/features/ee/billing/teams/index.ts deleted file mode 100644 index 308d43a178..0000000000 --- a/packages/features/ee/billing/teams/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; - -import { InternalTeamBilling } from "./internal-team-billing"; -import { StubTeamBilling } from "./stub-team-billing"; -import type { TeamBilling as _TeamBilling, TeamBillingInput } from "./team-billing"; -import { TeamBillingRepository } from "./team-billing.repository"; - -export class TeamBilling { - static repo = new TeamBillingRepository(); - - /** Initialize a single team billing */ - static init(team: TeamBillingInput): _TeamBilling { - if (IS_TEAM_BILLING_ENABLED) return new InternalTeamBilling(team); - return new StubTeamBilling(team); - } - /** Initialize multiple team billings at once for bulk operations */ - static initMany(teams: TeamBillingInput[]) { - return teams.map((team) => TeamBilling.init(team)); - } - /** Fetch and initialize multiple team billings in one go */ - static async findAndInit(teamId: number) { - const team = await TeamBilling.repo.find(teamId); - return TeamBilling.init(team); - } - /** Fetch and initialize multiple team billings in one go */ - static async findAndInitMany(teamIds: number[]) { - const teams = await TeamBilling.repo.findMany(teamIds); - return TeamBilling.initMany(teams); - } -} 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 c37f0ee9f4..611095b364 100644 --- a/packages/features/ee/billing/teams/internal-team-billing.test.ts +++ b/packages/features/ee/billing/teams/internal-team-billing.test.ts @@ -5,28 +5,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments"; import { WEBAPP_URL } from "@calcom/lib/constants"; -import { BillingRepositoryFactory } from "../repository/billingRepositoryFactory"; -import { StripeBillingService } from "../stripe-billing-service"; -import { InternalTeamBilling } from "./internal-team-billing"; -import { TeamBillingPublishResponseStatus } from "./team-billing"; - -const { - mockHandleSubscriptionCancel, - mockHandleSubscriptionUpdate, - mockCheckoutSessionIsPaid, - mockGetSubscriptionStatus, - mockHandleEndTrial, -} = vi.hoisted(() => ({ - mockHandleSubscriptionCancel: vi.fn(), - mockHandleSubscriptionUpdate: vi.fn(), - mockCheckoutSessionIsPaid: vi.fn(), - mockGetSubscriptionStatus: vi.fn(), - mockHandleEndTrial: vi.fn(), -})); - -vi.mock("../stripe-billing-service", () => ({ - StripeBillingService: vi.fn(), -})); +import type { IBillingRepository } from "../repository/billing/IBillingRepository"; +import type { ITeamBillingDataRepository } from "../repository/teamBillingData/ITeamBillingDataRepository"; +import type { IBillingProviderService } from "../service/billingProvider/IBillingProviderService"; +import { TeamBillingService } from "../service/teams/TeamBillingService"; +import { TeamBillingPublishResponseStatus } from "../service/teams/ITeamBillingService"; vi.mock("@calcom/lib/constants", async () => { const actual = await vi.importActual("@calcom/lib/constants"); @@ -39,8 +22,6 @@ vi.mock("@calcom/lib/constants", async () => { vi.mock("@calcom/features/ee/teams/lib/payments", () => ({ purchaseTeamOrOrgSubscription: vi.fn(), })); - -vi.mock("../repository/billingRepositoryFactory"); const mockTeam = { id: 1, metadata: { @@ -52,19 +33,37 @@ const mockTeam = { parentId: null, }; -describe("InternalTeamBilling", () => { - let internalTeamBilling: InternalTeamBilling; +describe("TeamBillingService", () => { + let teamBillingService: TeamBillingService; + let mockBillingProviderService: IBillingProviderService; + let mockTeamBillingDataRepository: ITeamBillingDataRepository; + let mockBillingRepository: IBillingRepository; beforeEach(() => { vi.clearAllMocks(); - vi.mocked(StripeBillingService).mockImplementation(() => ({ - handleSubscriptionCancel: mockHandleSubscriptionCancel, - handleSubscriptionUpdate: mockHandleSubscriptionUpdate, - checkoutSessionIsPaid: mockCheckoutSessionIsPaid, - getSubscriptionStatus: mockGetSubscriptionStatus, - handleEndTrial: mockHandleEndTrial, - })); - internalTeamBilling = new InternalTeamBilling(mockTeam); + + mockBillingProviderService = { + handleSubscriptionCancel: vi.fn(), + handleSubscriptionUpdate: vi.fn(), + checkoutSessionIsPaid: vi.fn(), + getSubscriptionStatus: vi.fn(), + handleEndTrial: vi.fn(), + } as IBillingProviderService; + + mockTeamBillingDataRepository = { + find: vi.fn(), + } as unknown as ITeamBillingDataRepository; + + mockBillingRepository = { + create: vi.fn(), + } as unknown as IBillingRepository; + + teamBillingService = new TeamBillingService({ + team: mockTeam, + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepository: mockBillingRepository, + }); }); afterEach(() => { @@ -73,9 +72,9 @@ describe("InternalTeamBilling", () => { describe("cancel", () => { it("should cancel the subscription and downgrade the team", async () => { - await internalTeamBilling.cancel(); + await teamBillingService.cancel(); - expect(mockHandleSubscriptionCancel).toHaveBeenCalledWith("sub_123"); + expect(mockBillingProviderService.handleSubscriptionCancel).toHaveBeenCalledWith("sub_123"); expect(prismaMock.team.update).toHaveBeenCalledWith({ where: { id: 1 }, data: { @@ -87,14 +86,14 @@ describe("InternalTeamBilling", () => { describe("publish", () => { it("should create a checkout session and update the team", async () => { - mockCheckoutSessionIsPaid.mockResolvedValue(false); + vi.mocked(mockBillingProviderService.checkoutSessionIsPaid).mockResolvedValue(false); vi.mocked(purchaseTeamOrOrgSubscription).mockResolvedValue({ url: "http://checkout.url", }); prismaMock.membership.count.mockResolvedValue(5); prismaMock.membership.findFirstOrThrow.mockResolvedValue({ userId: 123 }); - const result = await internalTeamBilling.publish(); + const result = await teamBillingService.publish(); expect(result).toEqual({ redirectUrl: "http://checkout.url", status: TeamBillingPublishResponseStatus.REQUIRES_PAYMENT, @@ -108,19 +107,19 @@ describe("InternalTeamBilling", () => { }); it("should return upgrade url if upgrade is required", async () => { const mockUrl = `${WEBAPP_URL}/api/teams/${mockTeam.id}/upgrade?session_id=cs_789`; - vi.spyOn(internalTeamBilling, "checkIfTeamPaymentRequired").mockResolvedValue({ + vi.spyOn(teamBillingService, "checkIfTeamPaymentRequired").mockResolvedValue({ url: mockUrl, paymentId: "cs_789", paymentRequired: false, }); - const result = await internalTeamBilling.publish(); + const result = await teamBillingService.publish(); expect(result).toEqual({ redirectUrl: mockUrl, status: TeamBillingPublishResponseStatus.REQUIRES_UPGRADE, }); - expect(internalTeamBilling.checkIfTeamPaymentRequired).toHaveBeenCalled(); + expect(teamBillingService.checkIfTeamPaymentRequired).toHaveBeenCalled(); }); }); @@ -130,17 +129,22 @@ describe("InternalTeamBilling", () => { ...mockTeam, isOrganization: false, }; - const internalTeamBilling = new InternalTeamBilling(mockTeamNotOrg); + const teamBillingServiceNotOrg = new TeamBillingService({ + team: mockTeamNotOrg, + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepository: mockBillingRepository, + }); prismaMock.membership.count.mockResolvedValue(10); - vi.spyOn(internalTeamBilling, "checkIfTeamPaymentRequired").mockResolvedValue({ + vi.spyOn(teamBillingServiceNotOrg, "checkIfTeamPaymentRequired").mockResolvedValue({ url: "http://checkout.url", paymentId: "cs_789", paymentRequired: false, }); - await internalTeamBilling.updateQuantity(); + await teamBillingServiceNotOrg.updateQuantity(); - expect(mockHandleSubscriptionUpdate).toHaveBeenCalledWith({ + expect(mockBillingProviderService.handleSubscriptionUpdate).toHaveBeenCalledWith({ subscriptionId: "sub_123", subscriptionItemId: "si_456", membershipCount: 10, @@ -148,17 +152,22 @@ describe("InternalTeamBilling", () => { }); it("should not update if membership count is less than minimum for organizations", async () => { - const internalTeamBilling = new InternalTeamBilling(mockTeam); + const teamBillingServiceOrg = new TeamBillingService({ + team: mockTeam, + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepository: mockBillingRepository, + }); prismaMock.membership.count.mockResolvedValue(2); - vi.spyOn(internalTeamBilling, "checkIfTeamPaymentRequired").mockResolvedValue({ + vi.spyOn(teamBillingServiceOrg, "checkIfTeamPaymentRequired").mockResolvedValue({ url: "http://checkout.url", paymentId: "cs_789", paymentRequired: false, }); - await internalTeamBilling.updateQuantity(); + await teamBillingServiceOrg.updateQuantity(); - expect(mockHandleSubscriptionUpdate).not.toHaveBeenCalled(); + expect(mockBillingProviderService.handleSubscriptionUpdate).not.toHaveBeenCalled(); }); }); @@ -171,27 +180,42 @@ describe("InternalTeamBilling", () => { paymentId: undefined, }, }; - const internalTeamBilling = new InternalTeamBilling(mockTeamNoPayment); + const teamBillingServiceNoPayment = new TeamBillingService({ + team: mockTeamNoPayment, + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepository: mockBillingRepository, + }); - const result = await internalTeamBilling.checkIfTeamPaymentRequired(); + const result = await teamBillingServiceNoPayment.checkIfTeamPaymentRequired(); expect(result).toEqual({ url: null, paymentId: null, paymentRequired: true }); }); it("should return payment required if checkout session is not paid", async () => { - mockCheckoutSessionIsPaid.mockResolvedValue(false); - const internalTeamBilling = new InternalTeamBilling(mockTeam); + vi.mocked(mockBillingProviderService.checkoutSessionIsPaid).mockResolvedValue(false); + const teamBillingServiceWithPayment = new TeamBillingService({ + team: mockTeam, + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepository: mockBillingRepository, + }); - const result = await internalTeamBilling.checkIfTeamPaymentRequired(); + const result = await teamBillingServiceWithPayment.checkIfTeamPaymentRequired(); expect(result).toEqual({ url: null, paymentId: "cs_789", paymentRequired: true }); }); it("should return upgrade URL if checkout session is paid", async () => { - mockCheckoutSessionIsPaid.mockResolvedValue(true); - const internalTeamBilling = new InternalTeamBilling(mockTeam); + vi.mocked(mockBillingProviderService.checkoutSessionIsPaid).mockResolvedValue(true); + const teamBillingServicePaid = new TeamBillingService({ + team: mockTeam, + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepository: mockBillingRepository, + }); - const result = await internalTeamBilling.checkIfTeamPaymentRequired(); + const result = await teamBillingServicePaid.checkIfTeamPaymentRequired(); expect(result).toEqual({ url: `${WEBAPP_URL}/api/teams/1/upgrade?session_id=cs_789`, @@ -202,22 +226,7 @@ describe("InternalTeamBilling", () => { }); describe("saveTeamBilling", () => { - const mockOrgRepository = { - create: vi.fn(), - }; - - const mockTeamRepository = { - create: vi.fn(), - }; - - it("should delegate to organization billing repository when team is an organization", async () => { - const mockOrgTeam = { - id: 1, - metadata: {}, - isOrganization: true, - parentId: null, - }; - + it("should call repository create with billing arguments", async () => { const mockBillingArgs = { teamId: 1, subscriptionId: "sub_org_123", @@ -227,69 +236,12 @@ describe("InternalTeamBilling", () => { status: "ACTIVE" as const, }; - const mockCreatedRecord = { - id: "billing_org_123", - ...mockBillingArgs, - createdAt: new Date(), - updatedAt: new Date(), - }; + await teamBillingService.saveTeamBilling(mockBillingArgs); - mockOrgRepository.create.mockResolvedValue(mockCreatedRecord); - vi.mocked(BillingRepositoryFactory.getRepository).mockReturnValue( - mockOrgRepository as unknown as ReturnType - ); - - const internalTeamBilling = new InternalTeamBilling(mockOrgTeam); - await internalTeamBilling.saveTeamBilling(mockBillingArgs); - - expect(BillingRepositoryFactory.getRepository).toHaveBeenCalledWith(true); - expect(mockOrgRepository.create).toHaveBeenCalledWith(mockBillingArgs); - }); - - it("should delegate to team billing repository when team is not an organization", async () => { - const mockRegularTeam = { - id: 2, - metadata: {}, - isOrganization: false, - parentId: null, - }; - - const mockBillingArgs = { - teamId: 2, - subscriptionId: "sub_team_456", - subscriptionItemId: "si_team_456", - customerId: "cus_team_456", - planName: "TEAM" as const, - status: "ACTIVE" as const, - }; - - const mockCreatedRecord = { - id: "billing_team_456", - ...mockBillingArgs, - createdAt: new Date(), - updatedAt: new Date(), - }; - - mockTeamRepository.create.mockResolvedValue(mockCreatedRecord); - vi.mocked(BillingRepositoryFactory.getRepository).mockReturnValue( - mockTeamRepository as unknown as ReturnType - ); - - const internalTeamBilling = new InternalTeamBilling(mockRegularTeam); - await internalTeamBilling.saveTeamBilling(mockBillingArgs); - - expect(BillingRepositoryFactory.getRepository).toHaveBeenCalledWith(false); - expect(mockTeamRepository.create).toHaveBeenCalledWith(mockBillingArgs); + expect(mockBillingRepository.create).toHaveBeenCalledWith(mockBillingArgs); }); it("should pass all billing arguments correctly to repository", async () => { - const mockTeam = { - id: 3, - metadata: {}, - isOrganization: false, - parentId: null, - }; - const mockBillingArgs = { teamId: 3, subscriptionId: "sub_detailed_789", @@ -299,22 +251,9 @@ describe("InternalTeamBilling", () => { status: "TRIALING" as const, }; - const mockCreatedRecord = { - id: "billing_detailed_789", - ...mockBillingArgs, - createdAt: new Date(), - updatedAt: new Date(), - }; + await teamBillingService.saveTeamBilling(mockBillingArgs); - mockTeamRepository.create.mockResolvedValue(mockCreatedRecord); - vi.mocked(BillingRepositoryFactory.getRepository).mockReturnValue( - mockTeamRepository as unknown as ReturnType - ); - - const internalTeamBilling = new InternalTeamBilling(mockTeam); - await internalTeamBilling.saveTeamBilling(mockBillingArgs); - - expect(mockTeamRepository.create).toHaveBeenCalledWith( + expect(mockBillingRepository.create).toHaveBeenCalledWith( expect.objectContaining({ teamId: 3, subscriptionId: "sub_detailed_789", @@ -327,13 +266,6 @@ describe("InternalTeamBilling", () => { }); it("should propagate repository errors", async () => { - const mockTeam = { - id: 4, - metadata: {}, - isOrganization: false, - parentId: null, - }; - const mockBillingArgs = { teamId: 4, subscriptionId: "sub_error_999", @@ -344,14 +276,9 @@ describe("InternalTeamBilling", () => { }; const repositoryError = new Error("Database constraint violation"); - mockTeamRepository.create.mockRejectedValue(repositoryError); - vi.mocked(BillingRepositoryFactory.getRepository).mockReturnValue( - mockTeamRepository as unknown as ReturnType - ); + vi.mocked(mockBillingRepository.create).mockRejectedValue(repositoryError); - const internalTeamBilling = new InternalTeamBilling(mockTeam); - - await expect(internalTeamBilling.saveTeamBilling(mockBillingArgs)).rejects.toThrow( + await expect(teamBillingService.saveTeamBilling(mockBillingArgs)).rejects.toThrow( "Database constraint violation" ); }); diff --git a/packages/features/ee/billing/teams/stub-team-billing.ts b/packages/features/ee/billing/teams/stub-team-billing.ts deleted file mode 100644 index 5d841d675c..0000000000 --- a/packages/features/ee/billing/teams/stub-team-billing.ts +++ /dev/null @@ -1,28 +0,0 @@ -import logger from "@calcom/lib/logger"; - -import { TeamBillingPublishResponseStatus, type TeamBilling, type TeamBillingInput } from "./team-billing"; - -const log = logger.getSubLogger({ prefix: ["StubTeamBilling"] }); - -/** - * Stub implementation of TeamBilling that does nothing. - * Usually used when team billing is disabled. - */ -export class StubTeamBilling implements TeamBilling { - constructor(_team: TeamBillingInput) { - log.info(`Skipping team billing`); - } - async cancel() { - log.info(`Skipping team billing cancellation due team billing being disabled`); - } - async publish() { - log.info(`Skipping team billing publish due team billing being disabled`); - return { redirectUrl: null, status: TeamBillingPublishResponseStatus.SUCCESS }; - } - async downgrade() { - log.info(`Skipping team billing downgrade due team billing being disabled`); - } - async updateQuantity() { - log.info(`Skipping team billing update due team billing being disabled`); - } -} diff --git a/packages/features/ee/billing/teams/team-billing.repository.test.ts b/packages/features/ee/billing/teams/team-billing.repository.test.ts deleted file mode 100644 index 76f30e632a..0000000000 --- a/packages/features/ee/billing/teams/team-billing.repository.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import prismaMock from "../../../../../tests/libs/__mocks__/prismaMock"; - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import * as constants from "@calcom/lib/constants"; - -import { TeamBillingRepository } from "./team-billing.repository"; - -vi.mock("@calcom/lib/constants", async () => { - const actual = await vi.importActual("@calcom/lib/constants"); - return { - ...actual, - IS_TEAM_BILLING_ENABLED: vi.fn(), - IS_PRODUCTION: false, - }; -}); - -describe("TeamBillingRepository", () => { - const mockTeam = { id: 1, metadata: null, isOrganization: true, parentId: null }; - const mockTeams = [mockTeam, { id: 2, metadata: null, isOrganization: false, parentId: 1 }]; - - beforeEach(() => { - vi.resetAllMocks(); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - }); - - describe("find", () => { - it("should return stubTeam when team billing is disabled", async () => { - // @ts-expect-error - IS_TEAM_BILLING_ENABLED is not writable - constants.IS_TEAM_BILLING_ENABLED = false; - - const tbr = new TeamBillingRepository(); - const result = await tbr.find(1); - expect(result).toEqual({ id: -1, metadata: expect.any(Object), isOrganization: true, parentId: -1 }); - }); - - it("should call prisma.team.findUniqueOrThrow when team billing is enabled", async () => { - // @ts-expect-error - IS_TEAM_BILLING_ENABLED is not writable - constants.IS_TEAM_BILLING_ENABLED = true; - - prismaMock.team.findUniqueOrThrow.mockResolvedValue(mockTeam); - - const tbr = new TeamBillingRepository(); - const result = await tbr.find(1); - expect(result).toEqual(mockTeam); - }); - }); - - describe("findBySubscriptionId", () => { - it("should return stubTeam when team billing is disabled", async () => { - // @ts-expect-error - IS_TEAM_BILLING_ENABLED is not writable - constants.IS_TEAM_BILLING_ENABLED = false; - - const tbr = new TeamBillingRepository(); - const result = await tbr.findBySubscriptionId("sub_123"); - expect(result).toEqual({ id: -1, metadata: {}, isOrganization: true, parentId: -1 }); - }); - - it("should call prisma.team.findFirstOrThrow when team billing is enabled", async () => { - // @ts-expect-error - IS_TEAM_BILLING_ENABLED is not writable - constants.IS_TEAM_BILLING_ENABLED = true; - - prismaMock.team.findFirstOrThrow.mockResolvedValue({ - id: 1, - metadata: { subscriptionId: "sub_123" }, - isOrganization: true, - parentId: null, - }); - - const tbr = new TeamBillingRepository(); - await tbr.findBySubscriptionId("sub_123"); - expect(prismaMock.team.findFirstOrThrow).toHaveBeenCalledWith({ - where: { - metadata: { - path: ["subscriptionId"], - equals: "sub_123", - }, - }, - select: { id: true, metadata: true, isOrganization: true, parentId: true }, - }); - }); - }); - - describe("findMany", () => { - it("should return an empty array when IS_TEAM_BILLING_ENABLED is false", async () => { - // @ts-expect-error - IS_TEAM_BILLING_ENABLED is not writable - constants.IS_TEAM_BILLING_ENABLED = false; - const tbr = new TeamBillingRepository(); - const result = await tbr.findMany([1, 2]); - expect(result).toEqual([]); - }); - - it("should call prisma.team.findMany when IS_TEAM_BILLING_ENABLED is true", async () => { - // @ts-expect-error - IS_TEAM_BILLING_ENABLED is not writable - constants.IS_TEAM_BILLING_ENABLED = true; - - prismaMock.team.findMany.mockResolvedValue([mockTeam]); - - const tbr = new TeamBillingRepository(); - await tbr.findMany([1, 2]); - expect(prismaMock.team.findMany).toHaveBeenCalledWith({ - where: { id: { in: [1, 2] } }, - select: { id: true, metadata: true, isOrganization: true, parentId: true }, - }); - }); - }); -}); diff --git a/packages/features/ee/billing/teams/team-billing.repository.ts b/packages/features/ee/billing/teams/team-billing.repository.ts deleted file mode 100644 index ba8ebbcdd8..0000000000 --- a/packages/features/ee/billing/teams/team-billing.repository.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; -import prisma from "@calcom/prisma"; - -import type { ITeamBillingRepository } from "./team-billing.repository.interface"; -import { teamBillingSelect } from "./team-billing.repository.interface"; - -const stubTeam = { id: -1, metadata: {}, isOrganization: true, parentId: -1 }; - -export class TeamBillingRepository implements ITeamBillingRepository { - /** Fetch a single team with minimal data needed for billing */ - async find(teamId: number) { - if (!IS_TEAM_BILLING_ENABLED) return stubTeam; - return prisma.team.findUniqueOrThrow({ where: { id: teamId }, select: teamBillingSelect }); - } - /** Fetch a single team with minimal data needed for billing */ - async findBySubscriptionId(subscriptionId: string) { - if (!IS_TEAM_BILLING_ENABLED) return stubTeam; - const team = await prisma.team.findFirstOrThrow({ - where: { - metadata: { - path: ["subscriptionId"], - equals: subscriptionId, - }, - }, - select: teamBillingSelect, - }); - return team; - } - /** Fetch multiple teams with minimal data needed for billing */ - async findMany(teamIds: number[]) { - if (!IS_TEAM_BILLING_ENABLED) return []; - return prisma.team.findMany({ where: { id: { in: teamIds } }, select: teamBillingSelect }); - } -} diff --git a/packages/features/ee/billing/teams/team-billing.test.ts b/packages/features/ee/billing/teams/team-billing.test.ts deleted file mode 100644 index 4e42086a4e..0000000000 --- a/packages/features/ee/billing/teams/team-billing.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import prismaMock from "../../../../../tests/libs/__mocks__/prismaMock"; - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import * as constants from "@calcom/lib/constants"; - -import { TeamBilling } from "./index"; -import { InternalTeamBilling } from "./internal-team-billing"; -import { StubTeamBilling } from "./stub-team-billing"; - -vi.mock("@calcom/lib/constants", async () => { - const actual = await vi.importActual("@calcom/lib/constants"); - return { - ...actual, - IS_TEAM_BILLING_ENABLED: vi.fn(), - IS_PRODUCTION: false, - }; -}); - -describe("TeamBilling", () => { - const mockTeam = { id: 1, metadata: null, isOrganization: true, parentId: null }; - const mockTeams = [mockTeam, { id: 2, metadata: null, isOrganization: false, parentId: 1 }]; - - beforeEach(() => { - vi.resetAllMocks(); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - }); - - describe("init", () => { - it("should return InternalTeamBilling when team billing is enabled", () => { - // @ts-expect-error - IS_TEAM_BILLING_ENABLED is not writable - constants.IS_TEAM_BILLING_ENABLED = true; - const result = TeamBilling.init(mockTeam); - expect(result).toBeInstanceOf(InternalTeamBilling); - }); - - it("should return StubTeamBilling when team billing is disabled", () => { - // @ts-expect-error - IS_TEAM_BILLING_ENABLED is not writable - constants.IS_TEAM_BILLING_ENABLED = false; - - const result = TeamBilling.init(mockTeam); - expect(result).toBeInstanceOf(StubTeamBilling); - }); - }); - - describe("initMany", () => { - it("should initialize multiple team billings", () => { - const result = TeamBilling.initMany(mockTeams); - expect(result).toHaveLength(2); - expect(result[0]).toBeInstanceOf(StubTeamBilling); - expect(result[1]).toBeInstanceOf(StubTeamBilling); - }); - }); - - describe("findAndInit", () => { - it("should find and initialize a single team billing", async () => { - // @ts-expect-error - IS_TEAM_BILLING_ENABLED is not writable - constants.IS_TEAM_BILLING_ENABLED = true; - - prismaMock.team.findUniqueOrThrow.mockResolvedValue(mockTeam); - - const result = await TeamBilling.findAndInit(1); - expect(result).toBeInstanceOf(InternalTeamBilling); - }); - }); - - describe("findAndInitMany", () => { - it("should find and initialize multiple team billings", async () => { - // @ts-expect-error - IS_TEAM_BILLING_ENABLED is not writable - constants.IS_TEAM_BILLING_ENABLED = true; - - prismaMock.team.findMany.mockResolvedValue([mockTeam, { ...mockTeam, id: 2 }]); - - const result = await TeamBilling.findAndInitMany([1, 2]); - expect(result).toHaveLength(2); - expect(result[0]).toBeInstanceOf(InternalTeamBilling); - expect(result[1]).toBeInstanceOf(InternalTeamBilling); - }); - }); -}); diff --git a/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts b/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts index 9a48ba4b20..f93d809711 100644 --- a/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts +++ b/packages/features/ee/organizations/lib/OrganizationPaymentService.test.ts @@ -42,15 +42,19 @@ vi.mock("@calcom/prisma", () => { }; }); -vi.mock("@calcom/features/ee/billing/stripe-billing-service", () => ({ - StripeBillingService: vi.fn().mockImplementation(() => ({ - createCustomer: vi.fn().mockResolvedValue({ id: "mock_customer_id" }), - createPrice: vi.fn().mockResolvedValue({ id: "mock_price_id", isCustom: false }), - createSubscription: vi.fn().mockResolvedValue({ id: "mock_subscription_id" }), - createSubscriptionCheckout: vi.fn().mockResolvedValue({ - checkoutUrl: "https://checkout.stripe.com/mock-checkout-url", - }), - })), +const mockBillingService = { + createCustomer: vi.fn().mockResolvedValue({ id: "mock_customer_id" }), + createPrice: vi.fn().mockResolvedValue({ id: "mock_price_id", isCustom: false }), + createSubscription: vi.fn().mockResolvedValue({ id: "mock_subscription_id" }), + createSubscriptionCheckout: vi.fn().mockResolvedValue({ + checkoutUrl: "https://checkout.stripe.com/mock-checkout-url", + }), +}; + +vi.mock("@calcom/features/ee/billing/di/containers/Billing", () => ({ + getBillingProviderService: vi.fn(() => mockBillingService), + getTeamBillingServiceFactory: vi.fn(), + getTeamBillingDataRepository: vi.fn(), })); describe("OrganizationPaymentService", () => { @@ -73,9 +77,8 @@ describe("OrganizationPaymentService", () => { validatePermissions: vi.fn().mockResolvedValue(true), }; service = new OrganizationPaymentService(mockUser, mockPermissionService); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ metadata: {} } as any); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ metadata: {} }); - // Mock successful organization onboarding creation vi.mocked(prisma.organizationOnboarding.create).mockResolvedValue({ id: 1, name: "Test Org", @@ -86,7 +89,7 @@ describe("OrganizationPaymentService", () => { pricePerSeat: 20, stripeCustomerId: "mock_customer_id", isComplete: false, - } as any); + }); }); describe("createPaymentIntent", () => { @@ -131,7 +134,7 @@ describe("OrganizationPaymentService", () => { expect(result).toBeDefined(); const updateCall = vi.mocked(prisma.organizationOnboarding.update).mock.calls[0][0]; expect(updateCall.where).toEqual({ id: "onboard-id-1" }); - const { updatedAt, ...data } = updateCall.data; + const { updatedAt: _updatedAt, ...data } = updateCall.data; expect(data).toEqual({ bio: "BIO", logo: "LOGO", @@ -181,7 +184,7 @@ describe("OrganizationPaymentService", () => { expect(result).toBeDefined(); const updateCall = vi.mocked(prisma.organizationOnboarding.update).mock.calls[0][0]; expect(updateCall.where).toEqual({ id: "onboard-id-1" }); - const { updatedAt, ...data } = updateCall.data; + const { updatedAt: _updatedAt, ...data } = updateCall.data; expect(data).toEqual({ bio: "BIO", logo: "LOGO", @@ -232,7 +235,7 @@ describe("OrganizationPaymentService", () => { expect(vi.mocked(prisma.organizationOnboarding.update)).toHaveBeenCalled(); const updateCall = vi.mocked(prisma.organizationOnboarding.update).mock.calls[0][0]; expect(updateCall.where).toEqual({ id: "onboard-id-1" }); - const { updatedAt, ...data } = updateCall.data; + const { updatedAt: _updatedAt, ...data } = updateCall.data; expect(data).toEqual({ bio: "BIO", logo: "LOGO", @@ -264,7 +267,7 @@ describe("OrganizationPaymentService", () => { expect(result).toBeDefined(); const updateCall = vi.mocked(prisma.organizationOnboarding.update).mock.calls[0][0]; expect(updateCall.where).toEqual({ id: "onboard-id-1" }); - const { updatedAt, ...data } = updateCall.data; + const { updatedAt: _updatedAt, ...data } = updateCall.data; expect(data).toEqual({ bio: "BIO", logo: "LOGO", diff --git a/packages/features/ee/organizations/lib/OrganizationPaymentService.ts b/packages/features/ee/organizations/lib/OrganizationPaymentService.ts index 18da38affa..639be238de 100644 --- a/packages/features/ee/organizations/lib/OrganizationPaymentService.ts +++ b/packages/features/ee/organizations/lib/OrganizationPaymentService.ts @@ -1,4 +1,5 @@ -import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service"; +import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing"; +import type { StripeBillingService } from "@calcom/features/ee/billing/service/billingProvider/StripeBillingService"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { ORGANIZATION_SELF_SERVE_MIN_SEATS, @@ -83,7 +84,7 @@ export class OrganizationPaymentService { protected user: OnboardingUser; constructor(user: OnboardingUser, permissionService?: OrganizationPermissionService) { - this.billingService = new StripeBillingService(); + this.billingService = getBillingProviderService(); this.permissionService = permissionService || new OrganizationPermissionService(user); this.user = user; } diff --git a/packages/features/ee/teams/services/teamService.alternative.test.ts b/packages/features/ee/teams/services/teamService.alternative.test.ts index a2628d78e0..559749cd6e 100644 --- a/packages/features/ee/teams/services/teamService.alternative.test.ts +++ b/packages/features/ee/teams/services/teamService.alternative.test.ts @@ -2,7 +2,6 @@ import { prisma } from "@calcom/prisma/__mocks__/prisma"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { TeamBilling } from "@calcom/features/ee/billing/teams"; import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService"; import { deleteDomain } from "@calcom/lib/domainManager/organization"; @@ -13,7 +12,7 @@ vi.mock("@calcom/prisma", () => ({ prisma, })); -vi.mock("@calcom/features/ee/billing/teams"); +vi.mock("@calcom/ee/billing/di/containers/Billing"); vi.mock("@calcom/features/ee/teams/repositories/TeamRepository"); vi.mock("@calcom/features/ee/workflows/lib/service/WorkflowService"); vi.mock("@calcom/lib/domainManager/organization"); @@ -51,8 +50,7 @@ const mockTeamRepo = { throw new Error(`Team with id ${id} not found`); }), }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo as any); +vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepo); vi.mocked(deleteDomain).mockImplementation(async (slug) => { database.domains.delete(slug); @@ -62,24 +60,27 @@ vi.mocked(WorkflowService.deleteWorkflowRemindersOfRemovedTeam).mockImplementati database.workflowReminders.delete(teamId); }); -vi.mocked(TeamBilling.findAndInit).mockImplementation(async (teamId) => { - // Create a team-specific billing mock - const teamSpecificBilling = { - ...mockTeamBilling, - teamId, - cancel: vi.fn().mockImplementation(() => { - const billing = database.billings.get(teamId); - if (billing) { - billing.cancelled = true; - } - }), - }; - return teamSpecificBilling; -}); - describe("TeamService", () => { - beforeEach(() => { + beforeEach(async () => { database.clear(); + + const { getTeamBillingServiceFactory } = await import("@calcom/ee/billing/di/containers/Billing"); + vi.mocked(getTeamBillingServiceFactory).mockReturnValue({ + findAndInit: vi.fn().mockImplementation(async (teamId) => { + const teamSpecificBilling = { + ...mockTeamBilling, + teamId, + cancel: vi.fn().mockImplementation(() => { + const billing = database.billings.get(teamId); + if (billing) { + billing.cancelled = true; + } + }), + }; + return teamSpecificBilling; + }), + findAndInitMany: vi.fn().mockResolvedValue([mockTeamBilling]), + }); database.teams.set(1, { id: 1, diff --git a/packages/features/ee/teams/services/teamService.integration-test.ts b/packages/features/ee/teams/services/teamService.integration-test.ts index 434ccdb4b5..409fbbcdcf 100644 --- a/packages/features/ee/teams/services/teamService.integration-test.ts +++ b/packages/features/ee/teams/services/teamService.integration-test.ts @@ -6,16 +6,19 @@ import { MembershipRole } from "@calcom/prisma/enums"; import { TeamService } from "./teamService"; -vi.mock("@calcom/features/ee/billing/teams", () => { +// Mock the DI container +vi.mock("@calcom/ee/billing/di/containers/Billing", () => { const mockUpdateQuantity = vi.fn().mockResolvedValue(undefined); - const mockTeamBilling = { + const mockTeamBillingService = { updateQuantity: mockUpdateQuantity, }; + const mockFactory = { + findAndInitMany: vi.fn().mockResolvedValue([mockTeamBillingService]), + }; + return { - TeamBilling: { - findAndInitMany: vi.fn().mockResolvedValue([mockTeamBilling]), - }, + getTeamBillingServiceFactory: vi.fn(() => mockFactory), }; }); @@ -952,16 +955,18 @@ describe("TeamService.removeMembers Integration Tests", () => { }); describe("Common Behaviors and Edge Cases", () => { - it("should call TeamBilling.updateQuantity for each team", async () => { - const { TeamBilling } = await import("@calcom/features/ee/billing/teams"); + it("should call TeamBillingService.updateQuantity for each team", async () => { + const { getTeamBillingServiceFactory } = await import("@calcom/ee/billing/di/containers/Billing"); await TeamService.removeMembers({ teamIds: [regularTeamTestData.team.id], userIds: [orgTestData.members[0].id, orgTestData.members[1].id], }); - expect(TeamBilling.findAndInitMany).toHaveBeenCalledWith([regularTeamTestData.team.id]); - const mockInstances = await TeamBilling.findAndInitMany([regularTeamTestData.team.id]); + expect(getTeamBillingServiceFactory).toHaveBeenCalled(); + const mockFactory = getTeamBillingServiceFactory(); + expect(mockFactory.findAndInitMany).toHaveBeenCalledWith([regularTeamTestData.team.id]); + const mockInstances = await mockFactory.findAndInitMany([regularTeamTestData.team.id]); expect(mockInstances[0].updateQuantity).toHaveBeenCalled(); }); diff --git a/packages/features/ee/teams/services/teamService.test.ts b/packages/features/ee/teams/services/teamService.test.ts index 0c1df1c3f6..d25aa8c684 100644 --- a/packages/features/ee/teams/services/teamService.test.ts +++ b/packages/features/ee/teams/services/teamService.test.ts @@ -2,7 +2,6 @@ import prismaMock from "../../../../../tests/libs/__mocks__/prismaMock"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { TeamBilling } from "@calcom/features/ee/billing/teams"; import { updateNewTeamMemberEventTypes } from "@calcom/features/ee/teams/lib/queries"; import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService"; @@ -15,7 +14,7 @@ import { TRPCError } from "@trpc/server"; import { TeamService } from "./teamService"; -vi.mock("@calcom/features/ee/billing/teams"); +vi.mock("@calcom/ee/billing/di/containers/Billing"); vi.mock("@calcom/features/ee/teams/repositories/TeamRepository"); vi.mock("@calcom/features/ee/workflows/lib/service/WorkflowService"); vi.mock("@calcom/lib/domainManager/organization"); @@ -30,12 +29,19 @@ const mockTeamBilling = { downgrade: vi.fn(), }; -vi.mocked(TeamBilling.findAndInit).mockResolvedValue(mockTeamBilling); +const mockTeamBillingFactory = { + findAndInit: vi.fn().mockResolvedValue(mockTeamBilling), + findAndInitMany: vi.fn().mockResolvedValue([mockTeamBilling]), +}; describe("TeamService", () => { - beforeEach(() => { + beforeEach(async () => { vi.resetAllMocks(); - vi.mocked(TeamBilling.findAndInit).mockResolvedValue(mockTeamBilling); + mockTeamBillingFactory.findAndInit.mockResolvedValue(mockTeamBilling); + mockTeamBillingFactory.findAndInitMany.mockResolvedValue([mockTeamBilling]); + + const { getTeamBillingServiceFactory } = await import("@calcom/ee/billing/di/containers/Billing"); + vi.mocked(getTeamBillingServiceFactory).mockReturnValue(mockTeamBillingFactory); }); afterEach(() => { @@ -59,7 +65,7 @@ describe("TeamService", () => { const result = await TeamService.delete({ id: 1 }); - expect(TeamBilling.findAndInit).toHaveBeenCalledWith(1); + expect(mockTeamBillingFactory.findAndInit).toHaveBeenCalledWith(1); expect(mockTeamBilling.cancel).toHaveBeenCalled(); expect(WorkflowService.deleteWorkflowRemindersOfRemovedTeam).toHaveBeenCalledWith(1); expect(mockTeamRepo.deleteById).toHaveBeenCalledWith({ id: 1 }); @@ -393,7 +399,7 @@ describe("TeamService", () => { it("should call publish on TeamBilling", async () => { await TeamService.publish(1); - expect(TeamBilling.findAndInit).toHaveBeenCalledWith(1); + expect(mockTeamBillingFactory.findAndInit).toHaveBeenCalledWith(1); expect(mockTeamBilling.publish).toHaveBeenCalled(); }); }); diff --git a/packages/features/ee/teams/services/teamService.ts b/packages/features/ee/teams/services/teamService.ts index 10beaae686..0a9440ae36 100644 --- a/packages/features/ee/teams/services/teamService.ts +++ b/packages/features/ee/teams/services/teamService.ts @@ -1,13 +1,13 @@ import { randomBytes } from "crypto"; -import { TeamBilling } from "@calcom/features/ee/billing/teams"; +import { getTeamBillingServiceFactory } from "@calcom/ee/billing/di/containers/Billing"; import { deleteWorkfowRemindersOfRemovedMember } from "@calcom/features/ee/teams/lib/deleteWorkflowRemindersOfRemovedMember"; import { updateNewTeamMemberEventTypes } from "@calcom/features/ee/teams/lib/queries"; import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; import { WorkflowService } from "@calcom/features/ee/workflows/lib/service/WorkflowService"; +import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service"; import { createAProfileForAnExistingUser } from "@calcom/features/profile/lib/createAProfileForAnExistingUser"; import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; -import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { deleteDomain } from "@calcom/lib/domainManager/organization"; import logger from "@calcom/lib/logger"; @@ -77,7 +77,10 @@ export class TeamService { if (!existingToken) throw new TRPCError({ code: "NOT_FOUND", message: "Invite token not found" }); return { token: existingToken.token, - inviteLink: await TeamService.buildInviteLink(existingToken.token, isOrganizationOrATeamInOrganization), + inviteLink: await TeamService.buildInviteLink( + existingToken.token, + isOrganizationOrATeamInOrganization + ), }; } @@ -115,8 +118,10 @@ export class TeamService { // Step 1: Cancel the external billing subscription first. // If this fails, the entire operation aborts, leaving the team and its data intact. // This prevents a state where the user is billed for a deleted team. - const teamBilling = await TeamBilling.findAndInit(id); - await teamBilling.cancel(); + // const teamBilling = await TeamBillingService.findAndInit(id); + const teamBillingServiceFactory = getTeamBillingServiceFactory(); + const teamBillingService = await teamBillingServiceFactory.findAndInit(id); + await teamBillingService.cancel(); // Step 2: Clean up internal, related data like workflow reminders. try { @@ -164,9 +169,11 @@ export class TeamService { } await Promise.all(deleteMembershipPromises); - - const teamsBilling = await TeamBilling.findAndInitMany(teamIds); - const teamBillingPromises = teamsBilling.map((teamBilling) => teamBilling.updateQuantity()); + const teamBillingServiceFactory = getTeamBillingServiceFactory(); + const teamBillingServices = await teamBillingServiceFactory.findAndInitMany(teamIds); + const teamBillingPromises = teamBillingServices.map((teamBillingService) => + teamBillingService.updateQuantity() + ); await Promise.allSettled(teamBillingPromises); } @@ -215,8 +222,9 @@ export class TeamService { } else throw e; } - const teamBilling = await TeamBilling.findAndInit(verificationToken.teamId); - await teamBilling.updateQuantity(); + const teamBillingServiceFactory = getTeamBillingServiceFactory(); + const teamBillingService = await teamBillingServiceFactory.findAndInit(verificationToken.teamId); + await teamBillingService.updateQuantity(); return verificationToken.team.name; } @@ -349,8 +357,9 @@ export class TeamService { } static async publish(teamId: number) { - const teamBilling = await TeamBilling.findAndInit(teamId); - return teamBilling.publish(); + const teamBillingServiceFactory = getTeamBillingServiceFactory(); + const teamBillingService = await teamBillingServiceFactory.findAndInit(teamId); + return teamBillingService.publish(); } private static async removeMember({ diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index defb3cff46..a797fe80f7 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -115,7 +115,7 @@ export const IS_STRIPE_ENABLED = !!( process.env.STRIPE_PRIVATE_KEY ); /** This has correct value only server side. When you want to use client side, go for IS_TEAM_BILLING_ENABLED_CLIENT. I think we should use the _CLIENT one only everywhere so that it works reliably everywhere on client as well as server */ -export const IS_TEAM_BILLING_ENABLED = IS_STRIPE_ENABLED && HOSTED_CAL_FEATURES; +export const IS_TEAM_BILLING_ENABLED = !!(IS_STRIPE_ENABLED && HOSTED_CAL_FEATURES); export const IS_TEAM_BILLING_ENABLED_CLIENT = !!process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && HOSTED_CAL_FEATURES; diff --git a/packages/trpc/server/routers/loggedInViewer/stripeCustomer.handler.ts b/packages/trpc/server/routers/loggedInViewer/stripeCustomer.handler.ts index c8bc004b47..f2f41f4b59 100644 --- a/packages/trpc/server/routers/loggedInViewer/stripeCustomer.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/stripeCustomer.handler.ts @@ -1,4 +1,4 @@ -import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service"; +import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing"; import { prisma } from "@calcom/prisma"; import { userMetadata } from "@calcom/prisma/zod-utils"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; @@ -16,7 +16,7 @@ export const stripeCustomerHandler = async ({ ctx }: StripeCustomerOptions) => { user: { id: userId }, } = ctx; - const billingService = new StripeBillingService(); + const billingService = getBillingProviderService(); const user = await prisma.user.findUnique({ where: { diff --git a/packages/trpc/server/routers/viewer/credits/buyCredits.handler.ts b/packages/trpc/server/routers/viewer/credits/buyCredits.handler.ts index bbce6372b8..f8dbab855f 100644 --- a/packages/trpc/server/routers/viewer/credits/buyCredits.handler.ts +++ b/packages/trpc/server/routers/viewer/credits/buyCredits.handler.ts @@ -1,4 +1,4 @@ -import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service"; +import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing"; import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; import { TeamService } from "@calcom/features/ee/teams/services/teamService"; import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; @@ -70,7 +70,7 @@ export const buyCreditsHandler = async ({ ctx, input }: BuyCreditsOptions) => { } } - const billingService = new StripeBillingService(); + const billingService = getBillingProviderService(); const { checkoutUrl } = await billingService.createOneTimeCheckout({ priceId: process.env.NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID, diff --git a/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts b/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts index 70c5212308..81d4f1cc4f 100644 --- a/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts +++ b/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts @@ -1,10 +1,9 @@ - import { keyBy } from "lodash"; import type { GetServerSidePropsContext, NextApiResponse } from "next"; import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils"; +import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing"; import { sendChangeOfEmailVerification } from "@calcom/features/auth/lib/verifyEmail"; -import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billing-service"; import { updateNewTeamMemberEventTypes } from "@calcom/features/ee/teams/lib/queries"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { checkUsername } from "@calcom/features/profile/lib/checkUsername"; @@ -37,7 +36,7 @@ type UpdateProfileOptions = { export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) => { const { user } = ctx; - const billingService = new StripeBillingService(); + const billingService = getBillingProviderService(); const userMetadata = handleUserMetadata({ ctx, input }); const locale = input.locale || user.locale; const featuresRepository = new FeaturesRepository(prisma); @@ -312,7 +311,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) username: updatedUser.username ?? "Nameless User", emailFrom: user.email, // We know email has been changed here so we can use input - + emailTo: input.email!, }, }); diff --git a/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts b/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts index 79010b0ab9..52fd7d30fc 100644 --- a/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts @@ -1,4 +1,4 @@ -import { TeamBilling } from "@calcom/ee/billing/teams"; +import { getTeamBillingServiceFactory } from "@calcom/ee/billing/di/containers/Billing"; import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry"; import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions"; import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; @@ -138,8 +138,9 @@ export async function bulkDeleteUsersHandler({ ctx, input }: BulkDeleteUsersHand removeHostAssignment, ]); - const teamBilling = await TeamBilling.findAndInit(currentUserOrgId); - await teamBilling.updateQuantity(); + const teamBillingServiceFactory = getTeamBillingServiceFactory(); + const teamBillingService = await teamBillingServiceFactory.findAndInit(currentUserOrgId); + await teamBillingService.updateQuantity(); return { success: true, diff --git a/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.handler.test.ts index 4f4a20163e..cf62481fdb 100644 --- a/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.handler.test.ts +++ b/packages/trpc/server/routers/viewer/organizations/createWithPaymentIntent.handler.test.ts @@ -45,9 +45,60 @@ const mockSharedStripe = vi.hoisted(() => ({ }, })); -vi.mock("@calcom/features/ee/payments/server/stripe", () => ({ - default: mockSharedStripe, -})); +vi.mock("@calcom/features/ee/billing/di/containers/Billing", () => { + type FakeBillingProvider = { + createCustomer(args: { email: string; metadata?: Record }): Promise<{ stripeCustomerId: string }>; + createPrice(args: { + amount: number; + productId: string; + currency: string; + interval: "month" | "year"; + nickname?: string; + metadata?: Record; + }): Promise<{ priceId: string }>; + createSubscriptionCheckout(args: { + customerId: string; + successUrl: string; + cancelUrl: string; + priceId: string; + quantity: number; + metadata?: Record; + }): Promise<{ checkoutUrl: string; sessionId: string }>; + }; + + const fake: FakeBillingProvider = { + async createCustomer({ email, metadata }) { + const res = await mockSharedStripe.customers.create({ email, metadata }); + return { stripeCustomerId: res.id }; + }, + async createPrice({ amount, productId, currency, interval, nickname, metadata }) { + const res = await mockSharedStripe.prices.create({ + unit_amount: amount, + currency, + product: productId, + recurring: { interval }, + nickname, + metadata, + }); + return { priceId: res.id }; + }, + async createSubscriptionCheckout({ customerId, successUrl, cancelUrl, priceId, quantity, metadata }) { + const res = await mockSharedStripe.checkout.sessions.create({ + customer: customerId, + line_items: [{ price: priceId, quantity }], + mode: "subscription", + success_url: successUrl, + cancel_url: cancelUrl, + metadata, + }); + return { checkoutUrl: res.url, sessionId: res.id }; + }, + }; + + return { + getBillingProviderService: () => fake as unknown as import("@calcom/features/ee/billing/service/billingProvider/StripeBillingService").StripeBillingService, + }; +}); const mockInput = { onboardingId: "test-onboarding-id", @@ -176,7 +227,7 @@ function expectStripeSubscriptionCreated({ let lastCreatedCustomerId = "null"; let lastCreatedPriceId = "null"; -let lastCreatedSessionId = "null"; +let _lastCreatedSessionId = "null"; const STRIPE_CHECKOUT_URL = `https://stripe.com/checkout`; describe("createWithPaymentIntent handler", () => { @@ -189,7 +240,7 @@ describe("createWithPaymentIntent handler", () => { // Set up the shared Stripe instance mock implementations mockSharedStripe.checkout.sessions.create.mockImplementation(() => { const sessionId = `test-session-id-${uuidv4()}`; - lastCreatedSessionId = sessionId; + _lastCreatedSessionId = sessionId; return { url: STRIPE_CHECKOUT_URL, id: sessionId, diff --git a/packages/trpc/server/routers/viewer/teams/hasActiveTeamPlan.handler.ts b/packages/trpc/server/routers/viewer/teams/hasActiveTeamPlan.handler.ts index 90d5c8fa9e..e78f103361 100644 --- a/packages/trpc/server/routers/viewer/teams/hasActiveTeamPlan.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/hasActiveTeamPlan.handler.ts @@ -1,4 +1,5 @@ -import { InternalTeamBilling } from "@calcom/ee/billing/teams/internal-team-billing"; +import { getTeamBillingServiceFactory } from "@calcom/ee/billing/di/containers/Billing"; +import { SubscriptionStatus } from "@calcom/ee/billing/repository/billing/IBillingRepository"; import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; import { IS_SELF_HOSTED } from "@calcom/lib/constants"; import { prisma } from "@calcom/prisma"; @@ -36,13 +37,19 @@ export const hasActiveTeamPlanHandler = async ({ ctx, input }: HasActiveTeamPlan return { isActive: true, isTrial: false }; } } - const teamBillingService = new InternalTeamBilling(team); + + const teamBillingServiceFactory = getTeamBillingServiceFactory(); + + const teamBillingService = teamBillingServiceFactory.init(team); const subscriptionStatus = await teamBillingService.getSubscriptionStatus(); - if (subscriptionStatus === "active" || subscriptionStatus === "past_due") { + if ( + subscriptionStatus === SubscriptionStatus.ACTIVE || + subscriptionStatus === SubscriptionStatus.PAST_DUE + ) { return { isActive: true, isTrial: false }; } - if (subscriptionStatus === "trialing") { + if (subscriptionStatus === SubscriptionStatus.TRIALING) { isTrial = 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 dcb6e105a7..d23f3cf3e6 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler.ts @@ -1,6 +1,6 @@ import { type TFunction } from "i18next"; -import { TeamBilling } from "@calcom/ee/billing/teams"; +import { getTeamBillingServiceFactory } from "@calcom/ee/billing/di/containers/Billing"; import { UserRepository } from "@calcom/features/users/repositories/UserRepository"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; import logger from "@calcom/lib/logger"; @@ -228,8 +228,9 @@ export const inviteMembersWithNoInviterPermissionCheck = async ( }); } - const teamBilling = TeamBilling.init(team); - await teamBilling.updateQuantity(); + const teamBillingServiceFactory = getTeamBillingServiceFactory(); + const teamBillingService = teamBillingServiceFactory.init(team); + await teamBillingService.updateQuantity(); return { // TODO: Better rename it to invitations only maybe? diff --git a/packages/trpc/server/routers/viewer/teams/skipTeamTrials.handler.ts b/packages/trpc/server/routers/viewer/teams/skipTeamTrials.handler.ts index 433eaee3a7..945403fb33 100644 --- a/packages/trpc/server/routers/viewer/teams/skipTeamTrials.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/skipTeamTrials.handler.ts @@ -1,4 +1,5 @@ -import { InternalTeamBilling } from "@calcom/ee/billing/teams/internal-team-billing"; +import { getTeamBillingServiceFactory } from "@calcom/ee/billing/di/containers/Billing"; +import { SubscriptionStatus } from "@calcom/ee/billing/repository/billing/IBillingRepository"; import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; import { IS_SELF_HOSTED } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; @@ -35,11 +36,12 @@ export const skipTeamTrialsHandler = async ({ ctx }: SkipTeamTrialsOptions) => { }); for (const team of ownedTeams) { - const teamBillingService = new InternalTeamBilling(team); + const teamBillingServiceFactory = getTeamBillingServiceFactory(); + const teamBillingService = teamBillingServiceFactory.init(team); const subscriptionStatus = await teamBillingService.getSubscriptionStatus(); - if (subscriptionStatus === "trialing") { + if (subscriptionStatus === SubscriptionStatus.TRIALING) { await teamBillingService.endTrial(); log.info(`Ended trial for team ${team.id}`); } diff --git a/packages/trpc/server/routers/viewer/teams/skipTeamTrials.test.ts b/packages/trpc/server/routers/viewer/teams/skipTeamTrials.test.ts index b9b25d0b80..0daf0a8cad 100644 --- a/packages/trpc/server/routers/viewer/teams/skipTeamTrials.test.ts +++ b/packages/trpc/server/routers/viewer/teams/skipTeamTrials.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; -import { InternalTeamBilling } from "@calcom/ee/billing/teams/internal-team-billing"; +import { SubscriptionStatus } from "@calcom/ee/billing/repository/billing/IBillingRepository"; import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; import { prisma } from "@calcom/prisma"; @@ -52,11 +52,11 @@ vi.mock("@calcom/features/membership/repositories/MembershipRepository", () => ( const mockGetSubscriptionStatus = vi.fn(); const mockEndTrial = vi.fn().mockResolvedValue(true); +const mockInit = vi.fn(); -vi.mock("@calcom/ee/billing/teams/internal-team-billing", () => ({ - InternalTeamBilling: vi.fn().mockImplementation(() => ({ - getSubscriptionStatus: mockGetSubscriptionStatus, - endTrial: mockEndTrial, +vi.mock("@calcom/ee/billing/di/containers/Billing", () => ({ + getTeamBillingServiceFactory: vi.fn(() => ({ + init: mockInit, })), })); @@ -70,6 +70,10 @@ describe("skipTeamTrialsHandler", () => { beforeEach(() => { vi.clearAllMocks(); + mockInit.mockReturnValue({ + getSubscriptionStatus: mockGetSubscriptionStatus, + endTrial: mockEndTrial, + }); }); it("should set user's trialEndsAt to null", async () => { @@ -91,15 +95,15 @@ describe("skipTeamTrialsHandler", () => { it("should end trials for all teams where user is OWNER", async () => { // Mock teams where user is owner const mockTeams = [ - { id: 101, name: "Team 1" }, - { id: 102, name: "Team 2" }, - ] as any; + { id: 101, name: "Team 1", isOrganization: false, parentId: null, metadata: null }, + { id: 102, name: "Team 2", isOrganization: false, parentId: null, metadata: null }, + ]; vi.mocked(MembershipRepository.findAllAcceptedTeamMemberships).mockResolvedValueOnce(mockTeams); mockGetSubscriptionStatus - .mockResolvedValueOnce("trialing") // First team is in trial - .mockResolvedValueOnce("active"); // Second team is active + .mockResolvedValueOnce(SubscriptionStatus.TRIALING) // First team is in trial + .mockResolvedValueOnce(SubscriptionStatus.ACTIVE); // Second team is active // @ts-expect-error - simplified context for testing await skipTeamTrialsHandler({ ctx: mockCtx, input: {} }); @@ -110,9 +114,9 @@ describe("skipTeamTrialsHandler", () => { role: "OWNER", }); - expect(InternalTeamBilling).toHaveBeenCalledTimes(2); - expect(InternalTeamBilling).toHaveBeenNthCalledWith(1, mockTeams[0]); - expect(InternalTeamBilling).toHaveBeenNthCalledWith(2, mockTeams[1]); + expect(mockInit).toHaveBeenCalledTimes(2); + expect(mockInit).toHaveBeenNthCalledWith(1, mockTeams[0]); + expect(mockInit).toHaveBeenNthCalledWith(2, mockTeams[1]); expect(mockGetSubscriptionStatus).toHaveBeenCalledTimes(2); expect(mockEndTrial).toHaveBeenCalledTimes(1); diff --git a/turbo.json b/turbo.json index eae6690ea8..1f3be4b7e5 100644 --- a/turbo.json +++ b/turbo.json @@ -183,6 +183,7 @@ "STRIPE_TEAM_MONTHLY_PRICE_ID", "STRIPE_TEAM_PRODUCT_ID", "STRIPE_ORG_MONTHLY_PRICE_ID", + "STRIPE_ORG_PRODUCT_ID", "ORG_MONTHLY_CREDITS", "TANDEM_BASE_URL", "TANDEM_CLIENT_ID", diff --git a/vitest.config.ts b/vitest.config.ts index c66b8d534d..e194a85bd5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,4 +21,5 @@ function setEnvVariablesThatAreUsedBeforeSetup() { // With same env variable, we can test both non org and org booking scenarios process.env.NEXT_PUBLIC_WEBAPP_URL = "http://app.cal.local:3000"; process.env.CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY = "UNIT_TEST_ENCRYPTION_KEY"; + process.env.STRIPE_PRIVATE_KEY = process.env.STRIPE_PRIVATE_KEY || "sk_test_dummy_unit_test_key"; }