diff --git a/apps/web/pages/api/twilio/webhook.ts b/apps/web/pages/api/twilio/webhook.ts index cc15930abf..825525c2d3 100644 --- a/apps/web/pages/api/twilio/webhook.ts +++ b/apps/web/pages/api/twilio/webhook.ts @@ -76,7 +76,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { const creditService = new CreditService(); if (countryCode === "US" || countryCode === "CA") { - // SMS to US and CA are free + // SMS to US and CA are free for teams let teamIdToCharge = parsedTeamId; if (!teamIdToCharge && parsedUserId) { @@ -92,15 +92,16 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { teamIdToCharge = teamMembership?.teamId; } - await creditService.chargeCredits({ - teamId: teamIdToCharge, - userId: !teamIdToCharge ? parsedUserId : undefined, - bookingUid: parsedBookingUid, - smsSid, - credits: 0, - }); + if (teamIdToCharge) { + await creditService.chargeCredits({ + teamId: teamIdToCharge, + bookingUid: parsedBookingUid, + smsSid, + credits: 0, + }); - return res.status(200).send(`SMS to US and CA are free. Credits set to 0`); + return res.status(200).send(`SMS to US and CA are free for teams. Credits set to 0`); + } } let orgId; diff --git a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx index 064cfe7fc6..4967b99cc0 100644 --- a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx +++ b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx @@ -814,7 +814,13 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { }} defaultValue={selectedTemplate} value={selectedTemplate} - options={templateOptions} + options={templateOptions.map((option) => ({ + label: option.label, + value: option.value, + needsTeamsUpgrade: + option.needsTeamsUpgrade && + !isSMSAction(form.getValues(`steps.${step.stepNumber - 1}.action`)), + }))} isOptionDisabled={(option: { label: string; value: any; @@ -860,9 +866,8 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { )} )} -
-
@@ -880,7 +885,11 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { updateTemplate={updateTemplate} firstRender={firstRender} setFirstRender={setFirstRender} - editable={!props.readOnly && !isWhatsappAction(step.action) && hasActiveTeamPlan} + editable={ + !props.readOnly && + !isWhatsappAction(step.action) && + (hasActiveTeamPlan || isSMSAction(step.action)) + } excludedToolbarItems={ !isSMSAction(step.action) ? [] : ["blockType", "bold", "italic", "link"] } diff --git a/packages/features/ee/workflows/lib/test/twilioWebhook.test.ts b/packages/features/ee/workflows/lib/test/twilioWebhook.test.ts new file mode 100644 index 0000000000..6f8945094f --- /dev/null +++ b/packages/features/ee/workflows/lib/test/twilioWebhook.test.ts @@ -0,0 +1,161 @@ +import { describe, beforeEach, vi, test, expect } from "vitest"; + +vi.mock("@calcom/lib/constants", async () => { + const actual = await vi.importActual("@calcom/lib/constants"); + return { + ...actual, + IS_SMS_CREDITS_ENABLED: true, + }; +}); + +vi.mock("../reminders/providers/twilioProvider", () => ({ + validateWebhookRequest: vi.fn().mockResolvedValue(true), + getCountryCodeForNumber: vi.fn().mockResolvedValue("US"), +})); + +const mockChargeCredits = vi.fn().mockResolvedValue({ teamId: 1 }); +vi.mock("@calcom/features/ee/billing/credit-service", () => ({ + CreditService: vi.fn().mockImplementation(() => ({ + chargeCredits: mockChargeCredits, + calculateCreditsFromPrice: vi.fn().mockReturnValue(1), + })), +})); + +const mockFindFirst = vi.fn(); +vi.mock("@calcom/prisma", () => ({ + default: { + membership: { + findFirst: mockFindFirst, + }, + team: { + findUnique: vi.fn().mockResolvedValue(null), + }, + }, +})); + +describe("Twilio Webhook Handler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("SMS to US and CA numbers", () => { + test("should create expense log with 0 credits with a given teamId", async () => { + const webhookHandler = (await import("../../../../../../apps/web/pages/api/twilio/webhook")).default; + + const mockRequest = { + method: "POST", + headers: { + "x-twilio-signature": "valid-signature", + }, + query: { + teamId: "1", + }, + body: { + MessageStatus: "delivered", + To: "+1234567890", // US number + SmsSid: "SM123", + }, + }; + + const mockResponse = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + send: vi.fn(), + }; + + await webhookHandler(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.send).toHaveBeenCalledWith("SMS to US and CA are free for teams. Credits set to 0"); + expect(mockChargeCredits).toHaveBeenCalledWith({ + teamId: 1, + bookingUid: undefined, + smsSid: "SM123", + credits: 0, + }); + }); + + test("should create expense log with 0 credits if userId is part of a team", async () => { + mockFindFirst.mockResolvedValue({ teamId: 1 }); + const webhookHandler = (await import("../../../../../../apps/web/pages/api/twilio/webhook")).default; + + const mockRequest = { + method: "POST", + headers: { + "x-twilio-signature": "valid-signature", + }, + query: { + userId: "123", + }, + body: { + MessageStatus: "delivered", + To: "+1234567890", // US number + SmsSid: "SM123", + }, + }; + + const mockResponse = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + send: vi.fn(), + }; + + await webhookHandler(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.send).toHaveBeenCalledWith("SMS to US and CA are free for teams. Credits set to 0"); + expect(mockChargeCredits).toHaveBeenCalledWith({ + teamId: 1, + bookingUid: undefined, + smsSid: "SM123", + credits: 0, + }); + }); + + test("should create expense log with null credits if userId is not part of a team", async () => { + mockFindFirst.mockResolvedValue(null); + const webhookHandler = (await import("../../../../../../apps/web/pages/api/twilio/webhook")).default; + + vi.mock("@calcom/lib/getOrgIdFromMemberOrTeamId", () => ({ + default: vi.fn().mockResolvedValue(null), + })); + + vi.mock("../reminders/providers/twilioProvider", () => ({ + validateWebhookRequest: vi.fn().mockResolvedValue(true), + getCountryCodeForNumber: vi.fn().mockResolvedValue("US"), + getPriceForSMS: vi.fn().mockResolvedValue(null), + })); + + const mockRequest = { + method: "POST", + headers: { + "x-twilio-signature": "valid-signature", + }, + query: { + userId: "123", + }, + body: { + MessageStatus: "delivered", + To: "+1234567890", // US number + SmsSid: "SM123", + }, + }; + + const mockResponse = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + send: vi.fn(), + }; + + await webhookHandler(mockRequest, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockChargeCredits).toHaveBeenCalledWith({ + userId: 123, + bookingUid: undefined, + smsSid: "SM123", + credits: null, + }); + }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/workflows/update.handler.ts b/packages/trpc/server/routers/viewer/workflows/update.handler.ts index e3bd034d29..59721980d2 100755 --- a/packages/trpc/server/routers/viewer/workflows/update.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/update.handler.ts @@ -333,7 +333,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { }); } else if (isStepEdited(oldStep, { ...newStep, verifiedAt: oldStep.verifiedAt })) { // check if step that require team plan already existed before - if (!hasPaidPlan) { + if (!hasPaidPlan && isEmailAction(newStep.action)) { const isChangingToCustomTemplate = newStep.template === WorkflowTemplates.CUSTOM && oldStep.template !== WorkflowTemplates.CUSTOM; @@ -348,16 +348,14 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { throw new TRPCError({ code: "UNAUTHORIZED", message: "Not available on free plan" }); } - if (isEmailAction(newStep.action)) { - // on free plans always use predefined templates - const { emailBody, emailSubject } = await getEmailTemplateText(newStep.template, { - locale: ctx.user.locale, - action: newStep.action, - timeFormat: ctx.user.timeFormat, - }); + // on free plans always use predefined templates + const { emailBody, emailSubject } = await getEmailTemplateText(newStep.template, { + locale: ctx.user.locale, + action: newStep.action, + timeFormat: ctx.user.timeFormat, + }); - newStep = { ...newStep, reminderBody: emailBody, emailSubject }; - } + newStep = { ...newStep, reminderBody: emailBody, emailSubject }; } } @@ -419,7 +417,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { steps .filter((step) => step.id <= 0) .map(async (newStep) => { - if (!hasPaidPlan) { + if (!hasPaidPlan && isEmailAction(newStep.action)) { if (newStep.template === WorkflowTemplates.CUSTOM) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Not available on free plan" }); }