Protect grandfathered Pro plan presentation

This commit is contained in:
rcourtman 2026-04-26 21:26:30 +01:00
parent 430f0d3fa2
commit 68eefb2b5f
7 changed files with 242 additions and 50 deletions

View file

@ -143,13 +143,9 @@ const ProLicensePanelContent: Component = () => {
>
<MonitoredSystemLedgerPanel
embedded
monitoredSystemCapacity={state.entitlements()?.monitored_system_capacity}
monitoredSystemContinuity={state.entitlements()?.monitored_system_continuity}
monitoredSystemLimit={
state
.entitlements()
?.limits?.find((entry) => entry.key === 'max_monitored_systems') ?? null
}
monitoredSystemCapacity={state.monitoredSystemCapacity()}
monitoredSystemContinuity={state.displayableMonitoredSystemContinuity()}
monitoredSystemLimit={state.monitoredSystemLimitStatus() ?? null}
showCountingRulesByDefault={state.showCountingRulesByDefault()}
/>
</CommercialSection>

View file

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

View file

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

View file

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

View file

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

View file

@ -35,6 +35,9 @@ const TIER_LABELS: Record<string, string> = {
const SELF_HOSTED_PLAN_LABELS: Record<string, string> = {
pro: 'Pulse Pro',
pro_annual: 'Pulse Pro Annual',
pro_plus: 'Pulse Pro+',
lifetime: 'Pulse Pro Lifetime',
};
const FEATURE_MIN_TIER_LABELS: Record<string, string> = {
@ -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<typeof getSelfHostedPlanDefinitionForBillingTier>,
): 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,

View file

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