fix: phone number billing bug (#23851)

* fix: phone number billing bug

* fix: phone number billing bug

* fix: use raw code

* fix: use zod

* chore: update test

* Remove newline.

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
Udit Takkar
2025-09-15 17:17:05 +01:00
committed by GitHub
co-authored by Alex van Andel
parent 3aca339c95
commit 0c2ba81269
2 changed files with 84 additions and 1 deletions
@@ -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,
@@ -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();
});
});
});