Files
aa48e72c12 feat: inbound calls in cal ai (#23890)
* feat: lang support

* fix: type errors

* feat: select voice agent

* refactor: address feedback

* refactor: address feedback

* refactor: missing import

* fix: types

* feat: add inbound calls

* chore: formatting

* chore

* feat: finish inbound call

* chore: formatting

* fix: update bug

* fix: types

* refactor: Agent Configuration Sheet (#23930)

* refactor: agent configuration sheet

* chore: use default phone numbre

* refactor: improvements

* refactor: improvements

* fix: types

* fix: feedback

* chore:

* fix: feedback

* fix: prompt

* fix: review

* fix: review

* refactor: class

* refactor: class

* refactor: rename

* Update apps/web/public/static/locales/en/common.json

* Update apps/web/public/static/locales/en/common.json

* chore: update set value

* fix: remove index

* fix: type error

* fix: update tetss

* fix: use logger

* refactor: don't use static

* fix: type

* fix: schema

* refactor:

---------

Co-authored-by: Carina Wollendorfer <[email protected]>
2025-10-09 11:40:37 +01:00

192 lines
5.9 KiB
TypeScript

import { z } from "zod";
import { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/lib/customer";
import { getPhoneNumberMonthlyPriceId } from "@calcom/app-store/stripepayment/lib/utils";
import { CHECKOUT_SESSION_TYPES } from "@calcom/features/ee/billing/constants";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { WEBAPP_URL, IS_PRODUCTION } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
import type { PhoneNumberRepositoryInterface } from "../../interfaces/PhoneNumberRepositoryInterface";
import type { RetellAIRepository } from "../types";
const stripeErrorSchema = z.object({
raw: z.object({
code: z.string(),
}),
});
export class BillingService {
private logger = logger.getSubLogger({ prefix: ["BillingService"] });
constructor(
private deps: {
phoneNumberRepository: PhoneNumberRepositoryInterface;
retellRepository: RetellAIRepository;
}
) {}
async generatePhoneNumberCheckoutSession({
userId,
teamId,
agentId,
workflowId,
}: {
userId: number;
teamId?: number;
agentId?: string | null;
workflowId?: string;
}) {
const phoneNumberPriceId = getPhoneNumberMonthlyPriceId();
if (!phoneNumberPriceId) {
throw new HttpError({
statusCode: 500,
message: "Phone number price ID not configured",
});
}
const stripeCustomerId = await getStripeCustomerIdFromUserId(userId);
if (!stripeCustomerId) {
throw new HttpError({
statusCode: 500,
message: "Failed to create Stripe customer.",
});
}
// Create Stripe checkout session for phone number subscription
const checkoutSession = await stripe.checkout.sessions.create({
customer: stripeCustomerId,
mode: "subscription",
line_items: [
{
price: phoneNumberPriceId,
quantity: 1,
},
],
success_url: `${WEBAPP_URL}/api/calAIPhone/subscription/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${WEBAPP_URL}/workflows/${workflowId}`,
allow_promotion_codes: true,
customer_update: {
address: "auto",
},
automatic_tax: {
enabled: IS_PRODUCTION,
},
metadata: {
userId: userId.toString(),
teamId: teamId?.toString() || "",
agentId: agentId || "",
workflowId: workflowId || "",
type: CHECKOUT_SESSION_TYPES.PHONE_NUMBER_SUBSCRIPTION,
},
subscription_data: {
metadata: {
userId: userId.toString(),
teamId: teamId?.toString() || "",
agentId: agentId || "",
workflowId: workflowId || "",
type: CHECKOUT_SESSION_TYPES.PHONE_NUMBER_SUBSCRIPTION,
},
},
});
if (!checkoutSession.url) {
throw new HttpError({
statusCode: 500,
message: "Failed to create checkout session.",
});
}
return { url: checkoutSession.url, message: "Payment required to purchase phone number" };
}
async cancelPhoneNumberSubscription({
phoneNumberId,
userId,
teamId,
}: {
phoneNumberId: number;
userId: number;
teamId?: number;
}) {
// Find phone number with proper team authorization
const phoneNumber = teamId
? await this.deps.phoneNumberRepository.findByIdWithTeamAccess({
id: phoneNumberId,
teamId,
userId,
})
: await this.deps.phoneNumberRepository.findByIdAndUserId({
id: phoneNumberId,
userId,
});
if (!phoneNumber) {
throw new HttpError({
statusCode: 404,
message: "Phone number not found or you don't have permission to cancel it.",
});
}
if (!phoneNumber.stripeSubscriptionId) {
throw new HttpError({
statusCode: 400,
message: "Phone number doesn't have an active subscription.",
});
}
try {
await this.deps.phoneNumberRepository.updateSubscriptionStatus({
id: phoneNumberId,
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
disconnectAgents: false,
});
try {
await stripe.subscriptions.cancel(phoneNumber.stripeSubscriptionId);
} catch (error) {
const parsedError = stripeErrorSchema.safeParse(error);
if (parsedError.success && parsedError.data.raw.code === "resource_missing") {
this.logger.info("Subscription not found in Stripe (already cancelled or deleted):", {
subscriptionId: phoneNumber.stripeSubscriptionId,
phoneNumberId,
stripeMessage: "Subscription resource not found",
});
} else {
await this.deps.phoneNumberRepository.updateSubscriptionStatus({
id: phoneNumberId,
subscriptionStatus: PhoneNumberSubscriptionStatus.ACTIVE,
});
throw error;
}
}
// Disconnnect agent after cancelling from stripe
await this.deps.phoneNumberRepository.updateSubscriptionStatus({
id: phoneNumberId,
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
disconnectAgents: true,
});
// Delete the phone number from Retell, DB
try {
await this.deps.retellRepository.deletePhoneNumber(phoneNumber.phoneNumber);
} catch (error) {
this.logger.error("Failed to delete phone number from AI service, but subscription was cancelled:", {
error,
});
}
return { success: true, message: "Phone number subscription cancelled successfully." };
} catch (error) {
this.logger.error("Error cancelling phone number subscription:", { error });
throw new HttpError({
statusCode: 500,
message: "Failed to cancel subscription. Please try again or contact support.",
});
}
}
}