feat: Billing page redesign plus credits (#23908)
* manage billing section * wip billing credits * WIP * WIP * Download expense log * credit worth * skeleton fixes * add org tip * add teams tip * restore service * type check * type check * fix types * additional credits * fix progress bar * add dashed prop * match new designs * hide area with no monthly credits * fix i18n * show current balance label * Update apps/web/modules/settings/billing/billing-view.tsx Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> * spacing + monthly credits not showing additional * Remove additional credits from monthly calculations * feat: replace add members redirect with invite modal in billing settings - Replace Button href with onClick handler to open MemberInvitationModal - Add MemberInvitationModalWithoutMembers import and state management - Maintain existing team/org context support - Follow established modal usage patterns from other components - Fix lint error by using undefined instead of empty arrow function Co-Authored-By: sean@cal.com <Sean@brydon.io> * Remove redudant vars from method * fix type check --------- Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
sean@cal.com
Carina Wollendorfer
Devin AI
parent
b407be933d
commit
cfeb18de3f
+1
-1
@@ -26,7 +26,7 @@ const Page = async () => {
|
||||
<SettingsHeader
|
||||
title={t("billing")}
|
||||
description={t("manage_billing_description")}
|
||||
borderInShellHeader={true}>
|
||||
borderInShellHeader={false}>
|
||||
<BillingView />
|
||||
</SettingsHeader>
|
||||
);
|
||||
|
||||
@@ -77,21 +77,26 @@ const BillingView = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-subtle space-y-6 rounded-b-lg border border-t-0 px-6 py-8 text-sm sm:space-y-8">
|
||||
<CtaRow title={t("view_and_manage_billing_details")} description={t("view_and_edit_billing_details")}>
|
||||
<Button color="primary" href={billingHref} target="_blank" EndIcon="external-link">
|
||||
<div className="bg-muted border-muted mt-5 rounded-xl border p-1">
|
||||
<div className="bg-default border-muted flex rounded-[10px] border px-5 py-4">
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<h3 className="text-emphasis text-sm font-semibold leading-none">{t("manage_billing")}</h3>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{t("view_and_manage_billing_details")}
|
||||
</p>
|
||||
</div>
|
||||
<Button color="primary" href={billingHref} target="_blank" size="sm" EndIcon="external-link">
|
||||
{t("billing_portal")}
|
||||
</Button>
|
||||
</CtaRow>
|
||||
</div>
|
||||
<BillingCredits />
|
||||
<div className="border-subtle mt-6 space-y-6 rounded-lg border px-6 py-8 text-sm sm:space-y-8">
|
||||
<CtaRow title={t("need_anything_else")} description={t("further_billing_help")}>
|
||||
<Button color="secondary" onClick={onContactSupportClick}>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-5">
|
||||
<p className="text-subtle text-sm font-medium leading-tight">{t("need_help")}</p>
|
||||
<Button color="secondary" size="sm" onClick={onContactSupportClick}>
|
||||
{t("contact_support")}
|
||||
</Button>
|
||||
</CtaRow>
|
||||
</div>
|
||||
</div>
|
||||
<BillingCredits />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,17 +8,21 @@ import { useForm } from "react-hook-form";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { MemberInvitationModalWithoutMembers } from "@calcom/features/ee/teams/components/MemberInvitationModal";
|
||||
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 classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Select } from "@calcom/ui/components/form";
|
||||
import { TextField, Label, InputError } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { ProgressBar } from "@calcom/ui/components/progress-bar";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
|
||||
|
||||
@@ -29,6 +33,39 @@ type MonthOption = {
|
||||
endDate: string;
|
||||
};
|
||||
|
||||
type CreditRowProps = {
|
||||
label: string;
|
||||
value: number;
|
||||
isBold?: boolean;
|
||||
underline?: "dashed" | "solid";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const CreditRow = ({ label, value, isBold = false, underline, className = "" }: CreditRowProps) => {
|
||||
const numberFormatter = new Intl.NumberFormat();
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
`my-1 flex justify-between`,
|
||||
underline === "dashed"
|
||||
? "border-subtle border-b border-dashed"
|
||||
: underline === "solid"
|
||||
? "border-subtle border-b border-solid"
|
||||
: "mt-1",
|
||||
className
|
||||
)}>
|
||||
<span
|
||||
className={classNames("text-sm", isBold ? "font-semibold" : "text-subtle font-medium leading-tight")}>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={classNames(`text-sm`, isBold ? "font-semibold" : "text-subtle font-medium leading-tight")}>
|
||||
{numberFormatter.format(value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getMonthOptions = (): MonthOption[] => {
|
||||
const options: MonthOption[] = [];
|
||||
const minDate = dayjs.utc("2025-05-01");
|
||||
@@ -60,6 +97,7 @@ export default function BillingCredits() {
|
||||
const monthOptions = useMemo(() => getMonthOptions(), []);
|
||||
const [selectedMonth, setSelectedMonth] = useState<MonthOption>(monthOptions[0]);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const {
|
||||
@@ -71,6 +109,7 @@ export default function BillingCredits() {
|
||||
|
||||
const params = useParamsWithFallback();
|
||||
const orgId = session.data?.user?.org?.id;
|
||||
const orgSlug = session.data?.user?.org?.slug;
|
||||
|
||||
const parsedTeamId = Number(params.id);
|
||||
const teamId: number | undefined = Number.isFinite(parsedTeamId)
|
||||
@@ -132,117 +171,153 @@ export default function BillingCredits() {
|
||||
buyCreditsMutation.mutate({ quantity: data.quantity, teamId });
|
||||
};
|
||||
|
||||
const teamCreditsPercentageUsed =
|
||||
creditsData.credits.totalMonthlyCredits > 0
|
||||
? (creditsData.credits.totalRemainingMonthlyCredits / creditsData.credits.totalMonthlyCredits) * 100
|
||||
: 0;
|
||||
const totalCredits = creditsData.credits.totalMonthlyCredits ?? 0;
|
||||
const totalUsed = creditsData.credits.totalCreditsUsedThisMonth ?? 0;
|
||||
|
||||
const teamCreditsPercentageUsed = totalCredits > 0 ? (totalUsed / totalCredits) * 100 : 0;
|
||||
const numberFormatter = new Intl.NumberFormat();
|
||||
|
||||
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 className="bg-muted border-muted mt-5 rounded-xl border p-1">
|
||||
<div className="flex flex-col gap-1 px-4 py-5">
|
||||
<h2 className="text-default text-base font-semibold leading-none">{t("credits")}</h2>
|
||||
<p className="text-subtle text-sm font-medium leading-tight">{t("view_and_manage_credits")}</p>
|
||||
</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 className="bg-default border-muted flex w-full rounded-[10px] border px-5 py-4">
|
||||
<div className="w-full">
|
||||
{totalCredits > 0 ? (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<CreditRow
|
||||
label={t("monthly_credits")}
|
||||
value={totalCredits}
|
||||
isBold={true}
|
||||
underline="dashed"
|
||||
/>
|
||||
<CreditRow label={t("credits_used")} value={totalUsed} underline="solid" />
|
||||
<CreditRow
|
||||
label={t("total_credits_remaining")}
|
||||
value={creditsData.credits.totalRemainingMonthlyCredits}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<ProgressBar color="green" percentageValue={100 - teamCreditsPercentageUsed} />
|
||||
</div>
|
||||
{/*750 credits per tip*/}
|
||||
<div className="mt-4 flex flex-1 items-center justify-between">
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{orgSlug ? t("credits_per_tip_org") : t("credits_per_tip_teams")}
|
||||
</p>
|
||||
<Button onClick={() => setShowMemberInvitationModal(true)} size="sm" color="secondary">
|
||||
{t("add_members_no_ellipsis")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{t("remaining_credits", {
|
||||
remainingCredits: creditsData.credits.totalRemainingMonthlyCredits,
|
||||
})}
|
||||
<div className="-mx-5 mt-5">
|
||||
<hr className="border-subtle" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
{/*Auto Top-Up goes here when we have it*/}
|
||||
{/*<div className="-mx-5 mt-5">
|
||||
<hr className="border-subtle" />
|
||||
</div>*/}
|
||||
{/*Additional Credits*/}
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="mt-4 flex">
|
||||
<div className="-mb-1 mr-auto w-full">
|
||||
<div className="flex justify-between">
|
||||
<Label>{t("additional_credits")}</Label>
|
||||
<div className="mb-2 flex items-center gap-1">
|
||||
<p className="text-sm font-semibold leading-none">
|
||||
<span className="text-subtle font-medium">{t("current_balance")}</span>{" "}
|
||||
{numberFormatter.format(creditsData.credits.additionalCredits)}
|
||||
</p>
|
||||
<Tooltip content={t("view_additional_credits_expense_tip")}>
|
||||
<Icon name="info" className="text-emphasis h-3 w-3" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<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-full -mt-1"
|
||||
size="sm"
|
||||
onChange={(e) => setValue("quantity", Number(e.target.value))}
|
||||
min={50}
|
||||
addOnSuffix={<>{t("credits")}</>}
|
||||
/>
|
||||
<Button color="secondary" target="_blank" size="sm" type="submit" data-testid="buy-credits">
|
||||
{t("buy")}
|
||||
</Button>
|
||||
</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>
|
||||
</form>
|
||||
<div className="-mx-5 mt-5">
|
||||
<hr className="border-subtle" />
|
||||
</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)}
|
||||
/>
|
||||
{/*Download Expense Log*/}
|
||||
<div className="mt-4 flex">
|
||||
<div className="mr-auto w-full">
|
||||
<Label className="mb-4">{t("download_expense_log")}</Label>
|
||||
<div className="mr-2 mt-1">
|
||||
<Select
|
||||
size="sm"
|
||||
className="w-full"
|
||||
innerClassNames={{
|
||||
control: "font-medium text-emphasis",
|
||||
}}
|
||||
options={monthOptions}
|
||||
value={selectedMonth}
|
||||
onChange={(option) => option && setSelectedMonth(option)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<Button onClick={handleDownload} loading={isDownloading} color="secondary" size="sm">
|
||||
{t("download")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<Button onClick={handleDownload} loading={isDownloading} StartIcon="file-down">
|
||||
{t("download")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/*Credit Worth Section*/}
|
||||
<div className="text-subtle px-5 py-4 text-sm font-medium leading-tight">
|
||||
<ServerTrans
|
||||
t={t}
|
||||
i18nKey="credit_worth_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>
|
||||
</div>
|
||||
</div>
|
||||
{teamId && (
|
||||
<MemberInvitationModalWithoutMembers
|
||||
teamId={teamId}
|
||||
showMemberInvitationModal={showMemberInvitationModal}
|
||||
hideInvitationModal={() => setShowMemberInvitationModal(false)}
|
||||
onSettingsOpen={() => {
|
||||
return;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,77 +3,76 @@ import { SkeletonText, SkeletonContainer, SkeletonButton } from "@calcom/ui/comp
|
||||
export function BillingCreditsSkeleton() {
|
||||
return (
|
||||
<SkeletonContainer>
|
||||
<div className="border-subtle mt-8 space-y-6 rounded-lg border px-6 py-6 text-sm sm:space-y-8">
|
||||
{/* Title and Description */}
|
||||
<div>
|
||||
<div>
|
||||
<SkeletonText className="mb-2 h-4 w-20" /> {/* Credits title */}
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonText className="h-4 w-72" /> {/* Description */}
|
||||
</div>
|
||||
|
||||
<div className="-mx-6 mt-6">
|
||||
<hr className="border-subtle" />
|
||||
</div>
|
||||
{/* Monthly credits */}
|
||||
<div className="mt-[20px]">
|
||||
<SkeletonText className="mb-4 h-4 w-32" /> {/* Monthly credits label */}
|
||||
<div className="h-2 w-full rounded-md bg-gray-200" /> {/* Progress bar */}
|
||||
<div className="text-subtle mt-2">
|
||||
<div>
|
||||
<SkeletonText className="h-4 w-40" /> {/* Total credits */}
|
||||
<div className="bg-muted border-muted mt-5 rounded-xl p-1">
|
||||
<div className="flex flex-col gap-1 px-4 py-5">
|
||||
<SkeletonText className="h-4 w-20" /> {/* Credits title */}
|
||||
<SkeletonText className="h-4 w-48" /> {/* View and manage credits description */}
|
||||
</div>
|
||||
<div className="bg-default border-muted flex w-full rounded-[10px] px-5 py-4">
|
||||
<div className="w-full">
|
||||
{/* Credits section */}
|
||||
<div className="mb-4">
|
||||
{/* Monthly credits row */}
|
||||
<div className="mt-1 flex justify-between">
|
||||
<SkeletonText className="h-4 w-32" />
|
||||
<SkeletonText className="h-4 w-20" />
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonText className="h-4 w-40" /> {/* Remaining credits */}
|
||||
{/* Additional credits row */}
|
||||
<div className="mt-1 flex justify-between">
|
||||
<SkeletonText className="h-4 w-36" />
|
||||
<SkeletonText className="h-4 w-20" />
|
||||
</div>
|
||||
{/* Remaining row */}
|
||||
<div className="mt-1 flex justify-between">
|
||||
<SkeletonText className="h-4 w-24" />
|
||||
<SkeletonText className="h-4 w-20" />
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="mt-4">
|
||||
<div className="h-2 w-full rounded-md bg-gray-200" />
|
||||
</div>
|
||||
{/* Credits per tip */}
|
||||
<div className="mt-4 flex flex-1 justify-between">
|
||||
<SkeletonText className="h-4 w-32" />
|
||||
<SkeletonButton className="h-8 w-24 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mx-5 mt-5">
|
||||
<hr className="border-subtle" />
|
||||
</div>
|
||||
{/* Additional Credits form */}
|
||||
<div className="mt-4 flex">
|
||||
<div className="-mb-1 mr-auto w-full">
|
||||
<SkeletonText className="mb-2 h-4 w-32" /> {/* Additional credits label */}
|
||||
<div className="flex w-full">
|
||||
<SkeletonText className="h-9 w-full rounded-md" /> {/* Input field */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 mt-auto">
|
||||
<SkeletonButton className="h-9 w-12 rounded-md" /> {/* Buy button */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mx-5 mt-5">
|
||||
<hr className="border-subtle" />
|
||||
</div>
|
||||
{/* Download Expense Log */}
|
||||
<div className="mt-4 flex">
|
||||
<div className="mr-auto w-full">
|
||||
<SkeletonText className="mb-4 h-4 w-40" /> {/* Download expense log label */}
|
||||
<div className="mr-2 mt-1">
|
||||
<SkeletonText className="h-9 w-full rounded-md" /> {/* Select dropdown */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<SkeletonButton className="h-9 w-20 rounded-md" /> {/* Download button */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Additional credits */}
|
||||
<div className="mt-4">
|
||||
<div>
|
||||
<SkeletonText className="mb-2 h-4 w-40" /> {/* Additional credits label */}
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonText className="h-4 w-24" /> {/* Additional credits value */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mx-6 mb-6 mt-4">
|
||||
<hr className="border-subtle mb-3 mt-3" />
|
||||
</div>
|
||||
{/* Buy credits form */}
|
||||
<div className="flex">
|
||||
<div className="mr-auto">
|
||||
<div>
|
||||
<SkeletonText className="mb-2 h-4 w-[268px]" /> {/* Buy credits label */}
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonText className="mt-2 h-8 w-[240px] rounded-md" /> {/* Input field */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto ">
|
||||
<SkeletonButton className="h-9 w-[120px] rounded-md" /> {/* Buy button */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mx-6 mb-6 mt-6">
|
||||
<hr className="border-subtle mb-3 mt-3" />
|
||||
</div>
|
||||
{/* Download expense log */}
|
||||
<div className="flex">
|
||||
<div className="mr-auto mt-1">
|
||||
<div>
|
||||
<SkeletonText className="mb-2 h-4 w-[156px]" /> {/* Label */}
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonText className="mt-2 h-8 w-[156px] rounded-md" /> {/* Select */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto ">
|
||||
<SkeletonButton className="h-9 w-[109px] rounded-md" /> {/* Download button */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Credit Worth Section */}
|
||||
<div className="text-subtle px-5 py-4">
|
||||
<SkeletonText className="h-4 w-full" />
|
||||
<SkeletonText className="mt-1 h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
|
||||
@@ -900,6 +900,7 @@
|
||||
"yes_cancel_subscription": "Yes, Cancel Subscription",
|
||||
"cancel_phone_number_subscription_confirmation": "Are you sure you want to cancel this phone number subscription? This action cannot be undone and you will lose access to this phone number.",
|
||||
"add_members": "Add members...",
|
||||
"add_members_no_ellipsis": "Add members",
|
||||
"no_assigned_members": "No assigned members",
|
||||
"assigned_to": "Assigned to",
|
||||
"you_must_be_logged_in_to": "You must be logged in to {{url}}",
|
||||
@@ -3234,9 +3235,15 @@
|
||||
"verify_email_change": "Verify email change",
|
||||
"buy_credits": "Buy Credits",
|
||||
"credits": "Credits",
|
||||
"view_and_manage_credits": "View and manage credits",
|
||||
"credits_used": "Credits used",
|
||||
"total_credits_remaining": "Total remaining",
|
||||
"credits_per_tip_org": "You receive 1000 credits per month, per team member",
|
||||
"credits_per_tip_teams": "You receive 750 credits per month, per team member",
|
||||
"view_and_manage_credits": "View and manage credits for sending SMS messages",
|
||||
"view_and_manage_credits_description": "View and manage credits for sending SMS messages. One credit is worth 1¢ (USD). <0>Learn more</0>",
|
||||
"credit_worth_description": "One credit is worth 1¢ (USD). <0>Learn more</0>",
|
||||
"buy_additional_credits": "Buy additional credits ($0.01 per credit)",
|
||||
"view_additional_credits_expense_tip": "You can view Additional credit spending in your expense log",
|
||||
"overview": "Overview",
|
||||
"organization_slug_taken": "Organization slug is already taken",
|
||||
"you_cannot_create_an_organization_as_you_are_already_part_of_an_organization": "You cannot create an organization as you are already a part of an organization",
|
||||
@@ -3417,10 +3424,13 @@
|
||||
"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",
|
||||
"current_balance": "Current balance:",
|
||||
"notification_about_your_booking": "Notification about your booking",
|
||||
"monthly_credits": "Monthly credits",
|
||||
"total_credits": "Total credits: {{totalCredits}}",
|
||||
"remaining_credits": "Remaining credits: {{remainingCredits}}",
|
||||
"remaining": "Remaining",
|
||||
"total": "Total",
|
||||
"additional_credits": "Additional credits",
|
||||
"routing_form_next_in_queue": "{{count}} next in queue",
|
||||
"routing_form_select_members_to_email": "Send email responses to",
|
||||
|
||||
@@ -59,14 +59,16 @@ vi.mock("@calcom/lib/server/repository/membership");
|
||||
vi.mock("@calcom/lib/server/repository/team");
|
||||
vi.mock("@calcom/emails/email-manager");
|
||||
vi.mock("../workflows/lib/reminders/reminderScheduler", () => ({
|
||||
cancelScheduledMessagesAndScheduleEmails: vi.fn(),
|
||||
cancelScheduledMessagesAndScheduleEmails: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const creditService = new CreditService();
|
||||
|
||||
vi.spyOn(creditService, "_getAllCreditsForTeam").mockResolvedValue({
|
||||
totalMonthlyCredits: 10,
|
||||
totalRemainingMonthlyCredits: 5,
|
||||
additionalCredits: 0,
|
||||
totalCreditsUsedThisMonth: 5,
|
||||
});
|
||||
|
||||
vi.spyOn(creditService, "_getTeamWithAvailableCredits").mockResolvedValue({
|
||||
@@ -233,6 +235,7 @@ describe("CreditService", () => {
|
||||
totalMonthlyCredits: 500,
|
||||
totalRemainingMonthlyCredits: 20,
|
||||
additionalCredits: 60,
|
||||
totalCreditsUsedThisMonth: 480,
|
||||
});
|
||||
|
||||
await creditService.chargeCredits({
|
||||
@@ -291,6 +294,7 @@ describe("CreditService", () => {
|
||||
totalMonthlyCredits: 500,
|
||||
totalRemainingMonthlyCredits: -1,
|
||||
additionalCredits: 0,
|
||||
totalCreditsUsedThisMonth: 501,
|
||||
});
|
||||
|
||||
await creditService.chargeCredits({
|
||||
@@ -321,6 +325,7 @@ describe("CreditService", () => {
|
||||
totalMonthlyCredits: 500,
|
||||
totalRemainingMonthlyCredits: 100,
|
||||
additionalCredits: 50,
|
||||
totalCreditsUsedThisMonth: 400,
|
||||
});
|
||||
|
||||
const result = await creditService.getUserOrTeamToCharge({
|
||||
@@ -340,6 +345,7 @@ describe("CreditService", () => {
|
||||
totalMonthlyCredits: 500,
|
||||
totalRemainingMonthlyCredits: 0,
|
||||
additionalCredits: 50,
|
||||
totalCreditsUsedThisMonth: 500,
|
||||
});
|
||||
|
||||
const result = await creditService.getUserOrTeamToCharge({
|
||||
@@ -483,13 +489,18 @@ describe("CreditService", () => {
|
||||
|
||||
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.mocked(CreditsRepository.findCreditBalanceWithExpenseLogs)
|
||||
.mockResolvedValueOnce({
|
||||
additionalCredits: 100,
|
||||
expenseLogs: [
|
||||
{ credits: 50, date: new Date() },
|
||||
{ credits: 30, date: new Date() },
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
additionalCredits: 100,
|
||||
expenseLogs: [{ credits: 80, date: new Date() }],
|
||||
});
|
||||
|
||||
vi.spyOn(CreditService.prototype, "getMonthlyCredits").mockResolvedValue(500);
|
||||
|
||||
@@ -498,14 +509,20 @@ describe("CreditService", () => {
|
||||
totalMonthlyCredits: 500,
|
||||
totalRemainingMonthlyCredits: 420, // 500 - (50 + 30)
|
||||
additionalCredits: 100,
|
||||
totalCreditsUsedThisMonth: 80, // 50 + 30
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle no expense logs", async () => {
|
||||
vi.mocked(CreditsRepository.findCreditBalanceWithExpenseLogs).mockResolvedValue({
|
||||
additionalCredits: 100,
|
||||
expenseLogs: [],
|
||||
});
|
||||
vi.mocked(CreditsRepository.findCreditBalanceWithExpenseLogs)
|
||||
.mockResolvedValueOnce({
|
||||
additionalCredits: 100,
|
||||
expenseLogs: [],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
additionalCredits: 100,
|
||||
expenseLogs: [],
|
||||
});
|
||||
|
||||
vi.spyOn(CreditService.prototype, "getMonthlyCredits").mockResolvedValue(500);
|
||||
|
||||
@@ -514,6 +531,57 @@ describe("CreditService", () => {
|
||||
totalMonthlyCredits: 500,
|
||||
totalRemainingMonthlyCredits: 500,
|
||||
additionalCredits: 100,
|
||||
totalCreditsUsedThisMonth: 0, // no expenses
|
||||
});
|
||||
});
|
||||
|
||||
it("should calculate total credits including additional credits for the month", async () => {
|
||||
vi.mocked(CreditsRepository.findCreditBalanceWithExpenseLogs)
|
||||
.mockResolvedValueOnce({
|
||||
additionalCredits: 150,
|
||||
expenseLogs: [
|
||||
{ credits: 80, date: new Date() },
|
||||
{ credits: 40, date: new Date() },
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
additionalCredits: 150,
|
||||
expenseLogs: [
|
||||
{ credits: 25, date: new Date() },
|
||||
{ credits: 15, date: new Date() },
|
||||
],
|
||||
});
|
||||
|
||||
vi.spyOn(CreditService.prototype, "getMonthlyCredits").mockResolvedValue(500);
|
||||
|
||||
const result = await creditService.getAllCreditsForTeam(1);
|
||||
expect(result).toEqual({
|
||||
totalMonthlyCredits: 500,
|
||||
totalRemainingMonthlyCredits: 380, // 500 - (80 + 40)
|
||||
additionalCredits: 150,
|
||||
totalCreditsUsedThisMonth: 120, // 80 + 40
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle zero additional credits", async () => {
|
||||
vi.mocked(CreditsRepository.findCreditBalanceWithExpenseLogs)
|
||||
.mockResolvedValueOnce({
|
||||
additionalCredits: 0,
|
||||
expenseLogs: [{ credits: 100, date: new Date() }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
additionalCredits: 0,
|
||||
expenseLogs: [],
|
||||
});
|
||||
|
||||
vi.spyOn(CreditService.prototype, "getMonthlyCredits").mockResolvedValue(500);
|
||||
|
||||
const result = await creditService.getAllCreditsForTeam(1);
|
||||
expect(result).toEqual({
|
||||
totalMonthlyCredits: 500,
|
||||
totalRemainingMonthlyCredits: 400, // 500 - 100
|
||||
additionalCredits: 0,
|
||||
totalCreditsUsedThisMonth: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -704,6 +772,7 @@ describe("CreditService", () => {
|
||||
totalMonthlyCredits: 500,
|
||||
totalRemainingMonthlyCredits: 200,
|
||||
additionalCredits: 100,
|
||||
totalCreditsUsedThisMonth: 300,
|
||||
});
|
||||
const result = await creditService.getTeamWithAvailableCredits(1);
|
||||
expect(result).toEqual({
|
||||
@@ -827,6 +896,7 @@ describe("CreditService", () => {
|
||||
totalMonthlyCredits: 500,
|
||||
totalRemainingMonthlyCredits: 100,
|
||||
additionalCredits: 100,
|
||||
totalCreditsUsedThisMonth: 400,
|
||||
});
|
||||
|
||||
await creditService.chargeCredits({
|
||||
|
||||
@@ -651,6 +651,7 @@ export class CreditService {
|
||||
totalMonthlyCredits: 0,
|
||||
totalRemainingMonthlyCredits: 0,
|
||||
additionalCredits: creditBalance?.additionalCredits ?? 0,
|
||||
totalCreditsUsedThisMonth: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -658,6 +659,7 @@ export class CreditService {
|
||||
totalMonthlyCredits: 0,
|
||||
totalRemainingMonthlyCredits: 0,
|
||||
additionalCredits: 0,
|
||||
totalCreditsUsedThisMonth: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -677,10 +679,14 @@ export class CreditService {
|
||||
const totalMonthlyCreditsUsed =
|
||||
creditBalance?.expenseLogs.reduce((sum, log) => sum + (log?.credits ?? 0), 0) || 0;
|
||||
|
||||
const additionalCredits = creditBalance?.additionalCredits ?? 0;
|
||||
const totalCreditsUsedThisMonth = totalMonthlyCreditsUsed;
|
||||
|
||||
return {
|
||||
totalMonthlyCredits,
|
||||
totalRemainingMonthlyCredits: Math.max(totalMonthlyCredits - totalMonthlyCreditsUsed, 0),
|
||||
additionalCredits: creditBalance?.additionalCredits ?? 0,
|
||||
additionalCredits,
|
||||
totalCreditsUsedThisMonth,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user