Public Access
1
0
Files
leads4less/server/src/billing/enforcement-service.ts
T
pguerrerox 5508e15da1 feat: launch Stripe billing flows with lifecycle hardening and analytics
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
2026-05-22 22:55:04 +00:00

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.