diff --git a/packages/features/ee/billing/lib/stripe-subscription-utils.ts b/packages/features/ee/billing/lib/stripe-subscription-utils.ts new file mode 100644 index 0000000000..36fe396a41 --- /dev/null +++ b/packages/features/ee/billing/lib/stripe-subscription-utils.ts @@ -0,0 +1,23 @@ +import type Stripe from "stripe"; +import type { BillingPeriod } from "@calcom/prisma/enums"; + +export interface BillingData { + billingPeriod: BillingPeriod; + pricePerSeat: number | undefined; + paidSeats: number | undefined; +} + +export function extractBillingDataFromStripeSubscription(subscription: Stripe.Subscription): BillingData { + const billingPeriod: BillingPeriod = + subscription.items.data[0]?.price.recurring?.interval === "year" ? "ANNUALLY" : "MONTHLY"; + + const pricePerSeat = subscription.items.data[0]?.price.unit_amount; + + const paidSeats = subscription.items.data[0]?.quantity; + + return { + billingPeriod, + pricePerSeat, + paidSeats, + }; +} diff --git a/packages/features/ee/billing/repository/billing/IBillingRepository.ts b/packages/features/ee/billing/repository/billing/IBillingRepository.ts index 9a8e44592c..a483d2d94b 100644 --- a/packages/features/ee/billing/repository/billing/IBillingRepository.ts +++ b/packages/features/ee/billing/repository/billing/IBillingRepository.ts @@ -44,4 +44,7 @@ export interface IBillingRepositoryCreateArgs { subscriptionStart?: Date; subscriptionTrialEnd?: Date; subscriptionEnd?: Date; + billingPeriod?: "MONTHLY" | "ANNUALLY"; + pricePerSeat?: number; + paidSeats?: number; } diff --git a/packages/features/ee/billing/repository/billingPeriod/BillingPeriodRepository.ts b/packages/features/ee/billing/repository/billingPeriod/BillingPeriodRepository.ts new file mode 100644 index 0000000000..a6cc89f2d8 --- /dev/null +++ b/packages/features/ee/billing/repository/billingPeriod/BillingPeriodRepository.ts @@ -0,0 +1,98 @@ +import type { PrismaClient } from "@calcom/prisma"; +import { prisma as defaultPrisma } from "@calcom/prisma"; +import type { BillingPeriod } from "@calcom/prisma/enums"; + +export interface TeamWithBillingInfo { + isOrganization: boolean; + billing: { + id: string; + billingPeriod: BillingPeriod | null; + subscriptionStart: Date | null; + subscriptionEnd: Date | null; + subscriptionTrialEnd: Date | null; + pricePerSeat: number | null; + paidSeats: number | null; + subscriptionId: string; + } | null; +} + +export interface UpdateBillingPeriodData { + billingPeriod: BillingPeriod; + pricePerSeat?: number | null; + paidSeats?: number | null; +} + +export class BillingPeriodRepository { + private prisma: PrismaClient; + + constructor(prisma?: PrismaClient) { + this.prisma = prisma || defaultPrisma; + } + + async getTeamWithBillingInfo(teamId: number): Promise { + const team = await this.prisma.team.findUnique({ + where: { id: teamId }, + select: { + isOrganization: true, + teamBilling: { + select: { + id: true, + billingPeriod: true, + subscriptionStart: true, + subscriptionEnd: true, + subscriptionTrialEnd: true, + pricePerSeat: true, + paidSeats: true, + subscriptionId: true, + }, + }, + organizationBilling: { + select: { + id: true, + billingPeriod: true, + subscriptionStart: true, + subscriptionEnd: true, + subscriptionTrialEnd: true, + pricePerSeat: true, + paidSeats: true, + subscriptionId: true, + }, + }, + }, + }); + + if (!team) return null; + + const billing = team.isOrganization ? team.organizationBilling : team.teamBilling; + + return { + isOrganization: team.isOrganization, + billing, + }; + } + + async updateOrganizationBillingPeriod(billingId: string, data: UpdateBillingPeriodData): Promise { + await this.prisma.organizationBilling.update({ + where: { id: billingId }, + data, + }); + } + + async updateTeamBillingPeriod(billingId: string, data: UpdateBillingPeriodData): Promise { + await this.prisma.teamBilling.update({ + where: { id: billingId }, + data, + }); + } + + async getTeamForBillingUpdate(teamId: number) { + return await this.prisma.team.findUnique({ + where: { id: teamId }, + select: { + isOrganization: true, + teamBilling: { select: { id: true } }, + organizationBilling: { select: { id: true } }, + }, + }); + } +} diff --git a/packages/features/ee/billing/repository/proration/MonthlyProrationRepository.ts b/packages/features/ee/billing/repository/proration/MonthlyProrationRepository.ts new file mode 100644 index 0000000000..85a5a86a5f --- /dev/null +++ b/packages/features/ee/billing/repository/proration/MonthlyProrationRepository.ts @@ -0,0 +1,92 @@ +import type { PrismaClient } from "@calcom/prisma"; +import { prisma as defaultPrisma } from "@calcom/prisma"; +import type { MonthlyProration } from "@calcom/prisma/client"; +import type { ProrationStatus } from "@calcom/prisma/enums"; + +export interface CreateProrationData { + teamId: number; + monthKey: string; + periodStart: Date; + periodEnd: Date; + seatsAtStart: number; + seatsAdded: number; + seatsRemoved: number; + netSeatIncrease: number; + seatsAtEnd: number; + subscriptionId: string; + subscriptionItemId: string; + customerId: string; + subscriptionStart: Date; + subscriptionEnd: Date; + remainingDays: number; + pricePerSeat: number; + proratedAmount: number; + teamBillingId: string | null; + organizationBillingId: string | null; +} + +export class MonthlyProrationRepository { + private prisma: PrismaClient; + + constructor(prisma?: PrismaClient) { + this.prisma = prisma || defaultPrisma; + } + + async createProration(data: CreateProrationData): Promise { + return await this.prisma.monthlyProration.create({ + data: { + ...data, + status: "PENDING" as ProrationStatus, + }, + }); + } + + async updateProrationStatus( + prorationId: string, + status: ProrationStatus, + additionalData?: { + invoiceItemId?: string; + invoiceId?: string; + chargedAt?: Date; + failedAt?: Date; + failureReason?: string; + retryCount?: number; + } + ): Promise { + return await this.prisma.monthlyProration.update({ + where: { id: prorationId }, + data: { + status, + ...additionalData, + }, + }); + } + + async findById(prorationId: string): Promise { + return await this.prisma.monthlyProration.findUnique({ + where: { id: prorationId }, + }); + } + + async findByTeamAndMonth(teamId: number, monthKey: string): Promise { + return await this.prisma.monthlyProration.findUnique({ + where: { + teamId_monthKey: { + teamId, + monthKey, + }, + }, + }); + } + + async incrementRetryCount(prorationId: string): Promise { + return await this.prisma.monthlyProration.update({ + where: { id: prorationId }, + data: { + retryCount: { + increment: 1, + }, + }, + }); + } +} diff --git a/packages/features/ee/billing/repository/proration/MonthlyProrationTeamRepository.ts b/packages/features/ee/billing/repository/proration/MonthlyProrationTeamRepository.ts new file mode 100644 index 0000000000..cb49d0cc71 --- /dev/null +++ b/packages/features/ee/billing/repository/proration/MonthlyProrationTeamRepository.ts @@ -0,0 +1,218 @@ +import type { PrismaClient } from "@calcom/prisma"; +import { prisma as defaultPrisma } from "@calcom/prisma"; +import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; + +export interface BillingInfo { + id: string; + subscriptionId: string; + subscriptionItemId: string | null; + customerId: string; + subscriptionStart: Date | null; + subscriptionEnd: Date | null; + pricePerSeat: number | null; + billingPeriod: string | null; + paidSeats: number | null; +} + +export interface TeamWithBilling { + id: number; + isOrganization: boolean; + metadata: unknown; + billing: BillingInfo | null; + memberCount: number; +} + +export class MonthlyProrationTeamRepository { + private prisma: PrismaClient; + + constructor(prisma?: PrismaClient) { + this.prisma = prisma || defaultPrisma; + } + + async getTeamWithBilling(teamId: number): Promise { + const team = await this.prisma.team.findUnique({ + where: { id: teamId }, + select: { + id: true, + isOrganization: true, + metadata: true, + teamBilling: { + select: { + id: true, + subscriptionId: true, + subscriptionItemId: true, + customerId: true, + subscriptionStart: true, + subscriptionEnd: true, + pricePerSeat: true, + billingPeriod: true, + paidSeats: true, + }, + }, + organizationBilling: { + select: { + id: true, + subscriptionId: true, + subscriptionItemId: true, + customerId: true, + subscriptionStart: true, + subscriptionEnd: true, + pricePerSeat: true, + billingPeriod: true, + paidSeats: true, + }, + }, + _count: { + select: { members: true }, + }, + }, + }); + + if (!team) return null; + + let billing: BillingInfo | null = team.isOrganization ? team.organizationBilling : team.teamBilling; + + if (!billing) { + billing = this.extractBillingFromMetadata(team.metadata); + } + + return { + id: team.id, + isOrganization: team.isOrganization, + metadata: team.metadata, + billing, + memberCount: team._count.members, + }; + } + + async getAnnualTeamsWithSeatChanges(monthKey: string): Promise { + const teams = await this.prisma.team.findMany({ + where: { + OR: [ + { + teamBilling: { + billingPeriod: "ANNUALLY", + subscriptionTrialEnd: { lt: new Date() }, + }, + }, + { + organizationBilling: { + billingPeriod: "ANNUALLY", + subscriptionTrialEnd: { lt: new Date() }, + }, + }, + ], + seatChangeLogs: { + some: { + monthKey, + processedInProrationId: null, + }, + }, + }, + select: { id: true }, + }); + + return teams.map((team) => team.id); + } + + async updateBillingInfo( + teamId: number, + isOrganization: boolean, + billingId: string, + data: { + billingPeriod?: "MONTHLY" | "ANNUALLY"; + pricePerSeat?: number; + subscriptionStart?: Date; + subscriptionEnd?: Date; + } + ): Promise { + if (!billingId) return; + + if (isOrganization) { + await this.prisma.organizationBilling.update({ + where: { id: billingId }, + data, + }); + } else { + await this.prisma.teamBilling.update({ + where: { id: billingId }, + data, + }); + } + } + + async updatePaidSeats( + teamId: number, + isOrganization: boolean, + billingId: string, + paidSeats: number + ): Promise { + if (!billingId) return; + + if (isOrganization) { + await this.prisma.organizationBilling.update({ + where: { id: billingId }, + data: { paidSeats }, + }); + } else { + await this.prisma.teamBilling.update({ + where: { id: billingId }, + data: { paidSeats }, + }); + } + } + + async createOrganizationBilling(data: { + teamId: number; + subscriptionId: string; + subscriptionItemId: string; + customerId: string; + status: string; + planName: string; + billingPeriod: "MONTHLY" | "ANNUALLY"; + pricePerSeat: number; + paidSeats: number; + subscriptionStart: Date | null; + subscriptionEnd: Date | null; + subscriptionTrialEnd: Date | null; + }) { + return await this.prisma.organizationBilling.create({ data }); + } + + async createTeamBilling(data: { + teamId: number; + subscriptionId: string; + subscriptionItemId: string; + customerId: string; + status: string; + planName: string; + billingPeriod: "MONTHLY" | "ANNUALLY"; + pricePerSeat: number; + paidSeats: number; + subscriptionStart: Date | null; + subscriptionEnd: Date | null; + subscriptionTrialEnd: Date | null; + }) { + return await this.prisma.teamBilling.create({ data }); + } + + private extractBillingFromMetadata(metadata: unknown): BillingInfo | null { + const parsed = teamMetadataSchema.parse(metadata); + + if (!parsed?.subscriptionId) { + return null; + } + + return { + id: "", + subscriptionId: parsed.subscriptionId, + subscriptionItemId: parsed.subscriptionItemId || null, + customerId: parsed.paymentId || "", + subscriptionStart: null, + subscriptionEnd: null, + pricePerSeat: parsed.orgPricePerSeat || null, + billingPeriod: parsed.billingPeriod || null, + paidSeats: null, + }; + } +} diff --git a/packages/features/ee/billing/repository/seatChangeLogs/SeatChangeLogRepository.ts b/packages/features/ee/billing/repository/seatChangeLogs/SeatChangeLogRepository.ts new file mode 100644 index 0000000000..8162ad692f --- /dev/null +++ b/packages/features/ee/billing/repository/seatChangeLogs/SeatChangeLogRepository.ts @@ -0,0 +1,117 @@ +import type { PrismaClient } from "@calcom/prisma"; +import { prisma as defaultPrisma } from "@calcom/prisma"; +import type { Prisma, SeatChangeLog } from "@calcom/prisma/client"; +import type { SeatChangeType } from "@calcom/prisma/enums"; + +export interface CreateSeatChangeLogData { + teamId: number; + changeType: SeatChangeType; + seatCount: number; + userId?: number; + triggeredBy?: number; + monthKey: string; + metadata?: Prisma.InputJsonValue; + teamBillingId: string | null; + organizationBillingId: string | null; +} + +export interface MonthlyChangesResult { + additions: number; + removals: number; +} + +export class SeatChangeLogRepository { + private prisma: PrismaClient; + + constructor(prisma?: PrismaClient) { + this.prisma = prisma || defaultPrisma; + } + + async create(data: CreateSeatChangeLogData): Promise { + return await this.prisma.seatChangeLog.create({ + data, + }); + } + + async getMonthlyChanges(params: { teamId: number; monthKey: string }): Promise { + const { teamId, monthKey } = params; + + const changes = await this.prisma.seatChangeLog.groupBy({ + by: ["changeType"], + where: { + teamId, + monthKey, + processedInProrationId: null, + }, + _sum: { + seatCount: true, + }, + }); + + const additions = changes.find((c) => c.changeType === "ADDITION")?._sum.seatCount || 0; + const removals = changes.find((c) => c.changeType === "REMOVAL")?._sum.seatCount || 0; + + return { + additions, + removals, + }; + } + + async getUnprocessedChanges(params: { teamId: number; monthKey: string }): Promise { + const { teamId, monthKey } = params; + + return await this.prisma.seatChangeLog.findMany({ + where: { + teamId, + monthKey, + processedInProrationId: null, + }, + orderBy: { + changeDate: "asc", + }, + }); + } + + async markAsProcessed(params: { teamId: number; monthKey: string; prorationId: string }): Promise { + const { teamId, monthKey, prorationId } = params; + + const result = await this.prisma.seatChangeLog.updateMany({ + where: { + teamId, + monthKey, + processedInProrationId: null, + }, + data: { + processedInProrationId: prorationId, + }, + }); + + return result.count; + } + + async getTeamBillingIds( + teamId: number + ): Promise<{ teamBillingId: string | null; organizationBillingId: string | null }> { + const team = await this.prisma.team.findUnique({ + where: { id: teamId }, + select: { + isOrganization: true, + teamBilling: { + select: { id: true }, + }, + organizationBilling: { + select: { id: true }, + }, + }, + }); + + if (!team) { + throw new Error(`Team ${teamId} not found`); + } + + return { + teamBillingId: team.teamBilling?.id || null, + organizationBillingId: team.organizationBilling?.id || null, + }; + } +} diff --git a/packages/features/ee/billing/service/billingPeriod/BillingPeriodService.ts b/packages/features/ee/billing/service/billingPeriod/BillingPeriodService.ts new file mode 100644 index 0000000000..3e0dff2e24 --- /dev/null +++ b/packages/features/ee/billing/service/billingPeriod/BillingPeriodService.ts @@ -0,0 +1,192 @@ +import { BillingPeriodRepository } from "@calcom/features/ee/billing/repository/billingPeriod/BillingPeriodRepository"; +import { extractBillingDataFromStripeSubscription } from "@calcom/features/ee/billing/lib/stripe-subscription-utils"; +import stripe from "@calcom/features/ee/payments/server/stripe"; +import { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import logger from "@calcom/lib/logger"; +import { prisma } from "@calcom/prisma"; +import type { BillingPeriod } from "@calcom/prisma/enums"; +import type { Logger } from "tslog"; + +const log = logger.getSubLogger({ prefix: ["BillingPeriodService"] }); + +export interface BillingPeriodInfo { + billingPeriod: BillingPeriod | null; + subscriptionStart: Date | null; + subscriptionEnd: Date | null; + trialEnd: Date | null; + isInTrial: boolean; + pricePerSeat: number | null; + isOrganization: boolean; +} + +export class BillingPeriodService { + private logger: Logger; + private repository: BillingPeriodRepository; + + constructor(customLogger?: Logger, repository?: BillingPeriodRepository) { + this.logger = customLogger || log; + this.repository = repository || new BillingPeriodRepository(); + } + + async isAnnualPlan(teamId: number): Promise { + const info = await this.getOrCreateBillingPeriodInfo(teamId); + return info.billingPeriod === "ANNUALLY"; + } + + async isInTrialPeriod(teamId: number): Promise { + const info = await this.getOrCreateBillingPeriodInfo(teamId); + return info.isInTrial; + } + + async shouldApplyMonthlyProration(teamId: number): Promise { + try { + const featuresRepository = new FeaturesRepository(prisma); + const isFeatureEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("monthly-proration"); + + if (!isFeatureEnabled) { + return false; + } + + const info = await this.getOrCreateBillingPeriodInfo(teamId); + + return info.billingPeriod === "ANNUALLY" && !info.isInTrial && info.subscriptionStart !== null; + } catch (error) { + log.error(`Failed to check if monthly proration should apply for team ${teamId}`, { error }); + return false; + } + } + + async getOrCreateBillingPeriodInfo(teamId: number): Promise { + const team = await prisma.team.findUnique({ + where: { id: teamId }, + select: { + isOrganization: true, + teamBilling: { + select: { + id: true, + billingPeriod: true, + subscriptionStart: true, + subscriptionEnd: true, + subscriptionTrialEnd: true, + pricePerSeat: true, + paidSeats: true, + subscriptionId: true, + }, + }, + organizationBilling: { + select: { + id: true, + billingPeriod: true, + subscriptionStart: true, + subscriptionEnd: true, + subscriptionTrialEnd: true, + pricePerSeat: true, + paidSeats: true, + subscriptionId: true, + }, + }, + }, + }); + + if (!team) throw new Error(`Team ${teamId} not found`); + + const isOrganization = team.isOrganization; + const billing = isOrganization ? team.organizationBilling : team.teamBilling; + + if (!billing) { + return { + billingPeriod: null, + subscriptionStart: null, + subscriptionEnd: null, + trialEnd: null, + isInTrial: false, + pricePerSeat: null, + isOrganization, + }; + } + + if (!billing.billingPeriod && billing.subscriptionId) { + log.info( + `Backfilling missing billing data for team ${teamId} from Stripe subscription ${billing.subscriptionId}` + ); + + try { + const subscription = await stripe.subscriptions.retrieve(billing.subscriptionId); + const { billingPeriod, pricePerSeat, paidSeats } = + extractBillingDataFromStripeSubscription(subscription); + + if (isOrganization) { + await this.repository.updateOrganizationBillingPeriod(billing.id, { + billingPeriod, + pricePerSeat: pricePerSeat ?? null, + paidSeats: paidSeats ?? null, + }); + } else { + await this.repository.updateTeamBillingPeriod(billing.id, { + billingPeriod, + pricePerSeat: pricePerSeat ?? null, + paidSeats: paidSeats ?? null, + }); + } + + const now = new Date(); + const isInTrial = billing.subscriptionTrialEnd ? new Date(billing.subscriptionTrialEnd) > now : false; + + return { + billingPeriod, + subscriptionStart: billing.subscriptionStart, + subscriptionEnd: billing.subscriptionEnd, + trialEnd: billing.subscriptionTrialEnd, + isInTrial, + pricePerSeat: pricePerSeat ?? null, + isOrganization, + }; + } catch (error) { + log.error(`Failed to backfill billing data from Stripe for team ${teamId}`, { error }); + } + } + + const now = new Date(); + const isInTrial = billing.subscriptionTrialEnd ? new Date(billing.subscriptionTrialEnd) > now : false; + + return { + billingPeriod: billing.billingPeriod, + subscriptionStart: billing.subscriptionStart, + subscriptionEnd: billing.subscriptionEnd, + trialEnd: billing.subscriptionTrialEnd, + isInTrial, + pricePerSeat: billing.pricePerSeat, + isOrganization, + }; + } + + async getBillingPeriodInfo(teamId: number): Promise { + return this.getOrCreateBillingPeriodInfo(teamId); + } + + async updateBillingPeriod(params: { + teamId: number; + billingPeriod: BillingPeriod; + pricePerSeat: number; + }): Promise { + const { teamId, billingPeriod, pricePerSeat } = params; + + const team = await this.repository.getTeamForBillingUpdate(teamId); + + if (!team) throw new Error(`Team ${teamId} not found`); + + if (team.isOrganization && team.organizationBilling) { + await this.repository.updateOrganizationBillingPeriod(team.organizationBilling.id, { + billingPeriod, + pricePerSeat, + }); + } else if (!team.isOrganization && team.teamBilling) { + await this.repository.updateTeamBillingPeriod(team.teamBilling.id, { + billingPeriod, + pricePerSeat, + }); + } else { + throw new Error(`No billing record found for team ${teamId}`); + } + } +} diff --git a/packages/features/ee/billing/service/billingPeriod/__tests__/BillingPeriodService.test.ts b/packages/features/ee/billing/service/billingPeriod/__tests__/BillingPeriodService.test.ts new file mode 100644 index 0000000000..67e1d1da04 --- /dev/null +++ b/packages/features/ee/billing/service/billingPeriod/__tests__/BillingPeriodService.test.ts @@ -0,0 +1,422 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { FeaturesRepository } from "@calcom/features/flags/features.repository"; +import { prisma } from "@calcom/prisma"; + +import { BillingPeriodService } from "../BillingPeriodService"; + +vi.mock("@calcom/prisma", () => ({ + prisma: { + team: { + findUnique: vi.fn(), + }, + teamBilling: { + update: vi.fn(), + }, + organizationBilling: { + update: vi.fn(), + }, + }, +})); + +vi.mock("@calcom/lib/logger", () => ({ + default: { + getSubLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), + }, +})); + +describe("BillingPeriodService", () => { + let service: BillingPeriodService; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(FeaturesRepository.prototype, "checkIfFeatureIsEnabledGlobally").mockResolvedValue(true); + service = new BillingPeriodService(); + }); + + describe("isAnnualPlan", () => { + it("should return true for annual plans", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValue({ + id: 1, + isOrganization: false, + teamBilling: { + id: 1, + billingPeriod: "ANNUALLY", + subscriptionStart: new Date(), + subscriptionEnd: new Date(), + subscriptionTrialEnd: null, + pricePerSeat: 10000, + paidSeats: 5, + subscriptionId: "sub_123", + }, + organizationBilling: null, + } as any); + + const result = await service.isAnnualPlan(1); + expect(result).toBe(true); + }); + + it("should return false for monthly plans", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValue({ + id: 1, + isOrganization: false, + teamBilling: { + id: 1, + billingPeriod: "MONTHLY", + subscriptionStart: new Date(), + subscriptionEnd: new Date(), + subscriptionTrialEnd: null, + pricePerSeat: 10000, + paidSeats: 5, + subscriptionId: "sub_123", + }, + organizationBilling: null, + } as any); + + const result = await service.isAnnualPlan(1); + expect(result).toBe(false); + }); + + it("should return false when no billing record exists", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValue({ + id: 1, + isOrganization: false, + teamBilling: null, + organizationBilling: null, + } as any); + + const result = await service.isAnnualPlan(1); + expect(result).toBe(false); + }); + }); + + describe("isInTrialPeriod", () => { + it("should return true when trial end is in the future", async () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 7); // 7 days in future + + vi.mocked(prisma.team.findUnique).mockResolvedValue({ + id: 1, + isOrganization: false, + teamBilling: { + id: 1, + billingPeriod: "ANNUALLY", + subscriptionStart: new Date(), + subscriptionEnd: new Date(), + subscriptionTrialEnd: futureDate, + pricePerSeat: 10000, + paidSeats: 5, + subscriptionId: "sub_123", + }, + organizationBilling: null, + } as any); + + const result = await service.isInTrialPeriod(1); + expect(result).toBe(true); + }); + + it("should return false when trial end is in the past", async () => { + const pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - 7); // 7 days in past + + vi.mocked(prisma.team.findUnique).mockResolvedValue({ + id: 1, + isOrganization: false, + teamBilling: { + id: 1, + billingPeriod: "ANNUALLY", + subscriptionStart: new Date(), + subscriptionEnd: new Date(), + subscriptionTrialEnd: pastDate, + pricePerSeat: 10000, + paidSeats: 5, + subscriptionId: "sub_123", + }, + organizationBilling: null, + } as any); + + const result = await service.isInTrialPeriod(1); + expect(result).toBe(false); + }); + + it("should return false when no trial end date", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValue({ + id: 1, + isOrganization: false, + teamBilling: { + id: 1, + billingPeriod: "ANNUALLY", + subscriptionStart: new Date(), + subscriptionEnd: new Date(), + subscriptionTrialEnd: null, + pricePerSeat: 10000, + paidSeats: 5, + subscriptionId: "sub_123", + }, + organizationBilling: null, + } as any); + + const result = await service.isInTrialPeriod(1); + expect(result).toBe(false); + }); + }); + + describe("shouldApplyMonthlyProration", () => { + it("should return true for annual plan not in trial with active subscription", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValue({ + id: 1, + isOrganization: false, + teamBilling: { + id: 1, + billingPeriod: "ANNUALLY", + subscriptionStart: new Date(), + subscriptionEnd: new Date(), + subscriptionTrialEnd: null, + pricePerSeat: 10000, + paidSeats: 5, + subscriptionId: "sub_123", + }, + organizationBilling: null, + } as any); + + const result = await service.shouldApplyMonthlyProration(1); + expect(result).toBe(true); + }); + + it("should return false for monthly plans", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValue({ + id: 1, + isOrganization: false, + teamBilling: { + id: 1, + billingPeriod: "MONTHLY", + subscriptionStart: new Date(), + subscriptionEnd: new Date(), + subscriptionTrialEnd: null, + pricePerSeat: 10000, + paidSeats: 5, + subscriptionId: "sub_123", + }, + organizationBilling: null, + } as any); + + const result = await service.shouldApplyMonthlyProration(1); + expect(result).toBe(false); + }); + + it("should return false when in trial period", async () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 7); + + vi.mocked(prisma.team.findUnique).mockResolvedValue({ + id: 1, + isOrganization: false, + teamBilling: { + id: 1, + billingPeriod: "ANNUALLY", + subscriptionStart: new Date(), + subscriptionEnd: new Date(), + subscriptionTrialEnd: futureDate, + pricePerSeat: 10000, + paidSeats: 5, + subscriptionId: "sub_123", + }, + organizationBilling: null, + } as any); + + const result = await service.shouldApplyMonthlyProration(1); + expect(result).toBe(false); + }); + + it("should return false when no subscription exists", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValue({ + id: 1, + isOrganization: false, + teamBilling: null, + organizationBilling: null, + } as any); + + const result = await service.shouldApplyMonthlyProration(1); + expect(result).toBe(false); + }); + + it("should return false on error", async () => { + vi.mocked(prisma.team.findUnique).mockRejectedValue(new Error("Database error")); + + const result = await service.shouldApplyMonthlyProration(1); + expect(result).toBe(false); + }); + }); + + describe("getBillingPeriodInfo", () => { + it("should return complete billing info for team", async () => { + const subscriptionStart = new Date("2026-01-01"); + const subscriptionEnd = new Date("2027-01-01"); + + vi.mocked(prisma.team.findUnique).mockResolvedValue({ + id: 1, + isOrganization: false, + teamBilling: { + id: 1, + billingPeriod: "ANNUALLY", + subscriptionStart, + subscriptionEnd, + subscriptionTrialEnd: null, + pricePerSeat: 10000, + paidSeats: 5, + subscriptionId: "sub_123", + }, + organizationBilling: null, + } as any); + + const result = await service.getBillingPeriodInfo(1); + + expect(result).toEqual({ + billingPeriod: "ANNUALLY", + subscriptionStart, + subscriptionEnd, + trialEnd: null, + isInTrial: false, + pricePerSeat: 10000, + isOrganization: false, + }); + }); + + it("should use organizationBilling for organizations", async () => { + const subscriptionStart = new Date("2026-01-01"); + + vi.mocked(prisma.team.findUnique).mockResolvedValue({ + id: 1, + isOrganization: true, + teamBilling: null, + organizationBilling: { + id: 1, + billingPeriod: "ANNUALLY", + subscriptionStart, + subscriptionEnd: new Date("2027-01-01"), + subscriptionTrialEnd: null, + pricePerSeat: 20000, + paidSeats: 10, + subscriptionId: "sub_456", + }, + } as any); + + const result = await service.getBillingPeriodInfo(1); + + expect(result.pricePerSeat).toBe(20000); + expect(result.isOrganization).toBe(true); + }); + + it("should return null values when no billing record", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValue({ + id: 1, + isOrganization: false, + teamBilling: null, + organizationBilling: null, + } as any); + + const result = await service.getBillingPeriodInfo(1); + + expect(result).toEqual({ + billingPeriod: null, + subscriptionStart: null, + subscriptionEnd: null, + trialEnd: null, + isInTrial: false, + pricePerSeat: null, + isOrganization: false, + }); + }); + + it("should throw error when team not found", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValue(null); + + await expect(service.getBillingPeriodInfo(999)).rejects.toThrow("Team 999 not found"); + }); + }); + + describe("updateBillingPeriod", () => { + it("should update team billing for non-organization teams", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValue({ + id: 1, + isOrganization: false, + teamBilling: { id: "team-billing-123" }, + organizationBilling: null, + } as any); + + vi.mocked(prisma.teamBilling.update).mockResolvedValue({} as any); + + await service.updateBillingPeriod({ + teamId: 1, + billingPeriod: "ANNUALLY", + pricePerSeat: 10000, + }); + + expect(prisma.teamBilling.update).toHaveBeenCalledWith({ + where: { id: "team-billing-123" }, + data: { + billingPeriod: "ANNUALLY", + pricePerSeat: 10000, + }, + }); + }); + + it("should update organization billing for organizations", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValue({ + id: 1, + isOrganization: true, + teamBilling: null, + organizationBilling: { id: "org-billing-456" }, + } as any); + + vi.mocked(prisma.organizationBilling.update).mockResolvedValue({} as any); + + await service.updateBillingPeriod({ + teamId: 1, + billingPeriod: "MONTHLY", + pricePerSeat: 5000, + }); + + expect(prisma.organizationBilling.update).toHaveBeenCalledWith({ + where: { id: "org-billing-456" }, + data: { + billingPeriod: "MONTHLY", + pricePerSeat: 5000, + }, + }); + }); + + it("should throw error when team not found", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValue(null); + + await expect( + service.updateBillingPeriod({ + teamId: 999, + billingPeriod: "ANNUALLY", + pricePerSeat: 10000, + }) + ).rejects.toThrow("Team 999 not found"); + }); + + it("should throw error when no billing record exists", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValue({ + id: 1, + isOrganization: false, + teamBilling: null, + organizationBilling: null, + } as any); + + await expect( + service.updateBillingPeriod({ + teamId: 1, + billingPeriod: "ANNUALLY", + pricePerSeat: 10000, + }) + ).rejects.toThrow("No billing record found for team 1"); + }); + }); +}); diff --git a/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts b/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts index 6b2f0eb0ff..b4b4ca43b2 100644 --- a/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts +++ b/packages/features/ee/billing/service/billingProvider/IBillingProviderService.ts @@ -65,4 +65,40 @@ export interface IBillingProviderService { getCustomer(customerId: string): Promise; getSubscriptions(customerId: string): Promise; updateCustomer(args: { customerId: string; email: string; userId?: number }): Promise; + + // Invoice management + createInvoiceItem(args: { + customerId: string; + amount: number; + currency: string; + description: string; + metadata?: Record; + }): Promise<{ invoiceItemId: string }>; + + createInvoice(args: { + customerId: string; + autoAdvance: boolean; + metadata?: Record; + }): Promise<{ invoiceId: string }>; + + finalizeInvoice(invoiceId: string): Promise; + + // Subscription queries + getSubscription(subscriptionId: string): Promise<{ + items: Array<{ + id: string; + quantity: number; + price: { + unit_amount: number | null; + recurring: { + interval: string; + } | null; + }; + }>; + customer: string; + status: string; + current_period_start: number; + current_period_end: number; + trial_end: number | null; + } | null>; } diff --git a/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts b/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts index c1bbda7262..ec4d2ec09c 100644 --- a/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts +++ b/packages/features/ee/billing/service/billingProvider/StripeBillingService.ts @@ -243,4 +243,53 @@ export class StripeBillingService implements IBillingProviderService { return status || SubscriptionStatus.ACTIVE; } + + async createInvoiceItem(args: Parameters[0]) { + const { customerId, amount, currency, description, metadata } = args; + const invoiceItem = await this.stripe.invoiceItems.create({ + customer: customerId, + amount, + currency, + description, + metadata, + }); + + return { invoiceItemId: invoiceItem.id }; + } + + async createInvoice(args: Parameters[0]) { + const { customerId, autoAdvance, metadata } = args; + const invoice = await this.stripe.invoices.create({ + customer: customerId, + auto_advance: autoAdvance, + metadata, + }); + + return { invoiceId: invoice.id }; + } + + async finalizeInvoice(invoiceId: string) { + await this.stripe.invoices.finalizeInvoice(invoiceId); + } + + async getSubscription(subscriptionId: string) { + const subscription = await this.stripe.subscriptions.retrieve(subscriptionId); + if (!subscription) return null; + + return { + items: subscription.items.data.map((item) => ({ + id: item.id, + quantity: item.quantity || 0, + price: { + unit_amount: item.price.unit_amount, + recurring: item.price.recurring, + }, + })), + customer: subscription.customer as string, + status: subscription.status, + current_period_start: subscription.current_period_start, + current_period_end: subscription.current_period_end, + trial_end: subscription.trial_end, + }; + } } diff --git a/packages/features/ee/billing/service/proration/MonthlyProrationService.ts b/packages/features/ee/billing/service/proration/MonthlyProrationService.ts new file mode 100644 index 0000000000..3d9c86fa77 --- /dev/null +++ b/packages/features/ee/billing/service/proration/MonthlyProrationService.ts @@ -0,0 +1,402 @@ +import stripe from "@calcom/features/ee/payments/server/stripe"; +import logger from "@calcom/lib/logger"; +import type { Logger } from "tslog"; +import { MonthlyProrationRepository } from "../../repository/proration/MonthlyProrationRepository"; +import type { BillingInfo } from "../../repository/proration/MonthlyProrationTeamRepository"; +import { MonthlyProrationTeamRepository } from "../../repository/proration/MonthlyProrationTeamRepository"; +import type { IBillingProviderService } from "../billingProvider/IBillingProviderService"; +import { StripeBillingService } from "../billingProvider/StripeBillingService"; +import { SeatChangeTrackingService } from "../seatTracking/SeatChangeTrackingService"; + +const log = logger.getSubLogger({ prefix: ["MonthlyProrationService"] }); + +interface ProrationCalculation { + proratedAmount: number; + remainingDays: number; +} + +interface CreateProrationParams { + teamId: number; + monthKey: string; +} + +interface ProcessMonthlyProrationsParams { + monthKey: string; + teamIds?: number[]; +} + +export class MonthlyProrationService { + private logger: Logger; + private teamRepository: MonthlyProrationTeamRepository; + private prorationRepository: MonthlyProrationRepository; + private billingService: IBillingProviderService; + + constructor(customLogger?: Logger, billingService?: IBillingProviderService) { + this.logger = customLogger || log; + this.teamRepository = new MonthlyProrationTeamRepository(); + this.prorationRepository = new MonthlyProrationRepository(); + this.billingService = billingService || new StripeBillingService(stripe); + } + + async processMonthlyProrations(params: ProcessMonthlyProrationsParams) { + const { monthKey, teamIds } = params; + + const teamIdsList = teamIds || (await this.teamRepository.getAnnualTeamsWithSeatChanges(monthKey)); + + const teamsToProcess = teamIdsList.map((id: number) => ({ id })); + + const results = []; + for (const team of teamsToProcess) { + const result = await this.createProrationForTeam({ + teamId: team.id, + monthKey, + }); + if (result) results.push(result); + } + + return results; + } + + async createProrationForTeam(params: CreateProrationParams) { + const { teamId, monthKey } = params; + + const seatTracker = new SeatChangeTrackingService(); + + const changes = await seatTracker.getMonthlyChanges({ teamId, monthKey }); + + // If there are no changes at all (no additions or removals), skip processing + if (changes.additions === 0 && changes.removals === 0) { + return null; + } + + const teamWithBilling = await this.teamRepository.getTeamWithBilling(teamId); + + if (!teamWithBilling) throw new Error(`Team ${teamId} not found`); + if (!teamWithBilling.billing) { + throw new Error(`No billing record or metadata found for team ${teamId}`); + } + + const billing = teamWithBilling.billing; + + if (!billing.billingPeriod || !billing.pricePerSeat) { + this.logger.info(`[${teamId}] no billing record, checking metadata for subscription info`); + } + + await this.ensureBillingDataPopulated(teamId, teamWithBilling.isOrganization, billing); + + if (!billing.pricePerSeat) throw new Error(`No price per seat found for team ${teamId}`); + if (!billing.subscriptionStart || !billing.subscriptionEnd) { + throw new Error(`Incomplete subscription info for team ${teamId}`); + } + if (!billing.subscriptionItemId) { + throw new Error(`No subscription item ID found for team ${teamId}`); + } + + const currentSeatCount = teamWithBilling.memberCount; + + const paidSeats = billing.paidSeats ?? (await this.getSubscriptionQuantity(billing.subscriptionId)); + + const [year, month] = monthKey.split("-").map(Number); + const periodEnd = new Date(Date.UTC(year, month, 0, 23, 59, 59, 999)); + + const calculation = this.calculateProration({ + netSeatIncrease: changes.netChange, + subscriptionStart: billing.subscriptionStart, + subscriptionEnd: billing.subscriptionEnd, + pricePerSeat: billing.pricePerSeat, + monthEnd: periodEnd, + }); + + const proration = await this.prorationRepository.createProration({ + teamId, + monthKey, + periodStart: new Date(Date.UTC(year, month - 1, 1)), + periodEnd, + seatsAtStart: paidSeats, + seatsAdded: changes.additions, + seatsRemoved: changes.removals, + netSeatIncrease: changes.netChange, + seatsAtEnd: currentSeatCount, + subscriptionId: billing.subscriptionId, + subscriptionItemId: billing.subscriptionItemId, + customerId: billing.customerId, + subscriptionStart: billing.subscriptionStart, + subscriptionEnd: billing.subscriptionEnd, + remainingDays: calculation.remainingDays, + pricePerSeat: billing.pricePerSeat, + proratedAmount: calculation.proratedAmount, + teamBillingId: teamWithBilling.isOrganization ? null : billing.id, + organizationBillingId: teamWithBilling.isOrganization ? billing.id : null, + }); + + await seatTracker.markAsProcessed({ + teamId, + monthKey, + prorationId: proration.id, + }); + + if (calculation.proratedAmount > 0) { + const updatedProration = await this.createStripeInvoiceItem(proration); + return updatedProration; + } + + await this.updateSubscriptionQuantity( + proration.subscriptionId, + proration.subscriptionItemId, + proration.seatsAtEnd + ); + + await this.teamRepository.updatePaidSeats( + teamId, + teamWithBilling.isOrganization, + billing.id, + proration.seatsAtEnd + ); + + return await this.prorationRepository.updateProrationStatus(proration.id, "CHARGED", { + chargedAt: new Date(), + }); + } + + private calculateProration(params: { + netSeatIncrease: number; + subscriptionStart: Date; + subscriptionEnd: Date; + pricePerSeat: number; + monthEnd: Date; + }): ProrationCalculation { + const { netSeatIncrease, subscriptionStart, subscriptionEnd, pricePerSeat, monthEnd } = params; + + const subscriptionEndDate = new Date(subscriptionEnd); + const monthEndDate = new Date(monthEnd); + const subscriptionStartDate = new Date(subscriptionStart); + + const remainingMs = subscriptionEndDate.getTime() - monthEndDate.getTime(); + const remainingDays = Math.max(0, Math.ceil(remainingMs / (1000 * 60 * 60 * 24))); + + const totalSubscriptionDays = Math.ceil( + (subscriptionEndDate.getTime() - subscriptionStartDate.getTime()) / (1000 * 60 * 60 * 24) + ); + + const proratedAmount = (pricePerSeat * netSeatIncrease * remainingDays) / totalSubscriptionDays; + + return { + proratedAmount: Math.round(proratedAmount), + remainingDays, + }; + } + + private async createStripeInvoiceItem(proration: { + id: string; + customerId: string; + proratedAmount: number; + netSeatIncrease: number; + monthKey: string; + teamId: number; + }) { + const amountInCents = Math.round(proration.proratedAmount); + + const { invoiceItemId } = await this.billingService.createInvoiceItem({ + customerId: proration.customerId, + amount: amountInCents, + currency: "usd", + description: `Additional ${proration.netSeatIncrease} seat${ + proration.netSeatIncrease > 1 ? "s" : "" + } for ${proration.monthKey}`, + metadata: { + type: "monthly_proration", + prorationId: proration.id, + teamId: proration.teamId.toString(), + monthKey: proration.monthKey, + }, + }); + + const { invoiceId } = await this.billingService.createInvoice({ + customerId: proration.customerId, + autoAdvance: true, + metadata: { + type: "monthly_proration", + prorationId: proration.id, + }, + }); + + await this.billingService.finalizeInvoice(invoiceId); + + const updatedProration = await this.prorationRepository.updateProrationStatus( + proration.id, + "INVOICE_CREATED", + { + invoiceItemId, + invoiceId, + } + ); + + return updatedProration; + } + + async handleProrationPaymentSuccess(prorationId: string) { + const proration = await this.prorationRepository.findById(prorationId); + if (!proration) { + throw new Error(`Proration ${prorationId} not found`); + } + + await this.prorationRepository.updateProrationStatus(prorationId, "CHARGED", { + chargedAt: new Date(), + }); + + await this.updateSubscriptionQuantity( + proration.subscriptionId, + proration.subscriptionItemId, + proration.seatsAtEnd + ); + + 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; + } + } + + async handleProrationPaymentFailure(params: { prorationId: string; reason: string }) { + const { prorationId, reason } = params; + + const proration = await this.prorationRepository.findById(prorationId); + if (!proration) throw new Error(`Proration ${prorationId} not found`); + + await this.prorationRepository.updateProrationStatus(prorationId, "FAILED", { + failedAt: new Date(), + failureReason: reason, + retryCount: (proration.retryCount || 0) + 1, + }); + } + + async retryFailedProration(prorationId: string) { + const proration = await this.prorationRepository.findById(prorationId); + + if (!proration) throw new Error(`Proration ${prorationId} not found`); + if (proration.status !== "FAILED") throw new Error(`Proration ${prorationId} is not in FAILED status`); + + await this.createStripeInvoiceItem(proration); + } + + private async ensureBillingDataPopulated(teamId: number, isOrganization: boolean, billing: BillingInfo) { + const needsCreation = !billing.id || billing.id === ""; + + if ( + !needsCreation && + billing.billingPeriod && + billing.pricePerSeat && + billing.subscriptionStart && + billing.subscriptionEnd + ) { + return; + } + + this.logger.info(`[${teamId}] ${needsCreation ? "creating" : "populating"} billing data from stripe`); + + try { + const subscription = await this.billingService.getSubscription(billing.subscriptionId); + + if (!subscription) { + 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 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, + subscriptionItemId, + customerId, + status: subscription.status.toUpperCase(), + planName: isOrganization ? "ORGANIZATION" : "TEAM", + billingPeriod, + pricePerSeat, + paidSeats, + subscriptionStart, + subscriptionEnd, + subscriptionTrialEnd, + }; + + const createdBilling = isOrganization + ? await this.teamRepository.createOrganizationBilling(billingData) + : await this.teamRepository.createTeamBilling(billingData); + + billing.id = createdBilling.id; + billing.customerId = customerId; + billing.subscriptionItemId = subscriptionItemId; + billing.paidSeats = paidSeats; + } else { + await this.teamRepository.updateBillingInfo(teamId, isOrganization, billing.id, { + billingPeriod, + pricePerSeat, + ...(subscriptionStart && { subscriptionStart }), + ...(subscriptionEnd && { subscriptionEnd }), + }); + } + + this.logger.info( + `[${teamId}] ${needsCreation ? "created" : "populated"}: ${billingPeriod}, ${pricePerSeat} cents/seat` + ); + + billing.billingPeriod = billingPeriod; + billing.pricePerSeat = pricePerSeat; + billing.subscriptionStart = subscriptionStart; + billing.subscriptionEnd = subscriptionEnd; + billing.subscriptionItemId = subscriptionItemId; + if (needsCreation) { + billing.customerId = customerId; + } + } catch (error) { + this.logger.error( + `[${teamId}] failed to ${needsCreation ? "create" : "populate"} billing data:`, + error + ); + throw error; + } + } + + private async getSubscriptionQuantity(subscriptionId: string): Promise { + const subscription = await this.billingService.getSubscription(subscriptionId); + return subscription?.items[0]?.quantity || 0; + } +} 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 new file mode 100644 index 0000000000..fdd5670e3d --- /dev/null +++ b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts @@ -0,0 +1,352 @@ +import prisma from "@calcom/prisma"; +import type { Team, User } from "@calcom/prisma/client"; +import { MembershipRole } from "@calcom/prisma/enums"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +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" }), + createInvoice: vi.fn().mockResolvedValue({ invoiceId: "in_test_123" }), + finalizeInvoice: vi.fn().mockResolvedValue(undefined), + getSubscription: vi.fn().mockResolvedValue({ + items: [ + { id: "si_test_123", quantity: 1, price: { unit_amount: 12000, recurring: { interval: "year" } } }, + ], + customer: "cus_test_123", + status: "active", + current_period_start: 1622505600, + current_period_end: 1654041600, + trial_end: null, + }), + handleSubscriptionUpdate: vi.fn().mockResolvedValue(undefined), + // Add stub implementations for other required interface methods + checkoutSessionIsPaid: vi.fn().mockResolvedValue(true), + handleSubscriptionCancel: vi.fn().mockResolvedValue(undefined), + handleSubscriptionCreation: vi.fn().mockResolvedValue(undefined), + handleEndTrial: vi.fn().mockResolvedValue(undefined), + createCustomer: vi.fn().mockResolvedValue({ stripeCustomerId: "cus_test_123" }), + createPaymentIntent: vi.fn().mockResolvedValue({ id: "pi_test_123", client_secret: "secret_123" }), + createSubscriptionCheckout: vi + .fn() + .mockResolvedValue({ checkoutUrl: "https://checkout.test", sessionId: "cs_test_123" }), + createPrice: vi.fn().mockResolvedValue({ priceId: "price_test_123" }), + getPrice: vi.fn().mockResolvedValue(null), + getSubscriptionStatus: vi.fn().mockResolvedValue(null), + getCheckoutSession: vi.fn().mockResolvedValue(null), + getCustomer: vi.fn().mockResolvedValue(null), + getSubscriptions: vi.fn().mockResolvedValue(null), + updateCustomer: vi.fn().mockResolvedValue(undefined), +} as IBillingProviderService; + +describe("MonthlyProrationService Integration Tests", () => { + let testUser: User; + let testTeam: Team; + const monthKey = "2026-01"; + + beforeEach(async () => { + const timestamp = Date.now(); + const randomSuffix = Math.random().toString(36).substring(7); + + testUser = await prisma.user.create({ + data: { + email: `test-proration-${timestamp}-${randomSuffix}@example.com`, + username: `testproration-${timestamp}-${randomSuffix}`, + name: "Test Proration User", + }, + }); + + testTeam = await prisma.team.create({ + data: { + name: `Test Proration Team ${timestamp}-${randomSuffix}`, + slug: `test-proration-team-${timestamp}-${randomSuffix}`, + isOrganization: false, + }, + }); + + await prisma.membership.create({ + data: { + userId: testUser.id, + teamId: testTeam.id, + role: MembershipRole.OWNER, + accepted: true, + }, + }); + + const subscriptionStart = new Date("2025-06-01T00:00:00Z"); + const subscriptionEnd = new Date("2026-06-01T00:00:00Z"); + const subscriptionTrialEnd = new Date("2025-06-08T00:00:00Z"); + + await prisma.teamBilling.create({ + data: { + teamId: testTeam.id, + subscriptionId: `sub_test_${timestamp}`, + subscriptionItemId: `si_test_${timestamp}`, + customerId: `cus_test_${timestamp}`, + billingPeriod: "ANNUALLY", + pricePerSeat: 12000, + paidSeats: 0, + subscriptionStart, + subscriptionEnd, + subscriptionTrialEnd, + status: "ACTIVE", + planName: "TEAM", + }, + }); + }); + + it("should process end-to-end proration for annual team with seat additions", async () => { + const seatTracker = new SeatChangeTrackingService(); + const prorationService = new MonthlyProrationService(undefined, mockBillingService); + + await seatTracker.logSeatAddition({ + teamId: testTeam.id, + userId: testUser.id, + triggeredBy: testUser.id, + seatCount: 3, + }); + + await seatTracker.logSeatRemoval({ + teamId: testTeam.id, + userId: testUser.id, + triggeredBy: testUser.id, + seatCount: 1, + }); + + const proration = await prorationService.createProrationForTeam({ + teamId: testTeam.id, + monthKey, + }); + + expect(proration).toBeDefined(); + expect(proration?.netSeatIncrease).toBe(2); + expect(proration?.seatsAdded).toBe(3); + expect(proration?.seatsRemoved).toBe(1); + expect(proration?.status).toBe("INVOICE_CREATED"); + expect(proration?.invoiceItemId).toBe("ii_test_123"); + + const seatChanges = await prisma.seatChangeLog.findMany({ + where: { teamId: testTeam.id, monthKey }, + }); + + expect(seatChanges).toHaveLength(2); + expect(seatChanges.every((sc) => sc.processedInProrationId === proration?.id)).toBe(true); + }); + + it("should create a $0 proration for team with no net change", async () => { + const seatTracker = new SeatChangeTrackingService(); + const prorationService = new MonthlyProrationService(undefined, mockBillingService); + + await seatTracker.logSeatAddition({ + teamId: testTeam.id, + userId: testUser.id, + triggeredBy: testUser.id, + seatCount: 2, + }); + + await seatTracker.logSeatRemoval({ + teamId: testTeam.id, + userId: testUser.id, + triggeredBy: testUser.id, + seatCount: 2, + }); + + const proration = await prorationService.createProrationForTeam({ + teamId: testTeam.id, + monthKey, + }); + + expect(proration).toBeDefined(); + expect(proration?.netSeatIncrease).toBe(0); + expect(proration?.seatsAdded).toBe(2); + expect(proration?.seatsRemoved).toBe(2); + expect(proration?.proratedAmount).toBe(0); + expect(proration?.status).toBe("CHARGED"); + expect(proration?.invoiceItemId).toBeNull(); + + const prorations = await prisma.monthlyProration.findMany({ + where: { teamId: testTeam.id, monthKey }, + }); + + expect(prorations).toHaveLength(1); + expect(prorations[0]?.id).toBe(proration?.id); + }); + + it("should process multiple teams in batch", async () => { + const timestamp = Date.now(); + + const testTeam2 = await prisma.team.create({ + data: { + name: `Test Proration Team 2 ${timestamp}`, + slug: `test-proration-team-2-${timestamp}`, + isOrganization: false, + }, + }); + + await prisma.membership.create({ + data: { + userId: testUser.id, + teamId: testTeam2.id, + role: MembershipRole.OWNER, + accepted: true, + }, + }); + + await prisma.teamBilling.create({ + data: { + teamId: testTeam2.id, + subscriptionId: `sub_test_2_${timestamp}`, + subscriptionItemId: `si_test_2_${timestamp}`, + customerId: `cus_test_2_${timestamp}`, + billingPeriod: "ANNUALLY", + pricePerSeat: 10000, + paidSeats: 0, + subscriptionStart: new Date("2025-06-01T00:00:00Z"), + subscriptionEnd: new Date("2026-06-01T00:00:00Z"), + subscriptionTrialEnd: new Date("2025-06-08T00:00:00Z"), + status: "ACTIVE", + planName: "TEAM", + }, + }); + + const seatTracker = new SeatChangeTrackingService(); + + await seatTracker.logSeatAddition({ + teamId: testTeam.id, + userId: testUser.id, + triggeredBy: testUser.id, + seatCount: 2, + }); + + await seatTracker.logSeatAddition({ + teamId: testTeam2.id, + userId: testUser.id, + triggeredBy: testUser.id, + seatCount: 3, + }); + + const prorationService = new MonthlyProrationService(undefined, mockBillingService); + const results = await prorationService.processMonthlyProrations({ monthKey }); + + const filteredResults = results.filter((r) => [testTeam.id, testTeam2.id].includes(r.teamId)); + expect(filteredResults).toHaveLength(2); + expect(filteredResults.every((r) => r.status === "INVOICE_CREATED")).toBe(true); + }); + + it("should handle payment success callback", async () => { + const seatTracker = new SeatChangeTrackingService(); + const prorationService = new MonthlyProrationService(undefined, mockBillingService); + + await seatTracker.logSeatAddition({ + teamId: testTeam.id, + userId: testUser.id, + triggeredBy: testUser.id, + seatCount: 2, + }); + + const proration = await prorationService.createProrationForTeam({ + teamId: testTeam.id, + monthKey, + }); + + expect(proration?.status).toBe("INVOICE_CREATED"); + + await prorationService.handleProrationPaymentSuccess(proration!.id); + + const updated = await prisma.monthlyProration.findUnique({ + where: { id: proration!.id }, + }); + + expect(updated?.status).toBe("CHARGED"); + expect(updated?.chargedAt).toBeDefined(); + }); + + it("should handle payment failure callback", async () => { + const seatTracker = new SeatChangeTrackingService(); + const prorationService = new MonthlyProrationService(undefined, mockBillingService); + + await seatTracker.logSeatAddition({ + teamId: testTeam.id, + userId: testUser.id, + triggeredBy: testUser.id, + seatCount: 2, + }); + + const proration = await prorationService.createProrationForTeam({ + teamId: testTeam.id, + monthKey, + }); + + await prorationService.handleProrationPaymentFailure({ + prorationId: proration!.id, + reason: "insufficient funds", + }); + + const updated = await prisma.monthlyProration.findUnique({ + where: { id: proration!.id }, + }); + + expect(updated?.status).toBe("FAILED"); + expect(updated?.failedAt).toBeDefined(); + expect(updated?.failureReason).toBe("insufficient funds"); + expect(updated?.retryCount).toBe(1); + }); + + it("should call handleSubscriptionUpdate when updating subscription quantity", async () => { + const seatTracker = new SeatChangeTrackingService(); + const prorationService = new MonthlyProrationService(undefined, mockBillingService); + + // Reset the mock to track calls + vi.mocked(mockBillingService.handleSubscriptionUpdate).mockClear(); + + await seatTracker.logSeatAddition({ + teamId: testTeam.id, + userId: testUser.id, + triggeredBy: testUser.id, + seatCount: 2, + }); + + const proration = await prorationService.createProrationForTeam({ + teamId: testTeam.id, + monthKey, + }); + + expect(proration?.status).toBe("INVOICE_CREATED"); + + await prorationService.handleProrationPaymentSuccess(proration!.id); + + // Verify handleSubscriptionUpdate was called with correct parameters + expect(mockBillingService.handleSubscriptionUpdate).toHaveBeenCalledWith({ + subscriptionId: proration!.subscriptionId, + subscriptionItemId: proration!.subscriptionItemId, + membershipCount: proration!.seatsAtEnd, + }); + }); + + it("should throw error when subscription update fails", async () => { + const seatTracker = new SeatChangeTrackingService(); + const failingBillingService = { + ...mockBillingService, + handleSubscriptionUpdate: vi.fn().mockRejectedValue(new Error("Subscription not found")), + }; + const prorationService = new MonthlyProrationService(undefined, failingBillingService); + + await seatTracker.logSeatAddition({ + teamId: testTeam.id, + userId: testUser.id, + triggeredBy: testUser.id, + seatCount: 2, + }); + + const proration = await prorationService.createProrationForTeam({ + teamId: testTeam.id, + monthKey, + }); + + // Should throw when trying to update subscription + await expect(prorationService.handleProrationPaymentSuccess(proration!.id)).rejects.toThrow( + "Subscription not found" + ); + }); +}); diff --git a/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts new file mode 100644 index 0000000000..a037fdec30 --- /dev/null +++ b/packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.test.ts @@ -0,0 +1,404 @@ +import { prisma } from "@calcom/prisma"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { IBillingProviderService } from "../../billingProvider/IBillingProviderService"; + +import { MonthlyProrationService } from "../MonthlyProrationService"; + +vi.mock("@calcom/prisma", () => ({ + prisma: { + team: { + findUnique: vi.fn(), + findMany: vi.fn(), + }, + monthlyProration: { + create: vi.fn(), + update: vi.fn(), + findUnique: vi.fn(), + }, + }, +})); + +vi.mock("@calcom/lib/logger", () => ({ + default: { + getSubLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), + }, +})); + +vi.mock("@calcom/features/ee/payments/server/stripe", () => ({ + default: { + invoiceItems: { + create: vi.fn(), + }, + invoices: { + create: vi.fn(), + finalizeInvoice: vi.fn(), + }, + subscriptions: { + retrieve: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("../../seatTracking/SeatChangeTrackingService", () => ({ + SeatChangeTrackingService: class { + async getMonthlyChanges() { + return { additions: 5, removals: 2, netChange: 3 }; + } + async markAsProcessed() { + return 3; + } + }, +})); + +const mockTeamRepository = { + getTeamWithBilling: vi.fn(), + getAnnualTeamsWithSeatChanges: vi.fn(), + updatePaidSeats: vi.fn(), +}; + +const mockProrationRepository = { + createProration: vi.fn(), + findById: vi.fn(), + updateProrationStatus: vi.fn(), +}; + +const mockBillingService: IBillingProviderService = { + createInvoiceItem: vi.fn().mockResolvedValue({ invoiceItemId: "ii_test_123" }), + createInvoice: vi.fn().mockResolvedValue({ invoiceId: "in_test_123" }), + finalizeInvoice: vi.fn().mockResolvedValue(undefined), + getSubscription: vi.fn().mockResolvedValue({ + items: [ + { id: "si_test_123", quantity: 1, price: { unit_amount: 12000, recurring: { interval: "year" } } }, + ], + customer: "cus_test_123", + status: "active", + current_period_start: 1622505600, + current_period_end: 1654041600, + trial_end: null, + }), + handleSubscriptionUpdate: vi.fn().mockResolvedValue(undefined), + checkoutSessionIsPaid: vi.fn().mockResolvedValue(true), + handleSubscriptionCancel: vi.fn().mockResolvedValue(undefined), + handleSubscriptionCreation: vi.fn().mockResolvedValue(undefined), + handleEndTrial: vi.fn().mockResolvedValue(undefined), + createCustomer: vi.fn().mockResolvedValue({ stripeCustomerId: "cus_test_123" }), + createPaymentIntent: vi.fn().mockResolvedValue({ id: "pi_test_123", client_secret: "secret_123" }), + createSubscriptionCheckout: vi + .fn() + .mockResolvedValue({ checkoutUrl: "https://checkout.test", sessionId: "cs_test_123" }), + createPrice: vi.fn().mockResolvedValue({ priceId: "price_test_123" }), + getPrice: vi.fn().mockResolvedValue(null), + getSubscriptionStatus: vi.fn().mockResolvedValue(null), + getCheckoutSession: vi.fn().mockResolvedValue(null), + getCustomer: vi.fn().mockResolvedValue(null), + getSubscriptions: vi.fn().mockResolvedValue(null), + updateCustomer: vi.fn().mockResolvedValue(undefined), +} as IBillingProviderService; + +vi.mock("../../../repository/proration/MonthlyProrationTeamRepository", () => ({ + MonthlyProrationTeamRepository: class { + getTeamWithBilling = mockTeamRepository.getTeamWithBilling; + getAnnualTeamsWithSeatChanges = mockTeamRepository.getAnnualTeamsWithSeatChanges; + updatePaidSeats = mockTeamRepository.updatePaidSeats; + }, +})); + +vi.mock("../../../repository/proration/MonthlyProrationRepository", () => ({ + MonthlyProrationRepository: class { + createProration = mockProrationRepository.createProration; + findById = mockProrationRepository.findById; + updateProrationStatus = mockProrationRepository.updateProrationStatus; + }, +})); + +describe("MonthlyProrationService", () => { + let service: MonthlyProrationService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new MonthlyProrationService(undefined, mockBillingService); + }); + + describe("createProrationForTeam", () => { + it("should create proration with $0 amount when seat count equals paid seats", async () => { + const { SeatChangeTrackingService } = await import("../../seatTracking/SeatChangeTrackingService"); + + vi.spyOn(SeatChangeTrackingService.prototype, "getMonthlyChanges").mockResolvedValueOnce({ + additions: 5, + removals: 5, + netChange: 0, + }); + + vi.spyOn(SeatChangeTrackingService.prototype, "markAsProcessed").mockResolvedValueOnce(0); + + mockTeamRepository.getTeamWithBilling.mockResolvedValueOnce({ + id: 1, + isOrganization: false, + memberCount: 10, + billing: { + id: "billing-123", + subscriptionId: "sub_123", + subscriptionItemId: "si_123", + customerId: "cus_123", + billingPeriod: "ANNUALLY", + pricePerSeat: 10000, + subscriptionStart: new Date("2026-01-01"), + subscriptionEnd: new Date("2027-01-01"), + paidSeats: 10, + }, + }); + + mockProrationRepository.createProration.mockResolvedValueOnce({ + id: "proration-123", + customerId: "cus_123", + proratedAmount: 0, + netSeatIncrease: 0, + monthKey: "2026-01", + teamId: 1, + subscriptionId: "sub_123", + subscriptionItemId: "si_123", + seatsAtEnd: 10, + } as any); + + mockProrationRepository.updateProrationStatus.mockResolvedValueOnce({ + id: "proration-123", + status: "CHARGED", + proratedAmount: 0, + } as any); + + const result = await service.createProrationForTeam({ + teamId: 1, + monthKey: "2026-01", + }); + + expect(result).toBeDefined(); + expect(result?.proratedAmount).toBe(0); + expect(mockBillingService.handleSubscriptionUpdate).toHaveBeenCalledWith({ + subscriptionId: "sub_123", + subscriptionItemId: "si_123", + membershipCount: 10, + }); + }); + + it("should create proration for team with net seat increase", 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(3); + + mockTeamRepository.getTeamWithBilling.mockResolvedValueOnce({ + id: 1, + isOrganization: false, + memberCount: 13, + billing: { + id: "team-billing-123", + subscriptionId: "sub_123", + subscriptionItemId: "si_123", + customerId: "cus_123", + billingPeriod: "ANNUALLY", + pricePerSeat: 10000, + subscriptionStart, + subscriptionEnd, + paidSeats: 10, + }, + }); + + mockProrationRepository.createProration.mockResolvedValueOnce({ + id: "proration-123", + customerId: "cus_123", + proratedAmount: 7541, + netSeatIncrease: 3, + monthKey: "2026-01", + teamId: 1, + subscriptionId: "sub_123", + subscriptionItemId: "si_123", + seatsAtEnd: 13, + } as any); + + mockProrationRepository.updateProrationStatus.mockResolvedValueOnce({ + id: "proration-123", + status: "INVOICE_CREATED", + } as any); + + const result = await service.createProrationForTeam({ + teamId: 1, + monthKey: "2026-01", + }); + + expect(result).toBeDefined(); + expect(mockProrationRepository.createProration).toHaveBeenCalled(); + expect(mockBillingService.createInvoiceItem).toHaveBeenCalled(); + expect(mockBillingService.createInvoice).toHaveBeenCalled(); + expect(mockBillingService.finalizeInvoice).toHaveBeenCalled(); + }); + + it("should use organization billing for organizations", async () => { + const { SeatChangeTrackingService } = await import("../../seatTracking/SeatChangeTrackingService"); + vi.spyOn(SeatChangeTrackingService.prototype, "markAsProcessed").mockResolvedValueOnce(3); + + mockTeamRepository.getTeamWithBilling.mockResolvedValueOnce({ + id: 1, + isOrganization: true, + memberCount: 13, + billing: { + id: "org-billing-456", + subscriptionId: "sub_456", + subscriptionItemId: "si_456", + customerId: "cus_456", + billingPeriod: "ANNUALLY", + pricePerSeat: 20000, + subscriptionStart: new Date("2026-01-01"), + subscriptionEnd: new Date("2027-01-01"), + paidSeats: 10, + }, + }); + + mockProrationRepository.createProration.mockResolvedValueOnce({ + id: "proration-456", + customerId: "cus_456", + proratedAmount: 15000, + netSeatIncrease: 3, + monthKey: "2026-01", + teamId: 1, + subscriptionId: "sub_456", + subscriptionItemId: "si_456", + seatsAtEnd: 13, + } as any); + + mockProrationRepository.updateProrationStatus.mockResolvedValueOnce({ + id: "proration-456", + status: "INVOICE_CREATED", + } as any); + + await service.createProrationForTeam({ teamId: 1, monthKey: "2026-01" }); + + expect(mockProrationRepository.createProration).toHaveBeenCalledWith( + expect.objectContaining({ + organizationBillingId: "org-billing-456", + teamBillingId: null, + }) + ); + }); + + it("should throw error when team not found", async () => { + mockTeamRepository.getTeamWithBilling.mockResolvedValueOnce(null); + + await expect(service.createProrationForTeam({ teamId: 999, monthKey: "2026-01" })).rejects.toThrow( + "Team 999 not found" + ); + }); + + it("should throw error when no billing record", async () => { + mockTeamRepository.getTeamWithBilling.mockResolvedValueOnce({ + id: 1, + isOrganization: false, + memberCount: 10, + billing: null, + }); + + await expect(service.createProrationForTeam({ teamId: 1, monthKey: "2026-01" })).rejects.toThrow( + "No billing record or metadata found for team 1" + ); + }); + }); + + describe("handleProrationPaymentSuccess", () => { + it("should update proration status to CHARGED", async () => { + mockProrationRepository.findById.mockResolvedValueOnce({ + id: "proration-123", + status: "INVOICE_CREATED", + subscriptionId: "sub_123", + subscriptionItemId: "si_123", + seatsAtEnd: 13, + teamId: 1, + teamBillingId: "billing-123", + organizationBillingId: null, + } as any); + + mockProrationRepository.updateProrationStatus.mockResolvedValueOnce(undefined); + + await service.handleProrationPaymentSuccess("proration-123"); + + expect(mockProrationRepository.updateProrationStatus).toHaveBeenCalledWith("proration-123", "CHARGED", { + chargedAt: expect.any(Date), + }); + expect(mockBillingService.handleSubscriptionUpdate).toHaveBeenCalledWith({ + subscriptionId: "sub_123", + subscriptionItemId: "si_123", + membershipCount: 13, + }); + }); + }); + + describe("handleProrationPaymentFailure", () => { + it("should update proration status to FAILED with reason", async () => { + mockProrationRepository.findById.mockResolvedValueOnce({ + id: "proration-123", + status: "INVOICE_CREATED", + retryCount: 0, + } as any); + + mockProrationRepository.updateProrationStatus.mockResolvedValueOnce(undefined); + + await service.handleProrationPaymentFailure({ + prorationId: "proration-123", + reason: "Card declined", + }); + + expect(mockProrationRepository.updateProrationStatus).toHaveBeenCalledWith("proration-123", "FAILED", { + failedAt: expect.any(Date), + failureReason: "Card declined", + retryCount: 1, + }); + }); + }); + + describe("retryFailedProration", () => { + it("should recreate invoice item for failed proration", async () => { + mockProrationRepository.findById.mockResolvedValueOnce({ + id: "proration-123", + status: "FAILED", + customerId: "cus_123", + proratedAmount: 10000, + netSeatIncrease: 2, + monthKey: "2026-01", + teamId: 1, + subscriptionId: "sub_123", + subscriptionItemId: "si_123", + } as any); + + mockProrationRepository.updateProrationStatus.mockResolvedValueOnce(undefined); + + await service.retryFailedProration("proration-123"); + + expect(mockBillingService.createInvoiceItem).toHaveBeenCalled(); + expect(mockBillingService.createInvoice).toHaveBeenCalled(); + expect(mockBillingService.finalizeInvoice).toHaveBeenCalled(); + }); + + it("should throw error if proration not found", async () => { + mockProrationRepository.findById.mockResolvedValueOnce(null); + + await expect(service.retryFailedProration("proration-999")).rejects.toThrow( + "Proration proration-999 not found" + ); + }); + + it("should throw error if proration is not in FAILED status", async () => { + mockProrationRepository.findById.mockResolvedValueOnce({ + id: "proration-123", + status: "CHARGED", + } as any); + + await expect(service.retryFailedProration("proration-123")).rejects.toThrow( + "Proration proration-123 is not in FAILED status" + ); + }); + }); +}); diff --git a/packages/features/ee/billing/service/seatTracking/SeatChangeTrackingService.ts b/packages/features/ee/billing/service/seatTracking/SeatChangeTrackingService.ts new file mode 100644 index 0000000000..03b9a83678 --- /dev/null +++ b/packages/features/ee/billing/service/seatTracking/SeatChangeTrackingService.ts @@ -0,0 +1,103 @@ +import type { Logger } from "tslog"; + +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"; + +const log = logger.getSubLogger({ prefix: ["SeatChangeTrackingService"] }); + +export interface SeatChangeLogParams { + teamId: number; + userId?: number; + triggeredBy?: number; + seatCount?: number; + metadata?: Prisma.InputJsonValue; + monthKey?: string; +} + +export interface MonthlyChanges { + additions: number; + removals: number; + netChange: number; +} + +export class SeatChangeTrackingService { + private logger: Logger; + private repository: SeatChangeLogRepository; + + constructor(customLogger?: Logger, repository?: SeatChangeLogRepository) { + this.logger = customLogger || log; + this.repository = repository || new SeatChangeLogRepository(); + } + + async logSeatAddition(params: SeatChangeLogParams): Promise { + const { teamId, userId, triggeredBy, seatCount = 1, metadata, monthKey: providedMonthKey } = params; + const monthKey = providedMonthKey || this.calculateMonthKey(new Date()); + + const { teamBillingId, organizationBillingId } = await this.repository.getTeamBillingIds(teamId); + + await this.repository.create({ + teamId, + changeType: "ADDITION" as SeatChangeType, + seatCount, + userId, + triggeredBy, + monthKey, + metadata: (metadata || {}) as Prisma.InputJsonValue, + teamBillingId, + organizationBillingId, + }); + } + + async logSeatRemoval(params: SeatChangeLogParams): Promise { + const { teamId, userId, triggeredBy, seatCount = 1, metadata, monthKey: providedMonthKey } = params; + const monthKey = providedMonthKey || this.calculateMonthKey(new Date()); + + const { teamBillingId, organizationBillingId } = await this.repository.getTeamBillingIds(teamId); + + await this.repository.create({ + teamId, + changeType: "REMOVAL" as SeatChangeType, + seatCount, + userId, + triggeredBy, + monthKey, + metadata: (metadata || {}) as Prisma.InputJsonValue, + teamBillingId, + organizationBillingId, + }); + } + + async getMonthlyChanges(params: { teamId: number; monthKey: string }): Promise { + const { teamId, monthKey } = params; + + const { additions, removals } = await this.repository.getMonthlyChanges({ teamId, monthKey }); + + const netChange = Math.max(0, additions - removals); + + return { + additions, + removals, + netChange, + }; + } + + async getUnprocessedChanges(params: { teamId: number; monthKey: string }) { + const { teamId, monthKey } = params; + + return await this.repository.getUnprocessedChanges({ teamId, monthKey }); + } + + async markAsProcessed(params: { teamId: number; monthKey: string; prorationId: string }): Promise { + const { teamId, monthKey, prorationId } = params; + + 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/seatTracking/__tests__/SeatChangeTrackingService.test.ts b/packages/features/ee/billing/service/seatTracking/__tests__/SeatChangeTrackingService.test.ts new file mode 100644 index 0000000000..f5fba52f16 --- /dev/null +++ b/packages/features/ee/billing/service/seatTracking/__tests__/SeatChangeTrackingService.test.ts @@ -0,0 +1,329 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { prisma } from "@calcom/prisma"; + +import { SeatChangeTrackingService } from "../SeatChangeTrackingService"; + +vi.mock("@calcom/prisma", () => ({ + prisma: { + seatChangeLog: { + create: vi.fn(), + groupBy: vi.fn(), + findMany: vi.fn(), + updateMany: vi.fn(), + }, + team: { + findUnique: vi.fn(), + }, + }, +})); + +vi.mock("@calcom/lib/logger", () => ({ + default: { + getSubLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), + }, +})); + +describe("SeatChangeTrackingService", () => { + let service: SeatChangeTrackingService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new SeatChangeTrackingService(); + }); + + describe("logSeatAddition", () => { + it("should log seat addition with correct data", async () => { + const teamId = 1; + const userId = 100; + const triggeredBy = 50; + + vi.mocked(prisma.team.findUnique).mockResolvedValue({ + id: teamId, + isOrganization: false, + teamBilling: { id: "team-billing-123" }, + organizationBilling: null, + } as any); + + vi.mocked(prisma.seatChangeLog.create).mockResolvedValue({} as any); + + await service.logSeatAddition({ + teamId, + userId, + triggeredBy, + seatCount: 1, + metadata: { test: "data" }, + }); + + expect(prisma.seatChangeLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + teamId, + changeType: "ADDITION", + seatCount: 1, + userId, + triggeredBy, + teamBillingId: "team-billing-123", + organizationBillingId: null, + }), + }); + }); + + it("should use organization billing ID for organizations", async () => { + const teamId = 1; + + vi.mocked(prisma.team.findUnique).mockResolvedValue({ + id: teamId, + isOrganization: true, + teamBilling: null, + organizationBilling: { id: "org-billing-456" }, + } as any); + + vi.mocked(prisma.seatChangeLog.create).mockResolvedValue({} as any); + + await service.logSeatAddition({ + teamId, + userId: 100, + }); + + expect(prisma.seatChangeLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + teamBillingId: null, + organizationBillingId: "org-billing-456", + }), + }); + }); + + it("should throw error if team not found", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValue(null); + + await expect( + service.logSeatAddition({ + teamId: 999, + userId: 100, + }) + ).rejects.toThrow("Team 999 not found"); + }); + + it("should calculate month key correctly", async () => { + const teamId = 1; + + vi.mocked(prisma.team.findUnique).mockResolvedValue({ + id: teamId, + isOrganization: false, + teamBilling: { id: "team-billing-123" }, + organizationBilling: null, + } as any); + + vi.mocked(prisma.seatChangeLog.create).mockResolvedValue({} as any); + + // Mock date to January 2026 + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T12:00:00Z")); + + await service.logSeatAddition({ + teamId, + userId: 100, + }); + + expect(prisma.seatChangeLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + monthKey: "2026-01", + }), + }); + + vi.useRealTimers(); + }); + }); + + describe("logSeatRemoval", () => { + it("should log seat removal with correct data", async () => { + const teamId = 1; + const userId = 100; + + vi.mocked(prisma.team.findUnique).mockResolvedValue({ + id: teamId, + isOrganization: false, + teamBilling: { id: "team-billing-123" }, + organizationBilling: null, + } as any); + + vi.mocked(prisma.seatChangeLog.create).mockResolvedValue({} as any); + + await service.logSeatRemoval({ + teamId, + userId, + seatCount: 2, + }); + + expect(prisma.seatChangeLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + teamId, + changeType: "REMOVAL", + seatCount: 2, + userId, + }), + }); + }); + }); + + describe("getMonthlyChanges", () => { + it("should calculate net change correctly", async () => { + const teamId = 1; + const monthKey = "2026-01"; + + vi.mocked(prisma.seatChangeLog.groupBy).mockResolvedValue([ + { + changeType: "ADDITION" as const, + _sum: { seatCount: 10 }, + }, + { + changeType: "REMOVAL" as const, + _sum: { seatCount: 3 }, + }, + ] as any); + + const result = await service.getMonthlyChanges({ teamId, monthKey }); + + expect(result).toEqual({ + additions: 10, + removals: 3, + netChange: 7, + }); + }); + + it("should handle month with only additions", async () => { + vi.mocked(prisma.seatChangeLog.groupBy).mockResolvedValue([ + { + changeType: "ADDITION" as const, + _sum: { seatCount: 5 }, + }, + ] as any); + + const result = await service.getMonthlyChanges({ + teamId: 1, + monthKey: "2026-01", + }); + + expect(result).toEqual({ + additions: 5, + removals: 0, + netChange: 5, + }); + }); + + it("should handle month with only removals", async () => { + vi.mocked(prisma.seatChangeLog.groupBy).mockResolvedValue([ + { + changeType: "REMOVAL" as const, + _sum: { seatCount: 3 }, + }, + ] as any); + + const result = await service.getMonthlyChanges({ + teamId: 1, + monthKey: "2026-01", + }); + + expect(result).toEqual({ + additions: 0, + removals: 3, + netChange: 0, // Never negative + }); + }); + + it("should cap net change at 0 for negative values", async () => { + vi.mocked(prisma.seatChangeLog.groupBy).mockResolvedValue([ + { + changeType: "ADDITION" as const, + _sum: { seatCount: 2 }, + }, + { + changeType: "REMOVAL" as const, + _sum: { seatCount: 5 }, + }, + ] as any); + + const result = await service.getMonthlyChanges({ + teamId: 1, + monthKey: "2026-01", + }); + + expect(result).toEqual({ + additions: 2, + removals: 5, + netChange: 0, // Capped at 0 + }); + }); + + it("should handle month with no changes", async () => { + vi.mocked(prisma.seatChangeLog.groupBy).mockResolvedValue([]); + + const result = await service.getMonthlyChanges({ + teamId: 1, + monthKey: "2026-01", + }); + + expect(result).toEqual({ + additions: 0, + removals: 0, + netChange: 0, + }); + }); + }); + + describe("getUnprocessedChanges", () => { + it("should fetch unprocessed changes for a team and month", async () => { + const teamId = 1; + const monthKey = "2026-01"; + + const mockChanges = [ + { id: "1", teamId, monthKey, changeType: "ADDITION" }, + { id: "2", teamId, monthKey, changeType: "REMOVAL" }, + ]; + + vi.mocked(prisma.seatChangeLog.findMany).mockResolvedValue(mockChanges as any); + + const result = await service.getUnprocessedChanges({ teamId, monthKey }); + + expect(result).toEqual(mockChanges); + expect(prisma.seatChangeLog.findMany).toHaveBeenCalledWith({ + where: { + teamId, + monthKey, + processedInProrationId: null, + }, + orderBy: { + changeDate: "asc", + }, + }); + }); + }); + + describe("markAsProcessed", () => { + it("should mark changes as processed and return count", async () => { + const teamId = 1; + const monthKey = "2026-01"; + const prorationId = "proration-123"; + + vi.mocked(prisma.seatChangeLog.updateMany).mockResolvedValue({ count: 5 } as any); + + const count = await service.markAsProcessed({ teamId, monthKey, prorationId }); + + expect(count).toBe(5); + expect(prisma.seatChangeLog.updateMany).toHaveBeenCalledWith({ + where: { + teamId, + monthKey, + processedInProrationId: null, + }, + data: { + processedInProrationId: prorationId, + }, + }); + }); + }); +}); diff --git a/packages/features/flags/config.ts b/packages/features/flags/config.ts index 2f42baa20a..e4e220d4fe 100644 --- a/packages/features/flags/config.ts +++ b/packages/features/flags/config.ts @@ -34,6 +34,7 @@ export type AppFlags = { "booking-email-sms-tasker": boolean; "bookings-v3": boolean; "booking-audit": boolean; + "monthly-proration": boolean; "sidebar-tips": boolean; }; diff --git a/packages/features/flags/hooks/index.ts b/packages/features/flags/hooks/index.ts index d114eb0a47..ca7d78c080 100644 --- a/packages/features/flags/hooks/index.ts +++ b/packages/features/flags/hooks/index.ts @@ -33,6 +33,7 @@ const initialData: AppFlags = { "booking-email-sms-tasker": false, "bookings-v3": false, "booking-audit": false, + "monthly-proration": false, "sidebar-tips": false, }; diff --git a/packages/prisma/migrations/20260106093811_add_monthly_proration_tracking/migration.sql b/packages/prisma/migrations/20260106093811_add_monthly_proration_tracking/migration.sql new file mode 100644 index 0000000000..76705f1d6d --- /dev/null +++ b/packages/prisma/migrations/20260106093811_add_monthly_proration_tracking/migration.sql @@ -0,0 +1,117 @@ +-- CreateEnum +CREATE TYPE "public"."SeatChangeType" AS ENUM ('ADDITION', 'REMOVAL'); + +-- CreateEnum +CREATE TYPE "public"."ProrationStatus" AS ENUM ('PENDING', 'INVOICE_CREATED', 'CHARGED', 'FAILED', 'CANCELLED'); + +-- AlterTable +ALTER TABLE "public"."OrganizationBilling" ADD COLUMN "billingPeriod" "public"."BillingPeriod", +ADD COLUMN "pricePerSeat" INTEGER, +ADD COLUMN "paidSeats" INTEGER; + +-- AlterTable +ALTER TABLE "public"."TeamBilling" ADD COLUMN "billingPeriod" "public"."BillingPeriod", +ADD COLUMN "pricePerSeat" INTEGER, +ADD COLUMN "paidSeats" INTEGER; + +-- CreateTable +CREATE TABLE "public"."SeatChangeLog" ( + "id" TEXT NOT NULL, + "teamId" INTEGER NOT NULL, + "changeType" "public"."SeatChangeType" NOT NULL, + "seatCount" INTEGER NOT NULL, + "userId" INTEGER, + "triggeredBy" INTEGER, + "changeDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "monthKey" TEXT NOT NULL, + "processedInProrationId" TEXT, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "teamBillingId" TEXT, + "organizationBillingId" TEXT, + + CONSTRAINT "SeatChangeLog_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."MonthlyProration" ( + "id" TEXT NOT NULL, + "teamId" INTEGER NOT NULL, + "monthKey" TEXT NOT NULL, + "periodStart" TIMESTAMP(3) NOT NULL, + "periodEnd" TIMESTAMP(3) NOT NULL, + "seatsAtStart" INTEGER NOT NULL, + "seatsAdded" INTEGER NOT NULL, + "seatsRemoved" INTEGER NOT NULL, + "netSeatIncrease" INTEGER NOT NULL, + "seatsAtEnd" INTEGER NOT NULL, + "subscriptionId" TEXT NOT NULL, + "subscriptionItemId" TEXT NOT NULL, + "customerId" TEXT NOT NULL, + "subscriptionStart" TIMESTAMP(3) NOT NULL, + "subscriptionEnd" TIMESTAMP(3) NOT NULL, + "remainingDays" INTEGER NOT NULL, + "pricePerSeat" INTEGER NOT NULL, + "proratedAmount" INTEGER NOT NULL, + "invoiceItemId" TEXT, + "invoiceId" TEXT, + "status" "public"."ProrationStatus" NOT NULL DEFAULT 'PENDING', + "chargedAt" TIMESTAMP(3), + "failedAt" TIMESTAMP(3), + "failureReason" TEXT, + "retryCount" INTEGER NOT NULL DEFAULT 0, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "teamBillingId" TEXT, + "organizationBillingId" TEXT, + + CONSTRAINT "MonthlyProration_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "SeatChangeLog_teamId_monthKey_idx" ON "public"."SeatChangeLog"("teamId", "monthKey"); + +-- CreateIndex +CREATE INDEX "SeatChangeLog_teamId_processedInProrationId_idx" ON "public"."SeatChangeLog"("teamId", "processedInProrationId"); + +-- CreateIndex +CREATE INDEX "SeatChangeLog_monthKey_idx" ON "public"."SeatChangeLog"("monthKey"); + +-- CreateIndex +CREATE INDEX "MonthlyProration_monthKey_status_idx" ON "public"."MonthlyProration"("monthKey", "status"); + +-- CreateIndex +CREATE INDEX "MonthlyProration_status_idx" ON "public"."MonthlyProration"("status"); + +-- CreateIndex +CREATE INDEX "MonthlyProration_teamId_idx" ON "public"."MonthlyProration"("teamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "MonthlyProration_teamId_monthKey_key" ON "public"."MonthlyProration"("teamId", "monthKey"); + +-- AddForeignKey +ALTER TABLE "public"."SeatChangeLog" ADD CONSTRAINT "SeatChangeLog_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "public"."Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."SeatChangeLog" ADD CONSTRAINT "SeatChangeLog_processedInProrationId_fkey" FOREIGN KEY ("processedInProrationId") REFERENCES "public"."MonthlyProration"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."SeatChangeLog" ADD CONSTRAINT "SeatChangeLog_teamBillingId_fkey" FOREIGN KEY ("teamBillingId") REFERENCES "public"."TeamBilling"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."SeatChangeLog" ADD CONSTRAINT "SeatChangeLog_organizationBillingId_fkey" FOREIGN KEY ("organizationBillingId") REFERENCES "public"."OrganizationBilling"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."MonthlyProration" ADD CONSTRAINT "MonthlyProration_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "public"."Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."MonthlyProration" ADD CONSTRAINT "MonthlyProration_teamBillingId_fkey" FOREIGN KEY ("teamBillingId") REFERENCES "public"."TeamBilling"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."MonthlyProration" ADD CONSTRAINT "MonthlyProration_organizationBillingId_fkey" FOREIGN KEY ("organizationBillingId") REFERENCES "public"."OrganizationBilling"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- Insert feature flag (disabled by default) +INSERT INTO "public"."Feature" ("slug", "enabled", "description", "type", "stale", "lastUsedAt", "createdAt", "updatedAt") +VALUES ('monthly-proration', false, 'Monthly aggregated seat proration for annual plans', 'RELEASE', false, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT ("slug") DO NOTHING; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index aaf4e265d8..3d3a1a47ad 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -619,6 +619,10 @@ model Team { autoOptInFeatures Boolean @default(false) + // Monthly proration tracking + seatChangeLogs SeatChangeLog[] + monthlyProrations MonthlyProration[] + @@unique([slug, parentId]) @@index([parentId]) } @@ -2933,6 +2937,12 @@ model TeamBilling { subscriptionTrialEnd DateTime? subscriptionEnd DateTime? + billingPeriod BillingPeriod? + pricePerSeat Int? + paidSeats Int? + monthlyProrations MonthlyProration[] + seatChangeLogs SeatChangeLog[] + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -2951,10 +2961,110 @@ model OrganizationBilling { subscriptionTrialEnd DateTime? subscriptionEnd DateTime? + billingPeriod BillingPeriod? + pricePerSeat Int? + paidSeats Int? + monthlyProrations MonthlyProration[] + seatChangeLogs SeatChangeLog[] + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } +model SeatChangeLog { + id String @id @default(uuid()) + teamId Int + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + + changeType SeatChangeType + seatCount Int + userId Int? + triggeredBy Int? + + changeDate DateTime @default(now()) + monthKey String + + processedInProrationId String? + proration MonthlyProration? @relation(fields: [processedInProrationId], references: [id]) + + metadata Json? + createdAt DateTime @default(now()) + + teamBillingId String? + teamBilling TeamBilling? @relation(fields: [teamBillingId], references: [id]) + organizationBillingId String? + organizationBilling OrganizationBilling? @relation(fields: [organizationBillingId], references: [id]) + + @@index([teamId, monthKey]) + @@index([teamId, processedInProrationId]) + @@index([monthKey]) +} + +enum SeatChangeType { + ADDITION + REMOVAL +} + +model MonthlyProration { + id String @id @default(uuid()) + teamId Int + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + + monthKey String + periodStart DateTime + periodEnd DateTime + + seatsAtStart Int + seatsAdded Int + seatsRemoved Int + netSeatIncrease Int + seatsAtEnd Int + + subscriptionId String + subscriptionItemId String + customerId String + + subscriptionStart DateTime + subscriptionEnd DateTime + remainingDays Int + pricePerSeat Int + proratedAmount Int + + invoiceItemId String? + invoiceId String? + + status ProrationStatus @default(PENDING) + + chargedAt DateTime? + failedAt DateTime? + failureReason String? + retryCount Int @default(0) + + seatChanges SeatChangeLog[] + + metadata Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + teamBillingId String? + teamBilling TeamBilling? @relation(fields: [teamBillingId], references: [id]) + organizationBillingId String? + organizationBilling OrganizationBilling? @relation(fields: [organizationBillingId], references: [id]) + + @@unique([teamId, monthKey]) + @@index([monthKey, status]) + @@index([status]) + @@index([teamId]) +} + +enum ProrationStatus { + PENDING // Created, waiting to be processed + INVOICE_CREATED // Stripe invoice item created + CHARGED // Payment succeeded + FAILED // Payment failed + CANCELLED // Cancelled (e.g., subscription ended before charge) +} + enum CalendarCacheEventStatus { confirmed @map("confirmed") tentative @map("tentative")