diff --git a/.env.example b/.env.example index f01c94434413e6..ca21dccf75d266 100644 --- a/.env.example +++ b/.env.example @@ -383,10 +383,23 @@ TASKER_ENABLE_EMAILS=0 # Ratelimiting via unkey UNKEY_ROOT_KEY= -# Used for Cal.ai Enterprise Voice AI Agents +# Used for Cal.ai Voice AI Agents # https://retellai.com RETELL_AI_KEY= +# Used for buying phone number for cal ai voice agent +STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID= + + +CAL_AI_CALL_RATE_PER_MINUTE=0.29 + + +STRIPE_WEBHOOK_SECRET_BILLING= + + +# Price for buying a phone number for cal.ai voice agent (Default is 5) +NEXT_PUBLIC_CAL_AI_PHONE_NUMBER_MONTHLY_PRICE= + # Used for the huddle01 integration HUDDLE01_API_TOKEN= diff --git a/.gitignore b/.gitignore index 803345d1ac09dc..3dbd11437617b4 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,5 @@ apps/auth !.yarn/sdks !.yarn/versions i18n.cache + +.claude diff --git a/apps/web/app/api/calAIPhone/subscription/success/route.ts b/apps/web/app/api/calAIPhone/subscription/success/route.ts new file mode 100644 index 00000000000000..2912d6ff716b5b --- /dev/null +++ b/apps/web/app/api/calAIPhone/subscription/success/route.ts @@ -0,0 +1,5 @@ +import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; + +import handler from "@calcom/features/calAIPhone/phoneNumberSubscriptionWebhook"; + +export const GET = defaultResponderForAppDir(handler); 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 new file mode 100644 index 00000000000000..9b10573c154122 --- /dev/null +++ b/apps/web/app/api/webhooks/retell-ai/__tests__/route.test.ts @@ -0,0 +1,686 @@ +import type { NextRequest } from "next/server"; +import { Retell } from "retell-sdk"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository"; +import type { CalAiPhoneNumber, User, Team } from "@calcom/prisma/client"; + +import { POST } from "../route"; + +type MockPhoneNumberWithUser = Omit & { + user: Pick; + team: null; +}; + +type MockPhoneNumberWithTeam = Omit & { + user: null; + team: Pick; +}; + +type RetellWebhookBody = { + event: "call_started" | "call_ended" | "call_analyzed"; + call: { + call_id: string; + agent_id?: string; + from_number?: string; + to_number?: string; + direction?: "inbound" | "outbound"; + call_status?: string; + start_timestamp?: number; + end_timestamp?: number; + disconnection_reason?: string; + metadata?: Record; + retell_llm_dynamic_variables?: Record; + transcript?: string; + opt_out_sensitive_data_storage?: boolean; + call_cost?: { + product_costs?: Array<{ + product: string; + unitPrice?: number; + cost?: number; + }>; + total_duration_seconds?: number; + total_duration_unit_price?: number; + total_one_time_price?: number; + combined_cost?: number; + }; + call_analysis?: { + call_summary?: string; + in_voicemail?: boolean; + user_sentiment?: string; + call_successful?: boolean; + custom_analysis_data?: Record; + }; + }; +}; + +vi.mock("app/api/defaultResponderForAppDir", () => ({ + defaultResponderForAppDir: + ( + handler: (req: NextRequest, context: { params: Promise> }) => Promise + ) => + async (req: NextRequest, context?: { params: Promise> }) => + await handler(req, context || { params: Promise.resolve({}) }), +})); + +vi.mock("retell-sdk", () => ({ + Retell: { + verify: vi.fn(), + }, +})); + +const mockHasAvailableCredits = vi.fn(); +const mockChargeCredits = vi.fn(); + +vi.mock("@calcom/features/ee/billing/credit-service", () => ({ + CreditService: vi.fn().mockImplementation(() => ({ + hasAvailableCredits: mockHasAvailableCredits, + chargeCredits: mockChargeCredits, + })), +})); + +vi.mock("@calcom/lib/server/repository/PrismaPhoneNumberRepository", () => ({ + PrismaPhoneNumberRepository: { + findByPhoneNumber: vi.fn(), + }, +})); + +vi.mock("next/server", () => ({ + NextResponse: { + json: vi.fn((data, options) => ({ + json: () => Promise.resolve(data), + status: options?.status || 200, + })), + }, +})); + +const createMockRequest = (body: RetellWebhookBody, signature?: string): NextRequest => { + const request = { + text: vi.fn().mockResolvedValue(JSON.stringify(body)), + headers: { + get: vi.fn((name: string) => (name === "x-retell-signature" ? signature : null)), + }, + } as unknown as NextRequest; + return request; +}; + +const callPOST = (request: NextRequest) => POST(request, { params: Promise.resolve({}) }); + +describe("Retell AI Webhook Handler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("RETELL_AI_KEY", "test-api-key"); + vi.stubEnv("CAL_AI_CALL_RATE_PER_MINUTE", "0.29"); + }); + + it("should return 401 when signature is missing", async () => { + const request = createMockRequest( + { event: "call_analyzed", call: { call_id: "test-call-id" } }, + "some-signature" + ); + const response = await callPOST(request); + + expect(response.status).toBe(401); + }); + + it("should return 401 when API key is not configured", async () => { + vi.unstubAllEnvs(); + + const request = createMockRequest( + { event: "call_analyzed", call: { call_id: "test-call-id" } }, + "some-signature" + ); + const response = await callPOST(request); + + expect(response.status).toBe(401); + + // Restore the stubbed env for subsequent tests + vi.stubEnv("RETELL_AI_KEY", "test-api-key"); + }); + + it("should return 401 when signature is invalid", async () => { + vi.mocked(Retell.verify).mockReturnValue(false); + + const request = createMockRequest( + { event: "call_analyzed", call: { call_id: "test-call-id" } }, + "invalid-signature" + ); + const response = await callPOST(request); + + expect(response.status).toBe(401); + }); + + it("should handle non-call_analyzed events", async () => { + vi.mocked(Retell.verify).mockReturnValue(true); + + const body: RetellWebhookBody = { + event: "call_started", + call: { call_id: "test-call-id" }, + }; + + 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(data.message).toContain("No handling for call_started"); + }); + + it("should process call_analyzed event with valid phone number and sufficient credits", async () => { + vi.mocked(Retell.verify).mockReturnValue(true); + + const mockPhoneNumber: MockPhoneNumberWithUser = { + id: 1, + phoneNumber: "+1234567890", + userId: 1, + teamId: null, + provider: "test-provider", + providerPhoneNumberId: "test-provider-id", + createdAt: new Date(), + updatedAt: new Date(), + stripeCustomerId: null, + stripeSubscriptionId: null, + subscriptionStatus: null, + inboundAgentId: null, + outboundAgentId: null, + user: { id: 1, email: "test@example.com", name: "Test User" }, + team: null, + }; + + vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber); + + mockHasAvailableCredits.mockResolvedValue(true); + mockChargeCredits.mockResolvedValue(undefined); + + const body: RetellWebhookBody = { + event: "call_analyzed", + call: { + call_id: "test-call-id", + from_number: "+1234567890", + to_number: "+0987654321", + direction: "outbound", + call_status: "completed", + start_timestamp: 1234567890, + call_cost: { + combined_cost: 100, + total_duration_seconds: 120, + }, + }, + }; + + 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(mockChargeCredits).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 1, + teamId: undefined, + credits: 58, // 120 seconds = 2 minutes * $0.29 = $0.58 = 58 credits + callDuration: 120, + externalRef: "retell:test-call-id", + }) + ); + }); + + it("should handle team phone numbers", async () => { + vi.mocked(Retell.verify).mockReturnValue(true); + + const mockTeamPhoneNumber: MockPhoneNumberWithTeam = { + id: 2, + phoneNumber: "+1234567890", + userId: null, + teamId: 5, + provider: "test-provider", + providerPhoneNumberId: "test-provider-id-2", + createdAt: new Date(), + updatedAt: new Date(), + stripeCustomerId: null, + stripeSubscriptionId: null, + subscriptionStatus: null, + inboundAgentId: null, + outboundAgentId: null, + team: { id: 5, name: "Test Team" }, + user: null, + }; + + vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockTeamPhoneNumber); + + mockHasAvailableCredits.mockResolvedValue(true); + mockChargeCredits.mockResolvedValue(undefined); + + const body: RetellWebhookBody = { + event: "call_analyzed", + call: { + call_id: "test-call-id", + from_number: "+1234567890", + to_number: "+0987654321", + direction: "outbound", + call_status: "completed", + start_timestamp: 1234567890, + call_cost: { + combined_cost: 50, + total_duration_seconds: 180, + }, + }, + }; + + const request = createMockRequest(body, "valid-signature"); + const response = await callPOST(request); + + expect(response.status).toBe(200); + expect(mockChargeCredits).toHaveBeenCalledWith( + expect.objectContaining({ + userId: undefined, + teamId: 5, + credits: 87, // 180 seconds = 3 minutes * $0.29 = $0.87 = 87 credits + callDuration: 180, + externalRef: "retell:test-call-id", + }) + ); + }); + + it("should handle missing total_duration_seconds", async () => { + vi.mocked(Retell.verify).mockReturnValue(true); + + const body: RetellWebhookBody = { + event: "call_analyzed", + call: { + call_id: "test-call-id", + from_number: "+1234567890", + to_number: "+0987654321", + direction: "outbound", + call_status: "completed", + start_timestamp: 1234567890, + call_cost: { + combined_cost: 100, + // missing total_duration_seconds + }, + }, + }; + + const request = createMockRequest(body, "valid-signature"); + const response = await callPOST(request); + + expect(response.status).toBe(200); + expect(PrismaPhoneNumberRepository.findByPhoneNumber).not.toHaveBeenCalled(); + }); + + it("should handle phone number not found", async () => { + vi.mocked(Retell.verify).mockReturnValue(true); + vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(null); + + const body: RetellWebhookBody = { + event: "call_analyzed", + call: { + call_id: "test-call-id", + from_number: "+1234567890", + to_number: "+0987654321", + direction: "outbound", + call_status: "completed", + start_timestamp: 1234567890, + call_cost: { + combined_cost: 100, + total_duration_seconds: 90, + }, + }, + }; + + const request = createMockRequest(body, "valid-signature"); + const response = await callPOST(request); + + expect(response.status).toBe(200); + expect(mockChargeCredits).not.toHaveBeenCalled(); + }); + + it("should handle insufficient credits", async () => { + vi.mocked(Retell.verify).mockReturnValue(true); + + const mockPhoneNumber: MockPhoneNumberWithUser = { + id: 3, + phoneNumber: "+1234567890", + userId: 1, + teamId: null, + provider: "test-provider", + providerPhoneNumberId: "test-provider-id-3", + createdAt: new Date(), + updatedAt: new Date(), + stripeCustomerId: null, + stripeSubscriptionId: null, + subscriptionStatus: null, + inboundAgentId: null, + outboundAgentId: null, + user: { id: 1, email: "test@example.com", name: "Test User" }, + team: null, + }; + + vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber); + + mockHasAvailableCredits.mockResolvedValue(false); + + const body: RetellWebhookBody = { + event: "call_analyzed", + call: { + call_id: "test-call-id", + from_number: "+1234567890", + to_number: "+0987654321", + direction: "outbound", + call_status: "completed", + start_timestamp: 1234567890, + call_cost: { + combined_cost: 100, + total_duration_seconds: 150, + }, + }, + }; + + const request = createMockRequest(body, "valid-signature"); + const response = await callPOST(request); + + expect(response.status).toBe(200); + }); + + it("should handle schema validation errors", async () => { + vi.mocked(Retell.verify).mockReturnValue(true); + + const body: RetellWebhookBody = { + event: "call_analyzed", + call: { + // Missing required fields like from_number, to_number, etc. + call_id: "test-call-id", + }, + }; + + const request = createMockRequest(body, "valid-signature"); + const response = await callPOST(request); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.error).toBe("Internal server error"); + }); + + it("should calculate credits correctly based on duration", async () => { + const testCases = [ + { durationSeconds: 60, expectedCredits: 29 }, // 1 minute * $0.29 = $0.29 = 29 credits + { durationSeconds: 120, expectedCredits: 58 }, // 2 minutes * $0.29 = $0.58 = 58 credits + { durationSeconds: 150, expectedCredits: 73 }, // 2.5 minutes * $0.29 = $0.725 = 73 credits (rounded up) + { durationSeconds: 30, expectedCredits: 15 }, // 0.5 minutes * $0.29 = $0.145 = 15 credits (rounded up) + ]; + + for (const { durationSeconds, expectedCredits } of testCases) { + vi.clearAllMocks(); + vi.mocked(Retell.verify).mockReturnValue(true); + + const mockPhoneNumber: MockPhoneNumberWithUser = { + id: 4, + phoneNumber: "+1234567890", + userId: 1, + teamId: null, + provider: "test-provider", + providerPhoneNumberId: "test-provider-id-4", + createdAt: new Date(), + updatedAt: new Date(), + stripeCustomerId: null, + stripeSubscriptionId: null, + subscriptionStatus: null, + inboundAgentId: null, + outboundAgentId: null, + user: { id: 1, email: "test@example.com", name: "Test User" }, + team: null, + }; + + vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber); + mockHasAvailableCredits.mockResolvedValue(true); + mockChargeCredits.mockResolvedValue(undefined); + + const body: RetellWebhookBody = { + event: "call_analyzed", + call: { + call_id: `test-call-${durationSeconds}s`, + from_number: "+1234567890", + to_number: "+0987654321", + direction: "outbound", + call_status: "completed", + start_timestamp: 1234567890, + call_cost: { + combined_cost: 100, // This is ignored now + total_duration_seconds: durationSeconds, + }, + }, + }; + + const request = createMockRequest(body, "valid-signature"); + await callPOST(request); + + expect(mockChargeCredits).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 1, + teamId: undefined, + credits: expectedCredits, + callDuration: durationSeconds, + externalRef: expect.stringMatching(/^retell:test-call-/), + }) + ); + } + }); + + it("should pass callDuration to chargeCredits when provided", async () => { + vi.mocked(Retell.verify).mockReturnValue(true); + const mockPhoneNumber: MockPhoneNumberWithUser = { + id: 10, + phoneNumber: "+15550001111", + userId: 42, + teamId: null, + provider: "test-provider", + providerPhoneNumberId: "prov-10", + createdAt: new Date(), + updatedAt: new Date(), + stripeCustomerId: null, + stripeSubscriptionId: null, + subscriptionStatus: null, + inboundAgentId: null, + outboundAgentId: null, + user: { id: 42, email: "u@example.com", name: "U" }, + team: null, + }; + vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber); + mockHasAvailableCredits.mockResolvedValue(true); + mockChargeCredits.mockResolvedValue(undefined); + + const body: RetellWebhookBody = { + event: "call_analyzed", + call: { + call_id: "call-dur", + from_number: "+15550002222", + to_number: "+15550001111", + direction: "outbound", + call_status: "completed", + start_timestamp: 123, + call_cost: { + combined_cost: 12, // This is ignored now + total_duration_seconds: 125, + }, + }, + }; + 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) + ); + }); + + describe("Idempotency", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should pass externalRef with correct format to chargeCredits", async () => { + vi.mocked(Retell.verify).mockReturnValue(true); + const mockPhoneNumber: MockPhoneNumberWithUser = { + id: 1, + phoneNumber: "+1234567890", + userId: 1, + teamId: null, + provider: "test-provider", + providerPhoneNumberId: "test-provider-id", + createdAt: new Date(), + updatedAt: new Date(), + stripeCustomerId: null, + stripeSubscriptionId: null, + subscriptionStatus: null, + inboundAgentId: null, + outboundAgentId: null, + user: { id: 1, email: "test@example.com", name: "Test User" }, + team: null, + }; + vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber); + mockChargeCredits.mockResolvedValue({ userId: 1 }); + + const body: RetellWebhookBody = { + event: "call_analyzed", + call: { + call_id: "test-idempotency-call", + from_number: "+1234567890", + to_number: "+0987654321", + direction: "outbound", + call_status: "completed", + start_timestamp: 1234567890, + call_cost: { + combined_cost: 100, + total_duration_seconds: 60, + }, + }, + }; + + const response = await callPOST(createMockRequest(body, "valid-signature")); + + expect(response.status).toBe(200); + expect(mockChargeCredits).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 1, + teamId: undefined, + credits: 29, // 60 seconds = 1 minute * $0.29 = $0.29 = 29 credits + callDuration: 60, + externalRef: "retell:test-idempotency-call", + }) + ); + }); + + it("should handle duplicate webhook calls idempotently", async () => { + vi.mocked(Retell.verify).mockReturnValue(true); + const mockPhoneNumber: MockPhoneNumberWithUser = { + id: 1, + phoneNumber: "+1234567890", + userId: 1, + teamId: null, + provider: "test-provider", + providerPhoneNumberId: "test-provider-id", + createdAt: new Date(), + updatedAt: new Date(), + stripeCustomerId: null, + stripeSubscriptionId: null, + subscriptionStatus: null, + inboundAgentId: null, + outboundAgentId: null, + user: { id: 1, email: "test@example.com", name: "Test User" }, + team: null, + }; + vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber); + + const body: RetellWebhookBody = { + event: "call_analyzed", + call: { + call_id: "duplicate-call-id", + from_number: "+1234567890", + to_number: "+0987654321", + direction: "outbound", + call_status: "completed", + start_timestamp: 1234567890, + call_cost: { + combined_cost: 100, + total_duration_seconds: 60, + }, + }, + }; + + // First call - should charge normally + mockChargeCredits.mockResolvedValue({ userId: 1 }); + const response1 = await callPOST(createMockRequest(body, "valid-signature")); + expect(response1.status).toBe(200); + const data1 = await response1.json(); + expect(data1.success).toBe(true); + expect(data1.message).toContain("Successfully charged 29 credits"); + + // Second call - should return duplicate + mockChargeCredits.mockResolvedValue({ bookingUid: null, duplicate: true }); + const response2 = await callPOST(createMockRequest(body, "valid-signature")); + expect(response2.status).toBe(200); + const data2 = await response2.json(); + expect(data2.success).toBe(true); + expect(data2.message).toContain("Successfully charged 29 credits"); + + // Verify chargeCredits was called twice with same externalRef + expect(mockChargeCredits).toHaveBeenCalledTimes(2); + expect(mockChargeCredits).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ externalRef: "retell:duplicate-call-id" }) + ); + expect(mockChargeCredits).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ externalRef: "retell:duplicate-call-id" }) + ); + }); + + it("should handle errors from chargeCredits gracefully", async () => { + vi.mocked(Retell.verify).mockReturnValue(true); + const mockPhoneNumber: MockPhoneNumberWithUser = { + id: 1, + phoneNumber: "+1234567890", + userId: 1, + teamId: null, + provider: "test-provider", + providerPhoneNumberId: "test-provider-id", + createdAt: new Date(), + updatedAt: new Date(), + stripeCustomerId: null, + stripeSubscriptionId: null, + subscriptionStatus: null, + inboundAgentId: null, + outboundAgentId: null, + user: { id: 1, email: "test@example.com", name: "Test User" }, + team: null, + }; + vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber); + + // Mock chargeCredits to throw an error + mockChargeCredits.mockRejectedValue(new Error("Credit service error")); + + const body: RetellWebhookBody = { + event: "call_analyzed", + call: { + call_id: "error-call-id", + from_number: "+1234567890", + to_number: "+0987654321", + direction: "outbound", + call_status: "completed", + start_timestamp: 1234567890, + call_cost: { + combined_cost: 100, + total_duration_seconds: 60, + }, + }, + }; + + const response = await callPOST(createMockRequest(body, "valid-signature")); + + // Should still return 200 to prevent retries + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.success).toBe(false); + expect(data.message).toContain("Error charging credits for Retell AI call"); + }); + }); +}); diff --git a/apps/web/app/api/webhooks/retell-ai/route.ts b/apps/web/app/api/webhooks/retell-ai/route.ts new file mode 100644 index 00000000000000..08a4f60d42cf33 --- /dev/null +++ b/apps/web/app/api/webhooks/retell-ai/route.ts @@ -0,0 +1,229 @@ +import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { Retell } from "retell-sdk"; +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 { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository"; +import { CreditUsageType } from "@calcom/prisma/enums"; + +const log = logger.getSubLogger({ prefix: ["retell-ai-webhook"] }); + +const RetellWebhookSchema = z.object({ + event: z.enum(["call_started", "call_ended", "call_analyzed"]), + call: z + .object({ + call_id: z.string(), + agent_id: z.string().optional(), + from_number: z.string(), + to_number: z.string(), + direction: z.enum(["inbound", "outbound"]), + call_status: z.string(), + start_timestamp: z.number(), + end_timestamp: z.number().optional(), + disconnection_reason: z.string().optional(), + metadata: z.record(z.any()).optional(), + retell_llm_dynamic_variables: z.record(z.any()).optional(), + transcript: z.string().optional(), + opt_out_sensitive_data_storage: z.boolean().optional(), + call_cost: z + .object({ + product_costs: z + .array( + z.object({ + product: z.string(), + unitPrice: z.number().optional(), + cost: z.number().optional(), + }) + ) + .optional(), + total_duration_seconds: z.number().optional(), + total_duration_unit_price: z.number().optional(), + total_one_time_price: z.number().optional(), + combined_cost: z.number().optional(), + }) + .optional(), + call_analysis: z + .object({ + call_summary: z.string().optional(), + in_voicemail: z.boolean().optional(), + user_sentiment: z.string().optional(), + call_successful: z.boolean().optional(), + custom_analysis_data: z.record(z.any()).optional(), + }) + .optional(), + }) + .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; + } + + 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; + // Convert to cents and round up to ensure we don't undercharge + const creditsToDeduct = Math.ceil(callCost * 100); + + const creditService = new CreditService(); + + try { + await creditService.chargeCredits({ + userId: userId ?? undefined, + teamId: teamId ?? undefined, + credits: creditsToDeduct, + callDuration: call_cost.total_duration_seconds, + creditFor: CreditUsageType.CAL_AI_PHONE_CALL, + externalRef: `retell:${call_id}`, + }); + } catch (e) { + log.error("Error charging credits for Retell AI call", { + error: e, + call_id, + call_cost, + userId, + teamId, + }); + return { + success: false, + message: `Error charging credits for Retell AI call: ${ + e instanceof Error ? e.message : "Unknown error" + }`, + }; + } + + 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}`, + }; +} + +/** + * Retell AI Webhook Handler + * + * Setup Instructions: + * 1. Add this webhook URL to your Retell AI dashboard: https://yourdomain.com/api/webhooks/retell-ai + * 2. Ensure your domain is accessible from the internet (for local development, use ngrok or similar) + * 3. Set the RETELL_AI_KEY environment variable with your Retell API key (must have webhook badge) + * + * This webhook will: + * - Verify webhook signature for security + * - Receive call_analyzed events from Retell AI + * - Charge credits based on the call cost from the user's or team's credit balance + * - Log all transactions for audit purposes + */ +async function handler(request: NextRequest) { + const rawBody = await request.text(); + const body = JSON.parse(rawBody); + + // Verify webhook signature + const signature = request.headers.get("x-retell-signature"); + const apiKey = process.env.RETELL_AI_KEY; + + if (!signature || !apiKey) { + log.error("Missing signature or API key for webhook verification"); + return NextResponse.json( + { + error: "Unauthorized", + message: "Missing signature or API key", + }, + { status: 401 } + ); + } + + if (!Retell.verify(rawBody, apiKey, signature)) { + log.error("Invalid webhook signature"); + return NextResponse.json( + { + error: "Unauthorized", + message: "Invalid signature", + }, + { status: 401 } + ); + } + + if (body.event !== "call_analyzed") { + return NextResponse.json( + { + success: true, + message: `No handling for ${body.event} for call ${body.call?.call_id ?? "unknown"}`, + }, + { status: 200 } + ); + } + + try { + const payload = RetellWebhookSchema.parse(body); + const callData = payload.call; + if (callData.direction === "inbound") { + return NextResponse.json( + { + success: true, + message: `Inbound calls are not charged or supported for now. Ignoring call ${callData.call_id}`, + }, + { status: 200 } + ); + } + + log.info(`Received Retell AI webhook: ${payload.event} for call ${callData.call_id}`); + + const result = await handleCallAnalyzed(callData); + + return NextResponse.json( + { + success: result?.success ?? true, + message: result?.message ?? `Processed ${payload.event} for call ${callData.call_id}`, + }, + { status: 200 } + ); + } catch (error) { + log.error("Error processing Retell AI webhook:", safeStringify(error)); + return NextResponse.json( + { + error: "Internal server error", + message: error instanceof Error ? error.message : "Unknown error", + }, + // we need to return 200 to retell ai to avoid retries + { status: 200 } + ); + } +} + +export const POST = defaultResponderForAppDir(handler); diff --git a/apps/web/modules/settings/billing/components/BillingCredits.tsx b/apps/web/modules/settings/billing/components/BillingCredits.tsx index 71e73c06b5ad37..8be53a386fc91a 100644 --- a/apps/web/modules/settings/billing/components/BillingCredits.tsx +++ b/apps/web/modules/settings/billing/components/BillingCredits.tsx @@ -1,11 +1,13 @@ "use client"; +import { useSession } from "next-auth/react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; +import { useRouter, usePathname } from "next/navigation"; import { useState, useMemo } from "react"; import { useForm } from "react-hook-form"; import dayjs from "@calcom/dayjs"; +import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; import ServerTrans from "@calcom/lib/components/ServerTrans"; import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants"; import { downloadAsCsv } from "@calcom/lib/csvUtils"; @@ -27,7 +29,6 @@ type MonthOption = { endDate: string; }; -// returns the last 12 months starting from May 2025 (when credits were introduced) const getMonthOptions = (): MonthOption[] => { const options: MonthOption[] = []; const minDate = dayjs.utc("2025-05-01"); @@ -53,6 +54,9 @@ const getMonthOptions = (): MonthOption[] => { export default function BillingCredits() { const { t } = useLocale(); const router = useRouter(); + const pathname = usePathname(); + const session = useSession(); + const orgBranding = useOrgBranding(); const monthOptions = useMemo(() => getMonthOptions(), []); const [selectedMonth, setSelectedMonth] = useState(monthOptions[0]); const [isDownloading, setIsDownloading] = useState(false); @@ -66,9 +70,28 @@ export default function BillingCredits() { } = useForm<{ quantity: number }>({ defaultValues: { quantity: 50 } }); const params = useParamsWithFallback(); - const teamId = params.id ? Number(params.id) : undefined; + const orgId = session.data?.user?.org?.id; - const { data: creditsData, isLoading } = trpc.viewer.credits.getAllCredits.useQuery({ teamId }); + const parsedTeamId = Number(params.id); + const teamId: number | undefined = Number.isFinite(parsedTeamId) + ? parsedTeamId + : typeof orgId === "number" + ? orgId + : undefined; + + const tokens = (pathname ?? "").split("/").filter(Boolean); + const settingsIndex = tokens.indexOf("settings"); + const isOrgScopedPath = + settingsIndex >= 0 && ["organizations", "teams"].includes(tokens[settingsIndex + 1]); + + const shouldRender = IS_SMS_CREDITS_ENABLED && !(orgId && !isOrgScopedPath && !orgBranding?.slug); + + const { data: creditsData, isLoading } = trpc.viewer.credits.getAllCredits.useQuery( + { teamId }, + { enabled: shouldRender } + ); + + if (!shouldRender) return null; const buyCreditsMutation = trpc.viewer.credits.buyCredits.useMutation({ onSuccess: (data) => { @@ -102,10 +125,6 @@ export default function BillingCredits() { } }; - if (!IS_SMS_CREDITS_ENABLED) { - return null; - } - if (isLoading && teamId) return ; if (!creditsData) return null; @@ -206,7 +225,7 @@ export default function BillingCredits() {
-
+
+
+
+
+ +

{t("general_prompt_description")}

+
+
+ {!readOnly && ( + + )} +
+