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>
282 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|