From e101ac341b8fa4a9c5b698f1c36cf3d1f5bfc16b Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 6 Feb 2026 17:41:20 -0800 Subject: [PATCH] Basic billing page is working --- messages/en-US.json | 13 + package.json | 3 +- server/lib/billing/features.ts | 27 +- server/lib/isSubscribed.ts | 3 + .../routers/approvals/listApprovals.ts | 1 - server/private/routers/billing/changeTier.ts | 10 +- .../routers/billing/createCheckoutSession.ts | 12 +- server/private/routers/billing/index.ts | 1 + server/private/routers/external.ts | 8 + .../loginPage/upsertLoginPageBranding.ts | 2 +- server/routers/idp/generateOidcUrl.ts | 2 +- server/routers/org/updateOrg.ts | 3 +- server/routers/user/createOrgUser.ts | 2 +- .../settings/(private)/billing/page.tsx | 1008 +++++++---------- 14 files changed, 451 insertions(+), 644 deletions(-) create mode 100644 server/lib/isSubscribed.ts diff --git a/messages/en-US.json b/messages/en-US.json index b08c1cf8..5616c666 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1520,6 +1520,19 @@ "resourcePortRequired": "Port number is required for non-HTTP resources", "resourcePortNotAllowed": "Port number should not be set for HTTP resources", "billingPricingCalculatorLink": "Pricing Calculator", + "billingYourPlan": "Your Plan", + "billingViewOrModifyPlan": "View or modify your current plan", + "billingViewPlanDetails": "View Plan Details", + "billingUsageAndLimits": "Usage and Limits", + "billingViewUsageAndLimits": "View your plan's limits and current usage", + "billingCurrentUsage": "Current Usage", + "billingMaximumLimits": "Maximum Limits", + "billingRemoteNodes": "Remote Nodes", + "billingUnlimited": "Unlimited", + "billingPaidLicenseKeys": "Paid License Keys", + "billingManageLicenseSubscription": "Manage your subscription for paid self-hosted license keys", + "billingCurrentKeys": "Current Keys", + "billingModifyCurrentPlan": "Modify Current Plan", "signUpTerms": { "IAgreeToThe": "I agree to the", "termsOfService": "terms of service", diff --git a/package.json b/package.json index 63d844bc..69da4cdf 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts", "dev:check": "npx tsc --noEmit && npm run format:check", "dev:setup": "cp config/config.example.yml config/config.yml && npm run set:oss && npm run set:sqlite && npm run db:generate && npm run db:sqlite:push", - "db:generate": "drizzle-kit generate --config=./drizzle.config.ts", + "db:pg:generate": "drizzle-kit generate --config=./drizzle.pg.config.ts", + "db:sqlite:generate": "drizzle-kit generate --config=./drizzle.sqlite.config.ts", "db:pg:push": "npx tsx server/db/pg/migrate.ts", "db:sqlite:push": "npx tsx server/db/sqlite/migrate.ts", "db:studio": "drizzle-kit studio --config=./drizzle.config.ts", diff --git a/server/lib/billing/features.ts b/server/lib/billing/features.ts index a3ab0cc8..b2eb2f0a 100644 --- a/server/lib/billing/features.ts +++ b/server/lib/billing/features.ts @@ -1,4 +1,5 @@ import Stripe from "stripe"; +import { usageService } from "./usageService"; export enum FeatureId { USERS = "users", @@ -95,10 +96,24 @@ export function getScaleFeaturePriceSet(): FeaturePriceSet { } } -export function getLineItems( - featurePriceSet: FeaturePriceSet -): Stripe.Checkout.SessionCreateParams.LineItem[] { - return Object.entries(featurePriceSet).map(([featureId, priceId]) => ({ - price: priceId - })); +export async function getLineItems( + featurePriceSet: FeaturePriceSet, + orgId: string, +): Promise { + const users = await usageService.getUsageDaily(orgId, FeatureId.USERS); + + return Object.entries(featurePriceSet).map(([featureId, priceId]) => { + let quantity: number | undefined; + + if (featureId === FeatureId.USERS) { + quantity = users?.instantaneousValue || 1; + } else if (featureId === FeatureId.HOME_LAB) { + quantity = 1; + } + + return { + price: priceId, + quantity: quantity + }; + }); } diff --git a/server/lib/isSubscribed.ts b/server/lib/isSubscribed.ts new file mode 100644 index 00000000..44a4c0b3 --- /dev/null +++ b/server/lib/isSubscribed.ts @@ -0,0 +1,3 @@ +export async function isSubscribed(orgId: string): Promise { + return false; +} diff --git a/server/private/routers/approvals/listApprovals.ts b/server/private/routers/approvals/listApprovals.ts index 509df5eb..753a2f1a 100644 --- a/server/private/routers/approvals/listApprovals.ts +++ b/server/private/routers/approvals/listApprovals.ts @@ -31,7 +31,6 @@ import { import { eq, isNull, sql, not, and, desc } from "drizzle-orm"; import response from "@server/lib/response"; import { getUserDeviceName } from "@server/db/names"; -import { isLicensedOrSubscribed } from "@server/private/lib/isLicencedOrSubscribed"; const paramsSchema = z.strictObject({ orgId: z.string() diff --git a/server/private/routers/billing/changeTier.ts b/server/private/routers/billing/changeTier.ts index 68a90c92..0d966346 100644 --- a/server/private/routers/billing/changeTier.ts +++ b/server/private/routers/billing/changeTier.ts @@ -25,6 +25,7 @@ import { getHomeLabFeaturePriceSet, getScaleFeaturePriceSet, getStarterFeaturePriceSet, + getLineItems, FeatureId, type FeaturePriceSet } from "@server/lib/billing"; @@ -149,7 +150,7 @@ export async function changeTier( // Determine if we're switching between different products // home_lab uses HOME_LAB product, starter/scale use USERS product const currentTier = subscription.type; - const switchingProducts = + const switchingProducts = (currentTier === "home_lab" && (tier === "starter" || tier === "scale")) || ((currentTier === "starter" || currentTier === "scale") && tier === "home_lab"); @@ -175,10 +176,9 @@ export async function changeTier( } // Add new items for the target tier - for (const [featureId, priceId] of Object.entries(targetPriceSet)) { - itemsToUpdate.push({ - price: priceId - }); + const newLineItems = await getLineItems(targetPriceSet, orgId); + for (const lineItem of newLineItems) { + itemsToUpdate.push(lineItem); } updatedSubscription = await stripe!.subscriptions.update( diff --git a/server/private/routers/billing/createCheckoutSession.ts b/server/private/routers/billing/createCheckoutSession.ts index a90c5e86..1a1c5c41 100644 --- a/server/private/routers/billing/createCheckoutSession.ts +++ b/server/private/routers/billing/createCheckoutSession.ts @@ -23,6 +23,8 @@ import config from "@server/lib/config"; import { fromError } from "zod-validation-error"; import stripe from "#private/lib/stripe"; import { getHomeLabFeaturePriceSet, getLineItems, getScaleFeaturePriceSet, getStarterFeaturePriceSet } from "@server/lib/billing"; +import { usageService } from "@server/lib/billing/usageService"; +import Stripe from "stripe"; const createCheckoutSessionSchema = z.strictObject({ orgId: z.string() @@ -80,19 +82,21 @@ export async function createCheckoutSession( ); } - let lineItems; + let lineItems: Stripe.Checkout.SessionCreateParams.LineItem[]; if (tier === "home_lab") { - lineItems = getLineItems(getHomeLabFeaturePriceSet()); + lineItems = await getLineItems(getHomeLabFeaturePriceSet(), orgId); } else if (tier === "starter") { - lineItems = getLineItems(getStarterFeaturePriceSet()); + lineItems = await getLineItems(getStarterFeaturePriceSet(), orgId); } else if (tier === "scale") { - lineItems = getLineItems(getScaleFeaturePriceSet()); + lineItems = await getLineItems(getScaleFeaturePriceSet(), orgId); } else { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid plan") ); } + logger.debug(`Line items: ${JSON.stringify(lineItems)}`) + const session = await stripe!.checkout.sessions.create({ client_reference_id: orgId, // So we can look it up the org later on the webhook billing_address_collection: "required", diff --git a/server/private/routers/billing/index.ts b/server/private/routers/billing/index.ts index b7bf02d4..6555f549 100644 --- a/server/private/routers/billing/index.ts +++ b/server/private/routers/billing/index.ts @@ -16,3 +16,4 @@ export * from "./createPortalSession"; export * from "./getOrgSubscriptions"; export * from "./getOrgUsage"; export * from "./internalGetOrgTier"; +export * from "./changeTier"; diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 0ef9077d..bef493ca 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -151,6 +151,14 @@ if (build === "saas") { billing.createCheckoutSession ); + authenticated.post( + "/org/:orgId/billing/change-tier", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.billing), + logActionAudit(ActionsEnum.billing), + billing.changeTier + ); + authenticated.post( "/org/:orgId/billing/create-portal-session", verifyOrgAccess, diff --git a/server/private/routers/loginPage/upsertLoginPageBranding.ts b/server/private/routers/loginPage/upsertLoginPageBranding.ts index e81628dc..bc93bfc0 100644 --- a/server/private/routers/loginPage/upsertLoginPageBranding.ts +++ b/server/private/routers/loginPage/upsertLoginPageBranding.ts @@ -26,7 +26,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, InferInsertModel } from "drizzle-orm"; import { build } from "@server/build"; -import config from "@server/private/lib/config"; +import config from "#private/lib/config"; const paramsSchema = z.strictObject({ orgId: z.string() diff --git a/server/routers/idp/generateOidcUrl.ts b/server/routers/idp/generateOidcUrl.ts index 5743631b..46f34ef5 100644 --- a/server/routers/idp/generateOidcUrl.ts +++ b/server/routers/idp/generateOidcUrl.ts @@ -14,7 +14,7 @@ import jsonwebtoken from "jsonwebtoken"; import config from "@server/lib/config"; import { decrypt } from "@server/lib/crypto"; import { build } from "@server/build"; -import { isSubscribed } from "@server/private/lib/isSubscribed"; +import { isSubscribed } from "#dynamic/lib/isSubscribed"; const paramsSchema = z .object({ diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index 38ffab18..4762c32f 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -12,8 +12,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import { build } from "@server/build"; import { cache } from "@server/lib/cache"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; -import { subscribe } from "node:diagnostics_channel"; -import { isSubscribed } from "@server/private/lib/isSubscribed"; +import { isSubscribed } from "#dynamic/lib/isSubscribed"; const updateOrgParamsSchema = z.strictObject({ orgId: z.string() diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index 3fe72a35..c061ef27 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -14,7 +14,7 @@ import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; import { build } from "@server/build"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; -import { isSubscribed } from "@server/private/lib/isSubscribed"; +import { isSubscribed } from "#dynamic/lib/isSubscribed"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 259d8a66..51424d3b 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -17,23 +17,21 @@ import { SettingsSectionBody, SettingsSectionFooter } from "@app/components/Settings"; -import { Badge } from "@/components/ui/badge"; -import { Separator } from "@/components/ui/separator"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Progress } from "@/components/ui/progress"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import { cn } from "@app/lib/cn"; import { CreditCard, - Database, - Clock, - AlertCircle, - CheckCircle, - Users, - Calculator, ExternalLink, - Gift, - Server + Users, + Globe, + Server, + Layout } from "lucide-react"; -import { InfoPopup } from "@/components/ui/info-popup"; import { GetOrgSubscriptionResponse, GetOrgUsageResponse @@ -41,13 +39,60 @@ import { import { useTranslations } from "use-intl"; import Link from "next/link"; -export default function GeneralPage() { +// Plan tier definitions matching the mockup +type PlanId = "starter" | "homelab" | "team" | "business" | "enterprise"; + +interface PlanOption { + id: PlanId; + name: string; + price: string; + priceDetail?: string; + tierType: "home_lab" | "starter" | "scale" | null; // Maps to backend tier types +} + +const planOptions: PlanOption[] = [ + { + id: "starter", + name: "Starter", + price: "Free", + tierType: null + }, + { + id: "homelab", + name: "Homelab", + price: "$15", + priceDetail: "/ month", + tierType: "home_lab" + }, + { + id: "team", + name: "Team", + price: "$5", + priceDetail: "per user / month", + tierType: "starter" + }, + { + id: "business", + name: "Business", + price: "$10", + priceDetail: "per user / month", + tierType: "scale" + }, + { + id: "enterprise", + name: "Enterprise", + price: "Custom", + tierType: null + } +]; + +export default function BillingPage() { const { org } = useOrgContext(); const envContext = useEnvContext(); const api = createApiClient(envContext); const t = useTranslations(); - // Subscription state - now handling multiple subscriptions + // Subscription state const [allSubscriptions, setAllSubscriptions] = useState< GetOrgSubscriptionResponse["subscriptions"] >([]); @@ -57,7 +102,7 @@ export default function GeneralPage() { useState(null); const [subscriptionLoading, setSubscriptionLoading] = useState(true); - // Example usage data (replace with real usage data if available) + // Usage and limits data const [usageData, setUsageData] = useState( [] ); @@ -65,6 +110,18 @@ export default function GeneralPage() { [] ); + const [hasSubscription, setHasSubscription] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [currentTier, setCurrentTier] = useState< + "home_lab" | "starter" | "scale" | null + >(null); + + // Usage IDs + const SITES = "sites"; + const USERS = "users"; + const DOMAINS = "domains"; + const REMOTE_EXIT_NODES = "remoteExitNodes"; + useEffect(() => { async function fetchSubscription() { setSubscriptionLoading(true); @@ -75,37 +132,31 @@ export default function GeneralPage() { const { subscriptions } = res.data.data; setAllSubscriptions(subscriptions); - // Import tier and license price sets - const { getLicensePriceSet } = await import("@server/lib/billing/licenses"); - - const tierPriceSet = getTierPriceSet( - envContext.env.app.environment, - envContext.env.app.sandbox_mode - ); - const licensePriceSet = getLicensePriceSet( - envContext.env.app.environment, - envContext.env.app.sandbox_mode - ); - - // Find tier subscription (subscription with items matching tier prices) - const tierSub = subscriptions.find(({ items }) => - items.some((item) => - item.priceId && Object.values(tierPriceSet).includes(item.priceId) - ) + // Find tier subscription + const tierSub = subscriptions.find(({ subscription }) => + subscription?.type === "home_lab" || + subscription?.type === "starter" || + subscription?.type === "scale" ); setTierSubscription(tierSub || null); - // Find license subscription (subscription with items matching license prices) - const licenseSub = subscriptions.find(({ items }) => - items.some((item) => - item.priceId && Object.values(licensePriceSet).includes(item.priceId) - ) + if (tierSub?.subscription) { + setCurrentTier( + tierSub.subscription.type as + | "home_lab" + | "starter" + | "scale" + ); + setHasSubscription( + tierSub.subscription.status === "active" + ); + } + + // Find license subscription + const licenseSub = subscriptions.find( + ({ subscription }) => subscription?.type === "license" ); setLicenseSubscription(licenseSub || null); - - setHasSubscription( - !!tierSub?.subscription && tierSub.subscription.status === "active" - ); } catch (error) { toast({ title: t("billingFailedToLoadSubscription"), @@ -126,7 +177,6 @@ export default function GeneralPage() { `/org/${org.org.orgId}/billing/usage` ); const { usage, limits } = res.data.data; - setUsageData(usage); setLimitsData(limits); } catch (error) { @@ -135,27 +185,18 @@ export default function GeneralPage() { description: formatAxiosError(error), variant: "destructive" }); - } finally { } } fetchUsage(); }, [org.org.orgId]); - const [hasSubscription, setHasSubscription] = useState(true); - const [isLoading, setIsLoading] = useState(false); - // const [newPricing, setNewPricing] = useState({ - // pricePerGB: mockSubscription.pricePerGB, - // pricePerMinute: mockSubscription.pricePerMinute, - // }) - - const handleStartSubscription = async () => { + const handleStartSubscription = async (tier: "home_lab" | "starter" | "scale") => { setIsLoading(true); try { const response = await api.post>( `/org/${org.org.orgId}/billing/create-checkout-session`, - {} + { tier } ); - console.log("Checkout session response:", response.data); const checkoutUrl = response.data.data; if (checkoutUrl) { window.location.href = checkoutUrl; @@ -205,209 +246,127 @@ export default function GeneralPage() { } }; - // Usage IDs - const SITES = "sites"; - const USERS = "users"; - const EGRESS_DATA_MB = "egressDataMb"; - const DOMAINS = "domains"; - const REMOTE_EXIT_NODES = "remoteExitNodes"; - - // Helper to calculate tiered price - function calculateTieredPrice( - usage: number, - tiersRaw: string | null | undefined - ) { - if (!tiersRaw) return 0; - let tiers: any[] = []; - try { - tiers = JSON.parse(tiersRaw); - } catch { - return 0; + const handleChangeTier = async (tier: "home_lab" | "starter" | "scale") => { + if (!hasSubscription) { + // If no subscription, start a new one + handleStartSubscription(tier); + return; } - let total = 0; - let remaining = usage; - for (const tier of tiers) { - const upTo = tier.up_to === null ? Infinity : Number(tier.up_to); - const unitAmount = - tier.unit_amount !== null - ? Number(tier.unit_amount / 100) - : tier.unit_amount_decimal - ? Number(tier.unit_amount_decimal / 100) - : 0; - const tierQty = Math.min( - remaining, - upTo === Infinity ? remaining : upTo - (usage - remaining) - ); - if (tierQty > 0) { - total += tierQty * unitAmount; - remaining -= tierQty; + + setIsLoading(true); + try { + await api.post(`/org/${org.org.orgId}/billing/change-tier`, { + tier + }); + // Refresh subscription data + window.location.reload(); + } catch (error) { + toast({ + title: "Failed to change tier", + description: formatAxiosError(error), + variant: "destructive" + }); + setIsLoading(false); + } + }; + + const handleContactUs = () => { + window.open("mailto:sales@pangolin.net", "_blank"); + }; + + // Get current plan ID from tier + const getCurrentPlanId = (): PlanId => { + if (!hasSubscription || !currentTier) return "starter"; + const plan = planOptions.find((p) => p.tierType === currentTier); + return plan?.id || "starter"; + }; + + const currentPlanId = getCurrentPlanId(); + + // Get button label and action for each plan + const getPlanAction = (plan: PlanOption) => { + if (plan.id === "enterprise") { + return { + label: "Contact Us", + action: handleContactUs, + variant: "outline" as const, + disabled: false + }; + } + + if (plan.id === currentPlanId) { + // If it's the free plan (starter with no subscription), show as current but disabled + if (plan.id === "starter" && !hasSubscription) { + return { + label: "Current Plan", + action: () => {}, + variant: "default" as const, + disabled: true + }; } - if (remaining <= 0) break; - } - return total; - } - - function getDisplayPrice(tiersRaw: string | null | undefined) { - //find the first non-zero tier price - if (!tiersRaw) return "$0.00"; - let tiers: any[] = []; - try { - tiers = JSON.parse(tiersRaw); - } catch { - return "$0.00"; - } - if (tiers.length === 0) return "$0.00"; - - // find the first tier with a non-zero price - const firstTier = - tiers.find( - (t) => - t.unit_amount > 0 || - (t.unit_amount_decimal && Number(t.unit_amount_decimal) > 0) - ) || tiers[0]; - const unitAmount = - firstTier.unit_amount !== null - ? Number(firstTier.unit_amount / 100) - : firstTier.unit_amount_decimal - ? Number(firstTier.unit_amount_decimal / 100) - : 0; - return `$${unitAmount.toFixed(4)}`; // ${firstTier.up_to === null ? "per unit" : `per ${firstTier.up_to} units`}`; - } - - // Helper to get included usage amount from subscription tier - function getIncludedUsage(tiersRaw: string | null | undefined) { - if (!tiersRaw) return 0; - let tiers: any[] = []; - try { - tiers = JSON.parse(tiersRaw); - } catch { - return 0; - } - if (tiers.length === 0) return 0; - - // Find the first tier (which represents included usage) - const firstTier = tiers[0]; - if (!firstTier) return 0; - - // If the first tier has a unit_amount of 0, it represents included usage - const isIncludedTier = - (firstTier.unit_amount === 0 || firstTier.unit_amount === null) && - (!firstTier.unit_amount_decimal || - Number(firstTier.unit_amount_decimal) === 0); - - if (isIncludedTier && firstTier.up_to !== null) { - return Number(firstTier.up_to); + return { + label: "Modify Current Plan", + action: handleModifySubscription, + variant: "default" as const, + disabled: false + }; } + const currentIndex = planOptions.findIndex( + (p) => p.id === currentPlanId + ); + const planIndex = planOptions.findIndex((p) => p.id === plan.id); + + if (planIndex < currentIndex) { + return { + label: "Downgrade", + action: () => + plan.tierType + ? handleChangeTier(plan.tierType) + : handleModifySubscription(), + variant: "outline" as const, + disabled: false + }; + } + + return { + label: "Upgrade", + action: () => + plan.tierType + ? hasSubscription + ? handleChangeTier(plan.tierType) + : handleStartSubscription(plan.tierType) + : handleModifySubscription(), + variant: "outline" as const, + disabled: false + }; + }; + + // Get usage value by feature ID + const getUsageValue = (featureId: string): number => { + const usage = usageData.find((u) => u.featureId === featureId); + return usage?.instantaneousValue || usage?.latestValue || 0; + }; + + // Get limit value by feature ID + const getLimitValue = (featureId: string): number | null => { + const limit = limitsData.find((l) => l.featureId === featureId); + return limit?.value ?? null; + }; + + // Calculate current usage cost for display + const getUserCount = () => getUsageValue(USERS); + const getPricePerUser = () => { + if (currentTier === "starter") return 5; + if (currentTier === "scale") return 10; return 0; - } + }; - // Helper to get display value for included usage - function getIncludedUsageDisplay(includedAmount: number, usageType: any) { - if (includedAmount === 0) return "0"; - - if (usageType.id === EGRESS_DATA_MB) { - // Convert MB to GB for data usage - return (includedAmount / 1000).toFixed(2); - } - - if (usageType.id === USERS || usageType.id === DOMAINS) { - // divide by 32 days - return (includedAmount / 32).toFixed(2); - } - - return includedAmount.toString(); - } - - // Helper to get usage, subscription item, and limit by usageId - function getUsageItemAndLimit( - usageData: any[], - subscriptionItems: any[], - limitsData: any[], - usageId: string - ) { - const usage = usageData.find((u) => u.featureId === usageId); - if (!usage) return { usage: 0, item: undefined, limit: undefined }; - const item = subscriptionItems.find((i) => i.meterId === usage.meterId); - const limit = limitsData.find((l) => l.featureId === usageId); - return { usage: usage ?? 0, item, limit }; - } - - // Get tier subscription items - const tierSubscriptionItems = tierSubscription?.items || []; - const tierSubscriptionData = tierSubscription?.subscription || null; - - // Helper to check if usage exceeds limit - function isOverLimit(usage: any, limit: any, usageType: any) { - if (!limit || !usage) return false; - const currentUsage = usageType.getLimitUsage(usage); - return currentUsage > limit.value; - } - - // Map usage and pricing for each usage type - const usageTypes = [ - { - id: EGRESS_DATA_MB, - label: t("billingDataUsage"), - icon: , - unit: "GB", - unitRaw: "MB", - info: t("billingDataUsageInfo"), - note: "Not counted on self-hosted nodes", - // Convert MB to GB for display and pricing - getDisplay: (v: any) => (v.latestValue / 1000).toFixed(2), - getLimitDisplay: (v: any) => (v.value / 1000).toFixed(2), - getUsage: (v: any) => v.latestValue, - getLimitUsage: (v: any) => v.latestValue - }, - { - id: SITES, - label: t("billingSites"), - icon: , - unit: "", - info: t("billingSitesInfo"), - getDisplay: (v: any) => v.latestValue, - getLimitDisplay: (v: any) => v.value, - getUsage: (v: any) => v.latestValue, - getLimitUsage: (v: any) => v.latestValue - }, - { - id: USERS, - label: t("billingUsers"), - icon: , - unit: "", - unitRaw: "user days", - info: t("billingUsersInfo"), - getDisplay: (v: any) => v.instantaneousValue, - getLimitDisplay: (v: any) => v.value, - getUsage: (v: any) => v.latestValue, - getLimitUsage: (v: any) => v.instantaneousValue - }, - { - id: DOMAINS, - label: t("billingDomains"), - icon: , - unit: "", - unitRaw: "domain days", - info: t("billingDomainInfo"), - getDisplay: (v: any) => v.instantaneousValue, - getLimitDisplay: (v: any) => v.value, - getUsage: (v: any) => v.latestValue, - getLimitUsage: (v: any) => v.instantaneousValue - }, - { - id: REMOTE_EXIT_NODES, - label: t("billingRemoteExitNodes"), - icon: , - unit: "", - unitRaw: "node days", - info: t("billingRemoteExitNodesInfo"), - getDisplay: (v: any) => v.instantaneousValue, - getLimitDisplay: (v: any) => v.value, - getUsage: (v: any) => v.latestValue, - getLimitUsage: (v: any) => v.instantaneousValue - } - ]; + // Get license key count + const getLicenseKeyCount = (): number => { + if (!licenseSubscription?.items) return 0; + return licenseSubscription.items.length; + }; if (subscriptionLoading) { return ( @@ -419,420 +378,225 @@ export default function GeneralPage() { return ( -
- - {tierSubscriptionData?.status === "active" && ( - - )} - {tierSubscriptionData - ? tierSubscriptionData.status.charAt(0).toUpperCase() + - tierSubscriptionData.status.slice(1) - : t("billingFreeTier")} - - - {t("billingPricingCalculatorLink")} - - -
- - {usageTypes.some((type) => { - const { usage, limit } = getUsageItemAndLimit( - usageData, - tierSubscriptionItems, - limitsData, - type.id - ); - return isOverLimit(usage, limit, type); - }) && ( - - - - {t("billingWarningOverLimit")} - - - )} - + {/* Your Plan Section */} - {t("billingUsageLimitsOverview")} + {t("billingYourPlan") || "Your Plan"} - {t("billingMonitorUsage")} + {t("billingViewOrModifyPlan") || + "View or modify your current plan"} -
- {usageTypes.map((type) => { - const { usage, limit } = getUsageItemAndLimit( - usageData, - tierSubscriptionItems, - limitsData, - type.id - ); - const displayUsage = type.getDisplay(usage); - const usageForPricing = type.getLimitUsage(usage); - const overLimit = isOverLimit(usage, limit, type); - const percentage = limit - ? Math.min( - (usageForPricing / limit.value) * 100, - 100 - ) - : 0; + {/* Plan Cards Grid */} +
+ {planOptions.map((plan) => { + const isCurrentPlan = plan.id === currentPlanId; + const planAction = getPlanAction(plan); return ( -
-
-
- {type.icon} - - {type.label} - - +
+
+
+ {plan.name}
-
- - {displayUsage} {type.unit} +
+ + {plan.price} - {limit && ( - - {" "} - /{" "} - {type.getLimitDisplay( - limit - )}{" "} - {type.unit} + {plan.priceDetail && ( + + {plan.priceDetail} )}
- {type.note && ( -
- {type.note} -
- )} - {limit && ( - + +
); })}
+ + + + + - {(hasSubscription || - (!hasSubscription && limitsData.length > 0)) && ( + {/* Usage and Limits Section */} + + + + {t("billingUsageAndLimits") || "Usage and Limits"} + + + {t("billingViewUsageAndLimits") || + "View your plan's limits and current usage"} + + + +
+ {/* Current Usage */} +
+
+ {t("billingCurrentUsage") || "Current Usage"} +
+
+ + {getUserCount()} + + + {t("billingUsers") || "Users"} + +
+ {hasSubscription && getPricePerUser() > 0 && ( +
+ x ${getPricePerUser()} / month = $ + {getUserCount() * getPricePerUser()} / month +
+ )} +
+ + {/* Maximum Limits */} +
+
+ {t("billingMaximumLimits") || "Maximum Limits"} +
+ + + + + {t("billingUsers") || "Users"} + + + {getLimitValue(USERS) ?? + t("billingUnlimited") ?? + "∞"}{" "} + {getLimitValue(USERS) !== null && + "users"} + + + + + + {t("billingSites") || "Sites"} + + + {getLimitValue(SITES) ?? + t("billingUnlimited") ?? + "∞"}{" "} + {getLimitValue(SITES) !== null && + "sites"} + + + + + + {t("billingDomains") || "Domains"} + + + {getLimitValue(DOMAINS) ?? + t("billingUnlimited") ?? + "∞"}{" "} + {getLimitValue(DOMAINS) !== null && + "domains"} + + + + + + {t("billingRemoteNodes") || + "Remote Nodes"} + + + {getLimitValue(REMOTE_EXIT_NODES) ?? + t("billingUnlimited") ?? + "∞"}{" "} + {getLimitValue(REMOTE_EXIT_NODES) !== + null && "remote nodes"} + + + +
+
+
+
+ + {/* Paid License Keys Section */} + {(licenseSubscription || getLicenseKeyCount() > 0) && ( - {t("billingIncludedUsage")} + {t("billingPaidLicenseKeys") || + "Paid License Keys"} - {hasSubscription - ? t("billingIncludedUsageDescription") - : t("billingFreeTierIncludedUsage")} + {t("billingManageLicenseSubscription") || + "Manage your subscription for paid self-hosted license keys"} -
- {usageTypes.map((type) => { - const { item, limit } = getUsageItemAndLimit( - usageData, - tierSubscriptionItems, - limitsData, - type.id - ); - - // For subscribed users, show included usage from tiers - // For free users, show the limit as "included" - let includedAmount = 0; - let displayIncluded = "0"; - - if (hasSubscription && item) { - includedAmount = getIncludedUsage( - item.tiers - ); - displayIncluded = getIncludedUsageDisplay( - includedAmount, - type - ); - } else if ( - !hasSubscription && - limit && - limit.value > 0 - ) { - // Show free tier limits as "included" - includedAmount = limit.value; - displayIncluded = - type.getLimitDisplay(limit); - } - - if (includedAmount === 0) return null; - - return ( -
-
- {type.icon} - - {type.label} - -
-
-
- {hasSubscription ? ( - - ) : ( - - )} - - {displayIncluded}{" "} - {type.unit} - -
-
- {hasSubscription - ? t("billingIncluded") - : t("billingFreeTier")} -
-
-
- ); - })} -
-
-
- )} - - {hasSubscription && ( - - - - {t("billingEstimatedPeriod")} - - - -
-
- {usageTypes.map((type) => { - const { usage, item } = - getUsageItemAndLimit( - usageData, - tierSubscriptionItems, - limitsData, - type.id - ); - const displayPrice = getDisplayPrice( - item?.tiers - ); - return ( -
- {type.label}: - - {type.getUsage(usage)}{" "} - {type.unitRaw || type.unit} x{" "} - {displayPrice} - -
- ); - })} - {/* Show recurring charges (items with unitAmount but no tiers/meterId) */} - {tierSubscriptionItems - .filter( - (item) => - item.unitAmount && - item.unitAmount > 0 && - !item.tiers && - !item.meterId - ) - .map((item, index) => ( -
- - {item.name || - t("billingRecurringCharge")} - : - - - $ - {( - (item.unitAmount || 0) / 100 - ).toFixed(2)} - -
- ))} - -
- {t("billingEstimatedTotal")} - - $ - {( - usageTypes.reduce((sum, type) => { - const { usage, item } = - getUsageItemAndLimit( - usageData, - tierSubscriptionItems, - limitsData, - type.id - ); - const usageForPricing = - type.getUsage(usage); - const cost = item - ? calculateTieredPrice( - usageForPricing, - item.tiers - ) - : 0; - return sum + cost; - }, 0) + - // Add recurring charges - tierSubscriptionItems - .filter( - (item) => - item.unitAmount && - item.unitAmount > 0 && - !item.tiers && - !item.meterId - ) - .reduce( - (sum, item) => - sum + - (item.unitAmount || 0) / - 100, - 0 - ) - ).toFixed(2)} +
+
+
+ {t("billingCurrentKeys") || "Current Keys"} +
+
+ + {getLicenseKeyCount()} + + + {getLicenseKeyCount() === 1 + ? "key" + : "keys"}
-
-

- {t("billingNotes")} -

-
-

{t("billingEstimateNote")}

-

{t("billingActualChargesMayVary")}

-

{t("billingBilledAtEnd")}

-
-
-
- - - - - - )} - - {!hasSubscription && ( - - -
- -

- {t("billingNoActiveSubscription")} -

-
)} - - {/* License Keys Section */} - {licenseSubscription && ( - - - - {t("billingLicenseKeys") || "License Keys"} - - - {t("billingLicenseKeysDescription") || "Manage your license key subscriptions"} - - - -
-
- - - {t("billingLicenseSubscription") || "License Subscription"} - -
- - {licenseSubscription.subscription?.status === "active" && ( - - )} - {licenseSubscription.subscription?.status - ? licenseSubscription.subscription.status - .charAt(0) - .toUpperCase() + - licenseSubscription.subscription.status.slice(1) - : t("billingInactive") || "Inactive"} - -
- - - -
-
- )} ); -} +} \ No newline at end of file