Skip to content

Commit 5feca13

Browse files
authored
fix: phone number cancel billing (calcom#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
1 parent f267cd9 commit 5feca13

3 files changed

Lines changed: 55 additions & 19 deletions

File tree

packages/features/calAIPhone/providers/retellAI/services/BillingService.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,10 @@ import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
1212
import type { PhoneNumberRepositoryInterface } from "../../interfaces/PhoneNumberRepositoryInterface";
1313
import type { RetellAIRepository } from "../types";
1414

15-
const stripeResourceMissingErrorSchema = z.object({
16-
type: z.literal("invalid_request_error"),
17-
code: z.literal("resource_missing"),
18-
message: z.string(),
19-
param: z.string().optional(),
20-
doc_url: z.string().optional(),
21-
request_log_url: z.string().optional(),
15+
const stripeErrorSchema = z.object({
16+
raw: z.object({
17+
code: z.string(),
18+
}),
2219
});
2320

2421
export class BillingService {
@@ -140,21 +137,32 @@ export class BillingService {
140137
}
141138

142139
try {
140+
await this.phoneNumberRepository.updateSubscriptionStatus({
141+
id: phoneNumberId,
142+
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
143+
disconnectOutboundAgent: false,
144+
});
145+
143146
try {
144147
await stripe.subscriptions.cancel(phoneNumber.stripeSubscriptionId);
145148
} catch (error) {
146-
const parsedError = stripeResourceMissingErrorSchema.safeParse(error);
147-
if (parsedError.success) {
149+
const parsedError = stripeErrorSchema.safeParse(error);
150+
if (parsedError.success && parsedError.data.raw.code === "resource_missing") {
148151
this.logger.info("Subscription not found in Stripe (already cancelled or deleted):", {
149152
subscriptionId: phoneNumber.stripeSubscriptionId,
150153
phoneNumberId,
151-
stripeMessage: parsedError.data.message,
154+
stripeMessage: "Subscription resource not found",
152155
});
153156
} else {
157+
await this.phoneNumberRepository.updateSubscriptionStatus({
158+
id: phoneNumberId,
159+
subscriptionStatus: PhoneNumberSubscriptionStatus.ACTIVE,
160+
});
154161
throw error;
155162
}
156163
}
157164

165+
// Disconnnect agent after cancelling from stripe
158166
await this.phoneNumberRepository.updateSubscriptionStatus({
159167
id: phoneNumberId,
160168
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,

packages/features/calAIPhone/providers/retellAI/services/__tests__/BillingService.test.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,13 @@ describe("BillingService", () => {
162162

163163
const stripe = (await import("@calcom/features/ee/payments/server/stripe")).default;
164164
expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_123");
165-
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenCalledWith({
165+
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenCalledTimes(2);
166+
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(1, {
167+
id: 1,
168+
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
169+
disconnectOutboundAgent: false,
170+
});
171+
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(2, {
166172
id: 1,
167173
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
168174
disconnectOutboundAgent: true,
@@ -268,11 +274,15 @@ describe("BillingService", () => {
268274

269275
const stripe = (await import("@calcom/features/ee/payments/server/stripe")).default;
270276
stripe.subscriptions.cancel.mockRejectedValue({
271-
type: "invalid_request_error",
277+
type: "StripeInvalidRequestError",
278+
raw: {
279+
code: "resource_missing",
280+
doc_url: "https://stripe.com/docs/error-codes/resource-missing",
281+
message: "No such subscription: 'sub_123'",
282+
param: "id",
283+
type: "invalid_request_error",
284+
},
272285
code: "resource_missing",
273-
message: "No such subscription: 'sub_123'",
274-
param: "id",
275-
doc_url: "https://stripe.com/docs/error-codes/resource-missing",
276286
});
277287

278288
const result = await service.cancelPhoneNumberSubscription(validCancelData);
@@ -284,8 +294,14 @@ describe("BillingService", () => {
284294

285295
// Should attempt to cancel
286296
expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_123");
287-
// Should still update database even after 404
288-
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenCalledWith({
297+
// Should update database twice: first CANCELLED (disconnectOutboundAgent: false), then final CANCELLED (disconnectOutboundAgent: true)
298+
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenCalledTimes(2);
299+
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(1, {
300+
id: 1,
301+
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
302+
disconnectOutboundAgent: false,
303+
});
304+
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(2, {
289305
id: 1,
290306
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
291307
disconnectOutboundAgent: true,
@@ -310,8 +326,16 @@ describe("BillingService", () => {
310326

311327
// Should attempt to cancel
312328
expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_123");
313-
// Should NOT update database due to error
314-
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).not.toHaveBeenCalled();
329+
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenCalledTimes(2);
330+
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(1, {
331+
id: 1,
332+
subscriptionStatus: PhoneNumberSubscriptionStatus.CANCELLED,
333+
disconnectOutboundAgent: false,
334+
});
335+
expect(mocks.mockPhoneNumberRepository.updateSubscriptionStatus).toHaveBeenNthCalledWith(2, {
336+
id: 1,
337+
subscriptionStatus: PhoneNumberSubscriptionStatus.ACTIVE,
338+
});
315339
});
316340
});
317341
});

packages/features/ee/billing/api/webhook/_customer.subscription.deleted.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ async function handleCalAIPhoneNumberSubscriptionDeleted(
7272
}
7373

7474
try {
75+
if (phoneNumber.subscriptionStatus === "CANCELLED") {
76+
return { success: true, subscriptionId: subscription.id, skipped: true };
77+
}
78+
7579
const aiService = createDefaultAIPhoneServiceProvider();
7680

7781
await aiService.cancelPhoneNumberSubscription({

0 commit comments

Comments
 (0)