diff --git a/apps/web/app/api/cron/monthly-proration/route.ts b/apps/web/app/api/cron/monthly-proration/route.ts new file mode 100644 index 00000000000000..82d79c7e8ee6ba --- /dev/null +++ b/apps/web/app/api/cron/monthly-proration/route.ts @@ -0,0 +1,131 @@ +import process from "node:process"; +import { getMonthlyProrationTasker } from "@calcom/features/ee/billing/di/tasker/MonthlyProrationTasker.container"; +import { formatMonthKey } from "@calcom/features/ee/billing/lib/month-key"; +import { MonthlyProrationTeamRepository } from "@calcom/features/ee/billing/repository/proration/MonthlyProrationTeamRepository"; +import { MONTHLY_PRORATION_BATCH_SIZE } from "@calcom/features/ee/billing/service/proration/tasker/constants"; +import { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import { ENABLE_ASYNC_TASKER } from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; +import { prisma } from "@calcom/prisma"; +import { subMonths } from "date-fns"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +const log = logger.getSubLogger({ prefix: ["monthly-proration-cron"] }); + +async function getHandler(request: NextRequest) { + const apiKey = request.headers.get("authorization"); + + if (process.env.CRON_API_KEY !== apiKey) { + return NextResponse.json({ message: "Not authenticated" }, { status: 401 }); + } + + const featuresRepository = new FeaturesRepository(prisma); + const isEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally( + "monthly-proration" + ); + + if (!isEnabled) { + return NextResponse.json({ message: "Monthly proration disabled" }); + } + + const requestedMonthKey = request.nextUrl.searchParams.get("monthKey"); + const monthKeyPattern = /^\d{4}-(0[1-9]|1[0-2])$/; + if (requestedMonthKey && !monthKeyPattern.test(requestedMonthKey)) { + return NextResponse.json( + { message: "Invalid monthKey format. Use YYYY-MM." }, + { status: 400 } + ); + } + + const now = new Date(); + const startOfCurrentMonthUtc = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) + ); + const previousMonthUtc = subMonths(startOfCurrentMonthUtc, 1); + const defaultMonthKey = formatMonthKey(previousMonthUtc); + const monthKey = requestedMonthKey || defaultMonthKey; + + // Validate monthKey is not in the future and is within a reasonable range (12 months) + if (requestedMonthKey) { + const [year, month] = requestedMonthKey.split("-").map(Number); + const requestedDate = new Date(Date.UTC(year, month - 1, 1)); + const twelveMonthsAgo = subMonths(startOfCurrentMonthUtc, 12); + + if (requestedDate >= startOfCurrentMonthUtc) { + return NextResponse.json( + { message: "monthKey cannot be the current month or in the future." }, + { status: 400 } + ); + } + + if (requestedDate < twelveMonthsAgo) { + return NextResponse.json( + { message: "monthKey cannot be more than 12 months in the past." }, + { status: 400 } + ); + } + } + + log.info(`Scheduling monthly proration tasks for ${monthKey}`); + + const teamRepository = new MonthlyProrationTeamRepository(prisma); + const teamIdsList = await teamRepository.getAnnualTeamsWithSeatChanges( + monthKey + ); + + if (teamIdsList.length === 0) { + return NextResponse.json({ + monthKey, + scheduledTeams: 0, + scheduledBatches: 0, + batchSize: MONTHLY_PRORATION_BATCH_SIZE, + }); + } + + const prorationTasker = getMonthlyProrationTasker(); + + const batches: number[][] = []; + for ( + let index = 0; + index < teamIdsList.length; + index += MONTHLY_PRORATION_BATCH_SIZE + ) { + batches.push( + teamIdsList.slice(index, index + MONTHLY_PRORATION_BATCH_SIZE) + ); + } + + log.info( + `Scheduling ${teamIdsList.length} teams in ${batches.length} batches for ${monthKey}` + ); + + const isAsyncTaskerEnabled = + ENABLE_ASYNC_TASKER && + process.env.TRIGGER_SECRET_KEY && + process.env.TRIGGER_API_URL; + + if (isAsyncTaskerEnabled) { + await Promise.all( + batches.map((teamIds) => + prorationTasker.processBatch({ + monthKey, + teamIds, + }) + ) + ); + } else { + for (const teamIds of batches) { + await prorationTasker.processBatch({ monthKey, teamIds }); + } + } + + return NextResponse.json({ + monthKey, + scheduledTeams: teamIdsList.length, + scheduledBatches: batches.length, + batchSize: MONTHLY_PRORATION_BATCH_SIZE, + }); +} + +export const GET = getHandler; diff --git a/apps/web/app/api/teams/[team]/upgrade/route.ts b/apps/web/app/api/teams/[team]/upgrade/route.ts index 57bd3264986b8a..5b0c6d66f44fb2 100644 --- a/apps/web/app/api/teams/[team]/upgrade/route.ts +++ b/apps/web/app/api/teams/[team]/upgrade/route.ts @@ -7,6 +7,10 @@ import type Stripe from "stripe"; import { z } from "zod"; import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing"; +import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing"; +import { getTeamBillingServiceFactory } from "@calcom/ee/billing/di/containers/Billing"; +import { extractBillingDataFromStripeSubscription } from "@calcom/features/ee/billing/lib/stripe-subscription-utils"; +import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/billing/IBillingRepository"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import stripe from "@calcom/features/ee/payments/server/stripe"; import { WEBAPP_URL } from "@calcom/lib/constants"; @@ -86,6 +90,35 @@ async function getHandler(req: NextRequest, { params }: { params: Promise { + const findByStripeSubscriptionIdFn = vi.fn().mockResolvedValue(null); + const prismaMockObj = { + teamBilling: { + findUnique: vi.fn(), + update: vi.fn(), + }, + organizationBilling: { + findUnique: vi.fn(), + update: vi.fn(), + }, + calAiPhoneNumber: { + update: vi.fn(), + }, + }; + return { + findByStripeSubscriptionId: findByStripeSubscriptionIdFn, + prismaMock: prismaMockObj, + }; +}); + +vi.mock("@calcom/features/calAIPhone/repositories/PrismaPhoneNumberRepository", () => { + return { + PrismaPhoneNumberRepository: class { + findByStripeSubscriptionId = findByStripeSubscriptionId; + }, + }; +}); + +vi.mock("@calcom/ee/billing/di/containers/Billing", () => ({ + getBillingProviderService: () => ({ + extractSubscriptionDates: () => ({ + subscriptionStart: new Date("2024-01-01T00:00:00.000Z"), + subscriptionEnd: new Date("2024-12-31T00:00:00.000Z"), + subscriptionTrialEnd: null, + }), + }), +})); + +vi.mock("@calcom/prisma", () => ({ + default: prismaMock, +})); + +describe("customer.subscription.updated webhook", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("updates team billing on renewal", async () => { + prismaMock.teamBilling.findUnique.mockResolvedValue({ id: "tb_1", teamId: 123 }); + prismaMock.organizationBilling.findUnique.mockResolvedValue(null); + + const data = { + object: { + id: "sub_123", + status: "active", + items: { + data: [ + { + quantity: 5, + price: { + unit_amount: 12000, + recurring: { interval: "year" }, + }, + }, + ], + }, + }, + previous_attributes: { + current_period_start: 1690000000, + }, + } as unknown as SWHMap["customer.subscription.updated"]["data"]; + + const result = await handler(data); + + expect(prismaMock.teamBilling.update).toHaveBeenCalledWith({ + where: { id: "tb_1" }, + data: expect.objectContaining({ + billingPeriod: "ANNUALLY", + pricePerSeat: 12000, + paidSeats: 5, + subscriptionStart: new Date("2024-01-01T00:00:00.000Z"), + subscriptionEnd: new Date("2024-12-31T00:00:00.000Z"), + subscriptionTrialEnd: null, + }), + }); + expect(result).toEqual({ + phoneNumber: null, + teamBilling: { success: true, type: "team", teamId: 123 }, + }); + }); +}); diff --git a/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts b/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts index 0621025b0bf865..76eb290f08d23f 100644 --- a/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts +++ b/packages/features/ee/billing/api/webhook/_customer.subscription.updated.ts @@ -1,5 +1,11 @@ +import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing"; import { PrismaPhoneNumberRepository } from "@calcom/features/calAIPhone/repositories/PrismaPhoneNumberRepository"; +import { extractBillingDataFromStripeSubscription } from "@calcom/features/ee/billing/lib/stripe-subscription-utils"; +import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; + +const log = logger.getSubLogger({ prefix: ["subscription-updated-webhook"] }); +import type { Prisma } from "@calcom/prisma/client"; import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums"; import type { SWHMap } from "./__handler"; @@ -9,6 +15,7 @@ type Data = SWHMap["customer.subscription.updated"]["data"]; const handler = async (data: Data) => { const subscription = data.object; + const previousAttributes = data.previous_attributes; if (!subscription.id) { throw new HttpCode(400, "Subscription ID not found"); @@ -19,11 +26,16 @@ const handler = async (data: Data) => { stripeSubscriptionId: subscription.id, }); - if (!phoneNumber) { - throw new HttpCode(202, "Phone number not found"); - } + const phoneNumberResult = phoneNumber + ? await handleCalAIPhoneNumberSubscriptionUpdate(subscription, phoneNumber) + : null; + + const teamBillingResult = await handleTeamBillingRenewal(subscription, previousAttributes); - return await handleCalAIPhoneNumberSubscriptionUpdate(subscription, phoneNumber); + return { + phoneNumber: phoneNumberResult, + teamBilling: teamBillingResult, + }; }; type Subscription = Data["object"]; @@ -58,4 +70,68 @@ async function handleCalAIPhoneNumberSubscriptionUpdate( return { success: true, subscriptionId: subscription.id, status: subscriptionStatus }; } +async function handleTeamBillingRenewal( + subscription: Subscription, + previousAttributes: Data["previous_attributes"] +) { + if (!previousAttributes?.current_period_start) { + return { skipped: true, reason: "not a renewal" }; + } + + const billingProviderService = getBillingProviderService(); + const { subscriptionStart, subscriptionEnd, subscriptionTrialEnd } = + billingProviderService.extractSubscriptionDates(subscription); + + const { billingPeriod, pricePerSeat, paidSeats } = extractBillingDataFromStripeSubscription(subscription); + + const teamBilling = await prisma.teamBilling.findUnique({ + where: { subscriptionId: subscription.id }, + }); + + if (teamBilling) { + const teamBillingUpdate = { + paidSeats: paidSeats ?? null, + subscriptionStart, + subscriptionEnd, + subscriptionTrialEnd, + billingPeriod, + pricePerSeat: pricePerSeat ?? null, + } as Prisma.TeamBillingUpdateInput; + + await prisma.teamBilling.update({ + where: { id: teamBilling.id }, + data: teamBillingUpdate, + }); + return { success: true, type: "team", teamId: teamBilling.teamId }; + } + + const orgBilling = await prisma.organizationBilling.findUnique({ + where: { subscriptionId: subscription.id }, + }); + + if (orgBilling) { + const organizationBillingUpdate = { + paidSeats: paidSeats ?? null, + subscriptionStart, + subscriptionEnd, + subscriptionTrialEnd, + billingPeriod, + pricePerSeat: pricePerSeat ?? null, + } as Prisma.OrganizationBillingUpdateInput; + + await prisma.organizationBilling.update({ + where: { id: orgBilling.id }, + data: organizationBillingUpdate, + }); + return { success: true, type: "organization", teamId: orgBilling.teamId }; + } + + log.warn("Subscription renewal received but no billing record found", { + subscriptionId: subscription.id, + customerId: typeof subscription.customer === "string" ? subscription.customer : subscription.customer?.id, + }); + + return { skipped: true, reason: "no billing record found" }; +} + export default handler; diff --git a/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts b/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts index 2558804303e227..b6ce4090adcf7c 100644 --- a/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts +++ b/packages/features/ee/billing/api/webhook/_invoice.paid.org.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { getBillingProviderService } from "@calcom/ee/billing/di/containers/Billing"; +import { extractBillingDataFromStripeSubscription } from "@calcom/features/ee/billing/lib/stripe-subscription-utils"; import { Plan, SubscriptionStatus } from "@calcom/features/ee/billing/repository/billing/IBillingRepository"; import { BillingEnabledOrgOnboardingService } from "@calcom/features/ee/organizations/lib/service/onboarding/BillingEnabledOrgOnboardingService"; import stripe from "@calcom/features/ee/payments/server/stripe"; @@ -124,7 +125,11 @@ const handler = async (data: SWHMap["invoice.paid"]["data"]) => { // Get the Stripe subscription object const stripeSubscription = await stripe.subscriptions.retrieve(paymentSubscriptionId); const billingService = getBillingProviderService(); - const { subscriptionStart } = billingService.extractSubscriptionDates(stripeSubscription); + const { subscriptionStart, subscriptionEnd, subscriptionTrialEnd } = + billingService.extractSubscriptionDates(stripeSubscription); + + const { billingPeriod, pricePerSeat, paidSeats } = + extractBillingDataFromStripeSubscription(stripeSubscription); const teamBillingServiceFactory = getTeamBillingServiceFactory(); const teamBillingService = teamBillingServiceFactory.init(organization); @@ -137,6 +142,11 @@ const handler = async (data: SWHMap["invoice.paid"]["data"]) => { status: SubscriptionStatus.ACTIVE, planName: Plan.ORGANIZATION, subscriptionStart, + subscriptionEnd, + subscriptionTrialEnd, + billingPeriod, + pricePerSeat, + paidSeats, }); logger.debug(`Marking onboarding as complete for organization ${organization.id}`); diff --git a/packages/features/ee/billing/api/webhook/_invoice.payment_failed.test.ts b/packages/features/ee/billing/api/webhook/_invoice.payment_failed.test.ts new file mode 100644 index 00000000000000..33ef977e389f58 --- /dev/null +++ b/packages/features/ee/billing/api/webhook/_invoice.payment_failed.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { buildMonthlyProrationMetadata } from "../../lib/proration-utils"; +import type { SWHMap } from "./__handler"; +import handler from "./_invoice.payment_failed"; + +const handleProrationPaymentFailure = vi.fn(); +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; + }, +})); + +describe("invoice.payment_failed webhook", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("records proration failure with payment intent reason", async () => { + const data = { + object: { + payment_intent: "pi_123", + status: "open", + lines: { + data: [ + { + metadata: buildMonthlyProrationMetadata({ prorationId: "pr_123" }), + }, + ], + }, + }, + } as unknown as SWHMap["invoice.payment_failed"]["data"]; + + const result = await handler(data); + + expect(getPaymentIntentFailureReason).toHaveBeenCalledWith("pi_123"); + expect(handleProrationPaymentFailure).toHaveBeenCalledWith({ + prorationId: "pr_123", + reason: "card_declined", + }); + expect(result).toEqual({ success: true }); + }); +}); diff --git a/packages/features/ee/billing/api/webhook/_invoice.payment_failed.ts b/packages/features/ee/billing/api/webhook/_invoice.payment_failed.ts new file mode 100644 index 00000000000000..f28eaad672fafa --- /dev/null +++ b/packages/features/ee/billing/api/webhook/_invoice.payment_failed.ts @@ -0,0 +1,48 @@ +import { getBillingProviderService } 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"] }); + +type Data = SWHMap["invoice.payment_failed"]["data"]; + +const handler = async (data: Data) => { + const invoice = data.object; + + const prorationLineItem = findMonthlyProrationLineItem(invoice.lines.data); + + if (!prorationLineItem) { + return { success: true, message: "no proration line items in 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; + + if (paymentIntentId) { + const billingProviderService = getBillingProviderService(); + const paymentFailureReason = await billingProviderService.getPaymentIntentFailureReason(paymentIntentId); + failureReason = paymentFailureReason ?? failureReason; + } + + await prorationService.handleProrationPaymentFailure({ + prorationId, + reason: failureReason, + }); + + log.info(`proration ${prorationId} marked as failed`); + + return { success: true }; +}; + +export default handler; diff --git a/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.test.ts b/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.test.ts new file mode 100644 index 00000000000000..2ebc00da4e5e0d --- /dev/null +++ b/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { buildMonthlyProrationMetadata } from "../../lib/proration-utils"; +import type { SWHMap } from "./__handler"; +import handler from "./_invoice.payment_succeeded"; + +const handleProrationPaymentSuccess = vi.fn(); + +vi.mock("../../service/proration/MonthlyProrationService", () => ({ + MonthlyProrationService: class { + handleProrationPaymentSuccess = handleProrationPaymentSuccess; + }, +})); + +describe("invoice.payment_succeeded webhook", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("marks proration as charged when line item is present", async () => { + const data = { + object: { + lines: { + data: [ + { + metadata: buildMonthlyProrationMetadata({ prorationId: "pr_123" }), + }, + ], + }, + }, + } as unknown as SWHMap["invoice.payment_succeeded"]["data"]; + + const result = await handler(data); + + expect(handleProrationPaymentSuccess).toHaveBeenCalledWith("pr_123"); + expect(result).toEqual({ success: true }); + }); + + it("skips when no proration line item exists", async () => { + const data = { + object: { + lines: { + data: [ + { + metadata: { + type: "other", + }, + }, + ], + }, + }, + } as unknown as SWHMap["invoice.payment_succeeded"]["data"]; + + const result = await handler(data); + + expect(handleProrationPaymentSuccess).not.toHaveBeenCalled(); + expect(result).toEqual({ success: true, message: "no proration line items in invoice" }); + }); +}); diff --git a/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.ts b/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.ts new file mode 100644 index 00000000000000..d7a4b448ade982 --- /dev/null +++ b/packages/features/ee/billing/api/webhook/_invoice.payment_succeeded.ts @@ -0,0 +1,35 @@ +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"] }); + +type Data = SWHMap["invoice.payment_succeeded"]["data"]; + +const handler = async (data: Data) => { + const invoice = data.object; + + const prorationLineItem = findMonthlyProrationLineItem(invoice.lines.data); + + if (!prorationLineItem) { + return { success: true, message: "no proration line items in 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(); + + await prorationService.handleProrationPaymentSuccess(prorationId); + + log.info(`proration ${prorationId} marked as charged`); + + return { success: true }; +}; + +export default handler; diff --git a/packages/features/ee/billing/api/webhook/index.ts b/packages/features/ee/billing/api/webhook/index.ts index 44729974614fc8..74346405e54e22 100644 --- a/packages/features/ee/billing/api/webhook/index.ts +++ b/packages/features/ee/billing/api/webhook/index.ts @@ -9,6 +9,8 @@ const handlers = { "customer.subscription.deleted": () => import("./_customer.subscription.deleted"), "customer.subscription.updated": () => import("./_customer.subscription.updated"), "invoice.paid": () => import("./_invoice.paid"), + "invoice.payment_failed": () => import("./_invoice.payment_failed"), + "invoice.payment_succeeded": () => import("./_invoice.payment_succeeded"), "checkout.session.completed": () => import("./_checkout.session.completed"), }; diff --git a/packages/features/ee/billing/lib/proration-utils.ts b/packages/features/ee/billing/lib/proration-utils.ts new file mode 100644 index 00000000000000..a981de1d133ca6 --- /dev/null +++ b/packages/features/ee/billing/lib/proration-utils.ts @@ -0,0 +1,30 @@ +export const MONTHLY_PRORATION_METADATA_TYPE = "monthly_proration"; + +type MetadataContainer = { + metadata?: Record | null; +}; + +export function buildMonthlyProrationMetadata(params: { + prorationId: string; + teamId?: number; + monthKey?: string; +}): Record { + const metadata: Record = { + type: MONTHLY_PRORATION_METADATA_TYPE, + prorationId: params.prorationId, + }; + + if (typeof params.teamId === "number") { + metadata.teamId = params.teamId.toString(); + } + + if (params.monthKey) { + metadata.monthKey = params.monthKey; + } + + return metadata; +} + +export function findMonthlyProrationLineItem(lineItems: T[]): T | undefined { + return lineItems.find((line) => line.metadata?.type === MONTHLY_PRORATION_METADATA_TYPE); +} diff --git a/packages/features/ee/billing/lib/stripe-subscription-utils.ts b/packages/features/ee/billing/lib/stripe-subscription-utils.ts index 36fe396a414795..d0725979459fb0 100644 --- a/packages/features/ee/billing/lib/stripe-subscription-utils.ts +++ b/packages/features/ee/billing/lib/stripe-subscription-utils.ts @@ -1,23 +1,62 @@ -import type Stripe from "stripe"; import type { BillingPeriod } from "@calcom/prisma/enums"; +type SubscriptionItemLike = { + id: string; + quantity?: number | null; + price: { + unit_amount: number | null; + recurring: { interval: string } | null; + }; +}; + +type SubscriptionItems = SubscriptionItemLike[] | { data: SubscriptionItemLike[] }; + +export interface StripeSubscriptionLike { + items: SubscriptionItems; + current_period_start: number; + current_period_end: number; + trial_end: number | null; +} + export interface BillingData { billingPeriod: BillingPeriod; pricePerSeat: number | undefined; paidSeats: number | undefined; + subscriptionStart: Date | undefined; + subscriptionEnd: Date | undefined; + subscriptionTrialEnd: Date | undefined; } -export function extractBillingDataFromStripeSubscription(subscription: Stripe.Subscription): BillingData { +const getSubscriptionItems = (subscription: StripeSubscriptionLike) => + Array.isArray(subscription.items) ? subscription.items : subscription.items.data; + +export function extractBillingDataFromStripeSubscription(subscription: StripeSubscriptionLike): BillingData { + const subscriptionItems = getSubscriptionItems(subscription); + const primaryItem = subscriptionItems[0]; + const billingPeriod: BillingPeriod = - subscription.items.data[0]?.price.recurring?.interval === "year" ? "ANNUALLY" : "MONTHLY"; + primaryItem?.price.recurring?.interval === "year" ? "ANNUALLY" : "MONTHLY"; + + const pricePerSeat = primaryItem?.price.unit_amount ?? undefined; + + const paidSeats = primaryItem?.quantity ?? undefined; + + const subscriptionStart = subscription.current_period_start + ? new Date(subscription.current_period_start * 1000) + : undefined; - const pricePerSeat = subscription.items.data[0]?.price.unit_amount; + const subscriptionEnd = subscription.current_period_end + ? new Date(subscription.current_period_end * 1000) + : undefined; - const paidSeats = subscription.items.data[0]?.quantity; + const subscriptionTrialEnd = subscription.trial_end ? new Date(subscription.trial_end * 1000) : undefined; return { billingPeriod, pricePerSeat, paidSeats, + subscriptionStart, + subscriptionEnd, + subscriptionTrialEnd, }; } diff --git a/packages/features/ee/billing/lib/subscription-updates.ts b/packages/features/ee/billing/lib/subscription-updates.ts new file mode 100644 index 00000000000000..a9980da0134201 --- /dev/null +++ b/packages/features/ee/billing/lib/subscription-updates.ts @@ -0,0 +1,30 @@ +import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service"; + +import type { IBillingProviderService } from "../service/billingProvider/IBillingProviderService"; + +type ProrationBehavior = "none" | "create_prorations" | "always_invoice"; + +export async function updateSubscriptionQuantity(params: { + billingService: IBillingProviderService; + subscriptionId: string; + subscriptionItemId: string; + quantity: number; + prorationBehavior?: ProrationBehavior; + logger?: ISimpleLogger; +}): Promise { + const { billingService, subscriptionId, subscriptionItemId, quantity, prorationBehavior, logger } = params; + + try { + await billingService.handleSubscriptionUpdate({ + subscriptionId, + subscriptionItemId, + membershipCount: quantity, + ...(prorationBehavior ? { prorationBehavior } : {}), + }); + } catch (error) { + if (logger) { + logger.error(`Failed to update subscription ${subscriptionId} quantity to ${quantity}:`, error); + } + throw error; + } +} diff --git a/packages/features/ee/billing/repository/proration/MonthlyProrationTeamRepository.ts b/packages/features/ee/billing/repository/proration/MonthlyProrationTeamRepository.ts index cb49d0cc712f9f..7e128a2ae46c8e 100644 --- a/packages/features/ee/billing/repository/proration/MonthlyProrationTeamRepository.ts +++ b/packages/features/ee/billing/repository/proration/MonthlyProrationTeamRepository.ts @@ -70,7 +70,9 @@ export class MonthlyProrationTeamRepository { if (!team) return null; - let billing: BillingInfo | null = team.isOrganization ? team.organizationBilling : team.teamBilling; + let billing: BillingInfo | null = team.isOrganization + ? team.organizationBilling + : team.teamBilling; if (!billing) { billing = this.extractBillingFromMetadata(team.metadata); @@ -162,6 +164,19 @@ export class MonthlyProrationTeamRepository { } } + async getTeamMemberCount(teamId: number): Promise { + const team = await this.prisma.team.findUnique({ + where: { id: teamId }, + select: { + _count: { + select: { members: true }, + }, + }, + }); + + return team?._count.members ?? null; + } + async createOrganizationBilling(data: { teamId: number; subscriptionId: string; diff --git a/packages/features/ee/billing/repository/seatChangeLogs/SeatChangeLogRepository.ts b/packages/features/ee/billing/repository/seatChangeLogs/SeatChangeLogRepository.ts index 8162ad692fcae6..576e41c3e42301 100644 --- a/packages/features/ee/billing/repository/seatChangeLogs/SeatChangeLogRepository.ts +++ b/packages/features/ee/billing/repository/seatChangeLogs/SeatChangeLogRepository.ts @@ -10,6 +10,7 @@ export interface CreateSeatChangeLogData { userId?: number; triggeredBy?: number; monthKey: string; + operationId?: string; metadata?: Prisma.InputJsonValue; teamBillingId: string | null; organizationBillingId: string | null; @@ -28,6 +29,20 @@ export class SeatChangeLogRepository { } async create(data: CreateSeatChangeLogData): Promise { + // If operationId is provided, use upsert to prevent duplicates + if (data.operationId) { + return await this.prisma.seatChangeLog.upsert({ + where: { + teamId_operationId: { + teamId: data.teamId, + operationId: data.operationId, + }, + }, + create: data, + update: {}, // No update needed - if it exists, we skip + }); + } + return await this.prisma.seatChangeLog.create({ data, }); diff --git a/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts b/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts index b4b4ca43b21f8b..9dc3680f02a7e9 100644 --- a/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts +++ b/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts @@ -1,6 +1,6 @@ import type Stripe from "stripe"; -import { SubscriptionStatus } from "../../repository/billing/IBillingRepository"; +import type { SubscriptionStatus } from "../../repository/billing/IBillingRepository"; export interface IBillingProviderService { checkoutSessionIsPaid(paymentId: string): Promise; @@ -10,6 +10,7 @@ export interface IBillingProviderService { subscriptionId: string; subscriptionItemId: string; membershipCount: number; + prorationBehavior?: "none" | "create_prorations" | "always_invoice"; }): Promise; handleEndTrial(subscriptionId: string): Promise; @@ -72,17 +73,31 @@ export interface IBillingProviderService { amount: number; currency: string; description: string; + subscriptionId?: string; + invoiceId?: string; metadata?: Record; }): Promise<{ invoiceItemId: string }>; + deleteInvoiceItem(invoiceItemId: string): Promise; + createInvoice(args: { customerId: string; autoAdvance: boolean; + collectionMethod?: "charge_automatically" | "send_invoice"; + daysUntilDue?: number; + pendingInvoiceItemsBehavior?: "exclude" | "include"; + subscriptionId?: string; metadata?: Record; }): Promise<{ invoiceId: string }>; finalizeInvoice(invoiceId: string): Promise; + voidInvoice(invoiceId: string): Promise; + + getPaymentIntentFailureReason(paymentIntentId: string): Promise; + + hasDefaultPaymentMethod(args: { customerId: string; subscriptionId?: string }): Promise; + // Subscription queries getSubscription(subscriptionId: string): Promise<{ items: Array<{ diff --git a/packages/features/ee/billing/service/billingProvider/StripeBillingService.test.ts b/packages/features/ee/billing/service/billingProvider/StripeBillingService.test.ts index 60acf3689c1b5b..1d2475e7476fb2 100644 --- a/packages/features/ee/billing/service/billingProvider/StripeBillingService.test.ts +++ b/packages/features/ee/billing/service/billingProvider/StripeBillingService.test.ts @@ -1,5 +1,5 @@ import type Stripe from "stripe"; -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { StripeBillingService } from "./StripeBillingService"; @@ -45,6 +45,15 @@ describe("StripeBillingService", () => { expect(stripeMock.subscriptions.update).toHaveBeenCalledWith(args.subscriptionId, { items: [{ quantity: args.membershipCount, id: args.subscriptionItemId }], }); + + await stripeBillingService.handleSubscriptionUpdate({ + ...args, + prorationBehavior: "none", + }); + expect(stripeMock.subscriptions.update).toHaveBeenCalledWith(args.subscriptionId, { + items: [{ quantity: args.membershipCount, id: args.subscriptionItemId }], + proration_behavior: "none", + }); }); it("should throw an error if subscription item is not found", async () => { diff --git a/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts b/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts index ec4d2ec09cc969..7b077d4416d899 100644 --- a/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts +++ b/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts @@ -1,10 +1,11 @@ -import type Stripe from "stripe"; - import logger from "@calcom/lib/logger"; +import type Stripe from "stripe"; import { SubscriptionStatus } from "../../repository/billing/IBillingRepository"; import type { IBillingProviderService } from "./IBillingProviderService"; +const log = logger.getSubLogger({ prefix: ["StripeBillingService"] }); + export class StripeBillingService implements IBillingProviderService { constructor(private stripe: Stripe) {} @@ -138,7 +139,7 @@ export class StripeBillingService implements IBillingProviderService { } async handleSubscriptionUpdate(args: Parameters[0]) { - const { subscriptionId, subscriptionItemId, membershipCount } = args; + const { subscriptionId, subscriptionItemId, membershipCount, prorationBehavior } = args; const subscription = await this.stripe.subscriptions.retrieve(subscriptionId); const subscriptionQuantity = subscription.items.data.find( (sub) => sub.id === subscriptionItemId @@ -146,6 +147,7 @@ export class StripeBillingService implements IBillingProviderService { if (!subscriptionQuantity) throw new Error("Subscription not found"); await this.stripe.subscriptions.update(subscriptionId, { items: [{ quantity: membershipCount, id: subscriptionItemId }], + ...(prorationBehavior ? { proration_behavior: prorationBehavior } : {}), }); } @@ -245,23 +247,44 @@ export class StripeBillingService implements IBillingProviderService { } async createInvoiceItem(args: Parameters[0]) { - const { customerId, amount, currency, description, metadata } = args; + const { customerId, amount, currency, description, subscriptionId, invoiceId, metadata } = args; const invoiceItem = await this.stripe.invoiceItems.create({ customer: customerId, amount, currency, description, + invoice: invoiceId, + subscription: subscriptionId, metadata, }); return { invoiceItemId: invoiceItem.id }; } + async deleteInvoiceItem(invoiceItemId: string) { + await this.stripe.invoiceItems.del(invoiceItemId); + } + async createInvoice(args: Parameters[0]) { - const { customerId, autoAdvance, metadata } = args; + const { + customerId, + autoAdvance, + collectionMethod, + daysUntilDue, + pendingInvoiceItemsBehavior, + subscriptionId, + metadata, + } = args; const invoice = await this.stripe.invoices.create({ customer: customerId, auto_advance: autoAdvance, + collection_method: collectionMethod, + ...(pendingInvoiceItemsBehavior && !subscriptionId + ? { pending_invoice_items_behavior: pendingInvoiceItemsBehavior } + : {}), + subscription: subscriptionId, + // days_until_due is required for send_invoice collection method + ...(collectionMethod === "send_invoice" && { days_until_due: daysUntilDue ?? 30 }), metadata, }); @@ -272,6 +295,60 @@ export class StripeBillingService implements IBillingProviderService { await this.stripe.invoices.finalizeInvoice(invoiceId); } + async voidInvoice(invoiceId: string) { + try { + const invoice = await this.stripe.invoices.retrieve(invoiceId); + // Can only void invoices that are open or uncollectible + if (invoice.status === "open" || invoice.status === "uncollectible") { + await this.stripe.invoices.voidInvoice(invoiceId); + } else if (invoice.status === "draft") { + // Delete draft invoices instead of voiding + await this.stripe.invoices.del(invoiceId); + } + // If paid or void, no action needed + } catch (error) { + log.warn("Failed to void invoice", { invoiceId, error }); + throw error; + } + } + + async getPaymentIntentFailureReason(paymentIntentId: string) { + try { + const paymentIntent = await this.stripe.paymentIntents.retrieve(paymentIntentId); + return paymentIntent.last_payment_error?.message ?? null; + } catch (error) { + log.warn("Failed to retrieve payment intent failure reason", { paymentIntentId, error }); + return null; + } + } + + async hasDefaultPaymentMethod(args: Parameters[0]) { + const { customerId, subscriptionId } = args; + const subscription = subscriptionId ? await this.stripe.subscriptions.retrieve(subscriptionId) : null; + + const subscriptionDefault = subscription + ? typeof subscription.default_payment_method === "string" + ? subscription.default_payment_method + : subscription.default_payment_method?.id + : null; + + if (subscriptionDefault) { + return true; + } + + const customer = await this.stripe.customers.retrieve(customerId); + if (customer.deleted) { + return false; + } + + const customerDefault = + typeof customer.invoice_settings?.default_payment_method === "string" + ? customer.invoice_settings.default_payment_method + : customer.invoice_settings?.default_payment_method?.id; + + return Boolean(customerDefault); + } + async getSubscription(subscriptionId: string) { const subscription = await this.stripe.subscriptions.retrieve(subscriptionId); if (!subscription) return null; diff --git a/packages/features/ee/billing/service/proration/MonthlyProrationService.ts b/packages/features/ee/billing/service/proration/MonthlyProrationService.ts index a987e7c973508f..0590b6655bfdf4 100644 --- a/packages/features/ee/billing/service/proration/MonthlyProrationService.ts +++ b/packages/features/ee/billing/service/proration/MonthlyProrationService.ts @@ -2,6 +2,9 @@ import stripe from "@calcom/features/ee/payments/server/stripe"; import type { ISimpleLogger } from "@calcom/features/di/shared/services/logger.service"; import logger from "@calcom/lib/logger"; import type { Logger } from "tslog"; +import { buildMonthlyProrationMetadata } from "../../lib/proration-utils"; +import { extractBillingDataFromStripeSubscription } from "../../lib/stripe-subscription-utils"; +import { updateSubscriptionQuantity } from "../../lib/subscription-updates"; import { MonthlyProrationRepository } from "../../repository/proration/MonthlyProrationRepository"; import type { BillingInfo } from "../../repository/proration/MonthlyProrationTeamRepository"; import { MonthlyProrationTeamRepository } from "../../repository/proration/MonthlyProrationTeamRepository"; @@ -56,6 +59,15 @@ export class MonthlyProrationService { const { monthKey, teamIds } = params; const teamIdsList = teamIds || (await this.teamRepository.getAnnualTeamsWithSeatChanges(monthKey)); + const teamIdsPreview = teamIds && teamIds.length <= 25 ? teamIds : undefined; + const teamIdsTruncated = teamIds ? teamIds.length > 25 : false; + + this.logger.info("Monthly proration batch started", { + monthKey, + teamCount: teamIdsList.length, + teamIds: teamIdsPreview, + teamIdsTruncated, + }); const teamsToProcess = teamIdsList.map((id: number) => ({ id })); @@ -68,18 +80,34 @@ export class MonthlyProrationService { if (result) results.push(result); } + this.logger.info("Monthly proration batch finished", { + monthKey, + teamCount: teamIdsList.length, + processedCount: results.length, + skippedCount: teamIdsList.length - results.length, + }); + return results; } async createProrationForTeam(params: CreateProrationParams) { const { teamId, monthKey } = params; + this.logger.info(`[${teamId}] starting monthly proration`, { monthKey }); + const seatTracker = new SeatChangeTrackingService(); const changes = await seatTracker.getMonthlyChanges({ teamId, monthKey }); + this.logger.info(`[${teamId}] seat changes`, { + additions: changes.additions, + removals: changes.removals, + netChange: changes.netChange, + }); + // If there are no changes at all (no additions or removals), skip processing if (changes.additions === 0 && changes.removals === 0) { + this.logger.info(`[${teamId}] no seat changes, skipping proration`); return null; } @@ -110,6 +138,12 @@ export class MonthlyProrationService { const paidSeats = billing.paidSeats ?? (await this.getSubscriptionQuantity(billing.subscriptionId)); + this.logger.info(`[${teamId}] billing summary`, { + billingPeriod: billing.billingPeriod, + pricePerSeat: billing.pricePerSeat, + paidSeats, + }); + const [year, month] = monthKey.split("-").map(Number); const periodEnd = new Date(Date.UTC(year, month, 0, 23, 59, 59, 999)); @@ -121,6 +155,13 @@ export class MonthlyProrationService { monthEnd: periodEnd, }); + this.logger.info(`[${teamId}] proration calculation`, { + netSeatIncrease: changes.netChange, + pricePerSeat: billing.pricePerSeat, + remainingDays: calculation.remainingDays, + proratedAmount: calculation.proratedAmount, + }); + const proration = await this.prorationRepository.createProration({ teamId, monthKey, @@ -143,6 +184,14 @@ export class MonthlyProrationService { organizationBillingId: teamWithBilling.isOrganization ? billing.id : null, }); + this.logger.info(`[${teamId}] proration record created`, { + prorationId: proration.id, + seatsAdded: proration.seatsAdded, + seatsRemoved: proration.seatsRemoved, + netSeatIncrease: proration.netSeatIncrease, + status: proration.status, + }); + await seatTracker.markAsProcessed({ teamId, monthKey, @@ -150,15 +199,32 @@ export class MonthlyProrationService { }); if (calculation.proratedAmount > 0) { + this.logger.info(`[${teamId}] creating invoice item`, { + prorationId: proration.id, + proratedAmount: proration.proratedAmount, + }); const updatedProration = await this.createStripeInvoiceItem(proration); + this.logger.info(`[${teamId}] invoice processed`, { + prorationId: updatedProration.id, + status: updatedProration.status, + invoiceId: updatedProration.invoiceId, + }); return updatedProration; } - await this.updateSubscriptionQuantity( - proration.subscriptionId, - proration.subscriptionItemId, - proration.seatsAtEnd - ); + this.logger.info(`[${teamId}] no charge required, updating subscription quantity`, { + prorationId: proration.id, + seatsAtEnd: proration.seatsAtEnd, + }); + + await updateSubscriptionQuantity({ + billingService: this.billingService, + subscriptionId: proration.subscriptionId, + subscriptionItemId: proration.subscriptionItemId, + quantity: proration.seatsAtEnd, + prorationBehavior: "none", + logger: this.logger, + }); await this.teamRepository.updatePaidSeats( teamId, @@ -167,9 +233,17 @@ export class MonthlyProrationService { proration.seatsAtEnd ); - return await this.prorationRepository.updateProrationStatus(proration.id, "CHARGED", { + const updatedProration = await this.prorationRepository.updateProrationStatus(proration.id, "CHARGED", { chargedAt: new Date(), }); + + this.logger.info(`[${teamId}] subscription updated without charge`, { + prorationId: updatedProration.id, + status: updatedProration.status, + seatsAtEnd: updatedProration.seatsAtEnd, + }); + + return updatedProration; } private calculateProration(params: { @@ -207,9 +281,16 @@ export class MonthlyProrationService { netSeatIncrease: number; monthKey: string; teamId: number; + subscriptionId: string; + invoiceId?: string | null; }) { const amountInCents = Math.round(proration.proratedAmount); + const hasDefaultPaymentMethod = await this.billingService.hasDefaultPaymentMethod({ + customerId: proration.customerId, + subscriptionId: proration.subscriptionId, + }); + const { invoiceItemId } = await this.billingService.createInvoiceItem({ customerId: proration.customerId, amount: amountInCents, @@ -217,35 +298,92 @@ export class MonthlyProrationService { description: `Additional ${proration.netSeatIncrease} seat${ proration.netSeatIncrease > 1 ? "s" : "" } for ${proration.monthKey}`, - metadata: { - type: "monthly_proration", + subscriptionId: proration.subscriptionId, + metadata: buildMonthlyProrationMetadata({ prorationId: proration.id, - teamId: proration.teamId.toString(), + teamId: proration.teamId, monthKey: proration.monthKey, - }, + }), }); - const { invoiceId } = await this.billingService.createInvoice({ - customerId: proration.customerId, - autoAdvance: true, - metadata: { - type: "monthly_proration", - prorationId: proration.id, - }, - }); + let invoiceId: string | null = null; + let invoiceFinalized = false; + + try { + const invoice = await this.billingService.createInvoice({ + customerId: proration.customerId, + autoAdvance: true, + collectionMethod: hasDefaultPaymentMethod ? "charge_automatically" : "send_invoice", + subscriptionId: proration.subscriptionId, + metadata: buildMonthlyProrationMetadata({ prorationId: proration.id }), + }); - await this.billingService.finalizeInvoice(invoiceId); + invoiceId = invoice.invoiceId; - const updatedProration = await this.prorationRepository.updateProrationStatus( - proration.id, - "INVOICE_CREATED", - { + await this.billingService.finalizeInvoice(invoiceId); + invoiceFinalized = true; + + return await this.prorationRepository.updateProrationStatus( + proration.id, + hasDefaultPaymentMethod ? "INVOICE_CREATED" : "PENDING", + { + invoiceItemId, + invoiceId, + } + ); + } catch (error) { + await this.handleInvoiceCreationFailure({ + error, + prorationId: proration.id, + invoiceId, invoiceItemId, + invoiceFinalized, + }); + throw error; + } + } + + private async handleInvoiceCreationFailure(params: { + error: unknown; + prorationId: string; + invoiceId: string | null; + invoiceItemId: string; + invoiceFinalized: boolean; + }) { + const { error, prorationId, invoiceId, invoiceItemId, invoiceFinalized } = params; + + if (invoiceFinalized) { + // Invoice is live and potentially being charged - don't clean up + // The webhook will handle payment success/failure + this.logger.error("Proration status update failed after invoice finalized - webhook will handle", { + prorationId, invoiceId, - } - ); + error, + }); + return; + } - return updatedProration; + // Invoice not yet live - mark as FAILED to enable retry via retryFailedProration() + const failureReason = error instanceof Error ? error.message : "Invoice creation failed"; + try { + await this.prorationRepository.updateProrationStatus(prorationId, "FAILED", { + failedAt: new Date(), + failureReason, + }); + } catch (statusError) { + this.logger.error("Failed to update proration status to FAILED", { prorationId, error: statusError }); + } + + // Clean up Stripe artifacts + try { + if (invoiceId) { + await this.billingService.voidInvoice(invoiceId); + } else { + await this.billingService.deleteInvoiceItem(invoiceItemId); + } + } catch (cleanupError) { + this.logger.error("Failed to clean up Stripe artifacts", { prorationId, invoiceId, invoiceItemId, error: cleanupError }); + } } async handleProrationPaymentSuccess(prorationId: string) { @@ -258,38 +396,23 @@ export class MonthlyProrationService { chargedAt: new Date(), }); - await this.updateSubscriptionQuantity( - proration.subscriptionId, - proration.subscriptionItemId, - proration.seatsAtEnd - ); + // Use the seat count that was captured when the proration was created. + // Any member changes after proration creation will be captured in the next month's cycle. + const seatsToApply = proration.seatsAtEnd; + + await updateSubscriptionQuantity({ + billingService: this.billingService, + subscriptionId: proration.subscriptionId, + subscriptionItemId: proration.subscriptionItemId, + quantity: seatsToApply, + prorationBehavior: "none", + logger: this.logger, + }); const billingId = proration.teamBillingId || proration.organizationBillingId; if (billingId) { const isOrganization = !!proration.organizationBillingId; - await this.teamRepository.updatePaidSeats( - proration.teamId, - isOrganization, - billingId, - proration.seatsAtEnd - ); - } - } - - private async updateSubscriptionQuantity( - subscriptionId: string, - subscriptionItemId: string, - quantity: number - ): Promise { - try { - await this.billingService.handleSubscriptionUpdate({ - subscriptionId, - subscriptionItemId, - membershipCount: quantity, - }); - } catch (error) { - this.logger.error(`Failed to update subscription ${subscriptionId} quantity to ${quantity}:`, error); - throw error; + await this.teamRepository.updatePaidSeats(proration.teamId, isOrganization, billingId, seatsToApply); } } @@ -312,6 +435,11 @@ export class MonthlyProrationService { if (!proration) throw new Error(`Proration ${prorationId} not found`); if (proration.status !== "FAILED") throw new Error(`Proration ${prorationId} is not in FAILED status`); + // Void the old invoice to prevent double charging + if (proration.invoiceId) { + await this.billingService.voidInvoice(proration.invoiceId); + } + await this.createStripeInvoiceItem(proration); } @@ -337,25 +465,21 @@ export class MonthlyProrationService { throw new Error(`Subscription ${billing.subscriptionId} not found`); } - const billingPeriod: "ANNUALLY" | "MONTHLY" = - subscription.items[0]?.price.recurring?.interval === "year" ? "ANNUALLY" : "MONTHLY"; - - const pricePerSeat = subscription.items[0]?.price.unit_amount ?? 0; - - const subscriptionStart = subscription.current_period_start - ? new Date(subscription.current_period_start * 1000) - : null; - - const subscriptionEnd = subscription.current_period_end - ? new Date(subscription.current_period_end * 1000) - : null; - + const { + billingPeriod, + pricePerSeat: rawPricePerSeat, + paidSeats: rawPaidSeats, + subscriptionStart, + subscriptionEnd, + subscriptionTrialEnd, + } = extractBillingDataFromStripeSubscription(subscription); + + const pricePerSeat = rawPricePerSeat ?? 0; + const paidSeats = rawPaidSeats ?? 0; const subscriptionItemId = subscription.items[0]?.id || billing.subscriptionItemId || ""; const customerId = subscription.customer; - const subscriptionTrialEnd = subscription.trial_end ? new Date(subscription.trial_end * 1000) : null; if (needsCreation) { - const paidSeats = subscription.items[0]?.quantity || 0; const billingData = { teamId, subscriptionId: billing.subscriptionId, diff --git a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts index fdd5670e3d2ca9..4718da95bcc293 100644 --- a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts +++ b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts @@ -1,13 +1,17 @@ +import { TeamService } from "@calcom/features/ee/teams/services/teamService"; import prisma from "@calcom/prisma"; import type { Team, User } from "@calcom/prisma/client"; import { MembershipRole } from "@calcom/prisma/enums"; +import { createMemberships } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { buildMonthlyProrationMetadata } from "../../../lib/proration-utils"; import type { IBillingProviderService } from "../../billingProvider/IBillingProviderService"; import { SeatChangeTrackingService } from "../../seatTracking/SeatChangeTrackingService"; import { MonthlyProrationService } from "../MonthlyProrationService"; const mockBillingService: IBillingProviderService = { createInvoiceItem: vi.fn().mockResolvedValue({ invoiceItemId: "ii_test_123" }), + deleteInvoiceItem: vi.fn().mockResolvedValue(undefined), createInvoice: vi.fn().mockResolvedValue({ invoiceId: "in_test_123" }), finalizeInvoice: vi.fn().mockResolvedValue(undefined), getSubscription: vi.fn().mockResolvedValue({ @@ -38,11 +42,14 @@ const mockBillingService: IBillingProviderService = { getCustomer: vi.fn().mockResolvedValue(null), getSubscriptions: vi.fn().mockResolvedValue(null), updateCustomer: vi.fn().mockResolvedValue(undefined), + getPaymentIntentFailureReason: vi.fn().mockResolvedValue(null), + hasDefaultPaymentMethod: vi.fn().mockResolvedValue(true), } as IBillingProviderService; describe("MonthlyProrationService Integration Tests", () => { let testUser: User; let testTeam: Team; + let billingCustomerId: string; const monthKey = "2026-01"; beforeEach(async () => { @@ -78,12 +85,14 @@ describe("MonthlyProrationService Integration Tests", () => { const subscriptionEnd = new Date("2026-06-01T00:00:00Z"); const subscriptionTrialEnd = new Date("2025-06-08T00:00:00Z"); + billingCustomerId = `cus_test_${timestamp}`; + await prisma.teamBilling.create({ data: { teamId: testTeam.id, subscriptionId: `sub_test_${timestamp}`, subscriptionItemId: `si_test_${timestamp}`, - customerId: `cus_test_${timestamp}`, + customerId: billingCustomerId, billingPeriod: "ANNUALLY", pricePerSeat: 12000, paidSeats: 0, @@ -97,21 +106,50 @@ describe("MonthlyProrationService Integration Tests", () => { }); it("should process end-to-end proration for annual team with seat additions", async () => { - const seatTracker = new SeatChangeTrackingService(); const prorationService = new MonthlyProrationService(undefined, mockBillingService); + const timestamp = Date.now(); + const randomSuffix = Math.random().toString(36).substring(7); - await seatTracker.logSeatAddition({ + vi.mocked(mockBillingService.createInvoice).mockClear(); + vi.mocked(mockBillingService.createInvoiceItem).mockClear(); + + const newMembers = await Promise.all( + [0, 1, 2].map((index) => + prisma.user.create({ + data: { + email: `test-proration-member-${timestamp}-${randomSuffix}-${index}@example.com`, + username: `testprorationmember-${timestamp}-${randomSuffix}-${index}`, + name: `Test Proration Member ${index}`, + }, + select: { + id: true, + email: true, + username: true, + identityProvider: true, + completedOnboarding: true, + }, + }) + ) + ); + + await createMemberships({ teamId: testTeam.id, - userId: testUser.id, - triggeredBy: testUser.id, - seatCount: 3, + language: "en", + invitees: newMembers.map((member) => ({ + ...member, + profiles: [], + teams: [], + password: null, + newRole: MembershipRole.MEMBER, + needToCreateOrgMembership: false, + })), + parentId: null, + accepted: true, }); - await seatTracker.logSeatRemoval({ + await TeamService.leaveTeamMembership({ teamId: testTeam.id, - userId: testUser.id, - triggeredBy: testUser.id, - seatCount: 1, + userId: newMembers[0].id, }); const proration = await prorationService.createProrationForTeam({ @@ -125,6 +163,13 @@ describe("MonthlyProrationService Integration Tests", () => { expect(proration?.seatsRemoved).toBe(1); expect(proration?.status).toBe("INVOICE_CREATED"); expect(proration?.invoiceItemId).toBe("ii_test_123"); + expect(mockBillingService.createInvoice).toHaveBeenCalledWith({ + customerId: billingCustomerId, + autoAdvance: true, + collectionMethod: "charge_automatically", + subscriptionId: proration!.subscriptionId, + metadata: buildMonthlyProrationMetadata({ prorationId: proration!.id }), + }); const seatChanges = await prisma.seatChangeLog.findMany({ where: { teamId: testTeam.id, monthKey }, @@ -321,6 +366,7 @@ describe("MonthlyProrationService Integration Tests", () => { subscriptionId: proration!.subscriptionId, subscriptionItemId: proration!.subscriptionItemId, membershipCount: proration!.seatsAtEnd, + prorationBehavior: "none", }); }); diff --git a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts index a037fdec30e7e0..1662e982513277 100644 --- a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts +++ b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts @@ -1,7 +1,7 @@ import { prisma } from "@calcom/prisma"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { buildMonthlyProrationMetadata } from "../../../lib/proration-utils"; import type { IBillingProviderService } from "../../billingProvider/IBillingProviderService"; - import { MonthlyProrationService } from "../MonthlyProrationService"; vi.mock("@calcom/prisma", () => ({ @@ -59,6 +59,7 @@ const mockTeamRepository = { getTeamWithBilling: vi.fn(), getAnnualTeamsWithSeatChanges: vi.fn(), updatePaidSeats: vi.fn(), + getTeamMemberCount: vi.fn(), }; const mockProrationRepository = { @@ -69,6 +70,7 @@ const mockProrationRepository = { const mockBillingService: IBillingProviderService = { createInvoiceItem: vi.fn().mockResolvedValue({ invoiceItemId: "ii_test_123" }), + deleteInvoiceItem: vi.fn().mockResolvedValue(undefined), createInvoice: vi.fn().mockResolvedValue({ invoiceId: "in_test_123" }), finalizeInvoice: vi.fn().mockResolvedValue(undefined), getSubscription: vi.fn().mockResolvedValue({ @@ -98,6 +100,9 @@ const mockBillingService: IBillingProviderService = { getCustomer: vi.fn().mockResolvedValue(null), getSubscriptions: vi.fn().mockResolvedValue(null), updateCustomer: vi.fn().mockResolvedValue(undefined), + getPaymentIntentFailureReason: vi.fn().mockResolvedValue(null), + hasDefaultPaymentMethod: vi.fn().mockResolvedValue(true), + voidInvoice: vi.fn().mockResolvedValue(undefined), } as IBillingProviderService; vi.mock("../../../repository/proration/MonthlyProrationTeamRepository", () => ({ @@ -105,6 +110,7 @@ vi.mock("../../../repository/proration/MonthlyProrationTeamRepository", () => ({ getTeamWithBilling = mockTeamRepository.getTeamWithBilling; getAnnualTeamsWithSeatChanges = mockTeamRepository.getAnnualTeamsWithSeatChanges; updatePaidSeats = mockTeamRepository.updatePaidSeats; + getTeamMemberCount = mockTeamRepository.getTeamMemberCount; }, })); @@ -182,6 +188,7 @@ describe("MonthlyProrationService", () => { subscriptionId: "sub_123", subscriptionItemId: "si_123", membershipCount: 10, + prorationBehavior: "none", }); }); @@ -238,6 +245,134 @@ describe("MonthlyProrationService", () => { expect(mockBillingService.finalizeInvoice).toHaveBeenCalled(); }); + it("should charge only for net additions when removals exist", async () => { + const subscriptionStart = new Date("2026-01-01"); + const subscriptionEnd = new Date("2027-01-01"); + + const { SeatChangeTrackingService } = await import("../../seatTracking/SeatChangeTrackingService"); + vi.spyOn(SeatChangeTrackingService.prototype, "getMonthlyChanges").mockResolvedValueOnce({ + additions: 20, + removals: 10, + netChange: 10, + }); + vi.spyOn(SeatChangeTrackingService.prototype, "markAsProcessed").mockResolvedValueOnce(10); + + mockTeamRepository.getTeamWithBilling.mockResolvedValueOnce({ + id: 1, + isOrganization: false, + memberCount: 120, + billing: { + id: "team-billing-999", + subscriptionId: "sub_999", + subscriptionItemId: "si_999", + customerId: "cus_999", + billingPeriod: "ANNUALLY", + pricePerSeat: 10000, + subscriptionStart, + subscriptionEnd, + paidSeats: 110, + }, + }); + + mockProrationRepository.createProration.mockResolvedValueOnce({ + id: "proration-999", + customerId: "cus_999", + proratedAmount: 25000, + netSeatIncrease: 10, + monthKey: "2026-01", + teamId: 1, + subscriptionId: "sub_999", + subscriptionItemId: "si_999", + seatsAtEnd: 120, + } as any); + + mockProrationRepository.updateProrationStatus.mockResolvedValueOnce({ + id: "proration-999", + status: "INVOICE_CREATED", + } as any); + + await service.createProrationForTeam({ + teamId: 1, + monthKey: "2026-01", + }); + + expect(mockProrationRepository.createProration).toHaveBeenCalledWith( + expect.objectContaining({ + seatsAdded: 20, + seatsRemoved: 10, + netSeatIncrease: 10, + seatsAtStart: 110, + seatsAtEnd: 120, + }) + ); + expect(mockBillingService.createInvoiceItem).toHaveBeenCalledWith( + expect.objectContaining({ + subscriptionId: "sub_999", + }) + ); + expect(mockBillingService.createInvoice).toHaveBeenCalled(); + }); + + it("should send invoice when no default payment method exists", async () => { + const subscriptionStart = new Date("2026-01-01"); + const subscriptionEnd = new Date("2027-01-01"); + + const { SeatChangeTrackingService } = await import("../../seatTracking/SeatChangeTrackingService"); + vi.spyOn(SeatChangeTrackingService.prototype, "markAsProcessed").mockResolvedValueOnce(2); + vi.mocked(mockBillingService.hasDefaultPaymentMethod).mockResolvedValueOnce(false); + + mockTeamRepository.getTeamWithBilling.mockResolvedValueOnce({ + id: 1, + isOrganization: false, + memberCount: 12, + billing: { + id: "team-billing-789", + subscriptionId: "sub_789", + subscriptionItemId: "si_789", + customerId: "cus_789", + billingPeriod: "ANNUALLY", + pricePerSeat: 10000, + subscriptionStart, + subscriptionEnd, + paidSeats: 10, + }, + }); + + mockProrationRepository.createProration.mockResolvedValueOnce({ + id: "proration-789", + customerId: "cus_789", + proratedAmount: 5000, + netSeatIncrease: 2, + monthKey: "2026-01", + teamId: 1, + subscriptionId: "sub_789", + subscriptionItemId: "si_789", + seatsAtEnd: 12, + } as any); + + mockProrationRepository.updateProrationStatus.mockResolvedValueOnce({ + id: "proration-789", + status: "PENDING", + } as any); + + await service.createProrationForTeam({ + teamId: 1, + monthKey: "2026-01", + }); + + expect(mockBillingService.createInvoice).toHaveBeenCalledWith({ + customerId: "cus_789", + autoAdvance: true, + collectionMethod: "send_invoice", + subscriptionId: "sub_789", + metadata: buildMonthlyProrationMetadata({ prorationId: "proration-789" }), + }); + expect(mockProrationRepository.updateProrationStatus).toHaveBeenCalledWith("proration-789", "PENDING", { + invoiceItemId: "ii_test_123", + invoiceId: "in_test_123", + }); + }); + it("should use organization billing for organizations", async () => { const { SeatChangeTrackingService } = await import("../../seatTracking/SeatChangeTrackingService"); vi.spyOn(SeatChangeTrackingService.prototype, "markAsProcessed").mockResolvedValueOnce(3); @@ -309,7 +444,7 @@ describe("MonthlyProrationService", () => { }); describe("handleProrationPaymentSuccess", () => { - it("should update proration status to CHARGED", async () => { + it("should update proration status to CHARGED using seatsAtEnd from proration record", async () => { mockProrationRepository.findById.mockResolvedValueOnce({ id: "proration-123", status: "INVOICE_CREATED", @@ -328,11 +463,14 @@ describe("MonthlyProrationService", () => { expect(mockProrationRepository.updateProrationStatus).toHaveBeenCalledWith("proration-123", "CHARGED", { chargedAt: expect.any(Date), }); + // Should use seatsAtEnd from proration record, not current member count expect(mockBillingService.handleSubscriptionUpdate).toHaveBeenCalledWith({ subscriptionId: "sub_123", subscriptionItemId: "si_123", membershipCount: 13, + prorationBehavior: "none", }); + expect(mockTeamRepository.updatePaidSeats).toHaveBeenCalledWith(1, false, "billing-123", 13); }); }); diff --git a/packages/features/ee/billing/service/proration/tasker/trigger/scheduleMonthlyProration.ts b/packages/features/ee/billing/service/proration/tasker/trigger/scheduleMonthlyProration.ts new file mode 100644 index 00000000000000..31a694d0fc309d --- /dev/null +++ b/packages/features/ee/billing/service/proration/tasker/trigger/scheduleMonthlyProration.ts @@ -0,0 +1,77 @@ +import { schedules } from "@trigger.dev/sdk"; + +import { monthlyProrationTaskConfig } from "./config"; + +export const scheduleMonthlyProration = schedules.task({ + id: "billing.monthly-proration.schedule", + ...monthlyProrationTaskConfig, + cron: { + pattern: "0 0 1 * *", + timezone: "UTC", + }, + run: async () => { + const { subMonths } = await import("date-fns"); + const { TriggerDevLogger } = await import("@calcom/lib/triggerDevLogger"); + const { formatMonthKey } = await import("@calcom/features/ee/billing/lib/month-key"); + const { MonthlyProrationTeamRepository } = await import( + "@calcom/features/ee/billing/repository/proration/MonthlyProrationTeamRepository" + ); + const { getFeaturesRepository } = await import("@calcom/features/di/containers/FeaturesRepository"); + const { MONTHLY_PRORATION_BATCH_SIZE } = await import("../constants"); + const { processMonthlyProrationBatch } = await import("./processMonthlyProrationBatch"); + + const triggerDevLogger = new TriggerDevLogger(); + const log = triggerDevLogger.getSubLogger({ name: "MonthlyProrationSchedule" }); + + const featuresRepository = getFeaturesRepository(); + const isEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("monthly-proration"); + + if (!isEnabled) { + log.info("Monthly proration feature is disabled"); + return { status: "disabled" }; + } + + const now = new Date(); + const startOfCurrentMonthUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + const previousMonthUtc = subMonths(startOfCurrentMonthUtc, 1); + const monthKey = formatMonthKey(previousMonthUtc); + + log.info(`Scheduling monthly proration tasks for ${monthKey}`); + + const teamRepository = new MonthlyProrationTeamRepository(); + const teamIdsList = await teamRepository.getAnnualTeamsWithSeatChanges(monthKey); + + if (teamIdsList.length === 0) { + log.info(`No teams with seat changes found for ${monthKey}`); + return { + monthKey, + scheduledTeams: 0, + scheduledBatches: 0, + batchSize: MONTHLY_PRORATION_BATCH_SIZE, + }; + } + + const batches: number[][] = []; + for (let index = 0; index < teamIdsList.length; index += MONTHLY_PRORATION_BATCH_SIZE) { + batches.push(teamIdsList.slice(index, index + MONTHLY_PRORATION_BATCH_SIZE)); + } + + log.info(`Scheduling ${teamIdsList.length} teams in ${batches.length} batches for ${monthKey}`); + + await Promise.all( + batches.map((teamIds) => + processMonthlyProrationBatch.trigger({ + monthKey, + teamIds, + }) + ) + ); + + return { + monthKey, + scheduledTeams: teamIdsList.length, + scheduledBatches: batches.length, + batchSize: MONTHLY_PRORATION_BATCH_SIZE, + }; + }, +}); diff --git a/packages/features/ee/billing/service/seatTracking/SeatChangeTrackingService.ts b/packages/features/ee/billing/service/seatTracking/SeatChangeTrackingService.ts index 03b9a83678d25e..c6e405d309018b 100644 --- a/packages/features/ee/billing/service/seatTracking/SeatChangeTrackingService.ts +++ b/packages/features/ee/billing/service/seatTracking/SeatChangeTrackingService.ts @@ -1,9 +1,9 @@ -import type { Logger } from "tslog"; - +import { formatMonthKey } from "@calcom/features/ee/billing/lib/month-key"; import { SeatChangeLogRepository } from "@calcom/features/ee/billing/repository/seatChangeLogs/SeatChangeLogRepository"; import logger from "@calcom/lib/logger"; import type { Prisma } from "@calcom/prisma/client"; import type { SeatChangeType } from "@calcom/prisma/enums"; +import type { Logger } from "tslog"; const log = logger.getSubLogger({ prefix: ["SeatChangeTrackingService"] }); @@ -14,6 +14,9 @@ export interface SeatChangeLogParams { seatCount?: number; metadata?: Prisma.InputJsonValue; monthKey?: string; + // Idempotency key to prevent duplicate seat change logs from race conditions + // Format: "{source}-{uniqueId}" e.g., "membership-123" or "invite-abc" + operationId?: string; } export interface MonthlyChanges { @@ -32,8 +35,16 @@ export class SeatChangeTrackingService { } async logSeatAddition(params: SeatChangeLogParams): Promise { - const { teamId, userId, triggeredBy, seatCount = 1, metadata, monthKey: providedMonthKey } = params; - const monthKey = providedMonthKey || this.calculateMonthKey(new Date()); + const { + teamId, + userId, + triggeredBy, + seatCount = 1, + metadata, + monthKey: providedMonthKey, + operationId, + } = params; + const monthKey = providedMonthKey || formatMonthKey(new Date()); const { teamBillingId, organizationBillingId } = await this.repository.getTeamBillingIds(teamId); @@ -44,6 +55,7 @@ export class SeatChangeTrackingService { userId, triggeredBy, monthKey, + operationId, metadata: (metadata || {}) as Prisma.InputJsonValue, teamBillingId, organizationBillingId, @@ -51,8 +63,16 @@ export class SeatChangeTrackingService { } async logSeatRemoval(params: SeatChangeLogParams): Promise { - const { teamId, userId, triggeredBy, seatCount = 1, metadata, monthKey: providedMonthKey } = params; - const monthKey = providedMonthKey || this.calculateMonthKey(new Date()); + const { + teamId, + userId, + triggeredBy, + seatCount = 1, + metadata, + monthKey: providedMonthKey, + operationId, + } = params; + const monthKey = providedMonthKey || formatMonthKey(new Date()); const { teamBillingId, organizationBillingId } = await this.repository.getTeamBillingIds(teamId); @@ -63,6 +83,7 @@ export class SeatChangeTrackingService { userId, triggeredBy, monthKey, + operationId, metadata: (metadata || {}) as Prisma.InputJsonValue, teamBillingId, organizationBillingId, @@ -94,10 +115,4 @@ export class SeatChangeTrackingService { return await this.repository.markAsProcessed({ teamId, monthKey, prorationId }); } - - private calculateMonthKey(date: Date): string { - const year = date.getUTCFullYear(); - const month = String(date.getUTCMonth() + 1).padStart(2, "0"); - return `${year}-${month}`; - } } diff --git a/packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts b/packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts index 8a7b7c596b52a0..aac1582bbb8640 100644 --- a/packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts +++ b/packages/features/ee/billing/service/teams/TeamBillingFactory.test.ts @@ -19,19 +19,31 @@ describe("TeamBilling", () => { const createMockBillingProviderService = (): IBillingProviderService => ({ handleSubscriptionCancel: vi.fn(), handleSubscriptionUpdate: vi.fn(), + handleSubscriptionCreation: vi.fn(), checkoutSessionIsPaid: vi.fn(), getSubscriptionStatus: vi.fn(), handleEndTrial: vi.fn(), createCustomer: vi.fn(), + createPaymentIntent: vi.fn(), + createSubscriptionCheckout: vi.fn(), createPrice: vi.fn(), getPrice: vi.fn(), getCheckoutSession: vi.fn(), - createCheckoutSession: vi.fn(), + getCustomer: vi.fn(), + getSubscriptions: vi.fn(), + updateCustomer: vi.fn(), + createInvoiceItem: vi.fn(), + deleteInvoiceItem: vi.fn(), + createInvoice: vi.fn(), + finalizeInvoice: vi.fn(), + getSubscription: vi.fn(), + getPaymentIntentFailureReason: vi.fn(), }); const createMockTeamBillingDataRepository = (): ITeamBillingDataRepository => ({ find: vi.fn(), findMany: vi.fn(), + findBySubscriptionId: vi.fn(), }); const createMockBillingRepository = (): IBillingRepository => ({ diff --git a/packages/features/ee/billing/service/teams/TeamBillingService.test.ts b/packages/features/ee/billing/service/teams/TeamBillingService.test.ts index 6e6605697e2c72..09aec002c83190 100644 --- a/packages/features/ee/billing/service/teams/TeamBillingService.test.ts +++ b/packages/features/ee/billing/service/teams/TeamBillingService.test.ts @@ -1,11 +1,9 @@ -import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock"; - -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; - 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 { TeamBillingPublishResponseStatus } from "./ITeamBillingService"; @@ -23,6 +21,14 @@ vi.mock("@calcom/features/ee/teams/lib/payments", () => ({ purchaseTeamOrOrgSubscription: vi.fn(), })); +const shouldApplyMonthlyProration = vi.fn().mockResolvedValue(false); + +vi.mock("../billingPeriod/BillingPeriodService", () => ({ + BillingPeriodService: class { + shouldApplyMonthlyProration = shouldApplyMonthlyProration; + }, +})); + const mockTeam = { id: 1, metadata: { @@ -37,18 +43,32 @@ const mockTeam = { const createMockBillingProviderService = (): IBillingProviderService => ({ handleSubscriptionCancel: vi.fn(), handleSubscriptionUpdate: vi.fn(), + handleSubscriptionCreation: vi.fn(), checkoutSessionIsPaid: vi.fn(), getSubscriptionStatus: vi.fn(), handleEndTrial: vi.fn(), createCustomer: vi.fn(), + createPaymentIntent: vi.fn(), + createSubscriptionCheckout: vi.fn(), createPrice: vi.fn(), getPrice: vi.fn(), getCheckoutSession: vi.fn(), - createCheckoutSession: vi.fn(), + getCustomer: vi.fn(), + getSubscriptions: vi.fn(), + updateCustomer: vi.fn(), + createInvoiceItem: vi.fn(), + deleteInvoiceItem: vi.fn(), + createInvoice: vi.fn(), + finalizeInvoice: vi.fn(), + getSubscription: vi.fn(), + getPaymentIntentFailureReason: vi.fn(), + hasDefaultPaymentMethod: vi.fn(), }); const createMockTeamBillingDataRepository = (): ITeamBillingDataRepository => ({ find: vi.fn(), + findMany: vi.fn(), + findBySubscriptionId: vi.fn(), }); const createMockBillingRepository = (): IBillingRepository => ({ @@ -164,6 +184,7 @@ describe("TeamBillingService", () => { paymentId: "cs_789", paymentRequired: false, }); + shouldApplyMonthlyProration.mockResolvedValue(false); await teamBillingService.updateQuantity(); @@ -173,6 +194,31 @@ describe("TeamBillingService", () => { membershipCount: 10, }); }); + + it("should skip subscription updates when monthly proration applies", async () => { + const mockTeamNotOrg = { + ...mockTeam, + isOrganization: false, + }; + const teamBillingService = new TeamBillingService({ + team: mockTeamNotOrg, + billingProviderService: mockBillingProviderService, + teamBillingDataRepository: mockTeamBillingDataRepository, + billingRepository: mockBillingRepository, + }); + + prismaMock.membership.count.mockResolvedValue(10); + vi.spyOn(teamBillingService, "checkIfTeamPaymentRequired").mockResolvedValue({ + url: "http://checkout.url", + paymentId: "cs_789", + paymentRequired: false, + }); + shouldApplyMonthlyProration.mockResolvedValue(true); + + await teamBillingService.updateQuantity(); + + expect(mockBillingProviderService.handleSubscriptionUpdate).not.toHaveBeenCalled(); + }); }); describe("checkIfTeamPaymentRequired", () => { @@ -244,8 +290,8 @@ describe("TeamBillingService", () => { subscriptionId: "sub_org_123", subscriptionItemId: "si_org_123", customerId: "cus_org_123", - planName: "ORGANIZATION" as const, - status: "ACTIVE" as const, + planName: Plan.ORGANIZATION, + status: SubscriptionStatus.ACTIVE, }; const mockCreatedRecord = { @@ -282,8 +328,8 @@ describe("TeamBillingService", () => { subscriptionId: "sub_team_456", subscriptionItemId: "si_team_456", customerId: "cus_team_456", - planName: "TEAM" as const, - status: "ACTIVE" as const, + planName: Plan.TEAM, + status: SubscriptionStatus.ACTIVE, }; const mockCreatedRecord = { @@ -320,8 +366,8 @@ describe("TeamBillingService", () => { subscriptionId: "sub_detailed_789", subscriptionItemId: "si_detailed_789", customerId: "cus_detailed_789", - planName: "ENTERPRISE" as const, - status: "TRIALING" as const, + planName: Plan.ENTERPRISE, + status: SubscriptionStatus.TRIALING, }; const mockCreatedRecord = { @@ -348,8 +394,8 @@ describe("TeamBillingService", () => { subscriptionId: "sub_detailed_789", subscriptionItemId: "si_detailed_789", customerId: "cus_detailed_789", - planName: "ENTERPRISE", - status: "TRIALING", + planName: Plan.ENTERPRISE, + status: SubscriptionStatus.TRIALING, }) ); }); @@ -367,8 +413,8 @@ describe("TeamBillingService", () => { subscriptionId: "sub_error_999", subscriptionItemId: "si_error_999", customerId: "cus_error_999", - planName: "TEAM" as const, - status: "ACTIVE" as const, + planName: Plan.TEAM, + status: SubscriptionStatus.ACTIVE, }; const repositoryError = new Error("Database constraint violation"); diff --git a/packages/features/ee/billing/service/teams/TeamBillingService.ts b/packages/features/ee/billing/service/teams/TeamBillingService.ts index 143a9f58276ba5..a17d262163f9c8 100644 --- a/packages/features/ee/billing/service/teams/TeamBillingService.ts +++ b/packages/features/ee/billing/service/teams/TeamBillingService.ts @@ -1,5 +1,3 @@ -import type { z } from "zod"; - import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing"; import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments"; import { WEBAPP_URL } from "@calcom/lib/constants"; @@ -10,18 +8,20 @@ import { safeStringify } from "@calcom/lib/safeStringify"; 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 { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository"; +import type { ITeamBillingDataRepository } from "../../repository/teamBillingData/ITeamBillingDataRepository"; +import { BillingPeriodService } from "../billingPeriod/BillingPeriodService"; import type { IBillingProviderService } from "../billingProvider/IBillingProviderService"; import { - TeamBillingPublishResponseStatus, type ITeamBillingService, type TeamBillingInput, + TeamBillingPublishResponseStatus, } from "./ITeamBillingService"; const log = logger.getSubLogger({ prefix: ["TeamBilling"] }); @@ -165,10 +165,19 @@ export class TeamBillingService implements ITeamBillingService { const membershipCount = await prisma.membership.count({ where: { teamId } }); if (!subscriptionId) throw Error("missing subscriptionId"); if (!subscriptionItemId) throw Error("missing subscriptionItemId"); - await this.billingProviderService.handleSubscriptionUpdate({ + + 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; + } + + await updateSubscriptionQuantity({ + billingService: this.billingProviderService, subscriptionId, subscriptionItemId, - membershipCount, + quantity: membershipCount, }); log.info(`Updated subscription ${subscriptionId} for team ${teamId} to ${membershipCount} seats.`); } catch (error) { @@ -221,6 +230,6 @@ export class TeamBillingService implements ITeamBillingService { } } async saveTeamBilling(args: IBillingRepositoryCreateArgs) { -await this.billingRepository.create(args); + await this.billingRepository.create(args); } } diff --git a/packages/features/ee/billing/teams/internal-team-billing.test.ts b/packages/features/ee/billing/teams/internal-team-billing.test.ts index dac7e034bdc75e..1109fff3eefb12 100644 --- a/packages/features/ee/billing/teams/internal-team-billing.test.ts +++ b/packages/features/ee/billing/teams/internal-team-billing.test.ts @@ -1,11 +1,9 @@ -import prismaMock from "@calcom/testing/lib/__mocks__/prismaMock"; - -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; - 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 { TeamBillingPublishResponseStatus } from "../service/teams/ITeamBillingService"; @@ -22,6 +20,14 @@ vi.mock("@calcom/lib/constants", async () => { vi.mock("@calcom/features/ee/teams/lib/payments", () => ({ purchaseTeamOrOrgSubscription: vi.fn(), })); + +const shouldApplyMonthlyProration = vi.fn().mockResolvedValue(false); + +vi.mock("../service/billingPeriod/BillingPeriodService", () => ({ + BillingPeriodService: class { + shouldApplyMonthlyProration = shouldApplyMonthlyProration; + }, +})); const mockTeam = { id: 1, metadata: { @@ -45,13 +51,32 @@ describe("TeamBillingService", () => { mockBillingProviderService = { handleSubscriptionCancel: vi.fn(), handleSubscriptionUpdate: vi.fn(), + handleSubscriptionCreation: vi.fn(), checkoutSessionIsPaid: vi.fn(), getSubscriptionStatus: vi.fn(), handleEndTrial: vi.fn(), + createCustomer: vi.fn(), + createPaymentIntent: vi.fn(), + createSubscriptionCheckout: vi.fn(), + createPrice: vi.fn(), + getPrice: vi.fn(), + getCheckoutSession: vi.fn(), + getCustomer: vi.fn(), + getSubscriptions: vi.fn(), + updateCustomer: vi.fn(), + createInvoiceItem: vi.fn(), + deleteInvoiceItem: vi.fn(), + createInvoice: vi.fn(), + finalizeInvoice: vi.fn(), + getSubscription: vi.fn(), + getPaymentIntentFailureReason: vi.fn(), + hasDefaultPaymentMethod: vi.fn(), } as IBillingProviderService; mockTeamBillingDataRepository = { find: vi.fn(), + findMany: vi.fn(), + findBySubscriptionId: vi.fn(), } as unknown as ITeamBillingDataRepository; mockBillingRepository = { @@ -141,6 +166,7 @@ describe("TeamBillingService", () => { paymentId: "cs_789", paymentRequired: false, }); + shouldApplyMonthlyProration.mockResolvedValue(false); await teamBillingServiceNotOrg.updateQuantity(); @@ -150,6 +176,30 @@ describe("TeamBillingService", () => { membershipCount: 10, }); }); + + 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", () => { @@ -213,8 +263,8 @@ describe("TeamBillingService", () => { subscriptionId: "sub_org_123", subscriptionItemId: "si_org_123", customerId: "cus_org_123", - planName: "ORGANIZATION" as const, - status: "ACTIVE" as const, + planName: Plan.ORGANIZATION, + status: SubscriptionStatus.ACTIVE, }; await teamBillingService.saveTeamBilling(mockBillingArgs); @@ -228,8 +278,8 @@ describe("TeamBillingService", () => { subscriptionId: "sub_detailed_789", subscriptionItemId: "si_detailed_789", customerId: "cus_detailed_789", - planName: "ENTERPRISE" as const, - status: "TRIALING" as const, + planName: Plan.ENTERPRISE, + status: SubscriptionStatus.TRIALING, }; await teamBillingService.saveTeamBilling(mockBillingArgs); @@ -240,8 +290,8 @@ describe("TeamBillingService", () => { subscriptionId: "sub_detailed_789", subscriptionItemId: "si_detailed_789", customerId: "cus_detailed_789", - planName: "ENTERPRISE", - status: "TRIALING", + planName: Plan.ENTERPRISE, + status: SubscriptionStatus.TRIALING, }) ); }); @@ -252,8 +302,8 @@ describe("TeamBillingService", () => { subscriptionId: "sub_error_999", subscriptionItemId: "si_error_999", customerId: "cus_error_999", - planName: "TEAM" as const, - status: "ACTIVE" as const, + planName: Plan.TEAM, + status: SubscriptionStatus.ACTIVE, }; const repositoryError = new Error("Database constraint violation"); diff --git a/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts b/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts index 51e2cfbb99a9c0..3621be9a240e57 100644 --- a/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts +++ b/packages/features/ee/dsync/lib/users/createUsersAndConnectToOrg.ts @@ -1,4 +1,5 @@ import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; +import { SeatChangeTrackingService } from "@calcom/features/ee/billing/service/seatTracking/SeatChangeTrackingService"; import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; import prisma from "@calcom/prisma"; import type { IdentityProvider } from "@calcom/prisma/enums"; @@ -69,7 +70,7 @@ export const createUsersAndConnectToOrg = async ({ }); // Create memberships for new members - await MembershipRepository.createMany( + const membershipResult = await MembershipRepository.createMany( users.map((user) => ({ userId: user.id, teamId: org.id, @@ -78,6 +79,14 @@ export const createUsersAndConnectToOrg = async ({ })) ); + if (membershipResult.count > 0) { + const seatTracker = new SeatChangeTrackingService(); + await seatTracker.logSeatAddition({ + teamId: org.id, + seatCount: membershipResult.count, + }); + } + return users; }; diff --git a/packages/features/ee/teams/services/teamService.test.ts b/packages/features/ee/teams/services/teamService.test.ts index cb3ad49863f3e6..ec680673beb412 100644 --- a/packages/features/ee/teams/services/teamService.test.ts +++ b/packages/features/ee/teams/services/teamService.test.ts @@ -14,6 +14,14 @@ import { MembershipRole } from "@calcom/prisma/enums"; import { TeamService } from "./teamService"; +const { MockSeatChangeTrackingService } = vi.hoisted(() => { + class MockSeatChangeTrackingService { + logSeatAddition = vi.fn().mockResolvedValue(undefined); + logSeatRemoval = vi.fn().mockResolvedValue(undefined); + } + return { MockSeatChangeTrackingService }; +}); + vi.mock("@calcom/ee/billing/di/containers/Billing"); vi.mock("@calcom/features/ee/teams/repositories/TeamRepository"); vi.mock("@calcom/features/ee/workflows/lib/service/WorkflowService"); @@ -21,6 +29,9 @@ vi.mock("@calcom/lib/domainManager/organization"); vi.mock("@calcom/features/ee/teams/lib/removeMember"); vi.mock("@calcom/features/profile/lib/createAProfileForAnExistingUser"); vi.mock("@calcom/features/ee/teams/lib/queries"); +vi.mock("@calcom/features/ee/billing/service/seatTracking/SeatChangeTrackingService", () => ({ + SeatChangeTrackingService: MockSeatChangeTrackingService, +})); const mockTeamBilling = { cancel: vi.fn(), diff --git a/packages/features/ee/teams/services/teamService.ts b/packages/features/ee/teams/services/teamService.ts index 865ab2912f7a3b..cb93ab7c868780 100644 --- a/packages/features/ee/teams/services/teamService.ts +++ b/packages/features/ee/teams/services/teamService.ts @@ -1,6 +1,7 @@ import { randomBytes } from "node:crypto"; import { getTeamBillingServiceFactory } from "@calcom/ee/billing/di/containers/Billing"; +import { SeatChangeTrackingService } from "@calcom/features/ee/billing/service/seatTracking/SeatChangeTrackingService"; import { deleteWorkfowRemindersOfRemovedMember } from "@calcom/features/ee/teams/lib/deleteWorkflowRemindersOfRemovedMember"; import { updateNewTeamMemberEventTypes } from "@calcom/features/ee/teams/lib/queries"; import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; @@ -188,6 +189,7 @@ export class TeamService { team: { select: { name: true, + parentId: true, }, }, }, @@ -218,6 +220,15 @@ export class TeamService { } else throw e; } + if (!verificationToken.team.parentId) { + const seatTracker = new SeatChangeTrackingService(); + await seatTracker.logSeatAddition({ + teamId: verificationToken.teamId, + userId, + triggeredBy: userId, + }); + } + const teamBillingServiceFactory = getTeamBillingServiceFactory(); const teamBillingService = await teamBillingServiceFactory.findAndInit(verificationToken.teamId); await teamBillingService.updateQuantity(); @@ -296,6 +307,15 @@ export class TeamService { }, }); } + + if (!membership.team.parentId) { + const seatTracker = new SeatChangeTrackingService(); + await seatTracker.logSeatRemoval({ + teamId, + userId, + triggeredBy: userId, + }); + } } catch (e) { console.log(e); } @@ -375,6 +395,14 @@ export class TeamService { await deleteWorkfowRemindersOfRemovedMember(team, userId, isOrg); + if (!team.parentId) { + const seatTracker = new SeatChangeTrackingService(); + await seatTracker.logSeatRemoval({ + teamId, + userId, + }); + } + return { membership }; } diff --git a/packages/features/trigger.config.ts b/packages/features/trigger.config.ts index 987ad096ad058e..95e71b3d0d65e5 100644 --- a/packages/features/trigger.config.ts +++ b/packages/features/trigger.config.ts @@ -16,7 +16,10 @@ export default defineConfig({ project: process.env.TRIGGER_DEV_PROJECT_REF ?? "", // e.g., "proj_abc123" // Directories containing your tasks - dirs: ["./bookings/lib/tasker/trigger/notifications", "./ee/billing/service/proration/tasker/trigger"], // Customize based on your project structure + dirs: [ + "./bookings/lib/tasker/trigger/notifications", + "./ee/billing/service/proration/tasker/trigger", + ], // Customize based on your project structure // Retry configuration retries: { @@ -35,7 +38,14 @@ export default defineConfig({ // Build configuration (optional) build: { - external: ["@prisma/client", "nodemailer", "jsdom", "playwright-core", "playwright", "chromium-bidi"], + external: [ + "@prisma/client", + "nodemailer", + "jsdom", + "playwright-core", + "playwright", + "chromium-bidi", + ], extensions: canSyncEnvVars ? [ syncVercelEnvVars({ diff --git a/packages/lib/tasker/Tasker.ts b/packages/lib/tasker/Tasker.ts index 1deb789b5a2b60..b8cf6e082ccc2a 100644 --- a/packages/lib/tasker/Tasker.ts +++ b/packages/lib/tasker/Tasker.ts @@ -7,18 +7,27 @@ import { redactError } from "../redactError"; import type { ILogger } from "./types"; const isAsyncTaskerEnabled = - ENABLE_ASYNC_TASKER && process.env.TRIGGER_SECRET_KEY && process.env.TRIGGER_API_URL; + ENABLE_ASYNC_TASKER && + process.env.TRIGGER_SECRET_KEY && + process.env.TRIGGER_API_URL; export abstract class Tasker { protected readonly asyncTasker: T; protected readonly syncTasker: T; protected readonly logger: ILogger; - constructor(dependencies: { asyncTasker: T; syncTasker: T; logger: ILogger }) { + constructor(dependencies: { + asyncTasker: T; + syncTasker: T; + logger: ILogger; + }) { this.logger = dependencies.logger; if (!isAsyncTaskerEnabled) { - if (ENABLE_ASYNC_TASKER && (!process.env.TRIGGER_SECRET_KEY || !process.env.TRIGGER_API_URL)) { + if ( + ENABLE_ASYNC_TASKER && + (!process.env.TRIGGER_SECRET_KEY || !process.env.TRIGGER_API_URL) + ) { this.logger.info( "Missing env variables TRIGGER_SECRET_KEY or TRIGGER_API_URL, falling back to Sync tasker." ); @@ -32,14 +41,18 @@ export abstract class Tasker { }); } - this.asyncTasker = isAsyncTaskerEnabled ? dependencies.asyncTasker : dependencies.syncTasker; + this.asyncTasker = isAsyncTaskerEnabled + ? dependencies.asyncTasker + : dependencies.syncTasker; this.syncTasker = dependencies.syncTasker; } public async dispatch( taskName: K, ...args: T[K] extends (...args: any[]) => any ? Parameters : never - ): Promise any ? Awaited> : never> { + ): Promise< + T[K] extends (...args: any[]) => any ? Awaited> : never + > { this.logger.info(`Safely Dispatching task '${String(taskName)}'`, { args }); return this._safeDispatch(taskName, ...args); } @@ -47,10 +60,14 @@ export abstract class Tasker { private async _safeDispatch( taskName: K, ...args: T[K] extends (...args: any[]) => any ? Parameters : never - ): Promise any ? Awaited> : never> { + ): Promise< + T[K] extends (...args: any[]) => any ? Awaited> : never + > { try { this.logger.info( - `${isAsyncTaskerEnabled ? "AsyncTasker" : "SyncTasker"} '${String(taskName)}' dispatched.` + `${isAsyncTaskerEnabled ? "AsyncTasker" : "SyncTasker"} '${String( + taskName + )}' dispatched.` ); const method = this.asyncTasker[taskName] as (...args: any[]) => any; return await method.apply(this.asyncTasker, args); @@ -68,13 +85,20 @@ export abstract class Tasker { throw err; } - this.logger.warn(`Trying again with SyncTasker for '${String(taskName)}'.`); + this.logger.warn( + `Trying again with SyncTasker for '${String(taskName)}'.` + ); try { - const fallbackMethod = this.syncTasker[taskName] as (...args: any[]) => any; + const fallbackMethod = this.syncTasker[taskName] as ( + ...args: any[] + ) => any; return await fallbackMethod.apply(this.syncTasker, args); } catch (err) { - this.logger.error(`SyncTasker failed for '${String(taskName)}'.`, this.getErrorDetails(err)); + this.logger.error( + `SyncTasker failed for '${String(taskName)}'.`, + this.getErrorDetails(err) + ); throw err; } } diff --git a/packages/prisma/migrations/20260115101600_add_unique_constraint/migration.sql b/packages/prisma/migrations/20260115101600_add_unique_constraint/migration.sql new file mode 100644 index 00000000000000..bd7c8e3e55e235 --- /dev/null +++ b/packages/prisma/migrations/20260115101600_add_unique_constraint/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - A unique constraint covering the columns `[teamId,operationId]` on the table `SeatChangeLog` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "public"."SeatChangeLog" ADD COLUMN "operationId" TEXT; + +-- CreateIndex +CREATE INDEX "IntegrationAttributeSync_credentialId_idx" ON "public"."IntegrationAttributeSync"("credentialId"); + +-- CreateIndex +CREATE UNIQUE INDEX "SeatChangeLog_teamId_operationId_key" ON "public"."SeatChangeLog"("teamId", "operationId"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 9c410085816422..72eb7d81568b8f 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -2989,6 +2989,9 @@ model SeatChangeLog { changeDate DateTime @default(now()) monthKey String + // Idempotency key to prevent duplicate seat change logs from race conditions + operationId String? + processedInProrationId String? proration MonthlyProration? @relation(fields: [processedInProrationId], references: [id]) @@ -3000,6 +3003,7 @@ model SeatChangeLog { organizationBillingId String? organizationBilling OrganizationBilling? @relation(fields: [organizationBillingId], references: [id]) + @@unique([teamId, operationId]) @@index([teamId, monthKey]) @@index([teamId, processedInProrationId]) @@index([monthKey]) diff --git a/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts b/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts index 52fd7d30fcaa23..27668fbeafb4dc 100644 --- a/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/bulkDeleteUsers.handler.ts @@ -1,4 +1,5 @@ import { getTeamBillingServiceFactory } from "@calcom/ee/billing/di/containers/Billing"; +import { SeatChangeTrackingService } from "@calcom/features/ee/billing/service/seatTracking/SeatChangeTrackingService"; import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry"; import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions"; import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; @@ -56,19 +57,22 @@ export async function bulkDeleteUsersHandler({ ctx, input }: BulkDeleteUsersHand throw new TRPCError({ code: "UNAUTHORIZED" }); } - // Loop over all users in input.userIds and remove all memberships for the organization including child teams - const deleteMany = prisma.membership.deleteMany({ + const deleteOrganizationMemberships = prisma.membership.deleteMany({ + where: { + teamId: currentUserOrgId, + userId: { + in: input.userIds, + }, + }, + }); + + const deleteSubteamMemberships = prisma.membership.deleteMany({ where: { userId: { in: input.userIds, }, team: { - OR: [ - { - parentId: currentUserOrgId, - }, - { id: currentUserOrgId }, - ], + parentId: currentUserOrgId, }, }, }); @@ -130,14 +134,24 @@ export async function bulkDeleteUsersHandler({ ctx, input }: BulkDeleteUsersHand // We do this in a transaction to make sure that all memberships are removed before we remove the organization relation from the user // We also do this to make sure that if one of the queries fail, the whole transaction fails - await prisma.$transaction([ + const [, { count: orgMembershipRemovalCount }] = await prisma.$transaction([ removeProfiles, - deleteMany, + deleteOrganizationMemberships, + deleteSubteamMemberships, removeOrgrelation, removeManagedEventTypes, removeHostAssignment, ]); + if (orgMembershipRemovalCount > 0) { + const seatTracker = new SeatChangeTrackingService(); + await seatTracker.logSeatRemoval({ + teamId: currentUserOrgId, + seatCount: orgMembershipRemovalCount, + triggeredBy: currentUser.id, + }); + } + const teamBillingServiceFactory = getTeamBillingServiceFactory(); const teamBillingService = await teamBillingServiceFactory.findAndInit(currentUserOrgId); await teamBillingService.updateQuantity(); diff --git a/packages/trpc/server/routers/viewer/organizations/utils.test.ts b/packages/trpc/server/routers/viewer/organizations/utils.test.ts index 1a376edba03b79..676220d0e4b683 100644 --- a/packages/trpc/server/routers/viewer/organizations/utils.test.ts +++ b/packages/trpc/server/routers/viewer/organizations/utils.test.ts @@ -55,6 +55,7 @@ describe("addMembersToTeams", () => { mockCheckPermission.mockResolvedValue(true); mockUpdateNewTeamMemberEventTypes.mockResolvedValue(undefined); + prisma.team.findMany.mockResolvedValue([]); prisma.membership.findMany.mockResolvedValue([]); prisma.membership.createMany.mockResolvedValue({ count: 0 }); }); diff --git a/packages/trpc/server/routers/viewer/organizations/utils.ts b/packages/trpc/server/routers/viewer/organizations/utils.ts index da83fe9471ce05..18453b33c9a249 100644 --- a/packages/trpc/server/routers/viewer/organizations/utils.ts +++ b/packages/trpc/server/routers/viewer/organizations/utils.ts @@ -1,4 +1,5 @@ import { TeamRepository } from "@calcom/ee/teams/repositories/TeamRepository"; +import { SeatChangeTrackingService } from "@calcom/features/ee/billing/service/seatTracking/SeatChangeTrackingService"; import { updateNewTeamMemberEventTypes } from "@calcom/features/ee/teams/lib/queries"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { prisma } from "@calcom/prisma"; @@ -15,14 +16,18 @@ interface AddBulkToTeamProps { input: TAddMembersToTeams; } -export const addMembersToTeams = async ({ user, input }: AddBulkToTeamProps) => { +export const addMembersToTeams = async ({ + user, + input, +}: AddBulkToTeamProps) => { if (!user.organizationId) throw new TRPCError({ code: "UNAUTHORIZED" }); const teamRepository = new TeamRepository(prisma); - const teamsNotBelongingToOrg = await teamRepository.findTeamsNotBelongingToOrgByIds({ - teamIds: input.teamIds, - orgId: user.organizationId, - }); + const teamsNotBelongingToOrg = + await teamRepository.findTeamsNotBelongingToOrgByIds({ + teamIds: input.teamIds, + orgId: user.organizationId, + }); if (teamsNotBelongingToOrg.length > 0) { throw new TRPCError({ @@ -33,6 +38,22 @@ export const addMembersToTeams = async ({ user, input }: AddBulkToTeamProps) => }); } + const teamsForSeatTracking = await prisma.team.findMany({ + where: { + id: { + in: input.teamIds, + }, + }, + select: { + id: true, + parentId: true, + }, + }); + + const topLevelTeamIds = new Set( + teamsForSeatTracking.filter((team) => !team.parentId).map((team) => team.id) + ); + // Check if user has permission to invite team members in the organization const permissionCheckService = new PermissionCheckService(); const hasPermission = await permissionCheckService.checkPermission({ @@ -45,7 +66,8 @@ export const addMembersToTeams = async ({ user, input }: AddBulkToTeamProps) => if (!hasPermission) { throw new TRPCError({ code: "UNAUTHORIZED", - message: "You are not authorized to add members to teams in this organization", + message: + "You are not authorized to add members to teams in this organization", }); } @@ -90,7 +112,9 @@ export const addMembersToTeams = async ({ user, input }: AddBulkToTeamProps) => // Loop over all users and add them to all teams in the array const membershipData = filteredUserIds.flatMap((userId) => input.teamIds.map((teamId) => { - const userMembership = usersInOrganization.find((membership) => membership.userId === userId); + const userMembership = usersInOrganization.find( + (membership) => membership.userId === userId + ); const accepted = userMembership && userMembership.accepted; return { createdAt: new Date(), @@ -106,6 +130,27 @@ export const addMembersToTeams = async ({ user, input }: AddBulkToTeamProps) => data: membershipData, }); + if (topLevelTeamIds.size > 0 && membershipData.length > 0) { + const seatTracker = new SeatChangeTrackingService(); + const additionsByTeam = Array.from(topLevelTeamIds) + .map((teamId) => ({ + teamId, + seatCount: membershipData.filter((entry) => entry.teamId === teamId) + .length, + })) + .filter((entry) => entry.seatCount > 0); + + await Promise.all( + additionsByTeam.map(({ teamId, seatCount }) => + seatTracker.logSeatAddition({ + teamId, + seatCount, + triggeredBy: user.id, + }) + ) + ); + } + await Promise.all( membershipData.map(({ userId, teamId }) => updateNewTeamMemberEventTypes(userId, teamId)) ); diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts index 2fb6806d1a4389..7067d8c03c65b4 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/inviteMemberUtils.test.ts @@ -1,27 +1,44 @@ -import { describe, it, vi, expect, beforeEach } from "vitest"; - import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; -import { MembershipRole } from "@calcom/prisma/enums"; - +import { CreationSource, MembershipRole } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { TeamWithParent } from "./types"; import type { UserWithMembership } from "./utils"; -import { INVITE_STATUS } from "./utils"; import { - ensureAtleastAdminPermissions, - getUniqueInvitationsOrThrowIfEmpty, - getOrgState, - getOrgConnectionInfo, canBeInvited, - getAutoJoinStatus, checkInputEmailIsValid, createMemberships, + createNewUsersConnectToOrgIfExists, + ensureAtleastAdminPermissions, + getAutoJoinStatus, + getOrgConnectionInfo, + getOrgState, + getUniqueInvitationsOrThrowIfEmpty, + INVITE_STATUS, } from "./utils"; -const { mockCreateMany } = vi.hoisted(() => { +const { mockCreateMany, mockUserCreate, mockMembershipCreate, mockTransaction } = vi.hoisted(() => { const mockCreateManyFn = vi.fn(); - return { mockCreateMany: mockCreateManyFn }; + const mockUserCreateFn = vi.fn(); + const mockMembershipCreateFn = vi.fn(); + const mockTransactionFn = vi.fn(async (callback: (tx: any) => Promise) => { + return callback({ + user: { + create: mockUserCreateFn, + }, + membership: { + create: mockMembershipCreateFn, + }, + }); + }); + + return { + mockCreateMany: mockCreateManyFn, + mockUserCreate: mockUserCreateFn, + mockMembershipCreate: mockMembershipCreateFn, + mockTransaction: mockTransactionFn, + }; }); vi.mock("@calcom/prisma", () => { @@ -30,6 +47,10 @@ vi.mock("@calcom/prisma", () => { membership: { createMany: mockCreateMany, }, + user: { + create: mockUserCreate, + }, + $transaction: mockTransaction, }, }; }); @@ -47,6 +68,20 @@ vi.mock("@calcom/features/pbac/services/permission-check.service", () => { }; }); +const { mockLogSeatAddition } = vi.hoisted(() => { + return { mockLogSeatAddition: vi.fn() }; +}); + +vi.mock("@calcom/features/ee/billing/service/seatTracking/SeatChangeTrackingService", () => ({ + SeatChangeTrackingService: class { + logSeatAddition = mockLogSeatAddition; + }, +})); + +vi.mock("@calcom/lib/server/i18n", () => ({ + getTranslation: vi.fn().mockResolvedValue((key: string) => key), +})); + vi.mock("@calcom/lib/logger", () => { const mockSubLogger = { debug: vi.fn(), @@ -639,6 +674,50 @@ describe("Invite Member Utils", () => { }); }); + describe("createNewUsersConnectToOrgIfExists", () => { + beforeEach(() => { + mockUserCreate.mockReset(); + mockMembershipCreate.mockReset(); + mockTransaction.mockClear(); + mockLogSeatAddition.mockClear(); + }); + + it("logs seat changes for new users on regular teams", async () => { + let nextId = 100; + mockUserCreate.mockImplementation(async ({ data }) => ({ + id: nextId++, + email: data.email, + })); + mockMembershipCreate.mockResolvedValue({}); + + const invitations = [ + { usernameOrEmail: "new1@example.com", role: MembershipRole.MEMBER }, + { usernameOrEmail: "new2@example.com", role: MembershipRole.MEMBER }, + ]; + const orgConnectInfoByUsernameOrEmail = { + "new1@example.com": { orgId: undefined, autoAccept: false }, + "new2@example.com": { orgId: undefined, autoAccept: false }, + }; + + const result = await createNewUsersConnectToOrgIfExists({ + invitations, + isOrg: false, + teamId: mockedRegularTeam.id, + parentId: null, + autoAcceptEmailDomain: null, + orgConnectInfoByUsernameOrEmail, + language: "en", + creationSource: CreationSource.WEBAPP, + }); + + expect(result).toHaveLength(2); + expect(mockLogSeatAddition).toHaveBeenCalledWith({ + teamId: mockedRegularTeam.id, + seatCount: 2, + }); + }); + }); + describe("createMemberships - Privilege Escalation Prevention", () => { beforeEach(() => { mockCreateMany.mockClear(); diff --git a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts index 826cc8f94628e6..be8486256c85a4 100644 --- a/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts +++ b/packages/trpc/server/routers/viewer/teams/inviteMember/utils.ts @@ -1,9 +1,8 @@ import { randomBytes } from "node:crypto"; -import type { TFunction } from "i18next"; - import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains"; import { sendTeamInviteEmail } from "@calcom/emails/organization-email-service"; import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner"; +import { SeatChangeTrackingService } from "@calcom/features/ee/billing/service/seatTracking/SeatChangeTrackingService"; import { getParsedTeam } from "@calcom/features/ee/teams/lib/getParsedTeam"; import { updateNewTeamMemberEventTypes } from "@calcom/features/ee/teams/lib/queries"; import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service"; @@ -18,14 +17,13 @@ import { safeStringify } from "@calcom/lib/safeStringify"; import { getTranslation } from "@calcom/lib/server/i18n"; import slugify from "@calcom/lib/slugify"; import { prisma } from "@calcom/prisma"; -import type { Membership, OrganizationSettings, Team } from "@calcom/prisma/client"; -import { type User as UserType, type UserPassword, Prisma } from "@calcom/prisma/client"; -import type { Profile as ProfileType } from "@calcom/prisma/client"; +import type { Membership, OrganizationSettings, Profile as ProfileType, Team } from "@calcom/prisma/client"; +import { Prisma, type UserPassword, type User as UserType } from "@calcom/prisma/client"; import type { CreationSource } from "@calcom/prisma/enums"; import { MembershipRole } from "@calcom/prisma/enums"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; - import { TRPCError } from "@trpc/server"; +import type { TFunction } from "i18next"; import { isEmail } from "../util"; import type { TeamWithParent } from "./types"; @@ -137,7 +135,7 @@ export async function getUniqueInvitationsOrThrowIfEmpty(invitations: Invitation return uniqueInvitations; } -export const enum INVITE_STATUS { +export enum INVITE_STATUS { USER_PENDING_MEMBER_OF_THE_ORG = "USER_PENDING_MEMBER_OF_THE_ORG", USER_ALREADY_INVITED_OR_MEMBER = "USER_ALREADY_INVITED_OR_MEMBER", USER_MEMBER_OF_OTHER_ORGANIZATION = "USER_MEMBER_OF_OTHER_ORGANIZATION", @@ -270,7 +268,7 @@ export function getOrgConnectionInfo({ team: Pick; isOrg: boolean; }) { - let orgId: number | undefined = undefined; + let orgId: number | undefined; let autoAccept = false; if (team.parentId || isOrg) { @@ -415,6 +413,16 @@ export async function createNewUsersConnectToOrgIfExists({ }, { timeout: 10000 } ); + + if (createdUsers.length > 0) { + const seatTracker = new SeatChangeTrackingService(); + const trackingTeamId = parentId ?? teamId; + await seatTracker.logSeatAddition({ + teamId: trackingTeamId, + seatCount: createdUsers.length, + }); + } + return createdUsers; } @@ -464,6 +472,33 @@ export async function createMemberships({ return data; }), }); + + const seatTracker = new SeatChangeTrackingService(); + const teamSeatAdditions = parentId ? 0 : invitees.length; + const organizationSeatAdditions = parentId + ? invitees.filter((invitee) => invitee.needToCreateOrgMembership).length + : 0; + + const trackingPromises: Promise[] = []; + if (teamSeatAdditions > 0) { + trackingPromises.push( + seatTracker.logSeatAddition({ + teamId, + seatCount: teamSeatAdditions, + }) + ); + } + + if (parentId && organizationSeatAdditions > 0) { + trackingPromises.push( + seatTracker.logSeatAddition({ + teamId: parentId, + seatCount: organizationSeatAdditions, + }) + ); + } + + await Promise.all(trackingPromises); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { logger.error("Failed to create memberships", teamId); @@ -928,6 +963,14 @@ export async function handleExistingUsersInvites({ }) ); + if (!team.parentId && existingUsersWithMembershipsNew.length > 0) { + const seatTracker = new SeatChangeTrackingService(); + await seatTracker.logSeatAddition({ + teamId: team.id, + seatCount: existingUsersWithMembershipsNew.length, + }); + } + const autoJoinUsers = existingUsersWithMembershipsNew.filter( (user) => orgConnectInfoByUsernameOrEmail[user.email].autoAccept ); diff --git a/yarn.lock b/yarn.lock index b2584d57fbc02f..6e5fb4efc7113c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9340,12 +9340,12 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/api-logs@npm:0.209.0": - version: 0.209.0 - resolution: "@opentelemetry/api-logs@npm:0.209.0" +"@opentelemetry/api-logs@npm:0.210.0": + version: 0.210.0 + resolution: "@opentelemetry/api-logs@npm:0.210.0" dependencies: "@opentelemetry/api": "npm:^1.3.0" - checksum: 10/bd270d8b60b2fb8124174f0ec0babe7b57eca69f58feb6f4e1394714591324939dd4049a29499f435e948011cc8e5bdc9d0da2a2a96bd8be8ee9e5a3d297dfa0 + checksum: 10/fc9bf8693105f73162d8e6999443d5f15b2b3226f36a88815d5c5cc5a9771ecec92a808356087da062c24a4ad467891b33d8ae884fbe515cb1711bb7b192e371 languageName: node linkType: hard @@ -9393,11 +9393,11 @@ __metadata: linkType: hard "@opentelemetry/context-async-hooks@npm:^2.2.0": - version: 2.3.0 - resolution: "@opentelemetry/context-async-hooks@npm:2.3.0" + version: 2.4.0 + resolution: "@opentelemetry/context-async-hooks@npm:2.4.0" peerDependencies: "@opentelemetry/api": ">=1.0.0 <1.10.0" - checksum: 10/f3867ede6a0864db34f677b0e98ac563e0df8e938a70d42ceeed76f927847a44bd38bf04fc60f618b395f064fb5e5c332db296b8ac6286265f88702d483fd383 + checksum: 10/8f700b94da4c12941c8d02f37d243c0cab466df8ff18ad41a6a16eb0429c4e0fade6e58df58857a1f0f676a008709f941090f0ca419a4e56d4a661d9c2be6bc5 languageName: node linkType: hard @@ -9434,14 +9434,14 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/core@npm:2.3.0, @opentelemetry/core@npm:^2.0.0, @opentelemetry/core@npm:^2.2.0": - version: 2.3.0 - resolution: "@opentelemetry/core@npm:2.3.0" +"@opentelemetry/core@npm:2.4.0, @opentelemetry/core@npm:^2.0.0, @opentelemetry/core@npm:^2.2.0": + version: 2.4.0 + resolution: "@opentelemetry/core@npm:2.4.0" dependencies: "@opentelemetry/semantic-conventions": "npm:^1.29.0" peerDependencies: "@opentelemetry/api": ">=1.0.0 <1.10.0" - checksum: 10/a4deceb8088e3d8d5ba0460778d758a1b74ab6768dd97ec645ae6d4cbb64746da1a29d103c98eeb1a713448da556fd7fcf350c6c5aff6388079365aa6ae32465 + checksum: 10/3efeb16da8910edd444d9215930d09cb98e3887fbb7ee11ee20dc38ca719b06bda425fb61572068840f14f9cb7a34443e80071e601bdfff0c7c02fb27924c04f languageName: node linkType: hard @@ -10129,15 +10129,15 @@ __metadata: linkType: hard "@opentelemetry/instrumentation@npm:>=0.52.0 <1": - version: 0.209.0 - resolution: "@opentelemetry/instrumentation@npm:0.209.0" + version: 0.210.0 + resolution: "@opentelemetry/instrumentation@npm:0.210.0" dependencies: - "@opentelemetry/api-logs": "npm:0.209.0" + "@opentelemetry/api-logs": "npm:0.210.0" import-in-the-middle: "npm:^2.0.0" require-in-the-middle: "npm:^8.0.0" peerDependencies: "@opentelemetry/api": ^1.3.0 - checksum: 10/b8c4f92f6fddd20a2a355a9e2dd4c8a265ada91570a79f9119e4dc6b70667e7ed65994ab8da52b5bcfafdcd407e68f964a944edb5a92bbbf2b3ae506982fed98 + checksum: 10/fe40f717cbc7cdf35b0061c075dd30beefd17644c830674025fe7abeb682f94d2a528c6e8b9f129ca1982eb862c93e796663dadeae175d86d90d7f043dca2c0c languageName: node linkType: hard @@ -10251,15 +10251,15 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/resources@npm:2.3.0, @opentelemetry/resources@npm:^2.2.0": - version: 2.3.0 - resolution: "@opentelemetry/resources@npm:2.3.0" +"@opentelemetry/resources@npm:2.4.0, @opentelemetry/resources@npm:^2.2.0": + version: 2.4.0 + resolution: "@opentelemetry/resources@npm:2.4.0" dependencies: - "@opentelemetry/core": "npm:2.3.0" + "@opentelemetry/core": "npm:2.4.0" "@opentelemetry/semantic-conventions": "npm:^1.29.0" peerDependencies: "@opentelemetry/api": ">=1.3.0 <1.10.0" - checksum: 10/8deee5b81a9fe730569402da71190def11ed8b9a8f87b8340dafc773a1e174e52ca28c8117d26ff75aaea975adf6949fa0433305f3c5c0ce718e312e2826765a + checksum: 10/40184775f613048a205988b02f8f0a348d4b3b3f8b8af1e9248e8faf3f4c0965bdfd312b4d3b64bfda91a80cc0a5413df37716513d60ff1d517555de87a89356 languageName: node linkType: hard @@ -10340,15 +10340,15 @@ __metadata: linkType: hard "@opentelemetry/sdk-trace-base@npm:^2.2.0": - version: 2.3.0 - resolution: "@opentelemetry/sdk-trace-base@npm:2.3.0" + version: 2.4.0 + resolution: "@opentelemetry/sdk-trace-base@npm:2.4.0" dependencies: - "@opentelemetry/core": "npm:2.3.0" - "@opentelemetry/resources": "npm:2.3.0" + "@opentelemetry/core": "npm:2.4.0" + "@opentelemetry/resources": "npm:2.4.0" "@opentelemetry/semantic-conventions": "npm:^1.29.0" peerDependencies: "@opentelemetry/api": ">=1.3.0 <1.10.0" - checksum: 10/235b3117704b69ead030819eaca5aa161ca6da2930603a26cb6aa32f07e6a4b1268a97471911a1939d308f8e9fda2c9b486febd208f5edcf506b79eeeed31aed + checksum: 10/e43a0540f37639fafdfe6eb144808a4bdf8c92565b12e4312ce8885d153a72841ac0015cfe977596fb6a92fb30127ec6687d2310a38ed62eb80f90e6ad68932a languageName: node linkType: hard @@ -13559,10 +13559,10 @@ __metadata: languageName: node linkType: hard -"@sentry/babel-plugin-component-annotate@npm:4.6.1": - version: 4.6.1 - resolution: "@sentry/babel-plugin-component-annotate@npm:4.6.1" - checksum: 10/8aff72493b2b5aeeb67887054dcb52a3faf4a1b9cf2888f576467df1954ee9784409eef210043c4f3a571958fcd01e940a28cfa5758c7393239405ab5fec7628 +"@sentry/babel-plugin-component-annotate@npm:4.6.2": + version: 4.6.2 + resolution: "@sentry/babel-plugin-component-annotate@npm:4.6.2" + checksum: 10/b4fb63f00e95ac8adee299130051cd89c01d5ed987898ba84be243611daf9b3692f6eb19370c01dfaad1a3b7c0ca3211968f93b0cb9418932a7492faf07109ad languageName: node linkType: hard @@ -13592,19 +13592,19 @@ __metadata: languageName: node linkType: hard -"@sentry/bundler-plugin-core@npm:4.6.1, @sentry/bundler-plugin-core@npm:^4.6.1": - version: 4.6.1 - resolution: "@sentry/bundler-plugin-core@npm:4.6.1" +"@sentry/bundler-plugin-core@npm:4.6.2, @sentry/bundler-plugin-core@npm:^4.6.1": + version: 4.6.2 + resolution: "@sentry/bundler-plugin-core@npm:4.6.2" dependencies: "@babel/core": "npm:^7.18.5" - "@sentry/babel-plugin-component-annotate": "npm:4.6.1" + "@sentry/babel-plugin-component-annotate": "npm:4.6.2" "@sentry/cli": "npm:^2.57.0" dotenv: "npm:^16.3.1" find-up: "npm:^5.0.0" glob: "npm:^10.5.0" magic-string: "npm:0.30.8" unplugin: "npm:1.0.1" - checksum: 10/9144b5dca6cb3846600e5829a95e183e89fbde05236a20402897d9eecc10b4f11bb94259777cdac9736deaf49d1c5ac7d9d3ef4f11bccb60981ce086d35e7abe + checksum: 10/dca517445e9522267c4d25f7604f056aa5c95d5575f2245f0e410413a12663b36ac45b144c1fe7e1388e44a8d213e5824ae78616594ac0cc7e9c5b4a87d3c05c languageName: node linkType: hard @@ -13958,15 +13958,15 @@ __metadata: linkType: hard "@sentry/webpack-plugin@npm:^4.6.1": - version: 4.6.1 - resolution: "@sentry/webpack-plugin@npm:4.6.1" + version: 4.6.2 + resolution: "@sentry/webpack-plugin@npm:4.6.2" dependencies: - "@sentry/bundler-plugin-core": "npm:4.6.1" + "@sentry/bundler-plugin-core": "npm:4.6.2" unplugin: "npm:1.0.1" uuid: "npm:^9.0.0" peerDependencies: webpack: ">=4.40.0" - checksum: 10/3a5fd82f60505e2724a3a458001dd9a95228607bb17e4425a7b3ed7cb211098a5a9fbcd3b5be613f1f91675f9bebad38142c102cbe48e9167f216c23ab8d0e6a + checksum: 10/d40fcd355cf25ddf329d8eec8684f229037ba5e961f270770760c458e8f58bea081a23b2df8ffd62d5aba4482b22e9028c89e835a2668484b76ebce36333915f languageName: node linkType: hard