Files
cal-diy-oidc/packages/features/ee/billing/credit-service.ts
T

886 lines
26 KiB
TypeScript

import dayjs from "@calcom/dayjs";
import { CreditsRepository } from "@calcom/features/credits/repositories/CreditsRepository";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import logger from "@calcom/lib/logger";
import { type PrismaTransaction, prisma } from "@calcom/prisma";
import { CreditType, CreditUsageType } from "@calcom/prisma/enums";
import type { TFunction } from "i18next";
import { getBillingProviderService, getTeamBillingServiceFactory } from "./di/containers/Billing";
import { SubscriptionStatus } from "./repository/billing/IBillingRepository";
const log = logger.getSubLogger({ prefix: ["[CreditService]"] });
type LowCreditBalanceResultBase = {
team?: {
id: number;
name: string;
adminAndOwners: {
id: number;
name: string | null;
email: string;
t: TFunction;
}[];
};
user?: {
id: number;
name: string | null;
email: string;
t: TFunction;
};
creditFor?: CreditUsageType;
};
type LowCreditBalanceLimitReachedResult = LowCreditBalanceResultBase & {
type: "LIMIT_REACHED";
teamId?: number | null;
userId?: number | null;
};
type LowCreditBalanceWarningResult = LowCreditBalanceResultBase & {
type: "WARNING";
balance: number;
};
type LowCreditBalanceResult = LowCreditBalanceLimitReachedResult | LowCreditBalanceWarningResult | null;
export type CreditCheckFn = CreditService["hasAvailableCredits"];
export class CreditService {
async chargeCredits({
userId,
teamId,
credits,
bookingUid,
smsSid,
smsSegments,
phoneNumber,
email,
callDuration,
creditFor,
externalRef,
}: {
userId?: number;
teamId?: number;
credits: number | null;
bookingUid?: string;
smsSid?: string;
smsSegments?: number;
phoneNumber?: string;
email?: string;
callDuration?: number;
creditFor?: CreditUsageType;
externalRef?: string;
}) {
if (externalRef) {
const existingLog = await CreditsRepository.findCreditExpenseLogByExternalRef(externalRef);
if (existingLog) {
log.warn("Credit expense log already exists", { externalRef, existingLog });
return {
bookingUid: existingLog.bookingUid,
duplicate: true,
userId,
teamId,
};
}
}
return await prisma
.$transaction(async (tx) => {
let teamIdToCharge = credits === 0 && teamId ? teamId : undefined;
let creditType: CreditType = CreditType.ADDITIONAL;
let remainingCredits;
let userIdToCharge;
if (!teamIdToCharge) {
const result = await this._getUserOrTeamToCharge({
credits: credits ?? 1, // if we don't have exact credits, we check for at east 1 credit available
userId,
teamId,
tx,
});
teamIdToCharge = result?.teamId;
userIdToCharge = result?.userId;
creditType = result?.creditType ?? creditType;
remainingCredits = result?.remainingCredits;
}
if (!teamIdToCharge && !userIdToCharge) {
log.error("No team or user found to charge. No credit expense log created");
return null;
}
await this._createExpenseLog({
bookingUid,
smsSid,
teamId: teamIdToCharge,
userId: userIdToCharge,
credits,
creditType,
smsSegments,
phoneNumber,
email,
callDuration,
creditFor,
tx,
externalRef,
});
let lowCreditBalanceResult = null;
if (credits) {
lowCreditBalanceResult = await this._handleLowCreditBalance({
teamId: teamIdToCharge,
userId: userIdToCharge,
remainingCredits: remainingCredits ?? 0,
creditFor,
tx,
});
}
return {
teamId: teamIdToCharge,
userId: userIdToCharge,
lowCreditBalanceResult,
};
})
.then(async (result) => {
if (result?.lowCreditBalanceResult) {
// send emails after transaction is successfully committed
await this._handleLowCreditBalanceResult(result.lowCreditBalanceResult);
}
return {
teamId: result?.teamId,
userId: result?.userId,
};
});
}
/*
also returns true if team has no available credits but limitReachedAt is not yet set
*/
async hasAvailableCredits({
userId,
teamId,
}: {
userId?: number | null;
teamId?: number | null;
}): Promise<boolean> {
return await prisma.$transaction(async (tx) => {
if (!IS_SMS_CREDITS_ENABLED) return true;
if (teamId) {
// Check if this team belongs to an organization or is itself an organization
const orgId = await getOrgIdFromMemberOrTeamId({ teamId }, tx);
// Use organization credits if team belongs to org, otherwise use team's own credits
const teamIdToCheck = orgId ?? teamId;
const creditBalance = await CreditsRepository.findCreditBalance({ teamId: teamIdToCheck }, tx);
const limitReached =
creditBalance?.limitReachedAt &&
dayjs(creditBalance.limitReachedAt).isAfter(dayjs().startOf("month"));
if (!limitReached) return true;
// check if team is still out of credits
const teamCredits = await this._getAllCreditsForTeam({ teamId: teamIdToCheck, tx });
const availableCredits = teamCredits.totalRemainingMonthlyCredits + teamCredits.additionalCredits;
if (availableCredits > 0) {
await CreditsRepository.updateCreditBalance(
{
teamId: teamIdToCheck,
data: {
limitReachedAt: null,
warningSentAt: null,
},
},
tx
);
return true;
}
// limitReachedAt is set and still no available credits
return false;
}
if (userId) {
const teamWithAvailableCredits = await this._getTeamWithAvailableCredits({ userId, tx });
if (teamWithAvailableCredits && !teamWithAvailableCredits.limitReached) return true;
const userCredits = await this._getAllCredits({ userId, tx });
return userCredits.additionalCredits > 0;
}
return false;
});
}
async getTeamWithAvailableCredits(userId: number) {
return prisma.$transaction(async (tx) => {
return this._getTeamWithAvailableCredits({ userId, tx });
});
}
/**
* Separates memberships into organization and team memberships.
* Organizations take precedence - if user belongs to any organization,
* only organization memberships are returned.
*
* @param memberships - User's accepted team memberships
* @param teams - Team data including isOrganization and parentId
* @returns Memberships to check (org memberships if any exist, otherwise team memberships)
*/
private static filterMembershipsForCreditCheck<T extends { teamId: number }>(
memberships: T[],
teams: Array<{ id: number; isOrganization: boolean; parentId: number | null }>
): T[] {
const teamMap = new Map(teams.map((t) => [t.id, t]));
const orgMemberships: T[] = [];
const teamMemberships: T[] = [];
for (const membership of memberships) {
const team = teamMap.get(membership.teamId);
if (team?.isOrganization && !team.parentId) {
orgMemberships.push(membership);
} else {
teamMemberships.push(membership);
}
}
// If user belongs to any organization, ONLY check organization credits
return orgMemberships.length > 0 ? orgMemberships : teamMemberships;
}
/*
If user has memberships, it always returns a team, even if all have limit reached. In that case, limitReached: true is returned
If user belongs to any organization, ONLY organization credits are checked (team memberships are ignored)
If user does not belong to an organization, team credits are checked
*/
protected async _getTeamWithAvailableCredits({ userId, tx }: { userId: number; tx: PrismaTransaction }) {
const memberships = await MembershipRepository.findAllAcceptedPublishedTeamMemberships(userId, tx);
if (!memberships || memberships.length === 0) {
return null;
}
const teamRepository = new TeamRepository(prisma);
const teams = await teamRepository.findTeamsForCreditCheck({
teamIds: memberships.map((m) => m.teamId),
});
const membershipsToCheck = CreditService.filterMembershipsForCreditCheck(memberships, teams);
for (const membership of membershipsToCheck) {
const creditBalance = await CreditsRepository.findCreditBalance({ teamId: membership.teamId }, tx);
const allCredits = await this._getAllCreditsForTeam({ teamId: membership.teamId, tx });
const limitReached =
creditBalance?.limitReachedAt &&
dayjs(creditBalance.limitReachedAt).isAfter(dayjs().startOf("month"));
const availableCredits = allCredits.totalRemainingMonthlyCredits + allCredits.additionalCredits;
if (!limitReached || availableCredits > 0) {
if (limitReached) {
await CreditsRepository.updateCreditBalance(
{
teamId: membership.teamId,
data: {
limitReachedAt: null,
warningSentAt: null,
},
},
tx
);
}
return {
teamId: membership.teamId,
availableCredits,
creditType:
allCredits.totalRemainingMonthlyCredits > 0 ? CreditType.MONTHLY : CreditType.ADDITIONAL,
};
}
}
return {
teamId: membershipsToCheck[0].teamId,
availableCredits: 0,
creditType: CreditType.ADDITIONAL,
limitReached: true,
};
}
/*
always returns a team or user, even if out of credits
*/
async getUserOrTeamToCharge({
credits,
userId,
teamId,
}: {
credits: number;
userId?: number | null;
teamId?: number | null;
}) {
return prisma.$transaction(async (tx) => {
return this._getUserOrTeamToCharge({ credits, userId, teamId, tx });
});
}
protected async _getUserOrTeamToCharge({
credits,
userId,
teamId,
tx,
}: {
credits: number;
userId?: number | null;
teamId?: number | null;
tx: PrismaTransaction;
}) {
if (teamId) {
const teamCredits = await this._getAllCreditsForTeam({ teamId, tx });
const remaningMonthlyCredits =
teamCredits.totalRemainingMonthlyCredits > 0 ? teamCredits.totalRemainingMonthlyCredits : 0;
return {
teamId,
remainingCredits: remaningMonthlyCredits + teamCredits.additionalCredits - credits,
creditType: remaningMonthlyCredits > 0 ? CreditType.MONTHLY : CreditType.ADDITIONAL,
};
}
if (userId) {
const team = await this._getTeamWithAvailableCredits({ userId, tx });
const teamHasCredits = team && !team.limitReached;
if (teamHasCredits) {
return { ...team, remainingCredits: team.availableCredits - credits };
}
const userCredits = await this._getAllCredits({ userId, tx });
const userHasCredits = userCredits.additionalCredits > 0;
if (userHasCredits) {
return {
userId,
remainingCredits: userCredits.additionalCredits - credits,
creditType: CreditType.ADDITIONAL,
};
}
if (team) {
return { ...team, remainingCredits: team.availableCredits - credits };
}
return {
userId,
remainingCredits: userCredits.additionalCredits - credits,
creditType: CreditType.ADDITIONAL,
};
}
return null;
}
protected async _createExpenseLog(props: {
bookingUid?: string;
smsSid?: string;
teamId?: number;
userId?: number;
credits: number | null;
creditType: CreditType;
smsSegments?: number;
phoneNumber?: string;
email?: string;
callDuration?: number;
creditFor?: CreditUsageType;
tx: PrismaTransaction;
externalRef?: string;
}) {
const {
credits,
creditType,
bookingUid,
smsSid,
teamId,
userId,
smsSegments,
callDuration,
creditFor,
phoneNumber,
email,
tx,
} = props;
let creditBalance: { id: string; additionalCredits: number } | null | undefined =
await CreditsRepository.findCreditBalance({ teamId, userId }, tx);
if (!creditBalance) {
creditBalance = await CreditsRepository.createCreditBalance(
{
teamId,
userId,
},
tx
);
}
if (credits && creditType === CreditType.ADDITIONAL) {
const decrementValue =
credits <= creditBalance.additionalCredits ? credits : creditBalance.additionalCredits;
await CreditsRepository.updateCreditBalance(
{
id: creditBalance.id,
data: {
additionalCredits: {
decrement: decrementValue,
},
},
},
tx
);
}
if (creditBalance) {
// also track logs with undefined credits (will be set on the cron job)
await CreditsRepository.createCreditExpenseLog(
{
creditBalanceId: creditBalance.id,
credits,
creditType,
creditFor,
date: new Date(),
bookingUid,
smsSid,
smsSegments,
phoneNumber,
email,
callDuration,
externalRef: props.externalRef,
},
tx
);
}
}
/*
Called when we know the exact amount of credits to be charged:
- Sets `limitReachedAt` and `warningSentAt`
- Sends warning email if balance is low
- Sends limit reached email
- cancels all already scheduled SMS (from the next two hours)
*/
protected async _handleLowCreditBalance({
teamId,
userId,
remainingCredits,
creditFor,
tx,
}: {
teamId?: number | null;
userId?: number | null;
remainingCredits: number;
creditFor?: CreditUsageType;
tx: PrismaTransaction;
}): Promise<LowCreditBalanceResult> {
let warningLimit = 0;
if (teamId) {
const { totalMonthlyCredits } = await this._getAllCreditsForTeam({ teamId, tx });
warningLimit = totalMonthlyCredits * 0.2;
} else if (userId) {
const billingService = getBillingProviderService();
const teamMonthlyPrice = await billingService.getPrice(process.env.STRIPE_TEAM_MONTHLY_PRICE_ID || "");
const pricePerSeat = teamMonthlyPrice.unit_amount ?? 0;
warningLimit = (pricePerSeat / 2) * 0.2;
}
if (remainingCredits < warningLimit) {
const creditBalance = await CreditsRepository.findCreditBalanceWithTeamOrUser({ teamId, userId }, tx);
if (
creditBalance?.limitReachedAt &&
(!teamId || dayjs(creditBalance?.limitReachedAt).isAfter(dayjs().startOf("month")))
) {
log.info("User or team has limit already reached this month", {
teamId,
userId,
creditBalance,
});
return null; // user has limit already reached or team has already reached limit this month
}
const { getTranslation } = await import("@calcom/i18n/server");
const teamWithAdmins = creditBalance?.team
? {
...creditBalance.team,
adminAndOwners: await Promise.all(
creditBalance.team.members.map(async (member) => ({
id: member.user.id,
name: member.user.name,
email: member.user.email,
t: await getTranslation(member.user.locale ?? "en", "common"),
}))
),
}
: undefined;
const user = creditBalance?.user
? {
...creditBalance.user,
t: await getTranslation(creditBalance.user.locale ?? "en", "common"),
}
: undefined;
if ((!teamWithAdmins || !teamWithAdmins.adminAndOwners?.length) && !user) {
log.error("Team or user not found to send warning email");
return null;
}
if (remainingCredits <= 0) {
await CreditsRepository.updateCreditBalance(
{
teamId,
userId,
data: {
limitReachedAt: new Date(),
warningSentAt: null,
},
},
tx
);
return {
type: "LIMIT_REACHED" as const,
team: teamWithAdmins,
user,
teamId,
userId,
creditFor,
};
}
if (
creditBalance?.warningSentAt &&
(!teamId || dayjs(creditBalance?.warningSentAt).isAfter(dayjs().startOf("month")))
) {
return null; // user has already received a warning or team has already sent warning email this month
}
await CreditsRepository.updateCreditBalance(
{
teamId,
userId,
data: {
warningSentAt: new Date(),
},
},
tx
);
return {
type: "WARNING" as const,
balance: remainingCredits,
team: teamWithAdmins,
user,
creditFor,
};
}
await CreditsRepository.updateCreditBalance(
{
teamId,
userId,
data: {
warningSentAt: null,
limitReachedAt: null,
},
},
tx
);
return null;
}
private async _handleLowCreditBalanceResult(result: LowCreditBalanceResult) {
if (!result) return;
try {
if (result.type === "LIMIT_REACHED") {
const { sendCreditBalanceLimitReachedEmails } = await import("@calcom/emails/billing-email-service");
const promises: Promise<unknown>[] = [
sendCreditBalanceLimitReachedEmails({
team: result.team,
user: result.user,
creditFor: result.creditFor,
}).catch((error) => {
log.error("Failed to send credit limit reached email", error, { result });
}),
];
if (!result.creditFor || result.creditFor === CreditUsageType.SMS) {
const { cancelScheduledMessagesAndScheduleEmails } = await import(
"@calcom/features/ee/workflows/lib/reminders/reminderScheduler"
);
promises.push(
cancelScheduledMessagesAndScheduleEmails({
teamId: result.teamId,
userIdsWithNoCredits: await this._getUserIdsWithoutCredits({
teamId: result.teamId ?? null,
userId: result.userId ?? null,
}),
}).catch((error) => {
log.error("Failed to cancel scheduled messages", error, { result });
})
);
}
await Promise.all(promises);
} else if (result.type === "WARNING") {
const { sendCreditBalanceLowWarningEmails } = await import("@calcom/emails/billing-email-service");
await sendCreditBalanceLowWarningEmails({
balance: result.balance,
team: result.team,
user: result.user,
creditFor: result.creditFor,
}).catch((error) => {
log.error("Failed to send credit warning email", error, { result });
});
}
} catch (error) {
// Catch any other unexpected errors
log.error("Unexpected error handling low credit balance result", error, { result });
}
}
async handleLowCreditBalance({
teamId,
userId,
remainingCredits,
}: {
teamId?: number | null;
userId?: number | null;
remainingCredits: number;
}) {
return prisma
.$transaction(async (tx) => {
const result = await this._handleLowCreditBalance({ teamId, userId, remainingCredits, tx });
return result;
})
.then(async (result) => {
await this._handleLowCreditBalanceResult(result);
});
}
async getMonthlyCredits(teamId: number) {
const teamRepo = new TeamRepository(prisma);
const team = await teamRepo.findTeamWithMembers(teamId);
if (!team) return 0;
const teamBillingServiceFactory = getTeamBillingServiceFactory();
const teamBillingService = teamBillingServiceFactory.init(team);
const subscriptionStatus = await teamBillingService.getSubscriptionStatus();
if (
subscriptionStatus !== SubscriptionStatus.ACTIVE &&
subscriptionStatus !== SubscriptionStatus.PAST_DUE
) {
return 0;
}
const activeMembers = team.members.filter((member) => member.accepted).length;
if (team.isOrganization) {
const orgMonthlyCredits = process.env.ORG_MONTHLY_CREDITS;
const creditsPerSeat = orgMonthlyCredits ? parseInt(orgMonthlyCredits) : 1000;
return activeMembers * creditsPerSeat;
}
const billingService = getBillingProviderService();
const priceId = process.env.STRIPE_TEAM_MONTHLY_PRICE_ID;
if (!priceId) {
log.warn("Monthly price ID not configured", { teamId });
return 0;
}
const monthlyPrice = await billingService.getPrice(priceId);
if (!monthlyPrice) {
log.warn("Failed to retrieve monthly price", { teamId, priceId });
return 0;
}
const pricePerSeat = monthlyPrice.unit_amount ?? 0;
const creditsPerSeat = pricePerSeat * 0.5;
return activeMembers * creditsPerSeat;
}
calculateCreditsFromPrice(price: number) {
const twilioPrice = price;
const priceWithMarkUp = twilioPrice * 1.8;
const credits = Math.ceil(priceWithMarkUp * 100);
return credits || null;
}
async getAllCredits({ userId, teamId }: { userId?: number | null; teamId?: number | null }) {
return prisma.$transaction(async (tx) => {
return this._getAllCredits({ userId, teamId, tx });
});
}
protected async _getAllCredits({
userId,
teamId,
tx,
}: {
userId?: number | null;
teamId?: number | null;
tx: PrismaTransaction;
}) {
if (teamId) {
return this._getAllCreditsForTeam({ teamId, tx });
}
if (userId) {
const creditBalance = await CreditsRepository.findCreditBalance({ userId }, tx);
return {
totalMonthlyCredits: 0,
totalRemainingMonthlyCredits: 0,
additionalCredits: creditBalance?.additionalCredits ?? 0,
totalCreditsUsedThisMonth: 0,
};
}
return {
totalMonthlyCredits: 0,
totalRemainingMonthlyCredits: 0,
additionalCredits: 0,
totalCreditsUsedThisMonth: 0,
};
}
async getAllCreditsForTeam(teamId: number) {
return prisma.$transaction(async (tx) => {
return this._getAllCreditsForTeam({ teamId, tx });
});
}
protected async _getAllCreditsForTeam({ teamId, tx }: { teamId: number; tx: PrismaTransaction }) {
const creditBalance = await CreditsRepository.findCreditBalanceWithExpenseLogs(
{ teamId, creditType: CreditType.MONTHLY },
tx
);
const totalMonthlyCredits = await this.getMonthlyCredits(teamId);
const totalMonthlyCreditsUsed =
creditBalance?.expenseLogs.reduce((sum, log) => sum + (log?.credits ?? 0), 0) || 0;
const additionalCredits = creditBalance?.additionalCredits ?? 0;
const totalCreditsUsedThisMonth = totalMonthlyCreditsUsed;
return {
totalMonthlyCredits,
totalRemainingMonthlyCredits: Math.max(totalMonthlyCredits - totalMonthlyCreditsUsed, 0),
additionalCredits,
totalCreditsUsedThisMonth,
};
}
async moveCreditsFromTeamToOrg({ teamId, orgId }: { teamId: number; orgId: number }) {
return await prisma.$transaction(async (tx) => {
// Get team's credit balance
const teamCreditBalance = await CreditsRepository.findCreditBalance({ teamId }, tx);
if (!teamCreditBalance || teamCreditBalance.additionalCredits <= 0) {
log.info("No credits to transfer from team to org", { teamId, orgId });
return;
}
// Get or create org's credit balance
let orgCreditBalance = await CreditsRepository.findCreditBalance({ teamId: orgId }, tx);
if (!orgCreditBalance) {
orgCreditBalance = await CreditsRepository.createCreditBalance(
{
teamId: orgId,
},
tx
);
}
const creditsToTransfer = teamCreditBalance.additionalCredits;
// Transfer credits from team to org
await CreditsRepository.updateCreditBalance(
{
teamId,
data: {
additionalCredits: 0,
},
},
tx
);
await CreditsRepository.updateCreditBalance(
{
teamId: orgId,
data: {
additionalCredits: {
increment: creditsToTransfer,
},
},
},
tx
);
log.info("Successfully transferred credits from team to org", {
teamId,
orgId,
creditsTransferred: creditsToTransfer,
});
return {
creditsTransferred: creditsToTransfer,
teamId,
orgId,
};
});
}
private async _getUserIdsWithoutCredits({
teamId,
userId,
}: {
teamId: number | null;
userId: number | null;
}) {
let userIdsWithNoCredits: number[] = userId ? [userId] : [];
if (teamId) {
const teamMembers = await prisma.membership.findMany({
where: {
teamId,
accepted: true,
},
});
userIdsWithNoCredits = (
await Promise.all(
teamMembers.map(async (member) => {
const hasCredits = await this.hasAvailableCredits({ userId: member.userId });
return { userId: member.userId, hasCredits };
})
)
)
.filter(({ hasCredits }) => !hasCredits)
.map(({ userId }) => userId);
}
return userIdsWithNoCredits;
}
}