feat: allow custom SMS templates for free users (#21469)

* charge free users for SMS to CA an US

* allow custom template for free users

* fix backend + some UI fixes

* make text editable

* rename back to needsTeamsUpgrade

* add twilio webhook handler tests

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
Carina Wollendorfer
2025-05-22 19:09:49 +01:00
committed by GitHub
co-authored by CarinaWolli
parent 357c82129c
commit 8c8ae127fe
4 changed files with 193 additions and 24 deletions
+10 -9
View File
@@ -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;
@@ -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) {
)}
</div>
)}
<div className="mb-2 flex items-center pb-1">
<Label className="mb-0 flex-none ">
<Label className="mb-0 flex-none">
{isEmailSubjectNeeded ? t("email_body") : t("text_message")}
</Label>
</div>
@@ -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"]
}
@@ -0,0 +1,161 @@
import { describe, beforeEach, vi, test, expect } from "vitest";
vi.mock("@calcom/lib/constants", async () => {
const actual = await vi.importActual<typeof import("@calcom/lib/constants")>("@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,
});
});
});
});
@@ -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" });
}