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:
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
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+18
-6
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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[]>;
|
||||
}
|
||||
+29
@@ -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 [];
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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>;
|
||||
+25
-37
@@ -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);
|
||||
});
|
||||
});
|
||||
+20
-19
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+37
-19
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
+56
-5
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user