Files
sean-brydonGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
d52e2d9fad feat: active user billing (#27867)
* factory and statergie

* chore: use correct method of DI

* feat: add onchagne

* add logic to HWM stat

* add webhook resolver methods to each statergy

* move seat tracking + webhooks over to own statergy

* Move to factory base approach

* move logic to correct class

* rename create -> createByTeamId

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

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

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

Co-Authored-By: unknown <>

* feat: active user billing

* add tests

* UI side for users on active billing

* use correct period of stripe sub

* feat: claude feedback

* fix: skip Stripe sync for canceled/expired subscriptions to prevent repeated API calls

Co-Authored-By: unknown <>

* feat: feedback

* feat: only render when active users mode is set

* fix type error

* fix: constants + feature flags

* fix: default to null in tests

* chore: use seats in test

* fix: use node:crypto protocol for Node.js builtin imports

Co-Authored-By: [email protected] <[email protected]>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-12 13:57:32 +00:00

254 lines
8.5 KiB
TypeScript

import { getFeaturesRepository } from "@calcom/features/di/containers/FeaturesRepository";
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 type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import type { BillingMode, BillingPeriod } from "@calcom/prisma/enums";
import type { Logger } from "tslog";
const log = logger.getSubLogger({ prefix: ["BillingPeriodService"] });
export interface BillingPeriodInfo {
billingPeriod: BillingPeriod | null;
billingMode: BillingMode | null;
subscriptionStart: Date | null;
subscriptionEnd: Date | null;
trialEnd: Date | null;
isInTrial: boolean;
pricePerSeat: number | null;
isOrganization: boolean;
}
export interface BillingPeriodServiceDeps {
logger?: Logger<unknown>;
repository?: BillingPeriodRepository;
featuresRepository?: IFeaturesRepository;
}
export class BillingPeriodService {
private logger: Logger<unknown>;
private repository: BillingPeriodRepository;
private featuresRepository: IFeaturesRepository;
constructor(deps?: BillingPeriodServiceDeps) {
this.logger = deps?.logger || log;
this.repository = deps?.repository || new BillingPeriodRepository();
this.featuresRepository = deps?.featuresRepository || getFeaturesRepository();
}
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 isEnabled = await this.featuresRepository.checkIfFeatureIsEnabledGlobally("monthly-proration");
if (!isEnabled) {
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 shouldApplyHighWaterMark(teamId: number): Promise<boolean> {
try {
const isEnabled = await this.featuresRepository.checkIfFeatureIsEnabledGlobally("hwm-seating");
if (!isEnabled) {
return false;
}
const info = await this.getOrCreateBillingPeriodInfo(teamId);
return info.billingPeriod === "MONTHLY" && !info.isInTrial && info.subscriptionStart !== null;
} catch (error) {
log.error(`Failed to check if high water mark should apply for team ${teamId}`, { error });
return false;
}
}
async isMonthlyBilling(teamId: number): Promise<boolean> {
const info = await this.getOrCreateBillingPeriodInfo(teamId);
return info.billingPeriod === "MONTHLY";
}
async getOrCreateBillingPeriodInfo(teamId: number): Promise<BillingPeriodInfo> {
const team = await prisma.team.findUnique({
where: { id: teamId },
select: {
isOrganization: true,
teamBilling: {
select: {
id: true,
billingPeriod: true,
billingMode: true,
subscriptionStart: true,
subscriptionEnd: true,
subscriptionTrialEnd: true,
pricePerSeat: true,
paidSeats: true,
subscriptionId: true,
},
},
organizationBilling: {
select: {
id: true,
billingPeriod: true,
billingMode: 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,
billingMode: null,
subscriptionStart: null,
subscriptionEnd: null,
trialEnd: null,
isInTrial: false,
pricePerSeat: null,
isOrganization,
};
}
const periodIsStale =
billing.subscriptionEnd && new Date(billing.subscriptionEnd) < new Date();
const needsSync = (!billing.billingPeriod || periodIsStale) && billing.subscriptionId;
if (needsSync) {
log.info(
`Syncing billing data for team ${teamId} from Stripe subscription ${billing.subscriptionId}`
);
try {
const subscription = await stripe.subscriptions.retrieve(billing.subscriptionId);
const terminalStatuses = ["canceled", "incomplete_expired"];
if (terminalStatuses.includes(subscription.status)) {
log.info(
`Skipping sync for team ${teamId} — subscription ${billing.subscriptionId} has status "${subscription.status}"`
);
const now = new Date();
const isInTrial = billing.subscriptionTrialEnd
? new Date(billing.subscriptionTrialEnd) > now
: false;
return {
billingPeriod: billing.billingPeriod,
billingMode: billing.billingMode,
subscriptionStart: billing.subscriptionStart,
subscriptionEnd: billing.subscriptionEnd,
trialEnd: billing.subscriptionTrialEnd,
isInTrial,
pricePerSeat: billing.pricePerSeat,
isOrganization,
};
}
const { billingPeriod, pricePerSeat, paidSeats, subscriptionStart, subscriptionEnd } =
extractBillingDataFromStripeSubscription(subscription);
const updateData = {
billingPeriod,
pricePerSeat: pricePerSeat ?? null,
paidSeats: paidSeats ?? null,
subscriptionStart: subscriptionStart ?? null,
subscriptionEnd: subscriptionEnd ?? null,
};
if (isOrganization) {
await this.repository.updateOrganizationBillingPeriod(billing.id, updateData);
} else {
await this.repository.updateTeamBillingPeriod(billing.id, updateData);
}
const now = new Date();
const isInTrial = billing.subscriptionTrialEnd ? new Date(billing.subscriptionTrialEnd) > now : false;
return {
billingPeriod,
billingMode: billing.billingMode,
subscriptionStart: subscriptionStart ?? null,
subscriptionEnd: subscriptionEnd ?? null,
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,
billingMode: billing.billingMode,
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}`);
}
}
}