Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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"] });

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
Loading