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
11 changes: 7 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Cron Phase 9 syncs Notion tasks → `cc_tasks`. Phase 10 reconciles legal tasks

### Database

Neon PostgreSQL via Hyperdrive binding. All tables prefixed `cc_`. Schema in `src/db/schema.ts`, SQL migrations in `migrations/` (0001-0012).
Neon PostgreSQL via Hyperdrive binding. All tables prefixed `cc_`. Schema in `src/db/schema.ts`, SQL migrations in `migrations/` (0001-0019). The `cc_vendors` table (migration 0019) tracks recurring vendor spend with deterministic spend-risk scoring (`src/lib/vendor-risk.ts`).

### Action Execution

Expand All @@ -97,20 +97,22 @@ Three modes:
- `src/lib/cron.ts` — Cron sync orchestrator (all data sources)
- `src/lib/integrations.ts` — Service clients (Mercury, Plaid, ChittyScrape, etc.)
- `src/lib/urgency.ts` — Deterministic urgency scoring engine
- `src/lib/vendor-risk.ts` — Deterministic vendor spend-risk engine (autopay-bounce / budget / spend-limit)
- `src/lib/validators.ts` — Zod schemas for request validation
- `src/lib/dispute-sync.ts` — Dispute ↔ Notion ↔ TriageAgent sync coordinator
- `src/routes/bridge/index.ts` — Inter-service bridge (scrape, ledger, finance, Plaid)
- `src/routes/bridge/disputes.ts` — Dispute-Notion manual sync bridge
- `src/routes/mcp.ts` — MCP server for Claude integration (50 tools)
- `src/routes/mcp.ts` — MCP server for Claude integration (52 tools)
- `src/routes/meta.ts` — Public canon/schema/beacon + authenticated whoami
- `src/routes/connect.ts` — ChittyConnect discovery proxy (rate-limited)
- `src/routes/ledger.ts` — ChittyLedger evidence/custody passthrough
- `src/routes/context.ts` — Persona/context management (user + global)
- `src/routes/auth.ts` — Login/verify flows
- `src/routes/token-management.ts` — Admin token CRUD
- `src/routes/dashboard.ts` — Dashboard summary with urgency scoring
- `src/routes/vendors.ts` — Vendor spend control (MTD by category, at-risk vendors, upcoming bills)
- `src/db/schema.ts` — Drizzle schema for all cc_* tables
- `migrations/` — SQL migration files (0001–0012)
- `migrations/` — SQL migration files (0001–0019)
- `docs/notion-task-triager-instructions.md` — Task Triager agent configuration for dispute ingestion
- `ui/` — React frontend (Vite + Tailwind)

