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:
sean-brydon
2025-09-19 16:18:42 +00:00
committed by GitHub
co-authored by sean@cal.com Carina Wollendorfer Devin AI
parent b407be933d
commit cfeb18de3f
7 changed files with 356 additions and 191 deletions
@@ -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>
+11 -1
View File
@@ -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,
};
}
}