feat(costs): add billing, quota, and budget control plane

This commit is contained in:
Dotta
2026-03-14 22:00:12 -05:00
parent 656b4659fc
commit 76e6cc08a6
91 changed files with 22406 additions and 769 deletions

View File

@@ -22,6 +22,9 @@ vi.mock("../services/index.js", () => ({
canUser: vi.fn(),
ensureMembership: vi.fn(),
}),
budgetService: () => ({
upsertPolicy: vi.fn(),
}),
logActivity: vi.fn(),
}));

View File

@@ -1,14 +1,9 @@
import express from "express";
import request from "supertest";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { costRoutes } from "../routes/costs.js";
import { errorHandler } from "../middleware/index.js";
// ---------------------------------------------------------------------------
// parseDateRange — tested via the route handler since it's a private function
// ---------------------------------------------------------------------------
// Minimal db stub — just enough for costService() not to throw at construction
function makeDb(overrides: Record<string, unknown> = {}) {
const selectChain = {
from: vi.fn().mockReturnThis(),
@@ -17,9 +12,10 @@ function makeDb(overrides: Record<string, unknown> = {}) {
innerJoin: vi.fn().mockReturnThis(),
groupBy: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
then: vi.fn().mockResolvedValue([]),
};
// Make it thenable so Drizzle query chains resolve to []
const thenableChain = Object.assign(Promise.resolve([]), selectChain);
return {
@@ -43,17 +39,40 @@ const mockAgentService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockFetchAllQuotaWindows = vi.hoisted(() => vi.fn());
const mockCostService = vi.hoisted(() => ({
createEvent: vi.fn(),
summary: vi.fn().mockResolvedValue({ spendCents: 0 }),
byAgent: vi.fn().mockResolvedValue([]),
byAgentModel: vi.fn().mockResolvedValue([]),
byProvider: vi.fn().mockResolvedValue([]),
byBiller: vi.fn().mockResolvedValue([]),
windowSpend: vi.fn().mockResolvedValue([]),
byProject: vi.fn().mockResolvedValue([]),
}));
const mockFinanceService = vi.hoisted(() => ({
createEvent: vi.fn(),
summary: vi.fn().mockResolvedValue({ debitCents: 0, creditCents: 0, netCents: 0, estimatedDebitCents: 0, eventCount: 0 }),
byBiller: vi.fn().mockResolvedValue([]),
byKind: vi.fn().mockResolvedValue([]),
list: vi.fn().mockResolvedValue([]),
}));
const mockBudgetService = vi.hoisted(() => ({
overview: vi.fn().mockResolvedValue({
companyId: "company-1",
policies: [],
activeIncidents: [],
pausedAgentCount: 0,
pausedProjectCount: 0,
pendingApprovalCount: 0,
}),
upsertPolicy: vi.fn(),
resolveIncident: vi.fn(),
}));
vi.mock("../services/index.js", () => ({
costService: () => ({
createEvent: vi.fn(),
summary: vi.fn().mockResolvedValue({ spendCents: 0 }),
byAgent: vi.fn().mockResolvedValue([]),
byAgentModel: vi.fn().mockResolvedValue([]),
byProvider: vi.fn().mockResolvedValue([]),
windowSpend: vi.fn().mockResolvedValue([]),
byProject: vi.fn().mockResolvedValue([]),
}),
budgetService: () => mockBudgetService,
costService: () => mockCostService,
financeService: () => mockFinanceService,
companyService: () => mockCompanyService,
agentService: () => mockAgentService,
logActivity: mockLogActivity,
@@ -75,8 +94,12 @@ function createApp() {
return app;
}
describe("parseDateRange — date validation via route", () => {
it("accepts valid ISO date strings and passes them to the service", async () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("cost routes", () => {
it("accepts valid ISO date strings and passes them to cost summary routes", async () => {
const app = createApp();
const res = await request(app)
.get("/api/companies/company-1/costs/summary")
@@ -102,138 +125,30 @@ describe("parseDateRange — date validation via route", () => {
expect(res.body.error).toMatch(/invalid 'to' date/i);
});
it("treats missing 'from' and 'to' as no range (passes undefined to service)", async () => {
it("returns finance summary rows for valid requests", async () => {
const app = createApp();
const res = await request(app).get("/api/companies/company-1/costs/summary");
const res = await request(app)
.get("/api/companies/company-1/costs/finance-summary")
.query({ from: "2026-02-01T00:00:00.000Z", to: "2026-02-28T23:59:59.999Z" });
expect(res.status).toBe(200);
});
});
// ---------------------------------------------------------------------------
// byProvider pro-rata subscription split — pure math, no DB needed
// ---------------------------------------------------------------------------
// The split logic operates on arrays returned by DB queries.
// We test it by calling the actual costService with a mock DB that yields
// controlled query results and verifying the output proportions.
import { costService } from "../services/index.js";
describe("byProvider — pro-rata subscription attribution", () => {
it("splits subscription counts proportionally by token share", async () => {
// Two models: modelA has 75% of tokens, modelB has 25%.
// Total subscription runs = 100, sub input tokens = 1000, sub output tokens = 400.
// Expected: modelA gets 75% of each, modelB gets 25%.
// We bypass the DB by directly exercising the accumulator math.
// Inline the accumulation logic from costs.ts to verify the arithmetic is correct.
const costRows = [
{ provider: "anthropic", model: "claude-sonnet", costCents: 300, inputTokens: 600, outputTokens: 150 },
{ provider: "anthropic", model: "claude-haiku", costCents: 100, inputTokens: 200, outputTokens: 50 },
];
const subscriptionTotals = {
apiRunCount: 20,
subscriptionRunCount: 100,
subscriptionInputTokens: 1000,
subscriptionOutputTokens: 400,
};
const totalTokens = costRows.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0);
// totalTokens = (600+150) + (200+50) = 750 + 250 = 1000
const result = costRows.map((row) => {
const rowTokens = row.inputTokens + row.outputTokens;
const share = totalTokens > 0 ? rowTokens / totalTokens : 0;
return {
...row,
apiRunCount: Math.round(subscriptionTotals.apiRunCount * share),
subscriptionRunCount: Math.round(subscriptionTotals.subscriptionRunCount * share),
subscriptionInputTokens: Math.round(subscriptionTotals.subscriptionInputTokens * share),
subscriptionOutputTokens: Math.round(subscriptionTotals.subscriptionOutputTokens * share),
};
});
// modelA: 750/1000 = 75%
expect(result[0]!.subscriptionRunCount).toBe(75); // 100 * 0.75
expect(result[0]!.subscriptionInputTokens).toBe(750); // 1000 * 0.75
expect(result[0]!.subscriptionOutputTokens).toBe(300); // 400 * 0.75
expect(result[0]!.apiRunCount).toBe(15); // 20 * 0.75
// modelB: 250/1000 = 25%
expect(result[1]!.subscriptionRunCount).toBe(25); // 100 * 0.25
expect(result[1]!.subscriptionInputTokens).toBe(250); // 1000 * 0.25
expect(result[1]!.subscriptionOutputTokens).toBe(100); // 400 * 0.25
expect(result[1]!.apiRunCount).toBe(5); // 20 * 0.25
});
it("assigns share=0 to all rows when totalTokens is zero (avoids divide-by-zero)", () => {
const costRows = [
{ provider: "anthropic", model: "claude-sonnet", costCents: 0, inputTokens: 0, outputTokens: 0 },
{ provider: "openai", model: "gpt-5", costCents: 0, inputTokens: 0, outputTokens: 0 },
];
const subscriptionTotals = { apiRunCount: 10, subscriptionRunCount: 5, subscriptionInputTokens: 100, subscriptionOutputTokens: 50 };
const totalTokens = 0;
const result = costRows.map((row) => {
const rowTokens = row.inputTokens + row.outputTokens;
const share = totalTokens > 0 ? rowTokens / totalTokens : 0;
return {
subscriptionRunCount: Math.round(subscriptionTotals.subscriptionRunCount * share),
subscriptionInputTokens: Math.round(subscriptionTotals.subscriptionInputTokens * share),
};
});
expect(result[0]!.subscriptionRunCount).toBe(0);
expect(result[0]!.subscriptionInputTokens).toBe(0);
expect(result[1]!.subscriptionRunCount).toBe(0);
expect(result[1]!.subscriptionInputTokens).toBe(0);
});
it("attribution rounds to nearest integer (no fractional run counts)", () => {
// 3 models, 10 runs to split — rounding may not sum to exactly 10, that's expected
const costRows = [
{ inputTokens: 1, outputTokens: 0 }, // 1/3
{ inputTokens: 1, outputTokens: 0 }, // 1/3
{ inputTokens: 1, outputTokens: 0 }, // 1/3
];
const totalTokens = 3;
const subscriptionRunCount = 10;
const result = costRows.map((row) => {
const share = row.inputTokens / totalTokens;
return Math.round(subscriptionRunCount * share);
});
// Each should be Math.round(10/3) = Math.round(3.33) = 3
expect(result).toEqual([3, 3, 3]);
for (const count of result) {
expect(Number.isInteger(count)).toBe(true);
}
});
});
// ---------------------------------------------------------------------------
// windowSpend — verify shape of rolling window results
// ---------------------------------------------------------------------------
describe("windowSpend — rolling window labels and hours", () => {
it("returns results for the three standard windows (5h, 24h, 7d)", async () => {
// The windowSpend method computes three rolling windows internally.
// We verify the expected window labels exist in a real call by checking
// the service contract shape. Since we're not connecting to a DB here,
// we verify the window definitions directly from service source by
// exercising the label computation inline.
const windows = [
{ label: "5h", hours: 5 },
{ label: "24h", hours: 24 },
{ label: "7d", hours: 168 },
] as const;
// All three standard windows must be present
expect(windows.map((w) => w.label)).toEqual(["5h", "24h", "7d"]);
// Hours must match expected durations
expect(windows[0]!.hours).toBe(5);
expect(windows[1]!.hours).toBe(24);
expect(windows[2]!.hours).toBe(168); // 7 * 24
expect(mockFinanceService.summary).toHaveBeenCalled();
});
it("returns 400 for invalid finance event list limits", async () => {
const app = createApp();
const res = await request(app)
.get("/api/companies/company-1/costs/finance-events")
.query({ limit: "0" });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid 'limit'/i);
});
it("accepts valid finance event list limits", async () => {
const app = createApp();
const res = await request(app)
.get("/api/companies/company-1/costs/finance-events")
.query({ limit: "25" });
expect(res.status).toBe(200);
expect(mockFinanceService.list).toHaveBeenCalledWith("company-1", undefined, 25);
});
});

View File

@@ -0,0 +1,56 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
vi.mock("../adapters/registry.js", () => ({
listServerAdapters: vi.fn(),
}));
import { listServerAdapters } from "../adapters/registry.js";
import { fetchAllQuotaWindows } from "../services/quota-windows.js";
describe("fetchAllQuotaWindows", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("returns adapter results without waiting for a slower provider to finish forever", async () => {
vi.mocked(listServerAdapters).mockReturnValue([
{
type: "codex_local",
getQuotaWindows: vi.fn().mockResolvedValue({
provider: "openai",
source: "codex-rpc",
ok: true,
windows: [{ label: "5h limit", usedPercent: 2, resetsAt: null, valueLabel: null, detail: null }],
}),
},
{
type: "claude_local",
getQuotaWindows: vi.fn(() => new Promise(() => {})),
},
] as never);
const promise = fetchAllQuotaWindows();
await vi.advanceTimersByTimeAsync(20_001);
const results = await promise;
expect(results).toEqual([
{
provider: "openai",
source: "codex-rpc",
ok: true,
windows: [{ label: "5h limit", usedPercent: 2, resetsAt: null, valueLabel: null, detail: null }],
},
{
provider: "anthropic",
ok: false,
error: "quota polling timed out after 20s",
windows: [],
},
]);
});
});

View File

@@ -8,14 +8,17 @@ import {
toPercent,
fetchWithTimeout,
fetchClaudeQuota,
parseClaudeCliUsageText,
readClaudeToken,
claudeConfigDir,
} from "@paperclipai/adapter-claude-local/server";
import {
secondsToWindowLabel,
readCodexAuthInfo,
readCodexToken,
fetchCodexQuota,
mapCodexRpcQuota,
codexHomeDir,
} from "@paperclipai/adapter-codex-local/server";
@@ -271,13 +274,86 @@ describe("readClaudeToken", () => {
expect(token).toBe("my-test-token");
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
});
it("reads the token from .credentials.json when that is the available Claude auth file", async () => {
const tmpDir = path.join(os.tmpdir(), `paperclip-test-claude-${Date.now()}`);
const creds = { claudeAiOauth: { accessToken: "dotfile-token" } };
await import("node:fs/promises").then((fs) =>
fs.mkdir(tmpDir, { recursive: true }).then(() =>
fs.writeFile(path.join(tmpDir, ".credentials.json"), JSON.stringify(creds)),
),
);
process.env.CLAUDE_CONFIG_DIR = tmpDir;
const token = await readClaudeToken();
expect(token).toBe("dotfile-token");
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
});
});
describe("parseClaudeCliUsageText", () => {
it("parses the Claude usage panel layout into quota windows", () => {
const raw = `
Settings: Status Config Usage
Current session
2% used
Resets 5pm (America/Chicago)
Current week (all models)
47% used
Resets Mar 18 at 7:59am (America/Chicago)
Current week (Sonnet only)
0% used
Resets Mar 18 at 8:59am (America/Chicago)
Extra usage
Extra usage not enabled • /extra-usage to enable
`;
expect(parseClaudeCliUsageText(raw)).toEqual([
{
label: "Current session",
usedPercent: 2,
resetsAt: null,
valueLabel: null,
detail: "Resets 5pm (America/Chicago)",
},
{
label: "Current week (all models)",
usedPercent: 47,
resetsAt: null,
valueLabel: null,
detail: "Resets Mar 18 at 7:59am (America/Chicago)",
},
{
label: "Current week (Sonnet only)",
usedPercent: 0,
resetsAt: null,
valueLabel: null,
detail: "Resets Mar 18 at 8:59am (America/Chicago)",
},
{
label: "Extra usage",
usedPercent: null,
resetsAt: null,
valueLabel: null,
detail: "Extra usage not enabled • /extra-usage to enable",
},
]);
});
it("throws a useful error when the Claude CLI panel reports a usage load failure", () => {
expect(() => parseClaudeCliUsageText("Failed to load usage data")).toThrow(
"Claude CLI could not load usage data. Open the CLI and retry `/usage`.",
);
});
});
// ---------------------------------------------------------------------------
// readCodexToken — filesystem paths
// readCodexAuthInfo / readCodexToken — filesystem paths
// ---------------------------------------------------------------------------
describe("readCodexToken", () => {
describe("readCodexAuthInfo", () => {
const savedEnv = process.env.CODEX_HOME;
afterEach(() => {
@@ -290,7 +366,7 @@ describe("readCodexToken", () => {
it("returns null when auth.json does not exist", async () => {
process.env.CODEX_HOME = "/tmp/__no_such_paperclip_codex_dir__";
const result = await readCodexToken();
const result = await readCodexAuthInfo();
expect(result).toBe(null);
});
@@ -302,7 +378,7 @@ describe("readCodexToken", () => {
),
);
process.env.CODEX_HOME = tmpDir;
const result = await readCodexToken();
const result = await readCodexAuthInfo();
expect(result).toBe(null);
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
});
@@ -315,12 +391,12 @@ describe("readCodexToken", () => {
),
);
process.env.CODEX_HOME = tmpDir;
const result = await readCodexToken();
const result = await readCodexAuthInfo();
expect(result).toBe(null);
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
});
it("returns token and accountId when both are present", async () => {
it("reads the legacy flat auth shape", async () => {
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
const auth = { accessToken: "codex-token", accountId: "acc-123" };
await import("node:fs/promises").then((fs) =>
@@ -329,21 +405,81 @@ describe("readCodexToken", () => {
),
);
process.env.CODEX_HOME = tmpDir;
const result = await readCodexToken();
expect(result).toEqual({ token: "codex-token", accountId: "acc-123" });
const result = await readCodexAuthInfo();
expect(result).toMatchObject({
accessToken: "codex-token",
accountId: "acc-123",
email: null,
planType: null,
});
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
});
it("returns token with null accountId when accountId is absent", async () => {
it("reads the modern nested auth shape", async () => {
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
const jwtPayload = Buffer.from(
JSON.stringify({
email: "codex@example.com",
"https://api.openai.com/auth": {
chatgpt_plan_type: "pro",
chatgpt_user_email: "codex@example.com",
},
}),
).toString("base64url");
const auth = {
tokens: {
access_token: `header.${jwtPayload}.sig`,
account_id: "acc-modern",
refresh_token: "refresh-me",
id_token: `header.${jwtPayload}.sig`,
},
last_refresh: "2026-03-14T12:00:00Z",
};
await import("node:fs/promises").then((fs) =>
fs.mkdir(tmpDir, { recursive: true }).then(() =>
fs.writeFile(path.join(tmpDir, "auth.json"), JSON.stringify(auth)),
),
);
process.env.CODEX_HOME = tmpDir;
const result = await readCodexAuthInfo();
expect(result).toMatchObject({
accessToken: `header.${jwtPayload}.sig`,
accountId: "acc-modern",
refreshToken: "refresh-me",
email: "codex@example.com",
planType: "pro",
lastRefresh: "2026-03-14T12:00:00Z",
});
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
});
});
describe("readCodexToken", () => {
const savedEnv = process.env.CODEX_HOME;
afterEach(() => {
if (savedEnv === undefined) {
delete process.env.CODEX_HOME;
} else {
process.env.CODEX_HOME = savedEnv;
}
});
it("returns token and accountId from the nested auth shape", async () => {
const tmpDir = path.join(os.tmpdir(), `paperclip-test-codex-${Date.now()}`);
await import("node:fs/promises").then((fs) =>
fs.mkdir(tmpDir, { recursive: true }).then(() =>
fs.writeFile(path.join(tmpDir, "auth.json"), JSON.stringify({ accessToken: "tok" })),
fs.writeFile(path.join(tmpDir, "auth.json"), JSON.stringify({
tokens: {
access_token: "nested-token",
account_id: "acc-nested",
},
})),
),
);
process.env.CODEX_HOME = tmpDir;
const result = await readCodexToken();
expect(result).toEqual({ token: "tok", accountId: null });
expect(result).toEqual({ token: "nested-token", accountId: "acc-nested" });
await import("node:fs/promises").then((fs) => fs.rm(tmpDir, { recursive: true }));
});
});
@@ -384,14 +520,22 @@ describe("fetchClaudeQuota", () => {
mockFetch({ five_hour: { utilization: 0.4, resets_at: "2026-01-01T00:00:00Z" } });
const windows = await fetchClaudeQuota("token");
expect(windows).toHaveLength(1);
expect(windows[0]).toMatchObject({ label: "5h", usedPercent: 40, resetsAt: "2026-01-01T00:00:00Z" });
expect(windows[0]).toMatchObject({
label: "Current session",
usedPercent: 40,
resetsAt: "2026-01-01T00:00:00Z",
});
});
it("parses seven_day window", async () => {
mockFetch({ seven_day: { utilization: 0.75, resets_at: null } });
const windows = await fetchClaudeQuota("token");
expect(windows).toHaveLength(1);
expect(windows[0]).toMatchObject({ label: "7d", usedPercent: 75, resetsAt: null });
expect(windows[0]).toMatchObject({
label: "Current week (all models)",
usedPercent: 75,
resetsAt: null,
});
});
it("parses seven_day_sonnet and seven_day_opus windows", async () => {
@@ -401,8 +545,8 @@ describe("fetchClaudeQuota", () => {
});
const windows = await fetchClaudeQuota("token");
expect(windows).toHaveLength(2);
expect(windows[0]!.label).toBe("Sonnet 7d");
expect(windows[1]!.label).toBe("Opus 7d");
expect(windows[0]!.label).toBe("Current week (Sonnet only)");
expect(windows[1]!.label).toBe("Current week (Opus only)");
});
it("sets usedPercent to null when utilization is absent", async () => {
@@ -421,7 +565,31 @@ describe("fetchClaudeQuota", () => {
const windows = await fetchClaudeQuota("token");
expect(windows).toHaveLength(4);
const labels = windows.map((w: QuotaWindow) => w.label);
expect(labels).toEqual(["5h", "7d", "Sonnet 7d", "Opus 7d"]);
expect(labels).toEqual([
"Current session",
"Current week (all models)",
"Current week (Sonnet only)",
"Current week (Opus only)",
]);
});
it("parses extra usage when the OAuth response includes it", async () => {
mockFetch({
extra_usage: {
is_enabled: false,
utilization: null,
},
});
const windows = await fetchClaudeQuota("token");
expect(windows).toEqual([
{
label: "Extra usage",
usedPercent: null,
resetsAt: null,
valueLabel: "Not enabled",
detail: "Extra usage not enabled",
},
]);
});
});
@@ -471,15 +639,15 @@ describe("fetchCodexQuota", () => {
expect(windows).toEqual([]);
});
it("parses primary_window with 24h label", async () => {
it("normalizes numeric reset timestamps from WHAM", async () => {
mockFetch({
rate_limit: {
primary_window: { used_percent: 30, limit_window_seconds: 86400, reset_at: "2026-01-02T00:00:00Z" },
primary_window: { used_percent: 30, limit_window_seconds: 86400, reset_at: 1_767_312_000 },
},
});
const windows = await fetchCodexQuota("token", null);
expect(windows).toHaveLength(1);
expect(windows[0]).toMatchObject({ label: "24h", usedPercent: 30, resetsAt: "2026-01-02T00:00:00Z" });
expect(windows[0]).toMatchObject({ label: "5h limit", usedPercent: 30, resetsAt: "2026-01-02T00:00:00.000Z" });
});
it("parses secondary_window alongside primary_window", async () => {
@@ -491,8 +659,8 @@ describe("fetchCodexQuota", () => {
});
const windows = await fetchCodexQuota("token", null);
expect(windows).toHaveLength(2);
expect(windows[0]!.label).toBe("5h");
expect(windows[1]!.label).toBe("7d");
expect(windows[0]!.label).toBe("5h limit");
expect(windows[1]!.label).toBe("Weekly limit");
});
it("includes Credits window when credits present and not unlimited", async () => {
@@ -521,6 +689,90 @@ describe("fetchCodexQuota", () => {
});
});
describe("mapCodexRpcQuota", () => {
it("maps account and model-specific Codex limits into quota windows", () => {
const snapshot = mapCodexRpcQuota(
{
rateLimits: {
limitId: "codex",
primary: { usedPercent: 1, windowDurationMins: 300, resetsAt: 1_763_500_000 },
secondary: { usedPercent: 27, windowDurationMins: 10_080 },
planType: "pro",
},
rateLimitsByLimitId: {
codex_bengalfox: {
limitId: "codex_bengalfox",
limitName: "GPT-5.3-Codex-Spark",
primary: { usedPercent: 8, windowDurationMins: 300 },
secondary: { usedPercent: 20, windowDurationMins: 10_080 },
},
},
},
{
account: {
email: "codex@example.com",
planType: "pro",
},
},
);
expect(snapshot.email).toBe("codex@example.com");
expect(snapshot.planType).toBe("pro");
expect(snapshot.windows).toEqual([
{
label: "5h limit",
usedPercent: 1,
resetsAt: "2025-11-18T21:06:40.000Z",
valueLabel: null,
detail: null,
},
{
label: "Weekly limit",
usedPercent: 27,
resetsAt: null,
valueLabel: null,
detail: null,
},
{
label: "GPT-5.3-Codex-Spark · 5h limit",
usedPercent: 8,
resetsAt: null,
valueLabel: null,
detail: null,
},
{
label: "GPT-5.3-Codex-Spark · Weekly limit",
usedPercent: 20,
resetsAt: null,
valueLabel: null,
detail: null,
},
]);
});
it("includes a credits row when the root Codex limit reports finite credits", () => {
const snapshot = mapCodexRpcQuota({
rateLimits: {
limitId: "codex",
credits: {
unlimited: false,
balance: "12.34",
},
},
});
expect(snapshot.windows).toEqual([
{
label: "Credits",
usedPercent: null,
resetsAt: null,
valueLabel: "$12.34 remaining",
detail: null,
},
]);
});
});
// ---------------------------------------------------------------------------
// fetchWithTimeout — abort on timeout
// ---------------------------------------------------------------------------

View File

@@ -23,6 +23,7 @@ import {
agentService,
accessService,
approvalService,
budgetService,
heartbeatService,
issueApprovalService,
issueService,
@@ -57,6 +58,7 @@ export function agentRoutes(db: Db) {
const svc = agentService(db);
const access = accessService(db);
const approvalsSvc = approvalService(db);
const budgets = budgetService(db);
const heartbeat = heartbeatService(db);
const issueApprovalsSvc = issueApprovalService(db);
const secretsSvc = secretService(db);
@@ -941,6 +943,19 @@ export function agentRoutes(db: Db) {
details: { name: agent.name, role: agent.role },
});
if (agent.budgetMonthlyCents > 0) {
await budgets.upsertPolicy(
companyId,
{
scopeType: "agent",
scopeId: agent.id,
amount: agent.budgetMonthlyCents,
windowKind: "calendar_month_utc",
},
actor.actorType === "user" ? actor.actorId : null,
);
}
res.status(201).json(agent);
});

View File

@@ -9,7 +9,13 @@ import {
} from "@paperclipai/shared";
import { forbidden } from "../errors.js";
import { validate } from "../middleware/validate.js";
import { accessService, companyPortabilityService, companyService, logActivity } from "../services/index.js";
import {
accessService,
budgetService,
companyPortabilityService,
companyService,
logActivity,
} from "../services/index.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
export function companyRoutes(db: Db) {
@@ -17,6 +23,7 @@ export function companyRoutes(db: Db) {
const svc = companyService(db);
const portability = companyPortabilityService(db);
const access = accessService(db);
const budgets = budgetService(db);
router.get("/", async (req, res) => {
assertBoard(req);
@@ -122,6 +129,18 @@ export function companyRoutes(db: Db) {
entityId: company.id,
details: { name: company.name },
});
if (company.budgetMonthlyCents > 0) {
await budgets.upsertPolicy(
company.id,
{
scopeType: "company",
scopeId: company.id,
amount: company.budgetMonthlyCents,
windowKind: "calendar_month_utc",
},
req.actor.userId ?? "board",
);
}
res.status(201).json(company);
});

View File

@@ -1,8 +1,21 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { createCostEventSchema, updateBudgetSchema } from "@paperclipai/shared";
import {
createCostEventSchema,
createFinanceEventSchema,
resolveBudgetIncidentSchema,
updateBudgetSchema,
upsertBudgetPolicySchema,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { costService, companyService, agentService, logActivity } from "../services/index.js";
import {
budgetService,
costService,
financeService,
companyService,
agentService,
logActivity,
} from "../services/index.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import { fetchAllQuotaWindows } from "../services/quota-windows.js";
import { badRequest } from "../errors.js";
@@ -10,6 +23,8 @@ import { badRequest } from "../errors.js";
export function costRoutes(db: Db) {
const router = Router();
const costs = costService(db);
const finance = financeService(db);
const budgets = budgetService(db);
const companies = companyService(db);
const agents = agentService(db);
@@ -42,6 +57,36 @@ export function costRoutes(db: Db) {
res.status(201).json(event);
});
router.post("/companies/:companyId/finance-events", validate(createFinanceEventSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const event = await finance.createEvent(companyId, {
...req.body,
occurredAt: new Date(req.body.occurredAt),
});
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "finance_event.reported",
entityType: "finance_event",
entityId: event.id,
details: {
amountCents: event.amountCents,
biller: event.biller,
eventKind: event.eventKind,
direction: event.direction,
},
});
res.status(201).json(event);
});
function parseDateRange(query: Record<string, unknown>) {
const fromRaw = query.from as string | undefined;
const toRaw = query.to as string | undefined;
@@ -52,6 +97,16 @@ export function costRoutes(db: Db) {
return (from || to) ? { from, to } : undefined;
}
function parseLimit(query: Record<string, unknown>) {
const raw = query.limit as string | undefined;
if (!raw) return 100;
const limit = Number.parseInt(raw, 10);
if (!Number.isFinite(limit) || limit <= 0 || limit > 500) {
throw badRequest("invalid 'limit' value");
}
return limit;
}
router.get("/companies/:companyId/costs/summary", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -84,6 +139,47 @@ export function costRoutes(db: Db) {
res.json(rows);
});
router.get("/companies/:companyId/costs/by-biller", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const range = parseDateRange(req.query);
const rows = await costs.byBiller(companyId, range);
res.json(rows);
});
router.get("/companies/:companyId/costs/finance-summary", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const range = parseDateRange(req.query);
const summary = await finance.summary(companyId, range);
res.json(summary);
});
router.get("/companies/:companyId/costs/finance-by-biller", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const range = parseDateRange(req.query);
const rows = await finance.byBiller(companyId, range);
res.json(rows);
});
router.get("/companies/:companyId/costs/finance-by-kind", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const range = parseDateRange(req.query);
const rows = await finance.byKind(companyId, range);
res.json(rows);
});
router.get("/companies/:companyId/costs/finance-events", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const range = parseDateRange(req.query);
const limit = parseLimit(req.query);
const rows = await finance.list(companyId, range, limit);
res.json(rows);
});
router.get("/companies/:companyId/costs/window-spend", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -106,6 +202,38 @@ export function costRoutes(db: Db) {
res.json(results);
});
router.get("/companies/:companyId/budgets/overview", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const overview = await budgets.overview(companyId);
res.json(overview);
});
router.post(
"/companies/:companyId/budgets/policies",
validate(upsertBudgetPolicySchema),
async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const summary = await budgets.upsertPolicy(companyId, req.body, req.actor.userId ?? "board");
res.json(summary);
},
);
router.post(
"/companies/:companyId/budget-incidents/:incidentId/resolve",
validate(resolveBudgetIncidentSchema),
async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
const incidentId = req.params.incidentId as string;
assertCompanyAccess(req, companyId);
const incident = await budgets.resolveIncident(companyId, incidentId, req.body, req.actor.userId ?? "board");
res.json(incident);
},
);
router.get("/companies/:companyId/costs/by-project", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -133,6 +261,17 @@ export function costRoutes(db: Db) {
details: { budgetMonthlyCents: req.body.budgetMonthlyCents },
});
await budgets.upsertPolicy(
companyId,
{
scopeType: "company",
scopeId: companyId,
amount: req.body.budgetMonthlyCents,
windowKind: "calendar_month_utc",
},
req.actor.userId ?? "board",
);
res.json(company);
});
@@ -169,6 +308,17 @@ export function costRoutes(db: Db) {
details: { budgetMonthlyCents: updated.budgetMonthlyCents },
});
await budgets.upsertPolicy(
updated.companyId,
{
scopeType: "agent",
scopeId: updated.id,
amount: updated.budgetMonthlyCents,
windowKind: "calendar_month_utc",
},
req.actor.type === "board" ? req.actor.userId ?? "board" : null,
);
res.json(updated);
});

View File

@@ -921,6 +921,19 @@ export function issueRoutes(db: Db, storage: StorageService) {
}
assertCompanyAccess(req, issue.companyId);
if (issue.projectId) {
const project = await projectsSvc.getById(issue.projectId);
if (project?.pausedAt) {
res.status(409).json({
error:
project.pauseReason === "budget"
? "Project is paused because its budget hard-stop was reached"
: "Project is paused",
});
return;
}
}
if (req.actor.type === "agent" && req.actor.agentId !== req.body.agentId) {
res.status(403).json({ error: "Agent can only checkout as itself" });
return;

View File

@@ -360,14 +360,19 @@ export function agentService(db: Db) {
update: updateAgent,
pause: async (id: string) => {
pause: async (id: string, reason: "manual" | "budget" | "system" = "manual") => {
const existing = await getById(id);
if (!existing) return null;
if (existing.status === "terminated") throw conflict("Cannot pause terminated agent");
const updated = await db
.update(agents)
.set({ status: "paused", updatedAt: new Date() })
.set({
status: "paused",
pauseReason: reason,
pausedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(agents.id, id))
.returning()
.then((rows) => rows[0] ?? null);
@@ -384,7 +389,12 @@ export function agentService(db: Db) {
const updated = await db
.update(agents)
.set({ status: "idle", updatedAt: new Date() })
.set({
status: "idle",
pauseReason: null,
pausedAt: null,
updatedAt: new Date(),
})
.where(eq(agents.id, id))
.returning()
.then((rows) => rows[0] ?? null);
@@ -397,7 +407,12 @@ export function agentService(db: Db) {
await db
.update(agents)
.set({ status: "terminated", updatedAt: new Date() })
.set({
status: "terminated",
pauseReason: null,
pausedAt: null,
updatedAt: new Date(),
})
.where(eq(agents.id, id));
await db

View File

@@ -4,6 +4,7 @@ import { approvalComments, approvals } from "@paperclipai/db";
import { notFound, unprocessable } from "../errors.js";
import { redactCurrentUserText } from "../log-redaction.js";
import { agentService } from "./agents.js";
import { budgetService } from "./budgets.js";
import { notifyHireApproved } from "./hire-hook.js";
function redactApprovalComment<T extends { body: string }>(comment: T): T {
@@ -15,6 +16,7 @@ function redactApprovalComment<T extends { body: string }>(comment: T): T {
export function approvalService(db: Db) {
const agentsSvc = agentService(db);
const budgets = budgetService(db);
const canResolveStatuses = new Set(["pending", "revision_requested"]);
const resolvableStatuses = Array.from(canResolveStatuses);
type ApprovalRecord = typeof approvals.$inferSelect;
@@ -137,6 +139,20 @@ export function approvalService(db: Db) {
hireApprovedAgentId = created?.id ?? null;
}
if (hireApprovedAgentId) {
const budgetMonthlyCents =
typeof payload.budgetMonthlyCents === "number" ? payload.budgetMonthlyCents : 0;
if (budgetMonthlyCents > 0) {
await budgets.upsertPolicy(
updated.companyId,
{
scopeType: "agent",
scopeId: hireApprovedAgentId,
amount: budgetMonthlyCents,
windowKind: "calendar_month_utc",
},
decidedByUserId,
);
}
void notifyHireApproved(db, {
companyId: updated.companyId,
agentId: hireApprovedAgentId,

View File

@@ -0,0 +1,919 @@
import { and, desc, eq, gte, inArray, lt, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
approvals,
budgetIncidents,
budgetPolicies,
companies,
costEvents,
projects,
} from "@paperclipai/db";
import type {
BudgetIncident,
BudgetIncidentResolutionInput,
BudgetMetric,
BudgetOverview,
BudgetPolicy,
BudgetPolicySummary,
BudgetPolicyUpsertInput,
BudgetScopeType,
BudgetThresholdType,
BudgetWindowKind,
} from "@paperclipai/shared";
import { notFound, unprocessable } from "../errors.js";
import { logActivity } from "./activity-log.js";
type ScopeRecord = {
companyId: string;
name: string;
paused: boolean;
pauseReason: "manual" | "budget" | "system" | null;
};
type PolicyRow = typeof budgetPolicies.$inferSelect;
type IncidentRow = typeof budgetIncidents.$inferSelect;
function currentUtcMonthWindow(now = new Date()) {
const year = now.getUTCFullYear();
const month = now.getUTCMonth();
const start = new Date(Date.UTC(year, month, 1, 0, 0, 0, 0));
const end = new Date(Date.UTC(year, month + 1, 1, 0, 0, 0, 0));
return { start, end };
}
function resolveWindow(windowKind: BudgetWindowKind, now = new Date()) {
if (windowKind === "lifetime") {
return {
start: new Date(Date.UTC(1970, 0, 1, 0, 0, 0, 0)),
end: new Date(Date.UTC(9999, 0, 1, 0, 0, 0, 0)),
};
}
return currentUtcMonthWindow(now);
}
function budgetStatusFromObserved(
observedAmount: number,
amount: number,
warnPercent: number,
): BudgetPolicySummary["status"] {
if (amount <= 0) return "ok";
if (observedAmount >= amount) return "hard_stop";
if (observedAmount >= Math.ceil((amount * warnPercent) / 100)) return "warning";
return "ok";
}
function normalizeScopeName(scopeType: BudgetScopeType, name: string) {
if (scopeType === "company") return name;
return name.trim().length > 0 ? name : scopeType;
}
async function resolveScopeRecord(db: Db, scopeType: BudgetScopeType, scopeId: string): Promise<ScopeRecord> {
if (scopeType === "company") {
const row = await db
.select({
companyId: companies.id,
name: companies.name,
status: companies.status,
})
.from(companies)
.where(eq(companies.id, scopeId))
.then((rows) => rows[0] ?? null);
if (!row) throw notFound("Company not found");
return {
companyId: row.companyId,
name: row.name,
paused: row.status === "paused",
pauseReason: row.status === "paused" ? "budget" : null,
};
}
if (scopeType === "agent") {
const row = await db
.select({
companyId: agents.companyId,
name: agents.name,
status: agents.status,
pauseReason: agents.pauseReason,
})
.from(agents)
.where(eq(agents.id, scopeId))
.then((rows) => rows[0] ?? null);
if (!row) throw notFound("Agent not found");
return {
companyId: row.companyId,
name: row.name,
paused: row.status === "paused",
pauseReason: (row.pauseReason as ScopeRecord["pauseReason"]) ?? null,
};
}
const row = await db
.select({
companyId: projects.companyId,
name: projects.name,
pauseReason: projects.pauseReason,
pausedAt: projects.pausedAt,
})
.from(projects)
.where(eq(projects.id, scopeId))
.then((rows) => rows[0] ?? null);
if (!row) throw notFound("Project not found");
return {
companyId: row.companyId,
name: row.name,
paused: Boolean(row.pausedAt),
pauseReason: (row.pauseReason as ScopeRecord["pauseReason"]) ?? null,
};
}
async function computeObservedAmount(
db: Db,
policy: Pick<PolicyRow, "companyId" | "scopeType" | "scopeId" | "windowKind" | "metric">,
) {
if (policy.metric !== "billed_cents") return 0;
const conditions = [eq(costEvents.companyId, policy.companyId)];
if (policy.scopeType === "agent") conditions.push(eq(costEvents.agentId, policy.scopeId));
if (policy.scopeType === "project") conditions.push(eq(costEvents.projectId, policy.scopeId));
const { start, end } = resolveWindow(policy.windowKind as BudgetWindowKind);
if (policy.windowKind === "calendar_month_utc") {
conditions.push(gte(costEvents.occurredAt, start));
conditions.push(lt(costEvents.occurredAt, end));
}
const [row] = await db
.select({
total: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
})
.from(costEvents)
.where(and(...conditions));
return Number(row?.total ?? 0);
}
function buildApprovalPayload(input: {
policy: PolicyRow;
scopeName: string;
thresholdType: BudgetThresholdType;
amountObserved: number;
windowStart: Date;
windowEnd: Date;
}) {
return {
scopeType: input.policy.scopeType,
scopeId: input.policy.scopeId,
scopeName: input.scopeName,
metric: input.policy.metric,
windowKind: input.policy.windowKind,
thresholdType: input.thresholdType,
budgetAmount: input.policy.amount,
observedAmount: input.amountObserved,
warnPercent: input.policy.warnPercent,
windowStart: input.windowStart.toISOString(),
windowEnd: input.windowEnd.toISOString(),
policyId: input.policy.id,
guidance: "Raise the budget and resume the scope, or keep the scope paused.",
};
}
async function markApprovalStatus(
db: Db,
approvalId: string | null,
status: "approved" | "rejected",
decisionNote: string | null | undefined,
decidedByUserId: string,
) {
if (!approvalId) return;
await db
.update(approvals)
.set({
status,
decisionNote: decisionNote ?? null,
decidedByUserId,
decidedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(approvals.id, approvalId));
}
export function budgetService(db: Db) {
async function pauseScopeForBudget(policy: PolicyRow) {
const now = new Date();
if (policy.scopeType === "agent") {
await db
.update(agents)
.set({
status: "paused",
pauseReason: "budget",
pausedAt: now,
updatedAt: now,
})
.where(and(eq(agents.id, policy.scopeId), inArray(agents.status, ["active", "idle", "running", "error"])));
return;
}
if (policy.scopeType === "project") {
await db
.update(projects)
.set({
pauseReason: "budget",
pausedAt: now,
updatedAt: now,
})
.where(eq(projects.id, policy.scopeId));
return;
}
await db
.update(companies)
.set({
status: "paused",
updatedAt: now,
})
.where(eq(companies.id, policy.scopeId));
}
async function resumeScopeFromBudget(policy: PolicyRow) {
const now = new Date();
if (policy.scopeType === "agent") {
await db
.update(agents)
.set({
status: "idle",
pauseReason: null,
pausedAt: null,
updatedAt: now,
})
.where(and(eq(agents.id, policy.scopeId), eq(agents.pauseReason, "budget")));
return;
}
if (policy.scopeType === "project") {
await db
.update(projects)
.set({
pauseReason: null,
pausedAt: null,
updatedAt: now,
})
.where(and(eq(projects.id, policy.scopeId), eq(projects.pauseReason, "budget")));
return;
}
await db
.update(companies)
.set({
status: "active",
updatedAt: now,
})
.where(eq(companies.id, policy.scopeId));
}
async function getPolicyRow(policyId: string) {
const policy = await db
.select()
.from(budgetPolicies)
.where(eq(budgetPolicies.id, policyId))
.then((rows) => rows[0] ?? null);
if (!policy) throw notFound("Budget policy not found");
return policy;
}
async function listPolicyRows(companyId: string) {
return db
.select()
.from(budgetPolicies)
.where(eq(budgetPolicies.companyId, companyId))
.orderBy(desc(budgetPolicies.updatedAt));
}
async function buildPolicySummary(policy: PolicyRow): Promise<BudgetPolicySummary> {
const scope = await resolveScopeRecord(db, policy.scopeType as BudgetScopeType, policy.scopeId);
const observedAmount = await computeObservedAmount(db, policy);
const { start, end } = resolveWindow(policy.windowKind as BudgetWindowKind);
const amount = policy.isActive ? policy.amount : 0;
const utilizationPercent =
amount > 0 ? Number(((observedAmount / amount) * 100).toFixed(2)) : 0;
return {
policyId: policy.id,
companyId: policy.companyId,
scopeType: policy.scopeType as BudgetScopeType,
scopeId: policy.scopeId,
scopeName: normalizeScopeName(policy.scopeType as BudgetScopeType, scope.name),
metric: policy.metric as BudgetMetric,
windowKind: policy.windowKind as BudgetWindowKind,
amount,
observedAmount,
remainingAmount: amount > 0 ? Math.max(0, amount - observedAmount) : 0,
utilizationPercent,
warnPercent: policy.warnPercent,
hardStopEnabled: policy.hardStopEnabled,
notifyEnabled: policy.notifyEnabled,
isActive: policy.isActive,
status: policy.isActive
? budgetStatusFromObserved(observedAmount, amount, policy.warnPercent)
: "ok",
paused: scope.paused,
pauseReason: scope.pauseReason,
windowStart: start,
windowEnd: end,
};
}
async function createIncidentIfNeeded(
policy: PolicyRow,
thresholdType: BudgetThresholdType,
amountObserved: number,
) {
const { start, end } = resolveWindow(policy.windowKind as BudgetWindowKind);
const existing = await db
.select()
.from(budgetIncidents)
.where(
and(
eq(budgetIncidents.policyId, policy.id),
eq(budgetIncidents.windowStart, start),
eq(budgetIncidents.thresholdType, thresholdType),
),
)
.then((rows) => rows[0] ?? null);
if (existing) return existing;
const scope = await resolveScopeRecord(db, policy.scopeType as BudgetScopeType, policy.scopeId);
const payload = buildApprovalPayload({
policy,
scopeName: normalizeScopeName(policy.scopeType as BudgetScopeType, scope.name),
thresholdType,
amountObserved,
windowStart: start,
windowEnd: end,
});
const approval = thresholdType === "hard"
? await db
.insert(approvals)
.values({
companyId: policy.companyId,
type: "budget_override_required",
requestedByUserId: null,
requestedByAgentId: null,
status: "pending",
payload,
})
.returning()
.then((rows) => rows[0] ?? null)
: null;
return db
.insert(budgetIncidents)
.values({
companyId: policy.companyId,
policyId: policy.id,
scopeType: policy.scopeType,
scopeId: policy.scopeId,
metric: policy.metric,
windowKind: policy.windowKind,
windowStart: start,
windowEnd: end,
thresholdType,
amountLimit: policy.amount,
amountObserved,
status: "open",
approvalId: approval?.id ?? null,
})
.returning()
.then((rows) => rows[0] ?? null);
}
async function resolveOpenSoftIncidents(policyId: string) {
await db
.update(budgetIncidents)
.set({
status: "resolved",
resolvedAt: new Date(),
updatedAt: new Date(),
})
.where(
and(
eq(budgetIncidents.policyId, policyId),
eq(budgetIncidents.thresholdType, "soft"),
eq(budgetIncidents.status, "open"),
),
);
}
async function resolveOpenIncidentsForPolicy(
policyId: string,
approvalStatus: "approved" | "rejected" | null,
decidedByUserId: string | null,
) {
const openRows = await db
.select()
.from(budgetIncidents)
.where(and(eq(budgetIncidents.policyId, policyId), eq(budgetIncidents.status, "open")));
await db
.update(budgetIncidents)
.set({
status: "resolved",
resolvedAt: new Date(),
updatedAt: new Date(),
})
.where(and(eq(budgetIncidents.policyId, policyId), eq(budgetIncidents.status, "open")));
if (!approvalStatus || !decidedByUserId) return;
for (const row of openRows) {
await markApprovalStatus(db, row.approvalId ?? null, approvalStatus, "Resolved via budget update", decidedByUserId);
}
}
async function hydrateIncidentRows(rows: IncidentRow[]): Promise<BudgetIncident[]> {
const approvalIds = rows.map((row) => row.approvalId).filter((value): value is string => Boolean(value));
const approvalRows = approvalIds.length > 0
? await db
.select({ id: approvals.id, status: approvals.status })
.from(approvals)
.where(inArray(approvals.id, approvalIds))
: [];
const approvalStatusById = new Map(approvalRows.map((row) => [row.id, row.status]));
return Promise.all(
rows.map(async (row) => {
const scope = await resolveScopeRecord(db, row.scopeType as BudgetScopeType, row.scopeId);
return {
id: row.id,
companyId: row.companyId,
policyId: row.policyId,
scopeType: row.scopeType as BudgetScopeType,
scopeId: row.scopeId,
scopeName: normalizeScopeName(row.scopeType as BudgetScopeType, scope.name),
metric: row.metric as BudgetMetric,
windowKind: row.windowKind as BudgetWindowKind,
windowStart: row.windowStart,
windowEnd: row.windowEnd,
thresholdType: row.thresholdType as BudgetThresholdType,
amountLimit: row.amountLimit,
amountObserved: row.amountObserved,
status: row.status as BudgetIncident["status"],
approvalId: row.approvalId ?? null,
approvalStatus: row.approvalId ? approvalStatusById.get(row.approvalId) ?? null : null,
resolvedAt: row.resolvedAt ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}),
);
}
return {
listPolicies: async (companyId: string): Promise<BudgetPolicy[]> => {
const rows = await listPolicyRows(companyId);
return rows.map((row) => ({
...row,
scopeType: row.scopeType as BudgetScopeType,
metric: row.metric as BudgetMetric,
windowKind: row.windowKind as BudgetWindowKind,
}));
},
upsertPolicy: async (
companyId: string,
input: BudgetPolicyUpsertInput,
actorUserId: string | null,
): Promise<BudgetPolicySummary> => {
const scope = await resolveScopeRecord(db, input.scopeType, input.scopeId);
if (scope.companyId !== companyId) {
throw unprocessable("Budget scope does not belong to company");
}
const metric = input.metric ?? "billed_cents";
const windowKind = input.windowKind ?? (input.scopeType === "project" ? "lifetime" : "calendar_month_utc");
const amount = Math.max(0, Math.floor(input.amount));
const nextIsActive = amount > 0 && (input.isActive ?? true);
const existing = await db
.select()
.from(budgetPolicies)
.where(
and(
eq(budgetPolicies.companyId, companyId),
eq(budgetPolicies.scopeType, input.scopeType),
eq(budgetPolicies.scopeId, input.scopeId),
eq(budgetPolicies.metric, metric),
eq(budgetPolicies.windowKind, windowKind),
),
)
.then((rows) => rows[0] ?? null);
const now = new Date();
const row = existing
? await db
.update(budgetPolicies)
.set({
amount,
warnPercent: input.warnPercent ?? existing.warnPercent,
hardStopEnabled: input.hardStopEnabled ?? existing.hardStopEnabled,
notifyEnabled: input.notifyEnabled ?? existing.notifyEnabled,
isActive: nextIsActive,
updatedByUserId: actorUserId,
updatedAt: now,
})
.where(eq(budgetPolicies.id, existing.id))
.returning()
.then((rows) => rows[0])
: await db
.insert(budgetPolicies)
.values({
companyId,
scopeType: input.scopeType,
scopeId: input.scopeId,
metric,
windowKind,
amount,
warnPercent: input.warnPercent ?? 80,
hardStopEnabled: input.hardStopEnabled ?? true,
notifyEnabled: input.notifyEnabled ?? true,
isActive: nextIsActive,
createdByUserId: actorUserId,
updatedByUserId: actorUserId,
})
.returning()
.then((rows) => rows[0]);
if (input.scopeType === "company" && windowKind === "calendar_month_utc") {
await db
.update(companies)
.set({
budgetMonthlyCents: amount,
updatedAt: now,
})
.where(eq(companies.id, input.scopeId));
}
if (input.scopeType === "agent" && windowKind === "calendar_month_utc") {
await db
.update(agents)
.set({
budgetMonthlyCents: amount,
updatedAt: now,
})
.where(eq(agents.id, input.scopeId));
}
if (amount > 0) {
const observedAmount = await computeObservedAmount(db, row);
if (observedAmount < amount) {
await resumeScopeFromBudget(row);
await resolveOpenIncidentsForPolicy(row.id, actorUserId ? "approved" : null, actorUserId);
} else {
const softThreshold = Math.ceil((row.amount * row.warnPercent) / 100);
if (row.notifyEnabled && observedAmount >= softThreshold) {
await createIncidentIfNeeded(row, "soft", observedAmount);
}
if (row.hardStopEnabled && observedAmount >= row.amount) {
await resolveOpenSoftIncidents(row.id);
await createIncidentIfNeeded(row, "hard", observedAmount);
await pauseScopeForBudget(row);
}
}
} else {
await resumeScopeFromBudget(row);
await resolveOpenIncidentsForPolicy(row.id, actorUserId ? "approved" : null, actorUserId);
}
await logActivity(db, {
companyId,
actorType: "user",
actorId: actorUserId ?? "board",
action: "budget.policy_upserted",
entityType: "budget_policy",
entityId: row.id,
details: {
scopeType: row.scopeType,
scopeId: row.scopeId,
amount: row.amount,
windowKind: row.windowKind,
},
});
return buildPolicySummary(row);
},
overview: async (companyId: string): Promise<BudgetOverview> => {
const rows = await listPolicyRows(companyId);
const policies = await Promise.all(rows.map((row) => buildPolicySummary(row)));
const activeIncidentRows = await db
.select()
.from(budgetIncidents)
.where(and(eq(budgetIncidents.companyId, companyId), eq(budgetIncidents.status, "open")))
.orderBy(desc(budgetIncidents.createdAt));
const activeIncidents = await hydrateIncidentRows(activeIncidentRows);
return {
companyId,
policies,
activeIncidents,
pausedAgentCount: policies.filter((policy) => policy.scopeType === "agent" && policy.paused).length,
pausedProjectCount: policies.filter((policy) => policy.scopeType === "project" && policy.paused).length,
pendingApprovalCount: activeIncidents.filter((incident) => incident.approvalStatus === "pending").length,
};
},
evaluateCostEvent: async (event: typeof costEvents.$inferSelect) => {
const candidatePolicies = await db
.select()
.from(budgetPolicies)
.where(
and(
eq(budgetPolicies.companyId, event.companyId),
eq(budgetPolicies.isActive, true),
inArray(budgetPolicies.scopeType, ["company", "agent", "project"]),
),
);
const relevantPolicies = candidatePolicies.filter((policy) => {
if (policy.scopeType === "company") return policy.scopeId === event.companyId;
if (policy.scopeType === "agent") return policy.scopeId === event.agentId;
if (policy.scopeType === "project") return Boolean(event.projectId) && policy.scopeId === event.projectId;
return false;
});
for (const policy of relevantPolicies) {
if (policy.metric !== "billed_cents" || policy.amount <= 0) continue;
const observedAmount = await computeObservedAmount(db, policy);
const softThreshold = Math.ceil((policy.amount * policy.warnPercent) / 100);
if (policy.notifyEnabled && observedAmount >= softThreshold) {
const softIncident = await createIncidentIfNeeded(policy, "soft", observedAmount);
if (softIncident) {
await logActivity(db, {
companyId: policy.companyId,
actorType: "system",
actorId: "budget_service",
action: "budget.soft_threshold_crossed",
entityType: "budget_incident",
entityId: softIncident.id,
details: {
scopeType: policy.scopeType,
scopeId: policy.scopeId,
amountObserved: observedAmount,
amountLimit: policy.amount,
},
});
}
}
if (policy.hardStopEnabled && observedAmount >= policy.amount) {
await resolveOpenSoftIncidents(policy.id);
const hardIncident = await createIncidentIfNeeded(policy, "hard", observedAmount);
await pauseScopeForBudget(policy);
if (hardIncident) {
await logActivity(db, {
companyId: policy.companyId,
actorType: "system",
actorId: "budget_service",
action: "budget.hard_threshold_crossed",
entityType: "budget_incident",
entityId: hardIncident.id,
details: {
scopeType: policy.scopeType,
scopeId: policy.scopeId,
amountObserved: observedAmount,
amountLimit: policy.amount,
approvalId: hardIncident.approvalId ?? null,
},
});
}
}
}
},
getInvocationBlock: async (
companyId: string,
agentId: string,
context?: { issueId?: string | null; projectId?: string | null },
) => {
const agent = await db
.select({
status: agents.status,
pauseReason: agents.pauseReason,
companyId: agents.companyId,
name: agents.name,
})
.from(agents)
.where(eq(agents.id, agentId))
.then((rows) => rows[0] ?? null);
if (!agent || agent.companyId !== companyId) throw notFound("Agent not found");
const company = await db
.select({
status: companies.status,
name: companies.name,
})
.from(companies)
.where(eq(companies.id, companyId))
.then((rows) => rows[0] ?? null);
if (!company) throw notFound("Company not found");
if (company.status === "paused") {
return {
scopeType: "company" as const,
scopeId: companyId,
scopeName: company.name,
reason: "Company is paused and cannot start new work.",
};
}
const companyPolicy = await db
.select()
.from(budgetPolicies)
.where(
and(
eq(budgetPolicies.companyId, companyId),
eq(budgetPolicies.scopeType, "company"),
eq(budgetPolicies.scopeId, companyId),
eq(budgetPolicies.isActive, true),
eq(budgetPolicies.metric, "billed_cents"),
),
)
.then((rows) => rows[0] ?? null);
if (companyPolicy && companyPolicy.hardStopEnabled && companyPolicy.amount > 0) {
const observed = await computeObservedAmount(db, companyPolicy);
if (observed >= companyPolicy.amount) {
return {
scopeType: "company" as const,
scopeId: companyId,
scopeName: company.name,
reason: "Company cannot start new work because its budget hard-stop is exceeded.",
};
}
}
if (agent.status === "paused" && agent.pauseReason === "budget") {
return {
scopeType: "agent" as const,
scopeId: agentId,
scopeName: agent.name,
reason: "Agent is paused because its budget hard-stop was reached.",
};
}
const agentPolicy = await db
.select()
.from(budgetPolicies)
.where(
and(
eq(budgetPolicies.companyId, companyId),
eq(budgetPolicies.scopeType, "agent"),
eq(budgetPolicies.scopeId, agentId),
eq(budgetPolicies.isActive, true),
eq(budgetPolicies.metric, "billed_cents"),
),
)
.then((rows) => rows[0] ?? null);
if (agentPolicy && agentPolicy.hardStopEnabled && agentPolicy.amount > 0) {
const observed = await computeObservedAmount(db, agentPolicy);
if (observed >= agentPolicy.amount) {
return {
scopeType: "agent" as const,
scopeId: agentId,
scopeName: agent.name,
reason: "Agent cannot start because its budget hard-stop is still exceeded.",
};
}
}
const candidateProjectId = context?.projectId ?? null;
if (!candidateProjectId) return null;
const project = await db
.select({
id: projects.id,
name: projects.name,
companyId: projects.companyId,
pauseReason: projects.pauseReason,
pausedAt: projects.pausedAt,
})
.from(projects)
.where(eq(projects.id, candidateProjectId))
.then((rows) => rows[0] ?? null);
if (!project || project.companyId !== companyId) return null;
const projectPolicy = await db
.select()
.from(budgetPolicies)
.where(
and(
eq(budgetPolicies.companyId, companyId),
eq(budgetPolicies.scopeType, "project"),
eq(budgetPolicies.scopeId, project.id),
eq(budgetPolicies.isActive, true),
eq(budgetPolicies.metric, "billed_cents"),
),
)
.then((rows) => rows[0] ?? null);
if (projectPolicy && projectPolicy.hardStopEnabled && projectPolicy.amount > 0) {
const observed = await computeObservedAmount(db, projectPolicy);
if (observed >= projectPolicy.amount) {
return {
scopeType: "project" as const,
scopeId: project.id,
scopeName: project.name,
reason: "Project cannot start work because its budget hard-stop is still exceeded.",
};
}
}
if (!project.pausedAt || project.pauseReason !== "budget") return null;
return {
scopeType: "project" as const,
scopeId: project.id,
scopeName: project.name,
reason: "Project is paused because its budget hard-stop was reached.",
};
},
resolveIncident: async (
companyId: string,
incidentId: string,
input: BudgetIncidentResolutionInput,
actorUserId: string,
): Promise<BudgetIncident> => {
const incident = await db
.select()
.from(budgetIncidents)
.where(eq(budgetIncidents.id, incidentId))
.then((rows) => rows[0] ?? null);
if (!incident) throw notFound("Budget incident not found");
if (incident.companyId !== companyId) throw notFound("Budget incident not found");
const policy = await getPolicyRow(incident.policyId);
if (input.action === "raise_budget_and_resume") {
const nextAmount = Math.max(0, Math.floor(input.amount ?? 0));
if (nextAmount <= incident.amountObserved) {
throw unprocessable("New budget must exceed current observed spend");
}
await db
.update(budgetPolicies)
.set({
amount: nextAmount,
isActive: true,
updatedByUserId: actorUserId,
updatedAt: new Date(),
})
.where(eq(budgetPolicies.id, policy.id));
if (policy.scopeType === "agent" && policy.windowKind === "calendar_month_utc") {
await db
.update(agents)
.set({ budgetMonthlyCents: nextAmount, updatedAt: new Date() })
.where(eq(agents.id, policy.scopeId));
}
await resumeScopeFromBudget(policy);
await db
.update(budgetIncidents)
.set({
status: "resolved",
resolvedAt: new Date(),
updatedAt: new Date(),
})
.where(and(eq(budgetIncidents.policyId, policy.id), eq(budgetIncidents.status, "open")));
await markApprovalStatus(db, incident.approvalId ?? null, "approved", input.decisionNote, actorUserId);
} else {
await db
.update(budgetIncidents)
.set({
status: "dismissed",
resolvedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(budgetIncidents.id, incident.id));
await markApprovalStatus(db, incident.approvalId ?? null, "rejected", input.decisionNote, actorUserId);
}
await logActivity(db, {
companyId: incident.companyId,
actorType: "user",
actorId: actorUserId,
action: "budget.incident_resolved",
entityType: "budget_incident",
entityId: incident.id,
details: {
action: input.action,
amount: input.amount ?? null,
scopeType: incident.scopeType,
scopeId: incident.scopeId,
},
});
const [updated] = await hydrateIncidentRows([{
...incident,
status: input.action === "raise_budget_and_resume" ? "resolved" : "dismissed",
resolvedAt: new Date(),
updatedAt: new Date(),
}]);
return updated!;
},
};
}

View File

@@ -16,6 +16,7 @@ import {
heartbeatRuns,
heartbeatRunEvents,
costEvents,
financeEvents,
approvalComments,
approvals,
activityLog,
@@ -206,6 +207,7 @@ export function companyService(db: Db) {
await tx.delete(agentRuntimeState).where(eq(agentRuntimeState.companyId, id));
await tx.delete(issueComments).where(eq(issueComments.companyId, id));
await tx.delete(costEvents).where(eq(costEvents.companyId, id));
await tx.delete(financeEvents).where(eq(financeEvents.companyId, id));
await tx.delete(approvalComments).where(eq(approvalComments.companyId, id));
await tx.delete(approvals).where(eq(approvals.companyId, id));
await tx.delete(companySecrets).where(eq(companySecrets.companyId, id));

View File

@@ -1,14 +1,19 @@
import { and, desc, eq, gte, isNotNull, lte, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { activityLog, agents, companies, costEvents, heartbeatRuns, issues, projects } from "@paperclipai/db";
import { activityLog, agents, companies, costEvents, issues, projects } from "@paperclipai/db";
import { notFound, unprocessable } from "../errors.js";
import { budgetService } from "./budgets.js";
export interface CostDateRange {
from?: Date;
to?: Date;
}
const METERED_BILLING_TYPE = "metered_api";
const SUBSCRIPTION_BILLING_TYPES = ["subscription_included", "subscription_overage"] as const;
export function costService(db: Db) {
const budgets = budgetService(db);
return {
createEvent: async (companyId: string, data: Omit<typeof costEvents.$inferInsert, "companyId">) => {
const agent = await db
@@ -24,7 +29,13 @@ export function costService(db: Db) {
const event = await db
.insert(costEvents)
.values({ ...data, companyId })
.values({
...data,
companyId,
biller: data.biller ?? data.provider,
billingType: data.billingType ?? "unknown",
cachedInputTokens: data.cachedInputTokens ?? 0,
})
.returning()
.then((rows) => rows[0]);
@@ -63,6 +74,8 @@ export function costService(db: Db) {
.where(eq(agents.id, updatedAgent.id));
}
await budgets.evaluateCostEvent(event);
return event;
},
@@ -105,52 +118,31 @@ export function costService(db: Db) {
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
const costRows = await db
return db
.select({
agentId: costEvents.agentId,
agentName: agents.name,
agentStatus: agents.status,
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
apiRunCount:
sql<number>`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`,
subscriptionRunCount:
sql<number>`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`,
subscriptionCachedInputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`,
subscriptionInputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`,
subscriptionOutputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`,
})
.from(costEvents)
.leftJoin(agents, eq(costEvents.agentId, agents.id))
.where(and(...conditions))
.groupBy(costEvents.agentId, agents.name, agents.status)
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
const runConditions: ReturnType<typeof eq>[] = [eq(heartbeatRuns.companyId, companyId)];
if (range?.from) runConditions.push(gte(heartbeatRuns.startedAt, range.from));
if (range?.to) runConditions.push(lte(heartbeatRuns.startedAt, range.to));
const runRows = await db
.select({
agentId: heartbeatRuns.agentId,
apiRunCount:
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'api' then 1 else 0 end), 0)::int`,
subscriptionRunCount:
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then 1 else 0 end), 0)::int`,
subscriptionInputTokens:
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then coalesce((${heartbeatRuns.usageJson} ->> 'inputTokens')::int, 0) else 0 end), 0)::int`,
subscriptionOutputTokens:
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then coalesce((${heartbeatRuns.usageJson} ->> 'outputTokens')::int, 0) else 0 end), 0)::int`,
})
.from(heartbeatRuns)
.where(and(...runConditions))
.groupBy(heartbeatRuns.agentId);
const runRowsByAgent = new Map(runRows.map((row) => [row.agentId, row]));
return costRows.map((row) => {
const runRow = runRowsByAgent.get(row.agentId);
return {
...row,
apiRunCount: runRow?.apiRunCount ?? 0,
subscriptionRunCount: runRow?.subscriptionRunCount ?? 0,
subscriptionInputTokens: runRow?.subscriptionInputTokens ?? 0,
subscriptionOutputTokens: runRow?.subscriptionOutputTokens ?? 0,
};
});
},
byProvider: async (companyId: string, range?: CostDateRange) => {
@@ -158,68 +150,62 @@ export function costService(db: Db) {
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
const costRows = await db
return db
.select({
provider: costEvents.provider,
biller: costEvents.biller,
billingType: costEvents.billingType,
model: costEvents.model,
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
apiRunCount:
sql<number>`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`,
subscriptionRunCount:
sql<number>`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`,
subscriptionCachedInputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`,
subscriptionInputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`,
subscriptionOutputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`,
})
.from(costEvents)
.where(and(...conditions))
.groupBy(costEvents.provider, costEvents.model)
.groupBy(costEvents.provider, costEvents.biller, costEvents.billingType, costEvents.model)
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
},
const runConditions: ReturnType<typeof eq>[] = [eq(heartbeatRuns.companyId, companyId)];
if (range?.from) runConditions.push(gte(heartbeatRuns.startedAt, range.from));
if (range?.to) runConditions.push(lte(heartbeatRuns.startedAt, range.to));
byBiller: async (companyId: string, range?: CostDateRange) => {
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
const runRows = await db
return db
.select({
agentId: heartbeatRuns.agentId,
biller: costEvents.biller,
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
apiRunCount:
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'api' then 1 else 0 end), 0)::int`,
sql<number>`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`,
subscriptionRunCount:
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then 1 else 0 end), 0)::int`,
sql<number>`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`,
subscriptionCachedInputTokens:
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`,
subscriptionInputTokens:
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then coalesce((${heartbeatRuns.usageJson} ->> 'inputTokens')::int, 0) else 0 end), 0)::int`,
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`,
subscriptionOutputTokens:
sql<number>`coalesce(sum(case when coalesce((${heartbeatRuns.usageJson} ->> 'billingType'), 'unknown') = 'subscription' then coalesce((${heartbeatRuns.usageJson} ->> 'outputTokens')::int, 0) else 0 end), 0)::int`,
sql<number>`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`,
providerCount: sql<number>`count(distinct ${costEvents.provider})::int`,
modelCount: sql<number>`count(distinct ${costEvents.model})::int`,
})
.from(heartbeatRuns)
.where(and(...runConditions))
.groupBy(heartbeatRuns.agentId);
// aggregate run billing splits across all agents (runs don't carry model info so we can't go per-model)
const totals = runRows.reduce(
(acc, r) => ({
apiRunCount: acc.apiRunCount + r.apiRunCount,
subscriptionRunCount: acc.subscriptionRunCount + r.subscriptionRunCount,
subscriptionInputTokens: acc.subscriptionInputTokens + r.subscriptionInputTokens,
subscriptionOutputTokens: acc.subscriptionOutputTokens + r.subscriptionOutputTokens,
}),
{ apiRunCount: 0, subscriptionRunCount: 0, subscriptionInputTokens: 0, subscriptionOutputTokens: 0 },
);
// pro-rate billing split across models by token share
const totalTokens = costRows.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0);
return costRows.map((row) => {
const rowTokens = row.inputTokens + row.outputTokens;
const share = totalTokens > 0 ? rowTokens / totalTokens : 0;
return {
provider: row.provider,
model: row.model,
costCents: row.costCents,
inputTokens: row.inputTokens,
outputTokens: row.outputTokens,
apiRunCount: Math.round(totals.apiRunCount * share),
subscriptionRunCount: Math.round(totals.subscriptionRunCount * share),
subscriptionInputTokens: Math.round(totals.subscriptionInputTokens * share),
subscriptionOutputTokens: Math.round(totals.subscriptionOutputTokens * share),
};
});
.from(costEvents)
.where(and(...conditions))
.groupBy(costEvents.biller)
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
},
/**
@@ -240,8 +226,10 @@ export function costService(db: Db) {
const rows = await db
.select({
provider: costEvents.provider,
biller: sql<string>`case when count(distinct ${costEvents.biller}) = 1 then min(${costEvents.biller}) else 'mixed' end`,
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
})
.from(costEvents)
@@ -256,10 +244,12 @@ export function costService(db: Db) {
return rows.map((row) => ({
provider: row.provider,
biller: row.biller,
window: label as string,
windowHours: hours,
costCents: row.costCents,
inputTokens: row.inputTokens,
cachedInputTokens: row.cachedInputTokens,
outputTokens: row.outputTokens,
}));
}),
@@ -282,16 +272,26 @@ export function costService(db: Db) {
agentId: costEvents.agentId,
agentName: agents.name,
provider: costEvents.provider,
biller: costEvents.biller,
billingType: costEvents.billingType,
model: costEvents.model,
costCents: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
})
.from(costEvents)
.leftJoin(agents, eq(costEvents.agentId, agents.id))
.where(and(...conditions))
.groupBy(costEvents.agentId, agents.name, costEvents.provider, costEvents.model)
.orderBy(costEvents.provider, costEvents.model);
.groupBy(
costEvents.agentId,
agents.name,
costEvents.provider,
costEvents.biller,
costEvents.billingType,
costEvents.model,
)
.orderBy(costEvents.provider, costEvents.biller, costEvents.billingType, costEvents.model);
},
byProject: async (companyId: string, range?: CostDateRange) => {
@@ -320,25 +320,27 @@ export function costService(db: Db) {
.orderBy(activityLog.runId, issues.projectId, desc(activityLog.createdAt))
.as("run_project_links");
const conditions: ReturnType<typeof eq>[] = [eq(heartbeatRuns.companyId, companyId)];
if (range?.from) conditions.push(gte(heartbeatRuns.startedAt, range.from));
if (range?.to) conditions.push(lte(heartbeatRuns.startedAt, range.to));
const effectiveProjectId = sql<string | null>`coalesce(${costEvents.projectId}, ${runProjectLinks.projectId})`;
const conditions: ReturnType<typeof eq>[] = [eq(costEvents.companyId, companyId)];
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
const costCentsExpr = sql<number>`coalesce(sum(round(coalesce((${heartbeatRuns.usageJson} ->> 'costUsd')::numeric, 0) * 100)), 0)::int`;
const costCentsExpr = sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`;
return db
.select({
projectId: runProjectLinks.projectId,
projectId: effectiveProjectId,
projectName: projects.name,
costCents: costCentsExpr,
inputTokens: sql<number>`coalesce(sum(coalesce((${heartbeatRuns.usageJson} ->> 'inputTokens')::int, 0)), 0)::int`,
outputTokens: sql<number>`coalesce(sum(coalesce((${heartbeatRuns.usageJson} ->> 'outputTokens')::int, 0)), 0)::int`,
inputTokens: sql<number>`coalesce(sum(${costEvents.inputTokens}), 0)::int`,
cachedInputTokens: sql<number>`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`,
outputTokens: sql<number>`coalesce(sum(${costEvents.outputTokens}), 0)::int`,
})
.from(runProjectLinks)
.innerJoin(heartbeatRuns, eq(runProjectLinks.runId, heartbeatRuns.id))
.innerJoin(projects, eq(runProjectLinks.projectId, projects.id))
.where(and(...conditions))
.groupBy(runProjectLinks.projectId, projects.name)
.from(costEvents)
.leftJoin(runProjectLinks, eq(costEvents.heartbeatRunId, runProjectLinks.runId))
.innerJoin(projects, sql`${projects.id} = ${effectiveProjectId}`)
.where(and(...conditions, sql`${effectiveProjectId} is not null`))
.groupBy(effectiveProjectId, projects.name)
.orderBy(desc(costCentsExpr));
},
};

View File

@@ -2,8 +2,10 @@ import { and, eq, gte, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agents, approvals, companies, costEvents, issues } from "@paperclipai/db";
import { notFound } from "../errors.js";
import { budgetService } from "./budgets.js";
export function dashboardService(db: Db) {
const budgets = budgetService(db);
return {
summary: async (companyId: string) => {
const company = await db
@@ -78,6 +80,7 @@ export function dashboardService(db: Db) {
company.budgetMonthlyCents > 0
? (monthSpendCents / company.budgetMonthlyCents) * 100
: 0;
const budgetOverview = await budgets.overview(companyId);
return {
companyId,
@@ -94,6 +97,12 @@ export function dashboardService(db: Db) {
monthUtilizationPercent: Number(utilization.toFixed(2)),
},
pendingApprovals,
budgets: {
activeIncidents: budgetOverview.activeIncidents.length,
pendingApprovals: budgetOverview.pendingApprovalCount,
pausedAgents: budgetOverview.pausedAgentCount,
pausedProjects: budgetOverview.pausedProjectCount,
},
};
},
};

View File

@@ -0,0 +1,134 @@
import { and, desc, eq, gte, lte, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agents, costEvents, financeEvents, goals, heartbeatRuns, issues, projects } from "@paperclipai/db";
import { notFound, unprocessable } from "../errors.js";
export interface FinanceDateRange {
from?: Date;
to?: Date;
}
async function assertBelongsToCompany(
db: Db,
table: any,
id: string,
companyId: string,
label: string,
) {
const row = await db
.select()
.from(table)
.where(eq(table.id, id))
.then((rows) => rows[0] ?? null);
if (!row) throw notFound(`${label} not found`);
if ((row as unknown as { companyId: string }).companyId !== companyId) {
throw unprocessable(`${label} does not belong to company`);
}
}
function rangeConditions(companyId: string, range?: FinanceDateRange) {
const conditions: ReturnType<typeof eq>[] = [eq(financeEvents.companyId, companyId)];
if (range?.from) conditions.push(gte(financeEvents.occurredAt, range.from));
if (range?.to) conditions.push(lte(financeEvents.occurredAt, range.to));
return conditions;
}
export function financeService(db: Db) {
const debitExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'debit' then ${financeEvents.amountCents} else 0 end), 0)::int`;
const creditExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'credit' then ${financeEvents.amountCents} else 0 end), 0)::int`;
const estimatedDebitExpr = sql<number>`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.estimated} = true then ${financeEvents.amountCents} else 0 end), 0)::int`;
return {
createEvent: async (companyId: string, data: Omit<typeof financeEvents.$inferInsert, "companyId">) => {
if (data.agentId) await assertBelongsToCompany(db, agents, data.agentId, companyId, "Agent");
if (data.issueId) await assertBelongsToCompany(db, issues, data.issueId, companyId, "Issue");
if (data.projectId) await assertBelongsToCompany(db, projects, data.projectId, companyId, "Project");
if (data.goalId) await assertBelongsToCompany(db, goals, data.goalId, companyId, "Goal");
if (data.heartbeatRunId) await assertBelongsToCompany(db, heartbeatRuns, data.heartbeatRunId, companyId, "Heartbeat run");
if (data.costEventId) await assertBelongsToCompany(db, costEvents, data.costEventId, companyId, "Cost event");
const event = await db
.insert(financeEvents)
.values({
...data,
companyId,
currency: data.currency ?? "USD",
direction: data.direction ?? "debit",
estimated: data.estimated ?? false,
})
.returning()
.then((rows) => rows[0]);
return event;
},
summary: async (companyId: string, range?: FinanceDateRange) => {
const conditions = rangeConditions(companyId, range);
const [row] = await db
.select({
debitCents: debitExpr,
creditCents: creditExpr,
estimatedDebitCents: estimatedDebitExpr,
eventCount: sql<number>`count(*)::int`,
})
.from(financeEvents)
.where(and(...conditions));
return {
companyId,
debitCents: Number(row?.debitCents ?? 0),
creditCents: Number(row?.creditCents ?? 0),
netCents: Number(row?.debitCents ?? 0) - Number(row?.creditCents ?? 0),
estimatedDebitCents: Number(row?.estimatedDebitCents ?? 0),
eventCount: Number(row?.eventCount ?? 0),
};
},
byBiller: async (companyId: string, range?: FinanceDateRange) => {
const conditions = rangeConditions(companyId, range);
return db
.select({
biller: financeEvents.biller,
debitCents: debitExpr,
creditCents: creditExpr,
estimatedDebitCents: estimatedDebitExpr,
eventCount: sql<number>`count(*)::int`,
kindCount: sql<number>`count(distinct ${financeEvents.eventKind})::int`,
netCents: sql<number>`(${debitExpr} - ${creditExpr})::int`,
})
.from(financeEvents)
.where(and(...conditions))
.groupBy(financeEvents.biller)
.orderBy(desc(sql`(${debitExpr} - ${creditExpr})::int`), financeEvents.biller);
},
byKind: async (companyId: string, range?: FinanceDateRange) => {
const conditions = rangeConditions(companyId, range);
return db
.select({
eventKind: financeEvents.eventKind,
debitCents: debitExpr,
creditCents: creditExpr,
estimatedDebitCents: estimatedDebitExpr,
eventCount: sql<number>`count(*)::int`,
billerCount: sql<number>`count(distinct ${financeEvents.biller})::int`,
netCents: sql<number>`(${debitExpr} - ${creditExpr})::int`,
})
.from(financeEvents)
.where(and(...conditions))
.groupBy(financeEvents.eventKind)
.orderBy(desc(sql`(${debitExpr} - ${creditExpr})::int`), financeEvents.eventKind);
},
list: async (companyId: string, range?: FinanceDateRange, limit: number = 100) => {
const conditions = rangeConditions(companyId, range);
return db
.select()
.from(financeEvents)
.where(and(...conditions))
.orderBy(desc(financeEvents.occurredAt), desc(financeEvents.createdAt))
.limit(limit);
},
};
}

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import type { BillingType } from "@paperclipai/shared";
import {
agents,
agentRuntimeState,
@@ -22,6 +23,7 @@ import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
import { costService } from "./costs.js";
import { budgetService } from "./budgets.js";
import { secretService } from "./secrets.js";
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
import { summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js";
@@ -170,6 +172,67 @@ function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function normalizeLedgerBillingType(value: unknown): BillingType {
const raw = readNonEmptyString(value);
switch (raw) {
case "api":
case "metered_api":
return "metered_api";
case "subscription":
case "subscription_included":
return "subscription_included";
case "subscription_overage":
return "subscription_overage";
case "credits":
return "credits";
case "fixed":
return "fixed";
default:
return "unknown";
}
}
function resolveLedgerBiller(result: AdapterExecutionResult): string {
return readNonEmptyString(result.biller) ?? readNonEmptyString(result.provider) ?? "unknown";
}
function normalizeBilledCostCents(costUsd: number | null | undefined, billingType: BillingType): number {
if (billingType === "subscription_included") return 0;
if (typeof costUsd !== "number" || !Number.isFinite(costUsd)) return 0;
return Math.max(0, Math.round(costUsd * 100));
}
async function resolveLedgerScopeForRun(
db: Db,
companyId: string,
run: typeof heartbeatRuns.$inferSelect,
) {
const context = parseObject(run.contextSnapshot);
const contextIssueId = readNonEmptyString(context.issueId);
const contextProjectId = readNonEmptyString(context.projectId);
if (!contextIssueId) {
return {
issueId: null,
projectId: contextProjectId,
};
}
const issue = await db
.select({
id: issues.id,
projectId: issues.projectId,
})
.from(issues)
.where(and(eq(issues.id, contextIssueId), eq(issues.companyId, companyId)))
.then((rows) => rows[0] ?? null);
return {
issueId: issue?.id ?? null,
projectId: issue?.projectId ?? contextProjectId,
};
}
function normalizeUsageTotals(usage: UsageSummary | null | undefined): UsageTotals | null {
if (!usage) return null;
return {
@@ -554,6 +617,7 @@ function resolveNextSessionState(input: {
export function heartbeatService(db: Db) {
const runLogStore = getRunLogStore();
const budgets = budgetService(db);
const secretsSvc = secretService(db);
const issuesSvc = issueService(db);
const activeRunExecutions = new Set<string>();
@@ -1294,8 +1358,12 @@ export function heartbeatService(db: Db) {
const inputTokens = usage?.inputTokens ?? 0;
const outputTokens = usage?.outputTokens ?? 0;
const cachedInputTokens = usage?.cachedInputTokens ?? 0;
const additionalCostCents = Math.max(0, Math.round((result.costUsd ?? 0) * 100));
const billingType = normalizeLedgerBillingType(result.billingType);
const additionalCostCents = normalizeBilledCostCents(result.costUsd, billingType);
const hasTokenUsage = inputTokens > 0 || outputTokens > 0 || cachedInputTokens > 0;
const provider = result.provider ?? "unknown";
const biller = resolveLedgerBiller(result);
const ledgerScope = await resolveLedgerScopeForRun(db, agent.companyId, run);
await db
.update(agentRuntimeState)
@@ -1316,10 +1384,16 @@ export function heartbeatService(db: Db) {
if (additionalCostCents > 0 || hasTokenUsage) {
const costs = costService(db);
await costs.createEvent(agent.companyId, {
heartbeatRunId: run.id,
agentId: agent.id,
provider: result.provider ?? "unknown",
issueId: ledgerScope.issueId,
projectId: ledgerScope.projectId,
provider,
biller,
billingType,
model: result.model ?? "unknown",
inputTokens,
cachedInputTokens,
outputTokens,
costCents: additionalCostCents,
occurredAt: new Date(),
@@ -1875,8 +1949,11 @@ export function heartbeatService(db: Db) {
freshSession: runtimeForAdapter.sessionId == null && runtimeForAdapter.sessionDisplayId == null,
sessionRotated: sessionCompaction.rotate,
sessionRotationReason: sessionCompaction.reason,
provider: readNonEmptyString(adapterResult.provider) ?? "unknown",
biller: resolveLedgerBiller(adapterResult),
model: readNonEmptyString(adapterResult.model) ?? "unknown",
...(adapterResult.costUsd != null ? { costUsd: adapterResult.costUsd } : {}),
...(adapterResult.billingType ? { billingType: adapterResult.billingType } : {}),
billingType: normalizeLedgerBillingType(adapterResult.billingType),
} as Record<string, unknown>)
: null;
@@ -2226,6 +2303,43 @@ export function heartbeatService(db: Db) {
const agent = await getAgent(agentId);
if (!agent) throw notFound("Agent not found");
const writeSkippedRequest = async (skipReason: string) => {
await db.insert(agentWakeupRequests).values({
companyId: agent.companyId,
agentId,
source,
triggerDetail,
reason: skipReason,
payload,
status: "skipped",
requestedByActorType: opts.requestedByActorType ?? null,
requestedByActorId: opts.requestedByActorId ?? null,
idempotencyKey: opts.idempotencyKey ?? null,
finishedAt: new Date(),
});
};
let projectId = readNonEmptyString(enrichedContextSnapshot.projectId);
if (!projectId && issueId) {
projectId = await db
.select({ projectId: issues.projectId })
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
.then((rows) => rows[0]?.projectId ?? null);
}
const budgetBlock = await budgets.getInvocationBlock(agent.companyId, agentId, {
issueId,
projectId,
});
if (budgetBlock) {
await writeSkippedRequest("budget.blocked");
throw conflict(budgetBlock.reason, {
scopeType: budgetBlock.scopeType,
scopeId: budgetBlock.scopeId,
});
}
if (
agent.status === "paused" ||
agent.status === "terminated" ||
@@ -2235,21 +2349,6 @@ export function heartbeatService(db: Db) {
}
const policy = parseHeartbeatPolicy(agent);
const writeSkippedRequest = async (reason: string) => {
await db.insert(agentWakeupRequests).values({
companyId: agent.companyId,
agentId,
source,
triggerDetail,
reason,
payload,
status: "skipped",
requestedByActorType: opts.requestedByActorType ?? null,
requestedByActorId: opts.requestedByActorId ?? null,
idempotencyKey: opts.idempotencyKey ?? null,
finishedAt: new Date(),
});
};
if (source === "timer" && !policy.enabled) {
await writeSkippedRequest("heartbeat.disabled");

View File

@@ -8,8 +8,10 @@ export { issueApprovalService } from "./issue-approvals.js";
export { goalService } from "./goals.js";
export { activityService, type ActivityFilters } from "./activity.js";
export { approvalService } from "./approvals.js";
export { budgetService } from "./budgets.js";
export { secretService } from "./secrets.js";
export { costService } from "./costs.js";
export { financeService } from "./finance.js";
export { heartbeatService } from "./heartbeat.js";
export { dashboardService } from "./dashboard.js";
export { sidebarBadgeService } from "./sidebar-badges.js";

View File

@@ -1,6 +1,19 @@
import type { ProviderQuotaResult } from "@paperclipai/shared";
import { listServerAdapters } from "../adapters/registry.js";
const QUOTA_PROVIDER_TIMEOUT_MS = 20_000;
function providerSlugForAdapterType(type: string): string {
switch (type) {
case "claude_local":
return "anthropic";
case "codex_local":
return "openai";
default:
return type;
}
}
/**
* Asks each registered adapter for its provider quota windows and aggregates the results.
* Adapters that don't implement getQuotaWindows() are silently skipped.
@@ -11,19 +24,41 @@ export async function fetchAllQuotaWindows(): Promise<ProviderQuotaResult[]> {
const adapters = listServerAdapters().filter((a) => a.getQuotaWindows != null);
const settled = await Promise.allSettled(
adapters.map((adapter) => adapter.getQuotaWindows!()),
adapters.map((adapter) => withQuotaTimeout(adapter.type, adapter.getQuotaWindows!())),
);
return settled.map((result, i) => {
if (result.status === "fulfilled") return result.value;
// Determine provider slug from the fulfilled value if available, otherwise fall back
// to the adapter type so the error is still attributable to the right provider.
const adapterType = adapters[i]!.type;
return {
provider: adapterType,
provider: providerSlugForAdapterType(adapterType),
ok: false,
error: String(result.reason),
windows: [],
};
});
}
async function withQuotaTimeout(
adapterType: string,
task: Promise<ProviderQuotaResult>,
): Promise<ProviderQuotaResult> {
let timeoutId: NodeJS.Timeout | null = null;
try {
return await Promise.race([
task,
new Promise<ProviderQuotaResult>((resolve) => {
timeoutId = setTimeout(() => {
resolve({
provider: providerSlugForAdapterType(adapterType),
ok: false,
error: `quota polling timed out after ${Math.round(QUOTA_PROVIDER_TIMEOUT_MS / 1000)}s`,
windows: [],
});
}, QUOTA_PROVIDER_TIMEOUT_MS);
}),
]);
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}