Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions database/system.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -133,6 +134,7 @@ export function createApp() {
app.route('/', allocationRoutes);
app.route('/', classificationRoutes);
app.route('/', emailRoutes);
app.route('/', comptrollerRoutes);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add the comptroller route to protected prefixes

This mounts /api/comptroller/sync with the authenticated routes, but I checked the protectedPrefixes list above and /api/comptroller is not included. As a result requests to this new endpoint do not run storageMiddleware or tenantMiddleware; 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 👍 / 👎.

app.route('/', googleRoutes);
app.route('/', commsRoutes);
app.route('/', workflowRoutes);
Expand Down
2 changes: 2 additions & 0 deletions server/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
116 changes: 116 additions & 0 deletions server/lib/comptroller-sync.ts
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/lib/comptroller-sync.ts` around lines 48 - 54, fetchComptrollerMetrics
performs the fetch to `${base}/api/v1/metrics` with no timeout; add an
AbortController and pass its signal to fetch, start a timer (configurable, e.g.,
5s) that calls controller.abort() on timeout, and clear the timer after fetch
resolves; ensure the function still throws on non-ok responses and returns the
parsed 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<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;
}
69 changes: 69 additions & 0 deletions server/routes/comptroller.ts
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 });
});
72 changes: 72 additions & 0 deletions server/storage/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve reconciled rows during comptroller upserts

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 amount, coaCode, metadata, and updatedAt anyway. That bypasses the reconciled-row lock enforced elsewhere in classifyTransaction and can change already-reconciled books whenever the daily total changes or the sync is rerun; the conflict update should exclude reconciled rows or require an L3/L4 unlock path.

Useful? React with 👍 / 👎.

},
Comment on lines +188 to +205

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

COA classification fields are being written without a corresponding audit row.

Lines 188-203 update coaCode/classifiedBy/classifiedAt directly, but this method does not insert into classification_audit. That bypasses the trust-path audit requirement and makes classification history incomplete for these synced transactions.

As per coding guidelines, "COA classification writes must write audit rows. Trust levels L0-L4 enforced per AGENTS.md."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/storage/system.ts` around lines 188 - 205, The UPDATE block that sets
coaCode/classifiedBy/classifiedAt on schema.transactions currently skips writing
a corresponding row to classification_audit, which violates the COA audit
requirement; modify the same transaction that performs the .onConflictDoUpdate
(the code updating schema.transactions) to also INSERT into classification_audit
a new audit row containing tenantId, transaction id or externalId, coaCode,
classifiedBy, classifiedAt, metadata and createdAt (and agent/trust fields per
AGENTS.md L0-L4) so every classification write creates an audit entry; ensure
the insert runs in the same DB transaction as the update and handle
conflict/duplicates appropriately (e.g., always insert a new audit row rather
than updating existing audit rows).

Source: 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)
Expand Down
40 changes: 40 additions & 0 deletions server/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Record cron comptroller syncs in the ledger

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 comptroller.sync ledger entry. In production cron runs, those automated financial-state mutations therefore have no immutable ledger/audit event to tie the inserted expense rows back to the sync, so add the same ledger logging (or equivalent) to the scheduled path.

Useful? React with 👍 / 👎.

}

const app = createApp();

Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Sync a closed cost window instead of today's partial total

The production cron I checked in wrangler.jsonc/deploy/system-wrangler.jsonc runs this scheduled handler once at 09:00 UTC, but this call records Comptroller's today data using the current date. For services that incur cost after 09:00 UTC, the transaction for that day is booked with only a partial daily total and will not be corrected automatically until a manual rerun; the cron should sync a completed previous-day window or run after the reporting day closes.

Useful? React with 👍 / 👎.

);

// Discovery heartbeat (keeps service marked active)
ctx.waitUntil(sendHeartbeat(env).then((ok) => {
if (!ok) console.warn('[cron:discovery] heartbeat failed');
Expand Down
Loading