src/dashboard/billing-health.js
Rama master · réplica de lectura del repositorio original.
/**
* P0-2 billing health — pure state machine + aggregate reader.
*
* The state is derived from six independent billing reads; one failed secondary
* source must not blank the banner. `billing_error` is free text and therefore a
* warning, never a state. A 403 is "no permission" (gray), never a stale state
* and never zeros.
*/
import {
TTL_MS,
cacheKey,
httpStatusForKind,
readCached,
readDevinJson,
redactUpstreamText,
resolveAccount,
resolveOrgId,
sourceSummary,
worstError,
ReadError,
} from './devin-read.js';
export const BILLING_BANNERS = {
ok: { severity: 'ok', bannerKey: 'billing.banner.ok' },
quota_exhausted: { severity: 'warn', bannerKey: 'billing.banner.quotaExhausted' },
paused: { severity: 'error', bannerKey: 'billing.banner.paused' },
unpaid: { severity: 'error', bannerKey: 'billing.banner.unpaid' },
recharge_failed: { severity: 'error', bannerKey: 'billing.banner.rechargeFailed' },
no_subscription: { severity: 'muted', bannerKey: 'billing.banner.noSubscription' },
unknown: { severity: 'muted', bannerKey: 'billing.banner.unknown' },
};
function num(value) {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function bool(value) {
return typeof value === 'boolean' ? value : null;
}
function objectOrEmpty(value) {
return value && typeof value === 'object' ? value : {};
}
/**
* Exact precedence:
* paused > unpaid > recharge_failed > quota_exhausted > no_subscription > ok
* `unknown` only when a blocking source is unavailable and no blocking state is
* known. `billing_error` never changes the state.
*/
export function deriveBillingState({ status, quota, suspended, unpaid, sources = {} } = {}) {
const s = objectOrEmpty(status);
const q = objectOrEmpty(quota);
const sus = objectOrEmpty(suspended);
const up = objectOrEmpty(unpaid);
const sessionCount = Array.isArray(sus.sessions) ? sus.sessions.length : null;
const canRestartNow = bool(sus.can_restart_now);
if (sessionCount !== null && sessionCount > 0 && canRestartNow === false) return 'paused';
if (up.has_unpaid_invoices === true) return 'unpaid';
if (s.has_failed_auto_recharge === true) return 'recharge_failed';
if (q.is_quota_plan === true && q.has_quota_allocation === true) {
const daily = num(q.daily_percentage);
const weekly = num(q.weekly_percentage);
if ((daily !== null && daily >= 100) || (weekly !== null && weekly >= 100)) return 'quota_exhausted';
}
if (s.has_subscription_or_credits === false || s.is_subscription_valid === false) return 'no_subscription';
const unavailable = (name) => sources?.[name]?.ok === false;
const blocking = ['status', 'suspended', 'unpaid'];
// A credits-only plan does not need quota/usage to decide ok/unknown.
if (s.uses_quota !== false) blocking.push('quota');
if (blocking.some(unavailable)) return 'unknown';
return 'ok';
}
export function buildBillingHealth({
accountId = null,
accountRef = null,
status,
quota,
suspended,
unpaid,
autorecharge,
spending,
sources = {},
now = Date.now(),
} = {}) {
const state = deriveBillingState({ status, quota, suspended, unpaid, sources });
const banner = BILLING_BANNERS[state] || BILLING_BANNERS.unknown;
const s = objectOrEmpty(status);
const sus = objectOrEmpty(suspended);
const up = objectOrEmpty(unpaid);
const auto = objectOrEmpty(autorecharge);
const spend = objectOrEmpty(spending);
const sessionCount = Array.isArray(sus.sessions) ? sus.sessions.length : null;
const canRestartNow = bool(sus.can_restart_now);
const billingError = typeof s.billing_error === 'string' && s.billing_error
? redactUpstreamText(s.billing_error)
: null;
const waived = Array.isArray(s.billing_waived_agent_versions)
? s.billing_waived_agent_versions.length
: null;
const warnings = [];
if (billingError) warnings.push('billing_error');
if (sessionCount !== null && sessionCount > 0 && canRestartNow === true) warnings.push('sessions_restartable');
if (auto.account_migrated_to_auto_recharge === true && auto.auto_recharge_enabled === false) {
warnings.push('auto_recharge_disabled');
}
const summaries = Object.values(sources || {});
return {
ok: true,
accountId,
accountRef,
fetchedAt: now,
stale: summaries.some((source) => source?.stale === true),
partial: summaries.some((source) => source?.ok === false),
state,
severity: banner.severity,
bannerKey: banner.bannerKey,
details: {
plan: typeof s.plan_slug === 'string' ? s.plan_slug : null,
billingValid: bool(s.is_subscription_valid),
billingMethod: typeof s.billing_method === 'string' ? s.billing_method : null,
isEnterprise: bool(s.is_enterprise),
usesQuota: bool(s.uses_quota),
planUsesCredits: bool(s.plan_uses_credits),
showConsumptionDashboard: bool(s.show_consumption_dashboard),
hasSubscriptionOrCredits: bool(s.has_subscription_or_credits),
billingError,
hasFailedAutoRecharge: bool(s.has_failed_auto_recharge),
hasUnpaidInvoices: bool(up.has_unpaid_invoices),
suspendedSessionCount: sessionCount,
canRestartNow,
spendingState: typeof spend.state === 'string' ? spend.state : null,
autoRecharge: {
migrated: bool(auto.account_migrated_to_auto_recharge),
enabled: bool(auto.auto_recharge_enabled),
threshold: num(auto.threshold),
amount: num(auto.amount),
maxMonthlyTotalSpend: num(auto.max_monthly_total_spend),
},
waivedAgentVersionsCount: waived,
},
warnings,
sources,
};
}
export async function readBillingHealth({ id, force = false, fetchImpl = null, now = Date.now() } = {}) {
const account = resolveAccount(id);
if (!account.ok) return { ok: false, error: account.error, httpStatus: account.status };
let orgId;
try {
orgId = await resolveOrgId({ token: account.token, fetchImpl, force });
} catch (err) {
const kind = err instanceof ReadError ? err.kind : 'upstream';
return {
ok: false,
accountId: account.accountId,
accountRef: account.accountRef,
error: kind,
httpStatus: err?.status || httpStatusForKind(kind),
};
}
const read = (name, path, ttl, orgScoped = true) => readCached(
cacheKey({ accountId: account.accountId, token: account.token, name }),
ttl,
() => readDevinJson({
token: account.token,
orgId: orgScoped ? orgId : '',
path,
fetchImpl,
}),
{ force },
);
const [status, quota, spending, suspended, unpaid, autorecharge] = await Promise.all([
read('status', `/${orgId}/billing/status`, TTL_MS.status),
read('quota', `/${orgId}/billing/quota/usage`, TTL_MS.quota),
read('spending', '/users/spending-state', TTL_MS.spending, false),
read('suspended', `/${orgId}/limit-suspended-sessions`, TTL_MS.suspended),
read('unpaid', '/billing/subscription/unpaid-invoices', TTL_MS.unpaid, false),
read('autorecharge', `/${orgId}/billing/auto-recharge/params`, TTL_MS.autorecharge),
]);
const results = { status, quota, spending, suspended, unpaid, autorecharge };
const sources = Object.fromEntries(
Object.entries(results).map(([name, result]) => [name, sourceSummary(result)]),
);
// A 403 on billing/status is "no permission": no state, no stale fallback.
if (status.error === 'forbidden') {
return {
ok: false,
accountId: account.accountId,
accountRef: account.accountRef,
error: 'forbidden',
httpStatus: 403,
sources,
};
}
if (Object.values(results).every((result) => !result.ok)) {
const kind = worstError(Object.values(results).map((result) => result.error));
return {
ok: false,
accountId: account.accountId,
accountRef: account.accountRef,
error: kind,
httpStatus: httpStatusForKind(kind),
sources,
};
}
return buildBillingHealth({
accountId: account.accountId,
accountRef: account.accountRef,
status: status.value,
quota: quota.value,
suspended: suspended.value,
unpaid: unpaid.value,
autorecharge: autorecharge.value,
spending: spending.value,
sources,
now,
});
}