Retire self-hosted trial posture prompts

This commit is contained in:
rcourtman
2026-04-28 17:39:09 +01:00
parent 7cc980ad1d
commit 2b1d82d965
26 changed files with 84 additions and 363 deletions
@@ -1629,7 +1629,9 @@ fleet surfaces must not expose direct trial CTAs or depend on
`POST /api/license/trial/start`, and the normal router must fail that path as
`404` without mutating entitlements. The retired `/auth/trial-activate`
self-hosted callback must also stay absent from lifecycle retry and backoff
behavior.
behavior. Lifecycle-adjacent setup and install surfaces must also treat
`trial_eligible` and `trial_eligibility_reason` as retired compatibility
fields, not as prompt state or setup transport state.
That same shared `internal/api/` dependency also assumes session-carried OIDC
refresh tokens stay fail-closed at rest: `session_store.go` may only persist
or recover those tokens through encrypted-at-rest session payloads, and any
@@ -676,7 +676,11 @@ the canonical monitored-system blocked payload.
`/auth/trial-activate` self-hosted return path must also stay absent from
the ordinary router and settings UI; signed hosted entitlement leases may
refresh cached hosted/cloud entitlement state, but they must not create a
local Pro trial acquisition callback.
local Pro trial acquisition callback. Entitlement payloads may retain
`trial_eligible` and `trial_eligibility_reason` as compatibility fields,
but ordinary self-hosted responses leave eligibility false and the reason
empty; active trial state is represented by `subscription_state`,
`trial_expires_at`, and `trial_days_remaining` only.
29. Keep `/api/security/dev/reset-first-run` transport-backed and genuinely
unauthenticated: when the dev reset route clears first-run auth it must
also clear any env-backed auth state that feeds `/api/security/status`, so
@@ -869,7 +873,7 @@ for upgrade links, trial CTAs, plan upsells, and paid-only navigation rather
than as a billing entitlement change.
That same contract split also makes the licensing boundary explicit:
`/api/license/runtime-capabilities` is the public runtime feature contract,
`/api/license/commercial-posture` is the non-billing upgrade/trial posture
`/api/license/commercial-posture` is the non-billing upgrade posture
contract for real customer workspaces, and `/api/license/entitlements`
remains billing-only. New callers must extend one of those owned shapes
instead of reviving a combined entitlement payload for mixed runtime,
@@ -2947,7 +2951,8 @@ out of the router inventory and must not return the old hosted-signup or
trial-rate-limit acquisition payloads from an ordinary self-hosted runtime;
`frontend-modern/src/api/license.ts`, demo mode, and feature gates must not
expose a start-trial client method or in-app CTA in the same slice as any
handler change.
handler change. Commercial migration state must travel through
`commercial_migration`, not through trial-denial reason strings.
That same shared commercial API boundary also owns hosted self-serve failure
transport semantics. Hosted trial request and verification failures may render
owned HTML pages, but they must preserve the originating Pulse instance and
@@ -241,7 +241,11 @@ Community limit enforcement.
13. Add or change hosted billing-admin presentation through `frontend-modern/src/components/Settings/BillingAdminPanel.tsx`, `frontend-modern/src/components/Settings/BillingAdminOrganizationsTable.tsx`, and `frontend-modern/src/components/Settings/useBillingAdminPanelState.ts`
14. Add or change shared commercial plan/usage presentation through `frontend-modern/src/components/Settings/CommercialBillingSections.tsx` and `frontend-modern/src/utils/commercialBillingModel.ts`
15. Add or change organization billing and usage presentation through `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationBillingLoadingState.tsx`, and `frontend-modern/src/components/Settings/useOrganizationBillingPanelState.ts`
16. Add or change self-hosted Pro plan, trial, recovery, and entitlement actions through `frontend-modern/src/components/Settings/ProLicensePanel.tsx`, `frontend-modern/src/components/Settings/ProLicensePlanSection.tsx`, `frontend-modern/src/components/Settings/SelfHostedCommercialRecoverySection.tsx`, and `frontend-modern/src/components/Settings/useProLicensePanelState.ts`
16. Add or change self-hosted Pro plan, recovery, and entitlement actions through `frontend-modern/src/components/Settings/ProLicensePanel.tsx`, `frontend-modern/src/components/Settings/ProLicensePlanSection.tsx`, `frontend-modern/src/components/Settings/SelfHostedCommercialRecoverySection.tsx`, and `frontend-modern/src/components/Settings/useProLicensePanelState.ts`
Ordinary self-hosted trial acquisition is retired: these plan surfaces may
show active historical `subscription_state=trial` entitlement state, but
they must not turn `trial_eligible`, `trial_eligibility_reason`, or an
expired trial marker into a default Pro CTA or banner.
17. Add or change monitored-system ledger, disclosure, or admission-preview presentation through `frontend-modern/src/components/Settings/MonitoredSystemLedgerPanel.tsx`, `frontend-modern/src/components/Settings/MonitoredSystemAdmissionPreview.tsx`, `frontend-modern/src/components/Commercial/MonitoredSystemDefinitionDisclosure.tsx`, and `frontend-modern/src/utils/monitoredSystemPresentation.ts`
18. Add or change paid relay settings and pairing presentation through `frontend-modern/src/components/Settings/RelaySettingsPanel.tsx`, `frontend-modern/src/components/Settings/RelayPairingSection.tsx`, and `frontend-modern/src/components/Settings/useRelaySettingsPanelState.ts`. The Dashboard shell must not carry a Relay onboarding card or equivalent blanket upsell — relay discovery stays inside its owning settings surface.
Public demo and other read-only presentation policy states must suppress
@@ -504,11 +508,14 @@ Community limit enforcement.
`frontend-modern/src/utils/pricingHandoff.ts` now routes pro-feature paywall
keys (`ai_alerts`, `ai_autofix`, `relay`, `rbac`, `audit_logging`,
`advanced_sso`, `agent_profiles`, `long_term_metrics`, `trial_expired`) to the
`advanced_sso`, `agent_profiles`, `long_term_metrics`) to the
self-hosted billing plan page instead of the Pulse Account purchase-start
handoff. The purchase-start handoff requires a `PublicURL` and fails on local
instances; routing these keys to the in-product billing plan keeps upgrades
accessible from self-hosted environments.
Retired trial-acquisition intents such as `trial_expired` must not be owned
pricing handoff destinations; no ordinary self-hosted runtime path should emit
them as an upgrade reason or plan-page CTA.
The monitored-system app-shell warning CTA now follows that same commercial
boundary by rendering only in hosted mode. Ordinary self-hosted installs must
not see finite monitored-system pressure in the global app shell; when hosted
@@ -567,7 +574,7 @@ endpoints are fully hidden (`404`) and which remain available only as a
non-commercial public contract. `/api/license/runtime-capabilities` is the
canonical public exception for feature truth and history retention.
`/api/license/commercial-posture` is the canonical non-billing commercial
contract for upgrade/trial posture in real customer workspaces, while
contract for upgrade posture in real customer workspaces, while
`/api/license/entitlements` remains billing-only. In public demo mode both
commercial routes, plus `/auth/license-purchase-start`, must stay hidden and
public browsers must not see licensed identity, plan labels, upgrade reasons,
@@ -623,7 +630,7 @@ owner for the first `/api/license/commercial-posture` read, while
other non-billing feature hooks may consume the resolved posture store but
must not each trigger their own mount-time posture fetch. Billing-owned
surfaces such as `frontend-modern/src/components/Settings/useProLicensePanelState.ts`
may still force refresh through the same shared store when plan, trial, or
may still force refresh through the same shared store when plan, activation, or
recovery actions mutate commercial truth.
Non-billing browser journeys must also stay off
`/api/license/entitlements` entirely. Dashboard, infrastructure, alerts,
@@ -756,9 +756,9 @@ storage and recovery may inherit demo-safe `/api/license/runtime-capabilities`
reads for capability and history-retention truth, but
`/api/license/commercial-posture`, `/api/license/entitlements`, and
`/auth/license-purchase-start` stay hidden and those surfaces must not expect
licensed identity, upgrade prompts, trial urgency, checkout handoff state, or
observed usage counts to remain present once the public-demo contract is
applied.
licensed identity, upgrade prompts, trial urgency or eligibility reasons,
checkout handoff state, or observed usage counts to remain present once the
public-demo contract is applied.
That same runtime-safe exception now also redacts monitored-system capacity
posture. Storage/recovery surfaces may keep demo-safe capability and retention
truth from `/api/license/runtime-capabilities`, but they must not expect
@@ -1887,7 +1887,9 @@ storage- or recovery-adjacent flows must not invoke or advertise the retired
`POST /api/license/trial/start` route, and the normal self-hosted router must
return `404` for that path without mutating entitlements. The retired
`/auth/trial-activate` self-hosted callback must also stay absent from
storage/recovery-local retry or backoff behavior.
storage/recovery-local retry or backoff behavior. Retired
`trial_eligible`/`trial_eligibility_reason` payload fields are compatibility
only and must not become storage/recovery prompt, identity, or restore state.
That same shared `internal/api/` dependency now also assumes adjacent
commercial helper surfaces speak in monitored-system terms: recovery- or
storage-adjacent API wiring may consume the canonical monitored-system ledger
@@ -114,7 +114,6 @@ const ProLicensePanelContent: Component = () => {
purchaseActivationAction={state.purchaseActivationAction()}
onPurchaseActivationActionClick={state.handlePurchaseActivationActionClick}
purchaseActivationNotice={state.purchaseActivationNotice()}
trialEnded={state.trialEnded()}
/>
<SelfHostedCommercialRecoverySection
@@ -1,12 +1,10 @@
import { Component, For, Show } from 'solid-js';
import RefreshCw from 'lucide-solid/icons/refresh-cw';
import { UpgradeLink } from '@/components/shared/UpgradeLink';
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { licenseEntitlementsLoadError } from '@/stores/licenseEntitlements';
import {
getLicenseStatusLoadingState,
getNoActiveProLicenseState,
getTrialEndedProLicenseNotice,
} from '@/utils/licensePresentation';
import { SELF_HOSTED_PRO_BILLING_PRESENTATION } from './selfHostedBillingPresentation';
import type { UpgradeDestination } from '@/utils/upgradeNavigation';
@@ -88,7 +86,6 @@ interface ProLicensePlanSectionProps {
destination: UpgradeDestination;
} | null;
onPurchaseActivationActionClick: () => void;
trialEnded: boolean;
}
const formatDate = (value?: string | null) => {
@@ -99,8 +96,6 @@ const formatDate = (value?: string | null) => {
};
export const ProLicensePlanSection: Component<ProLicensePlanSectionProps> = (props) => {
const trialEndedNotice = props.trialEnded ? getTrialEndedProLicenseNotice() : null;
return (
<>
<Show when={props.activationSuccessSummary}>
@@ -321,18 +316,6 @@ export const ProLicensePlanSection: Component<ProLicensePlanSectionProps> = (pro
</div>
)}
</Show>
<Show when={props.trialEnded && !licenseEntitlementsLoadError() && trialEndedNotice}>
<div class={`mb-4 rounded-md border p-3 text-sm ${trialEndedNotice?.tone ?? ''}`}>
<p class="font-medium">{trialEndedNotice?.title}</p>
<p class="text-xs mt-1 opacity-90">{trialEndedNotice?.body}</p>
<UpgradeLink
class="inline-flex items-center gap-1 mt-2 text-xs font-medium hover:underline"
destination={getUpgradeActionDestination('trial_expired')}
>
{trialEndedNotice?.actionLabel}
</UpgradeLink>
</div>
</Show>
<Show when={licenseEntitlementsLoadError()}>
<div class="rounded-md border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900 p-3 text-sm text-amber-800 dark:text-amber-200">
<p class="font-medium">Could not load license status</p>
@@ -118,7 +118,7 @@ describe('ProLicensePanel', () => {
subscription_state: 'expired',
upgrade_reasons: [],
tier: 'free',
trial_eligible: true,
trial_eligible: false,
};
loadRuntimeLicenseStatusMock.mockReset();
@@ -252,7 +252,7 @@ describe('ProLicensePanel', () => {
);
});
it('hides start trial action and shows trial-ended banner when trial was already used', async () => {
it('does not surface a trial-ended banner for retired self-hosted trial state', async () => {
mockEntitlements = {
capabilities: [],
limits: [],
@@ -268,7 +268,7 @@ describe('ProLicensePanel', () => {
expect(
screen.queryByRole('button', { name: /start 14-day pro trial/i }),
).not.toBeInTheDocument();
expect(screen.getByText('Your Pro trial has ended')).toBeInTheDocument();
expect(screen.queryByText('Your Pro trial has ended')).not.toBeInTheDocument();
});
it('renders trial countdown from entitlements payload', async () => {
@@ -1014,7 +1014,6 @@ describe('ProLicensePanel', () => {
upgrade_reasons: [],
tier: 'free',
trial_eligible: false,
trial_eligibility_reason: 'commercial_migration_pending',
commercial_migration: {
source: 'v5_license',
state: 'pending',
@@ -1040,7 +1039,6 @@ describe('ProLicensePanel', () => {
upgrade_reasons: [],
tier: 'free',
trial_eligible: false,
trial_eligibility_reason: 'commercial_migration_pending',
commercial_migration: {
source: 'v5_license',
state: 'pending',
@@ -1064,7 +1062,6 @@ describe('ProLicensePanel', () => {
upgrade_reasons: [],
tier: 'free',
trial_eligible: false,
trial_eligibility_reason: 'commercial_migration_failed',
commercial_migration: {
source: 'v5_license',
state: 'failed',
@@ -1117,7 +1114,7 @@ describe('ProLicensePanel', () => {
expect(proLicensePanelStateSource).toContain("'A license or activation key is required'");
expect(proLicensePlanSectionSource).toContain('getLicenseStatusLoadingState');
expect(proLicensePlanSectionSource).toContain('getNoActiveProLicenseState');
expect(proLicensePlanSectionSource).toContain('getTrialEndedProLicenseNotice');
expect(proLicensePlanSectionSource).not.toContain('getTrialEndedProLicenseNotice');
expect(proLicensePlanSectionSource).toContain('currentPlanSummary.title');
expect(proLicensePlanSectionSource).toContain('currentPlanSummary.supplementalBadges');
expect(proLicensePlanSectionSource).toContain('props.activationSuccessSummary');
@@ -1132,9 +1129,8 @@ describe('ProLicensePanel', () => {
expect(proLicensePlanSectionSource).not.toContain('MonitoredSystemDefinitionDisclosure');
expect(proLicensePlanSectionSource).not.toContain('trialStartTitle');
expect(proLicensePlanSectionSource).not.toContain('trialStartIdleActionLabel');
expect(proLicensePlanSectionSource).toContain(
'const trialEndedNotice = props.trialEnded ? getTrialEndedProLicenseNotice() : null;',
);
expect(proLicensePlanSectionSource).not.toContain('trialEndedNotice');
expect(proLicensePlanSectionSource).not.toContain('props.trialEnded');
expect(proLicensePlanSectionSource).toContain('planSelectionPrompt');
expect(proLicensePlanSectionSource).not.toContain(
"resolveSelfHostedPurchaseStartDestination('self_hosted_plan')",
@@ -427,12 +427,6 @@ export function useProLicensePanelState() {
: undefined;
});
const trialEnded = createMemo(
() =>
subscriptionState() === 'expired' &&
entitlements()?.trial_eligibility_reason === 'already_used',
);
const purchaseActivationNotice = createMemo(() => {
if (purchaseActivationResult().trim().toLowerCase() === 'activated') {
return null;
@@ -668,6 +662,5 @@ export function useProLicensePanelState() {
showCountingRulesByDefault,
showRecoveryByDefault,
statusPresentation,
trialEnded,
};
}
@@ -2505,7 +2505,7 @@ describe('frontend resource type boundaries', () => {
expect(proLicensePanelStateSource).toContain('getCommercialMigrationNotice');
expect(proLicensePlanSectionSource).toContain('getLicenseStatusLoadingState');
expect(proLicensePlanSectionSource).toContain('getNoActiveProLicenseState');
expect(proLicensePlanSectionSource).toContain('getTrialEndedProLicenseNotice');
expect(proLicensePlanSectionSource).not.toContain('getTrialEndedProLicenseNotice');
expect(proLicensePlanSectionSource).not.toContain('getInactiveProUpsellNotice');
expect(proLicensePanelSource).not.toContain('Loading license status...');
expect(proLicensePanelSource).not.toContain('No Pro license is active.');
@@ -2519,7 +2519,7 @@ describe('frontend resource type boundaries', () => {
);
expect(licensePresentationSource).toContain('export const getLicenseStatusLoadingState');
expect(licensePresentationSource).toContain('export const getNoActiveProLicenseState');
expect(licensePresentationSource).toContain('export const getTrialEndedProLicenseNotice');
expect(licensePresentationSource).not.toContain('export const getTrialEndedProLicenseNotice');
expect(licensePresentationSource).toContain('export const getInactiveProUpsellNotice');
expect(licensePresentationSource).toContain(
'export const getOrganizationBillingLicenseStatusLabel',
@@ -23,7 +23,6 @@ import {
getOrganizationBillingLicenseStatusLabel,
getInactiveProUpsellNotice,
getPurchaseActivationNotice,
getTrialEndedProLicenseNotice,
isDisplayableLicenseFeature,
isGrandfatheredRecurringV5PlanVersion,
isUncappedGrandfatheredPlanVersion,
@@ -50,12 +49,6 @@ describe('licensePresentation', () => {
expect(getNoActiveProLicenseState()).toEqual({
text: 'No Pro license is active.',
});
expect(getTrialEndedProLicenseNotice()).toEqual({
tone: 'border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-900 text-red-900 dark:text-red-100',
title: 'Your Pro trial has ended',
body: 'Choose a paid plan to keep root-cause analysis, safe remediation workflows, and 90-day history on this instance.',
actionLabel: 'View Pro plans',
});
expect(getInactiveProUpsellNotice()).toEqual({
tone: 'border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900 text-amber-800 dark:text-amber-200',
title: 'Pro options available',
@@ -46,6 +46,12 @@ describe('pricingHandoff', () => {
expect(getUpgradeFallbackDestination('rbac')).toBe(SELF_HOSTED_PRO_BILLING_PLAN_HREF);
});
it('does not keep retired trial-expired as an owned in-product billing intent', () => {
expect(getPricingRouteDestination('?feature=trial_expired')).toBe(
getSelfHostedPurchaseStartUrl('trial_expired', new URLSearchParams('feature=trial_expired')),
);
});
it('routes unknown feature upgrades to Pulse Account purchase start', () => {
expect(getUpgradeFallbackDestination('unknown_pro_feature')).toBe(
getSelfHostedPurchaseStartUrl('unknown_pro_feature'),
@@ -630,7 +630,6 @@ export const getCommercialMigrationNotice = (
if (!migration?.state) return null;
const actionText = getCommercialMigrationActionText(migration.recommended_action);
const blockedText = 'A new Pro trial stays blocked until this is resolved.';
if (migration.state === 'pending') {
let body =
@@ -651,7 +650,7 @@ export const getCommercialMigrationNotice = (
return {
tone: 'border-amber-200 dark:border-amber-900 bg-amber-50 dark:bg-amber-900 text-amber-900 dark:text-amber-100',
title: 'v5 license migration pending',
body: `${body} ${actionText} ${blockedText}`,
body: `${body} ${actionText}`,
};
}
@@ -680,7 +679,7 @@ export const getCommercialMigrationNotice = (
return {
tone: 'border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-900 text-red-900 dark:text-red-100',
title: 'v5 license migration needs attention',
body: `${body} ${actionText} ${blockedText}`,
body: `${body} ${actionText}`,
};
};
@@ -780,13 +779,6 @@ export const getNoActiveProLicenseState = (): LicenseLoadingStateCopy => ({
text: 'No Pro license is active.',
});
export const getTrialEndedProLicenseNotice = (): LicenseActionNotice => ({
tone: 'border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-900 text-red-900 dark:text-red-100',
title: 'Your Pro trial has ended',
body: 'Choose a paid plan to keep root-cause analysis, safe remediation workflows, and 90-day history on this instance.',
actionLabel: 'View Pro plans',
});
export const getInactiveProUpsellNotice = (): LicenseActionNotice => ({
tone: 'border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900 text-amber-800 dark:text-amber-200',
title: 'Pro options available',
@@ -63,7 +63,6 @@ const IN_PRODUCT_PRICING_DESTINATIONS: Record<string, string> = {
advanced_sso: SELF_HOSTED_PRO_BILLING_PLAN_HREF,
agent_profiles: SELF_HOSTED_PRO_BILLING_PLAN_HREF,
long_term_metrics: SELF_HOSTED_PRO_BILLING_PLAN_HREF,
trial_expired: SELF_HOSTED_PRO_BILLING_PLAN_HREF,
};
const INTERNAL_HREF_BASE = 'https://pulse.invalid';
+1 -1
View File
@@ -5327,7 +5327,7 @@ func TestContract_SelfHostedCommunityEntitlementsJSONSnapshot(t *testing.T) {
"valid":false,
"is_lifetime":false,
"days_remaining":0,
"trial_eligible":true,
"trial_eligible":false,
"max_history_days":7,
"overflow_days_remaining":14,
"legacy_connections":{"proxmox_nodes":0,"docker_hosts":0,"kubernetes_clusters":0},
+11 -11
View File
@@ -548,7 +548,7 @@ func TestHandleRuntimeCapabilities_HostedCommunityEvaluatorStateStripsLegacyComm
}
}
func TestEntitlementHandler_TrialEligibility_FreshOrgAllowed(t *testing.T) {
func TestEntitlementHandler_SelfHostedTrialEligibilityRetiredForFreshOrg(t *testing.T) {
baseDir := t.TempDir()
mtp := config.NewMultiTenantPersistence(baseDir)
h := NewLicenseHandlers(mtp, false)
@@ -569,8 +569,8 @@ func TestEntitlementHandler_TrialEligibility_FreshOrgAllowed(t *testing.T) {
if payload.SubscriptionState != string(license.SubStateActive) {
t.Fatalf("subscription_state=%q, want %q", payload.SubscriptionState, license.SubStateActive)
}
if !payload.TrialEligible {
t.Fatalf("trial_eligible=%v, want true", payload.TrialEligible)
if payload.TrialEligible {
t.Fatalf("trial_eligible=%v, want false", payload.TrialEligible)
}
if payload.TrialEligibilityReason != "" {
t.Fatalf("trial_eligibility_reason=%q, want empty", payload.TrialEligibilityReason)
@@ -622,8 +622,8 @@ func TestEntitlementHandler_OverflowOnlyBillingStateReportsActiveCommunity(t *te
if payload.SubscriptionState != string(license.SubStateActive) {
t.Fatalf("subscription_state=%q, want %q", payload.SubscriptionState, license.SubStateActive)
}
if !payload.TrialEligible {
t.Fatalf("trial_eligible=%v, want true", payload.TrialEligible)
if payload.TrialEligible {
t.Fatalf("trial_eligible=%v, want false", payload.TrialEligible)
}
if payload.MonitoredSystemCapacity == nil || payload.MonitoredSystemCapacity.Limit != 0 || payload.MonitoredSystemCapacity.BlocksNewSystems {
t.Fatalf("expected uncapped monitored_system_capacity, got %+v", payload.MonitoredSystemCapacity)
@@ -709,7 +709,7 @@ func TestEntitlementHandler_DevModeIncludesMultiTenantWhenRuntimeEnabled(t *test
}
}
func TestEntitlementHandler_TrialEligibility_AlreadyUsedDenied(t *testing.T) {
func TestEntitlementHandler_ExpiredTrialStateDoesNotExposeStartReason(t *testing.T) {
baseDir := t.TempDir()
mtp := config.NewMultiTenantPersistence(baseDir)
orgID := "default"
@@ -746,12 +746,12 @@ func TestEntitlementHandler_TrialEligibility_AlreadyUsedDenied(t *testing.T) {
if payload.TrialEligible {
t.Fatalf("trial_eligible=%v, want false", payload.TrialEligible)
}
if payload.TrialEligibilityReason != "already_used" {
t.Fatalf("trial_eligibility_reason=%q, want %q", payload.TrialEligibilityReason, "already_used")
if payload.TrialEligibilityReason != "" {
t.Fatalf("trial_eligibility_reason=%q, want empty", payload.TrialEligibilityReason)
}
}
func TestEntitlementHandler_CommercialMigrationBlocksTrialEligibility(t *testing.T) {
func TestEntitlementHandler_CommercialMigrationDoesNotExposeTrialStartReason(t *testing.T) {
baseDir := t.TempDir()
mtp := config.NewMultiTenantPersistence(baseDir)
orgID := "default"
@@ -795,8 +795,8 @@ func TestEntitlementHandler_CommercialMigrationBlocksTrialEligibility(t *testing
if payload.TrialEligible {
t.Fatalf("trial_eligible=%v, want false", payload.TrialEligible)
}
if payload.TrialEligibilityReason != "commercial_migration_pending" {
t.Fatalf("trial_eligibility_reason=%q, want %q", payload.TrialEligibilityReason, "commercial_migration_pending")
if payload.TrialEligibilityReason != "" {
t.Fatalf("trial_eligibility_reason=%q, want empty", payload.TrialEligibilityReason)
}
}
+2 -2
View File
@@ -1556,8 +1556,8 @@ func TestHandleClearLicense_ClearsActiveTrialButPreservesTrialUsedMarker(t *test
if payload.TrialEligible {
t.Fatalf("payload.trial_eligible=%v, want false", payload.TrialEligible)
}
if payload.TrialEligibilityReason != "already_used" {
t.Fatalf("payload.trial_eligibility_reason=%q, want %q", payload.TrialEligibilityReason, "already_used")
if payload.TrialEligibilityReason != "" {
t.Fatalf("payload.trial_eligibility_reason=%q, want empty", payload.TrialEligibilityReason)
}
}
-11
View File
@@ -35,8 +35,6 @@ type licenseFeaturesResponse = pkglicensing.LicenseFeaturesResponse
type activateLicenseRequestModel = pkglicensing.ActivateLicenseRequest
type activateLicenseResponseModel = pkglicensing.ActivateLicenseResponse
type activationStateModel = pkglicensing.ActivationState
type trialStartDecisionModel = pkglicensing.TrialStartDecision
type trialStartDenialReasonModel = pkglicensing.TrialStartDenialReason
type billingStoreModel = pkglicensing.BillingStore
type billingState = pkglicensing.BillingState
type subscriptionState = pkglicensing.SubscriptionState
@@ -92,7 +90,6 @@ const (
activationKeyPrefixValue = pkglicensing.ActivationKeyPrefix
// Conversion event type constants for backend-emitted events.
conversionEventTrialStarted = pkglicensing.EventTrialStarted
conversionEventLicenseActivated = pkglicensing.EventLicenseActivated
conversionEventLicenseActivationFailed = pkglicensing.EventLicenseActivationFailed
conversionEventCheckoutStarted = pkglicensing.EventCheckoutStarted
@@ -152,14 +149,6 @@ func buildTrialBillingStateWithPlanFromLicensing(now time.Time, capabilities []s
return pkglicensing.BuildTrialBillingStateWithPlan(now, capabilities, planVersion, duration)
}
func evaluateTrialStartEligibilityFromLicensing(hasActiveLicense bool, existing *billingState) trialStartDecisionModel {
return pkglicensing.EvaluateTrialStartEligibility(hasActiveLicense, existing)
}
func trialStartErrorFromLicensing(reason trialStartDenialReasonModel) (code, message string, includeOrgID bool) {
return pkglicensing.TrialStartError(reason)
}
func newLicensePersistenceFromLicensing(configDir string) (*licensePersistence, error) {
return pkglicensing.NewPersistence(configDir)
}
@@ -315,8 +315,8 @@ func TestGetTenantComponents_PersistsCommercialMigrationState_WhenAutoExchangeFa
if payload.TrialEligible {
t.Fatalf("trial_eligible=%v, want false", payload.TrialEligible)
}
if payload.TrialEligibilityReason != "commercial_migration_pending" {
t.Fatalf("trial_eligibility_reason=%q, want %q", payload.TrialEligibilityReason, "commercial_migration_pending")
if payload.TrialEligibilityReason != "" {
t.Fatalf("trial_eligibility_reason=%q, want empty", payload.TrialEligibilityReason)
}
handlers.StopAllBackgroundLoops()
+2 -30
View File
@@ -19,7 +19,7 @@ type EntitlementPayload = entitlementPayloadModel
type RuntimeCapabilitiesPayload = runtimeCapabilitiesPayloadModel
// CommercialPosturePayload is the canonical non-billing commercial response
// for upgrade/trial posture and monitored-system migration guidance.
// for upgrade posture and monitored-system migration guidance.
type CommercialPosturePayload = commercialPosturePayloadModel
// LimitStatus represents a quantitative limit with current usage state.
@@ -72,13 +72,12 @@ func (h *LicenseHandlers) buildCommercialEntitlementPayload(
if existing != nil {
payload.CommercialMigration = cloneCommercialMigrationStatusFromLicensing(existing.CommercialMigration)
}
payload.TrialEligible, payload.TrialEligibilityReason = h.trialStartEligibility(ctx, svc, existing)
payload.HostedMode = h != nil && h.hostedMode
return payload, nil
}
// HandleEntitlements returns the normalized entitlement payload for the current tenant.
// This is the commercial entitlement endpoint for billing, trial, and upgrade presentation.
// This is the commercial entitlement endpoint for billing, activation, and upgrade presentation.
func (h *LicenseHandlers) HandleEntitlements(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed", nil)
@@ -417,30 +416,3 @@ func (h *LicenseHandlers) billingStateForContext(ctx context.Context) *billingSt
}
return existing
}
func (h *LicenseHandlers) trialStartEligibility(ctx context.Context, svc *licenseService, existing *billingState) (eligible bool, reason string) {
if h == nil || h.mtPersistence == nil {
return false, "unavailable"
}
orgID := GetOrgID(ctx)
if orgID == "" {
orgID = "default"
}
if existing == nil {
billingStore := config.NewFileBillingStore(h.mtPersistence.BaseDataDir())
loaded, err := billingStore.GetBillingState(orgID)
if err != nil {
return false, "unavailable"
}
existing = loaded
}
hasActiveLicense := svc != nil && svc.Current() != nil && svc.IsValid()
decision := evaluateTrialStartEligibilityFromLicensing(hasActiveLicense, existing)
if decision.Allowed {
return true, ""
}
return false, string(decision.Reason)
}
+7 -5
View File
@@ -57,10 +57,12 @@ type EntitlementPayload struct {
// GracePeriodEnd is the RFC3339 grace period end timestamp when available.
GracePeriodEnd *string `json:"grace_period_end,omitempty"`
// TrialEligible indicates whether this org can start a self-serve trial right now.
// TrialEligible is retained for compatibility with retired self-hosted trial clients.
// New self-hosted payloads leave it false; active trial state is exposed through
// SubscriptionState, TrialExpiresAt, and TrialDaysRemaining.
TrialEligible bool `json:"trial_eligible"`
// TrialEligibilityReason is set when trial start is denied.
// TrialEligibilityReason is retained for compatibility with retired self-hosted trial clients.
TrialEligibilityReason string `json:"trial_eligibility_reason,omitempty"`
// MaxHistoryDays is the maximum metrics history retention in days for the current tier.
@@ -95,7 +97,7 @@ type EntitlementPayload struct {
}
// CommercialPosturePayload is the canonical non-billing commercial contract
// for upgrade messaging, trial posture, and monitored-system migration copy.
// for upgrade messaging and monitored-system migration copy.
// It intentionally excludes billing identity, grandfathered plan terms, and
// other full-entitlement details that belong only to billing surfaces.
type CommercialPosturePayload struct {
@@ -114,10 +116,10 @@ type CommercialPosturePayload struct {
// TrialDaysRemaining is the number of whole or partial days remaining in trial.
TrialDaysRemaining *int `json:"trial_days_remaining,omitempty"`
// TrialEligible indicates whether this org can start a self-serve trial right now.
// TrialEligible is retained for compatibility with retired self-hosted trial clients.
TrialEligible bool `json:"trial_eligible"`
// TrialEligibilityReason is set when trial start is denied.
// TrialEligibilityReason is retained for compatibility with retired self-hosted trial clients.
TrialEligibilityReason string `json:"trial_eligibility_reason,omitempty"`
// OverflowDaysRemaining is set when the onboarding overflow (+1 host) is active.
@@ -86,6 +86,12 @@ func TestBuildEntitlementPayload_FreeTier(t *testing.T) {
if len(payload.UpgradeReasons) == 0 {
t.Fatalf("expected upgrade reasons for free tier")
}
if payload.TrialEligible {
t.Fatal("expected retired self-hosted trial eligibility to stay false")
}
if payload.TrialEligibilityReason != "" {
t.Fatalf("TrialEligibilityReason=%q, want empty", payload.TrialEligibilityReason)
}
for _, reason := range payload.UpgradeReasons {
if reason.ActionURL == "" {
t.Fatalf("expected action_url for reason %q", reason.Key)
-59
View File
@@ -7,65 +7,6 @@ import (
const DefaultTrialDuration = 14 * 24 * time.Hour
type TrialStartDenialReason string
const (
TrialStartAllowed TrialStartDenialReason = ""
TrialStartDeniedLicense TrialStartDenialReason = "license_active"
TrialStartDeniedAlreadyUsed TrialStartDenialReason = "already_used"
TrialStartDeniedSubscription TrialStartDenialReason = "subscription_active"
TrialStartDeniedMigrationPending TrialStartDenialReason = "commercial_migration_pending"
TrialStartDeniedMigrationFailed TrialStartDenialReason = "commercial_migration_failed"
)
type TrialStartDecision struct {
Allowed bool
Reason TrialStartDenialReason
}
func EvaluateTrialStartEligibility(hasActiveLicense bool, existing *BillingState) TrialStartDecision {
if hasActiveLicense {
return TrialStartDecision{Allowed: false, Reason: TrialStartDeniedLicense}
}
if existing == nil {
return TrialStartDecision{Allowed: true, Reason: TrialStartAllowed}
}
if existing.CommercialMigration != nil {
switch existing.CommercialMigration.State {
case CommercialMigrationStatePending:
return TrialStartDecision{Allowed: false, Reason: TrialStartDeniedMigrationPending}
case CommercialMigrationStateFailed:
return TrialStartDecision{Allowed: false, Reason: TrialStartDeniedMigrationFailed}
}
}
if existing.TrialStartedAt != nil {
return TrialStartDecision{Allowed: false, Reason: TrialStartDeniedAlreadyUsed}
}
switch existing.SubscriptionState {
case SubStateActive, SubStateGrace, SubStateSuspended:
return TrialStartDecision{Allowed: false, Reason: TrialStartDeniedSubscription}
default:
return TrialStartDecision{Allowed: true, Reason: TrialStartAllowed}
}
}
func TrialStartError(reason TrialStartDenialReason) (code, message string, includeOrgID bool) {
switch reason {
case TrialStartDeniedLicense:
return "trial_not_available", "Trial cannot be started while a license is active", false
case TrialStartDeniedAlreadyUsed:
return "trial_already_used", "Trial has already been used for this organization", true
case TrialStartDeniedSubscription:
return "trial_not_available", "Trial cannot be started while a subscription is active", true
case TrialStartDeniedMigrationPending:
return "trial_not_available", "Trial cannot be started while a paid v5 license migration is pending", false
case TrialStartDeniedMigrationFailed:
return "trial_not_available", "Trial cannot be started until the paid v5 license migration is resolved", false
default:
return "", "", false
}
}
func TrialWindow(now time.Time, duration time.Duration) (startedAt, endsAt int64) {
if duration <= 0 {
duration = DefaultTrialDuration
-163
View File
@@ -5,169 +5,6 @@ import (
"time"
)
func TestEvaluateTrialStartEligibility(t *testing.T) {
now := time.Unix(1700000000, 0)
started := now.Add(-24 * time.Hour).Unix()
tests := []struct {
name string
hasActiveLicense bool
existing *BillingState
wantAllowed bool
wantReason TrialStartDenialReason
}{
{
name: "denied when license active",
hasActiveLicense: true,
existing: nil,
wantAllowed: false,
wantReason: TrialStartDeniedLicense,
},
{
name: "allowed with no state",
hasActiveLicense: false,
existing: nil,
wantAllowed: true,
wantReason: TrialStartAllowed,
},
{
name: "denied when trial already used",
hasActiveLicense: false,
existing: &BillingState{TrialStartedAt: &started},
wantAllowed: false,
wantReason: TrialStartDeniedAlreadyUsed,
},
{
name: "denied on active subscription",
hasActiveLicense: false,
existing: &BillingState{SubscriptionState: SubStateActive},
wantAllowed: false,
wantReason: TrialStartDeniedSubscription,
},
{
name: "denied on grace subscription",
hasActiveLicense: false,
existing: &BillingState{SubscriptionState: SubStateGrace},
wantAllowed: false,
wantReason: TrialStartDeniedSubscription,
},
{
name: "denied on suspended subscription",
hasActiveLicense: false,
existing: &BillingState{SubscriptionState: SubStateSuspended},
wantAllowed: false,
wantReason: TrialStartDeniedSubscription,
},
{
name: "denied when v5 migration is pending",
hasActiveLicense: false,
existing: &BillingState{CommercialMigration: &CommercialMigrationStatus{
Source: CommercialMigrationSourceV5License,
State: CommercialMigrationStatePending,
}},
wantAllowed: false,
wantReason: TrialStartDeniedMigrationPending,
},
{
name: "denied when v5 migration failed",
hasActiveLicense: false,
existing: &BillingState{CommercialMigration: &CommercialMigrationStatus{
Source: CommercialMigrationSourceV5License,
State: CommercialMigrationStateFailed,
}},
wantAllowed: false,
wantReason: TrialStartDeniedMigrationFailed,
},
{
name: "allowed on expired subscription",
hasActiveLicense: false,
existing: &BillingState{SubscriptionState: SubStateExpired},
wantAllowed: true,
wantReason: TrialStartAllowed,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := EvaluateTrialStartEligibility(tt.hasActiveLicense, tt.existing)
if got.Allowed != tt.wantAllowed {
t.Fatalf("allowed=%t, want %t", got.Allowed, tt.wantAllowed)
}
if got.Reason != tt.wantReason {
t.Fatalf("reason=%q, want %q", got.Reason, tt.wantReason)
}
})
}
}
func TestTrialStartError(t *testing.T) {
tests := []struct {
name string
reason TrialStartDenialReason
wantCode string
wantMessage string
wantIncludeOrgID bool
}{
{
name: "license active",
reason: TrialStartDeniedLicense,
wantCode: "trial_not_available",
wantMessage: "Trial cannot be started while a license is active",
wantIncludeOrgID: false,
},
{
name: "already used",
reason: TrialStartDeniedAlreadyUsed,
wantCode: "trial_already_used",
wantMessage: "Trial has already been used for this organization",
wantIncludeOrgID: true,
},
{
name: "subscription active",
reason: TrialStartDeniedSubscription,
wantCode: "trial_not_available",
wantMessage: "Trial cannot be started while a subscription is active",
wantIncludeOrgID: true,
},
{
name: "migration pending",
reason: TrialStartDeniedMigrationPending,
wantCode: "trial_not_available",
wantMessage: "Trial cannot be started while a paid v5 license migration is pending",
wantIncludeOrgID: false,
},
{
name: "migration failed",
reason: TrialStartDeniedMigrationFailed,
wantCode: "trial_not_available",
wantMessage: "Trial cannot be started until the paid v5 license migration is resolved",
wantIncludeOrgID: false,
},
{
name: "unknown",
reason: TrialStartAllowed,
wantCode: "",
wantMessage: "",
wantIncludeOrgID: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
code, message, includeOrgID := TrialStartError(tt.reason)
if code != tt.wantCode {
t.Fatalf("code=%q, want %q", code, tt.wantCode)
}
if message != tt.wantMessage {
t.Fatalf("message=%q, want %q", message, tt.wantMessage)
}
if includeOrgID != tt.wantIncludeOrgID {
t.Fatalf("includeOrgID=%t, want %t", includeOrgID, tt.wantIncludeOrgID)
}
})
}
}
func TestTrialWindow(t *testing.T) {
now := time.Unix(1700000000, 0)
@@ -21,7 +21,6 @@ type EntitlementPayload = {
type ExpectedCopy = {
title: RegExp;
bodyFragments: RegExp[];
trialReason: string;
};
const expectedState = process.env.PULSE_E2E_EXPECT_COMMERCIAL_MIGRATION_STATE || '';
@@ -98,8 +97,7 @@ function expectedCopyFor(state: string, reason: string, action: string): Expecte
return {
title: /v5 license migration pending/i,
bodyFragments: [reasonFragment, actionFragment, /new pro trial stays blocked/i],
trialReason: 'commercial_migration_pending',
bodyFragments: [reasonFragment, actionFragment],
};
}
@@ -118,8 +116,7 @@ function expectedCopyFor(state: string, reason: string, action: string): Expecte
return {
title: /v5 license migration needs attention/i,
bodyFragments: [reasonFragment, actionFragment, /new pro trial stays blocked/i],
trialReason: 'commercial_migration_failed',
bodyFragments: [reasonFragment, actionFragment],
};
}
@@ -173,7 +170,7 @@ test.describe.serial('v5 commercial migration notice', () => {
expect(entitlements.commercial_migration?.reason).toBe(expectedReason);
expect(entitlements.commercial_migration?.recommended_action).toBe(expectedAction);
expect(entitlements.trial_eligible).toBe(false);
expect(entitlements.trial_eligibility_reason).toBe(expectedCopy.trialReason);
expect(entitlements.trial_eligibility_reason || '').toBe('');
});
test('Pro settings renders the expected migration state', async ({ page }) => {
@@ -1,13 +1,13 @@
import { expect, test } from '@playwright/test';
import { ensureAuthenticated } from './helpers';
const FREE_TRIAL_ELIGIBLE_ENTITLEMENTS = {
const FREE_SELF_HOSTED_ENTITLEMENTS = {
capabilities: [],
limits: [],
subscription_state: 'active',
upgrade_reasons: [],
tier: 'free',
trial_eligible: true,
trial_eligible: false,
};
const SELF_HOSTED_SECURITY_STATUS = {
@@ -72,7 +72,7 @@ test.describe.serial('Self-hosted paid prompt visibility', () => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(FREE_TRIAL_ELIGIBLE_ENTITLEMENTS),
body: JSON.stringify(FREE_SELF_HOSTED_ENTITLEMENTS),
});
});
@@ -80,7 +80,7 @@ test.describe.serial('Self-hosted paid prompt visibility', () => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(FREE_TRIAL_ELIGIBLE_ENTITLEMENTS),
body: JSON.stringify(FREE_SELF_HOSTED_ENTITLEMENTS),
});
});
@@ -8,7 +8,7 @@ const INACTIVE_ENTITLEMENTS = {
subscription_state: 'expired',
upgrade_reasons: [],
tier: 'free',
trial_eligible: true,
trial_eligible: false,
hosted_mode: false,
valid: false,
};
@@ -56,7 +56,7 @@ const INACTIVE_COMMERCIAL_POSTURE = {
subscription_state: 'expired',
upgrade_reasons: [],
tier: 'free',
trial_eligible: true,
trial_eligible: false,
monitored_system_capacity: null,
monitored_system_continuity: null,
has_migration_gap: false,