From aadd0c7d631d363b9ef17efac1f09156fc3424ed Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 23:32:38 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(finance):=20vendor=20spend=20control?= =?UTF-8?q?=20=E2=80=94=20cc=5Fvendors=20+=20deterministic=20spend-risk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stand up a vendor spend-control surface so recurring org/operational vendors (GitHub, Cloudflare, Anthropic, OpenAI, Neon, 1Password, …) are tracked with autopay-bounce / budget / spend-limit risk — the failure mode behind a surprise billing block. - migration 0019: cc_vendors (billing cycle, expected/MTD spend, next bill, autopay + payment_status, provider spending_limit, internal budget, status) - src/lib/vendor-risk.ts: pure deterministic computeVendorRisk() (sibling of urgency.ts), unit-tested in tests/lib/vendor-risk.spec.ts - /api/vendors: list, /summary rollup (MTD by category, at-risk, upcoming bills, zombies), get / create(upsert) / patch / recompute-risk - MCP: query_vendors + get_vendor_risk (tool counts 50→52 / 54→56) - cron: weekly vendor-risk sweep over existing rows (no external feed) - global-setup applies 0019; schema.ts, validators.ts, CLAUDE.md updated Ingestion stays pluggable (POST/PATCH today); live billing-API ingestion is the cross-repo chittyagent-finance follow-up. https://claude.ai/code/session_015mkdG1VYH3AdqLe4E3i9H6 --- CLAUDE.md | 11 +- migrations/0019_vendors.sql | 62 +++++++++ src/db/schema.ts | 36 ++++++ src/index.ts | 2 + src/lib/cron.ts | 45 +++++++ src/lib/validators.ts | 52 ++++++++ src/lib/vendor-risk.ts | 163 +++++++++++++++++++++++ src/routes/mcp.ts | 74 ++++++++++- src/routes/vendors.ts | 211 ++++++++++++++++++++++++++++++ tests/lib/vendor-risk.spec.ts | 236 ++++++++++++++++++++++++++++++++++ tests/mcp.test.ts | 10 +- tests/setup/global-setup.ts | 2 +- 12 files changed, 891 insertions(+), 13 deletions(-) create mode 100644 migrations/0019_vendors.sql create mode 100644 src/lib/vendor-risk.ts create mode 100644 src/routes/vendors.ts create mode 100644 tests/lib/vendor-risk.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8aa5fe0..c88105d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -97,11 +97,12 @@ 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 @@ -109,8 +110,9 @@ Three modes: - `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) @@ -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` @@ -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. diff --git a/migrations/0019_vendors.sql b/migrations/0019_vendors.sql new file mode 100644 index 0000000..a5eaad3 --- /dev/null +++ b/migrations/0019_vendors.sql @@ -0,0 +1,62 @@ +-- 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. +-- 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; diff --git a/src/db/schema.ts b/src/db/schema.ts index 3fe80de..46bad39 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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), +})); diff --git a/src/index.ts b/src/index.ts index 01093bc..af8d46c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 @@ -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); diff --git a/src/lib/cron.ts b/src/lib/cron.ts index 5a23a1b..dfafe44 100644 --- a/src/lib/cron.ts +++ b/src/lib/cron.ts @@ -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. @@ -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') { @@ -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, +): 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[]) { + 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. diff --git a/src/lib/validators.ts b/src/lib/validators.ts index d95c1e8..aee1a2e 100644 --- a/src/lib/validators.ts +++ b/src/lib/validators.ts @@ -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(), +}); diff --git a/src/lib/vendor-risk.ts b/src/lib/vendor-risk.ts new file mode 100644 index 0000000..a37040d --- /dev/null +++ b/src/lib/vendor-risk.ts @@ -0,0 +1,163 @@ +/** + * Deterministic vendor spend-risk scoring. + * + * Sibling to src/lib/urgency.ts (obligation urgency). Where urgency answers + * "which bill needs attention", this answers "which recurring vendor is about + * to bounce a charge, blow a budget, or hit a provider spending cap" — the + * failure mode behind a surprise billing block. Pure + deterministic so it is + * unit-testable and safe to run identically in cron, routes, and the MCP + * surface. + * + * Returns 0-100; higher = more at risk. Level buckets reuse urgencyLevel so the + * dashboard speaks one risk vocabulary across obligations and vendors. + */ +import { urgencyLevel, type UrgencyLevel } from './urgency'; + +export type VendorPaymentStatus = 'active' | 'failed' | 'limited' | 'unknown'; +export type VendorStatus = 'active' | 'paused' | 'cancelled' | 'zombie'; + +export interface VendorRiskInput { + payment_status: VendorPaymentStatus; + auto_pay: boolean; + next_bill_date: string | null; // YYYY-MM-DD + mtd_spend: number | null; + budget_limit: number | null; + spending_limit: number | null; + status: VendorStatus; +} + +export interface VendorRisk { + score: number; + level: UrgencyLevel; + reasons: string[]; +} + +function isFiniteNum(n: number | null | undefined): n is number { + return n != null && Number.isFinite(n); +} + +function money(n: number): string { + return n.toFixed(2); +} + +// Whole-day difference from today (UTC, date-only) to a YYYY-MM-DD date. +// Mirrors the date handling in urgency.ts. Returns null for invalid input. +function daysUntil(dateStr: string): number | null { + const target = new Date(dateStr + 'T00:00:00Z'); + if (isNaN(target.getTime())) return null; + const now = new Date(); + const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + return Math.floor((target.getTime() - today.getTime()) / 86400000); +} + +export function computeVendorRisk(v: VendorRiskInput): VendorRisk { + const reasons: string[] = []; + + // Cancelled vendors carry no live billing risk. + if (v.status === 'cancelled') { + return { score: 0, level: urgencyLevel(0), reasons: ['vendor cancelled — no active billing'] }; + } + + let score = 0; + + // ── Payment health (the autopay-bounce signal) ── + if (v.payment_status === 'failed') { + score += 50; + reasons.push('payment failed — next charge will bounce'); + } else if (v.payment_status === 'limited') { + score += 35; + reasons.push('account limited / spending cap reached'); + } else if (v.payment_status === 'unknown') { + score += 5; + reasons.push('payment status unknown'); + } + + const mtd = isFiniteNum(v.mtd_spend) ? v.mtd_spend : null; + + // ── Provider spending-limit headroom (hard cap → charge bounces) ── + if (mtd != null && isFiniteNum(v.spending_limit) && v.spending_limit > 0) { + const ratio = mtd / v.spending_limit; + if (ratio >= 1) { + score += 25; + reasons.push(`MTD $${money(mtd)} at/over spending limit $${money(v.spending_limit)}`); + } else if (ratio >= 0.9) { + score += 15; + reasons.push(`MTD $${money(mtd)} ≥ 90% of spending limit`); + } else if (ratio >= 0.75) { + score += 8; + reasons.push(`MTD $${money(mtd)} ≥ 75% of spending limit`); + } + } + + // ── Internal budget overrun (our cap, not the provider's) ── + if (mtd != null && isFiniteNum(v.budget_limit) && v.budget_limit > 0) { + if (mtd > v.budget_limit) { + score += 15; + reasons.push(`MTD $${money(mtd)} over budget $${money(v.budget_limit)}`); + } else if (mtd >= 0.9 * v.budget_limit) { + score += 8; + reasons.push(`MTD $${money(mtd)} ≥ 90% of budget`); + } + } + + // ── Bill imminence (manual bills are riskier than autopay) ── + if (v.next_bill_date) { + const days = daysUntil(v.next_bill_date); + if (days != null) { + if (days < 0) { + score += 10; + reasons.push('next bill date is in the past (stale / unreconciled)'); + } else if (days <= 3) { + score += v.auto_pay ? 8 : 20; + reasons.push(`bill due in ${days}d${v.auto_pay ? '' : ' (manual)'}`); + } else if (days <= 7) { + score += v.auto_pay ? 4 : 10; + reasons.push(`bill due in ${days}d${v.auto_pay ? '' : ' (manual)'}`); + } else if (days <= 14 && !v.auto_pay) { + score += 5; + reasons.push(`bill due in ${days}d (manual)`); + } + } + } + + // ── Healthy autopay reduces surprise risk (it's handled) ── + if (v.auto_pay && v.payment_status === 'active') { + score -= 15; + reasons.push('autopay active & healthy'); + } + + // ── Paused vendors are lower risk ── + if (v.status === 'paused') { + score -= 20; + reasons.push('vendor paused'); + } + + score = Math.min(100, Math.max(0, score)); + return { score, level: urgencyLevel(score), reasons }; +} + +/** + * Coerce an unknown DB value to a finite number, else null. Neon returns + * NUMERIC columns as strings, so spend/limit fields need this. + */ +export function numOrNull(v: unknown): number | null { + if (v == null) return null; + const n = typeof v === 'number' ? v : parseFloat(String(v)); + return Number.isFinite(n) ? n : null; +} + +/** + * Build a VendorRiskInput from a raw cc_vendors DB row. Centralised so routes, + * cron, and MCP all assess rows identically. + */ +export function vendorRiskInputFromRow(r: Record): VendorRiskInput { + return { + payment_status: (r.payment_status as VendorPaymentStatus) || 'unknown', + auto_pay: Boolean(r.auto_pay), + next_bill_date: (r.next_bill_date as string) ?? null, + mtd_spend: numOrNull(r.mtd_spend), + budget_limit: numOrNull(r.budget_limit), + spending_limit: numOrNull(r.spending_limit), + status: (r.status as VendorStatus) || 'active', + }; +} diff --git a/src/routes/mcp.ts b/src/routes/mcp.ts index 6a003a4..23b3339 100644 --- a/src/routes/mcp.ts +++ b/src/routes/mcp.ts @@ -14,6 +14,7 @@ const TRIAGE_TOOL_NAMES = new Set([ ]); import { getDb, typedRows } from '../lib/db'; import type { NeonQueryFunction } from '@neondatabase/serverless'; +import { computeVendorRisk, vendorRiskInputFromRow, numOrNull } from '../lib/vendor-risk'; import { listJobs, getJobStatus, retryJob, getDeadLetters, enqueueJob } from '../lib/job-dispatcher'; import type { ScrapeJobType, ScrapeJobStatus } from '../lib/job-dispatcher'; import { evidenceClient, ledgerClient, govClient } from '../lib/integrations'; @@ -38,7 +39,7 @@ const TRIAGE_VALID_SPACE: ReadonlySet = new Set(['busi * MCP (Model Context Protocol) server for ChittyCommand. * * Implements JSON-RPC 2.0 over HTTP (Streamable HTTP transport). - * Provides 48 tools across 12 domains for Claude Code sessions. + * Provides 56 tools across 13 domains for Claude Code sessions. */ export const mcpRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>(); @@ -468,6 +469,26 @@ const TOOLS = [ required: [] as string[], }, }, + // ── Vendors (spend control) ──────────────────────────────── + { + name: 'query_vendors', + description: 'List recurring spend vendors (infra, AI inference, dev tooling, subscriptions) with live spend-risk. Filter by category, status, or at_risk to surface vendors about to bounce a charge or blow a budget.', + inputSchema: { + type: 'object' as const, + properties: { + category: { type: 'string', description: 'Filter: infra, ai_inference, dev_tooling, data, communication, subscription, other' }, + status: { type: 'string', description: 'Filter: active, paused, cancelled, zombie', enum: ['active', 'paused', 'cancelled', 'zombie'] }, + at_risk: { type: 'boolean', description: 'Only vendors with risk score >= 50 (high/critical)' }, + limit: { type: 'number', description: 'Max results (default 20)' }, + }, + required: [] as string[], + }, + }, + { + name: 'get_vendor_risk', + description: 'Portfolio spend-risk summary across all vendors: counts by risk level, total MTD spend, monthly committed spend, and the top at-risk vendors with reasons (failed autopay, spend-limit/budget breach, imminent manual bill).', + inputSchema: { type: 'object' as const, properties: {}, required: [] as string[] }, + }, // ChittyTriage / Roux — @canon: chittycanon://gov/governance#classification-axes STATUS:PENDING { name: 'triage_list_intents', @@ -658,7 +679,7 @@ async function executeTool(env: Env, sql: NeonQueryFunction, toolN endpoints: [ '/api/dashboard', '/api/accounts', '/api/obligations', '/api/disputes', '/api/recommendations', '/api/cashflow', '/api/legal', '/api/documents', - '/api/sync', '/api/queue', '/api/payment-plan', '/api/revenue', + '/api/sync', '/api/queue', '/api/payment-plan', '/api/revenue', '/api/vendors', '/api/email-connections', '/api/chat', '/api/tasks', '/api/bridge/plaid', '/api/bridge/finance', '/api/bridge/ledger', '/api/bridge/scrape', '/api/bridge/disputes', '/api/bridge/mercury', @@ -671,7 +692,7 @@ async function executeTool(env: Env, sql: NeonQueryFunction, toolN 'cc_legal_deadlines', 'cc_disputes', 'cc_dispute_correspondence', 'cc_documents', 'cc_recommendations', 'cc_actions_log', 'cc_cashflow_projections', 'cc_decision_feedback', 'cc_revenue_sources', - 'cc_payment_plans', 'cc_sync_log', 'cc_tasks', + 'cc_payment_plans', 'cc_sync_log', 'cc_tasks', 'cc_vendors', ], }; } @@ -1124,6 +1145,53 @@ async function executeTool(env: Env, sql: NeonQueryFunction, toolN return { count: rows.length, total_monthly: Math.round(total * 100) / 100, sources: rows }; } + case 'query_vendors': { + const category = args.category || null; + const status = args.status || null; + const atRisk = args.at_risk === true; + const limit = Math.min(Number(args.limit) || 20, 50); + const rows = await sql` + SELECT id, vendor_name, category, billing_cycle, expected_amount, currency, next_bill_date, auto_pay, payment_status, payment_method, spending_limit, mtd_spend, budget_limit, status, risk_score + FROM cc_vendors + WHERE (${category}::text IS NULL OR category = ${category}) + AND (${status}::text IS NULL OR status = ${status}) + ORDER BY risk_score DESC NULLS LAST, next_bill_date ASC NULLS LAST + LIMIT ${limit} + `; + const vendors = (rows as Record[]).map((r) => { + const risk = computeVendorRisk(vendorRiskInputFromRow(r)); + return { ...r, risk_score: risk.score, risk_level: risk.level, risk_reasons: risk.reasons }; + }); + const filtered = atRisk ? vendors.filter((v) => (v.risk_score as number) >= 50) : vendors; + return { count: filtered.length, vendors: filtered }; + } + + case 'get_vendor_risk': { + const rows = await sql` + SELECT id, vendor_name, category, billing_cycle, expected_amount, next_bill_date, auto_pay, payment_status, spending_limit, mtd_spend, budget_limit, status + FROM cc_vendors WHERE status != 'cancelled' + `; + const byLevel: Record = { critical: 0, high: 0, medium: 0, low: 0 }; + const atRisk: Array<{ vendor_name: unknown; category: unknown; score: number; level: string; reasons: string[] }> = []; + let totalMtd = 0; + let monthlyCommitted = 0; + for (const r of rows as Record[]) { + totalMtd += numOrNull(r.mtd_spend) ?? 0; + if (r.billing_cycle === 'monthly') monthlyCommitted += numOrNull(r.expected_amount) ?? 0; + const risk = computeVendorRisk(vendorRiskInputFromRow(r)); + byLevel[risk.level] = (byLevel[risk.level] || 0) + 1; + if (risk.score >= 50) atRisk.push({ vendor_name: r.vendor_name, category: r.category, score: risk.score, level: risk.level, reasons: risk.reasons }); + } + atRisk.sort((a, b) => b.score - a.score); + return { + vendor_count: rows.length, + by_level: byLevel, + total_mtd_spend: Math.round(totalMtd * 100) / 100, + monthly_committed: Math.round(monthlyCommitted * 100) / 100, + at_risk: atRisk.slice(0, 20), + }; + } + case 'get_sync_status': { const rows = await sql` SELECT source, status, records_synced, started_at, completed_at, error_message diff --git a/src/routes/vendors.ts b/src/routes/vendors.ts new file mode 100644 index 0000000..5a4113b --- /dev/null +++ b/src/routes/vendors.ts @@ -0,0 +1,211 @@ +import { Hono } from 'hono'; +import type { Env } from '../index'; +import { getDb } from '../lib/db'; +import { computeVendorRisk, vendorRiskInputFromRow, numOrNull } from '../lib/vendor-risk'; +import { createVendorSchema, updateVendorSchema, vendorQuerySchema } from '../lib/validators'; + +export const vendorRoutes = new Hono<{ Bindings: Env }>(); + +const round2 = (n: number) => Math.round(n * 100) / 100; + +// List vendors with filtering + live spend-risk +vendorRoutes.get('/', async (c) => { + const sql = getDb(c.env); + const qResult = vendorQuerySchema.safeParse({ + category: c.req.query('category'), + status: c.req.query('status'), + at_risk: c.req.query('at_risk'), + }); + if (!qResult.success) return c.json({ error: 'Invalid query params', issues: qResult.error.issues }, 400); + const category = qResult.data.category || null; + const status = qResult.data.status || null; + const atRisk = qResult.data.at_risk === 'true'; + + const rows = await sql` + SELECT * FROM cc_vendors + WHERE (${category}::text IS NULL OR category = ${category}) + AND (${status}::text IS NULL OR status = ${status}) + ORDER BY risk_score DESC NULLS LAST, next_bill_date ASC NULLS LAST, vendor_name ASC + `; + const vendors = rows.map((r) => ({ ...r, risk: computeVendorRisk(vendorRiskInputFromRow(r)) })); + const filtered = atRisk ? vendors.filter((v) => v.risk.score >= 50) : vendors; + return c.json({ count: filtered.length, vendors: filtered }); +}); + +// Spend-control rollup: MTD by category, at-risk vendors, upcoming bills, zombies. +// Registered before '/:id' so 'summary' isn't captured as an id. +vendorRoutes.get('/summary', async (c) => { + const sql = getDb(c.env); + const rows = await sql`SELECT * FROM cc_vendors WHERE status != 'cancelled'`; + + let totalMtd = 0; + let monthlyCommitted = 0; + const byLevel: Record = { critical: 0, high: 0, medium: 0, low: 0 }; + const byCategoryMap = new Map(); + const atRisk: Array> = []; + const zombies: Array> = []; + + for (const r of rows) { + const mtd = numOrNull(r.mtd_spend) ?? 0; + const expected = numOrNull(r.expected_amount) ?? 0; + const cycle = (r.billing_cycle as string | null) ?? null; + const cat = (r.category as string) || 'other'; + + totalMtd += mtd; + if (cycle === 'monthly') monthlyCommitted += expected; + + const agg = byCategoryMap.get(cat) || { category: cat, vendor_count: 0, mtd_spend: 0, monthly_committed: 0 }; + agg.vendor_count += 1; + agg.mtd_spend += mtd; + if (cycle === 'monthly') agg.monthly_committed += expected; + byCategoryMap.set(cat, agg); + + const risk = computeVendorRisk(vendorRiskInputFromRow(r)); + byLevel[risk.level] = (byLevel[risk.level] || 0) + 1; + if (risk.score >= 50) { + atRisk.push({ id: r.id, vendor_name: r.vendor_name, category: cat, payment_status: r.payment_status, score: risk.score, level: risk.level, reasons: risk.reasons }); + } + if (r.status === 'zombie') { + zombies.push({ id: r.id, vendor_name: r.vendor_name, expected_amount: expected, billing_cycle: cycle }); + } + } + + const upcomingBills = await sql` + SELECT id, vendor_name, category, next_bill_date, expected_amount, auto_pay, payment_status + FROM cc_vendors + WHERE status != 'cancelled' AND next_bill_date IS NOT NULL + AND next_bill_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '30 days' + ORDER BY next_bill_date ASC + `; + + atRisk.sort((a, b) => (b.score as number) - (a.score as number)); + const byCategory = [...byCategoryMap.values()] + .map((a) => ({ ...a, mtd_spend: round2(a.mtd_spend), monthly_committed: round2(a.monthly_committed) })) + .sort((a, b) => b.mtd_spend - a.mtd_spend); + + return c.json({ + vendor_count: rows.length, + total_mtd_spend: round2(totalMtd), + monthly_committed: round2(monthlyCommitted), + by_level: byLevel, + by_category: byCategory, + at_risk: atRisk, + upcoming_bills: upcomingBills, + zombies, + }); +}); + +// Single vendor with live spend-risk +vendorRoutes.get('/:id', async (c) => { + const sql = getDb(c.env); + const id = c.req.param('id'); + const [row] = await sql`SELECT * FROM cc_vendors WHERE id = ${id}`; + if (!row) return c.json({ error: 'Vendor not found' }, 404); + return c.json({ ...row, risk: computeVendorRisk(vendorRiskInputFromRow(row)) }); +}); + +// Create or upsert a vendor (keyed on vendor_name). POST is a full representation. +vendorRoutes.post('/', async (c) => { + const raw = await c.req.json(); + const result = createVendorSchema.safeParse(raw); + if (!result.success) return c.json({ error: 'Validation failed', issues: result.error.issues }, 400); + const body = result.data; + + const riskScore = computeVendorRisk({ + payment_status: body.payment_status || 'unknown', + auto_pay: body.auto_pay || false, + next_bill_date: body.next_bill_date || null, + mtd_spend: body.mtd_spend ?? null, + budget_limit: body.budget_limit ?? null, + spending_limit: body.spending_limit ?? null, + status: body.status || 'active', + }).score; + + const sql = getDb(c.env); + const [vendor] = await sql` + INSERT INTO cc_vendors (vendor_name, category, billing_cycle, expected_amount, currency, next_bill_date, auto_pay, payment_status, payment_method, spending_limit, mtd_spend, budget_limit, status, owner, account_id, risk_score, metadata) + VALUES (${body.vendor_name}, ${body.category || 'other'}, ${body.billing_cycle || null}, ${body.expected_amount ?? null}, ${body.currency || 'USD'}, ${body.next_bill_date || null}, ${body.auto_pay || false}, ${body.payment_status || 'unknown'}, ${body.payment_method || null}, ${body.spending_limit ?? null}, ${body.mtd_spend ?? 0}, ${body.budget_limit ?? null}, ${body.status || 'active'}, ${body.owner || null}, ${body.account_id || null}, ${riskScore}, ${JSON.stringify(body.metadata || {})}) + ON CONFLICT (vendor_name) DO UPDATE SET + category = EXCLUDED.category, + billing_cycle = EXCLUDED.billing_cycle, + expected_amount = EXCLUDED.expected_amount, + currency = EXCLUDED.currency, + next_bill_date = EXCLUDED.next_bill_date, + auto_pay = EXCLUDED.auto_pay, + payment_status = EXCLUDED.payment_status, + payment_method = EXCLUDED.payment_method, + spending_limit = EXCLUDED.spending_limit, + mtd_spend = EXCLUDED.mtd_spend, + budget_limit = EXCLUDED.budget_limit, + status = EXCLUDED.status, + owner = EXCLUDED.owner, + account_id = EXCLUDED.account_id, + risk_score = EXCLUDED.risk_score, + updated_at = NOW() + RETURNING * + `; + return c.json(vendor, 201); +}); + +// Partial update; recomputes risk from the merged row +vendorRoutes.patch('/:id', async (c) => { + const id = c.req.param('id'); + const raw = await c.req.json(); + const result = updateVendorSchema.safeParse(raw); + if (!result.success) return c.json({ error: 'Validation failed', issues: result.error.issues }, 400); + const body = result.data; + const sql = getDb(c.env); + + const [existing] = await sql`SELECT * FROM cc_vendors WHERE id = ${id}`; + if (!existing) return c.json({ error: 'Vendor not found' }, 404); + + const riskScore = computeVendorRisk(vendorRiskInputFromRow({ + payment_status: body.payment_status ?? existing.payment_status, + auto_pay: body.auto_pay ?? existing.auto_pay, + next_bill_date: body.next_bill_date ?? existing.next_bill_date, + mtd_spend: body.mtd_spend ?? existing.mtd_spend, + budget_limit: body.budget_limit ?? existing.budget_limit, + spending_limit: body.spending_limit ?? existing.spending_limit, + status: body.status ?? existing.status, + })).score; + + const [vendor] = await sql` + UPDATE cc_vendors SET + category = COALESCE(${body.category ?? null}, category), + billing_cycle = COALESCE(${body.billing_cycle ?? null}, billing_cycle), + expected_amount = COALESCE(${body.expected_amount ?? null}, expected_amount), + currency = COALESCE(${body.currency ?? null}, currency), + next_bill_date = COALESCE(${body.next_bill_date ?? null}, next_bill_date), + auto_pay = COALESCE(${body.auto_pay ?? null}, auto_pay), + payment_status = COALESCE(${body.payment_status ?? null}, payment_status), + payment_method = COALESCE(${body.payment_method ?? null}, payment_method), + spending_limit = COALESCE(${body.spending_limit ?? null}, spending_limit), + mtd_spend = COALESCE(${body.mtd_spend ?? null}, mtd_spend), + budget_limit = COALESCE(${body.budget_limit ?? null}, budget_limit), + status = COALESCE(${body.status ?? null}, status), + owner = COALESCE(${body.owner ?? null}, owner), + account_id = COALESCE(${body.account_id ?? null}, account_id), + risk_score = ${riskScore}, + updated_at = NOW() + WHERE id = ${id} RETURNING * + `; + return c.json(vendor); +}); + +// Recompute risk for all non-cancelled vendors (batched to avoid N+1) +vendorRoutes.post('/recompute-risk', async (c) => { + const sql = getDb(c.env); + const rows = await sql`SELECT * FROM cc_vendors WHERE status != 'cancelled'`; + const updates = rows.map((r) => ({ id: r.id as string, score: computeVendorRisk(vendorRiskInputFromRow(r)).score })); + + if (updates.length > 0) { + const ids = updates.map((u) => u.id); + const scores = updates.map((u) => u.score); + 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 c.json({ updated: updates.length, message: `Recomputed risk for ${updates.length} vendors` }); +}); diff --git a/tests/lib/vendor-risk.spec.ts b/tests/lib/vendor-risk.spec.ts new file mode 100644 index 0000000..9845063 --- /dev/null +++ b/tests/lib/vendor-risk.spec.ts @@ -0,0 +1,236 @@ +import { describe, it, expect } from 'vitest'; +import { computeVendorRisk, vendorRiskInputFromRow, numOrNull, type VendorRiskInput } from '../../src/lib/vendor-risk'; + +// Helper to create a YYYY-MM-DD date string N days from now (UTC) +function daysFromNow(n: number): string { + const d = new Date(); + d.setUTCDate(d.getUTCDate() + n); + return d.toISOString().slice(0, 10); +} + +const base: VendorRiskInput = { + payment_status: 'active', + auto_pay: false, + next_bill_date: null, + mtd_spend: null, + budget_limit: null, + spending_limit: null, + status: 'active', +}; + +describe('computeVendorRisk', () => { + it('healthy active vendor with no signals scores 0 (low)', () => { + const r = computeVendorRisk(base); + expect(r.score).toBe(0); + expect(r.level).toBe('low'); + }); + + // ── Payment health ────────────────────────────────────────── + describe('payment status', () => { + it('failed adds 50 (high) and explains the bounce', () => { + const r = computeVendorRisk({ ...base, payment_status: 'failed' }); + expect(r.score).toBe(50); + expect(r.level).toBe('high'); + expect(r.reasons.some((x) => x.includes('payment failed'))).toBe(true); + }); + + it('limited adds 35 (medium)', () => { + expect(computeVendorRisk({ ...base, payment_status: 'limited' }).score).toBe(35); + }); + + it('unknown adds 5 (low)', () => { + expect(computeVendorRisk({ ...base, payment_status: 'unknown' }).score).toBe(5); + }); + + it('active adds 0', () => { + expect(computeVendorRisk({ ...base, payment_status: 'active' }).score).toBe(0); + }); + }); + + // ── Provider spending limit ───────────────────────────────── + describe('spending limit headroom', () => { + it('at/over the cap adds 25', () => { + expect(computeVendorRisk({ ...base, mtd_spend: 100, spending_limit: 100 }).score).toBe(25); + expect(computeVendorRisk({ ...base, mtd_spend: 150, spending_limit: 100 }).score).toBe(25); + }); + + it('>= 90% adds 15', () => { + expect(computeVendorRisk({ ...base, mtd_spend: 90, spending_limit: 100 }).score).toBe(15); + }); + + it('>= 75% adds 8', () => { + expect(computeVendorRisk({ ...base, mtd_spend: 75, spending_limit: 100 }).score).toBe(8); + }); + + it('< 75% adds 0', () => { + expect(computeVendorRisk({ ...base, mtd_spend: 74, spending_limit: 100 }).score).toBe(0); + }); + + it('ignores a zero/invalid limit', () => { + expect(computeVendorRisk({ ...base, mtd_spend: 100, spending_limit: 0 }).score).toBe(0); + }); + + it('ignores when mtd_spend is null', () => { + expect(computeVendorRisk({ ...base, mtd_spend: null, spending_limit: 100 }).score).toBe(0); + }); + }); + + // ── Internal budget ───────────────────────────────────────── + describe('budget overrun', () => { + it('over budget adds 15', () => { + expect(computeVendorRisk({ ...base, mtd_spend: 101, budget_limit: 100 }).score).toBe(15); + }); + + it('at 90% (incl. exactly at budget) adds 8', () => { + expect(computeVendorRisk({ ...base, mtd_spend: 90, budget_limit: 100 }).score).toBe(8); + expect(computeVendorRisk({ ...base, mtd_spend: 100, budget_limit: 100 }).score).toBe(8); + }); + + it('< 90% adds 0', () => { + expect(computeVendorRisk({ ...base, mtd_spend: 89, budget_limit: 100 }).score).toBe(0); + }); + }); + + // ── Bill imminence ────────────────────────────────────────── + describe('bill imminence (manual)', () => { + it('due within 3 days adds 20', () => { + expect(computeVendorRisk({ ...base, next_bill_date: daysFromNow(2) }).score).toBe(20); + expect(computeVendorRisk({ ...base, next_bill_date: daysFromNow(0) }).score).toBe(20); + }); + + it('due within 7 days adds 10', () => { + expect(computeVendorRisk({ ...base, next_bill_date: daysFromNow(5) }).score).toBe(10); + }); + + it('due within 14 days adds 5', () => { + expect(computeVendorRisk({ ...base, next_bill_date: daysFromNow(10) }).score).toBe(5); + }); + + it('due beyond 14 days adds 0', () => { + expect(computeVendorRisk({ ...base, next_bill_date: daysFromNow(20) }).score).toBe(0); + }); + + it('past-due (stale) adds 10', () => { + expect(computeVendorRisk({ ...base, next_bill_date: daysFromNow(-1) }).score).toBe(10); + }); + + it('invalid date contributes nothing', () => { + expect(computeVendorRisk({ ...base, next_bill_date: 'not-a-date' }).score).toBe(0); + }); + }); + + describe('bill imminence (autopay) is softer', () => { + it('autopay bill due in 2 days adds only 8 (before the healthy-autopay credit)', () => { + // payment_status 'unknown' avoids the active-only healthy-autopay credit, + // isolating the autopay bill weighting: 5 (unknown) + 8 (autopay <=3d) = 13 + const r = computeVendorRisk({ ...base, payment_status: 'unknown', auto_pay: true, next_bill_date: daysFromNow(2) }); + expect(r.score).toBe(13); + }); + }); + + // ── Healthy autopay credit ────────────────────────────────── + describe('healthy autopay', () => { + it('active + autopay reduces by 15 (clamped to 0 alone)', () => { + const r = computeVendorRisk({ ...base, auto_pay: true }); + expect(r.score).toBe(0); + expect(r.reasons.some((x) => x.includes('autopay active'))).toBe(true); + }); + + it('credit nets against other risk', () => { + // active + autopay healthy (-15) + spend at cap (+25) = 10 + expect(computeVendorRisk({ ...base, auto_pay: true, mtd_spend: 100, spending_limit: 100 }).score).toBe(10); + }); + + it('no credit when payment is not active', () => { + // failed + autopay: the credit requires active status, so 50 stands + expect(computeVendorRisk({ ...base, payment_status: 'failed', auto_pay: true }).score).toBe(50); + }); + }); + + // ── Status modifiers ──────────────────────────────────────── + describe('status', () => { + it('cancelled short-circuits to 0 regardless of other signals', () => { + const r = computeVendorRisk({ + ...base, + status: 'cancelled', + payment_status: 'failed', + mtd_spend: 999, + spending_limit: 100, + next_bill_date: daysFromNow(-5), + }); + expect(r.score).toBe(0); + expect(r.reasons[0]).toContain('cancelled'); + }); + + it('paused reduces by 20', () => { + // spend at cap (+25) + paused (-20) = 5 + expect(computeVendorRisk({ ...base, status: 'paused', mtd_spend: 100, spending_limit: 100 }).score).toBe(5); + }); + + it('zombie still scores like active (live billing risk remains)', () => { + expect(computeVendorRisk({ ...base, status: 'zombie', mtd_spend: 100, spending_limit: 100 }).score).toBe(25); + }); + }); + + // ── Clamping ──────────────────────────────────────────────── + describe('clamping', () => { + it('clamps to 100 when signals stack past the ceiling', () => { + const r = computeVendorRisk({ + ...base, + payment_status: 'failed', // +50 + mtd_spend: 200, + spending_limit: 100, // +25 + budget_limit: 100, // +15 (over budget) + next_bill_date: daysFromNow(2), // +20 (manual, <=3d) + auto_pay: false, + }); + expect(r.score).toBe(100); + expect(r.level).toBe('critical'); + }); + + it('clamps to 0 when reductions exceed signals', () => { + expect(computeVendorRisk({ ...base, status: 'paused', auto_pay: true }).score).toBe(0); + }); + }); + + it('failed payment + spend over cap is critical', () => { + const r = computeVendorRisk({ ...base, payment_status: 'failed', mtd_spend: 200, spending_limit: 100 }); + expect(r.score).toBe(75); + expect(r.level).toBe('critical'); + }); +}); + +// ── Row coercion helpers ────────────────────────────────────── +describe('numOrNull', () => { + it('passes through finite numbers', () => expect(numOrNull(5)).toBe(5)); + it('parses numeric strings (Neon NUMERIC)', () => expect(numOrNull('150.00')).toBe(150)); + it('returns null for null/undefined', () => { + expect(numOrNull(null)).toBeNull(); + expect(numOrNull(undefined)).toBeNull(); + }); + it('returns null for non-numeric strings', () => expect(numOrNull('abc')).toBeNull()); +}); + +describe('vendorRiskInputFromRow', () => { + it('coerces a raw DB row (string numerics) into a scored input', () => { + const row = { + payment_status: 'failed', + auto_pay: false, + next_bill_date: null, + mtd_spend: '150.00', + budget_limit: null, + spending_limit: '100.00', + status: 'active', + }; + const r = computeVendorRisk(vendorRiskInputFromRow(row)); + // failed (+50) + spend over cap (+25) = 75 + expect(r.score).toBe(75); + }); + + it('defaults missing payment_status/status sensibly', () => { + const input = vendorRiskInputFromRow({}); + expect(input.payment_status).toBe('unknown'); + expect(input.status).toBe('active'); + expect(input.mtd_spend).toBeNull(); + }); +}); diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 75e10ed..deee6c9 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -141,7 +141,7 @@ describe('MCP — tools/list', () => { expect(tools.length).toBeGreaterThanOrEqual(1); }); - it('exposes 50 tools to unscoped callers (triage tools hidden)', async () => { + it('exposes 52 tools to unscoped callers (triage tools hidden)', async () => { // PR #104 round-3 fix: tools/list filters triage_* tools when caller // lacks chittytriage:write (or admin / chittytriage:admin / *). // The dev bypass in mcpAuthMiddleware grants scope `['mcp']` only, so @@ -152,7 +152,7 @@ describe('MCP — tools/list', () => { const json = await res.json() as Record; const result = json.result as Record; const tools = result.tools as Array<{ name: string }>; - expect(tools.length).toBe(50); + expect(tools.length).toBe(52); // None of the triage_* tools should be advertised. const triageNames = [ 'triage_list_intents', @@ -165,9 +165,9 @@ describe('MCP — tools/list', () => { } }); - it('exposes all 54 tools to callers with triage scope', async () => { + it('exposes all 56 tools to callers with triage scope', async () => { // When the caller's scope includes `chittytriage:write` (or admin / - // chittytriage:admin / *), all 4 triage tools become visible — total 54. + // chittytriage:admin / *), all 4 triage tools become visible — total 56. // We bypass mcpAuthMiddleware here and inject scopes directly to // exercise the scoped tools/list branch deterministically (the dev // bypass in mcpAuthMiddleware only grants ['mcp']). @@ -188,7 +188,7 @@ describe('MCP — tools/list', () => { const json = await res.json() as Record; const result = json.result as Record; const tools = result.tools as Array<{ name: string }>; - expect(tools.length).toBe(54); + expect(tools.length).toBe(56); expect(tools.find((t) => t.name === 'triage_list_intents')).toBeDefined(); expect(tools.find((t) => t.name === 'triage_complete_intent')).toBeDefined(); }); diff --git a/tests/setup/global-setup.ts b/tests/setup/global-setup.ts index 21b4ac5..941bb91 100644 --- a/tests/setup/global-setup.ts +++ b/tests/setup/global-setup.ts @@ -31,7 +31,7 @@ const MIGRATIONS_DIR = join(__dirname, '..', '..', 'migrations'); // Post-consolidation, additive hand-rolled migrations that complement the // journaled drizzle schema (not part of the alternative-history set). -const ADDITIVE_PREFIXES = ['0017_', '0018_']; +const ADDITIVE_PREFIXES = ['0017_', '0018_', '0019_']; // Postgres SQLSTATE codes for "this object already exists" — safe to skip // when re-applying overlapping migration sets across branches. From 35fe01242cbb5b04cfb6105664f578425d08cb5d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 23:42:29 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(vendors):=20address=20Codex=20review=20?= =?UTF-8?q?=E2=80=94=20PATCH=20metadata=20+=20at=5Frisk-before-limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vendors PATCH now persists `metadata`: updateVendorSchema accepted the field but the UPDATE never assigned it (silent no-op on ownership/context edits) - query_vendors MCP tool recomputes risk live and applies the at_risk filter BEFORE limiting, so high-risk vendors that sort past a page edge or carry a stale/null stored risk_score are no longer dropped - document migration 0019's prod apply path (psql) in its header — the drizzle journal ends at 0005, so 0006–0019 are hand-rolled and applied via psql, not `npm run db:migrate` https://claude.ai/code/session_015mkdG1VYH3AdqLe4E3i9H6 --- migrations/0019_vendors.sql | 4 ++++ src/routes/mcp.ts | 13 ++++++++----- src/routes/vendors.ts | 1 + 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/migrations/0019_vendors.sql b/migrations/0019_vendors.sql index a5eaad3..9c2ef86 100644 --- a/migrations/0019_vendors.sql +++ b/migrations/0019_vendors.sql @@ -6,6 +6,10 @@ -- 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. diff --git a/src/routes/mcp.ts b/src/routes/mcp.ts index 23b3339..3c6c38e 100644 --- a/src/routes/mcp.ts +++ b/src/routes/mcp.ts @@ -1150,20 +1150,23 @@ async function executeTool(env: Env, sql: NeonQueryFunction, toolN const status = args.status || null; const atRisk = args.at_risk === true; const limit = Math.min(Number(args.limit) || 20, 50); + // Fetch matching rows (vendor table is small) then recompute risk live — + // the stored risk_score may be stale — before filtering/limiting, so + // at_risk never drops a high-risk vendor that would sort past a page edge. const rows = await sql` SELECT id, vendor_name, category, billing_cycle, expected_amount, currency, next_bill_date, auto_pay, payment_status, payment_method, spending_limit, mtd_spend, budget_limit, status, risk_score FROM cc_vendors WHERE (${category}::text IS NULL OR category = ${category}) AND (${status}::text IS NULL OR status = ${status}) - ORDER BY risk_score DESC NULLS LAST, next_bill_date ASC NULLS LAST - LIMIT ${limit} `; - const vendors = (rows as Record[]).map((r) => { + let vendors = (rows as Record[]).map((r) => { const risk = computeVendorRisk(vendorRiskInputFromRow(r)); return { ...r, risk_score: risk.score, risk_level: risk.level, risk_reasons: risk.reasons }; }); - const filtered = atRisk ? vendors.filter((v) => (v.risk_score as number) >= 50) : vendors; - return { count: filtered.length, vendors: filtered }; + if (atRisk) vendors = vendors.filter((v) => (v.risk_score as number) >= 50); + vendors.sort((a, b) => (b.risk_score as number) - (a.risk_score as number)); + const limited = vendors.slice(0, limit); + return { count: limited.length, vendors: limited }; } case 'get_vendor_risk': { diff --git a/src/routes/vendors.ts b/src/routes/vendors.ts index 5a4113b..954a6f4 100644 --- a/src/routes/vendors.ts +++ b/src/routes/vendors.ts @@ -185,6 +185,7 @@ vendorRoutes.patch('/:id', async (c) => { status = COALESCE(${body.status ?? null}, status), owner = COALESCE(${body.owner ?? null}, owner), account_id = COALESCE(${body.account_id ?? null}, account_id), + metadata = COALESCE(${body.metadata !== undefined ? JSON.stringify(body.metadata) : null}::jsonb, metadata), risk_score = ${riskScore}, updated_at = NOW() WHERE id = ${id} RETURNING *