Files
calendar/packages/features/ee/billing/active-user/services/ActiveUserBillingService.test.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

282 lines
11 KiB
TypeScript

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([]),
getOrgMemberDetailsByOrgId: vi.fn().mockResolvedValue([]),
getActivePlatformUsersAsHost: vi.fn().mockResolvedValue([]),
getActiveUsersAsHost: vi.fn().mockResolvedValue([]),
getActiveUsersAsAttendee: vi.fn().mockResolvedValue([]),
getBookingsByHostUserId: vi.fn().mockResolvedValue([]),
getBookingsByAttendeeEmail: 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
);
});
});
describe("getActiveUsersForOrg", () => {
const orgId = 42;
it("returns empty result when org has no members", async () => {
mockRepo.getOrgMemberDetailsByOrgId.mockResolvedValue([]);
const result = await service.getActiveUsersForOrg(orgId, periodStart, periodEnd);
expect(result.activeUsers).toEqual([]);
expect(result.totalMembers).toBe(0);
expect(result.activeHosts).toBe(0);
expect(result.activeAttendees).toBe(0);
expect(mockRepo.getActiveUsersAsHost).not.toHaveBeenCalled();
});
it("returns only hosts when everyone hosted", async () => {
mockRepo.getOrgMemberDetailsByOrgId.mockResolvedValue([
{ id: 1, email: "alice@org.com", name: "Alice" },
{ id: 2, email: "bob@org.com", name: "Bob" },
]);
mockRepo.getActiveUsersAsHost.mockResolvedValue([
{ email: "alice@org.com" },
{ email: "bob@org.com" },
]);
const result = await service.getActiveUsersForOrg(orgId, periodStart, periodEnd);
expect(result.activeUsers).toEqual([
{ id: 1, email: "alice@org.com", name: "Alice", activeAs: "host" },
{ id: 2, email: "bob@org.com", name: "Bob", activeAs: "host" },
]);
expect(result.totalMembers).toBe(2);
expect(result.activeHosts).toBe(2);
expect(result.activeAttendees).toBe(0);
expect(mockRepo.getActiveUsersAsAttendee).not.toHaveBeenCalled();
});
it("returns only active users (hosts + attendees), excludes inactive", async () => {
mockRepo.getOrgMemberDetailsByOrgId.mockResolvedValue([
{ id: 1, email: "alice@org.com", name: "Alice" },
{ id: 2, email: "bob@org.com", name: "Bob" },
{ id: 3, email: "charlie@org.com", name: "Charlie" },
{ id: 4, email: "diana@org.com", name: null },
]);
mockRepo.getActiveUsersAsHost.mockResolvedValue([{ email: "alice@org.com" }]);
mockRepo.getActiveUsersAsAttendee.mockResolvedValue([{ email: "bob@org.com" }]);
const result = await service.getActiveUsersForOrg(orgId, periodStart, periodEnd);
expect(result.activeUsers).toEqual([
{ id: 1, email: "alice@org.com", name: "Alice", activeAs: "host" },
{ id: 2, email: "bob@org.com", name: "Bob", activeAs: "attendee" },
]);
expect(result.totalMembers).toBe(4);
expect(result.activeHosts).toBe(1);
expect(result.activeAttendees).toBe(1);
});
it("returns empty activeUsers when no one has bookings", async () => {
mockRepo.getOrgMemberDetailsByOrgId.mockResolvedValue([
{ id: 1, email: "alice@org.com", name: "Alice" },
{ id: 2, email: "bob@org.com", name: "Bob" },
]);
mockRepo.getActiveUsersAsHost.mockResolvedValue([]);
mockRepo.getActiveUsersAsAttendee.mockResolvedValue([]);
const result = await service.getActiveUsersForOrg(orgId, periodStart, periodEnd);
expect(result.activeUsers).toEqual([]);
expect(result.totalMembers).toBe(2);
expect(result.activeHosts).toBe(0);
expect(result.activeAttendees).toBe(0);
});
});
});