Skip to content

Commit ead42c1

Browse files
CarinaWolliCarinaWollihbjORbjUdit-takkarPeerRich
authored
feat: SMS credits for free users (calcom#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 e272978. * no need to dynamic-import credit service * Revert "no need to dynamic-import credit service" This reverts commit ba5ae48. * 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>
1 parent e669996 commit ead42c1

30 files changed

Lines changed: 1147 additions & 488 deletions

apps/web/app/api/cron/checkSmsPrices/route.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ async function postHandler(req: NextRequest) {
5959
id: true,
6060
additionalCredits: true,
6161
teamId: true,
62+
userId: true,
6263
},
6364
},
6465
creditType: true,
@@ -81,18 +82,22 @@ async function postHandler(req: NextRequest) {
8182
});
8283
}
8384

84-
if (!updatedLog.creditBalance.teamId) {
85-
logger.error(`teamId missing for expense log ${log.id}`);
85+
if (!updatedLog.creditBalance.teamId && !updatedLog.creditBalance.userId) {
86+
logger.error(`teamId or userId missing for expense log ${log.id}`);
8687
return;
8788
}
8889

89-
const teamCredits = await creditService.getAllCreditsForTeam(updatedLog.creditBalance.teamId);
90+
const availableCredits = await creditService.getAllCredits({
91+
teamId: updatedLog.creditBalance.teamId,
92+
userId: updatedLog.creditBalance.userId,
93+
});
9094

91-
const remainingMonthlyCredits = Math.max(0, teamCredits.totalRemainingMonthlyCredits);
95+
const remainingMonthlyCredits = Math.max(0, availableCredits.totalRemainingMonthlyCredits);
9296

9397
await creditService.handleLowCreditBalance({
94-
teamId: updatedLog.creditBalance.teamId ?? 0,
95-
remainingCredits: remainingMonthlyCredits + teamCredits.additionalCredits,
98+
userId: updatedLog.creditBalance.userId,
99+
teamId: updatedLog.creditBalance.teamId,
100+
remainingCredits: remainingMonthlyCredits + availableCredits.additionalCredits,
96101
});
97102

98103
pricesUpdated++;

apps/web/modules/settings/billing/components/BillingCredits.tsx

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -28,38 +28,33 @@ export default function BillingCredits() {
2828
const params = useParamsWithFallback();
2929

3030
const teamId = params.id ? Number(params.id) : undefined;
31-
const { data: creditsData, isLoading } = trpc.viewer.credits.getAllCredits.useQuery(
32-
{ teamId: teamId ?? 0 },
33-
{ enabled: !!teamId }
34-
);
31+
const { data: creditsData, isLoading } = trpc.viewer.credits.getAllCredits.useQuery({ teamId });
3532

3633
const buyCreditsMutation = trpc.viewer.credits.buyCredits.useMutation({
3734
onSuccess: (data) => {
3835
if (data.sessionUrl) {
3936
router.push(data.sessionUrl);
4037
}
4138
},
42-
onError: (err) => {
39+
onError: () => {
4340
showToast(t("credit_purchase_failed"), "error");
4441
},
4542
});
4643

47-
if (!teamId || !IS_SMS_CREDITS_ENABLED) {
44+
if (!IS_SMS_CREDITS_ENABLED) {
4845
return null;
4946
}
5047

51-
if (isLoading) return <BillingCreditsSkeleton />;
52-
48+
if (isLoading && teamId) return <BillingCreditsSkeleton />;
5349
if (!creditsData) return null;
5450

5551
const onSubmit = (data: { quantity: number }) => {
5652
buyCreditsMutation.mutate({ quantity: data.quantity, teamId });
5753
};
5854

5955
const teamCreditsPercentageUsed =
60-
creditsData.teamCredits.totalMonthlyCredits > 0
61-
? (creditsData.teamCredits.totalRemainingMonthlyCredits / creditsData.teamCredits.totalMonthlyCredits) *
62-
100
56+
creditsData.credits.totalMonthlyCredits > 0
57+
? (creditsData.credits.totalRemainingMonthlyCredits / creditsData.credits.totalMonthlyCredits) * 100
6358
: 0;
6459

6560
return (
@@ -71,24 +66,35 @@ export default function BillingCredits() {
7166
<hr className="border-subtle" />
7267
</div>
7368
<div className="mt-6">
74-
<div className="mb-4">
75-
<Label>{t("monthly_credits")}</Label>
76-
<ProgressBar
77-
color="green"
78-
percentageValue={teamCreditsPercentageUsed}
79-
label={`${Math.max(0, Math.round(teamCreditsPercentageUsed))}%`}
80-
/>
81-
<div className="text-subtle">
82-
<div>{t("total_credits", { totalCredits: creditsData.teamCredits.totalMonthlyCredits })}</div>
83-
<div>
84-
{t("remaining_credits", {
85-
remainingCredits: creditsData.teamCredits.totalRemainingMonthlyCredits,
86-
})}
69+
{creditsData.credits.totalMonthlyCredits > 0 ? (
70+
<div className="mb-4">
71+
<Label>{t("monthly_credits")}</Label>
72+
<ProgressBar
73+
color="green"
74+
percentageValue={teamCreditsPercentageUsed}
75+
label={`${Math.max(0, Math.round(teamCreditsPercentageUsed))}%`}
76+
/>
77+
<div className="text-subtle">
78+
<div>
79+
{t("total_credits", {
80+
totalCredits: creditsData.credits.totalMonthlyCredits,
81+
})}
82+
</div>
83+
<div>
84+
{t("remaining_credits", {
85+
remainingCredits: creditsData.credits.totalRemainingMonthlyCredits,
86+
})}
87+
</div>
8788
</div>
8889
</div>
89-
</div>
90-
<Label>{t("additional_credits")}</Label>
91-
<div className="mt-2 text-sm">{creditsData.teamCredits.additionalCredits}</div>{" "}
90+
) : (
91+
<></>
92+
)}
93+
94+
<Label>
95+
{creditsData.credits.totalMonthlyCredits ? t("additional_credits") : t("available_credits")}
96+
</Label>
97+
<div className="mt-2 text-sm">{creditsData.credits.additionalCredits}</div>
9298
<div className="-mx-6 mb-6 mt-6">
9399
<hr className="border-subtle mb-3 mt-3" />
94100
</div>

apps/web/pages/api/twilio/webhook.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -92,15 +92,15 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
9292
teamIdToCharge = teamMembership?.teamId;
9393
}
9494

95-
if (teamIdToCharge) {
96-
await creditService.chargeCredits({
97-
teamId: teamIdToCharge,
98-
bookingUid: parsedBookingUid,
99-
smsSid,
100-
credits: 0,
101-
});
102-
return res.status(200).send(`SMS to US and CA are free on a team plan. Credits set to 0`);
103-
}
95+
await creditService.chargeCredits({
96+
teamId: teamIdToCharge,
97+
userId: !teamIdToCharge ? parsedUserId : undefined,
98+
bookingUid: parsedBookingUid,
99+
smsSid,
100+
credits: 0,
101+
});
102+
103+
return res.status(200).send(`SMS to US and CA are free on a team plan. Credits set to 0`);
104104
}
105105

106106
let orgId;
@@ -140,18 +140,22 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
140140

141141
const credits = price ? creditService.calculateCreditsFromPrice(price) : null;
142142

143-
const chargedTeamId = await creditService.chargeCredits({
143+
const chargedUserOrTeamId = await creditService.chargeCredits({
144144
credits,
145145
teamId: parsedTeamId,
146146
userId: parsedUserId,
147147
smsSid,
148148
bookingUid: parsedBookingUid,
149149
});
150150

151-
if (chargedTeamId) {
151+
if (chargedUserOrTeamId) {
152152
return res.status(200).send(
153153
`Expense log with ${credits ? credits : "no"} credits created for
154-
teamId ${chargedTeamId}`
154+
${
155+
chargedUserOrTeamId.teamId
156+
? `teamId ${chargedUserOrTeamId.teamId}`
157+
: `userId ${chargedUserOrTeamId.userId}`
158+
}`
155159
);
156160
}
157161
// this should never happen - even when out of credits we still charge a team

apps/web/public/static/locales/en/common.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3195,9 +3195,13 @@
31953195
"view_form": "View Form",
31963196
"sms_opt_out_message": "Text STOP to opt-out of SMS messages",
31973197
"team_credits_low_warning": "Your team {{teamName}} is running low on credits",
3198+
"user_credits_low_warning": "You are running low on credits",
31983199
"action_required_out_of_credits": "[Action Required] Your team {{teamName}} has run out of credits",
3200+
"action_required_user_out_of_credits": "[Action Required] You have run out of credits",
31993201
"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.",
3202+
"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.",
32003203
"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.",
3204+
"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.",
32013205
"current_credit_balance": "Current balance: {{balance}} credits",
32023206
"notification_about_your_booking": "Notification about your booking",
32033207
"monthly_credits": "Monthly credits",
@@ -3211,5 +3215,8 @@
32113215
"matching": "Matching",
32123216
"event_redirect": "Event Redirect",
32133217
"reset_form": "Reset Form",
3218+
"requires_credits": "Requires credits",
3219+
"requires_credits_tooltip": "You need enough credits in your account to use this feature",
3220+
"available_credits": "Available credits",
32143221
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
32153222
}

packages/emails/email-manager.ts

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -762,47 +762,75 @@ export const sendBookingRedirectNotification = async (bookingRedirect: IBookingR
762762
};
763763

764764
export const sendCreditBalanceLowWarningEmails = async (input: {
765-
team: {
766-
name: string;
765+
team?: {
766+
name: string | null;
767767
id: number;
768768
adminAndOwners: {
769-
name: string;
769+
id: number;
770+
name: string | null;
770771
email: string;
771772
t: TFunction;
772773
}[];
773774
};
775+
user?: {
776+
id: number;
777+
name: string | null;
778+
email: string;
779+
t: TFunction;
780+
};
774781
balance: number;
775782
}) => {
776-
const { team, balance } = input;
777-
if (!team.adminAndOwners.length) return;
778-
const emailsToSend: Promise<unknown>[] = [];
783+
const { team, balance, user } = input;
784+
if ((!team || !team.adminAndOwners.length) && !user) return;
779785

780-
for (const admin of team.adminAndOwners) {
781-
emailsToSend.push(sendEmail(() => new CreditBalanceLowWarningEmail(admin, balance, team)));
786+
if (team) {
787+
const emailsToSend: Promise<unknown>[] = [];
788+
789+
for (const admin of team.adminAndOwners) {
790+
emailsToSend.push(sendEmail(() => new CreditBalanceLowWarningEmail({ user: admin, balance, team })));
791+
}
792+
793+
await Promise.all(emailsToSend);
782794
}
783795

784-
await Promise.all(emailsToSend);
796+
if (user) {
797+
await sendEmail(() => new CreditBalanceLowWarningEmail({ user, balance }));
798+
}
785799
};
786800

787801
export const sendCreditBalanceLimitReachedEmails = async ({
788802
team,
803+
user,
789804
}: {
790-
team: {
805+
team?: {
791806
name: string;
792807
id: number;
793808
adminAndOwners: {
794-
name: string;
809+
id: number;
810+
name: string | null;
795811
email: string;
796812
t: TFunction;
797813
}[];
798814
};
815+
user?: {
816+
id: number;
817+
name: string | null;
818+
email: string;
819+
t: TFunction;
820+
};
799821
}) => {
800-
if (!team.adminAndOwners.length) return;
801-
const emailsToSend: Promise<unknown>[] = [];
822+
if ((!team || !team.adminAndOwners.length) && !user) return;
802823

803-
for (const admin of team.adminAndOwners) {
804-
emailsToSend.push(sendEmail(() => new CreditBalanceLimitReachedEmail(admin, team)));
824+
if (team) {
825+
const emailsToSend: Promise<unknown>[] = [];
826+
827+
for (const admin of team.adminAndOwners) {
828+
emailsToSend.push(sendEmail(() => new CreditBalanceLimitReachedEmail({ user: admin, team })));
829+
}
830+
await Promise.all(emailsToSend);
805831
}
806832

807-
await Promise.all(emailsToSend);
833+
if (user) {
834+
await sendEmail(() => new CreditBalanceLimitReachedEmail({ user }));
835+
}
808836
};

packages/emails/src/templates/CreditBalanceLimitReachedEmail.tsx

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ import type { BaseScheduledEmail } from "./BaseScheduledEmail";
77

88
export const CreditBalanceLimitReachedEmail = (
99
props: {
10-
team: {
10+
team?: {
1111
id: number;
1212
name: string;
1313
};
1414
user: {
15+
id: number;
1516
name: string;
1617
email: string;
1718
t: TFunction;
@@ -20,21 +21,41 @@ export const CreditBalanceLimitReachedEmail = (
2021
) => {
2122
const { team, user } = props;
2223

24+
if (team) {
25+
return (
26+
<V2BaseEmailHtml subject={user.t("action_required_out_of_credits", { teamName: team.name })}>
27+
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
28+
<> {user.t("hi_user_name", { name: user.name })},</>
29+
</p>
30+
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "20px" }}>
31+
<>{user.t("credit_limit_reached_message", { teamName: team.name })}</>
32+
</p>
33+
<div style={{ textAlign: "center", marginTop: "24px" }}>
34+
<CallToAction
35+
label={user.t("buy_credits")}
36+
href={`${WEBAPP_URL}/settings/teams/${team.id}/billing`}
37+
endIconName="linkIcon"
38+
/>
39+
</div>{" "}
40+
</V2BaseEmailHtml>
41+
);
42+
}
43+
2344
return (
24-
<V2BaseEmailHtml subject={user.t("action_required_out_of_credits", { teamName: team.name })}>
45+
<V2BaseEmailHtml subject={user.t("action_required_user_out_of_credits")}>
2546
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
2647
<> {user.t("hi_user_name", { name: user.name })},</>
2748
</p>
2849
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "20px" }}>
29-
<>{user.t("credit_limit_reached_message", { teamName: team.name })}</>
50+
<>{user.t("credit_limit_reached_message_user")}</>
3051
</p>
3152
<div style={{ textAlign: "center", marginTop: "24px" }}>
3253
<CallToAction
3354
label={user.t("buy_credits")}
34-
href={`${WEBAPP_URL}/settings/teams/${team.id}/billing`}
55+
href={`${WEBAPP_URL}/settings/teams/${user.id}/credits`}
3556
endIconName="linkIcon"
3657
/>
37-
</div>{" "}
58+
</div>
3859
</V2BaseEmailHtml>
3960
);
4061
};

0 commit comments

Comments
 (0)