94b8c357b4
- add workspace-scoped billing storage, usage tracking, and add-on catalog - enforce plan entitlements for search and deep research routes - expand pricing and account UI around billing state, usage, and upgrades
487 lines
15 KiB
TypeScript
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' | 'billing_not_configured';
|
|
|
|
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 | null;
|
|
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.
|