mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 18:44:53 +00:00
parent
238545b207
commit
f4feccc151
7 changed files with 322 additions and 107 deletions
|
|
@ -1214,7 +1214,12 @@ adjacency must come from the shared self-hosted plan-definition owner rather
|
|||
than page-local strings. That owner must also keep Community, Relay, and Pro
|
||||
framed as unlimited core monitoring plus their bundle-specific extras rather
|
||||
than drifting back to bounded monitored-system sales copy, vague `systems`, or
|
||||
device-style language. Pro+ may appear only as a legacy continuity tier.
|
||||
device-style language. The same shared owner must also provide the billing
|
||||
surface bundle summary and metric-history facts for current uncapped
|
||||
self-hosted tiers, so `commercialBillingModel.ts` and
|
||||
`useProLicensePanelState.ts` do not regress back to legacy guest-capacity or
|
||||
meter-style entitlement fields on retail Community, Relay, or Pro plans. Pro+
|
||||
may appear only as a legacy continuity tier.
|
||||
The shared license presentation owner also holds self-hosted Pro settings
|
||||
trial-ended notice copy for `ProLicensePlanSection.tsx`; that surface must
|
||||
consume canonical helper notices instead of carrying inline upgrade copy or
|
||||
|
|
|
|||
|
|
@ -306,7 +306,7 @@ describe('ProLicensePanel', () => {
|
|||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Included Monitored Systems')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Max Guests')).toBeInTheDocument();
|
||||
expect(screen.getByText('Guest Capacity')).toBeInTheDocument();
|
||||
expect(screen.getByText('Core Monitoring')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Monitored Systems')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Capacity Status')).not.toBeInTheDocument();
|
||||
|
|
@ -363,7 +363,7 @@ describe('ProLicensePanel', () => {
|
|||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Capacity Status')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Included Monitored Systems')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Max Guests')).toBeInTheDocument();
|
||||
expect(screen.getByText('Guest Capacity')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Monitoring capacity')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('tab', { name: 'Usage' })).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText('Unlimited').length).toBeGreaterThan(0);
|
||||
|
|
@ -373,6 +373,35 @@ describe('ProLicensePanel', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('uses shared current-plan metadata for uncapped retail self-hosted tiers', async () => {
|
||||
mockEntitlements = {
|
||||
capabilities: ['relay', 'mobile_app', 'push_notifications', 'ai_patrol', 'ai_autofix'],
|
||||
limits: [],
|
||||
subscription_state: 'active',
|
||||
upgrade_reasons: [],
|
||||
tier: 'pro',
|
||||
plan_version: 'pro_monthly',
|
||||
licensed_email: 'owner@example.com',
|
||||
trial_eligible: false,
|
||||
};
|
||||
|
||||
renderPanel();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Metric History')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Core Monitoring')).toBeInTheDocument();
|
||||
expect(screen.getByText('Metric History')).toBeInTheDocument();
|
||||
expect(screen.getByText('Included Extras')).toBeInTheDocument();
|
||||
expect(screen.getByText('90 days')).toBeInTheDocument();
|
||||
expect(screen.getByText('AI operations and advanced admin')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Guest Capacity')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Included Monitored Systems')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Monitoring capacity')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('tab', { name: 'Usage' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows continuity verification while a bounded fallback migration is still pending', async () => {
|
||||
mockEntitlements = {
|
||||
capabilities: ['relay'],
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import {
|
|||
type SelfHostedBillingSection,
|
||||
} from '@/utils/pricingHandoff';
|
||||
import { buildSelfHostedCommercialPlanModel } from '@/utils/commercialBillingModel';
|
||||
import { getSelfHostedPlanDefinitionForBillingTier } from '@/utils/selfHostedPlans';
|
||||
import {
|
||||
buildMonitoredSystemCapacitySectionModel,
|
||||
getMonitoredSystemLimitCapacityStatusSummary,
|
||||
|
|
@ -328,6 +329,18 @@ export function useProLicensePanelState() {
|
|||
const monitoredSystemCapacityPosture = createMemo(() =>
|
||||
resolveMonitoredSystemCapacityStatus(monitoredSystemCapacity(), monitoredSystemLimitStatus()),
|
||||
);
|
||||
const currentRetailPlanDefinition = createMemo(() => {
|
||||
if (monitoredSystemContinuity()) {
|
||||
return null;
|
||||
}
|
||||
if (uncappedGrandfatheredPlan()) {
|
||||
return null;
|
||||
}
|
||||
if ((monitoredSystemCapacityPosture()?.limit ?? 0) > 0) {
|
||||
return null;
|
||||
}
|
||||
return getSelfHostedPlanDefinitionForBillingTier(entitlements()?.tier);
|
||||
});
|
||||
const monitoredSystemContinuityNotice = createMemo(() =>
|
||||
getMonitoredSystemContinuityNotice(
|
||||
monitoredSystemContinuity(),
|
||||
|
|
@ -437,6 +450,16 @@ export function useProLicensePanelState() {
|
|||
const state = subscriptionState();
|
||||
return state === 'active' || state === 'trial' || state === 'grace';
|
||||
});
|
||||
const showGuestCapacity = createMemo(() => {
|
||||
if (currentRetailPlanDefinition()) {
|
||||
return false;
|
||||
}
|
||||
if (uncappedGrandfatheredPlan()) {
|
||||
return true;
|
||||
}
|
||||
const guestLimit = limitStatus('max_guests')?.limit;
|
||||
return typeof guestLimit === 'number' && guestLimit > 0;
|
||||
});
|
||||
|
||||
const commercialPlanModel = createMemo(() =>
|
||||
buildSelfHostedCommercialPlanModel({
|
||||
|
|
@ -453,10 +476,12 @@ export function useProLicensePanelState() {
|
|||
limitStatus('max_monitored_systems')!.limit > 0
|
||||
? limitStatus('max_monitored_systems')!.limit
|
||||
: 'Unlimited',
|
||||
maxGuests:
|
||||
guestCapacity:
|
||||
typeof limitStatus('max_guests')?.limit === 'number' && limitStatus('max_guests')!.limit > 0
|
||||
? limitStatus('max_guests')!.limit
|
||||
: 'Unlimited',
|
||||
retailPlanDefinition: currentRetailPlanDefinition(),
|
||||
showGuestCapacity: showGuestCapacity(),
|
||||
monitoredSystemContinuity: monitoredSystemContinuity() ?? null,
|
||||
continuityCapturedAt: continuityCapturedAt(),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSelfHostedCommercialPlanModel } from '../commercialBillingModel';
|
||||
import { SELF_HOSTED_PLAN_BY_TIER } from '../selfHostedPlans';
|
||||
|
||||
const createBaseInput = () => ({
|
||||
licensedEmail: 'owner@example.com',
|
||||
statusLabel: 'Active',
|
||||
tierLabel: 'Pro',
|
||||
planTerms: 'Pro Monthly',
|
||||
expires: '12/31/2026',
|
||||
daysRemaining: 123,
|
||||
monitoredSystemsSummary: 'Unlimited',
|
||||
capacityStatusSummary: 'Unlimited',
|
||||
maxMonitoredSystems: 'Unlimited' as const,
|
||||
guestCapacity: 'Unlimited' as const,
|
||||
});
|
||||
|
||||
describe('commercialBillingModel', () => {
|
||||
it('uses shared current retail plan metadata for uncapped self-hosted plans', () => {
|
||||
const model = buildSelfHostedCommercialPlanModel({
|
||||
...createBaseInput(),
|
||||
retailPlanDefinition: SELF_HOSTED_PLAN_BY_TIER.pro,
|
||||
showGuestCapacity: false,
|
||||
});
|
||||
|
||||
expect(model.summary).toEqual([
|
||||
{ label: 'Core Monitoring', value: 'Unlimited' },
|
||||
{ label: 'Metric History', value: '90 days' },
|
||||
{ label: 'Included Extras', value: 'AI operations and advanced admin' },
|
||||
]);
|
||||
expect(model.details.map((item) => item.label)).toEqual([
|
||||
'Tier',
|
||||
'Licensed Email',
|
||||
'Plan Terms',
|
||||
'Expires',
|
||||
'Days Remaining',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps guest capacity visible for uncapped grandfathered continuity states', () => {
|
||||
const model = buildSelfHostedCommercialPlanModel({
|
||||
...createBaseInput(),
|
||||
planTerms: 'V5 Pro Monthly (Grandfathered)',
|
||||
retailPlanDefinition: null,
|
||||
showGuestCapacity: true,
|
||||
});
|
||||
|
||||
expect(model.summary).toEqual([
|
||||
{ label: 'Core Monitoring', value: 'Unlimited' },
|
||||
{ label: 'Guest Capacity', value: 'Unlimited' },
|
||||
{ label: 'Plan Status', value: 'Active' },
|
||||
]);
|
||||
expect(model.details.map((item) => item.label)).not.toContain('Included Monitored Systems');
|
||||
});
|
||||
|
||||
it('keeps bounded monitored-system details on legacy fallback paths', () => {
|
||||
const model = buildSelfHostedCommercialPlanModel({
|
||||
...createBaseInput(),
|
||||
monitoredSystemsSummary: '7 monitored systems',
|
||||
capacityStatusSummary: '3 remaining',
|
||||
maxMonitoredSystems: 10,
|
||||
guestCapacity: 50,
|
||||
retailPlanDefinition: null,
|
||||
showGuestCapacity: true,
|
||||
});
|
||||
|
||||
expect(model.summary).toEqual([
|
||||
{ label: 'Monitored Systems', value: '7 monitored systems' },
|
||||
{ label: 'Capacity Status', value: '3 remaining' },
|
||||
{ label: 'Plan Status', value: 'Active' },
|
||||
]);
|
||||
expect(model.details.map((item) => item.label)).toContain('Included Monitored Systems');
|
||||
expect(model.details.map((item) => item.label)).not.toContain('Guest Capacity');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getSelfHostedPlanDefinitionForBillingTier,
|
||||
SELF_HOSTED_COMMERCIAL_PRESENTATION,
|
||||
SELF_HOSTED_FEATURE_ROWS,
|
||||
SELF_HOSTED_PLAN_BY_TIER,
|
||||
|
|
@ -57,6 +58,15 @@ describe('selfHostedPlans', () => {
|
|||
|
||||
it('keeps Community quickstart copy scoped to Patrol activation support', () => {
|
||||
expect(SELF_HOSTED_PLAN_BY_TIER.community.subline).toBe('Unlimited self-hosted monitoring');
|
||||
expect(SELF_HOSTED_PLAN_BY_TIER.community.billingExtrasSummary).toBe(
|
||||
'Patrol, alerts, and OIDC',
|
||||
);
|
||||
expect(SELF_HOSTED_PLAN_BY_TIER.relay.billingExtrasSummary).toBe(
|
||||
'Remote access, mobile, and push',
|
||||
);
|
||||
expect(SELF_HOSTED_PLAN_BY_TIER.pro.billingExtrasSummary).toBe(
|
||||
'AI operations and advanced admin',
|
||||
);
|
||||
expect(SELF_HOSTED_PLAN_BY_TIER.community.highlights).toEqual(
|
||||
expect.arrayContaining([
|
||||
'Unlimited self-hosted monitoring',
|
||||
|
|
@ -70,4 +80,20 @@ describe('selfHostedPlans', () => {
|
|||
'Patrol quickstart: 25 runs, no API key',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps current billing tiers back to the shared self-hosted plan definitions', () => {
|
||||
expect(getSelfHostedPlanDefinitionForBillingTier('free')).toBe(
|
||||
SELF_HOSTED_PLAN_BY_TIER.community,
|
||||
);
|
||||
expect(getSelfHostedPlanDefinitionForBillingTier('community')).toBe(
|
||||
SELF_HOSTED_PLAN_BY_TIER.community,
|
||||
);
|
||||
expect(getSelfHostedPlanDefinitionForBillingTier('relay')).toBe(
|
||||
SELF_HOSTED_PLAN_BY_TIER.relay,
|
||||
);
|
||||
expect(getSelfHostedPlanDefinitionForBillingTier('pro')).toBe(
|
||||
SELF_HOSTED_PLAN_BY_TIER.pro,
|
||||
);
|
||||
expect(getSelfHostedPlanDefinitionForBillingTier('enterprise')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { LicenseStatus } from '@/api/license';
|
||||
import type { MonitoredSystemContinuityStatus } from '@/api/license';
|
||||
import type { SelfHostedPlanDefinition } from '@/utils/selfHostedPlans';
|
||||
|
||||
export interface CommercialStatValue {
|
||||
label: string;
|
||||
|
|
@ -32,7 +33,12 @@ export interface SelfHostedCommercialModelInput {
|
|||
monitoredSystemsSummary: string | number;
|
||||
capacityStatusSummary: string | number;
|
||||
maxMonitoredSystems: string | number;
|
||||
maxGuests: string | number;
|
||||
guestCapacity: string | number;
|
||||
retailPlanDefinition?: Pick<
|
||||
SelfHostedPlanDefinition,
|
||||
'billingExtrasSummary' | 'metricHistoryDays'
|
||||
> | null;
|
||||
showGuestCapacity?: boolean;
|
||||
monitoredSystemContinuity?: MonitoredSystemContinuityStatus | null;
|
||||
continuityCapturedAt?: string;
|
||||
}
|
||||
|
|
@ -57,110 +63,138 @@ const asUnlimitedLimit = (value?: number) =>
|
|||
const hasFiniteSelfHostedLimit = (value: string | number) =>
|
||||
typeof value === 'number' ? value > 0 : value.trim().toLowerCase() !== 'unlimited';
|
||||
|
||||
const buildSelfHostedBaseDetails = (
|
||||
input: SelfHostedCommercialModelInput,
|
||||
): CommercialPlanViewModel['details'] => [
|
||||
{
|
||||
label: 'Tier',
|
||||
value: input.tierLabel,
|
||||
},
|
||||
{
|
||||
label: 'Licensed Email',
|
||||
value: input.licensedEmail || 'Not available',
|
||||
},
|
||||
...(input.planTerms
|
||||
? [
|
||||
{
|
||||
label: 'Plan Terms',
|
||||
value: input.planTerms,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Expires',
|
||||
value: input.expires,
|
||||
},
|
||||
{
|
||||
label: 'Days Remaining',
|
||||
value: input.daysRemaining,
|
||||
},
|
||||
];
|
||||
|
||||
export const buildSelfHostedCommercialPlanModel = (
|
||||
input: SelfHostedCommercialModelInput,
|
||||
): CommercialPlanViewModel => ({
|
||||
summary:
|
||||
!input.monitoredSystemContinuity && !hasFiniteSelfHostedLimit(input.maxMonitoredSystems)
|
||||
? [
|
||||
{
|
||||
label: 'Core Monitoring',
|
||||
value: 'Unlimited',
|
||||
},
|
||||
{
|
||||
label: 'Plan Status',
|
||||
value: input.statusLabel,
|
||||
},
|
||||
{
|
||||
label: 'Guests',
|
||||
value: input.maxGuests,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: 'Monitored Systems',
|
||||
value: input.monitoredSystemsSummary,
|
||||
},
|
||||
{
|
||||
label: 'Capacity Status',
|
||||
value: input.capacityStatusSummary,
|
||||
},
|
||||
{
|
||||
label: 'Plan Status',
|
||||
value: input.statusLabel,
|
||||
},
|
||||
],
|
||||
details: [
|
||||
{
|
||||
label: 'Tier',
|
||||
value: input.tierLabel,
|
||||
},
|
||||
{
|
||||
label: 'Licensed Email',
|
||||
value: input.licensedEmail || 'Not available',
|
||||
},
|
||||
...(input.planTerms
|
||||
? [
|
||||
{
|
||||
label: 'Plan Terms',
|
||||
value: input.planTerms,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Expires',
|
||||
value: input.expires,
|
||||
},
|
||||
{
|
||||
label: 'Days Remaining',
|
||||
value: input.daysRemaining,
|
||||
},
|
||||
...(input.monitoredSystemContinuity
|
||||
? [
|
||||
{
|
||||
label: 'Plan Monitored System Limit',
|
||||
value:
|
||||
input.monitoredSystemContinuity.plan_limit > 0
|
||||
? input.monitoredSystemContinuity.plan_limit
|
||||
: 'Unlimited',
|
||||
},
|
||||
{
|
||||
label: 'Effective Monitored System Limit',
|
||||
value:
|
||||
input.monitoredSystemContinuity.effective_limit > 0
|
||||
? input.monitoredSystemContinuity.effective_limit
|
||||
: 'Unlimited',
|
||||
},
|
||||
...(typeof input.monitoredSystemContinuity.grandfathered_floor === 'number' &&
|
||||
input.monitoredSystemContinuity.grandfathered_floor > 0
|
||||
? [
|
||||
{
|
||||
label: 'Grandfathered Floor',
|
||||
value: input.monitoredSystemContinuity.grandfathered_floor,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Continuity Capture',
|
||||
value: input.monitoredSystemContinuity.capture_pending
|
||||
? 'Pending'
|
||||
: input.continuityCapturedAt || 'Captured',
|
||||
},
|
||||
]
|
||||
: hasFiniteSelfHostedLimit(input.maxMonitoredSystems)
|
||||
? [
|
||||
{
|
||||
label: 'Included Monitored Systems',
|
||||
value: input.maxMonitoredSystems,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Max Guests',
|
||||
value: input.maxGuests,
|
||||
},
|
||||
],
|
||||
});
|
||||
): CommercialPlanViewModel => {
|
||||
if (input.retailPlanDefinition) {
|
||||
return {
|
||||
summary: [
|
||||
{
|
||||
label: 'Core Monitoring',
|
||||
value: 'Unlimited',
|
||||
},
|
||||
{
|
||||
label: 'Metric History',
|
||||
value: `${input.retailPlanDefinition.metricHistoryDays} days`,
|
||||
},
|
||||
{
|
||||
label: 'Included Extras',
|
||||
value: input.retailPlanDefinition.billingExtrasSummary,
|
||||
},
|
||||
],
|
||||
details: buildSelfHostedBaseDetails(input),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
summary:
|
||||
!input.monitoredSystemContinuity && !hasFiniteSelfHostedLimit(input.maxMonitoredSystems)
|
||||
? [
|
||||
{
|
||||
label: 'Core Monitoring',
|
||||
value: 'Unlimited',
|
||||
},
|
||||
...(input.showGuestCapacity
|
||||
? [
|
||||
{
|
||||
label: 'Guest Capacity',
|
||||
value: input.guestCapacity,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Plan Status',
|
||||
value: input.statusLabel,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: 'Monitored Systems',
|
||||
value: input.monitoredSystemsSummary,
|
||||
},
|
||||
{
|
||||
label: 'Capacity Status',
|
||||
value: input.capacityStatusSummary,
|
||||
},
|
||||
{
|
||||
label: 'Plan Status',
|
||||
value: input.statusLabel,
|
||||
},
|
||||
],
|
||||
details: [
|
||||
...buildSelfHostedBaseDetails(input),
|
||||
...(input.monitoredSystemContinuity
|
||||
? [
|
||||
{
|
||||
label: 'Plan Monitored System Limit',
|
||||
value:
|
||||
input.monitoredSystemContinuity.plan_limit > 0
|
||||
? input.monitoredSystemContinuity.plan_limit
|
||||
: 'Unlimited',
|
||||
},
|
||||
{
|
||||
label: 'Effective Monitored System Limit',
|
||||
value:
|
||||
input.monitoredSystemContinuity.effective_limit > 0
|
||||
? input.monitoredSystemContinuity.effective_limit
|
||||
: 'Unlimited',
|
||||
},
|
||||
...(typeof input.monitoredSystemContinuity.grandfathered_floor === 'number' &&
|
||||
input.monitoredSystemContinuity.grandfathered_floor > 0
|
||||
? [
|
||||
{
|
||||
label: 'Grandfathered Floor',
|
||||
value: input.monitoredSystemContinuity.grandfathered_floor,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Continuity Capture',
|
||||
value: input.monitoredSystemContinuity.capture_pending
|
||||
? 'Pending'
|
||||
: input.continuityCapturedAt || 'Captured',
|
||||
},
|
||||
]
|
||||
: hasFiniteSelfHostedLimit(input.maxMonitoredSystems)
|
||||
? [
|
||||
{
|
||||
label: 'Included Monitored Systems',
|
||||
value: input.maxMonitoredSystems,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const buildHostedCommercialPlanModel = (
|
||||
input: HostedCommercialModelInput,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export interface SelfHostedPlanDefinition {
|
|||
price: string;
|
||||
subline: string;
|
||||
metricHistoryDays: number;
|
||||
billingExtrasSummary: string;
|
||||
highlights: readonly string[];
|
||||
}
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] =
|
|||
price: 'Free forever',
|
||||
subline: 'Unlimited self-hosted monitoring',
|
||||
metricHistoryDays: 7,
|
||||
billingExtrasSummary: 'Patrol, alerts, and OIDC',
|
||||
highlights: [
|
||||
'Real-time monitoring',
|
||||
'Unlimited self-hosted monitoring',
|
||||
|
|
@ -61,6 +63,7 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] =
|
|||
price: '$4.99/month',
|
||||
subline: 'or $39/year',
|
||||
metricHistoryDays: 14,
|
||||
billingExtrasSummary: 'Remote access, mobile, and push',
|
||||
highlights: [
|
||||
'Everything in Community',
|
||||
'Remote access via Relay',
|
||||
|
|
@ -76,6 +79,7 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] =
|
|||
price: '$8.99/month',
|
||||
subline: 'or $79/year',
|
||||
metricHistoryDays: 90,
|
||||
billingExtrasSummary: 'AI operations and advanced admin',
|
||||
highlights: [
|
||||
'Everything in Relay',
|
||||
'Patrol Auto-Fix & investigation',
|
||||
|
|
@ -95,6 +99,22 @@ export const SELF_HOSTED_PLAN_BY_TIER: Record<SelfHostedTierKey, SelfHostedPlanD
|
|||
pro: SELF_HOSTED_PLAN_DEFINITIONS[2],
|
||||
};
|
||||
|
||||
export function getSelfHostedPlanDefinitionForBillingTier(
|
||||
tier?: string | null,
|
||||
): SelfHostedPlanDefinition | null {
|
||||
switch ((tier || '').trim().toLowerCase()) {
|
||||
case 'community':
|
||||
case 'free':
|
||||
return SELF_HOSTED_PLAN_BY_TIER.community;
|
||||
case 'relay':
|
||||
return SELF_HOSTED_PLAN_BY_TIER.relay;
|
||||
case 'pro':
|
||||
return SELF_HOSTED_PLAN_BY_TIER.pro;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const SELF_HOSTED_COMMERCIAL_PRESENTATION: SelfHostedCommercialPresentation = {
|
||||
pageTitle: 'Pricing',
|
||||
pageDescription:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue