diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx
index e1879aa6..f64c9557 100644
--- a/src/app/[orgId]/settings/(private)/billing/page.tsx
+++ b/src/app/[orgId]/settings/(private)/billing/page.tsx
@@ -17,47 +17,152 @@ 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 {
- CreditCard,
- Database,
- Clock,
- AlertCircle,
- CheckCircle,
- Users,
- Calculator,
- ExternalLink,
- Gift,
- Server
-} from "lucide-react";
-import { InfoPopup } from "@/components/ui/info-popup";
+ InfoSection,
+ InfoSectionContent,
+ InfoSections,
+ InfoSectionTitle
+} from "@app/components/InfoSection";
+import {
+ Credenza,
+ CredenzaBody,
+ CredenzaClose,
+ CredenzaContent,
+ CredenzaDescription,
+ CredenzaFooter,
+ CredenzaHeader,
+ CredenzaTitle
+} from "@app/components/Credenza";
+import { cn } from "@app/lib/cn";
+import { CreditCard, ExternalLink, Check, AlertTriangle } from "lucide-react";
+import {
+ Alert,
+ AlertTitle,
+ AlertDescription
+} from "@app/components/ui/alert";
+import {
+ Tooltip,
+ TooltipTrigger,
+ TooltipContent
+} from "@app/components/ui/tooltip";
import {
GetOrgSubscriptionResponse,
GetOrgUsageResponse
} from "@server/routers/billing/types";
import { useTranslations } from "use-intl";
import Link from "next/link";
+import { Tier } from "@server/types/Tiers";
+import {
+ freeLimitSet,
+ tier1LimitSet,
+ tier2LimitSet,
+ tier3LimitSet
+} from "@server/lib/billing/limitSet";
+import { FeatureId } from "@server/lib/billing/features";
-export default function GeneralPage() {
+// Plan tier definitions matching the mockup
+type PlanId = "starter" | "home" | "team" | "business" | "enterprise";
+
+type PlanOption = {
+ id: PlanId;
+ name: string;
+ price: string;
+ priceDetail?: string;
+ tierType: Tier | null;
+};
+
+const planOptions: PlanOption[] = [
+ {
+ id: "starter",
+ name: "Starter",
+ price: "Free",
+ tierType: null
+ },
+ {
+ id: "home",
+ name: "Home",
+ price: "$12.50",
+ priceDetail: "/ month",
+ tierType: "tier1"
+ },
+ {
+ id: "team",
+ name: "Team",
+ price: "$4",
+ priceDetail: "per user / month",
+ tierType: "tier2"
+ },
+ {
+ id: "business",
+ name: "Business",
+ price: "$9",
+ priceDetail: "per user / month",
+ tierType: "tier3"
+ },
+ {
+ id: "enterprise",
+ name: "Enterprise",
+ price: "Custom",
+ tierType: null
+ }
+];
+
+// Tier limits mapping derived from limit sets
+const tierLimits: Record<
+ Tier | "starter",
+ { users: number; sites: number; domains: number; remoteNodes: number }
+> = {
+ starter: {
+ users: freeLimitSet[FeatureId.USERS]?.value ?? 0,
+ sites: freeLimitSet[FeatureId.SITES]?.value ?? 0,
+ domains: freeLimitSet[FeatureId.DOMAINS]?.value ?? 0,
+ remoteNodes: freeLimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
+ },
+ tier1: {
+ users: tier1LimitSet[FeatureId.USERS]?.value ?? 0,
+ sites: tier1LimitSet[FeatureId.SITES]?.value ?? 0,
+ domains: tier1LimitSet[FeatureId.DOMAINS]?.value ?? 0,
+ remoteNodes: tier1LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
+ },
+ tier2: {
+ users: tier2LimitSet[FeatureId.USERS]?.value ?? 0,
+ sites: tier2LimitSet[FeatureId.SITES]?.value ?? 0,
+ domains: tier2LimitSet[FeatureId.DOMAINS]?.value ?? 0,
+ remoteNodes: tier2LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
+ },
+ tier3: {
+ users: tier3LimitSet[FeatureId.USERS]?.value ?? 0,
+ sites: tier3LimitSet[FeatureId.SITES]?.value ?? 0,
+ domains: tier3LimitSet[FeatureId.DOMAINS]?.value ?? 0,
+ remoteNodes: tier3LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0
+ },
+ enterprise: {
+ users: 0, // Custom for enterprise
+ sites: 0, // Custom for enterprise
+ domains: 0, // Custom for enterprise
+ remoteNodes: 0 // Custom for enterprise
+ }
+};
+
+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"]
>([]);
- const [tierSubscription, setTierSubscription] =
- useState
(null);
- const [licenseSubscription, setLicenseSubscription] =
- useState(null);
+ const [tierSubscription, setTierSubscription] = useState<
+ GetOrgSubscriptionResponse["subscriptions"][0] | null
+ >(null);
+ const [licenseSubscription, setLicenseSubscription] = useState<
+ GetOrgSubscriptionResponse["subscriptions"][0] | null
+ >(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 +170,25 @@ export default function GeneralPage() {
[]
);
+ const [hasSubscription, setHasSubscription] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [currentTier, setCurrentTier] = useState(null);
+
+ // Usage IDs
+ const USERS = "users";
+ const SITES = "sites";
+ const DOMAINS = "domains";
+ const REMOTE_EXIT_NODES = "remoteExitNodes";
+
+ // Confirmation dialog state
+ const [showConfirmDialog, setShowConfirmDialog] = useState(false);
+ const [pendingTier, setPendingTier] = useState<{
+ tier: Tier | "starter";
+ action: "upgrade" | "downgrade";
+ planName: string;
+ price: string;
+ } | null>(null);
+
useEffect(() => {
async function fetchSubscription() {
setSubscriptionLoading(true);
@@ -75,38 +199,27 @@ export default function GeneralPage() {
const { subscriptions } = res.data.data;
setAllSubscriptions(subscriptions);
- // Import tier and license price sets
- const { getTierPriceSet } = await import("@server/lib/billing/tiers");
- 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 === "tier1" ||
+ subscription?.type === "tier2" ||
+ subscription?.type === "tier3"
);
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 Tier);
+ 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"),
@@ -127,7 +240,6 @@ export default function GeneralPage() {
`/org/${org.org.orgId}/billing/usage`
);
const { usage, limits } = res.data.data;
-
setUsageData(usage);
setLimitsData(limits);
} catch (error) {
@@ -136,27 +248,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: Tier) => {
setIsLoading(true);
try {
const response = await api.post>(
- `/org/${org.org.orgId}/billing/create-checkout-session-saas`,
- {}
+ `/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;
@@ -206,210 +309,318 @@ export default function GeneralPage() {
}
};
- // Usage IDs
- const SITE_UPTIME = "siteUptime";
- 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: Tier) => {
+ 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
+ });
+
+ // Poll the API to check if the tier change has been reflected
+ const pollForTierChange = async (targetTier: Tier) => {
+ const maxAttempts = 30; // 30 seconds with 1 second interval
+ let attempts = 0;
+
+ const poll = async (): Promise => {
+ try {
+ const res = await api.get<
+ AxiosResponse
+ >(`/org/${org.org.orgId}/billing/subscriptions`);
+ const { subscriptions } = res.data.data;
+
+ // Find tier subscription
+ const tierSub = subscriptions.find(
+ ({ subscription }) =>
+ subscription?.type === "tier1" ||
+ subscription?.type === "tier2" ||
+ subscription?.type === "tier3"
+ );
+
+ // Check if the tier has changed to the target tier
+ if (tierSub?.subscription?.type === targetTier) {
+ return true;
+ }
+
+ return false;
+ } catch (error) {
+ console.error("Error polling subscription:", error);
+ return false;
+ }
+ };
+
+ while (attempts < maxAttempts) {
+ const success = await poll();
+
+ if (success) {
+ // Tier change reflected, refresh the page
+ window.location.reload();
+ return;
+ }
+
+ attempts++;
+
+ if (attempts < maxAttempts) {
+ // Wait 1 second before next poll
+ await new Promise((resolve) =>
+ setTimeout(resolve, 1000)
+ );
+ }
+ }
+
+ // If we've exhausted all attempts, show an error
+ toast({
+ title: "Tier change processing",
+ description:
+ "Your tier change is taking longer than expected. Please refresh the page in a moment to see the changes.",
+ variant: "destructive"
+ });
+ setIsLoading(false);
+ };
+
+ // Start polling for the tier change
+ pollForTierChange(tier);
+ } catch (error) {
+ toast({
+ title: "Failed to change tier",
+ description: formatAxiosError(error),
+ variant: "destructive"
+ });
+ setIsLoading(false);
+ }
+ };
+
+ const confirmTierChange = () => {
+ if (!pendingTier) return;
+
+ if (
+ pendingTier.action === "upgrade" ||
+ pendingTier.action === "downgrade"
+ ) {
+ // If downgrading to starter (free tier), go to Stripe portal
+ if (pendingTier.tier === "starter") {
+ handleModifySubscription();
+ } else if (hasSubscription) {
+ handleChangeTier(pendingTier.tier);
+ } else {
+ handleStartSubscription(pendingTier.tier);
}
- 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";
+ // setShowConfirmDialog(false);
+ // setPendingTier(null);
+ };
+
+ const showTierConfirmation = (
+ tier: Tier | "starter",
+ action: "upgrade" | "downgrade",
+ planName: string,
+ price: string
+ ) => {
+ setPendingTier({ tier, action, planName, price });
+ setShowConfirmDialog(true);
+ };
+
+ const handleContactUs = () => {
+ window.open("https://pangolin.net/talk-to-us", "_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 (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 (plan.id === currentPlanId) {
+ // If it's the starter 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
+ };
+ }
+ return {
+ label: "Modify Current Plan",
+ action: handleModifySubscription,
+ variant: "default" as const,
+ disabled: false
+ };
}
- if (tiers.length === 0) return 0;
- // Find the first tier (which represents included usage)
- const firstTier = tiers[0];
- if (!firstTier) return 0;
+ const currentIndex = planOptions.findIndex(
+ (p) => p.id === currentPlanId
+ );
+ const planIndex = planOptions.findIndex((p) => p.id === plan.id);
- // 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 (planIndex < currentIndex) {
+ return {
+ label: "Downgrade",
+ action: () => {
+ if (plan.tierType) {
+ showTierConfirmation(
+ plan.tierType,
+ "downgrade",
+ plan.name,
+ plan.price + (" " + plan.priceDetail || "")
+ );
+ } else if (plan.id === "starter") {
+ // Show confirmation for downgrading to starter (free tier)
+ showTierConfirmation(
+ "starter",
+ "downgrade",
+ plan.name,
+ plan.price
+ );
+ } else {
+ handleModifySubscription();
+ }
+ },
+ variant: "outline" as const,
+ disabled: false
+ };
+ }
- if (isIncludedTier && firstTier.up_to !== null) {
- return Number(firstTier.up_to);
+ return {
+ label: "Upgrade",
+ action: () => {
+ if (plan.tierType) {
+ showTierConfirmation(
+ plan.tierType,
+ "upgrade",
+ plan.name,
+ plan.price + (" " + plan.priceDetail || "")
+ );
+ } else {
+ 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;
+ };
+
+ // Check if usage exceeds limit for a specific feature
+ const isOverLimit = (featureId: string): boolean => {
+ const usage = getUsageValue(featureId);
+ const limit = getLimitValue(featureId);
+ return limit !== null && usage > limit;
+ };
+
+ // Calculate current usage cost for display
+ const getUserCount = () => getUsageValue(USERS);
+ const getPricePerUser = () => {
+ if (!tierSubscription?.items) return 0;
+
+ // Find the subscription item for USERS feature
+ const usersItem = tierSubscription.items.find(
+ (item) => item.featureId === USERS
+ );
+
+ console.log("Users subscription item:", usersItem);
+
+ // unitAmount is in cents, convert to dollars
+ if (usersItem?.unitAmount) {
+ return usersItem.unitAmount / 100;
}
return 0;
- }
+ };
- // Helper to get display value for included usage
- function getIncludedUsageDisplay(includedAmount: number, usageType: any) {
- if (includedAmount === 0) return "0";
+ // Get license key count
+ const getLicenseKeyCount = (): number => {
+ if (!licenseSubscription?.items) return 0;
+ return licenseSubscription.items.length;
+ };
- if (usageType.id === EGRESS_DATA_MB) {
- // Convert MB to GB for data usage
- return (includedAmount / 1000).toFixed(2);
+ // Check if downgrading to a tier would violate current usage limits
+ const checkLimitViolations = (targetTier: Tier | "starter"): Array<{
+ feature: string;
+ currentUsage: number;
+ newLimit: number;
+ }> => {
+ const violations: Array<{
+ feature: string;
+ currentUsage: number;
+ newLimit: number;
+ }> = [];
+
+ const limits = tierLimits[targetTier];
+
+ // Check users
+ const usersUsage = getUsageValue(USERS);
+ if (limits.users > 0 && usersUsage > limits.users) {
+ violations.push({
+ feature: "Users",
+ currentUsage: usersUsage,
+ newLimit: limits.users
+ });
}
- if (usageType.id === USERS || usageType.id === DOMAINS) {
- // divide by 32 days
- return (includedAmount / 32).toFixed(2);
+ // Check sites
+ const sitesUsage = getUsageValue(SITES);
+ if (limits.sites > 0 && sitesUsage > limits.sites) {
+ violations.push({
+ feature: "Sites",
+ currentUsage: sitesUsage,
+ newLimit: limits.sites
+ });
}
- 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: SITE_UPTIME,
- label: t("billingOnlineTime"),
- icon: ,
- unit: "min",
- info: t("billingOnlineTimeInfo"),
- note: "Not counted on self-hosted nodes",
- 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
+ // Check domains
+ const domainsUsage = getUsageValue(DOMAINS);
+ if (limits.domains > 0 && domainsUsage > limits.domains) {
+ violations.push({
+ feature: "Domains",
+ currentUsage: domainsUsage,
+ newLimit: limits.domains
+ });
}
- ];
+
+ // Check remote nodes
+ const remoteNodesUsage = getUsageValue(REMOTE_EXIT_NODES);
+ if (limits.remoteNodes > 0 && remoteNodesUsage > limits.remoteNodes) {
+ violations.push({
+ feature: "Remote Exit Nodes",
+ currentUsage: remoteNodesUsage,
+ newLimit: limits.remoteNodes
+ });
+ }
+
+ return violations;
+ };
if (subscriptionLoading) {
return (
@@ -421,420 +632,469 @@ 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"}
+
+
+ {isOverLimit(USERS) ? (
+
+
+
+
+ {getLimitValue(USERS) ??
+ t("billingUnlimited") ??
+ "∞"}{" "}
+ {getLimitValue(USERS) !== null &&
+ "users"}
+
+
+
+ {t("billingUsageExceedsLimit", { current: getUsageValue(USERS), limit: getLimitValue(USERS) ?? 0 }) || `Current usage (${getUsageValue(USERS)}) exceeds limit (${getLimitValue(USERS)})`}
+
+
+ ) : (
+ <>
+ {getLimitValue(USERS) ??
+ t("billingUnlimited") ??
+ "∞"}{" "}
+ {getLimitValue(USERS) !== null &&
+ "users"}
+ >
+ )}
+
+
+
+
+ {t("billingSites") || "Sites"}
+
+
+ {isOverLimit(SITES) ? (
+
+
+
+
+ {getLimitValue(SITES) ??
+ t("billingUnlimited") ??
+ "∞"}{" "}
+ {getLimitValue(SITES) !== null &&
+ "sites"}
+
+
+
+ {t("billingUsageExceedsLimit", { current: getUsageValue(SITES), limit: getLimitValue(SITES) ?? 0 }) || `Current usage (${getUsageValue(SITES)}) exceeds limit (${getLimitValue(SITES)})`}
+
+
+ ) : (
+ <>
+ {getLimitValue(SITES) ??
+ t("billingUnlimited") ??
+ "∞"}{" "}
+ {getLimitValue(SITES) !== null &&
+ "sites"}
+ >
+ )}
+
+
+
+
+ {t("billingDomains") || "Domains"}
+
+
+ {isOverLimit(DOMAINS) ? (
+
+
+
+
+ {getLimitValue(DOMAINS) ??
+ t("billingUnlimited") ??
+ "∞"}{" "}
+ {getLimitValue(DOMAINS) !== null &&
+ "domains"}
+
+
+
+ {t("billingUsageExceedsLimit", { current: getUsageValue(DOMAINS), limit: getLimitValue(DOMAINS) ?? 0 }) || `Current usage (${getUsageValue(DOMAINS)}) exceeds limit (${getLimitValue(DOMAINS)})`}
+
+
+ ) : (
+ <>
+ {getLimitValue(DOMAINS) ??
+ t("billingUnlimited") ??
+ "∞"}{" "}
+ {getLimitValue(DOMAINS) !== null &&
+ "domains"}
+ >
+ )}
+
+
+
+
+ {t("billingRemoteNodes") ||
+ "Remote Nodes"}
+
+
+ {isOverLimit(REMOTE_EXIT_NODES) ? (
+
+
+
+
+ {getLimitValue(REMOTE_EXIT_NODES) ??
+ t("billingUnlimited") ??
+ "∞"}{" "}
+ {getLimitValue(REMOTE_EXIT_NODES) !==
+ null && "remote nodes"}
+
+
+
+ {t("billingUsageExceedsLimit", { current: getUsageValue(REMOTE_EXIT_NODES), limit: getLimitValue(REMOTE_EXIT_NODES) ?? 0 }) || `Current usage (${getUsageValue(REMOTE_EXIT_NODES)}) exceeds limit (${getLimitValue(REMOTE_EXIT_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
- );
+
+
+
+
+ {t("billingCurrentKeys") || "Current Keys"}
+
+
+
+ {getLicenseKeyCount()}
+
+
+ {getLicenseKeyCount() === 1
+ ? "key"
+ : "keys"}
+
+
+
+
+
+
+
+
+ )}
- // For subscribed users, show included usage from tiers
- // For free users, show the limit as "included"
- let includedAmount = 0;
- let displayIncluded = "0";
+ {/* Tier Change Confirmation Dialog */}
+
+
+
+
+ {pendingTier?.action === "upgrade"
+ ? t("billingConfirmUpgrade") ||
+ "Confirm Upgrade"
+ : t("billingConfirmDowngrade") ||
+ "Confirm Downgrade"}
+
+
+ {pendingTier?.action === "upgrade"
+ ? t("billingConfirmUpgradeDescription") ||
+ `You are about to upgrade to the ${pendingTier?.planName} plan.`
+ : t("billingConfirmDowngradeDescription") ||
+ `You are about to downgrade to the ${pendingTier?.planName} plan.`}
+
+
+
+ {pendingTier && pendingTier.tier && (
+
+
+
+ {pendingTier.planName}
+
+
+
+ {pendingTier.price}
+
+
+
- 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}
+ {tierLimits[pendingTier.tier] && (
+
+
+ {t("billingPlanIncludes") ||
+ "Plan Includes:"}
+
+
+
+
+
+ {
+ tierLimits[pendingTier.tier]
+ .users
+ }{" "}
+ {t("billingUsers") || "Users"}
-
- {hasSubscription
- ? t("billingIncluded")
- : t("billingFreeTier")}
+
+
+
+ {
+ tierLimits[pendingTier.tier]
+ .sites
+ }{" "}
+ {t("billingSites") || "Sites"}
+
+
+
+
+
+ {
+ tierLimits[pendingTier.tier]
+ .domains
+ }{" "}
+ {t("billingDomains") ||
+ "Domains"}
+
+
+
+
+
+ {
+ tierLimits[pendingTier.tier]
+ .remoteNodes
+ }{" "}
+ {t("billingRemoteNodes") ||
+ "Remote Nodes"}
+
- );
- })}
-
-
-
- )}
-
- {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("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"}
-
-
-
-
+ )}
+
+
+
+
-
-
-
- )}
+
+
+
+
+
);
}
diff --git a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx
index 2bd3ef13..7d4bece1 100644
--- a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx
+++ b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx
@@ -31,7 +31,6 @@ import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useState, useEffect } from "react";
-import { SwitchInput } from "@app/components/SwitchInput";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, ExternalLink } from "lucide-react";
import {
@@ -41,12 +40,13 @@ import {
InfoSectionTitle
} from "@app/components/InfoSection";
import CopyToClipboard from "@app/components/CopyToClipboard";
-import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import { useTranslations } from "next-intl";
import { AxiosResponse } from "axios";
import { ListRolesResponse } from "@server/routers/role";
-import AutoProvisionConfigWidget from "@app/components/private/AutoProvisionConfigWidget";
+import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget";
+import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
+import { tierMatrix } from "@server/lib/billing/tierMatrix";
export default function GeneralPage() {
const { env } = useEnvContext();
@@ -60,7 +60,6 @@ export default function GeneralPage() {
"role" | "expression"
>("role");
const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc");
- const { isUnlocked } = useLicenseStatusContext();
const dashboardRedirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
const [redirectUrl, setRedirectUrl] = useState(
@@ -499,6 +498,10 @@ export default function GeneralPage() {
+
+