mirror of
https://github.com/hanzoai/calendar.git
synced 2026-10-30 14:46:43 +00:00
* refactor: apply biome formatting to packages/features (batch 1 - small subdirs) Format small subdirectories in packages/features: di, flags, holidays, oauth, settings, users, assignment-reason, selectedCalendar, hashedLink, host, form, form-builder, availability, data-table, pbac, schedules, troubleshooter, eventtypes, calendar-subscription, and root-level files. Also includes straggler apps/web BookEventForm.tsx. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: apply biome formatting to packages/features (batch 2 - medium subdirs) Format medium subdirectories in packages/features: auth, credentials, calendars, routing-forms, routing-trace, attributes, watchlist, calAIPhone, tasker, and webhooks. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: apply biome formatting to packages/features (batch 3 - bookings + insights) Format bookings and insights subdirectories in packages/features. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: apply biome formatting to packages/features (batch 4 - ee) Format packages/features/ee subdirectory covering billing, workflows, organizations, teams, managed-event-types, round-robin, dsync, integration-attribute-sync, and payments. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: apply biome formatting to packages/features (batch 5 - booking-audit part 1) Format booking-audit di, actions, common, dto, repository, and types subdirectories in packages/features/booking-audit. Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: apply biome formatting to packages/features (batch 6 - booking-audit part 2) Format booking-audit service subdirectory in packages/features/booking-audit. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
203 lines
6.3 KiB
TypeScript
203 lines
6.3 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 type { TrackingData } from "@calcom/lib/tracking";
|
|
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,
|
|
tracking,
|
|
}: {
|
|
userId: number;
|
|
teamId?: number;
|
|
agentId?: string | null;
|
|
workflowId?: string;
|
|
tracking?: TrackingData;
|
|
}) {
|
|
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,
|
|
...(tracking?.googleAds?.gclid && {
|
|
gclid: tracking.googleAds.gclid,
|
|
campaignId: tracking.googleAds.campaignId,
|
|
}),
|
|
...(tracking?.linkedInAds?.liFatId && {
|
|
liFatId: tracking.linkedInAds.liFatId,
|
|
linkedInCampaignId: tracking.linkedInAds?.campaignId,
|
|
}),
|
|
},
|
|
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.",
|
|
});
|
|
}
|
|
}
|
|
}
|