mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
Clarify commercial offer boundaries
This commit is contained in:
@@ -4713,6 +4713,12 @@
|
||||
"path": "docs/release-control/v6/internal/subsystems/relay-runtime.md",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts",
|
||||
"kind": "file",
|
||||
"evidence_tier": "test-proof"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/api/contract_test.go",
|
||||
@@ -4737,6 +4743,12 @@
|
||||
"kind": "file",
|
||||
"evidence_tier": "test-proof"
|
||||
},
|
||||
{
|
||||
"repo": "pulse-pro",
|
||||
"path": "license-server/main_test.go",
|
||||
"kind": "file",
|
||||
"evidence_tier": "test-proof"
|
||||
},
|
||||
{
|
||||
"repo": "pulse-pro",
|
||||
"path": "license-server/v6_commercial_transitions_test.go",
|
||||
@@ -4772,6 +4784,12 @@
|
||||
"path": "relay-server/revocation_feed_test.go",
|
||||
"kind": "file",
|
||||
"evidence_tier": "test-proof"
|
||||
},
|
||||
{
|
||||
"repo": "pulse-pro",
|
||||
"path": "scripts/tests/test_validate_stripe_catalog.py",
|
||||
"kind": "file",
|
||||
"evidence_tier": "test-proof"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -222,6 +222,8 @@ avoids a cloud-control-plane report data path across clients.
|
||||
82. `pulse-pro:relay-server/main.go`
|
||||
83. `pulse-pro:relay-server/registry.go`
|
||||
84. `pulse-pro:relay-server/revocation_feed.go`
|
||||
85. `pulse-pro:landing-page/index.html`
|
||||
86. `pulse-pro:scripts/validate_stripe_catalog.py`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
@@ -3057,6 +3059,25 @@ Community copy may mention provider/local-model Patrol, but must not present
|
||||
hosted-model credits, account-backed AI access, or trial acquisition as a
|
||||
default self-hosted benefit or a reason to put a paid prompt in front of
|
||||
ordinary users.
|
||||
The public offer, Pulse settings presentation, and Pulse Account handoff must
|
||||
also describe Community, Relay, and Pro as distinct jobs rather than an
|
||||
artificial feature ladder. Relay is the secure-access product. Pro is the
|
||||
Patrol-powered operations product and explicitly includes Relay connectivity,
|
||||
Pulse Mobile pairing, and push notifications. Retired Pro trial acquisition
|
||||
copy and dormant Cloud checkout catalogs must not reappear in the ordinary
|
||||
self-hosted journey; historical Cloud plan identifiers may remain only as
|
||||
customer-state labels.
|
||||
The Stripe billing portal is limited to invoices and payment methods. Every
|
||||
portal-session request must name an explicit governed
|
||||
`STRIPE_BILLING_PORTAL_CONFIGURATION_ID` beginning with `bpc_`; a missing or
|
||||
invalid configuration fails closed. That Stripe configuration must keep
|
||||
subscription plan updates disabled because Relay/Pro product and cadence
|
||||
transitions stay in the Pulse-owned verified transition authority. Production
|
||||
readiness therefore requires a read-only external proof that the configured
|
||||
portal exposes only that intended scope before this commercial gate can pass.
|
||||
The public Stripe catalog audit must also enforce product descriptions that
|
||||
name the one owner-operated environment boundary and state that Pro includes
|
||||
Relay, so checkout cannot contradict the canonical package model.
|
||||
That public-demo commercial boundary also owns monitored-system preview
|
||||
unavailability wording. Browser presentation may keep the unavailable reason
|
||||
nullable until the formatting edge, but it must normalize the message through
|
||||
|
||||
@@ -2869,6 +2869,10 @@ handoff remains limited to explicit Plans, hosted, activation, recovery, or
|
||||
support surfaces; feature gates may show neutral "View plans" links through
|
||||
`frontend-modern/src/utils/upgradePresentation.ts` only where presentation
|
||||
policy allows commercial discovery.
|
||||
The shared self-hosted billing presentation and plan-section prop contract must
|
||||
therefore expose only the ordinary typed plan handoff. It must not retain a
|
||||
parallel trial label, card-required note, `trial=1` destination, or dormant
|
||||
trial-action slot after self-hosted trial acquisition has been retired.
|
||||
Top-level route files are now expected to stay thin when a feature owns the
|
||||
real product surface, but the former `/infrastructure` surface is not one of
|
||||
those compatibility cases. It never shipped as a stable v6 route, so
|
||||
|
||||
@@ -2815,6 +2815,7 @@
|
||||
"internal/cloudcp/routes.go",
|
||||
"internal/cloudcp/stripe/provisioner.go",
|
||||
"internal/hosted/provisioner.go",
|
||||
"pulse-pro:landing-page/index.html",
|
||||
"pulse-pro:landing-page/manage.html",
|
||||
"pulse-pro:landing-page/thanks.html",
|
||||
"pulse-pro:license-server/admin_ui.go",
|
||||
@@ -2831,7 +2832,8 @@
|
||||
"pulse-pro:relay-server/main.go",
|
||||
"pulse-pro:relay-server/registry.go",
|
||||
"pulse-pro:relay-server/revocation_feed.go",
|
||||
"pulse-pro:scripts/grandfathered_recurring_cutover_preview.py"
|
||||
"pulse-pro:scripts/grandfathered_recurring_cutover_preview.py",
|
||||
"pulse-pro:scripts/validate_stripe_catalog.py"
|
||||
],
|
||||
"verification": {
|
||||
"allow_same_subsystem_tests": true,
|
||||
@@ -2938,6 +2940,20 @@
|
||||
"pulse-pro:license-server/v6_store_test.go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "public-commercial-offer-surface",
|
||||
"label": "public commercial offer surface proof",
|
||||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
"pulse-pro:landing-page/index.html"
|
||||
],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"pulse-pro:scripts/tests/test_customer_facing_commercial_copy.py",
|
||||
"pulse-pro:scripts/tests/test_landing_page_localization.py"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "public-commercial-management-surface",
|
||||
"label": "public commercial management surface proof",
|
||||
@@ -2951,6 +2967,19 @@
|
||||
"pulse-pro:scripts/tests/test_customer_facing_commercial_copy.py"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "public-stripe-catalog-contract",
|
||||
"label": "public Stripe catalog contract proof",
|
||||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
"pulse-pro:scripts/validate_stripe_catalog.py"
|
||||
],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"pulse-pro:scripts/tests/test_validate_stripe_catalog.py"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "stripe-plan-derivation",
|
||||
"label": "stripe plan derivation proof",
|
||||
@@ -3602,6 +3631,20 @@
|
||||
"pulse-pro:relay-server/revocation_feed_test.go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "stripe-billing-portal-session",
|
||||
"label": "Stripe billing portal session scope proof",
|
||||
"match_prefixes": [],
|
||||
"match_files": [
|
||||
"pulse-pro:license-server/main.go"
|
||||
],
|
||||
"allow_same_subsystem_tests": false,
|
||||
"test_prefixes": [],
|
||||
"exact_files": [
|
||||
"pulse-pro:license-server/commercial_strict_json_test.go",
|
||||
"pulse-pro:license-server/main_test.go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "legacy-grandfathering-cutover",
|
||||
"label": "legacy recurring grandfathering cutover proof",
|
||||
|
||||
@@ -79,11 +79,6 @@ interface ProLicensePlanSectionProps {
|
||||
label: string;
|
||||
destination: UpgradeDestination;
|
||||
} | null;
|
||||
trialAction: {
|
||||
label: string;
|
||||
note: string;
|
||||
destination: UpgradeDestination;
|
||||
} | null;
|
||||
};
|
||||
planSelectionPrompt: ActionNotice | null;
|
||||
planStatus: {
|
||||
@@ -379,18 +374,6 @@ export const ProLicensePlanSection: Component<ProLicensePlanSectionProps> = (pro
|
||||
</For>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Show when={props.planComparisonSummary.trialAction}>
|
||||
{(trialAction) => (
|
||||
<UpgradeButtonLink
|
||||
variant="primary"
|
||||
size="settingsActionXs"
|
||||
class="mt-4 gap-1"
|
||||
destination={trialAction().destination}
|
||||
>
|
||||
{trialAction().label}
|
||||
</UpgradeButtonLink>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.planComparisonSummary.action}>
|
||||
{(action) => (
|
||||
<UpgradeButtonLink
|
||||
@@ -404,9 +387,6 @@ export const ProLicensePlanSection: Component<ProLicensePlanSectionProps> = (pro
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.planComparisonSummary.trialAction}>
|
||||
{(trialAction) => <p class="mt-2 text-xs text-muted">{trialAction().note}</p>}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.commercialMigrationNotice}>
|
||||
|
||||
@@ -398,7 +398,9 @@ describe('ProLicensePanel', () => {
|
||||
|
||||
expect(screen.getAllByText('Trial').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('7')).toBeInTheDocument();
|
||||
expect(screen.getByText('Patrol Applies Safe Fixes and Verifies the Result')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Patrol Applies Safe Fixes and Verifies the Result'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the Patrol mode entry point visible for existing active Pro plans', async () => {
|
||||
@@ -587,9 +589,7 @@ describe('ProLicensePanel', () => {
|
||||
expect(screen.queryByText('Patrol autonomy loop')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Decision recorded')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Pulse Pro is active on this instance. Review the current Patrol decision.',
|
||||
),
|
||||
screen.getByText('Pulse Pro is active on this instance. Review the current Patrol decision.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/External-agent setup/i)).not.toBeInTheDocument();
|
||||
const patrolControlLink = screen.getByRole('link', { name: 'Review Patrol decision' });
|
||||
@@ -712,9 +712,7 @@ describe('ProLicensePanel', () => {
|
||||
});
|
||||
|
||||
expect(screen.getByText('V5 Lifetime Grandfathered')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Current tier and enabled capabilities.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Current tier and enabled capabilities.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Included Monitored Systems')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Guest Capacity')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Core Monitoring')).toBeInTheDocument();
|
||||
@@ -832,12 +830,16 @@ describe('ProLicensePanel', () => {
|
||||
expect(screen.getByText('Current plan: Pulse Pro')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Pulse Pro is active on this instance. It includes Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.',
|
||||
'Pulse Pro is active on this instance. It includes Relay connectivity, Pulse Mobile pairing, push notifications, Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Primary capabilities')).toBeInTheDocument();
|
||||
expect(screen.getByText('Patrol Investigates Issues and Explains the Root Cause')).toBeInTheDocument();
|
||||
expect(screen.getByText('Patrol Applies Safe Fixes and Verifies the Result')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Patrol Investigates Issues and Explains the Root Cause'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Patrol Applies Safe Fixes and Verifies the Result'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Included extras')).toBeInTheDocument();
|
||||
expect(screen.getByText('Capability details')).toBeInTheDocument();
|
||||
expect(screen.getByText('(5 items ready)')).toBeInTheDocument();
|
||||
@@ -1085,8 +1087,12 @@ describe('ProLicensePanel', () => {
|
||||
|
||||
// Verify self-hosted displayable capabilities render with their expected labels.
|
||||
expect(screen.getByText('Pulse Patrol')).toBeInTheDocument();
|
||||
expect(screen.getByText('Patrol Investigates Issues and Explains the Root Cause')).toBeInTheDocument();
|
||||
expect(screen.getByText('Patrol Applies Safe Fixes and Verifies the Result')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Patrol Investigates Issues and Explains the Root Cause'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Patrol Applies Safe Fixes and Verifies the Result'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Update Alerts')).toBeInTheDocument();
|
||||
expect(screen.getByText('Role-Based Access Control (RBAC)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Audit Logging')).toBeInTheDocument();
|
||||
@@ -1190,12 +1196,12 @@ describe('ProLicensePanel', () => {
|
||||
expect(screen.getByText(title)).toBeInTheDocument();
|
||||
if (purchase === SELF_HOSTED_PRO_BILLING_PURCHASE_ACTIVATED) {
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Checkout completed and Pulse Pro is active\. Choose Patrol mode\./i,
|
||||
),
|
||||
screen.getByText(/Checkout completed and Pulse Pro is active\. Choose Patrol mode\./i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Available now on this instance')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Patrol Applies Safe Fixes and Verifies the Result').length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getAllByText('Patrol Applies Safe Fixes and Verifies the Result').length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(screen.getAllByRole('link', { name: 'Choose Patrol mode' })).toHaveLength(1);
|
||||
const patrolControlLink = screen.getByRole('link', { name: 'Choose Patrol mode' });
|
||||
expect(patrolControlLink).toHaveAttribute('href', PATROL_CONTROL_STARTER_URL);
|
||||
@@ -1280,12 +1286,12 @@ describe('ProLicensePanel', () => {
|
||||
|
||||
expect(screen.getByText('Pulse Pro is now active')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Checkout completed and Pulse Pro is active\. Choose Patrol mode\./i,
|
||||
),
|
||||
screen.getByText(/Checkout completed and Pulse Pro is active\. Choose Patrol mode\./i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Select a plan')).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText('Patrol Applies Safe Fixes and Verifies the Result').length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getAllByText('Patrol Applies Safe Fixes and Verifies the Result').length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('Available plans')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Review plan' })).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -1305,12 +1311,12 @@ describe('ProLicensePanel', () => {
|
||||
|
||||
expect(screen.queryByText('Pulse Pro is now active')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
/Checkout completed and Pulse Pro is active\. Choose Patrol mode\./i,
|
||||
),
|
||||
screen.queryByText(/Checkout completed and Pulse Pro is active\. Choose Patrol mode\./i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Current plan: Community')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Patrol Applies Safe Fixes and Verifies the Result')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Patrol Applies Safe Fixes and Verifies the Result'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Available now on this instance')).not.toBeInTheDocument();
|
||||
expect(navigateMock).toHaveBeenCalledWith(SELF_HOSTED_PRO_BILLING_PLAN_HREF, {
|
||||
replace: true,
|
||||
|
||||
+8
-41
@@ -39,10 +39,10 @@ const renderInRouter = (component: Component) => {
|
||||
));
|
||||
};
|
||||
|
||||
describe('ProLicensePlanSection trial action', () => {
|
||||
describe('ProLicensePlanSection retired trial acquisition', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('renders the trial button with its card-required note and trial link', () => {
|
||||
it('renders the ordinary plan handoff without a trial action', () => {
|
||||
renderInRouter(() => (
|
||||
<ProLicensePlanSection
|
||||
{...baseProps()}
|
||||
@@ -54,49 +54,16 @@ describe('ProLicensePlanSection trial action', () => {
|
||||
'/auth/license-purchase-start?feature=self_hosted_plan',
|
||||
),
|
||||
},
|
||||
trialAction: {
|
||||
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonTrialActionLabel,
|
||||
note: SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonTrialActionNote,
|
||||
destination: resolveUpgradeDestination(
|
||||
'/auth/license-purchase-start?trial=1&feature=self_hosted_plan',
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
||||
const trialLink = screen.getByRole('link', {
|
||||
name: SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonTrialActionLabel,
|
||||
const plansLink = screen.getByRole('link', {
|
||||
name: SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonActionLabel,
|
||||
});
|
||||
expect(trialLink.getAttribute('href')).toContain('trial=1');
|
||||
expect(trialLink.getAttribute('href')).toContain('feature=self_hosted_plan');
|
||||
expect(
|
||||
screen.getByText(SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonTrialActionNote),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders no trial button when trialAction is null', () => {
|
||||
renderInRouter(() => (
|
||||
<ProLicensePlanSection
|
||||
{...baseProps()}
|
||||
planComparisonSummary={{
|
||||
cards: [{ title: 'Pro', body: 'Everything', highlights: [] }],
|
||||
action: {
|
||||
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonActionLabel,
|
||||
destination: resolveUpgradeDestination(
|
||||
'/auth/license-purchase-start?feature=self_hosted_plan',
|
||||
),
|
||||
},
|
||||
trialAction: null,
|
||||
}}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(
|
||||
screen.queryByText(SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonTrialActionLabel),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.queryByText(SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonTrialActionNote),
|
||||
).toBeNull();
|
||||
expect(plansLink.getAttribute('href')).toContain('feature=self_hosted_plan');
|
||||
expect(plansLink.getAttribute('href')).not.toContain('trial=1');
|
||||
expect(screen.queryByText(/free pro trial/i)).toBeNull();
|
||||
expect(screen.queryByText(/card required/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,6 @@ export interface SelfHostedProBillingPresentation {
|
||||
planSectionDescription: string;
|
||||
planComparisonSectionTitle: string;
|
||||
planComparisonActionLabel: string;
|
||||
planComparisonTrialActionLabel: string;
|
||||
planComparisonTrialActionNote: string;
|
||||
usageSectionTitle: string;
|
||||
hiddenShellTitle: string;
|
||||
hiddenShellDescription: string;
|
||||
@@ -48,9 +46,6 @@ export const SELF_HOSTED_PRO_BILLING_PRESENTATION: SelfHostedProBillingPresentat
|
||||
planSectionDescription: 'Current tier and enabled capabilities.',
|
||||
planComparisonSectionTitle: 'Available plans',
|
||||
planComparisonActionLabel: 'View plans',
|
||||
planComparisonTrialActionLabel: 'Start 14-day free Pro trial',
|
||||
planComparisonTrialActionNote:
|
||||
'Card required. You will not be charged if you cancel during the trial.',
|
||||
usageSectionTitle: 'Usage',
|
||||
hiddenShellTitle: 'Demo mode',
|
||||
hiddenShellDescription: 'Commercial settings are hidden for this session.',
|
||||
|
||||
@@ -394,19 +394,9 @@ export function useProLicensePanelState() {
|
||||
};
|
||||
});
|
||||
const planStatus = createMemo(() => getSelfHostedPlanStatusPresentation(entitlements()));
|
||||
const trialActionEligible = createMemo(() => {
|
||||
const current = entitlements();
|
||||
const state = subscriptionState();
|
||||
if (state === 'active' || state === 'trial') return false;
|
||||
if (current?.trial_expires_at) return false;
|
||||
if (current?.trial_eligible === false) return false;
|
||||
const tier = current?.tier;
|
||||
return !tier || tier === 'free';
|
||||
});
|
||||
|
||||
const planComparisonSummary = createMemo(() => {
|
||||
if (!showPlanSelectionPrompt()) {
|
||||
return { cards: [], action: null, trialAction: null };
|
||||
return { cards: [], action: null };
|
||||
}
|
||||
const comparison = getSelfHostedPlanComparisonPresentation({
|
||||
entitlements: entitlements(),
|
||||
@@ -423,17 +413,6 @@ export function useProLicensePanelState() {
|
||||
),
|
||||
}
|
||||
: null,
|
||||
trialAction:
|
||||
showActions && trialActionEligible()
|
||||
? {
|
||||
label: SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonTrialActionLabel,
|
||||
note: SELF_HOSTED_PRO_BILLING_PRESENTATION.planComparisonTrialActionNote,
|
||||
destination: resolveSelfHostedPurchaseStartDestination(
|
||||
SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_INTENT,
|
||||
new URLSearchParams({ trial: '1' }),
|
||||
),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
CLOUD_PLAN_DEFINITIONS,
|
||||
CLOUD_PLAN_LABELS,
|
||||
DEFAULT_CLOUD_TIER,
|
||||
parseCloudTier,
|
||||
} from '@/utils/cloudPlans';
|
||||
|
||||
describe('parseCloudTier', () => {
|
||||
it('returns each tier key when given its canonical lowercase name', () => {
|
||||
expect(parseCloudTier('starter')).toBe('starter');
|
||||
expect(parseCloudTier('power')).toBe('power');
|
||||
expect(parseCloudTier('max')).toBe('max');
|
||||
});
|
||||
|
||||
it('normalizes mixed-case input through toLowerCase', () => {
|
||||
expect(parseCloudTier('STARTER')).toBe('starter');
|
||||
expect(parseCloudTier('Power')).toBe('power');
|
||||
expect(parseCloudTier('MAX')).toBe('max');
|
||||
expect(parseCloudTier('MaX')).toBe('max');
|
||||
expect(parseCloudTier('sTaRtEr')).toBe('starter');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace before matching', () => {
|
||||
expect(parseCloudTier(' power ')).toBe('power');
|
||||
expect(parseCloudTier('\tmax\n')).toBe('max');
|
||||
expect(parseCloudTier(' starter ')).toBe('starter');
|
||||
expect(parseCloudTier('\t POWER \n')).toBe('power');
|
||||
});
|
||||
|
||||
it('falls back to DEFAULT_CLOUD_TIER for empty, whitespace-only, null, and undefined', () => {
|
||||
expect(parseCloudTier('')).toBe(DEFAULT_CLOUD_TIER);
|
||||
expect(parseCloudTier(' ')).toBe(DEFAULT_CLOUD_TIER);
|
||||
expect(parseCloudTier('\t\n')).toBe(DEFAULT_CLOUD_TIER);
|
||||
expect(parseCloudTier(null)).toBe(DEFAULT_CLOUD_TIER);
|
||||
expect(parseCloudTier(undefined)).toBe(DEFAULT_CLOUD_TIER);
|
||||
});
|
||||
|
||||
it('falls back to the starter tier for any unrecognized non-empty string', () => {
|
||||
expect(parseCloudTier('enterprise')).toBe('starter');
|
||||
expect(parseCloudTier('free')).toBe('starter');
|
||||
expect(parseCloudTier('foo')).toBe('starter');
|
||||
expect(parseCloudTier('starter-annual')).toBe('starter');
|
||||
expect(parseCloudTier('power_pro')).toBe('starter');
|
||||
expect(parseCloudTier('123')).toBe('starter');
|
||||
});
|
||||
|
||||
it('does not treat cloud planVersion identifiers as tier names', () => {
|
||||
// planVersions use the cloud_<tier> form; they must not collide with bare tier keys
|
||||
expect(parseCloudTier('cloud_starter')).toBe('starter');
|
||||
expect(parseCloudTier('cloud_power')).toBe('starter');
|
||||
expect(parseCloudTier('cloud_max')).toBe('starter');
|
||||
expect(parseCloudTier('cloud_founding')).toBe('starter');
|
||||
});
|
||||
|
||||
it('does not treat MSP plan identifiers as cloud tiers', () => {
|
||||
expect(parseCloudTier('msp_starter')).toBe('starter');
|
||||
expect(parseCloudTier('msp_growth')).toBe('starter');
|
||||
expect(parseCloudTier('msp_scale')).toBe('starter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLOUD_PLAN_DEFINITIONS', () => {
|
||||
it('defines exactly the three canonical cloud tiers in canonical order', () => {
|
||||
expect(CLOUD_PLAN_DEFINITIONS.map((d) => d.tier)).toEqual(['starter', 'power', 'max']);
|
||||
});
|
||||
|
||||
it('keeps monitored-system capacity strictly increasing and distinct across tiers', () => {
|
||||
const capacities = CLOUD_PLAN_DEFINITIONS.map((d) => d.monitoredSystems);
|
||||
const sorted = [...capacities].sort((a, b) => a - b);
|
||||
expect(capacities).toEqual(sorted);
|
||||
expect(new Set(capacities).size).toBe(capacities.length);
|
||||
for (const capacity of capacities) {
|
||||
expect(capacity).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives every definition a non-empty planVersion that has a human-readable label', () => {
|
||||
for (const definition of CLOUD_PLAN_DEFINITIONS) {
|
||||
expect(definition.planVersion.length).toBeGreaterThan(0);
|
||||
expect(CLOUD_PLAN_LABELS[definition.planVersion]).toBeTruthy();
|
||||
expect(typeof CLOUD_PLAN_LABELS[definition.planVersion]).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps planVersions and names unique across definitions', () => {
|
||||
const planVersions = CLOUD_PLAN_DEFINITIONS.map((d) => d.planVersion);
|
||||
const names = CLOUD_PLAN_DEFINITIONS.map((d) => d.name);
|
||||
expect(new Set(planVersions).size).toBe(planVersions.length);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
it('keeps every definition structurally complete with valid support level', () => {
|
||||
for (const definition of CLOUD_PLAN_DEFINITIONS) {
|
||||
expect(definition.name.length).toBeGreaterThan(0);
|
||||
expect(definition.monthlyPrice.length).toBeGreaterThan(0);
|
||||
expect(definition.annualSummary.length).toBeGreaterThan(0);
|
||||
expect(['Community', 'Priority']).toContain(definition.support);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLOUD_PLAN_LABELS', () => {
|
||||
it('provides a label for every planVersion used by the cloud plan definitions', () => {
|
||||
const usedPlanVersions = CLOUD_PLAN_DEFINITIONS.map((d) => d.planVersion);
|
||||
for (const planVersion of usedPlanVersions) {
|
||||
expect(CLOUD_PLAN_LABELS).toHaveProperty(planVersion);
|
||||
}
|
||||
});
|
||||
|
||||
it('only maps non-empty string keys to non-empty string labels', () => {
|
||||
for (const [key, label] of Object.entries(CLOUD_PLAN_LABELS)) {
|
||||
expect(typeof label).toBe('string');
|
||||
expect(label.length).toBeGreaterThan(0);
|
||||
expect(key.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps display labels distinct from their raw keys', () => {
|
||||
for (const [key, label] of Object.entries(CLOUD_PLAN_LABELS)) {
|
||||
expect(label).not.toBe(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_CLOUD_TIER', () => {
|
||||
it('points at the starter tier', () => {
|
||||
expect(DEFAULT_CLOUD_TIER).toBe('starter');
|
||||
});
|
||||
|
||||
it('is one of the tiers defined in CLOUD_PLAN_DEFINITIONS', () => {
|
||||
expect(CLOUD_PLAN_DEFINITIONS.map((d) => d.tier)).toContain(DEFAULT_CLOUD_TIER);
|
||||
});
|
||||
|
||||
it('survives a parseCloudTier round-trip', () => {
|
||||
expect(parseCloudTier(DEFAULT_CLOUD_TIER)).toBe(DEFAULT_CLOUD_TIER);
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
CLOUD_ACCOUNT_FLOW_STEPS,
|
||||
CLOUD_COMMERCIAL_PRESENTATION,
|
||||
CLOUD_PLAN_BY_TIER,
|
||||
HOSTED_SIGNUP_PRESENTATION,
|
||||
getCloudPlanPricePresentation,
|
||||
} from '@/utils/cloudPlans';
|
||||
|
||||
describe('cloudPlans', () => {
|
||||
it('keeps founding-rate display in the shared cloud pricing contract', () => {
|
||||
expect(getCloudPlanPricePresentation(CLOUD_PLAN_BY_TIER.starter)).toEqual({
|
||||
monthlyPrice: '$19',
|
||||
cadence: '/month',
|
||||
annualSummary: 'or $249/year (save 29%)',
|
||||
compareAtMonthlyPrice: '$29',
|
||||
campaignBadge: 'Founding rate',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps non-founding tiers on their standard monthly display', () => {
|
||||
expect(getCloudPlanPricePresentation(CLOUD_PLAN_BY_TIER.power)).toEqual({
|
||||
monthlyPrice: '$49',
|
||||
cadence: '/month',
|
||||
annualSummary: 'or $449/year (save 24%)',
|
||||
compareAtMonthlyPrice: undefined,
|
||||
campaignBadge: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps shared cloud commercial copy in the common contract', () => {
|
||||
expect(CLOUD_COMMERCIAL_PRESENTATION).toEqual({
|
||||
pageTitle: 'Pulse Cloud',
|
||||
pageDescription:
|
||||
'Managed Pulse hosting with Pro features included. Cloud signup is handled through Pulse Account.',
|
||||
includedInAllHeading: 'Included in every Cloud plan',
|
||||
includedInAllItems: [
|
||||
'All Pro features',
|
||||
'Managed hosting',
|
||||
'Daily backups',
|
||||
'Secure agent connectivity via Relay',
|
||||
'Pulse Mobile pairing and push notifications',
|
||||
'Dedicated workspace URL',
|
||||
],
|
||||
setupHeading: 'How it works',
|
||||
setupSteps: CLOUD_ACCOUNT_FLOW_STEPS,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps hosted signup commercial copy in the common contract', () => {
|
||||
expect(HOSTED_SIGNUP_PRESENTATION).toEqual({
|
||||
pageTitlePrefix: 'Pulse Cloud',
|
||||
pageDescription:
|
||||
'Create a hosted Pulse workspace. Stripe handles billing before provisioning.',
|
||||
workspaceHeading: 'Workspace',
|
||||
planHeading: 'Plan',
|
||||
nextHeading: 'How it works',
|
||||
nextSteps: CLOUD_ACCOUNT_FLOW_STEPS,
|
||||
existingAccountHeading: 'Already signed up?',
|
||||
existingAccountDescription: 'Request a fresh Pulse Account sign-in link.',
|
||||
createWorkspaceLabel: 'Continue to Checkout',
|
||||
creatingWorkspaceLabel: 'Preparing Checkout...',
|
||||
emailSignInLinkLabel: 'Email Pulse Account Link',
|
||||
sendingSignInLinkLabel: 'Sending...',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -85,9 +85,6 @@ describe('licensePresentation', () => {
|
||||
planSectionDescription: 'Current tier and enabled capabilities.',
|
||||
planComparisonSectionTitle: 'Available plans',
|
||||
planComparisonActionLabel: 'View plans',
|
||||
planComparisonTrialActionLabel: 'Start 14-day free Pro trial',
|
||||
planComparisonTrialActionNote:
|
||||
'Card required. You will not be charged if you cancel during the trial.',
|
||||
usageSectionTitle: 'Usage',
|
||||
hiddenShellTitle: 'Demo mode',
|
||||
hiddenShellDescription: 'Commercial settings are hidden for this session.',
|
||||
@@ -335,11 +332,14 @@ describe('licensePresentation', () => {
|
||||
upgrade_reasons: [],
|
||||
runtime: { build: 'pro', label: 'Pulse Pro runtime' },
|
||||
},
|
||||
displayableCapabilities: ['Pulse Relay (Remote Access)', 'Patrol Applies Safe Fixes and Verifies the Result'],
|
||||
displayableCapabilities: [
|
||||
'Pulse Relay (Remote Access)',
|
||||
'Patrol Applies Safe Fixes and Verifies the Result',
|
||||
],
|
||||
}),
|
||||
).toMatchObject({
|
||||
title: 'Current plan: Pulse Pro',
|
||||
body: 'Pulse Pro is active on this instance. It includes Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.',
|
||||
body: 'Pulse Pro is active on this instance. It includes Relay connectivity, Pulse Mobile pairing, push notifications, Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.',
|
||||
supplementalBadges: [],
|
||||
supplementalSummary: '',
|
||||
});
|
||||
@@ -362,7 +362,7 @@ describe('licensePresentation', () => {
|
||||
}),
|
||||
).toEqual({
|
||||
title: 'Current plan: Pulse Pro',
|
||||
body: 'Pulse Pro is active on this instance. It includes Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.',
|
||||
body: 'Pulse Pro is active on this instance. It includes Relay connectivity, Pulse Mobile pairing, push notifications, Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.',
|
||||
unlockedFeaturesLabel: 'Primary capabilities',
|
||||
unlockedFeatures: [
|
||||
'Patrol Investigates Issues and Explains the Root Cause',
|
||||
@@ -629,11 +629,14 @@ describe('licensePresentation', () => {
|
||||
upgrade_reasons: [],
|
||||
runtime: { build: 'pro', label: 'Pulse Pro runtime' },
|
||||
},
|
||||
displayableCapabilities: ['Pulse Relay (Remote Access)', 'Patrol Applies Safe Fixes and Verifies the Result'],
|
||||
displayableCapabilities: [
|
||||
'Pulse Relay (Remote Access)',
|
||||
'Patrol Applies Safe Fixes and Verifies the Result',
|
||||
],
|
||||
}),
|
||||
).toMatchObject({
|
||||
title: 'Current plan: Legacy Pulse Pro+',
|
||||
body: 'Legacy Pulse Pro+ is active on this instance. It includes Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.',
|
||||
body: 'Legacy Pulse Pro+ is active on this instance. It includes Relay connectivity, Pulse Mobile pairing, push notifications, Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.',
|
||||
unlockedFeaturesLabel: 'Primary capabilities',
|
||||
unlockedFeatures: [
|
||||
'Patrol Investigates Issues and Explains the Root Cause',
|
||||
@@ -657,7 +660,7 @@ describe('licensePresentation', () => {
|
||||
}),
|
||||
).toEqual({
|
||||
title: 'Current plan: Pulse Pro',
|
||||
body: 'Pulse Pro is active on this instance. It includes Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.',
|
||||
body: 'Pulse Pro is active on this instance. It includes Relay connectivity, Pulse Mobile pairing, push notifications, Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.',
|
||||
unlockedFeaturesLabel: 'Primary capabilities',
|
||||
unlockedFeatures: [
|
||||
'Patrol Investigates Issues and Explains the Root Cause',
|
||||
@@ -803,7 +806,10 @@ describe('licensePresentation', () => {
|
||||
upgrade_reasons: [],
|
||||
runtime: { build: 'pro', label: 'Pulse Pro runtime' },
|
||||
},
|
||||
displayableCapabilities: ['Pulse Relay (Remote Access)', 'Patrol Applies Safe Fixes and Verifies the Result'],
|
||||
displayableCapabilities: [
|
||||
'Pulse Relay (Remote Access)',
|
||||
'Patrol Applies Safe Fixes and Verifies the Result',
|
||||
],
|
||||
}).body,
|
||||
).toBe(getSelfHostedPlanEntitlementSummary('pro', 'Legacy Pulse Pro+'));
|
||||
});
|
||||
@@ -825,7 +831,10 @@ describe('licensePresentation', () => {
|
||||
upgrade_reasons: [],
|
||||
runtime: { build: 'pro', label: 'Pulse Pro runtime' },
|
||||
},
|
||||
displayableCapabilities: ['Pulse Relay (Remote Access)', 'Patrol Applies Safe Fixes and Verifies the Result'],
|
||||
displayableCapabilities: [
|
||||
'Pulse Relay (Remote Access)',
|
||||
'Patrol Applies Safe Fixes and Verifies the Result',
|
||||
],
|
||||
}).patrolControlAction,
|
||||
).toEqual(patrolControlAction);
|
||||
|
||||
@@ -893,7 +902,7 @@ describe('licensePresentation', () => {
|
||||
title: 'Relay plan',
|
||||
body: 'Remote web access, Pulse Mobile pairing, push notifications, and 14-day metric history.',
|
||||
highlights: [
|
||||
'Everything in Community',
|
||||
'Secure access when you are away from your network',
|
||||
'Remote web access via Relay',
|
||||
'Pulse Mobile pairing',
|
||||
'Push notifications',
|
||||
@@ -903,9 +912,9 @@ describe('licensePresentation', () => {
|
||||
},
|
||||
{
|
||||
title: 'Pulse Pro plan',
|
||||
body: 'Patrol investigates issues, applies safe fixes, and verifies the result, plus 90-day metric history and team controls.',
|
||||
body: 'Patrol investigates issues, applies safe fixes, and verifies the result. Relay connectivity is included, plus 90-day metric history and team controls.',
|
||||
highlights: [
|
||||
'Everything in Relay',
|
||||
'Relay connectivity, Pulse Mobile pairing, and push notifications included',
|
||||
'Patrol modes: Ask first, Safe auto-fix, or Autopilot',
|
||||
'Patrol investigates issues and explains the root cause',
|
||||
'Patrol applies safe fixes and verifies the result',
|
||||
@@ -930,9 +939,9 @@ describe('licensePresentation', () => {
|
||||
cards: [
|
||||
{
|
||||
title: 'Pulse Pro plan',
|
||||
body: 'Patrol investigates issues, applies safe fixes, and verifies the result, plus 90-day metric history and team controls.',
|
||||
body: 'Patrol investigates issues, applies safe fixes, and verifies the result. Relay connectivity is included, plus 90-day metric history and team controls.',
|
||||
highlights: [
|
||||
'Everything in Relay',
|
||||
'Relay connectivity, Pulse Mobile pairing, and push notifications included',
|
||||
'Patrol modes: Ask first, Safe auto-fix, or Autopilot',
|
||||
'Patrol investigates issues and explains the root cause',
|
||||
'Patrol applies safe fixes and verifies the result',
|
||||
@@ -1295,7 +1304,10 @@ describe('licensePresentation', () => {
|
||||
limits: [],
|
||||
upgrade_reasons: [],
|
||||
},
|
||||
displayableCapabilities: ['Pulse Patrol', 'Patrol Applies Safe Fixes and Verifies the Result'],
|
||||
displayableCapabilities: [
|
||||
'Pulse Patrol',
|
||||
'Patrol Applies Safe Fixes and Verifies the Result',
|
||||
],
|
||||
source: 'purchase',
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
@@ -64,7 +64,6 @@ describe('selfHostedPlans', () => {
|
||||
expect(SELF_HOSTED_COMMERCIAL_PRESENTATION).toEqual({
|
||||
pageTitle: 'Pricing',
|
||||
pageDescription: 'Self-hosted plans and included capabilities.',
|
||||
mostPopularBadge: 'Most Popular',
|
||||
currentPlanLabel: 'Current Plan',
|
||||
includedLabel: 'Included',
|
||||
freeLabel: 'Free',
|
||||
@@ -129,10 +128,10 @@ describe('selfHostedPlans', () => {
|
||||
'Patrol modes, history, and team controls',
|
||||
);
|
||||
expect(SELF_HOSTED_PLAN_BY_TIER.pro.entitlementSummary).toContain(
|
||||
'Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.',
|
||||
'Relay connectivity, Pulse Mobile pairing, push notifications, Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.',
|
||||
);
|
||||
expect(SELF_HOSTED_PLAN_BY_TIER.pro.comparisonSummary).toBe(
|
||||
'Patrol investigates issues, applies safe fixes, and verifies the result, plus 90-day metric history and team controls.',
|
||||
'Patrol investigates issues, applies safe fixes, and verifies the result. Relay connectivity is included, plus 90-day metric history and team controls.',
|
||||
);
|
||||
expect(SELF_HOSTED_PLAN_BY_TIER.pro.comparisonSummary).not.toContain('how much control');
|
||||
expect(SELF_HOSTED_PLAN_BY_TIER.pro.entitlementHighlights).toEqual([
|
||||
@@ -172,7 +171,7 @@ describe('selfHostedPlans', () => {
|
||||
getSelfHostedPlanEntitlementSummary('pro'),
|
||||
);
|
||||
expect(getSelfHostedPlanEntitlementSummary('pro', 'Legacy Pulse Pro+')).toBe(
|
||||
'Legacy Pulse Pro+ is active on this instance. It includes Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.',
|
||||
'Legacy Pulse Pro+ is active on this instance. It includes Relay connectivity, Pulse Mobile pairing, push notifications, Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.',
|
||||
);
|
||||
|
||||
expect(SELF_HOSTED_PLAN_BY_TIER.relay.entitlementHighlights).toEqual(
|
||||
|
||||
@@ -1,97 +1,5 @@
|
||||
export type CloudTierKey = 'starter' | 'power' | 'max';
|
||||
|
||||
export interface CloudPlanDefinition {
|
||||
tier: CloudTierKey;
|
||||
planVersion: string;
|
||||
name: string;
|
||||
monthlyPrice: string;
|
||||
annualSummary: string;
|
||||
monitoredSystems: number;
|
||||
support: 'Community' | 'Priority';
|
||||
foundingMonthlyPrice?: string;
|
||||
campaignBadge?: string;
|
||||
highlighted?: boolean;
|
||||
}
|
||||
|
||||
export interface CloudPlanPricePresentation {
|
||||
monthlyPrice: string;
|
||||
cadence: '/month';
|
||||
annualSummary: string;
|
||||
compareAtMonthlyPrice?: string;
|
||||
campaignBadge?: string;
|
||||
}
|
||||
|
||||
export interface CloudCommercialPresentation {
|
||||
pageTitle: string;
|
||||
pageDescription: string;
|
||||
includedInAllHeading: string;
|
||||
includedInAllItems: readonly string[];
|
||||
setupHeading: string;
|
||||
setupSteps: readonly string[];
|
||||
}
|
||||
|
||||
export interface HostedSignupPresentation {
|
||||
pageTitlePrefix: string;
|
||||
pageDescription: string;
|
||||
workspaceHeading: string;
|
||||
planHeading: string;
|
||||
nextHeading: string;
|
||||
nextSteps: readonly string[];
|
||||
existingAccountHeading: string;
|
||||
existingAccountDescription: string;
|
||||
createWorkspaceLabel: string;
|
||||
creatingWorkspaceLabel: string;
|
||||
emailSignInLinkLabel: string;
|
||||
sendingSignInLinkLabel: string;
|
||||
}
|
||||
|
||||
export const CLOUD_ACCOUNT_FLOW_STEPS = [
|
||||
'Choose a Cloud plan and confirm billing in secure checkout.',
|
||||
'Use the email link to open Pulse Account.',
|
||||
'Open your workspace and connect systems.',
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_CLOUD_TIER: CloudTierKey = 'starter';
|
||||
|
||||
export const CLOUD_PLAN_DEFINITIONS: readonly CloudPlanDefinition[] = [
|
||||
{
|
||||
tier: 'starter',
|
||||
planVersion: 'cloud_starter',
|
||||
name: 'Starter',
|
||||
monthlyPrice: '$29',
|
||||
annualSummary: 'or $249/year (save 29%)',
|
||||
monitoredSystems: 10,
|
||||
support: 'Community',
|
||||
foundingMonthlyPrice: '$19',
|
||||
campaignBadge: 'Founding rate',
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
tier: 'power',
|
||||
planVersion: 'cloud_power',
|
||||
name: 'Power',
|
||||
monthlyPrice: '$49',
|
||||
annualSummary: 'or $449/year (save 24%)',
|
||||
monitoredSystems: 30,
|
||||
support: 'Priority',
|
||||
},
|
||||
{
|
||||
tier: 'max',
|
||||
planVersion: 'cloud_max',
|
||||
name: 'Max',
|
||||
monthlyPrice: '$79',
|
||||
annualSummary: 'or $699/year (save 26%)',
|
||||
monitoredSystems: 75,
|
||||
support: 'Priority',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const CLOUD_PLAN_BY_TIER: Record<CloudTierKey, CloudPlanDefinition> = {
|
||||
starter: CLOUD_PLAN_DEFINITIONS[0],
|
||||
power: CLOUD_PLAN_DEFINITIONS[1],
|
||||
max: CLOUD_PLAN_DEFINITIONS[2],
|
||||
};
|
||||
|
||||
// Cloud is not a current offer. These labels exist only to render historical
|
||||
// entitlement records without exposing the retired checkout catalog.
|
||||
export const CLOUD_PLAN_LABELS: Record<string, string> = {
|
||||
cloud_starter: 'Cloud Starter',
|
||||
cloud_founding: 'Cloud Starter (Founding)',
|
||||
@@ -101,59 +9,3 @@ export const CLOUD_PLAN_LABELS: Record<string, string> = {
|
||||
msp_growth: 'MSP Growth',
|
||||
msp_scale: 'MSP Scale',
|
||||
};
|
||||
|
||||
export const CLOUD_COMMERCIAL_PRESENTATION: CloudCommercialPresentation = {
|
||||
pageTitle: 'Pulse Cloud',
|
||||
pageDescription:
|
||||
'Managed Pulse hosting with Pro features included. Cloud signup is handled through Pulse Account.',
|
||||
includedInAllHeading: 'Included in every Cloud plan',
|
||||
includedInAllItems: [
|
||||
'All Pro features',
|
||||
'Managed hosting',
|
||||
'Daily backups',
|
||||
'Secure agent connectivity via Relay',
|
||||
'Pulse Mobile pairing and push notifications',
|
||||
'Dedicated workspace URL',
|
||||
],
|
||||
setupHeading: 'How it works',
|
||||
setupSteps: CLOUD_ACCOUNT_FLOW_STEPS,
|
||||
} as const;
|
||||
|
||||
export const HOSTED_SIGNUP_PRESENTATION: HostedSignupPresentation = {
|
||||
pageTitlePrefix: 'Pulse Cloud',
|
||||
pageDescription: 'Create a hosted Pulse workspace. Stripe handles billing before provisioning.',
|
||||
workspaceHeading: 'Workspace',
|
||||
planHeading: 'Plan',
|
||||
nextHeading: 'How it works',
|
||||
nextSteps: CLOUD_ACCOUNT_FLOW_STEPS,
|
||||
existingAccountHeading: 'Already signed up?',
|
||||
existingAccountDescription: 'Request a fresh Pulse Account sign-in link.',
|
||||
createWorkspaceLabel: 'Continue to Checkout',
|
||||
creatingWorkspaceLabel: 'Preparing Checkout...',
|
||||
emailSignInLinkLabel: 'Email Pulse Account Link',
|
||||
sendingSignInLinkLabel: 'Sending...',
|
||||
} as const;
|
||||
|
||||
export function parseCloudTier(value?: string | null): CloudTierKey {
|
||||
switch ((value || '').trim().toLowerCase()) {
|
||||
case 'power':
|
||||
return 'power';
|
||||
case 'max':
|
||||
return 'max';
|
||||
case 'starter':
|
||||
default:
|
||||
return DEFAULT_CLOUD_TIER;
|
||||
}
|
||||
}
|
||||
|
||||
export function getCloudPlanPricePresentation(
|
||||
plan: CloudPlanDefinition,
|
||||
): CloudPlanPricePresentation {
|
||||
return {
|
||||
monthlyPrice: plan.foundingMonthlyPrice || plan.monthlyPrice,
|
||||
cadence: '/month',
|
||||
annualSummary: plan.annualSummary,
|
||||
compareAtMonthlyPrice: plan.foundingMonthlyPrice ? plan.monthlyPrice : undefined,
|
||||
campaignBadge: plan.campaignBadge,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ export interface SelfHostedLinkCTA {
|
||||
export interface SelfHostedCommercialPresentation {
|
||||
pageTitle: string;
|
||||
pageDescription: string;
|
||||
mostPopularBadge: string;
|
||||
currentPlanLabel: string;
|
||||
includedLabel: string;
|
||||
freeLabel: string;
|
||||
@@ -84,7 +83,7 @@ export function getSelfHostedPlanEntitlementSummary(
|
||||
case 'relay':
|
||||
return `${planLabel} is active on this instance. It includes remote web access, Pulse Mobile pairing, push notifications, and 14-day metric history.`;
|
||||
case 'pro':
|
||||
return `${planLabel} is active on this instance. It includes Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.`;
|
||||
return `${planLabel} is active on this instance. It includes Relay connectivity, Pulse Mobile pairing, push notifications, Patrol modes (Ask first, Safe auto-fix, Autopilot), 90-day metric history, RBAC, audit logging, reporting, and agent profiles.`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,9 +133,10 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] =
|
||||
entitlementSummary: getSelfHostedPlanEntitlementSummary('relay'),
|
||||
entitlementHighlights: getTierEntitlementHighlights('relay', 14),
|
||||
includedExtras: [],
|
||||
comparisonSummary: 'Remote web access, Pulse Mobile pairing, push notifications, and 14-day metric history.',
|
||||
comparisonSummary:
|
||||
'Remote web access, Pulse Mobile pairing, push notifications, and 14-day metric history.',
|
||||
highlights: [
|
||||
'Everything in Community',
|
||||
'Secure access when you are away from your network',
|
||||
'Remote web access via Relay',
|
||||
'Pulse Mobile pairing',
|
||||
'Push notifications',
|
||||
@@ -155,9 +155,9 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] =
|
||||
entitlementHighlights: getTierEntitlementHighlights('pro', 90),
|
||||
includedExtras: getTierIncludedExtras('pro'),
|
||||
comparisonSummary:
|
||||
'Patrol investigates issues, applies safe fixes, and verifies the result, plus 90-day metric history and team controls.',
|
||||
'Patrol investigates issues, applies safe fixes, and verifies the result. Relay connectivity is included, plus 90-day metric history and team controls.',
|
||||
highlights: [
|
||||
'Everything in Relay',
|
||||
'Relay connectivity, Pulse Mobile pairing, and push notifications included',
|
||||
'Patrol modes: Ask first, Safe auto-fix, or Autopilot',
|
||||
'Patrol investigates issues and explains the root cause',
|
||||
'Patrol applies safe fixes and verifies the result',
|
||||
@@ -195,7 +195,6 @@ export function getSelfHostedPlanDefinitionForBillingTier(
|
||||
export const SELF_HOSTED_COMMERCIAL_PRESENTATION: SelfHostedCommercialPresentation = {
|
||||
pageTitle: 'Pricing',
|
||||
pageDescription: 'Self-hosted plans and included capabilities.',
|
||||
mostPopularBadge: 'Most Popular',
|
||||
currentPlanLabel: 'Current Plan',
|
||||
includedLabel: 'Included',
|
||||
freeLabel: 'Free',
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"source_hash": "ee52f635ea8a84c74ebb9212dae66b651f24d41a3bbf9f0ebea9f951c6136d20",
|
||||
"source_hash": "9e8a4084320ddb293e5e59bca05a429d6a5f942c7f73e6034ec392aa5558aa96",
|
||||
"build_inputs": [
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
|
||||
+3
-3
@@ -3379,13 +3379,13 @@
|
||||
var selfHostedBillingLeadCopy = showSelfHostedCommercial ? "Use self-hosted billing only for self-hosted purchases." : "Pulse Account owns the commercial handoff for self-hosted upgrades from the app.";
|
||||
var selfHostedBillingActionsHTML = renderSelfHostedUpgradeActionRow(context);
|
||||
if (showSelfHostedCommercial) {
|
||||
selfHostedBillingActionsHTML += renderBillingActionRow("open-manage-billing", "Manage subscriptions", "Open", "Open Stripe for self-hosted plan, invoice, and payment changes.", "manage-billing-panel", "manage-inline-email", ["Plan changes", "Invoices"]) + renderBillingActionRow("open-retrieve-billing", "Retrieve licenses", "Open", "Recover the latest active self-hosted license and invoice link.", "retrieve-billing-panel", "retrieve-inline-email", ["Latest active license", "Invoice lookup"]) + renderBillingActionRow("open-refund-billing", "Refund requests", "Open", "Request a self-serve refund when the purchase is still eligible.", "refund-billing-panel", "refund-inline-email", ["Eligibility check", "Revocation"]) + renderBillingActionRow("open-data-billing", "Data and privacy", "Open", "Request export or deletion for commercial account data.", "data-billing-panel", "data-export-email", ["Export", "Deletion"]);
|
||||
selfHostedBillingActionsHTML += renderBillingActionRow("open-manage-billing", "Invoices and payment methods", "Open", "Open Stripe for invoices and payment methods. Plan changes stay in Pulse Account.", "manage-billing-panel", "manage-inline-email", ["Invoices", "Payment methods"]) + renderBillingActionRow("open-retrieve-billing", "Retrieve licenses", "Open", "Recover the latest active self-hosted license and invoice link.", "retrieve-billing-panel", "retrieve-inline-email", ["Latest active license", "Invoice lookup"]) + renderBillingActionRow("open-refund-billing", "Refund requests", "Open", "Request a self-serve refund when the purchase is still eligible.", "refund-billing-panel", "refund-inline-email", ["Eligibility check", "Revocation"]) + renderBillingActionRow("open-data-billing", "Data and privacy", "Open", "Request export or deletion for commercial account data.", "data-billing-panel", "data-export-email", ["Export", "Deletion"]);
|
||||
}
|
||||
var selfHostedBillingPanelsHTML = renderSelfHostedUpgradeBillingPanel(context);
|
||||
if (showSelfHostedCommercial) {
|
||||
selfHostedBillingPanelsHTML += renderBillingTaskPanel(
|
||||
"Manage subscriptions",
|
||||
"Open Stripe for self-hosted plan, invoice, and payment changes.",
|
||||
"Invoices and payment methods",
|
||||
"Open Stripe for invoices and payment methods. Plan changes stay in Pulse Account.",
|
||||
"manage-billing-panel",
|
||||
'<div id="manage-billing-root"></div>'
|
||||
) + renderBillingTaskPanel(
|
||||
|
||||
@@ -373,6 +373,9 @@ describe('shell view', function() {
|
||||
expect(html).toContain('Use self-hosted billing only for self-hosted purchases.');
|
||||
expect(html).toContain('id="open-upgrade-billing"');
|
||||
expect(html).toContain('id="open-retrieve-billing"');
|
||||
expect(html).toContain('Invoices and payment methods');
|
||||
expect(html).toContain('Open Stripe for invoices and payment methods. Plan changes stay in Pulse Account.');
|
||||
expect(html).not.toContain('Open Stripe for self-hosted plan, invoice, and payment changes.');
|
||||
expect(html).toContain('id="billing-detail-shell" hidden');
|
||||
expect(html).toContain('data-account-billing-action="clear-billing-panel"');
|
||||
expect(html).toContain('id="upgrade-billing-panel"');
|
||||
|
||||
@@ -1655,7 +1655,7 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string
|
||||
var selfHostedBillingActionsHTML = renderSelfHostedUpgradeActionRow(context);
|
||||
if (showSelfHostedCommercial) {
|
||||
selfHostedBillingActionsHTML +=
|
||||
renderBillingActionRow('open-manage-billing', 'Manage subscriptions', 'Open', 'Open Stripe for self-hosted plan, invoice, and payment changes.', 'manage-billing-panel', 'manage-inline-email', ['Plan changes', 'Invoices']) +
|
||||
renderBillingActionRow('open-manage-billing', 'Invoices and payment methods', 'Open', 'Open Stripe for invoices and payment methods. Plan changes stay in Pulse Account.', 'manage-billing-panel', 'manage-inline-email', ['Invoices', 'Payment methods']) +
|
||||
renderBillingActionRow('open-retrieve-billing', 'Retrieve licenses', 'Open', 'Recover the latest active self-hosted license and invoice link.', 'retrieve-billing-panel', 'retrieve-inline-email', ['Latest active license', 'Invoice lookup']) +
|
||||
renderBillingActionRow('open-refund-billing', 'Refund requests', 'Open', 'Request a self-serve refund when the purchase is still eligible.', 'refund-billing-panel', 'refund-inline-email', ['Eligibility check', 'Revocation']) +
|
||||
renderBillingActionRow('open-data-billing', 'Data and privacy', 'Open', 'Request export or deletion for commercial account data.', 'data-billing-panel', 'data-export-email', ['Export', 'Deletion']);
|
||||
@@ -1664,8 +1664,8 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string
|
||||
if (showSelfHostedCommercial) {
|
||||
selfHostedBillingPanelsHTML +=
|
||||
renderBillingTaskPanel(
|
||||
'Manage subscriptions',
|
||||
'Open Stripe for self-hosted plan, invoice, and payment changes.',
|
||||
'Invoices and payment methods',
|
||||
'Open Stripe for invoices and payment methods. Plan changes stay in Pulse Account.',
|
||||
'manage-billing-panel',
|
||||
'<div id="manage-billing-root"></div>'
|
||||
) +
|
||||
|
||||
Reference in New Issue
Block a user