diff --git a/database/system.schema.ts b/database/system.schema.ts index 6e07ed5..363dbe2 100644 --- a/database/system.schema.ts +++ b/database/system.schema.ts @@ -3,6 +3,7 @@ // Uses Neon PostgreSQL with decimal precision for accounting import { pgTable, uuid, text, timestamp, decimal, boolean, integer, jsonb, index, uniqueIndex } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; import { createInsertSchema } from 'drizzle-zod'; import { z } from 'zod'; @@ -160,6 +161,12 @@ export const transactions = pgTable('transactions', { propertyIdx: index('transactions_property_idx').on(table.propertyId), coaIdx: index('transactions_coa_idx').on(table.tenantId, table.coaCode), unclassifiedIdx: index('transactions_unclassified_idx').on(table.tenantId, table.coaCode), + // Idempotency for external syncs (Mercury/Wave/Comptroller). Partial: only + // rows with a non-null external_id are constrained, so manual txns (NULL) are + // unaffected. Scoped per-tenant to avoid cross-tenant external_id collisions. + tenantExternalIdx: uniqueIndex('transactions_tenant_external_idx') + .on(table.tenantId, table.externalId) + .where(sql`${table.externalId} IS NOT NULL`), })); export const insertTransactionSchema = createInsertSchema(transactions); diff --git a/server/app.ts b/server/app.ts index d5d9ee0..5c223d3 100644 --- a/server/app.ts +++ b/server/app.ts @@ -39,6 +39,7 @@ import { chittyIdAuthRoutes } from './routes/chittyid-auth'; import { allocationRoutes } from './accounting/allocations'; import { classificationRoutes } from './routes/classification'; import { emailRoutes } from './routes/email'; +import { comptrollerRoutes } from './routes/comptroller'; import { createDb } from './db/connection'; import { SystemStorage } from './storage/system'; @@ -133,6 +134,7 @@ export function createApp() { app.route('/', allocationRoutes); app.route('/', classificationRoutes); app.route('/', emailRoutes); + app.route('/', comptrollerRoutes); app.route('/', googleRoutes); app.route('/', commsRoutes); app.route('/', workflowRoutes); diff --git a/server/env.ts b/server/env.ts index 3f119c7..3af924b 100644 --- a/server/env.ts +++ b/server/env.ts @@ -5,6 +5,8 @@ export interface Env { CHITTY_AUTH_ISSUER?: string; CHITTY_AUTH_AUDIENCE?: string; CHITTYCONNECT_API_BASE?: string; + // ChittyComptroller cost API base (defaults to https://comptroller.chitty.cc). + COMPTROLLER_API_BASE?: string; OPENAI_API_KEY?: string; STRIPE_SECRET_KEY?: string; STRIPE_WEBHOOK_SECRET?: string; diff --git a/server/lib/comptroller-sync.ts b/server/lib/comptroller-sync.ts new file mode 100644 index 0000000..4acbc3e --- /dev/null +++ b/server/lib/comptroller-sync.ts @@ -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 { + 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; +} + +/** + * 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 { + 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; +} diff --git a/server/routes/comptroller.ts b/server/routes/comptroller.ts new file mode 100644 index 0000000..6be4475 --- /dev/null +++ b/server/routes/comptroller.ts @@ -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(); + +// 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 }); +}); diff --git a/server/storage/system.ts b/server/storage/system.ts index 5c42330..f2fa652 100755 --- a/server/storage/system.ts +++ b/server/storage/system.ts @@ -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; + }) { + 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, + }, + }) + .returning(); + return row; + } + async updateTransaction(id: string, tenantId: string, data: Partial) { const [row] = await this.db .update(schema.transactions) diff --git a/server/worker.ts b/server/worker.ts index e454cee..a79d966 100755 --- a/server/worker.ts +++ b/server/worker.ts @@ -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 { + 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}`))); +} 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)); + }), + ); + // Discovery heartbeat (keeps service marked active) ctx.waitUntil(sendHeartbeat(env).then((ok) => { if (!ok) console.warn('[cron:discovery] heartbeat failed');