feat: add DAL for monthly proration tracking (#26588)
* feat(billing): add database schema for monthly proration tracking add new database models and migration for tracking seat changes and monthly proration on annual subscriptions - new models: SeatChangeLog, MonthlyProration - new enums: SeatChangeType, ProrationStatus - extend TeamBilling and OrganizationBilling with billingPeriod, pricePerSeat, paidSeats fields - add feature flag 'monthly-proration' (disabled by default) - squashed migration: 20260106093811_add_monthly_proration_tracking the schema supports: - tracking seat additions/removals per month with metadata - storing proration calculations and stripe invoice details - high-water mark billing via paidSeats field - proper foreign key relationships and indexes for query performance * feat(billing): add repository layer for monthly proration add repository pattern for proration data access - MonthlyProrationRepository: crud operations for proration records - create, update, and query proration records - status management (pending, invoice_created, charged, failed) - retry count tracking - MonthlyProrationTeamRepository: team-specific queries with billing context - fetch teams with billing information - query annual teams with unprocessed seat changes - update billing info and paidSeats - metadata fallback for legacy teams without billing records - update IBillingRepository interface to include proration fields these repositories provide clean data access layer without business logic * feat(billing): add seat change tracking service add service for logging and querying seat changes per month - SeatChangeTrackingService: track seat additions and removals - log seat additions with user and metadata context - log seat removals with optional triggered-by info - calculate monthly changes (additions, removals, net change) - retrieve unprocessed changes for a given month - mark changes as processed after proration calculation - auto-detect billing entity (team vs organization) - stripe-subscription-utils: extract billing data from stripe subscriptions - determine billing period (monthly vs annually) - extract price per seat and quantity includes comprehensive unit tests * feat(billing): add billing period service add service to determine billing period and proration eligibility - BillingPeriodService: query and manage billing period information - check if team is on annual vs monthly plan - check if team is in trial period - determine if monthly proration should apply (annual + not in trial + feature flag) - lazy load billing data from stripe if missing in database - backfill missing billing data automatically supports both TeamBilling and OrganizationBilling models includes comprehensive unit tests with stripe mocking * feat(billing): add monthly proration service add core service for calculating and processing monthly seat prorations - MonthlyProrationService: orchestrate monthly proration workflow - process monthly prorations for teams with seat changes - calculate prorated amounts based on remaining subscription days - create stripe invoice items for seat additions - handle payment success and failure webhooks - track high-water mark (paidSeats) for billing - update stripe subscription quantities after payment - lazy load and backfill missing billing data from stripe - support retry logic for failed payments calculation logic: - only charge for net seat increases (additions - removals, capped at 0) - prorate based on remaining days in annual subscription - track separately from stripe's built-in proration to aggregate monthly includes unit tests and integration tests with stripe mocking * refactor(billing): extract prisma logic from services to repositories move database access logic from services to repository layer for better separation of concerns - add SeatChangeLogRepository for seat change log data access - add BillingPeriodRepository for billing period queries and updates - refactor SeatChangeTrackingService to use SeatChangeLogRepository - refactor BillingPeriodService to use BillingPeriodRepository - add create methods to MonthlyProrationTeamRepository for billing records - refactor MonthlyProrationService to use repository for billing creation - remove direct prisma calls from services (except FeaturesRepository dependency) services now focus on business logic while repositories handle data access * refactor(billing): inject prisma via constructor in repositories make repositories more testable by accepting prisma client via constructor - SeatChangeLogRepository: accept optional prisma in constructor - BillingPeriodRepository: accept optional prisma in constructor - MonthlyProrationRepository: accept optional prisma in constructor - MonthlyProrationTeamRepository: accept optional prisma in constructor all repositories default to global prisma instance when not provided enables easier mocking in tests by injecting test prisma instances * refactor: remove verbose comments and unnecessary try-catch * refactor: change pricePerSeat and proratedAmount to cents (Int) store monetary values as integers in cents instead of floats for precision remove unnecessary schema comments * fix tests and add back try catch error handlign * Update packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * add faeture flag to config.ts * Update packages/features/ee/billing/service/proration/__tests__/MonthlyProrationService.integration-test.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * restore schema * add hooks types for flag * fix: correct pricePerSeat calculations for cents-based storage MonthlyProrationService now correctly handles pricePerSeat stored as cents (INTEGER) instead of dollars (DOUBLE PRECISION). Removed duplicate conversions to cents that were causing 100x price inflation. * Use cents for money values * fix type error * Move integration to test to use cents value * fix integration import * mock subscription * include paid seats tracking in proration service * add mocks for invoice creation * feat: use stripebilling service instead of using stripe directly * fix validation erorr handler in proration service * fix: fix sub * use correct seat value * fix(billing): mark seat changes as processed when netChange is zero When a team has additions and removals that net to zero, the function was returning null without calling markAsProcessed(). This left seat change logs in an unprocessed state indefinitely, causing subsequent proration runs to re-query the same changes repeatedly. Changed the early return condition from checking netChange === 0 to checking if there are no changes at all (additions === 0 && removals === 0). This ensures that when there are actual seat changes that net to zero, a proration record is still created and seat changes are marked as processed. Co-Authored-By: unknown <> * update test to reflect new return type of proration --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Keith Williams <keithwillcode@gmail.com>
This commit is contained in:
co-authored by
unknown
cubic-dev-ai[bot]
Anik Dhabal Babu
Devin AI
Keith Williams
parent
4c0b0115a2
commit
ac90caa81b
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -44,4 +44,7 @@ export interface IBillingRepositoryCreateArgs {
|
||||
subscriptionStart?: Date;
|
||||
subscriptionTrialEnd?: Date;
|
||||
subscriptionEnd?: Date;
|
||||
billingPeriod?: "MONTHLY" | "ANNUALLY";
|
||||
pricePerSeat?: number;
|
||||
paidSeats?: number;
|
||||
}
|
||||
|
||||
@@ -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<TeamWithBillingInfo | null> {
|
||||
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<void> {
|
||||
await this.prisma.organizationBilling.update({
|
||||
where: { id: billingId },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async updateTeamBillingPeriod(billingId: string, data: UpdateBillingPeriodData): Promise<void> {
|
||||
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 } },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<MonthlyProration> {
|
||||
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<MonthlyProration> {
|
||||
return await this.prisma.monthlyProration.update({
|
||||
where: { id: prorationId },
|
||||
data: {
|
||||
status,
|
||||
...additionalData,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findById(prorationId: string): Promise<MonthlyProration | null> {
|
||||
return await this.prisma.monthlyProration.findUnique({
|
||||
where: { id: prorationId },
|
||||
});
|
||||
}
|
||||
|
||||
async findByTeamAndMonth(teamId: number, monthKey: string): Promise<MonthlyProration | null> {
|
||||
return await this.prisma.monthlyProration.findUnique({
|
||||
where: {
|
||||
teamId_monthKey: {
|
||||
teamId,
|
||||
monthKey,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async incrementRetryCount(prorationId: string): Promise<MonthlyProration> {
|
||||
return await this.prisma.monthlyProration.update({
|
||||
where: { id: prorationId },
|
||||
data: {
|
||||
retryCount: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<TeamWithBilling | null> {
|
||||
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<number[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<SeatChangeLog> {
|
||||
return await this.prisma.seatChangeLog.create({
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async getMonthlyChanges(params: { teamId: number; monthKey: string }): Promise<MonthlyChangesResult> {
|
||||
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<SeatChangeLog[]> {
|
||||
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<number> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<unknown>;
|
||||
private repository: BillingPeriodRepository;
|
||||
|
||||
constructor(customLogger?: Logger<unknown>, repository?: BillingPeriodRepository) {
|
||||
this.logger = customLogger || log;
|
||||
this.repository = repository || new BillingPeriodRepository();
|
||||
}
|
||||
|
||||
async isAnnualPlan(teamId: number): Promise<boolean> {
|
||||
const info = await this.getOrCreateBillingPeriodInfo(teamId);
|
||||
return info.billingPeriod === "ANNUALLY";
|
||||
}
|
||||
|
||||
async isInTrialPeriod(teamId: number): Promise<boolean> {
|
||||
const info = await this.getOrCreateBillingPeriodInfo(teamId);
|
||||
return info.isInTrial;
|
||||
}
|
||||
|
||||
async shouldApplyMonthlyProration(teamId: number): Promise<boolean> {
|
||||
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<BillingPeriodInfo> {
|
||||
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<BillingPeriodInfo> {
|
||||
return this.getOrCreateBillingPeriodInfo(teamId);
|
||||
}
|
||||
|
||||
async updateBillingPeriod(params: {
|
||||
teamId: number;
|
||||
billingPeriod: BillingPeriod;
|
||||
pricePerSeat: number;
|
||||
}): Promise<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
+422
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -65,4 +65,40 @@ export interface IBillingProviderService {
|
||||
getCustomer(customerId: string): Promise<Stripe.Customer | Stripe.DeletedCustomer | null>;
|
||||
getSubscriptions(customerId: string): Promise<Stripe.Subscription[] | null>;
|
||||
updateCustomer(args: { customerId: string; email: string; userId?: number }): Promise<void>;
|
||||
|
||||
// Invoice management
|
||||
createInvoiceItem(args: {
|
||||
customerId: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
description: string;
|
||||
metadata?: Record<string, string>;
|
||||
}): Promise<{ invoiceItemId: string }>;
|
||||
|
||||
createInvoice(args: {
|
||||
customerId: string;
|
||||
autoAdvance: boolean;
|
||||
metadata?: Record<string, string>;
|
||||
}): Promise<{ invoiceId: string }>;
|
||||
|
||||
finalizeInvoice(invoiceId: string): Promise<void>;
|
||||
|
||||
// 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>;
|
||||
}
|
||||
|
||||
@@ -243,4 +243,53 @@ export class StripeBillingService implements IBillingProviderService {
|
||||
|
||||
return status || SubscriptionStatus.ACTIVE;
|
||||
}
|
||||
|
||||
async createInvoiceItem(args: Parameters<IBillingProviderService["createInvoiceItem"]>[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<IBillingProviderService["createInvoice"]>[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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<unknown>;
|
||||
private teamRepository: MonthlyProrationTeamRepository;
|
||||
private prorationRepository: MonthlyProrationRepository;
|
||||
private billingService: IBillingProviderService;
|
||||
|
||||
constructor(customLogger?: Logger<unknown>, 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<void> {
|
||||
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<number> {
|
||||
const subscription = await this.billingService.getSubscription(subscriptionId);
|
||||
return subscription?.items[0]?.quantity || 0;
|
||||
}
|
||||
}
|
||||
+352
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
+404
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<unknown>;
|
||||
private repository: SeatChangeLogRepository;
|
||||
|
||||
constructor(customLogger?: Logger<unknown>, repository?: SeatChangeLogRepository) {
|
||||
this.logger = customLogger || log;
|
||||
this.repository = repository || new SeatChangeLogRepository();
|
||||
}
|
||||
|
||||
async logSeatAddition(params: SeatChangeLogParams): Promise<void> {
|
||||
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<void> {
|
||||
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<MonthlyChanges> {
|
||||
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<number> {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
+329
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ export type AppFlags = {
|
||||
"booking-email-sms-tasker": boolean;
|
||||
"bookings-v3": boolean;
|
||||
"booking-audit": boolean;
|
||||
"monthly-proration": boolean;
|
||||
"sidebar-tips": boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ const initialData: AppFlags = {
|
||||
"booking-email-sms-tasker": false,
|
||||
"bookings-v3": false,
|
||||
"booking-audit": false,
|
||||
"monthly-proration": false,
|
||||
"sidebar-tips": false,
|
||||
};
|
||||
|
||||
|
||||
+117
@@ -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;
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user