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 <j.auyeung419@gmail.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 <j.auyeung419@gmail.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 <j.auyeung419@gmail.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 <j.auyeung419@gmail.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 <j.auyeung419@gmail.com>

* Address feedback

* fix: update teamService integration test to mock new DI factory pattern

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: remove duplicate imports in credit-service.test.ts

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.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 <j.auyeung419@gmail.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 <j.auyeung419@gmail.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 <j.auyeung419@gmail.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>
This commit is contained in:
Joe Au-Yeung
2025-11-19 10:21:22 -05:00
committed by GitHub
co-authored by cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Syed Ali Shahbaz
parent a7352cf587
commit 7e4d9e26c6
61 changed files with 1416 additions and 753 deletions
@@ -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);
+8 -7
View File
@@ -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,
+10 -6
View File
@@ -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,
+2 -2
View File
@@ -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,
@@ -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;
@@ -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,
+2 -2
View File
@@ -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<TClass extends new (deps: any) => any>(
}
};
}
export { createContainer, createModule, type Container, type Module };
export { createContainer, createModule, type Container, type Module, type ResolveFunction };
@@ -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 };
}
@@ -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,
@@ -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)
+15 -13
View File
@@ -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<unknown>[] = [
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;
@@ -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<TeamBillingServiceFactory>(DI_TOKENS.TEAM_BILLING_SERVICE_FACTORY);
}
export function getBillingProviderService(): StripeBillingService {
return billingContainer.get<StripeBillingService>(DI_TOKENS.BILLING_PROVIDER_SERVICE);
}
export function getTeamBillingDataRepository(): ITeamBillingDataRepository {
return billingContainer.get<ITeamBillingDataRepository>(DI_TOKENS.TEAM_BILLING_DATA_REPOSITORY);
}
@@ -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);
},
};
@@ -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);
},
};
@@ -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);
},
};
@@ -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);
},
};
@@ -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);
},
};
@@ -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,
};
+12
View File
@@ -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"),
};
@@ -0,0 +1,16 @@
import type { BillingRecord, IBillingRepository, IBillingRepositoryCreateArgs } from "./IBillingRepository";
export class StubBillingRepository implements IBillingRepository {
async create(args: IBillingRepositoryCreateArgs): Promise<BillingRecord> {
// 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,
};
}
}
@@ -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);
});
});
});
@@ -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);
}
}
@@ -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<TeamBillingType>;
findBySubscriptionId(subscriptionId: string): Promise<TeamBillingType>;
findBySubscriptionId(subscriptionId: string): Promise<TeamBillingType | null>;
findMany(teamIds: number[]): Promise<TeamBillingType[]>;
}
@@ -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 });
}
}
@@ -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<TeamBillingType> {
return this.stubTeam;
}
async findMany(): Promise<TeamBillingType[]> {
return [];
}
}
@@ -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<boolean>;
handleSubscriptionCancel(subscriptionId: string): Promise<void>;
handleSubscriptionCreation(subscriptionId: string): Promise<void>;
@@ -57,7 +59,7 @@ export interface BillingService {
metadata?: Record<string, string | number>;
}): Promise<{ priceId: string }>;
getPrice(priceId: string): Promise<Stripe.Price | null>;
getSubscriptionStatus(subscriptionId: string): Promise<Stripe.Subscription.Status | null>;
getSubscriptionStatus(subscriptionId: string): Promise<SubscriptionStatus | null>;
getCheckoutSession(checkoutSessionId: string): Promise<Stripe.Checkout.Session | null>;
getCustomer(customerId: string): Promise<Stripe.Customer | Stripe.DeletedCustomer | null>;
@@ -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<Stripe>;
beforeEach(() => {
stripeBillingService = new StripeBillingService();
stripeMock = {
subscriptions: {
cancel: vi.fn(),
retrieve: vi.fn(),
update: vi.fn(),
} as Partial<Stripe.SubscriptionsResource>,
checkout: {
sessions: {
retrieve: vi.fn(),
} as Partial<Stripe.Checkout.SessionsResource>,
} as Partial<Stripe.CheckoutResource>,
};
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);
});
});
@@ -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<BillingService["createCustomer"]>[0]) {
async createCustomer(args: Parameters<IBillingProviderService["createCustomer"]>[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<BillingService["createPaymentIntent"]>[0]) {
async createPaymentIntent(args: Parameters<IBillingProviderService["createPaymentIntent"]>[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<BillingService["createSubscriptionCheckout"]>[0]) {
async createSubscriptionCheckout(
args: Parameters<IBillingProviderService["createSubscriptionCheckout"]>[0]
) {
const {
customerId,
successUrl,
@@ -110,7 +108,7 @@ export class StripeBillingService implements BillingService {
};
}
async createPrice(args: Parameters<BillingService["createPrice"]>[0]) {
async createPrice(args: Parameters<IBillingProviderService["createPrice"]>[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<BillingService["handleSubscriptionUpdate"]>[0]) {
async handleSubscriptionUpdate(args: Parameters<IBillingProviderService["handleSubscriptionUpdate"]>[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<BillingService["updateCustomer"]>[0]) {
async updateCustomer(args: Parameters<IBillingProviderService["updateCustomer"]>[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<string, SubscriptionStatus> = {
active: SubscriptionStatus.ACTIVE,
@@ -239,5 +240,5 @@ export class StripeBillingService implements BillingService {
}
return status || SubscriptionStatus.ACTIVE;
};
}
}
@@ -1,5 +1,10 @@
import type { Team } from "@calcom/prisma/client";
import {
SubscriptionStatus,
IBillingRepositoryCreateArgs,
} from "../../repository/billing/IBillingRepository";
export type TeamBillingInput = Pick<Team, "id" | "parentId" | "metadata" | "isOrganization">;
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<void>;
publish(): Promise<TeamBillingPublishResponse>;
downgrade(): Promise<void>;
updateQuantity(): Promise<void>;
getSubscriptionStatus(): Promise<SubscriptionStatus | null>;
endTrial(): Promise<boolean>;
saveTeamBilling(args: IBillingRepositoryCreateArgs): Promise<void>;
}
@@ -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<void> {
// Stub implementation - no-op
}
async publish(): Promise<TeamBillingPublishResponse> {
return {
redirectUrl: null,
status: TeamBillingPublishResponseStatus.SUCCESS,
};
}
async downgrade(): Promise<void> {
// Stub implementation - no-op
}
async updateQuantity(): Promise<void> {
// Stub implementation - no-op
}
async getSubscriptionStatus() {
return null;
}
async endTrial() {
return true;
}
async saveTeamBilling() {}
}
@@ -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);
});
});
});
@@ -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"
);
});
});
});
@@ -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<TeamBillingInput, "metadata"> & {
metadata: NonNullable<z.infer<typeof teamPaymentMetadataSchema>>;
};
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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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<typeof BillingRepositoryFactory.getRepository>
);
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<typeof BillingRepositoryFactory.getRepository>
);
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<typeof BillingRepositoryFactory.getRepository>
);
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<typeof BillingRepositoryFactory.getRepository>
);
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"
);
});
@@ -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`);
}
}
@@ -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 },
});
});
});
});
@@ -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 });
}
}
@@ -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);
});
});
});
@@ -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",
@@ -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;
}
@@ -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,
@@ -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();
});
@@ -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();
});
});
@@ -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({
+1 -1
View File
@@ -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;
@@ -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: {
@@ -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,
@@ -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!,
},
});
@@ -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,
@@ -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<string, unknown> }): Promise<{ stripeCustomerId: string }>;
createPrice(args: {
amount: number;
productId: string;
currency: string;
interval: "month" | "year";
nickname?: string;
metadata?: Record<string, unknown>;
}): Promise<{ priceId: string }>;
createSubscriptionCheckout(args: {
customerId: string;
successUrl: string;
cancelUrl: string;
priceId: string;
quantity: number;
metadata?: Record<string, unknown>;
}): 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,
@@ -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;
}
}
@@ -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?
@@ -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}`);
}
@@ -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);
+1
View File
@@ -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",
+1
View File
@@ -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";
}