feat: add billing foundation and entitlement enforcement
- 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
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import type { AddonCode, ActivePlanCode } from './plans.js';
|
||||
import { getPlanByCode } from './plans.js';
|
||||
import type { UsageResource } from './entitlements.js';
|
||||
|
||||
export type AddonType = 'resource_pack' | 'feature_addon';
|
||||
|
||||
export type AddonPurchaseMode = 'one_time' | 'recurring';
|
||||
|
||||
export type AddonAvailability = 'active' | 'coming_soon' | 'internal_only';
|
||||
|
||||
export interface AddonDefinition {
|
||||
code: AddonCode;
|
||||
name: string;
|
||||
type: AddonType;
|
||||
resource: UsageResource | null;
|
||||
quantity: number | null;
|
||||
priceCents: number;
|
||||
currencyCode: 'USD';
|
||||
purchaseMode: AddonPurchaseMode;
|
||||
availability: AddonAvailability;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const addonCatalog: AddonDefinition[] = [
|
||||
{
|
||||
code: 'export_pack_10k',
|
||||
name: 'Export Pack 10k',
|
||||
type: 'resource_pack',
|
||||
resource: 'exports',
|
||||
quantity: 10000,
|
||||
priceCents: 2900,
|
||||
currencyCode: 'USD',
|
||||
purchaseMode: 'one_time',
|
||||
availability: 'active',
|
||||
description: 'Add 10,000 extra exports to a workspace. Base plan exports should be consumed before this pack is used.',
|
||||
},
|
||||
{
|
||||
code: 'export_pack_50k',
|
||||
name: 'Export Pack 50k',
|
||||
type: 'resource_pack',
|
||||
resource: 'exports',
|
||||
quantity: 50000,
|
||||
priceCents: 9900,
|
||||
currencyCode: 'USD',
|
||||
purchaseMode: 'one_time',
|
||||
availability: 'active',
|
||||
description: 'Add 50,000 extra exports to a workspace. Base plan exports should be consumed before this pack is used.',
|
||||
},
|
||||
{
|
||||
code: 'enrichment_pack_1k',
|
||||
name: 'Enrichment Pack 1k',
|
||||
type: 'resource_pack',
|
||||
resource: 'enrichments',
|
||||
quantity: 1000,
|
||||
priceCents: 4900,
|
||||
currencyCode: 'USD',
|
||||
purchaseMode: 'one_time',
|
||||
availability: 'coming_soon',
|
||||
description: 'Add 1,000 enrichment units once enrichment actions are live.',
|
||||
},
|
||||
{
|
||||
code: 'ai_assistant_monthly',
|
||||
name: 'AI Prospecting Assistant',
|
||||
type: 'feature_addon',
|
||||
resource: null,
|
||||
quantity: null,
|
||||
priceCents: 4900,
|
||||
currencyCode: 'USD',
|
||||
purchaseMode: 'recurring',
|
||||
availability: 'coming_soon',
|
||||
description: 'Recurring feature add-on for AI-assisted market and territory prompts.',
|
||||
},
|
||||
{
|
||||
code: 'white_label_monthly',
|
||||
name: 'White Label Toolkit',
|
||||
type: 'feature_addon',
|
||||
resource: null,
|
||||
quantity: null,
|
||||
priceCents: 19900,
|
||||
currencyCode: 'USD',
|
||||
purchaseMode: 'recurring',
|
||||
availability: 'coming_soon',
|
||||
description: 'Recurring agency add-on for branded outputs and white-label workflows.',
|
||||
},
|
||||
];
|
||||
|
||||
const addonCatalogByCode = new Map(addonCatalog.map((addon) => [addon.code, addon]));
|
||||
|
||||
export const ADDON_CATALOG = [...addonCatalog];
|
||||
|
||||
export function getAddonByCode(code: AddonCode) {
|
||||
return addonCatalogByCode.get(code) ?? null;
|
||||
}
|
||||
|
||||
export function getActiveAddons() {
|
||||
return ADDON_CATALOG.filter((addon) => addon.availability === 'active');
|
||||
}
|
||||
|
||||
export function getAddonsForResource(resource: UsageResource) {
|
||||
return ADDON_CATALOG.filter((addon) => addon.resource === resource);
|
||||
}
|
||||
|
||||
export function getEligibleAddonsForPlan(planCode: ActivePlanCode, options?: { includeComingSoon?: boolean }) {
|
||||
const plan = getPlanByCode(planCode);
|
||||
|
||||
if (!plan) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return plan.eligibleAddonCodes
|
||||
.map((addonCode) => getAddonByCode(addonCode))
|
||||
.filter((addon): addon is AddonDefinition => addon !== null)
|
||||
.filter((addon) => (options?.includeComingSoon ? addon.availability !== 'internal_only' : addon.availability === 'active'));
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export type EntitlementDecisionStatus =
|
||||
| 'blocked_addon_available'
|
||||
| 'contact_sales_required';
|
||||
|
||||
export type EntitlementDenialReason = 'feature_not_available' | 'quota_exhausted' | 'custom_enterprise_only' | 'not_launch_ready';
|
||||
export type EntitlementDenialReason = 'feature_not_available' | 'quota_exhausted' | 'custom_enterprise_only' | 'not_launch_ready' | 'billing_not_configured';
|
||||
|
||||
export interface UsageSubject {
|
||||
type: UsageSubjectType;
|
||||
@@ -75,7 +75,7 @@ export interface EntitlementDecision {
|
||||
resource: UsageResource;
|
||||
requiredAmount: number;
|
||||
remainingAmount: number | null;
|
||||
currentPlanCode: ActivePlanCode;
|
||||
currentPlanCode: ActivePlanCode | null;
|
||||
suggestedUpgradePlanCode: ActivePlanCode | null;
|
||||
addonEligible: boolean;
|
||||
contactSalesRequired: boolean;
|
||||
|
||||
@@ -524,6 +524,18 @@ export function getPublicPricingPlans() {
|
||||
return ACTIVE_PLAN_CATALOG.filter((plan) => plan.listingCategory === 'pricing_page_primary');
|
||||
}
|
||||
|
||||
export function getPublicPricingPlansForInterval(billingInterval: BillingInterval) {
|
||||
return ACTIVE_PLAN_CATALOG.filter((plan) => {
|
||||
if (plan.code === 'enterprise_custom') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return plan.listingCategory === 'pricing_page_hidden'
|
||||
? plan.billingInterval === billingInterval
|
||||
: plan.listingCategory === 'pricing_page_primary' && plan.billingInterval === billingInterval;
|
||||
});
|
||||
}
|
||||
|
||||
export function getPlanVariant(tier: PlanTier, billingInterval: BillingInterval) {
|
||||
return ACTIVE_PLAN_CATALOG.find((plan) => plan.planFamily === tier && plan.billingInterval === billingInterval) ?? null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
export type OwnershipScope =
|
||||
| 'workspace_scoped_now'
|
||||
| 'user_scoped_now_target_workspace'
|
||||
| 'user_scoped_permanent';
|
||||
|
||||
export type EnforceabilityState =
|
||||
| 'hard_enforce_now'
|
||||
| 'soft_gate_now'
|
||||
| 'requires_backend_route'
|
||||
| 'requires_schema_migration'
|
||||
| 'future';
|
||||
|
||||
export type CollaborationPhase = 'v1_personal_data_with_workspace_billing' | 'v2_shared_workspace_data';
|
||||
|
||||
export type WorkspaceEntityKey =
|
||||
| 'workspaces'
|
||||
| 'workspace_memberships'
|
||||
| 'workspace_billing_accounts'
|
||||
| 'workspace_usage_periods'
|
||||
| 'workspace_usage_counters'
|
||||
| 'workspace_addon_purchases'
|
||||
| 'workspace_addon_balances'
|
||||
| 'search_jobs'
|
||||
| 'deep_research_batches'
|
||||
| 'businesses'
|
||||
| 'search_job_results'
|
||||
| 'users'
|
||||
| 'sessions';
|
||||
|
||||
export interface WorkspaceEntityReadiness {
|
||||
entity: WorkspaceEntityKey;
|
||||
currentOwnership: OwnershipScope;
|
||||
targetOwnership: OwnershipScope;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export type WorkspaceCommercialCapability =
|
||||
| 'research_credits'
|
||||
| 'exports'
|
||||
| 'users_included'
|
||||
| 'workspace_limits'
|
||||
| 'shared_assets'
|
||||
| 'collaboration_permissions'
|
||||
| 'saved_searches'
|
||||
| 'deduplication'
|
||||
| 'export_history'
|
||||
| 'tagging_notes'
|
||||
| 'shared_lists'
|
||||
| 'scheduled_research'
|
||||
| 'crm_integrations'
|
||||
| 'api_access'
|
||||
| 'webhooks'
|
||||
| 'enrichments';
|
||||
|
||||
export interface WorkspaceCapabilityReadiness {
|
||||
capability: WorkspaceCommercialCapability;
|
||||
enforceability: EnforceabilityState;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface CollaborationPhaseDefinition {
|
||||
phase: CollaborationPhase;
|
||||
description: string;
|
||||
includedBehaviors: string[];
|
||||
}
|
||||
|
||||
export const WORKSPACE_ENTITY_READINESS: WorkspaceEntityReadiness[] = [
|
||||
{
|
||||
entity: 'workspaces',
|
||||
currentOwnership: 'workspace_scoped_now',
|
||||
targetOwnership: 'workspace_scoped_now',
|
||||
notes: 'Workspace metadata already exists and is the anchor for billing and future company-level ownership.',
|
||||
},
|
||||
{
|
||||
entity: 'workspace_memberships',
|
||||
currentOwnership: 'workspace_scoped_now',
|
||||
targetOwnership: 'workspace_scoped_now',
|
||||
notes: 'Memberships exist now, but most product data is not yet shared through them.',
|
||||
},
|
||||
{
|
||||
entity: 'workspace_billing_accounts',
|
||||
currentOwnership: 'workspace_scoped_now',
|
||||
targetOwnership: 'workspace_scoped_now',
|
||||
notes: 'Billing ownership is intentionally workspace-scoped and should remain that way.',
|
||||
},
|
||||
{
|
||||
entity: 'workspace_usage_periods',
|
||||
currentOwnership: 'workspace_scoped_now',
|
||||
targetOwnership: 'workspace_scoped_now',
|
||||
notes: 'Usage periods are workspace-scoped and power quota resets.',
|
||||
},
|
||||
{
|
||||
entity: 'workspace_usage_counters',
|
||||
currentOwnership: 'workspace_scoped_now',
|
||||
targetOwnership: 'workspace_scoped_now',
|
||||
notes: 'Usage counters already align with workspace billing and should stay workspace-owned.',
|
||||
},
|
||||
{
|
||||
entity: 'workspace_addon_purchases',
|
||||
currentOwnership: 'workspace_scoped_now',
|
||||
targetOwnership: 'workspace_scoped_now',
|
||||
notes: 'Add-on purchase history is workspace-owned so extra capacity can be shared later.',
|
||||
},
|
||||
{
|
||||
entity: 'workspace_addon_balances',
|
||||
currentOwnership: 'workspace_scoped_now',
|
||||
targetOwnership: 'workspace_scoped_now',
|
||||
notes: 'Add-on balances follow the same workspace billing model as quota usage.',
|
||||
},
|
||||
{
|
||||
entity: 'search_jobs',
|
||||
currentOwnership: 'user_scoped_now_target_workspace',
|
||||
targetOwnership: 'workspace_scoped_now',
|
||||
notes: 'Search jobs are still keyed by user and should gain workspace ownership before true team history is promised.',
|
||||
},
|
||||
{
|
||||
entity: 'deep_research_batches',
|
||||
currentOwnership: 'user_scoped_now_target_workspace',
|
||||
targetOwnership: 'workspace_scoped_now',
|
||||
notes: 'Deep research batches are user-owned today but need workspace ownership for shared territory workflows.',
|
||||
},
|
||||
{
|
||||
entity: 'businesses',
|
||||
currentOwnership: 'user_scoped_now_target_workspace',
|
||||
targetOwnership: 'workspace_scoped_now',
|
||||
notes: 'Saved businesses should become workspace-owned before collaboration or shared exports are sold as real capabilities.',
|
||||
},
|
||||
{
|
||||
entity: 'search_job_results',
|
||||
currentOwnership: 'user_scoped_now_target_workspace',
|
||||
targetOwnership: 'workspace_scoped_now',
|
||||
notes: 'Result links follow search jobs and businesses and should migrate with them.',
|
||||
},
|
||||
{
|
||||
entity: 'users',
|
||||
currentOwnership: 'user_scoped_permanent',
|
||||
targetOwnership: 'user_scoped_permanent',
|
||||
notes: 'Profiles and identities remain user-scoped even as product data becomes workspace-owned.',
|
||||
},
|
||||
{
|
||||
entity: 'sessions',
|
||||
currentOwnership: 'user_scoped_permanent',
|
||||
targetOwnership: 'user_scoped_permanent',
|
||||
notes: 'Sessions stay tied to user authentication, not workspace data ownership.',
|
||||
},
|
||||
];
|
||||
|
||||
export const WORKSPACE_CAPABILITY_READINESS: WorkspaceCapabilityReadiness[] = [
|
||||
{
|
||||
capability: 'research_credits',
|
||||
enforceability: 'hard_enforce_now',
|
||||
notes: 'Workspace-scoped billing and counters already support hard enforcement for research actions.',
|
||||
},
|
||||
{
|
||||
capability: 'exports',
|
||||
enforceability: 'requires_backend_route',
|
||||
notes: 'Export policy exists, but hard enforcement waits on a backend export endpoint.',
|
||||
},
|
||||
{
|
||||
capability: 'users_included',
|
||||
enforceability: 'soft_gate_now',
|
||||
notes: 'Workspace membership counts exist, but product data is not yet shared enough for full seat enforcement.',
|
||||
},
|
||||
{
|
||||
capability: 'workspace_limits',
|
||||
enforceability: 'soft_gate_now',
|
||||
notes: 'Commercial workspace limits can be surfaced, but multi-workspace UX and switching are still limited.',
|
||||
},
|
||||
{
|
||||
capability: 'shared_assets',
|
||||
enforceability: 'requires_schema_migration',
|
||||
notes: 'Shared search history, saved businesses, and list ownership require workspace-scoped domain data first.',
|
||||
},
|
||||
{
|
||||
capability: 'collaboration_permissions',
|
||||
enforceability: 'requires_schema_migration',
|
||||
notes: 'Role-aware collaboration depends on moving core entities from user ownership to workspace ownership.',
|
||||
},
|
||||
{
|
||||
capability: 'saved_searches',
|
||||
enforceability: 'future',
|
||||
notes: 'Marketed feature today, but still needs product implementation and likely workspace-aware persistence.',
|
||||
},
|
||||
{
|
||||
capability: 'deduplication',
|
||||
enforceability: 'future',
|
||||
notes: 'Commercially positioned but not implemented as a workspace-level workflow yet.',
|
||||
},
|
||||
{
|
||||
capability: 'export_history',
|
||||
enforceability: 'future',
|
||||
notes: 'Requires backend export jobs and persistent export records.',
|
||||
},
|
||||
{
|
||||
capability: 'tagging_notes',
|
||||
enforceability: 'requires_schema_migration',
|
||||
notes: 'Tags and notes should land on workspace-owned business/search entities before collaboration is enabled.',
|
||||
},
|
||||
{
|
||||
capability: 'shared_lists',
|
||||
enforceability: 'requires_schema_migration',
|
||||
notes: 'Shared lists require workspace-owned saved entities and list permission rules.',
|
||||
},
|
||||
{
|
||||
capability: 'scheduled_research',
|
||||
enforceability: 'future',
|
||||
notes: 'Needs async job scheduling plus ownership decisions for who can see and manage scheduled runs.',
|
||||
},
|
||||
{
|
||||
capability: 'crm_integrations',
|
||||
enforceability: 'future',
|
||||
notes: 'Requires integration surfaces and likely workspace-scoped credentials/settings.',
|
||||
},
|
||||
{
|
||||
capability: 'api_access',
|
||||
enforceability: 'future',
|
||||
notes: 'Entitlement policy exists, but actual API route surfaces and auth scopes are not live yet.',
|
||||
},
|
||||
{
|
||||
capability: 'webhooks',
|
||||
enforceability: 'future',
|
||||
notes: 'Depends on integration/event infrastructure and workspace-level endpoint management.',
|
||||
},
|
||||
{
|
||||
capability: 'enrichments',
|
||||
enforceability: 'future',
|
||||
notes: 'Entitlement model exists, but enrichment jobs and resource consumption are not active yet.',
|
||||
},
|
||||
];
|
||||
|
||||
export const COLLABORATION_PHASES: CollaborationPhaseDefinition[] = [
|
||||
{
|
||||
phase: 'v1_personal_data_with_workspace_billing',
|
||||
description: 'Billing, quotas, and memberships are workspace-based, but most saved operational data still behaves as personal user-owned data.',
|
||||
includedBehaviors: [
|
||||
'A user consumes usage against their primary workspace.',
|
||||
'Billing and quotas are tracked at the workspace level.',
|
||||
'Search history and saved businesses remain effectively personal even inside a workspace shell.',
|
||||
],
|
||||
},
|
||||
{
|
||||
phase: 'v2_shared_workspace_data',
|
||||
description: 'Core research and saved-business entities become workspace-owned, enabling true shared history, shared lists, and role-aware collaboration.',
|
||||
includedBehaviors: [
|
||||
'Search jobs and deep research batches are workspace-owned.',
|
||||
'Saved businesses and results can be shared across members.',
|
||||
'Collaboration permissions and shared asset rules can be enforced meaningfully.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const CURRENT_COLLABORATION_PHASE: CollaborationPhase = 'v1_personal_data_with_workspace_billing';
|
||||
+22
-1
@@ -1,4 +1,5 @@
|
||||
import type { BillingInterval, PlanCode } from './billing/plans.js';
|
||||
import type { AddonCode, BillingInterval, PlanCode } from './billing/plans.js';
|
||||
import type { UsageAllowanceAvailability, UsageResource } from './billing/entitlements.js';
|
||||
|
||||
export type JobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'stopped';
|
||||
|
||||
@@ -34,10 +35,30 @@ export interface AccountSummary {
|
||||
|
||||
export type AccountBillingStatus = 'not_configured' | 'inactive' | 'active' | 'past_due' | 'canceled';
|
||||
|
||||
export interface BillingUsageResourceSummary {
|
||||
resource: UsageResource;
|
||||
availability: UsageAllowanceAvailability;
|
||||
included: number | null;
|
||||
consumed: number;
|
||||
remaining: number | null;
|
||||
}
|
||||
|
||||
export interface BillingAddonBalanceSummary {
|
||||
addonCode: AddonCode;
|
||||
resource: UsageResource;
|
||||
remainingQuantity: number;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface AccountBillingState {
|
||||
status: AccountBillingStatus;
|
||||
planCode: PlanCode | null;
|
||||
billingInterval: BillingInterval | null;
|
||||
currentPeriodStartsAt: string | null;
|
||||
currentPeriodEndsAt: string | null;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
usage: BillingUsageResourceSummary[];
|
||||
addonBalances: BillingAddonBalanceSummary[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user