From 1ad4ad6e4e1d53a26cd6f2ac1bef0cde41eb3317 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:56:31 +0000 Subject: [PATCH] feat: High Water Mark (HWM) billing for monthly subscriptions (#27559) * wip * fix: claue reveiew commentsd * add seed test * fix: seed test plus subscriptin undefined in webhook * chore: udpate triggr * Integration test + HWM seed script * add feature flag for hmw * Remove redudant docs * feat: add hwm-seating feature flag for high water mark billing - Add new hwm-seating feature flag to control HWM billing logic - Update HighWaterMarkService to use hwm-seating flag - Update SeatChangeTrackingService to use hwm-seating flag - Update BillingPeriodService to use hwm-seating flag - Add flag to migration SQL - Enable flag in seed script and integration test Co-Authored-By: Claude Opus 4.5 * fix: add hwm-seating to useFlags and fix test mocks - Add hwm-seating to useFlags.ts initial data - Add shouldApplyHighWaterMark mock to TeamBillingService tests Co-Authored-By: Claude Opus 4.5 * fix test * feat: move to invoice paid subscription * fix: transactions for transiants updates + reusable util * fix: address Cubic AI review feedback for HWM billing - Fix dotenv import hoisting in seed-hwm-test.ts by using side-effect import - Remove logging of sensitive information (password/emails) in seed script - Fix incorrect newHighWaterMark return value when update is not performed Co-Authored-By: unknown <> * add additional unit tests * improve DI * fix migration + restore package.json trigger update * feat:move to featurerepo+update tests * fix mocks * fix DI calls * revert faeturerepo to featuresrepository * fix: pass featureRepository to BillingPeriodService and HighWaterMarkService - Fix TeamBillingService.ts: pass featureRepository to BillingPeriodService constructor - Fix _invoice.upcoming.ts: pass featureRepository to HighWaterMarkService constructor - Fix hwm-webhook-utils.ts: pass featureRepository to HighWaterMarkService constructor These services require featureRepository for feature flag checks but production call sites were missing this required dependency, causing runtime errors. Addresses Cubic AI review feedback on PR #27559 Co-Authored-By: unknown <> * fix import * update feature repo usage to features * remove test thats mocking the wrong stuff * revert RegularBookingService changes * remove hide branding * remove change in redudant file --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../modules/feature-flags/hooks/useFlags.ts | 1 + .../webhook/_customer.subscription.updated.ts | 25 +- .../billing/api/webhook/_invoice.paid.org.ts | 39 +- .../billing/api/webhook/_invoice.paid.team.ts | 50 + .../ee/billing/api/webhook/_invoice.paid.ts | 6 +- .../billing/api/webhook/_invoice.upcoming.ts | 57 ++ .../api/webhook/hwm-webhook-utils.test.ts | 204 ++++ .../billing/api/webhook/hwm-webhook-utils.ts | 86 ++ .../features/ee/billing/api/webhook/index.ts | 1 + .../di/modules/HighWaterMarkRepository.ts | 24 + .../di/modules/HighWaterMarkService.ts | 34 + .../modules/MonthlyProrationTeamRepository.ts | 24 + packages/features/ee/billing/di/tokens.ts | 6 + .../highWaterMark/HighWaterMarkRepository.ts | 262 +++++ .../__tests__/HighWaterMarkRepository.test.ts | 474 +++++++++ .../billingPeriod/BillingPeriodService.ts | 44 +- .../__tests__/BillingPeriodService.test.ts | 15 +- .../highWaterMark/HighWaterMarkService.ts | 369 ++++++++ .../__tests__/HighWaterMarkService.test.ts | 491 ++++++++++ .../HighWaterMarkStripe.integration-test.ts | 440 +++++++++ .../service/highWaterMark/seed-hwm-test.ts | 896 ++++++++++++++++++ .../seatTracking/SeatChangeTrackingService.ts | 100 +- .../service/teams/TeamBillingService.test.ts | 2 + .../service/teams/TeamBillingService.ts | 10 + .../teams/internal-team-billing.test.ts | 2 + packages/features/flags/config.ts | 1 + .../migration.sql | 11 + packages/prisma/schema.prisma | 8 + 28 files changed, 3641 insertions(+), 41 deletions(-) create mode 100644 packages/features/ee/billing/api/webhook/_invoice.paid.team.ts create mode 100644 packages/features/ee/billing/api/webhook/_invoice.upcoming.ts create mode 100644 packages/features/ee/billing/api/webhook/hwm-webhook-utils.test.ts create mode 100644 packages/features/ee/billing/api/webhook/hwm-webhook-utils.ts create mode 100644 packages/features/ee/billing/di/modules/HighWaterMarkRepository.ts create mode 100644 packages/features/ee/billing/di/modules/HighWaterMarkService.ts create mode 100644 packages/features/ee/billing/di/modules/MonthlyProrationTeamRepository.ts create mode 100644 packages/features/ee/billing/repository/highWaterMark/HighWaterMarkRepository.ts create mode 100644 packages/features/ee/billing/repository/highWaterMark/__tests__/HighWaterMarkRepository.test.ts create mode 100644 packages/features/ee/billing/service/highWaterMark/HighWaterMarkService.ts create mode 100644 packages/features/ee/billing/service/highWaterMark/__tests__/HighWaterMarkService.test.ts create mode 100644 packages/features/ee/billing/service/highWaterMark/__tests__/HighWaterMarkStripe.integration-test.ts create mode 100644 packages/features/ee/billing/service/highWaterMark/seed-hwm-test.ts create mode 100644 packages/prisma/migrations/20260130100000_add_high_water_mark_fields/migration.sql 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 }