Skip to content

Commit c8064f8

Browse files
committed
feat: auto-recharge-credit-balance
closes: #21976
1 parent 06fcea4 commit c8064f8

9 files changed

Lines changed: 626 additions & 71 deletions

File tree

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

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
/* eslint-disable prettier/prettier */
2+
3+
/* eslint-disable react/jsx-no-undef */
14
"use client";
25

36
import { useSession } from "next-auth/react";
@@ -22,6 +25,9 @@ import { showToast } from "@calcom/ui/components/toast";
2225

2326
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
2427

28+
/* eslint-disable prettier/prettier */
29+
/* eslint-disable react/jsx-no-undef */
30+
2531
type MonthOption = {
2632
value: string;
2733
label: string;
@@ -62,6 +68,8 @@ export default function BillingCredits() {
6268
const [isDownloading, setIsDownloading] = useState(false);
6369
const utils = trpc.useUtils();
6470

71+
const [showAutoRechargeModal, setShowAutoRechargeModal] = useState(false);
72+
6573
const {
6674
register,
6775
handleSubmit,
@@ -91,6 +99,11 @@ export default function BillingCredits() {
9199
{ enabled: shouldRender }
92100
);
93101

102+
const { data: autoRechargeData } = trpc.viewer.credits.getAutoRechargeSettings.useQuery(
103+
{ teamId },
104+
{ enabled: shouldRender }
105+
);
106+
94107
if (!shouldRender) return null;
95108

96109
const buyCreditsMutation = trpc.viewer.credits.buyCredits.useMutation({
@@ -104,6 +117,17 @@ export default function BillingCredits() {
104117
},
105118
});
106119

120+
const updateAutoRechargeMutation = trpc.viewer.credits.updateAutoRechargeSettings.useMutation({
121+
onSuccess: () => {
122+
showToast(t("auto_recharge_settings_updated"), "success");
123+
setShowAutoRechargeModal(false);
124+
utils.viewer.credits.getAutoRechargeSettings.invalidate({ teamId });
125+
},
126+
onError: () => {
127+
showToast(t("auto_recharge_settings_failed"), "error");
128+
},
129+
});
130+
107131
const handleDownload = async () => {
108132
setIsDownloading(true);
109133
try {
@@ -132,11 +156,22 @@ export default function BillingCredits() {
132156
buyCreditsMutation.mutate({ quantity: data.quantity, teamId });
133157
};
134158

159+
const handleAutoRechargeSubmit = (data: { enabled: boolean; threshold: number; amount: number }) => {
160+
updateAutoRechargeMutation.mutate({
161+
teamId,
162+
enabled: data.enabled,
163+
threshold: data.threshold,
164+
amount: data.amount,
165+
});
166+
};
167+
135168
const teamCreditsPercentageUsed =
136169
creditsData.credits.totalMonthlyCredits > 0
137170
? (creditsData.credits.totalRemainingMonthlyCredits / creditsData.credits.totalMonthlyCredits) * 100
138171
: 0;
139172

173+
const autoRechargeSettings = autoRechargeData?.settings;
174+
140175
return (
141176
<div className="border-subtle mt-8 space-y-6 rounded-lg border px-6 py-6 pb-6 text-sm sm:space-y-8">
142177
<div>
@@ -186,6 +221,38 @@ export default function BillingCredits() {
186221
{creditsData.credits.totalMonthlyCredits ? t("additional_credits") : t("available_credits")}
187222
</Label>
188223
<div className="mt-2 text-sm">{creditsData.credits.additionalCredits}</div>
224+
225+
{/* Auto-recharge section */}
226+
<div className="-mx-6 mb-6 mt-6">
227+
<hr className="border-subtle mb-3 mt-3" />
228+
</div>
229+
<div className="mb-4 flex items-center justify-between">
230+
<div>
231+
<Label>{t("auto_recharge")}</Label>
232+
<p className="text-subtle mt-1 text-sm">
233+
{autoRechargeSettings?.enabled
234+
? t("auto_recharge_enabled_description", {
235+
threshold: autoRechargeSettings?.threshold,
236+
amount: autoRechargeSettings?.amount,
237+
})
238+
: t("auto_recharge_disabled_description")}
239+
</p>
240+
{autoRechargeSettings?.lastAutoRechargeAt && (
241+
<p className="text-subtle mt-1 text-sm">
242+
{t("last_auto_recharged_at", {
243+
date: dayjs(autoRechargeSettings.lastAutoRechargeAt).format("MMM D, YYYY HH:mm"),
244+
})}
245+
</p>
246+
)}
247+
</div>
248+
<Button
249+
color="secondary"
250+
onClick={() => setShowAutoRechargeModal(true)}
251+
data-testid="configure-auto-recharge">
252+
{autoRechargeSettings?.enabled ? t("edit") : t("setup")}
253+
</Button>
254+
</div>
255+
189256
<div className="-mx-6 mb-6 mt-6">
190257
<hr className="border-subtle mb-3 mt-3" />
191258
</div>
@@ -243,6 +310,124 @@ export default function BillingCredits() {
243310
</div>
244311
</div>
245312
</div>
313+
314+
{/* Auto-recharge modal */}
315+
{showAutoRechargeModal && (
316+
<AutoRechargeModal
317+
defaultValues={autoRechargeSettings}
318+
onSubmit={handleAutoRechargeSubmit}
319+
onCancel={() => setShowAutoRechargeModal(false)}
320+
isLoading={updateAutoRechargeMutation.isLoading}
321+
/>
322+
)}
246323
</div>
247324
);
248325
}
326+
327+
function AutoRechargeModal({
328+
defaultValues,
329+
onSubmit,
330+
onCancel,
331+
isLoading,
332+
}: {
333+
defaultValues?: {
334+
enabled: boolean;
335+
threshold: number;
336+
amount: number;
337+
};
338+
onSubmit: (data: { enabled: boolean; threshold: number; amount: number }) => void;
339+
onCancel: () => void;
340+
isLoading: boolean;
341+
}) {
342+
const { t } = useLocale();
343+
const {
344+
register,
345+
handleSubmit,
346+
watch,
347+
formState: { errors },
348+
} = useForm<{
349+
enabled: boolean;
350+
threshold: number;
351+
amount: number;
352+
}>({
353+
defaultValues: {
354+
enabled: defaultValues?.enabled ?? false,
355+
threshold: defaultValues?.threshold ?? 50,
356+
amount: defaultValues?.amount ?? 100,
357+
},
358+
});
359+
360+
const enabled = watch("enabled");
361+
362+
return (
363+
<Dialog open onOpenChange={onCancel}>
364+
<DialogContent>
365+
<DialogHeader>
366+
<DialogTitle>{t("auto_recharge_settings")}</DialogTitle>
367+
<DialogDescription>{t("auto_recharge_description")}</DialogDescription>
368+
</DialogHeader>
369+
<form onSubmit={handleSubmit(onSubmit)}>
370+
<div className="space-y-4 py-4">
371+
<div className="flex items-center">
372+
<Switch
373+
{...register("enabled")}
374+
defaultChecked={defaultValues?.enabled}
375+
id="auto-recharge-toggle"
376+
/>
377+
<Label className="ml-2" htmlFor="auto-recharge-toggle">
378+
{t("enable_auto_recharge")}
379+
</Label>
380+
</div>
381+
382+
{enabled && (
383+
<>
384+
<div>
385+
<Label htmlFor="threshold">{t("recharge_threshold")}</Label>
386+
<TextField
387+
id="threshold"
388+
type="number"
389+
{...register("threshold", {
390+
required: t("error_required_field"),
391+
min: { value: 10, message: t("minimum_threshold") },
392+
valueAsNumber: true,
393+
})}
394+
placeholder="50"
395+
/>
396+
{errors.threshold && (
397+
<InputError message={errors.threshold.message ?? t("invalid_input")} />
398+
)}
399+
<p className="text-subtle mt-1 text-sm">{t("threshold_description")}</p>
400+
</div>
401+
402+
<div>
403+
<Label htmlFor="amount">{t("recharge_amount")}</Label>
404+
<TextField
405+
id="amount"
406+
type="number"
407+
{...register("amount", {
408+
required: t("error_required_field"),
409+
min: { value: 50, message: t("minimum_amount") },
410+
valueAsNumber: true,
411+
})}
412+
placeholder="100"
413+
/>
414+
{errors.amount && <InputError message={errors.amount.message ?? t("invalid_input")} />}
415+
<p className="text-subtle mt-1 text-sm">{t("amount_description")}</p>
416+
</div>
417+
</>
418+
)}
419+
</div>
420+
421+
<DialogFooter>
422+
<Button type="button" color="secondary" onClick={onCancel}>
423+
{t("cancel")}
424+
</Button>
425+
<Button type="submit" loading={isLoading}>
426+
{t("save")}
427+
</Button>
428+
</DialogFooter>
429+
</form>
430+
</DialogContent>
431+
</Dialog>
432+
);
433+
}

packages/emails/templates/credit-balance-limit-reached-email.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable import/no-cycle */
12
import type { TFunction } from "i18next";
23

34
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
@@ -16,35 +17,54 @@ export default class CreditBalanceLimitReachedEmail extends BaseEmail {
1617
id: number;
1718
name: string;
1819
};
20+
autoRechargeEnabled: boolean;
21+
autoRechargeFailed: boolean;
1922

2023
constructor({
2124
user,
2225
team,
26+
autoRechargeEnabled = false,
27+
autoRechargeFailed = false,
2328
}: {
2429
user: { id: number; name: string | null; email: string; t: TFunction };
2530
team?: { id: number; name: string | null };
31+
autoRechargeEnabled?: boolean;
32+
autoRechargeFailed?: boolean;
2633
}) {
2734
super();
2835
this.user = { ...user, name: user.name || "" };
2936
this.team = team ? { ...team, name: team.name || "" } : undefined;
37+
this.autoRechargeEnabled = autoRechargeEnabled;
38+
this.autoRechargeFailed = autoRechargeFailed;
3039
}
3140

3241
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
42+
const subject = this.autoRechargeFailed
43+
? this.user.t("auto_recharge_failed")
44+
: this.team
45+
? this.user.t("action_required_out_of_credits", { teamName: this.team.name })
46+
: this.user.t("action_required_user_out_of_credits");
47+
3348
return {
3449
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
3550
to: this.user.email,
36-
subject: this.team
37-
? this.user.t("action_required_out_of_credits", { teamName: this.team.name })
38-
: this.user.t("action_required_user_out_of_credits"),
51+
subject,
3952
html: await renderEmail("CreditBalanceLimitReachedEmail", {
4053
team: this.team,
4154
user: this.user,
55+
autoRechargeEnabled: this.autoRechargeEnabled,
56+
autoRechargeFailed: this.autoRechargeFailed,
4257
}),
4358
text: this.getTextBody(),
4459
};
4560
}
4661

4762
protected getTextBody(): string {
63+
if (this.autoRechargeFailed) {
64+
return "Your auto-recharge payment failed. Your team is out of credits. Please update your payment method and purchase more credits.";
65+
} else if (this.autoRechargeEnabled) {
66+
return "Your team ran out of credits, but auto-recharge is enabled. A recharge will be attempted soon.";
67+
}
4868
return "Your team ran out of credits. Please buy more credits.";
4969
}
5070
}

packages/emails/templates/credit-balance-low-warning-email.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable import/no-cycle */
12
import type { TFunction } from "i18next";
23

34
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
@@ -17,20 +18,24 @@ export default class CreditBalanceLowWarningEmail extends BaseEmail {
1718
name: string;
1819
};
1920
balance: number;
21+
autoRechargeEnabled: boolean;
2022

2123
constructor({
2224
user,
2325
balance,
2426
team,
27+
autoRechargeEnabled = false,
2528
}: {
2629
user: { id: number; name: string | null; email: string; t: TFunction };
2730
balance: number;
2831
team?: { id: number; name: string | null };
32+
autoRechargeEnabled?: boolean;
2933
}) {
3034
super();
3135
this.user = { ...user, name: user.name || "" };
3236
this.team = team ? { ...team, name: team.name || "" } : undefined;
3337
this.balance = balance;
38+
this.autoRechargeEnabled = autoRechargeEnabled;
3439
}
3540

3641
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
@@ -44,12 +49,16 @@ export default class CreditBalanceLowWarningEmail extends BaseEmail {
4449
balance: this.balance,
4550
team: this.team,
4651
user: this.user,
52+
autoRechargeEnabled: this.autoRechargeEnabled,
4753
}),
4854
text: this.getTextBody(),
4955
};
5056
}
5157

5258
protected getTextBody(): string {
59+
if (this.autoRechargeEnabled) {
60+
return "Your team is running low on credits. Auto-recharge will be triggered soon.";
61+
}
5362
return "Your team is running low on credits. Please buy more credits.";
5463
}
5564
}

