From fadfba8830b059307a4f94d74f3a9eba9c618d22 Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Tue, 13 May 2025 09:11:44 +0200 Subject: [PATCH] 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 Co-authored-by: hbjORbj Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: Peer Richelsen --- .env.example | 1 + .github/workflows/cron-checkSmsPrices.yml | 23 ++ apps/web/app/api/cron/checkSmsPrices/route.ts | 112 ++++++ .../modules/settings/billing/billing-view.tsx | 10 +- .../billing/components/BillingCredits.tsx | 126 ++++++ .../components/BillingCreditsSkeleton.tsx | 63 +++ apps/web/pages/api/trpc/credits/[trpc].ts | 4 + apps/web/pages/api/twilio/webhook.ts | 163 ++++++++ apps/web/public/static/locales/en/common.json | 18 + packages/emails/email-manager.ts | 48 +++ .../CreditBalanceLimitReachedEmail.tsx | 40 ++ .../CreditBalanceLowWarningEmail.tsx | 49 +++ packages/emails/src/templates/index.ts | 2 + .../credit-balance-limit-reached-email.ts | 41 ++ .../credit-balance-low-warning-email.ts | 48 +++ .../test/workflow-notifications.test.ts | 11 +- .../webhook/_checkout.session.completed.ts | 58 +++ .../features/ee/billing/api/webhook/index.ts | 1 + .../ee/billing/credit-service.test.ts | 306 +++++++++++++++ .../features/ee/billing/credit-service.ts | 363 ++++++++++++++++++ .../ee/billing/stripe-billling-service.ts | 23 ++ .../ee/workflows/api/scheduleSMSReminders.ts | 64 ++- .../api/scheduleWhatsappReminders.ts | 65 +++- .../lib/reminders/messageDispatcher.ts | 110 ++++++ .../lib/reminders/providers/twilioProvider.ts | 109 ++++-- .../lib/reminders/reminderScheduler.test.ts | 81 ++++ .../lib/reminders/reminderScheduler.ts | 123 +++++- .../lib/reminders/smsReminderManager.ts | 62 ++- .../lib/reminders/whatsappReminderManager.ts | 66 +++- .../ee/workflows/lib/test/workflows.test.ts | 34 +- packages/lib/constants.ts | 1 + packages/lib/server/repository/credits.ts | 73 ++++ packages/lib/server/repository/membership.ts | 28 +- packages/lib/server/repository/team.ts | 38 ++ .../migration.sql | 45 +++ packages/prisma/schema.prisma | 33 ++ packages/sms/sms-manager.ts | 25 +- packages/trpc/react/shared.ts | 1 + .../trpc/server/routers/viewer/_router.tsx | 2 + .../server/routers/viewer/credits/_router.tsx | 49 +++ .../viewer/credits/buyCredits.handler.ts | 48 +++ .../viewer/credits/buyCredits.schema.ts | 8 + .../viewer/credits/getAllCredits.handler.ts | 31 ++ .../viewer/credits/getAllCredits.schema.ts | 7 + turbo.json | 1 + 45 files changed, 2481 insertions(+), 133 deletions(-) create mode 100644 .github/workflows/cron-checkSmsPrices.yml create mode 100644 apps/web/app/api/cron/checkSmsPrices/route.ts create mode 100644 apps/web/modules/settings/billing/components/BillingCredits.tsx create mode 100644 apps/web/modules/settings/billing/components/BillingCreditsSkeleton.tsx create mode 100644 apps/web/pages/api/trpc/credits/[trpc].ts create mode 100644 apps/web/pages/api/twilio/webhook.ts create mode 100644 packages/emails/src/templates/CreditBalanceLimitReachedEmail.tsx create mode 100644 packages/emails/src/templates/CreditBalanceLowWarningEmail.tsx create mode 100644 packages/emails/templates/credit-balance-limit-reached-email.ts create mode 100644 packages/emails/templates/credit-balance-low-warning-email.ts create mode 100644 packages/features/ee/billing/api/webhook/_checkout.session.completed.ts create mode 100644 packages/features/ee/billing/credit-service.test.ts create mode 100644 packages/features/ee/billing/credit-service.ts create mode 100644 packages/features/ee/workflows/lib/reminders/messageDispatcher.ts create mode 100644 packages/features/ee/workflows/lib/reminders/reminderScheduler.test.ts create mode 100644 packages/lib/server/repository/credits.ts create mode 100644 packages/prisma/migrations/20250506113723_add_credit_balance/migration.sql create mode 100644 packages/trpc/server/routers/viewer/credits/_router.tsx create mode 100644 packages/trpc/server/routers/viewer/credits/buyCredits.handler.ts create mode 100644 packages/trpc/server/routers/viewer/credits/buyCredits.schema.ts create mode 100644 packages/trpc/server/routers/viewer/credits/getAllCredits.handler.ts create mode 100644 packages/trpc/server/routers/viewer/credits/getAllCredits.schema.ts diff --git a/.env.example b/.env.example index ddd0dd9e9b..dee4d6d081 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.github/workflows/cron-checkSmsPrices.yml b/.github/workflows/cron-checkSmsPrices.yml new file mode 100644 index 0000000000..3275314adb --- /dev/null +++ b/.github/workflows/cron-checkSmsPrices.yml @@ -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 diff --git a/apps/web/app/api/cron/checkSmsPrices/route.ts b/apps/web/app/api/cron/checkSmsPrices/route.ts new file mode 100644 index 0000000000..efddc35487 --- /dev/null +++ b/apps/web/app/api/cron/checkSmsPrices/route.ts @@ -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); diff --git a/apps/web/modules/settings/billing/billing-view.tsx b/apps/web/modules/settings/billing/billing-view.tsx index 25e0bbf435..d1098b9027 100644 --- a/apps/web/modules/settings/billing/billing-view.tsx +++ b/apps/web/modules/settings/billing/billing-view.tsx @@ -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")} - -
- + + +
+
+ + + + + ); +} diff --git a/apps/web/modules/settings/billing/components/BillingCreditsSkeleton.tsx b/apps/web/modules/settings/billing/components/BillingCreditsSkeleton.tsx new file mode 100644 index 0000000000..6970801df4 --- /dev/null +++ b/apps/web/modules/settings/billing/components/BillingCreditsSkeleton.tsx @@ -0,0 +1,63 @@ +import { SkeletonText, SkeletonContainer, SkeletonButton } from "@calcom/ui/components/skeleton"; + +export function BillingCreditsSkeleton() { + return ( + +
+ {/* Title and Description */} +
+
+ {/* Credits title */} +
+
+ {/* Description */} +
+ +
+
+
+ {/* Monthly credits */} +
+ {/* Monthly credits label */} +
{/* Progress bar */} +
+
+ {/* Total credits */} +
+
+ {/* Remaining credits */} +
+
+
+ {/* Additional credits */} +
+
+ {/* Additional credits label */} +
+
+ {/* Additional credits value */} +
+
+
+
+
+ {/* Buy credits form */} +
+
+
+ {/* Buy credits label */} +
+
+ {/* Input field */} +
+
+ +
+ {/* Buy button */} +
+
+
+
+ + ); +} diff --git a/apps/web/pages/api/trpc/credits/[trpc].ts b/apps/web/pages/api/trpc/credits/[trpc].ts new file mode 100644 index 0000000000..e5feab543b --- /dev/null +++ b/apps/web/pages/api/trpc/credits/[trpc].ts @@ -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); diff --git a/apps/web/pages/api/twilio/webhook.ts b/apps/web/pages/api/twilio/webhook.ts new file mode 100644 index 0000000000..c0149bdebd --- /dev/null +++ b/apps/web/pages/api/twilio/webhook.ts @@ -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).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 }), +}); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 1cc6b5b464..6f90b5204b 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index 8e13af4506..4c2b3f5025 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -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[] = []; + + 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[] = []; + + for (const admin of team.adminAndOwners) { + emailsToSend.push(sendEmail(() => new CreditBalanceLimitReachedEmail(admin, team))); + } + + await Promise.all(emailsToSend); +}; diff --git a/packages/emails/src/templates/CreditBalanceLimitReachedEmail.tsx b/packages/emails/src/templates/CreditBalanceLimitReachedEmail.tsx new file mode 100644 index 0000000000..562636a088 --- /dev/null +++ b/packages/emails/src/templates/CreditBalanceLimitReachedEmail.tsx @@ -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> +) => { + const { team, user } = props; + + return ( + +

+ <> {user.t("hi_user_name", { name: user.name })}, +

+

+ <>{user.t("credit_limit_reached_message", { teamName: team.name })} +

+
+ +
{" "} +
+ ); +}; diff --git a/packages/emails/src/templates/CreditBalanceLowWarningEmail.tsx b/packages/emails/src/templates/CreditBalanceLowWarningEmail.tsx new file mode 100644 index 0000000000..cb70d56e24 --- /dev/null +++ b/packages/emails/src/templates/CreditBalanceLowWarningEmail.tsx @@ -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> +) => { + const { team, balance, user } = props; + + return ( + +

+ <> {user.t("hi_user_name", { name: user.name })}, +

+

+ <>{user.t("low_credits_warning_message", { teamName: team.name })} +

+

+ {user.t("current_credit_balance", { balance })} +

+
+ +
+
+ ); +}; diff --git a/packages/emails/src/templates/index.ts b/packages/emails/src/templates/index.ts index 129c9ac89e..3fb98750eb 100644 --- a/packages/emails/src/templates/index.ts +++ b/packages/emails/src/templates/index.ts @@ -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"; diff --git a/packages/emails/templates/credit-balance-limit-reached-email.ts b/packages/emails/templates/credit-balance-limit-reached-email.ts new file mode 100644 index 0000000000..bf40f4a540 --- /dev/null +++ b/packages/emails/templates/credit-balance-limit-reached-email.ts @@ -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> { + 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."; + } +} diff --git a/packages/emails/templates/credit-balance-low-warning-email.ts b/packages/emails/templates/credit-balance-low-warning-email.ts new file mode 100644 index 0000000000..a6ed6f3c1a --- /dev/null +++ b/packages/emails/templates/credit-balance-low-warning-email.ts @@ -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> { + 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."; + } +} diff --git a/packages/features/bookings/lib/handleNewBooking/test/workflow-notifications.test.ts b/packages/features/bookings/lib/handleNewBooking/test/workflow-notifications.test.ts index cfbbd9fe4d..1fc06e2483 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/workflow-notifications.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/workflow-notifications.test.ts @@ -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("@calcom/lib/constants"); + + return { + ...actual, + IS_SMS_CREDITS_ENABLED: false, + }; +}); + // Local test runs sometime gets too slow const timeout = process.env.CI ? 5000 : 20000; diff --git a/packages/features/ee/billing/api/webhook/_checkout.session.completed.ts b/packages/features/ee/billing/api/webhook/_checkout.session.completed.ts new file mode 100644 index 0000000000..1e4d0642a9 --- /dev/null +++ b/packages/features/ee/billing/api/webhook/_checkout.session.completed.ts @@ -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; diff --git a/packages/features/ee/billing/api/webhook/index.ts b/packages/features/ee/billing/api/webhook/index.ts index c9c8e000ea..608f031430 100644 --- a/packages/features/ee/billing/api/webhook/index.ts +++ b/packages/features/ee/billing/api/webhook/index.ts @@ -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({ diff --git a/packages/features/ee/billing/credit-service.test.ts b/packages/features/ee/billing/credit-service.test.ts new file mode 100644 index 0000000000..2aec7aafed --- /dev/null +++ b/packages/features/ee/billing/credit-service.test.ts @@ -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, + }); + }); + }); +}); diff --git a/packages/features/ee/billing/credit-service.ts b/packages/features/ee/billing/credit-service.ts new file mode 100644 index 0000000000..96793ff2ee --- /dev/null +++ b/packages/features/ee/billing/credit-service.ts @@ -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, + }; + } +} diff --git a/packages/features/ee/billing/stripe-billling-service.ts b/packages/features/ee/billing/stripe-billling-service.ts index 585cf3be08..cd5f9edf96 100644 --- a/packages/features/ee/billing/stripe-billling-service.ts +++ b/packages/features/ee/billing/stripe-billling-service.ts @@ -40,6 +40,29 @@ export class StripeBillingService implements BillingService { }; } + async createOneTimeCheckout(args: { + priceId: string; + quantity: number; + successUrl: string; + cancelUrl: string; + metadata?: Record; + }) { + 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[0]) { const { customerId, diff --git a/packages/features/ee/workflows/api/scheduleSMSReminders.ts b/packages/features/ee/workflows/api/scheduleSMSReminders.ts index 0713bffbf2..21e4de3e00 100644 --- a/packages/features/ee/workflows/api/scheduleSMSReminders.ts +++ b/packages/features/ee/workflows/api/scheduleSMSReminders.ts @@ -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: { diff --git a/packages/features/ee/workflows/api/scheduleWhatsappReminders.ts b/packages/features/ee/workflows/api/scheduleWhatsappReminders.ts index b2bb350705..b6baa24067 100644 --- a/packages/features/ee/workflows/api/scheduleWhatsappReminders.ts +++ b/packages/features/ee/workflows/api/scheduleWhatsappReminders.ts @@ -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, - }, }); } } diff --git a/packages/features/ee/workflows/lib/reminders/messageDispatcher.ts b/packages/features/ee/workflows/lib/reminders/messageDispatcher.ts new file mode 100644 index 0000000000..9181412ac8 --- /dev/null +++ b/packages/features/ee/workflows/lib/reminders/messageDispatcher.ts @@ -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; +} diff --git a/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts b/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts index 08f0c5a4f5..671d81d5a7 100644 --- a/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts +++ b/packages/features/ee/workflows/lib/reminders/providers/twilioProvider.ts @@ -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, diff --git a/packages/features/ee/workflows/lib/reminders/reminderScheduler.test.ts b/packages/features/ee/workflows/lib/reminders/reminderScheduler.test.ts new file mode 100644 index 0000000000..d676a49f42 --- /dev/null +++ b/packages/features/ee/workflows/lib/reminders/reminderScheduler.test.ts @@ -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, + }, + }); + }); + }); +}); diff --git a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts index 793a6412b2..2336eb1ac3 100644 --- a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts +++ b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts @@ -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 & { @@ -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" +); diff --git a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts index 2bf1b18153..dba55cf860 100644 --- a/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/smsReminderManager.ts @@ -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, diff --git a/packages/features/ee/workflows/lib/reminders/whatsappReminderManager.ts b/packages/features/ee/workflows/lib/reminders/whatsappReminderManager.ts index a76f4ebf0c..a9da906b3c 100644 --- a/packages/features/ee/workflows/lib/reminders/whatsappReminderManager.ts +++ b/packages/features/ee/workflows/lib/reminders/whatsappReminderManager.ts @@ -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, diff --git a/packages/features/ee/workflows/lib/test/workflows.test.ts b/packages/features/ee/workflows/lib/test/workflows.test.ts index 7b34740376..4f1446b8f6 100644 --- a/packages/features/ee/workflows/lib/test/workflows.test.ts +++ b/packages/features/ee/workflows/lib/test/workflows.test.ts @@ -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"), ]; diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 99b9293d20..eead47748c 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -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); diff --git a/packages/lib/server/repository/credits.ts b/packages/lib/server/repository/credits.ts new file mode 100644 index 0000000000..dbd16b8175 --- /dev/null +++ b/packages/lib/server/repository/credits.ts @@ -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, + }); + } +} diff --git a/packages/lib/server/repository/membership.ts b/packages/lib/server/repository/membership.ts index b0580545e7..15bebe2140 100644 --- a/packages/lib/server/repository/membership.ts +++ b/packages/lib/server/repository/membership.ts @@ -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 */ diff --git a/packages/lib/server/repository/team.ts b/packages/lib/server/repository/team.ts index 653a78f632..0c35fded48 100644 --- a/packages/lib/server/repository/team.ts +++ b/packages/lib/server/repository/team.ts @@ -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, + }, + }); + } } diff --git a/packages/prisma/migrations/20250506113723_add_credit_balance/migration.sql b/packages/prisma/migrations/20250506113723_add_credit_balance/migration.sql new file mode 100644 index 0000000000..b8913e0b2e --- /dev/null +++ b/packages/prisma/migrations/20250506113723_add_credit_balance/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index d21ce43f2d..44bc6e6f61 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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]) diff --git a/packages/sms/sms-manager.ts b/packages/sms/sms-manager.ts index 66d6bec85a..602cd8d737 100644 --- a/packages/sms/sms-manager.ts +++ b/packages/sms/sms-manager.ts @@ -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[] = []; for (const attendee of this.calEvent.attendees) { - smsToSend.push(this.sendSMSToAttendee(attendee)); + smsToSend.push(this.sendSMSToAttendee(attendee, this.calEvent.uid)); } await Promise.all(smsToSend); diff --git a/packages/trpc/react/shared.ts b/packages/trpc/react/shared.ts index a5b974423c..57a19e7931 100644 --- a/packages/trpc/react/shared.ts +++ b/packages/trpc/react/shared.ts @@ -38,5 +38,6 @@ export const ENDPOINTS = [ "attributes", "delegationCredential", "routingForms", + "credits", "filterSegments", ] as const; diff --git a/packages/trpc/server/routers/viewer/_router.tsx b/packages/trpc/server/routers/viewer/_router.tsx index b9a1b4a7ad..af09f0c6f7 100644 --- a/packages/trpc/server/routers/viewer/_router.tsx +++ b/packages/trpc/server/routers/viewer/_router.tsx @@ -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, }) diff --git a/packages/trpc/server/routers/viewer/credits/_router.tsx b/packages/trpc/server/routers/viewer/credits/_router.tsx new file mode 100644 index 0000000000..fe93d462f5 --- /dev/null +++ b/packages/trpc/server/routers/viewer/credits/_router.tsx @@ -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, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/credits/buyCredits.handler.ts b/packages/trpc/server/routers/viewer/credits/buyCredits.handler.ts new file mode 100644 index 0000000000..a6c9fae015 --- /dev/null +++ b/packages/trpc/server/routers/viewer/credits/buyCredits.handler.ts @@ -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; + }; + 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 }; +}; diff --git a/packages/trpc/server/routers/viewer/credits/buyCredits.schema.ts b/packages/trpc/server/routers/viewer/credits/buyCredits.schema.ts new file mode 100644 index 0000000000..bff1bc514e --- /dev/null +++ b/packages/trpc/server/routers/viewer/credits/buyCredits.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZBuyCreditsSchema = z.object({ + quantity: z.number(), + teamId: z.number(), +}); + +export type TBuyCreditsSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/credits/getAllCredits.handler.ts b/packages/trpc/server/routers/viewer/credits/getAllCredits.handler.ts new file mode 100644 index 0000000000..f0cec0c92f --- /dev/null +++ b/packages/trpc/server/routers/viewer/credits/getAllCredits.handler.ts @@ -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; + }; + 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 }; +}; diff --git a/packages/trpc/server/routers/viewer/credits/getAllCredits.schema.ts b/packages/trpc/server/routers/viewer/credits/getAllCredits.schema.ts new file mode 100644 index 0000000000..d4ee6b5456 --- /dev/null +++ b/packages/trpc/server/routers/viewer/credits/getAllCredits.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetAllCreditsSchema = z.object({ + teamId: z.number(), +}); + +export type TGetAllCreditsSchema = z.infer; diff --git a/turbo.json b/turbo.json index f277beda56..28b389cb56 100644 --- a/turbo.json +++ b/turbo.json @@ -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",