From 9458cdb2e5420976f0262eec214615d6e9ece059 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 22 Apr 2026 20:09:24 +0100 Subject: [PATCH] Make self-hosted plan summaries entitlement-first --- .../v6/internal/subsystems/cloud-paid.md | 6 +- .../Settings/ProLicensePlanSection.tsx | 12 ++ .../__tests__/ProLicensePanel.test.tsx | 60 +++++++- .../Settings/useProLicensePanelState.ts | 65 +------- .../__tests__/licensePresentation.test.ts | 84 +++++++++++ .../utils/__tests__/selfHostedPlans.test.ts | 29 ++++ .../src/utils/licensePresentation.ts | 118 +++++++++++++++ frontend-modern/src/utils/selfHostedPlans.ts | 28 ++++ ...f-hosted-plans-entitlement-summary.spec.ts | 139 ++++++++++++++++++ 9 files changed, 475 insertions(+), 66 deletions(-) create mode 100644 tests/integration/tests/59-self-hosted-plans-entitlement-summary.spec.ts diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 47b25d742..ef39864a5 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -1139,7 +1139,11 @@ plan-owned rather than tier-owned: the navigation label is `Plans`, the surface title is `Plans & Activation`, and the canonical plan state must make the current unlocked tier plus capabilities immediately obvious so existing paid upgrades can confirm what their new key enabled without hunting through -generic billing details. +generic billing details. That entitlement-first summary must stay tier- +specific and continuity-aware: Relay and Pulse Pro should describe the actual +paid capabilities unlocked on this instance, while grandfathered pricing or +captured monitored-system continuity must be visible in that same top summary +instead of only in secondary billing detail blocks further down the page. That same router-owned billing contract now also includes recovery as a plan detail state instead of a fragment alias. The canonical recovery arrival is `/settings/system/billing/plan?details=recovery`, while diff --git a/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx b/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx index 3cb4a5765..f138087f4 100644 --- a/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx +++ b/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx @@ -46,6 +46,8 @@ interface ProLicensePlanSectionProps { badgeClass: string; statusLabel: string; unlockedFeatures: string[]; + supplementalBadges: string[]; + supplementalSummary?: string; }; entitlements: { in_grace_period?: boolean; @@ -123,6 +125,13 @@ export const ProLicensePlanSection: Component = (pro > {props.currentPlanSummary.statusLabel} + + {(badge) => ( + + {badge} + + )} + Grace until {formatDate(props.entitlements?.grace_period_end)} @@ -131,6 +140,9 @@ export const ProLicensePlanSection: Component = (pro

{props.currentPlanSummary.title}

{props.currentPlanSummary.body}

+ + {(summary) =>

{summary()}

} +
0}>

Unlocked on this instance

diff --git a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx index e3cea6d62..ec8a89279 100644 --- a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx @@ -223,7 +223,10 @@ describe('ProLicensePanel', () => { 'settings_self_hosted_billing_plan', SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT, ); - expect(screen.getByText(/Community keeps monitoring free/i)).toBeInTheDocument(); + expect( + screen.getByText(/Core self-hosted monitoring stays free on this instance/i), + ).toBeInTheDocument(); + expect(screen.getByText('Unlimited self-hosted monitoring')).toBeInTheDocument(); const compareLinks = screen.getAllByRole('link', { name: 'Compare plans' }); expect( compareLinks.some( @@ -400,6 +403,10 @@ describe('ProLicensePanel', () => { expect(screen.getByText(tc.expectedLabel)).toBeInTheDocument(); expect(screen.getByText('Grandfathered v5 pricing')).toBeInTheDocument(); + expect(screen.getByText('Grandfathered price')).toBeInTheDocument(); + expect( + screen.getByText(/keeps its existing recurring price and uncapped guest capacity/i), + ).toBeInTheDocument(); expect( screen.getByText( /keeps its existing recurring price and uncapped monitored-system and guest capacity until you cancel/i, @@ -443,6 +450,13 @@ describe('ProLicensePanel', () => { expect(screen.getByText('Core Monitoring')).toBeInTheDocument(); expect(screen.getByText('Metric History')).toBeInTheDocument(); expect(screen.getByText('Included Extras')).toBeInTheDocument(); + expect(screen.getByText('Current plan: Pulse Pro')).toBeInTheDocument(); + expect( + screen.getByText( + 'Pulse Pro is active on this instance. AI operations, advanced administration, and 90-day history are unlocked right now.', + ), + ).toBeInTheDocument(); + expect(screen.getByText('Patrol Auto-Fix')).toBeInTheDocument(); expect(screen.getByText('90 days')).toBeInTheDocument(); expect(screen.getByText('AI operations and advanced admin')).toBeInTheDocument(); expect(screen.queryByText('Guest Capacity')).not.toBeInTheDocument(); @@ -451,6 +465,34 @@ describe('ProLicensePanel', () => { expect(screen.queryByRole('tab', { name: 'Usage' })).not.toBeInTheDocument(); }); + it('shows Relay entitlement summaries from the paid capabilities unlocked on this instance', async () => { + mockEntitlements = { + capabilities: ['relay', 'mobile_app', 'push_notifications'], + limits: [], + subscription_state: 'active', + upgrade_reasons: [], + tier: 'relay', + plan_version: 'relay_monthly', + licensed_email: 'owner@example.com', + trial_eligible: false, + }; + + renderPanel(); + + await waitFor(() => { + expect(screen.getByText('Current plan: Relay')).toBeInTheDocument(); + }); + + expect( + screen.getByText( + 'Relay is active on this instance. Remote access, mobile, push, and longer history are unlocked right now.', + ), + ).toBeInTheDocument(); + expect(screen.getByText('Pulse Relay (Remote Access)')).toBeInTheDocument(); + expect(screen.getByText('Mobile App Access')).toBeInTheDocument(); + expect(screen.getByText('Push Notifications')).toBeInTheDocument(); + }); + it('shows continuity verification while a bounded fallback migration is still pending', async () => { mockEntitlements = { capabilities: ['relay'], @@ -497,8 +539,9 @@ describe('ProLicensePanel', () => { }); expect( - screen.getByText(/verifying the grandfathered monitored-system floor/i), - ).toBeInTheDocument(); + screen.getAllByText(/verifying the grandfathered monitored-system floor/i).length, + ).toBeGreaterThan(0); + expect(screen.getByText('Continuity pending')).toBeInTheDocument(); expect(screen.getAllByText('Verifying…').length).toBeGreaterThan(0); expect(screen.getAllByText('Unavailable').length).toBeGreaterThan(0); expect(screen.getByText('Plan Monitored System Limit')).toBeInTheDocument(); @@ -605,8 +648,9 @@ describe('ProLicensePanel', () => { }); expect( - screen.getByText(/keeps an effective monitored-system limit of 23/i), - ).toBeInTheDocument(); + screen.getAllByText(/keeps an effective monitored-system limit of 23/i).length, + ).toBeGreaterThan(0); + expect(screen.getByText('Grandfathered floor')).toBeInTheDocument(); expect(screen.getAllByText('23 monitored systems').length).toBeGreaterThan(0); expect(screen.getByText('Monitoring capacity')).toBeInTheDocument(); expect( @@ -869,7 +913,9 @@ describe('ProLicensePanel', () => { renderPanel(); expect(screen.getAllByText('Compare self-hosted plans').length).toBeGreaterThan(0); - expect(screen.getByText(/Community keeps monitoring free/i)).toBeInTheDocument(); + expect( + screen.getByText(/Core self-hosted monitoring stays free on this instance/i), + ).toBeInTheDocument(); const compareLinks = screen.getAllByRole('link', { name: 'Compare plans' }); expect( compareLinks.some( @@ -1019,6 +1065,7 @@ describe('ProLicensePanel', () => { expect(proLicensePanelStateSource).toContain('loadCommercialPosture(true)'); expect(proLicensePanelStateSource).toContain('loadRuntimeCapabilities(true)'); expect(proLicensePanelStateSource).toContain('buildSelfHostedCommercialPlanModel'); + expect(proLicensePanelStateSource).toContain('getSelfHostedCurrentPlanPresentation({'); expect(proLicensePanelStateSource).toContain('runStartProTrialAction({'); expect(proLicensePanelStateSource).not.toContain('startProTrial()'); expect(proLicensePanelStateSource).toContain("'A license or activation key is required'"); @@ -1026,6 +1073,7 @@ describe('ProLicensePanel', () => { expect(proLicensePlanSectionSource).toContain('getNoActiveProLicenseState'); expect(proLicensePlanSectionSource).toContain('getTrialEndedProLicenseNotice'); expect(proLicensePlanSectionSource).toContain('currentPlanSummary.title'); + expect(proLicensePlanSectionSource).toContain('currentPlanSummary.supplementalBadges'); expect(proLicensePlanSectionSource).toContain('Unlocked on this instance'); expect(proLicensePlanSectionSource).not.toContain('getInactiveProUpsellNotice'); expect(proLicensePlanSectionSource).not.toContain('MonitoredSystemDefinitionDisclosure'); diff --git a/frontend-modern/src/components/Settings/useProLicensePanelState.ts b/frontend-modern/src/components/Settings/useProLicensePanelState.ts index 99a609362..14f527e49 100644 --- a/frontend-modern/src/components/Settings/useProLicensePanelState.ts +++ b/frontend-modern/src/components/Settings/useProLicensePanelState.ts @@ -14,10 +14,10 @@ import { formatLicensePlanVersion, getCommercialMigrationNotice, getGrandfatheredPriceContinuityNotice, + getSelfHostedCurrentPlanPresentation, getLicenseFeatureLabel, getMonitoredSystemContinuityNotice, getPurchaseActivationNotice, - getSelfHostedPlanLabel, getLicenseSubscriptionStatusPresentation, getLicenseTierLabel, getTrialActivationNotice, @@ -330,12 +330,6 @@ export function useProLicensePanelState() { if (!current) return 'Unknown'; return getLicenseTierLabel(current.tier); }); - const currentPlanLabel = createMemo(() => { - const current = entitlements(); - if (!current) return 'Unknown'; - return getSelfHostedPlanLabel(current.tier); - }); - const formattedPlanTerms = createMemo(() => formatLicensePlanVersion(entitlements()?.plan_version), ); @@ -572,61 +566,14 @@ export function useProLicensePanelState() { ); const currentPlanSummary = createMemo(() => { const status = statusPresentation(); - const unlockedFeatures = formattedFeatures(); - const current = entitlements(); - const planLabel = currentPlanLabel(); - const normalizedState = (subscriptionState() || '').trim().toLowerCase(); - - if (!current) { - return { - title: 'Current plan: Unknown', - body: 'Pulse is still loading the current self-hosted entitlement for this instance.', - badgeClass: status.badgeClass, - statusLabel: status.label, - unlockedFeatures: [] as string[], - }; - } - - if (normalizedState === 'trial') { - return { - title: `Current plan: ${planLabel} Trial`, - body: - unlockedFeatures.length > 0 - ? `${planLabel} trial capabilities are active on this instance right now.` - : `${planLabel} trial activation is in progress on this instance.`, - badgeClass: status.badgeClass, - statusLabel: status.label, - unlockedFeatures, - }; - } - - if (unlockedFeatures.length > 0 && (normalizedState === 'active' || normalizedState === 'grace')) { - return { - title: `Current plan: ${planLabel}`, - body: `${planLabel} is active on this instance. These capabilities are unlocked right now.`, - badgeClass: status.badgeClass, - statusLabel: status.label, - unlockedFeatures, - }; - } - - if ((current.tier || '').trim().toLowerCase() === 'free') { - return { - title: 'Current plan: Community', - body: - 'Community keeps self-hosted core monitoring free on this instance. Upgrade only when you want Relay or Pro capabilities.', - badgeClass: status.badgeClass, - statusLabel: status.label, - unlockedFeatures: [] as string[], - }; - } - + const summary = getSelfHostedCurrentPlanPresentation({ + entitlements: entitlements(), + displayableCapabilities: formattedFeatures(), + }); return { - title: `Current plan: ${planLabel}`, - body: 'Review the plan details below to confirm what this key unlocks on this instance.', + ...summary, badgeClass: status.badgeClass, statusLabel: status.label, - unlockedFeatures, }; }); diff --git a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts index 0420ec9c3..8f6e7be3d 100644 --- a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts @@ -8,6 +8,7 @@ import { getCommercialMigrationNotice, getFeatureMinTierLabel, getGrandfatheredPriceContinuityNotice, + getSelfHostedCurrentPlanPresentation, getLicenseFeatureLabel, getLicenseStatusLoadingState, getLicenseSubscriptionStatusPresentation, @@ -196,6 +197,89 @@ describe('licensePresentation', () => { ).toBeNull(); }); + it('builds entitlement-first current-plan presentation for community, paid, and grandfathered installs', () => { + expect( + getSelfHostedCurrentPlanPresentation({ + entitlements: { + tier: 'free', + subscription_state: 'expired', + capabilities: [], + limits: [], + upgrade_reasons: [], + }, + displayableCapabilities: [], + }), + ).toEqual({ + title: 'Current plan: Community', + body: + 'Core self-hosted monitoring stays free on this instance. Upgrade only when you want Relay convenience or Pro operations features.', + unlockedFeatures: [ + 'Unlimited self-hosted monitoring', + '7-day metric history', + 'Pulse Patrol (BYOK)', + 'Update alerts', + ], + supplementalBadges: [], + supplementalSummary: '', + }); + + expect( + getSelfHostedCurrentPlanPresentation({ + entitlements: { + tier: 'pro', + subscription_state: 'active', + capabilities: ['relay', 'mobile_app', 'ai_autofix'], + limits: [], + upgrade_reasons: [], + }, + displayableCapabilities: [ + 'Pulse Relay (Remote Access)', + 'Mobile App Access', + 'Patrol Auto-Fix', + ], + }), + ).toEqual({ + title: 'Current plan: Pulse Pro', + body: + 'Pulse Pro is active on this instance. AI operations, advanced administration, and 90-day history are unlocked right now.', + unlockedFeatures: [ + 'Pulse Relay (Remote Access)', + 'Mobile App Access', + 'Patrol Auto-Fix', + ], + supplementalBadges: [], + supplementalSummary: '', + }); + + expect( + getSelfHostedCurrentPlanPresentation({ + entitlements: { + tier: 'pro', + subscription_state: 'active', + plan_version: 'v5_pro_monthly_grandfathered', + capabilities: ['relay'], + limits: [], + upgrade_reasons: [], + monitored_system_continuity: { + plan_limit: 10, + grandfathered_floor: 23, + effective_limit: 23, + capture_pending: false, + }, + }, + displayableCapabilities: ['Pulse Relay (Remote Access)'], + }), + ).toEqual({ + title: 'Current plan: Pulse Pro', + body: + 'Pulse Pro is active on this instance. AI operations, advanced administration, and 90-day history are unlocked right now.', + unlockedFeatures: ['Pulse Relay (Remote Access)'], + supplementalBadges: ['Grandfathered price', 'Grandfathered floor'], + 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.', + }); + }); + it('returns monitored-system continuity notices for pending verification and captured grandfathering', () => { expect( getMonitoredSystemContinuityNotice( diff --git a/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts b/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts index 2ee0718c6..6faeb6d18 100644 --- a/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts +++ b/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts @@ -61,12 +61,41 @@ describe('selfHostedPlans', () => { expect(SELF_HOSTED_PLAN_BY_TIER.community.billingExtrasSummary).toBe( 'Patrol, alerts, and OIDC', ); + expect(SELF_HOSTED_PLAN_BY_TIER.community.entitlementSummary).toContain( + 'Core self-hosted monitoring stays free on this instance.', + ); + expect(SELF_HOSTED_PLAN_BY_TIER.community.entitlementHighlights).toEqual([ + 'Unlimited self-hosted monitoring', + '7-day metric history', + 'Pulse Patrol (BYOK)', + 'Update alerts', + ]); expect(SELF_HOSTED_PLAN_BY_TIER.relay.billingExtrasSummary).toBe( 'Remote access, mobile, and push', ); + expect(SELF_HOSTED_PLAN_BY_TIER.relay.entitlementSummary).toContain( + 'Remote access, mobile, push, and longer history are unlocked right now.', + ); + expect(SELF_HOSTED_PLAN_BY_TIER.relay.entitlementHighlights).toEqual([ + 'Pulse Relay (Remote Access)', + 'Mobile App Access', + 'Push Notifications', + '14-day metric history', + ]); expect(SELF_HOSTED_PLAN_BY_TIER.pro.billingExtrasSummary).toBe( 'AI operations and advanced admin', ); + expect(SELF_HOSTED_PLAN_BY_TIER.pro.entitlementSummary).toContain( + 'AI operations, advanced administration, and 90-day history are unlocked right now.', + ); + expect(SELF_HOSTED_PLAN_BY_TIER.pro.entitlementHighlights).toEqual([ + 'Patrol Auto-Fix', + 'Pulse Alert Analysis', + 'Kubernetes Insights', + '90-day metric history', + 'Role-Based Access Control (RBAC)', + 'Audit Logging', + ]); expect(SELF_HOSTED_PLAN_BY_TIER.community.highlights).toEqual( expect.arrayContaining([ 'Unlimited self-hosted monitoring', diff --git a/frontend-modern/src/utils/licensePresentation.ts b/frontend-modern/src/utils/licensePresentation.ts index da56bed62..925e0d8c9 100644 --- a/frontend-modern/src/utils/licensePresentation.ts +++ b/frontend-modern/src/utils/licensePresentation.ts @@ -1,6 +1,7 @@ import type { BillingState, HostedOrganizationSummary } from '@/api/billingAdmin'; import type { CommercialMigrationStatus, + LicenseCommercialEntitlements, LicenseStatus, MonitoredSystemContinuityStatus, } from '@/api/license'; @@ -13,6 +14,7 @@ import { type MonitoredSystemCapacityStatus, type MonitoredSystemLimitUsageStatus, } from '@/utils/monitoredSystemPresentation'; +import { getSelfHostedPlanDefinitionForBillingTier } from '@/utils/selfHostedPlans'; import { titleCaseDelimitedLabel } from '@/utils/textPresentation'; const TIER_LABELS: Record = { @@ -106,6 +108,14 @@ export interface SelfHostedRecoveryPresentation { legacyNotice: SelfHostedActivationNoticeCopy; } +export interface SelfHostedCurrentPlanPresentation { + title: string; + body: string; + unlockedFeatures: string[]; + supplementalBadges: string[]; + supplementalSummary?: string; +} + const GRANDFATHERED_V5_PLAN_LABELS: Record = { v5_lifetime_grandfathered: 'V5 Lifetime Grandfathered', v5_pro_monthly_grandfathered: 'V5 Pro Monthly (Grandfathered)', @@ -248,6 +258,114 @@ export const getMonitoredSystemContinuityNotice = ( return null; }; +export const getSelfHostedCurrentPlanPresentation = ({ + entitlements, + displayableCapabilities, +}: { + entitlements?: LicenseCommercialEntitlements | null; + displayableCapabilities: string[]; +}): SelfHostedCurrentPlanPresentation => { + const current = entitlements; + if (!current) { + return { + title: 'Current plan: Unknown', + body: 'Pulse is still loading the current self-hosted entitlement for this instance.', + unlockedFeatures: [], + supplementalBadges: [], + }; + } + + const normalizedState = (current.subscription_state || '').trim().toLowerCase(); + const normalizedTier = (current.tier || '').trim().toLowerCase(); + const planLabel = getSelfHostedPlanLabel(current.tier); + const planDefinition = getSelfHostedPlanDefinitionForBillingTier(current.tier); + const fallbackHighlights = [...(planDefinition?.entitlementHighlights ?? [])]; + const unlockedFeatures = + normalizedTier === 'free' + ? fallbackHighlights + : displayableCapabilities.length > 0 + ? displayableCapabilities + : fallbackHighlights; + + const supplementalBadges: string[] = []; + const supplementalDetails: string[] = []; + + if (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)) { + supplementalBadges.push('Grandfathered lifetime'); + supplementalDetails.push( + 'This migrated lifetime install keeps uncapped monitored-system and guest capacity continuity.', + ); + } + + const continuity = current.monitored_system_continuity; + if (continuity?.capture_pending) { + supplementalBadges.push('Continuity pending'); + supplementalDetails.push( + 'Pulse is still verifying the grandfathered monitored-system floor for this migrated v5 installation.', + ); + } else if ( + continuity && + typeof continuity.grandfathered_floor === 'number' && + continuity.grandfathered_floor > 0 && + continuity.effective_limit > continuity.plan_limit + ) { + supplementalBadges.push('Grandfathered floor'); + supplementalDetails.push( + `This installation keeps an effective monitored-system limit of ${continuity.effective_limit} from the observed legacy estate.`, + ); + } + + if (normalizedState === 'trial') { + return { + title: `Current plan: ${planLabel} Trial`, + body: + unlockedFeatures.length > 0 + ? `${planLabel} trial capabilities are active on this instance right now.` + : `${planLabel} trial activation is in progress on this instance.`, + unlockedFeatures, + supplementalBadges, + supplementalSummary: supplementalDetails.join(' '), + }; + } + + if (normalizedTier === 'free') { + return { + title: 'Current plan: Community', + body: + planDefinition?.entitlementSummary || + 'Community keeps self-hosted core monitoring free on this instance. Upgrade only when you want Relay or Pro capabilities.', + unlockedFeatures, + supplementalBadges, + supplementalSummary: supplementalDetails.join(' '), + }; + } + + if (normalizedState === 'active' || normalizedState === 'grace') { + return { + title: `Current plan: ${planLabel}`, + body: + planDefinition?.entitlementSummary || + `${planLabel} is active on this instance. These capabilities are unlocked right now.`, + unlockedFeatures, + supplementalBadges, + supplementalSummary: supplementalDetails.join(' '), + }; + } + + return { + title: `Current plan: ${planLabel}`, + body: 'Review the plan details below to confirm what this key unlocks on this instance.', + unlockedFeatures, + supplementalBadges, + supplementalSummary: supplementalDetails.join(' '), + }; +}; + export const getCommercialMigrationActionText = (action?: string): string => { switch (action) { case 'retry_activation': diff --git a/frontend-modern/src/utils/selfHostedPlans.ts b/frontend-modern/src/utils/selfHostedPlans.ts index 33b710f8c..6f7eeb45b 100644 --- a/frontend-modern/src/utils/selfHostedPlans.ts +++ b/frontend-modern/src/utils/selfHostedPlans.ts @@ -7,6 +7,8 @@ export interface SelfHostedPlanDefinition { subline: string; metricHistoryDays: number; billingExtrasSummary: string; + entitlementSummary: string; + entitlementHighlights: readonly string[]; highlights: readonly string[]; } @@ -46,6 +48,14 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] = subline: 'Unlimited self-hosted monitoring', metricHistoryDays: 7, billingExtrasSummary: 'Patrol, alerts, and OIDC', + entitlementSummary: + 'Core self-hosted monitoring stays free on this instance. Upgrade only when you want Relay convenience or Pro operations features.', + entitlementHighlights: [ + 'Unlimited self-hosted monitoring', + '7-day metric history', + 'Pulse Patrol (BYOK)', + 'Update alerts', + ], highlights: [ 'Real-time monitoring', 'Unlimited self-hosted monitoring', @@ -64,6 +74,14 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] = subline: 'or $39/year', metricHistoryDays: 14, billingExtrasSummary: 'Remote access, mobile, and push', + entitlementSummary: + 'Relay is active on this instance. Remote access, mobile, push, and longer history are unlocked right now.', + entitlementHighlights: [ + 'Pulse Relay (Remote Access)', + 'Mobile App Access', + 'Push Notifications', + '14-day metric history', + ], highlights: [ 'Everything in Community', 'Remote access via Relay', @@ -80,6 +98,16 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] = subline: 'or $79/year', metricHistoryDays: 90, billingExtrasSummary: 'AI operations and advanced admin', + entitlementSummary: + 'Pulse Pro is active on this instance. AI operations, advanced administration, and 90-day history are unlocked right now.', + entitlementHighlights: [ + 'Patrol Auto-Fix', + 'Pulse Alert Analysis', + 'Kubernetes Insights', + '90-day metric history', + 'Role-Based Access Control (RBAC)', + 'Audit Logging', + ], highlights: [ 'Everything in Relay', 'Patrol Auto-Fix & investigation', diff --git a/tests/integration/tests/59-self-hosted-plans-entitlement-summary.spec.ts b/tests/integration/tests/59-self-hosted-plans-entitlement-summary.spec.ts new file mode 100644 index 000000000..864ba9d16 --- /dev/null +++ b/tests/integration/tests/59-self-hosted-plans-entitlement-summary.spec.ts @@ -0,0 +1,139 @@ +import { expect, test, type Page } from '@playwright/test'; + +import { ensureAuthenticated } from './helpers'; + +const PRO_PLAN_ENTITLEMENTS = { + capabilities: [ + 'relay', + 'mobile_app', + 'push_notifications', + 'ai_patrol', + 'ai_autofix', + 'ai_alerts', + 'kubernetes_ai', + 'rbac', + 'audit_logging', + ], + limits: [{ key: 'max_monitored_systems', limit: 23, current: 23, state: 'enforced' }], + 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, + }, + monitored_system_continuity: { + plan_limit: 10, + grandfathered_floor: 23, + effective_limit: 23, + capture_pending: false, + captured_at: 1_768_000_000, + }, + subscription_state: 'active', + upgrade_reasons: [], + tier: 'pro', + plan_version: 'v5_pro_monthly_grandfathered', + licensed_email: 'owner@example.com', + trial_eligible: false, + hosted_mode: false, + valid: true, + is_lifetime: false, + days_remaining: 30, + in_grace_period: false, + grace_period_end: null, + max_history_days: 90, +}; + +const PRO_PLAN_COMMERCIAL_POSTURE = { + subscription_state: 'active', + upgrade_reasons: [], + tier: 'pro', + trial_eligible: false, + monitored_system_capacity: PRO_PLAN_ENTITLEMENTS.monitored_system_capacity, + monitored_system_continuity: PRO_PLAN_ENTITLEMENTS.monitored_system_continuity, + has_migration_gap: false, +}; + +const PRO_PLAN_RUNTIME_CAPABILITIES = { + capabilities: PRO_PLAN_ENTITLEMENTS.capabilities, + limits: PRO_PLAN_ENTITLEMENTS.limits, + monitored_system_capacity: PRO_PLAN_ENTITLEMENTS.monitored_system_capacity, + hosted_mode: false, + max_history_days: 90, +}; + +async function stubSelfHostedPlanEndpoints(page: Page) { + await page.route('**/api/license/runtime-capabilities', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(PRO_PLAN_RUNTIME_CAPABILITIES), + }); + }); + + await page.route('**/api/license/entitlements', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(PRO_PLAN_ENTITLEMENTS), + }); + }); + + await page.route('**/api/license/commercial-posture', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(PRO_PLAN_COMMERCIAL_POSTURE), + }); + }); +} + +test.describe('Self-hosted plans entitlement summary', () => { + test('shows paid Pulse Pro entitlements and continuity at the top of Plans', async ({ + page, + }, testInfo) => { + test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop-only plans coverage'); + + await stubSelfHostedPlanEndpoints(page); + await ensureAuthenticated(page); + await page.goto('/settings/system/billing/plan', { waitUntil: 'domcontentloaded' }); + + await expect( + page.locator('[aria-label="Settings navigation"]').getByText('Plans', { exact: true }), + ).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Plans & Activation' }).first()).toBeVisible(); + const currentPlanCard = page + .locator('div.rounded-md.border.border-border.bg-surface-alt.p-4') + .filter({ has: page.getByText('Current plan: Pulse Pro') }) + .first(); + + await expect(currentPlanCard.getByText('Current plan: Pulse Pro')).toBeVisible(); + await expect( + currentPlanCard.getByText( + 'Pulse Pro is active on this instance. AI operations, advanced administration, and 90-day history are unlocked right now.', + ), + ).toBeVisible(); + await expect(currentPlanCard.getByText('Grandfathered price')).toBeVisible(); + await expect( + currentPlanCard + .locator('span') + .filter({ hasText: 'Grandfathered floor' }) + .first(), + ).toBeVisible(); + await expect( + currentPlanCard.getByText( + /existing recurring price and uncapped guest capacity until cancellation/i, + ), + ).toBeVisible(); + await expect(currentPlanCard.getByText(/effective monitored-system limit of 23/i)).toBeVisible(); + await expect(currentPlanCard.getByText('Pulse Relay (Remote Access)')).toBeVisible(); + await expect(currentPlanCard.getByText('Mobile App Access')).toBeVisible(); + await expect(currentPlanCard.getByText('Patrol Auto-Fix')).toBeVisible(); + await expect(currentPlanCard.getByText('Pulse Alert Analysis')).toBeVisible(); + }); +});