From fca5975c38b683b7a5b8c4cdbb578b3ba9ec5f22 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 8 Jul 2026 16:02:56 +0100 Subject: [PATCH] Add the dormant Business tier to the licensing core TierBusiness: Pro's feature set, 365-day history retention, uncapped core monitoring like every self-hosted tier, Business display name, and a slot in the min-tier ordering. Differentiation is by max_users limit, retention, and support rather than features (multi-user is already a Pro capability via RBAC; see specs/pricing-segmentation.md binder findings). Dormant until the license server issues business plan versions; no checkout, pricing, or UI surface references it yet. Contract shape recorded as cloud-paid Extension Point 26 and pinned by TestBusinessTierContractShape. --- .../v6/internal/subsystems/cloud-paid.md | 16 +++++++++ internal/license/features.go | 1 + pkg/licensing/features.go | 11 ++++-- pkg/licensing/features_test.go | 35 +++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 32f34c71a..5d4a2e07a 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -1034,6 +1034,22 @@ hands-on Patrol modes, issue investigation, verified fixes, and longer history`. Landing behavior for paid and hosted shells must also defer to the frontend-primitives-owned provider-first landing contract instead of defining a cloud-paid-specific order. +26. Introduce the self-hosted `business` tier only through this shape: + `TierBusiness` in `pkg/licensing/features.go` carries exactly the Pro + feature set (no feature-level differentiation), 365-day history + retention in `TierHistoryDays`, membership in the self-hosted + core-monitoring-uncapped tier and plan-version sets (`business`, + `business_annual`), the `Business` display name, and a slot between Pro + and MSP in the min-tier ordering. Business differentiates commercially + by the `max_users` license limit (unlimited for Business; newly issued + Pro licenses may carry a finite `max_users` while previously issued + licenses keep their unlimited posture), retention, and support, never + by gating features away from Pro. The tier stays dormant until the + license server issues business plan versions through a governed + rollout; no checkout, pricing-model payload, public pricing page, or + in-product plan surface may reference Business before that rollout, and + monitored-system volume stays out of the Business plan model per the + self-hosted commercial boundary above. ## Forbidden Paths diff --git a/internal/license/features.go b/internal/license/features.go index 818760c5b..9d34aeb17 100644 --- a/internal/license/features.go +++ b/internal/license/features.go @@ -38,6 +38,7 @@ const ( TierProPlus = licensing.TierProPlus TierProAnnual = licensing.TierProAnnual TierLifetime = licensing.TierLifetime + TierBusiness = licensing.TierBusiness TierCloud = licensing.TierCloud TierMSP = licensing.TierMSP TierEnterprise = licensing.TierEnterprise diff --git a/pkg/licensing/features.go b/pkg/licensing/features.go index fa5e4e72d..0947d86cd 100644 --- a/pkg/licensing/features.go +++ b/pkg/licensing/features.go @@ -55,6 +55,7 @@ const ( TierProPlus Tier = "pro_plus" TierProAnnual Tier = "pro_annual" // Legacy: same features as TierPro TierLifetime Tier = "lifetime" // Legacy: same features as TierPro + TierBusiness Tier = "business" // Pro features; differentiated by user limit, retention, and support TierCloud Tier = "cloud" TierMSP Tier = "msp" TierEnterprise Tier = "enterprise" @@ -134,7 +135,7 @@ func IsSelfHostedCommunityPlanVersion(planVersion string) bool { // self-hosted v6 contract where core monitoring volume is not monetized. func IsSelfHostedCoreMonitoringUncappedTier(tier Tier) bool { switch Tier(strings.ToLower(strings.TrimSpace(string(tier)))) { - case TierFree, TierRelay, TierPro, TierProPlus, TierProAnnual, TierLifetime: + case TierFree, TierRelay, TierPro, TierProPlus, TierProAnnual, TierLifetime, TierBusiness: return true default: return false @@ -151,6 +152,8 @@ func IsSelfHostedCoreMonitoringUncappedPlanVersion(planVersion string) bool { string(TierProPlus), string(TierProAnnual), string(TierLifetime), + string(TierBusiness), + "business_annual", "v5_lifetime_grandfathered": return true default: @@ -215,6 +218,7 @@ var TierHistoryDays = map[Tier]int{ TierProPlus: 90, TierProAnnual: 90, TierLifetime: 90, + TierBusiness: 365, TierCloud: 90, TierMSP: 90, TierEnterprise: 90, @@ -274,6 +278,7 @@ var TierFeatures = map[Tier][]string{ TierProPlus: proFeatures, // Legacy compatibility tier; same runtime features as Pro TierProAnnual: proFeatures, // Legacy: same features as Pro TierLifetime: proFeatures, // Legacy: same features as Pro + TierBusiness: proFeatures, // Same features as Pro; differentiated by max_users limit, 365-day history, and support TierCloud: proFeatures, // Cloud includes all Pro features + managed hosting TierMSP: mspFeatures, TierEnterprise: enterpriseFeatures, @@ -406,6 +411,8 @@ func GetTierDisplayName(tier Tier) string { return "Pro (Annual)" case TierLifetime: return "Pro (Lifetime)" + case TierBusiness: + return "Business" case TierCloud: return "Cloud" case TierMSP: @@ -421,7 +428,7 @@ func GetTierDisplayName(tier Tier) string { // This is used for user-facing messages like "requires Pulse Relay or above". // The tier ordering is: Free < Relay < Pro < MSP < Enterprise. func GetFeatureMinTierName(feature string) string { - orderedTiers := []Tier{TierFree, TierRelay, TierPro, TierMSP, TierEnterprise} + orderedTiers := []Tier{TierFree, TierRelay, TierPro, TierBusiness, TierMSP, TierEnterprise} for _, tier := range orderedTiers { if TierHasFeature(tier, feature) { return GetTierDisplayName(tier) diff --git a/pkg/licensing/features_test.go b/pkg/licensing/features_test.go index 156739867..1ef5b36a0 100644 --- a/pkg/licensing/features_test.go +++ b/pkg/licensing/features_test.go @@ -846,3 +846,38 @@ func TestPriceIDToPlanVersion_AllMapToKnownPlans(t *testing.T) { }) } } + +// TestBusinessTierContractShape pins the dormant Business tier's contract: +// Pro's exact feature set (differentiation is by max_users limit, retention, +// and support, never by features), 365-day history, uncapped self-hosted core +// monitoring, recognized plan versions, and the Business display name. See +// cloud-paid Extension Point 26. +func TestBusinessTierContractShape(t *testing.T) { + proSet := TierFeatures[TierPro] + businessSet := TierFeatures[TierBusiness] + if len(proSet) != len(businessSet) { + t.Fatalf("Business features (%d) must equal Pro features (%d)", len(businessSet), len(proSet)) + } + for i, feature := range proSet { + if businessSet[i] != feature { + t.Fatalf("Business feature[%d] = %q, want Pro's %q", i, businessSet[i], feature) + } + } + + if got := TierHistoryDays[TierBusiness]; got != 365 { + t.Fatalf("TierHistoryDays[TierBusiness] = %d, want 365", got) + } + + if !IsSelfHostedCoreMonitoringUncappedTier(TierBusiness) { + t.Fatal("TierBusiness must be self-hosted core-monitoring uncapped") + } + for _, plan := range []string{"business", "business_annual"} { + if !IsSelfHostedCoreMonitoringUncappedPlanVersion(plan) { + t.Fatalf("plan version %q must be self-hosted core-monitoring uncapped", plan) + } + } + + if got := GetTierDisplayName(TierBusiness); got != "Business" { + t.Fatalf("GetTierDisplayName(TierBusiness) = %q, want Business", got) + } +}