diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 16dc8d758..6dc2c1308 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -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" } ] } diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 14276fab6..659efbff4 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 48a0e692d..fa6ff6c36 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 46266f915..b2dfc3a0e 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -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", diff --git a/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx b/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx index 2f573f99f..13632ad65 100644 --- a/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx +++ b/frontend-modern/src/components/Settings/ProLicensePlanSection.tsx @@ -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 = (pro
- - {(trialAction) => ( - - {trialAction().label} - - )} - {(action) => ( = (pro )}
- - {(trialAction) =>

{trialAction().note}

} -
diff --git a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx index 3590b5810..49b985ca5 100644 --- a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx @@ -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, diff --git a/frontend-modern/src/components/Settings/__tests__/ProLicensePlanSectionTrial.test.tsx b/frontend-modern/src/components/Settings/__tests__/ProLicensePlanSectionTrial.test.tsx index 637a882c5..503c1ae18 100644 --- a/frontend-modern/src/components/Settings/__tests__/ProLicensePlanSectionTrial.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ProLicensePlanSectionTrial.test.tsx @@ -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(() => ( { '/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(() => ( - - )); - - 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(); }); }); diff --git a/frontend-modern/src/components/Settings/selfHostedBillingPresentation.ts b/frontend-modern/src/components/Settings/selfHostedBillingPresentation.ts index 5c0571624..aaeb738e1 100644 --- a/frontend-modern/src/components/Settings/selfHostedBillingPresentation.ts +++ b/frontend-modern/src/components/Settings/selfHostedBillingPresentation.ts @@ -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.', diff --git a/frontend-modern/src/components/Settings/useProLicensePanelState.ts b/frontend-modern/src/components/Settings/useProLicensePanelState.ts index 0f1959398..39123b371 100644 --- a/frontend-modern/src/components/Settings/useProLicensePanelState.ts +++ b/frontend-modern/src/components/Settings/useProLicensePanelState.ts @@ -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, }; }); diff --git a/frontend-modern/src/utils/__tests__/cloudPlans.coverage.test.ts b/frontend-modern/src/utils/__tests__/cloudPlans.coverage.test.ts deleted file mode 100644 index 46b28e029..000000000 --- a/frontend-modern/src/utils/__tests__/cloudPlans.coverage.test.ts +++ /dev/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_ 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); - }); -}); diff --git a/frontend-modern/src/utils/__tests__/cloudPlans.test.ts b/frontend-modern/src/utils/__tests__/cloudPlans.test.ts deleted file mode 100644 index eeaeecd9a..000000000 --- a/frontend-modern/src/utils/__tests__/cloudPlans.test.ts +++ /dev/null @@ -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...', - }); - }); -}); diff --git a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts index d1e97e160..6d948cbbf 100644 --- a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts @@ -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(); diff --git a/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts b/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts index 9854311d7..97556bf56 100644 --- a/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts +++ b/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts @@ -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( diff --git a/frontend-modern/src/utils/cloudPlans.ts b/frontend-modern/src/utils/cloudPlans.ts index 0a0f80fb6..0b594749c 100644 --- a/frontend-modern/src/utils/cloudPlans.ts +++ b/frontend-modern/src/utils/cloudPlans.ts @@ -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 = { - 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 = { cloud_starter: 'Cloud Starter', cloud_founding: 'Cloud Starter (Founding)', @@ -101,59 +9,3 @@ export const CLOUD_PLAN_LABELS: Record = { 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, - }; -} diff --git a/frontend-modern/src/utils/selfHostedPlans.ts b/frontend-modern/src/utils/selfHostedPlans.ts index 5a50a83f3..71698fc20 100644 --- a/frontend-modern/src/utils/selfHostedPlans.ts +++ b/frontend-modern/src/utils/selfHostedPlans.ts @@ -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', diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index c503dbbe3..d624760d5 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "ee52f635ea8a84c74ebb9212dae66b651f24d41a3bbf9f0ebea9f951c6136d20", + "source_hash": "9e8a4084320ddb293e5e59bca05a429d6a5f942c7f73e6034ec392aa5558aa96", "build_inputs": [ "package.json", "tsconfig.json", diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index b43bcbf35..ea2696aab 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -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", '
' ) + renderBillingTaskPanel( diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts index 12bb46c13..81923bf0c 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts @@ -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"'); diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts index 320691fe8..9d0991142 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.ts @@ -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', '
' ) +