Clarify commercial offer boundaries

This commit is contained in:
rcourtman
2026-07-14 17:18:14 +01:00
parent a8341b013f
commit 206b68cc94
19 changed files with 176 additions and 505 deletions
@@ -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,
@@ -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(
+2 -150
View File
@@ -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,
};
}
+6 -7
View File
@@ -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
View File
@@ -1,5 +1,5 @@
{
"source_hash": "ee52f635ea8a84c74ebb9212dae66b651f24d41a3bbf9f0ebea9f951c6136d20",
"source_hash": "9e8a4084320ddb293e5e59bca05a429d6a5f942c7f73e6034ec392aa5558aa96",
"build_inputs": [
"package.json",
"tsconfig.json",
+3 -3
View File
@@ -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>'
) +