feat: refactor billing to strategy implemention (#27828)

* factory and statergie

* chore: use correct method of DI

* feat: add onchagne

* add logic to HWM stat

* add webhook resolver methods to each statergy

* move seat tracking + webhooks over to own statergy

* Move to factory base approach

* move logic to correct class

* rename create -> createByTeamId

* fix: remove debug `true ||` overrides from IS_STRIPE_ENABLED and IS_TEAM_BILLING_ENABLED

Remove accidentally committed debug overrides that short-circuited
IS_STRIPE_ENABLED and IS_TEAM_BILLING_ENABLED to always be true,
bypassing Stripe credential checks. This would break self-hosted
instances without Stripe configured.

Identified by cubic (https://cubic.dev)

Co-Authored-By: unknown <>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
sean-brydon
2026-02-10 13:11:36 -03:00
committed by GitHub
co-authored by unknown <> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 3908cda372
commit f4abbb2de1
34 changed files with 1314 additions and 242 deletions
@@ -44,7 +44,7 @@ async function postHandler(request: NextRequest) {
const teamBillingFactory = getTeamBillingServiceFactory();
const teamsBilling = teamBillingFactory.initMany(teams);
const teamBillingPromises = teamsBilling.map((teamBilling) => teamBilling.updateQuantity());
const teamBillingPromises = teamsBilling.map((teamBilling) => teamBilling.updateQuantity("sync"));
await Promise.allSettled(teamBillingPromises);
pageNumber++;
@@ -1,7 +1,8 @@
import { getSeatBillingStrategyFactory } from "@calcom/features/ee/billing/di/containers/Billing";
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"] });
@@ -35,13 +36,17 @@ const handler = async (data: SWHMap["invoice.paid"]["data"]) => {
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);
const periodStart = invoice.lines.data[0]?.period?.start;
if (!periodStart) {
log.warn(`Invoice has no period start for subscription ${subscriptionId}, skipping renewal handling`);
return { success: true };
}
log.info(`Processing renewal invoice for team subscription ${subscriptionId}`);
const factory = getSeatBillingStrategyFactory();
const strategy = await factory.createBySubscriptionId(subscriptionId);
await strategy.onRenewalPaid(subscriptionId, new Date(periodStart * 1000));
}
return { success: true };
@@ -4,19 +4,15 @@ import { buildMonthlyProrationMetadata } from "../../lib/proration-utils";
import type { SWHMap } from "./__handler";
import handler from "./_invoice.payment_failed";
const handleProrationPaymentFailure = vi.fn();
const onPaymentFailed = vi.fn().mockResolvedValue({ handled: true });
const createBySubscriptionId = vi.fn().mockResolvedValue({ onPaymentFailed });
const getPaymentIntentFailureReason = vi.fn().mockResolvedValue("card_declined");
vi.mock("@calcom/ee/billing/di/containers/Billing", () => ({
getBillingProviderService: () => ({
getPaymentIntentFailureReason,
}),
}));
vi.mock("../../service/proration/MonthlyProrationService", () => ({
MonthlyProrationService: class {
handleProrationPaymentFailure = handleProrationPaymentFailure;
},
getSeatBillingStrategyFactory: () => ({ createBySubscriptionId }),
}));
describe("invoice.payment_failed webhook", () => {
@@ -27,6 +23,7 @@ describe("invoice.payment_failed webhook", () => {
it("records proration failure with payment intent reason", async () => {
const data = {
object: {
subscription: "sub_123",
payment_intent: "pi_123",
status: "open",
lines: {
@@ -42,10 +39,53 @@ describe("invoice.payment_failed webhook", () => {
const result = await handler(data);
expect(getPaymentIntentFailureReason).toHaveBeenCalledWith("pi_123");
expect(handleProrationPaymentFailure).toHaveBeenCalledWith({
prorationId: "pr_123",
reason: "card_declined",
});
expect(result).toEqual({ success: true });
expect(createBySubscriptionId).toHaveBeenCalledWith("sub_123");
expect(onPaymentFailed).toHaveBeenCalledWith(
{ lines: data.object.lines },
"card_declined"
);
expect(result).toEqual({ success: true, handled: true });
});
it("skips when no subscription on invoice", async () => {
const data = {
object: {
subscription: null,
payment_intent: "pi_123",
status: "open",
lines: { data: [] },
},
} as unknown as SWHMap["invoice.payment_failed"]["data"];
const result = await handler(data);
expect(createBySubscriptionId).not.toHaveBeenCalled();
expect(result).toEqual({ success: true, message: "not a subscription invoice" });
});
it("uses invoice status as fallback when no payment intent", async () => {
const data = {
object: {
subscription: "sub_456",
payment_intent: null,
status: "open",
lines: {
data: [
{
metadata: buildMonthlyProrationMetadata({ prorationId: "pr_456" }),
},
],
},
},
} as unknown as SWHMap["invoice.payment_failed"]["data"];
const result = await handler(data);
expect(getPaymentIntentFailureReason).not.toHaveBeenCalled();
expect(onPaymentFailed).toHaveBeenCalledWith(
{ lines: data.object.lines },
"open"
);
expect(result).toEqual({ success: true, handled: true });
});
});
@@ -1,8 +1,6 @@
import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing";
import { getBillingProviderService, getSeatBillingStrategyFactory } from "@calcom/ee/billing/di/containers/Billing";
import logger from "@calcom/lib/logger";
import { findMonthlyProrationLineItem } from "../../lib/proration-utils";
import { MonthlyProrationService } from "../../service/proration/MonthlyProrationService";
import type { SWHMap } from "./__handler";
const log = logger.getSubLogger({ prefix: ["invoice-payment-failed"] });
@@ -12,19 +10,14 @@ type Data = SWHMap["invoice.payment_failed"]["data"];
const handler = async (data: Data) => {
const invoice = data.object;
const prorationLineItem = findMonthlyProrationLineItem(invoice.lines.data);
const subscriptionId =
typeof invoice.subscription === "string" ? invoice.subscription : invoice.subscription?.id;
if (!prorationLineItem) {
return { success: true, message: "no proration line items in invoice" };
if (!subscriptionId) {
log.debug("Not a subscription invoice, skipping");
return { success: true, message: "not a subscription invoice" };
}
const prorationId = prorationLineItem.metadata?.prorationId;
if (!prorationId) {
log.warn("proration line item missing prorationId metadata");
return { success: false, message: "missing prorationId in metadata" };
}
const prorationService = new MonthlyProrationService();
let failureReason = invoice.status ?? "payment_failed";
const paymentIntentId =
typeof invoice.payment_intent === "string" ? invoice.payment_intent : invoice.payment_intent?.id;
@@ -35,14 +28,15 @@ const handler = async (data: Data) => {
failureReason = paymentFailureReason ?? failureReason;
}
await prorationService.handleProrationPaymentFailure({
prorationId,
reason: failureReason,
});
const factory = getSeatBillingStrategyFactory();
const strategy = await factory.createBySubscriptionId(subscriptionId);
const { handled } = await strategy.onPaymentFailed({ lines: invoice.lines }, failureReason);
log.info(`proration ${prorationId} marked as failed`);
if (handled) {
log.info("Strategy handled payment failure", { subscriptionId, failureReason });
}
return { success: true };
return { success: true, handled };
};
export default handler;
@@ -4,12 +4,11 @@ import { buildMonthlyProrationMetadata } from "../../lib/proration-utils";
import type { SWHMap } from "./__handler";
import handler from "./_invoice.payment_succeeded";
const handleProrationPaymentSuccess = vi.fn();
const onPaymentSucceeded = vi.fn().mockResolvedValue({ handled: true });
const createBySubscriptionId = vi.fn().mockResolvedValue({ onPaymentSucceeded });
vi.mock("../../service/proration/MonthlyProrationService", () => ({
MonthlyProrationService: class {
handleProrationPaymentSuccess = handleProrationPaymentSuccess;
},
vi.mock("@calcom/features/ee/billing/di/containers/Billing", () => ({
getSeatBillingStrategyFactory: () => ({ createBySubscriptionId }),
}));
describe("invoice.payment_succeeded webhook", () => {
@@ -20,6 +19,7 @@ describe("invoice.payment_succeeded webhook", () => {
it("marks proration as charged when line item is present", async () => {
const data = {
object: {
subscription: "sub_123",
lines: {
data: [
{
@@ -32,19 +32,21 @@ describe("invoice.payment_succeeded webhook", () => {
const result = await handler(data);
expect(handleProrationPaymentSuccess).toHaveBeenCalledWith("pr_123");
expect(result).toEqual({ success: true });
expect(createBySubscriptionId).toHaveBeenCalledWith("sub_123");
expect(onPaymentSucceeded).toHaveBeenCalledWith({
lines: data.object.lines,
});
expect(result).toEqual({ success: true, handled: true });
});
it("skips when no proration line item exists", async () => {
it("skips when no subscription on invoice", async () => {
const data = {
object: {
subscription: null,
lines: {
data: [
{
metadata: {
type: "other",
},
metadata: { type: "other" },
},
],
},
@@ -53,7 +55,24 @@ describe("invoice.payment_succeeded webhook", () => {
const result = await handler(data);
expect(handleProrationPaymentSuccess).not.toHaveBeenCalled();
expect(result).toEqual({ success: true, message: "no proration line items in invoice" });
expect(createBySubscriptionId).not.toHaveBeenCalled();
expect(result).toEqual({ success: true, message: "not a subscription invoice" });
});
it("returns handled=false when strategy does not handle the invoice", async () => {
onPaymentSucceeded.mockResolvedValueOnce({ handled: false });
const data = {
object: {
subscription: "sub_456",
lines: {
data: [{ metadata: { type: "other" } }],
},
},
} as unknown as SWHMap["invoice.payment_succeeded"]["data"];
const result = await handler(data);
expect(result).toEqual({ success: true, handled: false });
});
});
@@ -1,7 +1,6 @@
import { getSeatBillingStrategyFactory } from "@calcom/features/ee/billing/di/containers/Billing";
import logger from "@calcom/lib/logger";
import { findMonthlyProrationLineItem } from "../../lib/proration-utils";
import { MonthlyProrationService } from "../../service/proration/MonthlyProrationService";
import type { SWHMap } from "./__handler";
const log = logger.getSubLogger({ prefix: ["invoice-payment-succeeded"] });
@@ -11,25 +10,23 @@ type Data = SWHMap["invoice.payment_succeeded"]["data"];
const handler = async (data: Data) => {
const invoice = data.object;
const prorationLineItem = findMonthlyProrationLineItem(invoice.lines.data);
const subscriptionId =
typeof invoice.subscription === "string" ? invoice.subscription : invoice.subscription?.id;
if (!prorationLineItem) {
return { success: true, message: "no proration line items in invoice" };
if (!subscriptionId) {
log.debug("Not a subscription invoice, skipping");
return { success: true, message: "not a subscription invoice" };
}
const prorationId = prorationLineItem.metadata?.prorationId;
if (!prorationId) {
log.warn("proration line item missing prorationId metadata");
return { success: false, message: "missing prorationId in metadata" };
const factory = getSeatBillingStrategyFactory();
const strategy = await factory.createBySubscriptionId(subscriptionId);
const { handled } = await strategy.onPaymentSucceeded({ lines: invoice.lines });
if (handled) {
log.info("Strategy handled payment succeeded", { subscriptionId });
}
const prorationService = new MonthlyProrationService();
await prorationService.handleProrationPaymentSuccess(prorationId);
log.info(`proration ${prorationId} marked as charged`);
return { success: true };
return { success: true, handled };
};
export default handler;
@@ -1,5 +1,4 @@
import { getBillingProviderService } from "@calcom/features/ee/billing/di/containers/Billing";
import { HighWaterMarkService } from "@calcom/features/ee/billing/service/highWaterMark/HighWaterMarkService";
import { getSeatBillingStrategyFactory } from "@calcom/features/ee/billing/di/containers/Billing";
import logger from "@calcom/lib/logger";
import type { SWHMap } from "./__handler";
@@ -11,7 +10,6 @@ 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" };
@@ -26,30 +24,18 @@ const handler = async (data: Data) => {
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);
const factory = getSeatBillingStrategyFactory();
const strategy = await factory.createBySubscriptionId(subscriptionId);
const { applied } = await strategy.onInvoiceUpcoming(subscriptionId);
if (applied) {
log.info("Successfully applied high water mark before renewal", {
subscriptionId,
});
return { success: true, highWaterMarkApplied: true };
log.info("Strategy applied invoice.upcoming handling", { subscriptionId });
}
log.debug("No high water mark update needed", { subscriptionId });
return { success: true, highWaterMarkApplied: false };
return { success: true, highWaterMarkApplied: applied };
} 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
log.error("Failed to process invoice.upcoming", { subscriptionId, error });
return { success: false, error: String(error) };
}
};
@@ -1,9 +1,10 @@
import { createContainer } from "@calcom/features/di/di";
import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository";
import type { StripeBillingService } from "../../service/billingProvider/StripeBillingService";
import type { SeatBillingStrategyFactory } from "../../service/seatBillingStrategy/SeatBillingStrategyFactory";
import type { TeamBillingServiceFactory } from "../../service/teams/TeamBillingServiceFactory";
import { billingProviderServiceModuleLoader } from "../modules/BillingProviderService";
import { seatBillingStrategyFactoryModuleLoader } from "../modules/SeatBillingStrategyFactory.module";
import { teamBillingServiceFactoryModuleLoader } from "../modules/TeamBillingServiceFactory";
import { DI_TOKENS } from "../tokens";
@@ -12,6 +13,7 @@ const billingContainer = createContainer();
// Load all modules (dependencies are loaded recursively)
teamBillingServiceFactoryModuleLoader.loadModule(billingContainer);
billingProviderServiceModuleLoader.loadModule(billingContainer);
seatBillingStrategyFactoryModuleLoader.loadModule(billingContainer);
export function getTeamBillingServiceFactory(): TeamBillingServiceFactory {
return billingContainer.get<TeamBillingServiceFactory>(DI_TOKENS.TEAM_BILLING_SERVICE_FACTORY);
@@ -24,3 +26,7 @@ export function getBillingProviderService(): StripeBillingService {
export function getTeamBillingDataRepository(): ITeamBillingDataRepository {
return billingContainer.get<ITeamBillingDataRepository>(DI_TOKENS.TEAM_BILLING_DATA_REPOSITORY);
}
export function getSeatBillingStrategyFactory(): SeatBillingStrategyFactory {
return billingContainer.get<SeatBillingStrategyFactory>(DI_TOKENS.SEAT_BILLING_STRATEGY_FACTORY);
}
@@ -0,0 +1,23 @@
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
import { BillingPeriodRepository } from "@calcom/features/ee/billing/repository/billingPeriod/BillingPeriodRepository";
import { DI_TOKENS } from "../tokens";
const thisModule = createModule();
const token = DI_TOKENS.BILLING_PERIOD_REPOSITORY;
const moduleToken = DI_TOKENS.BILLING_PERIOD_REPOSITORY_MODULE;
const loadModule = bindModuleToClassOnToken({
module: thisModule,
moduleToken,
token,
classs: BillingPeriodRepository,
dep: prismaModuleLoader,
});
export const billingPeriodRepositoryModuleLoader: ModuleLoader = {
token,
loadModule,
};
export type { BillingPeriodRepository };
@@ -0,0 +1,27 @@
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/FeaturesRepository";
import { BillingPeriodService } from "@calcom/features/ee/billing/service/billingPeriod/BillingPeriodService";
import { DI_TOKENS } from "../tokens";
import { billingPeriodRepositoryModuleLoader } from "./BillingPeriodRepository";
const thisModule = createModule();
const token = DI_TOKENS.BILLING_PERIOD_SERVICE;
const moduleToken = DI_TOKENS.BILLING_PERIOD_SERVICE_MODULE;
const loadModule = bindModuleToClassOnToken({
module: thisModule,
moduleToken,
token,
classs: BillingPeriodService,
depsMap: {
repository: billingPeriodRepositoryModuleLoader,
featuresRepository: featuresRepositoryModuleLoader,
},
});
export const billingPeriodServiceModuleLoader: ModuleLoader = {
token,
loadModule,
};
export type { BillingPeriodService };
@@ -0,0 +1,37 @@
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
import { moduleLoader as featuresRepositoryModuleLoader } from "@calcom/features/di/modules/FeaturesRepository";
import { SeatBillingStrategyFactory } from "@calcom/features/ee/billing/service/seatBillingStrategy/SeatBillingStrategyFactory";
import { DI_TOKENS } from "../tokens";
import { billingPeriodServiceModuleLoader } from "./BillingPeriodService.module";
import { billingProviderServiceModuleLoader } from "./BillingProviderService";
import { highWaterMarkRepositoryModuleLoader } from "./HighWaterMarkRepository";
import { highWaterMarkServiceModuleLoader } from "./HighWaterMarkService";
import { monthlyProrationServiceModuleLoader } from "./MonthlyProrationService";
import { teamBillingDataRepositoryModuleLoader } from "./TeamBillingDataRepositoryFactory";
const thisModule = createModule();
const token = DI_TOKENS.SEAT_BILLING_STRATEGY_FACTORY;
const moduleToken = DI_TOKENS.SEAT_BILLING_STRATEGY_FACTORY_MODULE;
const loadModule = bindModuleToClassOnToken({
module: thisModule,
moduleToken,
token,
classs: SeatBillingStrategyFactory,
depsMap: {
billingPeriodService: billingPeriodServiceModuleLoader,
featuresRepository: featuresRepositoryModuleLoader,
billingProviderService: billingProviderServiceModuleLoader,
highWaterMarkRepository: highWaterMarkRepositoryModuleLoader,
highWaterMarkService: highWaterMarkServiceModuleLoader,
monthlyProrationService: monthlyProrationServiceModuleLoader,
teamBillingDataRepository: teamBillingDataRepositoryModuleLoader,
},
});
export const seatBillingStrategyFactoryModuleLoader: ModuleLoader = {
token,
loadModule,
};
export type { SeatBillingStrategyFactory };
@@ -1,10 +1,10 @@
import { createModule, ModuleLoader, bindModuleToClassOnToken } from "@calcom/features/di/di";
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
import { TeamBillingServiceFactory } from "../../service/teams/TeamBillingServiceFactory";
import { DI_TOKENS } from "../tokens";
import { billingProviderServiceModuleLoader } from "./BillingProviderService";
import { billingRepositoryFactoryModuleLoader } from "./BillingRepositoryFactory";
import { isTeamBillingEnabledModuleLoader } from "./IsTeamBillingEnabled";
import { seatBillingStrategyFactoryModuleLoader } from "./SeatBillingStrategyFactory.module";
import { teamBillingDataRepositoryModuleLoader } from "./TeamBillingDataRepositoryFactory";
const teamBillingServiceFactoryModule = createModule();
@@ -20,6 +20,7 @@ const loadModule = bindModuleToClassOnToken({
teamBillingDataRepository: teamBillingDataRepositoryModuleLoader,
billingRepositoryFactory: billingRepositoryFactoryModuleLoader,
isTeamBillingEnabled: isTeamBillingEnabledModuleLoader,
seatBillingStrategyFactory: seatBillingStrategyFactoryModuleLoader,
},
});
@@ -17,4 +17,10 @@ export const DI_TOKENS = {
HIGH_WATER_MARK_REPOSITORY_MODULE: Symbol("HighWaterMarkRepositoryModule"),
MONTHLY_PRORATION_TEAM_REPOSITORY: Symbol("MonthlyProrationTeamRepository"),
MONTHLY_PRORATION_TEAM_REPOSITORY_MODULE: Symbol("MonthlyProrationTeamRepositoryModule"),
BILLING_PERIOD_REPOSITORY: Symbol("BillingPeriodRepository"),
BILLING_PERIOD_REPOSITORY_MODULE: Symbol("BillingPeriodRepositoryModule"),
BILLING_PERIOD_SERVICE: Symbol("BillingPeriodService"),
BILLING_PERIOD_SERVICE_MODULE: Symbol("BillingPeriodServiceModule"),
SEAT_BILLING_STRATEGY_FACTORY: Symbol("SeatBillingStrategyFactory"),
SEAT_BILLING_STRATEGY_FACTORY_MODULE: Symbol("SeatBillingStrategyFactoryModule"),
};
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env npx tsx
/**
* Interactive seed script launcher for billing test data.
*
* Usage:
* npx tsx packages/features/ee/billing/seed.ts
*
* Non-interactive (CI-friendly):
* npx tsx packages/features/ee/billing/seed.ts --hwm
* npx tsx packages/features/ee/billing/seed.ts --proration
* npx tsx packages/features/ee/billing/seed.ts --all
* npx tsx packages/features/ee/billing/seed.ts --cleanup
*
* Flags (combinable with above):
* --skip-stripe Skip Stripe API calls (use fake IDs)
* --cleanup Clean up before seeding
*/
import { spawn } from "node:child_process";
import * as readline from "node:readline";
const HWM_SCRIPT = "packages/features/ee/billing/service/highWaterMark/seed-hwm-test.ts";
const PRORATION_SCRIPT = "packages/features/ee/billing/service/dueInvoice/seed-proration-test.ts";
const passthrough = process.argv.filter((a) => a === "--skip-stripe");
function run(script: string, extraArgs: string[] = []): Promise<number> {
const args = ["tsx", script, ...passthrough, ...extraArgs];
return new Promise((resolve) => {
const child = spawn("npx", args, { stdio: "inherit", shell: true });
child.on("close", (code) => resolve(code ?? 1));
});
}
async function seedHwm(cleanup: boolean) {
console.log("\n--- Seeding High Water Mark test data ---\n");
const extra = cleanup ? ["--cleanup"] : [];
return run(HWM_SCRIPT, extra);
}
async function seedProration(cleanup: boolean) {
console.log("\n--- Seeding Proration test data ---\n");
const extra = cleanup ? ["--cleanup"] : [];
return run(PRORATION_SCRIPT, extra);
}
async function seedAll(cleanup: boolean) {
let code = await seedHwm(cleanup);
if (code !== 0) return code;
code = await seedProration(cleanup);
return code;
}
async function cleanupAll() {
console.log("\n--- Cleaning up all billing test data ---\n");
let code = await run(HWM_SCRIPT, ["--cleanup", "--skip-stripe"]);
if (code !== 0) return code;
code = await run(PRORATION_SCRIPT, ["--cleanup", "--skip-stripe"]);
return code;
}
function prompt(question: string): Promise<string> {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
async function interactive() {
console.log("=== Billing Test Data Seeder ===\n");
console.log(" 1) Seed HWM (High Water Mark) test data");
console.log(" 2) Seed Proration test data");
console.log(" 3) Seed all");
console.log(" 4) Cleanup all test data");
console.log(" q) Quit\n");
const choice = await prompt("Choose [1-4, q]: ");
if (choice === "q" || choice === "") {
console.log("Bye.");
return;
}
let cleanup = false;
if (["1", "2", "3"].includes(choice)) {
const ans = await prompt("Run cleanup before seeding? [y/N]: ");
cleanup = ans.toLowerCase() === "y";
}
let code = 0;
switch (choice) {
case "1":
code = await seedHwm(cleanup);
break;
case "2":
code = await seedProration(cleanup);
break;
case "3":
code = await seedAll(cleanup);
break;
case "4":
code = await cleanupAll();
break;
default:
console.log(`Unknown option: ${choice}`);
code = 1;
}
process.exit(code);
}
async function main() {
const args = process.argv.slice(2).filter((a) => !a.startsWith("--skip-stripe"));
if (args.includes("--hwm")) {
process.exit(await seedHwm(args.includes("--cleanup")));
}
if (args.includes("--proration")) {
process.exit(await seedProration(args.includes("--cleanup")));
}
if (args.includes("--all")) {
process.exit(await seedAll(args.includes("--cleanup")));
}
if (args.includes("--cleanup") && !args.includes("--hwm") && !args.includes("--proration") && !args.includes("--all")) {
process.exit(await cleanupAll());
}
await interactive();
}
main();
@@ -0,0 +1,56 @@
import logger from "@calcom/lib/logger";
import type { HighWaterMarkService } from "../highWaterMark/HighWaterMarkService";
import type { HighWaterMarkRepository } from "../../repository/highWaterMark/HighWaterMarkRepository";
import { BaseSeatBillingStrategy } from "./ISeatBillingStrategy";
import type { SeatChangeContext } from "./ISeatBillingStrategy";
const log = logger.getSubLogger({ prefix: ["HighWaterMarkStrategy"] });
export interface IHighWaterMarkStrategyDeps {
highWaterMarkRepository: HighWaterMarkRepository;
highWaterMarkService: HighWaterMarkService;
}
export class HighWaterMarkStrategy extends BaseSeatBillingStrategy {
constructor(private readonly deps: IHighWaterMarkStrategyDeps) {
super();
}
async onSeatChange(context: SeatChangeContext): Promise<void> {
if (context.changeType !== "addition") return;
const billing = await this.deps.highWaterMarkRepository.getByTeamId(context.teamId);
if (!billing) return;
const periodStart = billing.highWaterMarkPeriodStart || billing.subscriptionStart;
if (!periodStart) return;
const result = await this.deps.highWaterMarkRepository.updateIfHigher({
teamId: context.teamId,
isOrganization: billing.isOrganization,
newSeatCount: context.membershipCount,
periodStart,
});
if (result.updated) {
log.info(`High water mark updated for team ${context.teamId}`, {
previousHighWaterMark: result.previousHighWaterMark,
newHighWaterMark: context.membershipCount,
});
}
}
override async onInvoiceUpcoming(subscriptionId: string): Promise<{ applied: boolean }> {
const applied = await this.deps.highWaterMarkService.applyHighWaterMarkToSubscription(subscriptionId);
return { applied };
}
override async onRenewalPaid(subscriptionId: string, periodStart: Date): Promise<{ reset: boolean }> {
const reset = await this.deps.highWaterMarkService.resetSubscriptionAfterRenewal({
subscriptionId,
newPeriodStart: periodStart,
});
return { reset };
}
}
@@ -0,0 +1,45 @@
export type SeatChangeType = "addition" | "removal" | "sync";
export interface SeatChangeContext {
teamId: number;
subscriptionId: string;
subscriptionItemId: string;
membershipCount: number;
changeType: SeatChangeType;
}
export interface StripeInvoiceData {
lines: {
data: Array<{
metadata?: Record<string, string | null | undefined> | null;
}>;
};
}
export interface ISeatBillingStrategy {
onSeatChange(context: SeatChangeContext): Promise<void>;
onInvoiceUpcoming(subscriptionId: string): Promise<{ applied: boolean }>;
onRenewalPaid(subscriptionId: string, periodStart: Date): Promise<{ reset: boolean }>;
onPaymentSucceeded(invoice: StripeInvoiceData): Promise<{ handled: boolean }>;
onPaymentFailed(invoice: StripeInvoiceData, reason: string): Promise<{ handled: boolean }>;
}
export abstract class BaseSeatBillingStrategy implements ISeatBillingStrategy {
abstract onSeatChange(context: SeatChangeContext): Promise<void>;
async onInvoiceUpcoming(_subscriptionId: string): Promise<{ applied: boolean }> {
return { applied: false };
}
async onRenewalPaid(_subscriptionId: string, _periodStart: Date): Promise<{ reset: boolean }> {
return { reset: false };
}
async onPaymentSucceeded(_invoice: StripeInvoiceData): Promise<{ handled: boolean }> {
return { handled: false };
}
async onPaymentFailed(_invoice: StripeInvoiceData, _reason: string): Promise<{ handled: boolean }> {
return { handled: false };
}
}
@@ -0,0 +1,17 @@
import type { IBillingProviderService } from "../billingProvider/IBillingProviderService";
import { BaseSeatBillingStrategy } from "./ISeatBillingStrategy";
import type { SeatChangeContext } from "./ISeatBillingStrategy";
export class ImmediateUpdateStrategy extends BaseSeatBillingStrategy {
constructor(private readonly billingProviderService: IBillingProviderService) {
super();
}
async onSeatChange(context: SeatChangeContext): Promise<void> {
await this.billingProviderService.handleSubscriptionUpdate({
subscriptionId: context.subscriptionId,
subscriptionItemId: context.subscriptionItemId,
membershipCount: context.membershipCount,
});
}
}
@@ -0,0 +1,55 @@
import logger from "@calcom/lib/logger";
import { findMonthlyProrationLineItem } from "../../lib/proration-utils";
import type { MonthlyProrationService } from "../proration/MonthlyProrationService";
import { BaseSeatBillingStrategy } from "./ISeatBillingStrategy";
import type { SeatChangeContext, StripeInvoiceData } from "./ISeatBillingStrategy";
const log = logger.getSubLogger({ prefix: ["MonthlyProrationStrategy"] });
export interface IMonthlyProrationStrategyDeps {
monthlyProrationService: MonthlyProrationService;
}
export class MonthlyProrationStrategy extends BaseSeatBillingStrategy {
constructor(private readonly deps: IMonthlyProrationStrategyDeps) {
super();
}
async onSeatChange(_context: SeatChangeContext): Promise<void> {
// No immediate Stripe update -- proration is calculated and invoiced on a monthly cycle
}
override async onPaymentSucceeded(invoice: StripeInvoiceData): Promise<{ handled: boolean }> {
const prorationLineItem = findMonthlyProrationLineItem(invoice.lines.data);
if (!prorationLineItem) return { handled: false };
const prorationId = prorationLineItem.metadata?.prorationId;
if (!prorationId) {
log.warn("proration line item missing prorationId metadata");
return { handled: false };
}
await this.deps.monthlyProrationService.handleProrationPaymentSuccess(prorationId);
log.info(`proration ${prorationId} marked as charged`);
return { handled: true };
}
override async onPaymentFailed(invoice: StripeInvoiceData, reason: string): Promise<{ handled: boolean }> {
const prorationLineItem = findMonthlyProrationLineItem(invoice.lines.data);
if (!prorationLineItem) return { handled: false };
const prorationId = prorationLineItem.metadata?.prorationId;
if (!prorationId) {
log.warn("proration line item missing prorationId metadata");
return { handled: false };
}
await this.deps.monthlyProrationService.handleProrationPaymentFailure({
prorationId,
reason,
});
log.info(`proration ${prorationId} marked as failed`);
return { handled: true };
}
}
@@ -0,0 +1,82 @@
import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface";
import logger from "@calcom/lib/logger";
import type { HighWaterMarkRepository } from "../../repository/highWaterMark/HighWaterMarkRepository";
import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository";
import type { BillingPeriodService } from "../billingPeriod/BillingPeriodService";
import type { IBillingProviderService } from "../billingProvider/IBillingProviderService";
import type { HighWaterMarkService } from "../highWaterMark/HighWaterMarkService";
import type { MonthlyProrationService } from "../proration/MonthlyProrationService";
import { HighWaterMarkStrategy } from "./HighWaterMarkStrategy";
import { ImmediateUpdateStrategy } from "./ImmediateUpdateStrategy";
import type { ISeatBillingStrategy } from "./ISeatBillingStrategy";
import { MonthlyProrationStrategy } from "./MonthlyProrationStrategy";
const log = logger.getSubLogger({ prefix: ["SeatBillingStrategyFactory"] });
export interface ISeatBillingStrategyFactoryDeps {
billingPeriodService: BillingPeriodService;
featuresRepository: IFeaturesRepository;
billingProviderService: IBillingProviderService;
highWaterMarkRepository: HighWaterMarkRepository;
highWaterMarkService: HighWaterMarkService;
monthlyProrationService: MonthlyProrationService;
teamBillingDataRepository: ITeamBillingDataRepository;
}
export class SeatBillingStrategyFactory {
private readonly prorationStrategy: ISeatBillingStrategy;
private readonly hwmStrategy: ISeatBillingStrategy;
private readonly fallback: ISeatBillingStrategy;
constructor(private readonly deps: ISeatBillingStrategyFactoryDeps) {
this.fallback = new ImmediateUpdateStrategy(deps.billingProviderService);
this.prorationStrategy = new MonthlyProrationStrategy({
monthlyProrationService: deps.monthlyProrationService,
});
this.hwmStrategy = new HighWaterMarkStrategy({
highWaterMarkRepository: deps.highWaterMarkRepository,
highWaterMarkService: deps.highWaterMarkService,
});
}
async createByTeamId(teamId: number): Promise<ISeatBillingStrategy> {
const info = await this.deps.billingPeriodService.getBillingPeriodInfo(
teamId
);
if (!info.isInTrial && info.subscriptionStart) {
if (info.billingPeriod === "ANNUALLY") {
const enabled =
await this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally(
"monthly-proration"
);
if (enabled) return this.prorationStrategy;
}
if (info.billingPeriod === "MONTHLY") {
const enabled =
await this.deps.featuresRepository.checkIfFeatureIsEnabledGlobally(
"hwm-seating"
);
if (enabled) return this.hwmStrategy;
}
}
return this.fallback;
}
async createBySubscriptionId(
subscriptionId: string
): Promise<ISeatBillingStrategy> {
const team = await this.deps.teamBillingDataRepository.findBySubscriptionId(
subscriptionId
);
if (!team) {
log.warn(
`No team found for subscription ${subscriptionId}, using fallback strategy`
);
return this.fallback;
}
return this.createByTeamId(team.id);
}
}
@@ -0,0 +1,191 @@
import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface";
import { describe, expect, it, vi } from "vitest";
import type { ITeamBillingDataRepository } from "../../../repository/teamBillingData/ITeamBillingDataRepository";
import type { BillingPeriodInfo } from "../../billingPeriod/BillingPeriodService";
import type { IBillingProviderService } from "../../billingProvider/IBillingProviderService";
import { HighWaterMarkStrategy } from "../HighWaterMarkStrategy";
import { ImmediateUpdateStrategy } from "../ImmediateUpdateStrategy";
import { MonthlyProrationStrategy } from "../MonthlyProrationStrategy";
import { SeatBillingStrategyFactory } from "../SeatBillingStrategyFactory";
function createMockBillingPeriodService(info: BillingPeriodInfo) {
return { getBillingPeriodInfo: vi.fn().mockResolvedValue(info) };
}
function createMockFeaturesRepository(enabledFlags: Record<string, boolean>): IFeaturesRepository {
return {
checkIfFeatureIsEnabledGlobally: vi.fn(async (slug: string) => enabledFlags[slug] ?? false),
} as unknown as IFeaturesRepository;
}
function createMockBillingProviderService(): IBillingProviderService {
return { handleSubscriptionUpdate: vi.fn() } as unknown as IBillingProviderService;
}
function createMockHighWaterMarkRepository() {
return {
getByTeamId: vi.fn(),
updateIfHigher: vi.fn().mockResolvedValue({ updated: false, previousHighWaterMark: null }),
};
}
function createMockHighWaterMarkService() {
return {
applyHighWaterMarkToSubscription: vi.fn().mockResolvedValue(false),
resetSubscriptionAfterRenewal: vi.fn().mockResolvedValue(false),
};
}
function createMockMonthlyProrationService() {
return {
handleProrationPaymentSuccess: vi.fn(),
handleProrationPaymentFailure: vi.fn(),
};
}
function createMockTeamBillingDataRepository(): ITeamBillingDataRepository {
return {
find: vi.fn(),
findBySubscriptionId: vi.fn().mockResolvedValue(null),
findMany: vi.fn(),
};
}
const baseBillingInfo: BillingPeriodInfo = {
billingPeriod: null,
subscriptionStart: new Date("2025-01-01"),
subscriptionEnd: new Date("2026-01-01"),
trialEnd: null,
isInTrial: false,
pricePerSeat: 1500,
isOrganization: false,
};
function createFactory(
info: BillingPeriodInfo,
enabledFlags: Record<string, boolean>,
overrides?: { teamBillingDataRepository?: ITeamBillingDataRepository }
): SeatBillingStrategyFactory {
return new SeatBillingStrategyFactory({
billingPeriodService: createMockBillingPeriodService(info),
featuresRepository: createMockFeaturesRepository(enabledFlags),
billingProviderService: createMockBillingProviderService(),
highWaterMarkRepository: createMockHighWaterMarkRepository(),
highWaterMarkService: createMockHighWaterMarkService(),
monthlyProrationService: createMockMonthlyProrationService(),
teamBillingDataRepository: overrides?.teamBillingDataRepository ?? createMockTeamBillingDataRepository(),
} as never);
}
describe("SeatBillingStrategyFactory", () => {
it("returns MonthlyProrationStrategy for annual plan with proration enabled", async () => {
const billingPeriodService = createMockBillingPeriodService({
...baseBillingInfo,
billingPeriod: "ANNUALLY",
});
const factory = new SeatBillingStrategyFactory({
billingPeriodService,
featuresRepository: createMockFeaturesRepository({ "monthly-proration": true }),
billingProviderService: createMockBillingProviderService(),
highWaterMarkRepository: createMockHighWaterMarkRepository(),
highWaterMarkService: createMockHighWaterMarkService(),
monthlyProrationService: createMockMonthlyProrationService(),
teamBillingDataRepository: createMockTeamBillingDataRepository(),
} as never);
const strategy = await factory.createByTeamId(1);
expect(strategy).toBeInstanceOf(MonthlyProrationStrategy);
expect(billingPeriodService.getBillingPeriodInfo).toHaveBeenCalledWith(1);
});
it("returns HighWaterMarkStrategy for monthly plan with HWM enabled", async () => {
const factory = createFactory(
{ ...baseBillingInfo, billingPeriod: "MONTHLY" },
{ "hwm-seating": true }
);
const strategy = await factory.createByTeamId(1);
expect(strategy).toBeInstanceOf(HighWaterMarkStrategy);
});
it("returns ImmediateUpdateStrategy for annual plan with proration disabled", async () => {
const factory = createFactory(
{ ...baseBillingInfo, billingPeriod: "ANNUALLY" },
{ "monthly-proration": false }
);
const strategy = await factory.createByTeamId(1);
expect(strategy).toBeInstanceOf(ImmediateUpdateStrategy);
});
it("returns ImmediateUpdateStrategy for monthly plan with HWM disabled", async () => {
const factory = createFactory(
{ ...baseBillingInfo, billingPeriod: "MONTHLY" },
{ "hwm-seating": false }
);
const strategy = await factory.createByTeamId(1);
expect(strategy).toBeInstanceOf(ImmediateUpdateStrategy);
});
it("returns ImmediateUpdateStrategy when team is in trial", async () => {
const factory = createFactory(
{ ...baseBillingInfo, billingPeriod: "ANNUALLY", isInTrial: true, trialEnd: new Date("2026-06-01") },
{ "monthly-proration": true }
);
const strategy = await factory.createByTeamId(1);
expect(strategy).toBeInstanceOf(ImmediateUpdateStrategy);
});
it("returns ImmediateUpdateStrategy when subscriptionStart is null", async () => {
const factory = createFactory(
{ ...baseBillingInfo, billingPeriod: "ANNUALLY", subscriptionStart: null },
{ "monthly-proration": true }
);
const strategy = await factory.createByTeamId(1);
expect(strategy).toBeInstanceOf(ImmediateUpdateStrategy);
});
it("returns ImmediateUpdateStrategy when billingPeriod is null", async () => {
const factory = createFactory(
{ ...baseBillingInfo, billingPeriod: null },
{ "monthly-proration": true, "hwm-seating": true }
);
const strategy = await factory.createByTeamId(1);
expect(strategy).toBeInstanceOf(ImmediateUpdateStrategy);
});
it("createBySubscriptionId looks up team and delegates to create", async () => {
const teamBillingDataRepo = createMockTeamBillingDataRepository();
vi.mocked(teamBillingDataRepo.findBySubscriptionId).mockResolvedValue({
id: 42,
metadata: {},
isOrganization: false,
parentId: null,
name: "Test Team",
});
const factory = createFactory(
{ ...baseBillingInfo, billingPeriod: "MONTHLY" },
{ "hwm-seating": true },
{ teamBillingDataRepository: teamBillingDataRepo }
);
const strategy = await factory.createBySubscriptionId("sub_abc");
expect(teamBillingDataRepo.findBySubscriptionId).toHaveBeenCalledWith("sub_abc");
expect(strategy).toBeInstanceOf(HighWaterMarkStrategy);
});
it("createBySubscriptionId returns fallback when no team found", async () => {
const teamBillingDataRepo = createMockTeamBillingDataRepository();
vi.mocked(teamBillingDataRepo.findBySubscriptionId).mockResolvedValue(null);
const factory = createFactory(baseBillingInfo, {}, { teamBillingDataRepository: teamBillingDataRepo });
const strategy = await factory.createBySubscriptionId("sub_unknown");
expect(strategy).toBeInstanceOf(ImmediateUpdateStrategy);
});
});
@@ -0,0 +1,286 @@
import { describe, expect, it, vi } from "vitest";
import type { IBillingProviderService } from "../../billingProvider/IBillingProviderService";
import type { HighWaterMarkService } from "../../highWaterMark/HighWaterMarkService";
import type { MonthlyProrationService } from "../../proration/MonthlyProrationService";
import type { HighWaterMarkRepository } from "../../../repository/highWaterMark/HighWaterMarkRepository";
import { HighWaterMarkStrategy } from "../HighWaterMarkStrategy";
import { ImmediateUpdateStrategy } from "../ImmediateUpdateStrategy";
import type { SeatChangeContext, StripeInvoiceData } from "../ISeatBillingStrategy";
import { MonthlyProrationStrategy } from "../MonthlyProrationStrategy";
const mockContext: SeatChangeContext = {
teamId: 1,
subscriptionId: "sub_123",
subscriptionItemId: "si_456",
membershipCount: 10,
changeType: "addition",
};
function createMockBillingProviderService(): IBillingProviderService {
return {
handleSubscriptionUpdate: vi.fn(),
} as unknown as IBillingProviderService;
}
function createMockHighWaterMarkRepository(): HighWaterMarkRepository {
return {
getByTeamId: vi.fn(),
updateIfHigher: vi.fn().mockResolvedValue({ updated: false, previousHighWaterMark: null }),
} as unknown as HighWaterMarkRepository;
}
function createMockHighWaterMarkService(): HighWaterMarkService {
return {
applyHighWaterMarkToSubscription: vi.fn().mockResolvedValue(false),
resetSubscriptionAfterRenewal: vi.fn().mockResolvedValue(false),
} as unknown as HighWaterMarkService;
}
function createMockMonthlyProrationService(): MonthlyProrationService {
return {
handleProrationPaymentSuccess: vi.fn(),
handleProrationPaymentFailure: vi.fn(),
} as unknown as MonthlyProrationService;
}
describe("ImmediateUpdateStrategy", () => {
it("calls handleSubscriptionUpdate on seat change", async () => {
const billingProvider = createMockBillingProviderService();
const strategy = new ImmediateUpdateStrategy(billingProvider);
await strategy.onSeatChange(mockContext);
expect(billingProvider.handleSubscriptionUpdate).toHaveBeenCalledWith({
subscriptionId: "sub_123",
subscriptionItemId: "si_456",
membershipCount: 10,
});
});
it("returns no-op for onInvoiceUpcoming", async () => {
const strategy = new ImmediateUpdateStrategy(createMockBillingProviderService());
expect(await strategy.onInvoiceUpcoming("sub_123")).toEqual({ applied: false });
});
it("returns no-op for onRenewalPaid", async () => {
const strategy = new ImmediateUpdateStrategy(createMockBillingProviderService());
expect(await strategy.onRenewalPaid("sub_123", new Date())).toEqual({ reset: false });
});
it("returns no-op for onPaymentSucceeded (inherited from base)", async () => {
const strategy = new ImmediateUpdateStrategy(createMockBillingProviderService());
const invoice: StripeInvoiceData = { lines: { data: [] } };
expect(await strategy.onPaymentSucceeded(invoice)).toEqual({ handled: false });
});
it("returns no-op for onPaymentFailed (inherited from base)", async () => {
const strategy = new ImmediateUpdateStrategy(createMockBillingProviderService());
const invoice: StripeInvoiceData = { lines: { data: [] } };
expect(await strategy.onPaymentFailed(invoice, "card_declined")).toEqual({ handled: false });
});
});
describe("HighWaterMarkStrategy", () => {
function createStrategy() {
const hwmRepo = createMockHighWaterMarkRepository();
const hwmService = createMockHighWaterMarkService();
const strategy = new HighWaterMarkStrategy({
highWaterMarkRepository: hwmRepo,
highWaterMarkService: hwmService,
});
return { strategy, hwmRepo, hwmService };
}
it("updates high water mark on seat addition", async () => {
const { strategy, hwmRepo } = createStrategy();
vi.mocked(hwmRepo.getByTeamId).mockResolvedValue({
subscriptionStart: new Date("2025-01-01"),
highWaterMarkPeriodStart: new Date("2025-06-01"),
isOrganization: false,
});
vi.mocked(hwmRepo.updateIfHigher).mockResolvedValue({ updated: true, previousHighWaterMark: 8 });
await strategy.onSeatChange(mockContext);
expect(hwmRepo.getByTeamId).toHaveBeenCalledWith(1);
expect(hwmRepo.updateIfHigher).toHaveBeenCalledWith({
teamId: 1,
isOrganization: false,
newSeatCount: 10,
periodStart: new Date("2025-06-01"),
});
});
it("uses subscriptionStart when highWaterMarkPeriodStart is null", async () => {
const { strategy, hwmRepo } = createStrategy();
vi.mocked(hwmRepo.getByTeamId).mockResolvedValue({
subscriptionStart: new Date("2025-01-01"),
highWaterMarkPeriodStart: null,
isOrganization: false,
});
await strategy.onSeatChange(mockContext);
expect(hwmRepo.updateIfHigher).toHaveBeenCalledWith(
expect.objectContaining({ periodStart: new Date("2025-01-01") })
);
});
it("skips HWM update on seat removal", async () => {
const { strategy, hwmRepo } = createStrategy();
await strategy.onSeatChange({ ...mockContext, changeType: "removal" });
expect(hwmRepo.getByTeamId).not.toHaveBeenCalled();
});
it("skips HWM update on sync", async () => {
const { strategy, hwmRepo } = createStrategy();
await strategy.onSeatChange({ ...mockContext, changeType: "sync" });
expect(hwmRepo.getByTeamId).not.toHaveBeenCalled();
});
it("skips HWM update when no billing record exists", async () => {
const { strategy, hwmRepo } = createStrategy();
vi.mocked(hwmRepo.getByTeamId).mockResolvedValue(null);
await strategy.onSeatChange(mockContext);
expect(hwmRepo.updateIfHigher).not.toHaveBeenCalled();
});
it("skips HWM update when no period start available", async () => {
const { strategy, hwmRepo } = createStrategy();
vi.mocked(hwmRepo.getByTeamId).mockResolvedValue({
subscriptionStart: null,
highWaterMarkPeriodStart: null,
isOrganization: false,
});
await strategy.onSeatChange(mockContext);
expect(hwmRepo.updateIfHigher).not.toHaveBeenCalled();
});
it("delegates onInvoiceUpcoming to HighWaterMarkService", async () => {
const { strategy, hwmService } = createStrategy();
vi.mocked(hwmService.applyHighWaterMarkToSubscription).mockResolvedValue(true);
const result = await strategy.onInvoiceUpcoming("sub_123");
expect(result).toEqual({ applied: true });
expect(hwmService.applyHighWaterMarkToSubscription).toHaveBeenCalledWith("sub_123");
});
it("delegates onRenewalPaid to HighWaterMarkService", async () => {
const { strategy, hwmService } = createStrategy();
vi.mocked(hwmService.resetSubscriptionAfterRenewal).mockResolvedValue(true);
const periodStart = new Date("2025-07-01");
const result = await strategy.onRenewalPaid("sub_123", periodStart);
expect(result).toEqual({ reset: true });
expect(hwmService.resetSubscriptionAfterRenewal).toHaveBeenCalledWith({
subscriptionId: "sub_123",
newPeriodStart: periodStart,
});
});
it("returns no-op for onPaymentSucceeded (inherited from base)", async () => {
const { strategy } = createStrategy();
const invoice: StripeInvoiceData = { lines: { data: [] } };
expect(await strategy.onPaymentSucceeded(invoice)).toEqual({ handled: false });
});
it("returns no-op for onPaymentFailed (inherited from base)", async () => {
const { strategy } = createStrategy();
const invoice: StripeInvoiceData = { lines: { data: [] } };
expect(await strategy.onPaymentFailed(invoice, "card_declined")).toEqual({ handled: false });
});
});
describe("MonthlyProrationStrategy", () => {
function createProrationStrategy() {
const prorationService = createMockMonthlyProrationService();
const strategy = new MonthlyProrationStrategy({
monthlyProrationService: prorationService,
});
return { strategy, prorationService };
}
it("does not call any external service on seat change", async () => {
const { strategy } = createProrationStrategy();
await expect(strategy.onSeatChange(mockContext)).resolves.toBeUndefined();
});
it("returns no-op for onInvoiceUpcoming (inherited from base)", async () => {
const { strategy } = createProrationStrategy();
expect(await strategy.onInvoiceUpcoming("sub_123")).toEqual({ applied: false });
});
it("returns no-op for onRenewalPaid (inherited from base)", async () => {
const { strategy } = createProrationStrategy();
expect(await strategy.onRenewalPaid("sub_123", new Date())).toEqual({ reset: false });
});
it("handles payment succeeded for proration invoice", async () => {
const { strategy, prorationService } = createProrationStrategy();
const invoice: StripeInvoiceData = {
lines: { data: [{ metadata: { type: "monthly_proration", prorationId: "pro_123" } }] },
};
const result = await strategy.onPaymentSucceeded(invoice);
expect(result).toEqual({ handled: true });
expect(prorationService.handleProrationPaymentSuccess).toHaveBeenCalledWith("pro_123");
});
it("returns not handled for non-proration invoice on payment succeeded", async () => {
const { strategy, prorationService } = createProrationStrategy();
const invoice: StripeInvoiceData = {
lines: { data: [{ metadata: { type: "other" } }] },
};
const result = await strategy.onPaymentSucceeded(invoice);
expect(result).toEqual({ handled: false });
expect(prorationService.handleProrationPaymentSuccess).not.toHaveBeenCalled();
});
it("returns not handled when proration line item has no prorationId", async () => {
const { strategy, prorationService } = createProrationStrategy();
const invoice: StripeInvoiceData = {
lines: { data: [{ metadata: { type: "monthly_proration" } }] },
};
const result = await strategy.onPaymentSucceeded(invoice);
expect(result).toEqual({ handled: false });
expect(prorationService.handleProrationPaymentSuccess).not.toHaveBeenCalled();
});
it("handles payment failed for proration invoice", async () => {
const { strategy, prorationService } = createProrationStrategy();
const invoice: StripeInvoiceData = {
lines: { data: [{ metadata: { type: "monthly_proration", prorationId: "pro_456" } }] },
};
const result = await strategy.onPaymentFailed(invoice, "card_declined");
expect(result).toEqual({ handled: true });
expect(prorationService.handleProrationPaymentFailure).toHaveBeenCalledWith({
prorationId: "pro_456",
reason: "card_declined",
});
});
it("returns not handled for non-proration invoice on payment failed", async () => {
const { strategy, prorationService } = createProrationStrategy();
const invoice: StripeInvoiceData = { lines: { data: [] } };
const result = await strategy.onPaymentFailed(invoice, "card_declined");
expect(result).toEqual({ handled: false });
expect(prorationService.handleProrationPaymentFailure).not.toHaveBeenCalled();
});
});
@@ -1,9 +1,7 @@
import type { Team } from "@calcom/prisma/client";
import {
SubscriptionStatus,
IBillingRepositoryCreateArgs,
} from "../../repository/billing/IBillingRepository";
import type { IBillingRepositoryCreateArgs, SubscriptionStatus } from "../../repository/billing/IBillingRepository";
import type { SeatChangeType } from "../seatBillingStrategy/ISeatBillingStrategy";
export type TeamBillingInput = Pick<Team, "id" | "parentId" | "metadata" | "isOrganization">;
export const TeamBillingPublishResponseStatus = {
@@ -21,7 +19,7 @@ export interface ITeamBillingService {
cancel(): Promise<void>;
publish(): Promise<TeamBillingPublishResponse>;
downgrade(): Promise<void>;
updateQuantity(): Promise<void>;
updateQuantity(changeType: SeatChangeType): Promise<void>;
getSubscriptionStatus(): Promise<SubscriptionStatus | null>;
endTrial(): Promise<boolean>;
saveTeamBilling(args: IBillingRepositoryCreateArgs): Promise<void>;
@@ -1,3 +1,4 @@
import type { SeatChangeType } from "../seatBillingStrategy/ISeatBillingStrategy";
import { TeamBillingPublishResponseStatus } from "./ITeamBillingService";
import type {
ITeamBillingService,
@@ -23,7 +24,7 @@ export class StubTeamBillingService implements ITeamBillingService {
// Stub implementation - no-op
}
async updateQuantity(): Promise<void> {
async updateQuantity(_changeType: SeatChangeType): Promise<void> {
// Stub implementation - no-op
}
@@ -1,8 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { IBillingRepository } from "../../repository/billing/IBillingRepository";
import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository";
import type { IBillingProviderService } from "../billingProvider/IBillingProviderService";
import type { SeatBillingStrategyFactory } from "../seatBillingStrategy/SeatBillingStrategyFactory";
import { StubTeamBillingService } from "./StubTeamBillingService";
import { TeamBillingService } from "./TeamBillingService";
import { TeamBillingServiceFactory } from "./TeamBillingServiceFactory";
@@ -14,6 +14,7 @@ describe("TeamBilling", () => {
let mockBillingProviderService: IBillingProviderService;
let mockTeamBillingDataRepository: ITeamBillingDataRepository;
let mockBillingRepository: IBillingRepository;
let mockSeatBillingStrategyFactory: SeatBillingStrategyFactory;
let factory: TeamBillingServiceFactory;
const createMockBillingProviderService = (): IBillingProviderService => ({
@@ -55,6 +56,7 @@ describe("TeamBilling", () => {
mockBillingProviderService = createMockBillingProviderService();
mockTeamBillingDataRepository = createMockTeamBillingDataRepository();
mockBillingRepository = createMockBillingRepository();
mockSeatBillingStrategyFactory = { createByTeamId: vi.fn() } as unknown as SeatBillingStrategyFactory;
});
afterEach(() => {
@@ -68,6 +70,7 @@ describe("TeamBilling", () => {
teamBillingDataRepository: mockTeamBillingDataRepository,
billingRepositoryFactory: () => mockBillingRepository,
isTeamBillingEnabled: true,
seatBillingStrategyFactory: mockSeatBillingStrategyFactory,
});
const result = factory.init(mockTeam);
@@ -81,6 +84,7 @@ describe("TeamBilling", () => {
teamBillingDataRepository: mockTeamBillingDataRepository,
billingRepositoryFactory: () => mockBillingRepository,
isTeamBillingEnabled: false,
seatBillingStrategyFactory: mockSeatBillingStrategyFactory,
});
const result = factory.init(mockTeam);
@@ -96,6 +100,7 @@ describe("TeamBilling", () => {
teamBillingDataRepository: mockTeamBillingDataRepository,
billingRepositoryFactory: () => mockBillingRepository,
isTeamBillingEnabled: false,
seatBillingStrategyFactory: mockSeatBillingStrategyFactory,
});
const result = factory.initMany(mockTeams);
@@ -113,6 +118,7 @@ describe("TeamBilling", () => {
teamBillingDataRepository: mockTeamBillingDataRepository,
billingRepositoryFactory: () => mockBillingRepository,
isTeamBillingEnabled: true,
seatBillingStrategyFactory: mockSeatBillingStrategyFactory,
});
vi.mocked(mockTeamBillingDataRepository.find).mockResolvedValue(mockTeam);
@@ -131,6 +137,7 @@ describe("TeamBilling", () => {
teamBillingDataRepository: mockTeamBillingDataRepository,
billingRepositoryFactory: () => mockBillingRepository,
isTeamBillingEnabled: true,
seatBillingStrategyFactory: mockSeatBillingStrategyFactory,
});
vi.mocked(mockTeamBillingDataRepository.findMany).mockResolvedValue([mockTeam, { ...mockTeam, id: 2 }]);
@@ -1,11 +1,13 @@
import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock";
import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments";
import { WEBAPP_URL } from "@calcom/lib/constants";
import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { IBillingRepository } from "../../repository/billing/IBillingRepository";
import { Plan, SubscriptionStatus } from "../../repository/billing/IBillingRepository";
import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository";
import type { IBillingProviderService } from "../billingProvider/IBillingProviderService";
import type { ISeatBillingStrategy } from "../seatBillingStrategy/ISeatBillingStrategy";
import type { SeatBillingStrategyFactory } from "../seatBillingStrategy/SeatBillingStrategyFactory";
import { TeamBillingPublishResponseStatus } from "./ITeamBillingService";
import { TeamBillingService } from "./TeamBillingService";
@@ -21,16 +23,6 @@ vi.mock("@calcom/features/ee/teams/lib/payments", () => ({
purchaseTeamOrOrgSubscription: vi.fn(),
}));
const shouldApplyMonthlyProration = vi.fn().mockResolvedValue(false);
const shouldApplyHighWaterMark = vi.fn().mockResolvedValue(false);
vi.mock("../billingPeriod/BillingPeriodService", () => ({
BillingPeriodService: class {
shouldApplyMonthlyProration = shouldApplyMonthlyProration;
shouldApplyHighWaterMark = shouldApplyHighWaterMark;
},
}));
const mockTeam = {
id: 1,
metadata: {
@@ -77,16 +69,26 @@ const createMockBillingRepository = (): IBillingRepository => ({
create: vi.fn(),
});
function createMockStrategy(): ISeatBillingStrategy {
return { onSeatChange: vi.fn() } as unknown as ISeatBillingStrategy;
}
function createMockFactory(strategy: ISeatBillingStrategy): SeatBillingStrategyFactory {
return { createByTeamId: vi.fn().mockResolvedValue(strategy) } as unknown as SeatBillingStrategyFactory;
}
describe("TeamBillingService", () => {
let mockBillingProviderService: IBillingProviderService;
let mockTeamBillingDataRepository: ITeamBillingDataRepository;
let mockBillingRepository: IBillingRepository;
let defaultResolver: SeatBillingStrategyFactory;
beforeEach(() => {
vi.resetAllMocks();
mockBillingProviderService = createMockBillingProviderService();
mockTeamBillingDataRepository = createMockTeamBillingDataRepository();
mockBillingRepository = createMockBillingRepository();
defaultResolver = createMockFactory(createMockStrategy());
});
afterEach(() => {
@@ -100,6 +102,7 @@ describe("TeamBillingService", () => {
billingProviderService: mockBillingProviderService,
teamBillingDataRepository: mockTeamBillingDataRepository,
billingRepository: mockBillingRepository,
seatBillingStrategyFactory: defaultResolver,
});
await teamBillingService.cancel();
@@ -121,6 +124,7 @@ describe("TeamBillingService", () => {
billingProviderService: mockBillingProviderService,
teamBillingDataRepository: mockTeamBillingDataRepository,
billingRepository: mockBillingRepository,
seatBillingStrategyFactory: defaultResolver,
});
vi.mocked(mockBillingProviderService.checkoutSessionIsPaid).mockResolvedValue(false);
@@ -148,6 +152,7 @@ describe("TeamBillingService", () => {
billingProviderService: mockBillingProviderService,
teamBillingDataRepository: mockTeamBillingDataRepository,
billingRepository: mockBillingRepository,
seatBillingStrategyFactory: defaultResolver,
});
const mockUrl = `${WEBAPP_URL}/api/teams/${mockTeam.id}/upgrade?session_id=cs_789`;
@@ -168,16 +173,17 @@ describe("TeamBillingService", () => {
});
describe("updateQuantity", () => {
it("should update the subscription quantity", async () => {
const mockTeamNotOrg = {
...mockTeam,
isOrganization: false,
};
it("should resolve and delegate to the seat billing strategy", async () => {
const strategy = createMockStrategy();
const resolver = createMockFactory(strategy);
const mockTeamNotOrg = { ...mockTeam, isOrganization: false };
const teamBillingService = new TeamBillingService({
team: mockTeamNotOrg,
billingProviderService: mockBillingProviderService,
teamBillingDataRepository: mockTeamBillingDataRepository,
billingRepository: mockBillingRepository,
seatBillingStrategyFactory: resolver,
});
prismaMock.membership.count.mockResolvedValue(10);
@@ -186,40 +192,43 @@ describe("TeamBillingService", () => {
paymentId: "cs_789",
paymentRequired: false,
});
shouldApplyMonthlyProration.mockResolvedValue(false);
await teamBillingService.updateQuantity();
await teamBillingService.updateQuantity("addition");
expect(mockBillingProviderService.handleSubscriptionUpdate).toHaveBeenCalledWith({
expect(resolver.createByTeamId).toHaveBeenCalledWith(mockTeamNotOrg.id);
expect(strategy.onSeatChange).toHaveBeenCalledWith({
teamId: mockTeamNotOrg.id,
subscriptionId: "sub_123",
subscriptionItemId: "si_456",
membershipCount: 10,
changeType: "addition",
});
});
it("should skip subscription updates when monthly proration applies", async () => {
const mockTeamNotOrg = {
...mockTeam,
isOrganization: false,
};
it("should pass correct membership count to strategy", async () => {
const strategy = createMockStrategy();
const resolver = createMockFactory(strategy);
const teamBillingService = new TeamBillingService({
team: mockTeamNotOrg,
team: mockTeam,
billingProviderService: mockBillingProviderService,
teamBillingDataRepository: mockTeamBillingDataRepository,
billingRepository: mockBillingRepository,
seatBillingStrategyFactory: resolver,
});
prismaMock.membership.count.mockResolvedValue(10);
prismaMock.membership.count.mockResolvedValue(7);
vi.spyOn(teamBillingService, "checkIfTeamPaymentRequired").mockResolvedValue({
url: "http://checkout.url",
paymentId: "cs_789",
paymentRequired: false,
});
shouldApplyMonthlyProration.mockResolvedValue(true);
await teamBillingService.updateQuantity();
await teamBillingService.updateQuantity("removal");
expect(mockBillingProviderService.handleSubscriptionUpdate).not.toHaveBeenCalled();
expect(strategy.onSeatChange).toHaveBeenCalledWith(
expect.objectContaining({ membershipCount: 7, changeType: "removal" })
);
});
});
@@ -237,6 +246,7 @@ describe("TeamBillingService", () => {
billingProviderService: mockBillingProviderService,
teamBillingDataRepository: mockTeamBillingDataRepository,
billingRepository: mockBillingRepository,
seatBillingStrategyFactory: defaultResolver,
});
const result = await teamBillingService.checkIfTeamPaymentRequired();
@@ -250,6 +260,7 @@ describe("TeamBillingService", () => {
billingProviderService: mockBillingProviderService,
teamBillingDataRepository: mockTeamBillingDataRepository,
billingRepository: mockBillingRepository,
seatBillingStrategyFactory: defaultResolver,
});
vi.mocked(mockBillingProviderService.checkoutSessionIsPaid).mockResolvedValue(false);
@@ -265,6 +276,7 @@ describe("TeamBillingService", () => {
billingProviderService: mockBillingProviderService,
teamBillingDataRepository: mockTeamBillingDataRepository,
billingRepository: mockBillingRepository,
seatBillingStrategyFactory: defaultResolver,
});
vi.mocked(mockBillingProviderService.checkoutSessionIsPaid).mockResolvedValue(true);
@@ -310,6 +322,7 @@ describe("TeamBillingService", () => {
billingProviderService: mockBillingProviderService,
teamBillingDataRepository: mockTeamBillingDataRepository,
billingRepository: mockBillingRepository,
seatBillingStrategyFactory: defaultResolver,
});
await teamBillingService.saveTeamBilling(mockBillingArgs);
@@ -348,6 +361,7 @@ describe("TeamBillingService", () => {
billingProviderService: mockBillingProviderService,
teamBillingDataRepository: mockTeamBillingDataRepository,
billingRepository: mockBillingRepository,
seatBillingStrategyFactory: defaultResolver,
});
await teamBillingService.saveTeamBilling(mockBillingArgs);
@@ -386,6 +400,7 @@ describe("TeamBillingService", () => {
billingProviderService: mockBillingProviderService,
teamBillingDataRepository: mockTeamBillingDataRepository,
billingRepository: mockBillingRepository,
seatBillingStrategyFactory: defaultResolver,
});
await teamBillingService.saveTeamBilling(mockBillingArgs);
@@ -427,6 +442,7 @@ describe("TeamBillingService", () => {
billingProviderService: mockBillingProviderService,
teamBillingDataRepository: mockTeamBillingDataRepository,
billingRepository: mockBillingRepository,
seatBillingStrategyFactory: defaultResolver,
});
await expect(teamBillingService.saveTeamBilling(mockBillingArgs)).rejects.toThrow(
@@ -9,15 +9,14 @@ import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils";
import type { z } from "zod";
import { updateSubscriptionQuantity } from "../../lib/subscription-updates";
// import billing from "../..";
import type {
IBillingRepository,
IBillingRepositoryCreateArgs,
} from "../../repository/billing/IBillingRepository";
import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository";
import { BillingPeriodService } from "../billingPeriod/BillingPeriodService";
import type { IBillingProviderService } from "../billingProvider/IBillingProviderService";
import type { SeatChangeType } from "../seatBillingStrategy/ISeatBillingStrategy";
import type { SeatBillingStrategyFactory } from "../seatBillingStrategy/SeatBillingStrategyFactory";
import {
type ITeamBillingService,
type TeamBillingInput,
@@ -35,22 +34,26 @@ export class TeamBillingService implements ITeamBillingService {
private billingProviderService: IBillingProviderService;
private billingRepository: IBillingRepository;
private teamBillingDataRepository: ITeamBillingDataRepository;
private seatBillingStrategyFactory: SeatBillingStrategyFactory;
constructor({
team,
billingProviderService,
teamBillingDataRepository,
billingRepository,
seatBillingStrategyFactory,
}: {
team: TeamBillingInput;
billingProviderService: IBillingProviderService;
teamBillingDataRepository: ITeamBillingDataRepository;
billingRepository: IBillingRepository;
seatBillingStrategyFactory: SeatBillingStrategyFactory;
}) {
this.team = team;
this.billingProviderService = billingProviderService;
this.teamBillingDataRepository = teamBillingDataRepository;
this.billingRepository = billingRepository;
this.seatBillingStrategyFactory = seatBillingStrategyFactory;
}
set team(team: TeamBillingInput) {
const metadata = teamPaymentMetadataSchema.parse(team.metadata || {});
@@ -142,7 +145,7 @@ export class TeamBillingService implements ITeamBillingService {
this.logErrorFromUnknown(error);
}
}
async updateQuantity() {
async updateQuantity(changeType: SeatChangeType) {
try {
await this.getOrgIfNeeded();
const { id: teamId, metadata, isOrganization } = this.team;
@@ -166,30 +169,15 @@ export class TeamBillingService implements ITeamBillingService {
if (!subscriptionId) throw Error("missing subscriptionId");
if (!subscriptionItemId) throw Error("missing subscriptionItemId");
const billingPeriodService = new BillingPeriodService();
const shouldApplyMonthlyProration = await billingPeriodService.shouldApplyMonthlyProration(teamId);
if (shouldApplyMonthlyProration) {
log.info(`Skipping subscription update for team ${teamId} because monthly proration is enabled.`);
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,
const strategy = await this.seatBillingStrategyFactory.createByTeamId(teamId);
await strategy.onSeatChange({
teamId,
subscriptionId,
subscriptionItemId,
quantity: membershipCount,
membershipCount,
changeType,
});
log.info(`Updated subscription ${subscriptionId} for team ${teamId} to ${membershipCount} seats.`);
log.info(`Seat change processed for team ${teamId} (${membershipCount} seats).`);
} catch (error) {
this.logErrorFromUnknown(error);
}
@@ -1,6 +1,7 @@
import type { IBillingRepository } from "../../repository/billing/IBillingRepository";
import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository";
import type { IBillingProviderService } from "../billingProvider/IBillingProviderService";
import type { SeatBillingStrategyFactory } from "../seatBillingStrategy/SeatBillingStrategyFactory";
import type { ITeamBillingService, TeamBillingInput } from "./ITeamBillingService";
import { StubTeamBillingService } from "./StubTeamBillingService";
import { TeamBillingService } from "./TeamBillingService";
@@ -11,6 +12,7 @@ export interface ITeamBillingServiceFactoryDeps {
teamBillingDataRepository: ITeamBillingDataRepository;
billingRepositoryFactory: (isOrganization: boolean) => IBillingRepository;
isTeamBillingEnabled: boolean;
seatBillingStrategyFactory: SeatBillingStrategyFactory;
}
export class TeamBillingServiceFactory {
@@ -31,6 +33,7 @@ export class TeamBillingServiceFactory {
billingProviderService: this.deps.billingProviderService,
teamBillingDataRepository: this.deps.teamBillingDataRepository,
billingRepository,
seatBillingStrategyFactory: this.deps.seatBillingStrategyFactory,
});
}
@@ -1,11 +1,13 @@
import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock";
import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments";
import { WEBAPP_URL } from "@calcom/lib/constants";
import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { IBillingRepository } from "../repository/billing/IBillingRepository";
import { Plan, SubscriptionStatus } from "../repository/billing/IBillingRepository";
import type { ITeamBillingDataRepository } from "../repository/teamBillingData/ITeamBillingDataRepository";
import type { IBillingProviderService } from "../service/billingProvider/IBillingProviderService";
import type { ISeatBillingStrategy } from "../service/seatBillingStrategy/ISeatBillingStrategy";
import type { SeatBillingStrategyFactory } from "../service/seatBillingStrategy/SeatBillingStrategyFactory";
import { TeamBillingPublishResponseStatus } from "../service/teams/ITeamBillingService";
import { TeamBillingService } from "../service/teams/TeamBillingService";
@@ -21,15 +23,14 @@ vi.mock("@calcom/features/ee/teams/lib/payments", () => ({
purchaseTeamOrOrgSubscription: vi.fn(),
}));
const shouldApplyMonthlyProration = vi.fn().mockResolvedValue(false);
const shouldApplyHighWaterMark = vi.fn().mockResolvedValue(false);
function createMockStrategy(): ISeatBillingStrategy {
return { onSeatChange: vi.fn() } as unknown as ISeatBillingStrategy;
}
function createMockFactory(strategy: ISeatBillingStrategy): SeatBillingStrategyFactory {
return { createByTeamId: vi.fn().mockResolvedValue(strategy) } as unknown as SeatBillingStrategyFactory;
}
vi.mock("../service/billingPeriod/BillingPeriodService", () => ({
BillingPeriodService: class {
shouldApplyMonthlyProration = shouldApplyMonthlyProration;
shouldApplyHighWaterMark = shouldApplyHighWaterMark;
},
}));
const mockTeam = {
id: 1,
metadata: {
@@ -46,6 +47,7 @@ describe("TeamBillingService", () => {
let mockBillingProviderService: IBillingProviderService;
let mockTeamBillingDataRepository: ITeamBillingDataRepository;
let mockBillingRepository: IBillingRepository;
let defaultResolver: SeatBillingStrategyFactory;
beforeEach(() => {
vi.clearAllMocks();
@@ -85,11 +87,14 @@ describe("TeamBillingService", () => {
create: vi.fn(),
} as unknown as IBillingRepository;
defaultResolver = createMockFactory(createMockStrategy());
teamBillingService = new TeamBillingService({
team: mockTeam,
billingProviderService: mockBillingProviderService,
teamBillingDataRepository: mockTeamBillingDataRepository,
billingRepository: mockBillingRepository,
seatBillingStrategyFactory: defaultResolver,
});
});
@@ -151,16 +156,17 @@ describe("TeamBillingService", () => {
});
describe("updateQuantity", () => {
it("should update the subscription quantity", async () => {
const mockTeamNotOrg = {
...mockTeam,
isOrganization: false,
};
it("should resolve and delegate to the seat billing strategy", async () => {
const strategy = createMockStrategy();
const resolver = createMockFactory(strategy);
const mockTeamNotOrg = { ...mockTeam, isOrganization: false };
const teamBillingServiceNotOrg = new TeamBillingService({
team: mockTeamNotOrg,
billingProviderService: mockBillingProviderService,
teamBillingDataRepository: mockTeamBillingDataRepository,
billingRepository: mockBillingRepository,
seatBillingStrategyFactory: resolver,
});
prismaMock.membership.count.mockResolvedValue(10);
vi.spyOn(teamBillingServiceNotOrg, "checkIfTeamPaymentRequired").mockResolvedValue({
@@ -168,40 +174,18 @@ describe("TeamBillingService", () => {
paymentId: "cs_789",
paymentRequired: false,
});
shouldApplyMonthlyProration.mockResolvedValue(false);
await teamBillingServiceNotOrg.updateQuantity();
await teamBillingServiceNotOrg.updateQuantity("addition");
expect(mockBillingProviderService.handleSubscriptionUpdate).toHaveBeenCalledWith({
expect(resolver.createByTeamId).toHaveBeenCalledWith(mockTeamNotOrg.id);
expect(strategy.onSeatChange).toHaveBeenCalledWith({
teamId: mockTeamNotOrg.id,
subscriptionId: "sub_123",
subscriptionItemId: "si_456",
membershipCount: 10,
changeType: "addition",
});
});
it("should skip subscription updates when monthly proration applies", async () => {
const mockTeamNotOrg = {
...mockTeam,
isOrganization: false,
};
const teamBillingServiceNotOrg = new TeamBillingService({
team: mockTeamNotOrg,
billingProviderService: mockBillingProviderService,
teamBillingDataRepository: mockTeamBillingDataRepository,
billingRepository: mockBillingRepository,
});
prismaMock.membership.count.mockResolvedValue(10);
vi.spyOn(teamBillingServiceNotOrg, "checkIfTeamPaymentRequired").mockResolvedValue({
url: "http://checkout.url",
paymentId: "cs_789",
paymentRequired: false,
});
shouldApplyMonthlyProration.mockResolvedValue(true);
await teamBillingServiceNotOrg.updateQuantity();
expect(mockBillingProviderService.handleSubscriptionUpdate).not.toHaveBeenCalled();
});
});
describe("checkIfTeamPaymentRequired", () => {
@@ -218,6 +202,7 @@ describe("TeamBillingService", () => {
billingProviderService: mockBillingProviderService,
teamBillingDataRepository: mockTeamBillingDataRepository,
billingRepository: mockBillingRepository,
seatBillingStrategyFactory: defaultResolver,
});
const result = await teamBillingServiceNoPayment.checkIfTeamPaymentRequired();
@@ -232,6 +217,7 @@ describe("TeamBillingService", () => {
billingProviderService: mockBillingProviderService,
teamBillingDataRepository: mockTeamBillingDataRepository,
billingRepository: mockBillingRepository,
seatBillingStrategyFactory: defaultResolver,
});
const result = await teamBillingServiceWithPayment.checkIfTeamPaymentRequired();
@@ -246,6 +232,7 @@ describe("TeamBillingService", () => {
billingProviderService: mockBillingProviderService,
teamBillingDataRepository: mockTeamBillingDataRepository,
billingRepository: mockBillingRepository,
seatBillingStrategyFactory: defaultResolver,
});
const result = await teamBillingServicePaid.checkIfTeamPaymentRequired();
@@ -967,7 +967,7 @@ describe("TeamService.removeMembers Integration Tests", () => {
const mockFactory = getTeamBillingServiceFactory();
expect(mockFactory.findAndInitMany).toHaveBeenCalledWith([regularTeamTestData.team.id]);
const mockInstances = await mockFactory.findAndInitMany([regularTeamTestData.team.id]);
expect(mockInstances[0].updateQuantity).toHaveBeenCalled();
expect(mockInstances[0].updateQuantity).toHaveBeenCalledWith("removal");
});
it("should throw error when membership doesn't exist", async () => {
@@ -115,7 +115,7 @@ describe("TeamService", () => {
userId: 1,
},
});
expect(mockTeamBilling.updateQuantity).toHaveBeenCalled();
expect(mockTeamBilling.updateQuantity).toHaveBeenCalledWith("addition");
expect(result).toBe("Test Team");
});
});
@@ -172,7 +172,7 @@ export class TeamService {
const teamBillingServiceFactory = getTeamBillingServiceFactory();
const teamBillingServices = await teamBillingServiceFactory.findAndInitMany(teamIds);
const teamBillingPromises = teamBillingServices.map((teamBillingService) =>
teamBillingService.updateQuantity()
teamBillingService.updateQuantity("removal")
);
await Promise.allSettled(teamBillingPromises);
}
@@ -230,7 +230,7 @@ export class TeamService {
const teamBillingServiceFactory = getTeamBillingServiceFactory();
const teamBillingService = await teamBillingServiceFactory.findAndInit(verificationToken.teamId);
await teamBillingService.updateQuantity();
await teamBillingService.updateQuantity("addition");
return verificationToken.team.name;
}
+108 -43
View File
@@ -9,14 +9,23 @@ function ensureProtocol(url: string | undefined): string {
return `https://${url}`;
}
const VERCEL_URL = process.env.NEXT_PUBLIC_VERCEL_URL ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` : "";
const RAILWAY_STATIC_URL = process.env.RAILWAY_STATIC_URL ? `https://${process.env.RAILWAY_STATIC_URL}` : "";
const HEROKU_URL = process.env.HEROKU_APP_NAME ? `https://${process.env.HEROKU_APP_NAME}.herokuapp.com` : "";
const RENDER_URL = process.env.RENDER_EXTERNAL_URL ? `https://${process.env.RENDER_EXTERNAL_URL}` : "";
const VERCEL_URL = process.env.NEXT_PUBLIC_VERCEL_URL
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
: "";
const RAILWAY_STATIC_URL = process.env.RAILWAY_STATIC_URL
? `https://${process.env.RAILWAY_STATIC_URL}`
: "";
const HEROKU_URL = process.env.HEROKU_APP_NAME
? `https://${process.env.HEROKU_APP_NAME}.herokuapp.com`
: "";
const RENDER_URL = process.env.RENDER_EXTERNAL_URL
? `https://${process.env.RENDER_EXTERNAL_URL}`
: "";
export const CALCOM_ENV = process.env.CALCOM_ENV || process.env.NODE_ENV;
export const IS_PRODUCTION = CALCOM_ENV === "production";
export const IS_PRODUCTION_BUILD = process.env.NODE_ENV === "production";
export const ORGANIZER_EMAIL_EXEMPT_DOMAINS = process.env.ORGANIZER_EMAIL_EXEMPT_DOMAINS || "";
export const ORGANIZER_EMAIL_EXEMPT_DOMAINS =
process.env.ORGANIZER_EMAIL_EXEMPT_DOMAINS || "";
const IS_DEV = CALCOM_ENV === "development";
export const SINGLE_ORG_SLUG = process.env.NEXT_PUBLIC_SINGLE_ORG_SLUG;
/** https://app.cal.com */
@@ -30,16 +39,21 @@ export const WEBAPP_URL =
// OAuth needs to have HTTPS(which is not generally setup locally) and a valid tld(*.local isn't a valid tld)
// So for development purpose, we would stick to localhost only
export const WEBAPP_URL_FOR_OAUTH = IS_PRODUCTION || IS_DEV ? WEBAPP_URL : "http://localhost:3000";
export const WEBAPP_URL_FOR_OAUTH =
IS_PRODUCTION || IS_DEV ? WEBAPP_URL : "http://localhost:3000";
/** @deprecated use `WEBAPP_URL` */
export const BASE_URL = WEBAPP_URL;
export const WEBSITE_URL = ensureProtocol(process.env.NEXT_PUBLIC_WEBSITE_URL) || "https://cal.com";
export const WEBSITE_URL =
ensureProtocol(process.env.NEXT_PUBLIC_WEBSITE_URL) || "https://cal.com";
export const APP_NAME = process.env.NEXT_PUBLIC_APP_NAME || "Cal.com";
export const SUPPORT_MAIL_ADDRESS = process.env.NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS || "help@cal.com";
export const COMPANY_NAME = process.env.NEXT_PUBLIC_COMPANY_NAME || "Cal.com, Inc.";
export const SUPPORT_MAIL_ADDRESS =
process.env.NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS || "help@cal.com";
export const COMPANY_NAME =
process.env.NEXT_PUBLIC_COMPANY_NAME || "Cal.com, Inc.";
export const SENDER_ID = process.env.NEXT_PUBLIC_SENDER_ID || "Cal";
export const SENDER_NAME = process.env.NEXT_PUBLIC_SENDGRID_SENDER_NAME || "Cal.com";
export const SENDER_NAME =
process.env.NEXT_PUBLIC_SENDGRID_SENDER_NAME || "Cal.com";
export const EMAIL_FROM_NAME = process.env.EMAIL_FROM_NAME || APP_NAME;
// This is the URL from which all Cal Links and their assets are served.
@@ -68,8 +82,11 @@ export const CONSOLE_URL =
: `https://console.cal.com`;
const CAL_DOMAINS = [".cal.com", ".cal.dev", ".cal.eu", ".cal.qa"];
const WEBAPP_HOSTNAME = new URL(WEBAPP_URL).hostname;
export const IS_SELF_HOSTED = !CAL_DOMAINS.some((domain) => WEBAPP_HOSTNAME.endsWith(domain));
export const EMBED_LIB_URL = process.env.NEXT_PUBLIC_EMBED_LIB_URL || `${WEBAPP_URL}/embed/embed.js`;
export const IS_SELF_HOSTED = !CAL_DOMAINS.some((domain) =>
WEBAPP_HOSTNAME.endsWith(domain)
);
export const EMBED_LIB_URL =
process.env.NEXT_PUBLIC_EMBED_LIB_URL || `${WEBAPP_URL}/embed/embed.js`;
export const TRIAL_LIMIT_DAYS = 14;
export const MAX_SEATS_PER_TIME_SLOT = 1000;
@@ -79,16 +96,26 @@ export const MAX_EVENT_DURATION_MINUTES = 1440;
/** Minimum duration allowed for an event in minutes */
export const MIN_EVENT_DURATION_MINUTES = 1;
export const HOSTED_CAL_FEATURES = process.env.NEXT_PUBLIC_HOSTED_CAL_FEATURES || !IS_SELF_HOSTED;
export const HOSTED_CAL_FEATURES =
process.env.NEXT_PUBLIC_HOSTED_CAL_FEATURES || !IS_SELF_HOSTED;
export const PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS =
parseInt(process.env.NEXT_PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS ?? "", 10) || 30;
parseInt(
process.env.NEXT_PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS ?? "",
10
) || 30;
// Must be lower than PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS
export const PUBLIC_QUERY_RESERVATION_STALE_TIME_SECONDS =
parseInt(process.env.NEXT_PUBLIC_QUERY_RESERVATION_STALE_TIME_SECONDS ?? "", 10) || 20;
parseInt(
process.env.NEXT_PUBLIC_QUERY_RESERVATION_STALE_TIME_SECONDS ?? "",
10
) || 20;
export const PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS =
parseInt(process.env.NEXT_PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS ?? "", 10) || 5 * 60;
parseInt(
process.env.NEXT_PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS ?? "",
10
) || 5 * 60;
export const PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM =
process.env.NEXT_PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM === "1";
@@ -97,7 +124,8 @@ export const PUBLIC_QUICK_AVAILABILITY_ROLLOUT =
parseInt(process.env.NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT ?? "", 10) || 0;
/** @deprecated use `WEBAPP_URL` */
export const NEXT_PUBLIC_BASE_URL = process.env.NEXT_PUBLIC_WEBAPP_URL || `https://${process.env.VERCEL_URL}`;
export const NEXT_PUBLIC_BASE_URL =
process.env.NEXT_PUBLIC_WEBAPP_URL || `https://${process.env.VERCEL_URL}`;
export const LOGO = "/calcom-logo-white-word.svg";
export const LOGO_DARK = "/cal-logo-word-black.svg";
export const LOGO_ICON = "/cal-com-icon-white.svg";
@@ -135,10 +163,15 @@ export const IS_TEAM_BILLING_ENABLED_CLIENT =
export const FULL_NAME_LENGTH_MAX_LIMIT = 50;
export const API_NAME_LENGTH_MAX_LIMIT = 80;
export const MINUTES_TO_BOOK = process.env.NEXT_PUBLIC_MINUTES_TO_BOOK || "5";
export const ENABLE_PROFILE_SWITCHER = process.env.NEXT_PUBLIC_ENABLE_PROFILE_SWITCHER === "1";
export const ENABLE_PROFILE_SWITCHER =
process.env.NEXT_PUBLIC_ENABLE_PROFILE_SWITCHER === "1";
// Needed for orgs
export const ALLOWED_HOSTNAMES = JSON.parse(`[${process.env.ALLOWED_HOSTNAMES || ""}]`) as string[];
export const RESERVED_SUBDOMAINS = JSON.parse(`[${process.env.RESERVED_SUBDOMAINS || ""}]`) as string[];
export const ALLOWED_HOSTNAMES = JSON.parse(
`[${process.env.ALLOWED_HOSTNAMES || ""}]`
) as string[];
export const RESERVED_SUBDOMAINS = JSON.parse(
`[${process.env.RESERVED_SUBDOMAINS || ""}]`
) as string[];
export const ORGANIZATION_SELF_SERVE_PRICE = parseFloat(
process.env.NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE_NEW || "37"
@@ -149,15 +182,19 @@ export const IS_MAILHOG_ENABLED = process.env.E2E_TEST_MAILHOG_ENABLED === "1";
export const CALCOM_VERSION = process.env.NEXT_PUBLIC_CALCOM_VERSION as string;
export const APP_CREDENTIAL_SHARING_ENABLED =
!!process.env.CALCOM_CREDENTIAL_SYNC_SECRET && !!process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY;
!!process.env.CALCOM_CREDENTIAL_SYNC_SECRET &&
!!process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY;
export const CREDENTIAL_SYNC_SECRET = process.env.CALCOM_CREDENTIAL_SYNC_SECRET;
export const CREDENTIAL_SYNC_SECRET_HEADER_NAME =
process.env.CALCOM_CREDENTIAL_SYNC_HEADER_NAME || "calcom-credential-sync-secret";
process.env.CALCOM_CREDENTIAL_SYNC_HEADER_NAME ||
"calcom-credential-sync-secret";
export const CREDENTIAL_SYNC_ENDPOINT = process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT;
export const CREDENTIAL_SYNC_ENDPOINT =
process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT;
// Service Account Encryption Key for encrypting/decrypting service account keys
export const SERVICE_ACCOUNT_ENCRYPTION_KEY = process.env.CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY;
export const SERVICE_ACCOUNT_ENCRYPTION_KEY =
process.env.CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY;
export const DEFAULT_LIGHT_BRAND_COLOR = "#292929";
export const DEFAULT_DARK_BRAND_COLOR = "#fafafa";
@@ -173,7 +210,9 @@ export const MAX_NB_INVITES = 100;
export const URL_PROTOCOL_REGEX = /(^\w+:|^)\/\//;
export const IS_VISUAL_REGRESSION_TESTING = Boolean(globalThis.window?.Meticulous?.isRunningAsTest);
export const IS_VISUAL_REGRESSION_TESTING = Boolean(
globalThis.window?.Meticulous?.isRunningAsTest
);
export const BOOKER_NUMBER_OF_DAYS_TO_LOAD = parseInt(
process.env.NEXT_PUBLIC_BOOKER_NUMBER_OF_DAYS_TO_LOAD ?? "0",
@@ -181,15 +220,20 @@ export const BOOKER_NUMBER_OF_DAYS_TO_LOAD = parseInt(
);
export const CLOUDFLARE_SITE_ID = process.env.NEXT_PUBLIC_CLOUDFLARE_SITEKEY;
export const CLOUDFLARE_USE_TURNSTILE_IN_BOOKER = process.env.NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER;
export const ORG_SELF_SERVE_ENABLED = process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED === "1";
export const CLOUDFLARE_USE_TURNSTILE_IN_BOOKER =
process.env.NEXT_PUBLIC_CLOUDFLARE_USE_TURNSTILE_IN_BOOKER;
export const ORG_SELF_SERVE_ENABLED =
process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED === "1";
export const ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE = 0;
export const ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE_HELPER_DIALOGUE = 1;
export const CALCOM_PRIVATE_API_ROUTE = process.env.CALCOM_PRIVATE_API_ROUTE || "https://goblin.cal.com";
export const CALCOM_PRIVATE_API_ROUTE =
process.env.CALCOM_PRIVATE_API_ROUTE || "https://goblin.cal.com";
export const WEBSITE_PRIVACY_POLICY_URL =
process.env.NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL || "https://cal.com/privacy";
export const WEBSITE_TERMS_URL = process.env.NEXT_PUBLIC_WEBSITE_TERMS_URL || "https://cal.com/terms";
process.env.NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL ||
"https://cal.com/privacy";
export const WEBSITE_TERMS_URL =
process.env.NEXT_PUBLIC_WEBSITE_TERMS_URL || "https://cal.com/terms";
export const LINGO_DOT_DEV_API_KEY = process.env.LINGO_DOT_DEV_API_KEY;
/**
@@ -214,36 +258,55 @@ export const RECORDING_IN_PROGRESS_ICON = IS_PRODUCTION
? `${WEBAPP_URL}/stop-recording.svg`
: `https://app.cal.com/stop-recording.svg`;
export const SCOPE_USERINFO_PROFILE = "https://www.googleapis.com/auth/userinfo.profile";
export const SCOPE_USERINFO_EMAIL = "https://www.googleapis.com/auth/userinfo.email";
export const GOOGLE_OAUTH_SCOPES = [SCOPE_USERINFO_PROFILE, SCOPE_USERINFO_EMAIL];
export const SCOPE_USERINFO_PROFILE =
"https://www.googleapis.com/auth/userinfo.profile";
export const SCOPE_USERINFO_EMAIL =
"https://www.googleapis.com/auth/userinfo.email";
export const GOOGLE_OAUTH_SCOPES = [
SCOPE_USERINFO_PROFILE,
SCOPE_USERINFO_EMAIL,
];
export const GOOGLE_CALENDAR_SCOPES = [
"https://www.googleapis.com/auth/calendar.events",
"https://www.googleapis.com/auth/calendar.readonly",
];
export const DIRECTORY_IDS_TO_LOG = process.env.DIRECTORY_IDS_TO_LOG?.split(",") || [];
export const SCANNING_WORKFLOW_STEPS = !!(!IS_SELF_HOSTED && process.env.IFFY_API_KEY);
export const DIRECTORY_IDS_TO_LOG =
process.env.DIRECTORY_IDS_TO_LOG?.split(",") || [];
export const SCANNING_WORKFLOW_STEPS = !!(
!IS_SELF_HOSTED && process.env.IFFY_API_KEY
);
// Cloudflare URL Scanner - checks URLs for malicious content in workflows and event types
export const URL_SCANNING_ENABLED =
!!process.env.CLOUDFLARE_URL_SCANNER_API_TOKEN && !!process.env.CLOUDFLARE_ACCOUNT_ID;
!!process.env.CLOUDFLARE_URL_SCANNER_API_TOKEN &&
!!process.env.CLOUDFLARE_ACCOUNT_ID;
export const IS_DUB_REFERRALS_ENABLED =
!!process.env.NEXT_PUBLIC_DUB_PROGRAM_ID && process.env.NEXT_PUBLIC_DUB_PROGRAM_ID !== "";
!!process.env.NEXT_PUBLIC_DUB_PROGRAM_ID &&
process.env.NEXT_PUBLIC_DUB_PROGRAM_ID !== "";
export const CAL_VIDEO_MEETING_LINK_FOR_TESTING = process.env.CAL_VIDEO_MEETING_LINK_FOR_TESTING;
export const CAL_VIDEO_MEETING_LINK_FOR_TESTING =
process.env.CAL_VIDEO_MEETING_LINK_FOR_TESTING;
export const IS_SMS_CREDITS_ENABLED =
!!process.env.NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID || !!process.env.NEXT_PUBLIC_IS_E2E;
export const DATABASE_CHUNK_SIZE = parseInt(process.env.DATABASE_CHUNK_SIZE || "25", 10);
!!process.env.NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID ||
!!process.env.NEXT_PUBLIC_IS_E2E;
export const DATABASE_CHUNK_SIZE = parseInt(
process.env.DATABASE_CHUNK_SIZE || "25",
10
);
export const NEXTJS_CACHE_TTL = 3600; // 1 hour
export const DEFAULT_GROUP_ID = "default_group_id";
const _rawCalAiPrice = process.env.NEXT_PUBLIC_CAL_AI_PHONE_NUMBER_MONTHLY_PRICE;
const _rawCalAiPrice =
process.env.NEXT_PUBLIC_CAL_AI_PHONE_NUMBER_MONTHLY_PRICE;
export const CAL_AI_PHONE_NUMBER_MONTHLY_PRICE = (() => {
const parsed = _rawCalAiPrice && _rawCalAiPrice.trim() !== "" ? Number(_rawCalAiPrice) : NaN;
const parsed =
_rawCalAiPrice && _rawCalAiPrice.trim() !== ""
? Number(_rawCalAiPrice)
: NaN;
return Number.isFinite(parsed) ? parsed : 5;
})();
@@ -275,4 +338,6 @@ export const ORG_TRIAL_DAYS = process.env.STRIPE_ORG_TRIAL_DAYS
export const IS_API_V2_E2E = process.env.IS_E2E === "true";
export const ENABLE_ASYNC_TASKER =
process.env.ENABLE_ASYNC_TASKER === "true" && !process.env.NEXT_PUBLIC_IS_E2E && !IS_API_V2_E2E;
process.env.ENABLE_ASYNC_TASKER === "true" &&
!process.env.NEXT_PUBLIC_IS_E2E &&
!IS_API_V2_E2E;
@@ -154,7 +154,7 @@ export async function bulkDeleteUsersHandler({ ctx, input }: BulkDeleteUsersHand
const teamBillingServiceFactory = getTeamBillingServiceFactory();
const teamBillingService = await teamBillingServiceFactory.findAndInit(currentUserOrgId);
await teamBillingService.updateQuantity();
await teamBillingService.updateQuantity("removal");
return {
success: true,
@@ -232,7 +232,7 @@ export const inviteMembersWithNoInviterPermissionCheck = async (
const teamBillingServiceFactory = getTeamBillingServiceFactory();
const teamBillingService = teamBillingServiceFactory.init(team);
await teamBillingService.updateQuantity();
await teamBillingService.updateQuantity("addition");
return {
// TODO: Better rename it to invitations only maybe?