* feat: Cal.ai Self Serve #2 * chore: fix import and remove logs * fix: update checkout session * fix: type errors and test * fix: imports * fix: type err * fix: type error * fix: tests * chore: save progress * fix: workflow flow * fix: workflow update bug * tests: add unit tests for retell ai webhoo * fix: status code * fix: test and delete bug * fix: add dynamic variables * fix: type err * chore: update unit test * fix: type error * chore: update default prompt * fix: type errors * fix: workflow permissions * fix: workflow page * fix: translations * feat: add call duration * chore: add booking uid * fix: button positioning * chore: update tests * chore: improvements * chore: some more improvements * refactor: improvements * refactor: code feedback * refactor: improvements * feat: enable credits for orgs (#23077) * Show credits UI for orgs * fix stripe callback url when buying credits * give orgs 20% credits * add test for calulating credits --------- Co-authored-by: CarinaWolli <wollencarina@gmail.com> Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> * fix: types * fix: types * chore: error * fix: type error * fix: type error * chore: mock env * feat: add idempotency key to prevent double charging * chore: add userId and teamId * fix: skip inbound calls * chore: update tests * feat: add feature flag for voice agent * feat: finish test call and other improvements * chore: add alert * chore: update .env.example * chore: improvements * fix: update tests * refactor: remove un necessary * feat: add setup badge * chore: improvements * fix: use referene id * chore: improvements * fix: type error * fix: type * refactor: change pricing logic * refactor: update tests * fix: conflicts * fix: billing link for orgs * fix: types * refactor: move feature flag up * fix: alert and test call credit check * fix: update unit tests * fix: feedback * refactor: improvements * refactor: move handlers to separate files * fix: types * fix: missing import * fix: type * refactor: change general tools functions handling * refactor: use repository * refactor: improvements * fix: types * fix: type errorr * fix: auth check * feat: add creditFor * fix: update defualt prompt * fix: throw error on frontend * fix: update unit tests * fix: use deleteAllWorkflowReminders * refactor: add connect phone number * refactor: improvements * chore: translation * chore: update message * chore: translation * design improvements buy number dialog * add translation for error message * use translation key in error message * refactor: improve connect phone number tab * feat: support un saved workflow to tests * chore: remove un used * fix: remove un used * fix: remove un used * refactor: similify billing --------- Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: CarinaWolli <wollencarina@gmail.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: Keith Williams <keithwillcode@gmail.com>
249 lines
8.4 KiB
TypeScript
249 lines
8.4 KiB
TypeScript
"use client";
|
|
|
|
import { useSession } from "next-auth/react";
|
|
import Link from "next/link";
|
|
import { useRouter, usePathname } from "next/navigation";
|
|
import { useState, useMemo } from "react";
|
|
import { useForm } from "react-hook-form";
|
|
|
|
import dayjs from "@calcom/dayjs";
|
|
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
|
import ServerTrans from "@calcom/lib/components/ServerTrans";
|
|
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
|
|
import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
|
import { trpc } from "@calcom/trpc/react";
|
|
import { Button } from "@calcom/ui/components/button";
|
|
import { Select } from "@calcom/ui/components/form";
|
|
import { TextField, Label, InputError } from "@calcom/ui/components/form";
|
|
import { ProgressBar } from "@calcom/ui/components/progress-bar";
|
|
import { showToast } from "@calcom/ui/components/toast";
|
|
|
|
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
|
|
|
|
type MonthOption = {
|
|
value: string;
|
|
label: string;
|
|
startDate: string;
|
|
endDate: string;
|
|
};
|
|
|
|
const getMonthOptions = (): MonthOption[] => {
|
|
const options: MonthOption[] = [];
|
|
const minDate = dayjs.utc("2025-05-01");
|
|
|
|
let date = dayjs.utc();
|
|
let count = 0;
|
|
while ((date.isAfter(minDate) || date.isSame(minDate, "month")) && count < 12) {
|
|
const startDate = date.startOf("month");
|
|
const endDate = date.endOf("month");
|
|
options.push({
|
|
value: date.format("MMMM YYYY"),
|
|
label: date.format("MMMM YYYY"),
|
|
startDate: startDate.toISOString(),
|
|
endDate: endDate.toISOString(),
|
|
});
|
|
date = date.subtract(1, "month");
|
|
count++;
|
|
}
|
|
|
|
return options;
|
|
};
|
|
|
|
export default function BillingCredits() {
|
|
const { t } = useLocale();
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const session = useSession();
|
|
const orgBranding = useOrgBranding();
|
|
const monthOptions = useMemo(() => getMonthOptions(), []);
|
|
const [selectedMonth, setSelectedMonth] = useState<MonthOption>(monthOptions[0]);
|
|
const [isDownloading, setIsDownloading] = useState(false);
|
|
const utils = trpc.useUtils();
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
setValue,
|
|
formState: { errors },
|
|
} = useForm<{ quantity: number }>({ defaultValues: { quantity: 50 } });
|
|
|
|
const params = useParamsWithFallback();
|
|
const orgId = session.data?.user?.org?.id;
|
|
|
|
const parsedTeamId = Number(params.id);
|
|
const teamId: number | undefined = Number.isFinite(parsedTeamId)
|
|
? parsedTeamId
|
|
: typeof orgId === "number"
|
|
? orgId
|
|
: undefined;
|
|
|
|
const tokens = (pathname ?? "").split("/").filter(Boolean);
|
|
const settingsIndex = tokens.indexOf("settings");
|
|
const isOrgScopedPath =
|
|
settingsIndex >= 0 && ["organizations", "teams"].includes(tokens[settingsIndex + 1]);
|
|
|
|
const shouldRender = IS_SMS_CREDITS_ENABLED && !(orgId && !isOrgScopedPath && !orgBranding?.slug);
|
|
|
|
const { data: creditsData, isLoading } = trpc.viewer.credits.getAllCredits.useQuery(
|
|
{ teamId },
|
|
{ enabled: shouldRender }
|
|
);
|
|
|
|
if (!shouldRender) return null;
|
|
|
|
const buyCreditsMutation = trpc.viewer.credits.buyCredits.useMutation({
|
|
onSuccess: (data) => {
|
|
if (data.sessionUrl) {
|
|
router.push(data.sessionUrl);
|
|
}
|
|
},
|
|
onError: () => {
|
|
showToast(t("credit_purchase_failed"), "error");
|
|
},
|
|
});
|
|
|
|
const handleDownload = async () => {
|
|
setIsDownloading(true);
|
|
try {
|
|
const result = await utils.viewer.credits.downloadExpenseLog.fetch({
|
|
teamId,
|
|
startDate: selectedMonth.startDate,
|
|
endDate: selectedMonth.endDate,
|
|
});
|
|
if (result?.csvData) {
|
|
const filename = `credit-expense-log-${selectedMonth.value.toLowerCase().replace(" ", "-")}.csv`;
|
|
downloadAsCsv(result.csvData, filename);
|
|
} else {
|
|
showToast(t("error_downloading_expense_log"), "error");
|
|
}
|
|
} catch (error) {
|
|
showToast(t("error_downloading_expense_log"), "error");
|
|
} finally {
|
|
setIsDownloading(false);
|
|
}
|
|
};
|
|
|
|
if (isLoading && teamId) return <BillingCreditsSkeleton />;
|
|
if (!creditsData) return null;
|
|
|
|
const onSubmit = (data: { quantity: number }) => {
|
|
buyCreditsMutation.mutate({ quantity: data.quantity, teamId });
|
|
};
|
|
|
|
const teamCreditsPercentageUsed =
|
|
creditsData.credits.totalMonthlyCredits > 0
|
|
? (creditsData.credits.totalRemainingMonthlyCredits / creditsData.credits.totalMonthlyCredits) * 100
|
|
: 0;
|
|
|
|
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>
|
|
<h2 className="text-base font-semibold">{t("credits")}</h2>
|
|
<ServerTrans
|
|
t={t}
|
|
i18nKey="view_and_manage_credits_description"
|
|
components={[
|
|
<Link
|
|
key="Credit System"
|
|
className="underline underline-offset-2"
|
|
target="_blank"
|
|
href="https://cal.com/help/billing-and-usage/messaging-credits">
|
|
Learn more
|
|
</Link>,
|
|
]}
|
|
/>
|
|
<div className="-mx-6 mt-6">
|
|
<hr className="border-subtle" />
|
|
</div>
|
|
<div className="mt-6">
|
|
{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>
|
|
) : (
|
|
<></>
|
|
)}
|
|
<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>
|
|
<form onSubmit={handleSubmit(onSubmit)} className="flex">
|
|
<div className="-mb-1 mr-auto">
|
|
<Label>{t("buy_additional_credits")}</Label>
|
|
<div className="flex flex-col">
|
|
<TextField
|
|
required
|
|
type="number"
|
|
{...register("quantity", {
|
|
required: t("error_required_field"),
|
|
min: { value: 50, message: t("minimum_of_credits_required") },
|
|
valueAsNumber: true,
|
|
})}
|
|
label=""
|
|
containerClassName="w-60"
|
|
onChange={(e) => setValue("quantity", Number(e.target.value))}
|
|
min={50}
|
|
addOnSuffix={<>{t("credits")}</>}
|
|
/>
|
|
{errors.quantity && <InputError message={errors.quantity.message ?? t("invalid_input")} />}
|
|
</div>
|
|
</div>
|
|
<div className="mt-auto">
|
|
<Button
|
|
color="primary"
|
|
target="_blank"
|
|
EndIcon="external-link"
|
|
type="submit"
|
|
data-testid="buy-credits">
|
|
{t("buy_credits")}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
<div className="-mx-6 mb-6 mt-6">
|
|
<hr className="border-subtle mb-3 mt-3" />
|
|
</div>
|
|
<div className="flex">
|
|
<div className="mr-auto">
|
|
<Label className="mb-4">{t("download_expense_log")}</Label>
|
|
<div className="mt-2 flex flex-col">
|
|
<Select
|
|
options={monthOptions}
|
|
value={selectedMonth}
|
|
onChange={(option) => option && setSelectedMonth(option)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="mt-auto">
|
|
<Button onClick={handleDownload} loading={isDownloading} StartIcon="file-down">
|
|
{t("download")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|