Public Access
1
0

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:
pguerrerox
2026-05-22 17:50:28 +00:00
parent f1a46c79f2
commit 94b8c357b4
21 changed files with 2269 additions and 151 deletions
+182
View File
@@ -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.