From 5feca13cfbd39ad7b72dc1b665a77b5bd7def509 Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Tue, 16 Sep 2025 15:39:32 +0530 Subject: [PATCH] fix: phone number cancel billing (#23854) * fix: phone number billing bug * fix: phone number billing bug * fix: use raw code * fix: use zod * chore: update test * fix: double cancel * fix: double cancel * fix: double cancel * fix: chore update * fix: schema --- .../retellAI/services/BillingService.ts | 28 ++++++++----- .../services/__tests__/BillingService.test.ts | 42 +++++++++++++++---- .../webhook/_customer.subscription.deleted.ts | 4 ++ 3 files changed, 55 insertions(+), 19 deletions(-) diff --git a/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts b/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts index c0324bfcc7..0cc70d29a5 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts @@ -12,13 +12,10 @@ 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(), +const stripeErrorSchema = z.object({ + raw: z.object({ + code: z.string(), + }), }); export class BillingService { @@ -140,21 +137,32 @@ export class BillingService { } try { + await this.phoneNumberRepository.updateSubscriptionStatus({ + id: phoneNumberId, + subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED, + disconnectOutboundAgent: false, + }); + try { await stripe.subscriptions.cancel(phoneNumber.stripeSubscriptionId); } catch (error) { - const parsedError = stripeResourceMissingErrorSchema.safeParse(error); - if (parsedError.success) { + 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: parsedError.data.message, + stripeMessage: "Subscription resource not found", }); } else { + await this.phoneNumberRepository.updateSubscriptionStatus({ + id: phoneNumberId, + subscriptionStatus: PhoneNumberSubscriptionStatus.ACTIVE, + }); throw error; } } + // Disconnnect agent after cancelling from stripe await this.phoneNumberRepository.updateSubscriptionStatus({ id: phoneNumberId, subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED, 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 332de4d1ed..4b7865f553 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/__tests__/BillingService.test.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/__tests__/BillingService.test.ts @@ -162,7 +162,13 @@ describe("BillingService", () => { const stripe = (await import("@calcom/features/ee/payments/server/stripe")).default; expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_123"); - expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenCalledWith({ + expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenCalledTimes(2); + expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(1, { + id: 1, + subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED, + disconnectOutboundAgent: false, + }); + expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(2, { id: 1, subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED, disconnectOutboundAgent: true, @@ -268,11 +274,15 @@ describe("BillingService", () => { const stripe = (await import("@calcom/features/ee/payments/server/stripe")).default; stripe.subscriptions.cancel.mockRejectedValue({ - type: "invalid_request_error", + type: "StripeInvalidRequestError", + raw: { + code: "resource_missing", + doc_url: "https://stripe.com/docs/error-codes/resource-missing", + message: "No such subscription: 'sub_123'", + param: "id", + 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); @@ -284,8 +294,14 @@ describe("BillingService", () => { // Should attempt to cancel expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_123"); - // Should still update database even after 404 - expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenCalledWith({ + // Should update database twice: first CANCELLED (disconnectOutboundAgent: false), then final CANCELLED (disconnectOutboundAgent: true) + expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenCalledTimes(2); + expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(1, { + id: 1, + subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED, + disconnectOutboundAgent: false, + }); + expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(2, { id: 1, subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED, disconnectOutboundAgent: true, @@ -310,8 +326,16 @@ describe("BillingService", () => { // Should attempt to cancel expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_123"); - // Should NOT update database due to error - expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).not.toHaveBeenCalled(); + expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenCalledTimes(2); + expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(1, { + id: 1, + subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED, + disconnectOutboundAgent: false, + }); + expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(2, { + id: 1, + subscriptionStatus: PhoneNumberSubscriptionStatus.ACTIVE, + }); }); }); }); diff --git a/packages/features/ee/billing/api/webhook/_customer.subscription.deleted.ts b/packages/features/ee/billing/api/webhook/_customer.subscription.deleted.ts index 3974d7eb0c..25b2376b6c 100644 --- a/packages/features/ee/billing/api/webhook/_customer.subscription.deleted.ts +++ b/packages/features/ee/billing/api/webhook/_customer.subscription.deleted.ts @@ -72,6 +72,10 @@ async function handleCalAIPhoneNumberSubscriptionDeleted( } try { + if (phoneNumber.subscriptionStatus === "CANCELLED") { + return { success: true, subscriptionId: subscription.id, skipped: true }; + } + const aiService = createDefaultAIPhoneServiceProvider(); await aiService.cancelPhoneNumberSubscription({