Grandfather active v5 Pro customers as uncapped

This commit is contained in:
rcourtman 2026-04-15 00:35:24 +01:00
parent a8f0613671
commit f3c4d4d83d
22 changed files with 343 additions and 76 deletions

View file

@ -243,6 +243,10 @@ func normalizeDatabaseSourceState(state BillingState) BillingState {
normalized.CommercialMigration = NormalizeCommercialMigrationStatus(normalized.CommercialMigration)
normalized.Limits = NormalizeMonitoredSystemLimits(normalized.Limits)
if IsGrandfatheredRecurringV5PlanVersion(normalized.PlanVersion) {
delete(normalized.Limits, MaxMonitoredSystemsLicenseGateKey)
delete(normalized.Limits, "max_guests")
}
switch normalized.SubscriptionState {
case SubStateExpired, SubStateSuspended, SubStateCanceled:
@ -250,7 +254,7 @@ func normalizeDatabaseSourceState(state BillingState) BillingState {
normalized.Limits = nil
normalized.MetersEnabled = nil
default:
if limit, known := CloudPlanMonitoredSystemLimits[normalized.PlanVersion]; known {
if limit, known := CloudPlanMonitoredSystemLimits[normalized.PlanVersion]; known && limit > 0 {
if normalized.Limits == nil {
normalized.Limits = map[string]int64{}
}

View file

@ -123,6 +123,31 @@ func TestDatabaseSourceCanonicalizesCloudPlanVersionAndLimits(t *testing.T) {
}
}
func TestDatabaseSourceGrandfatheredRecurringPlanStripsCappedLimits(t *testing.T) {
store := &mockBillingStore{
state: &BillingState{
PlanVersion: "v5_pro_monthly_grandfathered",
Limits: map[string]int64{
"max_monitored_systems": 10,
"max_guests": 50,
},
SubscriptionState: SubStateActive,
},
}
source := NewDatabaseSource(store, "org-1", time.Hour)
if got := source.PlanVersion(); got != "v5_pro_monthly_grandfathered" {
t.Fatalf("expected plan_version %q, got %q", "v5_pro_monthly_grandfathered", got)
}
if got := source.SubscriptionState(); got != SubStateActive {
t.Fatalf("expected subscription_state %q, got %q", SubStateActive, got)
}
if got := source.Limits(); len(got) != 0 {
t.Fatalf("expected grandfathered recurring plan to be uncapped, got limits %v", got)
}
}
func TestDatabaseSourcePreservesMissingPlanVersion(t *testing.T) {
store := &mockBillingStore{
state: &BillingState{

View file

@ -82,6 +82,7 @@ var TierMonitoredSystemLimits = map[Tier]int{
// plans that still renew through Stripe. Those subscriptions are not "unknown"
// just because they are no longer sold; they remain canonical paid states that
// must preserve their plan identity during webhook-driven billing updates.
// A value of 0 means uncapped continuity for active recurring v5 customers.
var CloudPlanMonitoredSystemLimits = map[string]int{
// Individual Cloud tiers
"cloud_starter": 10,
@ -89,9 +90,10 @@ var CloudPlanMonitoredSystemLimits = map[string]int{
"cloud_max": 75,
"cloud_founding": 10, // Founding rate = Starter limits
// Grandfathered recurring Pulse Pro continuity plans
"v5_pro_monthly_grandfathered": 10,
"v5_pro_annual_grandfathered": 10,
// Grandfathered recurring Pulse Pro continuity plans remain uncapped while
// the recurring subscription stays active.
"v5_pro_monthly_grandfathered": 0,
"v5_pro_annual_grandfathered": 0,
// MSP tiers — host pool limits from pricing spec
"msp_starter": 50, // MSP Starter: 10 clients, 50 host pool
@ -149,6 +151,15 @@ func PlanVersionForPriceID(priceID string) (string, bool) {
return v, ok
}
func IsGrandfatheredRecurringV5PlanVersion(planVersion string) bool {
switch CanonicalizePlanVersion(planVersion) {
case "v5_pro_monthly_grandfathered", "v5_pro_annual_grandfathered":
return true
default:
return false
}
}
// UnknownPlanDefaultMonitoredSystemLimit is the safe-default monitored-system limit applied when a
// plan version is not recognized. Fail-closed: unknown plans get the smallest
// tier limit rather than unlimited access.

View file

@ -392,6 +392,8 @@ func TestLimitsForCloudPlan_KnownPlans(t *testing.T) {
{"msp_hosted_v1", 50},
{"msp_growth", 150},
{"msp_scale", 400},
{"v5_pro_monthly_grandfathered", 0},
{"v5_pro_annual_grandfathered", 0},
}
for _, tt := range tests {
@ -576,6 +578,27 @@ func TestPlanVersionForPriceID_UnknownPrices(t *testing.T) {
}
}
func TestIsGrandfatheredRecurringV5PlanVersion(t *testing.T) {
tests := []struct {
plan string
want bool
}{
{plan: "v5_pro_monthly_grandfathered", want: true},
{plan: "v5_pro_annual_grandfathered", want: true},
{plan: "price_1ShIsdBrHBocJIGH71yQusLG", want: false},
{plan: "cloud_starter", want: false},
{plan: "", want: false},
}
for _, tt := range tests {
t.Run(tt.plan, func(t *testing.T) {
if got := IsGrandfatheredRecurringV5PlanVersion(tt.plan); got != tt.want {
t.Fatalf("IsGrandfatheredRecurringV5PlanVersion(%q) = %v, want %v", tt.plan, got, tt.want)
}
})
}
}
// TestPriceIDToPlanVersion_AllMapToKnownPlans ensures every plan version in the
// price→plan map is recognized by LimitsForCloudPlan (fail-closed safety net).
func TestPriceIDToPlanVersion_AllMapToKnownPlans(t *testing.T) {

View file

@ -90,6 +90,28 @@ func TestDatabaseSourceCanonicalBoundaryContract(t *testing.T) {
t.Fatalf("Limits()[max_monitored_systems] = %d, want %d", got, 42)
}
})
t.Run("grandfathered_recurring_v5_plans_stay_uncapped", func(t *testing.T) {
store := &mockBillingStore{
state: &BillingState{
PlanVersion: "v5_pro_annual_grandfathered",
Limits: map[string]int64{
"max_monitored_systems": 10,
"max_guests": 50,
},
SubscriptionState: SubStateActive,
},
}
source := NewDatabaseSource(store, "org-1", time.Hour)
if got := source.PlanVersion(); got != "v5_pro_annual_grandfathered" {
t.Fatalf("PlanVersion() = %q, want %q", got, "v5_pro_annual_grandfathered")
}
if got := source.Limits(); len(got) != 0 {
t.Fatalf("Limits() = %v, want no grandfathered recurring caps", got)
}
})
}
// grantContractJSONTags lists the JSON field names that MUST exist with

View file

@ -471,7 +471,7 @@ func TestRefreshGrantOnce_PreservesLegacyGrandfatherFloor(t *testing.T) {
newGrantJWT := makeTestGrantJWT(t, &GrantClaims{
LicenseID: "lic_refreshed_floor",
Tier: "pro",
PlanKey: "v5_pro_monthly_grandfathered",
PlanKey: "legacy_migration_fallback",
State: "active",
MaxMonitoredSystems: 10,
IssuedAt: time.Now().Unix(),
@ -503,7 +503,7 @@ func TestRefreshGrantOnce_PreservesLegacyGrandfatherFloor(t *testing.T) {
initialGrantJWT := makeTestGrantJWT(t, &GrantClaims{
LicenseID: "lic_initial_floor",
Tier: "pro",
PlanKey: "v5_pro_monthly_grandfathered",
PlanKey: "legacy_migration_fallback",
State: "active",
MaxMonitoredSystems: 10,
IssuedAt: time.Now().Unix(),

View file

@ -80,9 +80,10 @@ func (c Claims) EffectiveLimits() map[string]int64 {
limits["max_guests"] = int64(c.MaxGuests)
}
}
if c.Tier == TierLifetime {
// Grandfathered lifetime licenses must remain uncapped even if older
// tokens or migrated records still carry historical Pro-era limits.
if c.Tier == TierLifetime || IsGrandfatheredRecurringV5PlanVersion(c.PlanVersion) {
// Grandfathered lifetime licenses and active recurring v5 migrations
// must remain uncapped even if older tokens or migrated records still
// carry historical Pro-era limits.
delete(limits, MaxMonitoredSystemsLicenseGateKey)
delete(limits, "max_guests")
}

View file

@ -106,6 +106,17 @@ func TestClaims_EffectiveLimits(t *testing.T) {
},
expected: map[string]int64{},
},
{
name: "grandfathered_recurring_v5_strips_new_v6_caps",
claims: Claims{
Tier: TierPro,
PlanVersion: "v5_pro_annual_grandfathered",
Limits: map[string]int64{"max_monitored_systems": 15, "max_guests": 5},
MaxMonitoredSystems: 15,
MaxGuests: 5,
},
expected: map[string]int64{},
},
}
for _, tt := range tests {

View file

@ -514,6 +514,9 @@ func (s *Service) needsLegacyMonitoredSystemCaptureLocked() bool {
if s == nil || s.activationState == nil {
return false
}
if s.legacyMigrationUsesUncappedRecurringPlanLocked() {
return false
}
return normalizeActivationContinuity(s.activationState.Continuity).needsLegacyMonitoredSystemCapture()
}
@ -537,6 +540,9 @@ func (s *Service) monitoredSystemContinuityStatusLocked() *MonitoredSystemContin
if !continuity.LegacyMigration {
return nil
}
if s.legacyMigrationUsesUncappedRecurringPlanLocked() {
return nil
}
planLimit := 0
if gc, err := verifyAndParseGrantJWT(s.activationState.GrantJWT); err == nil && gc != nil {
@ -824,7 +830,7 @@ func (s *Service) Status() *LicenseStatus {
// Apply the tier default monitored-system limit when claims don't specify one.
// For recognized tiers, use their defined limit (0 = unlimited for Cloud/MSP/Enterprise).
// For unrecognized tiers, fall back to free tier limit to prevent unlimited access.
if status.MaxMonitoredSystems == 0 {
if status.MaxMonitoredSystems == 0 && !IsGrandfatheredRecurringV5PlanVersion(status.PlanVersion) {
if defaultSystems, ok := TierMonitoredSystemLimits[status.Tier]; ok {
status.MaxMonitoredSystems = defaultSystems
} else {
@ -971,6 +977,20 @@ func monitoredSystemLimitFromClaims(claims Claims) int {
return 0
}
func (s *Service) legacyMigrationUsesUncappedRecurringPlanLocked() bool {
if s == nil || s.activationState == nil {
return false
}
if s.license != nil && IsGrandfatheredRecurringV5PlanVersion(s.license.Claims.PlanVersion) {
return true
}
gc, err := verifyAndParseGrantJWT(s.activationState.GrantJWT)
if err != nil || gc == nil {
return false
}
return IsGrandfatheredRecurringV5PlanVersion(gc.PlanKey)
}
func remainingDaysCeil(expiresAtUnix, nowUnix int64) int {
deltaSeconds := expiresAtUnix - nowUnix
if deltaSeconds <= 0 {

View file

@ -388,7 +388,7 @@ func TestServiceCaptureLegacyMonitoredSystemGrandfatherFloorPersistsAndUpdatesSt
initialGrantJWT := makeTestGrantJWT(t, &GrantClaims{
LicenseID: "lic_floor",
Tier: "pro",
PlanKey: "v5_pro_monthly_grandfathered",
PlanKey: "legacy_migration_fallback",
State: "active",
MaxMonitoredSystems: 10,
IssuedAt: time.Now().Unix(),
@ -458,13 +458,13 @@ func TestServiceCaptureLegacyMonitoredSystemGrandfatherFloorPersistsAndUpdatesSt
}
}
func TestServiceStatus_ExposesMonitoredSystemContinuity(t *testing.T) {
func TestServiceStatus_ExposesMonitoredSystemContinuityForFallbackMigrations(t *testing.T) {
setupTestPublicKey(t)
grantJWT := makeTestGrantJWT(t, &GrantClaims{
LicenseID: "lic_continuity_status",
Tier: "pro",
PlanKey: "v5_pro_monthly_grandfathered",
PlanKey: "legacy_migration_fallback",
State: "active",
MaxMonitoredSystems: 10,
IssuedAt: time.Now().Unix(),
@ -544,6 +544,52 @@ func TestServiceStatus_ExposesMonitoredSystemContinuity(t *testing.T) {
}
}
func TestServiceStatus_GrandfatheredRecurringV5IsUncapped(t *testing.T) {
setupTestPublicKey(t)
grantJWT := makeTestGrantJWT(t, &GrantClaims{
LicenseID: "lic_recurring_grandfathered",
Tier: "pro",
PlanKey: "v5_pro_monthly_grandfathered",
State: "active",
MaxMonitoredSystems: 10,
MaxGuests: 5,
IssuedAt: time.Now().Unix(),
ExpiresAt: time.Now().Add(72 * time.Hour).Unix(),
})
svc := NewService()
if err := svc.RestoreActivation(&ActivationState{
InstallationID: "inst_recurring_grandfathered",
InstallationToken: "pit_live_recurring_grandfathered",
LicenseID: "lic_recurring_grandfathered",
GrantJWT: grantJWT,
GrantJTI: "grant_recurring_grandfathered",
InstanceFingerprint: "fp-recurring-grandfathered",
Continuity: ActivationContinuity{
LegacyMigration: true,
},
}); err != nil {
t.Fatalf("RestoreActivation: %v", err)
}
status := svc.Status()
if status.MaxMonitoredSystems != 0 {
t.Fatalf("status.MaxMonitoredSystems=%d, want 0 for uncapped grandfathered recurring plan", status.MaxMonitoredSystems)
}
if status.MonitoredSystemContinuity != nil {
t.Fatalf("expected no monitored-system continuity banner for uncapped recurring v5 migration, got %+v", status.MonitoredSystemContinuity)
}
current := svc.Current()
if current == nil {
t.Fatal("expected current license")
}
if got := current.Claims.EffectiveLimits(); len(got) != 0 {
t.Fatalf("EffectiveLimits() = %v, want no capped commercial limits", got)
}
}
func TestServiceActivate_RejectsMalformedLegacyKeyOutsideDevMode(t *testing.T) {
t.Setenv("PULSE_LICENSE_DEV_MODE", "false")