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.
This commit is contained in:
rcourtman 2026-07-08 16:02:56 +01:00
parent a205ba3498
commit fca5975c38
4 changed files with 61 additions and 2 deletions

View file

@ -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 Landing behavior for paid and hosted shells must also defer to the
frontend-primitives-owned provider-first landing contract instead of frontend-primitives-owned provider-first landing contract instead of
defining a cloud-paid-specific order. 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 ## Forbidden Paths

View file

@ -38,6 +38,7 @@ const (
TierProPlus = licensing.TierProPlus TierProPlus = licensing.TierProPlus
TierProAnnual = licensing.TierProAnnual TierProAnnual = licensing.TierProAnnual
TierLifetime = licensing.TierLifetime TierLifetime = licensing.TierLifetime
TierBusiness = licensing.TierBusiness
TierCloud = licensing.TierCloud TierCloud = licensing.TierCloud
TierMSP = licensing.TierMSP TierMSP = licensing.TierMSP
TierEnterprise = licensing.TierEnterprise TierEnterprise = licensing.TierEnterprise

View file

@ -55,6 +55,7 @@ const (
TierProPlus Tier = "pro_plus" TierProPlus Tier = "pro_plus"
TierProAnnual Tier = "pro_annual" // Legacy: same features as TierPro TierProAnnual Tier = "pro_annual" // Legacy: same features as TierPro
TierLifetime Tier = "lifetime" // 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" TierCloud Tier = "cloud"
TierMSP Tier = "msp" TierMSP Tier = "msp"
TierEnterprise Tier = "enterprise" TierEnterprise Tier = "enterprise"
@ -134,7 +135,7 @@ func IsSelfHostedCommunityPlanVersion(planVersion string) bool {
// self-hosted v6 contract where core monitoring volume is not monetized. // self-hosted v6 contract where core monitoring volume is not monetized.
func IsSelfHostedCoreMonitoringUncappedTier(tier Tier) bool { func IsSelfHostedCoreMonitoringUncappedTier(tier Tier) bool {
switch Tier(strings.ToLower(strings.TrimSpace(string(tier)))) { 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 return true
default: default:
return false return false
@ -151,6 +152,8 @@ func IsSelfHostedCoreMonitoringUncappedPlanVersion(planVersion string) bool {
string(TierProPlus), string(TierProPlus),
string(TierProAnnual), string(TierProAnnual),
string(TierLifetime), string(TierLifetime),
string(TierBusiness),
"business_annual",
"v5_lifetime_grandfathered": "v5_lifetime_grandfathered":
return true return true
default: default:
@ -215,6 +218,7 @@ var TierHistoryDays = map[Tier]int{
TierProPlus: 90, TierProPlus: 90,
TierProAnnual: 90, TierProAnnual: 90,
TierLifetime: 90, TierLifetime: 90,
TierBusiness: 365,
TierCloud: 90, TierCloud: 90,
TierMSP: 90, TierMSP: 90,
TierEnterprise: 90, TierEnterprise: 90,
@ -274,6 +278,7 @@ var TierFeatures = map[Tier][]string{
TierProPlus: proFeatures, // Legacy compatibility tier; same runtime features as Pro TierProPlus: proFeatures, // Legacy compatibility tier; same runtime features as Pro
TierProAnnual: proFeatures, // Legacy: same features as Pro TierProAnnual: proFeatures, // Legacy: same features as Pro
TierLifetime: 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 TierCloud: proFeatures, // Cloud includes all Pro features + managed hosting
TierMSP: mspFeatures, TierMSP: mspFeatures,
TierEnterprise: enterpriseFeatures, TierEnterprise: enterpriseFeatures,
@ -406,6 +411,8 @@ func GetTierDisplayName(tier Tier) string {
return "Pro (Annual)" return "Pro (Annual)"
case TierLifetime: case TierLifetime:
return "Pro (Lifetime)" return "Pro (Lifetime)"
case TierBusiness:
return "Business"
case TierCloud: case TierCloud:
return "Cloud" return "Cloud"
case TierMSP: case TierMSP:
@ -421,7 +428,7 @@ func GetTierDisplayName(tier Tier) string {
// This is used for user-facing messages like "requires Pulse Relay or above". // This is used for user-facing messages like "requires Pulse Relay or above".
// The tier ordering is: Free < Relay < Pro < MSP < Enterprise. // The tier ordering is: Free < Relay < Pro < MSP < Enterprise.
func GetFeatureMinTierName(feature string) string { func GetFeatureMinTierName(feature string) string {
orderedTiers := []Tier{TierFree, TierRelay, TierPro, TierMSP, TierEnterprise} orderedTiers := []Tier{TierFree, TierRelay, TierPro, TierBusiness, TierMSP, TierEnterprise}
for _, tier := range orderedTiers { for _, tier := range orderedTiers {
if TierHasFeature(tier, feature) { if TierHasFeature(tier, feature) {
return GetTierDisplayName(tier) return GetTierDisplayName(tier)

View file

@ -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)
}
}