feat: SMS credits for free users (#21245)
* 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 * add user logic checkSmsPrices endpoint * 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. * fix twilio webhook * add userId support in checkout.session.completed * clean up code * only select id in getAdminMembership * revert billing/package.json * fix type checks * fix type checks * adjust hasAvailableCredits function * fixes for checkout sessioned completed * add UI for user * fix type errors * adds requires credits badge * remove team check from update.handler * clean up inlcude statements * fix credit-service tests * add tests * fix type errors * fix type errors * fix and add tests * imrove badge * code clean up * add reminderScheduler test * add additional credits as title * fixes for warningSentAt and limitReachedAt * mock stripe --------- Co-authored-by: CarinaWolli <wollencarina@gmail.com> Co-authored-by: hbjORbj <sldisek783@gmail.com> Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
co-authored by
CarinaWolli
hbjORbj
Udit Takkar
Peer Richelsen
parent
e669996442
commit
ead42c1a9c
@@ -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++;
|
||||
|
||||
@@ -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 <BillingCreditsSkeleton />;
|
||||
|
||||
if (isLoading && teamId) return <BillingCreditsSkeleton />;
|
||||
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() {
|
||||
<hr className="border-subtle" />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<div className="mb-4">
|
||||
<Label>{t("monthly_credits")}</Label>
|
||||
<ProgressBar
|
||||
color="green"
|
||||
percentageValue={teamCreditsPercentageUsed}
|
||||
label={`${Math.max(0, Math.round(teamCreditsPercentageUsed))}%`}
|
||||
/>
|
||||
<div className="text-subtle">
|
||||
<div>{t("total_credits", { totalCredits: creditsData.teamCredits.totalMonthlyCredits })}</div>
|
||||
<div>
|
||||
{t("remaining_credits", {
|
||||
remainingCredits: creditsData.teamCredits.totalRemainingMonthlyCredits,
|
||||
})}
|
||||
{creditsData.credits.totalMonthlyCredits > 0 ? (
|
||||
<div className="mb-4">
|
||||
<Label>{t("monthly_credits")}</Label>
|
||||
<ProgressBar
|
||||
color="green"
|
||||
percentageValue={teamCreditsPercentageUsed}
|
||||
label={`${Math.max(0, Math.round(teamCreditsPercentageUsed))}%`}
|
||||
/>
|
||||
<div className="text-subtle">
|
||||
<div>
|
||||
{t("total_credits", {
|
||||
totalCredits: creditsData.credits.totalMonthlyCredits,
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
{t("remaining_credits", {
|
||||
remainingCredits: creditsData.credits.totalRemainingMonthlyCredits,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Label>{t("additional_credits")}</Label>
|
||||
<div className="mt-2 text-sm">{creditsData.teamCredits.additionalCredits}</div>{" "}
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<Label>
|
||||
{creditsData.credits.totalMonthlyCredits ? t("additional_credits") : t("available_credits")}
|
||||
</Label>
|
||||
<div className="mt-2 text-sm">{creditsData.credits.additionalCredits}</div>
|
||||
<div className="-mx-6 mb-6 mt-6">
|
||||
<hr className="border-subtle mb-3 mt-3" />
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -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<unknown>[] = [];
|
||||
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<unknown>[] = [];
|
||||
|
||||
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<unknown>[] = [];
|
||||
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<unknown>[] = [];
|
||||
|
||||
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 }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<V2BaseEmailHtml subject={user.t("action_required_out_of_credits", { teamName: team.name })}>
|
||||
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
|
||||
<> {user.t("hi_user_name", { name: user.name })},</>
|
||||
</p>
|
||||
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "20px" }}>
|
||||
<>{user.t("credit_limit_reached_message", { teamName: team.name })}</>
|
||||
</p>
|
||||
<div style={{ textAlign: "center", marginTop: "24px" }}>
|
||||
<CallToAction
|
||||
label={user.t("buy_credits")}
|
||||
href={`${WEBAPP_URL}/settings/teams/${team.id}/billing`}
|
||||
endIconName="linkIcon"
|
||||
/>
|
||||
</div>{" "}
|
||||
</V2BaseEmailHtml>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<V2BaseEmailHtml subject={user.t("action_required_out_of_credits", { teamName: team.name })}>
|
||||
<V2BaseEmailHtml subject={user.t("action_required_user_out_of_credits")}>
|
||||
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
|
||||
<> {user.t("hi_user_name", { name: user.name })},</>
|
||||
</p>
|
||||
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "20px" }}>
|
||||
<>{user.t("credit_limit_reached_message", { teamName: team.name })}</>
|
||||
<>{user.t("credit_limit_reached_message_user")}</>
|
||||
</p>
|
||||
<div style={{ textAlign: "center", marginTop: "24px" }}>
|
||||
<CallToAction
|
||||
label={user.t("buy_credits")}
|
||||
href={`${WEBAPP_URL}/settings/teams/${team.id}/billing`}
|
||||
href={`${WEBAPP_URL}/settings/teams/${user.id}/credits`}
|
||||
endIconName="linkIcon"
|
||||
/>
|
||||
</div>{" "}
|
||||
</div>
|
||||
</V2BaseEmailHtml>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<V2BaseEmailHtml subject={user.t("team_credits_low_warning", { teamName: team.name })}>
|
||||
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
|
||||
<> {user.t("hi_user_name", { name: user.name })},</>
|
||||
</p>
|
||||
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "20px" }}>
|
||||
<>{user.t("low_credits_warning_message", { teamName: team.name })}</>
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontWeight: "500",
|
||||
lineHeight: "24px",
|
||||
color: "#000",
|
||||
}}>
|
||||
{user.t("current_credit_balance", { balance })}
|
||||
</p>
|
||||
<div style={{ textAlign: "center", marginTop: "24px" }}>
|
||||
<CallToAction
|
||||
label={user.t("buy_credits")}
|
||||
href={`${WEBAPP_URL}/settings/teams/${team.id}/billing`}
|
||||
endIconName="linkIcon"
|
||||
/>
|
||||
</div>
|
||||
</V2BaseEmailHtml>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<V2BaseEmailHtml subject={user.t("team_credits_low_warning", { teamName: team.name })}>
|
||||
<V2BaseEmailHtml subject={user.t("user_credits_low_warning")}>
|
||||
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
|
||||
<> {user.t("hi_user_name", { name: user.name })},</>
|
||||
</p>
|
||||
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "20px" }}>
|
||||
<>{user.t("low_credits_warning_message", { teamName: team.name })}</>
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontWeight: "500",
|
||||
lineHeight: "24px",
|
||||
color: "#000",
|
||||
}}>
|
||||
{user.t("current_credit_balance", { balance })}
|
||||
<>{user.t("low_credits_warning_message_user")}</>
|
||||
</p>
|
||||
<div style={{ textAlign: "center", marginTop: "24px" }}>
|
||||
<CallToAction
|
||||
label={user.t("buy_credits")}
|
||||
href={`${WEBAPP_URL}/settings/teams/${team.id}/billing`}
|
||||
href={`${WEBAPP_URL}/settings/teams/${user.id}/billing`}
|
||||
endIconName="linkIcon"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<Record<string, unknown>> {
|
||||
return {
|
||||
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
|
||||
to: this.user.email,
|
||||
subject: this.user.t("action_required_out_of_credits", { teamName: this.team.name }),
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -171,8 +171,8 @@ export const AddActionDialog = (props: IAddActionDialog) => {
|
||||
isOptionDisabled={(option: {
|
||||
label: string;
|
||||
value: WorkflowActions;
|
||||
needsTeamsUpgrade: boolean;
|
||||
}) => option.needsTeamsUpgrade}
|
||||
needsCredits: boolean;
|
||||
}) => option.needsCredits}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }] : []),
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<typeof ZBuyCreditsSchema>;
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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<typeof ZGetAllCreditsSchema>;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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<TrpcSessionUser> & {
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<Tooltip content={t("requires_credits_tooltip")}>
|
||||
<Link href={teamId ? `/settings/teams/${teamId}/billing` : "/settings/billing"}>
|
||||
<Badge variant="gray">{t("requires_credits")}</Badge>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = <
|
||||
</span>
|
||||
{(props.data as unknown as ExtendedOption).needsTeamsUpgrade ? (
|
||||
<UpgradeTeamsBadge checkForActiveStatus={true} />
|
||||
) : (props.data as unknown as ExtendedOption).needsCredits ? (
|
||||
<CreditsBadge teamId={(props.data as unknown as ExtendedOption).creditsTeamId} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user