feat: High Water Mark (HWM) billing for monthly subscriptions (#27559)

* wip

* fix: claue reveiew commentsd

* add seed test

* fix: seed test plus subscriptin undefined in webhook

* chore: udpate triggr

* Integration test + HWM seed script

* add feature flag for hmw

* Remove redudant docs

* feat: add hwm-seating feature flag for high water mark billing

- Add new hwm-seating feature flag to control HWM billing logic
- Update HighWaterMarkService to use hwm-seating flag
- Update SeatChangeTrackingService to use hwm-seating flag
- Update BillingPeriodService to use hwm-seating flag
- Add flag to migration SQL
- Enable flag in seed script and integration test

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: add hwm-seating to useFlags and fix test mocks

- Add hwm-seating to useFlags.ts initial data
- Add shouldApplyHighWaterMark mock to TeamBillingService tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix test

* feat: move to invoice paid subscription

* fix: transactions for transiants updates + reusable util

* fix: address Cubic AI review feedback for HWM billing

- Fix dotenv import hoisting in seed-hwm-test.ts by using side-effect import
- Remove logging of sensitive information (password/emails) in seed script
- Fix incorrect newHighWaterMark return value when update is not performed

Co-Authored-By: unknown <>

* add additional unit tests

* improve DI

* fix migration + restore package.json trigger update

* feat:move to featurerepo+update tests

* fix mocks

* fix DI calls

* revert faeturerepo to featuresrepository

* fix: pass featureRepository to BillingPeriodService and HighWaterMarkService

- Fix TeamBillingService.ts: pass featureRepository to BillingPeriodService constructor
- Fix _invoice.upcoming.ts: pass featureRepository to HighWaterMarkService constructor
- Fix hwm-webhook-utils.ts: pass featureRepository to HighWaterMarkService constructor

These services require featureRepository for feature flag checks but production
call sites were missing this required dependency, causing runtime errors.

Addresses Cubic AI review feedback on PR #27559

Co-Authored-By: unknown <>

* fix import

* update feature repo usage to features

* remove test thats mocking the wrong stuff

* revert RegularBookingService changes

* remove hide branding

* remove change in redudant file

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
sean-brydon
2026-02-05 09:56:31 +00:00
committed by GitHub
co-authored by unknown Claude Opus 4.5 Devin AI
parent 249065851b
commit 1ad4ad6e4e
28 changed files with 3641 additions and 41 deletions
@@ -34,6 +34,7 @@ const initialData: AppFlags = {
"bookings-v3": false,
"booking-audit": false,
"monthly-proration": false,
"hwm-seating": false,
"sidebar-tips": false,
};
@@ -1,11 +1,10 @@
import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing";
import { PrismaPhoneNumberRepository } from "@calcom/features/calAIPhone/repositories/PrismaPhoneNumberRepository";
import { extractBillingDataFromStripeSubscription } from "@calcom/features/ee/billing/lib/stripe-subscription-utils";
import { PrismaOrganizationBillingRepository } from "@calcom/features/ee/billing/repository/billing/PrismaOrganizationBillingRepository";
import { PrismaTeamBillingRepository } from "@calcom/features/ee/billing/repository/billing/PrismaTeamBillingRepository";
import { extractBillingDataFromStripeSubscription } from "@calcom/features/ee/billing/lib/stripe-subscription-utils";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
const log = logger.getSubLogger({ prefix: ["subscription-updated-webhook"] });
@@ -32,10 +31,7 @@ const handler = async (data: Data) => {
? await handleCalAIPhoneNumberSubscriptionUpdate(subscription, phoneNumber)
: null;
const teamBillingResult = await handleTeamBillingRenewal(
subscription,
previousAttributes
);
const teamBillingResult = await handleTeamBillingRenewal(subscription, previousAttributes);
return {
phoneNumber: phoneNumberResult,
@@ -61,8 +57,7 @@ async function handleCalAIPhoneNumberSubscriptionUpdate(
paused: PhoneNumberSubscriptionStatus.CANCELLED,
};
const subscriptionStatus =
statusMap[subscription.status] || PhoneNumberSubscriptionStatus.UNPAID;
const subscriptionStatus = statusMap[subscription.status] || PhoneNumberSubscriptionStatus.UNPAID;
await prisma.calAiPhoneNumber.update({
where: {
@@ -92,8 +87,7 @@ async function handleTeamBillingRenewal(
const { subscriptionStart, subscriptionEnd, subscriptionTrialEnd } =
billingProviderService.extractSubscriptionDates(subscription);
const { billingPeriod, pricePerSeat, paidSeats } =
extractBillingDataFromStripeSubscription(subscription);
const { billingPeriod, pricePerSeat, paidSeats } = extractBillingDataFromStripeSubscription(subscription);
const teamBillingRepo = new PrismaTeamBillingRepository(prisma);
const orgBillingRepo = new PrismaOrganizationBillingRepository(prisma);
@@ -107,12 +101,11 @@ async function handleTeamBillingRenewal(
pricePerSeat: pricePerSeat ?? null,
};
const teamBilling = await teamBillingRepo.findBySubscriptionId(
subscription.id
);
const teamBilling = await teamBillingRepo.findBySubscriptionId(subscription.id);
if (teamBilling) {
await teamBillingRepo.updateById(teamBilling.id, billingUpdateData);
// HWM reset is handled by invoice.paid webhook after payment completes
return { success: true, type: "team", teamId: teamBilling.teamId };
}
@@ -120,15 +113,13 @@ async function handleTeamBillingRenewal(
if (orgBilling) {
await orgBillingRepo.updateById(orgBilling.id, billingUpdateData);
// HWM reset is handled by invoice.paid webhook after payment completes
return { success: true, type: "organization", teamId: orgBilling.teamId };
}
log.warn("Subscription renewal received but no billing record found", {
subscriptionId: subscription.id,
customerId:
typeof subscription.customer === "string"
? subscription.customer
: subscription.customer?.id,
customerId: typeof subscription.customer === "string" ? subscription.customer : subscription.customer?.id,
});
return { skipped: true, reason: "no billing record found" };
@@ -1,5 +1,3 @@
import { z } from "zod";
import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing";
import { extractBillingDataFromStripeSubscription } from "@calcom/features/ee/billing/lib/stripe-subscription-utils";
import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/billing/IBillingRepository";
@@ -10,18 +8,26 @@ import { UserRepository } from "@calcom/features/users/repositories/UserReposito
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { prisma } from "@calcom/prisma";
import { z } from "zod";
import { getTeamBillingServiceFactory } from "../../di/containers/Billing";
import type { SWHMap } from "./__handler";
import { handleHwmResetAfterRenewal, validateInvoiceLinesForHwm } from "./hwm-webhook-utils";
const invoicePaidSchema = z.object({
object: z.object({
customer: z.string(),
subscription: z.string(),
billing_reason: z.string().nullable(),
lines: z.object({
data: z.array(
z.object({
subscription_item: z.string(),
period: z
.object({
start: z.number(),
end: z.number(),
})
.optional(),
})
),
}),
@@ -57,9 +63,18 @@ const handler = async (data: SWHMap["invoice.paid"]["data"]) => {
if (!organizationOnboarding) {
// Invoice Paid is received for all organizations, even those that were created before Organization Onboarding was introduced.
logger.info(
`No onboarding record found for stripe customer id: ${invoice.customer}, Organization created before Organization Onboarding was introduced, so ignoring the webhook`
);
// For renewals, we still need to reset the HWM
if (invoice.billing_reason === "subscription_cycle") {
logger.info(`Processing renewal invoice for subscription ${subscriptionId}`);
const validation = validateInvoiceLinesForHwm(invoice.lines.data, subscriptionId, logger);
if (validation.isValid) {
await handleHwmResetAfterRenewal(subscriptionId, validation.periodStart, logger);
}
} else {
logger.info(
`No onboarding record found for stripe customer id: ${invoice.customer}, Organization created before Organization Onboarding was introduced, so ignoring the webhook`
);
}
return {
success: true,
@@ -87,11 +102,17 @@ const handler = async (data: SWHMap["invoice.paid"]["data"]) => {
);
if (organizationOnboarding.isComplete) {
// If the organization is already complete, there is nothing to do
// Repeat requests can come for recurring payments
// If the organization is already complete, handle renewal HWM reset
if (invoice.billing_reason === "subscription_cycle") {
logger.info(`Processing renewal invoice for completed org, subscription ${subscriptionId}`);
const validation = validateInvoiceLinesForHwm(invoice.lines.data, subscriptionId, logger);
if (validation.isValid) {
await handleHwmResetAfterRenewal(subscriptionId, validation.periodStart, logger);
}
}
return {
success: true,
message: "Onboarding already completed, skipping",
message: "Onboarding already completed",
};
}
@@ -0,0 +1,50 @@
import logger from "@calcom/lib/logger";
import { z } from "zod";
import type { SWHMap } from "./__handler";
import { handleHwmResetAfterRenewal, validateInvoiceLinesForHwm } from "./hwm-webhook-utils";
const log = logger.getSubLogger({ prefix: ["invoice-paid-team"] });
const invoicePaidSchema = z.object({
object: z.object({
customer: z.string(),
subscription: z.string(),
billing_reason: z.string().nullable(),
lines: z.object({
data: z.array(
z.object({
subscription_item: z.string(),
period: z
.object({
start: z.number(),
end: z.number(),
})
.optional(),
})
),
}),
}),
});
const handler = async (data: SWHMap["invoice.paid"]["data"]) => {
const { object: invoice } = invoicePaidSchema.parse(data);
const subscriptionId = invoice.subscription;
log.debug(`Processing invoice paid webhook for team subscription ${subscriptionId}`, {
billingReason: invoice.billing_reason,
customerId: invoice.customer,
});
// Only handle renewal invoices for HWM reset
if (invoice.billing_reason === "subscription_cycle") {
log.info(`Processing renewal invoice for team subscription ${subscriptionId}`);
const validation = validateInvoiceLinesForHwm(invoice.lines.data, subscriptionId, log);
if (validation.isValid) {
await handleHwmResetAfterRenewal(subscriptionId, validation.periodStart, log);
}
}
return { success: true };
};
export default handler;
@@ -1,13 +1,14 @@
import process from "node:process";
import logger from "@calcom/lib/logger";
import type { LazyModule, SWHMap } from "./__handler";
type Data = SWHMap["invoice.paid"]["data"];
type Handlers = Record<`prod_${string}`, () => LazyModule<Data>>;
// We can't crash here if STRIPE_ORG_PRODUCT_ID is not set, because not all self-hosters use Organizations and it might break the build on import of this module.
// We can't crash here if these are not set, because not all self-hosters use Organizations/Teams and it might break the build on import of this module.
const STRIPE_ORG_PRODUCT_ID = process.env.STRIPE_ORG_PRODUCT_ID || "";
const STRIPE_TEAM_PRODUCT_ID = process.env.STRIPE_TEAM_PRODUCT_ID || "";
const log = logger.getSubLogger({ prefix: ["stripe-webhook-invoice-paid"] });
@@ -48,4 +49,5 @@ const stripeWebhookProductHandler = (handlers: Handlers) => async (data: Data) =
export default stripeWebhookProductHandler({
[STRIPE_ORG_PRODUCT_ID]: () => import("./_invoice.paid.org"),
[STRIPE_TEAM_PRODUCT_ID]: () => import("./_invoice.paid.team"),
});
@@ -0,0 +1,57 @@
import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing";
import { HighWaterMarkService } from "@calcom/features/ee/billing/service/highWaterMark/HighWaterMarkService";
import logger from "@calcom/lib/logger";
import type { SWHMap } from "./__handler";
type Data = SWHMap["invoice.upcoming"]["data"];
const log = logger.getSubLogger({ prefix: ["stripe-webhook-invoice-upcoming"] });
const handler = async (data: Data) => {
const invoice = data.object;
// Only handle subscription invoices
if (!invoice.subscription) {
log.debug("Not a subscription invoice, skipping");
return { success: false, message: "Not a subscription invoice" };
}
const subscriptionId =
typeof invoice.subscription === "string" ? invoice.subscription : invoice.subscription.id;
log.info("Processing invoice.upcoming webhook", {
invoiceId: invoice.id,
subscriptionId,
customerId: typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id,
});
const billingService = getBillingProviderService();
const highWaterMarkService = new HighWaterMarkService({
logger: log,
billingService,
});
try {
const applied = await highWaterMarkService.applyHighWaterMarkToSubscription(subscriptionId);
if (applied) {
log.info("Successfully applied high water mark before renewal", {
subscriptionId,
});
return { success: true, highWaterMarkApplied: true };
}
log.debug("No high water mark update needed", { subscriptionId });
return { success: true, highWaterMarkApplied: false };
} catch (error) {
log.error("Failed to apply high water mark", {
subscriptionId,
error,
});
// Return success: false but don't throw - we don't want to fail the webhook
return { success: false, error: String(error) };
}
};
export default handler;
@@ -0,0 +1,204 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
extractPeriodStartFromInvoice,
validateInvoiceLinesForHwm,
handleHwmResetAfterRenewal,
} from "./hwm-webhook-utils";
// Mock dependencies
vi.mock("@calcom/ee/billing/di/containers/Billing", () => ({
getBillingProviderService: vi.fn(() => ({
handleSubscriptionUpdate: vi.fn(),
getSubscription: vi.fn(),
})),
}));
const mockResetSubscriptionAfterRenewal = vi.fn();
vi.mock("@calcom/features/ee/billing/service/highWaterMark/HighWaterMarkService", () => {
return {
HighWaterMarkService: class MockHighWaterMarkService {
resetSubscriptionAfterRenewal = mockResetSubscriptionAfterRenewal;
},
};
});
// Create a mock logger
const createMockLogger = () => ({
warn: vi.fn(),
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
});
describe("hwm-webhook-utils", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("extractPeriodStartFromInvoice", () => {
it("returns undefined for empty array", () => {
const result = extractPeriodStartFromInvoice([]);
expect(result).toBeUndefined();
});
it("returns undefined for null/undefined input", () => {
// @ts-expect-error - testing edge case
expect(extractPeriodStartFromInvoice(null)).toBeUndefined();
// @ts-expect-error - testing edge case
expect(extractPeriodStartFromInvoice(undefined)).toBeUndefined();
});
it("returns undefined when first item has no period", () => {
const result = extractPeriodStartFromInvoice([{}]);
expect(result).toBeUndefined();
});
it("returns undefined when period has no start", () => {
// @ts-expect-error - testing edge case
const result = extractPeriodStartFromInvoice([{ period: { end: 1234567890 } }]);
expect(result).toBeUndefined();
});
it("returns period start when valid", () => {
const timestamp = 1704067200; // 2024-01-01 00:00:00 UTC
const result = extractPeriodStartFromInvoice([
{ period: { start: timestamp, end: timestamp + 86400 } },
]);
expect(result).toBe(timestamp);
});
it("returns first item period start when multiple items exist", () => {
const firstTimestamp = 1704067200;
const secondTimestamp = 1704153600;
const result = extractPeriodStartFromInvoice([
{ period: { start: firstTimestamp, end: firstTimestamp + 86400 } },
{ period: { start: secondTimestamp, end: secondTimestamp + 86400 } },
]);
expect(result).toBe(firstTimestamp);
});
});
describe("validateInvoiceLinesForHwm", () => {
it("returns invalid for empty array and logs warning", () => {
const mockLog = createMockLogger();
// @ts-expect-error - mock logger
const result = validateInvoiceLinesForHwm([], "sub_123", mockLog);
expect(result.isValid).toBe(false);
expect(result.periodStart).toBeUndefined();
expect(mockLog.warn).toHaveBeenCalledWith(
"Invoice has no line items for subscription sub_123, cannot process HWM"
);
});
it("returns invalid for null input and logs warning", () => {
const mockLog = createMockLogger();
// @ts-expect-error - testing edge case
const result = validateInvoiceLinesForHwm(null, "sub_456", mockLog);
expect(result.isValid).toBe(false);
expect(mockLog.warn).toHaveBeenCalled();
});
it("returns invalid when period.start is missing and logs warning", () => {
const mockLog = createMockLogger();
// @ts-expect-error - mock logger
const result = validateInvoiceLinesForHwm([{ period: undefined }], "sub_789", mockLog);
expect(result.isValid).toBe(false);
expect(result.periodStart).toBeUndefined();
expect(mockLog.warn).toHaveBeenCalledWith(
"Invoice line item missing period.start for subscription sub_789, cannot process HWM"
);
});
it("returns valid with periodStart when data is correct", () => {
const mockLog = createMockLogger();
const timestamp = 1704067200;
const result = validateInvoiceLinesForHwm(
[{ period: { start: timestamp, end: timestamp + 86400 } }],
"sub_valid",
// @ts-expect-error - mock logger
mockLog
);
expect(result.isValid).toBe(true);
expect(result.periodStart).toBe(timestamp);
expect(mockLog.warn).not.toHaveBeenCalled();
});
});
describe("handleHwmResetAfterRenewal", () => {
it("returns failure when periodStartTimestamp is undefined", async () => {
const mockLog = createMockLogger();
// @ts-expect-error - mock logger
const result = await handleHwmResetAfterRenewal("sub_123", undefined, mockLog);
expect(result.success).toBe(false);
expect(result.error).toBe("No period start timestamp");
expect(mockLog.warn).toHaveBeenCalledWith(
"No period start timestamp for subscription sub_123, skipping HWM reset"
);
expect(mockResetSubscriptionAfterRenewal).not.toHaveBeenCalled();
});
it("returns success when reset succeeds", async () => {
const mockLog = createMockLogger();
mockResetSubscriptionAfterRenewal.mockResolvedValue(true);
const timestamp = 1704067200;
// @ts-expect-error - mock logger
const result = await handleHwmResetAfterRenewal("sub_456", timestamp, mockLog);
expect(result.success).toBe(true);
expect(result.updated).toBe(true);
expect(mockResetSubscriptionAfterRenewal).toHaveBeenCalledWith({
subscriptionId: "sub_456",
newPeriodStart: new Date(timestamp * 1000),
});
expect(mockLog.info).toHaveBeenCalled();
});
it("returns success with updated=false when no update needed", async () => {
const mockLog = createMockLogger();
mockResetSubscriptionAfterRenewal.mockResolvedValue(false);
const timestamp = 1704067200;
// @ts-expect-error - mock logger
const result = await handleHwmResetAfterRenewal("sub_789", timestamp, mockLog);
expect(result.success).toBe(true);
expect(result.updated).toBe(false);
});
it("returns failure when reset throws an error", async () => {
const mockLog = createMockLogger();
mockResetSubscriptionAfterRenewal.mockRejectedValue(new Error("Stripe API error"));
const timestamp = 1704067200;
// @ts-expect-error - mock logger
const result = await handleHwmResetAfterRenewal("sub_error", timestamp, mockLog);
expect(result.success).toBe(false);
expect(result.error).toBe("Stripe API error");
expect(mockLog.error).toHaveBeenCalledWith("Failed to reset HWM after invoice paid", {
subscriptionId: "sub_error",
error: "Stripe API error",
});
});
it("handles non-Error thrown values", async () => {
const mockLog = createMockLogger();
mockResetSubscriptionAfterRenewal.mockRejectedValue("string error");
const timestamp = 1704067200;
// @ts-expect-error - mock logger
const result = await handleHwmResetAfterRenewal("sub_string", timestamp, mockLog);
expect(result.success).toBe(false);
expect(result.error).toBe("string error");
});
});
});
@@ -0,0 +1,86 @@
import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing";
import { HighWaterMarkService } from "@calcom/features/ee/billing/service/highWaterMark/HighWaterMarkService";
import logger from "@calcom/lib/logger";
import type { Logger } from "tslog";
const defaultLogger = logger.getSubLogger({ prefix: ["hwm-webhook-utils"] });
export function extractPeriodStartFromInvoice(
linesData: Array<{ period?: { start: number; end: number } }>
): number | undefined {
if (!linesData || linesData.length === 0) {
return undefined;
}
return linesData[0]?.period?.start;
}
export function validateInvoiceLinesForHwm(
linesData: Array<{ period?: { start: number; end: number } }>,
subscriptionId: string,
log: Logger<unknown> = defaultLogger
): { isValid: boolean; periodStart?: number } {
if (!linesData || linesData.length === 0) {
log.warn(
`Invoice has no line items for subscription ${subscriptionId}, cannot process HWM`
);
return { isValid: false };
}
const periodStart = linesData[0]?.period?.start;
if (!periodStart) {
log.warn(
`Invoice line item missing period.start for subscription ${subscriptionId}, cannot process HWM`
);
return { isValid: false };
}
return { isValid: true, periodStart };
}
export interface HwmResetResult {
success: boolean;
updated?: boolean;
error?: string;
}
export async function handleHwmResetAfterRenewal(
subscriptionId: string,
periodStartTimestamp: number | undefined,
log: Logger<unknown> = defaultLogger
): Promise<HwmResetResult> {
if (!periodStartTimestamp) {
log.warn(
`No period start timestamp for subscription ${subscriptionId}, skipping HWM reset`
);
return { success: false, error: "No period start timestamp" };
}
const newPeriodStart = new Date(periodStartTimestamp * 1000);
const billingProviderService = getBillingProviderService();
const highWaterMarkService = new HighWaterMarkService({
logger: log,
billingService: billingProviderService,
});
try {
const updated = await highWaterMarkService.resetSubscriptionAfterRenewal({
subscriptionId,
newPeriodStart,
});
log.info("HWM reset after invoice paid", {
subscriptionId,
newPeriodStart,
updated,
});
return { success: true, updated };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
log.error("Failed to reset HWM after invoice paid", {
subscriptionId,
error: errorMessage,
});
return { success: false, error: errorMessage };
}
}
@@ -11,6 +11,7 @@ const handlers = {
"invoice.paid": () => import("./_invoice.paid"),
"invoice.payment_failed": () => import("./_invoice.payment_failed"),
"invoice.payment_succeeded": () => import("./_invoice.payment_succeeded"),
"invoice.upcoming": () => import("./_invoice.upcoming"),
"checkout.session.completed": () => import("./_checkout.session.completed"),
};
@@ -0,0 +1,24 @@
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
import { HighWaterMarkRepository } from "@calcom/features/ee/billing/repository/highWaterMark/HighWaterMarkRepository";
import { DI_TOKENS } from "../tokens";
const thisModule = createModule();
const token = DI_TOKENS.HIGH_WATER_MARK_REPOSITORY;
const moduleToken = DI_TOKENS.HIGH_WATER_MARK_REPOSITORY_MODULE;
const loadModule = bindModuleToClassOnToken({
module: thisModule,
moduleToken,
token,
classs: HighWaterMarkRepository,
dep: prismaModuleLoader,
});
export const highWaterMarkRepositoryModuleLoader: ModuleLoader = {
token,
loadModule,
};
export type { HighWaterMarkRepository };
@@ -0,0 +1,34 @@
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/FeaturesRepository";
import { moduleLoader as triggerDevLoggerServiceModule } from "@calcom/features/di/shared/services/triggerDevLogger.service";
import { HighWaterMarkService } from "@calcom/features/ee/billing/service/highWaterMark/HighWaterMarkService";
import { DI_TOKENS } from "../tokens";
import { billingProviderServiceModuleLoader } from "./BillingProviderService";
import { highWaterMarkRepositoryModuleLoader } from "./HighWaterMarkRepository";
import { monthlyProrationTeamRepositoryModuleLoader } from "./MonthlyProrationTeamRepository";
const thisModule = createModule();
const token = DI_TOKENS.HIGH_WATER_MARK_SERVICE;
const moduleToken = DI_TOKENS.HIGH_WATER_MARK_SERVICE_MODULE;
const loadModule = bindModuleToClassOnToken({
module: thisModule,
moduleToken,
token,
classs: HighWaterMarkService,
depsMap: {
logger: triggerDevLoggerServiceModule,
repository: highWaterMarkRepositoryModuleLoader,
teamRepository: monthlyProrationTeamRepositoryModuleLoader,
billingService: billingProviderServiceModuleLoader,
featuresRepository: featuresRepositoryModuleLoader,
},
});
export const highWaterMarkServiceModuleLoader: ModuleLoader = {
token,
loadModule,
};
export type { HighWaterMarkService };
@@ -0,0 +1,24 @@
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
import { MonthlyProrationTeamRepository } from "@calcom/features/ee/billing/repository/proration/MonthlyProrationTeamRepository";
import { DI_TOKENS } from "../tokens";
const thisModule = createModule();
const token = DI_TOKENS.MONTHLY_PRORATION_TEAM_REPOSITORY;
const moduleToken = DI_TOKENS.MONTHLY_PRORATION_TEAM_REPOSITORY_MODULE;
const loadModule = bindModuleToClassOnToken({
module: thisModule,
moduleToken,
token,
classs: MonthlyProrationTeamRepository,
dep: prismaModuleLoader,
});
export const monthlyProrationTeamRepositoryModuleLoader: ModuleLoader = {
token,
loadModule,
};
export type { MonthlyProrationTeamRepository };
@@ -11,4 +11,10 @@ export const DI_TOKENS = {
TEAM_BILLING_SERVICE_FACTORY_MODULE: Symbol("TeamBillingServiceFactoryModule"),
MONTHLY_PRORATION_SERVICE: Symbol("MonthlyProrationService"),
MONTHLY_PRORATION_SERVICE_MODULE: Symbol("MonthlyProrationServiceModule"),
HIGH_WATER_MARK_SERVICE: Symbol("HighWaterMarkService"),
HIGH_WATER_MARK_SERVICE_MODULE: Symbol("HighWaterMarkServiceModule"),
HIGH_WATER_MARK_REPOSITORY: Symbol("HighWaterMarkRepository"),
HIGH_WATER_MARK_REPOSITORY_MODULE: Symbol("HighWaterMarkRepositoryModule"),
MONTHLY_PRORATION_TEAM_REPOSITORY: Symbol("MonthlyProrationTeamRepository"),
MONTHLY_PRORATION_TEAM_REPOSITORY_MODULE: Symbol("MonthlyProrationTeamRepositoryModule"),
};
@@ -0,0 +1,262 @@
import type { PrismaClient } from "@calcom/prisma";
import { prisma as defaultPrisma } from "@calcom/prisma";
import type { BillingPeriod } from "@calcom/prisma/enums";
export interface HighWaterMarkData {
id: string;
teamId: number;
isOrganization: boolean;
highWaterMark: number | null;
highWaterMarkPeriodStart: Date | null;
paidSeats: number | null;
billingPeriod: BillingPeriod | null;
subscriptionId: string;
subscriptionItemId: string;
customerId: string;
subscriptionStart: Date | null;
}
export interface HighWaterMarkBySubscriptionData {
id: string;
teamId: number;
isOrganization: boolean;
highWaterMark: number | null;
highWaterMarkPeriodStart: Date | null;
paidSeats: number | null;
billingPeriod: BillingPeriod | null;
subscriptionId: string;
subscriptionItemId: string;
subscriptionStart: Date | null;
}
export class HighWaterMarkRepository {
private prisma: PrismaClient;
constructor(prisma?: PrismaClient) {
this.prisma = prisma || defaultPrisma;
}
async getByTeamId(teamId: number): Promise<HighWaterMarkData | null> {
const team = await this.prisma.team.findUnique({
where: { id: teamId },
select: {
isOrganization: true,
teamBilling: {
select: {
id: true,
teamId: true,
highWaterMark: true,
highWaterMarkPeriodStart: true,
paidSeats: true,
billingPeriod: true,
subscriptionId: true,
subscriptionItemId: true,
customerId: true,
subscriptionStart: true,
},
},
organizationBilling: {
select: {
id: true,
teamId: true,
highWaterMark: true,
highWaterMarkPeriodStart: true,
paidSeats: true,
billingPeriod: true,
subscriptionId: true,
subscriptionItemId: true,
customerId: true,
subscriptionStart: true,
},
},
},
});
if (!team) return null;
const billing = team.isOrganization
? team.organizationBilling
: team.teamBilling;
if (!billing) return null;
return {
...billing,
isOrganization: team.isOrganization,
};
}
async getBySubscriptionId(
subscriptionId: string
): Promise<HighWaterMarkBySubscriptionData | null> {
// Try team billing first
const teamBilling = await this.prisma.teamBilling.findUnique({
where: { subscriptionId },
select: {
id: true,
teamId: true,
highWaterMark: true,
highWaterMarkPeriodStart: true,
paidSeats: true,
billingPeriod: true,
subscriptionId: true,
subscriptionItemId: true,
subscriptionStart: true,
},
});
if (teamBilling) {
return {
...teamBilling,
isOrganization: false,
};
}
// Try organization billing
const orgBilling = await this.prisma.organizationBilling.findUnique({
where: { subscriptionId },
select: {
id: true,
teamId: true,
highWaterMark: true,
highWaterMarkPeriodStart: true,
paidSeats: true,
billingPeriod: true,
subscriptionId: true,
subscriptionItemId: true,
subscriptionStart: true,
},
});
if (orgBilling) {
return {
...orgBilling,
isOrganization: true,
};
}
return null;
}
async updateIfHigher(params: {
teamId: number;
isOrganization: boolean;
newSeatCount: number;
periodStart: Date;
}): Promise<{ updated: boolean; previousHighWaterMark: number | null }> {
const { teamId, isOrganization, newSeatCount, periodStart } = params;
return this.prisma.$transaction(async (tx) => {
// Get current high water mark within transaction
const team = await tx.team.findUnique({
where: { id: teamId },
select: {
isOrganization: true,
teamBilling: {
select: {
highWaterMark: true,
highWaterMarkPeriodStart: true,
},
},
organizationBilling: {
select: {
highWaterMark: true,
highWaterMarkPeriodStart: true,
},
},
},
});
if (!team) {
throw new Error(`No team found for teamId ${teamId}`);
}
const billing = isOrganization
? team.organizationBilling
: team.teamBilling;
if (!billing) {
throw new Error(`No billing record found for team ${teamId}`);
}
const currentHwm = billing.highWaterMark;
const currentPeriodStart = billing.highWaterMarkPeriodStart;
// Check if we're in a new period (period start changed)
const isNewPeriod =
!currentPeriodStart ||
periodStart.getTime() !== currentPeriodStart.getTime();
// If new period, always update. Otherwise, only update if higher.
const shouldUpdate =
isNewPeriod || currentHwm === null || newSeatCount > currentHwm;
if (!shouldUpdate) {
return { updated: false, previousHighWaterMark: currentHwm };
}
const updateData = {
highWaterMark: newSeatCount,
highWaterMarkPeriodStart: periodStart,
};
if (isOrganization) {
await tx.organizationBilling.update({
where: { teamId },
data: updateData,
});
} else {
await tx.teamBilling.update({
where: { teamId },
data: updateData,
});
}
return { updated: true, previousHighWaterMark: currentHwm };
});
}
async reset(params: {
teamId: number;
isOrganization: boolean;
currentSeatCount: number;
newPeriodStart: Date;
}): Promise<void> {
const { teamId, isOrganization, currentSeatCount, newPeriodStart } = params;
const updateData = {
highWaterMark: currentSeatCount,
highWaterMarkPeriodStart: newPeriodStart,
};
if (isOrganization) {
await this.prisma.organizationBilling.update({
where: { teamId },
data: updateData,
});
} else {
await this.prisma.teamBilling.update({
where: { teamId },
data: updateData,
});
}
}
async updateQuantityAfterStripeSync(params: {
teamId: number;
isOrganization: boolean;
paidSeats: number;
}): Promise<void> {
const { teamId, isOrganization, paidSeats } = params;
if (isOrganization) {
await this.prisma.organizationBilling.update({
where: { teamId },
data: { paidSeats },
});
} else {
await this.prisma.teamBilling.update({
where: { teamId },
data: { paidSeats },
});
}
}
}
@@ -0,0 +1,474 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { HighWaterMarkRepository } from "../HighWaterMarkRepository";
// Create mock Prisma client
const createMockPrisma = () => ({
team: {
findUnique: vi.fn(),
},
teamBilling: {
findUnique: vi.fn(),
update: vi.fn(),
},
organizationBilling: {
findUnique: vi.fn(),
update: vi.fn(),
},
$transaction: vi.fn(),
});
describe("HighWaterMarkRepository", () => {
let repository: HighWaterMarkRepository;
let mockPrisma: ReturnType<typeof createMockPrisma>;
beforeEach(() => {
mockPrisma = createMockPrisma();
// @ts-expect-error - mock prisma
repository = new HighWaterMarkRepository(mockPrisma);
});
describe("getByTeamId", () => {
it("returns null when team not found", async () => {
mockPrisma.team.findUnique.mockResolvedValue(null);
const result = await repository.getByTeamId(123);
expect(result).toBeNull();
});
it("returns null when team has no billing record", async () => {
mockPrisma.team.findUnique.mockResolvedValue({
isOrganization: false,
teamBilling: null,
organizationBilling: null,
});
const result = await repository.getByTeamId(123);
expect(result).toBeNull();
});
it("returns team billing data for non-organization", async () => {
const teamBillingData = {
id: "tb_1",
teamId: 123,
highWaterMark: 5,
highWaterMarkPeriodStart: new Date("2024-01-01"),
paidSeats: 5,
billingPeriod: "MONTHLY",
subscriptionId: "sub_123",
subscriptionItemId: "si_123",
customerId: "cus_123",
subscriptionStart: new Date("2024-01-01"),
};
mockPrisma.team.findUnique.mockResolvedValue({
isOrganization: false,
teamBilling: teamBillingData,
organizationBilling: null,
});
const result = await repository.getByTeamId(123);
expect(result).toEqual({
...teamBillingData,
isOrganization: false,
});
});
it("returns organization billing data for organization", async () => {
const orgBillingData = {
id: "ob_1",
teamId: 456,
highWaterMark: 10,
highWaterMarkPeriodStart: new Date("2024-01-01"),
paidSeats: 8,
billingPeriod: "MONTHLY",
subscriptionId: "sub_456",
subscriptionItemId: "si_456",
customerId: "cus_456",
subscriptionStart: new Date("2024-01-01"),
};
mockPrisma.team.findUnique.mockResolvedValue({
isOrganization: true,
teamBilling: null,
organizationBilling: orgBillingData,
});
const result = await repository.getByTeamId(456);
expect(result).toEqual({
...orgBillingData,
isOrganization: true,
});
});
});
describe("getBySubscriptionId", () => {
it("returns team billing when found in teamBilling", async () => {
const teamBillingData = {
id: "tb_1",
teamId: 123,
highWaterMark: 5,
highWaterMarkPeriodStart: new Date("2024-01-01"),
paidSeats: 5,
billingPeriod: "MONTHLY",
subscriptionId: "sub_123",
subscriptionItemId: "si_123",
subscriptionStart: new Date("2024-01-01"),
};
mockPrisma.teamBilling.findUnique.mockResolvedValue(teamBillingData);
const result = await repository.getBySubscriptionId("sub_123");
expect(result).toEqual({
...teamBillingData,
isOrganization: false,
});
expect(mockPrisma.organizationBilling.findUnique).not.toHaveBeenCalled();
});
it("returns organization billing when not found in teamBilling but found in orgBilling", async () => {
const orgBillingData = {
id: "ob_1",
teamId: 456,
highWaterMark: 10,
highWaterMarkPeriodStart: new Date("2024-01-01"),
paidSeats: 8,
billingPeriod: "MONTHLY",
subscriptionId: "sub_456",
subscriptionItemId: "si_456",
subscriptionStart: new Date("2024-01-01"),
};
mockPrisma.teamBilling.findUnique.mockResolvedValue(null);
mockPrisma.organizationBilling.findUnique.mockResolvedValue(orgBillingData);
const result = await repository.getBySubscriptionId("sub_456");
expect(result).toEqual({
...orgBillingData,
isOrganization: true,
});
});
it("returns null when subscription not found in either table", async () => {
mockPrisma.teamBilling.findUnique.mockResolvedValue(null);
mockPrisma.organizationBilling.findUnique.mockResolvedValue(null);
const result = await repository.getBySubscriptionId("sub_not_found");
expect(result).toBeNull();
});
});
describe("updateIfHigher", () => {
it("throws error when team not found", async () => {
mockPrisma.$transaction.mockImplementation(async (callback) => {
const mockTx = {
team: { findUnique: vi.fn().mockResolvedValue(null) },
teamBilling: { update: vi.fn() },
organizationBilling: { update: vi.fn() },
};
return callback(mockTx);
});
await expect(
repository.updateIfHigher({
teamId: 123,
isOrganization: false,
newSeatCount: 5,
periodStart: new Date(),
})
).rejects.toThrow("No team found for teamId 123");
});
it("throws error when billing record not found", async () => {
mockPrisma.$transaction.mockImplementation(async (callback) => {
const mockTx = {
team: {
findUnique: vi.fn().mockResolvedValue({
isOrganization: false,
teamBilling: null,
organizationBilling: null,
}),
},
teamBilling: { update: vi.fn() },
organizationBilling: { update: vi.fn() },
};
return callback(mockTx);
});
await expect(
repository.updateIfHigher({
teamId: 123,
isOrganization: false,
newSeatCount: 5,
periodStart: new Date(),
})
).rejects.toThrow("No billing record found for team 123");
});
it("does not update when newSeatCount is not higher than current HWM in same period", async () => {
const periodStart = new Date("2024-01-01");
mockPrisma.$transaction.mockImplementation(async (callback) => {
const mockTx = {
team: {
findUnique: vi.fn().mockResolvedValue({
isOrganization: false,
teamBilling: {
highWaterMark: 10,
highWaterMarkPeriodStart: periodStart,
},
organizationBilling: null,
}),
},
teamBilling: { update: vi.fn() },
organizationBilling: { update: vi.fn() },
};
return callback(mockTx);
});
const result = await repository.updateIfHigher({
teamId: 123,
isOrganization: false,
newSeatCount: 8, // Lower than current HWM of 10
periodStart,
});
expect(result).toEqual({
updated: false,
previousHighWaterMark: 10,
});
});
it("updates when newSeatCount is higher than current HWM", async () => {
const periodStart = new Date("2024-01-01");
const mockUpdate = vi.fn();
mockPrisma.$transaction.mockImplementation(async (callback) => {
const mockTx = {
team: {
findUnique: vi.fn().mockResolvedValue({
isOrganization: false,
teamBilling: {
highWaterMark: 5,
highWaterMarkPeriodStart: periodStart,
},
organizationBilling: null,
}),
},
teamBilling: { update: mockUpdate },
organizationBilling: { update: vi.fn() },
};
return callback(mockTx);
});
const result = await repository.updateIfHigher({
teamId: 123,
isOrganization: false,
newSeatCount: 8, // Higher than current HWM of 5
periodStart,
});
expect(result).toEqual({
updated: true,
previousHighWaterMark: 5,
});
expect(mockUpdate).toHaveBeenCalledWith({
where: { teamId: 123 },
data: {
highWaterMark: 8,
highWaterMarkPeriodStart: periodStart,
},
});
});
it("always updates when in a new period", async () => {
const oldPeriodStart = new Date("2024-01-01");
const newPeriodStart = new Date("2024-02-01");
const mockUpdate = vi.fn();
mockPrisma.$transaction.mockImplementation(async (callback) => {
const mockTx = {
team: {
findUnique: vi.fn().mockResolvedValue({
isOrganization: false,
teamBilling: {
highWaterMark: 10,
highWaterMarkPeriodStart: oldPeriodStart,
},
organizationBilling: null,
}),
},
teamBilling: { update: mockUpdate },
organizationBilling: { update: vi.fn() },
};
return callback(mockTx);
});
const result = await repository.updateIfHigher({
teamId: 123,
isOrganization: false,
newSeatCount: 5, // Lower than current HWM, but new period
periodStart: newPeriodStart,
});
expect(result).toEqual({
updated: true,
previousHighWaterMark: 10,
});
expect(mockUpdate).toHaveBeenCalledWith({
where: { teamId: 123 },
data: {
highWaterMark: 5,
highWaterMarkPeriodStart: newPeriodStart,
},
});
});
it("updates organization billing for organizations", async () => {
const periodStart = new Date("2024-01-01");
const mockOrgUpdate = vi.fn();
mockPrisma.$transaction.mockImplementation(async (callback) => {
const mockTx = {
team: {
findUnique: vi.fn().mockResolvedValue({
isOrganization: true,
teamBilling: null,
organizationBilling: {
highWaterMark: 5,
highWaterMarkPeriodStart: periodStart,
},
}),
},
teamBilling: { update: vi.fn() },
organizationBilling: { update: mockOrgUpdate },
};
return callback(mockTx);
});
await repository.updateIfHigher({
teamId: 456,
isOrganization: true,
newSeatCount: 10,
periodStart,
});
expect(mockOrgUpdate).toHaveBeenCalledWith({
where: { teamId: 456 },
data: {
highWaterMark: 10,
highWaterMarkPeriodStart: periodStart,
},
});
});
it("updates when current HWM is null", async () => {
const periodStart = new Date("2024-01-01");
const mockUpdate = vi.fn();
mockPrisma.$transaction.mockImplementation(async (callback) => {
const mockTx = {
team: {
findUnique: vi.fn().mockResolvedValue({
isOrganization: false,
teamBilling: {
highWaterMark: null,
highWaterMarkPeriodStart: null,
},
organizationBilling: null,
}),
},
teamBilling: { update: mockUpdate },
organizationBilling: { update: vi.fn() },
};
return callback(mockTx);
});
const result = await repository.updateIfHigher({
teamId: 123,
isOrganization: false,
newSeatCount: 5,
periodStart,
});
expect(result).toEqual({
updated: true,
previousHighWaterMark: null,
});
});
});
describe("reset", () => {
it("updates team billing for non-organization", async () => {
const newPeriodStart = new Date("2024-02-01");
await repository.reset({
teamId: 123,
isOrganization: false,
currentSeatCount: 5,
newPeriodStart,
});
expect(mockPrisma.teamBilling.update).toHaveBeenCalledWith({
where: { teamId: 123 },
data: {
highWaterMark: 5,
highWaterMarkPeriodStart: newPeriodStart,
},
});
});
it("updates organization billing for organization", async () => {
const newPeriodStart = new Date("2024-02-01");
await repository.reset({
teamId: 456,
isOrganization: true,
currentSeatCount: 10,
newPeriodStart,
});
expect(mockPrisma.organizationBilling.update).toHaveBeenCalledWith({
where: { teamId: 456 },
data: {
highWaterMark: 10,
highWaterMarkPeriodStart: newPeriodStart,
},
});
});
});
describe("updateQuantityAfterStripeSync", () => {
it("updates team billing paidSeats for non-organization", async () => {
await repository.updateQuantityAfterStripeSync({
teamId: 123,
isOrganization: false,
paidSeats: 8,
});
expect(mockPrisma.teamBilling.update).toHaveBeenCalledWith({
where: { teamId: 123 },
data: { paidSeats: 8 },
});
});
it("updates organization billing paidSeats for organization", async () => {
await repository.updateQuantityAfterStripeSync({
teamId: 456,
isOrganization: true,
paidSeats: 15,
});
expect(mockPrisma.organizationBilling.update).toHaveBeenCalledWith({
where: { teamId: 456 },
data: { paidSeats: 15 },
});
});
});
});
@@ -1,7 +1,8 @@
import { getFeaturesRepository } from "@calcom/features/di/containers/FeaturesRepository";
import { BillingPeriodRepository } from "@calcom/features/ee/billing/repository/billingPeriod/BillingPeriodRepository";
import { extractBillingDataFromStripeSubscription } from "@calcom/features/ee/billing/lib/stripe-subscription-utils";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
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";
@@ -19,13 +20,21 @@ export interface BillingPeriodInfo {
isOrganization: boolean;
}
export interface BillingPeriodServiceDeps {
logger?: Logger<unknown>;
repository?: BillingPeriodRepository;
featuresRepository?: IFeaturesRepository;
}
export class BillingPeriodService {
private logger: Logger<unknown>;
private repository: BillingPeriodRepository;
private featuresRepository: IFeaturesRepository;
constructor(customLogger?: Logger<unknown>, repository?: BillingPeriodRepository) {
this.logger = customLogger || log;
this.repository = repository || new BillingPeriodRepository();
constructor(deps?: BillingPeriodServiceDeps) {
this.logger = deps?.logger || log;
this.repository = deps?.repository || new BillingPeriodRepository();
this.featuresRepository = deps?.featuresRepository || getFeaturesRepository();
}
async isAnnualPlan(teamId: number): Promise<boolean> {
@@ -40,10 +49,8 @@ export class BillingPeriodService {
async shouldApplyMonthlyProration(teamId: number): Promise<boolean> {
try {
const featuresRepository = new FeaturesRepository(prisma);
const isFeatureEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("monthly-proration");
if (!isFeatureEnabled) {
const isEnabled = await this.featuresRepository.checkIfFeatureIsEnabledGlobally("monthly-proration");
if (!isEnabled) {
return false;
}
@@ -56,6 +63,27 @@ export class BillingPeriodService {
}
}
async shouldApplyHighWaterMark(teamId: number): Promise<boolean> {
try {
const isEnabled = await this.featuresRepository.checkIfFeatureIsEnabledGlobally("hwm-seating");
if (!isEnabled) {
return false;
}
const info = await this.getOrCreateBillingPeriodInfo(teamId);
return info.billingPeriod === "MONTHLY" && !info.isInTrial && info.subscriptionStart !== null;
} catch (error) {
log.error(`Failed to check if high water mark should apply for team ${teamId}`, { error });
return false;
}
}
async isMonthlyBilling(teamId: number): Promise<boolean> {
const info = await this.getOrCreateBillingPeriodInfo(teamId);
return info.billingPeriod === "MONTHLY";
}
async getOrCreateBillingPeriodInfo(teamId: number): Promise<BillingPeriodInfo> {
const team = await prisma.team.findUnique({
where: { id: teamId },
@@ -1,6 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { prisma } from "@calcom/prisma";
import { BillingPeriodService } from "../BillingPeriodService";
@@ -29,13 +28,23 @@ vi.mock("@calcom/lib/logger", () => ({
},
}));
const mockFeaturesRepository = {
checkIfFeatureIsEnabledGlobally: vi.fn(),
};
vi.mock("@calcom/features/di/containers/FeaturesRepository", () => ({
getFeaturesRepository: () => mockFeaturesRepository,
}));
describe("BillingPeriodService", () => {
let service: BillingPeriodService;
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(FeaturesRepository.prototype, "checkIfFeatureIsEnabledGlobally").mockResolvedValue(true);
service = new BillingPeriodService();
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true);
service = new BillingPeriodService({
featuresRepository: mockFeaturesRepository as any,
});
});
describe("isAnnualPlan", () => {
@@ -0,0 +1,369 @@
import { getFeaturesRepository } from "@calcom/features/di/containers/FeaturesRepository";
import { HighWaterMarkRepository } from "@calcom/features/ee/billing/repository/highWaterMark/HighWaterMarkRepository";
import { MonthlyProrationTeamRepository } from "@calcom/features/ee/billing/repository/proration/MonthlyProrationTeamRepository";
import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface";
import logger from "@calcom/lib/logger";
import type { Logger } from "tslog";
import type { IBillingProviderService } from "../billingProvider/IBillingProviderService";
const log = logger.getSubLogger({ prefix: ["HighWaterMarkService"] });
export interface HighWaterMarkUpdateResult {
updated: boolean;
previousHighWaterMark: number | null;
newHighWaterMark: number;
}
export interface HighWaterMarkServiceDeps {
logger?: Logger<unknown>;
repository?: HighWaterMarkRepository;
teamRepository?: MonthlyProrationTeamRepository;
billingService?: IBillingProviderService;
featuresRepository?: IFeaturesRepository;
}
export class HighWaterMarkService {
private logger: Logger<unknown>;
private repository: HighWaterMarkRepository;
private teamRepository: MonthlyProrationTeamRepository;
private billingService?: IBillingProviderService;
private featuresRepository: IFeaturesRepository;
constructor(deps?: HighWaterMarkServiceDeps) {
this.logger = deps?.logger || log;
this.repository = deps?.repository || new HighWaterMarkRepository();
this.teamRepository = deps?.teamRepository || new MonthlyProrationTeamRepository();
this.billingService = deps?.billingService;
this.featuresRepository = deps?.featuresRepository || getFeaturesRepository();
}
async shouldApplyHighWaterMark(teamId: number): Promise<boolean> {
try {
const isEnabled = await this.featuresRepository.checkIfFeatureIsEnabledGlobally("hwm-seating");
if (!isEnabled) {
return false;
}
const billing = await this.repository.getByTeamId(teamId);
if (!billing) {
return false;
}
// Only apply HWM for monthly billing
return billing.billingPeriod === "MONTHLY";
} catch (error) {
this.logger.error(`Failed to check if high water mark should apply for team ${teamId}`, { error });
return false;
}
}
async updateHighWaterMarkOnSeatAddition(params: {
teamId: number;
currentPeriodStart: Date;
}): Promise<HighWaterMarkUpdateResult | null> {
const { teamId, currentPeriodStart } = params;
const shouldApply = await this.shouldApplyHighWaterMark(teamId);
if (!shouldApply) {
return null;
}
const billing = await this.repository.getByTeamId(teamId);
if (!billing) {
this.logger.warn(`No billing record found for team ${teamId}`);
return null;
}
// Get current member count
const memberCount = await this.teamRepository.getTeamMemberCount(teamId);
if (memberCount === null) {
this.logger.warn(`Could not get member count for team ${teamId}`);
return null;
}
const result = await this.repository.updateIfHigher({
teamId,
isOrganization: billing.isOrganization,
newSeatCount: memberCount,
periodStart: currentPeriodStart,
});
// When updated=false, the actual HWM is the existing higher value (previousHighWaterMark),
// not memberCount. Return the correct current HWM state.
const actualHighWaterMark = result.updated ? memberCount : (result.previousHighWaterMark ?? memberCount);
this.logger.info(`High water mark update for team ${teamId}`, {
updated: result.updated,
previousHighWaterMark: result.previousHighWaterMark,
newHighWaterMark: actualHighWaterMark,
memberCount,
});
return {
updated: result.updated,
previousHighWaterMark: result.previousHighWaterMark,
newHighWaterMark: actualHighWaterMark,
};
}
async resetHighWaterMark(params: {
teamId: number;
isOrganization: boolean;
newPeriodStart: Date;
}): Promise<void> {
const { teamId, isOrganization, newPeriodStart } = params;
// Get current member count
const memberCount = await this.teamRepository.getTeamMemberCount(teamId);
if (memberCount === null) {
this.logger.warn(`Could not get member count for team ${teamId}`);
return;
}
await this.repository.reset({
teamId,
isOrganization,
currentSeatCount: memberCount,
newPeriodStart,
});
this.logger.info(`High water mark reset for team ${teamId}`, {
newHighWaterMark: memberCount,
newPeriodStart,
});
}
async applyHighWaterMarkToSubscription(subscriptionId: string): Promise<boolean> {
// Check feature flag first
const isEnabled = await this.featuresRepository.checkIfFeatureIsEnabledGlobally("hwm-seating");
if (!isEnabled) {
this.logger.debug(
`HWM feature flag disabled, skipping applyHighWaterMarkToSubscription for ${subscriptionId}`
);
return false;
}
if (!this.billingService) {
this.logger.error("BillingService not configured for high water mark processing");
return false;
}
const billing = await this.repository.getBySubscriptionId(subscriptionId);
if (!billing) {
this.logger.warn(`No billing record found for subscription ${subscriptionId}`);
return false;
}
// Only apply for monthly billing
if (billing.billingPeriod !== "MONTHLY") {
this.logger.debug(`Skipping HWM for non-monthly subscription ${subscriptionId}`);
return false;
}
const { teamId, subscriptionItemId, isOrganization } = billing;
let { highWaterMark, paidSeats } = billing;
// Lazy initialization: If HWM is not tracked yet, initialize it
if (highWaterMark === null) {
const memberCount = await this.teamRepository.getTeamMemberCount(teamId);
if (memberCount === null) {
this.logger.warn(`Could not get member count for team ${teamId} during lazy init`);
return false;
}
// Initialize HWM to current member count
// Prefer subscription start date over arbitrary new Date()
const periodStart = billing.highWaterMarkPeriodStart || billing.subscriptionStart;
if (!periodStart) {
this.logger.warn(
`Could not determine period start for team ${teamId} during lazy init - no subscriptionStart available`
);
return false;
}
await this.repository.reset({
teamId,
isOrganization,
currentSeatCount: memberCount,
newPeriodStart: periodStart,
});
this.logger.info(`Lazy initialized high water mark for team ${teamId}`, {
initialHighWaterMark: memberCount,
periodStart,
});
// Use the initialized value
highWaterMark = memberCount;
}
// If paidSeats is null, try to get it from Stripe subscription
if (paidSeats === null) {
const subscription = await this.billingService.getSubscription(subscriptionId);
if (subscription?.items?.[0]) {
paidSeats = subscription.items[0].quantity;
// Update local record
await this.repository.updateQuantityAfterStripeSync({
teamId,
isOrganization,
paidSeats,
});
this.logger.info(`Synced paidSeats from Stripe for team ${teamId}`, { paidSeats });
} else {
this.logger.warn(`Could not get subscription details for ${subscriptionId}`);
return false;
}
}
// If HWM equals paid seats, no update needed
if (highWaterMark === paidSeats) {
this.logger.debug(`No HWM update needed for team ${teamId} - already at correct quantity`, {
highWaterMark,
paidSeats,
});
return false;
}
// Determine if scaling up or down
const isScalingUp = highWaterMark > paidSeats;
this.logger.info(`${isScalingUp ? "Scaling up" : "Scaling down"} subscription for team ${teamId}`, {
highWaterMark,
currentPaidSeats: paidSeats,
subscriptionId,
direction: isScalingUp ? "up" : "down",
});
// Update the subscription quantity to the high water mark
await this.billingService.handleSubscriptionUpdate({
subscriptionId,
subscriptionItemId,
membershipCount: highWaterMark,
prorationBehavior: "none",
});
// Update our local record of paid seats
await this.repository.updateQuantityAfterStripeSync({
teamId,
isOrganization,
paidSeats: highWaterMark,
});
this.logger.info(`Successfully applied high water mark for team ${teamId}`, {
newPaidSeats: highWaterMark,
});
return true;
}
async getHighWaterMarkData(teamId: number) {
return this.repository.getByTeamId(teamId);
}
/**
* Reset subscription quantity to current member count after a billing period renewal.
* This allows seats to scale DOWN when members have been removed during the previous period.
*
* Flow:
* 1. invoice.upcoming: applyHighWaterMarkToSubscription sets quantity to HWM (peak)
* 2. Stripe generates invoice for HWM seats
* 3. customer.subscription.updated (period change): This method resets to current members
*/
async resetSubscriptionAfterRenewal(params: {
subscriptionId: string;
newPeriodStart: Date;
}): Promise<boolean> {
const { subscriptionId, newPeriodStart } = params;
// Check feature flag first
const isEnabled = await this.featuresRepository.checkIfFeatureIsEnabledGlobally("hwm-seating");
if (!isEnabled) {
this.logger.debug(
`HWM feature flag disabled, skipping resetSubscriptionAfterRenewal for ${subscriptionId}`
);
return false;
}
if (!this.billingService) {
this.logger.error("BillingService not configured for subscription reset");
return false;
}
const billing = await this.repository.getBySubscriptionId(subscriptionId);
if (!billing) {
this.logger.warn(`No billing record found for subscription ${subscriptionId}`);
return false;
}
// Only apply for monthly billing
if (billing.billingPeriod !== "MONTHLY") {
this.logger.debug(`Skipping reset for non-monthly subscription ${subscriptionId}`);
return false;
}
const { teamId, subscriptionItemId, isOrganization, paidSeats } = billing;
// Get current member count
const memberCount = await this.teamRepository.getTeamMemberCount(teamId);
if (memberCount === null) {
this.logger.warn(`Could not get member count for team ${teamId}`);
return false;
}
// If current members equals paid seats, no update needed
if (memberCount === paidSeats) {
this.logger.debug(`No reset needed for team ${teamId} - already at correct quantity`, {
memberCount,
paidSeats,
});
// Still reset HWM for the new period
await this.repository.reset({
teamId,
isOrganization,
currentSeatCount: memberCount,
newPeriodStart,
});
return false;
}
this.logger.info(`Resetting subscription for team ${teamId} after renewal`, {
currentMembers: memberCount,
previousPaidSeats: paidSeats,
subscriptionId,
direction: memberCount < (paidSeats ?? 0) ? "down" : "up",
});
// Update the subscription quantity to current member count
await this.billingService.handleSubscriptionUpdate({
subscriptionId,
subscriptionItemId,
membershipCount: memberCount,
prorationBehavior: "none",
});
// Reset HWM to current member count for the new period
await this.repository.reset({
teamId,
isOrganization,
currentSeatCount: memberCount,
newPeriodStart,
});
// Update our local record of paid seats
await this.repository.updateQuantityAfterStripeSync({
teamId,
isOrganization,
paidSeats: memberCount,
});
this.logger.info(`Successfully reset subscription for team ${teamId}`, {
newPaidSeats: memberCount,
newHighWaterMark: memberCount,
newPeriodStart,
});
return true;
}
}
@@ -0,0 +1,491 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { HighWaterMarkService } from "../HighWaterMarkService";
// Mock logger
const createMockLogger = () => ({
warn: vi.fn(),
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
});
// Mock repository
const createMockRepository = () => ({
getByTeamId: vi.fn(),
getBySubscriptionId: vi.fn(),
updateIfHigher: vi.fn(),
reset: vi.fn(),
updateQuantityAfterStripeSync: vi.fn(),
});
// Mock team repository
const createMockTeamRepository = () => ({
getTeamMemberCount: vi.fn(),
});
// Mock billing service
const createMockBillingService = () => ({
handleSubscriptionUpdate: vi.fn(),
getSubscription: vi.fn(),
});
// Mock features repository
const createMockFeaturesRepository = () => ({
checkIfFeatureIsEnabledGlobally: vi.fn(),
});
describe("HighWaterMarkService", () => {
let service: HighWaterMarkService;
let mockLogger: ReturnType<typeof createMockLogger>;
let mockRepository: ReturnType<typeof createMockRepository>;
let mockTeamRepository: ReturnType<typeof createMockTeamRepository>;
let mockBillingService: ReturnType<typeof createMockBillingService>;
let mockFeaturesRepository: ReturnType<typeof createMockFeaturesRepository>;
beforeEach(() => {
mockLogger = createMockLogger();
mockRepository = createMockRepository();
mockTeamRepository = createMockTeamRepository();
mockBillingService = createMockBillingService();
mockFeaturesRepository = createMockFeaturesRepository();
service = new HighWaterMarkService({
// @ts-expect-error - mock logger
logger: mockLogger,
// @ts-expect-error - mock repository
repository: mockRepository,
// @ts-expect-error - mock team repository
teamRepository: mockTeamRepository,
// @ts-expect-error - mock billing service
billingService: mockBillingService,
// @ts-expect-error - mock features repository
featuresRepository: mockFeaturesRepository,
});
});
describe("shouldApplyHighWaterMark", () => {
it("returns false when feature flag is disabled", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(false);
const result = await service.shouldApplyHighWaterMark(123);
expect(result).toBe(false);
expect(mockRepository.getByTeamId).not.toHaveBeenCalled();
});
it("returns false when no billing record exists", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true);
mockRepository.getByTeamId.mockResolvedValue(null);
const result = await service.shouldApplyHighWaterMark(123);
expect(result).toBe(false);
});
it("returns false for annual billing", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true);
mockRepository.getByTeamId.mockResolvedValue({ billingPeriod: "ANNUAL" });
const result = await service.shouldApplyHighWaterMark(123);
expect(result).toBe(false);
});
it("returns true for monthly billing with feature enabled", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true);
mockRepository.getByTeamId.mockResolvedValue({ billingPeriod: "MONTHLY" });
const result = await service.shouldApplyHighWaterMark(123);
expect(result).toBe(true);
});
it("returns false and logs error when exception occurs", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockRejectedValue(new Error("DB error"));
const result = await service.shouldApplyHighWaterMark(123);
expect(result).toBe(false);
expect(mockLogger.error).toHaveBeenCalled();
});
});
describe("updateHighWaterMarkOnSeatAddition", () => {
const teamId = 123;
const currentPeriodStart = new Date("2024-01-01");
it("returns null when shouldApplyHighWaterMark returns false", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(false);
const result = await service.updateHighWaterMarkOnSeatAddition({
teamId,
currentPeriodStart,
});
expect(result).toBeNull();
});
it("returns null when no billing record found", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true);
mockRepository.getByTeamId.mockResolvedValueOnce({ billingPeriod: "MONTHLY" });
mockRepository.getByTeamId.mockResolvedValueOnce(null);
const result = await service.updateHighWaterMarkOnSeatAddition({
teamId,
currentPeriodStart,
});
expect(result).toBeNull();
expect(mockLogger.warn).toHaveBeenCalled();
});
it("returns null when member count cannot be retrieved", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true);
mockRepository.getByTeamId.mockResolvedValue({
billingPeriod: "MONTHLY",
isOrganization: false,
});
mockTeamRepository.getTeamMemberCount.mockResolvedValue(null);
const result = await service.updateHighWaterMarkOnSeatAddition({
teamId,
currentPeriodStart,
});
expect(result).toBeNull();
expect(mockLogger.warn).toHaveBeenCalled();
});
it("updates HWM and returns result when successful", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true);
mockRepository.getByTeamId.mockResolvedValue({
billingPeriod: "MONTHLY",
isOrganization: false,
});
mockTeamRepository.getTeamMemberCount.mockResolvedValue(5);
mockRepository.updateIfHigher.mockResolvedValue({
updated: true,
previousHighWaterMark: 3,
});
const result = await service.updateHighWaterMarkOnSeatAddition({
teamId,
currentPeriodStart,
});
expect(result).toEqual({
updated: true,
previousHighWaterMark: 3,
newHighWaterMark: 5,
});
expect(mockRepository.updateIfHigher).toHaveBeenCalledWith({
teamId,
isOrganization: false,
newSeatCount: 5,
periodStart: currentPeriodStart,
});
});
});
describe("applyHighWaterMarkToSubscription", () => {
const subscriptionId = "sub_123";
it("returns false when feature flag is disabled", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(false);
const result = await service.applyHighWaterMarkToSubscription(subscriptionId);
expect(result).toBe(false);
expect(mockLogger.debug).toHaveBeenCalled();
});
it("returns false when billing service is not configured", async () => {
const serviceWithoutBilling = new HighWaterMarkService({
// @ts-expect-error - mock logger
logger: mockLogger,
// @ts-expect-error - mock repository
repository: mockRepository,
// @ts-expect-error - mock team repository
teamRepository: mockTeamRepository,
billingService: undefined,
// @ts-expect-error - mock features repository
featuresRepository: mockFeaturesRepository,
});
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true);
const result = await serviceWithoutBilling.applyHighWaterMarkToSubscription(subscriptionId);
expect(result).toBe(false);
expect(mockLogger.error).toHaveBeenCalled();
});
it("returns false when no billing record found", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true);
mockRepository.getBySubscriptionId.mockResolvedValue(null);
const result = await service.applyHighWaterMarkToSubscription(subscriptionId);
expect(result).toBe(false);
expect(mockLogger.warn).toHaveBeenCalled();
});
it("returns false for non-monthly billing", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true);
mockRepository.getBySubscriptionId.mockResolvedValue({
billingPeriod: "ANNUAL",
});
const result = await service.applyHighWaterMarkToSubscription(subscriptionId);
expect(result).toBe(false);
expect(mockLogger.debug).toHaveBeenCalled();
});
it("returns false when HWM equals paidSeats (no update needed)", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true);
mockRepository.getBySubscriptionId.mockResolvedValue({
teamId: 123,
subscriptionItemId: "si_123",
isOrganization: false,
billingPeriod: "MONTHLY",
highWaterMark: 5,
paidSeats: 5,
});
const result = await service.applyHighWaterMarkToSubscription(subscriptionId);
expect(result).toBe(false);
expect(mockBillingService.handleSubscriptionUpdate).not.toHaveBeenCalled();
});
it("lazy initializes HWM when null", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true);
mockRepository.getBySubscriptionId.mockResolvedValue({
teamId: 123,
subscriptionItemId: "si_123",
isOrganization: false,
billingPeriod: "MONTHLY",
highWaterMark: null,
paidSeats: 5,
subscriptionStart: new Date("2024-01-01"),
});
mockTeamRepository.getTeamMemberCount.mockResolvedValue(5);
const result = await service.applyHighWaterMarkToSubscription(subscriptionId);
expect(mockRepository.reset).toHaveBeenCalled();
expect(result).toBe(false); // HWM (5) equals paidSeats (5) after init
});
it("syncs paidSeats from Stripe when null", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true);
mockRepository.getBySubscriptionId.mockResolvedValue({
teamId: 123,
subscriptionItemId: "si_123",
isOrganization: false,
billingPeriod: "MONTHLY",
highWaterMark: 6,
paidSeats: null,
});
mockBillingService.getSubscription.mockResolvedValue({
items: [{ quantity: 4 }],
});
const result = await service.applyHighWaterMarkToSubscription(subscriptionId);
expect(mockRepository.updateQuantityAfterStripeSync).toHaveBeenCalledWith({
teamId: 123,
isOrganization: false,
paidSeats: 4,
});
expect(result).toBe(true); // HWM (6) > paidSeats (4) -> scale up
});
it("scales up subscription when HWM > paidSeats", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true);
mockRepository.getBySubscriptionId.mockResolvedValue({
teamId: 123,
subscriptionItemId: "si_123",
isOrganization: false,
billingPeriod: "MONTHLY",
highWaterMark: 8,
paidSeats: 5,
});
const result = await service.applyHighWaterMarkToSubscription(subscriptionId);
expect(result).toBe(true);
expect(mockBillingService.handleSubscriptionUpdate).toHaveBeenCalledWith({
subscriptionId,
subscriptionItemId: "si_123",
membershipCount: 8,
prorationBehavior: "none",
});
expect(mockRepository.updateQuantityAfterStripeSync).toHaveBeenCalledWith({
teamId: 123,
isOrganization: false,
paidSeats: 8,
});
});
});
describe("resetSubscriptionAfterRenewal", () => {
const subscriptionId = "sub_123";
const newPeriodStart = new Date("2024-02-01");
it("returns false when feature flag is disabled", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(false);
const result = await service.resetSubscriptionAfterRenewal({
subscriptionId,
newPeriodStart,
});
expect(result).toBe(false);
});
it("returns false when billing service is not configured", async () => {
const serviceWithoutBilling = new HighWaterMarkService({
// @ts-expect-error - mock logger
logger: mockLogger,
// @ts-expect-error - mock repository
repository: mockRepository,
// @ts-expect-error - mock team repository
teamRepository: mockTeamRepository,
billingService: undefined,
// @ts-expect-error - mock features repository
featuresRepository: mockFeaturesRepository,
});
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true);
const result = await serviceWithoutBilling.resetSubscriptionAfterRenewal({
subscriptionId,
newPeriodStart,
});
expect(result).toBe(false);
});
it("returns false for non-monthly billing", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true);
mockRepository.getBySubscriptionId.mockResolvedValue({
billingPeriod: "ANNUAL",
});
const result = await service.resetSubscriptionAfterRenewal({
subscriptionId,
newPeriodStart,
});
expect(result).toBe(false);
});
it("resets HWM but returns false when memberCount equals paidSeats", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true);
mockRepository.getBySubscriptionId.mockResolvedValue({
teamId: 123,
subscriptionItemId: "si_123",
isOrganization: false,
billingPeriod: "MONTHLY",
paidSeats: 5,
});
mockTeamRepository.getTeamMemberCount.mockResolvedValue(5);
const result = await service.resetSubscriptionAfterRenewal({
subscriptionId,
newPeriodStart,
});
expect(result).toBe(false);
expect(mockRepository.reset).toHaveBeenCalledWith({
teamId: 123,
isOrganization: false,
currentSeatCount: 5,
newPeriodStart,
});
expect(mockBillingService.handleSubscriptionUpdate).not.toHaveBeenCalled();
});
it("scales down subscription when memberCount < paidSeats", async () => {
mockFeaturesRepository.checkIfFeatureIsEnabledGlobally.mockResolvedValue(true);
mockRepository.getBySubscriptionId.mockResolvedValue({
teamId: 123,
subscriptionItemId: "si_123",
isOrganization: false,
billingPeriod: "MONTHLY",
paidSeats: 8,
});
mockTeamRepository.getTeamMemberCount.mockResolvedValue(5);
const result = await service.resetSubscriptionAfterRenewal({
subscriptionId,
newPeriodStart,
});
expect(result).toBe(true);
expect(mockBillingService.handleSubscriptionUpdate).toHaveBeenCalledWith({
subscriptionId,
subscriptionItemId: "si_123",
membershipCount: 5,
prorationBehavior: "none",
});
expect(mockRepository.reset).toHaveBeenCalledWith({
teamId: 123,
isOrganization: false,
currentSeatCount: 5,
newPeriodStart,
});
expect(mockRepository.updateQuantityAfterStripeSync).toHaveBeenCalledWith({
teamId: 123,
isOrganization: false,
paidSeats: 5,
});
});
});
describe("resetHighWaterMark", () => {
it("returns early when member count cannot be retrieved", async () => {
mockTeamRepository.getTeamMemberCount.mockResolvedValue(null);
await service.resetHighWaterMark({
teamId: 123,
isOrganization: false,
newPeriodStart: new Date(),
});
expect(mockRepository.reset).not.toHaveBeenCalled();
expect(mockLogger.warn).toHaveBeenCalled();
});
it("resets HWM to current member count", async () => {
mockTeamRepository.getTeamMemberCount.mockResolvedValue(4);
const newPeriodStart = new Date("2024-02-01");
await service.resetHighWaterMark({
teamId: 123,
isOrganization: false,
newPeriodStart,
});
expect(mockRepository.reset).toHaveBeenCalledWith({
teamId: 123,
isOrganization: false,
currentSeatCount: 4,
newPeriodStart,
});
});
});
describe("getHighWaterMarkData", () => {
it("delegates to repository", async () => {
const mockData = { highWaterMark: 5, paidSeats: 5 };
mockRepository.getByTeamId.mockResolvedValue(mockData);
const result = await service.getHighWaterMarkData(123);
expect(result).toBe(mockData);
expect(mockRepository.getByTeamId).toHaveBeenCalledWith(123);
});
});
});
@@ -0,0 +1,440 @@
/**
* Note: Stripe test clocks have a limitation where webhooks fire DURING clock
* advancement, but Stripe blocks any subscription modifications until the clock
* finishes advancing. This causes webhook handlers to fail with:
* "Test clock advancement underway - cannot perform modifications"
*
* To work around this, after advancing the clock and waiting for it to be ready,
* we manually call the HWM service methods to simulate what the webhook would do.
* In production (without test clocks), webhooks work normally.
*/
import Stripe from "stripe";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import type { Team, User } from "@calcom/prisma/client";
import { BillingPeriod, MembershipRole } from "@calcom/prisma/enums";
import { StripeBillingService } from "../../billingProvider/StripeBillingService";
import { HighWaterMarkService } from "../HighWaterMarkService";
const STRIPE_PRIVATE_KEY = process.env.STRIPE_PRIVATE_KEY;
const STRIPE_TEAM_MONTHLY_PRICE_ID = process.env.STRIPE_TEAM_MONTHLY_PRICE_ID;
// Skip if Stripe is not configured
const describeIfStripe =
STRIPE_PRIVATE_KEY && STRIPE_TEAM_MONTHLY_PRICE_ID ? describe : describe.skip;
describeIfStripe("HighWaterMark Stripe E2E Test", () => {
let stripe: Stripe;
let testClock: Stripe.TestHelpers.TestClock;
let customer: Stripe.Customer;
let subscription: Stripe.Subscription;
let testTeam: Team;
let testUsers: User[] = [];
const TEST_PREFIX = `hwm-e2e-${Date.now()}`;
beforeAll(async () => {
stripe = new Stripe(STRIPE_PRIVATE_KEY!, { apiVersion: "2020-08-27" });
// Enable the hwm-seating feature flag for the test
await prisma.feature.upsert({
where: { slug: "hwm-seating" },
update: { enabled: true },
create: {
slug: "hwm-seating",
enabled: true,
description: "High water mark seating for monthly billing",
type: "RELEASE",
stale: false,
},
});
// Create test users
const adminUser = await prisma.user.create({
data: {
email: `${TEST_PREFIX}-admin@example.com`,
username: `${TEST_PREFIX}-admin`,
name: "HWM E2E Admin",
},
});
testUsers.push(adminUser);
// Create 3 additional members (total 4 current members)
for (let i = 1; i <= 3; i++) {
const user = await prisma.user.create({
data: {
email: `${TEST_PREFIX}-member-${i}@example.com`,
username: `${TEST_PREFIX}-member-${i}`,
name: `HWM E2E Member ${i}`,
},
});
testUsers.push(user);
}
// Create test team
testTeam = await prisma.team.create({
data: {
name: `HWM E2E Team ${TEST_PREFIX}`,
slug: TEST_PREFIX,
isOrganization: false,
},
});
// Add all users as members
await prisma.membership.create({
data: {
userId: adminUser.id,
teamId: testTeam.id,
role: MembershipRole.OWNER,
accepted: true,
},
});
for (let i = 1; i < testUsers.length; i++) {
await prisma.membership.create({
data: {
userId: testUsers[i].id,
teamId: testTeam.id,
role: MembershipRole.MEMBER,
accepted: true,
},
});
}
// Create Stripe test clock
testClock = await stripe.testHelpers.testClocks.create({
frozen_time: Math.floor(Date.now() / 1000),
name: `HWM E2E Test ${TEST_PREFIX}`,
});
// Create Stripe customer attached to test clock
customer = await stripe.customers.create({
email: `${TEST_PREFIX}-admin@example.com`,
name: `HWM E2E Team ${TEST_PREFIX}`,
test_clock: testClock.id,
metadata: {
testData: "true",
teamId: testTeam.id.toString(),
},
});
// Add payment method
const paymentMethod = await stripe.paymentMethods.create({
type: "card",
card: { token: "tok_visa" },
});
await stripe.paymentMethods.attach(paymentMethod.id, {
customer: customer.id,
});
await stripe.customers.update(customer.id, {
invoice_settings: { default_payment_method: paymentMethod.id },
});
// Create subscription with 4 seats (current member count)
const currentMemberCount = 4;
const peakMemberCount = 6; // At peak, had 6 members
subscription = await stripe.subscriptions.create({
customer: customer.id,
items: [
{ price: STRIPE_TEAM_MONTHLY_PRICE_ID!, quantity: currentMemberCount },
],
metadata: {
testData: "true",
teamId: testTeam.id.toString(),
},
});
// Create TeamBilling with HWM higher than current members (simulating members who left)
await prisma.teamBilling.create({
data: {
teamId: testTeam.id,
customerId: customer.id,
subscriptionId: subscription.id,
subscriptionItemId: subscription.items.data[0].id,
billingPeriod: BillingPeriod.MONTHLY,
pricePerSeat: 699, // $6.99
paidSeats: currentMemberCount,
subscriptionStart: new Date(subscription.current_period_start * 1000),
subscriptionEnd: new Date(subscription.current_period_end * 1000),
status: "ACTIVE",
planName: "TEAM",
highWaterMark: peakMemberCount,
highWaterMarkPeriodStart: new Date(
subscription.current_period_start * 1000
),
},
});
console.log("\n=== E2E Test Setup Complete ===");
console.log(`Team ID: ${testTeam.id}`);
console.log(`Current Members: ${currentMemberCount}`);
console.log(`HWM (Peak): ${peakMemberCount}`);
console.log(`Subscription: ${subscription.id}`);
console.log(`Test Clock: ${testClock.id}`);
console.log(
`Period End: ${new Date(
subscription.current_period_end * 1000
).toISOString()}`
);
});
afterAll(async () => {
// Cleanup Stripe resources
try {
if (testClock) {
await stripe.testHelpers.testClocks.del(testClock.id);
console.log(`Deleted test clock: ${testClock.id}`);
}
} catch (error) {
console.log("Could not delete test clock:", error);
}
// Cleanup database
if (testTeam) {
await prisma.seatChangeLog.deleteMany({ where: { teamId: testTeam.id } });
await prisma.teamBilling.deleteMany({ where: { teamId: testTeam.id } });
await prisma.membership.deleteMany({ where: { teamId: testTeam.id } });
await prisma.team.delete({ where: { id: testTeam.id } });
}
for (const user of testUsers) {
await prisma.user.delete({ where: { id: user.id } }).catch(() => {});
}
console.log("E2E Test cleanup complete");
});
async function waitForClockAdvancement(
clockId: string,
maxWaitMs = 60000
): Promise<void> {
const startTime = Date.now();
const pollIntervalMs = 2000;
while (Date.now() - startTime < maxWaitMs) {
const clock = await stripe.testHelpers.testClocks.retrieve(clockId);
if (clock.status === "ready") {
console.log(
`Test clock ready at: ${new Date(
clock.frozen_time * 1000
).toISOString()}`
);
return;
}
if (clock.status === "advancing") {
console.log(
`Clock still advancing... (${Math.round(
(Date.now() - startTime) / 1000
)}s elapsed)`
);
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
continue;
}
throw new Error(`Unexpected clock status: ${clock.status}`);
}
throw new Error(`Clock advancement timed out after ${maxWaitMs}ms`);
}
async function waitForDatabaseUpdate(
check: () => Promise<boolean>,
description: string,
maxWaitMs = 30000
): Promise<void> {
const startTime = Date.now();
const pollIntervalMs = 1000;
while (Date.now() - startTime < maxWaitMs) {
if (await check()) {
console.log(`${description} - confirmed`);
return;
}
console.log(
`Waiting for ${description}... (${Math.round(
(Date.now() - startTime) / 1000
)}s elapsed)`
);
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
throw new Error(`Timed out waiting for: ${description}`);
}
it("should scale UP subscription quantity to HWM on invoice.upcoming", async () => {
// Get initial state
const initialBilling = await prisma.teamBilling.findUnique({
where: { teamId: testTeam.id },
select: { paidSeats: true, highWaterMark: true },
});
console.log("\n=== Step 1: Advance clock to trigger invoice.upcoming ===");
console.log(
`Initial state: paidSeats=${initialBilling?.paidSeats}, HWM=${initialBilling?.highWaterMark}`
);
// Note: For scale-up test, we start with qty=4, paidSeats=4, HWM=6
// But our setup now creates qty=6, paidSeats=6 for scale-down test
// So we need to reset to initial state for scale-up test
await stripe.subscriptions.update(subscription.id, {
items: [{ id: subscription.items.data[0].id, quantity: 4 }],
});
await prisma.teamBilling.update({
where: { teamId: testTeam.id },
data: { paidSeats: 4 },
});
const resetBilling = await prisma.teamBilling.findUnique({
where: { teamId: testTeam.id },
select: { paidSeats: true, highWaterMark: true },
});
console.log(
`Reset state: paidSeats=${resetBilling?.paidSeats}, HWM=${resetBilling?.highWaterMark}`
);
expect(resetBilling?.paidSeats).toBe(4);
expect(resetBilling?.highWaterMark).toBe(6);
// Advance clock to 3 days before period end (triggers invoice.upcoming)
const invoiceUpcomingTime =
subscription.current_period_end - 3 * 24 * 60 * 60;
console.log(
`Advancing clock to: ${new Date(
invoiceUpcomingTime * 1000
).toISOString()}`
);
await stripe.testHelpers.testClocks.advance(testClock.id, {
frozen_time: invoiceUpcomingTime,
});
// Wait for clock to finish advancing
await waitForClockAdvancement(testClock.id);
// The webhook fires during clock advancement but fails because Stripe blocks
// modifications while advancing. Now that clock is ready, manually trigger scale-up.
console.log(
"\\nManually triggering scale-up (webhook fails during clock advancement)..."
);
const billingService = new StripeBillingService(stripe);
const hwmService = new HighWaterMarkService({
logger: logger.getSubLogger({ prefix: ["hwm-test"] }),
billingService,
});
await hwmService.applyHighWaterMarkToSubscription(subscription.id);
// Verify Stripe subscription was updated
const updatedSub = await stripe.subscriptions.retrieve(subscription.id);
expect(updatedSub.items.data[0].quantity).toBe(6);
// Verify DB was updated
const updatedBilling = await prisma.teamBilling.findUnique({
where: { teamId: testTeam.id },
select: { paidSeats: true },
});
expect(updatedBilling?.paidSeats).toBe(6);
console.log("Scale UP verified: subscription quantity = 6 (HWM)");
}, 120000); // 2 minute timeout
it("should scale DOWN subscription quantity to current members after renewal", async () => {
console.log(
"\n=== Step 2: Advance clock past renewal to trigger scale down ==="
);
// Get the updated subscription (period may have changed)
const currentSub = await stripe.subscriptions.retrieve(subscription.id);
const periodEnd = currentSub.current_period_end;
// Advance clock past the billing period end (triggers renewal + subscription.updated)
const renewalTime = periodEnd + 60; // 1 minute after period end
console.log(
`Current period end: ${new Date(periodEnd * 1000).toISOString()}`
);
console.log(
`Advancing clock to: ${new Date(renewalTime * 1000).toISOString()}`
);
await stripe.testHelpers.testClocks.advance(testClock.id, {
frozen_time: renewalTime,
});
// Wait for clock to finish advancing
await waitForClockAdvancement(testClock.id);
// Debug: Check state immediately after clock advancement
const subAfterRenewal = await stripe.subscriptions.retrieve(
subscription.id
);
console.log("Stripe subscription after renewal:");
console.log(` - Status: ${subAfterRenewal.status}`);
console.log(` - Quantity: ${subAfterRenewal.items.data[0].quantity}`);
console.log(
` - Period: ${new Date(
subAfterRenewal.current_period_start * 1000
).toISOString()} to ${new Date(
subAfterRenewal.current_period_end * 1000
).toISOString()}`
);
const billingAfterRenewal = await prisma.teamBilling.findUnique({
where: { teamId: testTeam.id },
select: { paidSeats: true, highWaterMark: true },
});
console.log("Database after clock advancement:");
console.log(` - paidSeats: ${billingAfterRenewal?.paidSeats}`);
console.log(` - HWM: ${billingAfterRenewal?.highWaterMark}`);
// The webhook fires during clock advancement but fails because Stripe blocks
// modifications while advancing. Now that clock is ready, manually trigger reset.
// This simulates what would happen if we had retry logic or a separate job.
console.log(
"\\nManually triggering reset (webhook fails during clock advancement)..."
);
const billingService = new StripeBillingService(stripe);
const hwmService = new HighWaterMarkService({
logger: logger.getSubLogger({ prefix: ["hwm-test"] }),
billingService,
});
const newPeriodStart = new Date(
subAfterRenewal.current_period_start * 1000
);
await hwmService.resetSubscriptionAfterRenewal({
subscriptionId: subscription.id,
newPeriodStart,
});
console.log("Reset completed, verifying results...");
// Verify HWM was also reset
const billing = await prisma.teamBilling.findUnique({
where: { teamId: testTeam.id },
select: { paidSeats: true, highWaterMark: true },
});
expect(billing?.paidSeats).toBe(4);
expect(billing?.highWaterMark).toBe(4);
// Verify Stripe subscription was updated
const updatedSub = await stripe.subscriptions.retrieve(subscription.id);
expect(updatedSub.items.data[0].quantity).toBe(4);
console.log(
"Scale DOWN verified: subscription quantity = 4 (current members)"
);
console.log(
`Final state: paidSeats=${billing?.paidSeats}, HWM=${billing?.highWaterMark}`
);
}, 180000); // 3 minute timeout
});
@@ -0,0 +1,896 @@
#!/usr/bin/env npx tsx
/**
* Seed script for testing High Water Mark (HWM) billing for monthly plans
*
* This script creates:
* 1. A test team with monthly billing and HWM tracking
* 2. A test organization with monthly billing and HWM tracking
* 3. Test users with various membership scenarios
* 4. Real Stripe customers and subscriptions (monthly)
* 5. Seat change logs to simulate peak usage
*
* Prerequisites:
* - STRIPE_PRIVATE_KEY must be set (use test mode key)
*
* Usage:
* npx tsx packages/features/ee/billing/service/highWaterMark/seed-hwm-test.ts
*
* Options:
* --skip-stripe Skip Stripe API calls (use fake IDs)
* --cleanup Clean up test data before seeding
*/
// IMPORTANT: Load environment variables BEFORE any other imports
// The @calcom/prisma module initializes at import time and needs DATABASE_URL
// Using side-effect import ensures dotenv runs before other imports are evaluated
import "dotenv/config";
// Now import other modules
import bcrypt from "bcryptjs";
import Stripe from "stripe";
import { ORGANIZATION_SELF_SERVE_PRICE } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { BillingPeriod, MembershipRole } from "@calcom/prisma/enums";
// Stripe product/price IDs from environment
const STRIPE_TEAM_MONTHLY_PRICE_ID = process.env.STRIPE_TEAM_MONTHLY_PRICE_ID;
const STRIPE_ORG_MONTHLY_PRICE_ID = process.env.STRIPE_ORG_MONTHLY_PRICE_ID;
// Fallback pricing constants (in cents) - used when Stripe price lookup fails
const TEAM_PRICE_PER_SEAT_CENTS_FALLBACK = 1500; // $15/seat/month
const ORG_PRICE_PER_SEAT_CENTS_FALLBACK = ORGANIZATION_SELF_SERVE_PRICE * 100; // $37/seat/month
async function hashPassword(password: string): Promise<string> {
const salt = await bcrypt.genSalt(10);
return bcrypt.hash(password, salt);
}
const TEST_PASSWORD = "password123";
const SKIP_STRIPE = process.argv.includes("--skip-stripe");
const CLEANUP_FIRST = process.argv.includes("--cleanup");
// Test team (standalone, not in org)
const HWM_TEAM_SLUG = "hwm-test-team";
const HWM_TEAM_ADMIN_EMAIL = "hwm-team-admin@example.com";
const HWM_TEAM_MEMBER_EMAILS = Array.from({ length: 4 }, (_, i) => `hwm-team-member-${i + 1}@example.com`);
// Test organization
const HWM_ORG_SLUG = "hwm-test-org";
const HWM_ORG_ADMIN_EMAIL = "hwm-org-admin@example.com";
const HWM_ORG_MEMBER_EMAILS = Array.from({ length: 6 }, (_, i) => `hwm-org-member-${i + 1}@example.com`);
interface StripeResources {
customer: Stripe.Customer | null;
subscription: Stripe.Subscription | null;
product: Stripe.Product | null;
price: Stripe.Price | null;
testClock: Stripe.TestHelpers.TestClock | null;
}
interface TeamSeedResult {
team: { id: number; name: string; slug: string };
memberCount: number;
highWaterMark: number;
paidSeats: number;
stripe: StripeResources;
}
interface OrgSeedResult {
organization: { id: number; name: string; slug: string };
memberCount: number;
highWaterMark: number;
paidSeats: number;
stripe: StripeResources;
}
interface SeedResult {
team: TeamSeedResult;
organization: OrgSeedResult;
testUsers: Array<{ email: string; name: string; team: string }>;
}
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 createTestUser(email: string, name: string, username: string, organizationId?: number) {
const hashedPassword = await hashPassword(TEST_PASSWORD);
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,
},
});
// Create a Profile for the user in the organization if organizationId is set
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;
}
async function cleanupStripeResources(stripe: Stripe) {
console.log("Cleaning up existing Stripe test resources...");
// Clean up test clocks first (this will also delete associated customers/subscriptions)
const testClocks = await stripe.testHelpers.testClocks.list({ limit: 100 });
for (const clock of testClocks.data) {
if (clock.name?.startsWith("HWM Test")) {
try {
await stripe.testHelpers.testClocks.del(clock.id);
console.log(` Deleted test clock: ${clock.id} (${clock.name})`);
} catch (error) {
console.log(` Could not delete test clock ${clock.id}:`, error);
}
}
}
// Clean up any customers not attached to test clocks
const emailsToCleanup = [HWM_TEAM_ADMIN_EMAIL, HWM_ORG_ADMIN_EMAIL];
for (const email of emailsToCleanup) {
const customers = await stripe.customers.list({
limit: 100,
email,
});
for (const customer of customers.data) {
try {
// Cancel subscriptions first
const subscriptions = await stripe.subscriptions.list({
customer: customer.id,
status: "all",
});
for (const sub of subscriptions.data) {
if (sub.status !== "canceled") {
await stripe.subscriptions.cancel(sub.id);
console.log(` Cancelled subscription: ${sub.id}`);
}
}
// Delete customer
await stripe.customers.del(customer.id);
console.log(` Deleted customer: ${customer.id}`);
} catch (error) {
console.log(` Could not delete customer ${customer.id}:`, error);
}
}
}
}
async function createStripeResourcesForTeam(
stripe: Stripe,
teamId: number,
email: string,
name: string,
seatCount: number,
priceId: string
): Promise<StripeResources & { pricePerSeatCents: number }> {
console.log(`Creating Stripe resources for ${name}...`);
// Use existing price from env
const price = await stripe.prices.retrieve(priceId);
if (!price) {
throw new Error(`Price not found: ${priceId}`);
}
const pricePerSeatCents = price.unit_amount || 0;
console.log(` Using existing price: ${price.id} ($${pricePerSeatCents / 100}/seat)`);
// Get the product
const product =
typeof price.product === "string"
? await stripe.products.retrieve(price.product)
: (price.product as Stripe.Product);
console.log(` Using existing product: ${product.id} (${product.name})`);
// Create a test clock for time simulation
const testClock = await stripe.testHelpers.testClocks.create({
frozen_time: Math.floor(Date.now() / 1000),
name: `HWM Test - ${name}`,
});
console.log(` Created test clock: ${testClock.id}`);
// Create a customer attached to the test clock
const customer = await stripe.customers.create({
email,
name,
test_clock: testClock.id,
metadata: {
testData: "true",
teamId: teamId.toString(),
calTeamId: teamId.toString(),
},
});
console.log(` Created customer: ${customer.id} (attached to test clock)`);
// Add a test payment method (test card)
const paymentMethod = await stripe.paymentMethods.create({
type: "card",
card: { token: "tok_visa" },
});
await stripe.paymentMethods.attach(paymentMethod.id, { customer: customer.id });
await stripe.customers.update(customer.id, {
invoice_settings: { default_payment_method: paymentMethod.id },
});
console.log(` Attached payment method: ${paymentMethod.id}`);
// Create a subscription with the specified seat count
const subscription = await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: price.id, quantity: seatCount }],
metadata: {
testData: "true",
teamId: teamId.toString(),
},
});
console.log(` Created subscription: ${subscription.id} (${seatCount} seats)`);
return { customer, subscription, product, price, testClock, pricePerSeatCents };
}
async function cleanupDatabaseResources() {
console.log("Cleaning up database test resources...");
// Delete test users (cascade will handle related records)
const testEmails = [HWM_TEAM_ADMIN_EMAIL, ...HWM_TEAM_MEMBER_EMAILS, HWM_ORG_ADMIN_EMAIL, ...HWM_ORG_MEMBER_EMAILS];
const deleteResult = await prisma.user.deleteMany({
where: { email: { in: testEmails } },
});
if (deleteResult.count > 0) {
console.log(` Deleted ${deleteResult.count} test users`);
}
// Clean up HWM test team
const team = await prisma.team.findFirst({
where: { slug: HWM_TEAM_SLUG, isOrganization: false },
});
if (team) {
await prisma.seatChangeLog.deleteMany({ where: { teamId: team.id } });
await prisma.teamBilling.deleteMany({ where: { teamId: team.id } });
await prisma.membership.deleteMany({ where: { teamId: team.id } });
await prisma.team.delete({ where: { id: team.id } });
console.log(` Deleted HWM test team (ID: ${team.id})`);
}
// Clean up HWM test org
const org = await prisma.team.findFirst({
where: { slug: HWM_ORG_SLUG, isOrganization: true },
});
if (org) {
await prisma.seatChangeLog.deleteMany({ where: { teamId: org.id } });
await prisma.organizationBilling.deleteMany({ where: { teamId: org.id } });
await prisma.organizationSettings.deleteMany({ where: { organizationId: org.id } });
// Delete child teams
const childTeams = await prisma.team.findMany({ where: { parentId: org.id } });
for (const t of childTeams) {
await prisma.membership.deleteMany({ where: { teamId: t.id } });
await prisma.team.delete({ where: { id: t.id } });
}
await prisma.membership.deleteMany({ where: { teamId: org.id } });
await prisma.team.delete({ where: { id: org.id } });
console.log(` Deleted HWM test org (ID: ${org.id})`);
}
console.log(" Database cleanup complete");
}
async function seedHwmTeam(stripe: Stripe | null): Promise<TeamSeedResult> {
console.log("\nCreating HWM test team (standalone)...");
// Create team
let team = await prisma.team.findFirst({
where: { slug: HWM_TEAM_SLUG, isOrganization: false },
});
if (!team) {
team = await prisma.team.create({
data: {
name: "HWM Test Team",
slug: HWM_TEAM_SLUG,
isOrganization: false,
},
});
}
console.log(` Team created: ${team.name} (ID: ${team.id})`);
// Create admin user
const adminUser = await createTestUser(HWM_TEAM_ADMIN_EMAIL, "HWM Team Admin", "hwm-team-admin");
// Create member users
const members = [];
for (let i = 0; i < HWM_TEAM_MEMBER_EMAILS.length; i++) {
const user = await createTestUser(
HWM_TEAM_MEMBER_EMAILS[i],
`HWM Team Member ${i + 1}`,
`hwm-team-member-${i + 1}`
);
members.push(user);
}
// Add admin to team as OWNER
await prisma.membership.upsert({
where: { userId_teamId: { userId: adminUser.id, teamId: team.id } },
update: { role: MembershipRole.OWNER },
create: { userId: adminUser.id, teamId: team.id, role: MembershipRole.OWNER, accepted: true },
});
// Add 3 of 4 members to team (simulating current state after some churn)
// This creates scenario: 4 members currently, but HWM will be 5 (peak was higher)
for (let i = 0; i < 3; i++) {
await prisma.membership.upsert({
where: { userId_teamId: { userId: members[i].id, teamId: team.id } },
update: { role: MembershipRole.MEMBER },
create: { userId: members[i].id, teamId: team.id, role: MembershipRole.MEMBER, accepted: true },
});
}
const currentMemberCount = 4; // 1 admin + 3 members
const peakMemberCount = 5; // At some point had 5 members
const paidSeats = currentMemberCount; // Subscription quantity matches current members
console.log(` Users added: ${currentMemberCount} current members (peak was ${peakMemberCount})`);
// Stripe resources or fake IDs
let stripeResources: StripeResources = {
customer: null,
subscription: null,
product: null,
price: null,
testClock: null,
};
let stripeCustomerId = `cus_fake_hwm_team_${Date.now()}`;
let stripeSubscriptionId = `sub_fake_hwm_team_${Date.now()}`;
let stripeSubscriptionItemId = `si_fake_hwm_team_${Date.now()}`;
let teamPricePerSeatCents = TEAM_PRICE_PER_SEAT_CENTS_FALLBACK;
if (stripe) {
if (!STRIPE_TEAM_MONTHLY_PRICE_ID) {
throw new Error("STRIPE_TEAM_MONTHLY_PRICE_ID is required when running with Stripe");
}
const result = await createStripeResourcesForTeam(
stripe,
team.id,
HWM_TEAM_ADMIN_EMAIL,
"HWM Test Team",
paidSeats,
STRIPE_TEAM_MONTHLY_PRICE_ID
);
stripeResources = result;
stripeCustomerId = result.customer!.id;
stripeSubscriptionId = result.subscription!.id;
stripeSubscriptionItemId = result.subscription!.items.data[0].id;
teamPricePerSeatCents = result.pricePerSeatCents;
}
// Monthly subscription dates
const now = new Date();
const subscriptionStart = new Date(now);
subscriptionStart.setDate(1); // Start of current month
const subscriptionEnd = new Date(subscriptionStart);
subscriptionEnd.setMonth(subscriptionEnd.getMonth() + 1);
// Create TeamBilling with MONTHLY billing and HWM tracking
await prisma.teamBilling.upsert({
where: { teamId: team.id },
update: {
customerId: stripeCustomerId,
subscriptionId: stripeSubscriptionId,
subscriptionItemId: stripeSubscriptionItemId,
billingPeriod: BillingPeriod.MONTHLY,
pricePerSeat: teamPricePerSeatCents,
paidSeats,
subscriptionStart,
subscriptionEnd,
subscriptionTrialEnd: null,
highWaterMark: peakMemberCount,
highWaterMarkPeriodStart: subscriptionStart,
},
create: {
teamId: team.id,
customerId: stripeCustomerId,
subscriptionId: stripeSubscriptionId,
subscriptionItemId: stripeSubscriptionItemId,
status: "ACTIVE",
planName: "TEAM",
billingPeriod: BillingPeriod.MONTHLY,
pricePerSeat: teamPricePerSeatCents,
paidSeats,
subscriptionStart,
subscriptionEnd,
subscriptionTrialEnd: null,
highWaterMark: peakMemberCount,
highWaterMarkPeriodStart: subscriptionStart,
},
});
console.log(` TeamBilling created (MONTHLY, $${teamPricePerSeatCents / 100}/seat, ${paidSeats} paid, HWM=${peakMemberCount})`);
// Get billing record for linking seat changes
const teamBilling = await prisma.teamBilling.findUnique({ where: { teamId: team.id } });
// Clear existing seat changes
await prisma.seatChangeLog.deleteMany({ where: { teamId: team.id } });
// Create seat change logs to simulate the history
const monthKey = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
// Initial additions (admin + 4 members = 5 peak)
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
for (let i = 0; i < 5; i++) {
await prisma.seatChangeLog.create({
data: {
teamId: team.id,
changeType: "ADDITION",
seatCount: 1,
userId: i === 0 ? adminUser.id : members[Math.min(i - 1, members.length - 1)].id,
triggeredBy: adminUser.id,
changeDate: weekAgo,
monthKey,
teamBillingId: teamBilling?.id,
metadata: { source: "seed-script", note: "Initial member addition" },
},
});
}
// One removal (member 4 left, bringing current count to 4)
const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000);
await prisma.seatChangeLog.create({
data: {
teamId: team.id,
changeType: "REMOVAL",
seatCount: 1,
userId: members[3].id,
triggeredBy: adminUser.id,
changeDate: threeDaysAgo,
monthKey,
teamBillingId: teamBilling?.id,
metadata: { source: "seed-script", note: "Member removed (HWM should stay at 5)" },
},
});
console.log(` SeatChangeLog entries created (5 additions, 1 removal)`);
return {
team: { id: team.id, name: team.name, slug: team.slug! },
memberCount: currentMemberCount,
highWaterMark: peakMemberCount,
paidSeats,
stripe: stripeResources,
};
}
async function seedHwmOrg(stripe: Stripe | null): Promise<OrgSeedResult> {
console.log("\nCreating HWM test organization...");
// Create organization
let org = await prisma.team.findFirst({
where: { slug: HWM_ORG_SLUG, isOrganization: true },
});
if (!org) {
org = await prisma.team.create({
data: {
name: "HWM Test Org",
slug: HWM_ORG_SLUG,
isOrganization: true,
},
});
}
console.log(` Organization created: ${org.name} (ID: ${org.id})`);
// Create OrganizationSettings
await prisma.organizationSettings.upsert({
where: { organizationId: org.id },
update: {},
create: {
organizationId: org.id,
orgAutoAcceptEmail: "example.com",
isOrganizationConfigured: true,
isOrganizationVerified: true,
},
});
// Create admin user
const adminUser = await createTestUser(HWM_ORG_ADMIN_EMAIL, "HWM Org Admin", "hwm-org-admin", org.id);
// Create member users
const members = [];
for (let i = 0; i < HWM_ORG_MEMBER_EMAILS.length; i++) {
const user = await createTestUser(
HWM_ORG_MEMBER_EMAILS[i],
`HWM Org Member ${i + 1}`,
`hwm-org-member-${i + 1}`,
org.id
);
members.push(user);
}
// Add admin to org as OWNER
await prisma.membership.upsert({
where: { userId_teamId: { userId: adminUser.id, teamId: org.id } },
update: { role: MembershipRole.OWNER },
create: { userId: adminUser.id, teamId: org.id, role: MembershipRole.OWNER, accepted: true },
});
// Add 5 of 6 members to org (simulating current state)
// Peak was 8 (admin + 7 members at one point), now at 6
for (let i = 0; i < 5; i++) {
await prisma.membership.upsert({
where: { userId_teamId: { userId: members[i].id, teamId: org.id } },
update: { role: MembershipRole.MEMBER },
create: { userId: members[i].id, teamId: org.id, role: MembershipRole.MEMBER, accepted: true },
});
}
const currentMemberCount = 6; // 1 admin + 5 members
const peakMemberCount = 8; // At some point had 8 members
const paidSeats = currentMemberCount; // Subscription quantity matches current members
console.log(` Users added: ${currentMemberCount} current members (peak was ${peakMemberCount})`);
// Stripe resources or fake IDs
let stripeResources: StripeResources = {
customer: null,
subscription: null,
product: null,
price: null,
testClock: null,
};
let stripeCustomerId = `cus_fake_hwm_org_${Date.now()}`;
let stripeSubscriptionId = `sub_fake_hwm_org_${Date.now()}`;
let stripeSubscriptionItemId = `si_fake_hwm_org_${Date.now()}`;
let orgPricePerSeatCents = ORG_PRICE_PER_SEAT_CENTS_FALLBACK;
if (stripe) {
if (!STRIPE_ORG_MONTHLY_PRICE_ID) {
throw new Error("STRIPE_ORG_MONTHLY_PRICE_ID is required when running with Stripe");
}
const result = await createStripeResourcesForTeam(
stripe,
org.id,
HWM_ORG_ADMIN_EMAIL,
"HWM Test Org",
paidSeats,
STRIPE_ORG_MONTHLY_PRICE_ID
);
stripeResources = result;
stripeCustomerId = result.customer!.id;
stripeSubscriptionId = result.subscription!.id;
stripeSubscriptionItemId = result.subscription!.items.data[0].id;
orgPricePerSeatCents = result.pricePerSeatCents;
}
// Monthly subscription dates
const now = new Date();
const subscriptionStart = new Date(now);
subscriptionStart.setDate(1);
const subscriptionEnd = new Date(subscriptionStart);
subscriptionEnd.setMonth(subscriptionEnd.getMonth() + 1);
// Create OrganizationBilling with MONTHLY billing and HWM tracking
await prisma.organizationBilling.upsert({
where: { teamId: org.id },
update: {
customerId: stripeCustomerId,
subscriptionId: stripeSubscriptionId,
subscriptionItemId: stripeSubscriptionItemId,
billingPeriod: BillingPeriod.MONTHLY,
pricePerSeat: orgPricePerSeatCents,
paidSeats,
subscriptionStart,
subscriptionEnd,
subscriptionTrialEnd: null,
highWaterMark: peakMemberCount,
highWaterMarkPeriodStart: subscriptionStart,
},
create: {
teamId: org.id,
customerId: stripeCustomerId,
subscriptionId: stripeSubscriptionId,
subscriptionItemId: stripeSubscriptionItemId,
status: "ACTIVE",
planName: "ORGANIZATION",
billingPeriod: BillingPeriod.MONTHLY,
pricePerSeat: orgPricePerSeatCents,
paidSeats,
subscriptionStart,
subscriptionEnd,
subscriptionTrialEnd: null,
highWaterMark: peakMemberCount,
highWaterMarkPeriodStart: subscriptionStart,
},
});
// Set team metadata
await prisma.team.update({
where: { id: org.id },
data: {
metadata: {
subscriptionId: stripeSubscriptionId,
subscriptionItemId: stripeSubscriptionItemId,
},
},
});
console.log(` OrganizationBilling created (MONTHLY, $${orgPricePerSeatCents / 100}/seat, ${paidSeats} paid, HWM=${peakMemberCount})`);
// Get billing record for linking seat changes
const orgBilling = await prisma.organizationBilling.findUnique({ where: { teamId: org.id } });
// Clear existing seat changes
await prisma.seatChangeLog.deleteMany({ where: { teamId: org.id } });
// Create seat change logs to simulate the history
const monthKey = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
// Initial additions (admin + 7 members = 8 peak)
const twoWeeksAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
for (let i = 0; i < 8; i++) {
await prisma.seatChangeLog.create({
data: {
teamId: org.id,
changeType: "ADDITION",
seatCount: 1,
userId: i === 0 ? adminUser.id : members[Math.min(i - 1, members.length - 1)].id,
triggeredBy: adminUser.id,
changeDate: twoWeeksAgo,
monthKey,
organizationBillingId: orgBilling?.id,
metadata: { source: "seed-script", note: "Initial member addition" },
},
});
}
// Two removals (bringing current count from 8 to 6)
const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000);
for (let i = 0; i < 2; i++) {
await prisma.seatChangeLog.create({
data: {
teamId: org.id,
changeType: "REMOVAL",
seatCount: 1,
userId: members[5].id, // Last member
triggeredBy: adminUser.id,
changeDate: fiveDaysAgo,
monthKey,
organizationBillingId: orgBilling?.id,
metadata: { source: "seed-script", note: "Member removed (HWM should stay at 8)" },
},
});
}
console.log(` SeatChangeLog entries created (8 additions, 2 removals)`);
return {
organization: { id: org.id, name: org.name, slug: org.slug! },
memberCount: currentMemberCount,
highWaterMark: peakMemberCount,
paidSeats,
stripe: stripeResources,
};
}
async function seedHwmTest(): Promise<SeedResult> {
const stripe = getStripeClient();
// Enable the hwm-seating feature flag
await prisma.feature.upsert({
where: { slug: "hwm-seating" },
update: { enabled: true },
create: {
slug: "hwm-seating",
enabled: true,
description: "High water mark seating for monthly billing",
type: "RELEASE",
stale: false,
},
});
console.log("Enabled hwm-seating feature flag");
if (CLEANUP_FIRST) {
await cleanupDatabaseResources();
if (stripe) {
await cleanupStripeResources(stripe);
}
}
const teamResult = await seedHwmTeam(stripe);
const orgResult = await seedHwmOrg(stripe);
const testUsers = [
{ email: HWM_TEAM_ADMIN_EMAIL, name: "HWM Team Admin", team: "hwm-test-team" },
...HWM_TEAM_MEMBER_EMAILS.slice(0, 3).map((email, i) => ({
email,
name: `HWM Team Member ${i + 1}`,
team: "hwm-test-team",
})),
{ email: HWM_ORG_ADMIN_EMAIL, name: "HWM Org Admin", team: "hwm-test-org" },
...HWM_ORG_MEMBER_EMAILS.slice(0, 5).map((email, i) => ({
email,
name: `HWM Org Member ${i + 1}`,
team: "hwm-test-org",
})),
];
return {
team: teamResult,
organization: orgResult,
testUsers,
};
}
async function main() {
console.log("=== High Water Mark (HWM) Test Seed Script ===\n");
console.log("Options:");
console.log(` --skip-stripe: ${SKIP_STRIPE}`);
console.log(` --cleanup: ${CLEANUP_FIRST}`);
console.log("");
try {
const result = await seedHwmTest();
console.log("\n=== Seed Complete ===\n");
// Summary table
const teamSubUrl = result.team.stripe.subscription
? `https://dashboard.stripe.com/test/subscriptions/${result.team.stripe.subscription.id}`
: "N/A (--skip-stripe)";
const teamCustUrl = result.team.stripe.customer
? `https://dashboard.stripe.com/test/customers/${result.team.stripe.customer.id}`
: "N/A";
const teamClockUrl = result.team.stripe.testClock
? `https://dashboard.stripe.com/test/test-clocks/${result.team.stripe.testClock.id}`
: "N/A";
const orgSubUrl = result.organization.stripe.subscription
? `https://dashboard.stripe.com/test/subscriptions/${result.organization.stripe.subscription.id}`
: "N/A (--skip-stripe)";
const orgCustUrl = result.organization.stripe.customer
? `https://dashboard.stripe.com/test/customers/${result.organization.stripe.customer.id}`
: "N/A";
const orgClockUrl = result.organization.stripe.testClock
? `https://dashboard.stripe.com/test/test-clocks/${result.organization.stripe.testClock.id}`
: "N/A";
// Calculate test clock dates from subscription period end
const teamPeriodEnd = result.team.stripe.subscription?.current_period_end
? new Date(result.team.stripe.subscription.current_period_end * 1000)
: null;
const orgPeriodEnd = result.organization.stripe.subscription?.current_period_end
? new Date(result.organization.stripe.subscription.current_period_end * 1000)
: null;
// invoice.upcoming fires ~3 days before period end
const teamInvoiceUpcomingDate = teamPeriodEnd
? new Date(teamPeriodEnd.getTime() - 3 * 24 * 60 * 60 * 1000)
: null;
const orgInvoiceUpcomingDate = orgPeriodEnd
? new Date(orgPeriodEnd.getTime() - 3 * 24 * 60 * 60 * 1000)
: null;
const formatDate = (d: Date | null) => (d ? d.toISOString().split("T")[0] : "N/A");
console.log("=== Summary Table ===\n");
console.log(
"| Entity | Qty | Members | HWM | invoice.upcoming | After Renewal | Price |"
);
console.log(
"|--------|-----|---------|-----|------------------|---------------|-------|"
);
console.log(
`| Team (ID:${result.team.team.id}) | ${result.team.paidSeats} | ${result.team.memberCount} | ${result.team.highWaterMark} | qty → ${result.team.highWaterMark} | qty → ${result.team.memberCount} (scale down) | $6.99 |`
);
console.log(
`| Org (ID:${result.organization.organization.id}) | ${result.organization.paidSeats} | ${result.organization.memberCount} | ${result.organization.highWaterMark} | qty → ${result.organization.highWaterMark} | qty → ${result.organization.memberCount} (scale down) | $37 |`
);
console.log("\n=== Test Clock Dates ===\n");
console.log(` 1. Advance to ${formatDate(teamInvoiceUpcomingDate)} → triggers invoice.upcoming (scale UP to HWM)`);
console.log(` 2. Advance to ${formatDate(teamPeriodEnd)} → triggers renewal (scale DOWN to current members)`);
console.log("\n=== Stripe Resources ===\n");
console.log("Team:");
console.log(` Subscription: ${teamSubUrl}`);
console.log(` Customer: ${teamCustUrl}`);
console.log(` Test Clock: ${teamClockUrl}`);
console.log("\nOrg:");
console.log(` Subscription: ${orgSubUrl}`);
console.log(` Customer: ${orgCustUrl}`);
console.log(` Test Clock: ${orgClockUrl}`);
// Test users
console.log("\n=== Test Users ===");
console.log(`Total test users created: ${result.testUsers.length}`);
console.log("User credentials are defined in the seed script constants.");
console.log("\n=== Testing Instructions ===\n");
console.log("1. ADVANCE TEST CLOCK to trigger invoice.upcoming:");
console.log(" Go to the Test Clock URL above and click 'Advance time'");
console.log(" Advance to ~3 days before the billing period ends (e.g., +27 days)");
console.log(" This will trigger the invoice.upcoming webhook");
console.log("");
console.log(" Or use Stripe CLI:");
if (result.team.stripe.testClock) {
const advanceTime = Math.floor(Date.now() / 1000) + 27 * 24 * 60 * 60;
console.log(` stripe test_clocks advance ${result.team.stripe.testClock.id} --frozen-time ${advanceTime}`);
}
console.log("");
console.log("2. invoice.upcoming webhook flow:");
console.log(" - Stripe sends invoice.upcoming ~3 days before renewal");
console.log(" - The webhook calls HighWaterMarkService.applyHighWaterMarkToSubscription()");
console.log(" - This updates the subscription quantity to the HWM value");
console.log(" - Team: subscription quantity updated from 4 to 5");
console.log(" - Org: subscription quantity updated from 6 to 8");
console.log("");
console.log("3. ADVANCE TEST CLOCK again to trigger renewal:");
console.log(" Advance to after the billing period ends (e.g., +31 days from start)");
console.log(" This will trigger customer.subscription.updated webhook");
console.log("");
console.log("4. customer.subscription.updated webhook flow:");
console.log(" - After renewal completes, this webhook fires");
console.log(" - For MONTHLY billing, it calls resetSubscriptionAfterRenewal()");
console.log(" - This resets subscription quantity AND HWM to current member count");
console.log(" - Team: subscription quantity reset from 5 to 4, HWM reset to 4");
console.log(" - Org: subscription quantity reset from 8 to 6, HWM reset to 6");
console.log("");
console.log("5. To verify HWM in database:");
console.log(` SELECT "highWaterMark", "highWaterMarkPeriodStart", "paidSeats" FROM "TeamBilling" WHERE "teamId" = ${result.team.team.id};`);
console.log(` SELECT "highWaterMark", "highWaterMarkPeriodStart", "paidSeats" FROM "OrganizationBilling" WHERE "teamId" = ${result.organization.organization.id};`);
console.log("");
console.log("=== Cleanup ===\n");
console.log("To clean up all test data, run:");
console.log(
" npx tsx packages/features/ee/billing/service/highWaterMark/seed-hwm-test.ts --cleanup --skip-stripe"
);
console.log("");
} catch (error) {
console.error("Error seeding data:", error);
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
main();
@@ -1,8 +1,16 @@
import { formatMonthKey } from "@calcom/features/ee/billing/lib/month-key";
import { HighWaterMarkRepository } from "@calcom/features/ee/billing/repository/highWaterMark/HighWaterMarkRepository";
import { MonthlyProrationTeamRepository } from "@calcom/features/ee/billing/repository/proration/MonthlyProrationTeamRepository";
import { SeatChangeLogRepository } from "@calcom/features/ee/billing/repository/seatChangeLogs/SeatChangeLogRepository";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { SeatChangeType } from "@calcom/prisma/enums";
const log = logger.getSubLogger({ prefix: ["SeatChangeTrackingService"] });
export interface SeatChangeLogParams {
teamId: number;
userId?: number;
@@ -21,11 +29,42 @@ export interface MonthlyChanges {
netChange: number;
}
export interface SeatChangeTrackingServiceDeps {
repository?: SeatChangeLogRepository;
highWaterMarkRepo?: HighWaterMarkRepository;
teamRepo?: MonthlyProrationTeamRepository;
featuresRepository?: IFeaturesRepository;
}
export class SeatChangeTrackingService {
private repository: SeatChangeLogRepository;
private highWaterMarkRepo: HighWaterMarkRepository;
private teamRepo: MonthlyProrationTeamRepository;
private featuresRepository: IFeaturesRepository;
constructor(repository?: SeatChangeLogRepository) {
this.repository = repository || new SeatChangeLogRepository();
constructor(
repositoryOrDeps?: SeatChangeLogRepository | SeatChangeTrackingServiceDeps,
highWaterMarkRepo?: HighWaterMarkRepository,
teamRepo?: MonthlyProrationTeamRepository
) {
// Support both old positional args and new deps object for backwards compatibility
if (
repositoryOrDeps &&
typeof repositoryOrDeps === "object" &&
"featuresRepository" in repositoryOrDeps
) {
const deps = repositoryOrDeps as SeatChangeTrackingServiceDeps;
this.repository = deps.repository || new SeatChangeLogRepository();
this.highWaterMarkRepo = deps.highWaterMarkRepo || new HighWaterMarkRepository();
this.teamRepo = deps.teamRepo || new MonthlyProrationTeamRepository();
this.featuresRepository = deps.featuresRepository || new FeaturesRepository(prisma);
} else {
// Legacy constructor signature
this.repository = (repositoryOrDeps as SeatChangeLogRepository) || new SeatChangeLogRepository();
this.highWaterMarkRepo = highWaterMarkRepo || new HighWaterMarkRepository();
this.teamRepo = teamRepo || new MonthlyProrationTeamRepository();
this.featuresRepository = new FeaturesRepository(prisma);
}
}
async logSeatAddition(params: SeatChangeLogParams): Promise<void> {
@@ -54,6 +93,63 @@ export class SeatChangeTrackingService {
teamBillingId,
organizationBillingId,
});
// Update high water mark for monthly billing
await this.updateHighWaterMarkIfNeeded(teamId);
}
private async updateHighWaterMarkIfNeeded(teamId: number): Promise<void> {
try {
// Check if the feature is enabled
const isFeatureEnabled = await this.featuresRepository.checkIfFeatureIsEnabledGlobally("hwm-seating");
if (!isFeatureEnabled) {
return;
}
// Get billing info
const billing = await this.highWaterMarkRepo.getByTeamId(teamId);
if (!billing) {
return;
}
// Only update for monthly billing
if (billing.billingPeriod !== "MONTHLY") {
return;
}
// Get current member count
const memberCount = await this.teamRepo.getTeamMemberCount(teamId);
if (memberCount === null) {
log.warn(`Could not get member count for team ${teamId}`);
return;
}
// Use the billing period start as the HWM period start
// Prefer subscription start date over arbitrary new Date()
const periodStart = billing.highWaterMarkPeriodStart || billing.subscriptionStart;
if (!periodStart) {
log.warn(`Could not determine period start for team ${teamId} - no subscriptionStart available`);
return;
}
const result = await this.highWaterMarkRepo.updateIfHigher({
teamId,
isOrganization: billing.isOrganization,
newSeatCount: memberCount,
periodStart,
});
if (result.updated) {
log.info(`High water mark updated for team ${teamId}`, {
previousHighWaterMark: result.previousHighWaterMark,
newHighWaterMark: memberCount,
});
}
} catch (error) {
// Log but don't fail the main operation
log.error(`Failed to update high water mark for team ${teamId}`, { error });
}
}
async logSeatRemoval(params: SeatChangeLogParams): Promise<void> {
@@ -22,10 +22,12 @@ vi.mock("@calcom/features/ee/teams/lib/payments", () => ({
}));
const shouldApplyMonthlyProration = vi.fn().mockResolvedValue(false);
const shouldApplyHighWaterMark = vi.fn().mockResolvedValue(false);
vi.mock("../billingPeriod/BillingPeriodService", () => ({
BillingPeriodService: class {
shouldApplyMonthlyProration = shouldApplyMonthlyProration;
shouldApplyHighWaterMark = shouldApplyHighWaterMark;
},
}));
@@ -173,6 +173,16 @@ export class TeamBillingService implements ITeamBillingService {
return;
}
// Skip immediate subscription update for monthly billing with high water mark
// The subscription quantity will be updated before renewal via invoice.upcoming webhook
const shouldApplyHighWaterMark = await billingPeriodService.shouldApplyHighWaterMark(teamId);
if (shouldApplyHighWaterMark) {
log.info(
`Skipping subscription update for team ${teamId} because high water mark billing is enabled for monthly plans.`
);
return;
}
await updateSubscriptionQuantity({
billingService: this.billingProviderService,
subscriptionId,
@@ -22,10 +22,12 @@ vi.mock("@calcom/features/ee/teams/lib/payments", () => ({
}));
const shouldApplyMonthlyProration = vi.fn().mockResolvedValue(false);
const shouldApplyHighWaterMark = vi.fn().mockResolvedValue(false);
vi.mock("../service/billingPeriod/BillingPeriodService", () => ({
BillingPeriodService: class {
shouldApplyMonthlyProration = shouldApplyMonthlyProration;
shouldApplyHighWaterMark = shouldApplyHighWaterMark;
},
}));
const mockTeam = {
+1
View File
@@ -35,6 +35,7 @@ export type AppFlags = {
"bookings-v3": boolean;
"booking-audit": boolean;
"monthly-proration": boolean;
"hwm-seating": boolean;
"sidebar-tips": boolean;
};
@@ -0,0 +1,11 @@
-- AlterTable: Add high water mark tracking fields for monthly billing
ALTER TABLE "TeamBilling" ADD COLUMN "highWaterMark" INTEGER,
ADD COLUMN "highWaterMarkPeriodStart" TIMESTAMP(3);
-- AlterTable: Add high water mark tracking fields for monthly billing (organizations)
ALTER TABLE "OrganizationBilling" ADD COLUMN "highWaterMark" INTEGER,
ADD COLUMN "highWaterMarkPeriodStart" TIMESTAMP(3);
INSERT INTO "public"."Feature" ("slug", "enabled", "description", "type", "stale", "lastUsedAt", "createdAt", "updatedAt")
VALUES ('hwm-seating', false, 'High water mark seating for monthly billing - charges for peak seats used during billing period', 'RELEASE', false, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT ("slug") DO NOTHING;
+8
View File
@@ -3003,6 +3003,10 @@ model TeamBilling {
monthlyProrations MonthlyProration[]
seatChangeLogs SeatChangeLog[]
// High water mark tracking for monthly billing
highWaterMark Int?
highWaterMarkPeriodStart DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -3027,6 +3031,10 @@ model OrganizationBilling {
monthlyProrations MonthlyProration[]
seatChangeLogs SeatChangeLog[]
// High water mark tracking for monthly billing
highWaterMark Int?
highWaterMarkPeriodStart DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}