diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 03e4ede10e..3c28b11058 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -1214,7 +1214,12 @@ adjacency must come from the shared self-hosted plan-definition owner rather than page-local strings. That owner must also keep Community, Relay, and Pro framed as unlimited core monitoring plus their bundle-specific extras rather than drifting back to bounded monitored-system sales copy, vague `systems`, or -device-style language. Pro+ may appear only as a legacy continuity tier. +device-style language. The same shared owner must also provide the billing +surface bundle summary and metric-history facts for current uncapped +self-hosted tiers, so `commercialBillingModel.ts` and +`useProLicensePanelState.ts` do not regress back to legacy guest-capacity or +meter-style entitlement fields on retail Community, Relay, or Pro plans. Pro+ +may appear only as a legacy continuity tier. The shared license presentation owner also holds self-hosted Pro settings trial-ended notice copy for `ProLicensePlanSection.tsx`; that surface must consume canonical helper notices instead of carrying inline upgrade copy or diff --git a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx index a816e14aa0..d3e94e2581 100644 --- a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx @@ -306,7 +306,7 @@ describe('ProLicensePanel', () => { ), ).toBeInTheDocument(); expect(screen.queryByText('Included Monitored Systems')).not.toBeInTheDocument(); - expect(screen.getByText('Max Guests')).toBeInTheDocument(); + expect(screen.getByText('Guest Capacity')).toBeInTheDocument(); expect(screen.getByText('Core Monitoring')).toBeInTheDocument(); expect(screen.queryByText('Monitored Systems')).not.toBeInTheDocument(); expect(screen.queryByText('Capacity Status')).not.toBeInTheDocument(); @@ -363,7 +363,7 @@ describe('ProLicensePanel', () => { ).toBeInTheDocument(); expect(screen.queryByText('Capacity Status')).not.toBeInTheDocument(); expect(screen.queryByText('Included Monitored Systems')).not.toBeInTheDocument(); - expect(screen.getByText('Max Guests')).toBeInTheDocument(); + expect(screen.getByText('Guest Capacity')).toBeInTheDocument(); expect(screen.queryByText('Monitoring capacity')).not.toBeInTheDocument(); expect(screen.queryByRole('tab', { name: 'Usage' })).not.toBeInTheDocument(); expect(screen.getAllByText('Unlimited').length).toBeGreaterThan(0); @@ -373,6 +373,35 @@ describe('ProLicensePanel', () => { } }); + it('uses shared current-plan metadata for uncapped retail self-hosted tiers', async () => { + mockEntitlements = { + capabilities: ['relay', 'mobile_app', 'push_notifications', 'ai_patrol', 'ai_autofix'], + limits: [], + subscription_state: 'active', + upgrade_reasons: [], + tier: 'pro', + plan_version: 'pro_monthly', + licensed_email: 'owner@example.com', + trial_eligible: false, + }; + + renderPanel(); + + await waitFor(() => { + expect(screen.getByText('Metric History')).toBeInTheDocument(); + }); + + expect(screen.getByText('Core Monitoring')).toBeInTheDocument(); + expect(screen.getByText('Metric History')).toBeInTheDocument(); + expect(screen.getByText('Included Extras')).toBeInTheDocument(); + expect(screen.getByText('90 days')).toBeInTheDocument(); + expect(screen.getByText('AI operations and advanced admin')).toBeInTheDocument(); + expect(screen.queryByText('Guest Capacity')).not.toBeInTheDocument(); + expect(screen.queryByText('Included Monitored Systems')).not.toBeInTheDocument(); + expect(screen.queryByText('Monitoring capacity')).not.toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: 'Usage' })).not.toBeInTheDocument(); + }); + it('shows continuity verification while a bounded fallback migration is still pending', async () => { mockEntitlements = { capabilities: ['relay'], diff --git a/frontend-modern/src/components/Settings/useProLicensePanelState.ts b/frontend-modern/src/components/Settings/useProLicensePanelState.ts index fd0b0e5d8c..15e88f814a 100644 --- a/frontend-modern/src/components/Settings/useProLicensePanelState.ts +++ b/frontend-modern/src/components/Settings/useProLicensePanelState.ts @@ -48,6 +48,7 @@ import { type SelfHostedBillingSection, } from '@/utils/pricingHandoff'; import { buildSelfHostedCommercialPlanModel } from '@/utils/commercialBillingModel'; +import { getSelfHostedPlanDefinitionForBillingTier } from '@/utils/selfHostedPlans'; import { buildMonitoredSystemCapacitySectionModel, getMonitoredSystemLimitCapacityStatusSummary, @@ -328,6 +329,18 @@ export function useProLicensePanelState() { const monitoredSystemCapacityPosture = createMemo(() => resolveMonitoredSystemCapacityStatus(monitoredSystemCapacity(), monitoredSystemLimitStatus()), ); + const currentRetailPlanDefinition = createMemo(() => { + if (monitoredSystemContinuity()) { + return null; + } + if (uncappedGrandfatheredPlan()) { + return null; + } + if ((monitoredSystemCapacityPosture()?.limit ?? 0) > 0) { + return null; + } + return getSelfHostedPlanDefinitionForBillingTier(entitlements()?.tier); + }); const monitoredSystemContinuityNotice = createMemo(() => getMonitoredSystemContinuityNotice( monitoredSystemContinuity(), @@ -437,6 +450,16 @@ export function useProLicensePanelState() { const state = subscriptionState(); return state === 'active' || state === 'trial' || state === 'grace'; }); + const showGuestCapacity = createMemo(() => { + if (currentRetailPlanDefinition()) { + return false; + } + if (uncappedGrandfatheredPlan()) { + return true; + } + const guestLimit = limitStatus('max_guests')?.limit; + return typeof guestLimit === 'number' && guestLimit > 0; + }); const commercialPlanModel = createMemo(() => buildSelfHostedCommercialPlanModel({ @@ -453,10 +476,12 @@ export function useProLicensePanelState() { limitStatus('max_monitored_systems')!.limit > 0 ? limitStatus('max_monitored_systems')!.limit : 'Unlimited', - maxGuests: + guestCapacity: typeof limitStatus('max_guests')?.limit === 'number' && limitStatus('max_guests')!.limit > 0 ? limitStatus('max_guests')!.limit : 'Unlimited', + retailPlanDefinition: currentRetailPlanDefinition(), + showGuestCapacity: showGuestCapacity(), monitoredSystemContinuity: monitoredSystemContinuity() ?? null, continuityCapturedAt: continuityCapturedAt(), }), diff --git a/frontend-modern/src/utils/__tests__/commercialBillingModel.test.ts b/frontend-modern/src/utils/__tests__/commercialBillingModel.test.ts new file mode 100644 index 0000000000..72ae7d2c54 --- /dev/null +++ b/frontend-modern/src/utils/__tests__/commercialBillingModel.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; + +import { buildSelfHostedCommercialPlanModel } from '../commercialBillingModel'; +import { SELF_HOSTED_PLAN_BY_TIER } from '../selfHostedPlans'; + +const createBaseInput = () => ({ + licensedEmail: 'owner@example.com', + statusLabel: 'Active', + tierLabel: 'Pro', + planTerms: 'Pro Monthly', + expires: '12/31/2026', + daysRemaining: 123, + monitoredSystemsSummary: 'Unlimited', + capacityStatusSummary: 'Unlimited', + maxMonitoredSystems: 'Unlimited' as const, + guestCapacity: 'Unlimited' as const, +}); + +describe('commercialBillingModel', () => { + it('uses shared current retail plan metadata for uncapped self-hosted plans', () => { + const model = buildSelfHostedCommercialPlanModel({ + ...createBaseInput(), + retailPlanDefinition: SELF_HOSTED_PLAN_BY_TIER.pro, + showGuestCapacity: false, + }); + + expect(model.summary).toEqual([ + { label: 'Core Monitoring', value: 'Unlimited' }, + { label: 'Metric History', value: '90 days' }, + { label: 'Included Extras', value: 'AI operations and advanced admin' }, + ]); + expect(model.details.map((item) => item.label)).toEqual([ + 'Tier', + 'Licensed Email', + 'Plan Terms', + 'Expires', + 'Days Remaining', + ]); + }); + + it('keeps guest capacity visible for uncapped grandfathered continuity states', () => { + const model = buildSelfHostedCommercialPlanModel({ + ...createBaseInput(), + planTerms: 'V5 Pro Monthly (Grandfathered)', + retailPlanDefinition: null, + showGuestCapacity: true, + }); + + expect(model.summary).toEqual([ + { label: 'Core Monitoring', value: 'Unlimited' }, + { label: 'Guest Capacity', value: 'Unlimited' }, + { label: 'Plan Status', value: 'Active' }, + ]); + expect(model.details.map((item) => item.label)).not.toContain('Included Monitored Systems'); + }); + + it('keeps bounded monitored-system details on legacy fallback paths', () => { + const model = buildSelfHostedCommercialPlanModel({ + ...createBaseInput(), + monitoredSystemsSummary: '7 monitored systems', + capacityStatusSummary: '3 remaining', + maxMonitoredSystems: 10, + guestCapacity: 50, + retailPlanDefinition: null, + showGuestCapacity: true, + }); + + expect(model.summary).toEqual([ + { label: 'Monitored Systems', value: '7 monitored systems' }, + { label: 'Capacity Status', value: '3 remaining' }, + { label: 'Plan Status', value: 'Active' }, + ]); + expect(model.details.map((item) => item.label)).toContain('Included Monitored Systems'); + expect(model.details.map((item) => item.label)).not.toContain('Guest Capacity'); + }); +}); diff --git a/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts b/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts index 0be77a8d02..2ee0718c6c 100644 --- a/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts +++ b/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + getSelfHostedPlanDefinitionForBillingTier, SELF_HOSTED_COMMERCIAL_PRESENTATION, SELF_HOSTED_FEATURE_ROWS, SELF_HOSTED_PLAN_BY_TIER, @@ -57,6 +58,15 @@ describe('selfHostedPlans', () => { it('keeps Community quickstart copy scoped to Patrol activation support', () => { expect(SELF_HOSTED_PLAN_BY_TIER.community.subline).toBe('Unlimited self-hosted monitoring'); + expect(SELF_HOSTED_PLAN_BY_TIER.community.billingExtrasSummary).toBe( + 'Patrol, alerts, and OIDC', + ); + expect(SELF_HOSTED_PLAN_BY_TIER.relay.billingExtrasSummary).toBe( + 'Remote access, mobile, and push', + ); + expect(SELF_HOSTED_PLAN_BY_TIER.pro.billingExtrasSummary).toBe( + 'AI operations and advanced admin', + ); expect(SELF_HOSTED_PLAN_BY_TIER.community.highlights).toEqual( expect.arrayContaining([ 'Unlimited self-hosted monitoring', @@ -70,4 +80,20 @@ describe('selfHostedPlans', () => { 'Patrol quickstart: 25 runs, no API key', ); }); + + it('maps current billing tiers back to the shared self-hosted plan definitions', () => { + expect(getSelfHostedPlanDefinitionForBillingTier('free')).toBe( + SELF_HOSTED_PLAN_BY_TIER.community, + ); + expect(getSelfHostedPlanDefinitionForBillingTier('community')).toBe( + SELF_HOSTED_PLAN_BY_TIER.community, + ); + expect(getSelfHostedPlanDefinitionForBillingTier('relay')).toBe( + SELF_HOSTED_PLAN_BY_TIER.relay, + ); + expect(getSelfHostedPlanDefinitionForBillingTier('pro')).toBe( + SELF_HOSTED_PLAN_BY_TIER.pro, + ); + expect(getSelfHostedPlanDefinitionForBillingTier('enterprise')).toBeNull(); + }); }); diff --git a/frontend-modern/src/utils/commercialBillingModel.ts b/frontend-modern/src/utils/commercialBillingModel.ts index 3c7822ecfd..74d915262a 100644 --- a/frontend-modern/src/utils/commercialBillingModel.ts +++ b/frontend-modern/src/utils/commercialBillingModel.ts @@ -1,5 +1,6 @@ import type { LicenseStatus } from '@/api/license'; import type { MonitoredSystemContinuityStatus } from '@/api/license'; +import type { SelfHostedPlanDefinition } from '@/utils/selfHostedPlans'; export interface CommercialStatValue { label: string; @@ -32,7 +33,12 @@ export interface SelfHostedCommercialModelInput { monitoredSystemsSummary: string | number; capacityStatusSummary: string | number; maxMonitoredSystems: string | number; - maxGuests: string | number; + guestCapacity: string | number; + retailPlanDefinition?: Pick< + SelfHostedPlanDefinition, + 'billingExtrasSummary' | 'metricHistoryDays' + > | null; + showGuestCapacity?: boolean; monitoredSystemContinuity?: MonitoredSystemContinuityStatus | null; continuityCapturedAt?: string; } @@ -57,110 +63,138 @@ const asUnlimitedLimit = (value?: number) => const hasFiniteSelfHostedLimit = (value: string | number) => typeof value === 'number' ? value > 0 : value.trim().toLowerCase() !== 'unlimited'; +const buildSelfHostedBaseDetails = ( + input: SelfHostedCommercialModelInput, +): CommercialPlanViewModel['details'] => [ + { + label: 'Tier', + value: input.tierLabel, + }, + { + label: 'Licensed Email', + value: input.licensedEmail || 'Not available', + }, + ...(input.planTerms + ? [ + { + label: 'Plan Terms', + value: input.planTerms, + }, + ] + : []), + { + label: 'Expires', + value: input.expires, + }, + { + label: 'Days Remaining', + value: input.daysRemaining, + }, +]; + export const buildSelfHostedCommercialPlanModel = ( input: SelfHostedCommercialModelInput, -): CommercialPlanViewModel => ({ - summary: - !input.monitoredSystemContinuity && !hasFiniteSelfHostedLimit(input.maxMonitoredSystems) - ? [ - { - label: 'Core Monitoring', - value: 'Unlimited', - }, - { - label: 'Plan Status', - value: input.statusLabel, - }, - { - label: 'Guests', - value: input.maxGuests, - }, - ] - : [ - { - label: 'Monitored Systems', - value: input.monitoredSystemsSummary, - }, - { - label: 'Capacity Status', - value: input.capacityStatusSummary, - }, - { - label: 'Plan Status', - value: input.statusLabel, - }, - ], - details: [ - { - label: 'Tier', - value: input.tierLabel, - }, - { - label: 'Licensed Email', - value: input.licensedEmail || 'Not available', - }, - ...(input.planTerms - ? [ - { - label: 'Plan Terms', - value: input.planTerms, - }, - ] - : []), - { - label: 'Expires', - value: input.expires, - }, - { - label: 'Days Remaining', - value: input.daysRemaining, - }, - ...(input.monitoredSystemContinuity - ? [ - { - label: 'Plan Monitored System Limit', - value: - input.monitoredSystemContinuity.plan_limit > 0 - ? input.monitoredSystemContinuity.plan_limit - : 'Unlimited', - }, - { - label: 'Effective Monitored System Limit', - value: - input.monitoredSystemContinuity.effective_limit > 0 - ? input.monitoredSystemContinuity.effective_limit - : 'Unlimited', - }, - ...(typeof input.monitoredSystemContinuity.grandfathered_floor === 'number' && - input.monitoredSystemContinuity.grandfathered_floor > 0 - ? [ - { - label: 'Grandfathered Floor', - value: input.monitoredSystemContinuity.grandfathered_floor, - }, - ] - : []), - { - label: 'Continuity Capture', - value: input.monitoredSystemContinuity.capture_pending - ? 'Pending' - : input.continuityCapturedAt || 'Captured', - }, - ] - : hasFiniteSelfHostedLimit(input.maxMonitoredSystems) - ? [ - { - label: 'Included Monitored Systems', - value: input.maxMonitoredSystems, - }, - ] - : []), - { - label: 'Max Guests', - value: input.maxGuests, - }, - ], -}); +): CommercialPlanViewModel => { + if (input.retailPlanDefinition) { + return { + summary: [ + { + label: 'Core Monitoring', + value: 'Unlimited', + }, + { + label: 'Metric History', + value: `${input.retailPlanDefinition.metricHistoryDays} days`, + }, + { + label: 'Included Extras', + value: input.retailPlanDefinition.billingExtrasSummary, + }, + ], + details: buildSelfHostedBaseDetails(input), + }; + } + + return { + summary: + !input.monitoredSystemContinuity && !hasFiniteSelfHostedLimit(input.maxMonitoredSystems) + ? [ + { + label: 'Core Monitoring', + value: 'Unlimited', + }, + ...(input.showGuestCapacity + ? [ + { + label: 'Guest Capacity', + value: input.guestCapacity, + }, + ] + : []), + { + label: 'Plan Status', + value: input.statusLabel, + }, + ] + : [ + { + label: 'Monitored Systems', + value: input.monitoredSystemsSummary, + }, + { + label: 'Capacity Status', + value: input.capacityStatusSummary, + }, + { + label: 'Plan Status', + value: input.statusLabel, + }, + ], + details: [ + ...buildSelfHostedBaseDetails(input), + ...(input.monitoredSystemContinuity + ? [ + { + label: 'Plan Monitored System Limit', + value: + input.monitoredSystemContinuity.plan_limit > 0 + ? input.monitoredSystemContinuity.plan_limit + : 'Unlimited', + }, + { + label: 'Effective Monitored System Limit', + value: + input.monitoredSystemContinuity.effective_limit > 0 + ? input.monitoredSystemContinuity.effective_limit + : 'Unlimited', + }, + ...(typeof input.monitoredSystemContinuity.grandfathered_floor === 'number' && + input.monitoredSystemContinuity.grandfathered_floor > 0 + ? [ + { + label: 'Grandfathered Floor', + value: input.monitoredSystemContinuity.grandfathered_floor, + }, + ] + : []), + { + label: 'Continuity Capture', + value: input.monitoredSystemContinuity.capture_pending + ? 'Pending' + : input.continuityCapturedAt || 'Captured', + }, + ] + : hasFiniteSelfHostedLimit(input.maxMonitoredSystems) + ? [ + { + label: 'Included Monitored Systems', + value: input.maxMonitoredSystems, + }, + ] + : []), + ], + }; +}; export const buildHostedCommercialPlanModel = ( input: HostedCommercialModelInput, diff --git a/frontend-modern/src/utils/selfHostedPlans.ts b/frontend-modern/src/utils/selfHostedPlans.ts index 8d7ffe7101..33b710f8c5 100644 --- a/frontend-modern/src/utils/selfHostedPlans.ts +++ b/frontend-modern/src/utils/selfHostedPlans.ts @@ -6,6 +6,7 @@ export interface SelfHostedPlanDefinition { price: string; subline: string; metricHistoryDays: number; + billingExtrasSummary: string; highlights: readonly string[]; } @@ -44,6 +45,7 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] = price: 'Free forever', subline: 'Unlimited self-hosted monitoring', metricHistoryDays: 7, + billingExtrasSummary: 'Patrol, alerts, and OIDC', highlights: [ 'Real-time monitoring', 'Unlimited self-hosted monitoring', @@ -61,6 +63,7 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] = price: '$4.99/month', subline: 'or $39/year', metricHistoryDays: 14, + billingExtrasSummary: 'Remote access, mobile, and push', highlights: [ 'Everything in Community', 'Remote access via Relay', @@ -76,6 +79,7 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] = price: '$8.99/month', subline: 'or $79/year', metricHistoryDays: 90, + billingExtrasSummary: 'AI operations and advanced admin', highlights: [ 'Everything in Relay', 'Patrol Auto-Fix & investigation', @@ -95,6 +99,22 @@ export const SELF_HOSTED_PLAN_BY_TIER: Record