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" });
}