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[] = [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`coalesce(sum(case when ${financeEvents.direction} = 'debit' then ${financeEvents.amountCents} else 0 end), 0)::int`; const creditExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'credit' then ${financeEvents.amountCents} else 0 end), 0)::int`; const estimatedDebitExpr = sql`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) => { 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`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`count(*)::int`, kindCount: sql`count(distinct ${financeEvents.eventKind})::int`, netCents: sql`(${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`count(*)::int`, billerCount: sql`count(distinct ${financeEvents.biller})::int`, netCents: sql`(${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); }, }; }