fix: cal ai webhook (#24368)

* fix: cal ai email

* fix: remove

* fix: org

* replace Cal AI with Cal.ai

* fix: use

* fix: feedback

* fix: types

* fix: types

* fix: types

* fix: tests

* Merge branch 'main' into fix/cal-ai-credits

* refactor: feedback

* refactor: imporvement

* fix: type

* refactor: feedback

* fix: tests

* fix: use pbac

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
Udit TakkarandCarinaWolli authored and GitHub committed 2025-11-05 13:31:55 +04:00
1 parent 7f48c7fdad
commit 2df2868b20
23 files changed
+589 -74

No files matched your search

@@ -130,7 +130,7 @@ const Page = async ({ searchParams }: PageProps) => {
throw error;
}
console.error("Failed to create Cal AI workflow:", error);
console.error("Failed to create Cal.ai workflow:", error);
redirect("/workflows?error=failed-to-create-workflow");
}
};
@@ -3,6 +3,7 @@ import { Retell } from "retell-sdk";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { CalAiPhoneNumber, User, Team, Agent } from "@calcom/prisma/client";
import { CreditUsageType } from "@calcom/prisma/enums";
import { POST } from "../route";
@@ -71,6 +72,8 @@ vi.mock("retell-sdk", () => ({
const mockHasAvailableCredits = vi.fn();
const mockChargeCredits = vi.fn();
const mockSendCreditBalanceLimitReachedEmails = vi.fn();
const mockSendCreditBalanceLowWarningEmails = vi.fn();
vi.mock("@calcom/features/ee/billing/credit-service", () => ({
CreditService: vi.fn().mockImplementation(() => ({
@@ -79,6 +82,12 @@ vi.mock("@calcom/features/ee/billing/credit-service", () => ({
})),
}));
vi.mock("@calcom/emails/email-manager", () => ({
sendCreditBalanceLimitReachedEmails: (...args: unknown[]) =>
mockSendCreditBalanceLimitReachedEmails(...args),
sendCreditBalanceLowWarningEmails: (...args: unknown[]) => mockSendCreditBalanceLowWarningEmails(...args),
}));
const mockFindByPhoneNumber = vi.fn();
const mockFindByProviderAgentId = vi.fn();
@@ -231,6 +240,7 @@ describe("Retell AI Webhook Handler", () => {
credits: 58, // 120 seconds = 2 minutes * $0.29 = $0.58 = 58 credits
callDuration: 120,
externalRef: "retell:test-call-id",
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
})
);
});
@@ -288,6 +298,7 @@ describe("Retell AI Webhook Handler", () => {
credits: 87, // 180 seconds = 3 minutes * $0.29 = $0.87 = 87 credits
callDuration: 180,
externalRef: "retell:test-call-id",
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
})
);
});
@@ -471,6 +482,7 @@ describe("Retell AI Webhook Handler", () => {
credits: expectedCredits,
callDuration: durationSeconds,
externalRef: expect.stringMatching(/^retell:test-call-/),
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
})
);
}
@@ -517,7 +529,12 @@ describe("Retell AI Webhook Handler", () => {
const response = await callPOST(createMockRequest(body, "valid-signature"));
expect(response.status).toBe(200);
expect(mockChargeCredits).toHaveBeenCalledWith(
expect.objectContaining({ userId: 42, credits: 61, callDuration: 125 }) // 125s = 2.083 minutes * $0.29 = $0.604 = 61 credits (rounded up)
expect.objectContaining({
userId: 42,
credits: 61,
callDuration: 125,
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
}) // 125s = 2.083 minutes * $0.29 = $0.604 = 61 credits (rounded up)
);
});
@@ -574,6 +591,7 @@ describe("Retell AI Webhook Handler", () => {
credits: 29, // 60 seconds = 1 minute * $0.29 = $0.29 = 29 credits
callDuration: 60,
externalRef: "retell:test-idempotency-call",
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
})
);
});
@@ -760,6 +778,7 @@ describe("Retell AI Webhook Handler", () => {
credits: 4, // 7 seconds = 0.117 minutes * $0.29 = $0.034 = 4 credits (rounded up)
callDuration: 7,
externalRef: "retell:call_bcd94f5a50832873a5fd68cb1aa",
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
})
);
});
@@ -794,6 +813,7 @@ describe("Retell AI Webhook Handler", () => {
teamId: 10,
credits: 29, // 60 seconds = 1 minute * $0.29 = 29 credits
callDuration: 60,
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
})
);
});
+5 -3
View File
@@ -63,6 +63,8 @@ const RetellWebhookSchema = z.object({
.passthrough(),
});
type RetellCallData = z.infer<typeof RetellWebhookSchema>["call"];
async function chargeCreditsForCall({
userId,
teamId,
@@ -120,7 +122,7 @@ async function chargeCreditsForCall({
}
}
async function handleCallAnalyzed(callData: any) {
async function handleCallAnalyzed(callData: RetellCallData) {
const { from_number, call_id, call_cost, call_type, agent_id } = callData;
if (
@@ -165,7 +167,7 @@ async function handleCallAnalyzed(callData: any) {
}
userId = agent.userId ?? undefined;
teamId = agent.teamId ?? undefined;
teamId = agent.team?.parentId ?? agent.teamId ?? undefined;
log.info(`Processing web call ${call_id} for agent ${agent_id}, user ${userId}, team ${teamId}`);
} else {
@@ -181,7 +183,7 @@ async function handleCallAnalyzed(callData: any) {
}
userId = phoneNumber.userId ?? undefined;
teamId = phoneNumber.teamId ?? undefined;
teamId = phoneNumber.team?.parentId ?? phoneNumber.teamId ?? undefined;
log.info(`Processing phone call ${call_id} from ${from_number}, user ${userId}, team ${teamId}`);
}
@@ -3490,6 +3490,10 @@
"low_credits_warning_message_user": "Your Cal.com account is running low on credits. To avoid any disruption in service, please purchase additional credits. If your balance runs out, SMS messages will stop sending and will be sent as emails instead.",
"credit_limit_reached_message": "Your Cal.com team {{teamName}} has run out of credits. As a result, SMS messages are now being sent via email instead. To resume sending SMS, please purchase additional credits.",
"credit_limit_reached_message_user": "Your Cal.com account has run out of credits. As a result, SMS messages are now being sent via email instead. To resume sending SMS, please purchase additional credits.",
"cal_ai_low_credits_warning_message": "Your Cal.com team {{teamName}} is running low on credits. To avoid any disruption in Cal.ai phone service, please purchase additional credits. If your balance runs out, Cal.ai phone calls will be disabled.",
"cal_ai_low_credits_warning_message_user": "Your Cal.com account is running low on credits. To avoid any disruption in Cal.ai phone service, please purchase additional credits. If your balance runs out, Cal.ai phone calls will be disabled.",
"cal_ai_credit_limit_reached_message": "Your Cal.com team {{teamName}} has run out of credits. As a result, Cal.ai phone calls are now disabled. To resume using Cal.ai phone calls, please purchase additional credits.",
"cal_ai_credit_limit_reached_message_user": "Your Cal.com account has run out of credits. As a result, Cal.ai phone calls are now disabled. To resume using Cal.ai phone calls, please purchase additional credits.",
"current_credit_balance": "Current balance: {{balance}} credits",
"current_balance": "Current balance:",
"notification_about_your_booking": "Notification about your booking",
+13 -5
View File
@@ -10,6 +10,7 @@ import { formatCalEvent } from "@calcom/lib/formatCalendarEvent";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { withReporting } from "@calcom/lib/sentryWrapper";
import type { CreditUsageType } from "@calcom/prisma/enums";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
@@ -852,28 +853,32 @@ export const sendCreditBalanceLowWarningEmails = async (input: {
t: TFunction;
};
balance: number;
creditFor?: CreditUsageType;
}) => {
const { team, balance, user } = input;
const { team, balance, user, creditFor } = input;
if ((!team || !team.adminAndOwners.length) && !user) return;
if (team) {
const emailsToSend: Promise<unknown>[] = [];
for (const admin of team.adminAndOwners) {
emailsToSend.push(sendEmail(() => new CreditBalanceLowWarningEmail({ user: admin, balance, team })));
emailsToSend.push(
sendEmail(() => new CreditBalanceLowWarningEmail({ user: admin, balance, team, creditFor }))
);
}
await Promise.all(emailsToSend);
}
if (user) {
await sendEmail(() => new CreditBalanceLowWarningEmail({ user, balance }));
await sendEmail(() => new CreditBalanceLowWarningEmail({ user, balance, creditFor }));
}
};
export const sendCreditBalanceLimitReachedEmails = async ({
team,
user,
creditFor,
}: {
team?: {
name: string;
@@ -891,6 +896,7 @@ export const sendCreditBalanceLimitReachedEmails = async ({
email: string;
t: TFunction;
};
creditFor?: CreditUsageType;
}) => {
if ((!team || !team.adminAndOwners.length) && !user) return;
@@ -898,13 +904,15 @@ export const sendCreditBalanceLimitReachedEmails = async ({
const emailsToSend: Promise<unknown>[] = [];
for (const admin of team.adminAndOwners) {
emailsToSend.push(sendEmail(() => new CreditBalanceLimitReachedEmail({ user: admin, team })));
emailsToSend.push(
sendEmail(() => new CreditBalanceLimitReachedEmail({ user: admin, team, creditFor }))
);
}
await Promise.all(emailsToSend);
}
if (user) {
await sendEmail(() => new CreditBalanceLimitReachedEmail({ user }));
await sendEmail(() => new CreditBalanceLimitReachedEmail({ user, creditFor }));
}
};
@@ -1,6 +1,7 @@
import type { TFunction } from "i18next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { CreditUsageType } from "@calcom/prisma/enums";
import { CallToAction, V2BaseEmailHtml } from "../components";
import type { BaseScheduledEmail } from "./BaseScheduledEmail";
@@ -17,9 +18,11 @@ export const CreditBalanceLimitReachedEmail = (
email: string;
t: TFunction;
};
creditFor?: CreditUsageType;
} & Partial<React.ComponentProps<typeof BaseScheduledEmail>>
) => {
const { team, user } = props;
const { team, user, creditFor } = props;
const isCalAi = creditFor === CreditUsageType.CAL_AI_PHONE_CALL;
if (team) {
return (
@@ -28,7 +31,11 @@ export const CreditBalanceLimitReachedEmail = (
<> {user.t("hi_user_name", { name: user.name })},</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "20px" }}>
<>{user.t("credit_limit_reached_message", { teamName: team.name })}</>
<>
{isCalAi
? user.t("cal_ai_credit_limit_reached_message", { teamName: team.name })
: user.t("credit_limit_reached_message", { teamName: team.name })}
</>
</p>
<div style={{ textAlign: "center", marginTop: "24px" }}>
<CallToAction
@@ -47,7 +54,11 @@ export const CreditBalanceLimitReachedEmail = (
<> {user.t("hi_user_name", { name: user.name })},</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "20px" }}>
<>{user.t("credit_limit_reached_message_user")}</>
<>
{isCalAi
? user.t("cal_ai_credit_limit_reached_message_user")
: user.t("credit_limit_reached_message_user")}
</>
</p>
<div style={{ textAlign: "center", marginTop: "24px" }}>
<CallToAction
@@ -1,6 +1,7 @@
import type { TFunction } from "i18next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { CreditUsageType } from "@calcom/prisma/enums";
import { CallToAction, V2BaseEmailHtml } from "../components";
import type { BaseScheduledEmail } from "./BaseScheduledEmail";
@@ -18,9 +19,11 @@ export const CreditBalanceLowWarningEmail = (
email: string;
t: TFunction;
};
creditFor?: CreditUsageType;
} & Partial<React.ComponentProps<typeof BaseScheduledEmail>>
) => {
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 })},</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "20px" }}>
<>{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 })}
</>
</p>
<p
style={{
@@ -56,7 +63,11 @@ export const CreditBalanceLowWarningEmail = (
<> {user.t("hi_user_name", { name: user.name })},</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "20px" }}>
<>{user.t("low_credits_warning_message_user")}</>
<>
{isCalAi
? user.t("cal_ai_low_credits_warning_message_user")
: user.t("low_credits_warning_message_user")}
</>
</p>
<div style={{ textAlign: "center", marginTop: "24px" }}>
<CallToAction
@@ -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";
@@ -16,17 +17,21 @@ export default class CreditBalanceLimitReachedEmail extends BaseEmail {
id: number;
name: string;
};
creditFor?: CreditUsageType;
constructor({
user,
team,
creditFor,
}: {
user: { id: number; name: string | null; email: string; t: TFunction };
team?: { id: number; name: string | null };
creditFor?: CreditUsageType;
}) {
super();
this.user = { ...user, name: user.name || "" };
this.team = team ? { ...team, name: team.name || "" } : undefined;
this.creditFor = creditFor;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
@@ -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(),
};
@@ -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<Record<string, unknown>> {
@@ -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(),
};
@@ -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}` : ""}`,
});
}
@@ -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.
@@ -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", () => {
+82 -17
View File
@@ -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<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);
@@ -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<LowCreditBalanceResult> {
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<unknown>[] = [
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 });
});
@@ -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<GetUserAndTeamResult> {
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;
}
@@ -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<string, unknown>;
}): Promise<void> {
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,
});
}
}
@@ -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<UserResult[]>`
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 };
}
}
@@ -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;
}
@@ -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;
}
}
+10 -6
View File
@@ -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,
@@ -183,6 +183,12 @@ export class PrismaAgentRepository {
inboundEventTypeId: true,
createdAt: true,
updatedAt: true,
team: {
select: {
id: true,
parentId: true,
},
},
},
where: {
providerAgentId,
@@ -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;
@@ -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 } },
},
});
}
@@ -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;
}