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:
@@ -1,5 +1,6 @@
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import type { AccountPageData, AccountWorkspace, AppUser, WorkspaceType, WorkspaceRole } from '../../../shared/types.js';
|
||||
import { getWorkspaceBillingState } from '../billing/service.js';
|
||||
|
||||
type DbClient = Pool | PoolClient;
|
||||
|
||||
@@ -152,17 +153,13 @@ export async function buildAccountPageData(db: DbClient, user: AppUser): Promise
|
||||
}
|
||||
|
||||
const summary = await getAccountSummaryForUser(db, user.id);
|
||||
const billing = await getWorkspaceBillingState(db, workspace.id);
|
||||
|
||||
return {
|
||||
profile: user,
|
||||
workspace,
|
||||
summary,
|
||||
billing: {
|
||||
status: 'not_configured',
|
||||
planCode: null,
|
||||
billingInterval: null,
|
||||
message: 'Subscription management is being prepared. Plan details, usage tracking, and billing controls will appear here in a future update.',
|
||||
},
|
||||
billing,
|
||||
team: {
|
||||
canManageMembers: workspace.role === 'owner' || workspace.role === 'admin',
|
||||
message: 'Workspace member management is coming soon.',
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,521 @@
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import type { AddonCode, BillingInterval, PlanCode } from '../../../shared/billing/plans.js';
|
||||
import type { AccountBillingStatus, BillingAddonBalanceSummary } from '../../../shared/types.js';
|
||||
import type { UsageResource } from '../../../shared/billing/entitlements.js';
|
||||
|
||||
type DbClient = Pool | PoolClient;
|
||||
|
||||
export interface BillingAccountRecord {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
planCode: PlanCode | null;
|
||||
billingInterval: BillingInterval | null;
|
||||
status: AccountBillingStatus;
|
||||
currentPeriodStartsAt: string | null;
|
||||
currentPeriodEndsAt: string | null;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
canceledAt: string | null;
|
||||
trialEndsAt: string | null;
|
||||
externalCustomerRef: string | null;
|
||||
externalSubscriptionRef: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UsagePeriodRecord {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
billingAccountId: string;
|
||||
periodStartsAt: string;
|
||||
periodEndsAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UsageCounterRecord {
|
||||
id: string;
|
||||
usagePeriodId: string;
|
||||
workspaceId: string;
|
||||
resource: UsageResource;
|
||||
consumedQuantity: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AddonPurchaseRecord {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
addonCode: AddonCode;
|
||||
resource: UsageResource;
|
||||
purchasedQuantity: number;
|
||||
remainingQuantity: number;
|
||||
purchasedAt: string;
|
||||
expiresAt: string | null;
|
||||
externalPurchaseRef: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
type BillingAccountRow = {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
plan_code: string | null;
|
||||
billing_interval: BillingInterval | null;
|
||||
status: AccountBillingStatus;
|
||||
current_period_starts_at: string | null;
|
||||
current_period_ends_at: string | null;
|
||||
cancel_at_period_end: boolean;
|
||||
canceled_at: string | null;
|
||||
trial_ends_at: string | null;
|
||||
external_customer_ref: string | null;
|
||||
external_subscription_ref: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type UsagePeriodRow = {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
billing_account_id: string;
|
||||
period_starts_at: string;
|
||||
period_ends_at: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type UsageCounterRow = {
|
||||
id: string;
|
||||
usage_period_id: string;
|
||||
workspace_id: string;
|
||||
resource: UsageResource;
|
||||
consumed_quantity: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type AddonBalanceRow = {
|
||||
addon_code: AddonCode;
|
||||
resource: UsageResource;
|
||||
remaining_quantity: string;
|
||||
expires_at: string | null;
|
||||
};
|
||||
|
||||
type AddonPurchaseRow = {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
addon_code: AddonCode;
|
||||
resource: UsageResource;
|
||||
purchased_quantity: string;
|
||||
remaining_quantity: string;
|
||||
purchased_at: string;
|
||||
expires_at: string | null;
|
||||
external_purchase_ref: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export async function getBillingAccountForWorkspace(db: DbClient, workspaceId: string): Promise<BillingAccountRecord | null> {
|
||||
const result = await db.query<BillingAccountRow>(
|
||||
`
|
||||
select id, workspace_id, plan_code, billing_interval, status,
|
||||
current_period_starts_at, current_period_ends_at,
|
||||
cancel_at_period_end, canceled_at, trial_ends_at,
|
||||
external_customer_ref, external_subscription_ref,
|
||||
created_at, updated_at
|
||||
from public.workspace_billing_accounts
|
||||
where workspace_id = $1
|
||||
limit 1
|
||||
`,
|
||||
[workspaceId],
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mapBillingAccountRow(result.rows[0]);
|
||||
}
|
||||
|
||||
export async function createDefaultBillingAccountForWorkspace(db: DbClient, workspaceId: string): Promise<BillingAccountRecord> {
|
||||
const { currentPeriodStartsAt, currentPeriodEndsAt } = getDefaultBillingPeriodBounds();
|
||||
const result = await db.query<BillingAccountRow>(
|
||||
`
|
||||
insert into public.workspace_billing_accounts (
|
||||
workspace_id,
|
||||
plan_code,
|
||||
billing_interval,
|
||||
status,
|
||||
current_period_starts_at,
|
||||
current_period_ends_at
|
||||
)
|
||||
values ($1, 'starter_monthly', 'monthly', 'active', $2, $3)
|
||||
on conflict (workspace_id) do update set workspace_id = excluded.workspace_id
|
||||
returning id, workspace_id, plan_code, billing_interval, status,
|
||||
current_period_starts_at, current_period_ends_at,
|
||||
cancel_at_period_end, canceled_at, trial_ends_at,
|
||||
external_customer_ref, external_subscription_ref,
|
||||
created_at, updated_at
|
||||
`,
|
||||
[workspaceId, currentPeriodStartsAt, currentPeriodEndsAt],
|
||||
);
|
||||
|
||||
return mapBillingAccountRow(result.rows[0]);
|
||||
}
|
||||
|
||||
export async function ensureBillingAccountForWorkspace(db: DbClient, workspaceId: string) {
|
||||
const existingAccount = await getBillingAccountForWorkspace(db, workspaceId);
|
||||
if (existingAccount) {
|
||||
if (!existingAccount.planCode && existingAccount.status === 'not_configured') {
|
||||
return bootstrapDefaultBillingAccountState(db, workspaceId);
|
||||
}
|
||||
|
||||
return existingAccount;
|
||||
}
|
||||
|
||||
return createDefaultBillingAccountForWorkspace(db, workspaceId);
|
||||
}
|
||||
|
||||
export async function updateBillingAccountState(
|
||||
db: DbClient,
|
||||
input: {
|
||||
workspaceId: string;
|
||||
planCode: PlanCode | null;
|
||||
billingInterval: BillingInterval | null;
|
||||
status: AccountBillingStatus;
|
||||
currentPeriodStartsAt?: string | null;
|
||||
currentPeriodEndsAt?: string | null;
|
||||
cancelAtPeriodEnd?: boolean;
|
||||
canceledAt?: string | null;
|
||||
trialEndsAt?: string | null;
|
||||
externalCustomerRef?: string | null;
|
||||
externalSubscriptionRef?: string | null;
|
||||
},
|
||||
) {
|
||||
const result = await db.query<BillingAccountRow>(
|
||||
`
|
||||
insert into public.workspace_billing_accounts (
|
||||
workspace_id,
|
||||
plan_code,
|
||||
billing_interval,
|
||||
status,
|
||||
current_period_starts_at,
|
||||
current_period_ends_at,
|
||||
cancel_at_period_end,
|
||||
canceled_at,
|
||||
trial_ends_at,
|
||||
external_customer_ref,
|
||||
external_subscription_ref
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, coalesce($7, false), $8, $9, $10, $11)
|
||||
on conflict (workspace_id)
|
||||
do update set
|
||||
plan_code = excluded.plan_code,
|
||||
billing_interval = excluded.billing_interval,
|
||||
status = excluded.status,
|
||||
current_period_starts_at = excluded.current_period_starts_at,
|
||||
current_period_ends_at = excluded.current_period_ends_at,
|
||||
cancel_at_period_end = excluded.cancel_at_period_end,
|
||||
canceled_at = excluded.canceled_at,
|
||||
trial_ends_at = excluded.trial_ends_at,
|
||||
external_customer_ref = excluded.external_customer_ref,
|
||||
external_subscription_ref = excluded.external_subscription_ref
|
||||
returning id, workspace_id, plan_code, billing_interval, status,
|
||||
current_period_starts_at, current_period_ends_at,
|
||||
cancel_at_period_end, canceled_at, trial_ends_at,
|
||||
external_customer_ref, external_subscription_ref,
|
||||
created_at, updated_at
|
||||
`,
|
||||
[
|
||||
input.workspaceId,
|
||||
input.planCode,
|
||||
input.billingInterval,
|
||||
input.status,
|
||||
input.currentPeriodStartsAt ?? null,
|
||||
input.currentPeriodEndsAt ?? null,
|
||||
input.cancelAtPeriodEnd ?? false,
|
||||
input.canceledAt ?? null,
|
||||
input.trialEndsAt ?? null,
|
||||
input.externalCustomerRef ?? null,
|
||||
input.externalSubscriptionRef ?? null,
|
||||
],
|
||||
);
|
||||
|
||||
return mapBillingAccountRow(result.rows[0]);
|
||||
}
|
||||
|
||||
export async function getUsagePeriodForWorkspace(
|
||||
db: DbClient,
|
||||
workspaceId: string,
|
||||
periodStartsAt: string,
|
||||
periodEndsAt: string,
|
||||
): Promise<UsagePeriodRecord | null> {
|
||||
const result = await db.query<UsagePeriodRow>(
|
||||
`
|
||||
select id, workspace_id, billing_account_id, period_starts_at, period_ends_at, created_at, updated_at
|
||||
from public.workspace_usage_periods
|
||||
where workspace_id = $1 and period_starts_at = $2 and period_ends_at = $3
|
||||
limit 1
|
||||
`,
|
||||
[workspaceId, periodStartsAt, periodEndsAt],
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return mapUsagePeriodRow(result.rows[0]);
|
||||
}
|
||||
|
||||
export async function createUsagePeriodForWorkspace(
|
||||
db: DbClient,
|
||||
input: { workspaceId: string; billingAccountId: string; periodStartsAt: string; periodEndsAt: string },
|
||||
): Promise<UsagePeriodRecord> {
|
||||
const result = await db.query<UsagePeriodRow>(
|
||||
`
|
||||
insert into public.workspace_usage_periods (workspace_id, billing_account_id, period_starts_at, period_ends_at)
|
||||
values ($1, $2, $3, $4)
|
||||
on conflict (workspace_id, period_starts_at, period_ends_at)
|
||||
do update set billing_account_id = excluded.billing_account_id
|
||||
returning id, workspace_id, billing_account_id, period_starts_at, period_ends_at, created_at, updated_at
|
||||
`,
|
||||
[input.workspaceId, input.billingAccountId, input.periodStartsAt, input.periodEndsAt],
|
||||
);
|
||||
|
||||
return mapUsagePeriodRow(result.rows[0]);
|
||||
}
|
||||
|
||||
export async function ensureUsagePeriodForWorkspace(
|
||||
db: DbClient,
|
||||
input: { workspaceId: string; billingAccountId: string; periodStartsAt: string; periodEndsAt: string },
|
||||
) {
|
||||
const existingPeriod = await getUsagePeriodForWorkspace(db, input.workspaceId, input.periodStartsAt, input.periodEndsAt);
|
||||
if (existingPeriod) {
|
||||
return existingPeriod;
|
||||
}
|
||||
|
||||
return createUsagePeriodForWorkspace(db, input);
|
||||
}
|
||||
|
||||
export async function listUsageCountersForPeriod(db: DbClient, usagePeriodId: string): Promise<UsageCounterRecord[]> {
|
||||
const result = await db.query<UsageCounterRow>(
|
||||
`
|
||||
select id, usage_period_id, workspace_id, resource, consumed_quantity, created_at, updated_at
|
||||
from public.workspace_usage_counters
|
||||
where usage_period_id = $1
|
||||
order by resource asc
|
||||
`,
|
||||
[usagePeriodId],
|
||||
);
|
||||
|
||||
return result.rows.map(mapUsageCounterRow);
|
||||
}
|
||||
|
||||
export async function upsertUsageCounter(
|
||||
db: DbClient,
|
||||
input: { usagePeriodId: string; workspaceId: string; resource: UsageResource; consumedQuantity: number },
|
||||
) {
|
||||
const result = await db.query<UsageCounterRow>(
|
||||
`
|
||||
insert into public.workspace_usage_counters (usage_period_id, workspace_id, resource, consumed_quantity)
|
||||
values ($1, $2, $3, $4)
|
||||
on conflict (usage_period_id, resource)
|
||||
do update set consumed_quantity = excluded.consumed_quantity
|
||||
returning id, usage_period_id, workspace_id, resource, consumed_quantity, created_at, updated_at
|
||||
`,
|
||||
[input.usagePeriodId, input.workspaceId, input.resource, input.consumedQuantity],
|
||||
);
|
||||
|
||||
return mapUsageCounterRow(result.rows[0]);
|
||||
}
|
||||
|
||||
export async function incrementUsageCounter(
|
||||
db: DbClient,
|
||||
input: { usagePeriodId: string; workspaceId: string; resource: UsageResource; delta: number },
|
||||
) {
|
||||
const result = await db.query<UsageCounterRow>(
|
||||
`
|
||||
insert into public.workspace_usage_counters (usage_period_id, workspace_id, resource, consumed_quantity)
|
||||
values ($1, $2, $3, greatest($4, 0))
|
||||
on conflict (usage_period_id, resource)
|
||||
do update set consumed_quantity = greatest(public.workspace_usage_counters.consumed_quantity + $4, 0)
|
||||
returning id, usage_period_id, workspace_id, resource, consumed_quantity, created_at, updated_at
|
||||
`,
|
||||
[input.usagePeriodId, input.workspaceId, input.resource, input.delta],
|
||||
);
|
||||
|
||||
return mapUsageCounterRow(result.rows[0]);
|
||||
}
|
||||
|
||||
export async function listAddonBalancesForWorkspace(db: DbClient, workspaceId: string): Promise<BillingAddonBalanceSummary[]> {
|
||||
const result = await db.query<AddonBalanceRow>(
|
||||
`
|
||||
select addon_code, resource, remaining_quantity, expires_at
|
||||
from public.workspace_addon_balances
|
||||
where workspace_id = $1
|
||||
order by addon_code asc, resource asc
|
||||
`,
|
||||
[workspaceId],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
addonCode: row.addon_code,
|
||||
resource: row.resource,
|
||||
remainingQuantity: Number(row.remaining_quantity),
|
||||
expiresAt: row.expires_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function recordAddonPurchase(
|
||||
db: DbClient,
|
||||
input: {
|
||||
workspaceId: string;
|
||||
addonCode: AddonCode;
|
||||
resource: UsageResource;
|
||||
purchasedQuantity: number;
|
||||
remainingQuantity: number;
|
||||
purchasedAt?: string;
|
||||
expiresAt?: string | null;
|
||||
externalPurchaseRef?: string | null;
|
||||
},
|
||||
): Promise<AddonPurchaseRecord> {
|
||||
const result = await db.query<AddonPurchaseRow>(
|
||||
`
|
||||
insert into public.workspace_addon_purchases (
|
||||
workspace_id, addon_code, resource, purchased_quantity, remaining_quantity,
|
||||
purchased_at, expires_at, external_purchase_ref
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, coalesce($6, now()), $7, $8)
|
||||
returning id, workspace_id, addon_code, resource, purchased_quantity, remaining_quantity,
|
||||
purchased_at, expires_at, external_purchase_ref, created_at, updated_at
|
||||
`,
|
||||
[
|
||||
input.workspaceId,
|
||||
input.addonCode,
|
||||
input.resource,
|
||||
input.purchasedQuantity,
|
||||
input.remainingQuantity,
|
||||
input.purchasedAt ?? null,
|
||||
input.expiresAt ?? null,
|
||||
input.externalPurchaseRef ?? null,
|
||||
],
|
||||
);
|
||||
|
||||
return mapAddonPurchaseRow(result.rows[0]);
|
||||
}
|
||||
|
||||
export async function upsertAddonBalance(
|
||||
db: DbClient,
|
||||
input: {
|
||||
workspaceId: string;
|
||||
addonCode: AddonCode;
|
||||
resource: UsageResource;
|
||||
remainingQuantity: number;
|
||||
expiresAt?: string | null;
|
||||
},
|
||||
): Promise<BillingAddonBalanceSummary> {
|
||||
const result = await db.query<AddonBalanceRow>(
|
||||
`
|
||||
insert into public.workspace_addon_balances (workspace_id, addon_code, resource, remaining_quantity, expires_at)
|
||||
values ($1, $2, $3, $4, $5)
|
||||
on conflict (workspace_id, addon_code, resource)
|
||||
do update set
|
||||
remaining_quantity = excluded.remaining_quantity,
|
||||
expires_at = excluded.expires_at
|
||||
returning addon_code, resource, remaining_quantity, expires_at
|
||||
`,
|
||||
[input.workspaceId, input.addonCode, input.resource, input.remainingQuantity, input.expiresAt ?? null],
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
|
||||
return {
|
||||
addonCode: row.addon_code,
|
||||
resource: row.resource,
|
||||
remainingQuantity: Number(row.remaining_quantity),
|
||||
expiresAt: row.expires_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mapBillingAccountRow(row: BillingAccountRow): BillingAccountRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
workspaceId: row.workspace_id,
|
||||
planCode: row.plan_code as PlanCode | null,
|
||||
billingInterval: row.billing_interval,
|
||||
status: row.status,
|
||||
currentPeriodStartsAt: row.current_period_starts_at,
|
||||
currentPeriodEndsAt: row.current_period_ends_at,
|
||||
cancelAtPeriodEnd: row.cancel_at_period_end,
|
||||
canceledAt: row.canceled_at,
|
||||
trialEndsAt: row.trial_ends_at,
|
||||
externalCustomerRef: row.external_customer_ref,
|
||||
externalSubscriptionRef: row.external_subscription_ref,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mapUsagePeriodRow(row: UsagePeriodRow): UsagePeriodRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
workspaceId: row.workspace_id,
|
||||
billingAccountId: row.billing_account_id,
|
||||
periodStartsAt: row.period_starts_at,
|
||||
periodEndsAt: row.period_ends_at,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mapUsageCounterRow(row: UsageCounterRow): UsageCounterRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
usagePeriodId: row.usage_period_id,
|
||||
workspaceId: row.workspace_id,
|
||||
resource: row.resource,
|
||||
consumedQuantity: Number(row.consumed_quantity),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultBillingPeriodBounds() {
|
||||
const currentPeriodStartsAt = new Date();
|
||||
const currentPeriodEndsAt = new Date(currentPeriodStartsAt);
|
||||
currentPeriodEndsAt.setUTCMonth(currentPeriodEndsAt.getUTCMonth() + 1);
|
||||
|
||||
return {
|
||||
currentPeriodStartsAt: currentPeriodStartsAt.toISOString(),
|
||||
currentPeriodEndsAt: currentPeriodEndsAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function bootstrapDefaultBillingAccountState(db: DbClient, workspaceId: string) {
|
||||
const { currentPeriodStartsAt, currentPeriodEndsAt } = getDefaultBillingPeriodBounds();
|
||||
|
||||
return updateBillingAccountState(db, {
|
||||
workspaceId,
|
||||
planCode: 'starter_monthly',
|
||||
billingInterval: 'monthly',
|
||||
status: 'active',
|
||||
currentPeriodStartsAt,
|
||||
currentPeriodEndsAt,
|
||||
cancelAtPeriodEnd: false,
|
||||
});
|
||||
}
|
||||
|
||||
function mapAddonPurchaseRow(row: AddonPurchaseRow): AddonPurchaseRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
workspaceId: row.workspace_id,
|
||||
addonCode: row.addon_code,
|
||||
resource: row.resource,
|
||||
purchasedQuantity: Number(row.purchased_quantity),
|
||||
remainingQuantity: Number(row.remaining_quantity),
|
||||
purchasedAt: row.purchased_at,
|
||||
expiresAt: row.expires_at,
|
||||
externalPurchaseRef: row.external_purchase_ref,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import { getUsageAllowanceForPlan, isActivePlanCodeForEntitlements } from '../../../shared/billing/entitlements.js';
|
||||
import type { BillingAddonBalanceSummary, BillingUsageResourceSummary, AccountBillingState } from '../../../shared/types.js';
|
||||
import type { UsageResource } from '../../../shared/billing/entitlements.js';
|
||||
import {
|
||||
ensureBillingAccountForWorkspace,
|
||||
ensureUsagePeriodForWorkspace,
|
||||
listAddonBalancesForWorkspace,
|
||||
listUsageCountersForPeriod,
|
||||
type BillingAccountRecord,
|
||||
type UsagePeriodRecord,
|
||||
} from './repository.js';
|
||||
|
||||
type DbClient = Pool | PoolClient;
|
||||
|
||||
export async function getWorkspaceBillingState(db: DbClient, workspaceId: string): Promise<AccountBillingState> {
|
||||
const billingAccount = await ensureBillingAccountForWorkspace(db, workspaceId);
|
||||
|
||||
if (!billingAccount.planCode || !isActivePlanCodeForEntitlements(billingAccount.planCode)) {
|
||||
return {
|
||||
status: billingAccount.status,
|
||||
planCode: billingAccount.planCode,
|
||||
billingInterval: billingAccount.billingInterval,
|
||||
currentPeriodStartsAt: billingAccount.currentPeriodStartsAt,
|
||||
currentPeriodEndsAt: billingAccount.currentPeriodEndsAt,
|
||||
cancelAtPeriodEnd: billingAccount.cancelAtPeriodEnd,
|
||||
usage: [],
|
||||
addonBalances: await listAddonBalancesForWorkspace(db, workspaceId),
|
||||
message: 'Subscription management is being prepared. Plan details, usage tracking, and billing controls will appear here in a future update.',
|
||||
};
|
||||
}
|
||||
|
||||
const usageSnapshot = await getWorkspaceUsageSnapshot(db, billingAccount);
|
||||
const addonBalances = await listAddonBalancesForWorkspace(db, workspaceId);
|
||||
|
||||
return {
|
||||
status: billingAccount.status,
|
||||
planCode: billingAccount.planCode,
|
||||
billingInterval: billingAccount.billingInterval,
|
||||
currentPeriodStartsAt: usageSnapshot.currentPeriodStartsAt,
|
||||
currentPeriodEndsAt: usageSnapshot.currentPeriodEndsAt,
|
||||
cancelAtPeriodEnd: billingAccount.cancelAtPeriodEnd,
|
||||
usage: usageSnapshot.usage,
|
||||
addonBalances,
|
||||
message: billingAccount.status === 'active'
|
||||
? 'Billing state is active. Usage tracking is available and entitlement enforcement can build on this foundation.'
|
||||
: 'Billing state is stored, but subscription automation and enforcement are still being built.',
|
||||
};
|
||||
}
|
||||
|
||||
export async function getWorkspaceUsageSnapshot(db: DbClient, billingAccount: BillingAccountRecord): Promise<{
|
||||
currentPeriodStartsAt: string | null;
|
||||
currentPeriodEndsAt: string | null;
|
||||
usage: BillingUsageResourceSummary[];
|
||||
}> {
|
||||
if (!billingAccount.planCode || !isActivePlanCodeForEntitlements(billingAccount.planCode)) {
|
||||
return {
|
||||
currentPeriodStartsAt: billingAccount.currentPeriodStartsAt,
|
||||
currentPeriodEndsAt: billingAccount.currentPeriodEndsAt,
|
||||
usage: [],
|
||||
};
|
||||
}
|
||||
|
||||
const usageWindow = resolveUsageWindow(billingAccount, new Date());
|
||||
|
||||
if (!usageWindow) {
|
||||
const allowance = getUsageAllowanceForPlan(billingAccount.planCode);
|
||||
return {
|
||||
currentPeriodStartsAt: billingAccount.currentPeriodStartsAt,
|
||||
currentPeriodEndsAt: billingAccount.currentPeriodEndsAt,
|
||||
usage: buildUsageSummaries(allowance, new Map()),
|
||||
};
|
||||
}
|
||||
|
||||
const usagePeriod = await ensureUsagePeriodForWorkspace(db, {
|
||||
workspaceId: billingAccount.workspaceId,
|
||||
billingAccountId: billingAccount.id,
|
||||
periodStartsAt: usageWindow.periodStartsAt,
|
||||
periodEndsAt: usageWindow.periodEndsAt,
|
||||
});
|
||||
|
||||
const counters = await listUsageCountersForPeriod(db, usagePeriod.id);
|
||||
const allowance = getUsageAllowanceForPlan(billingAccount.planCode);
|
||||
|
||||
return {
|
||||
currentPeriodStartsAt: usagePeriod.periodStartsAt,
|
||||
currentPeriodEndsAt: usagePeriod.periodEndsAt,
|
||||
usage: buildUsageSummaries(
|
||||
allowance,
|
||||
new Map(counters.map((counter) => [counter.resource, counter.consumedQuantity])),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureCurrentUsagePeriodForBillingAccount(
|
||||
db: DbClient,
|
||||
billingAccount: BillingAccountRecord,
|
||||
): Promise<UsagePeriodRecord | null> {
|
||||
if (!billingAccount.planCode || !isActivePlanCodeForEntitlements(billingAccount.planCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usageWindow = resolveUsageWindow(billingAccount, new Date());
|
||||
|
||||
if (!usageWindow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ensureUsagePeriodForWorkspace(db, {
|
||||
workspaceId: billingAccount.workspaceId,
|
||||
billingAccountId: billingAccount.id,
|
||||
periodStartsAt: usageWindow.periodStartsAt,
|
||||
periodEndsAt: usageWindow.periodEndsAt,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveUsageWindow(billingAccount: BillingAccountRecord, referenceDate: Date) {
|
||||
if (
|
||||
!billingAccount.currentPeriodStartsAt
|
||||
|| !billingAccount.currentPeriodEndsAt
|
||||
|| (billingAccount.status !== 'active' && billingAccount.status !== 'past_due')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const billingPeriodStartsAt = new Date(billingAccount.currentPeriodStartsAt);
|
||||
const billingPeriodEndsAt = new Date(billingAccount.currentPeriodEndsAt);
|
||||
|
||||
if (Number.isNaN(billingPeriodStartsAt.getTime()) || Number.isNaN(billingPeriodEndsAt.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the stored billing period is already over, do not treat it as the current
|
||||
// usage window. Later subscription lifecycle handling can advance the period.
|
||||
if (referenceDate >= billingPeriodEndsAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let usagePeriodStartsAt = billingPeriodStartsAt;
|
||||
let usagePeriodEndsAt = minDate(addMonthsUtc(usagePeriodStartsAt, 1), billingPeriodEndsAt);
|
||||
|
||||
while (usagePeriodEndsAt <= referenceDate && usagePeriodEndsAt < billingPeriodEndsAt) {
|
||||
usagePeriodStartsAt = usagePeriodEndsAt;
|
||||
usagePeriodEndsAt = minDate(addMonthsUtc(usagePeriodStartsAt, 1), billingPeriodEndsAt);
|
||||
}
|
||||
|
||||
return {
|
||||
periodStartsAt: usagePeriodStartsAt.toISOString(),
|
||||
periodEndsAt: usagePeriodEndsAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildUsageSummaries(
|
||||
allowance: ReturnType<typeof getUsageAllowanceForPlan>,
|
||||
consumedByResource: Map<UsageResource, number>,
|
||||
): BillingUsageResourceSummary[] {
|
||||
return [allowance.researchCredits, allowance.exports, allowance.enrichments, allowance.apiRequests].map((usageAllowance) => {
|
||||
const consumed = consumedByResource.get(usageAllowance.resource) ?? 0;
|
||||
let remaining: number | null;
|
||||
|
||||
// Unavailable resources should report a hard zero remaining balance so later
|
||||
// enforcement and UI layers do not interpret them as unlimited or unknown.
|
||||
if (usageAllowance.availability === 'not_available') {
|
||||
remaining = 0;
|
||||
} else if (usageAllowance.availability === 'custom' || usageAllowance.availability === 'unlimited') {
|
||||
remaining = null;
|
||||
} else {
|
||||
remaining = usageAllowance.included === null ? 0 : Math.max(usageAllowance.included - consumed, 0);
|
||||
}
|
||||
|
||||
return {
|
||||
resource: usageAllowance.resource,
|
||||
availability: usageAllowance.availability,
|
||||
included: usageAllowance.included,
|
||||
consumed,
|
||||
remaining,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function addMonthsUtc(date: Date, monthsToAdd: number) {
|
||||
const nextDate = new Date(date);
|
||||
nextDate.setUTCMonth(nextDate.getUTCMonth() + monthsToAdd);
|
||||
return nextDate;
|
||||
}
|
||||
|
||||
function minDate(a: Date, b: Date) {
|
||||
return a.getTime() <= b.getTime() ? a : b;
|
||||
}
|
||||
|
||||
export async function getWorkspaceAddonBalances(db: DbClient, workspaceId: string): Promise<BillingAddonBalanceSummary[]> {
|
||||
return listAddonBalancesForWorkspace(db, workspaceId);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { estimateDeepResearchBatchCost, type UsageCostEstimate } from '../../../shared/billing/entitlements.js';
|
||||
import type { CreateDeepResearchBatchRequest, DeepResearchBatchDetail, DeepResearchBatchSummary, DeepResearchPreviewRequest, JobStatus } from '../../../shared/types.js';
|
||||
import { listPostalAreasByPropagation, findPostalAreaContainingPoint } from '../postal/repository.js';
|
||||
import { previewDeepResearchForPoint } from '../postal/service.js';
|
||||
@@ -23,12 +24,29 @@ export async function getDeepResearchBatchDetail(db: Pool, userId: string, batch
|
||||
return getDeepResearchBatchDetailForUser(db, userId, batchId);
|
||||
}
|
||||
|
||||
export async function getDeepResearchBatchEstimate(
|
||||
db: Pool,
|
||||
input: CreateDeepResearchBatchRequest,
|
||||
): Promise<{ preview: Awaited<ReturnType<typeof previewDeepResearchForPoint>>; costEstimate: UsageCostEstimate }> {
|
||||
const preview = await previewDeepResearchForPoint(db, input as DeepResearchPreviewRequest);
|
||||
const costEstimate = estimateDeepResearchBatchCost({
|
||||
estimatedChildJobs: preview.estimatedChildJobs,
|
||||
totalAreas: preview.totalAreas,
|
||||
});
|
||||
|
||||
return {
|
||||
preview,
|
||||
costEstimate,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createDeepResearchBatchForUser(
|
||||
db: Pool,
|
||||
userId: string,
|
||||
input: CreateDeepResearchBatchRequest,
|
||||
options?: { preview?: Awaited<ReturnType<typeof previewDeepResearchForPoint>> },
|
||||
): Promise<DeepResearchBatchDetail> {
|
||||
const preview = await previewDeepResearchForPoint(db, input as DeepResearchPreviewRequest);
|
||||
const preview = options?.preview ?? await previewDeepResearchForPoint(db, input as DeepResearchPreviewRequest);
|
||||
const baseArea = await findPostalAreaContainingPoint(db, input.lat, input.lng);
|
||||
|
||||
if (!baseArea) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
import { ZodError, z } from 'zod';
|
||||
import { estimateDeepResearchBatchCost } from '../../../shared/billing/entitlements.js';
|
||||
import { requireAuth } from '../auth/middleware.js';
|
||||
import { buildEntitlementErrorResponse, checkActionEntitlementForWorkspace, getWorkspaceEnforcementContext, recordSuccessfulActionUsage } from '../billing/enforcement-service.js';
|
||||
import { getDbPool } from '../db/pool.js';
|
||||
import { createDeepResearchBatchForUser, getDeepResearchBatchDetail, listDeepResearchBatches } from '../deep-research/service.js';
|
||||
import { createDeepResearchBatchForUser, getDeepResearchBatchDetail, getDeepResearchBatchEstimate, listDeepResearchBatches } from '../deep-research/service.js';
|
||||
import { previewDeepResearchForPoint } from '../postal/service.js';
|
||||
|
||||
const previewSchema = z.object({
|
||||
@@ -40,7 +42,27 @@ export const deepResearchRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post('/deep-research/batches', { preHandler: requireAuth }, async (request, reply) => {
|
||||
try {
|
||||
const payload = previewSchema.parse(request.body);
|
||||
const batch = await createDeepResearchBatchForUser(getDbPool(), request.authUser!.id, payload);
|
||||
const db = getDbPool();
|
||||
const enforcementContext = await getWorkspaceEnforcementContext(db, request.authUser!);
|
||||
const { preview, costEstimate } = await getDeepResearchBatchEstimate(db, payload);
|
||||
const enforcement = await checkActionEntitlementForWorkspace(db, {
|
||||
userId: request.authUser!.id,
|
||||
workspaceId: enforcementContext.workspaceId,
|
||||
action: 'deep_research_batch_run',
|
||||
costEstimate,
|
||||
});
|
||||
|
||||
if (!enforcement.allowed) {
|
||||
const errorResponse = buildEntitlementErrorResponse(enforcement.decision);
|
||||
return reply.code(errorResponse.statusCode).send(errorResponse.body);
|
||||
}
|
||||
|
||||
const batch = await createDeepResearchBatchForUser(db, request.authUser!.id, payload, { preview });
|
||||
await recordSuccessfulActionUsage(db, {
|
||||
workspaceId: enforcementContext.workspaceId,
|
||||
action: 'deep_research_batch_run',
|
||||
costEstimate,
|
||||
});
|
||||
return reply.code(201).send({ batch });
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { requireAuth } from '../auth/middleware.js';
|
||||
import { estimateBasicSearchCost } from '../../../shared/billing/entitlements.js';
|
||||
import { getDbPool } from '../db/pool.js';
|
||||
import { buildEntitlementErrorResponse, checkActionEntitlementForWorkspace, getWorkspaceEnforcementContext, recordSuccessfulActionUsage } from '../billing/enforcement-service.js';
|
||||
import { listBusinessesForJobIds, listBusinessesForUser, listSearchJobResultLinksForUser, listSearchJobsForUser, getSearchJobForUser } from '../search/repository.js';
|
||||
import { runSearchForUser } from '../search/run-search.js';
|
||||
|
||||
@@ -29,7 +31,26 @@ export const searchJobRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post('/search-jobs', { preHandler: requireAuth }, async (request, reply) => {
|
||||
try {
|
||||
const payload = runSearchSchema.parse(request.body);
|
||||
const result = await runSearchForUser(getDbPool(), request.authUser!.id, payload);
|
||||
const db = getDbPool();
|
||||
const enforcementContext = await getWorkspaceEnforcementContext(db, request.authUser!);
|
||||
const enforcement = await checkActionEntitlementForWorkspace(db, {
|
||||
userId: request.authUser!.id,
|
||||
workspaceId: enforcementContext.workspaceId,
|
||||
action: 'basic_search_run',
|
||||
costEstimate: estimateBasicSearchCost(),
|
||||
});
|
||||
|
||||
if (!enforcement.allowed) {
|
||||
const errorResponse = buildEntitlementErrorResponse(enforcement.decision);
|
||||
return reply.code(errorResponse.statusCode).send(errorResponse.body);
|
||||
}
|
||||
|
||||
const result = await runSearchForUser(db, request.authUser!.id, payload);
|
||||
await recordSuccessfulActionUsage(db, {
|
||||
workspaceId: enforcementContext.workspaceId,
|
||||
action: 'basic_search_run',
|
||||
costEstimate: estimateBasicSearchCost(),
|
||||
});
|
||||
return reply.code(201).send(result);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
|
||||
Reference in New Issue
Block a user