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:
@@ -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,
|
||||
|
||||
+33
-9
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user