diff --git a/apps/web/modules/feature-flags/hooks/useFlags.ts b/apps/web/modules/feature-flags/hooks/useFlags.ts index ca7d78c080..d5456a7867 100644 --- a/apps/web/modules/feature-flags/hooks/useFlags.ts +++ b/apps/web/modules/feature-flags/hooks/useFlags.ts @@ -34,6 +34,7 @@ const initialData: AppFlags = { "bookings-v3": false, "booking-audit": false, "monthly-proration": false, + "hwm-seating": false, "sidebar-tips": false, }; diff --git a/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts b/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts index 8b556ed6bb..5d779c5ff5 100644 --- a/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts +++ b/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts @@ -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" }; diff --git a/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts b/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts index 4a45382336..443afeff13 100644 --- a/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts +++ b/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts @@ -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", }; } diff --git a/packages/features/ee/billing/api/webhook/_invoice.paid.team.ts b/packages/features/ee/billing/api/webhook/_invoice.paid.team.ts new file mode 100644 index 0000000000..4f519523d2 --- /dev/null +++ b/packages/features/ee/billing/api/webhook/_invoice.paid.team.ts @@ -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; diff --git a/packages/features/ee/billing/api/webhook/_invoice.paid.ts b/packages/features/ee/billing/api/webhook/_invoice.paid.ts index d3a7b44ec4..a0023952dc 100644 --- a/packages/features/ee/billing/api/webhook/_invoice.paid.ts +++ b/packages/features/ee/billing/api/webhook/_invoice.paid.ts @@ -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>; -// 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"), }); diff --git a/packages/features/ee/billing/api/webhook/_invoice.upcoming.ts b/packages/features/ee/billing/api/webhook/_invoice.upcoming.ts new file mode 100644 index 0000000000..84dfb4e3fe --- /dev/null +++ b/packages/features/ee/billing/api/webhook/_invoice.upcoming.ts @@ -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; diff --git a/packages/features/ee/billing/api/webhook/hwm-webhook-utils.test.ts b/packages/features/ee/billing/api/webhook/hwm-webhook-utils.test.ts new file mode 100644 index 0000000000..e6ec5f23c2 --- /dev/null +++ b/packages/features/ee/billing/api/webhook/hwm-webhook-utils.test.ts @@ -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"); + }); + }); +}); diff --git a/packages/features/ee/billing/api/webhook/hwm-webhook-utils.ts b/packages/features/ee/billing/api/webhook/hwm-webhook-utils.ts new file mode 100644 index 0000000000..a4111ca890 --- /dev/null +++ b/packages/features/ee/billing/api/webhook/hwm-webhook-utils.ts @@ -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 = 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 = defaultLogger +): Promise { + 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 }; + } +} diff --git a/packages/features/ee/billing/api/webhook/index.ts b/packages/features/ee/billing/api/webhook/index.ts index 74346405e5..7edd9b151d 100644 --- a/packages/features/ee/billing/api/webhook/index.ts +++ b/packages/features/ee/billing/api/webhook/index.ts @@ -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"), }; diff --git a/packages/features/ee/billing/di/modules/HighWaterMarkRepository.ts b/packages/features/ee/billing/di/modules/HighWaterMarkRepository.ts new file mode 100644 index 0000000000..49c1d1da9f --- /dev/null +++ b/packages/features/ee/billing/di/modules/HighWaterMarkRepository.ts @@ -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 }; diff --git a/packages/features/ee/billing/di/modules/HighWaterMarkService.ts b/packages/features/ee/billing/di/modules/HighWaterMarkService.ts new file mode 100644 index 0000000000..f005bef227 --- /dev/null +++ b/packages/features/ee/billing/di/modules/HighWaterMarkService.ts @@ -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 }; diff --git a/packages/features/ee/billing/di/modules/MonthlyProrationTeamRepository.ts b/packages/features/ee/billing/di/modules/MonthlyProrationTeamRepository.ts new file mode 100644 index 0000000000..d9c115717b --- /dev/null +++ b/packages/features/ee/billing/di/modules/MonthlyProrationTeamRepository.ts @@ -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 }; diff --git a/packages/features/ee/billing/di/tokens.ts b/packages/features/ee/billing/di/tokens.ts index 02676a8780..1649768082 100644 --- a/packages/features/ee/billing/di/tokens.ts +++ b/packages/features/ee/billing/di/tokens.ts @@ -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"), }; diff --git a/packages/features/ee/billing/repository/highWaterMark/HighWaterMarkRepository.ts b/packages/features/ee/billing/repository/highWaterMark/HighWaterMarkRepository.ts new file mode 100644 index 0000000000..be42f930c9 --- /dev/null +++ b/packages/features/ee/billing/repository/highWaterMark/HighWaterMarkRepository.ts @@ -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 { + 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 { + // 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 { + 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 { + 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 }, + }); + } + } +} diff --git a/packages/features/ee/billing/repository/highWaterMark/__tests__/HighWaterMarkRepository.test.ts b/packages/features/ee/billing/repository/highWaterMark/__tests__/HighWaterMarkRepository.test.ts new file mode 100644 index 0000000000..e31519be8f --- /dev/null +++ b/packages/features/ee/billing/repository/highWaterMark/__tests__/HighWaterMarkRepository.test.ts @@ -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; + + 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 }, + }); + }); + }); +}); diff --git a/packages/features/ee/billing/service/billingPeriod/BillingPeriodService.ts b/packages/features/ee/billing/service/billingPeriod/BillingPeriodService.ts index 3e0dff2e24..d1a8e0b0a1 100644 --- a/packages/features/ee/billing/service/billingPeriod/BillingPeriodService.ts +++ b/packages/features/ee/billing/service/billingPeriod/BillingPeriodService.ts @@ -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; + repository?: BillingPeriodRepository; + featuresRepository?: IFeaturesRepository; +} + export class BillingPeriodService { private logger: Logger; private repository: BillingPeriodRepository; + private featuresRepository: IFeaturesRepository; - constructor(customLogger?: Logger, 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 { @@ -40,10 +49,8 @@ export class BillingPeriodService { async shouldApplyMonthlyProration(teamId: number): Promise { 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 { + 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 { + const info = await this.getOrCreateBillingPeriodInfo(teamId); + return info.billingPeriod === "MONTHLY"; + } + async getOrCreateBillingPeriodInfo(teamId: number): Promise { const team = await prisma.team.findUnique({ where: { id: teamId }, diff --git a/packages/features/ee/billing/service/billingPeriod/__tests__/BillingPeriodService.test.ts b/packages/features/ee/billing/service/billingPeriod/__tests__/BillingPeriodService.test.ts index 67e1d1da04..9727d83434 100644 --- a/packages/features/ee/billing/service/billingPeriod/__tests__/BillingPeriodService.test.ts +++ b/packages/features/ee/billing/service/billingPeriod/__tests__/BillingPeriodService.test.ts @@ -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", () => { diff --git a/packages/features/ee/billing/service/highWaterMark/HighWaterMarkService.ts b/packages/features/ee/billing/service/highWaterMark/HighWaterMarkService.ts new file mode 100644 index 0000000000..c818d41574 --- /dev/null +++ b/packages/features/ee/billing/service/highWaterMark/HighWaterMarkService.ts @@ -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; + repository?: HighWaterMarkRepository; + teamRepository?: MonthlyProrationTeamRepository; + billingService?: IBillingProviderService; + featuresRepository?: IFeaturesRepository; +} + +export class HighWaterMarkService { + private logger: Logger; + 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 { + 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 { + 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 { + 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 { + // 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 { + 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; + } +} diff --git a/packages/features/ee/billing/service/highWaterMark/__tests__/HighWaterMarkService.test.ts b/packages/features/ee/billing/service/highWaterMark/__tests__/HighWaterMarkService.test.ts new file mode 100644 index 0000000000..cd90757625 --- /dev/null +++ b/packages/features/ee/billing/service/highWaterMark/__tests__/HighWaterMarkService.test.ts @@ -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; + let mockRepository: ReturnType; + let mockTeamRepository: ReturnType; + let mockBillingService: ReturnType; + let mockFeaturesRepository: ReturnType; + + 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); + }); + }); +}); diff --git a/packages/features/ee/billing/service/highWaterMark/__tests__/HighWaterMarkStripe.integration-test.ts b/packages/features/ee/billing/service/highWaterMark/__tests__/HighWaterMarkStripe.integration-test.ts new file mode 100644 index 0000000000..a3a680de7e --- /dev/null +++ b/packages/features/ee/billing/service/highWaterMark/__tests__/HighWaterMarkStripe.integration-test.ts @@ -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 { + 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, + description: string, + maxWaitMs = 30000 + ): Promise { + 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 +}); diff --git a/packages/features/ee/billing/service/highWaterMark/seed-hwm-test.ts b/packages/features/ee/billing/service/highWaterMark/seed-hwm-test.ts new file mode 100644 index 0000000000..d6a787dba0 --- /dev/null +++ b/packages/features/ee/billing/service/highWaterMark/seed-hwm-test.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/packages/features/ee/billing/service/seatTracking/SeatChangeTrackingService.ts b/packages/features/ee/billing/service/seatTracking/SeatChangeTrackingService.ts index 76accd74ec..3ce21cd978 100644 --- a/packages/features/ee/billing/service/seatTracking/SeatChangeTrackingService.ts +++ b/packages/features/ee/billing/service/seatTracking/SeatChangeTrackingService.ts @@ -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 { @@ -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 { + 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 { diff --git a/packages/features/ee/billing/service/teams/TeamBillingService.test.ts b/packages/features/ee/billing/service/teams/TeamBillingService.test.ts index 09aec002c8..f214bb9420 100644 --- a/packages/features/ee/billing/service/teams/TeamBillingService.test.ts +++ b/packages/features/ee/billing/service/teams/TeamBillingService.test.ts @@ -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; }, })); diff --git a/packages/features/ee/billing/service/teams/TeamBillingService.ts b/packages/features/ee/billing/service/teams/TeamBillingService.ts index a17d262163..c6dc245d69 100644 --- a/packages/features/ee/billing/service/teams/TeamBillingService.ts +++ b/packages/features/ee/billing/service/teams/TeamBillingService.ts @@ -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, diff --git a/packages/features/ee/billing/teams/internal-team-billing.test.ts b/packages/features/ee/billing/teams/internal-team-billing.test.ts index 1109fff3ee..c59b4addfd 100644 --- a/packages/features/ee/billing/teams/internal-team-billing.test.ts +++ b/packages/features/ee/billing/teams/internal-team-billing.test.ts @@ -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 = { diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index e4e220d4fe..e206a30e5d 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -35,6 +35,7 @@ export type AppFlags = { "bookings-v3": boolean; "booking-audit": boolean; "monthly-proration": boolean; + "hwm-seating": boolean; "sidebar-tips": boolean; }; diff --git a/packages/prisma/migrations/20260130100000_add_high_water_mark_fields/migration.sql b/packages/prisma/migrations/20260130100000_add_high_water_mark_fields/migration.sql new file mode 100644 index 0000000000..5f8d2d62b1 --- /dev/null +++ b/packages/prisma/migrations/20260130100000_add_high_water_mark_fields/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 75df681615..ef02c39828 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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 }