mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-08 20:16:50 +02:00
Compare commits
4 Commits
94ac3ec76e
...
9ff863db5e
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ff863db5e | |||
| e2ac6e6d4d | |||
| df4101875a | |||
| 3f5c788d48 |
@@ -1541,6 +1541,11 @@
|
||||
"billingProcessing": "Processing...",
|
||||
"billingConfirmUpgradeButton": "Confirm Upgrade",
|
||||
"billingConfirmDowngradeButton": "Confirm Downgrade",
|
||||
"billingLimitViolationWarning": "Usage Exceeds New Plan Limits",
|
||||
"billingLimitViolationDescription": "Your current usage exceeds the limits of this plan. After downgrading, all actions will be disabled until you reduce usage within the new limits. Please review the features below that are currently over the limits. Limits in violation:",
|
||||
"billingFeatureLossWarning": "Feature Availability Notice",
|
||||
"billingFeatureLossDescription": "By downgrading, features not available in the new plan will be automatically disabled. Some settings and configurations may be lost. Please review the pricing matrix to understand which features will no longer be available.",
|
||||
"billingUsageExceedsLimit": "Current usage ({current}) exceeds limit ({limit})",
|
||||
"signUpTerms": {
|
||||
"IAgreeToThe": "I agree to the",
|
||||
"termsOfService": "terms of service",
|
||||
@@ -1955,6 +1960,7 @@
|
||||
"orgAuthBackToSignIn": "Back to standard sign in",
|
||||
"orgAuthNoAccount": "Don't have an account?",
|
||||
"subscriptionRequiredToUse": "A subscription is required to use this feature.",
|
||||
"mustUpgradeToUse": "You must upgrade your subscription to use this feature.",
|
||||
"idpDisabled": "Identity providers are disabled.",
|
||||
"orgAuthPageDisabled": "Organization auth page is disabled.",
|
||||
"domainRestartedDescription": "Domain verification restarted successfully",
|
||||
|
||||
@@ -2,19 +2,19 @@ import { Tier } from "@server/types/Tiers";
|
||||
|
||||
export enum TierFeature {
|
||||
OrgOidc = "orgOidc",
|
||||
LoginPageDomain = "loginPageDomain",
|
||||
DeviceApprovals = "deviceApprovals",
|
||||
LoginPageBranding = "loginPageBranding",
|
||||
LoginPageDomain = "loginPageDomain", // handle downgrade by removing custom domain
|
||||
DeviceApprovals = "deviceApprovals", // handle downgrade by disabling device approvals
|
||||
LoginPageBranding = "loginPageBranding", // handle downgrade by setting to default branding
|
||||
LogExport = "logExport",
|
||||
AccessLogs = "accessLogs",
|
||||
ActionLogs = "actionLogs",
|
||||
AccessLogs = "accessLogs", // set the retention period to none on downgrade
|
||||
ActionLogs = "actionLogs", // set the retention period to none on downgrade
|
||||
RotateCredentials = "rotateCredentials",
|
||||
MaintencePage = "maintencePage",
|
||||
MaintencePage = "maintencePage", // handle downgrade
|
||||
DevicePosture = "devicePosture",
|
||||
TwoFactorEnforcement = "twoFactorEnforcement",
|
||||
SessionDurationPolicies = "sessionDurationPolicies",
|
||||
PasswordExpirationPolicies = "passwordExpirationPolicies",
|
||||
AutoProvisioning = "autoProvisioning"
|
||||
TwoFactorEnforcement = "twoFactorEnforcement", // handle downgrade by setting to optional
|
||||
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
|
||||
PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration
|
||||
AutoProvisioning = "autoProvisioning" // handle downgrade by disabling auto provisioning
|
||||
}
|
||||
|
||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db, orgs } from "@server/db";
|
||||
import { userOrgs } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { usageService } from "@server/lib/billing/usageService";
|
||||
import { build } from "@server/build";
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { SubscriptionType } from "./hooks/getSubType";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { Tier } from "@server/types/Tiers";
|
||||
import logger from "@server/logger";
|
||||
import { db, idp, idpOrg, loginPage, loginPageBranding, loginPageBrandingOrg, loginPageOrg, orgs, resources, roles } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export async function handleTierChange(
|
||||
orgId: string,
|
||||
newTier: SubscriptionType | null,
|
||||
previousTier?: SubscriptionType | null
|
||||
): Promise<void> {
|
||||
logger.info(
|
||||
`Handling tier change for org ${orgId}: ${previousTier || "none"} -> ${newTier || "free"}`
|
||||
);
|
||||
|
||||
// License subscriptions are handled separately and don't use the tier matrix
|
||||
if (newTier === "license") {
|
||||
logger.debug(
|
||||
`New tier is license for org ${orgId}, no feature lifecycle handling needed`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// If newTier is null, treat as free tier - disable all features
|
||||
if (newTier === null) {
|
||||
logger.info(
|
||||
`Org ${orgId} is reverting to free tier, disabling all paid features`
|
||||
);
|
||||
// Disable all features in the tier matrix
|
||||
for (const [featureKey] of Object.entries(tierMatrix)) {
|
||||
const feature = featureKey as TierFeature;
|
||||
logger.info(
|
||||
`Feature ${feature} is not available in free tier for org ${orgId}. Disabling...`
|
||||
);
|
||||
await disableFeature(orgId, feature);
|
||||
}
|
||||
logger.info(
|
||||
`Completed free tier feature lifecycle handling for org ${orgId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the tier (cast as Tier since we've ruled out "license" and null)
|
||||
const tier = newTier as Tier;
|
||||
|
||||
// Check each feature in the tier matrix
|
||||
for (const [featureKey, allowedTiers] of Object.entries(tierMatrix)) {
|
||||
const feature = featureKey as TierFeature;
|
||||
const isFeatureAvailable = allowedTiers.includes(tier);
|
||||
|
||||
if (!isFeatureAvailable) {
|
||||
logger.info(
|
||||
`Feature ${feature} is not available in tier ${tier} for org ${orgId}. Disabling...`
|
||||
);
|
||||
await disableFeature(orgId, feature);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Feature ${feature} is available in tier ${tier} for org ${orgId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Completed tier change feature lifecycle handling for org ${orgId}`
|
||||
);
|
||||
}
|
||||
|
||||
async function disableFeature(
|
||||
orgId: string,
|
||||
feature: TierFeature
|
||||
): Promise<void> {
|
||||
try {
|
||||
switch (feature) {
|
||||
case TierFeature.OrgOidc:
|
||||
await disableOrgOidc(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.LoginPageDomain:
|
||||
await disableLoginPageDomain(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.DeviceApprovals:
|
||||
await disableDeviceApprovals(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.LoginPageBranding:
|
||||
await disableLoginPageBranding(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.LogExport:
|
||||
await disableLogExport(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.AccessLogs:
|
||||
await disableAccessLogs(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.ActionLogs:
|
||||
await disableActionLogs(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.RotateCredentials:
|
||||
await disableRotateCredentials(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.MaintencePage:
|
||||
await disableMaintencePage(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.DevicePosture:
|
||||
await disableDevicePosture(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.TwoFactorEnforcement:
|
||||
await disableTwoFactorEnforcement(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.SessionDurationPolicies:
|
||||
await disableSessionDurationPolicies(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.PasswordExpirationPolicies:
|
||||
await disablePasswordExpirationPolicies(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.AutoProvisioning:
|
||||
await disableAutoProvisioning(orgId);
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.warn(
|
||||
`Unknown feature ${feature} for org ${orgId}, skipping`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Successfully disabled feature ${feature} for org ${orgId}`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error disabling feature ${feature} for org ${orgId}:`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function disableOrgOidc(orgId: string): Promise<void> {}
|
||||
|
||||
async function disableDeviceApprovals(orgId: string): Promise<void> {
|
||||
await db
|
||||
.update(roles)
|
||||
.set({ requireDeviceApproval: false })
|
||||
.where(eq(roles.orgId, orgId));
|
||||
|
||||
logger.info(`Disabled device approvals on all roles for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableLoginPageBranding(orgId: string): Promise<void> {
|
||||
const [existingBranding] = await db
|
||||
.select()
|
||||
.from(loginPageBrandingOrg)
|
||||
.where(eq(loginPageBrandingOrg.orgId, orgId));
|
||||
|
||||
if (existingBranding) {
|
||||
await db
|
||||
.delete(loginPageBranding)
|
||||
.where(
|
||||
eq(
|
||||
loginPageBranding.loginPageBrandingId,
|
||||
existingBranding.loginPageBrandingId
|
||||
)
|
||||
);
|
||||
|
||||
logger.info(`Disabled login page branding for org ${orgId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function disableLoginPageDomain(orgId: string): Promise<void> {
|
||||
const [existingLoginPage] = await db
|
||||
.select()
|
||||
.from(loginPageOrg)
|
||||
.where(eq(loginPageOrg.orgId, orgId))
|
||||
.innerJoin(
|
||||
loginPage,
|
||||
eq(loginPage.loginPageId, loginPageOrg.loginPageId)
|
||||
);
|
||||
|
||||
if (existingLoginPage) {
|
||||
await db
|
||||
.delete(loginPageOrg)
|
||||
.where(eq(loginPageOrg.orgId, orgId));
|
||||
|
||||
await db
|
||||
.delete(loginPage)
|
||||
.where(
|
||||
eq(
|
||||
loginPage.loginPageId,
|
||||
existingLoginPage.loginPageOrg.loginPageId
|
||||
)
|
||||
);
|
||||
|
||||
logger.info(`Disabled login page domain for org ${orgId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function disableLogExport(orgId: string): Promise<void> {}
|
||||
|
||||
async function disableAccessLogs(orgId: string): Promise<void> {
|
||||
await db
|
||||
.update(orgs)
|
||||
.set({ settingsLogRetentionDaysAccess: 0 })
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
|
||||
logger.info(`Disabled access logs for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableActionLogs(orgId: string): Promise<void> {
|
||||
await db
|
||||
.update(orgs)
|
||||
.set({ settingsLogRetentionDaysAction: 0 })
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
|
||||
logger.info(`Disabled action logs for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableRotateCredentials(orgId: string): Promise<void> {}
|
||||
|
||||
async function disableMaintencePage(orgId: string): Promise<void> {
|
||||
await db
|
||||
.update(resources)
|
||||
.set({
|
||||
maintenanceModeEnabled: false
|
||||
})
|
||||
.where(eq(resources.orgId, orgId));
|
||||
|
||||
logger.info(`Disabled maintenance page on all resources for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableDevicePosture(orgId: string): Promise<void> {}
|
||||
|
||||
async function disableTwoFactorEnforcement(orgId: string): Promise<void> {
|
||||
await db
|
||||
.update(orgs)
|
||||
.set({ requireTwoFactor: false })
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
|
||||
logger.info(`Disabled two-factor enforcement for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableSessionDurationPolicies(orgId: string): Promise<void> {
|
||||
await db
|
||||
.update(orgs)
|
||||
.set({ maxSessionLengthHours: null })
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
|
||||
logger.info(`Disabled session duration policies for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disablePasswordExpirationPolicies(orgId: string): Promise<void> {
|
||||
await db
|
||||
.update(orgs)
|
||||
.set({ passwordExpiryDays: null })
|
||||
.where(eq(orgs.orgId, orgId));
|
||||
|
||||
logger.info(`Disabled password expiration policies for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableAutoProvisioning(orgId: string): Promise<void> {
|
||||
// Get all IDP IDs for this org through the idpOrg join table
|
||||
const orgIdps = await db
|
||||
.select({ idpId: idpOrg.idpId })
|
||||
.from(idpOrg)
|
||||
.where(eq(idpOrg.orgId, orgId));
|
||||
|
||||
// Update autoProvision to false for all IDPs in this org
|
||||
for (const { idpId } of orgIdps) {
|
||||
await db
|
||||
.update(idp)
|
||||
.set({ autoProvision: false })
|
||||
.where(eq(idp.idpId, idpId));
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { sendEmail } from "@server/emails";
|
||||
import EnterpriseEditionKeyGenerated from "@server/emails/templates/EnterpriseEditionKeyGenerated";
|
||||
import config from "@server/lib/config";
|
||||
import { getFeatureIdByPriceId } from "@server/lib/billing/features";
|
||||
import { handleTierChange } from "../featureLifecycle";
|
||||
|
||||
export async function handleSubscriptionCreated(
|
||||
subscription: Stripe.Subscription
|
||||
@@ -150,6 +151,12 @@ export async function handleSubscriptionCreated(
|
||||
type
|
||||
);
|
||||
|
||||
// Handle initial tier setup - disable features not available in this tier
|
||||
logger.info(
|
||||
`Setting up initial tier features for org ${customer.orgId} with type ${type}`
|
||||
);
|
||||
await handleTierChange(customer.orgId, type);
|
||||
|
||||
const [orgUserRes] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
|
||||
@@ -27,6 +27,7 @@ import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
|
||||
import { getSubType } from "./getSubType";
|
||||
import stripe from "#private/lib/stripe";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import { handleTierChange } from "../featureLifecycle";
|
||||
|
||||
export async function handleSubscriptionDeleted(
|
||||
subscription: Stripe.Subscription
|
||||
@@ -87,6 +88,12 @@ export async function handleSubscriptionDeleted(
|
||||
type
|
||||
);
|
||||
|
||||
// Handle feature lifecycle for cancellation - disable all tier-specific features
|
||||
logger.info(
|
||||
`Disabling tier-specific features for org ${customer.orgId} due to subscription deletion`
|
||||
);
|
||||
await handleTierChange(customer.orgId, null, type);
|
||||
|
||||
const [orgUserRes] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
|
||||
@@ -26,8 +26,9 @@ import logger from "@server/logger";
|
||||
import { getFeatureIdByMetricId, getFeatureIdByPriceId } from "@server/lib/billing/features";
|
||||
import stripe from "#private/lib/stripe";
|
||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||
import { getSubType } from "./getSubType";
|
||||
import { getSubType, SubscriptionType } from "./getSubType";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import { handleTierChange } from "../featureLifecycle";
|
||||
|
||||
export async function handleSubscriptionUpdated(
|
||||
subscription: Stripe.Subscription,
|
||||
@@ -65,6 +66,7 @@ export async function handleSubscriptionUpdated(
|
||||
.limit(1);
|
||||
|
||||
const type = getSubType(fullSubscription);
|
||||
const previousType = existingSubscription.type as SubscriptionType | null;
|
||||
|
||||
await db
|
||||
.update(subscriptions)
|
||||
@@ -79,6 +81,14 @@ export async function handleSubscriptionUpdated(
|
||||
})
|
||||
.where(eq(subscriptions.subscriptionId, subscription.id));
|
||||
|
||||
// Handle tier change if the subscription type changed
|
||||
if (type && type !== previousType) {
|
||||
logger.info(
|
||||
`Tier change detected for org ${customer.orgId}: ${previousType} -> ${type}`
|
||||
);
|
||||
await handleTierChange(customer.orgId, type, previousType ?? undefined);
|
||||
}
|
||||
|
||||
// Upsert subscription items
|
||||
if (Array.isArray(fullSubscription.items?.data)) {
|
||||
// First, get existing items to preserve featureId when there's no match
|
||||
@@ -178,7 +188,7 @@ export async function handleSubscriptionUpdated(
|
||||
const orgId = customer.orgId;
|
||||
|
||||
if (!orgId) {
|
||||
logger.warn(
|
||||
logger.debug(
|
||||
`No orgId found in subscription metadata for subscription ${subscription.id}. Skipping usage reset.`
|
||||
);
|
||||
continue;
|
||||
@@ -268,6 +278,18 @@ export async function handleSubscriptionUpdated(
|
||||
subscription.status,
|
||||
type
|
||||
);
|
||||
|
||||
// Handle feature lifecycle when subscription is canceled or becomes unpaid
|
||||
if (
|
||||
subscription.status === "canceled" ||
|
||||
subscription.status === "unpaid" ||
|
||||
subscription.status === "incomplete_expired"
|
||||
) {
|
||||
logger.info(
|
||||
`Subscription ${subscription.id} for org ${customer.orgId} is ${subscription.status}, disabling paid features`
|
||||
);
|
||||
await handleTierChange(customer.orgId, null, previousType ?? undefined);
|
||||
}
|
||||
} else if (type === "license") {
|
||||
if (subscription.status === "canceled" || subscription.status == "unpaid" || subscription.status == "incomplete_expired") {
|
||||
try {
|
||||
|
||||
@@ -34,7 +34,17 @@ import {
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { CreditCard, ExternalLink, Check } from "lucide-react";
|
||||
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
|
||||
@@ -43,6 +53,7 @@ import { useTranslations } from "use-intl";
|
||||
import Link from "next/link";
|
||||
import { Tier } from "@server/types/Tiers";
|
||||
import {
|
||||
freeLimitSet,
|
||||
tier1LimitSet,
|
||||
tier2LimitSet,
|
||||
tier3LimitSet
|
||||
@@ -64,7 +75,7 @@ const planOptions: PlanOption[] = [
|
||||
{
|
||||
id: "starter",
|
||||
name: "Starter",
|
||||
price: "Starter",
|
||||
price: "Free",
|
||||
tierType: null
|
||||
},
|
||||
{
|
||||
@@ -98,9 +109,15 @@ const planOptions: PlanOption[] = [
|
||||
|
||||
// Tier limits mapping derived from limit sets
|
||||
const tierLimits: Record<
|
||||
Tier,
|
||||
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,
|
||||
@@ -166,7 +183,7 @@ export default function BillingPage() {
|
||||
// Confirmation dialog state
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [pendingTier, setPendingTier] = useState<{
|
||||
tier: Tier;
|
||||
tier: Tier | "starter";
|
||||
action: "upgrade" | "downgrade";
|
||||
planName: string;
|
||||
price: string;
|
||||
@@ -385,7 +402,10 @@ export default function BillingPage() {
|
||||
pendingTier.action === "upgrade" ||
|
||||
pendingTier.action === "downgrade"
|
||||
) {
|
||||
if (hasSubscription) {
|
||||
// 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);
|
||||
@@ -397,7 +417,7 @@ export default function BillingPage() {
|
||||
};
|
||||
|
||||
const showTierConfirmation = (
|
||||
tier: Tier,
|
||||
tier: Tier | "starter",
|
||||
action: "upgrade" | "downgrade",
|
||||
planName: string,
|
||||
price: string
|
||||
@@ -464,6 +484,14 @@ export default function BillingPage() {
|
||||
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();
|
||||
}
|
||||
@@ -504,6 +532,13 @@ export default function BillingPage() {
|
||||
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 = () => {
|
||||
@@ -530,6 +565,63 @@ export default function BillingPage() {
|
||||
return licenseSubscription.items.length;
|
||||
};
|
||||
|
||||
// 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
|
||||
});
|
||||
}
|
||||
|
||||
// Check sites
|
||||
const sitesUsage = getUsageValue(SITES);
|
||||
if (limits.sites > 0 && sitesUsage > limits.sites) {
|
||||
violations.push({
|
||||
feature: "Sites",
|
||||
currentUsage: sitesUsage,
|
||||
newLimit: limits.sites
|
||||
});
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
@@ -666,11 +758,33 @@ export default function BillingPage() {
|
||||
{t("billingUsers") || "Users"}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent className="text-sm">
|
||||
{getLimitValue(USERS) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(USERS) !== null &&
|
||||
"users"}
|
||||
{isOverLimit(USERS) ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||
<span className={cn(
|
||||
"text-orange-600 dark:text-orange-400 font-medium"
|
||||
)}>
|
||||
{getLimitValue(USERS) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(USERS) !== null &&
|
||||
"users"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(USERS), limit: getLimitValue(USERS) ?? 0 }) || `Current usage (${getUsageValue(USERS)}) exceeds limit (${getLimitValue(USERS)})`}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
{getLimitValue(USERS) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(USERS) !== null &&
|
||||
"users"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
@@ -678,11 +792,33 @@ export default function BillingPage() {
|
||||
{t("billingSites") || "Sites"}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent className="text-sm">
|
||||
{getLimitValue(SITES) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(SITES) !== null &&
|
||||
"sites"}
|
||||
{isOverLimit(SITES) ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||
<span className={cn(
|
||||
"text-orange-600 dark:text-orange-400 font-medium"
|
||||
)}>
|
||||
{getLimitValue(SITES) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(SITES) !== null &&
|
||||
"sites"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(SITES), limit: getLimitValue(SITES) ?? 0 }) || `Current usage (${getUsageValue(SITES)}) exceeds limit (${getLimitValue(SITES)})`}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
{getLimitValue(SITES) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(SITES) !== null &&
|
||||
"sites"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
@@ -690,11 +826,33 @@ export default function BillingPage() {
|
||||
{t("billingDomains") || "Domains"}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent className="text-sm">
|
||||
{getLimitValue(DOMAINS) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(DOMAINS) !== null &&
|
||||
"domains"}
|
||||
{isOverLimit(DOMAINS) ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||
<span className={cn(
|
||||
"text-orange-600 dark:text-orange-400 font-medium"
|
||||
)}>
|
||||
{getLimitValue(DOMAINS) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(DOMAINS) !== null &&
|
||||
"domains"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(DOMAINS), limit: getLimitValue(DOMAINS) ?? 0 }) || `Current usage (${getUsageValue(DOMAINS)}) exceeds limit (${getLimitValue(DOMAINS)})`}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
{getLimitValue(DOMAINS) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(DOMAINS) !== null &&
|
||||
"domains"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
@@ -703,11 +861,33 @@ export default function BillingPage() {
|
||||
"Remote Nodes"}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent className="text-sm">
|
||||
{getLimitValue(REMOTE_EXIT_NODES) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(REMOTE_EXIT_NODES) !==
|
||||
null && "remote nodes"}
|
||||
{isOverLimit(REMOTE_EXIT_NODES) ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||
<span className={cn(
|
||||
"text-orange-600 dark:text-orange-400 font-medium"
|
||||
)}>
|
||||
{getLimitValue(REMOTE_EXIT_NODES) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(REMOTE_EXIT_NODES) !==
|
||||
null && "remote nodes"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{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)})`}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
{getLimitValue(REMOTE_EXIT_NODES) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(REMOTE_EXIT_NODES) !==
|
||||
null && "remote nodes"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
@@ -729,31 +909,34 @@ export default function BillingPage() {
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4 bg-muted/30">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">
|
||||
{t("billingCurrentKeys") || "Current Keys"}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold">
|
||||
{getLicenseKeyCount()}
|
||||
</span>
|
||||
<span className="text-lg">
|
||||
{getLicenseKeyCount() === 1
|
||||
? "key"
|
||||
: "keys"}
|
||||
</span>
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">
|
||||
{t("billingCurrentKeys") || "Current Keys"}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold">
|
||||
{getLicenseKeyCount()}
|
||||
</span>
|
||||
<span className="text-lg">
|
||||
{getLicenseKeyCount() === 1
|
||||
? "key"
|
||||
: "keys"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleModifySubscription}
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
{t("billingModifyCurrentPlan") ||
|
||||
"Modify Current Plan"}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleModifySubscription}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
{t("billingModifyCurrentPlan") ||
|
||||
"Modify Current Plan"}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
@@ -784,65 +967,111 @@ export default function BillingPage() {
|
||||
<CredenzaBody>
|
||||
{pendingTier && pendingTier.tier && (
|
||||
<div className="space-y-4">
|
||||
<div className="border rounded-lg p-4 bg-muted/30">
|
||||
<div className="font-semibold text-lg mb-2">
|
||||
<div className="border rounded-lg p-4">
|
||||
<div className="text-2xl">
|
||||
{pendingTier.planName}
|
||||
</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{pendingTier.price}
|
||||
<div className="mt-1">
|
||||
<span className="text-xl">
|
||||
{pendingTier.price}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">
|
||||
{t("billingPlanIncludes") ||
|
||||
"Plan Includes:"}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[pendingTier.tier]
|
||||
.users
|
||||
}{" "}
|
||||
{t("billingUsers") || "Users"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[pendingTier.tier]
|
||||
.sites
|
||||
}{" "}
|
||||
{t("billingSites") || "Sites"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[pendingTier.tier]
|
||||
.domains
|
||||
}{" "}
|
||||
{t("billingDomains") ||
|
||||
"Domains"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[pendingTier.tier]
|
||||
.remoteNodes
|
||||
}{" "}
|
||||
{t("billingRemoteNodes") ||
|
||||
"Remote Nodes"}
|
||||
</span>
|
||||
{tierLimits[pendingTier.tier] && (
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">
|
||||
{t("billingPlanIncludes") ||
|
||||
"Plan Includes:"}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[pendingTier.tier]
|
||||
.users
|
||||
}{" "}
|
||||
{t("billingUsers") || "Users"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[pendingTier.tier]
|
||||
.sites
|
||||
}{" "}
|
||||
{t("billingSites") || "Sites"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[pendingTier.tier]
|
||||
.domains
|
||||
}{" "}
|
||||
{t("billingDomains") ||
|
||||
"Domains"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[pendingTier.tier]
|
||||
.remoteNodes
|
||||
}{" "}
|
||||
{t("billingRemoteNodes") ||
|
||||
"Remote Nodes"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning for limit violations when downgrading */}
|
||||
{pendingTier.action === "downgrade" && (() => {
|
||||
const violations = checkLimitViolations(pendingTier.tier);
|
||||
if (violations.length > 0) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{t("billingLimitViolationWarning") || "Usage Exceeds New Plan Limits"}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className="mb-3">
|
||||
{t("billingLimitViolationDescription") || "Your current usage exceeds the limits of this plan. The following features will be disabled until you reduce usage:"}
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{violations.map((violation, index) => (
|
||||
<li key={index} className="flex items-center gap-2">
|
||||
<span className="font-medium">{violation.feature}:</span>
|
||||
<span>Currently using {violation.currentUsage}, new limit is {violation.newLimit}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* Warning for feature loss when downgrading */}
|
||||
{pendingTier.action === "downgrade" && (
|
||||
<Alert variant="warning">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{t("billingFeatureLossWarning") || "Feature Availability Notice"}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("billingFeatureLossDescription") || "By downgrading, features not available in the new plan will be automatically disabled. Some settings and configurations may be lost. Please review the pricing matrix to understand which features will no longer be available."}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CredenzaBody>
|
||||
|
||||
@@ -21,7 +21,7 @@ type Props = {
|
||||
|
||||
export function PaidFeaturesAlert({ tiers }: Props) {
|
||||
const t = useTranslations();
|
||||
const { hasSaasSubscription, hasEnterpriseLicense } = usePaidStatus();
|
||||
const { hasSaasSubscription, hasEnterpriseLicense, isActive } = usePaidStatus();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
if (env.flags.disableEnterpriseFeatures) {
|
||||
@@ -35,7 +35,7 @@ export function PaidFeaturesAlert({ tiers }: Props) {
|
||||
<CardContent className={bannerContentClassName}>
|
||||
<div className={bannerRowClassName}>
|
||||
<KeyRound className="size-4 shrink-0 text-primary" />
|
||||
<span>{t("subscriptionRequiredToUse")}</span>
|
||||
<span>{isActive ? t("mustUpgradeToUse") : t("subscriptionRequiredToUse")}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -33,6 +33,7 @@ export function usePaidStatus() {
|
||||
hasEnterpriseLicense,
|
||||
hasSaasSubscription,
|
||||
isPaidUser,
|
||||
isActive: tierData?.active,
|
||||
subscriptionTier: tierData?.tier
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user