Files
paperclip/server/src/services/costs.ts
Forgotten b459668009 fix: derive costs by-project from run usage instead of cost events
Joins heartbeat runs to issues via activity log to attribute costs to
projects. Shows project names instead of raw IDs in the UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:47:21 -06:00

173 lines
6.1 KiB
TypeScript

import { and, desc, eq, gte, isNotNull, lte, sql } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { activityLog, agents, companies, costEvents, heartbeatRuns, issues, projects } from "@paperclip/db";
import { notFound, unprocessable } from "../errors.js";
export interface CostDateRange {
from?: Date;
to?: Date;
}
export function costService(db: Db) {
return {
createEvent: async (companyId: string, data: Omit<typeof costEvents.$inferInsert, "companyId">) => {
const agent = await db
.select()
.from(agents)
.where(eq(agents.id, data.agentId))
.then((rows) => rows[0] ?? null);
if (!agent) throw notFound("Agent not found");
if (agent.companyId !== companyId) {
throw unprocessable("Agent does not belong to company");
}
const event = await db
.insert(costEvents)
.values({ ...data, companyId })
.returning()
.then((rows) => rows[0]);
await db
.update(agents)
.set({
spentMonthlyCents: sql`${agents.spentMonthlyCents} + ${event.costCents}`,
updatedAt: new Date(),
})
.where(eq(agents.id, event.agentId));
await db
.update(companies)
.set({
spentMonthlyCents: sql`${companies.spentMonthlyCents} + ${event.costCents}`,
updatedAt: new Date(),
})
.where(eq(companies.id, companyId));
const updatedAgent = await db
.select()
.from(agents)
.where(eq(agents.id, event.agentId))
.then((rows) => rows[0] ?? null);
if (
updatedAgent &&
updatedAgent.budgetMonthlyCents > 0 &&
updatedAgent.spentMonthlyCents >= updatedAgent.budgetMonthlyCents &&
updatedAgent.status !== "paused" &&
updatedAgent.status !== "terminated"
) {
await db
.update(agents)
.set({ status: "paused", updatedAt: new Date() })
.where(eq(agents.id, updatedAgent.id));
}
return event;
},
summary: async (companyId: string, range?: CostDateRange) => {
const company = await db
.select()
.from(companies)
.where(eq(companies.id, companyId))
.then((rows) => rows[0] ?? null);
if (!company) throw notFound("Company not found");
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 [{ total }] = await db
.select({
total: sql<number>`coalesce(sum(${costEvents.costCents}), 0)::int`,
})
.from(costEvents)
.where(and(...conditions));
const spendCents = Number(total);
const utilization =
company.budgetMonthlyCents > 0
? (spendCents / company.budgetMonthlyCents) * 100
: 0;
return {
companyId,
spendCents,
budgetCents: company.budgetMonthlyCents,
utilizationPercent: Number(utilization.toFixed(2)),
};
},
byAgent: 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));
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`,
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, agents.status)
.orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`));
},
byProject: async (companyId: string, range?: CostDateRange) => {
const issueIdAsText = sql<string>`${issues.id}::text`;
const runProjectLinks = db
.selectDistinctOn([activityLog.runId, issues.projectId], {
runId: sql<string>`${activityLog.runId}`,
projectId: sql<string>`${issues.projectId}`,
})
.from(activityLog)
.innerJoin(
issues,
and(
eq(activityLog.entityType, "issue"),
eq(activityLog.entityId, issueIdAsText),
),
)
.where(
and(
eq(activityLog.companyId, companyId),
eq(issues.companyId, companyId),
isNotNull(activityLog.runId),
isNotNull(issues.projectId),
),
)
.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.finishedAt, range.from));
if (range?.to) conditions.push(lte(heartbeatRuns.finishedAt, range.to));
const costCentsExpr = sql<number>`coalesce(sum(round(coalesce((${heartbeatRuns.usageJson} ->> 'costUsd')::numeric, 0) * 100)), 0)::int`;
return db
.select({
projectId: runProjectLinks.projectId,
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`,
})
.from(runProjectLinks)
.innerJoin(heartbeatRuns, eq(runProjectLinks.runId, heartbeatRuns.id))
.innerJoin(projects, eq(runProjectLinks.projectId, projects.id))
.where(and(...conditions))
.groupBy(runProjectLinks.projectId, projects.name)
.orderBy(desc(costCentsExpr));
},
};
}