Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions apps/web/modules/settings/billing/components/BillingCredits.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
/* eslint-disable prettier/prettier */

/* eslint-disable react/jsx-no-undef */
"use client";

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

import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";

/* eslint-disable prettier/prettier */
/* eslint-disable react/jsx-no-undef */

type MonthOption = {
value: string;
label: string;
Expand Down Expand Up @@ -62,6 +68,8 @@ export default function BillingCredits() {
const [isDownloading, setIsDownloading] = useState(false);
const utils = trpc.useUtils();

const [showAutoRechargeModal, setShowAutoRechargeModal] = useState(false);

const {
register,
handleSubmit,
Expand Down Expand Up @@ -91,6 +99,11 @@ export default function BillingCredits() {
{ enabled: shouldRender }
);

const { data: autoRechargeData } = trpc.viewer.credits.getAutoRechargeSettings.useQuery(
{ teamId },
{ enabled: shouldRender }
);

if (!shouldRender) return null;

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

const updateAutoRechargeMutation = trpc.viewer.credits.updateAutoRechargeSettings.useMutation({
onSuccess: () => {
showToast(t("auto_recharge_settings_updated"), "success");
setShowAutoRechargeModal(false);
utils.viewer.credits.getAutoRechargeSettings.invalidate({ teamId });
},
onError: () => {
showToast(t("auto_recharge_settings_failed"), "error");
},
});

const handleDownload = async () => {
setIsDownloading(true);
try {
Expand Down Expand Up @@ -132,11 +156,22 @@ export default function BillingCredits() {
buyCreditsMutation.mutate({ quantity: data.quantity, teamId });
};

const handleAutoRechargeSubmit = (data: { enabled: boolean; threshold: number; amount: number }) => {
updateAutoRechargeMutation.mutate({
teamId,
enabled: data.enabled,
threshold: data.threshold,
amount: data.amount,
});
};

const teamCreditsPercentageUsed =
creditsData.credits.totalMonthlyCredits > 0
? (creditsData.credits.totalRemainingMonthlyCredits / creditsData.credits.totalMonthlyCredits) * 100
: 0;

const autoRechargeSettings = autoRechargeData?.settings;

return (
<div className="border-subtle mt-8 space-y-6 rounded-lg border px-6 py-6 pb-6 text-sm sm:space-y-8">
<div>
Expand Down Expand Up @@ -186,6 +221,38 @@ export default function BillingCredits() {
{creditsData.credits.totalMonthlyCredits ? t("additional_credits") : t("available_credits")}
</Label>
<div className="mt-2 text-sm">{creditsData.credits.additionalCredits}</div>

{/* Auto-recharge section */}
<div className="-mx-6 mb-6 mt-6">
<hr className="border-subtle mb-3 mt-3" />
</div>
<div className="mb-4 flex items-center justify-between">
<div>
<Label>{t("auto_recharge")}</Label>
<p className="text-subtle mt-1 text-sm">
{autoRechargeSettings?.enabled
? t("auto_recharge_enabled_description", {
threshold: autoRechargeSettings?.threshold,
amount: autoRechargeSettings?.amount,
})
: t("auto_recharge_disabled_description")}
</p>
{autoRechargeSettings?.lastAutoRechargeAt && (
<p className="text-subtle mt-1 text-sm">
{t("last_auto_recharged_at", {
date: dayjs(autoRechargeSettings.lastAutoRechargeAt).format("MMM D, YYYY HH:mm"),
})}
</p>
)}
</div>
<Button
color="secondary"
onClick={() => setShowAutoRechargeModal(true)}
data-testid="configure-auto-recharge">
{autoRechargeSettings?.enabled ? t("edit") : t("setup")}
</Button>
</div>

<div className="-mx-6 mb-6 mt-6">
<hr className="border-subtle mb-3 mt-3" />
</div>
Expand Down Expand Up @@ -243,6 +310,124 @@ export default function BillingCredits() {
</div>
</div>
</div>

{/* Auto-recharge modal */}
{showAutoRechargeModal && (
<AutoRechargeModal
defaultValues={autoRechargeSettings}
onSubmit={handleAutoRechargeSubmit}
onCancel={() => setShowAutoRechargeModal(false)}
isLoading={updateAutoRechargeMutation.isLoading}
/>
)}
</div>
);
}

function AutoRechargeModal({
defaultValues,
onSubmit,
onCancel,
isLoading,
}: {
defaultValues?: {
enabled: boolean;
threshold: number;
amount: number;
};
onSubmit: (data: { enabled: boolean; threshold: number; amount: number }) => void;
onCancel: () => void;
isLoading: boolean;
}) {
const { t } = useLocale();
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<{
enabled: boolean;
threshold: number;
amount: number;
}>({
defaultValues: {
enabled: defaultValues?.enabled ?? false,
threshold: defaultValues?.threshold ?? 50,
amount: defaultValues?.amount ?? 100,
},
});

const enabled = watch("enabled");

return (
<Dialog open onOpenChange={onCancel}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("auto_recharge_settings")}</DialogTitle>
<DialogDescription>{t("auto_recharge_description")}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-4 py-4">
<div className="flex items-center">
<Switch
{...register("enabled")}
defaultChecked={defaultValues?.enabled}
id="auto-recharge-toggle"
/>
<Label className="ml-2" htmlFor="auto-recharge-toggle">
{t("enable_auto_recharge")}
</Label>
</div>

{enabled && (
<>
<div>
<Label htmlFor="threshold">{t("recharge_threshold")}</Label>
<TextField
id="threshold"
type="number"
{...register("threshold", {
required: t("error_required_field"),
min: { value: 10, message: t("minimum_threshold") },
valueAsNumber: true,
})}
placeholder="50"
/>
{errors.threshold && (
<InputError message={errors.threshold.message ?? t("invalid_input")} />
)}
<p className="text-subtle mt-1 text-sm">{t("threshold_description")}</p>
</div>

<div>
<Label htmlFor="amount">{t("recharge_amount")}</Label>
<TextField
id="amount"
type="number"
{...register("amount", {
required: t("error_required_field"),
min: { value: 50, message: t("minimum_amount") },
valueAsNumber: true,
})}
placeholder="100"
/>
{errors.amount && <InputError message={errors.amount.message ?? t("invalid_input")} />}
<p className="text-subtle mt-1 text-sm">{t("amount_description")}</p>
</div>
</>
)}
</div>

<DialogFooter>
<Button type="button" color="secondary" onClick={onCancel}>
{t("cancel")}
</Button>
<Button type="submit" loading={isLoading}>
{t("save")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
26 changes: 23 additions & 3 deletions packages/emails/templates/credit-balance-limit-reached-email.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable import/no-cycle */
import type { TFunction } from "i18next";

import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
Expand All @@ -16,35 +17,54 @@ export default class CreditBalanceLimitReachedEmail extends BaseEmail {
id: number;
name: string;
};
autoRechargeEnabled: boolean;
autoRechargeFailed: boolean;

constructor({
user,
team,
autoRechargeEnabled = false,
autoRechargeFailed = false,
}: {
user: { id: number; name: string | null; email: string; t: TFunction };
team?: { id: number; name: string | null };
autoRechargeEnabled?: boolean;
autoRechargeFailed?: boolean;
}) {
super();
this.user = { ...user, name: user.name || "" };
this.team = team ? { ...team, name: team.name || "" } : undefined;
this.autoRechargeEnabled = autoRechargeEnabled;
this.autoRechargeFailed = autoRechargeFailed;
}

protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
const subject = this.autoRechargeFailed
? this.user.t("auto_recharge_failed")
: this.team
? this.user.t("action_required_out_of_credits", { teamName: this.team.name })
: this.user.t("action_required_user_out_of_credits");

return {
from: `${EMAIL_FROM_NAME} <${this.getMailerOptions().from}>`,
to: this.user.email,
subject: this.team
? this.user.t("action_required_out_of_credits", { teamName: this.team.name })
: this.user.t("action_required_user_out_of_credits"),
subject,
html: await renderEmail("CreditBalanceLimitReachedEmail", {
team: this.team,
user: this.user,
autoRechargeEnabled: this.autoRechargeEnabled,
autoRechargeFailed: this.autoRechargeFailed,
}),
text: this.getTextBody(),
};
}

protected getTextBody(): string {
if (this.autoRechargeFailed) {
return "Your auto-recharge payment failed. Your team is out of credits. Please update your payment method and purchase more credits.";
} else if (this.autoRechargeEnabled) {
return "Your team ran out of credits, but auto-recharge is enabled. A recharge will be attempted soon.";
}
return "Your team ran out of credits. Please buy more credits.";
}
}
9 changes: 9 additions & 0 deletions packages/emails/templates/credit-balance-low-warning-email.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable import/no-cycle */
import type { TFunction } from "i18next";

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

constructor({
user,
balance,
team,
autoRechargeEnabled = false,
}: {
user: { id: number; name: string | null; email: string; t: TFunction };
balance: number;
team?: { id: number; name: string | null };
autoRechargeEnabled?: boolean;
}) {
super();
this.user = { ...user, name: user.name || "" };
this.team = team ? { ...team, name: team.name || "" } : undefined;
this.balance = balance;
this.autoRechargeEnabled = autoRechargeEnabled;
}

protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
Expand All @@ -44,12 +49,16 @@ export default class CreditBalanceLowWarningEmail extends BaseEmail {
balance: this.balance,
team: this.team,
user: this.user,
autoRechargeEnabled: this.autoRechargeEnabled,
}),
text: this.getTextBody(),
};
}

protected getTextBody(): string {
if (this.autoRechargeEnabled) {
return "Your team is running low on credits. Auto-recharge will be triggered soon.";
}
return "Your team is running low on credits. Please buy more credits.";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,26 @@ async function saveToCreditBalance({
}) {
const creditBalance = await CreditsRepository.findCreditBalance({ teamId, userId });

const stripeCustomerId = session.customer as string;

let creditBalanceId = creditBalance?.id;

if (creditBalance) {
await CreditsRepository.updateCreditBalance({
id: creditBalance.id,
data: { additionalCredits: { increment: nrOfCredits }, limitReachedAt: null, warningSentAt: null },
data: {
additionalCredits: { increment: nrOfCredits },
limitReachedAt: null,
warningSentAt: null,
stripeCustomerId: stripeCustomerId, // Store customer ID for future auto-recharge
},
});
} else {
const newCreditBalance = await CreditsRepository.createCreditBalance({
teamId: teamId,
userId: !teamId ? userId : undefined,
additionalCredits: nrOfCredits,
stripeCustomerId: stripeCustomerId, // Store customer ID for future auto-recharge
});
creditBalanceId = newCreditBalance.id;
}
Expand Down
Loading
Loading