Public Access
1
0
Files
leads4less/shared/billing/entitlements.ts
T
pguerrerox 5508e15da1 feat: launch Stripe billing flows with lifecycle hardening and analytics
add Stripe checkout, portal, webhook ingestion, and idempotent event persistence

add billing lifecycle state (grace/sync/timeline/admin visibility) and stronger entitlement handling

add analytics event tracking and admin summary APIs plus account/pricing UI integration
2026-05-22 22:55:04 +00:00

495 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'
| 'billing_past_due'
| 'billing_canceled'
| 'billing_inactive';
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.