Expand Down Expand Up @@ -146,7 +148,7 @@ Example client-side MCP configuration (conceptual):
}
```

The server exposes 50 tools across 12 domains:
The server exposes 52 tools across 13 domains:

**Core meta** — `get_canon_info`, `get_registry_status`, `get_schema_refs`, `whoami`, `get_context_summary`
**Financial** — `query_obligations`, `query_accounts`, `query_disputes`, `get_recommendations`, `get_cash_position`, `get_cashflow_projections`, `query_revenue_sources`, `get_payment_plan`
Expand All @@ -161,6 +163,7 @@ The server exposes 50 tools across 12 domains:
**Legal** — `query_legal_deadlines`
**Documents** — `query_documents`
**Sync** — `get_sync_status`, `trigger_sync`
**Vendors** — `query_vendors`, `get_vendor_risk`
**Evidence** — `get_case_timeline`, `get_case_facts`, `get_case_contradictions`, `get_pending_facts`, `synthesize_case_facts`

Tools return structured JSON using MCP `content: [{ type: "json", json: ... }]` where applicable, enabling Claude Code to consume results without text parsing.
66 changes: 66 additions & 0 deletions migrations/0019_vendors.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
-- 0019_vendors.sql — Vendor spend control: recurring org vendors + autopay/budget risk
-- Migration: 0019_vendors
-- Date: 2026-06-11
-- Creates: cc_vendors (recurring vendor spend tracking for budget/autopay-bounce control)
--
-- Additive + idempotent. Applied after the journaled drizzle schema (cc_accounts
-- must already exist for the optional account_id FK). Mirrors the 0017/0018
-- hand-rolled additive pattern and is registered in tests/setup/global-setup.ts.
--
-- Prod apply (same path as 0006–0018; the drizzle journal ends at
-- 0005_sour_dreadnoughts, so `npm run db:migrate` does NOT apply this file):
-- psql "$DATABASE_URL" < migrations/0019_vendors.sql
-- The shared cc_update_timestamp() trigger function is (re)defined here so this
-- migration is self-sufficient on branches where only the journaled drizzle
-- history (which does not manage triggers) has been applied.

BEGIN;

-- ── Shared trigger function (idempotent; defined in 0001_command_core too) ──
CREATE OR REPLACE FUNCTION cc_update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- ── Vendors ─────────────────────────────────────────────────────
-- One row per recurring spend relationship (GitHub, Cloudflare, Anthropic,
-- OpenAI, Neon, 1Password, …). payment_status='failed'|'limited' is the
-- autopay-bounce signal that surprised us with the GitHub Actions billing block.
CREATE TABLE IF NOT EXISTS cc_vendors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vendor_name TEXT NOT NULL UNIQUE,
category TEXT DEFAULT 'other', -- infra | ai_inference | dev_tooling | data | communication | subscription | other
billing_cycle TEXT, -- monthly | quarterly | annual | usage | one_time
expected_amount NUMERIC(12,2), -- expected recurring charge
currency TEXT DEFAULT 'USD',
next_bill_date DATE,
auto_pay BOOLEAN DEFAULT false,
payment_status TEXT DEFAULT 'unknown', -- active | failed | limited | unknown
payment_method TEXT, -- descriptor, e.g. 'amex-1234', 'mercury-ach'
spending_limit NUMERIC(12,2), -- provider hard cap (e.g. GH Actions spending limit)
mtd_spend NUMERIC(12,2) DEFAULT 0, -- month-to-date spend
budget_limit NUMERIC(12,2), -- our internal monthly budget
status TEXT DEFAULT 'active', -- active | paused | cancelled | zombie
owner TEXT, -- who owns the vendor relationship
account_id UUID REFERENCES cc_accounts(id), -- paying account (optional)
risk_score INTEGER, -- last computed spend-risk (0-100)
last_charge_at TIMESTAMPTZ,
last_synced_at TIMESTAMPTZ,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_cc_vendors_category ON cc_vendors(category);
CREATE INDEX IF NOT EXISTS idx_cc_vendors_status ON cc_vendors(status);
CREATE INDEX IF NOT EXISTS idx_cc_vendors_next_bill ON cc_vendors(next_bill_date);
CREATE INDEX IF NOT EXISTS idx_cc_vendors_risk ON cc_vendors(risk_score DESC NULLS LAST);

DROP TRIGGER IF EXISTS cc_vendors_updated_at ON cc_vendors;
CREATE TRIGGER cc_vendors_updated_at BEFORE UPDATE ON cc_vendors
FOR EACH ROW EXECUTE FUNCTION cc_update_timestamp();

COMMIT;
36 changes: 36 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,3 +564,39 @@ export const ccNodeLeases = pgTable('cc_node_leases', {
nodeIdx: index('idx_cc_node_leases_node').on(table.nodeId),
expiresIdx: index('idx_cc_node_leases_expires').on(table.leaseExpiresAt),
}));

// ─────────────────────────────────────────────────────────────
// Vendor spend control (migration 0019)
// Recurring org/operational vendors (GitHub, Cloudflare, Anthropic, OpenAI,
// Neon, 1Password, …). Risk scoring lives in src/lib/vendor-risk.ts; the
// payment_status='failed'|'limited' signal is the autopay-bounce alarm.
// ─────────────────────────────────────────────────────────────
export const ccVendors = pgTable('cc_vendors', {
id: uuid('id').primaryKey().defaultRandom(),
vendorName: text('vendor_name').notNull().unique(),
category: text('category').default('other'),
billingCycle: text('billing_cycle'),
expectedAmount: numeric('expected_amount', { precision: 12, scale: 2 }),
currency: text('currency').default('USD'),
nextBillDate: date('next_bill_date'),
autoPay: boolean('auto_pay').default(false),
paymentStatus: text('payment_status').default('unknown'),
paymentMethod: text('payment_method'),
spendingLimit: numeric('spending_limit', { precision: 12, scale: 2 }),
mtdSpend: numeric('mtd_spend', { precision: 12, scale: 2 }).default('0'),
budgetLimit: numeric('budget_limit', { precision: 12, scale: 2 }),
status: text('status').default('active'),
owner: text('owner'),
accountId: uuid('account_id').references(() => ccAccounts.id),
riskScore: integer('risk_score'),
lastChargeAt: timestamp('last_charge_at', { withTimezone: true }),
lastSyncedAt: timestamp('last_synced_at', { withTimezone: true }),
metadata: jsonb('metadata').default({}),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
}, (table) => ({
categoryIdx: index('idx_cc_vendors_category').on(table.category),
statusIdx: index('idx_cc_vendors_status').on(table.status),
nextBillIdx: index('idx_cc_vendors_next_bill').on(table.nextBillDate),
riskIdx: index('idx_cc_vendors_risk').on(table.riskScore),
}));
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { transactionRoutes } from './routes/transactions';
import { timelineRoutes } from './routes/timeline';
import { triageRoutes } from './routes/triage';
import { workspaceStudioRoutes } from './routes/workspace-studio';
import { vendorRoutes } from './routes/vendors';
import { runHealthProbes } from './routes/health';

// Re-export ActionAgent DO class so the runtime can find it
Expand Down Expand Up @@ -152,6 +153,7 @@ app.route('/api/cashflow', cashflowRoutes);
app.route('/api/queue', swipeQueueRoutes);
app.route('/api/payment-plan', paymentPlanRoutes);
app.route('/api/revenue', revenueRoutes);
app.route('/api/vendors', vendorRoutes);
app.route('/api/email-connections', emailConnectionRoutes);
app.route('/api/chat', chatRoutes);
app.route('/api/litigation', litigationRoutes);
Expand Down
45 changes: 45 additions & 0 deletions src/lib/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { generatePaymentPlan, savePaymentPlan } from './payment-planner';
import { reconcileNotionDisputes } from './dispute-sync';
import { enqueueJob, processQueue, type ScrapeJobType } from './job-dispatcher';
import { decayStaleRouxIntents } from './intent-decay';
import { computeVendorRisk, vendorRiskInputFromRow } from './vendor-risk';

/**
* Cron sync orchestrator.
Expand Down Expand Up @@ -191,6 +192,18 @@ export async function runCronSync(
} catch (err) {
console.error('[cron:email_bills] failed:', err);
}

// Vendor spend risk sweep — recompute risk_score for active vendors so the
// spend-control view stays fresh without an external billing feed.
try {
const vr = await sweepVendorRisk(sql);
if (vr.scanned > 0) {
recordsSynced += vr.updated;
console.log(`[cron:vendor_risk] scanned=${vr.scanned} updated=${vr.updated} at_risk=${vr.at_risk}`);
}
} catch (err) {
console.error('[cron:vendor_risk] failed:', err);
}
}

if (source === 'court_docket') {
Expand Down Expand Up @@ -232,6 +245,38 @@ export async function runCronSync(
}
}

/**
* Recompute spend-risk for every non-cancelled vendor. Deterministic; pulls
* rows, scores them via computeVendorRisk, and bulk-writes risk_score. Runs in
* the weekly cadence so the dashboard's vendor-risk view stays fresh without an
* external billing feed.
*/
async function sweepVendorRisk(
sql: NeonQueryFunction<false, false>,
): Promise<{ scanned: number; updated: number; at_risk: number }> {
const rows = await sql`
SELECT id, payment_status, auto_pay, next_bill_date, mtd_spend, budget_limit, spending_limit, status
FROM cc_vendors WHERE status != 'cancelled'
`;
if (rows.length === 0) return { scanned: 0, updated: 0, at_risk: 0 };

let atRisk = 0;
const ids: string[] = [];
const scores: number[] = [];
for (const r of rows as Record<string, unknown>[]) {
const { score } = computeVendorRisk(vendorRiskInputFromRow(r));
ids.push(r.id as string);
scores.push(score);
if (score >= 50) atRisk++;
}
await sql`
UPDATE cc_vendors SET risk_score = bulk.score, updated_at = NOW()
FROM (SELECT unnest(${ids}::uuid[]) AS id, unnest(${scores}::int[]) AS score) AS bulk
WHERE cc_vendors.id = bulk.id
`;
return { scanned: rows.length, updated: ids.length, at_risk: atRisk };
}

/**
* Sync Plaid balances and transactions.
* Uses batch lookups to avoid N+1 query patterns.
Expand Down
52 changes: 52 additions & 0 deletions src/lib/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,3 +362,55 @@ export const notionWebhookPayloadSchema = z.object({
verification_type: verificationTypeSchema.optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});

// ── Vendors (spend control) ──────────────────────────────────

export const vendorCategorySchema = z.enum([
'infra', 'ai_inference', 'dev_tooling', 'data', 'communication', 'subscription', 'other',
]);
export const vendorBillingCycleSchema = z.enum(['monthly', 'quarterly', 'annual', 'usage', 'one_time']);
export const vendorPaymentStatusSchema = z.enum(['active', 'failed', 'limited', 'unknown']);
export const vendorStatusSchema = z.enum(['active', 'paused', 'cancelled', 'zombie']);

export const createVendorSchema = z.object({
vendor_name: z.string().min(1).max(255),
category: vendorCategorySchema.optional(),
billing_cycle: vendorBillingCycleSchema.optional(),
expected_amount: z.number().min(0).optional(),
currency: z.string().length(3).optional(),
next_bill_date: dateString.optional(),
auto_pay: z.boolean().optional(),
payment_status: vendorPaymentStatusSchema.optional(),
payment_method: z.string().max(255).optional(),
spending_limit: z.number().min(0).optional(),
mtd_spend: z.number().min(0).optional(),
budget_limit: z.number().min(0).optional(),
status: vendorStatusSchema.optional(),
owner: z.string().max(100).optional(),
account_id: z.string().uuid().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});

export const updateVendorSchema = z.object({
category: vendorCategorySchema.optional(),
billing_cycle: vendorBillingCycleSchema.optional(),
expected_amount: z.number().min(0).optional(),
currency: z.string().length(3).optional(),
next_bill_date: dateString.optional(),
auto_pay: z.boolean().optional(),
payment_status: vendorPaymentStatusSchema.optional(),
payment_method: z.string().max(255).optional(),
spending_limit: z.number().min(0).optional(),
mtd_spend: z.number().min(0).optional(),
budget_limit: z.number().min(0).optional(),
status: vendorStatusSchema.optional(),
owner: z.string().max(100).optional(),
account_id: z.string().uuid().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});

export const vendorQuerySchema = z.object({
category: vendorCategorySchema.optional(),
status: vendorStatusSchema.optional(),
at_risk: z.enum(['true', 'false']).optional(),
});
Loading
Loading