From 10928a14996bc74e52d853c0b8f9c9cda454ce18 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Mon, 15 Sep 2025 20:02:42 +0530 Subject: [PATCH 1/6] fix: phone number billing bug --- .../retellAI/services/BillingService.ts | 26 +++++- .../services/__tests__/BillingService.test.ts | 92 +++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts b/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts index 79a35d1388a4d8..b81a4bd921b66c 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts @@ -129,7 +129,31 @@ export class BillingService { } try { - await stripe.subscriptions.cancel(phoneNumber.stripeSubscriptionId); + let needsStripeCancellation = true; + try { + const subscription = await stripe.subscriptions.retrieve(phoneNumber.stripeSubscriptionId); + if (subscription.status === 'canceled') { + needsStripeCancellation = false; + this.logger.info("Subscription already cancelled in Stripe:", { + subscriptionId: phoneNumber.stripeSubscriptionId, + phoneNumberId, + }); + } + } catch (error: any) { + if (error.code === 'resource_missing') { + needsStripeCancellation = false; + this.logger.warn("Subscription not found in Stripe:", { + subscriptionId: phoneNumber.stripeSubscriptionId, + phoneNumberId, + }); + } else { + throw error; + } + } + + if (needsStripeCancellation) { + await stripe.subscriptions.cancel(phoneNumber.stripeSubscriptionId); + } 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..ac2c1a22205d5e 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/__tests__/BillingService.test.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/__tests__/BillingService.test.ts @@ -22,6 +22,7 @@ vi.mock("@calcom/features/ee/payments/server/stripe", () => ({ }, subscriptions: { cancel: vi.fn().mockResolvedValue({}), + retrieve: vi.fn().mockResolvedValue({ status: "active" }), }, }, })); @@ -47,6 +48,7 @@ describe("BillingService", () => { url: "https://checkout.stripe.com/session-123", }); stripe.subscriptions.cancel.mockResolvedValue({}); + stripe.subscriptions.retrieve.mockResolvedValue({ status: "active" }); service = new BillingService(mocks.mockPhoneNumberRepository, mocks.mockRetellRepository); }); @@ -254,5 +256,95 @@ describe("BillingService", () => { "Failed to cancel subscription" ); }); + + it("should handle already cancelled subscription in Stripe", 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.retrieve.mockResolvedValue({ status: "canceled" }); + + const result = await service.cancelPhoneNumberSubscription(validCancelData); + + expect(result).toEqual({ + success: true, + message: "Phone number subscription cancelled successfully.", + }); + + // Should retrieve subscription to check status + expect(stripe.subscriptions.retrieve).toHaveBeenCalledWith("sub_123"); + // Should NOT attempt to cancel since it's already cancelled + expect(stripe.subscriptions.cancel).not.toHaveBeenCalled(); + // Should still update database + expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenCalledWith({ + id: 1, + subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED, + disconnectOutboundAgent: true, + }); + }); + + it("should handle subscription not found in Stripe", 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.retrieve.mockRejectedValue({ code: "resource_missing" }); + + const result = await service.cancelPhoneNumberSubscription(validCancelData); + + expect(result).toEqual({ + success: true, + message: "Phone number subscription cancelled successfully.", + }); + + // Should attempt to retrieve subscription + expect(stripe.subscriptions.retrieve).toHaveBeenCalledWith("sub_123"); + // Should NOT attempt to cancel since it doesn't exist + expect(stripe.subscriptions.cancel).not.toHaveBeenCalled(); + // Should still update database + expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenCalledWith({ + id: 1, + subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED, + disconnectOutboundAgent: true, + }); + }); + + it("should throw error on retrieve 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.retrieve.mockRejectedValue(new TestError("API Error")); + + await expect(service.cancelPhoneNumberSubscription(validCancelData)).rejects.toThrow( + "Failed to cancel subscription" + ); + + // Should attempt to retrieve subscription + expect(stripe.subscriptions.retrieve).toHaveBeenCalledWith("sub_123"); + // Should NOT attempt to cancel due to error + expect(stripe.subscriptions.cancel).not.toHaveBeenCalled(); + // Should NOT update database due to error + expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).not.toHaveBeenCalled(); + }); }); }); From b72934213b7f74643444bac0efb578924f7a17b3 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Mon, 15 Sep 2025 20:17:01 +0530 Subject: [PATCH 2/6] fix: phone number billing bug --- .../retellAI/services/BillingService.ts | 18 +----- .../services/__tests__/BillingService.test.ts | 57 +++---------------- 2 files changed, 12 insertions(+), 63 deletions(-) diff --git a/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts b/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts index b81a4bd921b66c..35c2efd4fdd9b1 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts @@ -129,20 +129,12 @@ export class BillingService { } try { - let needsStripeCancellation = true; try { - const subscription = await stripe.subscriptions.retrieve(phoneNumber.stripeSubscriptionId); - if (subscription.status === 'canceled') { - needsStripeCancellation = false; - this.logger.info("Subscription already cancelled in Stripe:", { - subscriptionId: phoneNumber.stripeSubscriptionId, - phoneNumberId, - }); - } + await stripe.subscriptions.cancel(phoneNumber.stripeSubscriptionId); } catch (error: any) { + // Handle 404 gracefully - subscription doesn't exist or already cancelled if (error.code === 'resource_missing') { - needsStripeCancellation = false; - this.logger.warn("Subscription not found in Stripe:", { + this.logger.info("Subscription not found in Stripe (already cancelled or deleted):", { subscriptionId: phoneNumber.stripeSubscriptionId, phoneNumberId, }); @@ -151,10 +143,6 @@ export class BillingService { } } - if (needsStripeCancellation) { - await stripe.subscriptions.cancel(phoneNumber.stripeSubscriptionId); - } - 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 ac2c1a22205d5e..508665f7b7281d 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/__tests__/BillingService.test.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/__tests__/BillingService.test.ts @@ -22,7 +22,6 @@ vi.mock("@calcom/features/ee/payments/server/stripe", () => ({ }, subscriptions: { cancel: vi.fn().mockResolvedValue({}), - retrieve: vi.fn().mockResolvedValue({ status: "active" }), }, }, })); @@ -48,7 +47,6 @@ describe("BillingService", () => { url: "https://checkout.stripe.com/session-123", }); stripe.subscriptions.cancel.mockResolvedValue({}); - stripe.subscriptions.retrieve.mockResolvedValue({ status: "active" }); service = new BillingService(mocks.mockPhoneNumberRepository, mocks.mockRetellRepository); }); @@ -257,7 +255,7 @@ describe("BillingService", () => { ); }); - it("should handle already cancelled subscription in Stripe", async () => { + it("should handle subscription not found (404) gracefully", async () => { const mockPhoneNumber = createMockPhoneNumberRecord({ id: 1, stripeSubscriptionId: "sub_123", @@ -269,7 +267,7 @@ describe("BillingService", () => { mocks.mockRetellRepository.deletePhoneNumber.mockResolvedValue(undefined); const stripe = (await import("@calcom/features/ee/payments/server/stripe")).default; - stripe.subscriptions.retrieve.mockResolvedValue({ status: "canceled" }); + stripe.subscriptions.cancel.mockRejectedValue({ code: "resource_missing" }); const result = await service.cancelPhoneNumberSubscription(validCancelData); @@ -278,44 +276,9 @@ describe("BillingService", () => { message: "Phone number subscription cancelled successfully.", }); - // Should retrieve subscription to check status - expect(stripe.subscriptions.retrieve).toHaveBeenCalledWith("sub_123"); - // Should NOT attempt to cancel since it's already cancelled - expect(stripe.subscriptions.cancel).not.toHaveBeenCalled(); - // Should still update database - expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenCalledWith({ - id: 1, - subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED, - disconnectOutboundAgent: true, - }); - }); - - it("should handle subscription not found in Stripe", 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.retrieve.mockRejectedValue({ code: "resource_missing" }); - - const result = await service.cancelPhoneNumberSubscription(validCancelData); - - expect(result).toEqual({ - success: true, - message: "Phone number subscription cancelled successfully.", - }); - - // Should attempt to retrieve subscription - expect(stripe.subscriptions.retrieve).toHaveBeenCalledWith("sub_123"); - // Should NOT attempt to cancel since it doesn't exist - expect(stripe.subscriptions.cancel).not.toHaveBeenCalled(); - // Should still update database + // 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, @@ -323,7 +286,7 @@ describe("BillingService", () => { }); }); - it("should throw error on retrieve failure that is not resource_missing", async () => { + it("should throw error on Stripe API failure that is not resource_missing", async () => { const mockPhoneNumber = createMockPhoneNumberRecord({ id: 1, stripeSubscriptionId: "sub_123", @@ -333,16 +296,14 @@ describe("BillingService", () => { mocks.mockPhoneNumberRepository.findByIdAndUserId.mockResolvedValue(mockPhoneNumber); const stripe = (await import("@calcom/features/ee/payments/server/stripe")).default; - stripe.subscriptions.retrieve.mockRejectedValue(new TestError("API Error")); + stripe.subscriptions.cancel.mockRejectedValue(new TestError("API Error")); await expect(service.cancelPhoneNumberSubscription(validCancelData)).rejects.toThrow( "Failed to cancel subscription" ); - // Should attempt to retrieve subscription - expect(stripe.subscriptions.retrieve).toHaveBeenCalledWith("sub_123"); - // Should NOT attempt to cancel due to error - expect(stripe.subscriptions.cancel).not.toHaveBeenCalled(); + // Should attempt to cancel + expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_123"); // Should NOT update database due to error expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).not.toHaveBeenCalled(); }); From a81561553402a4a2295d865ca6ff7779fb667718 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Mon, 15 Sep 2025 20:32:53 +0530 Subject: [PATCH 3/6] fix: use raw code --- .../calAIPhone/providers/retellAI/services/BillingService.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts b/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts index 35c2efd4fdd9b1..2c479bbaee3fe4 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts @@ -133,7 +133,9 @@ export class BillingService { await stripe.subscriptions.cancel(phoneNumber.stripeSubscriptionId); } catch (error: any) { // Handle 404 gracefully - subscription doesn't exist or already cancelled - if (error.code === 'resource_missing') { + // Stripe errors have the code in error.raw.code or error.code + const errorCode = error?.raw?.code || error?.code; + if (errorCode === 'resource_missing') { this.logger.info("Subscription not found in Stripe (already cancelled or deleted):", { subscriptionId: phoneNumber.stripeSubscriptionId, phoneNumberId, From ab210df7ec8ef3ae7f143592bf3acffa96f3c2dd Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Mon, 15 Sep 2025 20:51:31 +0530 Subject: [PATCH 4/6] fix: use zod --- .../retellAI/services/BillingService.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts b/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts index 2c479bbaee3fe4..b1f81c03d8b918 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"] }); @@ -131,14 +142,14 @@ export class BillingService { try { try { await stripe.subscriptions.cancel(phoneNumber.stripeSubscriptionId); - } catch (error: any) { - // Handle 404 gracefully - subscription doesn't exist or already cancelled - // Stripe errors have the code in error.raw.code or error.code - const errorCode = error?.raw?.code || error?.code; - if (errorCode === 'resource_missing') { + } 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; From a67b5a535d373f2c9f36b463355f05156a58b18f Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Mon, 15 Sep 2025 21:00:18 +0530 Subject: [PATCH 5/6] chore: update test --- .../retellAI/services/__tests__/BillingService.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 508665f7b7281d..332de4d1ed761d 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/__tests__/BillingService.test.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/__tests__/BillingService.test.ts @@ -267,7 +267,13 @@ describe("BillingService", () => { mocks.mockRetellRepository.deletePhoneNumber.mockResolvedValue(undefined); const stripe = (await import("@calcom/features/ee/payments/server/stripe")).default; - stripe.subscriptions.cancel.mockRejectedValue({ code: "resource_missing" }); + 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); From e3dc6872060bbb4e5b7680dea7dd6efde874e3b9 Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Mon, 15 Sep 2025 16:38:22 +0100 Subject: [PATCH 6/6] Remove newline. --- .../calAIPhone/providers/retellAI/services/BillingService.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts b/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts index b1f81c03d8b918..c0324bfcc71ea3 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts @@ -144,7 +144,6 @@ export class BillingService { 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,