From f8993261891a2ff90d27056eecadcce6f93aa292 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 5 Feb 2026 14:56:07 -0800 Subject: [PATCH] Change features, remove site uptime --- messages/en-US.json | 11 ++- server/lib/billing/features.ts | 71 +++++++++---------- server/lib/billing/limitSet.ts | 33 +++++++-- server/private/routers/billing/getOrgUsage.ts | 8 +-- server/routers/gerbil/receiveBandwidth.ts | 39 +--------- .../routers/newt/handleNewtRegisterMessage.ts | 8 +-- server/routers/site/createSite.ts | 71 ++++++++++++++++--- server/routers/site/deleteSite.ts | 17 ++++- .../settings/(private)/billing/page.tsx | 11 ++- 9 files changed, 160 insertions(+), 109 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index e9d8cc37..b08c1cf8 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1404,7 +1404,7 @@ "billingUsageLimitsOverview": "Usage Limits Overview", "billingMonitorUsage": "Monitor your usage against configured limits. If you need limits increased please contact us support@pangolin.net.", "billingDataUsage": "Data Usage", - "billingOnlineTime": "Site Online Time", + "billingSites": "Sites", "billingUsers": "Active Users", "billingDomains": "Active Domains", "billingRemoteExitNodes": "Active Self-hosted Nodes", @@ -1432,10 +1432,10 @@ "billingFailedToGetPortalUrl": "Failed to get portal URL", "billingPortalError": "Portal Error", "billingDataUsageInfo": "You're charged for all data transferred through your secure tunnels when connected to the cloud. This includes both incoming and outgoing traffic across all your sites. When you reach your limit, your sites will disconnect until you upgrade your plan or reduce usage. Data is not charged when using nodes.", - "billingOnlineTimeInfo": "You're charged based on how long your sites stay connected to the cloud. For example, 44,640 minutes equals one site running 24/7 for a full month. When you reach your limit, your sites will disconnect until you upgrade your plan or reduce usage. Time is not charged when using nodes.", - "billingUsersInfo": "You're charged for each user in the organization. Billing is calculated daily based on the number of active user accounts in your org.", - "billingDomainInfo": "You're charged for each domain in the organization. Billing is calculated daily based on the number of active domain accounts in your org.", - "billingRemoteExitNodesInfo": "You're charged for each managed Node in the organization. Billing is calculated daily based on the number of active managed Nodes in your org.", + "billingSInfo": "How many sites you can use", + "billingUsersInfo": "How many users you can use", + "billingDomainInfo": "How many domains you can use", + "billingRemoteExitNodesInfo": "How many remote nodes you can use", "billingLicenseKeys": "License Keys", "billingLicenseKeysDescription": "Manage your license key subscriptions", "billingLicenseSubscription": "License Subscription", @@ -1444,7 +1444,6 @@ "billingQuantity": "Quantity", "billingTotal": "total", "billingModifyLicenses": "Modify License Subscription", - "billingPricingCalculatorLink": "View Pricing Calculator", "domainNotFound": "Domain Not Found", "domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.", "failed": "Failed", diff --git a/server/lib/billing/features.ts b/server/lib/billing/features.ts index d074894a..1215a829 100644 --- a/server/lib/billing/features.ts +++ b/server/lib/billing/features.ts @@ -1,30 +1,22 @@ import Stripe from "stripe"; export enum FeatureId { - SITE_UPTIME = "siteUptime", USERS = "users", + SITES = "sites", EGRESS_DATA_MB = "egressDataMb", DOMAINS = "domains", REMOTE_EXIT_NODES = "remoteExitNodes" } -export const FeatureMeterIds: Record = { - [FeatureId.SITE_UPTIME]: "mtr_61Srrej5wUJuiTWgo41D3Ee2Ir7WmDLU", - [FeatureId.USERS]: "mtr_61SrreISyIWpwUNGR41D3Ee2Ir7WmQro", - [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW", - [FeatureId.DOMAINS]: "mtr_61Ss9nIKDNMw0LDRU41D3Ee2Ir7WmRPU", - [FeatureId.REMOTE_EXIT_NODES]: "mtr_61T86UXnfxTVXy9sD41D3Ee2Ir7WmFTE" +export const FeatureMeterIds: Partial> = { + [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW" }; -export const FeatureMeterIdsSandbox: Record = { - [FeatureId.SITE_UPTIME]: "mtr_test_61Snh3cees4w60gv841DCpkOb237BDEu", - [FeatureId.USERS]: "mtr_test_61Sn5fLtq1gSfRkyA41DCpkOb237B6au", - [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ", - [FeatureId.DOMAINS]: "mtr_test_61SsA8qrdAlgPpFRQ41DCpkOb237BGts", - [FeatureId.REMOTE_EXIT_NODES]: "mtr_test_61T86Vqmwa3D9ra3341DCpkOb237B94K" +export const FeatureMeterIdsSandbox: Partial> = { + [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ" }; -export function getFeatureMeterId(featureId: FeatureId): string { +export function getFeatureMeterId(featureId: FeatureId): string | undefined { if ( process.env.ENVIRONMENT == "prod" && process.env.SANDBOX_MODE !== "true" @@ -43,38 +35,43 @@ export function getFeatureIdByMetricId( )?.[0]; } -export type FeaturePriceSet = { - [key in Exclude]: string; -} & { - [FeatureId.DOMAINS]?: string; // Optional since domains are not billed +export type FeaturePriceSet = Partial>; + +export const starterFeaturePriceSet: FeaturePriceSet = { + [FeatureId.USERS]: "price_1RrQeJD3Ee2Ir7WmgveP3xea" }; -export const standardFeaturePriceSet: FeaturePriceSet = { - // Free tier matches the freeLimitSet - [FeatureId.SITE_UPTIME]: "price_1RrQc4D3Ee2Ir7WmaJGZ3MtF", - [FeatureId.USERS]: "price_1RrQeJD3Ee2Ir7WmgveP3xea", - [FeatureId.EGRESS_DATA_MB]: "price_1RrQXFD3Ee2Ir7WmvGDlgxQk", - // [FeatureId.DOMAINS]: "price_1Rz3tMD3Ee2Ir7Wm5qLeASzC", - [FeatureId.REMOTE_EXIT_NODES]: "price_1S46weD3Ee2Ir7Wm94KEHI4h" +export const starterFeaturePriceSetSandbox: FeaturePriceSet = { + [FeatureId.USERS]: "price_1ReNa4DCpkOb237Bc67G5muF" }; -export const standardFeaturePriceSetSandbox: FeaturePriceSet = { - // Free tier matches the freeLimitSet - [FeatureId.SITE_UPTIME]: "price_1RefFBDCpkOb237BPrKZ8IEU", - [FeatureId.USERS]: "price_1ReNa4DCpkOb237Bc67G5muF", - [FeatureId.EGRESS_DATA_MB]: "price_1Rfp9LDCpkOb237BwuN5Oiu0", - // [FeatureId.DOMAINS]: "price_1Ryi88DCpkOb237B2D6DM80b", - [FeatureId.REMOTE_EXIT_NODES]: "price_1RyiZvDCpkOb237BXpmoIYJL" -}; - -export function getStandardFeaturePriceSet(): FeaturePriceSet { +export function getStarterFeaturePriceSet(): FeaturePriceSet { if ( process.env.ENVIRONMENT == "prod" && process.env.SANDBOX_MODE !== "true" ) { - return standardFeaturePriceSet; + return starterFeaturePriceSet; } else { - return standardFeaturePriceSetSandbox; + return starterFeaturePriceSetSandbox; + } +} + +export const scaleFeaturePriceSet: FeaturePriceSet = { + [FeatureId.USERS]: "price_1RrQeJD3Ee2Ir7WmgveP3xea" +}; + +export const scaleFeaturePriceSetSandbox: FeaturePriceSet = { + [FeatureId.USERS]: "price_1ReNa4DCpkOb237Bc67G5muF" +}; + +export function getScaleFeaturePriceSet(): FeaturePriceSet { + if ( + process.env.ENVIRONMENT == "prod" && + process.env.SANDBOX_MODE !== "true" + ) { + return scaleFeaturePriceSet; + } else { + return scaleFeaturePriceSetSandbox; } } diff --git a/server/lib/billing/limitSet.ts b/server/lib/billing/limitSet.ts index fdd077d9..a7a21809 100644 --- a/server/lib/billing/limitSet.ts +++ b/server/lib/billing/limitSet.ts @@ -8,7 +8,7 @@ export type LimitSet = { }; export const sandboxLimitSet: LimitSet = { - [FeatureId.SITE_UPTIME]: { value: 2880, description: "Sandbox limit" }, // 1 site up for 2 days + [FeatureId.SITES]: { value: 1, description: "Sandbox limit" }, // 1 site up for 2 days [FeatureId.USERS]: { value: 1, description: "Sandbox limit" }, [FeatureId.EGRESS_DATA_MB]: { value: 1000, description: "Sandbox limit" }, // 1 GB [FeatureId.DOMAINS]: { value: 0, description: "Sandbox limit" }, @@ -16,7 +16,7 @@ export const sandboxLimitSet: LimitSet = { }; export const freeLimitSet: LimitSet = { - [FeatureId.SITE_UPTIME]: { value: 46080, description: "Free tier limit" }, // 1 site up for 32 days + [FeatureId.SITES]: { value: 3, description: "Free tier limit" }, // 1 site up for 32 days [FeatureId.USERS]: { value: 3, description: "Free tier limit" }, [FeatureId.EGRESS_DATA_MB]: { value: 25000, @@ -26,9 +26,32 @@ export const freeLimitSet: LimitSet = { [FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Free tier limit" } }; -export const subscribedLimitSet: LimitSet = { - [FeatureId.SITE_UPTIME]: { - value: 2232000, +export const starterLimitSet: LimitSet = { + [FeatureId.SITES]: { + value: 10, + description: "Contact us to increase soft limit." + }, // 50 sites up for 31 days + [FeatureId.USERS]: { + value: 150, + description: "Contact us to increase soft limit." + }, + [FeatureId.EGRESS_DATA_MB]: { + value: 12000000, + description: "Contact us to increase soft limit." + }, // 12000 GB + [FeatureId.DOMAINS]: { + value: 250, + description: "Contact us to increase soft limit." + }, + [FeatureId.REMOTE_EXIT_NODES]: { + value: 5, + description: "Contact us to increase soft limit." + } +}; + +export const scaleLimitSet: LimitSet = { + [FeatureId.SITES]: { + value: 10, description: "Contact us to increase soft limit." }, // 50 sites up for 31 days [FeatureId.USERS]: { diff --git a/server/private/routers/billing/getOrgUsage.ts b/server/private/routers/billing/getOrgUsage.ts index 1a343730..9d65e98b 100644 --- a/server/private/routers/billing/getOrgUsage.ts +++ b/server/private/routers/billing/getOrgUsage.ts @@ -78,9 +78,9 @@ export async function getOrgUsage( // Get usage for org const usageData = []; - const siteUptime = await usageService.getUsage( + const sites = await usageService.getUsage( orgId, - FeatureId.SITE_UPTIME + FeatureId.SITES ); const users = await usageService.getUsageDaily(orgId, FeatureId.USERS); const domains = await usageService.getUsageDaily( @@ -96,8 +96,8 @@ export async function getOrgUsage( FeatureId.EGRESS_DATA_MB ); - if (siteUptime) { - usageData.push(siteUptime); + if (sites) { + usageData.push(sites); } if (users) { usageData.push(users); diff --git a/server/routers/gerbil/receiveBandwidth.ts b/server/routers/gerbil/receiveBandwidth.ts index 5c9cacb2..a2306d27 100644 --- a/server/routers/gerbil/receiveBandwidth.ts +++ b/server/routers/gerbil/receiveBandwidth.ts @@ -114,7 +114,6 @@ export async function updateSiteBandwidth( // Aggregate usage data by organization (collected outside transaction) const orgUsageMap = new Map(); - const orgUptimeMap = new Map(); if (activePeers.length > 0) { // Remove any active peers from offline tracking since they're sending data @@ -166,14 +165,6 @@ export async function updateSiteBandwidth( updatedSite.orgId, currentOrgUsage + totalBandwidth ); - - // Add 10 seconds of uptime for each active site - const currentOrgUptime = - orgUptimeMap.get(updatedSite.orgId) || 0; - orgUptimeMap.set( - updatedSite.orgId, - currentOrgUptime + 10 / 60 - ); } } catch (error) { logger.error( @@ -187,10 +178,10 @@ export async function updateSiteBandwidth( // Process usage updates outside of site update transactions // This separates the concerns and reduces lock contention - if (calcUsageAndLimits && (orgUsageMap.size > 0 || orgUptimeMap.size > 0)) { + if (calcUsageAndLimits && (orgUsageMap.size > 0)) { // Sort org IDs to ensure consistent lock ordering const allOrgIds = [ - ...new Set([...orgUsageMap.keys(), ...orgUptimeMap.keys()]) + ...new Set([...orgUsageMap.keys()]) ].sort(); for (const orgId of allOrgIds) { @@ -220,32 +211,6 @@ export async function updateSiteBandwidth( }); } } - - // Process uptime usage for this org - const totalUptime = orgUptimeMap.get(orgId); - if (totalUptime) { - const uptimeUsage = await usageService.add( - orgId, - FeatureId.SITE_UPTIME, - totalUptime - ); - if (uptimeUsage) { - // Fire and forget - don't block on limit checking - usageService - .checkLimitSet( - orgId, - true, - FeatureId.SITE_UPTIME, - uptimeUsage - ) - .catch((error: any) => { - logger.error( - `Error checking uptime limits for org ${orgId}:`, - error - ); - }); - } - } } catch (error) { logger.error(`Error processing usage for org ${orgId}:`, error); // Continue with other orgs diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 9ffee919..3a018fdc 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -96,10 +96,10 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { fetchContainers(newt.newtId); } - const rejectSiteUptime = await usageService.checkLimitSet( + const rejectSites = await usageService.checkLimitSet( oldSite.orgId, false, - FeatureId.SITE_UPTIME + FeatureId.SITES ); const rejectEgressDataMb = await usageService.checkLimitSet( oldSite.orgId, @@ -111,8 +111,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { // const rejectUsers = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.USERS); // const rejectDomains = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.DOMAINS); - // if (rejectEgressDataMb || rejectSiteUptime || rejectUsers || rejectDomains) { - if (rejectEgressDataMb || rejectSiteUptime) { + // if (rejectEgressDataMb || rejectSites || rejectUsers || rejectDomains) { + if (rejectEgressDataMb || rejectSites) { logger.info( `Usage limits exceeded for org ${oldSite.orgId}. Rejecting newt registration.` ); diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index c798ea30..ece97d1d 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -18,6 +18,8 @@ import { isValidIP } from "@server/lib/validators"; import { isIpInCidr } from "@server/lib/ip"; import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes"; import { build } from "@server/build"; +import { usageService } from "@server/lib/billing/usageService"; +import { FeatureId } from "@server/lib/billing"; const createSiteParamsSchema = z.strictObject({ orgId: z.string() @@ -126,6 +128,35 @@ export async function createSite( ); } + if (build == "saas") { + const usage = await usageService.getUsage(orgId, FeatureId.SITES); + if (!usage) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No usage data found for this organization" + ) + ); + } + const rejectSites = await usageService.checkLimitSet( + orgId, + false, + FeatureId.SITES, + { + ...usage, + instantaneousValue: (usage.instantaneousValue || 0) + 1 + } // We need to add one to know if we are violating the limit + ); + if (rejectSites) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Sites limit exceeded. Please upgrade your plan." + ) + ); + } + } + let updatedAddress = null; if (address) { if (!org.subnet) { @@ -256,8 +287,8 @@ export async function createSite( const niceId = await getUniqueSiteName(orgId); - let newSite: Site; - + let newSite: Site | undefined; + let numSites: Site[] | undefined; await db.transaction(async (trx) => { if (type == "wireguard" || type == "newt") { // we are creating a site with an exit node (tunneled) @@ -402,13 +433,35 @@ export async function createSite( }); } - return response(res, { - data: newSite, - success: true, - error: false, - message: "Site created successfully", - status: HttpCode.CREATED - }); + numSites = await trx + .select() + .from(sites) + .where(eq(sites.orgId, orgId)); + }); + + if (numSites) { + await usageService.updateDaily( + orgId, + FeatureId.SITES, + numSites.length + ); + } + + if (!newSite) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create site" + ) + ); + } + + return response(res, { + data: newSite, + success: true, + error: false, + message: "Site created successfully", + status: HttpCode.CREATED }); } catch (error) { logger.error(error); diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 94d9d920..29159352 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, siteResources } from "@server/db"; +import { db, Site, siteResources } from "@server/db"; import { newts, newtSessions, sites } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; @@ -12,6 +12,8 @@ import { fromError } from "zod-validation-error"; import { sendToClient } from "#dynamic/routers/ws"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { usageService } from "@server/lib/billing/usageService"; +import { FeatureId } from "@server/lib/billing"; const deleteSiteSchema = z.strictObject({ siteId: z.string().transform(Number).pipe(z.int().positive()) @@ -62,6 +64,7 @@ export async function deleteSite( } let deletedNewtId: string | null = null; + let numSites: Site[] | undefined; await db.transaction(async (trx) => { if (site.type == "wireguard") { @@ -99,8 +102,20 @@ export async function deleteSite( } await trx.delete(sites).where(eq(sites.siteId, siteId)); + + numSites = await trx + .select() + .from(sites) + .where(eq(sites.orgId, site.orgId)); }); + if (numSites) { + await usageService.updateDaily( + site.orgId, + FeatureId.SITES, + numSites.length + ); + } // Send termination message outside of transaction to prevent blocking if (deletedNewtId) { const payload = { diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index e1879aa6..7b10dc4c 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -207,7 +207,7 @@ export default function GeneralPage() { }; // Usage IDs - const SITE_UPTIME = "siteUptime"; + const SITES = "sites"; const USERS = "users"; const EGRESS_DATA_MB = "egressDataMb"; const DOMAINS = "domains"; @@ -362,12 +362,11 @@ export default function GeneralPage() { getLimitUsage: (v: any) => v.latestValue }, { - id: SITE_UPTIME, - label: t("billingOnlineTime"), + id: SITES, + label: t("billingSites"), icon: , - unit: "min", - info: t("billingOnlineTimeInfo"), - note: "Not counted on self-hosted nodes", + unit: "", + info: t("billingSitesInfo"), getDisplay: (v: any) => v.latestValue, getLimitDisplay: (v: any) => v.value, getUsage: (v: any) => v.latestValue,