mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Retire legacy uncapped Pulse Pro usage tab
This commit is contained in:
parent
303988e482
commit
3608bf34ff
6 changed files with 156 additions and 47 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue