diff --git a/apps/api/v2/src/modules/billing/services/billing.service.ts b/apps/api/v2/src/modules/billing/services/billing.service.ts index 7d326c671e..eeec3a6bbd 100644 --- a/apps/api/v2/src/modules/billing/services/billing.service.ts +++ b/apps/api/v2/src/modules/billing/services/billing.service.ts @@ -1,3 +1,4 @@ +import { getActiveUserBillingService } from "@calcom/platform-libraries/organizations"; import { getIncrementUsageIdempotencyKey, getIncrementUsageJobTag } from "@calcom/platform-libraries/tasker"; import { InjectQueue } from "@nestjs/bull"; import { @@ -342,36 +343,13 @@ export class BillingService implements IBillingService, OnModuleDestroy { invoiceStart: Date, invoiceEnd: Date ): Promise { - const managedUsersEmails = - await this.usersRepository.getOrgsManagedUserEmailsBySubscriptionId(subscriptionId); - - if (!managedUsersEmails) return 0; - if (!invoiceStart || !invoiceEnd) { this.logger.log("Invoice period start or end date is null"); return 0; } - const activeManagedUserEmailsAsHost = await this.usersRepository.getActiveManagedUsersAsHost( - subscriptionId, - invoiceStart, - invoiceEnd - ); - - const activeHostEmails = activeManagedUserEmailsAsHost.map((email) => email.email); - const notActiveHostEmails = managedUsersEmails - .filter((email) => !activeHostEmails.includes(email.email)) - .map((email) => email.email); - - if (notActiveHostEmails.length === 0) return activeManagedUserEmailsAsHost.length; - - const activeManagedUserEmailsAsAttendee = await this.usersRepository.getActiveManagedUsersAsAttendee( - notActiveHostEmails, - invoiceStart, - invoiceEnd - ); - - return activeManagedUserEmailsAsAttendee.length + activeManagedUserEmailsAsHost.length; + const activeUserBillingService = getActiveUserBillingService(); + return activeUserBillingService.getActiveUserCountForPlatformOrg(subscriptionId, invoiceStart, invoiceEnd); } async updateStripeSubscriptionForTeam(teamId: number, plan: PlatformPlan): Promise { diff --git a/packages/features/di/tokens.ts b/packages/features/di/tokens.ts index 3f167eefea..bb0f2d8a92 100644 --- a/packages/features/di/tokens.ts +++ b/packages/features/di/tokens.ts @@ -1,5 +1,6 @@ import { BOOKING_DI_TOKENS } from "@calcom/features/bookings/di/tokens"; import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens"; +import { ACTIVE_USER_BILLING_DI_TOKENS } from "@calcom/features/ee/billing/active-user/di/tokens"; import { FEATURE_OPT_IN_DI_TOKENS } from "@calcom/features/feature-opt-in/di/tokens"; import { FLAGS_DI_TOKENS } from "@calcom/features/flags/di/tokens"; import { HASHED_LINK_DI_TOKENS } from "@calcom/features/hashedLink/di/tokens"; @@ -83,6 +84,7 @@ export const DI_TOKENS = { ...HASHED_LINK_DI_TOKENS, ...OAUTH_DI_TOKENS, ...WATCHLIST_DI_TOKENS, + ...ACTIVE_USER_BILLING_DI_TOKENS, ...ORGANIZATION_DI_TOKENS, ...WEBHOOK_TOKENS, }; diff --git a/packages/features/ee/billing/active-user/di/ActiveUserBillingRepository.module.ts b/packages/features/ee/billing/active-user/di/ActiveUserBillingRepository.module.ts new file mode 100644 index 0000000000..d6a07dce38 --- /dev/null +++ b/packages/features/ee/billing/active-user/di/ActiveUserBillingRepository.module.ts @@ -0,0 +1,22 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; +import { ActiveUserBillingRepository } from "@calcom/features/ee/billing/active-user/repositories/ActiveUserBillingRepository"; + +import { ACTIVE_USER_BILLING_DI_TOKENS } from "./tokens"; + +const thisModule = createModule(); +const token = ACTIVE_USER_BILLING_DI_TOKENS.ACTIVE_USER_BILLING_REPOSITORY; +const moduleToken = ACTIVE_USER_BILLING_DI_TOKENS.ACTIVE_USER_BILLING_REPOSITORY_MODULE; + +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: ActiveUserBillingRepository, + dep: prismaModuleLoader, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; diff --git a/packages/features/ee/billing/active-user/di/ActiveUserBillingService.container.ts b/packages/features/ee/billing/active-user/di/ActiveUserBillingService.container.ts new file mode 100644 index 0000000000..6a442b0ea9 --- /dev/null +++ b/packages/features/ee/billing/active-user/di/ActiveUserBillingService.container.ts @@ -0,0 +1,11 @@ +import { createContainer } from "@calcom/features/di/di"; +import type { ActiveUserBillingService } from "@calcom/features/ee/billing/active-user/services/ActiveUserBillingService"; + +import { moduleLoader as activeUserBillingServiceModuleLoader } from "./ActiveUserBillingService.module"; + +const container = createContainer(); + +export function getActiveUserBillingService(): ActiveUserBillingService { + activeUserBillingServiceModuleLoader.loadModule(container); + return container.get(activeUserBillingServiceModuleLoader.token); +} diff --git a/packages/features/ee/billing/active-user/di/ActiveUserBillingService.module.ts b/packages/features/ee/billing/active-user/di/ActiveUserBillingService.module.ts new file mode 100644 index 0000000000..07d58f6dad --- /dev/null +++ b/packages/features/ee/billing/active-user/di/ActiveUserBillingService.module.ts @@ -0,0 +1,24 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { ActiveUserBillingService } from "@calcom/features/ee/billing/active-user/services/ActiveUserBillingService"; + +import { moduleLoader as activeUserBillingRepositoryModuleLoader } from "./ActiveUserBillingRepository.module"; +import { ACTIVE_USER_BILLING_DI_TOKENS } from "./tokens"; + +const thisModule = createModule(); +const token = ACTIVE_USER_BILLING_DI_TOKENS.ACTIVE_USER_BILLING_SERVICE; +const moduleToken = ACTIVE_USER_BILLING_DI_TOKENS.ACTIVE_USER_BILLING_SERVICE_MODULE; + +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: ActiveUserBillingService, + depsMap: { + activeUserBillingRepository: activeUserBillingRepositoryModuleLoader, + }, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; diff --git a/packages/features/ee/billing/active-user/di/tokens.ts b/packages/features/ee/billing/active-user/di/tokens.ts new file mode 100644 index 0000000000..fc56487eae --- /dev/null +++ b/packages/features/ee/billing/active-user/di/tokens.ts @@ -0,0 +1,6 @@ +export const ACTIVE_USER_BILLING_DI_TOKENS = { + ACTIVE_USER_BILLING_REPOSITORY: Symbol("ActiveUserBillingRepository"), + ACTIVE_USER_BILLING_REPOSITORY_MODULE: Symbol("ActiveUserBillingRepositoryModule"), + ACTIVE_USER_BILLING_SERVICE: Symbol("ActiveUserBillingService"), + ACTIVE_USER_BILLING_SERVICE_MODULE: Symbol("ActiveUserBillingServiceModule"), +}; diff --git a/packages/features/ee/billing/active-user/repositories/ActiveUserBillingRepository.ts b/packages/features/ee/billing/active-user/repositories/ActiveUserBillingRepository.ts new file mode 100644 index 0000000000..defb2b810f --- /dev/null +++ b/packages/features/ee/billing/active-user/repositories/ActiveUserBillingRepository.ts @@ -0,0 +1,141 @@ +import type { PrismaClient } from "@calcom/prisma/client"; + +export class ActiveUserBillingRepository { + constructor(private readonly prismaClient: PrismaClient) {} + + /** + * Get all managed user emails for a platform org identified by its PlatformBilling subscription ID. + */ + async getManagedUserEmailsBySubscriptionId(subscriptionId: string): Promise<{ email: string }[]> { + return this.prismaClient.user.findMany({ + distinct: ["email"], + where: { + isPlatformManaged: true, + profiles: { + some: { + organization: { + platformBilling: { + subscriptionId, + }, + }, + }, + }, + }, + select: { + email: true, + }, + }); + } + + /** + * Get all member emails for a regular organization identified by its org (team) ID. + * Uses the Membership table to find accepted org members. + */ + async getOrgMemberEmailsByOrgId(orgId: number): Promise<{ email: string }[]> { + return this.prismaClient.user.findMany({ + distinct: ["email"], + where: { + teams: { + some: { + teamId: orgId, + accepted: true, + }, + }, + }, + select: { + email: true, + }, + }); + } + + /** + * Find platform-managed users (by email) who hosted at least one booking in the given period. + * Filters on isPlatformManaged to match only platform org users. + */ + async getActivePlatformUsersAsHost( + subscriptionId: string, + periodStart: Date, + periodEnd: Date + ): Promise<{ email: string }[]> { + return this.prismaClient.user.findMany({ + distinct: ["email"], + where: { + isPlatformManaged: true, + profiles: { + some: { + organization: { + platformBilling: { + subscriptionId, + }, + }, + }, + }, + bookings: { + some: { + userId: { not: null }, + startTime: { + gte: periodStart, + lte: periodEnd, + }, + }, + }, + }, + select: { + email: true, + }, + }); + } + + /** + * Find users (by email) who hosted at least one booking in the given period. + */ + async getActiveUsersAsHost( + userEmails: string[], + periodStart: Date, + periodEnd: Date + ): Promise<{ email: string }[]> { + return this.prismaClient.user.findMany({ + distinct: ["email"], + where: { + email: { in: userEmails }, + bookings: { + some: { + userId: { not: null }, + startTime: { + gte: periodStart, + lte: periodEnd, + }, + }, + }, + }, + select: { + email: true, + }, + }); + } + + /** + * Find users (by email) who attended at least one booking in the given period. + */ + async getActiveUsersAsAttendee( + userEmails: string[], + periodStart: Date, + periodEnd: Date + ): Promise<{ email: string }[]> { + return this.prismaClient.attendee.findMany({ + distinct: ["email"], + where: { + email: { in: userEmails }, + booking: { + startTime: { + gte: periodStart, + lte: periodEnd, + }, + }, + }, + select: { + email: true, + }, + }); + } +} diff --git a/packages/features/ee/billing/active-user/services/ActiveUserBillingService.test.ts b/packages/features/ee/billing/active-user/services/ActiveUserBillingService.test.ts new file mode 100644 index 0000000000..50df86a4d8 --- /dev/null +++ b/packages/features/ee/billing/active-user/services/ActiveUserBillingService.test.ts @@ -0,0 +1,203 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ActiveUserBillingRepository } from "../repositories/ActiveUserBillingRepository"; +import { ActiveUserBillingService } from "./ActiveUserBillingService"; + +function createMockRepository(): { + [K in keyof ActiveUserBillingRepository]: ReturnType; +} { + return { + getManagedUserEmailsBySubscriptionId: vi.fn().mockResolvedValue([]), + getOrgMemberEmailsByOrgId: vi.fn().mockResolvedValue([]), + getActivePlatformUsersAsHost: vi.fn().mockResolvedValue([]), + getActiveUsersAsHost: vi.fn().mockResolvedValue([]), + getActiveUsersAsAttendee: vi.fn().mockResolvedValue([]), + }; +} + +describe("ActiveUserBillingService", () => { + let service: ActiveUserBillingService; + let mockRepo: ReturnType; + + const periodStart = new Date("2026-01-01T00:00:00Z"); + const periodEnd = new Date("2026-02-01T00:00:00Z"); + + beforeEach(() => { + mockRepo = createMockRepository(); + service = new ActiveUserBillingService({ + activeUserBillingRepository: mockRepo as unknown as ActiveUserBillingRepository, + }); + }); + + describe("getActiveUserCountForPlatformOrg", () => { + const subscriptionId = "sub_abc123"; + + it("returns 0 when there are no managed users", async () => { + mockRepo.getManagedUserEmailsBySubscriptionId.mockResolvedValue([]); + + const count = await service.getActiveUserCountForPlatformOrg(subscriptionId, periodStart, periodEnd); + + expect(count).toBe(0); + expect(mockRepo.getManagedUserEmailsBySubscriptionId).toHaveBeenCalledWith(subscriptionId); + expect(mockRepo.getActivePlatformUsersAsHost).not.toHaveBeenCalled(); + expect(mockRepo.getActiveUsersAsAttendee).not.toHaveBeenCalled(); + }); + + it("counts users who were active as hosts only", async () => { + mockRepo.getManagedUserEmailsBySubscriptionId.mockResolvedValue([ + { email: "alice@test.com" }, + { email: "bob@test.com" }, + { email: "charlie@test.com" }, + ]); + mockRepo.getActivePlatformUsersAsHost.mockResolvedValue([ + { email: "alice@test.com" }, + { email: "bob@test.com" }, + ]); + mockRepo.getActiveUsersAsAttendee.mockResolvedValue([]); + + const count = await service.getActiveUserCountForPlatformOrg(subscriptionId, periodStart, periodEnd); + + expect(count).toBe(2); + expect(mockRepo.getActivePlatformUsersAsHost).toHaveBeenCalledWith( + subscriptionId, + periodStart, + periodEnd + ); + expect(mockRepo.getActiveUsersAsAttendee).toHaveBeenCalledWith( + ["charlie@test.com"], + periodStart, + periodEnd + ); + }); + + it("counts users who were active as attendees only", async () => { + mockRepo.getManagedUserEmailsBySubscriptionId.mockResolvedValue([ + { email: "alice@test.com" }, + { email: "bob@test.com" }, + ]); + mockRepo.getActivePlatformUsersAsHost.mockResolvedValue([]); + mockRepo.getActiveUsersAsAttendee.mockResolvedValue([{ email: "alice@test.com" }]); + + const count = await service.getActiveUserCountForPlatformOrg(subscriptionId, periodStart, periodEnd); + + expect(count).toBe(1); + }); + + it("counts combined hosts and attendees without double-counting", async () => { + mockRepo.getManagedUserEmailsBySubscriptionId.mockResolvedValue([ + { email: "alice@test.com" }, + { email: "bob@test.com" }, + { email: "charlie@test.com" }, + ]); + mockRepo.getActivePlatformUsersAsHost.mockResolvedValue([{ email: "alice@test.com" }]); + mockRepo.getActiveUsersAsAttendee.mockResolvedValue([{ email: "bob@test.com" }]); + + const count = await service.getActiveUserCountForPlatformOrg(subscriptionId, periodStart, periodEnd); + + expect(count).toBe(2); + // Should only check non-host emails for attendee activity + expect(mockRepo.getActiveUsersAsAttendee).toHaveBeenCalledWith( + ["bob@test.com", "charlie@test.com"], + periodStart, + periodEnd + ); + }); + + it("returns count of all users when everyone is a host", async () => { + mockRepo.getManagedUserEmailsBySubscriptionId.mockResolvedValue([ + { email: "alice@test.com" }, + { email: "bob@test.com" }, + ]); + mockRepo.getActivePlatformUsersAsHost.mockResolvedValue([ + { email: "alice@test.com" }, + { email: "bob@test.com" }, + ]); + + const count = await service.getActiveUserCountForPlatformOrg(subscriptionId, periodStart, periodEnd); + + expect(count).toBe(2); + // Should skip attendee check when all users are already hosts + expect(mockRepo.getActiveUsersAsAttendee).not.toHaveBeenCalled(); + }); + + it("returns 0 when no users are active", async () => { + mockRepo.getManagedUserEmailsBySubscriptionId.mockResolvedValue([ + { email: "alice@test.com" }, + { email: "bob@test.com" }, + ]); + mockRepo.getActivePlatformUsersAsHost.mockResolvedValue([]); + mockRepo.getActiveUsersAsAttendee.mockResolvedValue([]); + + const count = await service.getActiveUserCountForPlatformOrg(subscriptionId, periodStart, periodEnd); + + expect(count).toBe(0); + }); + }); + + describe("getActiveUserCountForOrg", () => { + const orgId = 42; + + it("returns 0 when org has no members", async () => { + mockRepo.getOrgMemberEmailsByOrgId.mockResolvedValue([]); + + const count = await service.getActiveUserCountForOrg(orgId, periodStart, periodEnd); + + expect(count).toBe(0); + expect(mockRepo.getOrgMemberEmailsByOrgId).toHaveBeenCalledWith(orgId); + expect(mockRepo.getActiveUsersAsHost).not.toHaveBeenCalled(); + }); + + it("counts org members who were active as hosts", async () => { + mockRepo.getOrgMemberEmailsByOrgId.mockResolvedValue([ + { email: "member1@org.com" }, + { email: "member2@org.com" }, + ]); + mockRepo.getActiveUsersAsHost.mockResolvedValue([{ email: "member1@org.com" }]); + mockRepo.getActiveUsersAsAttendee.mockResolvedValue([]); + + const count = await service.getActiveUserCountForOrg(orgId, periodStart, periodEnd); + + expect(count).toBe(1); + expect(mockRepo.getActiveUsersAsHost).toHaveBeenCalledWith( + ["member1@org.com", "member2@org.com"], + periodStart, + periodEnd + ); + }); + + it("counts org members who were active as attendees", async () => { + mockRepo.getOrgMemberEmailsByOrgId.mockResolvedValue([ + { email: "member1@org.com" }, + { email: "member2@org.com" }, + { email: "member3@org.com" }, + ]); + mockRepo.getActiveUsersAsHost.mockResolvedValue([{ email: "member1@org.com" }]); + mockRepo.getActiveUsersAsAttendee.mockResolvedValue([{ email: "member2@org.com" }]); + + const count = await service.getActiveUserCountForOrg(orgId, periodStart, periodEnd); + + expect(count).toBe(2); + }); + + it("handles large org with many members efficiently", async () => { + const emails = Array.from({ length: 100 }, (_, i) => ({ email: `user${i}@org.com` })); + mockRepo.getOrgMemberEmailsByOrgId.mockResolvedValue(emails); + + const hostEmails = emails.slice(0, 30); + mockRepo.getActiveUsersAsHost.mockResolvedValue(hostEmails); + + const attendeeEmails = emails.slice(30, 50).map((e) => ({ email: e.email })); + mockRepo.getActiveUsersAsAttendee.mockResolvedValue(attendeeEmails); + + const count = await service.getActiveUserCountForOrg(orgId, periodStart, periodEnd); + + expect(count).toBe(50); + // Attendee check should only receive the 70 non-host emails + expect(mockRepo.getActiveUsersAsAttendee).toHaveBeenCalledWith( + emails.slice(30).map((e) => e.email), + periodStart, + periodEnd + ); + }); + }); +}); diff --git a/packages/features/ee/billing/active-user/services/ActiveUserBillingService.ts b/packages/features/ee/billing/active-user/services/ActiveUserBillingService.ts new file mode 100644 index 0000000000..29a9e2fb58 --- /dev/null +++ b/packages/features/ee/billing/active-user/services/ActiveUserBillingService.ts @@ -0,0 +1,106 @@ +import type { ActiveUserBillingRepository } from "../repositories/ActiveUserBillingRepository"; + +export interface IActiveUserBillingServiceDeps { + activeUserBillingRepository: ActiveUserBillingRepository; +} + +export class ActiveUserBillingService { + constructor(private readonly deps: IActiveUserBillingServiceDeps) {} + + /** + * Count active users for a platform organization (uses PlatformBilling subscription ID). + * A user is "active" if they hosted or attended at least one booking in the period OR managed. + * + * Uses the platform-specific host query that filters on isPlatformManaged + subscriptionId + * to ensure only bookings belonging to this org's managed users are counted. + */ + async getActiveUserCountForPlatformOrg( + subscriptionId: string, + periodStart: Date, + periodEnd: Date + ): Promise { + const managedUserEmails = + await this.deps.activeUserBillingRepository.getManagedUserEmailsBySubscriptionId( + subscriptionId + ); + + if (managedUserEmails.length === 0) return 0; + + const activeHosts = + await this.deps.activeUserBillingRepository.getActivePlatformUsersAsHost( + subscriptionId, + periodStart, + periodEnd + ); + + const activeHostEmails = new Set(activeHosts.map((h) => h.email)); + + const nonHostEmails = managedUserEmails + .map((u) => u.email) + .filter((email) => !activeHostEmails.has(email)); + + if (nonHostEmails.length === 0) return activeHostEmails.size; + + const activeAttendees = + await this.deps.activeUserBillingRepository.getActiveUsersAsAttendee( + nonHostEmails, + periodStart, + periodEnd + ); + + return activeHostEmails.size + activeAttendees.length; + } + + /** + * Count active users for a regular organization (uses org/team ID). + * A user is "active" if they hosted or attended at least one booking in the period. + */ + async getActiveUserCountForOrg( + orgId: number, + periodStart: Date, + periodEnd: Date + ): Promise { + const memberEmails = + await this.deps.activeUserBillingRepository.getOrgMemberEmailsByOrgId( + orgId + ); + + return this.countActiveUsers( + memberEmails.map((u) => u.email), + periodStart, + periodEnd + ); + } + + private async countActiveUsers( + candidateEmails: string[], + periodStart: Date, + periodEnd: Date + ): Promise { + if (candidateEmails.length === 0) return 0; + + const activeHosts = + await this.deps.activeUserBillingRepository.getActiveUsersAsHost( + candidateEmails, + periodStart, + periodEnd + ); + + const activeHostEmails = new Set(activeHosts.map((h) => h.email)); + + const nonHostEmails = candidateEmails.filter( + (email) => !activeHostEmails.has(email) + ); + + if (nonHostEmails.length === 0) return activeHostEmails.size; + + const activeAttendees = + await this.deps.activeUserBillingRepository.getActiveUsersAsAttendee( + nonHostEmails, + periodStart, + periodEnd + ); + + return activeHostEmails.size + activeAttendees.length; + } +} diff --git a/packages/platform/libraries/organizations.ts b/packages/platform/libraries/organizations.ts index 4408fff179..fae722e354 100644 --- a/packages/platform/libraries/organizations.ts +++ b/packages/platform/libraries/organizations.ts @@ -12,3 +12,6 @@ export { PlatformOrganizationBillingSyncTasker } from "@calcom/features/ee/organ export { PlatformOrganizationBillingTriggerTasker } from "@calcom/features/ee/organizations/lib/billing/tasker/PlatformOrganizationBillingTriggerTasker"; export { PlatformOrganizationBillingTaskService } from "@calcom/features/ee/organizations/lib/billing/tasker/PlatformOrganizationBillingTaskService"; export type { IBillingProviderService } from "@calcom/features/ee/billing/service/billingProvider/IBillingProviderService"; + +export { getActiveUserBillingService } from "@calcom/features/ee/billing/active-user/di/ActiveUserBillingService.container"; +export { ActiveUserBillingService } from "@calcom/features/ee/billing/active-user/services/ActiveUserBillingService";