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
This commit is contained in:
Udit Takkar
2025-09-16 15:39:32 +05:30
committed by GitHub
parent f267cd9cd9
commit 5feca13cfb
3 changed files with 55 additions and 19 deletions
@@ -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,
@@ -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,
});
});
});
});
@@ -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({