d52e2d9fad
* 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>
208 lines
6.1 KiB
TypeScript
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;
|
|
}
|
|
}
|