From d52e2d9fad222e377befedf190d58fd8fdb91227 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:57:32 +0000 Subject: [PATCH] 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 --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../modules/feature-flags/hooks/useFlags.ts | 1 + .../modules/settings/billing/billing-view.tsx | 24 +- .../components/ActiveUserBreakdown.tsx | 256 ++++++++ .../ActiveUserBreakdownSkeleton.tsx | 29 + apps/web/public/static/locales/en/common.json | 7 + .../ActiveUserBillingRepository.ts | 109 +++- .../active-user/seed-active-user-test.ts | 560 ++++++++++++++++++ ...tiveUserBillingService.integration-test.ts | 197 ++++++ .../services/ActiveUserBillingService.test.ts | 78 +++ .../services/ActiveUserBillingService.ts | 116 ++++ .../SeatBillingStrategyFactory.module.ts | 8 +- .../billingPeriod/BillingPeriodRepository.ts | 2 + packages/features/ee/billing/seed.ts | 46 +- .../billingPeriod/BillingPeriodService.ts | 65 +- .../__tests__/BillingPeriodService.test.ts | 3 + .../ActiveUserBillingStrategy.ts | 64 ++ .../SeatBillingStrategyFactory.ts | 16 + .../SeatBillingStrategyFactory.test.ts | 140 ++++- .../__tests__/strategies.test.ts | 404 ++++++++++++- packages/features/flags/config.ts | 1 + .../migration.sql | 14 + packages/prisma/schema.prisma | 7 + .../server/routers/viewer/teams/_router.tsx | 10 + .../teams/getActiveUserBookings.handler.ts | 127 ++++ .../teams/getActiveUserBookings.schema.ts | 9 + .../teams/getActiveUserBreakdown.handler.ts | 99 ++++ .../teams/getActiveUserBreakdown.schema.ts | 7 + .../teams/getSubscriptionStatus.handler.ts | 7 +- 28 files changed, 2327 insertions(+), 79 deletions(-) create mode 100644 apps/web/modules/settings/billing/components/ActiveUserBreakdown.tsx create mode 100644 apps/web/modules/settings/billing/components/ActiveUserBreakdownSkeleton.tsx create mode 100644 packages/features/ee/billing/active-user/seed-active-user-test.ts create mode 100644 packages/features/ee/billing/active-user/services/ActiveUserBillingService.integration-test.ts create mode 100644 packages/features/ee/billing/service/seatBillingStrategy/ActiveUserBillingStrategy.ts create mode 100644 packages/prisma/migrations/20260210000000_add_billing_mode/migration.sql create mode 100644 packages/trpc/server/routers/viewer/teams/getActiveUserBookings.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/getActiveUserBookings.schema.ts create mode 100644 packages/trpc/server/routers/viewer/teams/getActiveUserBreakdown.handler.ts create mode 100644 packages/trpc/server/routers/viewer/teams/getActiveUserBreakdown.schema.ts diff --git a/apps/web/modules/feature-flags/hooks/useFlags.ts b/apps/web/modules/feature-flags/hooks/useFlags.ts index d5456a7867..59e6438255 100644 --- a/apps/web/modules/feature-flags/hooks/useFlags.ts +++ b/apps/web/modules/feature-flags/hooks/useFlags.ts @@ -35,6 +35,7 @@ const initialData: AppFlags = { "booking-audit": false, "monthly-proration": false, "hwm-seating": false, + "active-user-billing": false, "sidebar-tips": false, }; diff --git a/apps/web/modules/settings/billing/billing-view.tsx b/apps/web/modules/settings/billing/billing-view.tsx index e3454bb37e..6288b93e34 100644 --- a/apps/web/modules/settings/billing/billing-view.tsx +++ b/apps/web/modules/settings/billing/billing-view.tsx @@ -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 = () => { + {teamIdNumber && subscriptionStatus?.billingMode === "ACTIVE_USERS" && ( + + )} diff --git a/apps/web/modules/settings/billing/components/ActiveUserBreakdown.tsx b/apps/web/modules/settings/billing/components/ActiveUserBreakdown.tsx new file mode 100644 index 0000000000..f960563c4b --- /dev/null +++ b/apps/web/modules/settings/billing/components/ActiveUserBreakdown.tsx @@ -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 ; + if (!data) return null; + + return ; +} + +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(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 ( + <> + +
+

+ {t("active_users_summary", { + hosts: activeHosts, + attendees: activeAttendees, + active: activeHosts + activeAttendees, + total: totalMembers, + })} +

+
+ {activeUsers.length === 0 ? ( +
{t("no_active_users")}
+ ) : ( + <> +
+ + + + {t("name")} + {t("email")} + {t("status")} + + + + {paginatedUsers.map((user) => ( + setSelectedUser(user)}> + {user.name || "-"} + {user.email} + + {user.activeAs === "host" ? ( + {t("active_as_host")} + ) : ( + {t("active_as_attendee")} + )} + + + ))} + +
+
+ {totalPages > 1 && ( +
+ + +
+ )} + + )} +
+ + !open && setSelectedUser(null)}> + + + {selectedUser?.name || selectedUser?.email} + + + {selectedUser?.email} + {selectedUser?.activeAs === "host" ? ( + {t("active_as_host")} + ) : ( + {t("active_as_attendee")} + )} + + + + + + {selectedUser && ( + + )} + + + + {t("close")}} /> + + + + + ); +} + +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 ( +
+ + + + +
+ ); + } + + const bookings = data?.bookings ?? []; + + return ( +
+
+ {bookings.length} + + {bookings.length === 1 ? t("booking") : t("bookings")} + +
+ + {bookings.length === 0 ? ( +

{t("no_bookings")}

+ ) : ( + + + + {t("booking")} + {t("date")} + {activeAs === "host" ? t("attendees") : t("host")} + + + + {bookings.map((booking) => ( + + {booking.title} + + {dayjs.utc(booking.startTime).format("MMM D, YYYY h:mm A")} + + {booking.otherParty || "-"} + + ))} + +
+ )} +
+ ); +} diff --git a/apps/web/modules/settings/billing/components/ActiveUserBreakdownSkeleton.tsx b/apps/web/modules/settings/billing/components/ActiveUserBreakdownSkeleton.tsx new file mode 100644 index 0000000000..325ca0294f --- /dev/null +++ b/apps/web/modules/settings/billing/components/ActiveUserBreakdownSkeleton.tsx @@ -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 ( + +
+ +
+ + + +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + + +
+ ))} +
+
+ ); +} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 850ce9e9a8..3bdf264f1c 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/features/ee/billing/active-user/repositories/ActiveUserBillingRepository.ts b/packages/features/ee/billing/active-user/repositories/ActiveUserBillingRepository.ts index defb2b810f..0ac959147f 100644 --- a/packages/features/ee/billing/active-user/repositories/ActiveUserBillingRepository.ts +++ b/packages/features/ee/billing/active-user/repositories/ActiveUserBillingRepository.ts @@ -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, }, }, }, diff --git a/packages/features/ee/billing/active-user/seed-active-user-test.ts b/packages/features/ee/billing/active-user/seed-active-user-test.ts new file mode 100644 index 0000000000..4e02a678ba --- /dev/null +++ b/packages/features/ee/billing/active-user/seed-active-user-test.ts @@ -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 { + 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 { + 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(); diff --git a/packages/features/ee/billing/active-user/services/ActiveUserBillingService.integration-test.ts b/packages/features/ee/billing/active-user/services/ActiveUserBillingService.integration-test.ts new file mode 100644 index 0000000000..da645a2389 --- /dev/null +++ b/packages/features/ee/billing/active-user/services/ActiveUserBillingService.integration-test.ts @@ -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 { + 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 { + 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); + }); +}); diff --git a/packages/features/ee/billing/active-user/services/ActiveUserBillingService.test.ts b/packages/features/ee/billing/active-user/services/ActiveUserBillingService.test.ts index 50df86a4d8..aa343f12d9 100644 --- a/packages/features/ee/billing/active-user/services/ActiveUserBillingService.test.ts +++ b/packages/features/ee/billing/active-user/services/ActiveUserBillingService.test.ts @@ -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); + }); + }); }); diff --git a/packages/features/ee/billing/active-user/services/ActiveUserBillingService.ts b/packages/features/ee/billing/active-user/services/ActiveUserBillingService.ts index 6a6c143fc8..d44ad39b03 100644 --- a/packages/features/ee/billing/active-user/services/ActiveUserBillingService.ts +++ b/packages/features/ee/billing/active-user/services/ActiveUserBillingService.ts @@ -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 { + 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, diff --git a/packages/features/ee/billing/di/modules/SeatBillingStrategyFactory.module.ts b/packages/features/ee/billing/di/modules/SeatBillingStrategyFactory.module.ts index 3f6d185609..d8877601de 100644 --- a/packages/features/ee/billing/di/modules/SeatBillingStrategyFactory.module.ts +++ b/packages/features/ee/billing/di/modules/SeatBillingStrategyFactory.module.ts @@ -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, }, }); diff --git a/packages/features/ee/billing/repository/billingPeriod/BillingPeriodRepository.ts b/packages/features/ee/billing/repository/billingPeriod/BillingPeriodRepository.ts index a6cc89f2d8..4f6d0b3f64 100644 --- a/packages/features/ee/billing/repository/billingPeriod/BillingPeriodRepository.ts +++ b/packages/features/ee/billing/repository/billingPeriod/BillingPeriodRepository.ts @@ -20,6 +20,8 @@ export interface UpdateBillingPeriodData { billingPeriod: BillingPeriod; pricePerSeat?: number | null; paidSeats?: number | null; + subscriptionStart?: Date | null; + subscriptionEnd?: Date | null; } export class BillingPeriodRepository { diff --git a/packages/features/ee/billing/seed.ts b/packages/features/ee/billing/seed.ts index a73d4b8183..1348ee9982 100644 --- a/packages/features/ee/billing/seed.ts +++ b/packages/features/ee/billing/seed.ts @@ -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 { - 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()); diff --git a/packages/features/ee/billing/service/billingPeriod/BillingPeriodService.ts b/packages/features/ee/billing/service/billingPeriod/BillingPeriodService.ts index d1a8e0b0a1..e9e8694892 100644 --- a/packages/features/ee/billing/service/billingPeriod/BillingPeriodService.ts +++ b/packages/features/ee/billing/service/billingPeriod/BillingPeriodService.ts @@ -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, diff --git a/packages/features/ee/billing/service/billingPeriod/__tests__/BillingPeriodService.test.ts b/packages/features/ee/billing/service/billingPeriod/__tests__/BillingPeriodService.test.ts index 9727d83434..539453178f 100644 --- a/packages/features/ee/billing/service/billingPeriod/__tests__/BillingPeriodService.test.ts +++ b/packages/features/ee/billing/service/billingPeriod/__tests__/BillingPeriodService.test.ts @@ -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, diff --git a/packages/features/ee/billing/service/seatBillingStrategy/ActiveUserBillingStrategy.ts b/packages/features/ee/billing/service/seatBillingStrategy/ActiveUserBillingStrategy.ts new file mode 100644 index 0000000000..a6bbf87493 --- /dev/null +++ b/packages/features/ee/billing/service/seatBillingStrategy/ActiveUserBillingStrategy.ts @@ -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 { + // 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 }; + } +} diff --git a/packages/features/ee/billing/service/seatBillingStrategy/SeatBillingStrategyFactory.ts b/packages/features/ee/billing/service/seatBillingStrategy/SeatBillingStrategyFactory.ts index 54ef46e2f8..64b295307d 100644 --- a/packages/features/ee/billing/service/seatBillingStrategy/SeatBillingStrategyFactory.ts +++ b/packages/features/ee/billing/service/seatBillingStrategy/SeatBillingStrategyFactory.ts @@ -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 { 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"); diff --git a/packages/features/ee/billing/service/seatBillingStrategy/__tests__/SeatBillingStrategyFactory.test.ts b/packages/features/ee/billing/service/seatBillingStrategy/__tests__/SeatBillingStrategyFactory.test.ts index 7eb15f95f4..e40ec45d7e 100644 --- a/packages/features/ee/billing/service/seatBillingStrategy/__tests__/SeatBillingStrategyFactory.test.ts +++ b/packages/features/ee/billing/service/seatBillingStrategy/__tests__/SeatBillingStrategyFactory.test.ts @@ -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): IFeaturesRepository { +function createMockFeaturesRepository( + enabledFlags: Record +): 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); + }); }); diff --git a/packages/features/ee/billing/service/seatBillingStrategy/__tests__/strategies.test.ts b/packages/features/ee/billing/service/seatBillingStrategy/__tests__/strategies.test.ts index 1762bbe847..c6f2030dff 100644 --- a/packages/features/ee/billing/service/seatBillingStrategy/__tests__/strategies.test.ts +++ b/packages/features/ee/billing/service/seatBillingStrategy/__tests__/strategies.test.ts @@ -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, + }); }); }); diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index e206a30e5d..904698e6c6 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -36,6 +36,7 @@ export type AppFlags = { "booking-audit": boolean; "monthly-proration": boolean; "hwm-seating": boolean; + "active-user-billing": boolean; "sidebar-tips": boolean; }; diff --git a/packages/prisma/migrations/20260210000000_add_billing_mode/migration.sql b/packages/prisma/migrations/20260210000000_add_billing_mode/migration.sql new file mode 100644 index 0000000000..c373b0bb32 --- /dev/null +++ b/packages/prisma/migrations/20260210000000_add_billing_mode/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index ca3c239582..3f233473fa 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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[] diff --git a/packages/trpc/server/routers/viewer/teams/_router.tsx b/packages/trpc/server/routers/viewer/teams/_router.tsx index 193ae3beed..e2f5a783d7 100644 --- a/packages/trpc/server/routers/viewer/teams/_router.tsx +++ b/packages/trpc/server/routers/viewer/teams/_router.tsx @@ -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); + }), }); diff --git a/packages/trpc/server/routers/viewer/teams/getActiveUserBookings.handler.ts b/packages/trpc/server/routers/viewer/teams/getActiveUserBookings.handler.ts new file mode 100644 index 0000000000..83d221c7de --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/getActiveUserBookings.handler.ts @@ -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; + }; + 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; diff --git a/packages/trpc/server/routers/viewer/teams/getActiveUserBookings.schema.ts b/packages/trpc/server/routers/viewer/teams/getActiveUserBookings.schema.ts new file mode 100644 index 0000000000..72a6f00f2c --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/getActiveUserBookings.schema.ts @@ -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; diff --git a/packages/trpc/server/routers/viewer/teams/getActiveUserBreakdown.handler.ts b/packages/trpc/server/routers/viewer/teams/getActiveUserBreakdown.handler.ts new file mode 100644 index 0000000000..94d2c7d44a --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/getActiveUserBreakdown.handler.ts @@ -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; + }; + 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; diff --git a/packages/trpc/server/routers/viewer/teams/getActiveUserBreakdown.schema.ts b/packages/trpc/server/routers/viewer/teams/getActiveUserBreakdown.schema.ts new file mode 100644 index 0000000000..c4e4856892 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/getActiveUserBreakdown.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetActiveUserBreakdownInputSchema = z.object({ + teamId: z.number(), +}); + +export type TGetActiveUserBreakdownInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/teams/getSubscriptionStatus.handler.ts b/packages/trpc/server/routers/viewer/teams/getSubscriptionStatus.handler.ts index 02bd40f8d9..3a490af65e 100644 --- a/packages/trpc/server/routers/viewer/teams/getSubscriptionStatus.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/getSubscriptionStatus.handler.ts @@ -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) {