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:
co-authored by
unknown <>
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
3908cda372
commit
f4abbb2de1
@@ -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"),
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
+82
@@ -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);
|
||||
}
|
||||
}
|
||||
+191
@@ -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);
|
||||
});
|
||||
});
|
||||
+286
@@ -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
@@ -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?
|
||||
|
||||
Reference in New Issue
Block a user