feat: move platform active billing logic to services (#27704)

* wip

* feat: migrate from v2

* format

* remove plan
This commit is contained in:
sean-brydon
2026-02-06 12:45:40 +00:00
committed by GitHub
parent 8a7cf24f90
commit bb6b99d9b1
10 changed files with 521 additions and 25 deletions
@@ -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<number> {
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<void> {
+2
View File
@@ -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,
};
@@ -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,
};
@@ -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<ActiveUserBillingService>(activeUserBillingServiceModuleLoader.token);
}
@@ -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,
};
@@ -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"),
};
@@ -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,
},
});
}
}
@@ -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<typeof vi.fn>;
} {
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<typeof createMockRepository>;
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
);
});
});
});
@@ -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<number> {
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<number> {
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<number> {
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;
}
}
@@ -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";