mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Grandfather active v5 Pro customers as uncapped
This commit is contained in:
parent
a8f0613671
commit
f3c4d4d83d
22 changed files with 343 additions and 76 deletions
|
|
@ -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{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue