Files
calendar/packages/features/ee/billing/active-user/seed-active-user-test.ts
T
sean-brydonGitHubunknown <>unknown <>sean@cal.com <Sean@brydon.io>Devin 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

561 lines
19 KiB
TypeScript

#!/usr/bin/env npx tsx
/**
* Seed script for testing Active User billing
*
* This script creates:
* 1. An organization with 100 members (admin + 99 members)
* 2. Bookings so that 30 members are "active" (hosted or attended a booking)
* 3. Stripe customer + subscription with billingMode = ACTIVE_USERS
* 4. The active-user-billing feature flag
*
* After seeding it runs the ActiveUserBillingService to count active users
* and prints a comparison between created vs counted.
*
* Prerequisites:
* - DATABASE_URL must be set (or .env loaded)
* - STRIPE_PRIVATE_KEY for real Stripe resources (optional)
*
* Usage:
* npx tsx packages/features/ee/billing/active-user/seed-active-user-test.ts
*
* Options:
* --skip-stripe Skip Stripe API calls (use fake IDs)
* --cleanup Clean up test data before seeding
*/
import "dotenv/config";
import { randomUUID } from "node:crypto";
import bcrypt from "bcryptjs";
import Stripe from "stripe";
import { ORGANIZATION_SELF_SERVE_PRICE } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { BillingMode, BillingPeriod, BookingStatus, MembershipRole } from "@calcom/prisma/enums";
import { ActiveUserBillingRepository } from "./repositories/ActiveUserBillingRepository";
import { ActiveUserBillingService } from "./services/ActiveUserBillingService";
const SKIP_STRIPE = process.argv.includes("--skip-stripe");
const CLEANUP_FIRST = process.argv.includes("--cleanup");
const TOTAL_MEMBERS = 99;
const ACTIVE_HOSTS = 20;
const ACTIVE_ATTENDEES_ONLY = 10;
const TOTAL_ACTIVE = ACTIVE_HOSTS + ACTIVE_ATTENDEES_ONLY; // 30
const ORG_SLUG = "aub-test-org";
const ORG_ADMIN_EMAIL = "aub-org-admin@example.com";
const ORG_MEMBER_EMAILS = Array.from({ length: TOTAL_MEMBERS }, (_, i) => `aub-org-member-${i + 1}@example.com`);
const ALL_EMAILS = [ORG_ADMIN_EMAIL, ...ORG_MEMBER_EMAILS];
const ORG_PRICE_PER_SEAT_CENTS = ORGANIZATION_SELF_SERVE_PRICE * 100;
const STRIPE_ORG_MONTHLY_PRICE_ID = process.env.STRIPE_ORG_MONTHLY_PRICE_ID;
let hashedPasswordCache: string | null = null;
async function getHashedPassword(): Promise<string> {
if (!hashedPasswordCache) {
const salt = await bcrypt.genSalt(10);
hashedPasswordCache = await bcrypt.hash("password123", salt);
}
return hashedPasswordCache;
}
async function createTestUser(
email: string,
name: string,
username: string,
organizationId?: number
) {
const hashedPassword = await getHashedPassword();
const user = await prisma.user.upsert({
where: { email },
update: { completedOnboarding: true, organizationId },
create: {
email,
name,
username,
password: { create: { hash: hashedPassword } },
emailVerified: new Date(),
completedOnboarding: true,
organizationId,
},
});
if (organizationId) {
await prisma.profile.upsert({
where: { userId_organizationId: { userId: user.id, organizationId } },
update: {},
create: {
uid: `${user.id}-${organizationId}`,
userId: user.id,
organizationId,
username,
},
});
}
return user;
}
function getStripeClient(): Stripe | null {
if (SKIP_STRIPE) {
console.log("Skipping Stripe API calls (--skip-stripe flag)");
return null;
}
if (!process.env.STRIPE_PRIVATE_KEY) {
console.log("STRIPE_PRIVATE_KEY not set, skipping Stripe API calls");
return null;
}
return new Stripe(process.env.STRIPE_PRIVATE_KEY, { apiVersion: "2020-08-27" });
}
async function cleanupStripeResources(stripe: Stripe) {
console.log("Cleaning up Stripe resources...");
const testClocks = await stripe.testHelpers.testClocks.list({ limit: 100 });
for (const clock of testClocks.data) {
if (clock.name?.startsWith("AUB Test")) {
try {
await stripe.testHelpers.testClocks.del(clock.id);
console.log(` Deleted test clock: ${clock.id}`);
} catch {
// ignore
}
}
}
const customers = await stripe.customers.list({ limit: 100, email: ORG_ADMIN_EMAIL });
for (const customer of customers.data) {
try {
const subs = await stripe.subscriptions.list({ customer: customer.id, status: "all" });
for (const sub of subs.data) {
if (sub.status !== "canceled") await stripe.subscriptions.cancel(sub.id);
}
await stripe.customers.del(customer.id);
console.log(` Deleted customer: ${customer.id}`);
} catch {
// ignore
}
}
}
async function cleanupDatabaseResources() {
console.log("Cleaning up database resources...");
const org = await prisma.team.findFirst({ where: { slug: ORG_SLUG, isOrganization: true } });
if (org) {
const memberUsers = await prisma.user.findMany({
where: { email: { in: ALL_EMAILS } },
select: { id: true },
});
const memberIds = memberUsers.map((u) => u.id);
if (memberIds.length > 0) {
const bookings = await prisma.booking.findMany({
where: { userId: { in: memberIds } },
select: { id: true },
});
const bookingIds = bookings.map((b) => b.id);
if (bookingIds.length > 0) {
await prisma.attendee.deleteMany({ where: { bookingId: { in: bookingIds } } });
await prisma.booking.deleteMany({ where: { id: { in: bookingIds } } });
console.log(` Deleted ${bookingIds.length} bookings`);
}
}
await prisma.organizationBilling.deleteMany({ where: { teamId: org.id } });
await prisma.organizationSettings.deleteMany({ where: { organizationId: org.id } });
await prisma.membership.deleteMany({ where: { teamId: org.id } });
await prisma.team.delete({ where: { id: org.id } });
console.log(` Deleted org (ID: ${org.id})`);
}
const deleteResult = await prisma.user.deleteMany({ where: { email: { in: ALL_EMAILS } } });
if (deleteResult.count > 0) console.log(` Deleted ${deleteResult.count} users`);
console.log(" Database cleanup complete");
}
interface UserRecord {
id: number;
email: string;
name: string | null;
}
interface SeedResult {
orgId: number;
subscriptionId: string;
periodStart: Date;
periodEnd: Date;
totalMembers: number;
expectedActiveHosts: string[];
expectedActiveAttendees: string[];
expectedInactive: string[];
}
async function seed(): Promise<SeedResult> {
const stripe = getStripeClient();
// Enable feature flag
await prisma.feature.upsert({
where: { slug: "active-user-billing" },
update: { enabled: true },
create: {
slug: "active-user-billing",
enabled: true,
description: "Active user billing strategy",
type: "RELEASE",
stale: false,
},
});
console.log("Enabled active-user-billing feature flag");
if (CLEANUP_FIRST) {
await cleanupDatabaseResources();
if (stripe) await cleanupStripeResources(stripe);
}
// Create organization
console.log("\nCreating organization...");
let org = await prisma.team.findFirst({ where: { slug: ORG_SLUG, isOrganization: true } });
if (!org) {
org = await prisma.team.create({
data: { name: "AUB Test Org", slug: ORG_SLUG, isOrganization: true },
});
}
console.log(` Organization: ${org.name} (ID: ${org.id})`);
await prisma.organizationSettings.upsert({
where: { organizationId: org.id },
update: {},
create: {
organizationId: org.id,
orgAutoAcceptEmail: "example.com",
isOrganizationConfigured: true,
isOrganizationVerified: true,
},
});
// Create users (admin + 99 members = 100 total)
console.log(`Creating ${TOTAL_MEMBERS + 1} users...`);
const admin = await createTestUser(ORG_ADMIN_EMAIL, "AUB Org Admin", "aub-org-admin", org.id);
const members: UserRecord[] = [];
for (let i = 0; i < TOTAL_MEMBERS; i++) {
const user = await createTestUser(
ORG_MEMBER_EMAILS[i],
`AUB Member ${i + 1}`,
`aub-org-member-${i + 1}`,
org.id
);
members.push(user);
if ((i + 1) % 25 === 0) console.log(` ... created ${i + 1}/${TOTAL_MEMBERS} members`);
}
console.log(` Created admin + ${members.length} members = ${members.length + 1} total`);
// Add memberships
console.log("Adding memberships...");
await prisma.membership.upsert({
where: { userId_teamId: { userId: admin.id, teamId: org.id } },
update: { role: MembershipRole.OWNER },
create: { userId: admin.id, teamId: org.id, role: MembershipRole.OWNER, accepted: true },
});
for (const member of members) {
await prisma.membership.upsert({
where: { userId_teamId: { userId: member.id, teamId: org.id } },
update: { role: MembershipRole.MEMBER },
create: { userId: member.id, teamId: org.id, role: MembershipRole.MEMBER, accepted: true },
});
}
// Billing period: defaults to current calendar month, overridden by Stripe below
const now = new Date();
let periodStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
let periodEnd = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
// Stripe setup (before bookings so we know the actual billing period)
let stripeCustomerId = `cus_fake_aub_${Date.now()}`;
let stripeSubscriptionId = `sub_fake_aub_${Date.now()}`;
let stripeSubscriptionItemId = `si_fake_aub_${Date.now()}`;
let pricePerSeatCents = ORG_PRICE_PER_SEAT_CENTS;
if (stripe) {
if (!STRIPE_ORG_MONTHLY_PRICE_ID) {
throw new Error("STRIPE_ORG_MONTHLY_PRICE_ID is required when running with Stripe");
}
console.log("\nCreating Stripe resources...");
const price = await stripe.prices.retrieve(STRIPE_ORG_MONTHLY_PRICE_ID);
pricePerSeatCents = price.unit_amount || ORG_PRICE_PER_SEAT_CENTS;
const product =
typeof price.product === "string"
? await stripe.products.retrieve(price.product)
: (price.product as Stripe.Product);
const testClock = await stripe.testHelpers.testClocks.create({
frozen_time: Math.floor(Date.now() / 1000),
name: "AUB Test - Active User Billing",
});
console.log(` Test clock: ${testClock.id}`);
const customer = await stripe.customers.create({
email: ORG_ADMIN_EMAIL,
name: "AUB Test Org",
test_clock: testClock.id,
metadata: { testData: "true", calTeamId: org.id.toString() },
});
const pm = await stripe.paymentMethods.create({ type: "card", card: { token: "tok_visa" } });
await stripe.paymentMethods.attach(pm.id, { customer: customer.id });
await stripe.customers.update(customer.id, {
invoice_settings: { default_payment_method: pm.id },
});
const totalMembers = 1 + members.length;
const subscription = await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: price.id, quantity: totalMembers }],
metadata: { testData: "true", teamId: org.id.toString() },
});
stripeCustomerId = customer.id;
stripeSubscriptionId = subscription.id;
stripeSubscriptionItemId = subscription.items.data[0].id;
// Use Stripe's actual billing period so bookings align with what Stripe bills for
periodStart = new Date(subscription.current_period_start * 1000);
periodEnd = new Date(subscription.current_period_end * 1000);
console.log(` Customer: ${customer.id}`);
console.log(` Subscription: ${subscription.id} (${totalMembers} seats)`);
console.log(` Product: ${product.id} (${product.name})`);
console.log(` Stripe period: ${periodStart.toISOString()} - ${periodEnd.toISOString()}`);
console.log(
` Test clock URL: https://dashboard.stripe.com/test/test-clocks/${testClock.id}`
);
}
// Create bookings within the billing period to produce exactly 30 active users:
//
// Admin -> hosts 2 bookings in period (ACTIVE as host) [1]
// Members 1-19 -> each hosts 1 booking in period (ACTIVE as host) [19]
// Members 20-29 -> each attends one of the host bookings (ACTIVE as attendee only) [10]
// Member 30 -> hosts 1 booking OUTSIDE period (INACTIVE - out of range)
// Members 31-99 -> no bookings (INACTIVE)
//
// Total active = 1 admin host + 19 member hosts + 10 attendees = 30
// Total inactive = 1 out-of-range host + 69 idle = 70
console.log(`Creating bookings in period ${periodStart.toISOString()} - ${periodEnd.toISOString()}...`);
const bookingIds: number[] = [];
async function createBooking(
host: UserRecord,
startTime: Date,
endTime: Date,
attendeeEmail?: string
) {
const booking = await prisma.booking.create({
data: {
uid: randomUUID(),
title: `Booking by ${host.name}`,
startTime,
endTime,
userId: host.id,
status: BookingStatus.ACCEPTED,
},
});
bookingIds.push(booking.id);
if (attendeeEmail) {
await prisma.attendee.create({
data: {
email: attendeeEmail,
name: "Attendee",
timeZone: "UTC",
bookingId: booking.id,
},
});
}
return booking.id;
}
const inPeriod = (dayOffset: number) => {
const d = new Date(periodStart);
d.setUTCDate(d.getUTCDate() + dayOffset);
return d;
};
const hourLater = (d: Date) => new Date(d.getTime() + 60 * 60 * 1000);
// Admin hosts 2 bookings (shows multiple bookings still = 1 active user)
await createBooking(admin, inPeriod(1), hourLater(inPeriod(1)));
await createBooking(admin, inPeriod(2), hourLater(inPeriod(2)));
// Members 1-19 each host 1 booking
// Members 20-29 are attendees on those bookings (paired with members 1-10)
for (let i = 0; i < ACTIVE_HOSTS - 1; i++) {
const host = members[i];
const day = inPeriod(3 + i);
const attendeeEmail =
i < ACTIVE_ATTENDEES_ONLY ? members[ACTIVE_HOSTS - 1 + i].email : undefined;
await createBooking(host, day, hourLater(day), attendeeEmail);
}
// Member 30 (index 29) hosts a booking OUTSIDE the period (last month)
const outsidePeriod = new Date(periodStart);
outsidePeriod.setUTCMonth(outsidePeriod.getUTCMonth() - 1);
outsidePeriod.setUTCDate(15);
await createBooking(members[29], outsidePeriod, hourLater(outsidePeriod));
console.log(` Created ${bookingIds.length} bookings`);
// Build expected lists
const expectedActiveHosts = [
admin.email,
...members.slice(0, ACTIVE_HOSTS - 1).map((m) => m.email),
];
const expectedActiveAttendees = members
.slice(ACTIVE_HOSTS - 1, ACTIVE_HOSTS - 1 + ACTIVE_ATTENDEES_ONLY)
.map((m) => m.email);
const expectedInactive = members.slice(ACTIVE_HOSTS - 1 + ACTIVE_ATTENDEES_ONLY).map((m) => m.email);
// Create OrganizationBilling with ACTIVE_USERS mode
const totalMembers = 1 + members.length;
await prisma.organizationBilling.upsert({
where: { teamId: org.id },
update: {
customerId: stripeCustomerId,
subscriptionId: stripeSubscriptionId,
subscriptionItemId: stripeSubscriptionItemId,
billingPeriod: BillingPeriod.MONTHLY,
billingMode: BillingMode.ACTIVE_USERS,
pricePerSeat: pricePerSeatCents,
paidSeats: totalMembers,
subscriptionStart: periodStart,
subscriptionEnd: periodEnd,
subscriptionTrialEnd: null,
},
create: {
teamId: org.id,
customerId: stripeCustomerId,
subscriptionId: stripeSubscriptionId,
subscriptionItemId: stripeSubscriptionItemId,
status: "ACTIVE",
planName: "ORGANIZATION",
billingPeriod: BillingPeriod.MONTHLY,
billingMode: BillingMode.ACTIVE_USERS,
pricePerSeat: pricePerSeatCents,
paidSeats: totalMembers,
subscriptionStart: periodStart,
subscriptionEnd: periodEnd,
subscriptionTrialEnd: null,
},
});
await prisma.team.update({
where: { id: org.id },
data: {
metadata: {
subscriptionId: stripeSubscriptionId,
subscriptionItemId: stripeSubscriptionItemId,
},
},
});
console.log(
`\n OrganizationBilling created (MONTHLY, billingMode=ACTIVE_USERS, $${pricePerSeatCents / 100}/seat, ${totalMembers} paid seats)`
);
return {
orgId: org.id,
subscriptionId: stripeSubscriptionId,
periodStart,
periodEnd,
totalMembers,
expectedActiveHosts,
expectedActiveAttendees,
expectedInactive,
};
}
async function main() {
console.log("=== Active User Billing (AUB) Seed Script ===\n");
console.log("Options:");
console.log(` --skip-stripe: ${SKIP_STRIPE}`);
console.log(` --cleanup: ${CLEANUP_FIRST}`);
console.log("");
try {
const result = await seed();
// Run the service to count active users
console.log("\n--- Running ActiveUserBillingService ---\n");
const repo = new ActiveUserBillingRepository(prisma);
const service = new ActiveUserBillingService({ activeUserBillingRepository: repo });
const serviceCount = await service.getActiveUserCountForOrg(
result.orgId,
result.periodStart,
result.periodEnd
);
const expectedTotal =
result.expectedActiveHosts.length + result.expectedActiveAttendees.length;
// Print summary
console.log("=== Summary ===\n");
console.log(` Organization ID: ${result.orgId}`);
console.log(` Subscription ID: ${result.subscriptionId}`);
console.log(
` Billing period: ${result.periodStart.toISOString()} - ${result.periodEnd.toISOString()}`
);
console.log(` Total members: ${result.totalMembers}`);
console.log("");
console.log(` Active hosts (${result.expectedActiveHosts.length}):`);
for (const email of result.expectedActiveHosts) {
console.log(` - ${email}`);
}
console.log(` Active attendees only (${result.expectedActiveAttendees.length}):`);
for (const email of result.expectedActiveAttendees) {
console.log(` - ${email}`);
}
console.log(` Inactive (${result.expectedInactive.length}):`);
console.log(` (${result.expectedInactive.length} members with no in-period bookings)`);
console.log("");
console.log(" +--------------------------+---------+");
console.log(" | Category | Count |");
console.log(" +--------------------------+---------+");
console.log(` | Total org members | ${String(result.totalMembers).padStart(7)} |`);
console.log(` | Active hosts (created) | ${String(result.expectedActiveHosts.length).padStart(7)} |`);
console.log(` | Active attendees (created)| ${String(result.expectedActiveAttendees.length).padStart(6)} |`);
console.log(` | Total active (created) | ${String(expectedTotal).padStart(7)} |`);
console.log(` | Inactive (created) | ${String(result.expectedInactive.length).padStart(7)} |`);
console.log(` | Service counted | ${String(serviceCount).padStart(7)} |`);
console.log(" +--------------------------+---------+");
if (serviceCount === expectedTotal) {
console.log("\n [PASS] Service count matches expected count.");
} else {
console.log(
`\n [MISMATCH] Expected ${expectedTotal}, service returned ${serviceCount}.`
);
}
console.log("\n=== Done ===\n");
process.exit(0);
} catch (error) {
console.error("Seed failed:", error);
process.exit(1);
}
}
main();