diff --git a/apps/web/app/api/cron/checkSmsPrices/route.ts b/apps/web/app/api/cron/checkSmsPrices/route.ts index efddc35487..1f241aae01 100644 --- a/apps/web/app/api/cron/checkSmsPrices/route.ts +++ b/apps/web/app/api/cron/checkSmsPrices/route.ts @@ -59,6 +59,7 @@ async function postHandler(req: NextRequest) { id: true, additionalCredits: true, teamId: true, + userId: true, }, }, creditType: true, @@ -81,18 +82,22 @@ async function postHandler(req: NextRequest) { }); } - if (!updatedLog.creditBalance.teamId) { - logger.error(`teamId missing for expense log ${log.id}`); + if (!updatedLog.creditBalance.teamId && !updatedLog.creditBalance.userId) { + logger.error(`teamId or userId missing for expense log ${log.id}`); return; } - const teamCredits = await creditService.getAllCreditsForTeam(updatedLog.creditBalance.teamId); + const availableCredits = await creditService.getAllCredits({ + teamId: updatedLog.creditBalance.teamId, + userId: updatedLog.creditBalance.userId, + }); - const remainingMonthlyCredits = Math.max(0, teamCredits.totalRemainingMonthlyCredits); + const remainingMonthlyCredits = Math.max(0, availableCredits.totalRemainingMonthlyCredits); await creditService.handleLowCreditBalance({ - teamId: updatedLog.creditBalance.teamId ?? 0, - remainingCredits: remainingMonthlyCredits + teamCredits.additionalCredits, + userId: updatedLog.creditBalance.userId, + teamId: updatedLog.creditBalance.teamId, + remainingCredits: remainingMonthlyCredits + availableCredits.additionalCredits, }); pricesUpdated++; diff --git a/apps/web/modules/settings/billing/components/BillingCredits.tsx b/apps/web/modules/settings/billing/components/BillingCredits.tsx index a2506a781a..81409bf530 100644 --- a/apps/web/modules/settings/billing/components/BillingCredits.tsx +++ b/apps/web/modules/settings/billing/components/BillingCredits.tsx @@ -28,10 +28,7 @@ export default function BillingCredits() { const params = useParamsWithFallback(); const teamId = params.id ? Number(params.id) : undefined; - const { data: creditsData, isLoading } = trpc.viewer.credits.getAllCredits.useQuery( - { teamId: teamId ?? 0 }, - { enabled: !!teamId } - ); + const { data: creditsData, isLoading } = trpc.viewer.credits.getAllCredits.useQuery({ teamId }); const buyCreditsMutation = trpc.viewer.credits.buyCredits.useMutation({ onSuccess: (data) => { @@ -39,17 +36,16 @@ export default function BillingCredits() { router.push(data.sessionUrl); } }, - onError: (err) => { + onError: () => { showToast(t("credit_purchase_failed"), "error"); }, }); - if (!teamId || !IS_SMS_CREDITS_ENABLED) { + if (!IS_SMS_CREDITS_ENABLED) { return null; } - if (isLoading) return ; - + if (isLoading && teamId) return ; if (!creditsData) return null; const onSubmit = (data: { quantity: number }) => { @@ -57,9 +53,8 @@ export default function BillingCredits() { }; const teamCreditsPercentageUsed = - creditsData.teamCredits.totalMonthlyCredits > 0 - ? (creditsData.teamCredits.totalRemainingMonthlyCredits / creditsData.teamCredits.totalMonthlyCredits) * - 100 + creditsData.credits.totalMonthlyCredits > 0 + ? (creditsData.credits.totalRemainingMonthlyCredits / creditsData.credits.totalMonthlyCredits) * 100 : 0; return ( @@ -71,24 +66,35 @@ export default function BillingCredits() {
-
- - -
-
{t("total_credits", { totalCredits: creditsData.teamCredits.totalMonthlyCredits })}
-
- {t("remaining_credits", { - remainingCredits: creditsData.teamCredits.totalRemainingMonthlyCredits, - })} + {creditsData.credits.totalMonthlyCredits > 0 ? ( +
+ + +
+
+ {t("total_credits", { + totalCredits: creditsData.credits.totalMonthlyCredits, + })} +
+
+ {t("remaining_credits", { + remainingCredits: creditsData.credits.totalRemainingMonthlyCredits, + })} +
-
- -
{creditsData.teamCredits.additionalCredits}
{" "} + ) : ( + <> + )} + + +
{creditsData.credits.additionalCredits}

diff --git a/apps/web/pages/api/twilio/webhook.ts b/apps/web/pages/api/twilio/webhook.ts index c0149bdebd..93cf993b77 100644 --- a/apps/web/pages/api/twilio/webhook.ts +++ b/apps/web/pages/api/twilio/webhook.ts @@ -92,15 +92,15 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { 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`); - } + await creditService.chargeCredits({ + teamId: teamIdToCharge, + userId: !teamIdToCharge ? parsedUserId : undefined, + 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; @@ -140,7 +140,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { const credits = price ? creditService.calculateCreditsFromPrice(price) : null; - const chargedTeamId = await creditService.chargeCredits({ + const chargedUserOrTeamId = await creditService.chargeCredits({ credits, teamId: parsedTeamId, userId: parsedUserId, @@ -148,10 +148,14 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { bookingUid: parsedBookingUid, }); - if (chargedTeamId) { + if (chargedUserOrTeamId) { return res.status(200).send( `Expense log with ${credits ? credits : "no"} credits created for - teamId ${chargedTeamId}` + ${ + chargedUserOrTeamId.teamId + ? `teamId ${chargedUserOrTeamId.teamId}` + : `userId ${chargedUserOrTeamId.userId}` + }` ); } // this should never happen - even when out of credits we still charge a team diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 6f90b5204b..41dbdf6a94 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3195,9 +3195,13 @@ "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", + "user_credits_low_warning": "You are running low on credits", "action_required_out_of_credits": "[Action Required] Your team {{teamName}} has run out of credits", + "action_required_user_out_of_credits": "[Action Required] You have 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.", + "low_credits_warning_message_user": "Your Cal.com account 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.", + "credit_limit_reached_message_user": "Your Cal.com account 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", @@ -3211,5 +3215,8 @@ "matching": "Matching", "event_redirect": "Event Redirect", "reset_form": "Reset Form", + "requires_credits": "Requires credits", + "requires_credits_tooltip": "You need enough credits in your account to use this feature", + "available_credits": "Available credits", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index 4c2b3f5025..41cdc07556 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -762,47 +762,75 @@ export const sendBookingRedirectNotification = async (bookingRedirect: IBookingR }; export const sendCreditBalanceLowWarningEmails = async (input: { - team: { - name: string; + team?: { + name: string | null; id: number; adminAndOwners: { - name: string; + id: number; + name: string | null; email: string; t: TFunction; }[]; }; + user?: { + id: number; + name: string | null; + email: string; + t: TFunction; + }; balance: number; }) => { - const { team, balance } = input; - if (!team.adminAndOwners.length) return; - const emailsToSend: Promise[] = []; + const { team, balance, user } = input; + if ((!team || !team.adminAndOwners.length) && !user) return; - for (const admin of team.adminAndOwners) { - emailsToSend.push(sendEmail(() => new CreditBalanceLowWarningEmail(admin, balance, team))); + if (team) { + const emailsToSend: Promise[] = []; + + for (const admin of team.adminAndOwners) { + emailsToSend.push(sendEmail(() => new CreditBalanceLowWarningEmail({ user: admin, balance, team }))); + } + + await Promise.all(emailsToSend); } - await Promise.all(emailsToSend); + if (user) { + await sendEmail(() => new CreditBalanceLowWarningEmail({ user, balance })); + } }; export const sendCreditBalanceLimitReachedEmails = async ({ team, + user, }: { - team: { + team?: { name: string; id: number; adminAndOwners: { - name: string; + id: number; + name: string | null; email: string; t: TFunction; }[]; }; + user?: { + id: number; + name: string | null; + email: string; + t: TFunction; + }; }) => { - if (!team.adminAndOwners.length) return; - const emailsToSend: Promise[] = []; + if ((!team || !team.adminAndOwners.length) && !user) return; - for (const admin of team.adminAndOwners) { - emailsToSend.push(sendEmail(() => new CreditBalanceLimitReachedEmail(admin, team))); + if (team) { + const emailsToSend: Promise[] = []; + + for (const admin of team.adminAndOwners) { + emailsToSend.push(sendEmail(() => new CreditBalanceLimitReachedEmail({ user: admin, team }))); + } + await Promise.all(emailsToSend); } - await Promise.all(emailsToSend); + if (user) { + await sendEmail(() => new CreditBalanceLimitReachedEmail({ user })); + } }; diff --git a/packages/emails/src/templates/CreditBalanceLimitReachedEmail.tsx b/packages/emails/src/templates/CreditBalanceLimitReachedEmail.tsx index 562636a088..5c48a99fad 100644 --- a/packages/emails/src/templates/CreditBalanceLimitReachedEmail.tsx +++ b/packages/emails/src/templates/CreditBalanceLimitReachedEmail.tsx @@ -7,11 +7,12 @@ import type { BaseScheduledEmail } from "./BaseScheduledEmail"; export const CreditBalanceLimitReachedEmail = ( props: { - team: { + team?: { id: number; name: string; }; user: { + id: number; name: string; email: string; t: TFunction; @@ -20,21 +21,41 @@ export const CreditBalanceLimitReachedEmail = ( ) => { const { team, user } = props; + if (team) { + return ( + +

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

+

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

+
+ +
{" "} +
+ ); + } + return ( - +

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

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

-
{" "} +
); }; diff --git a/packages/emails/src/templates/CreditBalanceLowWarningEmail.tsx b/packages/emails/src/templates/CreditBalanceLowWarningEmail.tsx index cb70d56e24..0a6a26abe2 100644 --- a/packages/emails/src/templates/CreditBalanceLowWarningEmail.tsx +++ b/packages/emails/src/templates/CreditBalanceLowWarningEmail.tsx @@ -7,12 +7,13 @@ import type { BaseScheduledEmail } from "./BaseScheduledEmail"; export const CreditBalanceLowWarningEmail = ( props: { - team: { + team?: { id: number; name: string; }; balance: number; user: { + id: number; name: string; email: string; t: TFunction; @@ -21,26 +22,46 @@ export const CreditBalanceLowWarningEmail = ( ) => { const { team, balance, user } = props; + if (team) { + return ( + +

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

+

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

+

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

+
+ +
+
+ ); + } + return ( - +

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

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

-

- {user.t("current_credit_balance", { balance })} + <>{user.t("low_credits_warning_message_user")}

diff --git a/packages/emails/templates/credit-balance-limit-reached-email.ts b/packages/emails/templates/credit-balance-limit-reached-email.ts index bf40f4a540..c437aedbcb 100644 --- a/packages/emails/templates/credit-balance-limit-reached-email.ts +++ b/packages/emails/templates/credit-balance-limit-reached-email.ts @@ -7,26 +7,35 @@ import BaseEmail from "./_base-email"; export default class CreditBalanceLimitReachedEmail extends BaseEmail { user: { + id: number; name: string; email: string; t: TFunction; }; - team: { + team?: { id: number; name: string; }; - constructor(user: { name: string; email: string; t: TFunction }, team: { id: number; name: string }) { + constructor({ + user, + team, + }: { + user: { id: number; name: string | null; email: string; t: TFunction }; + team?: { id: number; name: string | null }; + }) { super(); - this.user = user; - this.team = team; + this.user = { ...user, name: user.name || "" }; + this.team = team ? { ...team, name: team.name || "" } : undefined; } 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 }), + subject: this.team + ? this.user.t("action_required_out_of_credits", { teamName: this.team.name }) + : this.user.t("action_required_user_out_of_credits"), html: await renderEmail("CreditBalanceLimitReachedEmail", { team: this.team, user: this.user, diff --git a/packages/emails/templates/credit-balance-low-warning-email.ts b/packages/emails/templates/credit-balance-low-warning-email.ts index a6ed6f3c1a..bc9c9f3435 100644 --- a/packages/emails/templates/credit-balance-low-warning-email.ts +++ b/packages/emails/templates/credit-balance-low-warning-email.ts @@ -7,24 +7,29 @@ import BaseEmail from "./_base-email"; export default class CreditBalanceLowWarningEmail extends BaseEmail { user: { + id: number; name: string; email: string; t: TFunction; }; - team: { + team?: { id: number; name: string; }; balance: number; - constructor( - user: { name: string; email: string; t: TFunction }, - balance: number, - team: { id: number; name: string } - ) { + constructor({ + user, + balance, + team, + }: { + user: { id: number; name: string | null; email: string; t: TFunction }; + balance: number; + team?: { id: number; name: string | null }; + }) { super(); - this.user = user; - this.team = team; + this.user = { ...user, name: user.name || "" }; + this.team = team ? { ...team, name: team.name || "" } : undefined; this.balance = balance; } @@ -32,7 +37,9 @@ export default class CreditBalanceLowWarningEmail extends BaseEmail { return { from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`, to: this.user.email, - subject: this.user.t("team_credits_low_warning", { teamName: this.team.name }), + subject: this.team + ? this.user.t("team_credits_low_warning", { teamName: this.team.name }) + : this.user.t("user_credits_low_warning"), html: await renderEmail("CreditBalanceLowWarningEmail", { balance: this.balance, team: this.team, diff --git a/packages/features/ee/billing/api/webhook/_checkout.session.completed.ts b/packages/features/ee/billing/api/webhook/_checkout.session.completed.ts index 1e4d0642a9..fd6fcebdb7 100644 --- a/packages/features/ee/billing/api/webhook/_checkout.session.completed.ts +++ b/packages/features/ee/billing/api/webhook/_checkout.session.completed.ts @@ -10,10 +10,11 @@ const handler = async (data: SWHMap["checkout.session.completed"]["data"]) => { throw new HttpCode(400, "Missing required payment details"); } - const teamId = session.metadata?.teamId ? Number(session.metadata.teamId) : null; + const teamId = session.metadata?.teamId ? Number(session.metadata.teamId) : undefined; + const userId = session.metadata?.userId ? Number(session.metadata.userId) : undefined; - if (!teamId) { - throw new HttpCode(400, "Team id missing but required"); + if (!teamId && !userId) { + throw new HttpCode(400, "Team id and user id, but at least one is required"); } const lineItems = await stripe.checkout.sessions.listLineItems(session.id); @@ -24,15 +25,24 @@ const handler = async (data: SWHMap["checkout.session.completed"]["data"]) => { throw new HttpCode(400, "Invalid price ID"); } - await saveToCreditBalance({ teamId, nrOfCredits }); + await saveToCreditBalance({ userId, teamId, nrOfCredits }); return { success: true }; }; -async function saveToCreditBalance({ teamId, nrOfCredits }: { teamId: number; nrOfCredits: number }) { +async function saveToCreditBalance({ + userId, + teamId, + nrOfCredits, +}: { + userId?: number; + teamId?: number; + nrOfCredits: number; +}) { const creditBalance = await prisma.creditBalance.findUnique({ where: { teamId, + userId: !teamId ? userId : undefined, }, select: { id: true, @@ -51,6 +61,7 @@ async function saveToCreditBalance({ teamId, nrOfCredits }: { teamId: number; nr await prisma.creditBalance.create({ data: { teamId: teamId, + userId: !teamId ? userId : undefined, additionalCredits: nrOfCredits, }, }); diff --git a/packages/features/ee/billing/credit-service.test.ts b/packages/features/ee/billing/credit-service.test.ts index 2aec7aafed..723fbf6216 100644 --- a/packages/features/ee/billing/credit-service.test.ts +++ b/packages/features/ee/billing/credit-service.test.ts @@ -1,3 +1,4 @@ +import Stripe from "stripe"; import { describe, it, expect, beforeEach, vi } from "vitest"; import dayjs from "@calcom/dayjs"; @@ -8,6 +9,10 @@ import { TeamRepository } from "@calcom/lib/server/repository/team"; import { CreditType } from "@calcom/prisma/enums"; import { CreditService } from "./credit-service"; +import { StripeBillingService } from "./stripe-billling-service"; +import { InternalTeamBilling } from "./teams/internal-team-billing"; + +vi.mock("stripe"); vi.mock("@calcom/lib/constants", async () => { const actual = (await vi.importActual("@calcom/lib/constants")) as typeof import("@calcom/lib/constants"); @@ -27,279 +32,549 @@ vi.mock("../workflows/lib/reminders/reminderScheduler", () => ({ describe("CreditService", () => { let creditService: CreditService; + let stripeMock: any; beforeEach(() => { + vi.restoreAllMocks(); + + stripeMock = { + prices: { + list: vi.fn().mockResolvedValue({ data: [] }), + retrieve: vi.fn().mockResolvedValue({ id: "price_123", unit_amount: 1000 }), + }, + }; + (Stripe as any).mockImplementation(() => stripeMock); 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, + describe("Team credits", () => { + 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); }); - const noLimitReached = await creditService.hasAvailableCredits({ teamId: 1 }); - expect(noLimitReached).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, "month").toDate(), - warningSentAt: null, - }); + vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({ + id: "1", + additionalCredits: 0, + limitReachedAt: dayjs().subtract(1, "week").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, + const result = await creditService.hasAvailableCredits({ teamId: 1 }); + expect(result).toBe(false); }); }); - 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: [ + describe("getTeamWithAvailableCredits", () => { + it("should return team with available credits", async () => { + vi.mocked(MembershipRepository.findAllAcceptedMemberships).mockResolvedValue([ { - user: { - name: "admin", - email: "admin@example.com", - locale: "en", - }, + id: 1, + teamId: 1, + userId: 1, + role: "MEMBER", + accepted: true, }, - ], - }); + ]); - vi.spyOn(EmailManager, "sendCreditBalanceLowWarningEmails").mockResolvedValue(); + vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({ + id: "1", + additionalCredits: 0, + limitReachedAt: null, + warningSentAt: null, + }); - 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", + const result = await creditService.getTeamWithAvailableCredits(1); + expect(result).toEqual({ + teamId: 1, + availableCredits: 0, creditType: CreditType.ADDITIONAL, - credits: 5, - smsSid: "sms-123", - }) - ); + }); + }); - expect(EmailManager.sendCreditBalanceLimitReachedEmails).toHaveBeenCalled(); + 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(CreditsRepository.findCreditBalanceWithTeamOrUser).mockResolvedValue({ + id: "1", + additionalCredits: 10, + limitReachedAt: null, + warningSentAt: null, + team: { + id: 1, + name: "team-name", + members: [ + { + user: { + id: 1, + name: "admin", + email: "admin@example.com", + locale: "en", + }, + }, + ], + }, + user: null, + }); + + vi.spyOn(CreditService.prototype, "getTeamWithAvailableCredits").mockResolvedValue(null); + + 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.findCreditBalanceWithTeamOrUser).mockResolvedValue({ + id: "1", + additionalCredits: 10, + limitReachedAt: null, + warningSentAt: null, + team: { + id: 1, + name: "team-name", + members: [ + { + user: { + id: 1, + name: "admin", + email: "admin@example.com", + locale: "en", + }, + }, + ], + }, + user: null, + }); + vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({ + id: "1", + additionalCredits: 0, + limitReachedAt: null, + warningSentAt: null, + }); + + 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("getUserOrTeamToCharge", () => { + 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.getUserOrTeamToCharge({ + 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.getUserOrTeamToCharge({ + 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([{ teamId: 1 }]); + + vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({ + id: "1", + additionalCredits: 0, + limitReachedAt: null, + warningSentAt: null, + }); + + vi.spyOn(CreditService.prototype, "getTeamWithAvailableCredits").mockResolvedValue({ + teamId: 1, + availableCredits: 150, + creditType: CreditType.MONTHLY, + }); + + const result = await creditService.getUserOrTeamToCharge({ + credits: 50, + userId: 1, + }); + + expect(result).toEqual({ + teamId: 1, + availableCredits: 150, + creditType: CreditType.MONTHLY, + remainingCredits: 100, + }); + }); + }); + + describe("getMonthlyCredits", () => { + it("should return 0 if subscription is not active", async () => { + vi.mocked(TeamRepository.findTeamWithMembers).mockResolvedValue({ + id: 1, + members: [{ accepted: true }], + } as any); + + const mockTeamBillingService = { + getSubscriptionStatus: vi.fn().mockResolvedValue("trialing"), + }; + vi.spyOn(InternalTeamBilling.prototype, "getSubscriptionStatus").mockImplementation( + mockTeamBillingService.getSubscriptionStatus + ); + + const result = await creditService.getMonthlyCredits(1); + expect(result).toBe(0); + }); + + it("should calculate credits based on active members and price", async () => { + vi.mocked(TeamRepository.findTeamWithMembers).mockResolvedValue({ + id: 1, + members: [{ accepted: true }, { accepted: true }, { accepted: true }], + } as any); + + const mockTeamBillingService = { + getSubscriptionStatus: vi.fn().mockResolvedValue("active"), + }; + vi.spyOn(InternalTeamBilling.prototype, "getSubscriptionStatus").mockImplementation( + mockTeamBillingService.getSubscriptionStatus + ); + + const mockStripeBillingService = { + getPrice: vi.fn().mockResolvedValue({ unit_amount: 1000 }), + }; + vi.spyOn(StripeBillingService.prototype, "getPrice").mockImplementation( + mockStripeBillingService.getPrice + ); + + const result = await creditService.getMonthlyCredits(1); + expect(result).toBe(1500); // (3 members * 1000 price) / 2 + }); + }); + + describe("getAllCreditsForTeam", () => { + it("should calculate total and remaining credits correctly", async () => { + vi.mocked(CreditsRepository.findCreditBalanceWithExpenseLogs).mockResolvedValue({ + additionalCredits: 100, + expenseLogs: [ + { credits: 50, date: new Date() }, + { credits: 30, date: new Date() }, + ], + }); + + vi.spyOn(CreditService.prototype, "getMonthlyCredits").mockResolvedValue(500); + + const result = await creditService.getAllCreditsForTeam(1); + console.log(`tesult ${JSON.stringify(result)}`); + expect(result).toEqual({ + totalMonthlyCredits: 500, + totalRemainingMonthlyCredits: 420, // 500 - (50 + 30) + additionalCredits: 100, + }); + }); + + it("should handle no expense logs", async () => { + vi.mocked(CreditsRepository.findCreditBalanceWithExpenseLogs).mockResolvedValue({ + additionalCredits: 100, + expenseLogs: [], + }); + + vi.spyOn(CreditService.prototype, "getMonthlyCredits").mockResolvedValue(500); + + const result = await creditService.getAllCreditsForTeam(1); + expect(result).toEqual({ + totalMonthlyCredits: 500, + totalRemainingMonthlyCredits: 500, + additionalCredits: 100, + }); + }); }); }); - 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, + describe("User credits", () => { + describe("hasAvailableCredits", () => { + it("should return true if user has not yet reached limit", async () => { + vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({ + id: "1", + additionalCredits: 10, + limitReachedAt: null, + warningSentAt: null, + }); + vi.spyOn(CreditService.prototype, "getTeamWithAvailableCredits").mockResolvedValue(null); + + const hasAvailableCredits = await creditService.hasAvailableCredits({ userId: 1 }); + expect(hasAvailableCredits).toBe(true); }); - const result = await creditService.getTeamToCharge({ - credits: 50, - teamId: 1, - }); + it("should return false if user limit reached this month", async () => { + vi.setSystemTime(new Date("2024-06-20T11:59:59Z")); - expect(result).toEqual({ - teamId: 1, - remainingCredits: 100, - creditType: CreditType.MONTHLY, + vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({ + id: "1", + additionalCredits: 0, + limitReachedAt: dayjs().subtract(1, "week").toDate(), + warningSentAt: null, + }); + + vi.spyOn(CreditService.prototype, "getTeamWithAvailableCredits").mockResolvedValue(null); + + const result = await creditService.hasAvailableCredits({ userId: 1 }); + expect(result).toBe(false); }); }); - it("should use additional credits when monthly credits are out", async () => { - vi.spyOn(CreditService.prototype, "getAllCreditsForTeam").mockResolvedValue({ - totalMonthlyCredits: 500, - totalRemainingMonthlyCredits: 0, - additionalCredits: 50, - }); + describe("chargeCredits", () => { + it("should create expense log and send low balance warning email for user", async () => { + vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({ + id: "1", + additionalCredits: 10, + limitReachedAt: null, + warningSentAt: null, + }); - const result = await creditService.getTeamToCharge({ - credits: 30, - teamId: 1, - }); + vi.mocked(CreditsRepository.findCreditBalanceWithTeamOrUser).mockResolvedValue({ + id: "1", + additionalCredits: 10, + limitReachedAt: null, + warningSentAt: null, + user: { + id: 1, + name: "user-name", + email: "user@example.com", + locale: "en", + }, + team: null, + }); - expect(result).toEqual({ - teamId: 1, - remainingCredits: 20, - creditType: CreditType.ADDITIONAL, - }); - }); + vi.spyOn(CreditService.prototype, "getTeamWithAvailableCredits").mockResolvedValue(null); - it("should return team with available credits when userId is provided", async () => { - vi.mocked(MembershipRepository.findAllAcceptedMemberships).mockResolvedValue([ - { - id: 1, - teamId: 1, + vi.spyOn(EmailManager, "sendCreditBalanceLowWarningEmails").mockResolvedValue(); + + vi.spyOn(CreditService.prototype, "getAllCredits").mockResolvedValue({ + totalMonthlyCredits: 0, + totalRemainingMonthlyCredits: 0, + additionalCredits: 10, + }); + + await creditService.chargeCredits({ userId: 1, - role: "MEMBER", - accepted: true, - }, - ]); + credits: 5, + bookingUid: "booking-123", + smsSid: "sms-123", + }); - vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({ - id: "1", - additionalCredits: 0, - limitReachedAt: null, - warningSentAt: null, + expect(CreditsRepository.createCreditExpenseLog).toHaveBeenCalledWith( + expect.objectContaining({ + bookingUid: "booking-123", + creditBalanceId: "1", + creditType: CreditType.ADDITIONAL, + credits: 5, + smsSid: "sms-123", + }) + ); + + expect(EmailManager.sendCreditBalanceLowWarningEmails).toHaveBeenCalled(); }); - vi.spyOn(CreditService.prototype, "getAllCreditsForTeam").mockResolvedValue({ - totalMonthlyCredits: 500, - totalRemainingMonthlyCredits: 100, - additionalCredits: 50, - }); + it("should create expense log and send limit reached email for user", async () => { + vi.mocked(CreditsRepository.findCreditBalance).mockResolvedValue({ + id: "1", + additionalCredits: 0, + limitReachedAt: null, + warningSentAt: null, + }); - const result = await creditService.getTeamToCharge({ - credits: 50, - userId: 1, - }); + vi.mocked(CreditsRepository.findCreditBalanceWithTeamOrUser).mockResolvedValue({ + id: "1", + additionalCredits: 0, + limitReachedAt: null, + warningSentAt: null, + team: { + id: 1, + name: "team-name", + members: [ + { + user: { + id: 1, + name: "admin", + email: "admin@example.com", + locale: "en", + }, + }, + ], + }, + user: null, + }); - expect(result).toEqual({ - teamId: 1, - availableCredits: 150, - creditType: CreditType.MONTHLY, - remainingCredits: 100, + vi.spyOn(EmailManager, "sendCreditBalanceLimitReachedEmails").mockResolvedValue(); + + vi.spyOn(CreditService.prototype, "getTeamWithAvailableCredits").mockResolvedValue(null); + + vi.spyOn(CreditService.prototype, "getAllCredits").mockResolvedValue({ + totalMonthlyCredits: 0, + totalRemainingMonthlyCredits: 0, + additionalCredits: 2, + }); + + await creditService.chargeCredits({ + userId: 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("getUserOrTeamToCharge", () => { + it("should return user with remaining credits when userId is provided", async () => { + vi.spyOn(CreditService.prototype, "getTeamWithAvailableCredits").mockResolvedValue(null); + + vi.spyOn(CreditService.prototype, "getAllCredits").mockResolvedValue({ + totalMonthlyCredits: 0, + totalRemainingMonthlyCredits: 0, + additionalCredits: 10, + }); + const result = await creditService.getUserOrTeamToCharge({ + credits: 2, + userId: 1, + }); + + expect(result).toEqual({ + userId: 1, + remainingCredits: 8, + creditType: CreditType.ADDITIONAL, + }); }); }); }); diff --git a/packages/features/ee/billing/credit-service.ts b/packages/features/ee/billing/credit-service.ts index 96793ff2ee..b9196a85c4 100644 --- a/packages/features/ee/billing/credit-service.ts +++ b/packages/features/ee/billing/credit-service.ts @@ -30,21 +30,23 @@ export class CreditService { bookingUid?: string; smsSid?: string; }) { - let teamToCharge = credits === 0 && teamId ? teamId : null; + let teamIdToCharge = credits === 0 && teamId ? teamId : undefined; let creditType: CreditType = CreditType.ADDITIONAL; let remainingCredits; - if (credits !== 0) { - const result = await this.getTeamToCharge({ + let userIdToCharge; + if (!teamIdToCharge) { + const result = await this.getUserOrTeamToCharge({ credits: credits ?? 1, // if we don't have exact credits, we check for at east 1 credit available userId, teamId, }); - teamToCharge = result?.teamId ?? null; + teamIdToCharge = result?.teamId; + userIdToCharge = result?.userId; creditType = result?.creditType ?? creditType; remainingCredits = result?.remainingCredits; } - if (!teamToCharge) { + if (!teamIdToCharge && !userIdToCharge) { log.error("No team or user found to charge. No credit expense log created"); return null; } @@ -52,19 +54,21 @@ export class CreditService { await this.createExpenseLog({ bookingUid, smsSid, - teamId: teamToCharge, + teamId: teamIdToCharge, + userId: userIdToCharge, credits, creditType, }); if (credits) { await this.handleLowCreditBalance({ - teamId: teamToCharge, - remainingCredits, + teamId: teamIdToCharge, + userId: userIdToCharge, + remainingCredits: remainingCredits ?? 0, }); } - return teamToCharge; + return { teamId: teamIdToCharge, userId: userIdToCharge }; } async hasAvailableCredits({ userId, teamId }: { userId?: number | null; teamId?: number | null }) { @@ -96,8 +100,13 @@ export class CreditService { } if (userId) { - const team = await this.getTeamWithAvailableCredits(userId); - return team.availableCredits > 0; + const teamWithAvailableCredits = await this.getTeamWithAvailableCredits(userId); + + if (teamWithAvailableCredits && teamWithAvailableCredits?.availableCredits > 0) return true; + + const userCredits = await this.getAllCredits({ userId }); + + return userCredits.additionalCredits > 0; } return false; @@ -106,6 +115,10 @@ export class CreditService { async getTeamWithAvailableCredits(userId: number) { const memberships = await MembershipRepository.findAllAcceptedMemberships(userId); + if (memberships.length === 0) { + return null; + } + //check if user is member of team that has available credits for (const membership of memberships) { const creditBalance = await CreditsRepository.findCreditBalance({ teamId: membership.teamId }); @@ -146,7 +159,7 @@ export class CreditService { /* always returns a team, even if all teams are out of credits */ - async getTeamToCharge({ + async getUserOrTeamToCharge({ credits, userId, teamId, @@ -168,7 +181,17 @@ export class CreditService { if (userId) { const team = await this.getTeamWithAvailableCredits(userId); - return { ...team, remainingCredits: team.availableCredits - credits }; + if (team) { + return { ...team, remainingCredits: team.availableCredits - credits }; + } + + const userCredits = await this.getAllCredits({ userId }); + + return { + userId, + remainingCredits: userCredits.additionalCredits - credits, + creditType: CreditType.ADDITIONAL, + }; } return null; } @@ -176,17 +199,19 @@ export class CreditService { private async createExpenseLog(props: { bookingUid?: string; smsSid?: string; - teamId: number; + teamId?: number; + userId?: number; credits: number | null; creditType: CreditType; }) { - const { credits, creditType, bookingUid, smsSid, teamId } = props; - let creditBalance: { id: string; additionalCredits: number } | null = - await CreditsRepository.findCreditBalance({ teamId }); + const { credits, creditType, bookingUid, smsSid, teamId, userId } = props; + let creditBalance: { id: string; additionalCredits: number } | null | undefined = + await CreditsRepository.findCreditBalance({ teamId, userId }); if (!creditBalance) { creditBalance = await CreditsRepository.createCreditBalance({ teamId, + userId, }); } @@ -225,80 +250,94 @@ export class CreditService { */ async handleLowCreditBalance({ teamId, - remainingCredits = 0, + userId, + remainingCredits, }: { - teamId: number; - remainingCredits?: number; + teamId?: number | null; + userId?: number | null; + remainingCredits: number; }) { - const { totalMonthlyCredits } = await this.getAllCreditsForTeam(teamId); - const warningLimit = totalMonthlyCredits * 0.2; + let warningLimit = 0; + if (teamId) { + const { totalMonthlyCredits } = await this.getAllCreditsForTeam(teamId); + warningLimit = totalMonthlyCredits * 0.2; + } else if (userId) { + const billingService = new StripeBillingService(); + const teamMonthlyPrice = await billingService.getPrice(process.env.STRIPE_TEAM_MONTHLY_PRICE_ID || ""); + const pricePerSeat = teamMonthlyPrice.unit_amount ?? 0; + warningLimit = (pricePerSeat / 2) * 0.2; + } + if (remainingCredits < warningLimit) { - const creditBalance = await CreditsRepository.findCreditBalance({ teamId }); + const creditBalance = await CreditsRepository.findCreditBalanceWithTeamOrUser({ teamId, userId }); if ( creditBalance?.limitReachedAt && - dayjs(creditBalance?.limitReachedAt).isAfter(dayjs().startOf("month")) + (!teamId || dayjs(creditBalance?.limitReachedAt).isAfter(dayjs().startOf("month"))) ) { - return; // team has already reached limit this month + return; // user has limit already reached or team has already reached limit this month } - const team = await TeamRepository.findTeamWithAdmins(teamId); + const teamWithAdmins = creditBalance?.team + ? { + ...creditBalance.team, + adminAndOwners: await Promise.all( + creditBalance.team.members.map(async (member) => ({ + id: member.user.id, + name: member.user.name, + email: member.user.email, + t: await getTranslation(member.user.locale ?? "en", "common"), + })) + ), + } + : undefined; - if (!team) { - log.error("Team not found to send warning email"); + const user = creditBalance?.user + ? { + ...creditBalance.user, + t: await getTranslation(creditBalance.user.locale ?? "en", "common"), + } + : undefined; + + if ((!teamWithAdmins || !teamWithAdmins.adminAndOwners?.length) && !user) { + log.error("Team or user 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"), - })) - ), - }, + team: teamWithAdmins, + user, }); await CreditsRepository.updateCreditBalance({ teamId, + userId, data: { limitReachedAt: new Date(), warningSentAt: null, }, }); - await cancelScheduledMessagesAndScheduleEmails(teamId); + + await cancelScheduledMessagesAndScheduleEmails({ teamId, userId }); return; } if ( creditBalance?.warningSentAt && - dayjs(creditBalance?.warningSentAt).isAfter(dayjs().startOf("month")) + (!teamId || dayjs(creditBalance?.warningSentAt).isAfter(dayjs().startOf("month"))) ) { - return; // team has already sent warning email this month + return; // user has already received a warning or 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"), - })) - ), - }, + team: teamWithAdmins, + user, }); await CreditsRepository.updateCreditBalance({ teamId, + userId, data: { warningSentAt: new Date(), }, @@ -308,6 +347,7 @@ export class CreditService { await CreditsRepository.updateCreditBalance({ teamId, + userId, data: { warningSentAt: null, limitReachedAt: null, @@ -347,6 +387,28 @@ export class CreditService { return credits || null; } + async getAllCredits({ userId, teamId }: { userId?: number | null; teamId?: number | null }) { + if (teamId) { + return this.getAllCreditsForTeam(teamId); + } + + if (userId) { + const creditBalance = await CreditsRepository.findCreditBalance({ userId }); + + return { + totalMonthlyCredits: 0, + totalRemainingMonthlyCredits: 0, + additionalCredits: creditBalance?.additionalCredits ?? 0, + }; + } + + return { + totalMonthlyCredits: 0, + totalRemainingMonthlyCredits: 0, + additionalCredits: 0, + }; + } + async getAllCreditsForTeam(teamId: number) { const creditBalance = await CreditsRepository.findCreditBalanceWithExpenseLogs({ teamId }); diff --git a/packages/features/ee/workflows/components/AddActionDialog.tsx b/packages/features/ee/workflows/components/AddActionDialog.tsx index d037dfd61a..34c5097290 100644 --- a/packages/features/ee/workflows/components/AddActionDialog.tsx +++ b/packages/features/ee/workflows/components/AddActionDialog.tsx @@ -171,8 +171,8 @@ export const AddActionDialog = (props: IAddActionDialog) => { isOptionDisabled={(option: { label: string; value: WorkflowActions; - needsTeamsUpgrade: boolean; - }) => option.needsTeamsUpgrade} + needsCredits: boolean; + }) => option.needsCredits} /> ); }} diff --git a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx index ec3809a957..064cfe7fc6 100644 --- a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx +++ b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx @@ -13,7 +13,13 @@ import { useHasActiveTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { HttpError } from "@calcom/lib/http-error"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; -import { TimeUnit, WorkflowActions, WorkflowTemplates, WorkflowTriggerEvents } from "@calcom/prisma/enums"; +import { + MembershipRole, + TimeUnit, + WorkflowActions, + WorkflowTemplates, + WorkflowTriggerEvents, +} from "@calcom/prisma/enums"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import classNames from "@calcom/ui/classNames"; @@ -45,6 +51,7 @@ import { isWhatsappAction, getTemplateBodyForAction, shouldScheduleEmailReminder, + isSMSOrWhatsappAction, } from "../lib/actionHelperFunctions"; import { DYNAMIC_TEXT_VARIABLES } from "../lib/constants"; import { getWorkflowTemplateOptions, getWorkflowTriggerOptions } from "../lib/getOptions"; @@ -86,6 +93,12 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { { enabled: !!teamId } ); + const { data: userTeams } = trpc.viewer.teams.list.useQuery({}, { enabled: !teamId }); + + const creditsTeamId = userTeams?.find( + (team) => team.accepted && (team.role === MembershipRole.ADMIN || team.role === MembershipRole.OWNER) + )?.id; + const { hasActiveTeamPlan } = useHasActiveTeamPlan(); const { data: _verifiedEmails } = trpc.viewer.workflows.getVerifiedEmails.useQuery({ teamId }); @@ -355,7 +368,8 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { const selectedAction = { label: actionString.charAt(0).toUpperCase() + actionString.slice(1), value: step.action, - needsTeamsUpgrade: false, + needsCredits: isSMSOrWhatsappAction(step.action), + creditsTeamId: teamId ?? creditsTeamId, }; const selectedTemplate = { @@ -494,12 +508,8 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { defaultValue={selectedAction} options={actionOptions?.map((option) => ({ ...option, + creditsTeamId: teamId ?? creditsTeamId, }))} - isOptionDisabled={(option: { - label: string; - value: WorkflowActions; - needsTeamsUpgrade: boolean; - }) => option.needsTeamsUpgrade} /> ); }} diff --git a/packages/features/ee/workflows/lib/getOptions.ts b/packages/features/ee/workflows/lib/getOptions.ts index cda37286a0..01e3644d73 100644 --- a/packages/features/ee/workflows/lib/getOptions.ts +++ b/packages/features/ee/workflows/lib/getOptions.ts @@ -12,14 +12,14 @@ import { ATTENDEE_WORKFLOW_TEMPLATES, } from "./constants"; -export function getWorkflowActionOptions(t: TFunction, isTeamsPlan?: boolean, isOrgsPlan?: boolean) { +export function getWorkflowActionOptions(t: TFunction, isOrgsPlan?: boolean) { return WORKFLOW_ACTIONS.map((action) => { const actionString = t(`${action.toLowerCase()}_action`); return { label: actionString.charAt(0).toUpperCase() + actionString.slice(1), value: action, - needsTeamsUpgrade: isSMSOrWhatsappAction(action) && !isTeamsPlan, + needsCredits: !isOrgsPlan && isSMSOrWhatsappAction(action), }; }); } diff --git a/packages/features/ee/workflows/lib/reminders/reminderScheduler.test.ts b/packages/features/ee/workflows/lib/reminders/reminderScheduler.test.ts index d676a49f42..d8c8179b7e 100644 --- a/packages/features/ee/workflows/lib/reminders/reminderScheduler.test.ts +++ b/packages/features/ee/workflows/lib/reminders/reminderScheduler.test.ts @@ -53,7 +53,7 @@ describe("reminderScheduler", () => { prismaMock.workflowReminder.updateMany.mockResolvedValue({ count: 1 }); - await cancelScheduledMessagesAndScheduleEmails(1); + await cancelScheduledMessagesAndScheduleEmails({ teamId: 10 }); expect(twilioProvider.cancelSMS).toHaveBeenCalledWith("sms-123"); @@ -65,6 +65,55 @@ describe("reminderScheduler", () => { }) ); + const callArgs = prismaMock.workflowReminder.findMany.mock.calls[0][0]; + expect(callArgs.where.workflowStep.workflow.OR).toEqual([{ userId: { in: [] } }, { teamId: 10 }]); + }); + + it("should cancel SMS messages and schedule emails for user", async () => { + prismaMock.membership.findMany.mockResolvedValue([]); + + const mockScheduledMessages = [ + { + id: 1, + referenceId: "sms-456", + workflowStep: { + action: "SMS_ATTENDEE", + }, + scheduledDate: new Date(), + uuid: "uuid-456", + booking: { + attendees: [ + { + email: "user-attendee@example.com", + locale: "en", + }, + ], + user: { + email: "user-organizer@example.com", + }, + }, + }, + ]; + + prismaMock.workflowReminder.findMany.mockResolvedValue(mockScheduledMessages); + + prismaMock.workflowReminder.updateMany.mockResolvedValue({ count: 1 }); + + await cancelScheduledMessagesAndScheduleEmails({ userId: 11 }); + + const callArgs = prismaMock.workflowReminder.findMany.mock.calls[0][0]; + expect(callArgs.where.workflowStep.workflow.OR).toEqual([{ userId: { in: [11] } }]); + + expect(twilioProvider.cancelSMS).toHaveBeenCalledWith("sms-456"); + + expect(sendOrScheduleWorkflowEmails).toHaveBeenCalledWith( + expect.objectContaining({ + to: ["user-attendee@example.com"], + replyTo: "user-organizer@example.com", + referenceUid: "uuid-456", + }) + ); + expect(prismaMock.workflowReminder.updateMany).toHaveBeenCalledWith({ where: { id: { diff --git a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts index 2336eb1ac3..5f739f241d 100644 --- a/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts +++ b/packages/features/ee/workflows/lib/reminders/reminderScheduler.ts @@ -256,27 +256,38 @@ const _sendCancelledReminders = async (args: SendCancelledRemindersArgs) => { } }; -const _cancelScheduledMessagesAndScheduleEmails = async (teamId: number) => { - const teamMembers = await prisma.membership.findMany({ - where: { - teamId, - accepted: true, - }, - }); +const _cancelScheduledMessagesAndScheduleEmails = async ({ + teamId, + userId, +}: { + teamId?: number | null; + userId?: number | null; +}) => { const { CreditService } = await import("@calcom/features/ee/billing/credit-service"); - const creditService = new CreditService(); + let userIdsWithNoCredits: number[] = userId ? [userId] : []; - const membersWithNoCredits = ( - await Promise.all( - teamMembers.map(async (member) => { - const hasCredits = await creditService.hasAvailableCredits({ userId: member.userId }); - return { member, hasCredits }; - }) + if (teamId) { + const teamMembers = await prisma.membership.findMany({ + where: { + teamId, + accepted: true, + }, + }); + + const creditService = new CreditService(); + + userIdsWithNoCredits = ( + await Promise.all( + teamMembers.map(async (member) => { + const hasCredits = await creditService.hasAvailableCredits({ userId: member.userId }); + return { userId: member.userId, hasCredits }; + }) + ) ) - ) - .filter(({ hasCredits }) => !hasCredits) - .map(({ member }) => member); + .filter(({ hasCredits }) => !hasCredits) + .map(({ userId }) => userId); + } const scheduledMessages = await prisma.workflowReminder.findMany({ where: { @@ -285,12 +296,10 @@ const _cancelScheduledMessagesAndScheduleEmails = async (teamId: number) => { OR: [ { userId: { - in: membersWithNoCredits.map((member) => member.userId), + in: userIdsWithNoCredits, }, }, - { - teamId, - }, + ...(teamId ? [{ teamId }] : []), ], }, }, diff --git a/packages/lib/server/repository/credits.ts b/packages/lib/server/repository/credits.ts index dbd16b8175..6329c06c52 100644 --- a/packages/lib/server/repository/credits.ts +++ b/packages/lib/server/repository/credits.ts @@ -4,21 +4,88 @@ 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 findCreditBalance({ teamId, userId }: { teamId?: number; userId?: number }) { + const select = { + id: true, + additionalCredits: true, + limitReachedAt: true, + warningSentAt: true, + }; + + if (teamId) { + return await prisma.creditBalance.findUnique({ + where: { + teamId, + }, + select, + }); + } + + if (userId) { + return await prisma.creditBalance.findUnique({ + where: { userId }, + select, + }); + } } - static async findCreditBalanceWithExpenseLogs({ teamId }: { teamId: number }) { + static async findCreditBalanceWithTeamOrUser({ + teamId, + userId, + }: { + teamId?: number | null; + userId?: number | null; + }) { + const select = { + id: true, + additionalCredits: true, + limitReachedAt: true, + warningSentAt: true, + team: { + select: { + id: true, + name: true, + members: { + select: { + user: { + select: { + id: true, + name: true, + email: true, + locale: true, + }, + }, + }, + }, + }, + }, + user: { + select: { + id: true, + name: true, + email: true, + locale: true, + }, + }, + }; + if (teamId) { + return await prisma.creditBalance.findUnique({ + where: { + teamId, + }, + select, + }); + } + + if (userId) { + return await prisma.creditBalance.findUnique({ + where: { userId }, + select, + }); + } + } + + static async findCreditBalanceWithExpenseLogs({ teamId }: { teamId?: number }) { return await prisma.creditBalance.findUnique({ where: { teamId, @@ -45,18 +112,36 @@ export class CreditsRepository { static async updateCreditBalance({ id, teamId, + userId, data, }: { id?: string; - teamId?: number; + teamId?: number | null; + userId?: number | null; data: Prisma.CreditBalanceUncheckedUpdateInput; }) { - if (!id && !teamId) return null; + if (id) { + return prisma.creditBalance.update({ + where: { id }, + data, + }); + } - return prisma.creditBalance.update({ - where: id ? { id } : { teamId }, - data, - }); + if (teamId) { + return prisma.creditBalance.update({ + where: { teamId }, + data, + }); + } + + if (userId) { + return prisma.creditBalance.update({ + where: { userId }, + data, + }); + } + + return null; } static async createCreditBalance(data: Prisma.CreditBalanceUncheckedCreateInput) { diff --git a/packages/lib/server/repository/membership.ts b/packages/lib/server/repository/membership.ts index 15bebe2140..8b607b153d 100644 --- a/packages/lib/server/repository/membership.ts +++ b/packages/lib/server/repository/membership.ts @@ -295,7 +295,7 @@ export class MembershipRepository { }; } - static async getAdminMembership(userId: number, teamId: number) { + static async getAdminOrOwnerMembership(userId: number, teamId: number) { return prisma.membership.findFirst({ where: { userId, diff --git a/packages/lib/server/repository/team.ts b/packages/lib/server/repository/team.ts index 0c35fded48..75eb87959b 100644 --- a/packages/lib/server/repository/team.ts +++ b/packages/lib/server/repository/team.ts @@ -302,27 +302,6 @@ export class TeamRepository { 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 }, diff --git a/packages/trpc/server/routers/viewer/credits/buyCredits.handler.ts b/packages/trpc/server/routers/viewer/credits/buyCredits.handler.ts index a6c9fae015..3cb0751a72 100644 --- a/packages/trpc/server/routers/viewer/credits/buyCredits.handler.ts +++ b/packages/trpc/server/routers/viewer/credits/buyCredits.handler.ts @@ -24,15 +24,28 @@ export const buyCreditsHandler = async ({ ctx, input }: BuyCreditsOptions) => { const { quantity, teamId } = input; - const adminMembership = await MembershipRepository.getAdminMembership(ctx.user.id, teamId); + if (teamId) { + const adminMembership = await MembershipRepository.getAdminOrOwnerMembership(ctx.user.id, teamId); - if (!adminMembership) { - throw new TRPCError({ - code: "UNAUTHORIZED", - }); + if (!adminMembership) { + throw new TRPCError({ + code: "UNAUTHORIZED", + }); + } + } else { + // if user id is part of a team, user can't buy credits for themselves + const membership = await MembershipRepository.findAllAcceptedMemberships(ctx.user.id); + + if (membership.length > 0) { + throw new TRPCError({ + code: "UNAUTHORIZED", + }); + } } - const redirect_uri = `${WEBAPP_URL}/settings/teams/${teamId}/billing`; + const redirect_uri = teamId + ? `${WEBAPP_URL}/settings/teams/${teamId}/billing` + : `${WEBAPP_URL}/settings/billing`; const billingService = new StripeBillingService(); @@ -41,7 +54,10 @@ export const buyCreditsHandler = async ({ ctx, input }: BuyCreditsOptions) => { quantity, successUrl: redirect_uri, cancelUrl: redirect_uri, - metadata: { teamId: teamId.toString() }, + metadata: { + ...(teamId && { teamId: teamId.toString() }), + userId: ctx.user.id.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 index bff1bc514e..9cda2ed653 100644 --- a/packages/trpc/server/routers/viewer/credits/buyCredits.schema.ts +++ b/packages/trpc/server/routers/viewer/credits/buyCredits.schema.ts @@ -2,7 +2,7 @@ import { z } from "zod"; export const ZBuyCreditsSchema = z.object({ quantity: z.number(), - teamId: z.number(), + teamId: z.number().optional(), }); 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 index f0cec0c92f..a9ff47e764 100644 --- a/packages/trpc/server/routers/viewer/credits/getAllCredits.handler.ts +++ b/packages/trpc/server/routers/viewer/credits/getAllCredits.handler.ts @@ -15,17 +15,26 @@ type GetAllCreditsOptions = { export const getAllCreditsHandler = async ({ ctx, input }: GetAllCreditsOptions) => { const { teamId } = input; - const adminMembership = await MembershipRepository.getAdminMembership(ctx.user.id, teamId); + if (teamId) { + const adminMembership = await MembershipRepository.getAdminOrOwnerMembership(ctx.user.id, teamId); - if (!adminMembership) { - throw new TRPCError({ - code: "UNAUTHORIZED", - }); + if (!adminMembership) { + throw new TRPCError({ + code: "UNAUTHORIZED", + }); + } + } else { + //if user is part of team, don't return any credits if teamId is not given + const membership = await MembershipRepository.findAllAcceptedMemberships(ctx.user.id); + + if (membership.length > 0) { + return null; + } } const { CreditService } = await import("@calcom/features/ee/billing/credit-service"); const creditService = new CreditService(); - const teamCredits = await creditService.getAllCreditsForTeam(teamId); - return { teamCredits }; + const credits = await creditService.getAllCredits({ userId: ctx.user.id, teamId }); + return { credits }; }; diff --git a/packages/trpc/server/routers/viewer/credits/getAllCredits.schema.ts b/packages/trpc/server/routers/viewer/credits/getAllCredits.schema.ts index d4ee6b5456..91e835c883 100644 --- a/packages/trpc/server/routers/viewer/credits/getAllCredits.schema.ts +++ b/packages/trpc/server/routers/viewer/credits/getAllCredits.schema.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const ZGetAllCreditsSchema = z.object({ - teamId: z.number(), + teamId: z.number().optional(), }); export type TGetAllCreditsSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts b/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts index f307c3a581..d384d291a9 100644 --- a/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/activateEventType.handler.ts @@ -46,8 +46,13 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType }, ], }, - include: { - children: true, + select: { + teamId: true, + children: { + select: { + id: true, + }, + }, }, }); @@ -67,8 +72,26 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType }, ], }, - include: { - steps: true, + select: { + steps: { + select: { + id: true, + action: true, + sendTo: true, + numberRequired: true, + template: true, + sender: true, + emailSubject: true, + reminderBody: true, + verifiedAt: true, + }, + }, + trigger: true, + time: true, + timeUnit: true, + isActiveOnAll: true, + teamId: true, + userId: true, team: { select: { isOrganization: true, @@ -206,8 +229,23 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType gte: new Date(), }, }, - include: { - attendees: true, + select: { + metadata: true, + userId: true, + smsReminderNumber: true, + userPrimaryEmail: true, + startTime: true, + endTime: true, + title: true, + uid: true, + attendees: { + select: { + name: true, + email: true, + timeZone: true, + locale: true, + }, + }, eventType: { select: { schedulingType: true, @@ -230,7 +268,15 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType hideOrganizerEmail: true, }, }, - user: true, + user: { + select: { + name: true, + email: true, + timeZone: true, + locale: true, + timeFormat: true, + }, + }, }, }); diff --git a/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.handler.ts b/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.handler.ts index ff2ef8f03c..5317ad041c 100644 --- a/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/getWorkflowActionOptions.handler.ts @@ -1,11 +1,7 @@ import { getWorkflowActionOptions } from "@calcom/features/ee/workflows/lib/getOptions"; -import { IS_SELF_HOSTED } from "@calcom/lib/constants"; -import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; import { getTranslation } from "@calcom/lib/server/i18n"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; -import hasActiveTeamPlanHandler from "../teams/hasActiveTeamPlan.handler"; - type GetWorkflowActionOptionsOptions = { ctx: { user: NonNullable & { @@ -17,20 +13,7 @@ type GetWorkflowActionOptionsOptions = { export const getWorkflowActionOptionsHandler = async ({ ctx }: GetWorkflowActionOptionsOptions) => { const { user } = ctx; - const isCurrentUsernamePremium = - user && hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false; - - let teamsPlan = { isActive: false, isTrial: false }; - if (!isCurrentUsernamePremium) { - teamsPlan = await hasActiveTeamPlanHandler({ ctx }); - } - - const hasOrgsPlan = !!user.profile?.organizationId; - const t = await getTranslation(ctx.user.locale, "common"); - return getWorkflowActionOptions( - t, - IS_SELF_HOSTED || isCurrentUsernamePremium || teamsPlan.isActive, - IS_SELF_HOSTED || hasOrgsPlan - ); + + return getWorkflowActionOptions(t, !!user.profile?.organizationId); }; diff --git a/packages/trpc/server/routers/viewer/workflows/update.handler.ts b/packages/trpc/server/routers/viewer/workflows/update.handler.ts index 8e5e27e305..e3bd034d29 100755 --- a/packages/trpc/server/routers/viewer/workflows/update.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/update.handler.ts @@ -1,7 +1,4 @@ -import { - isEmailAction, - isSMSOrWhatsappAction, -} from "@calcom/features/ee/workflows/lib/actionHelperFunctions"; +import { isEmailAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions"; import tasker from "@calcom/features/tasker"; import { IS_SELF_HOSTED, SCANNING_WORKFLOW_STEPS } from "@calcom/lib/constants"; import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; @@ -337,12 +334,10 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { } else if (isStepEdited(oldStep, { ...newStep, verifiedAt: oldStep.verifiedAt })) { // check if step that require team plan already existed before if (!hasPaidPlan) { - const isChangingToSMSOrWhatsapp = - !isSMSOrWhatsappAction(oldStep.action) && isSMSOrWhatsappAction(newStep.action); const isChangingToCustomTemplate = newStep.template === WorkflowTemplates.CUSTOM && oldStep.template !== WorkflowTemplates.CUSTOM; - if (isChangingToSMSOrWhatsapp || isChangingToCustomTemplate) { + if (isChangingToCustomTemplate) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Not available on free plan" }); } @@ -425,10 +420,9 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { .filter((step) => step.id <= 0) .map(async (newStep) => { if (!hasPaidPlan) { - if (isSMSOrWhatsappAction(newStep.action) || newStep.template === WorkflowTemplates.CUSTOM) { + if (newStep.template === WorkflowTemplates.CUSTOM) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Not available on free plan" }); } - // on free plans always use predefined templates const { emailBody, emailSubject } = await getEmailTemplateText(newStep.template, { locale: ctx.user.locale, diff --git a/packages/ui/components/badge/CreditsBadge.tsx b/packages/ui/components/badge/CreditsBadge.tsx new file mode 100644 index 0000000000..e26ae2bc15 --- /dev/null +++ b/packages/ui/components/badge/CreditsBadge.tsx @@ -0,0 +1,18 @@ +import Link from "next/link"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; + +import { Tooltip } from "../tooltip"; +import { Badge } from "./Badge"; + +export const CreditsBadge = function CreditsBadge({ teamId }: { teamId?: number }) { + const { t } = useLocale(); + + return ( + + + {t("requires_credits")} + + + ); +}; diff --git a/packages/ui/components/badge/index.ts b/packages/ui/components/badge/index.ts index ec420c85cb..b1ee3f44ef 100644 --- a/packages/ui/components/badge/index.ts +++ b/packages/ui/components/badge/index.ts @@ -1,5 +1,6 @@ export { Badge } from "./Badge"; export { UpgradeTeamsBadge } from "./UpgradeTeamsBadge"; +export { CreditsBadge } from "./CreditsBadge"; export { UpgradeOrgsBadge } from "./UpgradeOrgsBadge"; export { InfoBadge } from "./InfoBadge"; export type { BadgeProps } from "./Badge"; diff --git a/packages/ui/components/form/select/components.tsx b/packages/ui/components/form/select/components.tsx index 99060b382f..f775cfe36c 100644 --- a/packages/ui/components/form/select/components.tsx +++ b/packages/ui/components/form/select/components.tsx @@ -3,7 +3,7 @@ import { components as reactSelectComponents } from "react-select"; import classNames from "@calcom/ui/classNames"; -import { UpgradeTeamsBadge } from "../../badge"; +import { CreditsBadge, UpgradeTeamsBadge } from "../../badge"; import { Icon } from "../../icon"; import type { SelectProps } from "./types"; @@ -31,6 +31,8 @@ type ExtendedOption = { value: string | number; label: string; needsTeamsUpgrade?: boolean; + needsCredits?: boolean; + creditsTeamId?: number; }; export const OptionComponent = < @@ -49,6 +51,8 @@ export const OptionComponent = < {(props.data as unknown as ExtendedOption).needsTeamsUpgrade ? ( + ) : (props.data as unknown as ExtendedOption).needsCredits ? ( + ) : ( <> )}