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 9b10573c154122..68d32740a26846 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 @@ -2,8 +2,9 @@ import type { NextRequest } from "next/server"; import { Retell } from "retell-sdk"; import { describe, it, expect, vi, beforeEach } from "vitest"; +import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository"; import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository"; -import type { CalAiPhoneNumber, User, Team } from "@calcom/prisma/client"; +import type { CalAiPhoneNumber, User, Team, Agent } from "@calcom/prisma/client"; import { POST } from "../route"; @@ -25,6 +26,7 @@ type RetellWebhookBody = { from_number?: string; to_number?: string; direction?: "inbound" | "outbound"; + call_type?: string; call_status?: string; start_timestamp?: number; end_timestamp?: number; @@ -85,6 +87,12 @@ vi.mock("@calcom/lib/server/repository/PrismaPhoneNumberRepository", () => ({ }, })); +vi.mock("@calcom/lib/server/repository/PrismaAgentRepository", () => ({ + PrismaAgentRepository: { + findByProviderAgentId: vi.fn(), + }, +})); + vi.mock("next/server", () => ({ NextResponse: { json: vi.fn((data, options) => ({ @@ -683,4 +691,179 @@ describe("Retell AI Webhook Handler", () => { expect(data.message).toContain("Error charging credits for Retell AI call"); }); }); + + describe("Web Call Tests", () => { + const mockAgent: Pick< + Agent, + "id" | "name" | "providerAgentId" | "enabled" | "userId" | "teamId" | "createdAt" | "updatedAt" + > = { + id: "agent-123", + name: "Test Agent", + providerAgentId: "agent_5e3e0d29d692172c2c24d8f9a7", + enabled: true, + userId: 1, + teamId: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(Retell.verify).mockReturnValue(true); + }); + + it("should process web call with valid agent and charge credits", async () => { + vi.mocked(PrismaAgentRepository.findByProviderAgentId).mockResolvedValue(mockAgent); + mockChargeCredits.mockResolvedValue(undefined); + + const body: RetellWebhookBody = { + event: "call_analyzed", + call: { + call_id: "call_bcd94f5a50832873a5fd68cb1aa", + call_type: "web_call", + agent_id: "agent_5e3e0d29d692172c2c24d8f9a7", + call_status: "ended", + start_timestamp: 1757673314024, + end_timestamp: 1757673321010, + call_cost: { + total_duration_seconds: 7, + combined_cost: 1.3416667, + }, + }, + }; + + const request = createMockRequest(body, "valid-signature"); + const response = await callPOST(request); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.success).toBe(true); + + expect(PrismaAgentRepository.findByProviderAgentId).toHaveBeenCalledWith({ + providerAgentId: "agent_5e3e0d29d692172c2c24d8f9a7", + }); + + expect(mockChargeCredits).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 1, + teamId: undefined, + credits: 4, // 7 seconds = 0.117 minutes * $0.29 = $0.034 = 4 credits (rounded up) + callDuration: 7, + externalRef: "retell:call_bcd94f5a50832873a5fd68cb1aa", + }) + ); + }); + + it("should handle web call with team agent", async () => { + const teamAgent = { ...mockAgent, userId: 2, teamId: 10 }; + vi.mocked(PrismaAgentRepository.findByProviderAgentId).mockResolvedValue(teamAgent); + mockChargeCredits.mockResolvedValue(undefined); + + const body: RetellWebhookBody = { + event: "call_analyzed", + call: { + call_id: "web-call-team", + call_type: "web_call", + agent_id: "agent_team_123", + call_status: "ended", + start_timestamp: 1757673314024, + call_cost: { + total_duration_seconds: 60, + combined_cost: 2.0, + }, + }, + }; + + const request = createMockRequest(body, "valid-signature"); + const response = await callPOST(request); + + expect(response.status).toBe(200); + expect(mockChargeCredits).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 2, + teamId: 10, + credits: 29, // 60 seconds = 1 minute * $0.29 = 29 credits + callDuration: 60, + }) + ); + }); + + it("should handle web call without from_number", async () => { + vi.mocked(PrismaAgentRepository.findByProviderAgentId).mockResolvedValue(mockAgent); + mockChargeCredits.mockResolvedValue(undefined); + + const body: RetellWebhookBody = { + event: "call_analyzed", + call: { + call_id: "web-call-no-phone", + agent_id: "agent_5e3e0d29d692172c2c24d8f9a7", + call_status: "ended", + start_timestamp: 1757673314024, + call_cost: { + total_duration_seconds: 30, + combined_cost: 1.0, + }, + }, + }; + + const request = createMockRequest(body, "valid-signature"); + const response = await callPOST(request); + + expect(response.status).toBe(200); + expect(PrismaAgentRepository.findByProviderAgentId).toHaveBeenCalled(); + expect(PrismaPhoneNumberRepository.findByPhoneNumber).not.toHaveBeenCalled(); + expect(mockChargeCredits).toHaveBeenCalled(); + }); + + it("should handle web call with missing agent_id", async () => { + const body: RetellWebhookBody = { + event: "call_analyzed", + call: { + call_id: "web-call-no-agent", + call_type: "web_call", + call_status: "ended", + call_cost: { + total_duration_seconds: 30, + combined_cost: 1.0, + }, + }, + }; + + const request = createMockRequest(body, "valid-signature"); + const response = await callPOST(request); + + expect(response.status).toBe(200); + expect(PrismaAgentRepository.findByProviderAgentId).not.toHaveBeenCalled(); + expect(mockChargeCredits).not.toHaveBeenCalled(); + }); + + it("should handle web call with agent not found", async () => { + vi.mocked(PrismaAgentRepository.findByProviderAgentId).mockResolvedValue(null); + + const body: RetellWebhookBody = { + event: "call_analyzed", + call: { + call_id: "web-call-no-agent-found", + call_type: "web_call", + agent_id: "non-existent-agent", + call_status: "ended", + start_timestamp: 1757673314024, + call_cost: { + total_duration_seconds: 30, + combined_cost: 1.0, + }, + // Web calls don't have from_number, to_number, direction + }, + }; + + const request = createMockRequest(body, "valid-signature"); + const response = await callPOST(request); + + expect(response.status).toBe(200); + expect(PrismaAgentRepository.findByProviderAgentId).toHaveBeenCalledWith({ + providerAgentId: "non-existent-agent", + }); + expect(mockChargeCredits).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/web/app/api/webhooks/retell-ai/route.ts b/apps/web/app/api/webhooks/retell-ai/route.ts index 08a4f60d42cf33..ecf468a3ad73b0 100644 --- a/apps/web/app/api/webhooks/retell-ai/route.ts +++ b/apps/web/app/api/webhooks/retell-ai/route.ts @@ -7,6 +7,7 @@ import { z } from "zod"; import { CreditService } from "@calcom/features/ee/billing/credit-service"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; +import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository"; import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository"; import { CreditUsageType } from "@calcom/prisma/enums"; @@ -18,9 +19,11 @@ const RetellWebhookSchema = z.object({ .object({ call_id: z.string(), agent_id: z.string().optional(), - from_number: z.string(), - to_number: z.string(), - direction: z.enum(["inbound", "outbound"]), + // Make phone fields optional for web calls + from_number: z.string().optional(), + to_number: z.string().optional(), + direction: z.enum(["inbound", "outbound"]).optional(), + call_type: z.string().optional(), call_status: z.string(), start_timestamp: z.number(), end_timestamp: z.number().optional(), @@ -59,44 +62,27 @@ const RetellWebhookSchema = z.object({ .passthrough(), }); -async function handleCallAnalyzed(callData: any) { - const { from_number, call_id, call_cost } = callData; - if ( - !call_cost || - typeof call_cost.total_duration_seconds !== "number" || - !Number.isFinite(call_cost.total_duration_seconds) || - call_cost.total_duration_seconds <= 0 - ) { - log.error( - `Invalid or missing call_cost.total_duration_seconds for call ${call_id}: ${safeStringify(call_cost)}` - ); - return; - } - - const phoneNumber = await PrismaPhoneNumberRepository.findByPhoneNumber({ phoneNumber: from_number }); - - if (!phoneNumber) { - log.error(`No phone number found for ${from_number}, cannot deduct credits`); - return; - } - - // Support both personal and team phone numbers - const userId = phoneNumber.userId; - const teamId = phoneNumber.teamId; - - if (!userId && !teamId) { - log.error(`Phone number ${from_number} has no associated user or team`); - return; - } - +async function chargeCreditsForCall({ + userId, + teamId, + callCost, + callId, + callDuration, +}: { + userId?: number; + teamId?: number; + callCost: number; + callId: string; + callDuration: number; +}) { const rawRatePerMinute = process.env.CAL_AI_CALL_RATE_PER_MINUTE ?? "0.29"; const ratePerMinute = Number.parseFloat(rawRatePerMinute); const safeRatePerMinute = Number.isFinite(ratePerMinute) && ratePerMinute > 0 ? ratePerMinute : 0.29; - const durationInMinutes = call_cost.total_duration_seconds / 60; - const callCost = durationInMinutes * safeRatePerMinute; + const durationInMinutes = callDuration / 60; + const calculatedCallCost = durationInMinutes * safeRatePerMinute; // Convert to cents and round up to ensure we don't undercharge - const creditsToDeduct = Math.ceil(callCost * 100); + const creditsToDeduct = Math.ceil(calculatedCallCost * 100); const creditService = new CreditService(); @@ -105,15 +91,22 @@ async function handleCallAnalyzed(callData: any) { userId: userId ?? undefined, teamId: teamId ?? undefined, credits: creditsToDeduct, - callDuration: call_cost.total_duration_seconds, + callDuration: callDuration, creditFor: CreditUsageType.CAL_AI_PHONE_CALL, - externalRef: `retell:${call_id}`, + externalRef: `retell:${callId}`, }); + + return { + success: true, + message: `Successfully charged ${creditsToDeduct} credits (${callDuration}s at $${safeRatePerMinute}/min) for ${ + teamId ? `team:${teamId}` : "" + } ${userId ? `user:${userId}` : ""}, call ${callId}`, + }; } catch (e) { log.error("Error charging credits for Retell AI call", { error: e, - call_id, - call_cost, + call_id: callId, + call_cost: callCost, userId, teamId, }); @@ -124,15 +117,74 @@ async function handleCallAnalyzed(callData: any) { }`, }; } +} + +async function handleCallAnalyzed(callData: any) { + const { from_number, call_id, call_cost, call_type, agent_id } = callData; - return { - success: true, - message: `Successfully charged ${creditsToDeduct} credits (${ - call_cost.total_duration_seconds - }s at $${safeRatePerMinute}/min) for ${teamId ? `team:${teamId}` : ""} ${ - userId ? `user:${userId}` : "" - }, call ${call_id}`, - }; + if ( + !call_cost || + typeof call_cost.total_duration_seconds !== "number" || + !Number.isFinite(call_cost.total_duration_seconds) || + call_cost.total_duration_seconds <= 0 + ) { + log.error( + `Invalid or missing call_cost.total_duration_seconds for call ${call_id}: ${safeStringify(call_cost)}` + ); + return; + } + + let userId: number | undefined; + let teamId: number | undefined; + + // Handle web calls vs phone calls + if (call_type === "web_call" || !from_number) { + if (!agent_id) { + log.error(`Web call ${call_id} missing agent_id, cannot charge credits`); + return; + } + + const agent = await PrismaAgentRepository.findByProviderAgentId({ + providerAgentId: agent_id, + }); + + if (!agent) { + log.error(`No agent found for providerAgentId ${agent_id}, call ${call_id}`); + return; + } + + userId = agent.userId; + teamId = agent.teamId ?? undefined; + + log.info(`Processing web call ${call_id} for agent ${agent_id}, user ${userId}, team ${teamId}`); + } else { + const phoneNumber = await PrismaPhoneNumberRepository.findByPhoneNumber({ + phoneNumber: from_number, + }); + + if (!phoneNumber) { + log.error(`No phone number found for ${from_number}, call ${call_id}`); + return; + } + + userId = phoneNumber.userId ?? undefined; + teamId = phoneNumber.teamId ?? undefined; + + log.info(`Processing phone call ${call_id} from ${from_number}, user ${userId}, team ${teamId}`); + } + + if (!userId && !teamId) { + log.error(`Call ${call_id} has no associated user or team`); + return; + } + + return await chargeCreditsForCall({ + userId, + teamId, + callCost: call_cost.combined_cost || 0, + callId: call_id, + callDuration: call_cost.total_duration_seconds, + }); } /** @@ -192,6 +244,8 @@ async function handler(request: NextRequest) { try { const payload = RetellWebhookSchema.parse(body); const callData = payload.call; + + // Skip inbound calls (only for phone calls, web calls don't have direction) if (callData.direction === "inbound") { return NextResponse.json( { @@ -202,7 +256,9 @@ async function handler(request: NextRequest) { ); } - log.info(`Received Retell AI webhook: ${payload.event} for call ${callData.call_id}`); + log.info(`Received Retell AI webhook: ${payload.event} for call ${callData.call_id}`, { + call_id: callData.call_id, + }); const result = await handleCallAnalyzed(callData);