mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Make self-hosted plan summaries entitlement-first
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<ProLicensePlanSectionProps> = (pro
|
||||
>
|
||||
{props.currentPlanSummary.statusLabel}
|
||||
</span>
|
||||
<For each={props.currentPlanSummary.supplementalBadges}>
|
||||
{(badge) => (
|
||||
<span class="px-2 py-1 text-xs font-medium rounded-full bg-surface text-base-content border border-border">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
<Show when={props.entitlements?.in_grace_period}>
|
||||
<span class="text-xs text-amber-700 dark:text-amber-300">
|
||||
Grace until {formatDate(props.entitlements?.grace_period_end)}
|
||||
@@ -131,6 +140,9 @@ export const ProLicensePlanSection: Component<ProLicensePlanSectionProps> = (pro
|
||||
</div>
|
||||
<p class="mt-3 text-lg font-semibold text-base-content">{props.currentPlanSummary.title}</p>
|
||||
<p class="mt-1 text-sm text-muted">{props.currentPlanSummary.body}</p>
|
||||
<Show when={props.currentPlanSummary.supplementalSummary}>
|
||||
{(summary) => <p class="mt-2 text-xs text-muted">{summary()}</p>}
|
||||
</Show>
|
||||
<Show when={props.currentPlanSummary.unlockedFeatures.length > 0}>
|
||||
<div class="mt-4">
|
||||
<p class="text-xs uppercase tracking-wide text-muted mb-2">Unlocked on this instance</p>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string, string> = {
|
||||
@@ -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<string, string> = {
|
||||
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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user