From cfeb18de3f258ad6c049be24d98f7d0566c7cd7e Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Fri, 19 Sep 2025 17:18:42 +0100 Subject: [PATCH] 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 * 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> --- .../(org-admin-only)/billing/page.tsx | 2 +- .../modules/settings/billing/billing-view.tsx | 25 +- .../billing/components/BillingCredits.tsx | 273 +++++++++++------- .../components/BillingCreditsSkeleton.tsx | 133 +++++---- apps/web/public/static/locales/en/common.json | 12 +- .../ee/billing/credit-service.test.ts | 94 +++++- .../features/ee/billing/credit-service.ts | 8 +- 7 files changed, 356 insertions(+), 191 deletions(-) diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/billing/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/billing/page.tsx index 4b4f2b3930..97070aa5d0 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/billing/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/billing/page.tsx @@ -26,7 +26,7 @@ const Page = async () => { + borderInShellHeader={false}> ); diff --git a/apps/web/modules/settings/billing/billing-view.tsx b/apps/web/modules/settings/billing/billing-view.tsx index 739969cd40..1e324a34da 100644 --- a/apps/web/modules/settings/billing/billing-view.tsx +++ b/apps/web/modules/settings/billing/billing-view.tsx @@ -77,21 +77,26 @@ const BillingView = () => { return ( <> -
- - - -
- -
- -
+
+

{t("need_help")}

+ - +
+ ); }; diff --git a/apps/web/modules/settings/billing/components/BillingCredits.tsx b/apps/web/modules/settings/billing/components/BillingCredits.tsx index 8be53a386f..18bb117fb0 100644 --- a/apps/web/modules/settings/billing/components/BillingCredits.tsx +++ b/apps/web/modules/settings/billing/components/BillingCredits.tsx @@ -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 ( +
+ + {label} + + + {numberFormatter.format(value)} + +
+ ); +}; + 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(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 ( -
-
-

{t("credits")}

- - Learn more - , - ]} - /> -
-
+ <> +
+
+

{t("credits")}

+

{t("view_and_manage_credits")}

-
- {creditsData.credits.totalMonthlyCredits > 0 ? ( -
- - -
-
- {t("total_credits", { - totalCredits: creditsData.credits.totalMonthlyCredits, - })} +
+
+ {totalCredits > 0 ? ( + <> +
+ + + +
+ +
+ {/*750 credits per tip*/} +
+

+ {orgSlug ? t("credits_per_tip_org") : t("credits_per_tip_teams")} +

+ +
-
- {t("remaining_credits", { - remainingCredits: creditsData.credits.totalRemainingMonthlyCredits, - })} +
+
+
+ + ) : ( + <> + )} + + {/*Auto Top-Up goes here when we have it*/} + {/*
+
+
*/} + {/*Additional Credits*/} +
+
+
+ +
+

+ {t("current_balance")}{" "} + {numberFormatter.format(creditsData.credits.additionalCredits)} +

+ + + +
+
+
+ setValue("quantity", Number(e.target.value))} + min={50} + addOnSuffix={<>{t("credits")}} + /> +
-
-
- ) : ( - <> - )} - -
{creditsData.credits.additionalCredits}
-
-
-
- -
- -
- setValue("quantity", Number(e.target.value))} - min={50} - addOnSuffix={<>{t("credits")}} - /> {errors.quantity && }
+ +
+
-
- -
- -
-
-
-
-
- -
- option && setSelectedMonth(option)} + /> +
+
+
+
-
-
-
+ {/*Credit Worth Section*/} +
+ + Learn more + , + ]} + /> +
-
+ {teamId && ( + setShowMemberInvitationModal(false)} + onSettingsOpen={() => { + return; + }} + /> + )} + ); } diff --git a/apps/web/modules/settings/billing/components/BillingCreditsSkeleton.tsx b/apps/web/modules/settings/billing/components/BillingCreditsSkeleton.tsx index 7e49241f93..6de0b9576b 100644 --- a/apps/web/modules/settings/billing/components/BillingCreditsSkeleton.tsx +++ b/apps/web/modules/settings/billing/components/BillingCreditsSkeleton.tsx @@ -3,77 +3,76 @@ import { SkeletonText, SkeletonContainer, SkeletonButton } from "@calcom/ui/comp export function BillingCreditsSkeleton() { return ( -
- {/* Title and Description */} -
-
- {/* Credits title */} -
-
- {/* Description */} -
- -
-
-
- {/* Monthly credits */} -
- {/* Monthly credits label */} -
{/* Progress bar */} -
-
- {/* Total credits */} +
+
+ {/* Credits title */} + {/* View and manage credits description */} +
+
+
+ {/* Credits section */} +
+ {/* Monthly credits row */} +
+ +
-
- {/* Remaining credits */} + {/* Additional credits row */} +
+ + +
+ {/* Remaining row */} +
+ + +
+ {/* Progress bar */} +
+
+
+ {/* Credits per tip */} +
+ + +
+
+
+
+
+ {/* Additional Credits form */} +
+
+ {/* Additional credits label */} +
+ {/* Input field */} +
+
+
+ {/* Buy button */} +
+
+
+
+
+ {/* Download Expense Log */} +
+
+ {/* Download expense log label */} +
+ {/* Select dropdown */} +
+
+
+ {/* Download button */}
- {/* Additional credits */} -
-
- {/* Additional credits label */} -
-
- {/* Additional credits value */} -
-
-
-
-
- {/* Buy credits form */} -
-
-
- {/* Buy credits label */} -
-
- {/* Input field */} -
-
- -
- {/* Buy button */} -
-
-
-
-
- {/* Download expense log */} -
-
-
- {/* Label */} -
-
- {/* Select */} -
-
- -
- {/* Download button */} -
-
+
+ {/* Credit Worth Section */} +
+ +
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 90a32247a7..c3892521de 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", + "credit_worth_description": "One credit is worth 1¢ (USD). <0>Learn more", "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", diff --git a/packages/features/ee/billing/credit-service.test.ts b/packages/features/ee/billing/credit-service.test.ts index 702f93b9ca..e19106ab6a 100644 --- a/packages/features/ee/billing/credit-service.test.ts +++ b/packages/features/ee/billing/credit-service.test.ts @@ -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({ diff --git a/packages/features/ee/billing/credit-service.ts b/packages/features/ee/billing/credit-service.ts index 067b3f3dde..3f80f16d81 100644 --- a/packages/features/ee/billing/credit-service.ts +++ b/packages/features/ee/billing/credit-service.ts @@ -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, }; } }