5508e15da1
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
229 lines
7.3 KiB
TypeScript
229 lines
7.3 KiB
TypeScript
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<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,
|
|
},
|
|
};
|
|
}
|
|
|
|
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<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 '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.
|