-
Notifications
You must be signed in to change notification settings - Fork 0
feat(comptroller): bridge ChittyComptroller cost into the books #133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| /** | ||
| * ChittyComptroller cost bridge. | ||
| * | ||
| * ChittyComptroller (comptroller.chitty.cc) is the source of truth for AI/infra | ||
| * cost. ChittyFinance CONSUMES its HTTP cost API and mirrors the daily | ||
| * per-service total into the books as an expense transaction — it does NOT | ||
| * re-ingest gateway logs ("fed back, no duplication"). | ||
| * | ||
| * Shared by the POST /api/comptroller/sync route (request-scoped tenant) and | ||
| * the daily cron in worker.ts (tenant resolved from the seeded infra account). | ||
| */ | ||
|
|
||
| import type { SystemStorage } from '../storage/system'; | ||
|
|
||
| export const DEFAULT_COMPTROLLER_BASE = 'https://comptroller.chitty.cc'; | ||
|
|
||
| // COA code for AI/infra subscriptions. 6010 "Software Subscriptions" (expense, | ||
| // tax-deductible) is the seeded ChittyFinance account for this cost class. | ||
| export const INFRA_COA_CODE = '6010'; | ||
|
|
||
| // The financial account that owns infra expense. Resolved at runtime by its | ||
| // deterministic external_id (seeded once), so no UUID is baked into source. | ||
| export const INFRA_ACCOUNT_EXTERNAL_ID = 'chittyos-infra'; | ||
|
|
||
| export interface ComptrollerTodayRow { | ||
| service: string; | ||
| tier: string; | ||
| cost_usd: number; | ||
| tokens_in: number; | ||
| tokens_out: number; | ||
| calls: number; | ||
| } | ||
|
|
||
| export interface ComptrollerMetrics { | ||
| status: string; | ||
| today: ComptrollerTodayRow[]; | ||
| ts?: string; | ||
| } | ||
|
|
||
| export interface SyncedRow { | ||
| service: string; | ||
| amount: string; | ||
| exactCostUsd: number; | ||
| externalId: string; | ||
| id: string; | ||
| } | ||
|
|
||
| export async function fetchComptrollerMetrics(base: string): Promise<ComptrollerMetrics> { | ||
| const res = await fetch(`${base}/api/v1/metrics`, { headers: { accept: 'application/json' } }); | ||
| if (!res.ok) { | ||
| throw new Error(`Comptroller returned ${res.status}`); | ||
| } | ||
| return (await res.json()) as ComptrollerMetrics; | ||
| } | ||
|
Comment on lines
+48
to
+54
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add an explicit timeout for the Comptroller fetch call. Line 49 performs an external call without a timeout. If Comptroller hangs, this can stall request handling and cron execution. Suggested fix export async function fetchComptrollerMetrics(base: string): Promise<ComptrollerMetrics> {
- const res = await fetch(`${base}/api/v1/metrics`, { headers: { accept: 'application/json' } });
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 10_000);
+ const res = await fetch(`${base}/api/v1/metrics`, {
+ headers: { accept: 'application/json' },
+ signal: controller.signal,
+ }).finally(() => clearTimeout(timeout));
if (!res.ok) {
throw new Error(`Comptroller returned ${res.status}`);
}
return (await res.json()) as ComptrollerMetrics;
}🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * Aggregate the Comptroller's per-(service,tier) `today` rows into a single | ||
| * total per service, then upsert one idempotent expense transaction per service | ||
| * for the given date. Returns the recorded rows. | ||
| */ | ||
| export async function syncComptrollerCosts(opts: { | ||
| storage: SystemStorage; | ||
| tenantId: string; | ||
| accountId: string; | ||
| metrics: ComptrollerMetrics; | ||
| date?: Date; | ||
| }): Promise<SyncedRow[]> { | ||
| const { storage, tenantId, accountId, metrics } = opts; | ||
| const date = opts.date ?? new Date(); | ||
| const day = date.toISOString().slice(0, 10); | ||
| const today = metrics.today ?? []; | ||
|
|
||
| // One row per (service, tier); aggregate to a per-service total so multi-tier | ||
| // services (e.g. chittyclaw T0 + T3_sonnet + manual) don't overwrite each | ||
| // other under a single external_id. | ||
| const byService = new Map< | ||
| string, | ||
| { costUsd: number; tokensIn: number; tokensOut: number; calls: number; tiers: ComptrollerTodayRow[] } | ||
| >(); | ||
| for (const row of today) { | ||
| const agg = byService.get(row.service) ?? { costUsd: 0, tokensIn: 0, tokensOut: 0, calls: 0, tiers: [] }; | ||
| agg.costUsd += row.cost_usd || 0; | ||
| agg.tokensIn += row.tokens_in || 0; | ||
| agg.tokensOut += row.tokens_out || 0; | ||
| agg.calls += row.calls || 0; | ||
| agg.tiers.push(row); | ||
| byService.set(row.service, agg); | ||
| } | ||
|
|
||
| const rows: SyncedRow[] = []; | ||
| for (const [service, agg] of byService) { | ||
| const saved = await storage.upsertComptrollerCost({ | ||
| tenantId, | ||
| accountId, | ||
| date, | ||
| service, | ||
| costUsd: agg.costUsd, | ||
| coaCode: INFRA_COA_CODE, | ||
| metadata: { | ||
| tokensIn: agg.tokensIn, | ||
| tokensOut: agg.tokensOut, | ||
| calls: agg.calls, | ||
| tiers: agg.tiers, | ||
| comptrollerTs: metrics.ts, | ||
| }, | ||
| }); | ||
| rows.push({ | ||
| service, | ||
| amount: saved.amount, | ||
| exactCostUsd: agg.costUsd, | ||
| externalId: saved.externalId ?? `comptroller:${day}:${service}`, | ||
| id: saved.id, | ||
| }); | ||
| } | ||
| return rows; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import { Hono } from 'hono'; | ||
| import type { HonoEnv } from '../env'; | ||
| import { ledgerLog } from '../lib/ledger-client'; | ||
| import { | ||
| DEFAULT_COMPTROLLER_BASE, | ||
| INFRA_ACCOUNT_EXTERNAL_ID, | ||
| fetchComptrollerMetrics, | ||
| syncComptrollerCosts, | ||
| } from '../lib/comptroller-sync'; | ||
|
|
||
| export const comptrollerRoutes = new Hono<HonoEnv>(); | ||
|
|
||
| // ChittyComptroller is the source of truth for AI/infra cost. ChittyFinance | ||
| // CONSUMES its HTTP cost API and mirrors the daily per-service total into the | ||
| // books as an expense — it does NOT re-ingest gateway logs ("fed back, no | ||
| // duplication"). | ||
| // | ||
| // POST /api/comptroller/sync — pull today's cost from ChittyComptroller and | ||
| // upsert one expense transaction per service (idempotent by external_id). | ||
| // | ||
| // Tenant: resolved from c.var.tenantId (tenant middleware). The infra cost is | ||
| // owned by IT CAN BE LLC, so the caller (admin / daily cron) passes that | ||
| // tenant's id via the X-Tenant-ID header. The "ChittyOS Infrastructure" | ||
| // account must exist for that tenant (external_id = 'chittyos-infra'). | ||
| comptrollerRoutes.post('/api/comptroller/sync', async (c) => { | ||
| const storage = c.get('storage'); | ||
| const tenantId = c.get('tenantId'); | ||
|
|
||
| const account = await storage.getAccountByExternalId(INFRA_ACCOUNT_EXTERNAL_ID, tenantId); | ||
| if (!account) { | ||
| return c.json( | ||
| { | ||
| error: 'infra_account_missing', | ||
| message: `No account with external_id='${INFRA_ACCOUNT_EXTERNAL_ID}' for this tenant. Seed the "ChittyOS Infrastructure" account first.`, | ||
| }, | ||
| 400, | ||
| ); | ||
| } | ||
|
|
||
| const base = c.env.COMPTROLLER_API_BASE || DEFAULT_COMPTROLLER_BASE; | ||
| let metrics; | ||
| try { | ||
| metrics = await fetchComptrollerMetrics(base); | ||
| } catch (err) { | ||
| return c.json( | ||
| { error: 'comptroller_unavailable', message: err instanceof Error ? err.message : String(err) }, | ||
| 502, | ||
| ); | ||
| } | ||
|
|
||
| const rows = await syncComptrollerCosts({ storage, tenantId, accountId: account.id, metrics }); | ||
|
|
||
| ledgerLog( | ||
| c, | ||
| { | ||
| entityType: 'audit', | ||
| action: 'comptroller.sync', | ||
| metadata: { | ||
| tenantId, | ||
| date: new Date().toISOString().slice(0, 10), | ||
| synced: rows.length, | ||
| services: rows.map((r) => r.service), | ||
| }, | ||
| }, | ||
| c.env, | ||
| ); | ||
|
|
||
| return c.json({ synced: rows.length, date: new Date().toISOString().slice(0, 10), rows }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -136,6 +136,78 @@ export class SystemStorage { | |
| return row; | ||
| } | ||
|
|
||
| /** | ||
| * Idempotently record a single day's AI/infra cost for one service, sourced | ||
| * from ChittyComptroller (comptroller.chitty.cc). ChittyComptroller remains | ||
| * the source of truth; ChittyFinance only mirrors the daily per-service total | ||
| * as an expense transaction so infra cost flows into the books without being | ||
| * re-derived from gateway logs. | ||
| * | ||
| * Idempotency key: external_id = `comptroller:{YYYY-MM-DD}:{service}`, enforced | ||
| * by the partial unique index transactions_tenant_external_idx (tenant_id, | ||
| * external_id). Re-runs UPDATE amount/metadata/classifiedAt — never duplicate. | ||
| * | ||
| * `amount` is stored at decimal(12,2) cents precision (sub-cent costs round to | ||
| * 0.00). The exact cost_usd plus token/tier breakdown is preserved in metadata | ||
| * so no precision is lost and the Comptroller's number is recoverable. | ||
| */ | ||
| async upsertComptrollerCost(input: { | ||
| tenantId: string; | ||
| accountId: string; | ||
| date: Date; | ||
| service: string; | ||
| costUsd: number; | ||
| coaCode: string; | ||
| metadata?: Record<string, unknown>; | ||
| }) { | ||
| const day = input.date.toISOString().slice(0, 10); | ||
| const externalId = `comptroller:${day}:${input.service}`; | ||
| const amount = input.costUsd.toFixed(2); | ||
| const now = new Date(); | ||
| const metadata = { | ||
| source: 'comptroller', | ||
| service: input.service, | ||
| exactCostUsd: input.costUsd, | ||
| reportDate: day, | ||
| ...input.metadata, | ||
| }; | ||
|
|
||
| const [row] = await this.db | ||
| .insert(schema.transactions) | ||
| .values({ | ||
| tenantId: input.tenantId, | ||
| accountId: input.accountId, | ||
| amount, | ||
| currency: 'USD', | ||
| type: 'expense', | ||
| category: 'ai_infrastructure', | ||
| description: `AI/Infra: ${input.service}`, | ||
| date: input.date, | ||
| payee: input.service, | ||
| externalId, | ||
| coaCode: input.coaCode, | ||
| classifiedBy: 'comptroller', | ||
| classifiedAt: now, | ||
| metadata, | ||
| }) | ||
| .onConflictDoUpdate({ | ||
| target: [schema.transactions.tenantId, schema.transactions.externalId], | ||
| // The arbiter index is partial (WHERE external_id IS NOT NULL); Postgres | ||
| // requires the predicate in the conflict target to infer a partial index. | ||
| targetWhere: sql`${schema.transactions.externalId} IS NOT NULL`, | ||
| set: { | ||
| amount, | ||
| coaCode: input.coaCode, | ||
| classifiedBy: 'comptroller', | ||
| classifiedAt: now, | ||
| metadata, | ||
| updatedAt: now, | ||
|
Comment on lines
+198
to
+204
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a Comptroller transaction for the same tenant/day/service has already been reconciled, any later sync for that day hits this conflict path and updates Useful? React with 👍 / 👎. |
||
| }, | ||
|
Comment on lines
+188
to
+205
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. COA classification fields are being written without a corresponding audit row. Lines 188-203 update As per coding guidelines, "COA classification writes must write audit rows. Trust levels L0-L4 enforced per AGENTS.md." 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| }) | ||
| .returning(); | ||
| return row; | ||
| } | ||
|
|
||
| async updateTransaction(id: string, tenantId: string, data: Partial<typeof schema.transactions.$inferInsert>) { | ||
| const [row] = await this.db | ||
| .update(schema.transactions) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,39 @@ import { createApp } from './app'; | |
| import type { Env } from './env'; | ||
| import { processLeaseExpirations } from './lib/lease-expiration'; | ||
| import { sendHeartbeat, registerWithDiscovery } from './lib/discovery-client'; | ||
| import { createDb } from './db/connection'; | ||
| import { SystemStorage } from './storage/system'; | ||
| import { | ||
| DEFAULT_COMPTROLLER_BASE, | ||
| INFRA_ACCOUNT_EXTERNAL_ID, | ||
| fetchComptrollerMetrics, | ||
| syncComptrollerCosts, | ||
| } from './lib/comptroller-sync'; | ||
|
|
||
| // Daily ChittyComptroller cost bridge. Resolves the infra tenant from the seeded | ||
| // "ChittyOS Infrastructure" account (external_id = 'chittyos-infra') so no tenant | ||
| // header is needed in the cron context. | ||
| async function runComptrollerSync(env: Env): Promise<void> { | ||
| if (!env.DATABASE_URL) { | ||
| console.warn('[cron:comptroller] DATABASE_URL not configured — skipping'); | ||
| return; | ||
| } | ||
| const storage = new SystemStorage(createDb(env.DATABASE_URL)); | ||
| const account = await storage.lookupAccountByExternalId(INFRA_ACCOUNT_EXTERNAL_ID); | ||
| if (!account) { | ||
| console.warn(`[cron:comptroller] no account with external_id='${INFRA_ACCOUNT_EXTERNAL_ID}' — skipping`); | ||
| return; | ||
| } | ||
| const base = env.COMPTROLLER_API_BASE || DEFAULT_COMPTROLLER_BASE; | ||
| const metrics = await fetchComptrollerMetrics(base); | ||
| const rows = await syncComptrollerCosts({ | ||
| storage, | ||
| tenantId: account.tenantId, | ||
| accountId: account.id, | ||
| metrics, | ||
| }); | ||
| console.log(`[cron:comptroller] synced ${rows.length} services:`, JSON.stringify(rows.map((r) => `${r.service}=${r.amount}`))); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For the scheduled path that creates or updates the daily Comptroller transactions, the only audit emitted after the write is this console log, while the manual route records a Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| const app = createApp(); | ||
|
|
||
|
|
@@ -16,6 +49,13 @@ export default { | |
| console.error(`[cron:lease-expiration] ${stats.errors.length} failures during processing`); | ||
| } | ||
|
|
||
| // ChittyComptroller cost bridge — record today's per-service AI/infra cost | ||
| ctx.waitUntil( | ||
| runComptrollerSync(env).catch((err) => { | ||
| console.error('[cron:comptroller] sync failed:', err instanceof Error ? err.message : String(err)); | ||
| }), | ||
|
Comment on lines
+52
to
+56
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The production cron I checked in Useful? React with 👍 / 👎. |
||
| ); | ||
|
|
||
| // Discovery heartbeat (keeps service marked active) | ||
| ctx.waitUntil(sendHeartbeat(env).then((ok) => { | ||
| if (!ok) console.warn('[cron:discovery] heartbeat failed'); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This mounts
/api/comptroller/syncwith the authenticated routes, but I checked theprotectedPrefixeslist above and/api/comptrolleris not included. As a result requests to this new endpoint do not runstorageMiddlewareortenantMiddleware;c.get('storage')/c.get('tenantId')are unset, so the route fails before it can perform the documented tenant-scoped manual sync.Useful? React with 👍 / 👎.