Preserve grandfathered recurring billing baselines

This commit is contained in:
rcourtman 2026-04-15 14:32:46 +01:00
parent bd8b2efd1b
commit 6d65571105
3 changed files with 57 additions and 1 deletions

View file

@ -216,6 +216,14 @@ Insights`, rather than reviving generic `AI Patrol` or `AI ... analysis`
monitored-system and guest capacity while the subscription remains
continuous, and only new v6 retail purchases or post-cancellation re-entry
may take the current Pro/Pro+ caps.
7. Keep persisted billing baselines and live recurring continuity distinct:
`pkg/licensing/billing_state_normalization.go` must store the canonical
monitored-system billing baseline for recognized grandfathered recurring
v5/v1 Stripe plans so webhook persistence and admin-visible hosted billing
state stay deterministic, while `pkg/licensing/database_source.go`,
`pkg/licensing/models.go`, and downstream runtime entitlement evaluation
must strip that stored cap before enforcement so active recurring
grandfathered continuity remains uncapped until cancellation.
## Current State
@ -1379,6 +1387,16 @@ canonical Cloud/MSP limit rule: when paid state is granted, billing-state
writes must persist authoritative `limits.max_monitored_systems` derived from canonical
plan resolution, and when paid state is revoked they must clear those stored
limits instead of preserving stale paid capacity.
Grandfathered recurring v5/v1 continuity is the explicit stored-state
exception inside that same boundary. `pkg/licensing/billing_state_normalization.go`
must persist the canonical billing baseline for recognized grandfathered
recurring Stripe plans even though the live entitlement contract is uncapped,
so webhook persistence, hosted billing state, and admin inspection stay
deterministic instead of leaking an internal `0 == unlimited` convention into
saved billing records. `pkg/licensing/database_source.go`,
`pkg/licensing/models.go`, and downstream entitlement evaluation must then
strip that stored monitored-system cap back out before runtime enforcement, so
continuous grandfathered recurring customers stay uncapped until cancellation.
That same monitored-system entitlement boundary also owns the shared operator
warning copy: the limit banner and migration guidance must present the counted
surface as monitored systems, not drift back into agent-install language while

View file

@ -68,7 +68,7 @@ func NormalizeBillingState(state *BillingState) *BillingState {
normalized.Limits = map[string]int64{}
normalized.MetersEnabled = []string{}
default:
if limit, known := CloudPlanMonitoredSystemLimits[normalized.PlanVersion]; known {
if limit, ok := billingStateStoredMonitoredSystemLimit(normalized.PlanVersion); ok {
normalized.Limits[MaxMonitoredSystemsLicenseGateKey] = int64(limit)
}
}
@ -76,6 +76,18 @@ func NormalizeBillingState(state *BillingState) *BillingState {
return normalized
}
func billingStateStoredMonitoredSystemLimit(planVersion string) (int, bool) {
planVersion = CanonicalizePlanVersion(planVersion)
if IsGrandfatheredRecurringV5PlanVersion(planVersion) {
return UnknownPlanDefaultMonitoredSystemLimit, true
}
limit, known := CloudPlanMonitoredSystemLimits[planVersion]
if !known || limit <= 0 {
return 0, false
}
return limit, true
}
func IsValidBillingSubscriptionState(state SubscriptionState) bool {
switch state {
case SubStateTrial,

View file

@ -306,6 +306,32 @@ func TestNormalizeBillingState_CanonicalizesCloudPlanVersionAndLimits(t *testing
}
}
func TestNormalizeBillingState_GrandfatheredRecurringPlanUsesStoredBillingBaseline(t *testing.T) {
state := &BillingState{
PlanVersion: " v5_pro_monthly_grandfathered ",
Limits: map[string]int64{
"max_monitored_systems": 0,
"max_nodes": 99,
"max_guests": 50,
},
SubscriptionState: SubStateActive,
}
normalized := NormalizeBillingState(state)
if normalized.PlanVersion != "v5_pro_monthly_grandfathered" {
t.Fatalf("plan_version=%q, want %q", normalized.PlanVersion, "v5_pro_monthly_grandfathered")
}
if got := normalized.Limits["max_monitored_systems"]; got != UnknownPlanDefaultMonitoredSystemLimit {
t.Fatalf("limits[max_monitored_systems]=%d, want %d", got, UnknownPlanDefaultMonitoredSystemLimit)
}
if got := normalized.Limits["max_guests"]; got != 50 {
t.Fatalf("limits[max_guests]=%d, want %d", got, 50)
}
if _, hasOld := normalized.Limits["max_nodes"]; hasOld {
t.Fatal("expected max_nodes to be deleted during normalization")
}
}
func TestNormalizeBillingState_PreservesNonCloudPlanLimits(t *testing.T) {
state := &BillingState{
PlanVersion: "pro-v2",