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:
co-authored by
CarinaWolli
parent
357c82129c
commit
8c8ae127fe
@@ -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" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user