diff --git a/docs/PULSE_PRO.md b/docs/PULSE_PRO.md index d515aa52d..dc5501d3a 100644 --- a/docs/PULSE_PRO.md +++ b/docs/PULSE_PRO.md @@ -58,7 +58,7 @@ Runtime rules: - Deduplication follows canonical unified-resource identity rather than transport-specific state. Migration policy: -- Existing paid v5 customers keep their grandfathered recurring continuity until cancellation. +- Existing active recurring v5 customers keep their grandfathered recurring price and uncapped monitored-system and guest capacity until cancellation. - Existing free users above the new Community cap are not hard-broken on rollout day. - During grace, existing monitoring keeps working. - During grace, only new counted-system additions are blocked until the user removes systems or upgrades. diff --git a/docs/UPGRADE_v6.md b/docs/UPGRADE_v6.md index 10839a416..72b5a4a9a 100644 --- a/docs/UPGRADE_v6.md +++ b/docs/UPGRADE_v6.md @@ -131,7 +131,7 @@ Pulse v6 uses the activation/grant model for active licensing, but it can migrat - a Pulse v6 activation key, or - a valid Pulse v5 Pro/Lifetime license key, which Pulse will try to exchange automatically - If the exchange service cannot complete the migration, retry from the v6 license panel or use the self-serve retrieval flow to fetch the current v6 activation key. Email is only a backup copy of that key. -- Existing paid v5 customers keep their grandfathered recurring continuity until cancellation. If they cancel and later return, current v6 pricing applies. +- Existing active recurring v5 customers keep their grandfathered recurring price and uncapped monitored-system and guest capacity until cancellation. If they cancel and later return, current v6 pricing and limits apply. Practical recommendation: diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 2e99b2bc1..20877c491 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -1449,7 +1449,7 @@ }, { "id": "RA16", - "summary": "Commercial continuity fails closed across cancellation and re-entry: active grandfathered v5 recurring customers keep their legacy recurring price only while the subscription remains continuous, cancellation revokes paid access cleanly, and any later re-entry uses current public v6 pricing rather than reviving grandfathered terms.", + "summary": "Commercial continuity fails closed across cancellation and re-entry: active grandfathered v5 recurring customers keep their legacy recurring price plus uncapped monitored-system and guest capacity only while the subscription remains continuous, cancellation revokes paid access cleanly, and any later re-entry uses current public v6 pricing and limits rather than reviving grandfathered terms.", "kind": "invariant", "blocking_level": "rc-ready", "proof_type": "hybrid", @@ -4395,7 +4395,7 @@ }, { "id": "v5-pro-price-grandfathering", - "summary": "Paid Pulse Pro v5 customers keep their existing recurring price through the v6 pricing change until they cancel; renewals must preserve that grandfathered price state, while any return after cancellation re-enters on current v6 pricing.", + "summary": "Paid Pulse Pro v5 recurring customers keep their existing recurring price plus uncapped monitored-system and guest capacity through the v6 pricing change until they cancel; renewals must preserve that grandfathered state, while any return after cancellation re-enters on current v6 pricing and limits.", "kind": "pricing", "decided_at": "2026-03-12", "subsystem_ids": [ diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 9ae3e895d..054c1b74f 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -192,6 +192,12 @@ still grows monitored-system usage. they keep the Pro feature set, but they must not inherit monitored-system or guest caps from recurring Pro contracts anywhere in runtime, issuance, or migrated-license storage. +6. Treat active recurring v5 Pulse Pro customers as uncapped grandfathered + commercial entitlements until cancellation: migrated and renewing v5/v1 + recurring plans keep their existing recurring price plus uncapped + 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. ## Current State @@ -990,32 +996,24 @@ hosted tenant settings routes. Paid Pulse Pro v5 grandfathering is now part of that same canonical boundary: when a recurring v5 customer migrates into v6, billing persistence, entitlement evaluation, renewal handling, and Pro-license presentation must -preserve the customer's existing recurring price identity instead of silently -rewriting them onto current v6 retail pricing. -That same continuity boundary now also owns monitored-system capacity -preservation for migrated self-hosted estates. Activation persistence and -grant refresh must carry local-only legacy-migration continuity metadata, and -when canonical deduped monitored-system usage first resolves on a migrated -installation Pulse must persist a one-time grandfather floor so status, -runtime entitlement evaluation, and later restore/refresh flows keep the -existing estate admissible without silently grandfathering post-migration -growth. -That first resolved usage snapshot must be a settled canonical monitor view, -not the first store-backed read-state seen during startup. When provider-owned -supplemental inventories such as TrueNAS or VMware are still between initial -provider wiring and the first canonical store rebuild after their baseline -poll, cloud-paid continuity must keep usage unavailable and delay floor -capture rather than sealing a lower count forever. -That continuity capture is reconciler-owned rather than read-owned. Ordinary -status or entitlement reads may expose pending continuity state, but they must -not persist the grandfather floor directly from the request path once a -migrated installation is running. The owning licensing reconciler may backfill -the floor asynchronously after canonical monitored-system usage becomes -settled. The reconcile loop itself is activation-state-owned as well: -activation, restore, grant refresh, and revocation/clear transitions may -start or stop continuity reconciliation, but ordinary billing reads must stay -observer-only and must not bootstrap that background work on demand. -The continuity capture path must notify through that same activation-state +preserve the customer's existing recurring price identity and uncapped +commercial capacity instead of silently rewriting them onto current v6 retail +pricing or Pro-era caps. Activation persistence and grant refresh may still +carry local-only legacy-migration continuity metadata as a defensive fallback +for any bounded legacy grant that survives outside the canonical v5 recurring +contracts, but active recurring v5/v1 customers must not rely on a captured +floor to stay admissible in v6. +That fallback continuity path is reconciler-owned rather than read-owned. +Ordinary status or entitlement reads may expose pending continuity state for a +bounded legacy fallback, but they must not persist the grandfather floor +directly from the request path once a migrated installation is running. The +owning licensing reconciler may backfill the floor asynchronously after +canonical monitored-system usage becomes settled. The reconcile loop itself is +activation-state-owned as well: activation, restore, grant refresh, and +revocation/clear transitions may start or stop continuity reconciliation, but +ordinary billing reads must stay observer-only and must not bootstrap that +background work on demand. +That fallback continuity path must notify through that same activation-state ownership boundary after it persists the grandfather floor, so the reconciler can stop because state changed rather than because a later status or entitlements read happened to observe the captured floor. @@ -1034,7 +1032,8 @@ resolves to `v5_pro_monthly_grandfathered` or That Pro-license presentation rule is explicit UX, not only hidden metadata: when a migrated recurring v5 plan is active or in grace, the settings surface must render plan terms and a continuity notice that makes it clear the -existing recurring price remains in force until cancellation. +existing recurring price plus uncapped monitored-system and guest capacity +remain in force until cancellation. The self-hosted commercial presentation on that same surface is now locked to the monitored-system model as well. `ProLicensePanel.tsx`, `CommercialBillingSections.tsx`, and @@ -1042,11 +1041,14 @@ the monitored-system model as well. `ProLicensePanel.tsx`, retail capacity as monitored systems rather than agents for Community, Relay, Pro, and Pro+, while leaving Cloud/MSP pricing semantics unchanged and preserving grandfathered v5 continuity copy as an explicit boundary policy. -That same settings-owned presentation must make monitored-system continuity -explicit when migrated estates are involved: the plan surface must render the -base plan limit, the effective monitored-system limit, any grandfathered -floor, and whether continuity capture is still pending. When monitored-system -usage is unavailable during continuity verification, the shell must present a +That same settings-owned presentation must distinguish between active +grandfathered recurring v5 continuity and bounded legacy fallbacks. Active +grandfathered recurring v5 plans must render uncapped monitored-system and +guest capacity directly and must not show a pending or captured floor banner. +Only bounded legacy fallback migrations may render the base plan limit, the +effective monitored-system limit, any grandfathered floor, and whether +continuity capture is still pending. When monitored-system usage is +unavailable during fallback continuity verification, the shell must present a verification state rather than implying `0 / limit`. That same pricing boundary now also owns the shared frontend plan-definition models. `frontend-modern/src/utils/cloudPlans.ts` and diff --git a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx index 1c682d211..46a7ad7db 100644 --- a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library'; import { Route, Router } from '@solidjs/router'; import type { LicenseEntitlements } from '@/api/license'; @@ -300,7 +300,7 @@ describe('ProLicensePanel', () => { expect(screen.queryByText('5 / 12')).not.toBeInTheDocument(); }); - it('shows recurring grandfathered pricing continuity for migrated v5 Pro plans', async () => { + it('shows recurring grandfathered v5 Pro plans as uncapped while they remain active', async () => { const tests = [ { name: 'monthly', @@ -336,14 +336,28 @@ describe('ProLicensePanel', () => { expect(screen.getByText(tc.expectedLabel)).toBeInTheDocument(); expect(screen.getByText('Grandfathered v5 pricing')).toBeInTheDocument(); expect( - screen.getByText(/keeps its existing recurring price until you cancel/i), + screen.getByText(/keeps its existing recurring price and uncapped monitored-system and guest capacity until you cancel/i), ).toBeInTheDocument(); + expect( + within(screen.getByText('Monitored Systems').parentElement as HTMLElement).getByText( + 'Unlimited', + ), + ).toBeInTheDocument(); + expect( + within(screen.getByText('Remaining System Capacity').parentElement as HTMLElement).getByText( + 'Unlimited', + ), + ).toBeInTheDocument(); + expect(screen.getByText('Included Monitored Systems')).toBeInTheDocument(); + expect(screen.getByText('Max Guests')).toBeInTheDocument(); + expect(screen.getAllByText('Unlimited').length).toBeGreaterThan(0); + expect(screen.queryByText('Plan Monitored System Limit')).not.toBeInTheDocument(); cleanup(); } }); - it('shows continuity verification while the migrated monitored-system floor is still pending', async () => { + it('shows continuity verification while a bounded fallback migration is still pending', async () => { mockEntitlements = { capabilities: ['relay'], limits: [ @@ -359,7 +373,7 @@ describe('ProLicensePanel', () => { subscription_state: 'active', upgrade_reasons: [], tier: 'pro', - plan_version: 'v5_pro_monthly_grandfathered', + plan_version: 'legacy_migration_fallback', licensed_email: 'owner@example.com', trial_eligible: false, monitored_system_continuity: { @@ -387,7 +401,7 @@ describe('ProLicensePanel', () => { expect(screen.queryByText('0 / 10')).not.toBeInTheDocument(); }); - it('shows grandfathered monitored-system continuity once the migrated floor is captured', async () => { + it('shows monitored-system continuity once a bounded fallback migration floor is captured', async () => { mockEntitlements = { capabilities: ['relay'], limits: [ @@ -402,7 +416,7 @@ describe('ProLicensePanel', () => { subscription_state: 'active', upgrade_reasons: [], tier: 'pro', - plan_version: 'v5_pro_monthly_grandfathered', + plan_version: 'legacy_migration_fallback', licensed_email: 'owner@example.com', trial_eligible: false, monitored_system_continuity: { diff --git a/frontend-modern/src/components/Settings/useProLicensePanelState.ts b/frontend-modern/src/components/Settings/useProLicensePanelState.ts index 5a252b0c5..a78892326 100644 --- a/frontend-modern/src/components/Settings/useProLicensePanelState.ts +++ b/frontend-modern/src/components/Settings/useProLicensePanelState.ts @@ -21,6 +21,7 @@ import { getLicenseTierLabel, getTrialActivationNotice, isDisplayableLicenseFeature, + isUncappedGrandfatheredPlanVersion, } from '@/utils/licensePresentation'; import { getSelfHostedBillingHref, @@ -245,12 +246,23 @@ export function useProLicensePanelState() { const limitStatus = (key: string) => entitlements()?.limits?.find((entry) => entry.key === key); const monitoredSystemLimitStatus = createMemo(() => limitStatus('max_monitored_systems')); - const monitoredSystemUsageSummary = createMemo(() => - getMonitoredSystemLimitUsageSummary(monitoredSystemLimitStatus()), - ); - const remainingSystemCapacity = createMemo(() => - getMonitoredSystemLimitRemainingCapacity(monitoredSystemLimitStatus()), + const uncappedGrandfatheredPlan = createMemo(() => + isUncappedGrandfatheredPlanVersion(entitlements()?.plan_version, entitlements()?.is_lifetime), ); + const monitoredSystemUsageSummary = createMemo(() => { + const limit = monitoredSystemLimitStatus(); + if (!limit && uncappedGrandfatheredPlan()) { + return 'Unlimited'; + } + return getMonitoredSystemLimitUsageSummary(limit); + }); + const remainingSystemCapacity = createMemo(() => { + const limit = monitoredSystemLimitStatus(); + if (!limit && uncappedGrandfatheredPlan()) { + return 'Unlimited'; + } + return getMonitoredSystemLimitRemainingCapacity(limit); + }); const monitoredSystemContinuity = createMemo(() => entitlements()?.monitored_system_continuity); const monitoredSystemContinuityNotice = createMemo(() => getMonitoredSystemContinuityNotice(monitoredSystemContinuity(), monitoredSystemLimitStatus()), diff --git a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts index 3d9801f7c..e422c49e6 100644 --- a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts @@ -20,6 +20,8 @@ import { getTrialEndedProLicenseNotice, getTrialActivationNotice, isDisplayableLicenseFeature, + isGrandfatheredRecurringV5PlanVersion, + isUncappedGrandfatheredPlanVersion, SELF_HOSTED_RECOVERY_PRESENTATION, } from '@/utils/licensePresentation'; import { SELF_HOSTED_PRO_BILLING_PRESENTATION } from '@/components/Settings/selfHostedBillingPresentation'; @@ -163,11 +165,19 @@ describe('licensePresentation', () => { }); it('returns grandfathered recurring price continuity notices only for active recurring v5 plans', () => { + expect(isGrandfatheredRecurringV5PlanVersion('v5_pro_monthly_grandfathered')).toBe(true); + expect(isGrandfatheredRecurringV5PlanVersion('v5_pro_annual_grandfathered')).toBe(true); + expect(isGrandfatheredRecurringV5PlanVersion('v5_lifetime_grandfathered')).toBe(false); + expect(isUncappedGrandfatheredPlanVersion('v5_pro_monthly_grandfathered', false)).toBe(true); + expect(isUncappedGrandfatheredPlanVersion('v5_pro_annual_grandfathered', false)).toBe(true); + expect(isUncappedGrandfatheredPlanVersion(undefined, true)).toBe(true); + expect(isUncappedGrandfatheredPlanVersion('pro', false)).toBe(false); expect( getGrandfatheredPriceContinuityNotice('v5_pro_monthly_grandfathered', 'active'), ).toMatchObject({ title: 'Grandfathered v5 pricing', tone: expect.stringContaining('green'), + body: expect.stringContaining('uncapped monitored-system and guest capacity'), }); expect( getGrandfatheredPriceContinuityNotice('v5_pro_annual_grandfathered', 'grace'), diff --git a/frontend-modern/src/utils/licensePresentation.ts b/frontend-modern/src/utils/licensePresentation.ts index c59dcd67a..72679c73d 100644 --- a/frontend-modern/src/utils/licensePresentation.ts +++ b/frontend-modern/src/utils/licensePresentation.ts @@ -106,6 +106,23 @@ const GRANDFATHERED_V5_PLAN_LABELS: Record = { v5_pro_annual_grandfathered: 'V5 Pro Annual (Grandfathered)', }; +export const isGrandfatheredRecurringV5PlanVersion = (planVersion?: string | null): boolean => { + const normalized = (planVersion || '').trim().toLowerCase(); + return ( + normalized === 'v5_pro_monthly_grandfathered' || normalized === 'v5_pro_annual_grandfathered' + ); +}; + +export const isUncappedGrandfatheredPlanVersion = ( + planVersion?: string | null, + isLifetime?: boolean | null, +): boolean => { + if (isLifetime) { + return true; + } + return isGrandfatheredRecurringV5PlanVersion(planVersion); +}; + export const getLicenseTierLabel = (tier?: string | null): string => { const normalized = (tier || '').trim().toLowerCase(); if (!normalized) return 'Unknown'; @@ -144,11 +161,7 @@ export const getGrandfatheredPriceContinuityNotice = ( planVersion?: string | null, subscriptionState?: string | null, ): LicenseInlineNotice | null => { - const normalizedPlan = (planVersion || '').trim().toLowerCase(); - if ( - normalizedPlan !== 'v5_pro_monthly_grandfathered' && - normalizedPlan !== 'v5_pro_annual_grandfathered' - ) { + if (!isGrandfatheredRecurringV5PlanVersion(planVersion)) { return null; } @@ -160,7 +173,7 @@ export const getGrandfatheredPriceContinuityNotice = ( return { tone: 'border-green-200 dark:border-green-900 bg-green-50 dark:bg-green-900 text-green-900 dark:text-green-100', title: 'Grandfathered v5 pricing', - body: 'This migrated v5 Pro subscription keeps its existing recurring price until you cancel. If you cancel and return later, current v6 pricing applies.', + body: 'This migrated v5 Pro subscription keeps its existing recurring price and uncapped monitored-system and guest capacity until you cancel. If you cancel and return later, current v6 pricing and limits apply.', }; }; diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 6509b5091..9754dc6b2 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -5470,14 +5470,14 @@ func TestContract_EntitlementUsageSnapshotWaitsForSettledSupplementalInventory(t } } -func TestContract_LegacyMigrationGrandfatherFloorJSONSnapshot(t *testing.T) { +func TestContract_LegacyMigrationGrandfatherFloorFallbackJSONSnapshot(t *testing.T) { t.Setenv("PULSE_LICENSE_DEV_MODE", "false") const expectedClientVersion = "6.0.0-rc.1" grantJWT, grantPublicKey, err := pkglicensing.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{ LicenseID: "lic_contract_floor", Tier: "pro", - PlanKey: "v5_pro_monthly_grandfathered", + PlanKey: "legacy_migration_fallback", State: "active", Features: []string{"relay"}, MaxMonitoredSystems: 10, @@ -5640,14 +5640,14 @@ func TestContract_LegacyMigrationGrandfatherFloorJSONSnapshot(t *testing.T) { const want = `{ "status":{ "tier":"pro", - "plan_version":"v5_pro_monthly_grandfathered", + "plan_version":"legacy_migration_fallback", "max_monitored_systems":23, "valid":true, "monitored_system_continuity":{"plan_limit":10,"grandfathered_floor":23,"effective_limit":23,"capture_pending":false,"captured_at":123} }, "entitlements":{ "tier":"pro", - "plan_version":"v5_pro_monthly_grandfathered", + "plan_version":"legacy_migration_fallback", "subscription_state":"active", "limits":[{"key":"max_monitored_systems","limit":23,"current":23,"current_available":true,"state":"enforced"}], "monitored_system_continuity":{"plan_limit":10,"grandfathered_floor":23,"effective_limit":23,"capture_pending":false,"captured_at":123} diff --git a/internal/api/entitlement_handlers_test.go b/internal/api/entitlement_handlers_test.go index b3d5ff3d2..d69834d66 100644 --- a/internal/api/entitlement_handlers_test.go +++ b/internal/api/entitlement_handlers_test.go @@ -444,6 +444,57 @@ func TestEntitlementHandler_UsesEvaluatorWhenNoLicense(t *testing.T) { } } +func TestEntitlementHandler_GrandfatheredRecurringEvaluatorStateIsUncapped(t *testing.T) { + baseDir := t.TempDir() + mtp := config.NewMultiTenantPersistence(baseDir) + + orgID := "test-grandfathered-recurring-entitlements" + if _, err := mtp.GetPersistence(orgID); err != nil { + t.Fatalf("GetPersistence(%s) failed: %v", orgID, err) + } + + store := config.NewFileBillingStore(baseDir) + if err := store.SaveBillingState(orgID, &entitlements.BillingState{ + Capabilities: []string{ + license.FeatureAIPatrol, + license.FeatureAIAutoFix, + }, + Limits: map[string]int64{ + "max_monitored_systems": 10, + "max_guests": 50, + }, + PlanVersion: "v5_pro_monthly_grandfathered", + SubscriptionState: entitlements.SubStateActive, + }); err != nil { + t.Fatalf("SaveBillingState(%s) failed: %v", orgID, err) + } + + h := NewLicenseHandlers(mtp, true) + + req := httptest.NewRequest(http.MethodGet, "/api/license/entitlements", nil) + req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, orgID)) + rec := httptest.NewRecorder() + h.HandleEntitlements(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d, want %d", rec.Code, http.StatusOK) + } + + var payload EntitlementPayload + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal payload failed: %v", err) + } + + if payload.PlanVersion != "v5_pro_monthly_grandfathered" { + t.Fatalf("plan_version=%q, want %q", payload.PlanVersion, "v5_pro_monthly_grandfathered") + } + for _, limit := range payload.Limits { + if limit.Key == "max_monitored_systems" || limit.Key == "max_guests" { + t.Fatalf("expected grandfathered recurring evaluator payload to omit capped limits, got %+v", payload.Limits) + } + } +} + func TestEntitlementHandler_TrialEligibility_FreshOrgAllowed(t *testing.T) { baseDir := t.TempDir() mtp := config.NewMultiTenantPersistence(baseDir) diff --git a/internal/api/licensing_handlers_auto_migrate_test.go b/internal/api/licensing_handlers_auto_migrate_test.go index 84baf2848..494219bcc 100644 --- a/internal/api/licensing_handlers_auto_migrate_test.go +++ b/internal/api/licensing_handlers_auto_migrate_test.go @@ -328,7 +328,7 @@ func TestGetTenantComponents_AutoExchangeGrandfathersObservedMonitoredSystems(t grantJWT, grantPublicKey, err := pkglicensing.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{ LicenseID: "lic_floor_auto", Tier: "pro", - PlanKey: "v5_pro_monthly_grandfathered", + PlanKey: "legacy_migration_fallback", State: "active", Features: []string{"relay"}, MaxMonitoredSystems: 10, @@ -427,7 +427,7 @@ func TestGetTenantComponents_BackfillsGrandfatherFloorAfterRestoreWhenMonitorArr grantJWT, grantPublicKey, err := pkglicensing.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{ LicenseID: "lic_floor_restore", Tier: "pro", - PlanKey: "v5_pro_monthly_grandfathered", + PlanKey: "legacy_migration_fallback", State: "active", Features: []string{"relay"}, MaxMonitoredSystems: 10, @@ -524,7 +524,7 @@ func TestBillingReads_DoNotRestartLegacyGrandfatherReconcileLoop(t *testing.T) { grantJWT, grantPublicKey, err := pkglicensing.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{ LicenseID: "lic_floor_read_only", Tier: "pro", - PlanKey: "v5_pro_monthly_grandfathered", + PlanKey: "legacy_migration_fallback", State: "active", Features: []string{"relay"}, MaxMonitoredSystems: 10, @@ -622,7 +622,7 @@ func TestActivateLicenseKey_GrandfathersObservedMonitoredSystemsForLegacyMigrati grantJWT, grantPublicKey, err := pkglicensing.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{ LicenseID: "lic_floor_manual", Tier: "pro", - PlanKey: "v5_pro_annual_grandfathered", + PlanKey: "legacy_migration_fallback", State: "active", Features: []string{"relay"}, MaxMonitoredSystems: 10, @@ -697,7 +697,7 @@ func TestGetTenantComponents_DelaysGrandfatherFloorUntilSupplementalInventorySet grantJWT, grantPublicKey, err := pkglicensing.GenerateGrantJWTForTesting(pkglicensing.GrantClaims{ LicenseID: "lic_floor_supplemental", Tier: "pro", - PlanKey: "v5_pro_monthly_grandfathered", + PlanKey: "legacy_migration_fallback", State: "active", Features: []string{"relay"}, MaxMonitoredSystems: 10, diff --git a/pkg/licensing/database_source.go b/pkg/licensing/database_source.go index b7e3e07d7..12907f10c 100644 --- a/pkg/licensing/database_source.go +++ b/pkg/licensing/database_source.go @@ -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{} } diff --git a/pkg/licensing/database_source_test.go b/pkg/licensing/database_source_test.go index e5a0ded1d..f98ebc76e 100644 --- a/pkg/licensing/database_source_test.go +++ b/pkg/licensing/database_source_test.go @@ -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{ diff --git a/pkg/licensing/features.go b/pkg/licensing/features.go index 381de06ca..1e25f2087 100644 --- a/pkg/licensing/features.go +++ b/pkg/licensing/features.go @@ -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. diff --git a/pkg/licensing/features_test.go b/pkg/licensing/features_test.go index 419009925..ec5a4989b 100644 --- a/pkg/licensing/features_test.go +++ b/pkg/licensing/features_test.go @@ -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) { diff --git a/pkg/licensing/grant_claims_contract_test.go b/pkg/licensing/grant_claims_contract_test.go index d8706f81b..ba9e145f4 100644 --- a/pkg/licensing/grant_claims_contract_test.go +++ b/pkg/licensing/grant_claims_contract_test.go @@ -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 diff --git a/pkg/licensing/grant_refresh_test.go b/pkg/licensing/grant_refresh_test.go index 620dec83a..fef02aaa3 100644 --- a/pkg/licensing/grant_refresh_test.go +++ b/pkg/licensing/grant_refresh_test.go @@ -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(), diff --git a/pkg/licensing/models.go b/pkg/licensing/models.go index df78c4041..e982d60cc 100644 --- a/pkg/licensing/models.go +++ b/pkg/licensing/models.go @@ -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") } diff --git a/pkg/licensing/models_test.go b/pkg/licensing/models_test.go index 54169086e..3d34fc9f1 100644 --- a/pkg/licensing/models_test.go +++ b/pkg/licensing/models_test.go @@ -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 { diff --git a/pkg/licensing/service.go b/pkg/licensing/service.go index e2bd4727d..f320b9924 100644 --- a/pkg/licensing/service.go +++ b/pkg/licensing/service.go @@ -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 { diff --git a/pkg/licensing/service_activate_test.go b/pkg/licensing/service_activate_test.go index fb9c950d1..5b4fcbe62 100644 --- a/pkg/licensing/service_activate_test.go +++ b/pkg/licensing/service_activate_test.go @@ -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") diff --git a/tests/integration/tests/12-v5-commercial-migration.spec.ts b/tests/integration/tests/12-v5-commercial-migration.spec.ts index c33565b19..1f16e58c0 100644 --- a/tests/integration/tests/12-v5-commercial-migration.spec.ts +++ b/tests/integration/tests/12-v5-commercial-migration.spec.ts @@ -200,6 +200,8 @@ test.describe.serial('v5 commercial migration notice', () => { expectFieldLocator(page, 'Included Monitored Systems'), String(expectedMaxMonitoredSystems), ); + } else { + await expectFieldValue(expectFieldLocator(page, 'Included Monitored Systems'), 'Unlimited'); } return; }