diff --git a/frontend-modern/src/components/Settings/ProLicensePanel.tsx b/frontend-modern/src/components/Settings/ProLicensePanel.tsx index 3092b6879..2a20100d2 100644 --- a/frontend-modern/src/components/Settings/ProLicensePanel.tsx +++ b/frontend-modern/src/components/Settings/ProLicensePanel.tsx @@ -143,13 +143,9 @@ const ProLicensePanelContent: Component = () => { > entry.key === 'max_monitored_systems') ?? null - } + monitoredSystemCapacity={state.monitoredSystemCapacity()} + monitoredSystemContinuity={state.displayableMonitoredSystemContinuity()} + monitoredSystemLimit={state.monitoredSystemLimitStatus() ?? null} showCountingRulesByDefault={state.showCountingRulesByDefault()} /> diff --git a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx index 994d34511..22a4d046e 100644 --- a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx @@ -297,7 +297,7 @@ describe('ProLicensePanel', () => { expect(screen.getByText('Patrol Auto-Fix')).toBeInTheDocument(); }); - it('shows migrated plan terms when plan_version is present', async () => { + it('shows active recurring v5 plan terms as uncapped even if stale limit metadata is present', async () => { mockEntitlements = { capabilities: ['ai_patrol'], limits: [{ key: 'max_monitored_systems', limit: 12, current: 5, state: 'ok' }], @@ -308,6 +308,24 @@ describe('ProLicensePanel', () => { licensed_email: 'owner@example.com', is_lifetime: false, trial_eligible: false, + monitored_system_continuity: { + plan_limit: 12, + grandfathered_floor: 23, + effective_limit: 23, + capture_pending: false, + }, + monitored_system_capacity: { + mode: 'at_limit_blocking_new', + urgency: 'enforced', + current: 23, + limit: 23, + current_available: true, + available_slots: 0, + overage: 0, + reason: 'limit_reached', + blocks_new_systems: true, + existing_monitoring_continues: true, + }, }; renderPanel(); @@ -316,20 +334,19 @@ describe('ProLicensePanel', () => { expect(screen.getByText('Plan Terms')).toBeInTheDocument(); }); - expect(screen.getAllByText('5 monitored systems').length).toBeGreaterThan(0); - expect(screen.getAllByText('7 remaining').length).toBeGreaterThan(0); expect(screen.getAllByText('Active').length).toBeGreaterThan(0); expect(screen.getByText('V5 Pro Monthly (Grandfathered)')).toBeInTheDocument(); - expect(screen.getByText('Included Monitored Systems')).toBeInTheDocument(); + expect(screen.getByText('Core Monitoring')).toBeInTheDocument(); + expect( + within(screen.getByText('Core Monitoring').parentElement as HTMLElement).getByText( + 'Unlimited', + ), + ).toBeInTheDocument(); + expect(screen.queryByText('Included Monitored Systems')).not.toBeInTheDocument(); + expect(screen.queryByText('Grandfathered monitored-system floor')).not.toBeInTheDocument(); + expect(screen.queryByText('Plan Monitored System Limit')).not.toBeInTheDocument(); expect(screen.getByRole('tab', { name: 'Plan' })).toHaveAttribute('aria-selected', 'true'); - expect(screen.getByRole('tab', { name: 'Usage' })).toHaveAttribute('aria-selected', 'false'); - - fireEvent.click(screen.getByRole('tab', { name: 'Usage' })); - - expect(navigateMock).toHaveBeenCalledWith(SELF_HOSTED_PRO_BILLING_USAGE_HREF, { - replace: false, - scroll: false, - }); + expect(screen.queryByRole('tab', { name: 'Usage' })).not.toBeInTheDocument(); }); it('shows lifetime grandfathered plans as uncapped', async () => { @@ -788,7 +805,7 @@ describe('ProLicensePanel', () => { { purchase: SELF_HOSTED_PRO_BILLING_PURCHASE_EXPIRED, title: 'Upgrade return expired', - actionLabel: 'Restart upgrade', + actionLabel: 'Compare plans', actionHref: getSelfHostedPurchaseStartUrl(), }, { diff --git a/frontend-modern/src/components/Settings/useProLicensePanelState.ts b/frontend-modern/src/components/Settings/useProLicensePanelState.ts index 36aa789bf..62489c6d4 100644 --- a/frontend-modern/src/components/Settings/useProLicensePanelState.ts +++ b/frontend-modern/src/components/Settings/useProLicensePanelState.ts @@ -24,8 +24,9 @@ import { getLicenseSubscriptionStatusPresentation, getLicenseTierLabel, getTrialActivationNotice, + getDisplayableMonitoredSystemContinuity, + hasActiveUncappedSelfHostedContinuity, isDisplayableLicenseFeature, - isUncappedGrandfatheredPlanVersion, } from '@/utils/licensePresentation'; import { getSelfHostedBillingHref, @@ -157,16 +158,38 @@ export function useProLicensePanelState() { ); const limitStatus = (key: string) => entitlements()?.limits?.find((entry) => entry.key === key); - const monitoredSystemLimitStatus = createMemo(() => limitStatus('max_monitored_systems')); - const monitoredSystemCapacity = createMemo(() => entitlements()?.monitored_system_capacity); const monitoredSystemContinuity = createMemo(() => entitlements()?.monitored_system_continuity); + const uncappedGrandfatheredPlan = createMemo(() => + hasActiveUncappedSelfHostedContinuity({ + planVersion: entitlements()?.plan_version, + isLifetime: entitlements()?.is_lifetime, + subscriptionState: entitlements()?.subscription_state, + }), + ); + const monitoredSystemLimitStatus = createMemo(() => + uncappedGrandfatheredPlan() ? undefined : limitStatus('max_monitored_systems'), + ); + const monitoredSystemCapacity = createMemo(() => + uncappedGrandfatheredPlan() ? undefined : entitlements()?.monitored_system_capacity, + ); + const guestLimitStatus = createMemo(() => + uncappedGrandfatheredPlan() ? undefined : limitStatus('max_guests'), + ); + const displayableMonitoredSystemContinuity = createMemo(() => + getDisplayableMonitoredSystemContinuity({ + continuity: monitoredSystemContinuity(), + planVersion: entitlements()?.plan_version, + isLifetime: entitlements()?.is_lifetime, + subscriptionState: entitlements()?.subscription_state, + }), + ); const showUsageSection = createMemo(() => { if (!panelDataSettled()) { return true; } - const continuity = monitoredSystemContinuity(); + const continuity = displayableMonitoredSystemContinuity(); if (continuity) { if (continuity.capture_pending) { return true; @@ -351,9 +374,6 @@ export function useProLicensePanelState() { return segments.length === 3 && segments.every((segment) => segment.length > 0); }); - const uncappedGrandfatheredPlan = createMemo(() => - isUncappedGrandfatheredPlanVersion(entitlements()?.plan_version, entitlements()?.is_lifetime), - ); const monitoredSystemUsageSummary = createMemo(() => { const limit = monitoredSystemLimitStatus(); const capacity = monitoredSystemCapacity(); @@ -374,7 +394,7 @@ export function useProLicensePanelState() { resolveMonitoredSystemCapacityStatus(monitoredSystemCapacity(), monitoredSystemLimitStatus()), ); const currentRetailPlanDefinition = createMemo(() => { - if (monitoredSystemContinuity()) { + if (displayableMonitoredSystemContinuity()) { return null; } if (uncappedGrandfatheredPlan()) { @@ -390,6 +410,11 @@ export function useProLicensePanelState() { monitoredSystemContinuity(), monitoredSystemLimitStatus(), monitoredSystemCapacity(), + { + planVersion: entitlements()?.plan_version, + isLifetime: entitlements()?.is_lifetime, + subscriptionState: entitlements()?.subscription_state, + }, ), ); const monitoredSystemCapacitySection = createMemo(() => { @@ -406,7 +431,7 @@ export function useProLicensePanelState() { }; }); const continuityCapturedAt = createMemo(() => { - const capturedAt = monitoredSystemContinuity()?.captured_at; + const capturedAt = displayableMonitoredSystemContinuity()?.captured_at; return typeof capturedAt === 'number' && capturedAt > 0 ? formatUnixDate(capturedAt) : undefined; @@ -511,7 +536,7 @@ export function useProLicensePanelState() { if (uncappedGrandfatheredPlan()) { return true; } - const guestLimit = limitStatus('max_guests')?.limit; + const guestLimit = guestLimitStatus()?.limit; return typeof guestLimit === 'number' && guestLimit > 0; }); @@ -526,17 +551,17 @@ export function useProLicensePanelState() { monitoredSystemsSummary: monitoredSystemUsageSummary(), capacityStatusSummary: monitoredSystemCapacityStatusSummary(), maxMonitoredSystems: - typeof limitStatus('max_monitored_systems')?.limit === 'number' && - limitStatus('max_monitored_systems')!.limit > 0 - ? limitStatus('max_monitored_systems')!.limit + typeof monitoredSystemLimitStatus()?.limit === 'number' && + monitoredSystemLimitStatus()!.limit > 0 + ? monitoredSystemLimitStatus()!.limit : 'Unlimited', guestCapacity: - typeof limitStatus('max_guests')?.limit === 'number' && limitStatus('max_guests')!.limit > 0 - ? limitStatus('max_guests')!.limit + typeof guestLimitStatus()?.limit === 'number' && guestLimitStatus()!.limit > 0 + ? guestLimitStatus()!.limit : 'Unlimited', retailPlanDefinition: currentRetailPlanDefinition(), showGuestCapacity: showGuestCapacity(), - monitoredSystemContinuity: monitoredSystemContinuity() ?? null, + monitoredSystemContinuity: displayableMonitoredSystemContinuity() ?? null, continuityCapturedAt: continuityCapturedAt(), }), ); @@ -634,6 +659,8 @@ export function useProLicensePanelState() { currentPlanSummary, planComparisonSummary, monitoredSystemCapacity, + monitoredSystemLimitStatus, + displayableMonitoredSystemContinuity, monitoredSystemCapacitySection, monitoredSystemContinuityNotice, entitlements, diff --git a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts index ccf3ba897..27fc806f7 100644 --- a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts @@ -38,6 +38,8 @@ describe('licensePresentation', () => { expect(getLicenseTierLabel('enterprise')).toBe('Enterprise'); expect(getLicenseTierLabel('custom_tier')).toBe('Custom Tier'); expect(getSelfHostedPlanLabel('pro')).toBe('Pulse Pro'); + expect(getSelfHostedPlanLabel('pro_plus')).toBe('Pulse Pro+'); + expect(getSelfHostedPlanLabel('lifetime')).toBe('Pulse Pro Lifetime'); expect(getSelfHostedPlanLabel('relay')).toBe('Relay'); }); @@ -279,6 +281,25 @@ describe('licensePresentation', () => { supplementalSummary: '', }); + expect( + getSelfHostedCurrentPlanPresentation({ + entitlements: { + tier: 'pro_plus', + subscription_state: 'active', + capabilities: ['relay', 'ai_autofix'], + limits: [], + upgrade_reasons: [], + }, + displayableCapabilities: ['Pulse Relay (Remote Access)', 'Patrol Auto-Fix'], + }), + ).toMatchObject({ + title: 'Current plan: Pulse Pro+', + body: + 'Pulse Pro+ is active on this instance. Root-cause analysis, safe remediation, and 90-day history are unlocked right now.', + unlockedFeaturesLabel: 'Primary capabilities', + unlockedFeatures: ['Pulse Alert Analysis', 'Patrol Auto-Fix', '90-day metric history'], + }); + expect( getSelfHostedCurrentPlanPresentation({ entitlements: { @@ -311,9 +332,9 @@ describe('licensePresentation', () => { 'PDF/CSV Reporting', 'Centralized Agent Profiles', ], - supplementalBadges: ['Grandfathered price', 'Grandfathered floor'], + supplementalBadges: ['Grandfathered price'], supplementalSummary: - 'This migrated v5 subscription keeps its existing recurring price and uncapped guest capacity until cancellation. This installation keeps an effective monitored-system limit of 23 from the observed legacy estate.', + 'This migrated v5 subscription keeps its existing recurring price and uncapped guest capacity until cancellation.', }); }); @@ -534,6 +555,37 @@ describe('licensePresentation', () => { body: expect.stringContaining('already monitoring 23'), tone: expect.stringContaining('amber'), }); + expect( + getMonitoredSystemContinuityNotice( + { + plan_limit: 10, + grandfathered_floor: 23, + effective_limit: 23, + capture_pending: false, + captured_at: 123, + }, + { + current_available: true, + }, + { + mode: 'at_limit_blocking_new', + urgency: 'enforced', + current: 23, + limit: 23, + current_available: true, + available_slots: 0, + overage: 0, + reason: 'limit_reached', + blocks_new_systems: true, + existing_monitoring_continues: true, + }, + { + planVersion: 'v5_pro_monthly_grandfathered', + isLifetime: false, + subscriptionState: 'active', + }, + ), + ).toBeNull(); expect( getMonitoredSystemContinuityNotice( { diff --git a/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts b/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts index dde38d15e..93f692457 100644 --- a/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts +++ b/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts @@ -157,6 +157,15 @@ describe('selfHostedPlans', () => { expect(getSelfHostedPlanDefinitionForBillingTier('pro')).toBe( SELF_HOSTED_PLAN_BY_TIER.pro, ); + expect(getSelfHostedPlanDefinitionForBillingTier('pro_annual')).toBe( + SELF_HOSTED_PLAN_BY_TIER.pro, + ); + expect(getSelfHostedPlanDefinitionForBillingTier('pro_plus')).toBe( + SELF_HOSTED_PLAN_BY_TIER.pro, + ); + expect(getSelfHostedPlanDefinitionForBillingTier('lifetime')).toBe( + SELF_HOSTED_PLAN_BY_TIER.pro, + ); expect(getSelfHostedPlanDefinitionForBillingTier('enterprise')).toBeNull(); }); }); diff --git a/frontend-modern/src/utils/licensePresentation.ts b/frontend-modern/src/utils/licensePresentation.ts index 5cb7d7070..3597a66e5 100644 --- a/frontend-modern/src/utils/licensePresentation.ts +++ b/frontend-modern/src/utils/licensePresentation.ts @@ -35,6 +35,9 @@ const TIER_LABELS: Record = { const SELF_HOSTED_PLAN_LABELS: Record = { pro: 'Pulse Pro', + pro_annual: 'Pulse Pro Annual', + pro_plus: 'Pulse Pro+', + lifetime: 'Pulse Pro Lifetime', }; const FEATURE_MIN_TIER_LABELS: Record = { @@ -139,6 +142,49 @@ export const isUncappedGrandfatheredPlanVersion = ( return isGrandfatheredRecurringV5PlanVersion(planVersion); }; +const isActiveOrGraceSubscription = (subscriptionState?: string | null): boolean => { + const normalized = (subscriptionState || '').trim().toLowerCase(); + return normalized === 'active' || normalized === 'grace'; +}; + +export const hasActiveUncappedSelfHostedContinuity = ({ + planVersion, + isLifetime, + subscriptionState, +}: { + planVersion?: string | null; + isLifetime?: boolean | null; + subscriptionState?: string | null; +}): boolean => { + if (isLifetime) { + return true; + } + return ( + isActiveOrGraceSubscription(subscriptionState) && + isGrandfatheredRecurringV5PlanVersion(planVersion) + ); +}; + +export const getDisplayableMonitoredSystemContinuity = ({ + continuity, + planVersion, + isLifetime, + subscriptionState, +}: { + continuity?: MonitoredSystemContinuityStatus | null; + planVersion?: string | null; + isLifetime?: boolean | null; + subscriptionState?: string | null; +}): MonitoredSystemContinuityStatus | null => { + if (!continuity) { + return null; + } + if (hasActiveUncappedSelfHostedContinuity({ planVersion, isLifetime, subscriptionState })) { + return null; + } + return continuity; +}; + export const getLicenseTierLabel = (tier?: string | null): string => { const normalized = (tier || '').trim().toLowerCase(); if (!normalized) return 'Unknown'; @@ -204,10 +250,24 @@ export const getMonitoredSystemContinuityNotice = ( continuity?: MonitoredSystemContinuityStatus | null, limit?: MonitoredSystemLimitUsageStatus | null, capacity?: MonitoredSystemCapacityStatus | null, + context?: { + planVersion?: string | null; + isLifetime?: boolean | null; + subscriptionState?: string | null; + }, ): LicenseInlineNotice | null => { + const displayContinuity = getDisplayableMonitoredSystemContinuity({ + continuity, + planVersion: context?.planVersion, + isLifetime: context?.isLifetime, + subscriptionState: context?.subscriptionState, + }); + if (!displayContinuity) { + return null; + } const resolvedCapacity = resolveMonitoredSystemCapacityStatus(capacity, limit); - if (continuity?.capture_pending) { + if (displayContinuity.capture_pending) { if (!resolvedCapacity?.current_available) { return { tone: 'border-amber-200 dark:border-amber-900 bg-amber-50 dark:bg-amber-900 text-amber-900 dark:text-amber-100', @@ -222,7 +282,7 @@ export const getMonitoredSystemContinuityNotice = ( return { tone: 'border-amber-200 dark:border-amber-900 bg-amber-50 dark:bg-amber-900 text-amber-900 dark:text-amber-100', title: 'Migration continuity verification pending', - body: `Pulse is still verifying the grandfathered monitored-system floor for this migrated v5 installation. The finite policy includes ${continuity.plan_limit}, while this installation is already monitoring ${resolvedCapacity.current}. Existing monitoring continues while additional monitored-system admissions pause until continuity capture finishes.`, + body: `Pulse is still verifying the grandfathered monitored-system floor for this migrated v5 installation. The finite policy includes ${displayContinuity.plan_limit}, while this installation is already monitoring ${resolvedCapacity.current}. Existing monitoring continues while additional monitored-system admissions pause until continuity capture finishes.`, }; } @@ -244,15 +304,14 @@ export const getMonitoredSystemContinuityNotice = ( } if ( - continuity && - typeof continuity.grandfathered_floor === 'number' && - continuity.grandfathered_floor > 0 && - continuity.effective_limit > continuity.plan_limit + typeof displayContinuity.grandfathered_floor === 'number' && + displayContinuity.grandfathered_floor > 0 && + displayContinuity.effective_limit > displayContinuity.plan_limit ) { 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 monitored-system floor', - body: `This migrated v5 installation keeps an effective monitored-system limit of ${continuity.effective_limit}. The current plan includes ${continuity.plan_limit}, and the observed legacy estate was grandfathered at ${continuity.grandfathered_floor}.`, + body: `This migrated v5 installation keeps an effective monitored-system limit of ${displayContinuity.effective_limit}. The current plan includes ${displayContinuity.plan_limit}, and the observed legacy estate was grandfathered at ${displayContinuity.grandfathered_floor}.`, }; } @@ -320,6 +379,22 @@ const getSelfHostedActivationHighlights = ({ return highlights; }; +const getSelfHostedActivePlanSummary = ( + planLabel: string, + planDefinition: ReturnType, +): string | null => { + switch (planDefinition?.tier) { + case 'community': + return `${planLabel} is active on this instance. It includes self-hosted monitoring, 7-day metric history, Pulse Patrol (BYOK), and update alerts.`; + case 'relay': + return `${planLabel} is active on this instance. Remote access, mobile, push, and longer history are unlocked right now.`; + case 'pro': + return `${planLabel} is active on this instance. Root-cause analysis, safe remediation, and 90-day history are unlocked right now.`; + default: + return null; + } +}; + export const getSelfHostedPlanComparisonPresentation = ({ entitlements, }: { @@ -373,6 +448,11 @@ export const getSelfHostedCurrentPlanPresentation = ({ const normalizedTier = (current.tier || '').trim().toLowerCase(); const planLabel = getSelfHostedPlanLabel(current.tier); const planDefinition = getSelfHostedPlanDefinitionForBillingTier(current.tier); + const hasUncappedContinuity = hasActiveUncappedSelfHostedContinuity({ + planVersion: current.plan_version, + isLifetime: current.is_lifetime, + subscriptionState: current.subscription_state, + }); const unlockedFeatures = getSelfHostedUnlockedFeatures({ entitlements: current, displayableCapabilities, @@ -386,19 +466,27 @@ export const getSelfHostedCurrentPlanPresentation = ({ const supplementalBadges: string[] = []; const supplementalDetails: string[] = []; - if (isGrandfatheredRecurringV5PlanVersion(current.plan_version)) { + if ( + isActiveOrGraceSubscription(current.subscription_state) && + isGrandfatheredRecurringV5PlanVersion(current.plan_version) + ) { supplementalBadges.push('Grandfathered price'); supplementalDetails.push( 'This migrated v5 subscription keeps its existing recurring price and uncapped guest capacity until cancellation.', ); - } else if (current.is_lifetime && isUncappedGrandfatheredPlanVersion(current.plan_version, true)) { + } else if (hasUncappedContinuity && current.is_lifetime) { supplementalBadges.push('Grandfathered lifetime'); supplementalDetails.push( 'This migrated lifetime install keeps uncapped monitored-system and guest capacity continuity.', ); } - const continuity = current.monitored_system_continuity; + const continuity = getDisplayableMonitoredSystemContinuity({ + continuity: current.monitored_system_continuity, + planVersion: current.plan_version, + isLifetime: current.is_lifetime, + subscriptionState: current.subscription_state, + }); if (continuity?.capture_pending) { supplementalBadges.push('Continuity pending'); supplementalDetails.push( @@ -436,7 +524,7 @@ export const getSelfHostedCurrentPlanPresentation = ({ return { title: 'Current plan: Community', body: - planDefinition?.entitlementSummary || + getSelfHostedActivePlanSummary('Community', planDefinition) || 'Community is active on this instance. Self-hosted monitoring, 7-day metric history, Pulse Patrol (BYOK), and update alerts are included here.', unlockedFeaturesLabel, unlockedFeatures, @@ -450,7 +538,7 @@ export const getSelfHostedCurrentPlanPresentation = ({ return { title: `Current plan: ${planLabel}`, body: - planDefinition?.entitlementSummary || + getSelfHostedActivePlanSummary(planLabel, planDefinition) || `${planLabel} is active on this instance. These capabilities are unlocked right now.`, unlockedFeaturesLabel, unlockedFeatures, diff --git a/frontend-modern/src/utils/selfHostedPlans.ts b/frontend-modern/src/utils/selfHostedPlans.ts index 44845df36..b23eb9d92 100644 --- a/frontend-modern/src/utils/selfHostedPlans.ts +++ b/frontend-modern/src/utils/selfHostedPlans.ts @@ -167,6 +167,9 @@ export function getSelfHostedPlanDefinitionForBillingTier( case 'relay': return SELF_HOSTED_PLAN_BY_TIER.relay; case 'pro': + case 'pro_annual': + case 'pro_plus': + case 'lifetime': return SELF_HOSTED_PLAN_BY_TIER.pro; default: return null;