From b459668009833f118cd202c8d28c75b5ea63e816 Mon Sep 17 00:00:00 2001 From: Forgotten Date: Fri, 20 Feb 2026 13:47:21 -0600 Subject: [PATCH] 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 --- server/src/services/costs.ts | 55 +++++++++++++++++++++++++++--------- ui/src/api/costs.ts | 8 +++--- ui/src/pages/Costs.tsx | 10 +++---- 3 files changed, 50 insertions(+), 23 deletions(-) diff --git a/server/src/services/costs.ts b/server/src/services/costs.ts index fdb31045..a9011f21 100644 --- a/server/src/services/costs.ts +++ b/server/src/services/costs.ts @@ -1,6 +1,6 @@ import { and, desc, eq, gte, isNotNull, lte, sql } from "drizzle-orm"; import type { Db } from "@paperclip/db"; -import { agents, companies, costEvents } from "@paperclip/db"; +import { activityLog, agents, companies, costEvents, heartbeatRuns, issues, projects } from "@paperclip/db"; import { notFound, unprocessable } from "../errors.js"; export interface CostDateRange { @@ -122,24 +122,51 @@ export function costService(db: Db) { }, byProject: async (companyId: string, range?: CostDateRange) => { - const conditions: ReturnType[] = [ - eq(costEvents.companyId, companyId), - isNotNull(costEvents.projectId), - ]; - if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from)); - if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to)); + const issueIdAsText = sql`${issues.id}::text`; + const runProjectLinks = db + .selectDistinctOn([activityLog.runId, issues.projectId], { + runId: sql`${activityLog.runId}`, + projectId: sql`${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[] = [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`coalesce(sum(round(coalesce((${heartbeatRuns.usageJson} ->> 'costUsd')::numeric, 0) * 100)), 0)::int`; return db .select({ - projectId: costEvents.projectId, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + projectId: runProjectLinks.projectId, + projectName: projects.name, + costCents: costCentsExpr, + inputTokens: sql`coalesce(sum(coalesce((${heartbeatRuns.usageJson} ->> 'inputTokens')::int, 0)), 0)::int`, + outputTokens: sql`coalesce(sum(coalesce((${heartbeatRuns.usageJson} ->> 'outputTokens')::int, 0)), 0)::int`, }) - .from(costEvents) + .from(runProjectLinks) + .innerJoin(heartbeatRuns, eq(runProjectLinks.runId, heartbeatRuns.id)) + .innerJoin(projects, eq(runProjectLinks.projectId, projects.id)) .where(and(...conditions)) - .groupBy(costEvents.projectId) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .groupBy(runProjectLinks.projectId, projects.name) + .orderBy(desc(costCentsExpr)); }, }; } diff --git a/ui/src/api/costs.ts b/ui/src/api/costs.ts index bb90fd49..1187e4d0 100644 --- a/ui/src/api/costs.ts +++ b/ui/src/api/costs.ts @@ -1,9 +1,9 @@ import type { CostSummary, CostByAgent } from "@paperclip/shared"; import { api } from "./client"; -export interface CostByEntity { - agentId?: string | null; - projectId?: string | null; +export interface CostByProject { + projectId: string | null; + projectName: string | null; costCents: number; inputTokens: number; outputTokens: number; @@ -23,5 +23,5 @@ export const costsApi = { byAgent: (companyId: string, from?: string, to?: string) => api.get(`/companies/${companyId}/costs/by-agent${dateParams(from, to)}`), byProject: (companyId: string, from?: string, to?: string) => - api.get(`/companies/${companyId}/costs/by-project${dateParams(from, to)}`), + api.get(`/companies/${companyId}/costs/by-project${dateParams(from, to)}`), }; diff --git a/ui/src/pages/Costs.tsx b/ui/src/pages/Costs.tsx index e39d6150..26f10c81 100644 --- a/ui/src/pages/Costs.tsx +++ b/ui/src/pages/Costs.tsx @@ -202,16 +202,16 @@ export function Costs() {

By Project

{data.byProject.length === 0 ? ( -

No project-attributed costs yet.

+

No project-attributed run costs yet.

) : (
- {data.byProject.map((row, idx) => ( + {data.byProject.map((row) => (
- - {row.projectId ?? "Unattributed"} + + {row.projectName ?? row.projectId ?? "Unattributed"} {formatCents(row.costCents)}