packages/features/ee/billing/api/webhook/_checkout.session.completed.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,26 @@ async function saveToCreditBalance({
5353
}) {
5454
const creditBalance = await CreditsRepository.findCreditBalance({ teamId, userId });
5555

56+
const stripeCustomerId = session.customer as string;
57+
5658
let creditBalanceId = creditBalance?.id;
5759

5860
if (creditBalance) {
5961
await CreditsRepository.updateCreditBalance({
6062
id: creditBalance.id,
61-
data: { additionalCredits: { increment: nrOfCredits }, limitReachedAt: null, warningSentAt: null },
63+
data: {
64+
additionalCredits: { increment: nrOfCredits },
65+
limitReachedAt: null,
66+
warningSentAt: null,
67+
stripeCustomerId: stripeCustomerId, // Store customer ID for future auto-recharge
68+
},
6269
});
6370
} else {
6471
const newCreditBalance = await CreditsRepository.createCreditBalance({
6572
teamId: teamId,
6673
userId: !teamId ? userId : undefined,
6774
additionalCredits: nrOfCredits,
75+
stripeCustomerId: stripeCustomerId, // Store customer ID for future auto-recharge
6876
});
6977
creditBalanceId = newCreditBalance.id;
7078
}

0 commit comments

Comments
 (0)