Public Access
1
0
Files
leads4less/shared/billing/entitlements.ts
T
pguerrerox f1a46c79f2 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.
2026-05-13 03:50:29 +00:00

487 lines
15 KiB
TypeScript

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.