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,182 @@
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import type { AccountBillingState } from '../../../shared/types.js';
|
||||
import type { EntitlementDecision, UsageAction, UsageAmount, UsageCostEstimate, UsageResource } from '../../../shared/billing/entitlements.js';
|
||||
import { evaluateActionEntitlement, isActivePlanCodeForEntitlements } from '../../../shared/billing/entitlements.js';
|
||||
import { ensureWorkspaceForUser } from '../account/repository.js';
|
||||
import { ensureBillingAccountForWorkspace, incrementUsageCounter } from './repository.js';
|
||||
import { ensureCurrentUsagePeriodForBillingAccount, getWorkspaceBillingState } from './service.js';
|
||||
|
||||
type DbClient = Pool | PoolClient;
|
||||
|
||||
export interface WorkspaceEnforcementContext {
|
||||
workspaceId: string;
|
||||
billing: AccountBillingState;
|
||||
}
|
||||
|
||||
export interface EnforcementCheckInput {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
action: UsageAction;
|
||||
costEstimate: UsageCostEstimate;
|
||||
}
|
||||
|
||||
export interface EnforcementCheckResult {
|
||||
allowed: boolean;
|
||||
decision: EntitlementDecision;
|
||||
}
|
||||
|
||||
export interface UsageRecordingInput {
|
||||
workspaceId: string;
|
||||
action: UsageAction;
|
||||
costEstimate: UsageCostEstimate;
|
||||
}
|
||||
|
||||
export async function getWorkspaceEnforcementContext(
|
||||
db: DbClient,
|
||||
user: { id: string; email: string; displayName?: string | null },
|
||||
): Promise<WorkspaceEnforcementContext> {
|
||||
const workspace = await ensureWorkspaceForUser(db, user);
|
||||
|
||||
if (!workspace) {
|
||||
throw new Error('Failed to resolve enforcement workspace.');
|
||||
}
|
||||
|
||||
const billing = await getWorkspaceBillingState(db, workspace.id);
|
||||
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
billing,
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkActionEntitlementForWorkspace(
|
||||
db: DbClient,
|
||||
input: EnforcementCheckInput,
|
||||
): Promise<EnforcementCheckResult> {
|
||||
const billing = await getWorkspaceBillingState(db, input.workspaceId);
|
||||
|
||||
if (!billing.planCode || !isActivePlanCodeForEntitlements(billing.planCode)) {
|
||||
return {
|
||||
allowed: false,
|
||||
decision: {
|
||||
status: 'blocked_upgrade_required',
|
||||
denialReason: 'billing_not_configured',
|
||||
action: input.action,
|
||||
resource: getPrimaryUsageAmount(input.costEstimate)?.resource ?? 'research_credits',
|
||||
requiredAmount: getPrimaryUsageAmount(input.costEstimate)?.amount ?? 0,
|
||||
remainingAmount: 0,
|
||||
currentPlanCode: null,
|
||||
suggestedUpgradePlanCode: 'starter_monthly',
|
||||
addonEligible: false,
|
||||
contactSalesRequired: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.costEstimate.isChargeable) {
|
||||
return {
|
||||
allowed: true,
|
||||
decision: evaluateActionEntitlement({
|
||||
planCode: billing.planCode,
|
||||
action: input.action,
|
||||
resource: getPrimaryUsageAmount(input.costEstimate)?.resource ?? 'research_credits',
|
||||
requiredAmount: getPrimaryUsageAmount(input.costEstimate)?.amount ?? 0,
|
||||
remainingAmount: null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const primaryAmount = getPrimaryUsageAmount(input.costEstimate);
|
||||
|
||||
if (!primaryAmount) {
|
||||
throw new Error(`Missing usage amount for chargeable action '${input.action}'.`);
|
||||
}
|
||||
|
||||
const remainingAmount = getRemainingAmountForResource(billing, primaryAmount.resource);
|
||||
const decision = evaluateActionEntitlement({
|
||||
planCode: billing.planCode,
|
||||
action: input.action,
|
||||
resource: primaryAmount.resource,
|
||||
requiredAmount: primaryAmount.amount,
|
||||
remainingAmount,
|
||||
});
|
||||
|
||||
return {
|
||||
allowed: decision.status === 'allowed',
|
||||
decision,
|
||||
};
|
||||
}
|
||||
|
||||
export async function recordSuccessfulActionUsage(
|
||||
db: DbClient,
|
||||
input: UsageRecordingInput,
|
||||
): Promise<void> {
|
||||
if (!input.costEstimate.isChargeable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const billingAccount = await ensureBillingAccountForWorkspace(db, input.workspaceId);
|
||||
const usagePeriod = await ensureCurrentUsagePeriodForBillingAccount(db, billingAccount);
|
||||
|
||||
if (!usagePeriod) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const usageAmount of input.costEstimate.amounts) {
|
||||
if (usageAmount.amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await incrementUsageCounter(db, {
|
||||
usagePeriodId: usagePeriod.id,
|
||||
workspaceId: input.workspaceId,
|
||||
resource: usageAmount.resource,
|
||||
delta: usageAmount.amount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function buildEntitlementErrorResponse(decision: EntitlementDecision) {
|
||||
return {
|
||||
statusCode: decision.denialReason === 'quota_exhausted' ? 409 : 403,
|
||||
body: {
|
||||
error: formatEntitlementErrorMessage(decision),
|
||||
code: 'entitlement_blocked' as const,
|
||||
entitlement: decision,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getRemainingAmountForResource(billing: AccountBillingState, resource: UsageResource): number | null {
|
||||
const resourceSummary = billing.usage.find((usage) => usage.resource === resource);
|
||||
|
||||
if (!resourceSummary) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return resourceSummary.remaining;
|
||||
}
|
||||
|
||||
function getPrimaryUsageAmount(costEstimate: UsageCostEstimate): UsageAmount | null {
|
||||
return costEstimate.amounts[0] ?? null;
|
||||
}
|
||||
|
||||
function formatEntitlementErrorMessage(decision: EntitlementDecision) {
|
||||
switch (decision.denialReason) {
|
||||
case 'billing_not_configured':
|
||||
return 'A billing plan is required before this action can run.';
|
||||
case 'feature_not_available':
|
||||
return 'Your current plan does not include this feature.';
|
||||
case 'not_launch_ready':
|
||||
return 'This feature is included in plan definitions but is not launch-ready yet.';
|
||||
case 'custom_enterprise_only':
|
||||
return 'This action requires an enterprise or custom sales engagement.';
|
||||
case 'quota_exhausted':
|
||||
return 'Your current plan has exhausted the available allowance for this action.';
|
||||
default:
|
||||
return 'This action is blocked by the current entitlement policy.';
|
||||
}
|
||||
}
|
||||
|
||||
// Export policy exists in shared billing modules, but route-level export enforcement
|
||||
// stays deferred until export generation moves to a backend endpoint.
|
||||
Reference in New Issue
Block a user