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 fdb310451..a9011f21b 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 bb90fd49c..1187e4d05 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 e39d6150d..26f10c81b 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)}