Use shared self-hosted plan metadata

Refs #1409
This commit is contained in:
rcourtman 2026-04-19 19:59:03 +01:00
parent 238545b207
commit f4feccc151
7 changed files with 322 additions and 107 deletions

View file

@ -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

View file

@ -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'],

View file

@ -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(),
}),

View file

@ -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');
});
});

View file

@ -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();
});
});

View file

@ -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,

View file

@ -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<SelfHostedTierKey, SelfHostedPlanD
pro: SELF_HOSTED_PLAN_DEFINITIONS[2],
};
export function getSelfHostedPlanDefinitionForBillingTier(
tier?: string | null,
): SelfHostedPlanDefinition | null {
switch ((tier || '').trim().toLowerCase()) {
case 'community':
case 'free':
return SELF_HOSTED_PLAN_BY_TIER.community;
case 'relay':
return SELF_HOSTED_PLAN_BY_TIER.relay;
case 'pro':
return SELF_HOSTED_PLAN_BY_TIER.pro;
default:
return null;
}
}
export const SELF_HOSTED_COMMERCIAL_PRESENTATION: SelfHostedCommercialPresentation = {
pageTitle: 'Pricing',
pageDescription: