Retire legacy uncapped Pulse Pro usage tab

This commit is contained in:
rcourtman 2026-04-19 13:00:29 +01:00
parent 303988e482
commit 3608bf34ff
6 changed files with 156 additions and 47 deletions

View file

@ -981,15 +981,19 @@ shared shell/model first instead of letting self-hosted Pulse Pro and hosted
organization billing drift back into parallel local layouts or vocabularies.
For self-hosted Pulse Pro specifically, the plan/usage split is now also a
router contract: `/settings/system/billing/plan` is the canonical plan state,
`/settings/system/billing/usage` is the canonical monitored-system usage state,
and `/settings/system/billing` remains a compatibility handoff rather than the
primary owned destination. Arrival-specific UI affordances belong to that same
owned billing surface: usage arrivals may open counting rules by default, and
plan arrivals may surface an upgrade callout that hands off into `Pulse Account`
for self-hosted plan comparison and checkout before returning through the
runtime-owned activation callback. Pulse product routes keep ownership of
license status, usage, and activation state; `Pulse Account` owns the commerce
flow itself.
`/settings/system/billing/usage` is the canonical monitored-system usage state
only when Pulse is still reconciling or enforcing a finite monitored-system
limit, and `/settings/system/billing` remains a compatibility handoff rather
than the primary owned destination. Uncapped current self-hosted Pulse Pro
plans must not keep a standing Usage subtab alive just to restate counted-unit
totals; direct `/usage` arrivals on those no-cap plans should canonicalize back
to `/plan`. Arrival-specific UI affordances belong to that same owned billing
surface: legacy usage arrivals may open counting rules by default, and plan
arrivals may surface an upgrade callout that hands off into `Pulse Account`
for self-hosted plan comparison and checkout before returning to the owned
plan state through the runtime-owned activation callback. Pulse product routes
keep ownership of license status, usage, and activation state; `Pulse Account`
owns the commerce flow itself.
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
@ -1234,11 +1238,11 @@ as bounded migration continuity, grandfathered floors, or other explicit
carry-forward limits. Uncapped self-hosted plans should not keep a
`Monitoring capacity` section alive just to restate that monitoring is
unlimited; those plan surfaces should describe core monitoring as unlimited in
their summary model and leave counted-unit explanation plus current usage
inspection to the usage-owned ledger/disclosure path. When a finite plan is
full, the section must explain that existing monitoring continues while new
monitored systems are blocked; when an installation is already above the
current plan, it must explain that Pulse is in an over-plan frozen state
their summary model and reserve counted-unit explanation plus current usage
inspection for the bounded legacy usage ledger/disclosure path. When a finite
plan is full, the section must explain that existing monitoring continues
while new monitored systems are blocked; when an installation is already above
the current plan, it must explain that Pulse is in an over-plan frozen state
rather than implying a hard runtime blackout.
Community overflow/setup-slot messaging must still explain the included
monitored systems plus the temporary setup slot in customer terms rather than

View file

