Spaces:
Paused
Paused
| 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); | |
| }, | |
| }; | |
| } | |