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>
198 lines
5.2 KiB
TypeScript
198 lines
5.2 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
|
|
import { prisma } from "@calcom/prisma";
|
|
import type { Team, User } from "@calcom/prisma/client";
|
|
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
|
|
import { ActiveUserBillingRepository } from "../repositories/ActiveUserBillingRepository";
|
|
import { ActiveUserBillingService } from "./ActiveUserBillingService";
|
|
|
|
const TEST_PREFIX = `aub-int-${Date.now()}`;
|
|
|
|
let org: Team;
|
|
let users: User[] = [];
|
|
let service: ActiveUserBillingService;
|
|
|
|
const periodStart = new Date("2026-01-01T00:00:00Z");
|
|
const periodEnd = new Date("2026-02-01T00:00:00Z");
|
|
|
|
async function createUser(suffix: string): Promise<User> {
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
email: `${TEST_PREFIX}-${suffix}@example.com`,
|
|
username: `${TEST_PREFIX}-${suffix}`,
|
|
name: `Test ${suffix}`,
|
|
},
|
|
});
|
|
users.push(user);
|
|
return user;
|
|
}
|
|
|
|
async function createBookingForHost(
|
|
host: User,
|
|
startTime: Date,
|
|
endTime: Date,
|
|
attendeeEmail?: string
|
|
): Promise<number> {
|
|
const booking = await prisma.booking.create({
|
|
data: {
|
|
uid: randomUUID(),
|
|
title: `Booking by ${host.name}`,
|
|
startTime,
|
|
endTime,
|
|
userId: host.id,
|
|
status: BookingStatus.ACCEPTED,
|
|
},
|
|
});
|
|
|
|
if (attendeeEmail) {
|
|
await prisma.attendee.create({
|
|
data: {
|
|
email: attendeeEmail,
|
|
name: "Attendee",
|
|
timeZone: "UTC",
|
|
bookingId: booking.id,
|
|
},
|
|
});
|
|
}
|
|
|
|
return booking.id;
|
|
}
|
|
|
|
describe("ActiveUserBillingService Integration", () => {
|
|
const bookingIds: number[] = [];
|
|
|
|
beforeAll(async () => {
|
|
const repo = new ActiveUserBillingRepository(prisma);
|
|
service = new ActiveUserBillingService({
|
|
activeUserBillingRepository: repo,
|
|
});
|
|
|
|
org = await prisma.team.create({
|
|
data: {
|
|
name: `${TEST_PREFIX} Org`,
|
|
slug: TEST_PREFIX,
|
|
isOrganization: true,
|
|
},
|
|
});
|
|
|
|
const alice = await createUser("alice");
|
|
const bob = await createUser("bob");
|
|
const charlie = await createUser("charlie");
|
|
const inactive = await createUser("inactive");
|
|
|
|
for (const user of [alice, bob, charlie, inactive]) {
|
|
await prisma.membership.create({
|
|
data: {
|
|
userId: user.id,
|
|
teamId: org.id,
|
|
role: MembershipRole.MEMBER,
|
|
accepted: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Alice hosts a booking inside the period
|
|
const id1 = await createBookingForHost(
|
|
alice,
|
|
new Date("2026-01-10T10:00:00Z"),
|
|
new Date("2026-01-10T11:00:00Z")
|
|
);
|
|
bookingIds.push(id1);
|
|
|
|
// Bob hosts a booking inside the period, with Charlie as attendee
|
|
const id2 = await createBookingForHost(
|
|
bob,
|
|
new Date("2026-01-15T14:00:00Z"),
|
|
new Date("2026-01-15T15:00:00Z"),
|
|
charlie.email
|
|
);
|
|
bookingIds.push(id2);
|
|
|
|
// Inactive user has a booking OUTSIDE the period (before)
|
|
const id3 = await createBookingForHost(
|
|
inactive,
|
|
new Date("2025-12-20T10:00:00Z"),
|
|
new Date("2025-12-20T11:00:00Z")
|
|
);
|
|
bookingIds.push(id3);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await prisma.attendee.deleteMany({
|
|
where: { bookingId: { in: bookingIds } },
|
|
});
|
|
await prisma.booking.deleteMany({ where: { id: { in: bookingIds } } });
|
|
await prisma.membership.deleteMany({ where: { teamId: org.id } });
|
|
await prisma.team.delete({ where: { id: org.id } });
|
|
for (const user of users) {
|
|
await prisma.user.delete({ where: { id: user.id } }).catch(() => {});
|
|
}
|
|
});
|
|
|
|
it("counts hosts and attendees as active, excludes inactive members", async () => {
|
|
const count = await service.getActiveUserCountForOrg(
|
|
org.id,
|
|
periodStart,
|
|
periodEnd
|
|
);
|
|
|
|
// alice: host, bob: host, charlie: attendee of bob's booking
|
|
// inactive: booking is outside the period
|
|
expect(count).toBe(3);
|
|
});
|
|
|
|
it("returns 0 when period has no bookings", async () => {
|
|
const emptyStart = new Date("2027-01-01T00:00:00Z");
|
|
const emptyEnd = new Date("2027-02-01T00:00:00Z");
|
|
|
|
const count = await service.getActiveUserCountForOrg(
|
|
org.id,
|
|
emptyStart,
|
|
emptyEnd
|
|
);
|
|
|
|
expect(count).toBe(0);
|
|
});
|
|
|
|
it("does not double-count a user who is both host and attendee", async () => {
|
|
// Alice hosts a booking where Bob is attendee -- Bob is already a host too
|
|
const id = await createBookingForHost(
|
|
users[0], // alice
|
|
new Date("2026-01-20T09:00:00Z"),
|
|
new Date("2026-01-20T10:00:00Z"),
|
|
users[1].email // bob as attendee
|
|
);
|
|
bookingIds.push(id);
|
|
|
|
const count = await service.getActiveUserCountForOrg(
|
|
org.id,
|
|
periodStart,
|
|
periodEnd
|
|
);
|
|
|
|
// Still 3: alice (host), bob (host + attendee but counted once), charlie (attendee)
|
|
expect(count).toBe(3);
|
|
});
|
|
|
|
it("includes bookings right at period boundaries", async () => {
|
|
// Booking starting exactly at periodStart
|
|
const id = await createBookingForHost(
|
|
users[3], // inactive user
|
|
new Date("2026-01-01T00:00:00Z"),
|
|
new Date("2026-01-01T01:00:00Z")
|
|
);
|
|
bookingIds.push(id);
|
|
|
|
const count = await service.getActiveUserCountForOrg(
|
|
org.id,
|
|
periodStart,
|
|
periodEnd
|
|
);
|
|
|
|
// Now all 4 are active
|
|
expect(count).toBe(4);
|
|
});
|
|
});
|