diff --git a/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts b/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts index 79a35d1388a4d8..c0324bfcc71ea3 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts @@ -1,3 +1,5 @@ +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"; @@ -10,6 +12,15 @@ import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums"; import type { PhoneNumberRepositoryInterface } from "../../interfaces/PhoneNumberRepositoryInterface"; import type { RetellAIRepository } from "../types"; +const stripeResourceMissingErrorSchema = z.object({ + type: z.literal("invalid_request_error"), + code: z.literal("resource_missing"), + message: z.string(), + param: z.string().optional(), + doc_url: z.string().optional(), + request_log_url: z.string().optional(), +}); + export class BillingService { private logger = logger.getSubLogger({ prefix: ["BillingService"] }); @@ -129,7 +140,20 @@ export class BillingService { } try { - await stripe.subscriptions.cancel(phoneNumber.stripeSubscriptionId); + try { + await stripe.subscriptions.cancel(phoneNumber.stripeSubscriptionId); + } catch (error) { + const parsedError = stripeResourceMissingErrorSchema.safeParse(error); + if (parsedError.success) { + this.logger.info("Subscription not found in Stripe (already cancelled or deleted):", { + subscriptionId: phoneNumber.stripeSubscriptionId, + phoneNumberId, + stripeMessage: parsedError.data.message, + }); + } else { + throw error; + } + } await this.phoneNumberRepository.updateSubscriptionStatus({ id: phoneNumberId, diff --git a/packages/features/calAIPhone/providers/retellAI/services/__tests__/BillingService.test.ts b/packages/features/calAIPhone/providers/retellAI/services/__tests__/BillingService.test.ts index 3245885fe63541..332de4d1ed761d 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/__tests__/BillingService.test.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/__tests__/BillingService.test.ts @@ -254,5 +254,64 @@ describe("BillingService", () => { "Failed to cancel subscription" ); }); + + it("should handle subscription not found (404) gracefully", async () => { + const mockPhoneNumber = createMockPhoneNumberRecord({ + id: 1, + stripeSubscriptionId: "sub_123", + subscriptionStatus: PhoneNumberSubscriptionStatus.ACTIVE, + }); + + mocks.mockPhoneNumberRepository.findByIdAndUserId.mockResolvedValue(mockPhoneNumber); + mocks.mockPhoneNumberRepository.updateSubscriptionStatus.mockResolvedValue(undefined); + mocks.mockRetellRepository.deletePhoneNumber.mockResolvedValue(undefined); + + const stripe = (await import("@calcom/features/ee/payments/server/stripe")).default; + stripe.subscriptions.cancel.mockRejectedValue({ + type: "invalid_request_error", + code: "resource_missing", + message: "No such subscription: 'sub_123'", + param: "id", + doc_url: "https://stripe.com/docs/error-codes/resource-missing", + }); + + const result = await service.cancelPhoneNumberSubscription(validCancelData); + + expect(result).toEqual({ + success: true, + message: "Phone number subscription cancelled successfully.", + }); + + // Should attempt to cancel + expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_123"); + // Should still update database even after 404 + expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenCalledWith({ + id: 1, + subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED, + disconnectOutboundAgent: true, + }); + }); + + it("should throw error on Stripe API failure that is not resource_missing", async () => { + const mockPhoneNumber = createMockPhoneNumberRecord({ + id: 1, + stripeSubscriptionId: "sub_123", + subscriptionStatus: PhoneNumberSubscriptionStatus.ACTIVE, + }); + + mocks.mockPhoneNumberRepository.findByIdAndUserId.mockResolvedValue(mockPhoneNumber); + + const stripe = (await import("@calcom/features/ee/payments/server/stripe")).default; + stripe.subscriptions.cancel.mockRejectedValue(new TestError("API Error")); + + await expect(service.cancelPhoneNumberSubscription(validCancelData)).rejects.toThrow( + "Failed to cancel subscription" + ); + + // Should attempt to cancel + expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_123"); + // Should NOT update database due to error + expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).not.toHaveBeenCalled(); + }); }); });