@ -82,10 +82,14 @@ const ProLicensePanelContent: Component = () => {
value: 'plan',
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.planTabLabel,
},
{
value: 'usage',
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.usageTabLabel,
},
...(state.showUsageSection()
? [
{
value: 'usage',
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.usageTabLabel,
},
]
: []),
]}
/>
@ -132,7 +136,7 @@ const ProLicensePanelContent: Component = () => {
</CommercialSection>
</Show>
<Show when={state.activeSection() === 'usage'}>
<Show when={state.showUsageSection() && state.activeSection() === 'usage'}>
<CommercialSection
id={SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID}
title={SELF_HOSTED_PRO_BILLING_PRESENTATION.usageSectionTitle}

View file

@ -23,6 +23,7 @@ import {
SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID,
SELF_HOSTED_PRO_BILLING_USAGE_HREF,
SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID,
SELF_HOSTED_PRO_BILLING_PLAN_ROUTE,
} from '@/utils/pricingHandoff';
let mockEntitlements: LicenseEntitlements | null = null;
@ -193,6 +194,7 @@ describe('ProLicensePanel', () => {
});
expect(screen.getByText('Pulse Pro')).toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'Usage' })).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /start 14-day pro trial/i }),
).not.toBeInTheDocument();
@ -309,6 +311,7 @@ describe('ProLicensePanel', () => {
expect(screen.queryByText('Monitored Systems')).not.toBeInTheDocument();
expect(screen.queryByText('Capacity Status')).not.toBeInTheDocument();
expect(screen.queryByText('Monitoring capacity')).not.toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'Usage' })).not.toBeInTheDocument();
expect(screen.getAllByText('Unlimited').length).toBeGreaterThan(0);
expect(screen.queryByText('5 / 12')).not.toBeInTheDocument();
});
@ -362,6 +365,7 @@ describe('ProLicensePanel', () => {
expect(screen.queryByText('Included Monitored Systems')).not.toBeInTheDocument();
expect(screen.getByText('Max Guests')).toBeInTheDocument();
expect(screen.queryByText('Monitoring capacity')).not.toBeInTheDocument();
expect(screen.queryByRole('tab', { name: 'Usage' })).not.toBeInTheDocument();
expect(screen.getAllByText('Unlimited').length).toBeGreaterThan(0);
expect(screen.queryByText('Plan Monitored System Limit')).not.toBeInTheDocument();
@ -690,7 +694,7 @@ describe('ProLicensePanel', () => {
},
);
it('shows a monitored-system activation success action and suppresses the compare-plans arrival callout', async () => {
it('returns self-hosted plan purchases to the plan surface instead of the legacy usage tab', async () => {
useLocationMock.mockReturnValue({
search: `?purchase=${SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED}&intent=${SELF_HOSTED_PRO_BILLING_MONITORED_SYSTEM_INTENT}`,
pathname: '/settings/system/billing/plan',
@ -700,9 +704,9 @@ describe('ProLicensePanel', () => {
renderPanel();
expect(screen.getByText('Pulse Pro activated')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Review usage' })).toHaveAttribute(
expect(screen.getByRole('link', { name: 'Review plan' })).toHaveAttribute(
'href',
SELF_HOSTED_PRO_BILLING_USAGE_HREF,
SELF_HOSTED_PRO_BILLING_PLAN_HREF,
);
expect(screen.queryByText('Compare self-hosted plans')).not.toBeInTheDocument();
});
@ -720,7 +724,17 @@ describe('ProLicensePanel', () => {
expect(recoveryDisclosure).toHaveAttribute('open');
});
it('focuses the usage billing section when the usage route requests counting rules', async () => {
it('focuses the usage billing section when a bounded legacy billing route requests counting rules', async () => {
mockEntitlements = {
capabilities: ['relay'],
limits: [{ key: 'max_monitored_systems', limit: 10, current: 5, state: 'ok' }],
subscription_state: 'active',
upgrade_reasons: [],
tier: 'pro',
plan_version: 'legacy_migration_fallback',
licensed_email: 'owner@example.com',
trial_eligible: false,
};
useLocationMock.mockReturnValue({
search: '?details=counting-rules',
pathname: '/settings/system/billing/usage',
@ -738,6 +752,28 @@ describe('ProLicensePanel', () => {
expect(screen.getByRole('button', { name: 'Hide counting rules' })).toBeInTheDocument();
});
it('redirects uncapped self-hosted billing usage routes back to the plan surface', async () => {
useLocationMock.mockReturnValue({
search: '?details=counting-rules',
pathname: '/settings/system/billing/usage',
hash: '',
});
renderPanel();
await waitFor(() => {
expect(loadLicenseEntitlementsMock).toHaveBeenCalled();
});
expect(screen.queryByRole('tab', { name: 'Usage' })).not.toBeInTheDocument();
expect(document.getElementById(SELF_HOSTED_PRO_BILLING_USAGE_SECTION_ID)).not.toBeInTheDocument();
expect(document.getElementById(SELF_HOSTED_PRO_BILLING_PLAN_SECTION_ID)).toBeInTheDocument();
expect(navigateMock).toHaveBeenCalledWith(SELF_HOSTED_PRO_BILLING_PLAN_ROUTE, {
replace: true,
scroll: false,
});
});
it('does not render a monitored-system upgrade arrival callout on the plan upgrade route', async () => {
useLocationMock.mockReturnValue({
search: `?intent=${SELF_HOSTED_PRO_BILLING_MONITORED_SYSTEM_INTENT}`,
@ -761,7 +797,17 @@ describe('ProLicensePanel', () => {
expect(screen.queryByText('Monitoring capacity')).not.toBeInTheDocument();
});
it('navigates between plan and usage focus states through the billing subtabs', async () => {
it('navigates between plan and usage focus states through the billing subtabs when legacy capacity handling is active', async () => {
mockEntitlements = {
capabilities: ['relay'],
limits: [{ key: 'max_monitored_systems', limit: 10, current: 5, state: 'ok' }],
subscription_state: 'active',
upgrade_reasons: [],
tier: 'pro',
plan_version: 'legacy_migration_fallback',
licensed_email: 'owner@example.com',
trial_eligible: false,
};
useLocationMock.mockReturnValue({
search: '',
pathname: '/settings/system/billing/plan',

View file

@ -19,7 +19,7 @@ export interface SelfHostedProBillingPresentation {
monitoredSystemUpgradeArrivalTitle: string;
monitoredSystemUpgradeArrivalBody: string;
monitoredSystemUpgradeArrivalActionLabel: string;
purchaseActivatedUsageActionLabel: string;
purchaseActivatedPlanActionLabel: string;
purchaseCancelledActionLabel: string;
purchaseExpiredActionLabel: string;
purchaseFailedActionLabel: string;
@ -59,7 +59,7 @@ export const SELF_HOSTED_PRO_BILLING_PRESENTATION: SelfHostedProBillingPresentat
monitoredSystemUpgradeArrivalBody:
'Community keeps core monitoring free. Compare Relay and Pro in Pulse Account, then return here with Pulse Pro activated automatically.',
monitoredSystemUpgradeArrivalActionLabel: 'Compare plans',
purchaseActivatedUsageActionLabel: 'Review usage',
purchaseActivatedPlanActionLabel: 'Review plan',
purchaseCancelledActionLabel: 'Compare plans',
purchaseExpiredActionLabel: 'Restart upgrade',
purchaseFailedActionLabel: 'Open recovery',

View file

@ -1,4 +1,4 @@
import { createMemo, createSignal, onMount } from 'solid-js';
import { createEffect, createMemo, createSignal, onMount } from 'solid-js';
import { useLocation, useNavigate } from '@solidjs/router';
import { notificationStore } from '@/stores/notifications';
import { loadCommercialPosture } from '@/stores/licenseCommercial';
@ -40,6 +40,7 @@ import {
SELF_HOSTED_PRO_BILLING_PURCHASE_FAILED,
SELF_HOSTED_PRO_BILLING_PURCHASE_UNAVAILABLE,
SELF_HOSTED_PRO_BILLING_PLAN_DETAILS_QUERY_PARAM,
SELF_HOSTED_PRO_BILLING_PLAN_HREF,
SELF_HOSTED_PRO_BILLING_PLAN_ROUTE,
SELF_HOSTED_PRO_BILLING_PURCHASE_QUERY_PARAM,
SELF_HOSTED_PRO_BILLING_USAGE_HREF,
@ -76,6 +77,9 @@ export function useProLicensePanelState() {
const navigate = useNavigate();
const [licenseKey, setLicenseKey] = createSignal('');
const [loading, setLoading] = createSignal(false);
const [panelDataSettled, setPanelDataSettled] = createSignal(
Boolean(licenseEntitlements() || licenseEntitlementsLoadError()),
);
const [activating, setActivating] = createSignal(false);
const [clearing, setClearing] = createSignal(false);
const [startingTrial, setStartingTrial] = createSignal(false);
@ -89,8 +93,12 @@ export function useProLicensePanelState() {
const loadPanelData = async () => {
setLoading(true);
await loadLicenseEntitlements(true);
setLoading(false);
try {
await loadLicenseEntitlements(true);
} finally {
setLoading(false);
setPanelDataSettled(true);
}
};
onMount(() => {
@ -122,28 +130,80 @@ export function useProLicensePanelState() {
void loadPanelData();
});
const requestedSection = createMemo<SelfHostedBillingSection>(() =>
resolveSelfHostedBillingSection(location.pathname, location.search, location.hash),
);
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 showUsageSection = createMemo(() => {
if (!panelDataSettled()) {
return true;
}
const continuity = monitoredSystemContinuity();
if (continuity) {
if (continuity.capture_pending) {
return true;
}
if (typeof continuity.plan_limit === 'number' && continuity.plan_limit > 0) {
return true;
}
if (typeof continuity.effective_limit === 'number' && continuity.effective_limit > 0) {
return true;
}
if (
typeof continuity.grandfathered_floor === 'number' &&
continuity.grandfathered_floor > 0
) {
return true;
}
}
const resolved = resolveMonitoredSystemCapacityStatus(
monitoredSystemCapacity(),
monitoredSystemLimitStatus(),
);
return Boolean(resolved && resolved.limit > 0);
});
const activeSection = createMemo<SelfHostedBillingSection>(() => {
return resolveSelfHostedBillingSection(location.pathname, location.search, location.hash);
if (!panelDataSettled()) {
return requestedSection();
}
if (requestedSection() === 'usage' && !showUsageSection()) {
return 'plan';
}
return requestedSection();
});
createEffect(() => {
if (!panelDataSettled()) {
return;
}
if (requestedSection() !== 'usage' || showUsageSection()) {
return;
}
navigate(SELF_HOSTED_PRO_BILLING_PLAN_ROUTE, { replace: true, scroll: false });
});
const setActiveSection = (section: string) => {
if (section !== 'plan' && section !== 'usage') {
return;
}
if (section === 'usage' && panelDataSettled() && !showUsageSection()) {
navigate(SELF_HOSTED_PRO_BILLING_PLAN_ROUTE, { replace: false, scroll: false });
return;
}
const nextPath =
section === 'usage'
? SELF_HOSTED_PRO_BILLING_USAGE_ROUTE
: SELF_HOSTED_PRO_BILLING_PLAN_ROUTE;
navigate(nextPath, { replace: false, scroll: false });
};
const showMonitoredSystemUpgradeArrival = createMemo(
() =>
!purchaseActivationResult() &&
activeSection() === 'plan' &&
getSelfHostedBillingPlanIntent(location.search) ===
SELF_HOSTED_PRO_BILLING_MONITORED_SYSTEM_INTENT,
);
const showCountingRulesByDefault = createMemo(
() =>
activeSection() === 'usage' &&
@ -246,10 +306,6 @@ export function useProLicensePanelState() {
return segments.length === 3 && segments.every((segment) => segment.length > 0);
});
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 uncappedGrandfatheredPlan = createMemo(() =>
isUncappedGrandfatheredPlanVersion(entitlements()?.plan_version, entitlements()?.is_lifetime),
);
@ -272,7 +328,6 @@ export function useProLicensePanelState() {
const monitoredSystemCapacityPosture = createMemo(() =>
resolveMonitoredSystemCapacityStatus(monitoredSystemCapacity(), monitoredSystemLimitStatus()),
);
const monitoredSystemContinuity = createMemo(() => entitlements()?.monitored_system_continuity);
const monitoredSystemContinuityNotice = createMemo(() =>
getMonitoredSystemContinuityNotice(
monitoredSystemContinuity(),
@ -332,8 +387,8 @@ export function useProLicensePanelState() {
case SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED:
if (intent === SELF_HOSTED_PRO_BILLING_MONITORED_SYSTEM_INTENT) {
return {
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.purchaseActivatedUsageActionLabel,
destination: resolveUpgradeDestination(SELF_HOSTED_PRO_BILLING_USAGE_HREF),
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.purchaseActivatedPlanActionLabel,
destination: resolveUpgradeDestination(SELF_HOSTED_PRO_BILLING_PLAN_HREF),
};
}
return null;
@ -481,8 +536,8 @@ export function useProLicensePanelState() {
purchaseActivationAction,
setActiveSection,
setLicenseKey,
showUsageSection,
showCountingRulesByDefault,
showMonitoredSystemUpgradeArrival,
showRecoveryByDefault,
showTrialStart,
startingTrial,

View file

@ -89,7 +89,7 @@ describe('licensePresentation', () => {
monitoredSystemUpgradeArrivalBody:
'Community keeps core monitoring free. Compare Relay and Pro in Pulse Account, then return here with Pulse Pro activated automatically.',
monitoredSystemUpgradeArrivalActionLabel: 'Compare plans',
purchaseActivatedUsageActionLabel: 'Review usage',
purchaseActivatedPlanActionLabel: 'Review plan',
purchaseCancelledActionLabel: 'Compare plans',
purchaseExpiredActionLabel: 'Restart upgrade',
purchaseFailedActionLabel: 'Open recovery',