feat: support web calls in webhook (#23788)

* feat: support web calls in webhook

* fix: test

* chore: formating

* fix: type error

* chore: improvements

* chore: improvements
This commit is contained in:
Udit Takkar authored and GitHub committed 2025-09-12 19:04:19 +00:00
1 parent 5230694e2c
commit 3915348d03
2 files changed
+303 -50

No files matched your search

@@ -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();
});
});
});
+119 -49
View File
@@ -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,88 @@ async function handleCallAnalyzed(callData: any) {
}`,
};
}
}
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}`,
};
async function handleCallAnalyzed(callData: any) {
const { from_number, call_id, call_cost, call_type, agent_id } = 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.info(
`Invalid or missing call_cost.total_duration_seconds for call ${call_id}: ${safeStringify(call_cost)}`
);
return {
success: true,
message: `Invalid or missing call_cost.total_duration_seconds for call ${call_id}`
};
}
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 {
success: false,
message: `Web call ${call_id} missing agent_id, cannot charge credits`
};
}
const agent = await PrismaAgentRepository.findByProviderAgentId({
providerAgentId: agent_id,
});
if (!agent) {
log.error(`No agent found for providerAgentId ${agent_id}, call ${call_id}`);
return {
success: false,
message: `No agent found for providerAgentId ${agent_id}, call ${call_id}`
};
}
userId = agent.userId ?? undefined;
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) {
const msg = `No phone number found for ${from_number}, call ${call_id}`;
log.error(msg);
return { success: false, message: msg };
}
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 {
success: false,
message: `Call ${call_id} has no associated user or team`,
};
}
return await chargeCreditsForCall({
userId,
teamId,
callCost: call_cost.combined_cost || 0,
callId: call_id,
callDuration: call_cost.total_duration_seconds,
});
}
/**
@@ -192,6 +258,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 +270,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);