Files
calendar/packages/features/ee/billing/active-user/services/ActiveUserBillingService.ts
T
sean-brydonGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
d52e2d9fad feat: active user billing (#27867)
* factory and statergie

* chore: use correct method of DI

* feat: add onchagne

* add logic to HWM stat

* add webhook resolver methods to each statergy

* move seat tracking + webhooks over to own statergy

* Move to factory base approach

* move logic to correct class

* rename create -> createByTeamId

* fix: remove debug `true ||` overrides from IS_STRIPE_ENABLED and IS_TEAM_BILLING_ENABLED

Remove accidentally committed debug overrides that short-circuited
IS_STRIPE_ENABLED and IS_TEAM_BILLING_ENABLED to always be true,
bypassing Stripe credential checks. This would break self-hosted
instances without Stripe configured.

Identified by cubic (https://cubic.dev)

Co-Authored-By: unknown <>

* feat: active user billing

* add tests

* UI side for users on active billing

* use correct period of stripe sub

* feat: claude feedback

* fix: skip Stripe sync for canceled/expired subscriptions to prevent repeated API calls

Co-Authored-By: unknown <>

* feat: feedback

* feat: only render when active users mode is set

* fix type error

* fix: constants + feature flags

* fix: default to null in tests

* chore: use seats in test

* fix: use node:crypto protocol for Node.js builtin imports

Co-Authored-By: sean@cal.com <Sean@brydon.io>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-12 13:57:32 +00:00

208 lines
6.1 KiB
TypeScript

import type { ActiveUserBillingRepository } from "../repositories/ActiveUserBillingRepository";
export interface ActiveUsersBreakdown {
activeUsers: Array<{
id: number;
email: string;
name: string | null;
activeAs: "host" | "attendee";
}>;
totalMembers: number;
activeHosts: number;
activeAttendees: number;
}
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
);
}
/**
* Get only active users (hosts + attendees) for an org.
* Inactive members are excluded from the result to keep the payload small.
*/
async getActiveUsersForOrg(
orgId: number,
periodStart: Date,
periodEnd: Date
): Promise<ActiveUsersBreakdown> {
const memberDetails =
await this.deps.activeUserBillingRepository.getOrgMemberDetailsByOrgId(orgId);
if (memberDetails.length === 0) {
return { activeUsers: [], totalMembers: 0, activeHosts: 0, activeAttendees: 0 };
}
const emails = memberDetails.map((m) => m.email);
const memberByEmail = new Map(memberDetails.map((m) => [m.email, m]));
const activeHosts = await this.deps.activeUserBillingRepository.getActiveUsersAsHost(
emails,
periodStart,
periodEnd
);
const activeHostEmails = new Set(activeHosts.map((h) => h.email));
const nonHostEmails = emails.filter((e) => !activeHostEmails.has(e));
const activeAttendees =
nonHostEmails.length > 0
? await this.deps.activeUserBillingRepository.getActiveUsersAsAttendee(
nonHostEmails,
periodStart,
periodEnd
)
: [];
const activeAttendeeEmails = new Set(activeAttendees.map((a) => a.email));
const activeUsers: ActiveUsersBreakdown["activeUsers"] = [];
for (const email of Array.from(activeHostEmails)) {
const m = memberByEmail.get(email);
if (m) activeUsers.push({ id: m.id, email: m.email, name: m.name, activeAs: "host" });
}
for (const email of Array.from(activeAttendeeEmails)) {
const m = memberByEmail.get(email);
if (m) activeUsers.push({ id: m.id, email: m.email, name: m.name, activeAs: "attendee" });
}
return {
activeUsers,
totalMembers: memberDetails.length,
activeHosts: activeHostEmails.size,
activeAttendees: activeAttendeeEmails.size,
};
}
async getBookingsForUser(
userId: number,
email: string,
activeAs: "host" | "attendee",
periodStart: Date,
periodEnd: Date
): Promise<
Array<{
id: number;
uid: string;
title: string;
startTime: Date;
endTime: Date;
otherParty: string;
}>
> {
if (activeAs === "host") {
const bookings = await this.deps.activeUserBillingRepository.getBookingsByHostUserId(
userId,
periodStart,
periodEnd
);
return bookings.map((b) => ({
id: b.id,
uid: b.uid,
title: b.title,
startTime: b.startTime,
endTime: b.endTime,
otherParty: b.attendees.map((a) => a.name || a.email).join(", "),
}));
}
const bookings = await this.deps.activeUserBillingRepository.getBookingsByAttendeeEmail(
email,
periodStart,
periodEnd
);
return bookings.map((b) => ({
id: b.id,
uid: b.uid,
title: b.title,
startTime: b.startTime,
endTime: b.endTime,
otherParty: b.user ? b.user.name || b.user.email : "-",
}));
}
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;
}
}