fix: show personal credits even when user is on teams plan (#27511)

* show personal credits even if on team plan

* show personal credits even when on teams plan

* fix learn more link

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
Carina WollendorferandCarinaWolli authored and GitHub committed 2026-02-03 10:02:14 +01:00
1 parent 336295c0ab
commit fc3815a150
2 files changed
+147 -78

No files matched your search

@@ -1,14 +1,12 @@
"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";
@@ -24,6 +22,7 @@ import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { MemberInvitationModalWithoutMembers } from "~/ee/teams/components/MemberInvitationModal";
import { LearnMoreLink } from "~/event-types/components/LearnMoreLink";
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
@@ -42,7 +41,13 @@ type CreditRowProps = {
className?: string;
};
const CreditRow = ({ label, value, isBold = false, underline, className = "" }: CreditRowProps) => {
const CreditRow = ({
label,
value,
isBold = false,
underline,
className = "",
}: CreditRowProps) => {
const numberFormatter = new Intl.NumberFormat();
return (
<div
@@ -54,13 +59,22 @@ const CreditRow = ({ label, value, isBold = false, underline, className = "" }:
? "border-subtle border-b border-solid"
: "mt-1",
className
)}>
)}
>
<span
className={classNames("text-sm", isBold ? "font-semibold" : "text-subtle font-medium leading-tight")}>
className={classNames(
"text-sm",
isBold ? "font-semibold" : "font-medium leading-tight text-subtle"
)}
>
{label}
</span>
<span
className={classNames(`text-sm`, isBold ? "font-semibold" : "text-subtle font-medium leading-tight")}>
className={classNames(
`text-sm`,
isBold ? "font-semibold" : "font-medium leading-tight text-subtle"
)}
>
{numberFormatter.format(value)}
</span>
</div>
@@ -73,7 +87,10 @@ const getMonthOptions = (): MonthOption[] => {
let date = dayjs.utc();
let count = 0;
while ((date.isAfter(minDate) || date.isSame(minDate, "month")) && count < 12) {
while (
(date.isAfter(minDate) || date.isSame(minDate, "month")) &&
count < 12
) {
const startDate = date.startOf("month");
const endDate = date.endOf("month");
options.push({
@@ -96,9 +113,12 @@ export default function BillingCredits() {
const session = useSession();
const orgBranding = useOrgBranding();
const monthOptions = useMemo(() => getMonthOptions(), []);
const [selectedMonth, setSelectedMonth] = useState<MonthOption>(monthOptions[0]);
const [selectedMonth, setSelectedMonth] = useState<MonthOption>(
monthOptions[0]
);
const [isDownloading, setIsDownloading] = useState(false);
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
const [showMemberInvitationModal, setShowMemberInvitationModal] =
useState(false);
const utils = trpc.useUtils();
const {
@@ -122,14 +142,26 @@ export default function BillingCredits() {
const tokens = (pathname ?? "").split("/").filter(Boolean);
const settingsIndex = tokens.indexOf("settings");
const isOrgScopedPath =
settingsIndex >= 0 && ["organizations", "teams"].includes(tokens[settingsIndex + 1]);
settingsIndex >= 0 &&
["organizations", "teams"].includes(tokens[settingsIndex + 1]);
const shouldRender = IS_SMS_CREDITS_ENABLED && !(orgId && !isOrgScopedPath && !orgBranding?.slug);
const shouldRender =
IS_SMS_CREDITS_ENABLED &&
!(orgId && !isOrgScopedPath && !orgBranding?.slug);
const { data: creditsData, isLoading } = trpc.viewer.credits.getAllCredits.useQuery(
{ teamId },
{ enabled: shouldRender }
const { data: creditsData, isLoading } =
trpc.viewer.credits.getAllCredits.useQuery(
{ teamId },
{ enabled: shouldRender }
);
const { data: teamPlanData } = trpc.viewer.teams.hasTeamPlan.useQuery(
undefined,
{
enabled: shouldRender && !teamId,
}
);
const isUserInTeam = teamPlanData?.hasTeamPlan ?? false;
if (!shouldRender) return null;
@@ -153,7 +185,9 @@ export default function BillingCredits() {
endDate: selectedMonth.endDate,
});
if (result?.csvData) {
const filename = `credit-expense-log-${selectedMonth.value.toLowerCase().replace(" ", "-")}.csv`;
const filename = `credit-expense-log-${selectedMonth.value
.toLowerCase()
.replace(" ", "-")}.csv`;
downloadAsCsv(result.csvData, filename);
} else {
showToast(t("error_downloading_expense_log"), "error");
@@ -168,6 +202,11 @@ export default function BillingCredits() {
if (isLoading && teamId) return <BillingCreditsSkeleton />;
if (!creditsData) return null;
// For personal billing page: hide credits section if user is part of a team and has no personal credits
if (!teamId && isUserInTeam && creditsData.credits.additionalCredits <= 0) {
return null;
}
const onSubmit = (data: { quantity: number }) => {
buyCreditsMutation.mutate({ quantity: data.quantity, teamId });
};
@@ -175,15 +214,20 @@ export default function BillingCredits() {
const totalCredits = creditsData.credits.totalMonthlyCredits ?? 0;
const totalUsed = creditsData.credits.totalCreditsUsedThisMonth ?? 0;
const teamCreditsPercentageUsed = totalCredits > 0 ? (totalUsed / totalCredits) * 100 : 0;
const teamCreditsPercentageUsed =
totalCredits > 0 ? (totalUsed / totalCredits) * 100 : 0;
const numberFormatter = new Intl.NumberFormat();
return (
<>
<div className="bg-cal-muted border-muted mt-5 rounded-xl border p-1">
<div className="p-1 mt-5 rounded-xl border bg-cal-muted border-muted">
<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>
<h2 className="text-base font-semibold leading-none text-default">
{t("credits")}
</h2>
<p className="text-sm font-medium leading-tight text-subtle">
{t("view_and_manage_credits")}
</p>
</div>
<div className="bg-default border-muted flex w-full rounded-[10px] border px-5 py-4">
<div className="w-full">
@@ -196,20 +240,33 @@ export default function BillingCredits() {
isBold={true}
underline="dashed"
/>
<CreditRow label={t("credits_used")} value={totalUsed} underline="solid" />
<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} />
<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")}
<div className="flex flex-1 justify-between items-center mt-4">
<p className="text-sm font-medium leading-tight text-subtle">
{orgSlug
? t("credits_per_tip_org")
: t("credits_per_tip_teams")}
</p>
<Button onClick={() => setShowMemberInvitationModal(true)} size="sm" color="secondary">
<Button
onClick={() => setShowMemberInvitationModal(true)}
size="sm"
color="secondary"
>
{t("add_members_no_ellipsis")}
</Button>
</div>
@@ -227,51 +284,76 @@ export default function BillingCredits() {
<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 mt-4">
<div className="mr-auto -mb-1 w-full">
<div className="flex justify-between">
<Label>{t("additional_credits")}</Label>
<div className="mb-2 flex items-center gap-1">
<div className="flex gap-1 items-center mb-2">
<p className="text-sm font-semibold leading-none">
<span className="text-subtle font-medium">{t("current_balance")}</span>{" "}
{numberFormatter.format(creditsData.credits.additionalCredits)}
<span className="font-medium text-subtle">
{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" />
<Icon name="info" className="w-3 h-3 text-emphasis" />
</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")}</>}
{/* Users who are part of a team cannot buy personal credits */}
{teamId || !isUserInTeam ? (
<form
onSubmit={handleSubmit(onSubmit)}
className="flex gap-2 items-center w-full"
>
<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>
</form>
) : null}
{errors.quantity && (
<InputError
message={errors.quantity.message ?? t("invalid_input")}
/>
<Button color="secondary" target="_blank" size="sm" type="submit" data-testid="buy-credits">
{t("buy")}
</Button>
</div>
{errors.quantity && <InputError message={errors.quantity.message ?? t("invalid_input")} />}
)}
</div>
</form>
</div>
<div className="-mx-5 mt-5">
<hr className="border-subtle" />
</div>
{/*Download Expense Log*/}
<div className="mt-4 flex">
<div className="flex mt-4">
<div className="mr-auto w-full">
<Label className="mb-4">{t("download_expense_log")}</Label>
<div className="mr-2 mt-1">
<div className="mt-1 mr-2">
<Select
size="sm"
className="w-full"
@@ -285,7 +367,12 @@ export default function BillingCredits() {
</div>
</div>
<div className="mt-auto">
<Button onClick={handleDownload} loading={isDownloading} color="secondary" size="sm">
<Button
onClick={handleDownload}
loading={isDownloading}
color="secondary"
size="sm"
>
{t("download")}
</Button>
</div>
@@ -293,19 +380,11 @@ export default function BillingCredits() {
</div>
</div>
{/*Credit Worth Section*/}
<div className="text-subtle px-5 py-4 text-sm font-medium leading-tight">
<ServerTrans
<div className="px-5 py-4 text-sm font-medium leading-tight text-subtle">
<LearnMoreLink
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>,
]}
href="https://cal.com/help/billing-and-usage/messaging-credits"
/>
</div>
</div>
@@ -1,11 +1,8 @@
import { TeamService } from "@calcom/features/ee/teams/services/teamService";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import type { TGetAllCreditsSchema } from "./getAllCredits.schema";
type GetAllCreditsOptions = {
@@ -34,18 +31,11 @@ export const getAllCreditsHandler = async ({ ctx, input }: GetAllCreditsOptions)
code: "UNAUTHORIZED",
});
}
} else {
//if user is part of team, don't return any credits if teamId is not given
const memberships = await MembershipRepository.findAllAcceptedPublishedTeamMemberships(ctx.user.id);
if (memberships && memberships.length > 0) {
return null;
}
}
const { CreditService } = await import("@calcom/features/ee/billing/credit-service");
const creditService = new CreditService();
const credits = await creditService.getAllCredits({ userId: ctx.user.id, teamId });
return { credits };
};