>
) => {
- const { team, balance, user } = props;
+ const { team, balance, user, creditFor } = props;
+ const isCalAi = creditFor === CreditUsageType.CAL_AI_PHONE_CALL;
if (team) {
return (
@@ -29,7 +32,11 @@ export const CreditBalanceLowWarningEmail = (
<> {user.t("hi_user_name", { name: user.name })},>
- <>{user.t("low_credits_warning_message", { teamName: team.name })}>
+ <>
+ {isCalAi
+ ? user.t("cal_ai_low_credits_warning_message", { teamName: team.name })
+ : user.t("low_credits_warning_message", { teamName: team.name })}
+ >
{user.t("hi_user_name", { name: user.name })},>
- <>{user.t("low_credits_warning_message_user")}>
+ <>
+ {isCalAi
+ ? user.t("cal_ai_low_credits_warning_message_user")
+ : user.t("low_credits_warning_message_user")}
+ >
> {
@@ -39,6 +44,7 @@ export default class CreditBalanceLimitReachedEmail extends BaseEmail {
html: await renderEmail("CreditBalanceLimitReachedEmail", {
team: this.team,
user: this.user,
+ creditFor: this.creditFor,
}),
text: this.getTextBody(),
};
diff --git a/packages/emails/templates/credit-balance-low-warning-email.ts b/packages/emails/templates/credit-balance-low-warning-email.ts
index ef069ef9e4fd1c..c2725ac31728cb 100644
--- a/packages/emails/templates/credit-balance-low-warning-email.ts
+++ b/packages/emails/templates/credit-balance-low-warning-email.ts
@@ -1,6 +1,7 @@
import type { TFunction } from "i18next";
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
+import type { CreditUsageType } from "@calcom/prisma/enums";
import renderEmail from "../src/renderEmail";
import BaseEmail from "./_base-email";
@@ -17,20 +18,24 @@ export default class CreditBalanceLowWarningEmail extends BaseEmail {
name: string;
};
balance: number;
+ creditFor?: CreditUsageType;
constructor({
user,
balance,
team,
+ creditFor,
}: {
user: { id: number; name: string | null; email: string; t: TFunction };
balance: number;
team?: { id: number; name: string | null };
+ creditFor?: CreditUsageType;
}) {
super();
this.user = { ...user, name: user.name || "" };
this.team = team ? { ...team, name: team.name || "" } : undefined;
this.balance = balance;
+ this.creditFor = creditFor;
}
protected async getNodeMailerPayload(): Promise> {
@@ -44,6 +49,7 @@ export default class CreditBalanceLowWarningEmail extends BaseEmail {
balance: this.balance,
team: this.team,
user: this.user,
+ creditFor: this.creditFor,
}),
text: this.getTextBody(),
};
diff --git a/packages/features/calAIPhone/providers/retellAI/services/AgentService.ts b/packages/features/calAIPhone/providers/retellAI/services/AgentService.ts
index 68ad305c27192b..871d64342e2447 100644
--- a/packages/features/calAIPhone/providers/retellAI/services/AgentService.ts
+++ b/packages/features/calAIPhone/providers/retellAI/services/AgentService.ts
@@ -37,7 +37,7 @@ export class AgentService {
userId,
teamId,
expiresAt: null,
- note: `Cal AI Phone API Key for agent ${userId} ${teamId ? `for team ${teamId}` : ""}`,
+ note: `Cal.ai Phone API Key for agent ${userId} ${teamId ? `for team ${teamId}` : ""}`,
});
}
diff --git a/packages/features/calAIPhone/workflowTemplates.ts b/packages/features/calAIPhone/workflowTemplates.ts
index b8bed40a7f91af..79fff877a13c9a 100644
--- a/packages/features/calAIPhone/workflowTemplates.ts
+++ b/packages/features/calAIPhone/workflowTemplates.ts
@@ -18,7 +18,7 @@ const scheduleRule = ` ## Schedule Rule
// Key are from components/sections/template/data/workflows.ts page in https://github.com/calcom/website
export const calAIPhoneWorkflowTemplates = {
- // name: "Cal AI No-show Follow-up Call",
+ // name: "Cal.ai No-show Follow-up Call",
// description: "Automatically call attendee when marked as no-show"
"wf-10": {
generalPrompt: `## You are calling an attendee who was marked as a no-show for their appointment. Your goal is to help them reschedule. Be understanding, friendly, and non-judgmental.
@@ -47,7 +47,7 @@ ${responseGuideline}
9. Thank them for their time and call function end_call to hang up.`,
},
- // name: "Cal AI 1-hour Meeting Reminder",
+ // name: "Cal.ai 1-hour Meeting Reminder",
// description: "Remind attendee 1 hour before the meeting"
"wf-11": {
generalPrompt: `## You are calling to remind an attendee about their upcoming appointment in 1 hour. Be friendly, helpful, and concise.
diff --git a/packages/features/ee/billing/credit-service.test.ts b/packages/features/ee/billing/credit-service.test.ts
index 01c0f74d49e4a8..251aa2c66b6a8d 100644
--- a/packages/features/ee/billing/credit-service.test.ts
+++ b/packages/features/ee/billing/credit-service.test.ts
@@ -3,6 +3,9 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import dayjs from "@calcom/dayjs";
import * as EmailManager from "@calcom/emails/email-manager";
+import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
+import { CreditsRepository } from "@calcom/lib/server/repository/credits";
+import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { CreditsRepository } from "@calcom/lib/server/repository/credits";
@@ -12,7 +15,12 @@ import { CreditService } from "./credit-service";
import { StripeBillingService } from "./stripe-billing-service";
import { InternalTeamBilling } from "./teams/internal-team-billing";
-const MOCK_TX = {};
+const MOCK_TX = {
+ team: {
+ findMany: vi.fn().mockResolvedValue([]),
+ findFirst: vi.fn().mockResolvedValue(null),
+ },
+};
vi.mock("@calcom/prisma", async (importOriginal) => {
const actual = await importOriginal();
@@ -62,10 +70,16 @@ vi.mock("@calcom/prisma/enums", async (importOriginal) => {
vi.mock("@calcom/lib/server/repository/credits");
vi.mock("@calcom/features/membership/repositories/MembershipRepository");
vi.mock("@calcom/features/ee/teams/repositories/TeamRepository");
-vi.mock("@calcom/emails/email-manager");
+vi.mock("@calcom/emails/email-manager", () => ({
+ sendCreditBalanceLimitReachedEmails: vi.fn().mockResolvedValue(undefined),
+ sendCreditBalanceLowWarningEmails: vi.fn().mockResolvedValue(undefined),
+}));
vi.mock("../workflows/lib/reminders/reminderScheduler", () => ({
cancelScheduledMessagesAndScheduleEmails: vi.fn().mockResolvedValue(undefined),
}));
+vi.mock("@calcom/lib/getOrgIdFromMemberOrTeamId", () => ({
+ default: vi.fn().mockResolvedValue(null),
+}));
const creditService = new CreditService();
@@ -110,6 +124,8 @@ describe("CreditService", () => {
describe("Team credits", () => {
describe("hasAvailableCredits", () => {
it("should return true if team has not yet reached limit", async () => {
+ vi.mocked(getOrgIdFromMemberOrTeamId).mockResolvedValue(null);
+
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
id: "1",
additionalCredits: 0,
@@ -134,6 +150,8 @@ describe("CreditService", () => {
it("should return false if team limit reached this month", async () => {
vi.setSystemTime(new Date("2024-06-20T11:59:59Z"));
+ vi.mocked(getOrgIdFromMemberOrTeamId).mockResolvedValue(null);
+
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
id: "1",
additionalCredits: 0,
@@ -158,6 +176,10 @@ describe("CreditService", () => {
},
]);
+ vi.spyOn(TeamRepository.prototype, "findTeamsForCreditCheck").mockResolvedValue([
+ { id: 1, isOrganization: false, parentId: null, parent: null },
+ ]);
+
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
id: "1",
additionalCredits: 0,
@@ -184,6 +206,10 @@ describe("CreditService", () => {
},
]);
+ vi.spyOn(TeamRepository.prototype, "findTeamsForCreditCheck").mockResolvedValue([
+ { id: 1, isOrganization: false, parentId: null, parent: null },
+ ]);
+
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
id: "1",
additionalCredits: 0,
@@ -767,6 +793,10 @@ describe("CreditService", () => {
{ teamId: 2 },
]);
+ vi.spyOn(TeamRepository.prototype, "findTeamsForCreditCheck").mockResolvedValue([
+ { id: 2, isOrganization: false, parentId: null, parent: null },
+ ]);
+
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
id: "2",
additionalCredits: 100,
@@ -790,6 +820,131 @@ describe("CreditService", () => {
expect(CreditsRepository.findCreditBalance).toHaveBeenCalledTimes(1);
expect(CreditsRepository.findCreditBalance).toHaveBeenCalledWith({ teamId: 2 }, MOCK_TX);
});
+
+ describe("Organization priority", () => {
+ it("should use organization credits when user belongs to org, ignoring team memberships", async () => {
+ vi.mocked(MembershipRepository.findAllAcceptedPublishedTeamMemberships).mockResolvedValue([
+ { teamId: 1 },
+ { teamId: 2 },
+ ]);
+
+ vi.spyOn(TeamRepository.prototype, "findTeamsForCreditCheck").mockResolvedValue([
+ { id: 1, isOrganization: true, parentId: null, parent: null },
+ { id: 2, isOrganization: false, parentId: null, parent: null },
+ ]);
+
+ vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
+ id: "1",
+ additionalCredits: 50,
+ limitReachedAt: null,
+ warningSentAt: null,
+ });
+
+ vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({
+ totalMonthlyCredits: 1000,
+ totalRemainingMonthlyCredits: 800,
+ additionalCredits: 50,
+ totalCreditsUsedThisMonth: 200,
+ });
+
+ const result = await creditService.getTeamWithAvailableCredits(1);
+
+ expect(result).toEqual({
+ teamId: 1,
+ availableCredits: 850,
+ creditType: CreditType.MONTHLY,
+ });
+ });
+
+ it("should return org with limitReached when org has no credits, ignoring teams", async () => {
+ vi.mocked(MembershipRepository.findAllAcceptedPublishedTeamMemberships).mockResolvedValue([
+ { teamId: 1 },
+ { teamId: 2 },
+ ]);
+
+ vi.spyOn(TeamRepository.prototype, "findTeamsForCreditCheck").mockResolvedValue([
+ { id: 1, isOrganization: true, parentId: null, parent: null },
+ { id: 2, isOrganization: false, parentId: null, parent: null },
+ ]);
+
+ vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
+ id: "1",
+ additionalCredits: 0,
+ limitReachedAt: new Date(),
+ warningSentAt: null,
+ });
+
+ vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({
+ totalMonthlyCredits: 1000,
+ totalRemainingMonthlyCredits: 0,
+ additionalCredits: 0,
+ totalCreditsUsedThisMonth: 1000,
+ });
+
+ const result = await creditService.getTeamWithAvailableCredits(1);
+
+ expect(result).toEqual({
+ teamId: 1,
+ availableCredits: 0,
+ creditType: CreditType.ADDITIONAL,
+ limitReached: true,
+ });
+ });
+
+ it("should check teams when user has no org membership", async () => {
+ vi.mocked(MembershipRepository.findAllAcceptedPublishedTeamMemberships).mockResolvedValue([
+ { teamId: 2 },
+ ]);
+
+ vi.spyOn(TeamRepository.prototype, "findTeamsForCreditCheck").mockResolvedValue([
+ { id: 2, isOrganization: false, parentId: null, parent: null },
+ ]);
+
+ vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
+ id: "2",
+ additionalCredits: 100,
+ limitReachedAt: null,
+ warningSentAt: null,
+ });
+
+ vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({
+ totalMonthlyCredits: 500,
+ totalRemainingMonthlyCredits: 300,
+ additionalCredits: 100,
+ totalCreditsUsedThisMonth: 200,
+ });
+
+ const result = await creditService.getTeamWithAvailableCredits(1);
+
+ expect(result).toEqual({
+ teamId: 2,
+ availableCredits: 400,
+ creditType: CreditType.MONTHLY,
+ });
+ });
+
+ it("should use parent org credits when teamId belongs to org", async () => {
+ vi.mocked(getOrgIdFromMemberOrTeamId).mockResolvedValue(100);
+
+ vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
+ id: "100",
+ additionalCredits: 200,
+ limitReachedAt: null,
+ warningSentAt: null,
+ });
+
+ vi.spyOn(CreditService.prototype, "_getAllCreditsForTeam").mockResolvedValue({
+ totalMonthlyCredits: 2000,
+ totalRemainingMonthlyCredits: 1500,
+ additionalCredits: 200,
+ totalCreditsUsedThisMonth: 500,
+ });
+
+ const result = await creditService.hasAvailableCredits({ teamId: 50 });
+
+ expect(result).toBe(true);
+ });
+ });
});
describe("Idempotency", () => {
diff --git a/packages/features/ee/billing/credit-service.ts b/packages/features/ee/billing/credit-service.ts
index b0d536e1ffa11a..da341902483cd5 100644
--- a/packages/features/ee/billing/credit-service.ts
+++ b/packages/features/ee/billing/credit-service.ts
@@ -11,12 +11,12 @@ import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepos
import { cancelScheduledMessagesAndScheduleEmails } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
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 { getTranslation } from "@calcom/lib/server/i18n";
import { CreditsRepository } from "@calcom/lib/server/repository/credits";
-import prisma, { type PrismaTransaction } from "@calcom/prisma";
-import type { CreditUsageType } from "@calcom/prisma/enums";
-import { CreditType } from "@calcom/prisma/enums";
+import { prisma, type PrismaTransaction } from "@calcom/prisma";
+import { CreditUsageType, CreditType } from "@calcom/prisma/enums";
const log = logger.getSubLogger({ prefix: ["[CreditService]"] });
@@ -37,6 +37,7 @@ type LowCreditBalanceResultBase = {
email: string;
t: TFunction;
};
+ creditFor?: CreditUsageType;
};
type LowCreditBalanceLimitReachedResult = LowCreditBalanceResultBase & {
@@ -136,6 +137,7 @@ export class CreditService {
teamId: teamIdToCharge,
userId: userIdToCharge,
remainingCredits: remainingCredits ?? 0,
+ creditFor,
tx,
});
}
@@ -166,7 +168,13 @@ export class CreditService {
if (!IS_SMS_CREDITS_ENABLED) return true;
if (teamId) {
- const creditBalance = await CreditsRepository.findCreditBalance({ teamId }, tx);
+ // 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 &&
@@ -175,13 +183,13 @@ export class CreditService {
if (!limitReached) return true;
// check if team is still out of credits
- const teamCredits = await this._getAllCreditsForTeam({ teamId, tx });
+ const teamCredits = await this._getAllCreditsForTeam({ teamId: teamIdToCheck, tx });
const availableCredits = teamCredits.totalRemainingMonthlyCredits + teamCredits.additionalCredits;
if (availableCredits > 0) {
await CreditsRepository.updateCreditBalance(
{
- teamId,
+ teamId: teamIdToCheck,
data: {
limitReachedAt: null,
warningSentAt: null,
@@ -215,8 +223,41 @@ export class CreditService {
});
}
+ /**
+ * 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(
+ 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);
@@ -225,11 +266,17 @@ export class CreditService {
return null;
}
- //check if user is member of team that has available credits
- for (const membership of memberships) {
- const creditBalance = await CreditsRepository.findCreditBalance({ teamId: membership.teamId }, tx);
+ 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"));
@@ -259,7 +306,7 @@ export class CreditService {
}
return {
- teamId: memberships[0].teamId,
+ teamId: membershipsToCheck[0].teamId,
availableCredits: 0,
creditType: CreditType.ADDITIONAL,
limitReached: true,
@@ -413,11 +460,13 @@ export class CreditService {
teamId,
userId,
remainingCredits,
+ creditFor,
tx,
}: {
teamId?: number | null;
userId?: number | null;
remainingCredits: number;
+ creditFor?: CreditUsageType;
tx: PrismaTransaction;
}): Promise {
let warningLimit = 0;
@@ -438,6 +487,11 @@ export class CreditService {
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
}
@@ -486,6 +540,7 @@ export class CreditService {
user,
teamId,
userId,
+ creditFor,
};
}
@@ -512,6 +567,7 @@ export class CreditService {
balance: remainingCredits,
team: teamWithAdmins,
user,
+ creditFor,
};
}
@@ -535,24 +591,33 @@ export class CreditService {
try {
if (result.type === "LIMIT_REACHED") {
- await Promise.all([
+ const promises: Promise[] = [
sendCreditBalanceLimitReachedEmails({
team: result.team,
user: result.user,
+ creditFor: result.creditFor,
}).catch((error) => {
log.error("Failed to send credit limit reached email", error, { result });
}),
- cancelScheduledMessagesAndScheduleEmails({ teamId: result.teamId, userId: result.userId }).catch(
- (error) => {
- log.error("Failed to cancel scheduled messages", error, { result });
- }
- ),
- ]);
+ ];
+
+ if (!result.creditFor || result.creditFor === CreditUsageType.SMS) {
+ promises.push(
+ cancelScheduledMessagesAndScheduleEmails({ teamId: result.teamId, userId: result.userId }).catch(
+ (error) => {
+ log.error("Failed to cancel scheduled messages", error, { result });
+ }
+ )
+ );
+ }
+
+ await Promise.all(promises);
} else if (result.type === "WARNING") {
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 });
});
diff --git a/packages/features/ee/billing/helpers/getUserAndTeamWithBillingPermission.ts b/packages/features/ee/billing/helpers/getUserAndTeamWithBillingPermission.ts
new file mode 100644
index 00000000000000..31dea651a77665
--- /dev/null
+++ b/packages/features/ee/billing/helpers/getUserAndTeamWithBillingPermission.ts
@@ -0,0 +1,82 @@
+import type { TFunction } from "i18next";
+
+import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
+import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
+import { getTranslation } from "@calcom/lib/server/i18n";
+import type { PrismaClient } from "@calcom/prisma";
+import { MembershipRole } from "@calcom/prisma/enums";
+
+interface UserWithBillingAccess {
+ id: number;
+ name: string | null;
+ email: string;
+ t: TFunction;
+}
+
+interface TeamWithBillingAdmins {
+ id: number;
+ name: string;
+ adminAndOwners: UserWithBillingAccess[];
+}
+
+interface GetUserAndTeamResult {
+ user?: UserWithBillingAccess;
+ team?: TeamWithBillingAdmins;
+}
+
+export async function getUserAndTeamWithBillingPermission({
+ userId,
+ teamId,
+ prismaClient,
+}: {
+ userId?: number | null;
+ teamId?: number | null;
+ prismaClient: PrismaClient;
+}): Promise {
+ const result: GetUserAndTeamResult = {};
+
+ if (teamId) {
+ const teamRepository = new TeamRepository(prismaClient);
+ const team = await teamRepository.findById({ id: teamId });
+
+ if (!team) {
+ return result;
+ }
+
+ const permission = team.isOrganization ? "organization.manageBilling" : "team.manageBilling";
+ const users = await teamRepository.findTeamMembersWithPermission({
+ teamId,
+ permission,
+ fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ });
+
+ const usersWithBillingAccess: UserWithBillingAccess[] = await Promise.all(
+ users.map(async (user) => ({
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ t: await getTranslation(user.locale ?? "en", "common"),
+ }))
+ );
+
+ result.team = {
+ id: team.id,
+ name: team.name ?? "",
+ adminAndOwners: usersWithBillingAccess,
+ };
+ } else if (userId) {
+ const userRepository = new UserRepository(prismaClient);
+ const userRecord = await userRepository.findById({ id: userId });
+
+ if (userRecord) {
+ result.user = {
+ id: userRecord.id,
+ name: userRecord.name,
+ email: userRecord.email,
+ t: await getTranslation(userRecord.locale ?? "en", "common"),
+ };
+ }
+ }
+
+ return result;
+}
diff --git a/packages/features/ee/billing/helpers/handleInsufficientCredits.ts b/packages/features/ee/billing/helpers/handleInsufficientCredits.ts
new file mode 100644
index 00000000000000..9df24f67fe6edd
--- /dev/null
+++ b/packages/features/ee/billing/helpers/handleInsufficientCredits.ts
@@ -0,0 +1,60 @@
+import { sendCreditBalanceLimitReachedEmails } from "@calcom/emails/email-manager";
+import logger from "@calcom/lib/logger";
+import type { PrismaClient } from "@calcom/prisma";
+import type { CreditUsageType } from "@calcom/prisma/enums";
+
+import { getUserAndTeamWithBillingPermission } from "./getUserAndTeamWithBillingPermission";
+
+const log = logger.getSubLogger({ prefix: ["handleInsufficientCredits"] });
+
+export async function handleInsufficientCredits({
+ userId,
+ teamId,
+ creditFor,
+ prismaClient,
+ context,
+}: {
+ userId?: number | null;
+ teamId?: number | null;
+ creditFor: CreditUsageType;
+ prismaClient: PrismaClient;
+ context?: Record;
+}): Promise {
+ try {
+ const { user, team } = await getUserAndTeamWithBillingPermission({
+ userId,
+ teamId,
+ prismaClient,
+ });
+
+ if (team || user) {
+ await sendCreditBalanceLimitReachedEmails({
+ team,
+ user,
+ creditFor,
+ });
+
+ log.info("Credit limit reached email sent", {
+ userId,
+ teamId,
+ creditFor,
+ ...context,
+ });
+ } else {
+ log.warn("No user or team found to send credit limit email", {
+ userId,
+ teamId,
+ creditFor,
+ ...context,
+ });
+ }
+ } catch (error) {
+ log.error("Failed to send credit limit email", {
+ error,
+ userId,
+ teamId,
+ creditFor,
+ ...context,
+ });
+ }
+}
diff --git a/packages/features/ee/teams/repositories/TeamRepository.ts b/packages/features/ee/teams/repositories/TeamRepository.ts
index 09fccc603cb3c0..293c5a375bb35b 100644
--- a/packages/features/ee/teams/repositories/TeamRepository.ts
+++ b/packages/features/ee/teams/repositories/TeamRepository.ts
@@ -391,26 +391,6 @@ export class TeamRepository {
});
}
- async getTeamByIdIfUserIsAdmin({ userId, teamId }: { userId: number; teamId: number }) {
- return await this.prismaClient.team.findUnique({
- where: {
- id: teamId,
- },
- select: {
- id: true,
- metadata: true,
- members: {
- where: {
- userId,
- role: {
- in: [MembershipRole.ADMIN, MembershipRole.OWNER],
- },
- },
- },
- },
- });
- }
-
async findTeamWithParentHideBranding({ teamId }: { teamId: number }) {
return await this.prismaClient.team.findUnique({
where: { id: teamId },
@@ -473,6 +453,26 @@ export class TeamRepository {
return !conflictingTeam;
}
+ async getTeamByIdIfUserIsAdmin({ userId, teamId }: { userId: number; teamId: number }) {
+ return await this.prismaClient.team.findUnique({
+ where: {
+ id: teamId,
+ },
+ select: {
+ id: true,
+ metadata: true,
+ members: {
+ where: {
+ userId,
+ role: {
+ in: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ },
+ },
+ },
+ },
+ });
+ }
+
async findOrgTeamsExcludingTeam({ parentId, excludeTeamId }: { parentId: number; excludeTeamId: number }) {
return await this.prismaClient.team.findMany({
where: {
@@ -484,4 +484,67 @@ export class TeamRepository {
select: { id: true },
});
}
+
+ async findTeamsForCreditCheck({ teamIds }: { teamIds: number[] }) {
+ return await this.prismaClient.team.findMany({
+ where: { id: { in: teamIds } },
+ select: { id: true, isOrganization: true, parentId: true, parent: { select: { id: true } } },
+ });
+ }
+
+ async findTeamMembersWithPermission({
+ teamId,
+ permission,
+ fallbackRoles,
+ }: {
+ teamId: number;
+ permission: string;
+ fallbackRoles: MembershipRole[];
+ }) {
+ const { resource, action } = this.parsePermission(permission);
+
+ type UserResult = {
+ id: number;
+ name: string | null;
+ email: string;
+ locale: string | null;
+ };
+
+ const users = await this.prismaClient.$queryRaw`
+ SELECT DISTINCT u.id, u.name, u.email, u.locale
+ FROM "Membership" m
+ INNER JOIN "User" u ON m."userId" = u.id
+ LEFT JOIN "Role" r ON m."customRoleId" = r.id
+ LEFT JOIN "TeamFeatures" f ON m."teamId" = f."teamId" AND f."featureId" = 'pbac'
+ WHERE m."teamId" = ${teamId}
+ AND m."accepted" = true
+ AND (
+ -- Scenario 1: PBAC enabled + custom role with permission
+ (f."teamId" IS NOT NULL
+ AND m."customRoleId" IS NOT NULL
+ AND EXISTS (
+ SELECT 1 FROM "RolePermission" rp
+ WHERE rp."roleId" = r.id
+ AND (
+ (rp."resource" = '*' AND rp."action" = '*') OR
+ (rp."resource" = ${resource} AND rp."action" = ${action}) OR
+ (rp."resource" = ${resource} AND rp."action" = '*') OR
+ (rp."resource" = '*' AND rp."action" = ${action})
+ )
+ ))
+ OR
+ -- Scenario 2 & 3: Legacy role ADMIN/OWNER (works for both PBAC and non-PBAC teams)
+ (m."role"::text = ANY(${fallbackRoles}))
+ )
+ `;
+
+ return users;
+ }
+
+ private parsePermission(permission: string): { resource: string; action: string } {
+ const lastDotIndex = permission.lastIndexOf(".");
+ const resource = permission.substring(0, lastDotIndex);
+ const action = permission.substring(lastDotIndex + 1);
+ return { resource, action };
+ }
}
diff --git a/packages/features/ee/workflows/lib/reminders/aiPhoneCallManager.ts b/packages/features/ee/workflows/lib/reminders/aiPhoneCallManager.ts
index 3b2f45535da501..a6a3defe5661fe 100644
--- a/packages/features/ee/workflows/lib/reminders/aiPhoneCallManager.ts
+++ b/packages/features/ee/workflows/lib/reminders/aiPhoneCallManager.ts
@@ -140,7 +140,7 @@ export const scheduleAIPhoneCall = async (args: ScheduleAIPhoneCallArgs) => {
const featuresRepository = new FeaturesRepository(prisma);
const calAIVoiceAgents = await featuresRepository.checkIfFeatureIsEnabledGlobally("cal-ai-voice-agents");
if (!calAIVoiceAgents) {
- logger.warn("Cal AI voice agents are disabled - skipping AI phone call scheduling");
+ logger.warn("Cal.ai voice agents are disabled - skipping AI phone call scheduling");
return;
}
@@ -259,7 +259,7 @@ const scheduleAIPhoneCallTask = async (args: ScheduleAIPhoneCallTaskArgs) => {
const featuresRepository = new FeaturesRepository(prisma);
const calAIVoiceAgents = await featuresRepository.checkIfFeatureIsEnabledGlobally("cal-ai-voice-agents");
if (!calAIVoiceAgents) {
- logger.warn("Cal AI voice agents are disabled - skipping AI phone call");
+ logger.warn("Cal.ai voice agents are disabled - skipping AI phone call");
return;
}
diff --git a/packages/features/tasker/tasks/executeAIPhoneCall.ts b/packages/features/tasker/tasks/executeAIPhoneCall.ts
index 308442ab175700..158cbac8bd9e2c 100644
--- a/packages/features/tasker/tasks/executeAIPhoneCall.ts
+++ b/packages/features/tasker/tasks/executeAIPhoneCall.ts
@@ -1,10 +1,12 @@
import dayjs from "@calcom/dayjs";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone";
+import { handleInsufficientCredits } from "@calcom/features/ee/billing/helpers/handleInsufficientCredits";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
+import { CreditUsageType } from "@calcom/prisma/enums";
interface ExecuteAIPhoneCallPayload {
workflowReminderId: number;
@@ -31,7 +33,7 @@ export async function executeAIPhoneCall(payload: string) {
const calAIVoiceAgents = await featuresRepository.checkIfFeatureIsEnabledGlobally("cal-ai-voice-agents");
if (!calAIVoiceAgents) {
- log.warn("Cal AI voice agents are disabled - skipping AI phone call");
+ log.warn("Cal.ai voice agents are disabled - skipping AI phone call");
return;
}
@@ -107,15 +109,25 @@ export async function executeAIPhoneCall(payload: string) {
});
if (!hasCredits) {
- log.warn(`Insufficient credits for AI phone call`, {
+ log.warn(`Insufficient credits for AI phone call for workflow reminder ${data.workflowReminderId}`, {
userId: data.userId,
teamId: data.teamId,
workflowReminderId: data.workflowReminderId,
bookingUid: workflowReminder.booking?.uid,
});
- throw new Error(
- `Insufficient credits to make AI phone call. Please purchase more credits. user: ${data?.userId}, team: ${data?.teamId}`
- );
+
+ await handleInsufficientCredits({
+ userId: data.userId,
+ teamId: data.teamId,
+ creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
+ prismaClient: prisma,
+ context: {
+ workflowReminderId: data.workflowReminderId,
+ bookingUid: workflowReminder.booking?.uid,
+ },
+ });
+
+ return;
}
}
diff --git a/packages/lib/getOrgIdFromMemberOrTeamId.ts b/packages/lib/getOrgIdFromMemberOrTeamId.ts
index 82875fc3a7dfd4..0aae179d13ed14 100644
--- a/packages/lib/getOrgIdFromMemberOrTeamId.ts
+++ b/packages/lib/getOrgIdFromMemberOrTeamId.ts
@@ -1,4 +1,4 @@
-import prisma from "@calcom/prisma";
+import { prisma, type PrismaTransaction } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
const getOrgMemberOrTeamWhere = (memberId?: number | null, teamId?: number | null) => {
@@ -44,11 +44,15 @@ const getOrgMemberOrTeamWhere = (memberId?: number | null, teamId?: number | nul
};
};
-export default async function getOrgIdFromMemberOrTeamId(args: {
- memberId?: number | null;
- teamId?: number | null;
-}) {
- const orgId = await prisma.team.findFirst({
+export default async function getOrgIdFromMemberOrTeamId(
+ args: {
+ memberId?: number | null;
+ teamId?: number | null;
+ },
+ tx?: PrismaTransaction
+) {
+ const client = tx ?? prisma;
+ const orgId = await client.team.findFirst({
where: getOrgMemberOrTeamWhere(args.memberId, args.teamId),
select: {
id: true,
diff --git a/packages/lib/server/repository/PrismaAgentRepository.ts b/packages/lib/server/repository/PrismaAgentRepository.ts
index ea27ee94b06a70..d0676cd5ebd69e 100644
--- a/packages/lib/server/repository/PrismaAgentRepository.ts
+++ b/packages/lib/server/repository/PrismaAgentRepository.ts
@@ -183,6 +183,12 @@ export class PrismaAgentRepository {
inboundEventTypeId: true,
createdAt: true,
updatedAt: true,
+ team: {
+ select: {
+ id: true,
+ parentId: true,
+ },
+ },
},
where: {
providerAgentId,
diff --git a/packages/lib/server/repository/PrismaApiKeyRepository.ts b/packages/lib/server/repository/PrismaApiKeyRepository.ts
index fc4440f6f60dab..a5e1e48c67c922 100644
--- a/packages/lib/server/repository/PrismaApiKeyRepository.ts
+++ b/packages/lib/server/repository/PrismaApiKeyRepository.ts
@@ -47,7 +47,7 @@ export class PrismaApiKeyRepository {
orderBy: { createdAt: "desc" },
});
return apiKeys.filter((apiKey) => {
- if (apiKey.note?.startsWith("Cal AI Phone API Key")) {
+ if (apiKey.note?.startsWith("Cal.ai Phone API Key")) {
return false;
}
return true;
diff --git a/packages/lib/server/repository/PrismaPhoneNumberRepository.ts b/packages/lib/server/repository/PrismaPhoneNumberRepository.ts
index 173924969786fd..c543f06e8a90e7 100644
--- a/packages/lib/server/repository/PrismaPhoneNumberRepository.ts
+++ b/packages/lib/server/repository/PrismaPhoneNumberRepository.ts
@@ -547,7 +547,7 @@ export class PrismaPhoneNumberRepository {
userId: true,
teamId: true,
user: { select: { id: true, email: true, name: true } },
- team: { select: { id: true, name: true } },
+ team: { select: { id: true, name: true, parentId: true } },
},
});
}
diff --git a/packages/trpc/server/routers/viewer/aiVoiceAgent/testCall.handler.ts b/packages/trpc/server/routers/viewer/aiVoiceAgent/testCall.handler.ts
index 19f376eccf26d1..1b1c7cfc8f77ce 100644
--- a/packages/trpc/server/routers/viewer/aiVoiceAgent/testCall.handler.ts
+++ b/packages/trpc/server/routers/viewer/aiVoiceAgent/testCall.handler.ts
@@ -23,7 +23,7 @@ export const testCallHandler = async ({ ctx, input }: TestCallHandlerOptions) =>
const featuresRepository = new FeaturesRepository(prisma);
const calAIVoiceAgents = await featuresRepository.checkIfFeatureIsEnabledGlobally("cal-ai-voice-agents");
if (!calAIVoiceAgents) {
- logger.warn("Cal AI voice agents are disabled - skipping AI phone call scheduling");
+ logger.warn("Cal.ai voice agents are disabled - skipping AI phone call scheduling");
return;
}