feat: active user billing (#27867)
* factory and statergie * chore: use correct method of DI * feat: add onchagne * add logic to HWM stat * add webhook resolver methods to each statergy * move seat tracking + webhooks over to own statergy * Move to factory base approach * move logic to correct class * rename create -> createByTeamId * fix: remove debug `true ||` overrides from IS_STRIPE_ENABLED and IS_TEAM_BILLING_ENABLED Remove accidentally committed debug overrides that short-circuited IS_STRIPE_ENABLED and IS_TEAM_BILLING_ENABLED to always be true, bypassing Stripe credential checks. This would break self-hosted instances without Stripe configured. Identified by cubic (https://cubic.dev) Co-Authored-By: unknown <> * feat: active user billing * add tests * UI side for users on active billing * use correct period of stripe sub * feat: claude feedback * fix: skip Stripe sync for canceled/expired subscriptions to prevent repeated API calls Co-Authored-By: unknown <> * feat: feedback * feat: only render when active users mode is set * fix type error * fix: constants + feature flags * fix: default to null in tests * chore: use seats in test * fix: use node:crypto protocol for Node.js builtin imports Co-Authored-By: sean@cal.com <Sean@brydon.io> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
unknown
sean@cal.com
Devin AI
parent
bf348ee209
commit
d52e2d9fad
@@ -35,6 +35,7 @@ const initialData: AppFlags = {
|
||||
"booking-audit": false,
|
||||
"monthly-proration": false,
|
||||
"hwm-seating": false,
|
||||
"active-user-billing": false,
|
||||
"sidebar-tips": false,
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/co
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { ActiveUserBreakdown } from "~/settings/billing/components/ActiveUserBreakdown";
|
||||
import BillingCredits from "~/settings/billing/components/BillingCredits";
|
||||
import { InvoicesTable } from "~/settings/billing/components/InvoicesTable";
|
||||
|
||||
@@ -51,25 +52,21 @@ const BillingView = () => {
|
||||
const [showSkipTrialDialog, setShowSkipTrialDialog] = useState(false);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Determine the billing context and extract appropriate team/org ID
|
||||
const getTeamIdFromContext = () => {
|
||||
const teamIdNumber = useMemo(() => {
|
||||
if (!pathname) return null;
|
||||
|
||||
// Team billing: /settings/teams/{id}/billing
|
||||
if (pathname.includes("/teams/") && pathname.includes("/billing")) {
|
||||
const teamIdMatch = pathname.match(/\/teams\/(\d+)\/billing/);
|
||||
return teamIdMatch ? teamIdMatch[1] : null;
|
||||
return teamIdMatch ? parseInt(teamIdMatch[1], 10) : null;
|
||||
}
|
||||
|
||||
// Organization billing: /settings/organizations/billing
|
||||
if (pathname.includes("/organizations/billing")) {
|
||||
const orgId = session.data?.user?.org?.id;
|
||||
return typeof orgId === "number" ? orgId.toString() : null;
|
||||
return typeof orgId === "number" ? orgId : null;
|
||||
}
|
||||
};
|
||||
|
||||
const teamId = getTeamIdFromContext();
|
||||
const teamIdNumber = teamId ? parseInt(teamId, 10) : null;
|
||||
return null;
|
||||
}, [pathname, session.data?.user?.org?.id]);
|
||||
|
||||
const { data: subscriptionStatus, isLoading: isLoadingStatus } =
|
||||
trpc.viewer.teams.getSubscriptionStatus.useQuery(
|
||||
@@ -91,8 +88,8 @@ const BillingView = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const billingHref = teamId
|
||||
? `/api/integrations/stripepayment/portal?teamId=${teamId}&returnTo=${WEBAPP_URL}${returnTo}`
|
||||
const billingHref = teamIdNumber
|
||||
? `/api/integrations/stripepayment/portal?teamId=${teamIdNumber}&returnTo=${WEBAPP_URL}${returnTo}`
|
||||
: `/api/integrations/stripepayment/portal?returnTo=${WEBAPP_URL}${returnTo}`;
|
||||
|
||||
const onContactSupportClick = async () => {
|
||||
@@ -146,6 +143,9 @@ const BillingView = () => {
|
||||
</div>
|
||||
</div>
|
||||
<BillingCredits />
|
||||
{teamIdNumber && subscriptionStatus?.billingMode === "ACTIVE_USERS" && (
|
||||
<ActiveUserBreakdown teamId={teamIdNumber} />
|
||||
)}
|
||||
<InvoicesTable />
|
||||
|
||||
<Dialog open={showSkipTrialDialog} onOpenChange={setShowSkipTrialDialog}>
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Badge } from "@coss/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { PanelCard } from "@calcom/ui/components/card";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetPanel,
|
||||
SheetTitle,
|
||||
SheetClose,
|
||||
} from "@coss/ui/components/sheet";
|
||||
import { Skeleton } from "@coss/ui/components/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@coss/ui/components/table";
|
||||
|
||||
import { ActiveUserBreakdownSkeleton } from "./ActiveUserBreakdownSkeleton";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export function ActiveUserBreakdown({ teamId }: { teamId: number }) {
|
||||
const { data, isLoading } = trpc.viewer.teams.getActiveUserBreakdown.useQuery(
|
||||
{ teamId },
|
||||
{ enabled: !!teamId }
|
||||
);
|
||||
|
||||
if (isLoading) return <ActiveUserBreakdownSkeleton />;
|
||||
if (!data) return null;
|
||||
|
||||
return <ActiveUserBreakdownContent data={data} teamId={teamId} />;
|
||||
}
|
||||
|
||||
type BreakdownData = {
|
||||
activeUsers: Array<{ id: number; email: string; name: string | null; activeAs: "host" | "attendee" }>;
|
||||
totalMembers: number;
|
||||
activeHosts: number;
|
||||
activeAttendees: number;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
pricePerSeat: number | null;
|
||||
};
|
||||
|
||||
type SelectedUser = {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
activeAs: "host" | "attendee";
|
||||
};
|
||||
|
||||
function ActiveUserBreakdownContent({ data, teamId }: { data: BreakdownData; teamId: number }) {
|
||||
const { t } = useLocale();
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [selectedUser, setSelectedUser] = useState<SelectedUser | null>(null);
|
||||
|
||||
const { activeUsers, totalMembers, activeHosts, activeAttendees, periodStart, periodEnd } = data;
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(0);
|
||||
}, [activeUsers]);
|
||||
|
||||
const totalPages = Math.ceil(activeUsers.length / PAGE_SIZE);
|
||||
const paginatedUsers = activeUsers.slice(currentPage * PAGE_SIZE, (currentPage + 1) * PAGE_SIZE);
|
||||
|
||||
const formattedStart = dayjs.utc(periodStart).format("MMM D, YYYY");
|
||||
const formattedEnd = dayjs.utc(periodEnd).format("MMM D, YYYY");
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelCard
|
||||
title={t("active_users_billing")}
|
||||
subtitle={t("active_users_billing_description", {
|
||||
start: formattedStart,
|
||||
end: formattedEnd,
|
||||
})}
|
||||
className="mt-5">
|
||||
<div className="px-5 py-3">
|
||||
<p className="text-subtle text-sm font-medium">
|
||||
{t("active_users_summary", {
|
||||
hosts: activeHosts,
|
||||
attendees: activeAttendees,
|
||||
active: activeHosts + activeAttendees,
|
||||
total: totalMembers,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{activeUsers.length === 0 ? (
|
||||
<div className="text-subtle p-6 text-center text-sm">{t("no_active_users")}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("email")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedUsers.map((user) => (
|
||||
<TableRow
|
||||
key={user.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setSelectedUser(user)}>
|
||||
<TableCell>{user.name || "-"}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
{user.activeAs === "host" ? (
|
||||
<Badge variant="success">{t("active_as_host")}</Badge>
|
||||
) : (
|
||||
<Badge variant="info">{t("active_as_attendee")}</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="border-muted flex items-center justify-end gap-2 border-t px-4 py-3">
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={currentPage === 0}
|
||||
StartIcon="arrow-left">
|
||||
{t("previous")}
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages - 1}
|
||||
EndIcon="arrow-right">
|
||||
{t("next")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PanelCard>
|
||||
|
||||
<Sheet open={!!selectedUser} onOpenChange={(open) => !open && setSelectedUser(null)}>
|
||||
<SheetContent side="right" inset>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{selectedUser?.name || selectedUser?.email}</SheetTitle>
|
||||
<SheetDescription>
|
||||
<span className="flex items-center gap-2">
|
||||
{selectedUser?.email}
|
||||
{selectedUser?.activeAs === "host" ? (
|
||||
<Badge variant="success">{t("active_as_host")}</Badge>
|
||||
) : (
|
||||
<Badge variant="info">{t("active_as_attendee")}</Badge>
|
||||
)}
|
||||
</span>
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<SheetPanel>
|
||||
{selectedUser && (
|
||||
<UserBookingsSheet
|
||||
teamId={teamId}
|
||||
userId={selectedUser.id}
|
||||
activeAs={selectedUser.activeAs}
|
||||
/>
|
||||
)}
|
||||
</SheetPanel>
|
||||
|
||||
<SheetFooter>
|
||||
<SheetClose render={<Button color="secondary">{t("close")}</Button>} />
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserBookingsSheet({
|
||||
teamId,
|
||||
userId,
|
||||
activeAs,
|
||||
}: {
|
||||
teamId: number;
|
||||
userId: number;
|
||||
activeAs: "host" | "attendee";
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const { data, isLoading } = trpc.viewer.teams.getActiveUserBookings.useQuery(
|
||||
{ teamId, userId, activeAs },
|
||||
{ enabled: true }
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-8 w-24" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const bookings = data?.bookings ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold">{bookings.length}</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{bookings.length === 1 ? t("booking") : t("bookings")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{bookings.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">{t("no_bookings")}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("booking")}</TableHead>
|
||||
<TableHead>{t("date")}</TableHead>
|
||||
<TableHead>{activeAs === "host" ? t("attendees") : t("host")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{bookings.map((booking) => (
|
||||
<TableRow key={booking.id}>
|
||||
<TableCell className="font-medium">{booking.title}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{dayjs.utc(booking.startTime).format("MMM D, YYYY h:mm A")}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{booking.otherParty || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { PanelCard } from "@calcom/ui/components/card";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
export function ActiveUserBreakdownSkeleton() {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<PanelCard title={t("active_users_billing")} className="mt-5">
|
||||
<div className="p-4">
|
||||
<SkeletonText className="mb-4 h-4 w-64" />
|
||||
<div className="mb-3 flex gap-4 border-b pb-3">
|
||||
<SkeletonText className="h-4 w-32" />
|
||||
<SkeletonText className="h-4 w-40" />
|
||||
<SkeletonText className="h-4 w-20" />
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-4 py-3">
|
||||
<SkeletonText className="h-4 w-32" />
|
||||
<SkeletonText className="h-4 w-40" />
|
||||
<SkeletonText className="h-4 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
@@ -2173,6 +2173,13 @@
|
||||
"skip_trial_confirmation_description": "Are you sure you want to skip your trial period? Your paid subscription will start immediately and you will be charged according to your plan.",
|
||||
"trial_skipped_successfully": "Trial period skipped successfully",
|
||||
"error_skipping_trial": "Failed to skip trial period",
|
||||
"active_users_billing": "Active users",
|
||||
"active_users_billing_description": "Billing period: {{start}} - {{end}}",
|
||||
"active_as_host": "Host",
|
||||
"active_as_attendee": "Attendee",
|
||||
"inactive_member": "Inactive",
|
||||
"active_users_summary": "{{hosts}} hosts + {{attendees}} attendees = {{active}} active of {{total}} members",
|
||||
"no_active_users": "No active users in this billing period",
|
||||
"billing_help_cta": "Contact support",
|
||||
"ignore_special_characters_booking_questions": "Ignore special characters in your booking question identifier. Use only letters and numbers",
|
||||
"ignore_special_characters_form_responses": "Ignore special characters in your form field identifier. Use only letters and numbers",
|
||||
|
||||
+106
-3
@@ -1,4 +1,7 @@
|
||||
import type { PrismaClient } from "@calcom/prisma/client";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
const BOOKING_DETAIL_LIMIT = 100;
|
||||
|
||||
export class ActiveUserBillingRepository {
|
||||
constructor(private readonly prismaClient: PrismaClient) {}
|
||||
@@ -48,6 +51,27 @@ export class ActiveUserBillingRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async getOrgMemberDetailsByOrgId(
|
||||
orgId: number
|
||||
): Promise<{ id: number; email: string; name: string | null }[]> {
|
||||
return this.prismaClient.user.findMany({
|
||||
distinct: ["email"],
|
||||
where: {
|
||||
teams: {
|
||||
some: {
|
||||
teamId: orgId,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find platform-managed users (by email) who hosted at least one booking in the given period.
|
||||
* Filters on isPlatformManaged to match only platform org users.
|
||||
@@ -73,9 +97,10 @@ export class ActiveUserBillingRepository {
|
||||
bookings: {
|
||||
some: {
|
||||
userId: { not: null },
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: {
|
||||
gte: periodStart,
|
||||
lte: periodEnd,
|
||||
lt: periodEnd,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -101,9 +126,10 @@ export class ActiveUserBillingRepository {
|
||||
bookings: {
|
||||
some: {
|
||||
userId: { not: null },
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: {
|
||||
gte: periodStart,
|
||||
lte: periodEnd,
|
||||
lt: periodEnd,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -114,6 +140,82 @@ export class ActiveUserBillingRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async getBookingsByHostUserId(
|
||||
userId: number,
|
||||
periodStart: Date,
|
||||
periodEnd: Date
|
||||
): Promise<
|
||||
Array<{
|
||||
id: number;
|
||||
uid: string;
|
||||
title: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
attendees: Array<{ email: string; name: string | null }>;
|
||||
}>
|
||||
> {
|
||||
return this.prismaClient.booking.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: { gte: periodStart, lt: periodEnd },
|
||||
},
|
||||
orderBy: { startTime: "desc" },
|
||||
take: BOOKING_DETAIL_LIMIT,
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
title: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
attendees: {
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getBookingsByAttendeeEmail(
|
||||
email: string,
|
||||
periodStart: Date,
|
||||
periodEnd: Date
|
||||
): Promise<
|
||||
Array<{
|
||||
id: number;
|
||||
uid: string;
|
||||
title: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
user: { name: string | null; email: string } | null;
|
||||
}>
|
||||
> {
|
||||
return this.prismaClient.booking.findMany({
|
||||
where: {
|
||||
attendees: { some: { email } },
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: { gte: periodStart, lt: periodEnd },
|
||||
},
|
||||
orderBy: { startTime: "desc" },
|
||||
take: BOOKING_DETAIL_LIMIT,
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
title: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find users (by email) who attended at least one booking in the given period.
|
||||
*/
|
||||
@@ -127,9 +229,10 @@ export class ActiveUserBillingRepository {
|
||||
where: {
|
||||
email: { in: userEmails },
|
||||
booking: {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: {
|
||||
gte: periodStart,
|
||||
lte: periodEnd,
|
||||
lt: periodEnd,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Seed script for testing Active User billing
|
||||
*
|
||||
* This script creates:
|
||||
* 1. An organization with 100 members (admin + 99 members)
|
||||
* 2. Bookings so that 30 members are "active" (hosted or attended a booking)
|
||||
* 3. Stripe customer + subscription with billingMode = ACTIVE_USERS
|
||||
* 4. The active-user-billing feature flag
|
||||
*
|
||||
* After seeding it runs the ActiveUserBillingService to count active users
|
||||
* and prints a comparison between created vs counted.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - DATABASE_URL must be set (or .env loaded)
|
||||
* - STRIPE_PRIVATE_KEY for real Stripe resources (optional)
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx packages/features/ee/billing/active-user/seed-active-user-test.ts
|
||||
*
|
||||
* Options:
|
||||
* --skip-stripe Skip Stripe API calls (use fake IDs)
|
||||
* --cleanup Clean up test data before seeding
|
||||
*/
|
||||
|
||||
import "dotenv/config";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import bcrypt from "bcryptjs";
|
||||
import Stripe from "stripe";
|
||||
|
||||
import { ORGANIZATION_SELF_SERVE_PRICE } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { BillingMode, BillingPeriod, BookingStatus, MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { ActiveUserBillingRepository } from "./repositories/ActiveUserBillingRepository";
|
||||
import { ActiveUserBillingService } from "./services/ActiveUserBillingService";
|
||||
|
||||
const SKIP_STRIPE = process.argv.includes("--skip-stripe");
|
||||
const CLEANUP_FIRST = process.argv.includes("--cleanup");
|
||||
|
||||
const TOTAL_MEMBERS = 99;
|
||||
const ACTIVE_HOSTS = 20;
|
||||
const ACTIVE_ATTENDEES_ONLY = 10;
|
||||
const TOTAL_ACTIVE = ACTIVE_HOSTS + ACTIVE_ATTENDEES_ONLY; // 30
|
||||
|
||||
const ORG_SLUG = "aub-test-org";
|
||||
const ORG_ADMIN_EMAIL = "aub-org-admin@example.com";
|
||||
const ORG_MEMBER_EMAILS = Array.from({ length: TOTAL_MEMBERS }, (_, i) => `aub-org-member-${i + 1}@example.com`);
|
||||
|
||||
const ALL_EMAILS = [ORG_ADMIN_EMAIL, ...ORG_MEMBER_EMAILS];
|
||||
|
||||
const ORG_PRICE_PER_SEAT_CENTS = ORGANIZATION_SELF_SERVE_PRICE * 100;
|
||||
|
||||
const STRIPE_ORG_MONTHLY_PRICE_ID = process.env.STRIPE_ORG_MONTHLY_PRICE_ID;
|
||||
|
||||
let hashedPasswordCache: string | null = null;
|
||||
async function getHashedPassword(): Promise<string> {
|
||||
if (!hashedPasswordCache) {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
hashedPasswordCache = await bcrypt.hash("password123", salt);
|
||||
}
|
||||
return hashedPasswordCache;
|
||||
}
|
||||
|
||||
async function createTestUser(
|
||||
email: string,
|
||||
name: string,
|
||||
username: string,
|
||||
organizationId?: number
|
||||
) {
|
||||
const hashedPassword = await getHashedPassword();
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: { completedOnboarding: true, organizationId },
|
||||
create: {
|
||||
email,
|
||||
name,
|
||||
username,
|
||||
password: { create: { hash: hashedPassword } },
|
||||
emailVerified: new Date(),
|
||||
completedOnboarding: true,
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (organizationId) {
|
||||
await prisma.profile.upsert({
|
||||
where: { userId_organizationId: { userId: user.id, organizationId } },
|
||||
update: {},
|
||||
create: {
|
||||
uid: `${user.id}-${organizationId}`,
|
||||
userId: user.id,
|
||||
organizationId,
|
||||
username,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
function getStripeClient(): Stripe | null {
|
||||
if (SKIP_STRIPE) {
|
||||
console.log("Skipping Stripe API calls (--skip-stripe flag)");
|
||||
return null;
|
||||
}
|
||||
if (!process.env.STRIPE_PRIVATE_KEY) {
|
||||
console.log("STRIPE_PRIVATE_KEY not set, skipping Stripe API calls");
|
||||
return null;
|
||||
}
|
||||
return new Stripe(process.env.STRIPE_PRIVATE_KEY, { apiVersion: "2020-08-27" });
|
||||
}
|
||||
|
||||
async function cleanupStripeResources(stripe: Stripe) {
|
||||
console.log("Cleaning up Stripe resources...");
|
||||
const testClocks = await stripe.testHelpers.testClocks.list({ limit: 100 });
|
||||
for (const clock of testClocks.data) {
|
||||
if (clock.name?.startsWith("AUB Test")) {
|
||||
try {
|
||||
await stripe.testHelpers.testClocks.del(clock.id);
|
||||
console.log(` Deleted test clock: ${clock.id}`);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const customers = await stripe.customers.list({ limit: 100, email: ORG_ADMIN_EMAIL });
|
||||
for (const customer of customers.data) {
|
||||
try {
|
||||
const subs = await stripe.subscriptions.list({ customer: customer.id, status: "all" });
|
||||
for (const sub of subs.data) {
|
||||
if (sub.status !== "canceled") await stripe.subscriptions.cancel(sub.id);
|
||||
}
|
||||
await stripe.customers.del(customer.id);
|
||||
console.log(` Deleted customer: ${customer.id}`);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupDatabaseResources() {
|
||||
console.log("Cleaning up database resources...");
|
||||
|
||||
const org = await prisma.team.findFirst({ where: { slug: ORG_SLUG, isOrganization: true } });
|
||||
|
||||
if (org) {
|
||||
const memberUsers = await prisma.user.findMany({
|
||||
where: { email: { in: ALL_EMAILS } },
|
||||
select: { id: true },
|
||||
});
|
||||
const memberIds = memberUsers.map((u) => u.id);
|
||||
|
||||
if (memberIds.length > 0) {
|
||||
const bookings = await prisma.booking.findMany({
|
||||
where: { userId: { in: memberIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
const bookingIds = bookings.map((b) => b.id);
|
||||
if (bookingIds.length > 0) {
|
||||
await prisma.attendee.deleteMany({ where: { bookingId: { in: bookingIds } } });
|
||||
await prisma.booking.deleteMany({ where: { id: { in: bookingIds } } });
|
||||
console.log(` Deleted ${bookingIds.length} bookings`);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.organizationBilling.deleteMany({ where: { teamId: org.id } });
|
||||
await prisma.organizationSettings.deleteMany({ where: { organizationId: org.id } });
|
||||
await prisma.membership.deleteMany({ where: { teamId: org.id } });
|
||||
await prisma.team.delete({ where: { id: org.id } });
|
||||
console.log(` Deleted org (ID: ${org.id})`);
|
||||
}
|
||||
|
||||
const deleteResult = await prisma.user.deleteMany({ where: { email: { in: ALL_EMAILS } } });
|
||||
if (deleteResult.count > 0) console.log(` Deleted ${deleteResult.count} users`);
|
||||
|
||||
console.log(" Database cleanup complete");
|
||||
}
|
||||
|
||||
interface UserRecord {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
interface SeedResult {
|
||||
orgId: number;
|
||||
subscriptionId: string;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
totalMembers: number;
|
||||
expectedActiveHosts: string[];
|
||||
expectedActiveAttendees: string[];
|
||||
expectedInactive: string[];
|
||||
}
|
||||
|
||||
async function seed(): Promise<SeedResult> {
|
||||
const stripe = getStripeClient();
|
||||
|
||||
// Enable feature flag
|
||||
await prisma.feature.upsert({
|
||||
where: { slug: "active-user-billing" },
|
||||
update: { enabled: true },
|
||||
create: {
|
||||
slug: "active-user-billing",
|
||||
enabled: true,
|
||||
description: "Active user billing strategy",
|
||||
type: "RELEASE",
|
||||
stale: false,
|
||||
},
|
||||
});
|
||||
console.log("Enabled active-user-billing feature flag");
|
||||
|
||||
if (CLEANUP_FIRST) {
|
||||
await cleanupDatabaseResources();
|
||||
if (stripe) await cleanupStripeResources(stripe);
|
||||
}
|
||||
|
||||
// Create organization
|
||||
console.log("\nCreating organization...");
|
||||
let org = await prisma.team.findFirst({ where: { slug: ORG_SLUG, isOrganization: true } });
|
||||
if (!org) {
|
||||
org = await prisma.team.create({
|
||||
data: { name: "AUB Test Org", slug: ORG_SLUG, isOrganization: true },
|
||||
});
|
||||
}
|
||||
console.log(` Organization: ${org.name} (ID: ${org.id})`);
|
||||
|
||||
await prisma.organizationSettings.upsert({
|
||||
where: { organizationId: org.id },
|
||||
update: {},
|
||||
create: {
|
||||
organizationId: org.id,
|
||||
orgAutoAcceptEmail: "example.com",
|
||||
isOrganizationConfigured: true,
|
||||
isOrganizationVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create users (admin + 99 members = 100 total)
|
||||
console.log(`Creating ${TOTAL_MEMBERS + 1} users...`);
|
||||
const admin = await createTestUser(ORG_ADMIN_EMAIL, "AUB Org Admin", "aub-org-admin", org.id);
|
||||
const members: UserRecord[] = [];
|
||||
for (let i = 0; i < TOTAL_MEMBERS; i++) {
|
||||
const user = await createTestUser(
|
||||
ORG_MEMBER_EMAILS[i],
|
||||
`AUB Member ${i + 1}`,
|
||||
`aub-org-member-${i + 1}`,
|
||||
org.id
|
||||
);
|
||||
members.push(user);
|
||||
if ((i + 1) % 25 === 0) console.log(` ... created ${i + 1}/${TOTAL_MEMBERS} members`);
|
||||
}
|
||||
console.log(` Created admin + ${members.length} members = ${members.length + 1} total`);
|
||||
|
||||
// Add memberships
|
||||
console.log("Adding memberships...");
|
||||
await prisma.membership.upsert({
|
||||
where: { userId_teamId: { userId: admin.id, teamId: org.id } },
|
||||
update: { role: MembershipRole.OWNER },
|
||||
create: { userId: admin.id, teamId: org.id, role: MembershipRole.OWNER, accepted: true },
|
||||
});
|
||||
for (const member of members) {
|
||||
await prisma.membership.upsert({
|
||||
where: { userId_teamId: { userId: member.id, teamId: org.id } },
|
||||
update: { role: MembershipRole.MEMBER },
|
||||
create: { userId: member.id, teamId: org.id, role: MembershipRole.MEMBER, accepted: true },
|
||||
});
|
||||
}
|
||||
|
||||
// Billing period: defaults to current calendar month, overridden by Stripe below
|
||||
const now = new Date();
|
||||
let periodStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
let periodEnd = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
|
||||
|
||||
// Stripe setup (before bookings so we know the actual billing period)
|
||||
let stripeCustomerId = `cus_fake_aub_${Date.now()}`;
|
||||
let stripeSubscriptionId = `sub_fake_aub_${Date.now()}`;
|
||||
let stripeSubscriptionItemId = `si_fake_aub_${Date.now()}`;
|
||||
let pricePerSeatCents = ORG_PRICE_PER_SEAT_CENTS;
|
||||
|
||||
if (stripe) {
|
||||
if (!STRIPE_ORG_MONTHLY_PRICE_ID) {
|
||||
throw new Error("STRIPE_ORG_MONTHLY_PRICE_ID is required when running with Stripe");
|
||||
}
|
||||
|
||||
console.log("\nCreating Stripe resources...");
|
||||
const price = await stripe.prices.retrieve(STRIPE_ORG_MONTHLY_PRICE_ID);
|
||||
pricePerSeatCents = price.unit_amount || ORG_PRICE_PER_SEAT_CENTS;
|
||||
const product =
|
||||
typeof price.product === "string"
|
||||
? await stripe.products.retrieve(price.product)
|
||||
: (price.product as Stripe.Product);
|
||||
|
||||
const testClock = await stripe.testHelpers.testClocks.create({
|
||||
frozen_time: Math.floor(Date.now() / 1000),
|
||||
name: "AUB Test - Active User Billing",
|
||||
});
|
||||
console.log(` Test clock: ${testClock.id}`);
|
||||
|
||||
const customer = await stripe.customers.create({
|
||||
email: ORG_ADMIN_EMAIL,
|
||||
name: "AUB Test Org",
|
||||
test_clock: testClock.id,
|
||||
metadata: { testData: "true", calTeamId: org.id.toString() },
|
||||
});
|
||||
|
||||
const pm = await stripe.paymentMethods.create({ type: "card", card: { token: "tok_visa" } });
|
||||
await stripe.paymentMethods.attach(pm.id, { customer: customer.id });
|
||||
await stripe.customers.update(customer.id, {
|
||||
invoice_settings: { default_payment_method: pm.id },
|
||||
});
|
||||
|
||||
const totalMembers = 1 + members.length;
|
||||
const subscription = await stripe.subscriptions.create({
|
||||
customer: customer.id,
|
||||
items: [{ price: price.id, quantity: totalMembers }],
|
||||
metadata: { testData: "true", teamId: org.id.toString() },
|
||||
});
|
||||
|
||||
stripeCustomerId = customer.id;
|
||||
stripeSubscriptionId = subscription.id;
|
||||
stripeSubscriptionItemId = subscription.items.data[0].id;
|
||||
|
||||
// Use Stripe's actual billing period so bookings align with what Stripe bills for
|
||||
periodStart = new Date(subscription.current_period_start * 1000);
|
||||
periodEnd = new Date(subscription.current_period_end * 1000);
|
||||
|
||||
console.log(` Customer: ${customer.id}`);
|
||||
console.log(` Subscription: ${subscription.id} (${totalMembers} seats)`);
|
||||
console.log(` Product: ${product.id} (${product.name})`);
|
||||
console.log(` Stripe period: ${periodStart.toISOString()} - ${periodEnd.toISOString()}`);
|
||||
console.log(
|
||||
` Test clock URL: https://dashboard.stripe.com/test/test-clocks/${testClock.id}`
|
||||
);
|
||||
}
|
||||
|
||||
// Create bookings within the billing period to produce exactly 30 active users:
|
||||
//
|
||||
// Admin -> hosts 2 bookings in period (ACTIVE as host) [1]
|
||||
// Members 1-19 -> each hosts 1 booking in period (ACTIVE as host) [19]
|
||||
// Members 20-29 -> each attends one of the host bookings (ACTIVE as attendee only) [10]
|
||||
// Member 30 -> hosts 1 booking OUTSIDE period (INACTIVE - out of range)
|
||||
// Members 31-99 -> no bookings (INACTIVE)
|
||||
//
|
||||
// Total active = 1 admin host + 19 member hosts + 10 attendees = 30
|
||||
// Total inactive = 1 out-of-range host + 69 idle = 70
|
||||
|
||||
console.log(`Creating bookings in period ${periodStart.toISOString()} - ${periodEnd.toISOString()}...`);
|
||||
const bookingIds: number[] = [];
|
||||
|
||||
async function createBooking(
|
||||
host: UserRecord,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
attendeeEmail?: string
|
||||
) {
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
uid: randomUUID(),
|
||||
title: `Booking by ${host.name}`,
|
||||
startTime,
|
||||
endTime,
|
||||
userId: host.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
bookingIds.push(booking.id);
|
||||
|
||||
if (attendeeEmail) {
|
||||
await prisma.attendee.create({
|
||||
data: {
|
||||
email: attendeeEmail,
|
||||
name: "Attendee",
|
||||
timeZone: "UTC",
|
||||
bookingId: booking.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
return booking.id;
|
||||
}
|
||||
|
||||
const inPeriod = (dayOffset: number) => {
|
||||
const d = new Date(periodStart);
|
||||
d.setUTCDate(d.getUTCDate() + dayOffset);
|
||||
return d;
|
||||
};
|
||||
const hourLater = (d: Date) => new Date(d.getTime() + 60 * 60 * 1000);
|
||||
|
||||
// Admin hosts 2 bookings (shows multiple bookings still = 1 active user)
|
||||
await createBooking(admin, inPeriod(1), hourLater(inPeriod(1)));
|
||||
await createBooking(admin, inPeriod(2), hourLater(inPeriod(2)));
|
||||
|
||||
// Members 1-19 each host 1 booking
|
||||
// Members 20-29 are attendees on those bookings (paired with members 1-10)
|
||||
for (let i = 0; i < ACTIVE_HOSTS - 1; i++) {
|
||||
const host = members[i];
|
||||
const day = inPeriod(3 + i);
|
||||
const attendeeEmail =
|
||||
i < ACTIVE_ATTENDEES_ONLY ? members[ACTIVE_HOSTS - 1 + i].email : undefined;
|
||||
await createBooking(host, day, hourLater(day), attendeeEmail);
|
||||
}
|
||||
|
||||
// Member 30 (index 29) hosts a booking OUTSIDE the period (last month)
|
||||
const outsidePeriod = new Date(periodStart);
|
||||
outsidePeriod.setUTCMonth(outsidePeriod.getUTCMonth() - 1);
|
||||
outsidePeriod.setUTCDate(15);
|
||||
await createBooking(members[29], outsidePeriod, hourLater(outsidePeriod));
|
||||
|
||||
console.log(` Created ${bookingIds.length} bookings`);
|
||||
|
||||
// Build expected lists
|
||||
const expectedActiveHosts = [
|
||||
admin.email,
|
||||
...members.slice(0, ACTIVE_HOSTS - 1).map((m) => m.email),
|
||||
];
|
||||
const expectedActiveAttendees = members
|
||||
.slice(ACTIVE_HOSTS - 1, ACTIVE_HOSTS - 1 + ACTIVE_ATTENDEES_ONLY)
|
||||
.map((m) => m.email);
|
||||
const expectedInactive = members.slice(ACTIVE_HOSTS - 1 + ACTIVE_ATTENDEES_ONLY).map((m) => m.email);
|
||||
|
||||
// Create OrganizationBilling with ACTIVE_USERS mode
|
||||
const totalMembers = 1 + members.length;
|
||||
await prisma.organizationBilling.upsert({
|
||||
where: { teamId: org.id },
|
||||
update: {
|
||||
customerId: stripeCustomerId,
|
||||
subscriptionId: stripeSubscriptionId,
|
||||
subscriptionItemId: stripeSubscriptionItemId,
|
||||
billingPeriod: BillingPeriod.MONTHLY,
|
||||
billingMode: BillingMode.ACTIVE_USERS,
|
||||
pricePerSeat: pricePerSeatCents,
|
||||
paidSeats: totalMembers,
|
||||
subscriptionStart: periodStart,
|
||||
subscriptionEnd: periodEnd,
|
||||
subscriptionTrialEnd: null,
|
||||
},
|
||||
create: {
|
||||
teamId: org.id,
|
||||
customerId: stripeCustomerId,
|
||||
subscriptionId: stripeSubscriptionId,
|
||||
subscriptionItemId: stripeSubscriptionItemId,
|
||||
status: "ACTIVE",
|
||||
planName: "ORGANIZATION",
|
||||
billingPeriod: BillingPeriod.MONTHLY,
|
||||
billingMode: BillingMode.ACTIVE_USERS,
|
||||
pricePerSeat: pricePerSeatCents,
|
||||
paidSeats: totalMembers,
|
||||
subscriptionStart: periodStart,
|
||||
subscriptionEnd: periodEnd,
|
||||
subscriptionTrialEnd: null,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.team.update({
|
||||
where: { id: org.id },
|
||||
data: {
|
||||
metadata: {
|
||||
subscriptionId: stripeSubscriptionId,
|
||||
subscriptionItemId: stripeSubscriptionItemId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`\n OrganizationBilling created (MONTHLY, billingMode=ACTIVE_USERS, $${pricePerSeatCents / 100}/seat, ${totalMembers} paid seats)`
|
||||
);
|
||||
|
||||
return {
|
||||
orgId: org.id,
|
||||
subscriptionId: stripeSubscriptionId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
totalMembers,
|
||||
expectedActiveHosts,
|
||||
expectedActiveAttendees,
|
||||
expectedInactive,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("=== Active User Billing (AUB) Seed Script ===\n");
|
||||
console.log("Options:");
|
||||
console.log(` --skip-stripe: ${SKIP_STRIPE}`);
|
||||
console.log(` --cleanup: ${CLEANUP_FIRST}`);
|
||||
console.log("");
|
||||
|
||||
try {
|
||||
const result = await seed();
|
||||
|
||||
// Run the service to count active users
|
||||
console.log("\n--- Running ActiveUserBillingService ---\n");
|
||||
const repo = new ActiveUserBillingRepository(prisma);
|
||||
const service = new ActiveUserBillingService({ activeUserBillingRepository: repo });
|
||||
|
||||
const serviceCount = await service.getActiveUserCountForOrg(
|
||||
result.orgId,
|
||||
result.periodStart,
|
||||
result.periodEnd
|
||||
);
|
||||
|
||||
const expectedTotal =
|
||||
result.expectedActiveHosts.length + result.expectedActiveAttendees.length;
|
||||
|
||||
// Print summary
|
||||
console.log("=== Summary ===\n");
|
||||
console.log(` Organization ID: ${result.orgId}`);
|
||||
console.log(` Subscription ID: ${result.subscriptionId}`);
|
||||
console.log(
|
||||
` Billing period: ${result.periodStart.toISOString()} - ${result.periodEnd.toISOString()}`
|
||||
);
|
||||
console.log(` Total members: ${result.totalMembers}`);
|
||||
console.log("");
|
||||
|
||||
console.log(` Active hosts (${result.expectedActiveHosts.length}):`);
|
||||
for (const email of result.expectedActiveHosts) {
|
||||
console.log(` - ${email}`);
|
||||
}
|
||||
|
||||
console.log(` Active attendees only (${result.expectedActiveAttendees.length}):`);
|
||||
for (const email of result.expectedActiveAttendees) {
|
||||
console.log(` - ${email}`);
|
||||
}
|
||||
|
||||
console.log(` Inactive (${result.expectedInactive.length}):`);
|
||||
console.log(` (${result.expectedInactive.length} members with no in-period bookings)`);
|
||||
|
||||
console.log("");
|
||||
console.log(" +--------------------------+---------+");
|
||||
console.log(" | Category | Count |");
|
||||
console.log(" +--------------------------+---------+");
|
||||
console.log(` | Total org members | ${String(result.totalMembers).padStart(7)} |`);
|
||||
console.log(` | Active hosts (created) | ${String(result.expectedActiveHosts.length).padStart(7)} |`);
|
||||
console.log(` | Active attendees (created)| ${String(result.expectedActiveAttendees.length).padStart(6)} |`);
|
||||
console.log(` | Total active (created) | ${String(expectedTotal).padStart(7)} |`);
|
||||
console.log(` | Inactive (created) | ${String(result.expectedInactive.length).padStart(7)} |`);
|
||||
console.log(` | Service counted | ${String(serviceCount).padStart(7)} |`);
|
||||
console.log(" +--------------------------+---------+");
|
||||
|
||||
if (serviceCount === expectedTotal) {
|
||||
console.log("\n [PASS] Service count matches expected count.");
|
||||
} else {
|
||||
console.log(
|
||||
`\n [MISMATCH] Expected ${expectedTotal}, service returned ${serviceCount}.`
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\n=== Done ===\n");
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error("Seed failed:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { Team, User } from "@calcom/prisma/client";
|
||||
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { ActiveUserBillingRepository } from "../repositories/ActiveUserBillingRepository";
|
||||
import { ActiveUserBillingService } from "./ActiveUserBillingService";
|
||||
|
||||
const TEST_PREFIX = `aub-int-${Date.now()}`;
|
||||
|
||||
let org: Team;
|
||||
let users: User[] = [];
|
||||
let service: ActiveUserBillingService;
|
||||
|
||||
const periodStart = new Date("2026-01-01T00:00:00Z");
|
||||
const periodEnd = new Date("2026-02-01T00:00:00Z");
|
||||
|
||||
async function createUser(suffix: string): Promise<User> {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `${TEST_PREFIX}-${suffix}@example.com`,
|
||||
username: `${TEST_PREFIX}-${suffix}`,
|
||||
name: `Test ${suffix}`,
|
||||
},
|
||||
});
|
||||
users.push(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async function createBookingForHost(
|
||||
host: User,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
attendeeEmail?: string
|
||||
): Promise<number> {
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
uid: randomUUID(),
|
||||
title: `Booking by ${host.name}`,
|
||||
startTime,
|
||||
endTime,
|
||||
userId: host.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
});
|
||||
|
||||
if (attendeeEmail) {
|
||||
await prisma.attendee.create({
|
||||
data: {
|
||||
email: attendeeEmail,
|
||||
name: "Attendee",
|
||||
timeZone: "UTC",
|
||||
bookingId: booking.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return booking.id;
|
||||
}
|
||||
|
||||
describe("ActiveUserBillingService Integration", () => {
|
||||
const bookingIds: number[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const repo = new ActiveUserBillingRepository(prisma);
|
||||
service = new ActiveUserBillingService({
|
||||
activeUserBillingRepository: repo,
|
||||
});
|
||||
|
||||
org = await prisma.team.create({
|
||||
data: {
|
||||
name: `${TEST_PREFIX} Org`,
|
||||
slug: TEST_PREFIX,
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
|
||||
const alice = await createUser("alice");
|
||||
const bob = await createUser("bob");
|
||||
const charlie = await createUser("charlie");
|
||||
const inactive = await createUser("inactive");
|
||||
|
||||
for (const user of [alice, bob, charlie, inactive]) {
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
teamId: org.id,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Alice hosts a booking inside the period
|
||||
const id1 = await createBookingForHost(
|
||||
alice,
|
||||
new Date("2026-01-10T10:00:00Z"),
|
||||
new Date("2026-01-10T11:00:00Z")
|
||||
);
|
||||
bookingIds.push(id1);
|
||||
|
||||
// Bob hosts a booking inside the period, with Charlie as attendee
|
||||
const id2 = await createBookingForHost(
|
||||
bob,
|
||||
new Date("2026-01-15T14:00:00Z"),
|
||||
new Date("2026-01-15T15:00:00Z"),
|
||||
charlie.email
|
||||
);
|
||||
bookingIds.push(id2);
|
||||
|
||||
// Inactive user has a booking OUTSIDE the period (before)
|
||||
const id3 = await createBookingForHost(
|
||||
inactive,
|
||||
new Date("2025-12-20T10:00:00Z"),
|
||||
new Date("2025-12-20T11:00:00Z")
|
||||
);
|
||||
bookingIds.push(id3);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.attendee.deleteMany({
|
||||
where: { bookingId: { in: bookingIds } },
|
||||
});
|
||||
await prisma.booking.deleteMany({ where: { id: { in: bookingIds } } });
|
||||
await prisma.membership.deleteMany({ where: { teamId: org.id } });
|
||||
await prisma.team.delete({ where: { id: org.id } });
|
||||
for (const user of users) {
|
||||
await prisma.user.delete({ where: { id: user.id } }).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("counts hosts and attendees as active, excludes inactive members", async () => {
|
||||
const count = await service.getActiveUserCountForOrg(
|
||||
org.id,
|
||||
periodStart,
|
||||
periodEnd
|
||||
);
|
||||
|
||||
// alice: host, bob: host, charlie: attendee of bob's booking
|
||||
// inactive: booking is outside the period
|
||||
expect(count).toBe(3);
|
||||
});
|
||||
|
||||
it("returns 0 when period has no bookings", async () => {
|
||||
const emptyStart = new Date("2027-01-01T00:00:00Z");
|
||||
const emptyEnd = new Date("2027-02-01T00:00:00Z");
|
||||
|
||||
const count = await service.getActiveUserCountForOrg(
|
||||
org.id,
|
||||
emptyStart,
|
||||
emptyEnd
|
||||
);
|
||||
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("does not double-count a user who is both host and attendee", async () => {
|
||||
// Alice hosts a booking where Bob is attendee -- Bob is already a host too
|
||||
const id = await createBookingForHost(
|
||||
users[0], // alice
|
||||
new Date("2026-01-20T09:00:00Z"),
|
||||
new Date("2026-01-20T10:00:00Z"),
|
||||
users[1].email // bob as attendee
|
||||
);
|
||||
bookingIds.push(id);
|
||||
|
||||
const count = await service.getActiveUserCountForOrg(
|
||||
org.id,
|
||||
periodStart,
|
||||
periodEnd
|
||||
);
|
||||
|
||||
// Still 3: alice (host), bob (host + attendee but counted once), charlie (attendee)
|
||||
expect(count).toBe(3);
|
||||
});
|
||||
|
||||
it("includes bookings right at period boundaries", async () => {
|
||||
// Booking starting exactly at periodStart
|
||||
const id = await createBookingForHost(
|
||||
users[3], // inactive user
|
||||
new Date("2026-01-01T00:00:00Z"),
|
||||
new Date("2026-01-01T01:00:00Z")
|
||||
);
|
||||
bookingIds.push(id);
|
||||
|
||||
const count = await service.getActiveUserCountForOrg(
|
||||
org.id,
|
||||
periodStart,
|
||||
periodEnd
|
||||
);
|
||||
|
||||
// Now all 4 are active
|
||||
expect(count).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -9,9 +9,12 @@ function createMockRepository(): {
|
||||
return {
|
||||
getManagedUserEmailsBySubscriptionId: vi.fn().mockResolvedValue([]),
|
||||
getOrgMemberEmailsByOrgId: vi.fn().mockResolvedValue([]),
|
||||
getOrgMemberDetailsByOrgId: vi.fn().mockResolvedValue([]),
|
||||
getActivePlatformUsersAsHost: vi.fn().mockResolvedValue([]),
|
||||
getActiveUsersAsHost: vi.fn().mockResolvedValue([]),
|
||||
getActiveUsersAsAttendee: vi.fn().mockResolvedValue([]),
|
||||
getBookingsByHostUserId: vi.fn().mockResolvedValue([]),
|
||||
getBookingsByAttendeeEmail: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,4 +203,79 @@ describe("ActiveUserBillingService", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActiveUsersForOrg", () => {
|
||||
const orgId = 42;
|
||||
|
||||
it("returns empty result when org has no members", async () => {
|
||||
mockRepo.getOrgMemberDetailsByOrgId.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getActiveUsersForOrg(orgId, periodStart, periodEnd);
|
||||
|
||||
expect(result.activeUsers).toEqual([]);
|
||||
expect(result.totalMembers).toBe(0);
|
||||
expect(result.activeHosts).toBe(0);
|
||||
expect(result.activeAttendees).toBe(0);
|
||||
expect(mockRepo.getActiveUsersAsHost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns only hosts when everyone hosted", async () => {
|
||||
mockRepo.getOrgMemberDetailsByOrgId.mockResolvedValue([
|
||||
{ id: 1, email: "alice@org.com", name: "Alice" },
|
||||
{ id: 2, email: "bob@org.com", name: "Bob" },
|
||||
]);
|
||||
mockRepo.getActiveUsersAsHost.mockResolvedValue([
|
||||
{ email: "alice@org.com" },
|
||||
{ email: "bob@org.com" },
|
||||
]);
|
||||
|
||||
const result = await service.getActiveUsersForOrg(orgId, periodStart, periodEnd);
|
||||
|
||||
expect(result.activeUsers).toEqual([
|
||||
{ id: 1, email: "alice@org.com", name: "Alice", activeAs: "host" },
|
||||
{ id: 2, email: "bob@org.com", name: "Bob", activeAs: "host" },
|
||||
]);
|
||||
expect(result.totalMembers).toBe(2);
|
||||
expect(result.activeHosts).toBe(2);
|
||||
expect(result.activeAttendees).toBe(0);
|
||||
expect(mockRepo.getActiveUsersAsAttendee).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns only active users (hosts + attendees), excludes inactive", async () => {
|
||||
mockRepo.getOrgMemberDetailsByOrgId.mockResolvedValue([
|
||||
{ id: 1, email: "alice@org.com", name: "Alice" },
|
||||
{ id: 2, email: "bob@org.com", name: "Bob" },
|
||||
{ id: 3, email: "charlie@org.com", name: "Charlie" },
|
||||
{ id: 4, email: "diana@org.com", name: null },
|
||||
]);
|
||||
mockRepo.getActiveUsersAsHost.mockResolvedValue([{ email: "alice@org.com" }]);
|
||||
mockRepo.getActiveUsersAsAttendee.mockResolvedValue([{ email: "bob@org.com" }]);
|
||||
|
||||
const result = await service.getActiveUsersForOrg(orgId, periodStart, periodEnd);
|
||||
|
||||
expect(result.activeUsers).toEqual([
|
||||
{ id: 1, email: "alice@org.com", name: "Alice", activeAs: "host" },
|
||||
{ id: 2, email: "bob@org.com", name: "Bob", activeAs: "attendee" },
|
||||
]);
|
||||
expect(result.totalMembers).toBe(4);
|
||||
expect(result.activeHosts).toBe(1);
|
||||
expect(result.activeAttendees).toBe(1);
|
||||
});
|
||||
|
||||
it("returns empty activeUsers when no one has bookings", async () => {
|
||||
mockRepo.getOrgMemberDetailsByOrgId.mockResolvedValue([
|
||||
{ id: 1, email: "alice@org.com", name: "Alice" },
|
||||
{ id: 2, email: "bob@org.com", name: "Bob" },
|
||||
]);
|
||||
mockRepo.getActiveUsersAsHost.mockResolvedValue([]);
|
||||
mockRepo.getActiveUsersAsAttendee.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getActiveUsersForOrg(orgId, periodStart, periodEnd);
|
||||
|
||||
expect(result.activeUsers).toEqual([]);
|
||||
expect(result.totalMembers).toBe(2);
|
||||
expect(result.activeHosts).toBe(0);
|
||||
expect(result.activeAttendees).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import type { ActiveUserBillingRepository } from "../repositories/ActiveUserBillingRepository";
|
||||
|
||||
export interface ActiveUsersBreakdown {
|
||||
activeUsers: Array<{
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
activeAs: "host" | "attendee";
|
||||
}>;
|
||||
totalMembers: number;
|
||||
activeHosts: number;
|
||||
activeAttendees: number;
|
||||
}
|
||||
|
||||
export interface IActiveUserBillingServiceDeps {
|
||||
activeUserBillingRepository: ActiveUserBillingRepository;
|
||||
}
|
||||
@@ -61,6 +73,110 @@ export class ActiveUserBillingService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only active users (hosts + attendees) for an org.
|
||||
* Inactive members are excluded from the result to keep the payload small.
|
||||
*/
|
||||
async getActiveUsersForOrg(
|
||||
orgId: number,
|
||||
periodStart: Date,
|
||||
periodEnd: Date
|
||||
): Promise<ActiveUsersBreakdown> {
|
||||
const memberDetails =
|
||||
await this.deps.activeUserBillingRepository.getOrgMemberDetailsByOrgId(orgId);
|
||||
|
||||
if (memberDetails.length === 0) {
|
||||
return { activeUsers: [], totalMembers: 0, activeHosts: 0, activeAttendees: 0 };
|
||||
}
|
||||
|
||||
const emails = memberDetails.map((m) => m.email);
|
||||
const memberByEmail = new Map(memberDetails.map((m) => [m.email, m]));
|
||||
|
||||
const activeHosts = await this.deps.activeUserBillingRepository.getActiveUsersAsHost(
|
||||
emails,
|
||||
periodStart,
|
||||
periodEnd
|
||||
);
|
||||
const activeHostEmails = new Set(activeHosts.map((h) => h.email));
|
||||
|
||||
const nonHostEmails = emails.filter((e) => !activeHostEmails.has(e));
|
||||
|
||||
const activeAttendees =
|
||||
nonHostEmails.length > 0
|
||||
? await this.deps.activeUserBillingRepository.getActiveUsersAsAttendee(
|
||||
nonHostEmails,
|
||||
periodStart,
|
||||
periodEnd
|
||||
)
|
||||
: [];
|
||||
const activeAttendeeEmails = new Set(activeAttendees.map((a) => a.email));
|
||||
|
||||
const activeUsers: ActiveUsersBreakdown["activeUsers"] = [];
|
||||
|
||||
for (const email of Array.from(activeHostEmails)) {
|
||||
const m = memberByEmail.get(email);
|
||||
if (m) activeUsers.push({ id: m.id, email: m.email, name: m.name, activeAs: "host" });
|
||||
}
|
||||
for (const email of Array.from(activeAttendeeEmails)) {
|
||||
const m = memberByEmail.get(email);
|
||||
if (m) activeUsers.push({ id: m.id, email: m.email, name: m.name, activeAs: "attendee" });
|
||||
}
|
||||
|
||||
return {
|
||||
activeUsers,
|
||||
totalMembers: memberDetails.length,
|
||||
activeHosts: activeHostEmails.size,
|
||||
activeAttendees: activeAttendeeEmails.size,
|
||||
};
|
||||
}
|
||||
|
||||
async getBookingsForUser(
|
||||
userId: number,
|
||||
email: string,
|
||||
activeAs: "host" | "attendee",
|
||||
periodStart: Date,
|
||||
periodEnd: Date
|
||||
): Promise<
|
||||
Array<{
|
||||
id: number;
|
||||
uid: string;
|
||||
title: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
otherParty: string;
|
||||
}>
|
||||
> {
|
||||
if (activeAs === "host") {
|
||||
const bookings = await this.deps.activeUserBillingRepository.getBookingsByHostUserId(
|
||||
userId,
|
||||
periodStart,
|
||||
periodEnd
|
||||
);
|
||||
return bookings.map((b) => ({
|
||||
id: b.id,
|
||||
uid: b.uid,
|
||||
title: b.title,
|
||||
startTime: b.startTime,
|
||||
endTime: b.endTime,
|
||||
otherParty: b.attendees.map((a) => a.name || a.email).join(", "),
|
||||
}));
|
||||
}
|
||||
|
||||
const bookings = await this.deps.activeUserBillingRepository.getBookingsByAttendeeEmail(
|
||||
email,
|
||||
periodStart,
|
||||
periodEnd
|
||||
);
|
||||
return bookings.map((b) => ({
|
||||
id: b.id,
|
||||
uid: b.uid,
|
||||
title: b.title,
|
||||
startTime: b.startTime,
|
||||
endTime: b.endTime,
|
||||
otherParty: b.user ? b.user.name || b.user.email : "-",
|
||||
}));
|
||||
}
|
||||
|
||||
private async countActiveUsers(
|
||||
candidateEmails: string[],
|
||||
periodStart: Date,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
|
||||
import {
|
||||
bindModuleToClassOnToken,
|
||||
createModule,
|
||||
type ModuleLoader,
|
||||
} from "@calcom/features/di/di";
|
||||
import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/FeaturesRepository";
|
||||
import { moduleLoader as activeUserBillingServiceModuleLoader } from "@calcom/features/ee/billing/active-user/di/ActiveUserBillingService.module";
|
||||
import { SeatBillingStrategyFactory } from "@calcom/features/ee/billing/service/seatBillingStrategy/SeatBillingStrategyFactory";
|
||||
import { DI_TOKENS } from "../tokens";
|
||||
import { billingPeriodServiceModuleLoader } from "./BillingPeriodService.module";
|
||||
@@ -26,6 +31,7 @@ const loadModule = bindModuleToClassOnToken({
|
||||
highWaterMarkService: highWaterMarkServiceModuleLoader,
|
||||
monthlyProrationService: monthlyProrationServiceModuleLoader,
|
||||
teamBillingDataRepository: teamBillingDataRepositoryModuleLoader,
|
||||
activeUserBillingService: activeUserBillingServiceModuleLoader,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface UpdateBillingPeriodData {
|
||||
billingPeriod: BillingPeriod;
|
||||
pricePerSeat?: number | null;
|
||||
paidSeats?: number | null;
|
||||
subscriptionStart?: Date | null;
|
||||
subscriptionEnd?: Date | null;
|
||||
}
|
||||
|
||||
export class BillingPeriodRepository {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* Non-interactive (CI-friendly):
|
||||
* npx tsx packages/features/ee/billing/seed.ts --hwm
|
||||
* npx tsx packages/features/ee/billing/seed.ts --proration
|
||||
* npx tsx packages/features/ee/billing/seed.ts --active-user
|
||||
* npx tsx packages/features/ee/billing/seed.ts --all
|
||||
* npx tsx packages/features/ee/billing/seed.ts --cleanup
|
||||
*
|
||||
@@ -19,8 +20,12 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import * as readline from "node:readline";
|
||||
|
||||
const HWM_SCRIPT = "packages/features/ee/billing/service/highWaterMark/seed-hwm-test.ts";
|
||||
const PRORATION_SCRIPT = "packages/features/ee/billing/service/dueInvoice/seed-proration-test.ts";
|
||||
const HWM_SCRIPT =
|
||||
"packages/features/ee/billing/service/highWaterMark/seed-hwm-test.ts";
|
||||
const PRORATION_SCRIPT =
|
||||
"packages/features/ee/billing/service/dueInvoice/seed-proration-test.ts";
|
||||
const ACTIVE_USER_SCRIPT =
|
||||
"packages/features/ee/billing/active-user/seed-active-user-test.ts";
|
||||
|
||||
const passthrough = process.argv.filter((a) => a === "--skip-stripe");
|
||||
|
||||
@@ -44,10 +49,18 @@ async function seedProration(cleanup: boolean) {
|
||||
return run(PRORATION_SCRIPT, extra);
|
||||
}
|
||||
|
||||
async function seedActiveUser(cleanup: boolean) {
|
||||
console.log("\n--- Seeding Active User Billing test data ---\n");
|
||||
const extra = cleanup ? ["--cleanup"] : [];
|
||||
return run(ACTIVE_USER_SCRIPT, extra);
|
||||
}
|
||||
|
||||
async function seedAll(cleanup: boolean) {
|
||||
let code = await seedHwm(cleanup);
|
||||
if (code !== 0) return code;
|
||||
code = await seedProration(cleanup);
|
||||
if (code !== 0) return code;
|
||||
code = await seedActiveUser(cleanup);
|
||||
return code;
|
||||
}
|
||||
|
||||
@@ -56,11 +69,16 @@ async function cleanupAll() {
|
||||
let code = await run(HWM_SCRIPT, ["--cleanup", "--skip-stripe"]);
|
||||
if (code !== 0) return code;
|
||||
code = await run(PRORATION_SCRIPT, ["--cleanup", "--skip-stripe"]);
|
||||
if (code !== 0) return code;
|
||||
code = await run(ACTIVE_USER_SCRIPT, ["--cleanup", "--skip-stripe"]);
|
||||
return code;
|
||||
}
|
||||
|
||||
function prompt(question: string): Promise<string> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
@@ -73,11 +91,12 @@ async function interactive() {
|
||||
console.log("=== Billing Test Data Seeder ===\n");
|
||||
console.log(" 1) Seed HWM (High Water Mark) test data");
|
||||
console.log(" 2) Seed Proration test data");
|
||||
console.log(" 3) Seed all");
|
||||
console.log(" 4) Cleanup all test data");
|
||||
console.log(" 3) Seed Active User Billing test data");
|
||||
console.log(" 4) Seed all");
|
||||
console.log(" 5) Cleanup all test data");
|
||||
console.log(" q) Quit\n");
|
||||
|
||||
const choice = await prompt("Choose [1-4, q]: ");
|
||||
const choice = await prompt("Choose [1-5, q]: ");
|
||||
|
||||
if (choice === "q" || choice === "") {
|
||||
console.log("Bye.");
|
||||
@@ -85,7 +104,7 @@ async function interactive() {
|
||||
}
|
||||
|
||||
let cleanup = false;
|
||||
if (["1", "2", "3"].includes(choice)) {
|
||||
if (["1", "2", "3", "4"].includes(choice)) {
|
||||
const ans = await prompt("Run cleanup before seeding? [y/N]: ");
|
||||
cleanup = ans.toLowerCase() === "y";
|
||||
}
|
||||
@@ -99,9 +118,12 @@ async function interactive() {
|
||||
code = await seedProration(cleanup);
|
||||
break;
|
||||
case "3":
|
||||
code = await seedAll(cleanup);
|
||||
code = await seedActiveUser(cleanup);
|
||||
break;
|
||||
case "4":
|
||||
code = await seedAll(cleanup);
|
||||
break;
|
||||
case "5":
|
||||
code = await cleanupAll();
|
||||
break;
|
||||
default:
|
||||
@@ -113,7 +135,9 @@ async function interactive() {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2).filter((a) => !a.startsWith("--skip-stripe"));
|
||||
const args = process.argv
|
||||
.slice(2)
|
||||
.filter((a) => !a.startsWith("--skip-stripe"));
|
||||
|
||||
if (args.includes("--hwm")) {
|
||||
process.exit(await seedHwm(args.includes("--cleanup")));
|
||||
@@ -121,6 +145,9 @@ async function main() {
|
||||
if (args.includes("--proration")) {
|
||||
process.exit(await seedProration(args.includes("--cleanup")));
|
||||
}
|
||||
if (args.includes("--active-user")) {
|
||||
process.exit(await seedActiveUser(args.includes("--cleanup")));
|
||||
}
|
||||
if (args.includes("--all")) {
|
||||
process.exit(await seedAll(args.includes("--cleanup")));
|
||||
}
|
||||
@@ -128,6 +155,7 @@ async function main() {
|
||||
args.includes("--cleanup") &&
|
||||
!args.includes("--hwm") &&
|
||||
!args.includes("--proration") &&
|
||||
!args.includes("--active-user") &&
|
||||
!args.includes("--all")
|
||||
) {
|
||||
process.exit(await cleanupAll());
|
||||
|
||||
@@ -5,13 +5,14 @@ import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { BillingPeriod } from "@calcom/prisma/enums";
|
||||
import type { BillingMode, BillingPeriod } from "@calcom/prisma/enums";
|
||||
import type { Logger } from "tslog";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["BillingPeriodService"] });
|
||||
|
||||
export interface BillingPeriodInfo {
|
||||
billingPeriod: BillingPeriod | null;
|
||||
billingMode: BillingMode | null;
|
||||
subscriptionStart: Date | null;
|
||||
subscriptionEnd: Date | null;
|
||||
trialEnd: Date | null;
|
||||
@@ -93,6 +94,7 @@ export class BillingPeriodService {
|
||||
select: {
|
||||
id: true,
|
||||
billingPeriod: true,
|
||||
billingMode: true,
|
||||
subscriptionStart: true,
|
||||
subscriptionEnd: true,
|
||||
subscriptionTrialEnd: true,
|
||||
@@ -105,6 +107,7 @@ export class BillingPeriodService {
|
||||
select: {
|
||||
id: true,
|
||||
billingPeriod: true,
|
||||
billingMode: true,
|
||||
subscriptionStart: true,
|
||||
subscriptionEnd: true,
|
||||
subscriptionTrialEnd: true,
|
||||
@@ -124,6 +127,7 @@ export class BillingPeriodService {
|
||||
if (!billing) {
|
||||
return {
|
||||
billingPeriod: null,
|
||||
billingMode: null,
|
||||
subscriptionStart: null,
|
||||
subscriptionEnd: null,
|
||||
trialEnd: null,
|
||||
@@ -133,28 +137,55 @@ export class BillingPeriodService {
|
||||
};
|
||||
}
|
||||
|
||||
if (!billing.billingPeriod && billing.subscriptionId) {
|
||||
const periodIsStale =
|
||||
billing.subscriptionEnd && new Date(billing.subscriptionEnd) < new Date();
|
||||
const needsSync = (!billing.billingPeriod || periodIsStale) && billing.subscriptionId;
|
||||
|
||||
if (needsSync) {
|
||||
log.info(
|
||||
`Backfilling missing billing data for team ${teamId} from Stripe subscription ${billing.subscriptionId}`
|
||||
`Syncing billing data for team ${teamId} from Stripe subscription ${billing.subscriptionId}`
|
||||
);
|
||||
|
||||
try {
|
||||
const subscription = await stripe.subscriptions.retrieve(billing.subscriptionId);
|
||||
const { billingPeriod, pricePerSeat, paidSeats } =
|
||||
|
||||
const terminalStatuses = ["canceled", "incomplete_expired"];
|
||||
if (terminalStatuses.includes(subscription.status)) {
|
||||
log.info(
|
||||
`Skipping sync for team ${teamId} — subscription ${billing.subscriptionId} has status "${subscription.status}"`
|
||||
);
|
||||
const now = new Date();
|
||||
const isInTrial = billing.subscriptionTrialEnd
|
||||
? new Date(billing.subscriptionTrialEnd) > now
|
||||
: false;
|
||||
|
||||
return {
|
||||
billingPeriod: billing.billingPeriod,
|
||||
billingMode: billing.billingMode,
|
||||
subscriptionStart: billing.subscriptionStart,
|
||||
subscriptionEnd: billing.subscriptionEnd,
|
||||
trialEnd: billing.subscriptionTrialEnd,
|
||||
isInTrial,
|
||||
pricePerSeat: billing.pricePerSeat,
|
||||
isOrganization,
|
||||
};
|
||||
}
|
||||
|
||||
const { billingPeriod, pricePerSeat, paidSeats, subscriptionStart, subscriptionEnd } =
|
||||
extractBillingDataFromStripeSubscription(subscription);
|
||||
|
||||
const updateData = {
|
||||
billingPeriod,
|
||||
pricePerSeat: pricePerSeat ?? null,
|
||||
paidSeats: paidSeats ?? null,
|
||||
subscriptionStart: subscriptionStart ?? null,
|
||||
subscriptionEnd: subscriptionEnd ?? null,
|
||||
};
|
||||
|
||||
if (isOrganization) {
|
||||
await this.repository.updateOrganizationBillingPeriod(billing.id, {
|
||||
billingPeriod,
|
||||
pricePerSeat: pricePerSeat ?? null,
|
||||
paidSeats: paidSeats ?? null,
|
||||
});
|
||||
await this.repository.updateOrganizationBillingPeriod(billing.id, updateData);
|
||||
} else {
|
||||
await this.repository.updateTeamBillingPeriod(billing.id, {
|
||||
billingPeriod,
|
||||
pricePerSeat: pricePerSeat ?? null,
|
||||
paidSeats: paidSeats ?? null,
|
||||
});
|
||||
await this.repository.updateTeamBillingPeriod(billing.id, updateData);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
@@ -162,8 +193,9 @@ export class BillingPeriodService {
|
||||
|
||||
return {
|
||||
billingPeriod,
|
||||
subscriptionStart: billing.subscriptionStart,
|
||||
subscriptionEnd: billing.subscriptionEnd,
|
||||
billingMode: billing.billingMode,
|
||||
subscriptionStart: subscriptionStart ?? null,
|
||||
subscriptionEnd: subscriptionEnd ?? null,
|
||||
trialEnd: billing.subscriptionTrialEnd,
|
||||
isInTrial,
|
||||
pricePerSeat: pricePerSeat ?? null,
|
||||
@@ -179,6 +211,7 @@ export class BillingPeriodService {
|
||||
|
||||
return {
|
||||
billingPeriod: billing.billingPeriod,
|
||||
billingMode: billing.billingMode,
|
||||
subscriptionStart: billing.subscriptionStart,
|
||||
subscriptionEnd: billing.subscriptionEnd,
|
||||
trialEnd: billing.subscriptionTrialEnd,
|
||||
|
||||
+3
@@ -272,6 +272,7 @@ describe("BillingPeriodService", () => {
|
||||
teamBilling: {
|
||||
id: 1,
|
||||
billingPeriod: "ANNUALLY",
|
||||
billingMode: "SEATS",
|
||||
subscriptionStart,
|
||||
subscriptionEnd,
|
||||
subscriptionTrialEnd: null,
|
||||
@@ -286,6 +287,7 @@ describe("BillingPeriodService", () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
billingPeriod: "ANNUALLY",
|
||||
billingMode: "SEATS",
|
||||
subscriptionStart,
|
||||
subscriptionEnd,
|
||||
trialEnd: null,
|
||||
@@ -332,6 +334,7 @@ describe("BillingPeriodService", () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
billingPeriod: null,
|
||||
billingMode: null,
|
||||
subscriptionStart: null,
|
||||
subscriptionEnd: null,
|
||||
trialEnd: null,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
import type { ActiveUserBillingService } from "../../active-user/services/ActiveUserBillingService";
|
||||
import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository";
|
||||
import type { IBillingProviderService } from "../billingProvider/IBillingProviderService";
|
||||
import { BaseSeatBillingStrategy } from "./ISeatBillingStrategy";
|
||||
import type { SeatChangeContext } from "./ISeatBillingStrategy";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["ActiveUserBillingStrategy"] });
|
||||
|
||||
export interface IActiveUserBillingStrategyDeps {
|
||||
activeUserBillingService: ActiveUserBillingService;
|
||||
billingProviderService: IBillingProviderService;
|
||||
teamBillingDataRepository: ITeamBillingDataRepository;
|
||||
}
|
||||
|
||||
export class ActiveUserBillingStrategy extends BaseSeatBillingStrategy {
|
||||
constructor(private readonly deps: IActiveUserBillingStrategyDeps) {
|
||||
super();
|
||||
}
|
||||
|
||||
async onSeatChange(_context: SeatChangeContext): Promise<void> {
|
||||
// No-op: active user count is determined at billing time, not on seat changes
|
||||
}
|
||||
|
||||
override async onInvoiceUpcoming(subscriptionId: string): Promise<{ applied: boolean }> {
|
||||
const team = await this.deps.teamBillingDataRepository.findBySubscriptionId(subscriptionId);
|
||||
if (!team) {
|
||||
log.warn(`No team found for subscription ${subscriptionId}`);
|
||||
return { applied: false };
|
||||
}
|
||||
|
||||
const subscription = await this.deps.billingProviderService.getSubscription(subscriptionId);
|
||||
if (!subscription) {
|
||||
log.warn(`No subscription found for ${subscriptionId}`);
|
||||
return { applied: false };
|
||||
}
|
||||
|
||||
const periodStart = new Date(subscription.current_period_start * 1000);
|
||||
const periodEnd = new Date(subscription.current_period_end * 1000);
|
||||
|
||||
const activeUserCount = await this.deps.activeUserBillingService.getActiveUserCountForOrg(
|
||||
team.id,
|
||||
periodStart,
|
||||
periodEnd
|
||||
);
|
||||
|
||||
const subscriptionItem = subscription.items[0];
|
||||
if (!subscriptionItem) {
|
||||
log.warn(`No subscription item found for ${subscriptionId}`);
|
||||
return { applied: false };
|
||||
}
|
||||
|
||||
await this.deps.billingProviderService.handleSubscriptionUpdate({
|
||||
subscriptionId,
|
||||
subscriptionItemId: subscriptionItem.id,
|
||||
membershipCount: activeUserCount,
|
||||
prorationBehavior: "none",
|
||||
});
|
||||
|
||||
log.info(`Updated subscription ${subscriptionId} to ${activeUserCount} active users for team ${team.id}`);
|
||||
return { applied: true };
|
||||
}
|
||||
}
|
||||
+16
@@ -1,12 +1,14 @@
|
||||
import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface";
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
import type { ActiveUserBillingService } from "../../active-user/services/ActiveUserBillingService";
|
||||
import type { HighWaterMarkRepository } from "../../repository/highWaterMark/HighWaterMarkRepository";
|
||||
import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository";
|
||||
import type { BillingPeriodService } from "../billingPeriod/BillingPeriodService";
|
||||
import type { IBillingProviderService } from "../billingProvider/IBillingProviderService";
|
||||
import type { HighWaterMarkService } from "../highWaterMark/HighWaterMarkService";
|
||||
import type { MonthlyProrationService } from "../proration/MonthlyProrationService";
|
||||
import { ActiveUserBillingStrategy } from "./ActiveUserBillingStrategy";
|
||||
import { HighWaterMarkStrategy } from "./HighWaterMarkStrategy";
|
||||
import { ImmediateUpdateStrategy } from "./ImmediateUpdateStrategy";
|
||||
import type { ISeatBillingStrategy } from "./ISeatBillingStrategy";
|
||||
@@ -22,11 +24,13 @@ export interface ISeatBillingStrategyFactoryDeps {
|
||||
highWaterMarkService: HighWaterMarkService;
|
||||
monthlyProrationService: MonthlyProrationService;
|
||||
teamBillingDataRepository: ITeamBillingDataRepository;
|
||||
activeUserBillingService: ActiveUserBillingService;
|
||||
}
|
||||
|
||||
export class SeatBillingStrategyFactory {
|
||||
private readonly prorationStrategy: ISeatBillingStrategy;
|
||||
private readonly hwmStrategy: ISeatBillingStrategy;
|
||||
private readonly activeUserStrategy: ISeatBillingStrategy;
|
||||
private readonly fallback: ISeatBillingStrategy;
|
||||
|
||||
constructor(private readonly deps: ISeatBillingStrategyFactoryDeps) {
|
||||
@@ -38,12 +42,24 @@ export class SeatBillingStrategyFactory {
|
||||
highWaterMarkRepository: deps.highWaterMarkRepository,
|
||||
highWaterMarkService: deps.highWaterMarkService,
|
||||
});
|
||||
this.activeUserStrategy = new ActiveUserBillingStrategy({
|
||||
activeUserBillingService: deps.activeUserBillingService,
|
||||
billingProviderService: deps.billingProviderService,
|
||||
teamBillingDataRepository: deps.teamBillingDataRepository,
|
||||
});
|
||||
}
|
||||
|
||||
async createByTeamId(teamId: number): Promise<ISeatBillingStrategy> {
|
||||
const info = await this.deps.billingPeriodService.getBillingPeriodInfo(teamId);
|
||||
|
||||
if (!info.isInTrial && info.subscriptionStart) {
|
||||
if (info.billingMode === "ACTIVE_USERS") {
|
||||
const enabled =
|
||||
await this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally(
|
||||
"active-user-billing"
|
||||
);
|
||||
if (enabled) return this.activeUserStrategy;
|
||||
}
|
||||
if (info.billingPeriod === "ANNUALLY") {
|
||||
const enabled =
|
||||
await this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally("monthly-proration");
|
||||
|
||||
+130
-10
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { ITeamBillingDataRepository } from "../../../repository/teamBillingData/ITeamBillingDataRepository";
|
||||
import type { BillingPeriodInfo } from "../../billingPeriod/BillingPeriodService";
|
||||
import type { IBillingProviderService } from "../../billingProvider/IBillingProviderService";
|
||||
import { ActiveUserBillingStrategy } from "../ActiveUserBillingStrategy";
|
||||
import { HighWaterMarkStrategy } from "../HighWaterMarkStrategy";
|
||||
import { ImmediateUpdateStrategy } from "../ImmediateUpdateStrategy";
|
||||
import { MonthlyProrationStrategy } from "../MonthlyProrationStrategy";
|
||||
@@ -12,20 +13,28 @@ function createMockBillingPeriodService(info: BillingPeriodInfo) {
|
||||
return { getBillingPeriodInfo: vi.fn().mockResolvedValue(info) };
|
||||
}
|
||||
|
||||
function createMockFeaturesRepository(enabledFlags: Record<string, boolean>): IFeaturesRepository {
|
||||
function createMockFeaturesRepository(
|
||||
enabledFlags: Record<string, boolean>
|
||||
): IFeaturesRepository {
|
||||
return {
|
||||
checkIfFeatureIsEnabledGlobally: vi.fn(async (slug: string) => enabledFlags[slug] ?? false),
|
||||
checkIfFeatureIsEnabledGlobally: vi.fn(
|
||||
async (slug: string) => enabledFlags[slug] ?? false
|
||||
),
|
||||
} as unknown as IFeaturesRepository;
|
||||
}
|
||||
|
||||
function createMockBillingProviderService(): IBillingProviderService {
|
||||
return { handleSubscriptionUpdate: vi.fn() } as unknown as IBillingProviderService;
|
||||
return {
|
||||
handleSubscriptionUpdate: vi.fn(),
|
||||
} as unknown as IBillingProviderService;
|
||||
}
|
||||
|
||||
function createMockHighWaterMarkRepository() {
|
||||
return {
|
||||
getByTeamId: vi.fn(),
|
||||
updateIfHigher: vi.fn().mockResolvedValue({ updated: false, previousHighWaterMark: null }),
|
||||
updateIfHigher: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ updated: false, previousHighWaterMark: null }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,6 +52,13 @@ function createMockMonthlyProrationService() {
|
||||
};
|
||||
}
|
||||
|
||||
function createMockActiveUserBillingService() {
|
||||
return {
|
||||
getActiveUserCountForOrg: vi.fn().mockResolvedValue(0),
|
||||
getActiveUserCountForPlatformOrg: vi.fn().mockResolvedValue(0),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockTeamBillingDataRepository(): ITeamBillingDataRepository {
|
||||
return {
|
||||
find: vi.fn(),
|
||||
@@ -53,6 +69,7 @@ function createMockTeamBillingDataRepository(): ITeamBillingDataRepository {
|
||||
|
||||
const baseBillingInfo: BillingPeriodInfo = {
|
||||
billingPeriod: null,
|
||||
billingMode: "SEATS",
|
||||
subscriptionStart: new Date("2025-01-01"),
|
||||
subscriptionEnd: new Date("2026-01-01"),
|
||||
trialEnd: null,
|
||||
@@ -73,7 +90,10 @@ function createFactory(
|
||||
highWaterMarkRepository: createMockHighWaterMarkRepository(),
|
||||
highWaterMarkService: createMockHighWaterMarkService(),
|
||||
monthlyProrationService: createMockMonthlyProrationService(),
|
||||
teamBillingDataRepository: overrides?.teamBillingDataRepository ?? createMockTeamBillingDataRepository(),
|
||||
teamBillingDataRepository:
|
||||
overrides?.teamBillingDataRepository ??
|
||||
createMockTeamBillingDataRepository(),
|
||||
activeUserBillingService: createMockActiveUserBillingService(),
|
||||
} as never);
|
||||
}
|
||||
|
||||
@@ -85,12 +105,15 @@ describe("SeatBillingStrategyFactory", () => {
|
||||
});
|
||||
const factory = new SeatBillingStrategyFactory({
|
||||
billingPeriodService,
|
||||
featuresRepository: createMockFeaturesRepository({ "monthly-proration": true }),
|
||||
featuresRepository: createMockFeaturesRepository({
|
||||
"monthly-proration": true,
|
||||
}),
|
||||
billingProviderService: createMockBillingProviderService(),
|
||||
highWaterMarkRepository: createMockHighWaterMarkRepository(),
|
||||
highWaterMarkService: createMockHighWaterMarkService(),
|
||||
monthlyProrationService: createMockMonthlyProrationService(),
|
||||
teamBillingDataRepository: createMockTeamBillingDataRepository(),
|
||||
activeUserBillingService: createMockActiveUserBillingService(),
|
||||
} as never);
|
||||
const strategy = await factory.createByTeamId(1);
|
||||
|
||||
@@ -124,7 +147,12 @@ describe("SeatBillingStrategyFactory", () => {
|
||||
|
||||
it("returns ImmediateUpdateStrategy when team is in trial", async () => {
|
||||
const factory = createFactory(
|
||||
{ ...baseBillingInfo, billingPeriod: "ANNUALLY", isInTrial: true, trialEnd: new Date("2026-06-01") },
|
||||
{
|
||||
...baseBillingInfo,
|
||||
billingPeriod: "ANNUALLY",
|
||||
isInTrial: true,
|
||||
trialEnd: new Date("2026-06-01"),
|
||||
},
|
||||
{ "monthly-proration": true }
|
||||
);
|
||||
const strategy = await factory.createByTeamId(1);
|
||||
@@ -134,7 +162,11 @@ describe("SeatBillingStrategyFactory", () => {
|
||||
|
||||
it("returns ImmediateUpdateStrategy when subscriptionStart is null", async () => {
|
||||
const factory = createFactory(
|
||||
{ ...baseBillingInfo, billingPeriod: "ANNUALLY", subscriptionStart: null },
|
||||
{
|
||||
...baseBillingInfo,
|
||||
billingPeriod: "ANNUALLY",
|
||||
subscriptionStart: null,
|
||||
},
|
||||
{ "monthly-proration": true }
|
||||
);
|
||||
const strategy = await factory.createByTeamId(1);
|
||||
@@ -169,7 +201,9 @@ describe("SeatBillingStrategyFactory", () => {
|
||||
);
|
||||
const strategy = await factory.createBySubscriptionId("sub_abc");
|
||||
|
||||
expect(teamBillingDataRepo.findBySubscriptionId).toHaveBeenCalledWith("sub_abc");
|
||||
expect(teamBillingDataRepo.findBySubscriptionId).toHaveBeenCalledWith(
|
||||
"sub_abc"
|
||||
);
|
||||
expect(strategy).toBeInstanceOf(HighWaterMarkStrategy);
|
||||
});
|
||||
|
||||
@@ -177,9 +211,95 @@ describe("SeatBillingStrategyFactory", () => {
|
||||
const teamBillingDataRepo = createMockTeamBillingDataRepository();
|
||||
vi.mocked(teamBillingDataRepo.findBySubscriptionId).mockResolvedValue(null);
|
||||
|
||||
const factory = createFactory(baseBillingInfo, {}, { teamBillingDataRepository: teamBillingDataRepo });
|
||||
const factory = createFactory(
|
||||
baseBillingInfo,
|
||||
{},
|
||||
{ teamBillingDataRepository: teamBillingDataRepo }
|
||||
);
|
||||
const strategy = await factory.createBySubscriptionId("sub_unknown");
|
||||
|
||||
expect(strategy).toBeInstanceOf(ImmediateUpdateStrategy);
|
||||
});
|
||||
|
||||
it("returns ActiveUserBillingStrategy when billingMode=ACTIVE_USERS and feature flag enabled", async () => {
|
||||
const factory = createFactory(
|
||||
{
|
||||
...baseBillingInfo,
|
||||
billingPeriod: "MONTHLY",
|
||||
billingMode: "ACTIVE_USERS",
|
||||
},
|
||||
{ "active-user-billing": true }
|
||||
);
|
||||
const strategy = await factory.createByTeamId(1);
|
||||
|
||||
expect(strategy).toBeInstanceOf(ActiveUserBillingStrategy);
|
||||
});
|
||||
|
||||
it("returns fallback when billingMode=ACTIVE_USERS but feature flag disabled", async () => {
|
||||
const factory = createFactory(
|
||||
{
|
||||
...baseBillingInfo,
|
||||
billingPeriod: "MONTHLY",
|
||||
billingMode: "ACTIVE_USERS",
|
||||
},
|
||||
{ "active-user-billing": false }
|
||||
);
|
||||
const strategy = await factory.createByTeamId(1);
|
||||
|
||||
expect(strategy).toBeInstanceOf(ImmediateUpdateStrategy);
|
||||
});
|
||||
|
||||
it("returns fallback when billingMode=SEATS even with active-user-billing flag enabled", async () => {
|
||||
const factory = createFactory(
|
||||
{ ...baseBillingInfo, billingPeriod: "MONTHLY", billingMode: "SEATS" },
|
||||
{ "active-user-billing": true, "hwm-seating": true }
|
||||
);
|
||||
const strategy = await factory.createByTeamId(1);
|
||||
|
||||
expect(strategy).toBeInstanceOf(HighWaterMarkStrategy);
|
||||
});
|
||||
|
||||
it("returns ActiveUserBillingStrategy for MONTHLY billing with billingMode=ACTIVE_USERS", async () => {
|
||||
const factory = createFactory(
|
||||
{
|
||||
...baseBillingInfo,
|
||||
billingPeriod: "MONTHLY",
|
||||
billingMode: "ACTIVE_USERS",
|
||||
},
|
||||
{ "active-user-billing": true }
|
||||
);
|
||||
const strategy = await factory.createByTeamId(1);
|
||||
|
||||
expect(strategy).toBeInstanceOf(ActiveUserBillingStrategy);
|
||||
});
|
||||
|
||||
it("returns ActiveUserBillingStrategy for ANNUALLY billing with billingMode=ACTIVE_USERS", async () => {
|
||||
const factory = createFactory(
|
||||
{
|
||||
...baseBillingInfo,
|
||||
billingPeriod: "ANNUALLY",
|
||||
billingMode: "ACTIVE_USERS",
|
||||
},
|
||||
{ "active-user-billing": true }
|
||||
);
|
||||
const strategy = await factory.createByTeamId(1);
|
||||
|
||||
expect(strategy).toBeInstanceOf(ActiveUserBillingStrategy);
|
||||
});
|
||||
|
||||
it("returns fallback when in trial even with billingMode=ACTIVE_USERS", async () => {
|
||||
const factory = createFactory(
|
||||
{
|
||||
...baseBillingInfo,
|
||||
billingPeriod: "MONTHLY",
|
||||
billingMode: "ACTIVE_USERS",
|
||||
isInTrial: true,
|
||||
trialEnd: new Date("2026-06-01"),
|
||||
},
|
||||
{ "active-user-billing": true }
|
||||
);
|
||||
const strategy = await factory.createByTeamId(1);
|
||||
|
||||
expect(strategy).toBeInstanceOf(ImmediateUpdateStrategy);
|
||||
});
|
||||
});
|
||||
|
||||
+377
-27
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ export type AppFlags = {
|
||||
"booking-audit": boolean;
|
||||
"monthly-proration": boolean;
|
||||
"hwm-seating": boolean;
|
||||
"active-user-billing": boolean;
|
||||
"sidebar-tips": boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BillingMode" AS ENUM ('SEATS', 'ACTIVE_USERS');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "TeamBilling" ADD COLUMN "billingMode" "BillingMode" NOT NULL DEFAULT 'SEATS';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "OrganizationBilling" ADD COLUMN "billingMode" "BillingMode" NOT NULL DEFAULT 'SEATS';
|
||||
|
||||
-- Seed feature flag
|
||||
INSERT INTO "Feature" ("slug", "enabled", "type", "description", "createdAt", "updatedAt")
|
||||
VALUES
|
||||
('active-user-billing', false, 'OPERATIONAL', 'Enable active-user-based billing for teams and organizations', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT ("slug") DO NOTHING;
|
||||
@@ -2600,6 +2600,11 @@ enum BillingPeriod {
|
||||
ANNUALLY
|
||||
}
|
||||
|
||||
enum BillingMode {
|
||||
SEATS
|
||||
ACTIVE_USERS
|
||||
}
|
||||
|
||||
model OrganizationOnboarding {
|
||||
// TODO: Use uuid for id
|
||||
id String @id @default(uuid())
|
||||
@@ -3039,6 +3044,7 @@ model TeamBilling {
|
||||
subscriptionEnd DateTime?
|
||||
|
||||
billingPeriod BillingPeriod?
|
||||
billingMode BillingMode @default(SEATS)
|
||||
pricePerSeat Int?
|
||||
paidSeats Int?
|
||||
monthlyProrations MonthlyProration[]
|
||||
@@ -3067,6 +3073,7 @@ model OrganizationBilling {
|
||||
subscriptionEnd DateTime?
|
||||
|
||||
billingPeriod BillingPeriod?
|
||||
billingMode BillingMode @default(SEATS)
|
||||
pricePerSeat Int?
|
||||
paidSeats Int?
|
||||
monthlyProrations MonthlyProration[]
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { NextApiRequest } from "next";
|
||||
import authedProcedure from "../../../procedures/authedProcedure";
|
||||
import { router } from "../../../trpc";
|
||||
import { ZAcceptOrLeaveInputSchema } from "./acceptOrLeave.schema";
|
||||
import { ZGetActiveUserBookingsInputSchema } from "./getActiveUserBookings.schema";
|
||||
import { ZGetActiveUserBreakdownInputSchema } from "./getActiveUserBreakdown.schema";
|
||||
import { ZAddMembersToEventTypes } from "./addMembersToEventTypes.schema";
|
||||
import { ZChangeMemberRoleInputSchema } from "./changeMemberRole.schema";
|
||||
import { ZCheckIfMembershipExistsInputSchema } from "./checkIfMembershipExists.schema";
|
||||
@@ -233,4 +235,12 @@ export const viewerTeamsRouter = router({
|
||||
const { default: handler } = await import("./listInvoices.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
getActiveUserBreakdown: authedProcedure.input(ZGetActiveUserBreakdownInputSchema).query(async (opts) => {
|
||||
const { default: handler } = await import("./getActiveUserBreakdown.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
getActiveUserBookings: authedProcedure.input(ZGetActiveUserBookingsInputSchema).query(async (opts) => {
|
||||
const { default: handler } = await import("./getActiveUserBookings.handler");
|
||||
return handler(opts);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { getActiveUserBillingService } from "@calcom/features/ee/billing/active-user/di/ActiveUserBillingService.container";
|
||||
import { BillingPeriodService } from "@calcom/features/ee/billing/service/billingPeriod/BillingPeriodService";
|
||||
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { TeamService } from "@calcom/features/ee/teams/services/teamService";
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../types";
|
||||
import type { TGetActiveUserBookingsInputSchema } from "./getActiveUserBookings.schema";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["getActiveUserBookings"] });
|
||||
|
||||
type GetActiveUserBookingsOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TGetActiveUserBookingsInputSchema;
|
||||
};
|
||||
|
||||
export const getActiveUserBookingsHandler = async ({ ctx, input }: GetActiveUserBookingsOptions) => {
|
||||
if (!IS_TEAM_BILLING_ENABLED) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { teamId, userId, activeAs } = input;
|
||||
|
||||
const membershipRepository = new MembershipRepository();
|
||||
const membership = await membershipRepository.findUniqueByUserIdAndTeamId({
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
});
|
||||
|
||||
if (!membership || !membership.accepted) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not a member of this team",
|
||||
});
|
||||
}
|
||||
|
||||
const team = await TeamService.fetchTeamOrThrow(teamId);
|
||||
const permissionService = new PermissionCheckService();
|
||||
const hasManageBillingPermission = await permissionService.checkPermission({
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
permission: team.isOrganization ? "organization.manageBilling" : "team.manageBilling",
|
||||
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
});
|
||||
|
||||
if (!hasManageBillingPermission) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only team owners and admins can view active user bookings",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const targetMembership = await membershipRepository.findUniqueByUserIdAndTeamId({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
if (!targetMembership || !targetMembership.accepted) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Target user is not a member of this team",
|
||||
});
|
||||
}
|
||||
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true },
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Target user not found",
|
||||
});
|
||||
}
|
||||
|
||||
const billingPeriodService = new BillingPeriodService();
|
||||
const billingInfo = await billingPeriodService.getOrCreateBillingPeriodInfo(teamId);
|
||||
|
||||
if (
|
||||
billingInfo.billingMode !== "ACTIVE_USERS" ||
|
||||
!billingInfo.subscriptionStart ||
|
||||
!billingInfo.subscriptionEnd
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeUserBillingService = getActiveUserBillingService();
|
||||
const bookings = await activeUserBillingService.getBookingsForUser(
|
||||
userId,
|
||||
targetUser.email,
|
||||
activeAs,
|
||||
billingInfo.subscriptionStart,
|
||||
billingInfo.subscriptionEnd
|
||||
);
|
||||
|
||||
return {
|
||||
bookings: bookings.map((b) => ({
|
||||
id: b.id,
|
||||
uid: b.uid,
|
||||
title: b.title,
|
||||
startTime: b.startTime.toISOString(),
|
||||
endTime: b.endTime.toISOString(),
|
||||
otherParty: b.otherParty,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
log.error("Error getting active user bookings", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to get active user bookings",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default getActiveUserBookingsHandler;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZGetActiveUserBookingsInputSchema = z.object({
|
||||
teamId: z.number(),
|
||||
userId: z.number(),
|
||||
activeAs: z.enum(["host", "attendee"]),
|
||||
});
|
||||
|
||||
export type TGetActiveUserBookingsInputSchema = z.infer<typeof ZGetActiveUserBookingsInputSchema>;
|
||||
@@ -0,0 +1,99 @@
|
||||
import { getActiveUserBillingService } from "@calcom/features/ee/billing/active-user/di/ActiveUserBillingService.container";
|
||||
import { BillingPeriodService } from "@calcom/features/ee/billing/service/billingPeriod/BillingPeriodService";
|
||||
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { TeamService } from "@calcom/features/ee/teams/services/teamService";
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../types";
|
||||
import type { TGetActiveUserBreakdownInputSchema } from "./getActiveUserBreakdown.schema";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["getActiveUserBreakdown"] });
|
||||
|
||||
type GetActiveUserBreakdownOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TGetActiveUserBreakdownInputSchema;
|
||||
};
|
||||
|
||||
export const getActiveUserBreakdownHandler = async ({ ctx, input }: GetActiveUserBreakdownOptions) => {
|
||||
if (!IS_TEAM_BILLING_ENABLED) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { teamId } = input;
|
||||
|
||||
const membershipRepository = new MembershipRepository();
|
||||
const membership = await membershipRepository.findUniqueByUserIdAndTeamId({
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
});
|
||||
|
||||
if (!membership || !membership.accepted) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not a member of this team",
|
||||
});
|
||||
}
|
||||
|
||||
const team = await TeamService.fetchTeamOrThrow(teamId);
|
||||
const permissionService = new PermissionCheckService();
|
||||
const hasManageBillingPermission = await permissionService.checkPermission({
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
permission: team.isOrganization ? "organization.manageBilling" : "team.manageBilling",
|
||||
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
});
|
||||
|
||||
if (!hasManageBillingPermission) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only team owners and admins can view active user breakdown",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const billingPeriodService = new BillingPeriodService();
|
||||
const billingInfo = await billingPeriodService.getOrCreateBillingPeriodInfo(teamId);
|
||||
|
||||
if (billingInfo.billingMode !== "ACTIVE_USERS") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!billingInfo.subscriptionStart || !billingInfo.subscriptionEnd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeUserBillingService = getActiveUserBillingService();
|
||||
const breakdown = await activeUserBillingService.getActiveUsersForOrg(
|
||||
teamId,
|
||||
billingInfo.subscriptionStart,
|
||||
billingInfo.subscriptionEnd
|
||||
);
|
||||
|
||||
return {
|
||||
activeUsers: breakdown.activeUsers,
|
||||
totalMembers: breakdown.totalMembers,
|
||||
activeHosts: breakdown.activeHosts,
|
||||
activeAttendees: breakdown.activeAttendees,
|
||||
periodStart: billingInfo.subscriptionStart.toISOString(),
|
||||
periodEnd: billingInfo.subscriptionEnd.toISOString(),
|
||||
pricePerSeat: billingInfo.pricePerSeat,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
log.error("Error getting active user breakdown", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to get active user breakdown",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default getActiveUserBreakdownHandler;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZGetActiveUserBreakdownInputSchema = z.object({
|
||||
teamId: z.number(),
|
||||
});
|
||||
|
||||
export type TGetActiveUserBreakdownInputSchema = z.infer<typeof ZGetActiveUserBreakdownInputSchema>;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getTeamBillingServiceFactory } from "@calcom/ee/billing/di/containers/Billing";
|
||||
import { SubscriptionStatus } from "@calcom/ee/billing/repository/billing/IBillingRepository";
|
||||
import { BillingPeriodService } from "@calcom/features/ee/billing/service/billingPeriod/BillingPeriodService";
|
||||
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { TeamService } from "@calcom/features/ee/teams/services/teamService";
|
||||
@@ -22,7 +23,7 @@ type GetSubscriptionStatusOptions = {
|
||||
|
||||
export const getSubscriptionStatusHandler = async ({ ctx, input }: GetSubscriptionStatusOptions) => {
|
||||
if (!IS_TEAM_BILLING_ENABLED) {
|
||||
return { status: null, isTrialing: false };
|
||||
return { status: null, isTrialing: false, billingMode: null };
|
||||
}
|
||||
|
||||
const { teamId } = input;
|
||||
@@ -62,11 +63,15 @@ export const getSubscriptionStatusHandler = async ({ ctx, input }: GetSubscripti
|
||||
|
||||
const subscriptionStatus = await teamBillingService.getSubscriptionStatus();
|
||||
|
||||
const billingPeriodService = new BillingPeriodService();
|
||||
const billingInfo = await billingPeriodService.getBillingPeriodInfo(teamId);
|
||||
|
||||
log.debug(`Subscription status for team ${teamId}: ${subscriptionStatus}`);
|
||||
|
||||
return {
|
||||
status: subscriptionStatus,
|
||||
isTrialing: subscriptionStatus === SubscriptionStatus.TRIALING,
|
||||
billingMode: billingInfo.billingMode,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
|
||||
Reference in New Issue
Block a user