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>
This commit is contained in:
sean-brydon
2026-02-12 13:57:32 +00:00
committed by GitHub
co-authored by unknown sean@cal.com Devin AI
parent bf348ee209
commit d52e2d9fad
28 changed files with 2327 additions and 79 deletions
@@ -35,6 +35,7 @@ const initialData: AppFlags = {
"booking-audit": false,
"monthly-proration": false,
"hwm-seating": false,
"active-user-billing": false,
"sidebar-tips": false,
};
@@ -9,7 +9,8 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/co
import { showToast } from "@calcom/ui/components/toast";
import { usePathname } from "next/navigation";
import { useSession } from "next-auth/react";
import { useState } from "react";
import { useMemo, useState } from "react";
import { ActiveUserBreakdown } from "~/settings/billing/components/ActiveUserBreakdown";
import BillingCredits from "~/settings/billing/components/BillingCredits";
import { InvoicesTable } from "~/settings/billing/components/InvoicesTable";
@@ -51,25 +52,21 @@ const BillingView = () => {
const [showSkipTrialDialog, setShowSkipTrialDialog] = useState(false);
const utils = trpc.useUtils();
// Determine the billing context and extract appropriate team/org ID
const getTeamIdFromContext = () => {
const teamIdNumber = useMemo(() => {
if (!pathname) return null;
// Team billing: /settings/teams/{id}/billing
if (pathname.includes("/teams/") && pathname.includes("/billing")) {
const teamIdMatch = pathname.match(/\/teams\/(\d+)\/billing/);
return teamIdMatch ? teamIdMatch[1] : null;
return teamIdMatch ? parseInt(teamIdMatch[1], 10) : null;
}
// Organization billing: /settings/organizations/billing
if (pathname.includes("/organizations/billing")) {
const orgId = session.data?.user?.org?.id;
return typeof orgId === "number" ? orgId.toString() : null;
return typeof orgId === "number" ? orgId : null;
}
};
const teamId = getTeamIdFromContext();
const teamIdNumber = teamId ? parseInt(teamId, 10) : null;
return null;
}, [pathname, session.data?.user?.org?.id]);
const { data: subscriptionStatus, isLoading: isLoadingStatus } =
trpc.viewer.teams.getSubscriptionStatus.useQuery(
@@ -91,8 +88,8 @@ const BillingView = () => {
},
});
const billingHref = teamId
? `/api/integrations/stripepayment/portal?teamId=${teamId}&returnTo=${WEBAPP_URL}${returnTo}`
const billingHref = teamIdNumber
? `/api/integrations/stripepayment/portal?teamId=${teamIdNumber}&returnTo=${WEBAPP_URL}${returnTo}`
: `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}`;
const onContactSupportClick = async () => {
@@ -146,6 +143,9 @@ const BillingView = () => {
</div>
</div>
<BillingCredits />
{teamIdNumber && subscriptionStatus?.billingMode === "ACTIVE_USERS" && (
<ActiveUserBreakdown teamId={teamIdNumber} />
)}
<InvoicesTable />
<Dialog open={showSkipTrialDialog} onOpenChange={setShowSkipTrialDialog}>
@@ -0,0 +1,256 @@
"use client";
import { useEffect, useState } from "react";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Badge } from "@coss/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { PanelCard } from "@calcom/ui/components/card";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetPanel,
SheetTitle,
SheetClose,
} from "@coss/ui/components/sheet";
import { Skeleton } from "@coss/ui/components/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@coss/ui/components/table";
import { ActiveUserBreakdownSkeleton } from "./ActiveUserBreakdownSkeleton";
const PAGE_SIZE = 10;
export function ActiveUserBreakdown({ teamId }: { teamId: number }) {
const { data, isLoading } = trpc.viewer.teams.getActiveUserBreakdown.useQuery(
{ teamId },
{ enabled: !!teamId }
);
if (isLoading) return <ActiveUserBreakdownSkeleton />;
if (!data) return null;
return <ActiveUserBreakdownContent data={data} teamId={teamId} />;
}
type BreakdownData = {
activeUsers: Array<{ id: number; email: string; name: string | null; activeAs: "host" | "attendee" }>;
totalMembers: number;
activeHosts: number;
activeAttendees: number;
periodStart: string;
periodEnd: string;
pricePerSeat: number | null;
};
type SelectedUser = {
id: number;
email: string;
name: string | null;
activeAs: "host" | "attendee";
};
function ActiveUserBreakdownContent({ data, teamId }: { data: BreakdownData; teamId: number }) {
const { t } = useLocale();
const [currentPage, setCurrentPage] = useState(0);
const [selectedUser, setSelectedUser] = useState<SelectedUser | null>(null);
const { activeUsers, totalMembers, activeHosts, activeAttendees, periodStart, periodEnd } = data;
useEffect(() => {
setCurrentPage(0);
}, [activeUsers]);
const totalPages = Math.ceil(activeUsers.length / PAGE_SIZE);
const paginatedUsers = activeUsers.slice(currentPage * PAGE_SIZE, (currentPage + 1) * PAGE_SIZE);
const formattedStart = dayjs.utc(periodStart).format("MMM D, YYYY");
const formattedEnd = dayjs.utc(periodEnd).format("MMM D, YYYY");
return (
<>
<PanelCard
title={t("active_users_billing")}
subtitle={t("active_users_billing_description", {
start: formattedStart,
end: formattedEnd,
})}
className="mt-5">
<div className="px-5 py-3">
<p className="text-subtle text-sm font-medium">
{t("active_users_summary", {
hosts: activeHosts,
attendees: activeAttendees,
active: activeHosts + activeAttendees,
total: totalMembers,
})}
</p>
</div>
{activeUsers.length === 0 ? (
<div className="text-subtle p-6 text-center text-sm">{t("no_active_users")}</div>
) : (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("email")}</TableHead>
<TableHead>{t("status")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedUsers.map((user) => (
<TableRow
key={user.id}
className="cursor-pointer"
onClick={() => setSelectedUser(user)}>
<TableCell>{user.name || "-"}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
{user.activeAs === "host" ? (
<Badge variant="success">{t("active_as_host")}</Badge>
) : (
<Badge variant="info">{t("active_as_attendee")}</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{totalPages > 1 && (
<div className="border-muted flex items-center justify-end gap-2 border-t px-4 py-3">
<Button
color="secondary"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 0}
StartIcon="arrow-left">
{t("previous")}
</Button>
<Button
color="secondary"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage >= totalPages - 1}
EndIcon="arrow-right">
{t("next")}
</Button>
</div>
)}
</>
)}
</PanelCard>
<Sheet open={!!selectedUser} onOpenChange={(open) => !open && setSelectedUser(null)}>
<SheetContent side="right" inset>
<SheetHeader>
<SheetTitle>{selectedUser?.name || selectedUser?.email}</SheetTitle>
<SheetDescription>
<span className="flex items-center gap-2">
{selectedUser?.email}
{selectedUser?.activeAs === "host" ? (
<Badge variant="success">{t("active_as_host")}</Badge>
) : (
<Badge variant="info">{t("active_as_attendee")}</Badge>
)}
</span>
</SheetDescription>
</SheetHeader>
<SheetPanel>
{selectedUser && (
<UserBookingsSheet
teamId={teamId}
userId={selectedUser.id}
activeAs={selectedUser.activeAs}
/>
)}
</SheetPanel>
<SheetFooter>
<SheetClose render={<Button color="secondary">{t("close")}</Button>} />
</SheetFooter>
</SheetContent>
</Sheet>
</>
);
}
function UserBookingsSheet({
teamId,
userId,
activeAs,
}: {
teamId: number;
userId: number;
activeAs: "host" | "attendee";
}) {
const { t } = useLocale();
const { data, isLoading } = trpc.viewer.teams.getActiveUserBookings.useQuery(
{ teamId, userId, activeAs },
{ enabled: true }
);
if (isLoading) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-8 w-24" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
}
const bookings = data?.bookings ?? [];
return (
<div className="flex flex-col gap-4">
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold">{bookings.length}</span>
<span className="text-muted-foreground text-sm">
{bookings.length === 1 ? t("booking") : t("bookings")}
</span>
</div>
{bookings.length === 0 ? (
<p className="text-muted-foreground text-sm">{t("no_bookings")}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("booking")}</TableHead>
<TableHead>{t("date")}</TableHead>
<TableHead>{activeAs === "host" ? t("attendees") : t("host")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bookings.map((booking) => (
<TableRow key={booking.id}>
<TableCell className="font-medium">{booking.title}</TableCell>
<TableCell className="text-muted-foreground">
{dayjs.utc(booking.startTime).format("MMM D, YYYY h:mm A")}
</TableCell>
<TableCell className="text-muted-foreground">{booking.otherParty || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
);
}
@@ -0,0 +1,29 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { PanelCard } from "@calcom/ui/components/card";
import { SkeletonText } from "@calcom/ui/components/skeleton";
export function ActiveUserBreakdownSkeleton() {
const { t } = useLocale();
return (
<PanelCard title={t("active_users_billing")} className="mt-5">
<div className="p-4">
<SkeletonText className="mb-4 h-4 w-64" />
<div className="mb-3 flex gap-4 border-b pb-3">
<SkeletonText className="h-4 w-32" />
<SkeletonText className="h-4 w-40" />
<SkeletonText className="h-4 w-20" />
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4 py-3">
<SkeletonText className="h-4 w-32" />
<SkeletonText className="h-4 w-40" />
<SkeletonText className="h-4 w-20" />
</div>
))}
</div>
</PanelCard>
);
}
@@ -2173,6 +2173,13 @@
"skip_trial_confirmation_description": "Are you sure you want to skip your trial period? Your paid subscription will start immediately and you will be charged according to your plan.",
"trial_skipped_successfully": "Trial period skipped successfully",
"error_skipping_trial": "Failed to skip trial period",
"active_users_billing": "Active users",
"active_users_billing_description": "Billing period: {{start}} - {{end}}",
"active_as_host": "Host",
"active_as_attendee": "Attendee",
"inactive_member": "Inactive",
"active_users_summary": "{{hosts}} hosts + {{attendees}} attendees = {{active}} active of {{total}} members",
"no_active_users": "No active users in this billing period",
"billing_help_cta": "Contact support",
"ignore_special_characters_booking_questions": "Ignore special characters in your booking question identifier. Use only letters and numbers",
"ignore_special_characters_form_responses": "Ignore special characters in your form field identifier. Use only letters and numbers",
@@ -1,4 +1,7 @@
import type { PrismaClient } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
const BOOKING_DETAIL_LIMIT = 100;
export class ActiveUserBillingRepository {
constructor(private readonly prismaClient: PrismaClient) {}
@@ -48,6 +51,27 @@ export class ActiveUserBillingRepository {
});
}
async getOrgMemberDetailsByOrgId(
orgId: number
): Promise<{ id: number; email: string; name: string | null }[]> {
return this.prismaClient.user.findMany({
distinct: ["email"],
where: {
teams: {
some: {
teamId: orgId,
accepted: true,
},
},
},
select: {
id: true,
email: true,
name: true,
},
});
}
/**
* Find platform-managed users (by email) who hosted at least one booking in the given period.
* Filters on isPlatformManaged to match only platform org users.
@@ -73,9 +97,10 @@ export class ActiveUserBillingRepository {
bookings: {
some: {
userId: { not: null },
status: BookingStatus.ACCEPTED,
startTime: {
gte: periodStart,
lte: periodEnd,
lt: periodEnd,
},
},
},
@@ -101,9 +126,10 @@ export class ActiveUserBillingRepository {
bookings: {
some: {
userId: { not: null },
status: BookingStatus.ACCEPTED,
startTime: {
gte: periodStart,
lte: periodEnd,
lt: periodEnd,
},
},
},
@@ -114,6 +140,82 @@ export class ActiveUserBillingRepository {
});
}
async getBookingsByHostUserId(
userId: number,
periodStart: Date,
periodEnd: Date
): Promise<
Array<{
id: number;
uid: string;
title: string;
startTime: Date;
endTime: Date;
attendees: Array<{ email: string; name: string | null }>;
}>
> {
return this.prismaClient.booking.findMany({
where: {
userId,
status: BookingStatus.ACCEPTED,
startTime: { gte: periodStart, lt: periodEnd },
},
orderBy: { startTime: "desc" },
take: BOOKING_DETAIL_LIMIT,
select: {
id: true,
uid: true,
title: true,
startTime: true,
endTime: true,
attendees: {
select: {
email: true,
name: true,
},
},
},
});
}
async getBookingsByAttendeeEmail(
email: string,
periodStart: Date,
periodEnd: Date
): Promise<
Array<{
id: number;
uid: string;
title: string;
startTime: Date;
endTime: Date;
user: { name: string | null; email: string } | null;
}>
> {
return this.prismaClient.booking.findMany({
where: {
attendees: { some: { email } },
status: BookingStatus.ACCEPTED,
startTime: { gte: periodStart, lt: periodEnd },
},
orderBy: { startTime: "desc" },
take: BOOKING_DETAIL_LIMIT,
select: {
id: true,
uid: true,
title: true,
startTime: true,
endTime: true,
user: {
select: {
name: true,
email: true,
},
},
},
});
}
/**
* Find users (by email) who attended at least one booking in the given period.
*/
@@ -127,9 +229,10 @@ export class ActiveUserBillingRepository {
where: {
email: { in: userEmails },
booking: {
status: BookingStatus.ACCEPTED,
startTime: {
gte: periodStart,
lte: periodEnd,
lt: periodEnd,
},
},
},
@@ -0,0 +1,560 @@
#!/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();
@@ -0,0 +1,197 @@
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);
});
});
@@ -9,9 +9,12 @@ function createMockRepository(): {
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([]),
};
}
@@ -200,4 +203,79 @@ describe("ActiveUserBillingService", () => {
);
});
});
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);
});
});
});
@@ -1,5 +1,17 @@
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;
}
@@ -61,6 +73,110 @@ export class ActiveUserBillingService {
);
}
/**
* 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,
@@ -1,5 +1,10 @@
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
import {
bindModuleToClassOnToken,
createModule,
type ModuleLoader,
} from "@calcom/features/di/di";
import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/FeaturesRepository";
import { moduleLoader as activeUserBillingServiceModuleLoader } from "@calcom/features/ee/billing/active-user/di/ActiveUserBillingService.module";
import { SeatBillingStrategyFactory } from "@calcom/features/ee/billing/service/seatBillingStrategy/SeatBillingStrategyFactory";
import { DI_TOKENS } from "../tokens";
import { billingPeriodServiceModuleLoader } from "./BillingPeriodService.module";
@@ -26,6 +31,7 @@ const loadModule = bindModuleToClassOnToken({
highWaterMarkService: highWaterMarkServiceModuleLoader,
monthlyProrationService: monthlyProrationServiceModuleLoader,
teamBillingDataRepository: teamBillingDataRepositoryModuleLoader,
activeUserBillingService: activeUserBillingServiceModuleLoader,
},
});
@@ -20,6 +20,8 @@ export interface UpdateBillingPeriodData {
billingPeriod: BillingPeriod;
pricePerSeat?: number | null;
paidSeats?: number | null;
subscriptionStart?: Date | null;
subscriptionEnd?: Date | null;
}
export class BillingPeriodRepository {
+37 -9
View File
@@ -8,6 +8,7 @@
* Non-interactive (CI-friendly):
* npx tsx packages/features/ee/billing/seed.ts --hwm
* npx tsx packages/features/ee/billing/seed.ts --proration
* npx tsx packages/features/ee/billing/seed.ts --active-user
* npx tsx packages/features/ee/billing/seed.ts --all
* npx tsx packages/features/ee/billing/seed.ts --cleanup
*
@@ -19,8 +20,12 @@
import { spawn } from "node:child_process";
import * as readline from "node:readline";
const HWM_SCRIPT = "packages/features/ee/billing/service/highWaterMark/seed-hwm-test.ts";
const PRORATION_SCRIPT = "packages/features/ee/billing/service/dueInvoice/seed-proration-test.ts";
const HWM_SCRIPT =
"packages/features/ee/billing/service/highWaterMark/seed-hwm-test.ts";
const PRORATION_SCRIPT =
"packages/features/ee/billing/service/dueInvoice/seed-proration-test.ts";
const ACTIVE_USER_SCRIPT =
"packages/features/ee/billing/active-user/seed-active-user-test.ts";
const passthrough = process.argv.filter((a) => a === "--skip-stripe");
@@ -44,10 +49,18 @@ async function seedProration(cleanup: boolean) {
return run(PRORATION_SCRIPT, extra);
}
async function seedActiveUser(cleanup: boolean) {
console.log("\n--- Seeding Active User Billing test data ---\n");
const extra = cleanup ? ["--cleanup"] : [];
return run(ACTIVE_USER_SCRIPT, extra);
}
async function seedAll(cleanup: boolean) {
let code = await seedHwm(cleanup);
if (code !== 0) return code;
code = await seedProration(cleanup);
if (code !== 0) return code;
code = await seedActiveUser(cleanup);
return code;
}
@@ -56,11 +69,16 @@ async function cleanupAll() {
let code = await run(HWM_SCRIPT, ["--cleanup", "--skip-stripe"]);
if (code !== 0) return code;
code = await run(PRORATION_SCRIPT, ["--cleanup", "--skip-stripe"]);
if (code !== 0) return code;
code = await run(ACTIVE_USER_SCRIPT, ["--cleanup", "--skip-stripe"]);
return code;
}
function prompt(question: string): Promise<string> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
@@ -73,11 +91,12 @@ async function interactive() {
console.log("=== Billing Test Data Seeder ===\n");
console.log(" 1) Seed HWM (High Water Mark) test data");
console.log(" 2) Seed Proration test data");
console.log(" 3) Seed all");
console.log(" 4) Cleanup all test data");
console.log(" 3) Seed Active User Billing test data");
console.log(" 4) Seed all");
console.log(" 5) Cleanup all test data");
console.log(" q) Quit\n");
const choice = await prompt("Choose [1-4, q]: ");
const choice = await prompt("Choose [1-5, q]: ");
if (choice === "q" || choice === "") {
console.log("Bye.");
@@ -85,7 +104,7 @@ async function interactive() {
}
let cleanup = false;
if (["1", "2", "3"].includes(choice)) {
if (["1", "2", "3", "4"].includes(choice)) {
const ans = await prompt("Run cleanup before seeding? [y/N]: ");
cleanup = ans.toLowerCase() === "y";
}
@@ -99,9 +118,12 @@ async function interactive() {
code = await seedProration(cleanup);
break;
case "3":
code = await seedAll(cleanup);
code = await seedActiveUser(cleanup);
break;
case "4":
code = await seedAll(cleanup);
break;
case "5":
code = await cleanupAll();
break;
default:
@@ -113,7 +135,9 @@ async function interactive() {
}
async function main() {
const args = process.argv.slice(2).filter((a) => !a.startsWith("--skip-stripe"));
const args = process.argv
.slice(2)
.filter((a) => !a.startsWith("--skip-stripe"));
if (args.includes("--hwm")) {
process.exit(await seedHwm(args.includes("--cleanup")));
@@ -121,6 +145,9 @@ async function main() {
if (args.includes("--proration")) {
process.exit(await seedProration(args.includes("--cleanup")));
}
if (args.includes("--active-user")) {
process.exit(await seedActiveUser(args.includes("--cleanup")));
}
if (args.includes("--all")) {
process.exit(await seedAll(args.includes("--cleanup")));
}
@@ -128,6 +155,7 @@ async function main() {
args.includes("--cleanup") &&
!args.includes("--hwm") &&
!args.includes("--proration") &&
!args.includes("--active-user") &&
!args.includes("--all")
) {
process.exit(await cleanupAll());
@@ -5,13 +5,14 @@ import stripe from "@calcom/features/ee/payments/server/stripe";
import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import type { BillingPeriod } from "@calcom/prisma/enums";
import type { BillingMode, BillingPeriod } from "@calcom/prisma/enums";
import type { Logger } from "tslog";
const log = logger.getSubLogger({ prefix: ["BillingPeriodService"] });
export interface BillingPeriodInfo {
billingPeriod: BillingPeriod | null;
billingMode: BillingMode | null;
subscriptionStart: Date | null;
subscriptionEnd: Date | null;
trialEnd: Date | null;
@@ -93,6 +94,7 @@ export class BillingPeriodService {
select: {
id: true,
billingPeriod: true,
billingMode: true,
subscriptionStart: true,
subscriptionEnd: true,
subscriptionTrialEnd: true,
@@ -105,6 +107,7 @@ export class BillingPeriodService {
select: {
id: true,
billingPeriod: true,
billingMode: true,
subscriptionStart: true,
subscriptionEnd: true,
subscriptionTrialEnd: true,
@@ -124,6 +127,7 @@ export class BillingPeriodService {
if (!billing) {
return {
billingPeriod: null,
billingMode: null,
subscriptionStart: null,
subscriptionEnd: null,
trialEnd: null,
@@ -133,28 +137,55 @@ export class BillingPeriodService {
};
}
if (!billing.billingPeriod && billing.subscriptionId) {
const periodIsStale =
billing.subscriptionEnd && new Date(billing.subscriptionEnd) < new Date();
const needsSync = (!billing.billingPeriod || periodIsStale) && billing.subscriptionId;
if (needsSync) {
log.info(
`Backfilling missing billing data for team ${teamId} from Stripe subscription ${billing.subscriptionId}`
`Syncing billing data for team ${teamId} from Stripe subscription ${billing.subscriptionId}`
);
try {
const subscription = await stripe.subscriptions.retrieve(billing.subscriptionId);
const { billingPeriod, pricePerSeat, paidSeats } =
const terminalStatuses = ["canceled", "incomplete_expired"];
if (terminalStatuses.includes(subscription.status)) {
log.info(
`Skipping sync for team ${teamId} — subscription ${billing.subscriptionId} has status "${subscription.status}"`
);
const now = new Date();
const isInTrial = billing.subscriptionTrialEnd
? new Date(billing.subscriptionTrialEnd) > now
: false;
return {
billingPeriod: billing.billingPeriod,
billingMode: billing.billingMode,
subscriptionStart: billing.subscriptionStart,
subscriptionEnd: billing.subscriptionEnd,
trialEnd: billing.subscriptionTrialEnd,
isInTrial,
pricePerSeat: billing.pricePerSeat,
isOrganization,
};
}
const { billingPeriod, pricePerSeat, paidSeats, subscriptionStart, subscriptionEnd } =
extractBillingDataFromStripeSubscription(subscription);
const updateData = {
billingPeriod,
pricePerSeat: pricePerSeat ?? null,
paidSeats: paidSeats ?? null,
subscriptionStart: subscriptionStart ?? null,
subscriptionEnd: subscriptionEnd ?? null,
};
if (isOrganization) {
await this.repository.updateOrganizationBillingPeriod(billing.id, {
billingPeriod,
pricePerSeat: pricePerSeat ?? null,
paidSeats: paidSeats ?? null,
});
await this.repository.updateOrganizationBillingPeriod(billing.id, updateData);
} else {
await this.repository.updateTeamBillingPeriod(billing.id, {
billingPeriod,
pricePerSeat: pricePerSeat ?? null,
paidSeats: paidSeats ?? null,
});
await this.repository.updateTeamBillingPeriod(billing.id, updateData);
}
const now = new Date();
@@ -162,8 +193,9 @@ export class BillingPeriodService {
return {
billingPeriod,
subscriptionStart: billing.subscriptionStart,
subscriptionEnd: billing.subscriptionEnd,
billingMode: billing.billingMode,
subscriptionStart: subscriptionStart ?? null,
subscriptionEnd: subscriptionEnd ?? null,
trialEnd: billing.subscriptionTrialEnd,
isInTrial,
pricePerSeat: pricePerSeat ?? null,
@@ -179,6 +211,7 @@ export class BillingPeriodService {
return {
billingPeriod: billing.billingPeriod,
billingMode: billing.billingMode,
subscriptionStart: billing.subscriptionStart,
subscriptionEnd: billing.subscriptionEnd,
trialEnd: billing.subscriptionTrialEnd,
@@ -272,6 +272,7 @@ describe("BillingPeriodService", () => {
teamBilling: {
id: 1,
billingPeriod: "ANNUALLY",
billingMode: "SEATS",
subscriptionStart,
subscriptionEnd,
subscriptionTrialEnd: null,
@@ -286,6 +287,7 @@ describe("BillingPeriodService", () => {
expect(result).toEqual({
billingPeriod: "ANNUALLY",
billingMode: "SEATS",
subscriptionStart,
subscriptionEnd,
trialEnd: null,
@@ -332,6 +334,7 @@ describe("BillingPeriodService", () => {
expect(result).toEqual({
billingPeriod: null,
billingMode: null,
subscriptionStart: null,
subscriptionEnd: null,
trialEnd: null,
@@ -0,0 +1,64 @@
import logger from "@calcom/lib/logger";
import type { ActiveUserBillingService } from "../../active-user/services/ActiveUserBillingService";
import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository";
import type { IBillingProviderService } from "../billingProvider/IBillingProviderService";
import { BaseSeatBillingStrategy } from "./ISeatBillingStrategy";
import type { SeatChangeContext } from "./ISeatBillingStrategy";
const log = logger.getSubLogger({ prefix: ["ActiveUserBillingStrategy"] });
export interface IActiveUserBillingStrategyDeps {
activeUserBillingService: ActiveUserBillingService;
billingProviderService: IBillingProviderService;
teamBillingDataRepository: ITeamBillingDataRepository;
}
export class ActiveUserBillingStrategy extends BaseSeatBillingStrategy {
constructor(private readonly deps: IActiveUserBillingStrategyDeps) {
super();
}
async onSeatChange(_context: SeatChangeContext): Promise<void> {
// No-op: active user count is determined at billing time, not on seat changes
}
override async onInvoiceUpcoming(subscriptionId: string): Promise<{ applied: boolean }> {
const team = await this.deps.teamBillingDataRepository.findBySubscriptionId(subscriptionId);
if (!team) {
log.warn(`No team found for subscription ${subscriptionId}`);
return { applied: false };
}
const subscription = await this.deps.billingProviderService.getSubscription(subscriptionId);
if (!subscription) {
log.warn(`No subscription found for ${subscriptionId}`);
return { applied: false };
}
const periodStart = new Date(subscription.current_period_start * 1000);
const periodEnd = new Date(subscription.current_period_end * 1000);
const activeUserCount = await this.deps.activeUserBillingService.getActiveUserCountForOrg(
team.id,
periodStart,
periodEnd
);
const subscriptionItem = subscription.items[0];
if (!subscriptionItem) {
log.warn(`No subscription item found for ${subscriptionId}`);
return { applied: false };
}
await this.deps.billingProviderService.handleSubscriptionUpdate({
subscriptionId,
subscriptionItemId: subscriptionItem.id,
membershipCount: activeUserCount,
prorationBehavior: "none",
});
log.info(`Updated subscription ${subscriptionId} to ${activeUserCount} active users for team ${team.id}`);
return { applied: true };
}
}
@@ -1,12 +1,14 @@
import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface";
import logger from "@calcom/lib/logger";
import type { ActiveUserBillingService } from "../../active-user/services/ActiveUserBillingService";
import type { HighWaterMarkRepository } from "../../repository/highWaterMark/HighWaterMarkRepository";
import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository";
import type { BillingPeriodService } from "../billingPeriod/BillingPeriodService";
import type { IBillingProviderService } from "../billingProvider/IBillingProviderService";
import type { HighWaterMarkService } from "../highWaterMark/HighWaterMarkService";
import type { MonthlyProrationService } from "../proration/MonthlyProrationService";
import { ActiveUserBillingStrategy } from "./ActiveUserBillingStrategy";
import { HighWaterMarkStrategy } from "./HighWaterMarkStrategy";
import { ImmediateUpdateStrategy } from "./ImmediateUpdateStrategy";
import type { ISeatBillingStrategy } from "./ISeatBillingStrategy";
@@ -22,11 +24,13 @@ export interface ISeatBillingStrategyFactoryDeps {
highWaterMarkService: HighWaterMarkService;
monthlyProrationService: MonthlyProrationService;
teamBillingDataRepository: ITeamBillingDataRepository;
activeUserBillingService: ActiveUserBillingService;
}
export class SeatBillingStrategyFactory {
private readonly prorationStrategy: ISeatBillingStrategy;
private readonly hwmStrategy: ISeatBillingStrategy;
private readonly activeUserStrategy: ISeatBillingStrategy;
private readonly fallback: ISeatBillingStrategy;
constructor(private readonly deps: ISeatBillingStrategyFactoryDeps) {
@@ -38,12 +42,24 @@ export class SeatBillingStrategyFactory {
highWaterMarkRepository: deps.highWaterMarkRepository,
highWaterMarkService: deps.highWaterMarkService,
});
this.activeUserStrategy = new ActiveUserBillingStrategy({
activeUserBillingService: deps.activeUserBillingService,
billingProviderService: deps.billingProviderService,
teamBillingDataRepository: deps.teamBillingDataRepository,
});
}
async createByTeamId(teamId: number): Promise<ISeatBillingStrategy> {
const info = await this.deps.billingPeriodService.getBillingPeriodInfo(teamId);
if (!info.isInTrial && info.subscriptionStart) {
if (info.billingMode === "ACTIVE_USERS") {
const enabled =
await this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally(
"active-user-billing"
);
if (enabled) return this.activeUserStrategy;
}
if (info.billingPeriod === "ANNUALLY") {
const enabled =
await this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally("monthly-proration");
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import type { ITeamBillingDataRepository } from "../../../repository/teamBillingData/ITeamBillingDataRepository";
import type { BillingPeriodInfo } from "../../billingPeriod/BillingPeriodService";
import type { IBillingProviderService } from "../../billingProvider/IBillingProviderService";
import { ActiveUserBillingStrategy } from "../ActiveUserBillingStrategy";
import { HighWaterMarkStrategy } from "../HighWaterMarkStrategy";
import { ImmediateUpdateStrategy } from "../ImmediateUpdateStrategy";
import { MonthlyProrationStrategy } from "../MonthlyProrationStrategy";
@@ -12,20 +13,28 @@ function createMockBillingPeriodService(info: BillingPeriodInfo) {
return { getBillingPeriodInfo: vi.fn().mockResolvedValue(info) };
}
function createMockFeaturesRepository(enabledFlags: Record<string, boolean>): IFeaturesRepository {
function createMockFeaturesRepository(
enabledFlags: Record<string, boolean>
): IFeaturesRepository {
return {
checkIfFeatureIsEnabledGlobally: vi.fn(async (slug: string) => enabledFlags[slug] ?? false),
checkIfFeatureIsEnabledGlobally: vi.fn(
async (slug: string) => enabledFlags[slug] ?? false
),
} as unknown as IFeaturesRepository;
}
function createMockBillingProviderService(): IBillingProviderService {
return { handleSubscriptionUpdate: vi.fn() } as unknown as IBillingProviderService;
return {
handleSubscriptionUpdate: vi.fn(),
} as unknown as IBillingProviderService;
}
function createMockHighWaterMarkRepository() {
return {
getByTeamId: vi.fn(),
updateIfHigher: vi.fn().mockResolvedValue({ updated: false, previousHighWaterMark: null }),
updateIfHigher: vi
.fn()
.mockResolvedValue({ updated: false, previousHighWaterMark: null }),
};
}
@@ -43,6 +52,13 @@ function createMockMonthlyProrationService() {
};
}
function createMockActiveUserBillingService() {
return {
getActiveUserCountForOrg: vi.fn().mockResolvedValue(0),
getActiveUserCountForPlatformOrg: vi.fn().mockResolvedValue(0),
};
}
function createMockTeamBillingDataRepository(): ITeamBillingDataRepository {
return {
find: vi.fn(),
@@ -53,6 +69,7 @@ function createMockTeamBillingDataRepository(): ITeamBillingDataRepository {
const baseBillingInfo: BillingPeriodInfo = {
billingPeriod: null,
billingMode: "SEATS",
subscriptionStart: new Date("2025-01-01"),
subscriptionEnd: new Date("2026-01-01"),
trialEnd: null,
@@ -73,7 +90,10 @@ function createFactory(
highWaterMarkRepository: createMockHighWaterMarkRepository(),
highWaterMarkService: createMockHighWaterMarkService(),
monthlyProrationService: createMockMonthlyProrationService(),
teamBillingDataRepository: overrides?.teamBillingDataRepository ?? createMockTeamBillingDataRepository(),
teamBillingDataRepository:
overrides?.teamBillingDataRepository ??
createMockTeamBillingDataRepository(),
activeUserBillingService: createMockActiveUserBillingService(),
} as never);
}
@@ -85,12 +105,15 @@ describe("SeatBillingStrategyFactory", () => {
});
const factory = new SeatBillingStrategyFactory({
billingPeriodService,
featuresRepository: createMockFeaturesRepository({ "monthly-proration": true }),
featuresRepository: createMockFeaturesRepository({
"monthly-proration": true,
}),
billingProviderService: createMockBillingProviderService(),
highWaterMarkRepository: createMockHighWaterMarkRepository(),
highWaterMarkService: createMockHighWaterMarkService(),
monthlyProrationService: createMockMonthlyProrationService(),
teamBillingDataRepository: createMockTeamBillingDataRepository(),
activeUserBillingService: createMockActiveUserBillingService(),
} as never);
const strategy = await factory.createByTeamId(1);
@@ -124,7 +147,12 @@ describe("SeatBillingStrategyFactory", () => {
it("returns ImmediateUpdateStrategy when team is in trial", async () => {
const factory = createFactory(
{ ...baseBillingInfo, billingPeriod: "ANNUALLY", isInTrial: true, trialEnd: new Date("2026-06-01") },
{
...baseBillingInfo,
billingPeriod: "ANNUALLY",
isInTrial: true,
trialEnd: new Date("2026-06-01"),
},
{ "monthly-proration": true }
);
const strategy = await factory.createByTeamId(1);
@@ -134,7 +162,11 @@ describe("SeatBillingStrategyFactory", () => {
it("returns ImmediateUpdateStrategy when subscriptionStart is null", async () => {
const factory = createFactory(
{ ...baseBillingInfo, billingPeriod: "ANNUALLY", subscriptionStart: null },
{
...baseBillingInfo,
billingPeriod: "ANNUALLY",
subscriptionStart: null,
},
{ "monthly-proration": true }
);
const strategy = await factory.createByTeamId(1);
@@ -169,7 +201,9 @@ describe("SeatBillingStrategyFactory", () => {
);
const strategy = await factory.createBySubscriptionId("sub_abc");
expect(teamBillingDataRepo.findBySubscriptionId).toHaveBeenCalledWith("sub_abc");
expect(teamBillingDataRepo.findBySubscriptionId).toHaveBeenCalledWith(
"sub_abc"
);
expect(strategy).toBeInstanceOf(HighWaterMarkStrategy);
});
@@ -177,9 +211,95 @@ describe("SeatBillingStrategyFactory", () => {
const teamBillingDataRepo = createMockTeamBillingDataRepository();
vi.mocked(teamBillingDataRepo.findBySubscriptionId).mockResolvedValue(null);
const factory = createFactory(baseBillingInfo, {}, { teamBillingDataRepository: teamBillingDataRepo });
const factory = createFactory(
baseBillingInfo,
{},
{ teamBillingDataRepository: teamBillingDataRepo }
);
const strategy = await factory.createBySubscriptionId("sub_unknown");
expect(strategy).toBeInstanceOf(ImmediateUpdateStrategy);
});
it("returns ActiveUserBillingStrategy when billingMode=ACTIVE_USERS and feature flag enabled", async () => {
const factory = createFactory(
{
...baseBillingInfo,
billingPeriod: "MONTHLY",
billingMode: "ACTIVE_USERS",
},
{ "active-user-billing": true }
);
const strategy = await factory.createByTeamId(1);
expect(strategy).toBeInstanceOf(ActiveUserBillingStrategy);
});
it("returns fallback when billingMode=ACTIVE_USERS but feature flag disabled", async () => {
const factory = createFactory(
{
...baseBillingInfo,
billingPeriod: "MONTHLY",
billingMode: "ACTIVE_USERS",
},
{ "active-user-billing": false }
);
const strategy = await factory.createByTeamId(1);
expect(strategy).toBeInstanceOf(ImmediateUpdateStrategy);
});
it("returns fallback when billingMode=SEATS even with active-user-billing flag enabled", async () => {
const factory = createFactory(
{ ...baseBillingInfo, billingPeriod: "MONTHLY", billingMode: "SEATS" },
{ "active-user-billing": true, "hwm-seating": true }
);
const strategy = await factory.createByTeamId(1);
expect(strategy).toBeInstanceOf(HighWaterMarkStrategy);
});
it("returns ActiveUserBillingStrategy for MONTHLY billing with billingMode=ACTIVE_USERS", async () => {
const factory = createFactory(
{
...baseBillingInfo,
billingPeriod: "MONTHLY",
billingMode: "ACTIVE_USERS",
},
{ "active-user-billing": true }
);
const strategy = await factory.createByTeamId(1);
expect(strategy).toBeInstanceOf(ActiveUserBillingStrategy);
});
it("returns ActiveUserBillingStrategy for ANNUALLY billing with billingMode=ACTIVE_USERS", async () => {
const factory = createFactory(
{
...baseBillingInfo,
billingPeriod: "ANNUALLY",
billingMode: "ACTIVE_USERS",
},
{ "active-user-billing": true }
);
const strategy = await factory.createByTeamId(1);
expect(strategy).toBeInstanceOf(ActiveUserBillingStrategy);
});
it("returns fallback when in trial even with billingMode=ACTIVE_USERS", async () => {
const factory = createFactory(
{
...baseBillingInfo,
billingPeriod: "MONTHLY",
billingMode: "ACTIVE_USERS",
isInTrial: true,
trialEnd: new Date("2026-06-01"),
},
{ "active-user-billing": true }
);
const strategy = await factory.createByTeamId(1);
expect(strategy).toBeInstanceOf(ImmediateUpdateStrategy);
});
});
@@ -1,11 +1,17 @@
import { describe, expect, it, vi } from "vitest";
import type { ActiveUserBillingService } from "../../../active-user/services/ActiveUserBillingService";
import type { ITeamBillingDataRepository } from "../../../repository/teamBillingData/ITeamBillingDataRepository";
import type { IBillingProviderService } from "../../billingProvider/IBillingProviderService";
import type { HighWaterMarkService } from "../../highWaterMark/HighWaterMarkService";
import type { MonthlyProrationService } from "../../proration/MonthlyProrationService";
import type { HighWaterMarkRepository } from "../../../repository/highWaterMark/HighWaterMarkRepository";
import { ActiveUserBillingStrategy } from "../ActiveUserBillingStrategy";
import { HighWaterMarkStrategy } from "../HighWaterMarkStrategy";
import { ImmediateUpdateStrategy } from "../ImmediateUpdateStrategy";
import type { SeatChangeContext, StripeInvoiceData } from "../ISeatBillingStrategy";
import type {
SeatChangeContext,
StripeInvoiceData,
} from "../ISeatBillingStrategy";
import { MonthlyProrationStrategy } from "../MonthlyProrationStrategy";
const mockContext: SeatChangeContext = {
@@ -19,13 +25,16 @@ const mockContext: SeatChangeContext = {
function createMockBillingProviderService(): IBillingProviderService {
return {
handleSubscriptionUpdate: vi.fn(),
getSubscription: vi.fn().mockResolvedValue(null),
} as unknown as IBillingProviderService;
}
function createMockHighWaterMarkRepository(): HighWaterMarkRepository {
return {
getByTeamId: vi.fn(),
updateIfHigher: vi.fn().mockResolvedValue({ updated: false, previousHighWaterMark: null }),
updateIfHigher: vi
.fn()
.mockResolvedValue({ updated: false, previousHighWaterMark: null }),
} as unknown as HighWaterMarkRepository;
}
@@ -58,25 +67,41 @@ describe("ImmediateUpdateStrategy", () => {
});
it("returns no-op for onInvoiceUpcoming", async () => {
const strategy = new ImmediateUpdateStrategy(createMockBillingProviderService());
expect(await strategy.onInvoiceUpcoming("sub_123")).toEqual({ applied: false });
const strategy = new ImmediateUpdateStrategy(
createMockBillingProviderService()
);
expect(await strategy.onInvoiceUpcoming("sub_123")).toEqual({
applied: false,
});
});
it("returns no-op for onRenewalPaid", async () => {
const strategy = new ImmediateUpdateStrategy(createMockBillingProviderService());
expect(await strategy.onRenewalPaid("sub_123", new Date())).toEqual({ reset: false });
const strategy = new ImmediateUpdateStrategy(
createMockBillingProviderService()
);
expect(await strategy.onRenewalPaid("sub_123", new Date())).toEqual({
reset: false,
});
});
it("returns no-op for onPaymentSucceeded (inherited from base)", async () => {
const strategy = new ImmediateUpdateStrategy(createMockBillingProviderService());
const strategy = new ImmediateUpdateStrategy(
createMockBillingProviderService()
);
const invoice: StripeInvoiceData = { lines: { data: [] } };
expect(await strategy.onPaymentSucceeded(invoice)).toEqual({ handled: false });
expect(await strategy.onPaymentSucceeded(invoice)).toEqual({
handled: false,
});
});
it("returns no-op for onPaymentFailed (inherited from base)", async () => {
const strategy = new ImmediateUpdateStrategy(createMockBillingProviderService());
const strategy = new ImmediateUpdateStrategy(
createMockBillingProviderService()
);
const invoice: StripeInvoiceData = { lines: { data: [] } };
expect(await strategy.onPaymentFailed(invoice, "card_declined")).toEqual({ handled: false });
expect(await strategy.onPaymentFailed(invoice, "card_declined")).toEqual({
handled: false,
});
});
});
@@ -98,7 +123,10 @@ describe("HighWaterMarkStrategy", () => {
highWaterMarkPeriodStart: new Date("2025-06-01"),
isOrganization: false,
});
vi.mocked(hwmRepo.updateIfHigher).mockResolvedValue({ updated: true, previousHighWaterMark: 8 });
vi.mocked(hwmRepo.updateIfHigher).mockResolvedValue({
updated: true,
previousHighWaterMark: 8,
});
await strategy.onSeatChange(mockContext);
@@ -164,12 +192,16 @@ describe("HighWaterMarkStrategy", () => {
it("delegates onInvoiceUpcoming to HighWaterMarkService", async () => {
const { strategy, hwmService } = createStrategy();
vi.mocked(hwmService.applyHighWaterMarkToSubscription).mockResolvedValue(true);
vi.mocked(hwmService.applyHighWaterMarkToSubscription).mockResolvedValue(
true
);
const result = await strategy.onInvoiceUpcoming("sub_123");
expect(result).toEqual({ applied: true });
expect(hwmService.applyHighWaterMarkToSubscription).toHaveBeenCalledWith("sub_123");
expect(hwmService.applyHighWaterMarkToSubscription).toHaveBeenCalledWith(
"sub_123"
);
});
it("delegates onRenewalPaid to HighWaterMarkService", async () => {
@@ -189,13 +221,17 @@ describe("HighWaterMarkStrategy", () => {
it("returns no-op for onPaymentSucceeded (inherited from base)", async () => {
const { strategy } = createStrategy();
const invoice: StripeInvoiceData = { lines: { data: [] } };
expect(await strategy.onPaymentSucceeded(invoice)).toEqual({ handled: false });
expect(await strategy.onPaymentSucceeded(invoice)).toEqual({
handled: false,
});
});
it("returns no-op for onPaymentFailed (inherited from base)", async () => {
const { strategy } = createStrategy();
const invoice: StripeInvoiceData = { lines: { data: [] } };
expect(await strategy.onPaymentFailed(invoice, "card_declined")).toEqual({ handled: false });
expect(await strategy.onPaymentFailed(invoice, "card_declined")).toEqual({
handled: false,
});
});
});
@@ -215,24 +251,34 @@ describe("MonthlyProrationStrategy", () => {
it("returns no-op for onInvoiceUpcoming (inherited from base)", async () => {
const { strategy } = createProrationStrategy();
expect(await strategy.onInvoiceUpcoming("sub_123")).toEqual({ applied: false });
expect(await strategy.onInvoiceUpcoming("sub_123")).toEqual({
applied: false,
});
});
it("returns no-op for onRenewalPaid (inherited from base)", async () => {
const { strategy } = createProrationStrategy();
expect(await strategy.onRenewalPaid("sub_123", new Date())).toEqual({ reset: false });
expect(await strategy.onRenewalPaid("sub_123", new Date())).toEqual({
reset: false,
});
});
it("handles payment succeeded for proration invoice", async () => {
const { strategy, prorationService } = createProrationStrategy();
const invoice: StripeInvoiceData = {
lines: { data: [{ metadata: { type: "monthly_proration", prorationId: "pro_123" } }] },
lines: {
data: [
{ metadata: { type: "monthly_proration", prorationId: "pro_123" } },
],
},
};
const result = await strategy.onPaymentSucceeded(invoice);
expect(result).toEqual({ handled: true });
expect(prorationService.handleProrationPaymentSuccess).toHaveBeenCalledWith("pro_123");
expect(prorationService.handleProrationPaymentSuccess).toHaveBeenCalledWith(
"pro_123"
);
});
it("returns not handled for non-proration invoice on payment succeeded", async () => {
@@ -244,7 +290,9 @@ describe("MonthlyProrationStrategy", () => {
const result = await strategy.onPaymentSucceeded(invoice);
expect(result).toEqual({ handled: false });
expect(prorationService.handleProrationPaymentSuccess).not.toHaveBeenCalled();
expect(
prorationService.handleProrationPaymentSuccess
).not.toHaveBeenCalled();
});
it("returns not handled when proration line item has no prorationId", async () => {
@@ -256,22 +304,30 @@ describe("MonthlyProrationStrategy", () => {
const result = await strategy.onPaymentSucceeded(invoice);
expect(result).toEqual({ handled: false });
expect(prorationService.handleProrationPaymentSuccess).not.toHaveBeenCalled();
expect(
prorationService.handleProrationPaymentSuccess
).not.toHaveBeenCalled();
});
it("handles payment failed for proration invoice", async () => {
const { strategy, prorationService } = createProrationStrategy();
const invoice: StripeInvoiceData = {
lines: { data: [{ metadata: { type: "monthly_proration", prorationId: "pro_456" } }] },
lines: {
data: [
{ metadata: { type: "monthly_proration", prorationId: "pro_456" } },
],
},
};
const result = await strategy.onPaymentFailed(invoice, "card_declined");
expect(result).toEqual({ handled: true });
expect(prorationService.handleProrationPaymentFailure).toHaveBeenCalledWith({
prorationId: "pro_456",
reason: "card_declined",
});
expect(prorationService.handleProrationPaymentFailure).toHaveBeenCalledWith(
{
prorationId: "pro_456",
reason: "card_declined",
}
);
});
it("returns not handled for non-proration invoice on payment failed", async () => {
@@ -281,6 +337,300 @@ describe("MonthlyProrationStrategy", () => {
const result = await strategy.onPaymentFailed(invoice, "card_declined");
expect(result).toEqual({ handled: false });
expect(prorationService.handleProrationPaymentFailure).not.toHaveBeenCalled();
expect(
prorationService.handleProrationPaymentFailure
).not.toHaveBeenCalled();
});
});
function createMockActiveUserBillingService(): ActiveUserBillingService {
return {
getActiveUserCountForOrg: vi.fn().mockResolvedValue(0),
getActiveUserCountForPlatformOrg: vi.fn().mockResolvedValue(0),
} as unknown as ActiveUserBillingService;
}
function createMockTeamBillingDataRepository(): ITeamBillingDataRepository {
return {
find: vi.fn(),
findBySubscriptionId: vi.fn().mockResolvedValue(null),
findMany: vi.fn(),
};
}
describe("ActiveUserBillingStrategy", () => {
function createStrategy() {
const activeUserService = createMockActiveUserBillingService();
const billingProvider = createMockBillingProviderService();
const teamBillingDataRepo = createMockTeamBillingDataRepository();
const strategy = new ActiveUserBillingStrategy({
activeUserBillingService: activeUserService,
billingProviderService: billingProvider,
teamBillingDataRepository: teamBillingDataRepo,
});
return {
strategy,
activeUserService,
billingProvider,
teamBillingDataRepo,
};
}
it.each(["addition", "removal", "sync"] as const)(
"onSeatChange is a no-op for %s",
async (changeType) => {
const {
strategy,
activeUserService,
billingProvider,
teamBillingDataRepo,
} = createStrategy();
await strategy.onSeatChange({ ...mockContext, changeType });
expect(activeUserService.getActiveUserCountForOrg).not.toHaveBeenCalled();
expect(billingProvider.handleSubscriptionUpdate).not.toHaveBeenCalled();
expect(teamBillingDataRepo.findBySubscriptionId).not.toHaveBeenCalled();
}
);
it("counts active users and updates Stripe on onInvoiceUpcoming", async () => {
const {
strategy,
activeUserService,
billingProvider,
teamBillingDataRepo,
} = createStrategy();
vi.mocked(teamBillingDataRepo.findBySubscriptionId).mockResolvedValue({
id: 42,
metadata: {},
isOrganization: false,
parentId: null,
name: "Test Team",
});
vi.mocked(billingProvider.getSubscription).mockResolvedValue({
items: [
{
id: "si_456",
quantity: 5,
price: { unit_amount: 1500, recurring: { interval: "month" } },
},
],
customer: "cus_123",
status: "active",
current_period_start: Math.floor(new Date("2025-06-01").getTime() / 1000),
current_period_end: Math.floor(new Date("2025-07-01").getTime() / 1000),
trial_end: null,
});
vi.mocked(activeUserService.getActiveUserCountForOrg).mockResolvedValue(7);
const result = await strategy.onInvoiceUpcoming("sub_123");
expect(result).toEqual({ applied: true });
expect(activeUserService.getActiveUserCountForOrg).toHaveBeenCalledWith(
42,
new Date("2025-06-01"),
new Date("2025-07-01")
);
expect(billingProvider.handleSubscriptionUpdate).toHaveBeenCalledWith({
subscriptionId: "sub_123",
subscriptionItemId: "si_456",
membershipCount: 7,
prorationBehavior: "none",
});
});
it("updates Stripe to 0 when no users were active", async () => {
const {
strategy,
activeUserService,
billingProvider,
teamBillingDataRepo,
} = createStrategy();
vi.mocked(teamBillingDataRepo.findBySubscriptionId).mockResolvedValue({
id: 42,
metadata: {},
isOrganization: false,
parentId: null,
name: "Test Team",
});
vi.mocked(billingProvider.getSubscription).mockResolvedValue({
items: [
{
id: "si_456",
quantity: 5,
price: { unit_amount: 1500, recurring: { interval: "month" } },
},
],
customer: "cus_123",
status: "active",
current_period_start: Math.floor(new Date("2025-06-01").getTime() / 1000),
current_period_end: Math.floor(new Date("2025-07-01").getTime() / 1000),
trial_end: null,
});
vi.mocked(activeUserService.getActiveUserCountForOrg).mockResolvedValue(0);
const result = await strategy.onInvoiceUpcoming("sub_123");
expect(result).toEqual({ applied: true });
expect(billingProvider.handleSubscriptionUpdate).toHaveBeenCalledWith(
expect.objectContaining({ membershipCount: 0 })
);
});
it("uses the first subscription item when multiple exist", async () => {
const {
strategy,
activeUserService,
billingProvider,
teamBillingDataRepo,
} = createStrategy();
vi.mocked(teamBillingDataRepo.findBySubscriptionId).mockResolvedValue({
id: 42,
metadata: {},
isOrganization: false,
parentId: null,
name: "Test Team",
});
vi.mocked(billingProvider.getSubscription).mockResolvedValue({
items: [
{
id: "si_first",
quantity: 5,
price: { unit_amount: 1500, recurring: { interval: "month" } },
},
{
id: "si_second",
quantity: 10,
price: { unit_amount: 3000, recurring: { interval: "month" } },
},
],
customer: "cus_123",
status: "active",
current_period_start: Math.floor(new Date("2025-06-01").getTime() / 1000),
current_period_end: Math.floor(new Date("2025-07-01").getTime() / 1000),
trial_end: null,
});
vi.mocked(activeUserService.getActiveUserCountForOrg).mockResolvedValue(3);
await strategy.onInvoiceUpcoming("sub_123");
expect(billingProvider.handleSubscriptionUpdate).toHaveBeenCalledWith(
expect.objectContaining({ subscriptionItemId: "si_first" })
);
});
it("does not query active users when team lookup fails", async () => {
const { strategy, activeUserService, teamBillingDataRepo } =
createStrategy();
vi.mocked(teamBillingDataRepo.findBySubscriptionId).mockResolvedValue(null);
await strategy.onInvoiceUpcoming("sub_unknown");
expect(activeUserService.getActiveUserCountForOrg).not.toHaveBeenCalled();
});
it("does not query active users when subscription lookup fails", async () => {
const {
strategy,
activeUserService,
teamBillingDataRepo,
billingProvider,
} = createStrategy();
vi.mocked(teamBillingDataRepo.findBySubscriptionId).mockResolvedValue({
id: 42,
metadata: {},
isOrganization: false,
parentId: null,
name: "Test Team",
});
vi.mocked(billingProvider.getSubscription).mockResolvedValue(null);
await strategy.onInvoiceUpcoming("sub_123");
expect(activeUserService.getActiveUserCountForOrg).not.toHaveBeenCalled();
});
it("returns applied: false when team not found", async () => {
const { strategy, teamBillingDataRepo } = createStrategy();
vi.mocked(teamBillingDataRepo.findBySubscriptionId).mockResolvedValue(null);
const result = await strategy.onInvoiceUpcoming("sub_unknown");
expect(result).toEqual({ applied: false });
});
it("returns applied: false when subscription not found", async () => {
const { strategy, teamBillingDataRepo, billingProvider } = createStrategy();
vi.mocked(teamBillingDataRepo.findBySubscriptionId).mockResolvedValue({
id: 42,
metadata: {},
isOrganization: false,
parentId: null,
name: "Test Team",
});
vi.mocked(billingProvider.getSubscription).mockResolvedValue(null);
const result = await strategy.onInvoiceUpcoming("sub_123");
expect(result).toEqual({ applied: false });
});
it("returns applied: false when subscription has no items", async () => {
const { strategy, teamBillingDataRepo, billingProvider } = createStrategy();
vi.mocked(teamBillingDataRepo.findBySubscriptionId).mockResolvedValue({
id: 42,
metadata: {},
isOrganization: false,
parentId: null,
name: "Test Team",
});
vi.mocked(billingProvider.getSubscription).mockResolvedValue({
items: [],
customer: "cus_123",
status: "active",
current_period_start: Math.floor(new Date("2025-06-01").getTime() / 1000),
current_period_end: Math.floor(new Date("2025-07-01").getTime() / 1000),
trial_end: null,
});
const result = await strategy.onInvoiceUpcoming("sub_123");
expect(result).toEqual({ applied: false });
});
it("returns no-op for onRenewalPaid (inherited from base)", async () => {
const { strategy } = createStrategy();
expect(await strategy.onRenewalPaid("sub_123", new Date())).toEqual({
reset: false,
});
});
it("returns no-op for onPaymentSucceeded (inherited from base)", async () => {
const { strategy } = createStrategy();
const invoice: StripeInvoiceData = { lines: { data: [] } };
expect(await strategy.onPaymentSucceeded(invoice)).toEqual({
handled: false,
});
});
it("returns no-op for onPaymentFailed (inherited from base)", async () => {
const { strategy } = createStrategy();
const invoice: StripeInvoiceData = { lines: { data: [] } };
expect(await strategy.onPaymentFailed(invoice, "card_declined")).toEqual({
handled: false,
});
});
});
+1
View File
@@ -36,6 +36,7 @@ export type AppFlags = {
"booking-audit": boolean;
"monthly-proration": boolean;
"hwm-seating": boolean;
"active-user-billing": boolean;
"sidebar-tips": boolean;
};
@@ -0,0 +1,14 @@
-- CreateEnum
CREATE TYPE "BillingMode" AS ENUM ('SEATS', 'ACTIVE_USERS');
-- AlterTable
ALTER TABLE "TeamBilling" ADD COLUMN "billingMode" "BillingMode" NOT NULL DEFAULT 'SEATS';
-- AlterTable
ALTER TABLE "OrganizationBilling" ADD COLUMN "billingMode" "BillingMode" NOT NULL DEFAULT 'SEATS';
-- Seed feature flag
INSERT INTO "Feature" ("slug", "enabled", "type", "description", "createdAt", "updatedAt")
VALUES
('active-user-billing', false, 'OPERATIONAL', 'Enable active-user-based billing for teams and organizations', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("slug") DO NOTHING;
+7
View File
@@ -2600,6 +2600,11 @@ enum BillingPeriod {
ANNUALLY
}
enum BillingMode {
SEATS
ACTIVE_USERS
}
model OrganizationOnboarding {
// TODO: Use uuid for id
id String @id @default(uuid())
@@ -3039,6 +3044,7 @@ model TeamBilling {
subscriptionEnd DateTime?
billingPeriod BillingPeriod?
billingMode BillingMode @default(SEATS)
pricePerSeat Int?
paidSeats Int?
monthlyProrations MonthlyProration[]
@@ -3067,6 +3073,7 @@ model OrganizationBilling {
subscriptionEnd DateTime?
billingPeriod BillingPeriod?
billingMode BillingMode @default(SEATS)
pricePerSeat Int?
paidSeats Int?
monthlyProrations MonthlyProration[]
@@ -2,6 +2,8 @@ import type { NextApiRequest } from "next";
import authedProcedure from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
import { ZAcceptOrLeaveInputSchema } from "./acceptOrLeave.schema";
import { ZGetActiveUserBookingsInputSchema } from "./getActiveUserBookings.schema";
import { ZGetActiveUserBreakdownInputSchema } from "./getActiveUserBreakdown.schema";
import { ZAddMembersToEventTypes } from "./addMembersToEventTypes.schema";
import { ZChangeMemberRoleInputSchema } from "./changeMemberRole.schema";
import { ZCheckIfMembershipExistsInputSchema } from "./checkIfMembershipExists.schema";
@@ -233,4 +235,12 @@ export const viewerTeamsRouter = router({
const { default: handler } = await import("./listInvoices.handler");
return handler(opts);
}),
getActiveUserBreakdown: authedProcedure.input(ZGetActiveUserBreakdownInputSchema).query(async (opts) => {
const { default: handler } = await import("./getActiveUserBreakdown.handler");
return handler(opts);
}),
getActiveUserBookings: authedProcedure.input(ZGetActiveUserBookingsInputSchema).query(async (opts) => {
const { default: handler } = await import("./getActiveUserBookings.handler");
return handler(opts);
}),
});
@@ -0,0 +1,127 @@
import { getActiveUserBillingService } from "@calcom/features/ee/billing/active-user/di/ActiveUserBillingService.container";
import { BillingPeriodService } from "@calcom/features/ee/billing/service/billingPeriod/BillingPeriodService";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { TeamService } from "@calcom/features/ee/teams/services/teamService";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import type { TGetActiveUserBookingsInputSchema } from "./getActiveUserBookings.schema";
const log = logger.getSubLogger({ prefix: ["getActiveUserBookings"] });
type GetActiveUserBookingsOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TGetActiveUserBookingsInputSchema;
};
export const getActiveUserBookingsHandler = async ({ ctx, input }: GetActiveUserBookingsOptions) => {
if (!IS_TEAM_BILLING_ENABLED) {
return null;
}
const { teamId, userId, activeAs } = input;
const membershipRepository = new MembershipRepository();
const membership = await membershipRepository.findUniqueByUserIdAndTeamId({
userId: ctx.user.id,
teamId,
});
if (!membership || !membership.accepted) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not a member of this team",
});
}
const team = await TeamService.fetchTeamOrThrow(teamId);
const permissionService = new PermissionCheckService();
const hasManageBillingPermission = await permissionService.checkPermission({
userId: ctx.user.id,
teamId,
permission: team.isOrganization ? "organization.manageBilling" : "team.manageBilling",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
if (!hasManageBillingPermission) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only team owners and admins can view active user bookings",
});
}
try {
const targetMembership = await membershipRepository.findUniqueByUserIdAndTeamId({
userId,
teamId,
});
if (!targetMembership || !targetMembership.accepted) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Target user is not a member of this team",
});
}
const targetUser = await prisma.user.findUnique({
where: { id: userId },
select: { email: true },
});
if (!targetUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Target user not found",
});
}
const billingPeriodService = new BillingPeriodService();
const billingInfo = await billingPeriodService.getOrCreateBillingPeriodInfo(teamId);
if (
billingInfo.billingMode !== "ACTIVE_USERS" ||
!billingInfo.subscriptionStart ||
!billingInfo.subscriptionEnd
) {
return null;
}
const activeUserBillingService = getActiveUserBillingService();
const bookings = await activeUserBillingService.getBookingsForUser(
userId,
targetUser.email,
activeAs,
billingInfo.subscriptionStart,
billingInfo.subscriptionEnd
);
return {
bookings: bookings.map((b) => ({
id: b.id,
uid: b.uid,
title: b.title,
startTime: b.startTime.toISOString(),
endTime: b.endTime.toISOString(),
otherParty: b.otherParty,
})),
};
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
log.error("Error getting active user bookings", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to get active user bookings",
});
}
};
export default getActiveUserBookingsHandler;
@@ -0,0 +1,9 @@
import { z } from "zod";
export const ZGetActiveUserBookingsInputSchema = z.object({
teamId: z.number(),
userId: z.number(),
activeAs: z.enum(["host", "attendee"]),
});
export type TGetActiveUserBookingsInputSchema = z.infer<typeof ZGetActiveUserBookingsInputSchema>;
@@ -0,0 +1,99 @@
import { getActiveUserBillingService } from "@calcom/features/ee/billing/active-user/di/ActiveUserBillingService.container";
import { BillingPeriodService } from "@calcom/features/ee/billing/service/billingPeriod/BillingPeriodService";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { TeamService } from "@calcom/features/ee/teams/services/teamService";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import type { TGetActiveUserBreakdownInputSchema } from "./getActiveUserBreakdown.schema";
const log = logger.getSubLogger({ prefix: ["getActiveUserBreakdown"] });
type GetActiveUserBreakdownOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TGetActiveUserBreakdownInputSchema;
};
export const getActiveUserBreakdownHandler = async ({ ctx, input }: GetActiveUserBreakdownOptions) => {
if (!IS_TEAM_BILLING_ENABLED) {
return null;
}
const { teamId } = input;
const membershipRepository = new MembershipRepository();
const membership = await membershipRepository.findUniqueByUserIdAndTeamId({
userId: ctx.user.id,
teamId,
});
if (!membership || !membership.accepted) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not a member of this team",
});
}
const team = await TeamService.fetchTeamOrThrow(teamId);
const permissionService = new PermissionCheckService();
const hasManageBillingPermission = await permissionService.checkPermission({
userId: ctx.user.id,
teamId,
permission: team.isOrganization ? "organization.manageBilling" : "team.manageBilling",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
if (!hasManageBillingPermission) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only team owners and admins can view active user breakdown",
});
}
try {
const billingPeriodService = new BillingPeriodService();
const billingInfo = await billingPeriodService.getOrCreateBillingPeriodInfo(teamId);
if (billingInfo.billingMode !== "ACTIVE_USERS") {
return null;
}
if (!billingInfo.subscriptionStart || !billingInfo.subscriptionEnd) {
return null;
}
const activeUserBillingService = getActiveUserBillingService();
const breakdown = await activeUserBillingService.getActiveUsersForOrg(
teamId,
billingInfo.subscriptionStart,
billingInfo.subscriptionEnd
);
return {
activeUsers: breakdown.activeUsers,
totalMembers: breakdown.totalMembers,
activeHosts: breakdown.activeHosts,
activeAttendees: breakdown.activeAttendees,
periodStart: billingInfo.subscriptionStart.toISOString(),
periodEnd: billingInfo.subscriptionEnd.toISOString(),
pricePerSeat: billingInfo.pricePerSeat,
};
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
log.error("Error getting active user breakdown", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to get active user breakdown",
});
}
};
export default getActiveUserBreakdownHandler;
@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZGetActiveUserBreakdownInputSchema = z.object({
teamId: z.number(),
});
export type TGetActiveUserBreakdownInputSchema = z.infer<typeof ZGetActiveUserBreakdownInputSchema>;
@@ -1,5 +1,6 @@
import { getTeamBillingServiceFactory } from "@calcom/ee/billing/di/containers/Billing";
import { SubscriptionStatus } from "@calcom/ee/billing/repository/billing/IBillingRepository";
import { BillingPeriodService } from "@calcom/features/ee/billing/service/billingPeriod/BillingPeriodService";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { TeamService } from "@calcom/features/ee/teams/services/teamService";
@@ -22,7 +23,7 @@ type GetSubscriptionStatusOptions = {
export const getSubscriptionStatusHandler = async ({ ctx, input }: GetSubscriptionStatusOptions) => {
if (!IS_TEAM_BILLING_ENABLED) {
return { status: null, isTrialing: false };
return { status: null, isTrialing: false, billingMode: null };
}
const { teamId } = input;
@@ -62,11 +63,15 @@ export const getSubscriptionStatusHandler = async ({ ctx, input }: GetSubscripti
const subscriptionStatus = await teamBillingService.getSubscriptionStatus();
const billingPeriodService = new BillingPeriodService();
const billingInfo = await billingPeriodService.getBillingPeriodInfo(teamId);
log.debug(`Subscription status for team ${teamId}: ${subscriptionStatus}`);
return {
status: subscriptionStatus,
isTrialing: subscriptionStatus === SubscriptionStatus.TRIALING,
billingMode: billingInfo.billingMode,
};
} catch (error) {
if (error instanceof TRPCError) {