feat: add billing plan foundations and refresh LocaleScope pricing
Introduce a shared pricing and entitlement model so plan metadata can drive public pricing and future subscription enforcement. Rebrand the product to LocaleScope and align the UI copy around market intelligence and business research workflows.
This commit is contained in:
@@ -0,0 +1,486 @@
|
||||
import type { ActivePlanCode, BillingInterval, FeatureReadiness, PlanCode, PlanFeatures } from './plans.js';
|
||||
import { getPlanByCode } from './plans.js';
|
||||
|
||||
// Public pricing stays expressed as research runs per month, but internal accounting
|
||||
// uses research credits so future actions can consume variable amounts.
|
||||
|
||||
export type UsageSubjectType = 'user';
|
||||
|
||||
export type UsageResource = 'research_credits' | 'exports' | 'enrichments' | 'api_requests';
|
||||
|
||||
export type UsageAllowanceAvailability = 'included' | 'not_available' | 'custom' | 'unlimited';
|
||||
|
||||
export type UsageAction =
|
||||
| 'basic_search_run'
|
||||
| 'deep_research_preview'
|
||||
| 'deep_research_batch_run'
|
||||
| 'csv_export'
|
||||
| 'enrichment_run'
|
||||
| 'api_request';
|
||||
|
||||
export type EntitlementDecisionStatus =
|
||||
| 'allowed'
|
||||
| 'blocked_upgrade_required'
|
||||
| 'blocked_addon_available'
|
||||
| 'contact_sales_required';
|
||||
|
||||
export type EntitlementDenialReason = 'feature_not_available' | 'quota_exhausted' | 'custom_enterprise_only' | 'not_launch_ready';
|
||||
|
||||
export interface UsageSubject {
|
||||
type: UsageSubjectType;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface UsageAmount {
|
||||
resource: UsageResource;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface UsageAllowance {
|
||||
resource: UsageResource;
|
||||
included: number | null;
|
||||
billingInterval: BillingInterval | null;
|
||||
pooled: boolean;
|
||||
availability: UsageAllowanceAvailability;
|
||||
}
|
||||
|
||||
export interface PlanUsageAllowance {
|
||||
planCode: ActivePlanCode;
|
||||
billingInterval: BillingInterval;
|
||||
researchCredits: UsageAllowance;
|
||||
exports: UsageAllowance;
|
||||
enrichments: UsageAllowance;
|
||||
apiRequests: UsageAllowance;
|
||||
}
|
||||
|
||||
export interface UsageCostEstimate {
|
||||
action: UsageAction;
|
||||
amounts: UsageAmount[];
|
||||
isChargeable: boolean;
|
||||
details?: Record<string, number | string | boolean | null>;
|
||||
}
|
||||
|
||||
export interface ActionPolicy {
|
||||
action: UsageAction;
|
||||
chargeable: boolean;
|
||||
consumedResources: UsageResource[];
|
||||
requiredFeatures: Array<keyof PlanFeatures>;
|
||||
readinessFeatures: Array<keyof PlanFeatures>;
|
||||
}
|
||||
|
||||
export interface EntitlementDecision {
|
||||
status: EntitlementDecisionStatus;
|
||||
denialReason: EntitlementDenialReason | null;
|
||||
action: UsageAction;
|
||||
resource: UsageResource;
|
||||
requiredAmount: number;
|
||||
remainingAmount: number | null;
|
||||
currentPlanCode: ActivePlanCode;
|
||||
suggestedUpgradePlanCode: ActivePlanCode | null;
|
||||
addonEligible: boolean;
|
||||
contactSalesRequired: boolean;
|
||||
}
|
||||
|
||||
export interface DeepResearchBatchUsageInput {
|
||||
estimatedChildJobs?: number | null;
|
||||
totalAreas?: number | null;
|
||||
}
|
||||
|
||||
export interface ActionEntitlementEvaluationInput {
|
||||
planCode: ActivePlanCode;
|
||||
action: UsageAction;
|
||||
resource: UsageResource;
|
||||
requiredAmount: number;
|
||||
remainingAmount: number | null;
|
||||
}
|
||||
|
||||
const actionPolicies: Record<UsageAction, ActionPolicy> = {
|
||||
basic_search_run: {
|
||||
action: 'basic_search_run',
|
||||
chargeable: true,
|
||||
consumedResources: ['research_credits'],
|
||||
requiredFeatures: [],
|
||||
readinessFeatures: [],
|
||||
},
|
||||
deep_research_preview: {
|
||||
action: 'deep_research_preview',
|
||||
chargeable: false,
|
||||
consumedResources: [],
|
||||
requiredFeatures: ['territoryMapping'],
|
||||
readinessFeatures: ['territoryMapping'],
|
||||
},
|
||||
deep_research_batch_run: {
|
||||
action: 'deep_research_batch_run',
|
||||
chargeable: true,
|
||||
consumedResources: ['research_credits'],
|
||||
requiredFeatures: ['territoryMapping'],
|
||||
readinessFeatures: ['territoryMapping'],
|
||||
},
|
||||
csv_export: {
|
||||
action: 'csv_export',
|
||||
chargeable: true,
|
||||
consumedResources: ['exports'],
|
||||
requiredFeatures: ['csvExport'],
|
||||
readinessFeatures: ['csvExport'],
|
||||
},
|
||||
enrichment_run: {
|
||||
action: 'enrichment_run',
|
||||
chargeable: true,
|
||||
consumedResources: ['enrichments'],
|
||||
requiredFeatures: ['enrichments'],
|
||||
readinessFeatures: ['enrichments'],
|
||||
},
|
||||
api_request: {
|
||||
action: 'api_request',
|
||||
chargeable: true,
|
||||
consumedResources: ['api_requests'],
|
||||
requiredFeatures: ['apiAccess'],
|
||||
readinessFeatures: ['apiAccess'],
|
||||
},
|
||||
};
|
||||
|
||||
export function isChargeableAction(action: UsageAction) {
|
||||
return actionPolicies[action].chargeable;
|
||||
}
|
||||
|
||||
export function getActionPolicy(action: UsageAction) {
|
||||
return actionPolicies[action];
|
||||
}
|
||||
|
||||
export function getIncludedResearchCredits(planCode: ActivePlanCode) {
|
||||
const plan = getRequiredActivePlan(planCode);
|
||||
return plan.limits.researchRunsPerMonth;
|
||||
}
|
||||
|
||||
export function getIncludedExports(planCode: ActivePlanCode) {
|
||||
const plan = getRequiredActivePlan(planCode);
|
||||
return plan.limits.exportsPerMonth;
|
||||
}
|
||||
|
||||
export function getUsageAllowanceForPlan(planCode: ActivePlanCode): PlanUsageAllowance {
|
||||
const plan = getRequiredActivePlan(planCode);
|
||||
|
||||
return {
|
||||
planCode,
|
||||
billingInterval: plan.billingInterval,
|
||||
researchCredits: {
|
||||
resource: 'research_credits',
|
||||
included: plan.limits.researchRunsPerMonth,
|
||||
billingInterval: plan.billingInterval,
|
||||
pooled: plan.limits.pooledUsage,
|
||||
availability: plan.limits.researchRunsPerMonth === null ? 'custom' : 'included',
|
||||
},
|
||||
exports: {
|
||||
resource: 'exports',
|
||||
included: plan.limits.exportsPerMonth,
|
||||
billingInterval: plan.billingInterval,
|
||||
pooled: plan.limits.pooledUsage,
|
||||
availability: plan.limits.exportsPerMonth === null ? 'custom' : 'included',
|
||||
},
|
||||
enrichments: {
|
||||
resource: 'enrichments',
|
||||
included: plan.limits.enrichmentCreditsIncluded,
|
||||
billingInterval: plan.billingInterval,
|
||||
pooled: plan.limits.pooledUsage,
|
||||
availability: plan.features.enrichments ? (plan.limits.enrichmentCreditsIncluded === null ? 'custom' : 'included') : 'not_available',
|
||||
},
|
||||
apiRequests: {
|
||||
resource: 'api_requests',
|
||||
included: null,
|
||||
billingInterval: plan.billingInterval,
|
||||
pooled: plan.limits.pooledUsage,
|
||||
availability: plan.features.apiAccess ? 'custom' : 'not_available',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function estimateBasicSearchCost(): UsageCostEstimate {
|
||||
return {
|
||||
action: 'basic_search_run',
|
||||
amounts: [{ resource: 'research_credits', amount: 1 }],
|
||||
isChargeable: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function estimateDeepResearchPreviewCost(): UsageCostEstimate {
|
||||
return {
|
||||
action: 'deep_research_preview',
|
||||
amounts: [],
|
||||
isChargeable: false,
|
||||
details: { estimatedChargeableRuns: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
export function estimateDeepResearchBatchCost(input: DeepResearchBatchUsageInput): UsageCostEstimate {
|
||||
const estimatedChildJobs = normalizePositiveWholeNumber(input.estimatedChildJobs);
|
||||
const totalAreas = normalizePositiveWholeNumber(input.totalAreas);
|
||||
const amount = estimatedChildJobs ?? totalAreas ?? 0;
|
||||
const estimateConfidence = estimatedChildJobs !== null ? 'high' : totalAreas !== null ? 'fallback' : 'unknown';
|
||||
|
||||
return {
|
||||
action: 'deep_research_batch_run',
|
||||
amounts: [{ resource: 'research_credits', amount }],
|
||||
isChargeable: amount > 0,
|
||||
details: {
|
||||
estimatedChildJobs: estimatedChildJobs ?? null,
|
||||
totalAreas: totalAreas ?? null,
|
||||
billingBasis: estimatedChildJobs !== null ? 'estimated_child_jobs' : totalAreas !== null ? 'total_areas' : 'unknown',
|
||||
estimateConfidence,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function estimateExportUsage(rowCount: number): UsageCostEstimate {
|
||||
const amount = Math.max(0, Math.floor(rowCount));
|
||||
|
||||
return {
|
||||
action: 'csv_export',
|
||||
amounts: [{ resource: 'exports', amount }],
|
||||
isChargeable: amount > 0,
|
||||
details: { exportedRows: amount },
|
||||
};
|
||||
}
|
||||
|
||||
export function canAllowanceCoverCost(allowance: UsageAllowance, amount: number) {
|
||||
if (amount <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (allowance.availability === 'not_available') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (allowance.availability === 'custom' || allowance.availability === 'unlimited') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (allowance.included === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return allowance.included >= amount;
|
||||
}
|
||||
|
||||
// Quantity-only helper. This does not check whether the action itself is enabled for the plan.
|
||||
export function canPlanCoverEstimatedCost(planCode: ActivePlanCode, estimate: UsageCostEstimate) {
|
||||
if (!estimate.isChargeable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const allowance = getUsageAllowanceForPlan(planCode);
|
||||
|
||||
return estimate.amounts.every((usageAmount) => {
|
||||
const matchingAllowance = getAllowanceByResource(allowance, usageAmount.resource);
|
||||
return canAllowanceCoverCost(matchingAllowance, usageAmount.amount);
|
||||
});
|
||||
}
|
||||
|
||||
export function isFeatureAvailableForAction(planCode: ActivePlanCode, action: UsageAction) {
|
||||
const plan = getRequiredActivePlan(planCode);
|
||||
const policy = getActionPolicy(action);
|
||||
|
||||
return policy.requiredFeatures.every((featureKey) => plan.features[featureKey]);
|
||||
}
|
||||
|
||||
export function canPlanUseAction(planCode: ActivePlanCode, action: UsageAction) {
|
||||
const plan = getRequiredActivePlan(planCode);
|
||||
const policy = getActionPolicy(action);
|
||||
|
||||
if (!isFeatureAvailableForAction(planCode, action)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return policy.readinessFeatures.every((featureKey) => {
|
||||
const readiness = getFeatureReadiness(plan.featureReadiness[featureKey]);
|
||||
return readiness !== 'future';
|
||||
});
|
||||
}
|
||||
|
||||
export function buildEntitlementDecision(input: {
|
||||
planCode: ActivePlanCode;
|
||||
action: UsageAction;
|
||||
resource: UsageResource;
|
||||
requiredAmount: number;
|
||||
remainingAmount: number | null;
|
||||
}): EntitlementDecision {
|
||||
const plan = getRequiredActivePlan(input.planCode);
|
||||
const addonEligible = hasEligibleAddonForResource(plan.code, input.resource);
|
||||
const actionFeatureAvailable = isFeatureAvailableForAction(plan.code, input.action);
|
||||
const actionReady = canPlanUseAction(plan.code, input.action);
|
||||
const canCover = input.remainingAmount === null || input.remainingAmount >= input.requiredAmount;
|
||||
|
||||
if (!actionFeatureAvailable) {
|
||||
return {
|
||||
status: plan.contactSalesRequired ? 'contact_sales_required' : 'blocked_upgrade_required',
|
||||
denialReason: 'feature_not_available',
|
||||
action: input.action,
|
||||
resource: input.resource,
|
||||
requiredAmount: input.requiredAmount,
|
||||
remainingAmount: input.remainingAmount,
|
||||
currentPlanCode: plan.code,
|
||||
suggestedUpgradePlanCode: getSuggestedUpgradePlanCode(plan.code, input.resource),
|
||||
addonEligible: false,
|
||||
contactSalesRequired: plan.contactSalesRequired,
|
||||
};
|
||||
}
|
||||
|
||||
if (!actionReady) {
|
||||
return {
|
||||
status: plan.contactSalesRequired ? 'contact_sales_required' : 'blocked_upgrade_required',
|
||||
denialReason: 'not_launch_ready',
|
||||
action: input.action,
|
||||
resource: input.resource,
|
||||
requiredAmount: input.requiredAmount,
|
||||
remainingAmount: input.remainingAmount,
|
||||
currentPlanCode: plan.code,
|
||||
suggestedUpgradePlanCode: getSuggestedUpgradePlanCode(plan.code, input.resource),
|
||||
addonEligible: false,
|
||||
contactSalesRequired: plan.contactSalesRequired,
|
||||
};
|
||||
}
|
||||
|
||||
if (canCover) {
|
||||
return {
|
||||
status: 'allowed',
|
||||
denialReason: null,
|
||||
action: input.action,
|
||||
resource: input.resource,
|
||||
requiredAmount: input.requiredAmount,
|
||||
remainingAmount: input.remainingAmount,
|
||||
currentPlanCode: plan.code,
|
||||
suggestedUpgradePlanCode: null,
|
||||
addonEligible,
|
||||
contactSalesRequired: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!plan.isSelfServe || plan.contactSalesRequired) {
|
||||
return {
|
||||
status: 'contact_sales_required',
|
||||
denialReason: 'custom_enterprise_only',
|
||||
action: input.action,
|
||||
resource: input.resource,
|
||||
requiredAmount: input.requiredAmount,
|
||||
remainingAmount: input.remainingAmount,
|
||||
currentPlanCode: plan.code,
|
||||
suggestedUpgradePlanCode: null,
|
||||
addonEligible,
|
||||
contactSalesRequired: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (addonEligible) {
|
||||
return {
|
||||
status: 'blocked_addon_available',
|
||||
denialReason: 'quota_exhausted',
|
||||
action: input.action,
|
||||
resource: input.resource,
|
||||
requiredAmount: input.requiredAmount,
|
||||
remainingAmount: input.remainingAmount,
|
||||
currentPlanCode: plan.code,
|
||||
suggestedUpgradePlanCode: getSuggestedUpgradePlanCode(plan.code, input.resource),
|
||||
addonEligible: true,
|
||||
contactSalesRequired: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'blocked_upgrade_required',
|
||||
denialReason: 'quota_exhausted',
|
||||
action: input.action,
|
||||
resource: input.resource,
|
||||
requiredAmount: input.requiredAmount,
|
||||
remainingAmount: input.remainingAmount,
|
||||
currentPlanCode: plan.code,
|
||||
suggestedUpgradePlanCode: getSuggestedUpgradePlanCode(plan.code, input.resource),
|
||||
addonEligible: false,
|
||||
contactSalesRequired: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluateActionEntitlement(input: ActionEntitlementEvaluationInput) {
|
||||
return buildEntitlementDecision({
|
||||
planCode: input.planCode,
|
||||
action: input.action,
|
||||
resource: input.resource,
|
||||
requiredAmount: input.requiredAmount,
|
||||
remainingAmount: input.remainingAmount,
|
||||
});
|
||||
}
|
||||
|
||||
function getRequiredActivePlan(planCode: ActivePlanCode) {
|
||||
const plan = getPlanByCode(planCode);
|
||||
|
||||
if (!plan) {
|
||||
throw new Error(`Unknown active plan code: ${planCode}`);
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
function getAllowanceByResource(allowance: PlanUsageAllowance, resource: UsageResource) {
|
||||
switch (resource) {
|
||||
case 'research_credits':
|
||||
return allowance.researchCredits;
|
||||
case 'exports':
|
||||
return allowance.exports;
|
||||
case 'enrichments':
|
||||
return allowance.enrichments;
|
||||
case 'api_requests':
|
||||
return allowance.apiRequests;
|
||||
}
|
||||
}
|
||||
|
||||
function getSuggestedUpgradePlanCode(planCode: ActivePlanCode, resource: UsageResource): ActivePlanCode | null {
|
||||
switch (planCode) {
|
||||
case 'starter_monthly':
|
||||
return resource === 'research_credits' || resource === 'exports' ? 'growth_monthly' : 'growth_monthly';
|
||||
case 'starter_annual':
|
||||
return resource === 'research_credits' || resource === 'exports' ? 'growth_annual' : 'growth_annual';
|
||||
case 'growth_monthly':
|
||||
return 'pro_monthly';
|
||||
case 'growth_annual':
|
||||
return 'pro_annual';
|
||||
case 'pro_monthly':
|
||||
case 'pro_annual':
|
||||
return 'enterprise_custom';
|
||||
case 'enterprise_custom':
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasEligibleAddonForResource(planCode: ActivePlanCode, resource: UsageResource) {
|
||||
const plan = getRequiredActivePlan(planCode);
|
||||
|
||||
switch (resource) {
|
||||
case 'exports':
|
||||
return plan.eligibleAddonCodes.includes('export_pack_10k') || plan.eligibleAddonCodes.includes('export_pack_50k');
|
||||
case 'enrichments':
|
||||
return plan.eligibleAddonCodes.includes('enrichment_pack_1k');
|
||||
case 'research_credits':
|
||||
case 'api_requests':
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getFeatureReadiness(readiness?: FeatureReadiness) {
|
||||
return readiness ?? 'launch_ready';
|
||||
}
|
||||
|
||||
function normalizePositiveWholeNumber(value: number | null | undefined) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rounded = Math.max(0, Math.ceil(value));
|
||||
return rounded > 0 ? rounded : 0;
|
||||
}
|
||||
|
||||
// Reserved plan codes are intentionally excluded from the active policy helpers until
|
||||
// founder/LTD packaging is activated in a later step.
|
||||
export function isActivePlanCodeForEntitlements(planCode: PlanCode): planCode is ActivePlanCode {
|
||||
return getPlanByCode(planCode) !== null;
|
||||
}
|
||||
|
||||
// Export policy is defined here for later backend enforcement, but current CSV export
|
||||
// remains client-side until a server-backed export endpoint exists.
|
||||
@@ -0,0 +1,320 @@
|
||||
import type { UsageAction } from './entitlements.js';
|
||||
import type { ActivePlanCode, FeatureReadiness, PlanFeatures } from './plans.js';
|
||||
import { getPlanByCode } from './plans.js';
|
||||
|
||||
export type FeatureGateState = 'available' | 'upgrade_required' | 'coming_soon' | 'contact_sales' | 'hidden';
|
||||
|
||||
export interface FeatureGateResult {
|
||||
feature: keyof PlanFeatures;
|
||||
state: FeatureGateState;
|
||||
currentPlanCode: ActivePlanCode;
|
||||
upgradePlanCode: ActivePlanCode | null;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface FeatureGatePolicy {
|
||||
enterpriseOnly?: boolean;
|
||||
hiddenWhenUnavailable?: boolean;
|
||||
upgradeTargetByPlan?: Partial<Record<ActivePlanCode, ActivePlanCode | 'enterprise_custom'>>;
|
||||
}
|
||||
|
||||
const featureGatePolicies: Partial<Record<keyof PlanFeatures, FeatureGatePolicy>> = {
|
||||
advancedFilters: {
|
||||
upgradeTargetByPlan: {
|
||||
starter_monthly: 'growth_monthly',
|
||||
starter_annual: 'growth_annual',
|
||||
},
|
||||
},
|
||||
savedSearches: {
|
||||
upgradeTargetByPlan: {
|
||||
starter_monthly: 'growth_monthly',
|
||||
starter_annual: 'growth_annual',
|
||||
},
|
||||
},
|
||||
territoryMapping: {
|
||||
upgradeTargetByPlan: {
|
||||
starter_monthly: 'growth_monthly',
|
||||
starter_annual: 'growth_annual',
|
||||
},
|
||||
},
|
||||
deduplication: {
|
||||
upgradeTargetByPlan: {
|
||||
starter_monthly: 'growth_monthly',
|
||||
starter_annual: 'growth_annual',
|
||||
},
|
||||
},
|
||||
exportHistory: {
|
||||
upgradeTargetByPlan: {
|
||||
starter_monthly: 'growth_monthly',
|
||||
starter_annual: 'growth_annual',
|
||||
},
|
||||
},
|
||||
taggingNotes: {
|
||||
upgradeTargetByPlan: {
|
||||
starter_monthly: 'growth_monthly',
|
||||
starter_annual: 'growth_annual',
|
||||
},
|
||||
},
|
||||
prioritySupport: {
|
||||
upgradeTargetByPlan: {
|
||||
starter_monthly: 'growth_monthly',
|
||||
starter_annual: 'growth_annual',
|
||||
},
|
||||
},
|
||||
sharedLists: {
|
||||
hiddenWhenUnavailable: true,
|
||||
upgradeTargetByPlan: {
|
||||
starter_monthly: 'pro_monthly',
|
||||
starter_annual: 'pro_annual',
|
||||
growth_monthly: 'pro_monthly',
|
||||
growth_annual: 'pro_annual',
|
||||
},
|
||||
},
|
||||
scheduledResearch: {
|
||||
hiddenWhenUnavailable: true,
|
||||
upgradeTargetByPlan: {
|
||||
starter_monthly: 'pro_monthly',
|
||||
starter_annual: 'pro_annual',
|
||||
growth_monthly: 'pro_monthly',
|
||||
growth_annual: 'pro_annual',
|
||||
},
|
||||
},
|
||||
bulkExports: {
|
||||
hiddenWhenUnavailable: true,
|
||||
upgradeTargetByPlan: {
|
||||
starter_monthly: 'pro_monthly',
|
||||
starter_annual: 'pro_annual',
|
||||
growth_monthly: 'pro_monthly',
|
||||
growth_annual: 'pro_annual',
|
||||
},
|
||||
},
|
||||
crmIntegrations: {
|
||||
hiddenWhenUnavailable: true,
|
||||
upgradeTargetByPlan: {
|
||||
starter_monthly: 'pro_monthly',
|
||||
starter_annual: 'pro_annual',
|
||||
growth_monthly: 'pro_monthly',
|
||||
growth_annual: 'pro_annual',
|
||||
},
|
||||
},
|
||||
apiAccess: {
|
||||
hiddenWhenUnavailable: true,
|
||||
upgradeTargetByPlan: {
|
||||
starter_monthly: 'pro_monthly',
|
||||
starter_annual: 'pro_annual',
|
||||
growth_monthly: 'pro_monthly',
|
||||
growth_annual: 'pro_annual',
|
||||
},
|
||||
},
|
||||
webhooks: {
|
||||
hiddenWhenUnavailable: true,
|
||||
upgradeTargetByPlan: {
|
||||
starter_monthly: 'pro_monthly',
|
||||
starter_annual: 'pro_annual',
|
||||
growth_monthly: 'pro_monthly',
|
||||
growth_annual: 'pro_annual',
|
||||
},
|
||||
},
|
||||
collaboration: {
|
||||
hiddenWhenUnavailable: true,
|
||||
upgradeTargetByPlan: {
|
||||
starter_monthly: 'pro_monthly',
|
||||
starter_annual: 'pro_annual',
|
||||
growth_monthly: 'pro_monthly',
|
||||
growth_annual: 'pro_annual',
|
||||
},
|
||||
},
|
||||
enrichments: {
|
||||
hiddenWhenUnavailable: true,
|
||||
upgradeTargetByPlan: {
|
||||
starter_monthly: 'pro_monthly',
|
||||
starter_annual: 'pro_annual',
|
||||
growth_monthly: 'pro_monthly',
|
||||
growth_annual: 'pro_annual',
|
||||
},
|
||||
},
|
||||
sso: { enterpriseOnly: true },
|
||||
sla: { enterpriseOnly: true },
|
||||
whiteLabel: { enterpriseOnly: true },
|
||||
};
|
||||
|
||||
export function getFeatureGate(planCode: ActivePlanCode, feature: keyof PlanFeatures): FeatureGateResult {
|
||||
const plan = getRequiredActivePlan(planCode);
|
||||
const included = plan.features[feature];
|
||||
const readiness = resolveFeatureReadiness(planCode, feature);
|
||||
const upgradePlanCode = getUpgradePlanForFeature(planCode, feature);
|
||||
|
||||
if (included && readiness === 'launch_ready') {
|
||||
return {
|
||||
feature,
|
||||
state: 'available',
|
||||
currentPlanCode: planCode,
|
||||
upgradePlanCode: null,
|
||||
message: getFeatureGateMessageForState('available', feature, null),
|
||||
};
|
||||
}
|
||||
|
||||
if (included && readiness !== 'launch_ready') {
|
||||
const comingSoonState = shouldUseContactSalesForReadyLaterFeature(feature) ? 'contact_sales' : 'coming_soon';
|
||||
|
||||
return {
|
||||
feature,
|
||||
state: comingSoonState,
|
||||
currentPlanCode: planCode,
|
||||
upgradePlanCode: comingSoonState === 'contact_sales' ? 'enterprise_custom' : null,
|
||||
message: getFeatureGateMessageForState(comingSoonState, feature, upgradePlanCode),
|
||||
};
|
||||
}
|
||||
|
||||
if (upgradePlanCode) {
|
||||
const upgradeState = upgradePlanCode === 'enterprise_custom' ? 'contact_sales' : 'upgrade_required';
|
||||
|
||||
return {
|
||||
feature,
|
||||
state: upgradeState,
|
||||
currentPlanCode: planCode,
|
||||
upgradePlanCode,
|
||||
message: getFeatureGateMessageForState(upgradeState, feature, upgradePlanCode),
|
||||
};
|
||||
}
|
||||
|
||||
if (isEnterpriseOnlyFeature(feature)) {
|
||||
return {
|
||||
feature,
|
||||
state: 'contact_sales',
|
||||
currentPlanCode: planCode,
|
||||
upgradePlanCode: 'enterprise_custom',
|
||||
message: getFeatureGateMessageForState('contact_sales', feature, 'enterprise_custom'),
|
||||
};
|
||||
}
|
||||
|
||||
const hidden = shouldHideWhenUnavailable(feature);
|
||||
|
||||
return {
|
||||
feature,
|
||||
state: hidden ? 'hidden' : 'coming_soon',
|
||||
currentPlanCode: planCode,
|
||||
upgradePlanCode: null,
|
||||
message: getFeatureGateMessageForState(hidden ? 'hidden' : 'coming_soon', feature, null),
|
||||
};
|
||||
}
|
||||
|
||||
export function isFeatureAvailable(planCode: ActivePlanCode, feature: keyof PlanFeatures) {
|
||||
return getFeatureGate(planCode, feature).state === 'available';
|
||||
}
|
||||
|
||||
export function getUpgradePlanForFeature(planCode: ActivePlanCode, feature: keyof PlanFeatures): ActivePlanCode | null {
|
||||
const policy = featureGatePolicies[feature];
|
||||
const directTarget = policy?.upgradeTargetByPlan?.[planCode] ?? null;
|
||||
|
||||
if (directTarget) {
|
||||
return directTarget;
|
||||
}
|
||||
|
||||
if (policy?.enterpriseOnly) {
|
||||
return 'enterprise_custom';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getFeatureGateMessage(planCode: ActivePlanCode, feature: keyof PlanFeatures) {
|
||||
const result = getFeatureGate(planCode, feature);
|
||||
return result.message;
|
||||
}
|
||||
|
||||
// territoryMapping currently proxies deep research capability until a dedicated
|
||||
// deep-research feature key exists in the plan catalog.
|
||||
export function getFeatureForAction(action: UsageAction): keyof PlanFeatures | null {
|
||||
switch (action) {
|
||||
case 'basic_search_run':
|
||||
return null;
|
||||
case 'deep_research_preview':
|
||||
case 'deep_research_batch_run':
|
||||
return 'territoryMapping';
|
||||
case 'csv_export':
|
||||
return 'csvExport';
|
||||
case 'enrichment_run':
|
||||
return 'enrichments';
|
||||
case 'api_request':
|
||||
return 'apiAccess';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFeatureReadiness(planCode: ActivePlanCode, feature: keyof PlanFeatures): FeatureReadiness {
|
||||
const plan = getRequiredActivePlan(planCode);
|
||||
return plan.featureReadiness[feature] ?? 'launch_ready';
|
||||
}
|
||||
|
||||
function isEnterpriseOnlyFeature(feature: keyof PlanFeatures) {
|
||||
return featureGatePolicies[feature]?.enterpriseOnly === true;
|
||||
}
|
||||
|
||||
function shouldHideWhenUnavailable(feature: keyof PlanFeatures) {
|
||||
return featureGatePolicies[feature]?.hiddenWhenUnavailable === true;
|
||||
}
|
||||
|
||||
function shouldUseContactSalesForReadyLaterFeature(feature: keyof PlanFeatures) {
|
||||
return isEnterpriseOnlyFeature(feature);
|
||||
}
|
||||
|
||||
function getFeatureGateMessageForState(
|
||||
state: FeatureGateState,
|
||||
feature: keyof PlanFeatures,
|
||||
upgradePlanCode: ActivePlanCode | null,
|
||||
) {
|
||||
switch (state) {
|
||||
case 'available':
|
||||
return null;
|
||||
case 'upgrade_required':
|
||||
return upgradePlanCode ? `Upgrade to ${getRequiredActivePlan(upgradePlanCode).name} to access ${formatFeatureName(feature)}.` : `Upgrade to access ${formatFeatureName(feature)}.`;
|
||||
case 'coming_soon':
|
||||
return `${formatFeatureName(feature)} is included in this tier and coming soon.`;
|
||||
case 'contact_sales':
|
||||
return `Contact sales to access ${formatFeatureName(feature)}.`;
|
||||
case 'hidden':
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getRequiredActivePlan(planCode: ActivePlanCode) {
|
||||
const plan = getPlanByCode(planCode);
|
||||
|
||||
if (!plan) {
|
||||
throw new Error(`Unknown active plan code: ${planCode}`);
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
function formatFeatureName(feature: keyof PlanFeatures) {
|
||||
const names: Record<keyof PlanFeatures, string> = {
|
||||
csvExport: 'CSV export',
|
||||
mapSearch: 'map search',
|
||||
radiusSearch: 'radius search',
|
||||
basicFilters: 'basic filters',
|
||||
advancedFilters: 'advanced filters',
|
||||
savedSearches: 'saved searches',
|
||||
territoryMapping: 'territory mapping',
|
||||
deduplication: 'deduplication',
|
||||
exportHistory: 'export history',
|
||||
taggingNotes: 'tagging and notes',
|
||||
sharedLists: 'shared lists',
|
||||
scheduledResearch: 'scheduled research',
|
||||
bulkExports: 'bulk exports',
|
||||
crmIntegrations: 'CRM integrations',
|
||||
apiAccess: 'API access',
|
||||
webhooks: 'webhooks',
|
||||
collaboration: 'collaboration features',
|
||||
enrichments: 'enrichments',
|
||||
prioritySupport: 'priority support',
|
||||
sso: 'SSO',
|
||||
sla: 'SLA support',
|
||||
whiteLabel: 'white-labeling',
|
||||
};
|
||||
|
||||
return names[feature];
|
||||
}
|
||||
|
||||
// This module interprets plan feature availability and rollout state. It does not
|
||||
// perform route enforcement or usage quota checks.
|
||||
@@ -0,0 +1,607 @@
|
||||
export type PlanTier = 'starter' | 'growth' | 'pro' | 'enterprise';
|
||||
|
||||
export type BillingInterval = 'monthly' | 'annual' | 'custom';
|
||||
|
||||
export type PlanListingCategory = 'pricing_page_primary' | 'pricing_page_hidden' | 'internal_only';
|
||||
|
||||
export type FeatureReadiness = 'launch_ready' | 'marketed_not_enforced' | 'future';
|
||||
|
||||
export type ActivePlanCode =
|
||||
| 'starter_monthly'
|
||||
| 'starter_annual'
|
||||
| 'growth_monthly'
|
||||
| 'growth_annual'
|
||||
| 'pro_monthly'
|
||||
| 'pro_annual'
|
||||
| 'enterprise_custom';
|
||||
|
||||
export type ReservedPlanCode = 'founder_lifetime' | 'founder_pro_lifetime';
|
||||
|
||||
export type PlanCode = ActivePlanCode | ReservedPlanCode;
|
||||
|
||||
export type AddonCode = 'export_pack_10k' | 'export_pack_50k' | 'enrichment_pack_1k' | 'ai_assistant_monthly' | 'white_label_monthly';
|
||||
|
||||
export type ProcessingTier = 'standard' | 'priority' | 'dedicated';
|
||||
|
||||
export interface PlanLimits {
|
||||
researchRunsPerMonth: number | null;
|
||||
exportsPerMonth: number | null;
|
||||
// These are commercial packaging allowances for now. Hard enforcement comes later
|
||||
// after workspace-scoped ownership and collaboration rules are ready.
|
||||
usersIncluded: number | null;
|
||||
workspacesIncluded: number | null;
|
||||
enrichmentCreditsIncluded: number | null;
|
||||
pooledUsage: boolean;
|
||||
}
|
||||
|
||||
export interface PlanFeatures {
|
||||
csvExport: boolean;
|
||||
mapSearch: boolean;
|
||||
radiusSearch: boolean;
|
||||
basicFilters: boolean;
|
||||
advancedFilters: boolean;
|
||||
savedSearches: boolean;
|
||||
territoryMapping: boolean;
|
||||
deduplication: boolean;
|
||||
exportHistory: boolean;
|
||||
taggingNotes: boolean;
|
||||
sharedLists: boolean;
|
||||
scheduledResearch: boolean;
|
||||
bulkExports: boolean;
|
||||
crmIntegrations: boolean;
|
||||
apiAccess: boolean;
|
||||
webhooks: boolean;
|
||||
collaboration: boolean;
|
||||
enrichments: boolean;
|
||||
prioritySupport: boolean;
|
||||
sso: boolean;
|
||||
sla: boolean;
|
||||
whiteLabel: boolean;
|
||||
}
|
||||
|
||||
export interface PlanDefinition {
|
||||
code: ActivePlanCode;
|
||||
tier: PlanTier;
|
||||
planFamily: PlanTier;
|
||||
name: string;
|
||||
billingInterval: BillingInterval;
|
||||
listingCategory: PlanListingCategory;
|
||||
isSelfServe: boolean;
|
||||
contactSalesRequired: boolean;
|
||||
priceCents: number | null;
|
||||
currencyCode: 'USD';
|
||||
annualDiscountPercent: number | null;
|
||||
limits: PlanLimits;
|
||||
features: PlanFeatures;
|
||||
featureReadiness: Partial<Record<keyof PlanFeatures, FeatureReadiness>>;
|
||||
processingTier: ProcessingTier;
|
||||
eligibleAddonCodes: AddonCode[];
|
||||
}
|
||||
|
||||
export interface PlanDisplayMeta {
|
||||
audience: string;
|
||||
summary: string;
|
||||
ctaLabel: string;
|
||||
ctaMode: 'sign_in' | 'sign_up';
|
||||
badgeLabel?: string;
|
||||
qualitativeBullets: string[];
|
||||
}
|
||||
|
||||
function createFeatureFlags(overrides: Partial<PlanFeatures>): PlanFeatures {
|
||||
return {
|
||||
csvExport: false,
|
||||
mapSearch: false,
|
||||
radiusSearch: false,
|
||||
basicFilters: false,
|
||||
advancedFilters: false,
|
||||
savedSearches: false,
|
||||
territoryMapping: false,
|
||||
deduplication: false,
|
||||
exportHistory: false,
|
||||
taggingNotes: false,
|
||||
sharedLists: false,
|
||||
scheduledResearch: false,
|
||||
bulkExports: false,
|
||||
crmIntegrations: false,
|
||||
apiAccess: false,
|
||||
webhooks: false,
|
||||
collaboration: false,
|
||||
enrichments: false,
|
||||
prioritySupport: false,
|
||||
sso: false,
|
||||
sla: false,
|
||||
whiteLabel: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createPlan(plan: PlanDefinition): PlanDefinition {
|
||||
return plan;
|
||||
}
|
||||
|
||||
const activePlanCatalog = [
|
||||
createPlan({
|
||||
code: 'starter_monthly',
|
||||
tier: 'starter',
|
||||
planFamily: 'starter',
|
||||
name: 'Starter',
|
||||
billingInterval: 'monthly',
|
||||
listingCategory: 'pricing_page_primary',
|
||||
isSelfServe: true,
|
||||
contactSalesRequired: false,
|
||||
priceCents: 3900,
|
||||
currencyCode: 'USD',
|
||||
annualDiscountPercent: null,
|
||||
limits: {
|
||||
researchRunsPerMonth: 25,
|
||||
exportsPerMonth: 2500,
|
||||
usersIncluded: 1,
|
||||
workspacesIncluded: 1,
|
||||
enrichmentCreditsIncluded: 0,
|
||||
pooledUsage: false,
|
||||
},
|
||||
features: createFeatureFlags({
|
||||
csvExport: true,
|
||||
mapSearch: true,
|
||||
radiusSearch: true,
|
||||
basicFilters: true,
|
||||
}),
|
||||
featureReadiness: {},
|
||||
processingTier: 'standard',
|
||||
eligibleAddonCodes: ['export_pack_10k', 'export_pack_50k'],
|
||||
}),
|
||||
createPlan({
|
||||
code: 'starter_annual',
|
||||
tier: 'starter',
|
||||
planFamily: 'starter',
|
||||
name: 'Starter',
|
||||
billingInterval: 'annual',
|
||||
listingCategory: 'pricing_page_hidden',
|
||||
isSelfServe: true,
|
||||
contactSalesRequired: false,
|
||||
priceCents: 37440,
|
||||
currencyCode: 'USD',
|
||||
annualDiscountPercent: 20,
|
||||
limits: {
|
||||
researchRunsPerMonth: 25,
|
||||
exportsPerMonth: 2500,
|
||||
usersIncluded: 1,
|
||||
workspacesIncluded: 1,
|
||||
enrichmentCreditsIncluded: 0,
|
||||
pooledUsage: false,
|
||||
},
|
||||
features: createFeatureFlags({
|
||||
csvExport: true,
|
||||
mapSearch: true,
|
||||
radiusSearch: true,
|
||||
basicFilters: true,
|
||||
}),
|
||||
featureReadiness: {},
|
||||
processingTier: 'standard',
|
||||
eligibleAddonCodes: ['export_pack_10k', 'export_pack_50k'],
|
||||
}),
|
||||
createPlan({
|
||||
code: 'growth_monthly',
|
||||
tier: 'growth',
|
||||
planFamily: 'growth',
|
||||
name: 'Growth',
|
||||
billingInterval: 'monthly',
|
||||
listingCategory: 'pricing_page_primary',
|
||||
isSelfServe: true,
|
||||
contactSalesRequired: false,
|
||||
priceCents: 9900,
|
||||
currencyCode: 'USD',
|
||||
annualDiscountPercent: null,
|
||||
limits: {
|
||||
researchRunsPerMonth: 150,
|
||||
exportsPerMonth: 15000,
|
||||
usersIncluded: 3,
|
||||
workspacesIncluded: 5,
|
||||
enrichmentCreditsIncluded: 0,
|
||||
pooledUsage: false,
|
||||
},
|
||||
features: createFeatureFlags({
|
||||
csvExport: true,
|
||||
mapSearch: true,
|
||||
radiusSearch: true,
|
||||
basicFilters: true,
|
||||
advancedFilters: true,
|
||||
savedSearches: true,
|
||||
territoryMapping: true,
|
||||
deduplication: true,
|
||||
exportHistory: true,
|
||||
taggingNotes: true,
|
||||
prioritySupport: true,
|
||||
}),
|
||||
featureReadiness: {
|
||||
savedSearches: 'marketed_not_enforced',
|
||||
territoryMapping: 'launch_ready',
|
||||
deduplication: 'marketed_not_enforced',
|
||||
exportHistory: 'marketed_not_enforced',
|
||||
taggingNotes: 'marketed_not_enforced',
|
||||
},
|
||||
processingTier: 'priority',
|
||||
eligibleAddonCodes: ['export_pack_10k', 'export_pack_50k', 'enrichment_pack_1k'],
|
||||
}),
|
||||
createPlan({
|
||||
code: 'growth_annual',
|
||||
tier: 'growth',
|
||||
planFamily: 'growth',
|
||||
name: 'Growth',
|
||||
billingInterval: 'annual',
|
||||
listingCategory: 'pricing_page_hidden',
|
||||
isSelfServe: true,
|
||||
contactSalesRequired: false,
|
||||
priceCents: 95040,
|
||||
currencyCode: 'USD',
|
||||
annualDiscountPercent: 20,
|
||||
limits: {
|
||||
researchRunsPerMonth: 150,
|
||||
exportsPerMonth: 15000,
|
||||
usersIncluded: 3,
|
||||
workspacesIncluded: 5,
|
||||
enrichmentCreditsIncluded: 0,
|
||||
pooledUsage: false,
|
||||
},
|
||||
features: createFeatureFlags({
|
||||
csvExport: true,
|
||||
mapSearch: true,
|
||||
radiusSearch: true,
|
||||
basicFilters: true,
|
||||
advancedFilters: true,
|
||||
savedSearches: true,
|
||||
territoryMapping: true,
|
||||
deduplication: true,
|
||||
exportHistory: true,
|
||||
taggingNotes: true,
|
||||
prioritySupport: true,
|
||||
}),
|
||||
featureReadiness: {
|
||||
savedSearches: 'marketed_not_enforced',
|
||||
territoryMapping: 'launch_ready',
|
||||
deduplication: 'marketed_not_enforced',
|
||||
exportHistory: 'marketed_not_enforced',
|
||||
taggingNotes: 'marketed_not_enforced',
|
||||
},
|
||||
processingTier: 'priority',
|
||||
eligibleAddonCodes: ['export_pack_10k', 'export_pack_50k', 'enrichment_pack_1k'],
|
||||
}),
|
||||
createPlan({
|
||||
code: 'pro_monthly',
|
||||
tier: 'pro',
|
||||
planFamily: 'pro',
|
||||
name: 'Pro',
|
||||
billingInterval: 'monthly',
|
||||
listingCategory: 'pricing_page_primary',
|
||||
isSelfServe: true,
|
||||
contactSalesRequired: false,
|
||||
priceCents: 24900,
|
||||
currencyCode: 'USD',
|
||||
annualDiscountPercent: null,
|
||||
limits: {
|
||||
researchRunsPerMonth: 500,
|
||||
exportsPerMonth: 75000,
|
||||
usersIncluded: 15,
|
||||
workspacesIncluded: null,
|
||||
enrichmentCreditsIncluded: 0,
|
||||
pooledUsage: false,
|
||||
},
|
||||
features: createFeatureFlags({
|
||||
csvExport: true,
|
||||
mapSearch: true,
|
||||
radiusSearch: true,
|
||||
basicFilters: true,
|
||||
advancedFilters: true,
|
||||
savedSearches: true,
|
||||
territoryMapping: true,
|
||||
deduplication: true,
|
||||
exportHistory: true,
|
||||
taggingNotes: true,
|
||||
sharedLists: true,
|
||||
scheduledResearch: true,
|
||||
bulkExports: true,
|
||||
crmIntegrations: true,
|
||||
apiAccess: true,
|
||||
webhooks: true,
|
||||
collaboration: true,
|
||||
enrichments: true,
|
||||
prioritySupport: true,
|
||||
}),
|
||||
featureReadiness: {
|
||||
savedSearches: 'marketed_not_enforced',
|
||||
deduplication: 'marketed_not_enforced',
|
||||
exportHistory: 'marketed_not_enforced',
|
||||
taggingNotes: 'marketed_not_enforced',
|
||||
sharedLists: 'marketed_not_enforced',
|
||||
scheduledResearch: 'marketed_not_enforced',
|
||||
bulkExports: 'marketed_not_enforced',
|
||||
crmIntegrations: 'marketed_not_enforced',
|
||||
apiAccess: 'marketed_not_enforced',
|
||||
webhooks: 'marketed_not_enforced',
|
||||
collaboration: 'marketed_not_enforced',
|
||||
enrichments: 'marketed_not_enforced',
|
||||
},
|
||||
processingTier: 'priority',
|
||||
eligibleAddonCodes: ['export_pack_10k', 'export_pack_50k', 'enrichment_pack_1k', 'ai_assistant_monthly', 'white_label_monthly'],
|
||||
}),
|
||||
createPlan({
|
||||
code: 'pro_annual',
|
||||
tier: 'pro',
|
||||
planFamily: 'pro',
|
||||
name: 'Pro',
|
||||
billingInterval: 'annual',
|
||||
listingCategory: 'pricing_page_hidden',
|
||||
isSelfServe: true,
|
||||
contactSalesRequired: false,
|
||||
priceCents: 239040,
|
||||
currencyCode: 'USD',
|
||||
annualDiscountPercent: 20,
|
||||
limits: {
|
||||
researchRunsPerMonth: 500,
|
||||
exportsPerMonth: 75000,
|
||||
usersIncluded: 15,
|
||||
workspacesIncluded: null,
|
||||
enrichmentCreditsIncluded: 0,
|
||||
pooledUsage: false,
|
||||
},
|
||||
features: createFeatureFlags({
|
||||
csvExport: true,
|
||||
mapSearch: true,
|
||||
radiusSearch: true,
|
||||
basicFilters: true,
|
||||
advancedFilters: true,
|
||||
savedSearches: true,
|
||||
territoryMapping: true,
|
||||
deduplication: true,
|
||||
exportHistory: true,
|
||||
taggingNotes: true,
|
||||
sharedLists: true,
|
||||
scheduledResearch: true,
|
||||
bulkExports: true,
|
||||
crmIntegrations: true,
|
||||
apiAccess: true,
|
||||
webhooks: true,
|
||||
collaboration: true,
|
||||
enrichments: true,
|
||||
prioritySupport: true,
|
||||
}),
|
||||
featureReadiness: {
|
||||
savedSearches: 'marketed_not_enforced',
|
||||
deduplication: 'marketed_not_enforced',
|
||||
exportHistory: 'marketed_not_enforced',
|
||||
taggingNotes: 'marketed_not_enforced',
|
||||
sharedLists: 'marketed_not_enforced',
|
||||
scheduledResearch: 'marketed_not_enforced',
|
||||
bulkExports: 'marketed_not_enforced',
|
||||
crmIntegrations: 'marketed_not_enforced',
|
||||
apiAccess: 'marketed_not_enforced',
|
||||
webhooks: 'marketed_not_enforced',
|
||||
collaboration: 'marketed_not_enforced',
|
||||
enrichments: 'marketed_not_enforced',
|
||||
},
|
||||
processingTier: 'priority',
|
||||
eligibleAddonCodes: ['export_pack_10k', 'export_pack_50k', 'enrichment_pack_1k', 'ai_assistant_monthly', 'white_label_monthly'],
|
||||
}),
|
||||
createPlan({
|
||||
code: 'enterprise_custom',
|
||||
tier: 'enterprise',
|
||||
planFamily: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
billingInterval: 'custom',
|
||||
listingCategory: 'pricing_page_primary',
|
||||
isSelfServe: false,
|
||||
contactSalesRequired: true,
|
||||
priceCents: null,
|
||||
currencyCode: 'USD',
|
||||
annualDiscountPercent: null,
|
||||
limits: {
|
||||
researchRunsPerMonth: null,
|
||||
exportsPerMonth: null,
|
||||
usersIncluded: null,
|
||||
workspacesIncluded: null,
|
||||
enrichmentCreditsIncluded: null,
|
||||
pooledUsage: true,
|
||||
},
|
||||
features: createFeatureFlags({
|
||||
csvExport: true,
|
||||
mapSearch: true,
|
||||
radiusSearch: true,
|
||||
basicFilters: true,
|
||||
advancedFilters: true,
|
||||
savedSearches: true,
|
||||
territoryMapping: true,
|
||||
deduplication: true,
|
||||
exportHistory: true,
|
||||
taggingNotes: true,
|
||||
sharedLists: true,
|
||||
scheduledResearch: true,
|
||||
bulkExports: true,
|
||||
crmIntegrations: true,
|
||||
apiAccess: true,
|
||||
webhooks: true,
|
||||
collaboration: true,
|
||||
enrichments: true,
|
||||
prioritySupport: true,
|
||||
sso: true,
|
||||
sla: true,
|
||||
whiteLabel: true,
|
||||
}),
|
||||
featureReadiness: {
|
||||
sharedLists: 'marketed_not_enforced',
|
||||
scheduledResearch: 'marketed_not_enforced',
|
||||
bulkExports: 'marketed_not_enforced',
|
||||
crmIntegrations: 'marketed_not_enforced',
|
||||
apiAccess: 'marketed_not_enforced',
|
||||
webhooks: 'marketed_not_enforced',
|
||||
collaboration: 'marketed_not_enforced',
|
||||
enrichments: 'marketed_not_enforced',
|
||||
sso: 'future',
|
||||
sla: 'future',
|
||||
whiteLabel: 'future',
|
||||
},
|
||||
processingTier: 'dedicated',
|
||||
eligibleAddonCodes: ['export_pack_10k', 'export_pack_50k', 'enrichment_pack_1k', 'ai_assistant_monthly', 'white_label_monthly'],
|
||||
}),
|
||||
] as const satisfies readonly PlanDefinition[];
|
||||
|
||||
const activePlanCatalogByCode = new Map(activePlanCatalog.map((plan) => [plan.code, plan]));
|
||||
|
||||
const planDisplayMetaByCode: Record<ActivePlanCode, PlanDisplayMeta> = {
|
||||
starter_monthly: {
|
||||
audience: 'For freelancers and solo operators',
|
||||
summary: 'A focused entry point for recurring local market research.',
|
||||
ctaLabel: 'Choose Starter',
|
||||
ctaMode: 'sign_up',
|
||||
qualitativeBullets: ['CSV export, map search, radius search, and basic filters'],
|
||||
},
|
||||
starter_annual: {
|
||||
audience: 'For freelancers and solo operators',
|
||||
summary: 'A focused entry point for recurring local market research.',
|
||||
ctaLabel: 'Choose Starter',
|
||||
ctaMode: 'sign_up',
|
||||
badgeLabel: 'Save 20%',
|
||||
qualitativeBullets: ['CSV export, map search, radius search, and basic filters'],
|
||||
},
|
||||
growth_monthly: {
|
||||
audience: 'For agencies and outbound teams',
|
||||
summary: 'The best-value plan for repeatable territory workflows.',
|
||||
ctaLabel: 'Choose Growth',
|
||||
ctaMode: 'sign_up',
|
||||
badgeLabel: 'Best Value',
|
||||
qualitativeBullets: ['Saved searches, territory mapping, advanced filtering, deduplication, export history, and priority support'],
|
||||
},
|
||||
growth_annual: {
|
||||
audience: 'For agencies and outbound teams',
|
||||
summary: 'The best-value plan for repeatable territory workflows.',
|
||||
ctaLabel: 'Choose Growth',
|
||||
ctaMode: 'sign_up',
|
||||
badgeLabel: 'Save 20%',
|
||||
qualitativeBullets: ['Saved searches, territory mapping, advanced filtering, deduplication, export history, and priority support'],
|
||||
},
|
||||
pro_monthly: {
|
||||
audience: 'For power users and scaling teams',
|
||||
summary: 'Operational infrastructure for teams that need more scale and automation.',
|
||||
ctaLabel: 'Choose Pro',
|
||||
ctaMode: 'sign_up',
|
||||
qualitativeBullets: ['Scheduled research, bulk exports, CRM integrations, API access, enrichment access, and collaboration features'],
|
||||
},
|
||||
pro_annual: {
|
||||
audience: 'For power users and scaling teams',
|
||||
summary: 'Operational infrastructure for teams that need more scale and automation.',
|
||||
ctaLabel: 'Choose Pro',
|
||||
ctaMode: 'sign_up',
|
||||
badgeLabel: 'Save 20%',
|
||||
qualitativeBullets: ['Scheduled research, bulk exports, CRM integrations, API access, enrichment access, and collaboration features'],
|
||||
},
|
||||
enterprise_custom: {
|
||||
audience: 'For multi-location and enterprise rollouts',
|
||||
summary: 'Custom market intelligence infrastructure for security, governance, and scale.',
|
||||
ctaLabel: 'Talk to sales',
|
||||
ctaMode: 'sign_in',
|
||||
qualitativeBullets: ['SSO, SLA, onboarding, and account management', 'White-labeling, custom enrichments, and custom integrations', 'Dedicated infrastructure and advanced API scaling'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ACTIVE_PLAN_CATALOG = [...activePlanCatalog];
|
||||
|
||||
export function getActivePlanCatalog() {
|
||||
return ACTIVE_PLAN_CATALOG;
|
||||
}
|
||||
|
||||
export function getPlanByCode(code: PlanCode) {
|
||||
return activePlanCatalogByCode.get(code as ActivePlanCode) ?? null;
|
||||
}
|
||||
|
||||
export function getSelfServePlans() {
|
||||
return ACTIVE_PLAN_CATALOG.filter((plan) => plan.isSelfServe);
|
||||
}
|
||||
|
||||
export function getAnnualPlans() {
|
||||
return ACTIVE_PLAN_CATALOG.filter((plan) => plan.billingInterval === 'annual');
|
||||
}
|
||||
|
||||
export function getPublicPricingPlans() {
|
||||
return ACTIVE_PLAN_CATALOG.filter((plan) => plan.listingCategory === 'pricing_page_primary');
|
||||
}
|
||||
|
||||
export function getPlanVariant(tier: PlanTier, billingInterval: BillingInterval) {
|
||||
return ACTIVE_PLAN_CATALOG.find((plan) => plan.planFamily === tier && plan.billingInterval === billingInterval) ?? null;
|
||||
}
|
||||
|
||||
export function getSiblingPlans(code: ActivePlanCode) {
|
||||
const selectedPlan = getPlanByCode(code);
|
||||
|
||||
if (!selectedPlan) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ACTIVE_PLAN_CATALOG.filter((plan) => plan.planFamily === selectedPlan.planFamily);
|
||||
}
|
||||
|
||||
export function isAnnualPlan(code: ActivePlanCode) {
|
||||
return getPlanByCode(code)?.billingInterval === 'annual';
|
||||
}
|
||||
|
||||
export function getPlanDisplayMeta(code: ActivePlanCode) {
|
||||
return planDisplayMetaByCode[code];
|
||||
}
|
||||
|
||||
export function getPlanCardBullets(code: ActivePlanCode) {
|
||||
const plan = getPlanByCode(code);
|
||||
const display = getPlanDisplayMeta(code);
|
||||
|
||||
if (!plan) {
|
||||
return display.qualitativeBullets;
|
||||
}
|
||||
|
||||
const quantitativeBullets = [
|
||||
formatResearchRunsBullet(plan.limits.researchRunsPerMonth),
|
||||
formatExportsBullet(plan.limits.exportsPerMonth),
|
||||
formatSeatAllowanceBullet(plan.limits.usersIncluded, plan.limits.workspacesIncluded, plan.limits.pooledUsage),
|
||||
].filter((bullet): bullet is string => bullet !== null);
|
||||
|
||||
return [...quantitativeBullets, ...display.qualitativeBullets];
|
||||
}
|
||||
|
||||
function formatResearchRunsBullet(researchRunsPerMonth: number | null) {
|
||||
if (researchRunsPerMonth === null) {
|
||||
return 'Pooled or custom research capacity';
|
||||
}
|
||||
|
||||
return `${formatCount(researchRunsPerMonth)} research runs / month`;
|
||||
}
|
||||
|
||||
function formatExportsBullet(exportsPerMonth: number | null) {
|
||||
if (exportsPerMonth === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${formatCount(exportsPerMonth)} exports / month`;
|
||||
}
|
||||
|
||||
function formatSeatAllowanceBullet(usersIncluded: number | null, workspacesIncluded: number | null, pooledUsage: boolean) {
|
||||
if (pooledUsage) {
|
||||
return 'Pooled or custom team usage';
|
||||
}
|
||||
|
||||
if (usersIncluded === null && workspacesIncluded === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (usersIncluded !== null && workspacesIncluded === null) {
|
||||
return usersIncluded === 1 ? '1 user and unlimited workspaces' : `${formatCount(usersIncluded)} users and unlimited workspaces`;
|
||||
}
|
||||
|
||||
if (usersIncluded === null && workspacesIncluded !== null) {
|
||||
return workspacesIncluded === 1 ? '1 workspace' : `${formatCount(workspacesIncluded)} workspaces`;
|
||||
}
|
||||
|
||||
const userLabel = usersIncluded === 1 ? '1 user' : `${formatCount(usersIncluded as number)} users`;
|
||||
const workspaceLabel = workspacesIncluded === 1 ? '1 workspace' : `${formatCount(workspacesIncluded as number)} workspaces`;
|
||||
|
||||
return `${userLabel} and ${workspaceLabel}`;
|
||||
}
|
||||
|
||||
function formatCount(value: number) {
|
||||
return new Intl.NumberFormat('en-US').format(value);
|
||||
}
|
||||
+9
-4
@@ -1,3 +1,5 @@
|
||||
import type { BillingInterval, PlanCode } from './billing/plans.js';
|
||||
|
||||
export type JobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'stopped';
|
||||
|
||||
export interface AppUser {
|
||||
@@ -30,9 +32,12 @@ export interface AccountSummary {
|
||||
totalBusinesses: number;
|
||||
}
|
||||
|
||||
export interface AccountBillingPlaceholder {
|
||||
status: 'not_configured';
|
||||
planName: string | null;
|
||||
export type AccountBillingStatus = 'not_configured' | 'inactive' | 'active' | 'past_due' | 'canceled';
|
||||
|
||||
export interface AccountBillingState {
|
||||
status: AccountBillingStatus;
|
||||
planCode: PlanCode | null;
|
||||
billingInterval: BillingInterval | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -45,7 +50,7 @@ export interface AccountPageData {
|
||||
profile: AppUser;
|
||||
workspace: AccountWorkspace;
|
||||
summary: AccountSummary;
|
||||
billing: AccountBillingPlaceholder;
|
||||
billing: AccountBillingState;
|
||||
team: AccountTeamPlaceholder;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user