diff --git a/apps/web/app/(use-page-wrapper)/workflow/new/page.tsx b/apps/web/app/(use-page-wrapper)/workflow/new/page.tsx index b6f616440920b8..6555cf19d8b015 100644 --- a/apps/web/app/(use-page-wrapper)/workflow/new/page.tsx +++ b/apps/web/app/(use-page-wrapper)/workflow/new/page.tsx @@ -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"); } }; diff --git a/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts b/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts index a6dedcdf75e00b..4a912cd40fc550 100644 --- a/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts +++ b/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts @@ -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, }) ); }); diff --git a/apps/web/app/api/webhooks/retell-ai/route.ts b/apps/web/app/api/webhooks/retell-ai/route.ts index bca92b4ece5040..53bef78c878eb3 100644 --- a/apps/web/app/api/webhooks/retell-ai/route.ts +++ b/apps/web/app/api/webhooks/retell-ai/route.ts @@ -63,6 +63,8 @@ const RetellWebhookSchema = z.object({ .passthrough(), }); +type RetellCallData = z.infer["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}`); } diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 495934ec70a215..29bab1f1a4af4c 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index 695550de533e42..c8f639cb3e8930 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -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[] = []; 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[] = []; 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 })); } }; diff --git a/packages/emails/src/templates/CreditBalanceLimitReachedEmail.tsx b/packages/emails/src/templates/CreditBalanceLimitReachedEmail.tsx index 23353747987c89..dbd43d74b6fe05 100644 --- a/packages/emails/src/templates/CreditBalanceLimitReachedEmail.tsx +++ b/packages/emails/src/templates/CreditBalanceLimitReachedEmail.tsx @@ -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> ) => { - 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 })},

- <>{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 })} +

{user.t("hi_user_name", { name: user.name })},

- <>{user.t("credit_limit_reached_message_user")} + <> + {isCalAi + ? user.t("cal_ai_credit_limit_reached_message_user") + : user.t("credit_limit_reached_message_user")} +

> ) => { - 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; }