import type { Pool, PoolClient } from 'pg'; import { resolveBillingAccessState } from '../../../shared/billing/lifecycle.js'; 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 { 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 { 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, }, }; } const billingAccess = resolveBillingAccessState({ status: billing.status, currentPeriodEndsAt: billing.currentPeriodEndsAt, cancelAtPeriodEnd: billing.cancelAtPeriodEnd, gracePeriodEndsAt: billing.gracePeriodEndsAt, }); if (input.costEstimate.isChargeable && billingAccess.accessMode === 'blocked') { return { allowed: false, decision: { status: 'blocked_upgrade_required', denialReason: mapBillingStatusToDenialReason(billing.status), action: input.action, resource: getPrimaryUsageAmount(input.costEstimate)?.resource ?? 'research_credits', requiredAmount: getPrimaryUsageAmount(input.costEstimate)?.amount ?? 0, remainingAmount: 0, currentPlanCode: billing.planCode, suggestedUpgradePlanCode: billing.planCode, 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 { 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 'billing_past_due': return 'Payment is overdue and billing access is currently blocked.'; case 'billing_canceled': return 'This subscription is canceled. Reactivate billing to continue.'; case 'billing_inactive': return 'Billing is inactive for this workspace.'; 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.'; } } function mapBillingStatusToDenialReason(status: AccountBillingState['status']) { switch (status) { case 'past_due': return 'billing_past_due' as const; case 'canceled': return 'billing_canceled' as const; case 'inactive': case 'not_configured': return 'billing_inactive' as const; case 'active': return 'billing_inactive' as const; } } // Export policy exists in shared billing modules, but route-level export enforcement // stays deferred until export generation moves to a backend endpoint.