diff --git a/messages/en-US.json b/messages/en-US.json index 5616c666..88829759 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1405,9 +1405,9 @@ "billingMonitorUsage": "Monitor your usage against configured limits. If you need limits increased please contact us support@pangolin.net.", "billingDataUsage": "Data Usage", "billingSites": "Sites", - "billingUsers": "Active Users", - "billingDomains": "Active Domains", - "billingRemoteExitNodes": "Active Self-hosted Nodes", + "billingUsers": "Users", + "billingDomains": "Domains", + "billingRemoteExitNodes": "Remote Nodes", "billingNoLimitConfigured": "No limit configured", "billingEstimatedPeriod": "Estimated Billing Period", "billingIncludedUsage": "Included Usage", @@ -1533,6 +1533,14 @@ "billingManageLicenseSubscription": "Manage your subscription for paid self-hosted license keys", "billingCurrentKeys": "Current Keys", "billingModifyCurrentPlan": "Modify Current Plan", + "billingConfirmUpgrade": "Confirm Upgrade", + "billingConfirmDowngrade": "Confirm Downgrade", + "billingConfirmUpgradeDescription": "You are about to upgrade your plan. Review the new limits and pricing below.", + "billingConfirmDowngradeDescription": "You are about to downgrade your plan. Review the new limits and pricing below.", + "billingPlanIncludes": "Plan Includes", + "billingProcessing": "Processing...", + "billingConfirmUpgradeButton": "Confirm Upgrade", + "billingConfirmDowngradeButton": "Confirm Downgrade", "signUpTerms": { "IAgreeToThe": "I agree to the", "termsOfService": "terms of service", diff --git a/server/lib/billing/limitSet.ts b/server/lib/billing/limitSet.ts index 0419262c..a95b607f 100644 --- a/server/lib/billing/limitSet.ts +++ b/server/lib/billing/limitSet.ts @@ -23,7 +23,7 @@ export const freeLimitSet: LimitSet = { description: "Free tier limit" }, // 25 GB [FeatureId.DOMAINS]: { value: 3, description: "Free tier limit" }, - [FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Free tier limit" } + [FeatureId.REMOTE_EXIT_NODES]: { value: 0, description: "Free tier limit" } }; export const homeLabLimitSet: LimitSet = { diff --git a/server/lib/billing/usageService.ts b/server/lib/billing/usageService.ts index 0fde8eba..b043e05e 100644 --- a/server/lib/billing/usageService.ts +++ b/server/lib/billing/usageService.ts @@ -33,9 +33,7 @@ interface StripeEvent { export function noop() { if ( - build !== "saas" || - !process.env.S3_BUCKET || - !process.env.LOCAL_FILE_PATH + build !== "saas" ) { return true; } diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 51424d3b..803e8199 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -23,6 +23,16 @@ import { 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, @@ -30,7 +40,8 @@ import { Users, Globe, Server, - Layout + Layout, + Check } from "lucide-react"; import { GetOrgSubscriptionResponse, @@ -40,7 +51,7 @@ import { useTranslations } from "use-intl"; import Link from "next/link"; // Plan tier definitions matching the mockup -type PlanId = "starter" | "homelab" | "team" | "business" | "enterprise"; +type PlanId = "free" | "homelab" | "team" | "business" | "enterprise"; interface PlanOption { id: PlanId; @@ -50,10 +61,39 @@ interface PlanOption { tierType: "home_lab" | "starter" | "scale" | null; // Maps to backend tier types } +// Tier limits for display in confirmation dialog +interface TierLimits { + sites: number; + users: number; + domains: number; + remoteNodes: number; +} + +const tierLimits: Record<"home_lab" | "starter" | "scale", TierLimits> = { + home_lab: { + sites: 3, + users: 3, + domains: 3, + remoteNodes: 1 + }, + starter: { + sites: 10, + users: 150, + domains: 250, + remoteNodes: 5 + }, + scale: { + sites: 10, + users: 150, + domains: 250, + remoteNodes: 5 + } +}; + const planOptions: PlanOption[] = [ { - id: "starter", - name: "Starter", + id: "free", + name: "Free", price: "Free", tierType: null }, @@ -96,10 +136,12 @@ export default function BillingPage() { 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); // Usage and limits data @@ -122,6 +164,15 @@ export default function BillingPage() { const DOMAINS = "domains"; const REMOTE_EXIT_NODES = "remoteExitNodes"; + // Confirmation dialog state + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [pendingTier, setPendingTier] = useState<{ + tier: "home_lab" | "starter" | "scale"; + action: "upgrade" | "downgrade"; + planName: string; + price: string; + } | null>(null); + useEffect(() => { async function fetchSubscription() { setSubscriptionLoading(true); @@ -133,10 +184,11 @@ export default function BillingPage() { setAllSubscriptions(subscriptions); // Find tier subscription - const tierSub = subscriptions.find(({ subscription }) => - subscription?.type === "home_lab" || - subscription?.type === "starter" || - subscription?.type === "scale" + const tierSub = subscriptions.find( + ({ subscription }) => + subscription?.type === "home_lab" || + subscription?.type === "starter" || + subscription?.type === "scale" ); setTierSubscription(tierSub || null); @@ -190,7 +242,9 @@ export default function BillingPage() { fetchUsage(); }, [org.org.orgId]); - const handleStartSubscription = async (tier: "home_lab" | "starter" | "scale") => { + const handleStartSubscription = async ( + tier: "home_lab" | "starter" | "scale" + ) => { setIsLoading(true); try { const response = await api.post>( @@ -270,15 +324,43 @@ export default function BillingPage() { } }; + const confirmTierChange = () => { + if (!pendingTier) return; + + if ( + pendingTier.action === "upgrade" || + pendingTier.action === "downgrade" + ) { + if (hasSubscription) { + handleChangeTier(pendingTier.tier); + } else { + handleStartSubscription(pendingTier.tier); + } + } + + setShowConfirmDialog(false); + setPendingTier(null); + }; + + const showTierConfirmation = ( + tier: "home_lab" | "starter" | "scale", + action: "upgrade" | "downgrade", + planName: string, + price: string + ) => { + setPendingTier({ tier, action, planName, price }); + setShowConfirmDialog(true); + }; + const handleContactUs = () => { window.open("mailto:sales@pangolin.net", "_blank"); }; // Get current plan ID from tier const getCurrentPlanId = (): PlanId => { - if (!hasSubscription || !currentTier) return "starter"; + if (!hasSubscription || !currentTier) return "free"; const plan = planOptions.find((p) => p.tierType === currentTier); - return plan?.id || "starter"; + return plan?.id || "free"; }; const currentPlanId = getCurrentPlanId(); @@ -295,8 +377,8 @@ export default function BillingPage() { } if (plan.id === currentPlanId) { - // If it's the free plan (starter with no subscription), show as current but disabled - if (plan.id === "starter" && !hasSubscription) { + // If it's the free plan (free with no subscription), show as current but disabled + if (plan.id === "free" && !hasSubscription) { return { label: "Current Plan", action: () => {}, @@ -320,10 +402,18 @@ export default function BillingPage() { if (planIndex < currentIndex) { return { label: "Downgrade", - action: () => - plan.tierType - ? handleChangeTier(plan.tierType) - : handleModifySubscription(), + action: () => { + if (plan.tierType) { + showTierConfirmation( + plan.tierType, + "downgrade", + plan.name, + plan.price + (plan.priceDetail || "") + ); + } else { + handleModifySubscription(); + } + }, variant: "outline" as const, disabled: false }; @@ -331,12 +421,18 @@ export default function BillingPage() { return { label: "Upgrade", - action: () => - plan.tierType - ? hasSubscription - ? handleChangeTier(plan.tierType) - : handleStartSubscription(plan.tierType) - : handleModifySubscription(), + action: () => { + if (plan.tierType) { + showTierConfirmation( + plan.tierType, + "upgrade", + plan.name, + plan.price + (plan.priceDetail || "") + ); + } else { + handleModifySubscription(); + } + }, variant: "outline" as const, disabled: false }; @@ -407,11 +503,11 @@ export default function BillingPage() { )} >
-
+
{plan.name}
- + {plan.price} {plan.priceDetail && ( @@ -431,7 +527,9 @@ export default function BillingPage() { size="sm" className="w-full" onClick={planAction.action} - disabled={isLoading || planAction.disabled} + disabled={ + isLoading || planAction.disabled + } > {planAction.label} @@ -469,7 +567,7 @@ export default function BillingPage() {
{/* Current Usage */} -
+
{t("billingCurrentUsage") || "Current Usage"}
@@ -480,27 +578,27 @@ export default function BillingPage() { {t("billingUsers") || "Users"} + {hasSubscription && getPricePerUser() > 0 && ( +
+ x ${getPricePerUser()} / month = $ + {getUserCount() * getPricePerUser()} / + month +
+ )}
- {hasSubscription && getPricePerUser() > 0 && ( -
- x ${getPricePerUser()} / month = $ - {getUserCount() * getPricePerUser()} / month -
- )}
{/* Maximum Limits */} -
+
{t("billingMaximumLimits") || "Maximum Limits"}
- - + {t("billingUsers") || "Users"} - + {getLimitValue(USERS) ?? t("billingUnlimited") ?? "∞"}{" "} @@ -509,11 +607,10 @@ export default function BillingPage() { - - + {t("billingSites") || "Sites"} - + {getLimitValue(SITES) ?? t("billingUnlimited") ?? "∞"}{" "} @@ -522,11 +619,10 @@ export default function BillingPage() { - - + {t("billingDomains") || "Domains"} - + {getLimitValue(DOMAINS) ?? t("billingUnlimited") ?? "∞"}{" "} @@ -535,12 +631,11 @@ export default function BillingPage() { - - + {t("billingRemoteNodes") || "Remote Nodes"} - + {getLimitValue(REMOTE_EXIT_NODES) ?? t("billingUnlimited") ?? "∞"}{" "} @@ -559,8 +654,7 @@ export default function BillingPage() { - {t("billingPaidLicenseKeys") || - "Paid License Keys"} + {t("billingPaidLicenseKeys") || "Paid License Keys"} {t("billingManageLicenseSubscription") || @@ -597,6 +691,115 @@ export default function BillingPage() { )} + + {/* 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} +
+
+ +
+

+ {t("billingPlanIncludes") || + "Plan Includes:"} +

+
+
+ + + { + tierLimits[pendingTier.tier] + .sites + }{" "} + {t("billingSites") || "Sites"} + +
+
+ + + { + tierLimits[pendingTier.tier] + .users + }{" "} + {t("billingUsers") || "Users"} + +
+
+ + + { + tierLimits[pendingTier.tier] + .domains + }{" "} + {t("billingDomains") || + "Domains"} + +
+
+ + + { + tierLimits[pendingTier.tier] + .remoteNodes + }{" "} + {t("billingRemoteNodes") || + "Remote Nodes"} + +
+
+
+
+ )} +
+ + + + + + +
+
); -} \ No newline at end of file +}