feat: Credit System (SMS) (#20126)

* Add credits section to billing

* create seperate router for credits

* add stripe checkout session

* schema changes + code improvements

* rename to creditBalance

* custom quantify input with error message

* add checkout session completed webhook endpoint

* fix typo

* UI fixes

* add payCredits handler

* add error toast message

* allow scheduling sms up as close to 15 minutes in the future

* schedule at most 2 hours in advance

* webhook to pay for sent sms

* continued work on twilio callback

* code clean up

* further implementation for credit handling

* add migration

* object as param for scheduleSMS

* object as param for sendSMS

* fix TrpcSessionUser imports

* fix imports

* add db changes

* add cron job for price setting

* twilio status callback to create expense log

* remove unused code

* set up low credit balance email

* fixes for buying credits

* fixes in api/twilio/webhook

* add test to save credits to credits balance

* fix typos

* add new helper function chargeCredits

* expand twilioProvider

* fix type errors

* adjust tests

* type errors

* clean up

* clean up

* fix subscription active check

* remove some user/org related code

* more changes to remove user/org support

* send emails seperatly to admins

* fixes for team billing page

* fix stripe success url

* fixes to creating expense log

* email imrovements and more

* get monthly team price from stripe

* fix import

* fix monthly credits calculation

* finsih low credit balance warning email

* credit balance limit reached email

* create CreditService

* cancel SMS and send as email instead

* add messageDispatcher

* fix type error

* fix type error

* fix type error

* fix import

* fix unit test

* clean up twilioProvider

* clean up chckSmsPrices/route

* add missing translations

* add skeleton loader

* add admin check to get handler

* code clean up + fixes

* improve scheduling with fallback

* fix type error

* add bookingUid to handleSendingSMS

* add unit tests for creditService

* add more tests to credit-service.test.ts

* add test for cancelScheduledMessagesAndScheduleEmails

* fix test and type error

* add back resolve

* fix empty resolve

* adjust limitReachedAt logic

* address mrge comment on styling

* add getAdminMembership to repository

* twilio/webhook clean up (feedback)

* feedback - clean up

* remove todo comment

* clean up twilio/webhook

* code clean up

* add use client

* add createOneTimeCheckout to stripe service

* refactor repository pattern

* small fixes + clean up

* fix type error

* add missing import

* fix hasAvailableCredits for user

* force-dynamic

* rename credits to creditBalance

* fix stripe import

* remove not needed code

* fix e2e tests

* improve low balance warning email

* dynamic-import CreditService

* index.ts

* fix e2e tests

* remove dynamic import CreditService

* Revert "remove dynamic import CreditService"

This reverts commit e272978a7ff3fc5a04139e656c9f8d2c84a40dda.

* no need to dynamic-import credit service

* Revert "no need to dynamic-import credit service"

This reverts commit ba5ae488d08979a65fb47b5d0722cda9f45d6ea0.

* only select id in getAdminMembership

* revert billing/package.json

* fix type checks

* fix type checks

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: hbjORbj <sldisek783@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Carina Wollendorfer
2025-05-13 09:11:44 +02:00
committed by GitHub
co-authored by CarinaWolli hbjORbj Udit Takkar Peer Richelsen
parent 43be9bd6a8
commit fadfba8830
45 changed files with 2481 additions and 133 deletions
+1
View File
@@ -201,6 +201,7 @@ NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
STRIPE_TEAM_MONTHLY_PRICE_ID=
NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID=
STRIPE_TEAM_PRODUCT_ID=
# It is a price ID in the product with id STRIPE_ORG_PRODUCT_ID
STRIPE_ORG_MONTHLY_PRICE_ID=
+23
View File
@@ -0,0 +1,23 @@
name: Cron - checkSmsPrices
on:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule:
# Runs “every minute” (see https://crontab.guru)
- cron: "* * * * *"
jobs:
cron-checkSmsPrices:
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_API_KEY: ${{ secrets.CRON_API_KEY }}
runs-on: ubuntu-latest
steps:
- name: cURL request
if: ${{ env.APP_URL && env.CRON_API_KEY }}
run: |
curl ${{ secrets.APP_URL }}/api/cron/checkSmsPrices \
-X POST \
-H 'content-type: application/json' \
-H 'authorization: ${{ secrets.CRON_API_KEY }}' \
--fail
@@ -0,0 +1,112 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import dayjs from "@calcom/dayjs";
import * as twilio from "@calcom/features/ee/workflows/lib/reminders/providers/twilioProvider";
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import { CreditType } from "@calcom/prisma/enums";
async function postHandler(req: NextRequest) {
const apiKey = req.headers.get("authorization") || req.nextUrl.searchParams.get("apiKey");
if (process.env.CRON_API_KEY !== apiKey) {
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
}
if (!IS_SMS_CREDITS_ENABLED) {
return NextResponse.json({ ok: true, message: "SMS credits not enabled" });
}
const smsLogsWithoutPrice = await prisma.creditExpenseLog.findMany({
where: {
credits: null,
smsSid: {
not: null,
},
date: {
gte: dayjs().subtract(1, "hour").toDate(),
},
},
select: {
smsSid: true,
id: true,
},
});
let pricesUpdated = 0;
const { CreditService } = await import("@calcom/features/ee/billing/credit-service");
const creditService = new CreditService();
await Promise.allSettled(
smsLogsWithoutPrice.map(async (log) => {
if (!log.smsSid) return;
try {
const price = await twilio.getPriceForSMS(log.smsSid);
const credits = price ? creditService.calculateCreditsFromPrice(price) : null;
if (!credits) return;
const updatedLog = await prisma.creditExpenseLog.update({
where: { id: log.id },
data: { credits },
select: {
creditBalance: {
select: {
id: true,
additionalCredits: true,
teamId: true,
},
},
creditType: true,
},
});
if (updatedLog.creditType === CreditType.ADDITIONAL) {
const decrementValue =
credits <= updatedLog.creditBalance.additionalCredits
? credits
: updatedLog.creditBalance.additionalCredits;
await prisma.creditBalance.update({
where: { id: updatedLog.creditBalance.id },
data: {
additionalCredits: {
decrement: decrementValue,
},
},
});
}
if (!updatedLog.creditBalance.teamId) {
logger.error(`teamId missing for expense log ${log.id}`);
return;
}
const teamCredits = await creditService.getAllCreditsForTeam(updatedLog.creditBalance.teamId);
const remainingMonthlyCredits = Math.max(0, teamCredits.totalRemainingMonthlyCredits);
await creditService.handleLowCreditBalance({
teamId: updatedLog.creditBalance.teamId ?? 0,
remainingCredits: remainingMonthlyCredits + teamCredits.additionalCredits,
});
pricesUpdated++;
} catch (err) {
logger.error(`Failed to process SMS log ${log.smsSid}`, err);
await prisma.creditExpenseLog.update({
where: { id: log.id },
data: { credits: 0 },
});
}
})
);
return NextResponse.json({ ok: true, pricesUpdated });
}
export const POST = defaultResponderForAppDir(postHandler);
@@ -4,8 +4,10 @@ import { usePathname } from "next/navigation";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import BillingCredits from "~/settings/billing/components/BillingCredits";
interface CtaRowProps {
title: string;
@@ -58,9 +60,9 @@ const BillingView = () => {
{t("billing_portal")}
</Button>
</CtaRow>
<hr className="border-subtle" />
</div>
<BillingCredits />
<div className="border-subtle mt-6 space-y-6 rounded-lg border px-6 py-8 text-sm sm:space-y-8">
<CtaRow title={t("need_anything_else")} description={t("further_billing_help")}>
<Button color="secondary" onClick={onContactSupportClick}>
{t("contact_support")}
@@ -0,0 +1,126 @@
"use client";
import { ProgressBar } from "@tremor/react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { TextField, Label, InputError } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/toast";
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
export default function BillingCredits() {
const { t } = useLocale();
const router = useRouter();
const {
register,
handleSubmit,
setValue,
formState: { errors },
} = useForm<{ quantity: number }>({ defaultValues: { quantity: 50 } });
const params = useParamsWithFallback();
const teamId = params.id ? Number(params.id) : undefined;
const { data: creditsData, isLoading } = trpc.viewer.credits.getAllCredits.useQuery(
{ teamId: teamId ?? 0 },
{ enabled: !!teamId }
);
const buyCreditsMutation = trpc.viewer.credits.buyCredits.useMutation({
onSuccess: (data) => {
if (data.sessionUrl) {
router.push(data.sessionUrl);
}
},
onError: (err) => {
showToast(t("credit_purchase_failed"), "error");
},
});
if (!teamId || !IS_SMS_CREDITS_ENABLED) {
return null;
}
if (isLoading) return <BillingCreditsSkeleton />;
if (!creditsData) return null;
const onSubmit = (data: { quantity: number }) => {
buyCreditsMutation.mutate({ quantity: data.quantity, teamId });
};
const teamCreditsPercentageUsed =
creditsData.teamCredits.totalMonthlyCredits > 0
? (creditsData.teamCredits.totalRemainingMonthlyCredits / creditsData.teamCredits.totalMonthlyCredits) *
100
: 0;
return (
<div className="border-subtle mt-8 space-y-6 rounded-lg border px-6 py-6 pb-6 text-sm sm:space-y-8">
<div>
<h2 className="text-base font-semibold">{t("credits")}</h2>
<p>{t("view_and_manage_credits_description")}</p>
<div className="-mx-6 mt-6">
<hr className="border-subtle" />
</div>
<div className="mt-6">
<div className="mb-4">
<Label>{t("monthly_credits")}</Label>
<ProgressBar
color="green"
percentageValue={teamCreditsPercentageUsed}
label={`${Math.max(0, Math.round(teamCreditsPercentageUsed))}%`}
/>
<div className="text-subtle">
<div>{t("total_credits", { totalCredits: creditsData.teamCredits.totalMonthlyCredits })}</div>
<div>
{t("remaining_credits", {
remainingCredits: creditsData.teamCredits.totalRemainingMonthlyCredits,
})}
</div>
</div>
</div>
<Label>{t("additional_credits")}</Label>
<div className="mt-2 text-sm">{creditsData.teamCredits.additionalCredits}</div>{" "}
<div className="-mx-6 mb-6 mt-6">
<hr className="border-subtle mb-3 mt-3" />
</div>
<form onSubmit={handleSubmit(onSubmit)} className="flex">
<div className="mr-auto">
<div className="mb-2 font-semibold">{t("buy_additional_credits")}</div>
<div className="flex flex-col">
<TextField
required
type="number"
{...register("quantity", {
required: t("error_required_field"),
min: { value: 50, message: t("minimum_of_credits_required") },
valueAsNumber: true,
})}
label=""
containerClassName="w-60"
onChange={(e) => setValue("quantity", Number(e.target.value))}
min={50}
addOnSuffix={<>{t("credits")}</>}
/>
{errors.quantity && <InputError message={errors.quantity.message ?? t("invalid_input")} />}
</div>
</div>
<div className="mb-1 mt-auto">
<Button color="primary" target="_blank" EndIcon="external-link" type="submit">
{t("buy_credits")}
</Button>
</div>
</form>
</div>
</div>
</div>
);
}
@@ -0,0 +1,63 @@
import { SkeletonText, SkeletonContainer, SkeletonButton } from "@calcom/ui/components/skeleton";
export function BillingCreditsSkeleton() {
return (
<SkeletonContainer>
<div className="border-subtle mt-8 space-y-6 rounded-lg border px-6 py-6 text-sm sm:space-y-8">
{/* Title and Description */}
<div>
<div>
<SkeletonText className="mb-2 h-4 w-20" /> {/* Credits title */}
</div>
<div>
<SkeletonText className="h-4 w-72" /> {/* Description */}
</div>
<div className="-mx-6 mt-6">
<hr className="border-subtle" />
</div>
{/* Monthly credits */}
<div className="mt-[20px]">
<SkeletonText className="mb-4 h-4 w-32" /> {/* Monthly credits label */}
<div className="h-2 w-full rounded-md bg-gray-200" /> {/* Progress bar */}
<div className="text-subtle mt-2">
<div>
<SkeletonText className="h-4 w-40" /> {/* Total credits */}
</div>
<div>
<SkeletonText className="h-4 w-40" /> {/* Remaining credits */}
</div>
</div>
</div>
{/* Additional credits */}
<div className="mt-4">
<div>
<SkeletonText className="mb-2 h-4 w-40" /> {/* Additional credits label */}
</div>
<div>
<SkeletonText className="h-4 w-24" /> {/* Additional credits value */}
</div>
</div>
<div className="-mx-6 mb-6 mt-4">
<hr className="border-subtle mb-3 mt-3" />
</div>
{/* Buy credits form */}
<div className="flex">
<div className="mr-auto">
<div>
<SkeletonText className="mb-2 mt-1 h-5 w-[268px]" /> {/* Buy credits label */}
</div>
<div>
<SkeletonText className="mt-2 h-8 w-[240px] rounded-md" /> {/* Input field */}
</div>
</div>
<div className="mt-auto ">
<SkeletonButton className="h-9 w-[120px] rounded-md" /> {/* Buy button */}
</div>
</div>
</div>
</div>
</SkeletonContainer>
);
}
@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { creditsRouter } from "@calcom/trpc/server/routers/viewer/credits/_router";
export default createNextApiHandler(creditsRouter);
+163
View File
@@ -0,0 +1,163 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import * as twilio from "@calcom/features/ee/workflows/lib/reminders/providers/twilioProvider";
import { IS_SMS_CREDITS_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
import prisma from "@calcom/prisma";
const InputSchema = z.object({
userId: z
.string()
.optional()
.transform((val) => {
const num = Number(val);
return isNaN(num) ? undefined : num;
}),
teamId: z
.string()
.optional()
.transform((val) => {
const num = Number(val);
return isNaN(num) ? undefined : num;
}),
bookingUid: z.string().optional(),
});
/*
Twilio status callback: creates expense log when sms is delivered or undelivered
*/
async function handler(req: NextApiRequest, res: NextApiResponse) {
const signature = req.headers["x-twilio-signature"];
const baseUrl = `${WEBAPP_URL}/api/twilio/webhook`;
const queryParams = new URLSearchParams(req.query as Record<string, string>).toString();
const requestUrl = queryParams ? `${baseUrl}?${queryParams}` : baseUrl;
if (typeof signature !== "string") {
return res.status(401).send("Missing Twilio signature");
}
const isSignatureValid = await twilio.validateWebhookRequest({
requestUrl,
signature,
params: req.body,
});
if (!isSignatureValid) {
return res.status(401).send("Invalid Twilio signature");
}
const messageStatus = req.body.MessageStatus;
if (messageStatus !== "delivered" && messageStatus !== "undelivered") {
return res.status(200).send(`SMS not yet delivered/undelivered`);
}
if (!IS_SMS_CREDITS_ENABLED) {
return res.status(200).send(`SMS credits are not enabled.`);
}
const countryCode = await twilio.getCountryCodeForNumber(req.body.To);
const smsSid = req.body.SmsSid;
const {
userId: parsedUserId,
teamId: parsedTeamId,
bookingUid: parsedBookingUid,
} = InputSchema.parse(req.query);
if (!parsedUserId && !parsedTeamId) {
return res.status(401).send("Team or user id is required");
}
const { CreditService } = await import("@calcom/features/ee/billing/credit-service");
const creditService = new CreditService();
if (countryCode === "US" || countryCode === "CA") {
// SMS to US and CA are free on a team plan
let teamIdToCharge = parsedTeamId;
if (!teamIdToCharge && parsedUserId) {
const teamMembership = await prisma.membership.findFirst({
where: {
userId: parsedUserId,
accepted: true,
},
select: {
teamId: true,
},
});
teamIdToCharge = teamMembership?.teamId;
}
if (teamIdToCharge) {
await creditService.chargeCredits({
teamId: teamIdToCharge,
bookingUid: parsedBookingUid,
smsSid,
credits: 0,
});
return res.status(200).send(`SMS to US and CA are free on a team plan. Credits set to 0`);
}
}
let orgId;
if (parsedTeamId) {
const team = await prisma.team.findUnique({
where: {
id: parsedTeamId,
},
select: {
isOrganization: true,
id: true,
},
});
orgId = team?.isOrganization ? team.id : undefined;
}
if (!orgId) {
orgId = await getOrgIdFromMemberOrTeamId({
memberId: parsedUserId,
teamId: parsedTeamId,
});
}
if (orgId) {
await creditService.chargeCredits({
teamId: orgId,
bookingUid: parsedBookingUid,
smsSid,
credits: 0,
});
return res.status(200).send(`SMS are free for organizations. Credits set to 0`);
}
const price = await twilio.getPriceForSMS(smsSid);
const credits = price ? creditService.calculateCreditsFromPrice(price) : null;
const chargedTeamId = await creditService.chargeCredits({
credits,
teamId: parsedTeamId,
userId: parsedUserId,
smsSid,
bookingUid: parsedBookingUid,
});
if (chargedTeamId) {
return res.status(200).send(
`Expense log with ${credits ? credits : "no"} credits created for
teamId ${chargedTeamId}`
);
}
// this should never happen - even when out of credits we still charge a team
return res.status(500).send("No team or users found to be charged");
}
export default defaultHandler({
POST: Promise.resolve({ default: handler }),
});
@@ -3021,6 +3021,12 @@
"asc": "Asc",
"desc": "Desc",
"verify_email_change": "Verify email change",
"buy_credits": "Buy Credits",
"credits": "Credits",
"view_and_manage_credits": "View and manage credits",
"view_and_manage_credits_description": "View and manage credits for sending SMS messages. One credit is worth 1¢ (USD)",
"buy_additional_credits": "Buy additional credits ($0.01 per credit)",
"overview": "Overview",
"organization_slug_taken": "Organization slug is already taken",
"you_cannot_create_an_organization_as_you_are_already_part_of_an_organization": "You cannot create an organization as you are already a part of an organization",
"you_need_to_complete_user_onboarding_before_creating_an_organization": "You need to complete user onboarding before creating an organization",
@@ -3114,6 +3120,8 @@
"most_cancelled_bookings": "Most Cancelled Bookings",
"name_or_email": "Name or Email",
"salesforce_round_robin_skip_fallback_to_lead_owner": "If no contact is found, fallback to lead owner if it exists",
"credit_purchase_failed": "Credit purchase failed. Please try again or contact support.",
"minimum_of_credits_required": "Minimum of 50 credits required",
"enable_delegation_credential": "Enable Delegation Credential",
"enable_delegation_credential_description": "Grant Cal.com automatic access to the calendars of all organization members by enabling delegation credential.",
"disable_delegation_credential": "Disable Delegation Credential",
@@ -3186,6 +3194,16 @@
"results": "Results",
"view_form": "View Form",
"sms_opt_out_message": "Text STOP to opt-out of SMS messages",
"team_credits_low_warning": "Your team {{teamName}} is running low on credits",
"action_required_out_of_credits": "[Action Required] Your team {{teamName}} has run out of credits",
"low_credits_warning_message": "Your Cal.com team {{teamName}} is running low on credits. To avoid any disruption in service, please purchase additional credits. If your balance runs out, SMS messages will stop sending and will be sent as emails instead.",
"credit_limit_reached_message": "Your Cal.com team {{teamName}} has run out of credits. As a result, SMS messages are now being sent via email instead. To resume sending SMS, please purchase additional credits.",
"current_credit_balance": "Current balance: {{balance}} credits",
"notification_about_your_booking": "Notification about your booking",
"monthly_credits": "Monthly credits",
"total_credits": "Total credits: {{totalCredits}}",
"remaining_credits": "Remaining credits: {{remainingCredits}}",
"additional_credits": "Additional credits",
"routing_form_next_in_queue": "{{count}} next in queue",
"routing_form_select_members_to_email": "Send email responses to",
"routing_incomplete_booking_tab": "Incomplete Bookings",
+48
View File
@@ -47,6 +47,8 @@ import type { IBookingRedirect } from "./templates/booking-redirect-notification
import BrokenIntegrationEmail from "./templates/broken-integration-email";
import type { ChangeOfEmailVerifyLink } from "./templates/change-account-email-verify";
import ChangeOfEmailVerifyEmail from "./templates/change-account-email-verify";
import CreditBalanceLimitReachedEmail from "./templates/credit-balance-limit-reached-email";
import CreditBalanceLowWarningEmail from "./templates/credit-balance-low-warning-email";
import DisabledAppEmail from "./templates/disabled-app-email";
import type { Feedback } from "./templates/feedback-email";
import FeedbackEmail from "./templates/feedback-email";
@@ -758,3 +760,49 @@ export const sendAdminOrganizationNotification = async (input: OrganizationNotif
export const sendBookingRedirectNotification = async (bookingRedirect: IBookingRedirect) => {
await sendEmail(() => new BookingRedirectEmailNotification(bookingRedirect));
};
export const sendCreditBalanceLowWarningEmails = async (input: {
team: {
name: string;
id: number;
adminAndOwners: {
name: string;
email: string;
t: TFunction;
}[];
};
balance: number;
}) => {
const { team, balance } = input;
if (!team.adminAndOwners.length) return;
const emailsToSend: Promise<unknown>[] = [];
for (const admin of team.adminAndOwners) {
emailsToSend.push(sendEmail(() => new CreditBalanceLowWarningEmail(admin, balance, team)));
}
await Promise.all(emailsToSend);
};
export const sendCreditBalanceLimitReachedEmails = async ({
team,
}: {
team: {
name: string;
id: number;
adminAndOwners: {
name: string;
email: string;
t: TFunction;
}[];
};
}) => {
if (!team.adminAndOwners.length) return;
const emailsToSend: Promise<unknown>[] = [];
for (const admin of team.adminAndOwners) {
emailsToSend.push(sendEmail(() => new CreditBalanceLimitReachedEmail(admin, team)));
}
await Promise.all(emailsToSend);
};
@@ -0,0 +1,40 @@
import type { TFunction } from "next-i18next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { CallToAction, V2BaseEmailHtml } from "../components";
import type { BaseScheduledEmail } from "./BaseScheduledEmail";
export const CreditBalanceLimitReachedEmail = (
props: {
team: {
id: number;
name: string;
};
user: {
name: string;
email: string;
t: TFunction;
};
} & Partial<React.ComponentProps<typeof BaseScheduledEmail>>
) => {
const { team, user } = props;
return (
<V2BaseEmailHtml subject={user.t("action_required_out_of_credits", { teamName: team.name })}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<> {user.t("hi_user_name", { name: user.name })},</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "20px" }}>
<>{user.t("credit_limit_reached_message", { teamName: team.name })}</>
</p>
<div style={{ textAlign: "center", marginTop: "24px" }}>
<CallToAction
label={user.t("buy_credits")}
href={`${WEBAPP_URL}/settings/teams/${team.id}/billing`}
endIconName="linkIcon"
/>
</div>{" "}
</V2BaseEmailHtml>
);
};
@@ -0,0 +1,49 @@
import type { TFunction } from "next-i18next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { CallToAction, V2BaseEmailHtml } from "../components";
import type { BaseScheduledEmail } from "./BaseScheduledEmail";
export const CreditBalanceLowWarningEmail = (
props: {
team: {
id: number;
name: string;
};
balance: number;
user: {
name: string;
email: string;
t: TFunction;
};
} & Partial<React.ComponentProps<typeof BaseScheduledEmail>>
) => {
const { team, balance, user } = props;
return (
<V2BaseEmailHtml subject={user.t("team_credits_low_warning", { teamName: team.name })}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<> {user.t("hi_user_name", { name: user.name })},</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "20px" }}>
<>{user.t("low_credits_warning_message", { teamName: team.name })}</>
</p>
<p
style={{
fontWeight: "500",
lineHeight: "24px",
color: "#000",
}}>
{user.t("current_credit_balance", { balance })}
</p>
<div style={{ textAlign: "center", marginTop: "24px" }}>
<CallToAction
label={user.t("buy_credits")}
href={`${WEBAPP_URL}/settings/teams/${team.id}/billing`}
endIconName="linkIcon"
/>
</div>
</V2BaseEmailHtml>
);
};
+2
View File
@@ -24,6 +24,8 @@ export { OrganizerRescheduledEmail } from "./OrganizerRescheduledEmail";
export { OrganizerScheduledEmail } from "./OrganizerScheduledEmail";
export { TeamInviteEmail } from "./TeamInviteEmail";
export { BrokenIntegrationEmail } from "./BrokenIntegrationEmail";
export { CreditBalanceLowWarningEmail } from "./CreditBalanceLowWarningEmail";
export { CreditBalanceLimitReachedEmail } from "./CreditBalanceLimitReachedEmail";
export { OrganizerAttendeeCancelledSeatEmail } from "./OrganizerAttendeeCancelledSeatEmail";
export { NoShowFeeChargedEmail } from "./NoShowFeeChargedEmail";
export { VerifyAccountEmail } from "./VerifyAccountEmail";
@@ -0,0 +1,41 @@
import type { TFunction } from "i18next";
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { renderEmail } from "..";
import BaseEmail from "./_base-email";
export default class CreditBalanceLimitReachedEmail extends BaseEmail {
user: {
name: string;
email: string;
t: TFunction;
};
team: {
id: number;
name: string;
};
constructor(user: { name: string; email: string; t: TFunction }, team: { id: number; name: string }) {
super();
this.user = user;
this.team = team;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: this.user.email,
subject: this.user.t("action_required_out_of_credits", { teamName: this.team.name }),
html: await renderEmail("CreditBalanceLimitReachedEmail", {
team: this.team,
user: this.user,
}),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return "Your team ran out of credits. Please buy more credits.";
}
}
@@ -0,0 +1,48 @@
import type { TFunction } from "i18next";
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import { renderEmail } from "..";
import BaseEmail from "./_base-email";
export default class CreditBalanceLowWarningEmail extends BaseEmail {
user: {
name: string;
email: string;
t: TFunction;
};
team: {
id: number;
name: string;
};
balance: number;
constructor(
user: { name: string; email: string; t: TFunction },
balance: number,
team: { id: number; name: string }
) {
super();
this.user = user;
this.team = team;
this.balance = balance;
}
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
return {
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: this.user.email,
subject: this.user.t("team_credits_low_warning", { teamName: this.team.name }),
html: await renderEmail("CreditBalanceLowWarningEmail", {
balance: this.balance,
team: this.team,
user: this.user,
}),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return "Your team is running low on credits. Please buy more credits.";
}
}
@@ -20,12 +20,21 @@ import {
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { describe, beforeEach } from "vitest";
import { describe, beforeEach, vi } from "vitest";
import { resetTestSMS } from "@calcom/lib/testSMS";
import { SMSLockState, SchedulingType } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
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: false,
};
});
// Local test runs sometime gets too slow
const timeout = process.env.CI ? 5000 : 20000;
@@ -0,0 +1,58 @@
import stripe from "@calcom/features/ee/payments/server/stripe";
import prisma from "@calcom/prisma";
import type { SWHMap } from "./__handler";
import { HttpCode } from "./__handler";
const handler = async (data: SWHMap["checkout.session.completed"]["data"]) => {
const session = data.object;
if (!session.amount_total) {
throw new HttpCode(400, "Missing required payment details");
}
const teamId = session.metadata?.teamId ? Number(session.metadata.teamId) : null;
if (!teamId) {
throw new HttpCode(400, "Team id missing but required");
}
const lineItems = await stripe.checkout.sessions.listLineItems(session.id);
const priceId = lineItems.data[0]?.price?.id;
const nrOfCredits = lineItems.data[0]?.quantity ?? 0;
if (!priceId || priceId !== process.env.NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID || !nrOfCredits) {
throw new HttpCode(400, "Invalid price ID");
}
await saveToCreditBalance({ teamId, nrOfCredits });
return { success: true };
};
async function saveToCreditBalance({ teamId, nrOfCredits }: { teamId: number; nrOfCredits: number }) {
const creditBalance = await prisma.creditBalance.findUnique({
where: {
teamId,
},
select: {
id: true,
},
});
if (creditBalance) {
await prisma.creditBalance.update({
where: {
id: creditBalance.id,
},
data: { additionalCredits: { increment: nrOfCredits }, limitReachedAt: null, warningSentAt: null },
});
return;
}
await prisma.creditBalance.create({
data: {
teamId: teamId,
additionalCredits: nrOfCredits,
},
});
}
export default handler;
@@ -8,6 +8,7 @@ const handlers = {
"payment_intent.succeeded": () => import("./_payment_intent.succeeded"),
"customer.subscription.deleted": () => import("./_customer.subscription.deleted"),
"invoice.paid": () => import("./_invoice.paid"),
"checkout.session.completed": () => import("./_checkout.session.completed"),
};
export default defaultHandler({
@@ -0,0 +1,306 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import dayjs from "@calcom/dayjs";
import * as EmailManager from "@calcom/emails/email-manager";
import { CreditsRepository } from "@calcom/lib/server/repository/credits";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { CreditType } from "@calcom/prisma/enums";
import { CreditService } from "./credit-service";
vi.mock("@calcom/lib/constants", async () => {
const actual = (await vi.importActual("@calcom/lib/constants")) as typeof import("@calcom/lib/constants");
return {
...actual,
IS_SMS_CREDITS_ENABLED: true,
};
});
vi.mock("@calcom/lib/server/repository/credits");
vi.mock("@calcom/lib/server/repository/membership");
vi.mock("@calcom/lib/server/repository/team");
vi.mock("@calcom/emails/email-manager");
vi.mock("../workflows/lib/reminders/reminderScheduler", () => ({
cancelScheduledMessagesAndScheduleEmails: vi.fn(),
}));
describe("CreditService", () => {
let creditService: CreditService;
beforeEach(() => {
creditService = new CreditService();
vi.clearAllMocks();
});
describe("hasAvailableCredits", () => {
it("should return true if team has not yet reached limit", async () => {
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
id: "1",
additionalCredits: 0,
limitReachedAt: null,
warningSentAt: null,
});
const noLimitReached = await creditService.hasAvailableCredits({ teamId: 1 });
expect(noLimitReached).toBe(true);
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
id: "1",
additionalCredits: 0,
limitReachedAt: dayjs().subtract(1, "month").toDate(),
warningSentAt: null,
});
const limitReachedLastMonth = await creditService.hasAvailableCredits({ teamId: 1 });
expect(limitReachedLastMonth).toBe(true);
});
it("should return false if team limit reached this month", async () => {
vi.setSystemTime(new Date("2024-06-20T11:59:59Z"));
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
id: "1",
additionalCredits: 0,
limitReachedAt: dayjs().subtract(1, "week").toDate(),
warningSentAt: null,
});
const result = await creditService.hasAvailableCredits({ teamId: 1 });
expect(result).toBe(false);
});
});
describe("getTeamWithAvailableCredits", () => {
it("should return team with available credits", async () => {
vi.mocked(MembershipRepository.findAllAcceptedMemberships).mockResolvedValue([
{
id: 1,
teamId: 1,
userId: 1,
role: "MEMBER",
accepted: true,
},
]);
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
id: "1",
additionalCredits: 0,
limitReachedAt: null,
warningSentAt: null,
});
const result = await creditService.getTeamWithAvailableCredits(1);
expect(result).toEqual({
teamId: 1,
availableCredits: 0,
creditType: CreditType.ADDITIONAL,
});
});
it("should return first team if no team has available credits", async () => {
vi.mocked(MembershipRepository.findAllAcceptedMemberships).mockResolvedValue([
{
id: 1,
teamId: 1,
userId: 1,
role: "MEMBER",
accepted: true,
},
]);
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
id: "1",
additionalCredits: 0,
limitReachedAt: new Date(),
warningSentAt: null,
});
const result = await creditService.getTeamWithAvailableCredits(1);
expect(result).toEqual({
teamId: 1,
availableCredits: 0,
creditType: CreditType.ADDITIONAL,
});
});
});
describe("chargeCredits", () => {
it("should create expense log and send low balance warning email", async () => {
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
id: "1",
additionalCredits: 10,
limitReachedAt: null,
warningSentAt: null,
});
vi.mocked(TeamRepository.findTeamWithAdmins).mockResolvedValue({
id: 1,
name: "team-name",
members: [
{
user: {
name: "admin",
email: "admin@example.com",
locale: "en",
},
},
],
});
vi.spyOn(EmailManager, "sendCreditBalanceLowWarningEmails").mockResolvedValue();
vi.spyOn(CreditService.prototype, "getAllCreditsForTeam").mockResolvedValue({
totalMonthlyCredits: 500,
totalRemainingMonthlyCredits: 20,
additionalCredits: 60,
});
await creditService.chargeCredits({
teamId: 1,
credits: 5,
bookingUid: "booking-123",
smsSid: "sms-123",
});
expect(CreditsRepository.createCreditExpenseLog).toHaveBeenCalledWith(
expect.objectContaining({
bookingUid: "booking-123",
creditBalanceId: "1",
creditType: CreditType.MONTHLY,
credits: 5,
smsSid: "sms-123",
})
);
expect(EmailManager.sendCreditBalanceLowWarningEmails).toHaveBeenCalled();
});
it("should create expense log and send limit reached email", async () => {
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
id: "1",
additionalCredits: 0,
limitReachedAt: null,
warningSentAt: null,
});
vi.mocked(TeamRepository.findTeamWithAdmins).mockResolvedValue({
id: 1,
name: "team-name",
members: [
{
user: {
name: "admin",
email: "admin@example.com",
locale: "en",
},
},
],
});
vi.spyOn(EmailManager, "sendCreditBalanceLimitReachedEmails").mockResolvedValue();
vi.spyOn(CreditService.prototype, "getAllCreditsForTeam").mockResolvedValue({
totalMonthlyCredits: 500,
totalRemainingMonthlyCredits: -1,
additionalCredits: 0,
});
await creditService.chargeCredits({
teamId: 1,
credits: 5,
bookingUid: "booking-123",
smsSid: "sms-123",
});
expect(CreditsRepository.createCreditExpenseLog).toHaveBeenCalledWith(
expect.objectContaining({
bookingUid: "booking-123",
creditBalanceId: "1",
creditType: CreditType.ADDITIONAL,
credits: 5,
smsSid: "sms-123",
})
);
expect(EmailManager.sendCreditBalanceLimitReachedEmails).toHaveBeenCalled();
});
});
describe("getTeamToCharge", () => {
it("should return team with remaining credits when teamId is provided", async () => {
vi.spyOn(CreditService.prototype, "getAllCreditsForTeam").mockResolvedValue({
totalMonthlyCredits: 500,
totalRemainingMonthlyCredits: 100,
additionalCredits: 50,
});
const result = await creditService.getTeamToCharge({
credits: 50,
teamId: 1,
});
expect(result).toEqual({
teamId: 1,
remainingCredits: 100,
creditType: CreditType.MONTHLY,
});
});
it("should use additional credits when monthly credits are out", async () => {
vi.spyOn(CreditService.prototype, "getAllCreditsForTeam").mockResolvedValue({
totalMonthlyCredits: 500,
totalRemainingMonthlyCredits: 0,
additionalCredits: 50,
});
const result = await creditService.getTeamToCharge({
credits: 30,
teamId: 1,
});
expect(result).toEqual({
teamId: 1,
remainingCredits: 20,
creditType: CreditType.ADDITIONAL,
});
});
it("should return team with available credits when userId is provided", async () => {
vi.mocked(MembershipRepository.findAllAcceptedMemberships).mockResolvedValue([
{
id: 1,
teamId: 1,
userId: 1,
role: "MEMBER",
accepted: true,
},
]);
vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({
id: "1",
additionalCredits: 0,
limitReachedAt: null,
warningSentAt: null,
});
vi.spyOn(CreditService.prototype, "getAllCreditsForTeam").mockResolvedValue({
totalMonthlyCredits: 500,
totalRemainingMonthlyCredits: 100,
additionalCredits: 50,
});
const result = await creditService.getTeamToCharge({
credits: 50,
userId: 1,
});
expect(result).toEqual({
teamId: 1,
availableCredits: 150,
creditType: CreditType.MONTHLY,
remainingCredits: 100,
});
});
});
});
@@ -0,0 +1,363 @@
import dayjs from "@calcom/dayjs";
import {
sendCreditBalanceLimitReachedEmails,
sendCreditBalanceLowWarningEmails,
} from "@calcom/emails/email-manager";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { InternalTeamBilling } from "@calcom/features/ee/billing/teams/internal-team-billing";
import { cancelScheduledMessagesAndScheduleEmails } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { CreditsRepository } from "@calcom/lib/server/repository/credits";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { CreditType } from "@calcom/prisma/enums";
const log = logger.getSubLogger({ prefix: ["[CreditService]"] });
export class CreditService {
async chargeCredits({
userId,
teamId,
credits,
bookingUid,
smsSid,
}: {
userId?: number;
teamId?: number;
credits: number | null;
bookingUid?: string;
smsSid?: string;
}) {
let teamToCharge = credits === 0 && teamId ? teamId : null;
let creditType: CreditType = CreditType.ADDITIONAL;
let remainingCredits;
if (credits !== 0) {
const result = await this.getTeamToCharge({
credits: credits ?? 1, // if we don't have exact credits, we check for at east 1 credit available
userId,
teamId,
});
teamToCharge = result?.teamId ?? null;
creditType = result?.creditType ?? creditType;
remainingCredits = result?.remainingCredits;
}
if (!teamToCharge) {
log.error("No team or user found to charge. No credit expense log created");
return null;
}
await this.createExpenseLog({
bookingUid,
smsSid,
teamId: teamToCharge,
credits,
creditType,
});
if (credits) {
await this.handleLowCreditBalance({
teamId: teamToCharge,
remainingCredits,
});
}
return teamToCharge;
}
async hasAvailableCredits({ userId, teamId }: { userId?: number | null; teamId?: number | null }) {
if (!IS_SMS_CREDITS_ENABLED) return true;
if (teamId) {
const creditBalance = await CreditsRepository.findCreditBalance({ teamId });
const limitReached =
creditBalance?.limitReachedAt &&
dayjs(creditBalance.limitReachedAt).isAfter(dayjs().startOf("month"));
if (!limitReached) return true;
// check if team is still out of credits
const teamCredits = await this.getAllCreditsForTeam(teamId);
const availableCredits = teamCredits.totalRemainingMonthlyCredits + teamCredits.additionalCredits;
if (availableCredits > 0) {
await CreditsRepository.updateCreditBalance({
teamId,
data: {
limitReachedAt: null,
warningSentAt: null,
},
});
return true;
}
}
if (userId) {
const team = await this.getTeamWithAvailableCredits(userId);
return team.availableCredits > 0;
}
return false;
}
async getTeamWithAvailableCredits(userId: number) {
const memberships = await MembershipRepository.findAllAcceptedMemberships(userId);
//check if user is member of team that has available credits
for (const membership of memberships) {
const creditBalance = await CreditsRepository.findCreditBalance({ teamId: membership.teamId });
const allCredits = await this.getAllCreditsForTeam(membership.teamId);
const limitReached =
creditBalance?.limitReachedAt &&
dayjs(creditBalance.limitReachedAt).isAfter(dayjs().startOf("month"));
const availableCredits = allCredits.totalRemainingMonthlyCredits + allCredits.additionalCredits;
if (!limitReached || availableCredits > 0) {
if (limitReached) {
await CreditsRepository.updateCreditBalance({
teamId: membership.teamId,
data: {
limitReachedAt: null,
warningSentAt: null,
},
});
}
return {
teamId: membership.teamId,
availableCredits,
creditType:
allCredits.totalRemainingMonthlyCredits > 0 ? CreditType.MONTHLY : CreditType.ADDITIONAL,
};
}
}
return {
teamId: memberships[0].teamId,
availableCredits: 0,
creditType: CreditType.ADDITIONAL,
};
}
/*
always returns a team, even if all teams are out of credits
*/
async getTeamToCharge({
credits,
userId,
teamId,
}: {
credits: number;
userId?: number | null;
teamId?: number | null;
}) {
if (teamId) {
const teamCredits = await this.getAllCreditsForTeam(teamId);
const remaningMonthlyCredits =
teamCredits.totalRemainingMonthlyCredits > 0 ? teamCredits.totalRemainingMonthlyCredits : 0;
return {
teamId,
remainingCredits: remaningMonthlyCredits + teamCredits.additionalCredits - credits,
creditType: remaningMonthlyCredits > 0 ? CreditType.MONTHLY : CreditType.ADDITIONAL,
};
}
if (userId) {
const team = await this.getTeamWithAvailableCredits(userId);
return { ...team, remainingCredits: team.availableCredits - credits };
}
return null;
}
private async createExpenseLog(props: {
bookingUid?: string;
smsSid?: string;
teamId: number;
credits: number | null;
creditType: CreditType;
}) {
const { credits, creditType, bookingUid, smsSid, teamId } = props;
let creditBalance: { id: string; additionalCredits: number } | null =
await CreditsRepository.findCreditBalance({ teamId });
if (!creditBalance) {
creditBalance = await CreditsRepository.createCreditBalance({
teamId,
});
}
if (credits && creditType === CreditType.ADDITIONAL) {
const decrementValue =
credits <= creditBalance.additionalCredits ? credits : creditBalance.additionalCredits;
await CreditsRepository.updateCreditBalance({
id: creditBalance.id,
data: {
additionalCredits: {
decrement: decrementValue,
},
},
});
}
if (creditBalance) {
// also track logs with undefined credits (will be set on the cron job)
await CreditsRepository.createCreditExpenseLog({
creditBalanceId: creditBalance.id,
credits,
creditType,
date: new Date(),
bookingUid,
smsSid,
});
}
}
/*
Called when we know the exact amount of credits to be charged:
- Sets `limitReachedAt` and `warningSentAt`
- Sends warning email if balance is low
- Sends limit reached email
- cancels all already scheduled SMS (from the next two hours)
*/
async handleLowCreditBalance({
teamId,
remainingCredits = 0,
}: {
teamId: number;
remainingCredits?: number;
}) {
const { totalMonthlyCredits } = await this.getAllCreditsForTeam(teamId);
const warningLimit = totalMonthlyCredits * 0.2;
if (remainingCredits < warningLimit) {
const creditBalance = await CreditsRepository.findCreditBalance({ teamId });
if (
creditBalance?.limitReachedAt &&
dayjs(creditBalance?.limitReachedAt).isAfter(dayjs().startOf("month"))
) {
return; // team has already reached limit this month
}
const team = await TeamRepository.findTeamWithAdmins(teamId);
if (!team) {
log.error("Team not found to send warning email");
return;
}
if (remainingCredits <= 0) {
await sendCreditBalanceLimitReachedEmails({
team: {
id: teamId,
name: team.name,
adminAndOwners: await Promise.all(
team.members.map(async (member) => ({
name: member.user.name ?? "",
email: member.user.email,
t: await getTranslation(member.user.locale ?? "en", "common"),
}))
),
},
});
await CreditsRepository.updateCreditBalance({
teamId,
data: {
limitReachedAt: new Date(),
warningSentAt: null,
},
});
await cancelScheduledMessagesAndScheduleEmails(teamId);
return;
}
if (
creditBalance?.warningSentAt &&
dayjs(creditBalance?.warningSentAt).isAfter(dayjs().startOf("month"))
) {
return; // team has already sent warning email this month
}
// team balance below 20% of total monthly credits
await sendCreditBalanceLowWarningEmails({
balance: remainingCredits,
team: {
id: teamId,
name: team.name,
adminAndOwners: await Promise.all(
team.members.map(async (member) => ({
name: member.user.name ?? "",
email: member.user.email,
t: await getTranslation(member.user.locale ?? "en", "common"),
}))
),
},
});
await CreditsRepository.updateCreditBalance({
teamId,
data: {
warningSentAt: new Date(),
},
});
return;
}
await CreditsRepository.updateCreditBalance({
teamId,
data: {
warningSentAt: null,
limitReachedAt: null,
},
});
}
async getMonthlyCredits(teamId: number) {
const team = await TeamRepository.findTeamWithMembers(teamId);
if (!team) return 0;
let totalMonthlyCredits = 0;
const teamBillingService = new InternalTeamBilling(team);
const subscriptionStatus = await teamBillingService.getSubscriptionStatus();
if (subscriptionStatus !== "active" && subscriptionStatus !== "past_due") {
return 0;
}
const activeMembers = team.members.filter((member) => member.accepted).length;
const billingService = new StripeBillingService();
const teamMonthlyPrice = await billingService.getPrice(process.env.STRIPE_TEAM_MONTHLY_PRICE_ID || "");
const pricePerSeat = teamMonthlyPrice.unit_amount ?? 0;
totalMonthlyCredits = (activeMembers * pricePerSeat) / 2;
return totalMonthlyCredits;
}
calculateCreditsFromPrice(price: number) {
const twilioPrice = price;
const priceWithMarkUp = twilioPrice * 1.8;
const credits = Math.ceil(priceWithMarkUp * 100);
return credits || null;
}
async getAllCreditsForTeam(teamId: number) {
const creditBalance = await CreditsRepository.findCreditBalanceWithExpenseLogs({ teamId });
const totalMonthlyCredits = await this.getMonthlyCredits(teamId);
const totalMonthlyCreditsUsed =
creditBalance?.expenseLogs.reduce((sum, log) => sum + (log?.credits ?? 0), 0) || 0;
return {
totalMonthlyCredits,
totalRemainingMonthlyCredits: Math.max(totalMonthlyCredits - totalMonthlyCreditsUsed, 0),
additionalCredits: creditBalance?.additionalCredits ?? 0,
};
}
}
@@ -40,6 +40,29 @@ export class StripeBillingService implements BillingService {
};
}
async createOneTimeCheckout(args: {
priceId: string;
quantity: number;
successUrl: string;
cancelUrl: string;
metadata?: Record<string, string>;
}) {
const { priceId, quantity, successUrl, cancelUrl, metadata } = args;
const session = await this.stripe.checkout.sessions.create({
line_items: [{ price: priceId, quantity }],
mode: "payment",
success_url: successUrl,
cancel_url: cancelUrl,
metadata: metadata,
});
return {
checkoutUrl: session.url,
sessionId: session.id,
};
}
async createSubscriptionCheckout(args: Parameters<BillingService["createSubscriptionCheckout"]>[0]) {
const {
customerId,
@@ -1,11 +1,14 @@
/* Schedule any workflow reminder that falls within 7 days for SMS */
/* Schedule any workflow reminder that falls within the next 2 hours for SMS */
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import dayjs from "@calcom/dayjs";
import { bulkShortenLinks } from "@calcom/ee/workflows/lib/reminders/utils";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { isAttendeeAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import { scheduleSmsOrFallbackEmail } from "@calcom/features/ee/workflows/lib/reminders/messageDispatcher";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import { WorkflowActions, WorkflowMethods, WorkflowTemplates } from "@calcom/prisma/enums";
@@ -14,7 +17,6 @@ import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import { getSenderId } from "../lib/alphanumericSenderIdSupport";
import type { PartialWorkflowReminder } from "../lib/getWorkflowReminders";
import { select } from "../lib/getWorkflowReminders";
import * as twilio from "../lib/reminders/providers/twilioProvider";
import type { VariablesType } from "../lib/reminders/templates/customTemplate";
import customTemplate from "../lib/reminders/templates/customTemplate";
import smsReminderTemplate from "../lib/reminders/templates/smsReminderTemplate";
@@ -52,7 +54,7 @@ export async function handler(req: NextRequest) {
method: WorkflowMethods.SMS,
scheduled: false,
scheduledDate: {
lte: dayjs().add(7, "day").toISOString(),
lte: dayjs().add(2, "hour").toISOString(),
},
},
select: {
@@ -178,25 +180,45 @@ export async function handler(req: NextRequest) {
message = await WorkflowOptOutService.addOptOutMessage(message, locale || "en");
}
const scheduledSMS = await twilio.scheduleSMS(
sendTo,
message,
reminder.scheduledDate,
senderID,
userId,
teamId
);
const scheduledNotification = await scheduleSmsOrFallbackEmail({
twilioData: {
phoneNumber: sendTo,
body: message,
scheduledDate: reminder.scheduledDate,
sender: senderID,
bookingUid: reminder.booking.uid,
userId,
teamId,
},
fallbackData:
reminder.workflowStep.action && isAttendeeAction(reminder.workflowStep.action)
? {
email: reminder.booking.attendees[0].email,
t: await getTranslation(locale || "en", "common"),
replyTo: reminder.booking?.user?.email ?? "",
workflowStepId: reminder.workflowStep.id,
}
: undefined,
});
if (scheduledSMS) {
await prisma.workflowReminder.update({
where: {
id: reminder.id,
},
data: {
scheduled: true,
referenceId: scheduledSMS.sid,
},
});
if (scheduledNotification) {
if (scheduledNotification.sid) {
await prisma.workflowReminder.update({
where: {
id: reminder.id,
},
data: {
scheduled: true,
referenceId: scheduledNotification.sid,
},
});
} else if (scheduledNotification.emailReminderId) {
await prisma.workflowReminder.delete({
where: {
id: reminder.id,
},
});
}
} else {
await prisma.workflowReminder.update({
where: {
@@ -1,16 +1,17 @@
/* Schedule any workflow reminder that falls within 7 days for WHATSAPP */
/* Schedule any workflow reminder that falls within the next 2 hours for WHATSAPP */
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import dayjs from "@calcom/dayjs";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import { WorkflowActions, WorkflowMethods } from "@calcom/prisma/enums";
import { getWhatsappTemplateFunction } from "../lib/actionHelperFunctions";
import { getWhatsappTemplateFunction, isAttendeeAction } from "../lib/actionHelperFunctions";
import type { PartialWorkflowReminder } from "../lib/getWorkflowReminders";
import { select } from "../lib/getWorkflowReminders";
import * as twilio from "../lib/reminders/providers/twilioProvider";
import { scheduleSmsOrFallbackEmail } from "../lib/reminders/messageDispatcher";
export async function handler(req: NextRequest) {
const apiKey = req.headers.get("authorization") || req.nextUrl.searchParams.get("apiKey");
@@ -35,7 +36,7 @@ export async function handler(req: NextRequest) {
method: WorkflowMethods.WHATSAPP,
scheduled: false,
scheduledDate: {
lte: dayjs().add(7, "day").toISOString(),
lte: dayjs().add(2, "hour").toISOString(),
},
},
select,
@@ -87,25 +88,51 @@ export async function handler(req: NextRequest) {
);
if (message?.length && message?.length > 0 && sendTo) {
const scheduledSMS = await twilio.scheduleSMS(
sendTo,
message,
reminder.scheduledDate,
"",
userId,
teamId,
true
);
const scheduledNotification = await scheduleSmsOrFallbackEmail({
twilioData: {
phoneNumber: sendTo,
body: message,
scheduledDate: reminder.scheduledDate,
sender: "",
bookingUid: reminder.booking.uid,
userId,
teamId,
isWhatsapp: true,
},
fallbackData:
reminder.workflowStep.action && isAttendeeAction(reminder.workflowStep.action)
? {
email: reminder.booking.attendees[0].email,
t: await getTranslation(reminder.booking.attendees[0].locale || "en", "common"),
replyTo: reminder.booking?.user?.email ?? "",
workflowStepId: reminder.workflowStep.id,
}
: undefined,
});
if (scheduledSMS) {
await prisma.workflowReminder.update({
if (scheduledNotification) {
if (scheduledNotification.sid) {
await prisma.workflowReminder.update({
where: {
id: reminder.id,
},
data: {
scheduled: true,
referenceId: scheduledNotification.sid,
},
});
} else if (scheduledNotification.emailReminderId) {
await prisma.workflowReminder.delete({
where: {
id: reminder.id,
},
});
}
} else {
await prisma.workflowReminder.delete({
where: {
id: reminder.id,
},
data: {
scheduled: true,
referenceId: scheduledSMS.sid,
},
});
}
}
@@ -0,0 +1,110 @@
import type { TFunction } from "i18next";
import { sendOrScheduleWorkflowEmails } from "@calcom/features/ee/workflows/lib/reminders/providers/emailProvider";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import { WorkflowMethods } from "@calcom/prisma/enums";
import * as twilio from "./providers/twilioProvider";
const log = logger.getSubLogger({ prefix: ["[reminderScheduler]"] });
export async function sendSmsOrFallbackEmail(props: {
twilioData: {
phoneNumber: string;
body: string;
sender: string;
bookingUid?: string | null;
userId?: number | null;
teamId?: number | null;
isWhatsapp?: boolean;
};
fallbackData?: {
email: string;
t: TFunction;
replyTo: string;
};
}) {
const { userId, teamId } = props.twilioData;
const { CreditService } = await import("@calcom/features/ee/billing/credit-service");
const creditService = new CreditService();
const hasCredits = await creditService.hasAvailableCredits({ userId, teamId });
if (!hasCredits) {
const { fallbackData, twilioData } = props;
if (fallbackData) {
await sendOrScheduleWorkflowEmails({
to: [fallbackData.email],
subject: fallbackData.t("notification_about_your_booking"),
html: twilioData.body,
replyTo: fallbackData.replyTo,
});
}
log.debug(
`SMS not sent because ${teamId ? `Team id ${teamId} ` : `User id ${userId} `} has no available credits`
);
return;
}
await twilio.sendSMS(props.twilioData);
}
export async function scheduleSmsOrFallbackEmail(props: {
twilioData: {
phoneNumber: string;
body: string;
scheduledDate: Date;
sender: string;
bookingUid?: string | null;
userId?: number | null;
teamId?: number | null;
isWhatsapp?: boolean;
};
fallbackData?: {
email: string;
t: TFunction;
replyTo: string;
workflowStepId?: number;
};
}) {
const { userId, teamId } = props.twilioData;
const { CreditService } = await import("@calcom/features/ee/billing/credit-service");
const creditService = new CreditService();
const hasCredits = await creditService.hasAvailableCredits({ userId, teamId });
if (!hasCredits) {
const { fallbackData, twilioData } = props;
if (fallbackData) {
const reminder = await prisma.workflowReminder.create({
data: {
bookingUid: twilioData.bookingUid,
workflowStepId: fallbackData.workflowStepId,
method: WorkflowMethods.EMAIL,
scheduledDate: twilioData.scheduledDate,
scheduled: true,
},
});
await sendOrScheduleWorkflowEmails({
to: [fallbackData.email],
subject: fallbackData.t("notification_about_your_booking"),
html: twilioData.body,
replyTo: fallbackData.replyTo,
sendAt: twilioData.scheduledDate,
referenceUid: reminder.uuid || undefined,
});
return { emailReminderId: reminder.id, sid: null };
}
log.debug(
`SMS not sent because ${teamId ? `Team id ${teamId} ` : `User id ${userId} `} has no available credits`
);
return null;
}
const scheduledSMS = await twilio.scheduleSMS(props.twilioData);
return scheduledSMS?.sid ? { emailReminderId: null, sid: scheduledSMS.sid } : null;
}
@@ -20,26 +20,35 @@ function createTwilioClient() {
throw new Error("Twilio credentials are missing from the .env file");
}
function getDefaultSender(whatsapp = false) {
function getDefaultSender(isWhatsapp = false) {
let defaultSender = process.env.TWILIO_PHONE_NUMBER;
if (whatsapp) {
if (isWhatsapp) {
defaultSender = `whatsapp:+${process.env.TWILIO_WHATSAPP_PHONE_NUMBER}`;
}
return defaultSender || "";
}
function getSMSNumber(phone: string, whatsapp = false) {
return whatsapp ? `whatsapp:${phone}` : phone;
function getSMSNumber(phone: string, isWhatsapp = false) {
return isWhatsapp ? `whatsapp:${phone}` : phone;
}
export const sendSMS = async (
phoneNumber: string,
body: string,
sender: string,
userId?: number | null,
teamId?: number | null,
whatsapp = false
) => {
export const sendSMS = async ({
phoneNumber,
body,
sender,
bookingUid,
userId,
teamId,
isWhatsapp = false,
}: {
phoneNumber: string;
body: string;
sender: string;
bookingUid?: string | null;
userId?: number | null;
teamId?: number | null;
isWhatsapp?: boolean;
}) => {
log.silly("sendSMS", JSON.stringify({ phoneNumber, body, sender, userId, teamId }));
const isSMSSendingLocked = await isLockedForSMSSending(userId, teamId);
@@ -51,8 +60,8 @@ export const sendSMS = async (
if (testMode) {
setTestSMS({
to: getSMSNumber(phoneNumber, whatsapp),
from: whatsapp ? getDefaultSender(whatsapp) : sender ? sender : getDefaultSender(),
to: getSMSNumber(phoneNumber, isWhatsapp),
from: isWhatsapp ? getDefaultSender(isWhatsapp) : sender || getDefaultSender(),
message: body,
});
console.log(
@@ -74,22 +83,41 @@ export const sendSMS = async (
const response = await twilio.messages.create({
body: body,
messagingServiceSid: process.env.TWILIO_MESSAGING_SID,
to: getSMSNumber(phoneNumber, whatsapp),
from: whatsapp ? getDefaultSender(whatsapp) : sender ? sender : getDefaultSender(),
to: getSMSNumber(phoneNumber, isWhatsapp),
from: isWhatsapp ? getDefaultSender(isWhatsapp) : sender || getDefaultSender(),
statusCallback: getStatusCallbackUrl(userId, teamId, bookingUid),
});
return response;
};
export const scheduleSMS = async (
phoneNumber: string,
body: string,
scheduledDate: Date,
sender: string,
userId?: number | null,
teamId?: number | null,
whatsapp = false
) => {
const getStatusCallbackUrl = (userId?: number | null, teamId?: number | null, bookingUid?: string | null) => {
const query = new URLSearchParams();
if (userId) query.append("userId", String(userId));
if (teamId) query.append("teamId", String(teamId));
if (bookingUid) query.append("bookingUid", bookingUid);
return `${WEBAPP_URL}/api/twilio/webhook${query.toString() ? `?${query.toString()}` : ""}`;
};
export const scheduleSMS = async ({
phoneNumber,
body,
scheduledDate,
sender,
bookingUid,
userId,
teamId,
isWhatsapp = false,
}: {
phoneNumber: string;
body: string;
scheduledDate: Date;
sender: string;
bookingUid?: string | null;
userId?: number | null;
teamId?: number | null;
isWhatsapp?: boolean;
}) => {
const isSMSSendingLocked = await isLockedForSMSSending(userId, teamId);
if (isSMSSendingLocked) {
@@ -99,8 +127,8 @@ export const scheduleSMS = async (
if (testMode) {
setTestSMS({
to: getSMSNumber(phoneNumber, whatsapp),
from: whatsapp ? getDefaultSender(whatsapp) : sender ? sender : getDefaultSender(),
to: getSMSNumber(phoneNumber, isWhatsapp),
from: isWhatsapp ? getDefaultSender(isWhatsapp) : sender || getDefaultSender(),
message: body,
});
console.log(
@@ -117,14 +145,14 @@ export const scheduleSMS = async (
rateLimitingType: "smsMonth",
});
}
const response = await twilio.messages.create({
body: body,
body,
messagingServiceSid: process.env.TWILIO_MESSAGING_SID,
to: getSMSNumber(phoneNumber, whatsapp),
to: getSMSNumber(phoneNumber, isWhatsapp),
scheduleType: "fixed",
sendAt: scheduledDate,
from: whatsapp ? getDefaultSender(whatsapp) : sender ? sender : getDefaultSender(),
from: isWhatsapp ? getDefaultSender(isWhatsapp) : sender || getDefaultSender(),
statusCallback: getStatusCallbackUrl(userId, teamId, bookingUid),
});
return response;
@@ -158,6 +186,12 @@ export const verifyNumber = async (phoneNumber: string, code: string) => {
}
};
export const getMessageBody = async (referenceId: string) => {
const twilio = createTwilioClient();
const message = await twilio.messages(referenceId).fetch();
return message.body;
};
async function isLockedForSMSSending(userId?: number | null, teamId?: number | null) {
if (teamId) {
const team = await prisma.team.findFirst({
@@ -199,6 +233,19 @@ async function isLockedForSMSSending(userId?: number | null, teamId?: number | n
}
}
export async function getCountryCodeForNumber(phoneNumber: string) {
const twilio = createTwilioClient();
const { countryCode } = await twilio.lookups.v2.phoneNumbers(phoneNumber).fetch();
return countryCode;
}
export async function getPriceForSMS(smsSid: string) {
const twilio = createTwilioClient();
const message = await twilio.messages(smsSid).fetch();
if (message.price == null || message.price === "null") return null;
return Math.abs(parseFloat(message.price));
}
export async function validateWebhookRequest({
requestUrl,
params,
@@ -0,0 +1,81 @@
import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { WorkflowMethods } from "@calcom/prisma/enums";
import { sendOrScheduleWorkflowEmails } from "./providers/emailProvider";
import * as twilioProvider from "./providers/twilioProvider";
import { cancelScheduledMessagesAndScheduleEmails } from "./reminderScheduler";
vi.mock("@calcom/features/ee/workflows/lib/reminders/providers/twilioProvider", () => ({
cancelSMS: vi.fn(),
getMessageBody: vi.fn().mockResolvedValue("Test message body"),
}));
vi.mock("@calcom/features/ee/workflows/lib/reminders/providers/emailProvider", () => ({
sendOrScheduleWorkflowEmails: vi.fn(),
}));
describe("reminderScheduler", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("cancelScheduledMessagesAndScheduleEmails", () => {
it("should cancel SMS messages and schedule emails for team", async () => {
prismaMock.membership.findMany.mockResolvedValue([]);
const mockScheduledMessages = [
{
id: 1,
referenceId: "sms-123",
workflowStep: {
action: "SMS_ATTENDEE",
},
scheduledDate: new Date(),
uuid: "uuid-123",
booking: {
attendees: [
{
email: "attendee@example.com",
locale: "en",
},
],
user: {
email: "organizer@example.com",
},
},
},
];
prismaMock.workflowReminder.findMany.mockResolvedValue(mockScheduledMessages);
prismaMock.workflowReminder.updateMany.mockResolvedValue({ count: 1 });
await cancelScheduledMessagesAndScheduleEmails(1);
expect(twilioProvider.cancelSMS).toHaveBeenCalledWith("sms-123");
expect(sendOrScheduleWorkflowEmails).toHaveBeenCalledWith(
expect.objectContaining({
to: ["attendee@example.com"],
replyTo: "organizer@example.com",
referenceUid: "uuid-123",
})
);
expect(prismaMock.workflowReminder.updateMany).toHaveBeenCalledWith({
where: {
id: {
in: [1],
},
},
data: {
method: WorkflowMethods.EMAIL,
referenceId: null,
},
});
});
});
});
@@ -1,19 +1,24 @@
import {
isAttendeeAction,
isSMSAction,
isSMSOrWhatsappAction,
isWhatsappAction,
} from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import { sendOrScheduleWorkflowEmails } from "@calcom/features/ee/workflows/lib/reminders/providers/emailProvider";
import * as twilio from "@calcom/features/ee/workflows/lib/reminders/providers/twilioProvider";
import type { Workflow, WorkflowStep } from "@calcom/features/ee/workflows/lib/types";
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
import { SENDER_NAME } from "@calcom/lib/constants";
import { withReporting } from "@calcom/lib/sentryWrapper";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
import { SchedulingType, WorkflowActions, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import { SchedulingType } from "@calcom/prisma/enums";
import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { scheduleEmailReminder } from "./emailReminderManager";
import type { ScheduleTextReminderAction } from "./smsReminderManager";
import { scheduleSMSReminder } from "./smsReminderManager";
import type { ScheduleTextReminderAction } from "./smsReminderManager";
import { scheduleWhatsappReminder } from "./whatsappReminderManager";
export type ExtendedCalendarEvent = Omit<CalendarEvent, "bookerUrl"> & {
@@ -251,9 +256,123 @@ const _sendCancelledReminders = async (args: SendCancelledRemindersArgs) => {
}
};
const _cancelScheduledMessagesAndScheduleEmails = async (teamId: number) => {
const teamMembers = await prisma.membership.findMany({
where: {
teamId,
accepted: true,
},
});
const { CreditService } = await import("@calcom/features/ee/billing/credit-service");
const creditService = new CreditService();
const membersWithNoCredits = (
await Promise.all(
teamMembers.map(async (member) => {
const hasCredits = await creditService.hasAvailableCredits({ userId: member.userId });
return { member, hasCredits };
})
)
)
.filter(({ hasCredits }) => !hasCredits)
.map(({ member }) => member);
const scheduledMessages = await prisma.workflowReminder.findMany({
where: {
workflowStep: {
workflow: {
OR: [
{
userId: {
in: membersWithNoCredits.map((member) => member.userId),
},
},
{
teamId,
},
],
},
},
scheduled: true,
OR: [{ cancelled: false }, { cancelled: null }],
referenceId: {
not: null,
},
method: {
in: [WorkflowMethods.SMS, WorkflowMethods.WHATSAPP],
},
},
select: {
referenceId: true,
workflowStep: {
select: {
action: true,
},
},
scheduledDate: true,
uuid: true,
id: true,
booking: {
select: {
attendees: {
select: {
email: true,
locale: true,
},
},
user: {
select: {
email: true,
},
},
},
},
},
});
await Promise.allSettled(scheduledMessages.map((msg) => twilio.cancelSMS(msg.referenceId ?? "")));
await Promise.allSettled(
scheduledMessages.map(async (msg) => {
if (msg.workflowStep?.action && isAttendeeAction(msg.workflowStep.action)) {
const messageBody = await twilio.getMessageBody(msg.referenceId ?? "");
const sendTo = msg.booking?.attendees?.[0];
if (sendTo) {
const t = await getTranslation(sendTo.locale ?? "en", "common");
await sendOrScheduleWorkflowEmails({
to: [sendTo.email],
subject: t("notification_about_your_booking"),
html: messageBody,
replyTo: msg.booking?.user?.email ?? "",
sendAt: msg.scheduledDate,
referenceUid: msg.uuid || undefined,
});
}
}
})
);
await prisma.workflowReminder.updateMany({
where: {
id: {
in: scheduledMessages.map((msg) => msg.id),
},
},
data: {
method: WorkflowMethods.EMAIL,
referenceId: null,
},
});
};
// Export functions wrapped with withReporting
export const scheduleWorkflowReminders = withReporting(
_scheduleWorkflowReminders,
"scheduleWorkflowReminders"
);
export const sendCancelledReminders = withReporting(_sendCancelledReminders, "sendCancelledReminders");
export const cancelScheduledMessagesAndScheduleEmails = withReporting(
_cancelScheduledMessagesAndScheduleEmails,
"cancelScheduledMessagesAndScheduleEmails"
);
@@ -3,6 +3,7 @@ import { bulkShortenLinks } from "@calcom/ee/workflows/lib/reminders/utils";
import { SENDER_ID, WEBSITE_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import type { TimeFormat } from "@calcom/lib/timeFormat";
import type { PrismaClient } from "@calcom/prisma";
import prisma from "@calcom/prisma";
@@ -12,10 +13,12 @@ import { WorkflowTriggerEvents } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import type { CalEventResponses, RecurringEvent } from "@calcom/types/Calendar";
import { isAttendeeAction } from "../actionHelperFunctions";
import { getSenderId } from "../alphanumericSenderIdSupport";
import { WorkflowOptOutContactRepository } from "../repository/workflowOptOutContact";
import { WorkflowOptOutService } from "../service/workflowOptOutService";
import type { ScheduleReminderArgs } from "./emailReminderManager";
import { scheduleSmsOrFallbackEmail, sendSmsOrFallbackEmail } from "./messageDispatcher";
import * as twilio from "./providers/twilioProvider";
import type { VariablesType } from "./templates/customTemplate";
import customTemplate from "./templates/customTemplate";
@@ -230,7 +233,23 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => {
triggerEvent === WorkflowTriggerEvents.RESCHEDULE_EVENT
) {
try {
await twilio.sendSMS(reminderPhone, smsMessage, senderID, userId, teamId);
await sendSmsOrFallbackEmail({
twilioData: {
phoneNumber: reminderPhone,
body: smsMessage,
sender: senderID,
bookingUid: evt.uid,
userId,
teamId,
},
fallbackData: isAttendeeAction(action)
? {
email: evt.attendees[0].email,
t: await getTranslation(evt.attendees[0].language.locale, "common"),
replyTo: evt.organizer.email,
}
: undefined,
});
} catch (error) {
log.error(`Error sending SMS with error ${error}`);
}
@@ -239,22 +258,33 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => {
triggerEvent === WorkflowTriggerEvents.AFTER_EVENT) &&
scheduledDate
) {
// Can only schedule at least 60 minutes in advance and at most 7 days in advance
// schedule at least 15 minutes in advance and at most 2 hours in advance
if (
currentDate.isBefore(scheduledDate.subtract(1, "hour")) &&
!scheduledDate.isAfter(currentDate.add(7, "day"))
currentDate.isBefore(scheduledDate.subtract(15, "minute")) &&
!scheduledDate.isAfter(currentDate.add(2, "hour"))
) {
try {
const scheduledSMS = await twilio.scheduleSMS(
reminderPhone,
smsMessage,
scheduledDate.toDate(),
senderID,
userId,
teamId
);
const scheduledNotification = await scheduleSmsOrFallbackEmail({
twilioData: {
phoneNumber: reminderPhone,
body: smsMessage,
scheduledDate: scheduledDate.toDate(),
sender: senderID,
bookingUid: evt.uid,
userId,
teamId,
},
fallbackData: isAttendeeAction(action)
? {
email: evt.attendees[0].email,
t: await getTranslation(evt.attendees[0].language.locale, "common"),
replyTo: evt.organizer.email,
workflowStepId,
}
: undefined,
});
if (scheduledSMS) {
if (scheduledNotification?.sid) {
await prisma.workflowReminder.create({
data: {
bookingUid: uid,
@@ -262,7 +292,7 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => {
method: WorkflowMethods.SMS,
scheduledDate: scheduledDate.toDate(),
scheduled: true,
referenceId: scheduledSMS.sid,
referenceId: scheduledNotification.sid,
seatReferenceId: seatReferenceUid,
},
});
@@ -270,8 +300,8 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => {
} catch (error) {
log.error(`Error scheduling SMS with error ${error}`);
}
} else if (scheduledDate.isAfter(currentDate.add(7, "day"))) {
// Write to DB and send to CRON if scheduled reminder date is past 7 days
} else if (scheduledDate.isAfter(currentDate.add(2, "hour"))) {
// Write to DB and send to CRON if scheduled reminder date is past 2 hours from now
await prisma.workflowReminder.create({
data: {
bookingUid: uid,
@@ -1,5 +1,6 @@
import dayjs from "@calcom/dayjs";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
import {
WorkflowTriggerEvents,
@@ -8,7 +9,8 @@ import {
WorkflowMethods,
} from "@calcom/prisma/enums";
import * as twilio from "./providers/twilioProvider";
import { isAttendeeAction } from "../actionHelperFunctions";
import { scheduleSmsOrFallbackEmail, sendSmsOrFallbackEmail } from "./messageDispatcher";
import type { ScheduleTextReminderArgs, timeUnitLowerCase } from "./smsReminderManager";
import { deleteScheduledSMSReminder } from "./smsReminderManager";
import {
@@ -159,7 +161,24 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs) =
triggerEvent === WorkflowTriggerEvents.RESCHEDULE_EVENT
) {
try {
await twilio.sendSMS(reminderPhone, textMessage, "", userId, teamId, true);
await sendSmsOrFallbackEmail({
twilioData: {
phoneNumber: reminderPhone,
body: textMessage,
sender: "",
bookingUid: evt.uid,
userId,
teamId,
isWhatsapp: true,
},
fallbackData: isAttendeeAction(action)
? {
email: evt.attendees[0].email,
t: await getTranslation(evt.attendees[0].language.locale ?? "en", "common"),
replyTo: evt.organizer.email,
}
: undefined,
});
} catch (error) {
console.log(`Error sending WHATSAPP with error ${error}`);
}
@@ -168,23 +187,34 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs) =
triggerEvent === WorkflowTriggerEvents.AFTER_EVENT) &&
scheduledDate
) {
// Can only schedule at least 60 minutes in advance and at most 7 days in advance
// schedule at least 15 minutes in advance and at most 2 hours in advance
if (
currentDate.isBefore(scheduledDate.subtract(1, "hour")) &&
!scheduledDate.isAfter(currentDate.add(7, "day"))
currentDate.isBefore(scheduledDate.subtract(15, "minute")) &&
!scheduledDate.isAfter(currentDate.add(2, "hour"))
) {
try {
const scheduledWHATSAPP = await twilio.scheduleSMS(
reminderPhone,
textMessage,
scheduledDate.toDate(),
"",
userId,
teamId,
true
);
const scheduledNotification = await scheduleSmsOrFallbackEmail({
twilioData: {
phoneNumber: reminderPhone,
body: textMessage,
scheduledDate: scheduledDate.toDate(),
sender: "",
bookingUid: evt.uid ?? "",
userId,
teamId,
isWhatsapp: true,
},
fallbackData: isAttendeeAction(action)
? {
email: evt.attendees[0].email,
t: await getTranslation(evt.attendees[0].language.locale ?? "en", "common"),
replyTo: evt.organizer.email,
workflowStepId,
}
: undefined,
});
if (scheduledWHATSAPP) {
if (scheduledNotification?.sid) {
await prisma.workflowReminder.create({
data: {
bookingUid: uid,
@@ -192,7 +222,7 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs) =
method: WorkflowMethods.WHATSAPP,
scheduledDate: scheduledDate.toDate(),
scheduled: true,
referenceId: scheduledWHATSAPP.sid,
referenceId: scheduledNotification.sid,
seatReferenceId: seatReferenceUid,
},
});
@@ -200,8 +230,8 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs) =
} catch (error) {
console.log(`Error scheduling WHATSAPP with error ${error}`);
}
} else if (scheduledDate.isAfter(currentDate.add(7, "day"))) {
// Write to DB and send to CRON if scheduled reminder date is past 7 days
} else if (scheduledDate.isAfter(currentDate.add(2, "hour"))) {
// Write to DB and send to CRON if scheduled reminder date is past 2 hours from now
await prisma.workflowReminder.create({
data: {
bookingUid: uid,
@@ -89,15 +89,15 @@ const mockEventTypes = [
],
},
];
//2024-05-20T11:59:59Z
const mockBookings = [
{
uid: "jK7Rf8iYsOpmQUw9hB1vZxP",
eventTypeId: 1,
userId: 101,
status: BookingStatus.ACCEPTED,
startTime: `2024-05-20T14:00:00.000Z`,
endTime: `2024-05-20T14:30:00.000Z`,
startTime: `2024-05-20T09:00:00.000Z`,
endTime: `2024-05-20T09:15:00.000Z`,
attendees: [{ email: "attendee@example.com", locale: "en" }],
},
{
@@ -105,8 +105,8 @@ const mockBookings = [
eventTypeId: 1,
userId: 101,
status: BookingStatus.ACCEPTED,
startTime: `2024-05-20T14:30:00.000Z`,
endTime: `2024-05-20T15:00:00.000Z`,
startTime: `2024-05-20T09:15:00.000Z`,
endTime: `2024-05-20T09:30:00.000Z`,
attendees: [{ email: "attendee@example.com", locale: "en" }],
},
{
@@ -241,6 +241,14 @@ async function createWorkflowRemindersAndTasksForWorkflow(workflowName: string)
return workflow;
}
vi.mock("@calcom/lib/constants", async () => {
const actual = (await vi.importActual("@calcom/lib/constants")) as typeof import("@calcom/lib/constants");
return {
...actual,
IS_SMS_CREDITS_ENABLED: false,
};
});
describe("deleteRemindersOfActiveOnIds", () => {
test("should delete all reminders and tasks from removed event types", async ({}) => {
const organizer = getOrganizer({
@@ -510,8 +518,8 @@ describe("scheduleBookingReminders", () => {
);
const expectedScheduledDates = [
new Date("2024-05-20T13:00:00.000"),
new Date("2024-05-20T13:30:00.000Z"),
new Date("2024-05-20T08:00:00.000"),
new Date("2024-05-20T08:15:00.000Z"),
new Date("2024-06-01T03:30:00.000Z"),
new Date("2024-06-02T03:30:00.000Z"),
];
@@ -596,8 +604,8 @@ describe("scheduleBookingReminders", () => {
);
const expectedScheduledDates = [
new Date("2024-05-20T15:30:00.000"),
new Date("2024-05-20T16:00:00.000Z"),
new Date("2024-05-20T10:15:00.000"),
new Date("2024-05-20T10:30:00.000Z"),
new Date("2024-06-01T06:00:00.000Z"),
new Date("2024-06-02T06:00:00.000Z"),
];
@@ -702,13 +710,13 @@ describe("scheduleBookingReminders", () => {
expectSMSWorkflowToBeTriggered({
sms,
toNumber: "000",
includedString: "2024 May 20 at 7:30pm Asia/Kolkata",
includedString: "2024 May 20 at 2:30pm Asia/Kolkata",
});
expectSMSWorkflowToBeTriggered({
sms,
toNumber: "000",
includedString: "2024 May 20 at 8:00pm Asia/Kolkata",
includedString: "2024 May 20 at 2:45pm Asia/Kolkata",
});
// sms are too far in future
@@ -736,8 +744,8 @@ describe("scheduleBookingReminders", () => {
);
const expectedScheduledDates = [
new Date("2024-05-20T17:30:00.000"),
new Date("2024-05-20T18:00:00.000Z"),
new Date("2024-05-20T12:15:00.000"),
new Date("2024-05-20T12:30:00.000Z"),
new Date("2024-06-01T08:00:00.000Z"),
new Date("2024-06-02T08:00:00.000Z"),
];
+1
View File
@@ -218,4 +218,5 @@ export const IS_DUB_REFERRALS_ENABLED =
export const CAL_VIDEO_MEETING_LINK_FOR_TESTING = process.env.CAL_VIDEO_MEETING_LINK_FOR_TESTING;
export const IS_SMS_CREDITS_ENABLED = !!process.env.NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID;
export const DATABASE_CHUNK_SIZE = parseInt(process.env.DATABASE_CHUNK_SIZE || "25", 10);
+73
View File
@@ -0,0 +1,73 @@
import dayjs from "@calcom/dayjs";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { CreditType } from "@calcom/prisma/enums";
export class CreditsRepository {
static async findCreditBalance({ teamId }: { teamId: number }) {
return await prisma.creditBalance.findUnique({
where: {
teamId,
},
select: {
id: true,
additionalCredits: true,
limitReachedAt: true,
warningSentAt: true,
},
});
}
static async findCreditBalanceWithExpenseLogs({ teamId }: { teamId: number }) {
return await prisma.creditBalance.findUnique({
where: {
teamId,
},
select: {
additionalCredits: true,
expenseLogs: {
where: {
date: {
gte: dayjs().startOf("month").toDate(),
lte: new Date(),
},
creditType: CreditType.MONTHLY,
},
select: {
date: true,
credits: true,
},
},
},
});
}
static async updateCreditBalance({
id,
teamId,
data,
}: {
id?: string;
teamId?: number;
data: Prisma.CreditBalanceUncheckedUpdateInput;
}) {
if (!id && !teamId) return null;
return prisma.creditBalance.update({
where: id ? { id } : { teamId },
data,
});
}
static async createCreditBalance(data: Prisma.CreditBalanceUncheckedCreateInput) {
return prisma.creditBalance.create({
data,
});
}
static async createCreditExpenseLog(data: Prisma.CreditExpenseLogUncheckedCreateInput) {
return prisma.creditExpenseLog.create({
data,
});
}
}
+27 -1
View File
@@ -1,5 +1,5 @@
import { availabilityUserSelect, prisma } from "@calcom/prisma";
import type { MembershipRole } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/client";
import { Prisma } from "@calcom/prisma/client";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
@@ -295,6 +295,32 @@ export class MembershipRepository {
};
}
static async getAdminMembership(userId: number, teamId: number) {
return prisma.membership.findFirst({
where: {
userId,
teamId,
accepted: true,
role: {
in: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
select: {
id: true,
},
});
}
static async findAllAcceptedMemberships(userId: number) {
return prisma.membership.findMany({
where: {
userId,
accepted: true,
},
select: {
teamId: true,
},
});
}
/**
* Get all team IDs that a user is a member of
*/
+38
View File
@@ -301,4 +301,42 @@ export class TeamRepository {
const teamBillingPromises = teamsBilling.map((teamBilling) => teamBilling.updateQuantity());
await Promise.allSettled(teamBillingPromises);
}
static async findTeamWithAdmins(teamId: number) {
return await prisma.team.findUnique({
where: { id: teamId },
select: {
name: true,
members: {
where: { role: { in: ["ADMIN", "OWNER"] }, accepted: true },
select: {
user: {
select: {
name: true,
email: true,
locale: true,
},
},
},
},
},
});
}
static async findTeamWithMembers(teamId: number) {
return await prisma.team.findUnique({
where: { id: teamId },
select: {
members: {
select: {
accepted: true,
},
},
id: true,
metadata: true,
parentId: true,
isOrganization: true,
},
});
}
}
@@ -0,0 +1,45 @@
-- CreateEnum
CREATE TYPE "CreditType" AS ENUM ('MONTHLY', 'ADDITIONAL');
-- CreateTable
CREATE TABLE "CreditBalance" (
"id" TEXT NOT NULL,
"teamId" INTEGER,
"userId" INTEGER,
"additionalCredits" INTEGER NOT NULL DEFAULT 0,
"limitReachedAt" TIMESTAMP(3),
"warningSentAt" TIMESTAMP(3),
CONSTRAINT "CreditBalance_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CreditExpenseLog" (
"id" TEXT NOT NULL,
"creditBalanceId" TEXT NOT NULL,
"bookingUid" TEXT,
"credits" INTEGER,
"creditType" "CreditType" NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"smsSid" TEXT,
CONSTRAINT "CreditExpenseLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "CreditBalance_teamId_key" ON "CreditBalance"("teamId");
-- CreateIndex
CREATE UNIQUE INDEX "CreditBalance_userId_key" ON "CreditBalance"("userId");
-- AddForeignKey
ALTER TABLE "CreditBalance" ADD CONSTRAINT "CreditBalance_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CreditBalance" ADD CONSTRAINT "CreditBalance_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CreditExpenseLog" ADD CONSTRAINT "CreditExpenseLog_creditBalanceId_fkey" FOREIGN KEY ("creditBalanceId") REFERENCES "CreditBalance"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CreditExpenseLog" ADD CONSTRAINT "CreditExpenseLog_bookingUid_fkey" FOREIGN KEY ("bookingUid") REFERENCES "Booking"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
+33
View File
@@ -390,6 +390,7 @@ model User {
creationSource CreationSource?
createdOrganizationOnboardings OrganizationOnboarding[] @relation("CreatedOrganizationOnboardings")
filterSegments FilterSegment[]
creditBalance CreditBalance?
whitelistWorkflows Boolean @default(false)
@@unique([email])
@@ -506,6 +507,7 @@ model Team {
bookingLimits Json?
includeManagedEventsInLimits Boolean @default(false)
internalNotePresets InternalNotePreset[]
creditBalance CreditBalance?
organizationOnboarding OrganizationOnboarding?
// note(Lauris): if a Team has parentId it is a team, if parentId is null it is an organization, but if parentId is null and managedOrganization is set,
@@ -518,6 +520,36 @@ model Team {
@@index([parentId])
}
model CreditBalance {
id String @id @default(uuid())
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
teamId Int? @unique
// user credit balances will be supported in the future
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int? @unique
additionalCredits Int @default(0)
limitReachedAt DateTime?
warningSentAt DateTime?
expenseLogs CreditExpenseLog[]
}
model CreditExpenseLog {
id String @id @default(uuid())
creditBalanceId String
creditBalance CreditBalance @relation(fields: [creditBalanceId], references: [id], onDelete: Cascade)
bookingUid String?
booking Booking? @relation(fields: [bookingUid], references: [uid], onDelete: Cascade)
credits Int?
creditType CreditType
date DateTime
smsSid String?
}
enum CreditType {
MONTHLY
ADDITIONAL
}
model OrganizationSettings {
id Int @id @default(autoincrement())
organization Team @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@ -715,6 +747,7 @@ model Booking {
internalNote BookingInternalNote[]
creationSource CreationSource?
tracking Tracking?
expenseLogs CreditExpenseLog[]
@@index([eventTypeId])
@@index([userId])
+18 -7
View File
@@ -1,6 +1,6 @@
import dayjs from "@calcom/dayjs";
import { getSenderId } from "@calcom/features/ee/workflows/lib/alphanumericSenderIdSupport";
import * as twilio from "@calcom/features/ee/workflows/lib/reminders/providers/twilioProvider";
import { sendSmsOrFallbackEmail } from "@calcom/features/ee/workflows/lib/reminders/messageDispatcher";
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
import { SENDER_ID } from "@calcom/lib/constants";
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
@@ -13,11 +13,13 @@ const handleSendingSMS = async ({
smsMessage,
senderID,
teamId,
bookingUid,
}: {
reminderPhone: string;
smsMessage: string;
senderID: string;
teamId: number;
bookingUid?: string | null;
}) => {
const team = await prisma.team.findUnique({
where: { id: teamId },
@@ -45,10 +47,19 @@ const handleSendingSMS = async ({
rateLimitingType: "sms",
});
const sms = await twilio.sendSMS(reminderPhone, smsMessage, senderID, teamId);
return sms;
const smsOrFallbackEmail = await sendSmsOrFallbackEmail({
twilioData: {
phoneNumber: reminderPhone,
body: smsMessage,
sender: senderID,
teamId,
bookingUid,
},
});
return smsOrFallbackEmail;
} catch (e) {
console.error("twilio.sendSMS failed", e);
console.error("sendSmsOrFallbackEmail failed", e);
throw e; // propagate the error
}
};
@@ -83,7 +94,7 @@ export default abstract class SMSManager {
abstract getMessage(attendee: Person): string;
async sendSMSToAttendee(attendee: Person) {
async sendSMSToAttendee(attendee: Person, bookingUid?: string | null) {
const teamId = this.teamId;
const attendeePhoneNumber = attendee.phoneNumber;
const isPhoneOnlyBooking = attendeePhoneNumber && isSmsCalEmail(attendee.email);
@@ -92,7 +103,7 @@ export default abstract class SMSManager {
const smsMessage = this.getMessage(attendee);
const senderID = getSenderId(attendeePhoneNumber, SENDER_ID);
return handleSendingSMS({ reminderPhone: attendeePhoneNumber, smsMessage, senderID, teamId });
return handleSendingSMS({ reminderPhone: attendeePhoneNumber, smsMessage, senderID, teamId, bookingUid });
}
async sendSMSToAttendees() {
@@ -100,7 +111,7 @@ export default abstract class SMSManager {
const smsToSend: Promise<unknown>[] = [];
for (const attendee of this.calEvent.attendees) {
smsToSend.push(this.sendSMSToAttendee(attendee));
smsToSend.push(this.sendSMSToAttendee(attendee, this.calEvent.uid));
}
await Promise.all(smsToSend);
+1
View File
@@ -38,5 +38,6 @@ export const ENDPOINTS = [
"attributes",
"delegationCredential",
"routingForms",
"credits",
"filterSegments",
] as const;
@@ -18,6 +18,7 @@ import { bookingsRouter } from "./bookings/_router";
import { calVideoRouter } from "./calVideo/_router";
import { calendarsRouter } from "./calendars/_router";
import { credentialsRouter } from "./credentials/_router";
import { creditsRouter } from "./credits/_router";
import { delegationCredentialRouter } from "./delegationCredential/_router";
import { deploymentSetupRouter } from "./deploymentSetup/_router";
import { dsyncRouter } from "./dsync/_router";
@@ -80,6 +81,7 @@ export const viewerRouter = mergeRouters(
attributes: attributesRouter,
highPerf: highPerfRouter,
routingForms: routingFormsRouter,
credits: creditsRouter,
ooo: oooRouter,
travelSchedules: travelSchedulesRouter,
})
@@ -0,0 +1,49 @@
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
import { router } from "../../../trpc";
import { ZBuyCreditsSchema } from "./buyCredits.schema";
import { ZGetAllCreditsSchema } from "./getAllCredits.schema";
type CreditsCache = {
getAllCredits?: typeof import("./getAllCredits.handler").getAllCreditsHandler;
buyCredits?: typeof import("./buyCredits.handler").buyCreditsHandler;
};
const UNSTABLE_HANDLER_CACHE: CreditsCache = {};
export const creditsRouter = router({
getAllCredits: authedProcedure.input(ZGetAllCreditsSchema).query(async ({ input, ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.getAllCredits) {
UNSTABLE_HANDLER_CACHE.getAllCredits = await import("./getAllCredits.handler").then(
(mod) => mod.getAllCreditsHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.getAllCredits) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.getAllCredits({
ctx,
input,
});
}),
buyCredits: authedProcedure.input(ZBuyCreditsSchema).mutation(async ({ input, ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.buyCredits) {
UNSTABLE_HANDLER_CACHE.buyCredits = await import("./buyCredits.handler").then(
(mod) => mod.buyCreditsHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.buyCredits) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.buyCredits({
ctx,
input,
});
}),
});
@@ -0,0 +1,48 @@
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import type { TBuyCreditsSchema } from "./buyCredits.schema";
type BuyCreditsOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TBuyCreditsSchema;
};
export const buyCreditsHandler = async ({ ctx, input }: BuyCreditsOptions) => {
if (!process.env.NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Credits are not enabled",
});
}
const { quantity, teamId } = input;
const adminMembership = await MembershipRepository.getAdminMembership(ctx.user.id, teamId);
if (!adminMembership) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
const redirect_uri = `${WEBAPP_URL}/settings/teams/${teamId}/billing`;
const billingService = new StripeBillingService();
const { checkoutUrl } = await billingService.createOneTimeCheckout({
priceId: process.env.NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID,
quantity,
successUrl: redirect_uri,
cancelUrl: redirect_uri,
metadata: { teamId: teamId.toString() },
});
return { sessionUrl: checkoutUrl };
};
@@ -0,0 +1,8 @@
import { z } from "zod";
export const ZBuyCreditsSchema = z.object({
quantity: z.number(),
teamId: z.number(),
});
export type TBuyCreditsSchema = z.infer<typeof ZBuyCreditsSchema>;
@@ -0,0 +1,31 @@
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import type { TGetAllCreditsSchema } from "./getAllCredits.schema";
type GetAllCreditsOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TGetAllCreditsSchema;
};
export const getAllCreditsHandler = async ({ ctx, input }: GetAllCreditsOptions) => {
const { teamId } = input;
const adminMembership = await MembershipRepository.getAdminMembership(ctx.user.id, teamId);
if (!adminMembership) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
const { CreditService } = await import("@calcom/features/ee/billing/credit-service");
const creditService = new CreditService();
const teamCredits = await creditService.getAllCreditsForTeam(teamId);
return { teamCredits };
};
@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZGetAllCreditsSchema = z.object({
teamId: z.number(),
});
export type TGetAllCreditsSchema = z.infer<typeof ZGetAllCreditsSchema>;
+1
View File
@@ -48,6 +48,7 @@
"SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE",
"STRIPE_PREMIUM_PLAN_PRODUCT_ID",
"STRIPE_TEAM_MONTHLY_PRICE_ID",
"NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID",
"STRIPE_TEAM_PRODUCT_ID",
"STRIPE_ORG_MONTHLY_PRICE_ID",
"STRIPE_ORG_PRODUCT_ID",