diff --git a/CLAUDE.md b/CLAUDE.md index 48bd85c5..3f8c8eaa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,6 +113,7 @@ pnpm lighthouse # Lighthouse CI - Server Components by default — `"use client"` only when client interactivity needed ## Recent Changes +- 036-budget-forecast-simulation: Added the Budget / Cost Forecast Simulation scenario (`/scenarios/budget-forecast`) — a pure projection engine + Recharts burn-up UI (Nothing design), read-only over existing budget/cost tables - 025-running-api-costs: Added TypeScript 5.9.3 (strict mode) + Next.js 15.5.12 (App Router), Drizzle ORM 0.45.1, React 19.2.4 - 023-ingestion-history: Added TypeScript 5.9.3 (strict mode) + Next.js 15.5.12 (App Router), React 19.2.4, Drizzle ORM 0.45.1, TanStack Table 8.21.3, shadcn/ui (new-york), Lucide React - 022-profile-api-preview: Added TypeScript 5.9.3 (strict mode) + Next.js 15.5.12 (App Router), React 19.2.4, shadcn/ui (new-york), Zod 4.3.6, Sonner (toasts), Lucide React diff --git a/docs/mcp-server.md b/docs/mcp-server.md new file mode 100644 index 00000000..34c5e198 --- /dev/null +++ b/docs/mcp-server.md @@ -0,0 +1,80 @@ +# MCP Server + +The AI Developer Hub exposes a read-only [Model Context Protocol](https://modelcontextprotocol.io) +server so MCP clients (Claude Desktop / Code, Cursor, etc.) can query live +AI-spend data in natural language. + +- **Endpoint:** `POST /api/mcp/mcp` (Streamable HTTP transport) +- **Auth:** `Authorization: Bearer ` +- **Access:** read-only. No mutations, no decrypted API keys, no password hashes. + +## Setup + +1. Generate a secret (min 16 chars) and set it in the environment: + + ```bash + MCP_SERVER_SECRET="$(openssl rand -base64 32)" + ``` + +2. Deploy. The server is **dormant by default**: when `MCP_SERVER_SECRET` is + unset, every request is rejected with `401` and a one-time warning is logged, + so the feature stays off until a secret is provisioned. + +## Client configuration + +```jsonc +{ + "mcpServers": { + "ai-developer-hub": { + "type": "http", + "url": "https:///api/mcp/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +Test locally with the MCP Inspector: + +```bash +npx @modelcontextprotocol/inspector +# then point it at http://localhost:3000/api/mcp/mcp with the bearer header +``` + +## Tools + +All monetary fields are returned as both integer cents (`*Cents`) and a derived +USD number (`*Usd`). + +| Tool | Input | Description | +| --- | --- | --- | +| `list_ai_tools` | – | Active AI tools and their access tiers with monthly cost. | +| `get_user_cost_profile` | `email`, `month?` (YYYY-MM) | A user's active licenses and Claude API cost breakdown for a month. Looked up by exact email. | +| `get_claude_spend_summary` | `month?` (YYYY-MM) | Org-wide Claude spend KPIs: MTD total, MoM delta, month-end projection, workspaces over 80% of cap, today's estimate. Defaults to current month. | +| `list_claude_workspaces` | – | Anthropic workspaces with current-month spend, cap, utilization %, and today's estimate. | +| `get_budget_status` | `fiscalYear?` | Annual budget: per-period planned/billed/expected/actual and an OLS forecast with on-track / at-risk verdict. Defaults to the active budget. | +| `get_copilot_usage_summary` | `since?`, `until?` (YYYY-MM-DD) | GitHub Copilot seat/billing snapshot and aggregated usage over a range. Defaults to the last 28 days. | +| `list_recent_sync_events` | `sourceType?`, `limit?` | Recent data-pipeline sync events plus Claude-spend data freshness. | + +## Architecture + +- `src/app/api/mcp/[transport]/route.ts` — mounts the server via `mcp-handler` + (`createMcpHandler` + `withMcpAuth`). Thin by design. +- `src/lib/mcp/auth.ts` — shared-secret `verifyMcpToken` (constant-time compare). +- `src/lib/mcp/tools.ts` — registers the tools (Zod input schemas). +- `src/lib/mcp/data.ts` — data assembly; delegates to the existing tested read + layer (`profile-data`, `anthropic/queries`, `actions/budget`, ...). +- `src/lib/mcp/format.ts` — pure helpers (`centsToUsd`, `usd`, result wrappers). + +The route is excluded from the NextAuth middleware matcher (so unauthenticated +clients get a clean `401` instead of a redirect to `/login`) and added to the +nighthawk agent deny-list as defense-in-depth, mirroring `/api/sync`. + +## Security notes + +- Read-only: there are no write/mutation tools. +- `get_user_cost_profile` returns only the same surface as the existing + `/api/profile` route — never decrypted API keys. +- The shared-secret model matches the Hub's other machine-to-machine endpoints + (`PROFILE_API_SECRET`, `CRON_SECRET`). OAuth 2.1 is the documented upgrade + path; `withMcpAuth` keeps that door open without reworking the tools. diff --git a/package.json b/package.json index de6902f5..97d3c90f 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@aws-sdk/client-s3": "^3.1002.0", "@aws-sdk/s3-request-presigner": "^3.1002.0", "@hookform/resolvers": "^5.2.2", + "@modelcontextprotocol/sdk": "1.26.0", "@neondatabase/serverless": "^1.0.2", "@react-email/components": "^1.0.9", "@tanstack/react-table": "^8.21.3", @@ -45,6 +46,7 @@ "dotenv": "^17.3.1", "drizzle-orm": "^0.45.1", "lucide-react": "^0.576.0", + "mcp-handler": "1.1.0", "next": "^15.5.12", "next-auth": "5.0.0-beta.30", "next-themes": "^0.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a7d8a74..2afc83ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@hookform/resolvers': specifier: ^5.2.2 version: 5.2.2(react-hook-form@7.71.2(react@19.2.4)) + '@modelcontextprotocol/sdk': + specifier: 1.26.0 + version: 1.26.0(zod@4.3.6) '@neondatabase/serverless': specifier: ^1.0.2 version: 1.0.2 @@ -56,6 +59,9 @@ importers: lucide-react: specifier: ^0.576.0 version: 0.576.0(react@19.2.4) + mcp-handler: + specifier: 1.1.0 + version: 1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(next@15.5.12(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) next: specifier: ^15.5.12 version: 15.5.12(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1391,8 +1397,8 @@ packages: '@lhci/utils@0.15.1': resolution: {integrity: sha512-WclJnUQJeOMY271JSuaOjCv/aA0pgvuHZS29NFNdIeI14id8eiFsjith85EGKYhljgoQhJ2SiW4PsVfFiakNNw==} - '@modelcontextprotocol/sdk@1.27.1': - resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + '@modelcontextprotocol/sdk@1.26.0': + resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -2216,96 +2222,112 @@ packages: '@react-email/body@0.3.0': resolution: {integrity: sha512-uGo0BOOzjbMUo3lu+BIDWayvn5o6Xyfmnlla5VGf05n8gHMvO1ll7U4FtzWe3hxMLwt53pmc4iE0M+B5slG+Ug==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/button@0.2.1': resolution: {integrity: sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/code-block@0.2.1': resolution: {integrity: sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/code-inline@0.0.6': resolution: {integrity: sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/column@0.0.14': resolution: {integrity: sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/components@1.0.9': resolution: {integrity: sha512-2vi1w423KdjGa9rLUJAq8daTq5xVvB5VHDuI8fRu3/JfqqihzUu5r0bET3qWDw9QpKOIXcZzWO3jN2+yMVtzUw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/container@0.0.16': resolution: {integrity: sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/font@0.0.10': resolution: {integrity: sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/head@0.0.13': resolution: {integrity: sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/heading@0.0.16': resolution: {integrity: sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/hr@0.0.12': resolution: {integrity: sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/html@0.0.12': resolution: {integrity: sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/img@0.0.12': resolution: {integrity: sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/link@0.0.13': resolution: {integrity: sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/markdown@0.0.18': resolution: {integrity: sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/preview@0.0.14': resolution: {integrity: sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc @@ -2319,18 +2341,21 @@ packages: '@react-email/row@0.0.13': resolution: {integrity: sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/section@0.0.17': resolution: {integrity: sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/tailwind@2.0.5': resolution: {integrity: sha512-7Ey+kiWliJdxPMCLYsdDts8ffp4idlP//w4Ui3q/A5kokVaLSNKG8DOg/8qAuzWmRiGwNQVOKBk7PXNlK5W+sg==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: '@react-email/body': 0.2.1 '@react-email/button': 0.2.1 @@ -2369,9 +2394,39 @@ packages: '@react-email/text@0.1.6': resolution: {integrity: sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc + '@redis/bloom@1.2.0': + resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/client@1.6.1': + resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==} + engines: {node: '>=14'} + + '@redis/graph@1.1.1': + resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/json@1.0.7': + resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/search@1.2.0': + resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/time-series@1.1.0': + resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==} + peerDependencies: + '@redis/client': ^1.0.0 + '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} @@ -3423,6 +3478,7 @@ packages: basic-ftp@5.2.0: resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.1, please upgrade bcryptjs@3.0.3: resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} @@ -3566,6 +3622,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + cmdk@1.1.1: resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} peerDependencies: @@ -4459,6 +4519,10 @@ packages: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} + generic-pool@3.9.0: + resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} + engines: {node: '>= 4'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -5159,6 +5223,16 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mcp-handler@1.1.0: + resolution: {integrity: sha512-MVCES7g18gcoZy+R/3v5nadkUMzMAWdos8jRl6DyljOKvd2/ZKDmwlCjL6zp4vo+7FeCXOYL1uWinHWlkKAAUg==} + hasBin: true + peerDependencies: + '@modelcontextprotocol/sdk': 1.26.0 + next: '>=13.0.0' + peerDependenciesMeta: + next: + optional: true + mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} @@ -5829,10 +5903,14 @@ packages: recharts@2.15.4: resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} engines: {node: '>=14'} + deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide peerDependencies: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + redis@4.7.1: + resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -6257,8 +6335,8 @@ packages: third-party-web@0.26.7: resolution: {integrity: sha512-buUzX4sXC4efFX6xg2bw6/eZsCUh8qQwSavC4D9HpONMFlRbcHhD8Je5qwYdCpViR6q0qla2wPP+t91a2vgolg==} - third-party-web@0.29.0: - resolution: {integrity: sha512-nBDSJw5B7Sl1YfsATG2XkW5qgUPODbJhXw++BKygi9w6O/NKS98/uY/nR/DxDq2axEjL6halHW1v+jhm/j1DBQ==} + third-party-web@0.29.2: + resolution: {integrity: sha512-fegtha91tq2DHphyoiBXVHjVi2YG9zFaRnboT9C28tO1en9Y3wJsfspuy40F+u5wl3hHVbw7cnd1b67kEGHb8g==} through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -6501,10 +6579,12 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true validate-npm-package-name@7.0.2: @@ -6734,6 +6814,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yargs-parser@13.1.2: resolution: {integrity: sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==} @@ -8096,7 +8179,7 @@ snapshots: - supports-color - utf-8-validate - '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.9(hono@4.12.3) ajv: 8.18.0 @@ -8118,6 +8201,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.26.0(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.9(hono@4.12.3) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.2.1(express@5.2.1) + hono: 4.12.3 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - supports-color + '@mswjs/interceptors@0.41.3': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -8205,7 +8310,7 @@ snapshots: '@paulirish/trace_engine@0.0.53': dependencies: legacy-javascript: 0.0.1 - third-party-web: 0.29.0 + third-party-web: 0.29.2 '@playwright/test@1.58.2': dependencies: @@ -9097,6 +9202,32 @@ snapshots: dependencies: react: 19.2.4 + '@redis/bloom@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/client@1.6.1': + dependencies: + cluster-key-slot: 1.1.2 + generic-pool: 3.9.0 + yallist: 4.0.0 + + '@redis/graph@1.1.1(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/json@1.0.7(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/search@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/time-series@1.1.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + '@rolldown/pluginutils@1.0.0-rc.3': {} '@rollup/rollup-android-arm-eabi@4.59.0': @@ -10363,6 +10494,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) @@ -11429,6 +11562,8 @@ snapshots: generator-function@2.0.1: {} + generic-pool@3.9.0: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -12121,6 +12256,15 @@ snapshots: math-intrinsics@1.1.0: {} + mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(next@15.5.12(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): + dependencies: + '@modelcontextprotocol/sdk': 1.26.0(zod@4.3.6) + chalk: 5.6.2 + commander: 11.1.0 + redis: 4.7.1 + optionalDependencies: + next: 15.5.12(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + mdn-data@2.12.2: {} media-typer@0.3.0: {} @@ -12836,6 +12980,15 @@ snapshots: tiny-invariant: 1.3.3 victory-vendor: 36.9.2 + redis@4.7.1: + dependencies: + '@redis/bloom': 1.2.0(@redis/client@1.6.1) + '@redis/client': 1.6.1 + '@redis/graph': 1.1.1(@redis/client@1.6.1) + '@redis/json': 1.0.7(@redis/client@1.6.1) + '@redis/search': 1.2.0(@redis/client@1.6.1) + '@redis/time-series': 1.1.0(@redis/client@1.6.1) + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -13092,7 +13245,7 @@ snapshots: '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) '@dotenvx/dotenvx': 1.52.0 - '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76) + '@modelcontextprotocol/sdk': 1.26.0(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 browserslist: 4.28.1 commander: 14.0.3 @@ -13443,7 +13596,7 @@ snapshots: third-party-web@0.26.7: {} - third-party-web@0.29.0: {} + third-party-web@0.29.2: {} through@2.3.8: {} @@ -13910,6 +14063,8 @@ snapshots: yallist@3.1.1: {} + yallist@4.0.0: {} + yargs-parser@13.1.2: dependencies: camelcase: 5.3.1 @@ -13961,6 +14116,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.1(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod@3.25.76: {} zod@4.3.6: {} diff --git a/specs/026-budget-extensions/concept.md b/specs/026-budget-extensions/concept.md new file mode 100644 index 00000000..87004166 --- /dev/null +++ b/specs/026-budget-extensions/concept.md @@ -0,0 +1,148 @@ +# Budget Extensions — Feature Concept + +**Status:** Exploration / concept +**Author:** drafted 2026-05-22 +**Related worktree branch:** `budget-extensions` + +--- + +## 1. Problem + +The annual budget is a single ceiling per fiscal year (`annual_budgets.totalAmountCents`). When mid-year reality changes — a new tool we didn't plan for (e.g. Claude API for engineering), an unexpected seat-count increase, a vendor price bump — the only way to absorb it today is to call `updateBudgetTotal()` and bump the ceiling. That mutation is logged in `change_history` as a generic `updated` row with an old/new number and nothing else. + +What we lose: + +- **Why** the ceiling moved — no reason, justification, or category. +- **Attribution** — extensions for "new Claude tool" and "vendor price increase" look identical in history. +- **Baseline vs. extended** — the original plan disappears. We can no longer answer "how much did we extend the budget this year?" without diffing history rows. +- **Visibility** — the dashboard, budget detail hero, and reports just show a larger number; nothing signals that the number was extended after the plan was approved. + +## 2. Proposed model + +Introduce a first-class **`budget_extensions`** entity. Each row is an additive delta to the annual ceiling with a documented reason and (optional) attribution to a tool. + +```text +budget_extensions + id serial pk + budget_id int fk → annual_budgets.id (cascade delete) + amount_cents int not null -- positive = extension, negative = reduction + reason varchar(120) not null -- short title, e.g. "Add Claude API for engineering" + description text -- longer justification + category enum( -- new enum budget_extension_category + 'new_tool', + 'scope_increase', + 'seat_increase', + 'vendor_price_increase', + 'reallocation', + 'other' + ) not null + linked_tool_id int fk → ai_tools.id -- optional + effective_date date not null -- defaults to today + created_by int fk → users.id + created_at timestamp not null + updated_at timestamp not null +``` + +**Derivation rule:** + +```text +effectiveCeiling(budget) = budget.totalAmountCents + Σ extension.amountCents +``` + +`annual_budgets.totalAmountCents` keeps its current meaning as the **original baseline** — the number set at budget creation. The effective ceiling is computed. This keeps the "what did we plan" vs "what did we end up at" question answerable forever. + +> Alternative considered: store an `original_amount_cents` column and keep `totalAmountCents` as the live effective total. Rejected because it forces every existing read site to switch which column it reads; the proposed model leaves the existing column meaningful and adds derivation on top. + +**Migration of existing data:** trivial. `totalAmountCents` is already the original for any budget that has never been touched. Budgets that were already extended via `updateBudgetTotal()` keep their current value as the "baseline" — historical fidelity is lost for those, but going forward all extensions are tracked. + +**Validators (`src/lib/validators.ts`):** + +```ts +budgetExtensionSchema = { + budgetId: number positive, + amountCents: number int (non-zero), + reason: string min 3 max 120, + description: string optional max 2000, + category: enum, + linkedToolId: number positive optional, + effectiveDate: date, +} +``` + +**Server actions (`src/actions/budget-extensions.ts`):** + +- `createBudgetExtension(input)` — insert row, record in `change_history` with `entityType="budget_extension"`. +- `updateBudgetExtension(input)` — edit reason/description/category/linkedTool/effectiveDate only. Amount edits go through a new extension (audit-clean). +- `deleteBudgetExtension(id)` — only allowed if budget is still `active`. Snapshot full row into change history. +- `getBudgetExtensions(budgetId)` — list ordered by `effectiveDate desc`. + +## 3. Allocation behavior + +When an extension is created, the dialog asks **how to allocate** the delta across periods. Four options: + +1. **Leave unallocated** — the ceiling rises; planned-YTD numbers don't move. Existing "unallocated" tile in the hero surfaces the headroom. +2. **Distribute across remaining periods** — split evenly across periods whose `endDate >= effectiveDate`. +3. **Add to a single period** — pick one period (useful for one-off costs). +4. **Distribute custom** — opens the existing allocation editor pre-loaded with the delta. + +Allocation is a separate concern from the extension record. The extension is the audit trail; the allocation update is just a regular `updateBudgetAllocations` call. Both are wrapped in a single transaction in `createBudgetExtension`. + +## 4. Where it surfaces in the UI + +| Surface | Today | With extensions | +|---|---|---| +| Budget detail hero | "Annual ceiling: €50,000" | "Annual ceiling: €60,000" + small tag `€50k baseline + €10k extended` linking to the extensions list | +| Budget detail page | No extension section | New "Budget extensions" card listing each extension with reason, category badge, linked tool, amount, who/when. Admin: "Add extension" CTA | +| Period allocation table | Planned column shows current planned | When a period has been bumped by an extension, sub-label under planned: `+€2,500 from extension` | +| Dashboard budget hero | Just the ceiling | Adds a subtle "extended +€10k" inline tag | +| Reports → Budget Report | Forecast chart vs ceiling | Optional dashed line at the original baseline; main ceiling line uses the effective number | +| Budget history page | Lists archived/active budgets | Adds an "Extensions" count column | +| Change history feed | One row per total bump | Extension creates a structured row (entity = `budget_extension`); allocation distribution shows as separate `budget_period` updates | + +## 5. Workflow + +For this app's user base (one or a few admins, no separate approver) the workflow is just **direct create** — no `pending`/`approved` states. The schema deliberately omits a status enum to keep things simple; if a multi-stakeholder approval workflow is ever needed it can be added later without a destructive migration. + +Permission model: extension create/edit/delete restricted to `role = admin`, same gate as the budget itself. Read access matches existing budget read access (admin + finance roles, per current `AuthGuard` rules). + +## 6. Edge cases & rules + +- **Archived budgets are immutable** — same rule as today. Cannot add/edit/delete extensions once `budget.status = "archived"`. Past extensions stay visible (read-only). +- **Negative extensions** — allowed (a "reduction"). UI shows them with destructive badge. Same audit trail. +- **Effective date in the past** — allowed; doesn't move period boundaries. Some admins backfill the documentation of a bump that was already made. +- **Effective date after fiscal year end** — disallowed. +- **Reducing below current commitments** — if `effectiveCeiling < totalAllocations`, block save and show "would over-allocate by €X" — same guard as `updateBudgetTotal`. +- **Linked tool optional** — not all extensions map to a tool (e.g. "seat increase across portfolio"). Category is mandatory; tool link is the fine-grained attribution. + +## 7. Open questions + +- Should `updateBudgetTotal()` be deprecated in favor of "create extension"? Recommendation: **yes**, eventually. Phase 1 keeps both working. Phase 2 the budget edit dialog only offers "add extension" and the raw total edit is removed from the UI. Existing API stays. +- Should the dashboard be louder about extensions (banner) or quieter (tag)? Recommendation: **quieter** — extensions are normal business, not an incident. Loud only if reductions are involved. +- Should we surface extensions on the **Tools** detail page (e.g. "this tool received €10k in extensions this year")? Out of scope for v1, but the `linked_tool_id` makes it trivial later. + +## 8. Files this would touch (rough scope) + +- `src/lib/db/schema.ts` — add table + enum +- `src/lib/db/migrations/` — new migration +- `src/lib/validators.ts` — new schemas +- `src/actions/budget-extensions.ts` — new file +- `src/actions/budget.ts` — `getBudgetWithCosts` augments periods with extension info; helpers for effective ceiling +- `src/app/budget/components/budget-health-hero.tsx` — show baseline + extended +- `src/app/budget/components/budget-detail-client.tsx` — render extensions card, open dialog +- `src/app/budget/components/budget-extensions-card.tsx` — **new** +- `src/app/budget/components/dialogs/add-extension-dialog.tsx` — **new** +- `src/app/budget/components/period-allocations-table.tsx` — show "+ from extension" sub-label +- `src/components/dashboard/admin/budget-hero-section.tsx` — small inline tag +- `src/components/reports/budget/*` — baseline reference line on chart + +## 9. Visual mockups + +See `mockup.html` in this folder. Open it in a browser; it contains five views laid out vertically: + +1. Budget detail page with extensions section +2. Add extension dialog +3. Dashboard budget widget with extension tag +4. Reports forecast with baseline reference line +5. Change history feed showing extension entries + +The mockups use the exact `oklch()` theme tokens from `src/app/globals.css` and the Inter typeface, and include a light/dark toggle. diff --git a/specs/026-budget-extensions/implementation-notes.html b/specs/026-budget-extensions/implementation-notes.html new file mode 100644 index 00000000..595c1f6a --- /dev/null +++ b/specs/026-budget-extensions/implementation-notes.html @@ -0,0 +1,330 @@ + + + + + Budget Extensions — Implementation Notes + + + + + + + + +
+
📓 Budget Extensions · Implementation Notes
+
Updated as work progresses
+ +
+ +
+ +
+

Implementation notes

+

+ Companion to implementation-plan.html. Captures decisions, deviations, tradeoffs, and open questions as the plan is executed. + Newest entries at the top. +

+
+ + +
+ +
+
+ Decision +
Upgrade pass · 2026-06-10 · Claude (Fable 5)
+
+
+ Migration timestamp bump — merge-blocking discovery. + The unmerged spec-034 branch (claude/injection-types-distinction-Xcf9K) applied its own + 0023_perfect_runaways migration directly to the production Neon main branch on 2026-06-03, + with journal when = 1780475418076. Drizzle's migrator only applies local journal entries whose + when is greater than the DB's last applied created_at — so our + 0023_white_gauntlet (when = 2026-05-22) was silently skipped: "migrations applied successfully" + with no DDL executed. Reproduced and verified on a fresh wt/budget-026 Neon branch forked from production. + Fix: bumped the journal when to 1781080218076 (2026-06-10). Checked all paths: + production applies ours and (already having theirs) skips theirs; fresh DBs apply both in journal order. When 034 merges it must + renumber to 0024 — its original when stays older than ours, which is correct since production already has it. + Side flag for the team: production schema currently contains an enum/column from an unmerged branch — that drift predates this PR. +
+
+ +
+
+ Decision +
Upgrade pass · 2026-06-10 · Claude (Fable 5)
+
+
+ Removed updateBudgetTotal + its schema (concept doc "Open question Q2" resolved). + After the Nothing redesign (#108) the action had no UI caller and no test caller left — yet it remained a reachable + server action that mutated totalAmountCents without touching originalAmountCents, + i.e. the one remaining way to silently break the total = original + Σ extensions invariant the hero's + "baseline + extended" tag depends on. Ceiling changes now go exclusively through create/delete extension, which keep the + invariant and carry a reason. If a deliberate re-baseline is ever needed, add an explicit action that shifts + both columns by the same delta — never resurrect the raw setter. +
+
+ +
+
+ Deviation +
Upgrade pass · 2026-06-10 · Claude (Fable 5)
+
+
+ Nothing design migration (spec 028 landed on main after this PR was written). +
    +
  • Toasts are gone by design. The redesign removed sonner; extension handlers now use the + StatusText/useInlineStatus idiom. Two channels: submit/delete errors + render inside the open dialog footer (a page-level status would sit behind the modal overlay — a flaw the billed-cost + dialogs on main still have); success renders in the extensions card header after the dialog closes + ([EXTENSION ADDED] / [EXTENSION DELETED]).
  • +
  • Tags are border-only, never filled. The hero's "extended/reduced" link was a filled + bg-accent pill — now a Badge asChild outline pill. Category/tool/count + badges inherit mono-caps from the redesigned Badge primitive (dropped the ad-hoc text-[10px] uppercase).
  • +
  • Monochrome value colors. Positive extension amounts read as ink (text-ink); + red (text-destructive) is reserved for reductions. Applied in the card, history column, and period sub-labels.
  • +
  • Borders over fills. The dialog's "Effect on FY budget" preview panel and the allocation radio cards are + border-separated (border-input / selected = border-ink) instead of filled surfaces; + the panel label is Space Mono caps.
  • +
  • Card adopts the redesigned CardHeader/CardTitle/CardDescription structure; dashed-border empty + state matches the redesigned ingestion screens.
  • +
+ Verification screenshots verify-01…08 replaced wholesale — the old set showed the pre-redesign UI. + New set covers dark + light, the full create→delete round-trip in the browser, dashboard badge, history column, and the + forecast chart's dashed original-baseline reference line. +
+
+ +
+
+ Decision +
Upgrade pass · 2026-06-10 · Claude (Fable 5)
+
+
+ Integration tests now run on the unpooled endpoint (vitest.config.integration.mts + rewrites DATABASE_URL from DATABASE_URL_UNPOOLED). + Root cause of a persistent flaky failure: syncInvoices guards with a session-scoped + pg_try_advisory_lock; through the pooled endpoint the lock and unlock can land on different + pooler backends, the unlock silently fails, and the leaked lock makes every later sync return "Another sync is already + in progress" — across test runs, until the backend dies. Observed live (lock row granted to a stale pid). Note the same + hazard exists at runtime in production, which also uses the pooled URL; it self-heals only because Neon suspends the + compute. Worth a follow-up: move the advisory lock to a transaction-scoped pg_try_advisory_xact_lock + inside a single transaction, which is pooler-safe. +
+
+ +
+
+ Decision +
Upgrade pass · 2026-06-10 · Claude (Fable 5)
+
+
+ Follow-up closures from the "Open" list below: +
    +
  • Closed: updateBudgetTotal invariant (action removed — see entry above).
  • +
  • Closed: RSC payload leak in getBudgetWithCosts — joined + linkedTool/creator objects are now destructured out.
  • +
  • Closed: asymmetric Drizzle relations — users and aiTools + now declare inverse many(budgetExtensions).
  • +
  • Still open (unchanged, deliberate): TOCTOU concurrency model (codebase-wide), + recordCreation outside the tx (codebase convention), + linkedToolId ON DELETE SET NULL, no edit-extension UI + (updateBudgetExtension action kept for a future dialog).
  • +
+ Merge mechanics: only textual conflict with main was src/lib/forecast.ts (prettier reformat vs + originalCeilingCents plumbing — both kept). Migration numbering vs main unchanged (main still ends + at 0022). Verified: typecheck, lint, 484 unit tests, 25 integration tests (two consecutive runs) on Neon branch + wt/budget-026. +
+
+ +
+
+ Open +
Code review · follow-ups
+
+
+ Findings the code-review surfaced that I'm deferring: +
    +
  • TOCTOU on totalAmountCents / plannedAmountCents. createBudgetExtension reads outside the transaction and writes either an absolute value (ceiling) or relative arithmetic (periods). Two concurrent extensions on the same budget can produce a ceiling that disagrees with the sum of extension rows, or drive a period planned amount negative. Single-admin app today; no SELECT FOR UPDATE added. Same shape exists in the existing updateBudgetAllocations and updateBudgetTotal actions, so this is a codebase-wide concurrency model, not a new regression.
  • +
  • updateBudgetTotal still mutates totalAmountCents directly without touching originalAmountCents. After it runs on an already-extended budget, the hero's "baseline + extended" math is misleading. Per the concept doc / plan section "Open questions Q2", this action is kept working in v1 and a follow-up is supposed to either remove it from the UI or convert each call into a synthetic "other" extension. Filed as follow-up; not blocking.
  • +
  • updateBudgetExtension action has no UI caller yet. Schema + action + tests are in place; the edit dialog is intentionally out of scope for v1 (the plan calls amount/effectiveDate immutable and pushes other edits through delete-and-recreate). Keeping the action so a future edit-extension dialog has a stable contract.
  • +
  • recordCreation outside the transaction. If the audit-insert fails after the tx commits, the extension persists with no history row. This matches the existing createBudget pattern verbatim — changing it would diverge from a convention the whole codebase relies on. Future refactor: thread recordCreation's optional txClient param through.
  • +
  • getBudgetWithCosts spreads {...e} which leaks linkedTool: {name} / creator: {name} into the RSC payload alongside the flattened linkedToolName / createdByName. Bytes wasted but no functional bug; type contract is the source of truth. Cheap to fix later by destructuring explicitly.
  • +
  • linkedToolId uses ON DELETE SET NULL. Deleting an AI tool silently strips attribution from historical extensions — including on archived budgets the action layer treats as immutable. Deliberate: deleting a tool is rare and a tighter constraint would prevent it entirely. If the audit trail of "which tool funded this" becomes load-bearing, denormalize tool_name onto the extension row at create time.
  • +
  • Drizzle relations asymmetricaiTools and users don't declare inverse many(budgetExtensions). Forward queries work; reverse queries would fail. No current call site needs reverse, so this is latent. Cheap fix in a future PR.
  • +
+
+
+ +
+
+ Deviation +
Code review · fixes
+
+
+ Six issues the code-review surfaced that I fixed before merge: +
    +
  1. Stale allocations state — added a useEffect keyed on budget.updatedAt in budget-detail-client.tsx. Without it, creating an extension would bump plannedAmountCents server-side but the local input would still show the old value, and the next "Save Allocations" click would silently write the pre-extension values back. This is the most impactful finding from the review.
  2. +
  3. Hero "+ −$X" rendering for net reductions — the hard-coded "+" between baseline and the variance badge produced "$X baseline + -$Y extended" for reductions. Now conditionally renders "+ extended" or "− reduced" with formatCurrency(Math.abs(…)).
  4. +
  5. deleteBudgetExtension missing negative-planned guard — symmetric with create. Added a pre-tx check that refuses the reversal when any affected period would go below zero (which happens if the user manually lowered planned via updateBudgetAllocations after the extension was created).
  6. +
  7. Single-period picker offered closed periods — now disables periods whose endDate is before today, with a "(closed)" hint. Kept the items visible but disabled so backfill workflows aren't blocked.
  8. +
  9. Amount input parsing — rewrote extensionFormToActionInput to (a) strip thousands separators, (b) reject scientific notation / leading "+" / stray characters via a strict regex, and (c) cap at $20M per extension so users get a clear error instead of an INT32 overflow at the DB.
  10. +
  11. Zod calendar-date validation — added a .refine() to createBudgetExtensionSchema.effectiveDate that round-trips through Date, so "2026-13-99" / "2026-02-30" now fail with a clean validation error instead of a raw Postgres 22008.
  12. +
+
+
+ +
+
+ Tradeoff +
Phase 2
+
+
+ updateBudgetExtension deliberately cannot edit amountCents, effectiveDate, or the allocation breakdown. The plan called those out as immutable to keep the audit trail clean — re-doing an extension goes through delete + create. The validator's updateBudgetExtensionSchema only accepts reason, description, category, linkedToolId. If you later want amount edits, the cleanest path is to add a separate "adjust extension" action that creates a new extension equal to the delta — never a partial update — so each row stays a single moment in the audit trail. +
+
+ +
+
+ Decision +
Phase 2
+
+
+ Distribute-remaining rounding goes to the first period. When the user picks distribute_remaining and the amount doesn't divide evenly across the target periods, the integer remainder is added to the first period in the target set. Keeps the sum exact (so the join-row totals equal amountCents) without introducing fractional cents. Alternatives considered: spread one extra cent across the first N periods until exhausted (more uniform but harder to explain); store the original per-period amount as a fraction (defeats the "money is integer cents" rule). The single-bucket dump is simplest and the variance is at most ~$0.11 in the worst case (12 periods). +
+
+ +
+
+ Decision +
Phase 2
+
+
+ "Distribute remaining" falls back to all periods when effectiveDate is before every period's end. A backdated extension covering the whole year would otherwise hit "no remaining periods" and fail. Falling back to all periods matches user intent — "I'm documenting a year-round bump." Documented in the action; tested implicitly. +
+
+ +
+
+ Deviation +
Phase 1
+
+
+ Storage strategy: the plan committed to "mutate totalAmountCents live and store originalAmountCents alongside" — chosen so the ~8 existing read sites (hero, period table, forecast, dashboard, reports) keep working unchanged. The concept doc's original semantics (effective ceiling derived from a separate extensions table) are equivalent; this is purely a representation choice. The budget_extensions table and the budget_extension_period_allocations join still exist as the source of truth for "why did the ceiling move" and for cleanly reversing on delete. +
+
+ +
+
+ Decision +
Phase 1
+
+
+ Added two indexes beyond what the plan listedbudget_extensions_linked_tool_idx and budget_extensions_created_by_idx. The drizzle-migration-reviewer subagent flagged the rest of the schema indexes every FK; these two were the only un-indexed FKs in the new tables. Cheap insurance against lock escalation on the rare event a tool or user row is removed, and aligns with the codebase pattern. +
+
+ +
+
+ Deviation +
Phase 1
+
+
+ Backfill is in the same migration as the column add. The plan said "after pnpm db:generate, hand-edit the SQL so the order is ADD (nullable) → UPDATE backfill → SET NOT NULL" — that's what I did. Drizzle generated ADD COLUMN ... NOT NULL which would fail on a non-empty table. The hand-edit splits it into the three-step pattern; 0022_far_aaron_stack.sql shows it explicitly with a comment. +
+
+ +
+
+ Tradeoff +
Phase 1
+
+
+ Made ForecastOptions.originalCeilingCents optional with fallback to budgetCeilingCents. The alternative was to update the 13 unit-test cases in tests/unit/forecast.test.ts to pass it explicitly. Optional-with-fallback is semantically correct (un-extended budgets do have original == effective) and keeps the test surface narrow. Production code paths still pass it explicitly via buildBudgetForecast. +
+
+ +
+
+ Decision +
Phase 0 · prep
+
+
+ Notes file initialized. The plan calls out five "phases" inside a single PR. I'll treat each phase as a commit checkpoint and record notes against the phase it relates to. If a note straddles phases, I'll mention all relevant ones. +
+
+ +
+ +
+ + + + diff --git a/specs/026-budget-extensions/implementation-plan.html b/specs/026-budget-extensions/implementation-plan.html new file mode 100644 index 00000000..e5a3da7f --- /dev/null +++ b/specs/026-budget-extensions/implementation-plan.html @@ -0,0 +1,1298 @@ + + + + + Budget Extensions — Implementation Plan + + + + + + + + + + +
+
+ 📋 + Implementation Plan · Budget Extensions +
+ +
+ +
+ + +
+
+ +

Budget Extensions

+

+ Make every mid-year budget ceiling change a first-class, audit-friendly record — with reason, category, optional tool link, and visibility wherever the budget appears. + Drives off the agreed concept in specs/026-budget-extensions/concept.md and the visual design in mockup.html. +

+
+ + +
+
+
Effort estimate
+
~4–6 days
+
One engineer, sequential. Everything ships as a single PR.
+
+
+
Surface area
+
1 new table · 1 join · 1 enum
+
+ 1 new column on annual_budgets, 0 destructive changes.
+
+
+
Delivery
+
1 PR · 5 phases
+
Phases are work-order milestones inside the same PR — Schema → Actions → Detail UI → Period table → Cross-surface polish.
+
+
+ + +
+
+ single PR +
+ Delivery model: phases below describe the build order and the natural review checkpoints, but the whole feature merges as one pull request. Reviewers should still walk the diff phase-by-phase — commit boundaries follow the phase boundaries, so a phase-aligned diff (e.g. git diff phase-1-tip..phase-2-tip) is easy to produce locally. Each phase still has its own acceptance criteria; all must pass before the PR is ready. +
+
+
+ + +
+

Key design decisions (locked before build)

+ +
+
+ +
Mutate totalAmountCents on extension create, and add a new originalAmountCents column for the baseline. + Pragmatic: every existing read site (hero, table, forecast, dashboard, reports) keeps working unchanged. Only the new "baseline + extended" tag reads the new column.
+
+
+ +
Per-period allocation tracking via a join table. Each extension can attribute its delta to specific periods, which powers the "+€X from extension" sub-label and lets a delete cleanly reverse its effect.
+
+
+ +
No approval workflow in v1. Single-admin app; direct create only. Schema leaves room for a future status enum.
+
+
+ +
Reuse existing change_history infra with entityType = "budget_extension". Mirrors how "billed_cost" is wired.
+
+
+ +
Plain controlled inputs for the dialog (no react-hook-form). Matches BilledCostDialog convention; form state lifted to budget-detail-client.tsx.
+
+
+ +
Archived budgets stay immutable. Same guard as updateBudgetTotal: refuse extension create/edit/delete on archived budgets; past extensions remain visible read-only.
+
+
+ +
Reductions (negative extensions) allowed, rendered with destructive styling. Validation refuses a net effective ceiling below current commitments.
+
+
+
+ + +
+

Phase timeline

+
Each cell ≈ ½ day. Phases run serially within the single PR; later phases depend on earlier schema/types. Use the phase boundaries as commit checkpoints.
+
+
+
1 Schema & data
+
+
+
+
+
+
+
~1 day
+
+
+
2 Server actions
+
+
+
+
+
+
+
~1 day
+
+
+
3 Detail UI
+
+
+
+
+
+
~1.5 days
+
+
+
4 Period table
+
+
+
+
+
+
~½ day
+
+
+
5 Cross-surface
+
+
+
+
+
+
~1 day
+
+
+
+
+ + + + +
+
+
+ 1 +

Schema & data layer

+
+ ~1 day +
+

+ Add the new tables, enum, and column. Auto-generate the Drizzle migration. Backfill originalAmountCents for existing budgets. Update shared types so downstream phases have a typed surface to work against. +

+ + +
+

Files changed

+
+ editsrc/lib/db/schema.ts + newsrc/lib/db/migrations/0022_*.sql + newsrc/lib/db/migrations/meta/0022_snapshot.json + editsrc/types/index.ts +
+
+ + +
+

1.1 — src/lib/db/schema.ts

+

Add one enum, one column on annual_budgets, and two new tables. Mirrors the existing style — pgEnum, pgTable, FK with { onDelete: "cascade" }.

+ +
// after periodTypeEnum, line ~31
+export const budgetExtensionCategoryEnum = pgEnum("budget_extension_category", [
+  "new_tool",
+  "scope_increase",
+  "seat_increase",
+  "vendor_price_increase",
+  "reallocation",
+  "other",
+]);
+
+// modify annualBudgets — add originalAmountCents column
+export const annualBudgets = pgTable("annual_budgets", {
+  id: serial("id").primaryKey(),
+  fiscalYear: integer("fiscal_year").notNull(),
+  totalAmountCents: integer("total_amount_cents").notNull(),
+  // NEW — the originally approved ceiling, never mutated after creation.
+  // Backfilled to current totalAmountCents for existing rows.
+  originalAmountCents: integer("original_amount_cents").notNull(),
+  periodType: periodTypeEnum("period_type").notNull(),
+  status: budgetStatusEnum("status").notNull().default("active"),
+  createdAt: timestamp("created_at").notNull().defaultNow(),
+  updatedAt: timestamp("updated_at").notNull().defaultNow(),
+}, ...);
+
+// NEW table — one row per extension event
+export const budgetExtensions = pgTable("budget_extensions", {
+  id: serial("id").primaryKey(),
+  budgetId: integer("budget_id").notNull()
+    .references(() => annualBudgets.id, { onDelete: "cascade" }),
+  amountCents: integer("amount_cents").notNull(),  // non-zero; negative = reduction
+  reason: varchar("reason", { length: 120 }).notNull(),
+  description: text("description"),
+  category: budgetExtensionCategoryEnum("category").notNull(),
+  linkedToolId: integer("linked_tool_id")
+    .references(() => aiTools.id, { onDelete: "set null" }),
+  effectiveDate: date("effective_date").notNull(),
+  createdBy: integer("created_by").notNull()
+    .references(() => users.id, { onDelete: "restrict" }),
+  createdAt: timestamp("created_at").notNull().defaultNow(),
+  updatedAt: timestamp("updated_at").notNull().defaultNow(),
+}, (t) => [
+  index("budget_extensions_budget_idx").on(t.budgetId),
+  index("budget_extensions_effective_idx").on(t.effectiveDate),
+]);
+
+// NEW join table — how much of an extension landed in each period
+export const budgetExtensionPeriodAllocations = pgTable(
+  "budget_extension_period_allocations",
+  {
+    id: serial("id").primaryKey(),
+    extensionId: integer("extension_id").notNull()
+      .references(() => budgetExtensions.id, { onDelete: "cascade" }),
+    periodId: integer("period_id").notNull()
+      .references(() => budgetPeriods.id, { onDelete: "cascade" }),
+    amountCents: integer("amount_cents").notNull(),
+    createdAt: timestamp("created_at").notNull().defaultNow(),
+  },
+  (t) => [
+    uniqueIndex("bepa_unique_ext_period").on(t.extensionId, t.periodId),
+    index("bepa_period_idx").on(t.periodId),
+  ]
+);
+
+
+ + +
+

1.2 — Generate migration

+

Drizzle auto-names the file (e.g. 0022_lovely_silver_surfer.sql) and emits a snapshot under migrations/meta/. Verify the generated SQL adds the column as NOT NULL with the backfill — Drizzle's generator may need a hand-edit to land the backfill before the constraint.

+
pnpm db:generate
+# Inspect the new file. Expected operations:
+#   CREATE TYPE budget_extension_category AS ENUM (...);
+#   ALTER TABLE annual_budgets ADD COLUMN original_amount_cents int;
+#   UPDATE annual_budgets SET original_amount_cents = total_amount_cents;
+#   ALTER TABLE annual_budgets ALTER COLUMN original_amount_cents SET NOT NULL;
+#   CREATE TABLE budget_extensions (...);
+#   CREATE TABLE budget_extension_period_allocations (...);
+#   CREATE INDEX ...;
+
+# If Drizzle generated the ALTER without the backfill, edit the .sql
+# to slot the UPDATE between the ADD COLUMN and the SET NOT NULL.
+
+# Run drizzle-migration-reviewer subagent on the result before merging.
+pnpm db:migrate
+
+
+ Migration safety: non-destructive — only ADD COLUMN / CREATE TABLE / CREATE INDEX / CREATE TYPE. Safe under concurrent writes. The backfill is one short UPDATE against a tiny table (1 row per fiscal year). +
+
+ + +
+

1.3 — src/types/index.ts

+

Add inferred types and extend BudgetWithCosts / PeriodWithCosts so phases 3 and 4 can render typed data.

+
export type BudgetExtension = typeof budgetExtensions.$inferSelect;
+export type BudgetExtensionPeriodAllocation =
+  typeof budgetExtensionPeriodAllocations.$inferSelect;
+
+export interface BudgetExtensionWithAllocations extends BudgetExtension {
+  allocations: BudgetExtensionPeriodAllocation[];
+  linkedToolName: string | null;
+  createdByName: string;
+}
+
+export interface PeriodWithCosts extends BudgetPeriod {
+  billedCosts: BilledCost[];
+  billedTotalCents: number;
+  expectedSpendCents: number;
+  // NEW — sum of allocations from extensions landing in this period
+  extensionAmountCents: number;
+}
+
+export interface BudgetWithCosts extends AnnualBudget {
+  periods: PeriodWithCosts[];
+  // NEW — ordered effective-date desc
+  extensions: BudgetExtensionWithAllocations[];
+}
+
+
+ + +
+

Acceptance criteria

+
    +
  • pnpm db:push against a fresh DB produces the new schema with no errors
  • +
  • pnpm db:migrate against a copy of prod data succeeds; every existing annual_budgets row has original_amount_cents = total_amount_cents
  • +
  • pnpm typecheck passes — the new types are wired into src/types/index.ts but no existing code reads .extensions yet (defaults to never-accessed)
  • +
  • drizzle-migration-reviewer subagent gives an OK on the generated SQL
  • +
+
+
+ + + + +
+
+
+ 2 +

Server actions & validators

+
+ ~1 day +
+

+ All business logic — create, edit, delete extensions. Mirrors createBilledCost / archiveBudget patterns: requireAdminsafeParse → guards → transaction → history → revalidatePath. +

+ +
+

Files changed

+
+ editsrc/lib/validators.ts + newsrc/actions/budget-extensions.ts + editsrc/actions/budget.ts + newtests/integration/budget-extensions.test.ts +
+
+ + +
+

2.1 — src/lib/validators.ts

+

Three Zod schemas, sitting next to the existing budgetSchema / updateBudgetTotalSchema. Same style — no client-imports, plain z.object.

+
export const budgetExtensionAllocationModeSchema = z.discriminatedUnion("mode", [
+  z.object({ mode: z.literal("unallocated") }),
+  z.object({ mode: z.literal("distribute_remaining") }),
+  z.object({
+    mode: z.literal("single_period"),
+    periodId: z.number().int().positive(),
+  }),
+  z.object({
+    mode: z.literal("custom"),
+    allocations: z.array(z.object({
+      periodId: z.number().int().positive(),
+      amountCents: z.number().int(),
+    })).min(1),
+  }),
+]);
+
+export const createBudgetExtensionSchema = z.object({
+  budgetId: z.number().int().positive(),
+  amountCents: z.number().int().refine(
+    (n) => n !== 0,
+    { message: "Amount must be non-zero" }
+  ),
+  reason: z.string().trim().min(3).max(120),
+  description: z.string().trim().max(2000).optional(),
+  category: z.enum([
+    "new_tool", "scope_increase", "seat_increase",
+    "vendor_price_increase", "reallocation", "other",
+  ]),
+  linkedToolId: z.number().int().positive().optional(),
+  effectiveDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
+  allocation: budgetExtensionAllocationModeSchema,
+});
+
+export const updateBudgetExtensionSchema = z.object({
+  extensionId: z.number().int().positive(),
+  reason: z.string().trim().min(3).max(120).optional(),
+  description: z.string().trim().max(2000).optional(),
+  category: z.enum([/* same as above */]).optional(),
+  linkedToolId: z.number().int().positive().nullable().optional(),
+  // effectiveDate & amount are deliberately immutable — edit those by
+  // deleting and re-creating, for a clean audit trail.
+});
+
+export const deleteBudgetExtensionSchema = z.object({
+  extensionId: z.number().int().positive(),
+});
+
+
+ + +
+

2.2 — src/actions/budget-extensions.ts new file

+

Five exported functions. Pattern lifted verbatim from src/actions/budget.ts — same auth check, same validation, same transaction style, same revalidation set.

+
"use server";
+
+// ───────────────── createBudgetExtension ─────────────────
+export async function createBudgetExtension(
+  input: unknown
+): Promise<ActionResult<{ id: number }>> {
+  const admin = await requireAdmin();
+  if (!admin) return { success: false, error: "Unauthorized" };
+
+  const parsed = createBudgetExtensionSchema.safeParse(input);
+  if (!parsed.success) {
+    return { success: false, error: "Validation failed",
+             fieldErrors: parsed.error.flatten().fieldErrors };
+  }
+  const data = parsed.data;
+
+  const budget = await getBudgetWithCosts(data.budgetId);
+  if (!budget) return { success: false, error: "Budget not found" };
+  if (budget.status === "archived")
+    return { success: false, error: "Archived budgets cannot be modified" };
+
+  // Effective-date must be within the fiscal year
+  const fy = budget.fiscalYear;
+  if (!data.effectiveDate.startsWith(`${fy}-`))
+    return { success: false, error: "Effective date must fall within the fiscal year" };
+
+  // Compute per-period allocations from the chosen mode
+  const perPeriod = resolveAllocations(budget, data.amountCents, data.allocation);
+  if (perPeriod.error) return { success: false, error: perPeriod.error };
+
+  // Guard: total allocations must remain ≤ effective ceiling after this change
+  const newCeiling = budget.totalAmountCents + data.amountCents;
+  const newAllocTotal = budget.periods.reduce(
+    (sum, p) => sum + p.plannedAmountCents + (perPeriod.byPeriodId[p.id] ?? 0),
+    0
+  );
+  if (newAllocTotal > newCeiling)
+    return { success: false, error: "Allocations would exceed new ceiling" };
+  if (newCeiling < 0)
+    return { success: false, error: "Ceiling cannot go negative" };
+
+  let extensionId: number;
+  await db.transaction(async (tx) => {
+    // 1. Insert the extension row
+    const [ext] = await tx.insert(budgetExtensions)
+      .values({
+        budgetId: data.budgetId,
+        amountCents: data.amountCents,
+        reason: data.reason,
+        description: data.description ?? null,
+        category: data.category,
+        linkedToolId: data.linkedToolId ?? null,
+        effectiveDate: data.effectiveDate,
+        createdBy: Number(admin.id),
+      })
+      .returning({ id: budgetExtensions.id });
+    extensionId = ext.id;
+
+    // 2. Bump the live ceiling
+    await tx.update(annualBudgets)
+      .set({ totalAmountCents: newCeiling, updatedAt: new Date() })
+      .where(eq(annualBudgets.id, data.budgetId));
+
+    // 3. Write the per-period allocation rows + bump plannedAmountCents
+    for (const [periodIdStr, amt] of Object.entries(perPeriod.byPeriodId)) {
+      const periodId = Number(periodIdStr);
+      if (amt === 0) continue;
+      await tx.insert(budgetExtensionPeriodAllocations)
+        .values({ extensionId: ext.id, periodId, amountCents: amt });
+      await tx.update(budgetPeriods)
+        .set({
+          plannedAmountCents: sql`${budgetPeriods.plannedAmountCents} + ${amt}`,
+          updatedAt: new Date(),
+        })
+        .where(eq(budgetPeriods.id, periodId));
+    }
+  });
+
+  // 4. History (outside the tx, matching the createBudget pattern)
+  await recordCreation("budget_extension", extensionId!, Number(admin.id));
+
+  revalidatePath("/");                              // dashboard widget
+  revalidatePath("/budget");
+  revalidatePath(`/budget/${data.budgetId}`);
+  revalidatePath("/reports");
+  revalidatePath("/reports/budget");
+
+  return { success: true, data: { id: extensionId! } };
+}
+
+ +
+ ▸ deleteBudgetExtension (similar structure, reversed) — click to expand +
export async function deleteBudgetExtension(
+  input: unknown
+): Promise<ActionResult> {
+  const admin = await requireAdmin();
+  if (!admin) return { success: false, error: "Unauthorized" };
+
+  const parsed = deleteBudgetExtensionSchema.safeParse(input);
+  if (!parsed.success) return { success: false, error: "Validation failed" };
+
+  const ext = await db.query.budgetExtensions.findFirst({
+    where: eq(budgetExtensions.id, parsed.data.extensionId),
+    with: { allocations: true, budget: true },
+  });
+  if (!ext) return { success: false, error: "Extension not found" };
+  if (ext.budget.status === "archived")
+    return { success: false, error: "Archived budgets cannot be modified" };
+
+  await db.transaction(async (tx) => {
+    for (const alloc of ext.allocations) {
+      await tx.update(budgetPeriods)
+        .set({
+          plannedAmountCents: sql`${budgetPeriods.plannedAmountCents} - ${alloc.amountCents}`,
+        })
+        .where(eq(budgetPeriods.id, alloc.periodId));
+    }
+    await tx.update(annualBudgets)
+      .set({
+        totalAmountCents: sql`${annualBudgets.totalAmountCents} - ${ext.amountCents}`,
+        updatedAt: new Date(),
+      })
+      .where(eq(annualBudgets.id, ext.budgetId));
+    // cascade deletes the allocation rows for us
+    await tx.delete(budgetExtensions).where(eq(budgetExtensions.id, ext.id));
+  });
+
+  await recordStatusChange("budget_extension", ext.id,
+    Number(admin.id), "active", "deleted");
+  // revalidate the same 5 paths as create
+  return { success: true, data: undefined };
+}
+
+
+
+ + +
+

2.3 — Extend getBudgetWithCosts in src/actions/budget.ts

+

Add a second query for extensions+allocations, attach to budget.extensions, and compute extensionAmountCents per period.

+
// inside getBudgetWithCosts, after periods are loaded:
+
+const extensions = await db.query.budgetExtensions.findMany({
+  where: eq(budgetExtensions.budgetId, budgetId),
+  orderBy: desc(budgetExtensions.effectiveDate),
+  with: {
+    allocations: true,
+    linkedTool: { columns: { name: true } },
+    creator:     { columns: { name: true } },
+  },
+});
+
+const extByPeriod: Record<number, number> = {};
+for (const ext of extensions) {
+  for (const a of ext.allocations) {
+    extByPeriod[a.periodId] = (extByPeriod[a.periodId] ?? 0) + a.amountCents;
+  }
+}
+
+return {
+  ...budget,
+  periods: periodsWithCosts.map((p) => ({
+    ...p,
+    extensionAmountCents: extByPeriod[p.id] ?? 0,
+  })),
+  extensions: extensions.map((e) => ({
+    ...e,
+    linkedToolName: e.linkedTool?.name ?? null,
+    createdByName: e.creator.name,
+  })),
+};
+
+
+ + +
+

2.4 — Integration tests new file

+

Mirror tests/integration/invoice-sync.test.ts setup. Real Neon test branch. 6 specs:

+
// tests/integration/budget-extensions.test.ts
+
+describe("createBudgetExtension", () => {
+  it("creates a positive extension and bumps the ceiling");
+  it("distributes across remaining periods evenly");
+  it("allocates to a single period");
+  it("refuses extension on an archived budget");
+  it("refuses effective date outside fiscal year");
+  it("refuses if total allocations would exceed new ceiling");
+  it("records a change_history row with entityType=budget_extension");
+});
+
+describe("deleteBudgetExtension", () => {
+  it("reverses the ceiling and period allocations");
+  it("cascade-deletes allocation rows");
+});
+
+
+ +
+

Acceptance criteria

+
    +
  • pnpm test:integration passes all 9 specs against a Neon test branch
  • +
  • ✓ Creating a +€8,000 extension on a €52,000 budget results in totalAmountCents = 60_000_00 and originalAmountCents = 52_000_00
  • +
  • ✓ Deleting that extension restores both to €52,000 with no allocation rows remaining
  • +
  • ✓ Manual smoke test via /api from a logged-in admin session returns { success: true }
  • +
+
+
+ + + + +
+
+
+ 3 +

Budget detail UI

+
+ ~1.5 days +
+

+ Light touch on the hero, big new card for extensions, new dialog. By the end of this phase, an admin can fully manage extensions end-to-end from the budget detail page — phase 4 (period-table sub-label) is the next checkpoint inside the same PR. +

+ +
+

Files changed

+
+ editsrc/app/budget/components/budget-health-hero.tsx + newsrc/app/budget/components/budget-extensions-card.tsx + newsrc/app/budget/components/dialogs/add-extension-dialog.tsx + newsrc/app/budget/components/dialogs/delete-extension-dialog.tsx + newsrc/app/budget/components/forms/extension-form.ts + editsrc/app/budget/components/budget-detail-client.tsx +
+
+ + +
+

3.1 — budget-health-hero.tsx

+

Right-hand "Annual ceiling" column gains a baseline+extended breakdown beneath the big number. Adds one dashed marker on the multi-marker bar. Two read changes, no logic change.

+
// inside the existing "Annual ceiling" block, ~line 213
+<p className="text-2xl font-semibold tabular-nums">
+  {formatCurrency(ceiling)}
+</p>
+{budget.originalAmountCents !== ceiling && (
+  <div className="mt-1 flex items-center justify-end gap-1.5 text-xs text-muted-foreground">
+    <span className="tabular-nums">
+      {formatCurrency(budget.originalAmountCents)} baseline
+    </span>
+    <span>+</span>
+    <Badge variant="secondary" className="tabular-nums">
+      {formatVariance(ceiling - budget.originalAmountCents)} extended
+    </Badge>
+  </div>
+)}
+
+

Multi-marker bar gets a 5th marker (dashed vertical line at originalAmountCents / ceiling) and a corresponding legend entry. ~15 lines added inside the existing MultiMarkerBar.

+
+ + +
+

3.2 — budget-extensions-card.tsx new

+

Renders the list. Props receive extensions + admin/archived flags + callback to open the dialog. Server-side render is fine (no client interactivity inside the rows; "Add" and "Delete" buttons bubble events up).

+
interface Props {
+  extensions: BudgetExtensionWithAllocations[];
+  isAdmin: boolean;
+  isArchived: boolean;
+  onAdd: () => void;
+  onDelete: (ext: BudgetExtensionWithAllocations) => void;
+}
+
+export function BudgetExtensionsCard({ extensions, isAdmin, isArchived, onAdd, onDelete }: Props) {
+  const net = extensions.reduce((s, e) => s + e.amountCents, 0);
+
+  if (extensions.length === 0 && !isAdmin) return null;
+
+  return (
+    <Card>
+      <CardContent className="space-y-4 pt-6">
+        <HeaderRow count={extensions.length} netCents={net} />
+        {extensions.length === 0 ? (
+          <EmptyState isAdmin={isAdmin} isArchived={isArchived} onAdd={onAdd} />
+        ) : (
+          <div className="border rounded-md overflow-hidden">
+            {extensions.map((e) => (
+              <ExtensionRow key={e.id} extension={e}
+                onDelete={() => onDelete(e)}
+                canEdit={isAdmin && !isArchived} />
+            ))}
+          </div>
+        )}
+        {isAdmin && !isArchived && (
+          <Button variant="secondary" onClick={onAdd}>
+            <Plus className="size-4" /> Add extension
+          </Button>
+        )}
+      </CardContent>
+    </Card>
+  );
+}
+
+

Each <ExtensionRow> renders exactly the layout from the mockup: title + category badge + linked tool badge, description, meta line (added X by Y · effective Z · allocation summary), and a right-aligned amount + actions column.

+
+ + +
+

3.3 — add-extension-dialog.tsx new

+

Mirrors BilledCostDialog's pattern exactly — controlled inputs, form state lifted to parent, disabled submit until required fields are filled. Adds the "allocation mode" radio group (4 options from concept doc) and a live "Effect on FY budget" preview that recomputes as the user types.

+
export interface ExtensionFormState {
+  reason: string;
+  amountDollars: string;       // stays string while the user types; parsed on submit
+  effectiveDate: string;
+  category: BudgetExtensionCategory;
+  linkedToolId: string;        // "" = none
+  description: string;
+  allocationMode: "unallocated" | "distribute_remaining" | "single_period" | "custom";
+  singlePeriodId: string;       // only when mode === single_period
+}
+
+interface Props {
+  open: boolean;
+  onOpenChange: (b: boolean) => void;
+  form: ExtensionFormState;
+  onFormChange: (next: ExtensionFormState) => void;
+  budget: BudgetWithCosts;        // for live preview & period dropdown
+  tools: { id: number; name: string }[];
+  onSubmit: () => void;
+  saving: boolean;
+}
+
+

Live preview is a small <EffectPreview budget form/> component sitting above the footer — reads budget.totalAmountCents + parsed amountDollars + allocation mode and shows the deltas the way the mockup does.

+
+ + +
+

3.4 — budget-detail-client.tsx wiring

+

Add state for the extension dialog (mirroring the billed-cost dialog state group). Render BudgetExtensionsCard between PastMonthSpotlight and the period allocations card.

+
// new state additions (lines ~70-85)
+const [extensionAddOpen, setExtensionAddOpen] = useState(false);
+const [extensionForm, setExtensionForm] =
+  useState<ExtensionFormState>(makeEmptyExtensionForm);
+const [extensionSaving, setExtensionSaving] = useState(false);
+const [extensionDeleteTarget, setExtensionDeleteTarget] =
+  useState<BudgetExtensionWithAllocations | null>(null);
+
+async function handleSubmitExtension() {
+  setExtensionSaving(true);
+  const result = await createBudgetExtension(
+    extensionFormToActionInput(extensionForm, budget.id)
+  );
+  setExtensionSaving(false);
+  if (result.success) {
+    toast.success("Extension added");
+    setExtensionAddOpen(false);
+    setExtensionForm(makeEmptyExtensionForm());
+    router.refresh();
+  } else {
+    toast.error(result.error);
+  }
+}
+
+// render insertion (~line 210)
+<BudgetExtensionsCard
+  extensions={budget.extensions}
+  isAdmin={isAdmin}
+  isArchived={isArchived}
+  onAdd={() => setExtensionAddOpen(true)}
+  onDelete={(e) => setExtensionDeleteTarget(e)} />
+
+
+ +
+

Acceptance criteria

+
    +
  • ✓ As admin on /budget: clicking "Add extension" opens the dialog with all fields blank and default mode = distribute
  • +
  • ✓ Submitting with valid values closes the dialog, shows a green toast, and the new extension appears in the card without a full page reload (uses router.refresh())
  • +
  • ✓ Hero now shows "€60,000 · €50,000 baseline + €10,000 extended"
  • +
  • ✓ Deleting an extension reverses both the hero ceiling and the extensions list
  • +
  • ✓ As a non-admin viewer (or on archived budget): extensions card is read-only — no Add button, no delete buttons
  • +
  • pnpm lint && pnpm typecheck pass
  • +
+
+
+ + + + +
+
+
+ 4 +

Period table propagation

+
+ ~½ day +
+

+ The "+€X from extension" sub-label under each period's planned amount. Smallest phase — one component touched, one prop added. Logically a near-extension of phase 3; lives in its own commit for review clarity but the same PR. +

+ +
+

Files changed

+
+ editsrc/app/budget/components/period-allocations-table.tsx +
+
+ +
+

4.1 — Sub-label in the planned cell

+

Insert immediately after the Input / formatCurrency(planned) in the planned column (~line 205). Read directly from period.extensionAmountCents which Phase 2 added.

+
// inside the <TableCell> for the Planned column, after the editable Input
+{period.extensionAmountCents > 0 && (
+  <button
+    type="button"
+    onClick={() => scrollToExtensionsCard()}
+    className="block text-xs text-primary mt-0.5 hover:underline"
+    title="View extensions contributing to this period"
+  >
+    +{formatCurrency(period.extensionAmountCents)} from extension
+  </button>
+)}
+{period.extensionAmountCents < 0 && (
+  <span className="block text-xs text-destructive mt-0.5">
+    {formatCurrency(period.extensionAmountCents)} from reduction
+  </span>
+)}
+
+
+ +
+

Acceptance criteria

+
    +
  • ✓ A period that received €1,000 from an extension shows "+€1,000.00 from extension" beneath its planned amount
  • +
  • ✓ The sub-label is a clickable link that scrolls to and briefly highlights the extensions card
  • +
  • ✓ Negative extensions render in text-destructive with appropriate copy ("from reduction")
  • +
  • ✓ Periods not touched by any extension show no sub-label
  • +
+
+
+ + + + +
+
+
+ 5 +

Cross-surface polish

+
+ ~1 day +
+

+ Every place the budget shows up gets the extension treatment. Dashboard widget tag, forecast chart baseline line, history feed entries. +

+ +
+

Files changed

+
+ editsrc/components/dashboard/admin/budget-hero-section.tsx + editsrc/components/reports/budget/forecast-cumulative-chart.tsx + editsrc/actions/budget.ts (getBudgetForecast) + editsrc/types/index.ts (BudgetForecast) + editsrc/app/budget/history/page.tsx + editsrc/components/change-history/* (if applicable) +
+
+ + +
+

5.1 — Dashboard widget tag

+

Inside BudgetHeroSection, next to the big number, render an inline extended +€Xk tag when totalAmountCents !== originalAmountCents. The dashboard query already fetches the budget; just project the extra column through.

+
{budget.totalAmountCents !== budget.originalAmountCents && (
+  <Badge variant="secondary" className="text-xs tabular-nums">
+    extended {formatVariance(budget.totalAmountCents - budget.originalAmountCents)}
+  </Badge>
+)}
+
+
+ + +
+

5.2 — Forecast chart baseline reference

+

Threads originalAmountCents through BudgetForecast → forecast component → ReferenceLine. Two-line type change, one prop pass-through, one new ReferenceLine.

+
// src/types/index.ts — extend BudgetForecast
+export interface BudgetForecast {
+  /* ...existing fields... */
+  budgetCeilingCents: number;
+  originalCeilingCents: number;   // NEW
+  status: "on_track" | "at_risk";
+}
+
+// src/actions/budget.ts — populate it in buildBudgetForecast()
+return {
+  /* ... */
+  budgetCeilingCents: budget.totalAmountCents,
+  originalCeilingCents: budget.originalAmountCents,
+};
+
+// src/components/reports/budget/forecast-cumulative-chart.tsx (~line 88)
+<ReferenceLine
+  y={forecast.budgetCeilingCents}
+  stroke="var(--chart-3)"
+  strokeDasharray="6 4"
+  label={...}
+/>
+{forecast.originalCeilingCents !== forecast.budgetCeilingCents && (
+  <ReferenceLine
+    y={forecast.originalCeilingCents}
+    stroke="var(--muted-foreground)"
+    strokeDasharray="2 4"
+    strokeOpacity={0.6}
+    label={{
+      value: `Original baseline ${formatCurrency(forecast.originalCeilingCents)}`,
+      position: "insideBottomRight",
+      fontSize: 11,
+      fill: "var(--muted-foreground)",
+    }}
+  />
+)}
+
+
+ + +
+

5.3 — Change history feed

+

If a centralized history feed component exists (search change_history read sites), add a case for entityType === "budget_extension" that renders the row using the extension's reason + category + amount. If no such component exists yet, this can be deferred — the data is already being recorded.

+
+ + +
+

5.4 — Budget history page

+

Add an "Extensions" column to the budgets table at /budget/history. Shows count + net delta. Trivial: budget.extensions.length and budget.totalAmountCents - budget.originalAmountCents.

+
+ +
+

Acceptance criteria

+
    +
  • ✓ Dashboard widget shows the extension tag whenever totalAmountCents !== originalAmountCents
  • +
  • ✓ Forecast chart shows a second dashed line at the original baseline, only when there are extensions
  • +
  • ✓ Budget history page shows extension count per fiscal year
  • +
  • ✓ Change history feed renders extension entries with category + reason (or, if no central feed exists, ticket created for future work)
  • +
+
+
+ + + + +
+

+ 🧪 Test plan summary +

+
+
+
Unit · Vitest
+
    +
  • resolveAllocations — modes
  • +
  • • Form parsing (cents from "8,000.00")
  • +
  • • Effect-preview math
  • +
+
Location: tests/unit/budget-extensions/*
+
+
+
Integration · Vitest
+
    +
  • • Create + ceiling math
  • +
  • • Allocation mode propagation
  • +
  • • Delete reverses cleanly
  • +
  • • Archived budget guard
  • +
  • • Over-allocation guard
  • +
  • • change_history row written
  • +
+
Real Neon test branch · .env.local
+
+
+
E2E · Playwright
+
    +
  • • Admin creates extension end-to-end
  • +
  • • Hero reflects new ceiling
  • +
  • • Period row shows sub-label
  • +
  • • Viewer (non-admin) cannot see Add button
  • +
+
Optional — defer if running short.
+
+
+
+ + + + +
+

🚀 Rollout & risk

+ +
+

Migration safety

+

All schema changes are additive — ADD COLUMN, CREATE TABLE, CREATE TYPE, CREATE INDEX. The one nuance is the backfill of original_amount_cents: Drizzle's generator may emit the column as NOT NULL without the backfill in between. Action: after pnpm db:generate, hand-edit the SQL so the order is ADD (nullable) → UPDATE backfill → SET NOT NULL. Then run drizzle-migration-reviewer before merging.

+
+ +
+

Deployment order

+

Single PR merge to main. The deploy is straightforward because every change is additive — the only sequencing that matters happens inside the build:

+
    +
  1. Vercel runs pnpm db:migrate in the build step → schema and backfill land first (verify in vercel.ts / build command).
  2. +
  3. App build compiles the new actions + components → server actions are immediately callable.
  4. +
  5. New deployment goes live → admins can use extensions; dashboard / reports / forecast pick up the changes on first render.
  6. +
+

No feature flag needed — feature is admin-only and additive. Existing budget flows are untouched. If anything looks off in production, the rollback plan below restores the previous deployment in one click.

+
+ +
+

Risks & mitigations

+
+
+ low +
Forecast chart drift. getBudgetForecast already reads totalAmountCents, so the projected-vs-ceiling math automatically follows the new (extended) ceiling. Mitigation: the new baseline reference line documents the original ceiling visually so the change is interpretable.
+
+
+ low +
Existing updateBudgetTotal() bypass. Admins could still bump the total via the existing endpoint without leaving an extension audit trail. Mitigation: not addressed in v1 (decision per concept doc); follow-up ticket to deprecate or convert that call into a synthetic other-category extension.
+
+
+ low +
Period boundary changes. If a period's date range ever changes after an extension is allocated to it, the allocation row persists. Mitigation: periods are never edited in practice (they're generated at budget creation and immutable). Out of scope.
+
+
+ very low +
Concurrent extensions racing. Two admins creating extensions simultaneously could both pass the "would over-allocate" guard against the same baseline. Mitigation: single-admin app today; if multi-admin arrives, wrap the read+guard+write in a row-level SELECT … FOR UPDATE on the budget row inside the transaction.
+
+
+
+ +
+

Rollback plan

+

App rollback — promote the previous Vercel deployment from the dashboard, or git revert the merge commit and redeploy. Because the PR is one merge commit, this is a single action.

+

Schema rollback — write a reverse migration (drop the two new tables + the enum + the new column). Safe because there's no production data dependency on the new structures yet, and total_amount_cents already carries the live effective ceiling. If any extensions were created before rollback, the historical "why" is lost but the financial numbers stay intact.

+

Recommended: keep the previous deployment as the rollback target for the first 48 hours after merge. After that, treat original_amount_cents as load-bearing.

+
+
+ + + + +
+

❓ Open questions before kickoff

+
+
+

Q1 — Allow editing the amount post-creation?

+

Plan as drafted: no — edit reason/category/description/tool-link only. Amount edits go through delete + re-create for a clean audit. Confirm this is acceptable, or relax to allow amount edits with a follow-up recordUpdate.

+
+
+

Q2 — Deprecate updateBudgetTotal()?

+

Plan as drafted: keep working in v1. After this lands, file a follow-up to either remove it from the UI or convert each invocation into an "other"-category extension automatically.

+
+
+

Q3 — Reductions in the dialog?

+

Plan as drafted: yes, allowed (negative amount). Some teams may want this gated behind a different intent ("Reduce budget" button) to avoid accidental keystrokes. Worth confirming with a usage check.

+
+
+

Q4 — Tool-detail page integration?

+

Plan as drafted: out of scope for v1. The linkedToolId FK makes it easy to add "this tool received €X in extensions this year" later. Confirm we can defer.

+
+
+
+ + + + +
+

🛑 Explicitly out of scope (v1)

+
    +
  • • Approval workflow (pending → approved/rejected states). Schema accommodates this later.
  • +
  • • Multi-stakeholder notifications (email/Slack on extension create).
  • +
  • • Bulk-import of extensions from a CSV.
  • +
  • • Tool-page rollup of extensions ("this tool received €X this year").
  • +
  • • Auto-generating an extension from an unplanned invoice.
  • +
  • • Re-architecting the existing "raw total edit" path — left running in parallel.
  • +
+
+ + + + +
+

📎 Reference index

+
+
+
Pattern sources
+
    +
  • src/actions/budget.ts:28–80createBudget pattern
  • +
  • src/actions/budget.ts:380–400createBilledCost w/ revalidate
  • +
  • src/actions/history.ts:8–43recordCreation / recordUpdate
  • +
  • src/lib/validators.ts:82–100 — existing budget schemas
  • +
  • src/app/budget/components/dialogs/billed-cost-dialog.tsx — dialog pattern
  • +
  • src/app/budget/components/budget-detail-client.tsx:41–72 — state lift pattern
  • +
  • tests/integration/invoice-sync.test.ts:49–112 — integration test setup
  • +
+
+
+
New files this plan creates
+
    +
  • src/actions/budget-extensions.ts
  • +
  • src/app/budget/components/budget-extensions-card.tsx
  • +
  • src/app/budget/components/dialogs/add-extension-dialog.tsx
  • +
  • src/app/budget/components/dialogs/delete-extension-dialog.tsx
  • +
  • src/app/budget/components/forms/extension-form.ts
  • +
  • tests/integration/budget-extensions.test.ts
  • +
  • src/lib/db/migrations/0022_*.sql (generated)
  • +
+
+
+
+ Concept: specs/026-budget-extensions/concept.md · + Mockups: specs/026-budget-extensions/mockup.html · + This document: specs/026-budget-extensions/implementation-plan.html +
+
+ +
+ + + + diff --git a/specs/026-budget-extensions/mockup.html b/specs/026-budget-extensions/mockup.html new file mode 100644 index 00000000..e51fd0c7 --- /dev/null +++ b/specs/026-budget-extensions/mockup.html @@ -0,0 +1,918 @@ + + + + + Budget Extensions — Concept Mockups + + + + + + + + + +
+
+
+
+ + Mockups · not yet implemented +
+

Budget Extensions

+

+ A first-class record of mid-year ceiling changes — with reason, category, optional tool link, and visibility wherever the budget appears. +

+
+
+ Budget detail + Add dialog + Dashboard + Reports + History + +
+
+
+ +
+ + +
+
+
+

1 · Budget detail page

+

The hero shows baseline + extended, and a new section lists each extension. Period table reveals which periods absorbed the extension.

+
+ /budget +
+ + +
+ +
+ +
AI Developer Hub
+
+ tobias.studer@unic.com + admin +
+
+ +
+ + + + +
+ + +
+
+
Budget
+

FY 2026 Budget

+
+ active + Monthly allocation +
+
+
+ + +
+
+ + +
+
+
+
+ On track + −€420 through Apr 2026 +
+

+ On pace through the closed window — €420 under expected through Apr 2026, projected to land €1,080 under the ceiling. May 2026 is 71% through (€3,820 so far). +

+
+ +
+

Annual ceiling

+

€60,000.00

+ +
+ €50,000 baseline + + + + + €10,000 extended + +
+

€2,140 unallocated

+
+
+ + +
+
+
+
+ +
+
+
+
+
+ Actual YTD + Allocated remaining + Planned YTD + Expected through Apr 2026 + Original baseline (€50k) + Ceiling €60,000.00 +
+
+ + +
+
+

Billed YTD

+

€22,840.00

+

+ €640 running API

+
+
+

Actual YTD

+

€23,480.00

+

€3,820 in May 2026 so far

+
+
+

Projected year-end

+

€58,920.00

+

€1,080 under ceiling

+
+
+

Variance through Apr 2026

+

−€420.00

+

€19,000 vs €19,420 expected

+
+
+
+ + +
+
+
+
+

Budget extensions

+ 2 +
+

Mid-year changes to the annual ceiling. Each extension records why the ceiling moved and (optionally) which tool it funds.

+
+
+

Net extended

+

+€10,000.00

+
+
+ + +
+ + +
+
+
+ Add Claude API for engineering team + new tool + + + Claude API + +
+

Engineering started using Claude for internal tooling work in late April. Initial monthly cost run-rate ~€1,400; covers May through Dec 2026.

+
+ Added 2026-05-08 by Tobias Studer + · Effective May 2026 + · Distributed across May–Dec (8 periods) +
+
+
+

+€8,000.00

+
+ + +
+
+
+ + +
+
+
+ Cursor seat expansion — design discipline + seat increase + + + Cursor + +
+

Six designers added to the Cursor team plan in April. Covers seat fees through year-end.

+
+ Added 2026-04-22 by Tobias Studer + · Effective Apr 2026 + · Left unallocated (absorbed into headroom) +
+
+
+

+€2,000.00

+
+ + +
+
+
+
+ + +
+ + +
+
+

Period allocations

+

Periods that absorbed an extension show a sub-label under the planned amount.

+
+ +
+
+
Period
+
Planned
+
Actual
+
Variance
+
Status
+
+ +
+
Apr 2026
+
€4,500.00
+
€4,820.00
+
+€320.00
+
past
+
+ + +
+
+
May 2026 · current
+
May 1 – May 31
+
+
+ €5,500.00 +
+€1,000 from extension
+
+
€3,820.00
+
+
current
+
+ +
+
+
Jun 2026
+
+
+ €5,500.00 +
+€1,000 from extension
+
+
+
+
future
+
+ +
+
… 6 more periods
+
+
+
+
+
+
+ +
+ Tip — the green sub-label identifies where an extension contributed to planned spend; clicking it scrolls to the corresponding entry in the extensions card. +
+
+ +
+
+
+
+ + +
+
+
+

2 · Add extension dialog

+

Opened from "Add extension" on the budget detail header or extensions card.

+
+ modal +
+ + + +
+ + +
+
+
+

3 · Dashboard budget widget

+

Subtle inline tag — extensions are normal business, not an incident.

+
+ / (dashboard) +
+ +
+
+
+

FY 2026 Budget

+

Monthly allocation · 5 of 12 periods

+
+ + + On track + +
+ +
+
+

€23,480 / €60,000

+ extended +€10k +
+
+
+ +
+
+
+ 39% used + Projected year-end €58,920 +
+
+ +
+
+

YTD billed

+

€22,840

+
+
+

Running API

+

€640

+
+
+

Remaining

+

€36,520

+
+
+
+
+ + +
+
+
+

4 · Budget report — forecast chart with baseline reference

+

An extra dashed line marks the original baseline so readers can see "how much we extended" at a glance.

+
+ /reports/budget +
+ +
+
+
+

Forecast vs ceiling

+

Projected cumulative spend through year-end

+
+
+ Actual cumulative + Projected cumulative + Ceiling €60,000 + Original baseline €50,000 +
+
+ + +
+ + + + €60k + €50k + €40k + €30k + €20k + €10k + €0 + + + + + + + + + + + + + + + + + + + + + + Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + Sep + Oct + Nov + Dec + + + + + + +€10,000 extended this year + + +
+ +

+ Projection runs above the original baseline (€50k) because of two approved extensions this year. Without them the year-end projection would be ~€48,920 — under the original plan. See the + Extensions section on the budget detail for context. +

+
+
+ + +
+
+
+

5 · Change history feed

+

Extensions appear as structured entries with category badge, amount, and reason — vs the bare "total changed from X to Y" entry today.

+
+ change_history table +
+ +
+
+ + +
+
+
+ +
+
+
+
+
+ Budget extension added + new tool + Claude API + 2026-05-08 · 10:42 +
+

+ +€8,000.00 + — Add Claude API for engineering team +

+

by Tobias Studer · distributed across May–Dec (8 periods × €1,000)

+
+
+ +
+
+
+ +
+
+
+
+
+ Allocations updated + 2026-05-08 · 10:42 +
+

May 2026 +€1,000 · Jun 2026 +€1,000 · … 6 more

+

automatic — triggered by extension #4

+
+
+ +
+
+
+ +
+
+
+
+
+ Budget extension added + seat increase + Cursor + 2026-04-22 · 14:18 +
+

+ +€2,000.00 + — Cursor seat expansion — design discipline +

+

by Tobias Studer · left unallocated

+
+
+ +
+
+
+ +
+
+
+
+
+ Budget created + 2026-01-04 · 09:02 +
+

FY 2026 · €50,000.00 baseline · monthly allocation

+

by Tobias Studer

+
+
+ +
+ +
+ Old behavior (replaced): the same situation today would produce a single row + annual_budget #3 totalAmountCents 5000000 → 6000000 + — no reason, no category, no attribution. +
+
+
+ + +
+
+

Design notes

+
+
+
+

Why a separate table, not just bumping the total

+

A column-edit loses the most important data — the why. A dedicated table makes every change observable, attributable, and reversible while keeping the original baseline intact forever.

+
+
+

Why no approval workflow in v1

+

This app has a single admin role today. A pending/approved state machine would add ceremony without value. The schema is shaped so adding `status` later is non-destructive.

+
+
+

Why the extension tag is quiet, not loud

+

Extensions are normal mid-year business. A red banner would create alarm fatigue. Reductions (negative extensions) get destructive styling — those are unusual and deserve attention.

+
+
+

What this unlocks later

+

Reports can answer "how much did we extend per tool this year?", "how often does the budget move in Q3?", and "which categories drive the most extensions?". The data is already structured the right way.

+
+
+
+ +
+ + + + diff --git a/specs/026-budget-extensions/verify-01-budget-detail-empty.png b/specs/026-budget-extensions/verify-01-budget-detail-empty.png new file mode 100644 index 00000000..8df81c02 Binary files /dev/null and b/specs/026-budget-extensions/verify-01-budget-detail-empty.png differ diff --git a/specs/026-budget-extensions/verify-02-add-dialog.png b/specs/026-budget-extensions/verify-02-add-dialog.png new file mode 100644 index 00000000..8f0fcd95 Binary files /dev/null and b/specs/026-budget-extensions/verify-02-add-dialog.png differ diff --git a/specs/026-budget-extensions/verify-03-after-add.png b/specs/026-budget-extensions/verify-03-after-add.png new file mode 100644 index 00000000..1c5d6f60 Binary files /dev/null and b/specs/026-budget-extensions/verify-03-after-add.png differ diff --git a/specs/026-budget-extensions/verify-04-dashboard.png b/specs/026-budget-extensions/verify-04-dashboard.png new file mode 100644 index 00000000..47a0810d Binary files /dev/null and b/specs/026-budget-extensions/verify-04-dashboard.png differ diff --git a/specs/026-budget-extensions/verify-05-history.png b/specs/026-budget-extensions/verify-05-history.png new file mode 100644 index 00000000..c021a261 Binary files /dev/null and b/specs/026-budget-extensions/verify-05-history.png differ diff --git a/specs/026-budget-extensions/verify-06-reports.png b/specs/026-budget-extensions/verify-06-reports.png new file mode 100644 index 00000000..c3ce4bf9 Binary files /dev/null and b/specs/026-budget-extensions/verify-06-reports.png differ diff --git a/specs/026-budget-extensions/verify-06b-reports-forecast.png b/specs/026-budget-extensions/verify-06b-reports-forecast.png new file mode 100644 index 00000000..5f8902d1 Binary files /dev/null and b/specs/026-budget-extensions/verify-06b-reports-forecast.png differ diff --git a/specs/026-budget-extensions/verify-07-delete-dialog.png b/specs/026-budget-extensions/verify-07-delete-dialog.png new file mode 100644 index 00000000..7938eedb Binary files /dev/null and b/specs/026-budget-extensions/verify-07-delete-dialog.png differ diff --git a/specs/026-budget-extensions/verify-08-light-mode.png b/specs/026-budget-extensions/verify-08-light-mode.png new file mode 100644 index 00000000..493f3850 Binary files /dev/null and b/specs/026-budget-extensions/verify-08-light-mode.png differ diff --git a/specs/034-mcp-server/implementation-plan.html b/specs/034-mcp-server/implementation-plan.html new file mode 100644 index 00000000..bd22ca43 --- /dev/null +++ b/specs/034-mcp-server/implementation-plan.html @@ -0,0 +1,206 @@ + + + + + + AI Developer Hub - MCP Server Implementation Plan + + + +
+
+

AI Developer Hub — MCP Server

+
Implementation plan, design rationale, and self-critique · generated for branch claude/mcp-capabilities-overview-D6RMz
+
+ Read-only v1 + Streamable HTTP + Shared-secret auth + 7 tools +
+
+
+ +
+ +

1. Goal

+

+ Expose the Hub's read-only AI-spend data through a Model Context Protocol (MCP) server, so + MCP clients (Claude Desktop / Code, Cursor, etc.) can answer questions like + “What did Alice spend on Claude last month?” or + “Which Claude workspaces are over 80% of their cap?” + directly against live Hub data, reusing the existing tested data layer rather than duplicating logic. +

+ +

2. SDK research (verified June 2026)

+ + + + + + + + + +
QuestionFinding
Which library?mcp-handler (Vercel-maintained), wraps the official @modelcontextprotocol/sdk as a Next.js App Router route handler.
Next.js 15 supported?Yes — peer dep is next >=13.0.0. (An earlier search result claiming “Next 16 required” conflated this with Next's own built-in MCP guide; the package README confirms 13+.)
Versions pinnedmcp-handler@1.1.0 (peer requires exactly @modelcontextprotocol/sdk@1.26.0) — SDK pinned to 1.26.0 to satisfy the peer and avoid pnpm warnings. 1.26.0 also clears the pre-1.26 security advisory.
Zod v4 (app uses 4.3.6)?Supported since SDK 1.22.0 via Standard Schema. Known quirks: z.discriminatedUnion() can be silently dropped, and per-field descriptions may not always propagate. Mitigation: keep tool inputs to flat strings/numbers/optionals, no unions.
TransportStreamable HTTP (default since the 2025-03 spec). Stateless mode works on serverless with no Redis. redis is a dormant dependency only used for SSE resumability.
Tool APIserver.registerTool(name, { title, description, inputSchema }, handler)inputSchema is a Zod raw shape; handler returns { content: [{ type: "text", text }] }.
AuthwithMcpAuth(handler, verifyToken, { required: true })verifyToken(req, bearerToken) returns an AuthInfo or undefined (→ 401). Lets us plug in the Hub's shared-secret model.
+ +

3. Architecture

+

One dynamic route mounts the MCP server; all domain logic lives in a small, unit-testable src/lib/mcp/ module that delegates to the existing data layer. The route stays thin.

+
src/app/api/mcp/[transport]/route.ts   # createMcpHandler + withMcpAuth (thin)
+src/lib/mcp/
+  ├─ auth.ts     # verifyMcpToken: constant-time shared-secret check
+  ├─ format.ts   # centsToUsd, jsonResult, errorResult (pure)
+  ├─ data.ts     # per-tool data assembly (delegates to existing lib/actions)
+  └─ tools.ts    # registerHubTools(server): wires names → handlers
+docs/mcp-server.md                      # operator + client setup guide
+

Endpoint resolves to POST /api/mcp/mcp (basePath /api/mcp). Namespacing under /api/mcp avoids a catch-all dynamic segment colliding with existing /api/* routes.

+ +

Auth & middleware

+
    +
  • New optional env var MCP_SERVER_SECRET (z.string().min(16).optional()) — mirrors PROFILE_API_SECRET. When unset, the server rejects every request (401) and logs a one-time warning, so the feature is dormant-by-default and safe to ship.
  • +
  • Constant-time comparison (crypto.timingSafeEqual with a length guard) to avoid timing leaks.
  • +
  • Add api/mcp to the middleware.ts matcher exclusion, so unauthenticated MCP clients get a clean 401 instead of a 302 → /login redirect (same pattern as /api/profile, /api/sync).
  • +
  • Defense-in-depth: add /api/mcp to the nighthawk agent BUILT_IN_DENY_PATHS, matching how /api/sync is treated.
  • +
+ +

4. Tools to expose (the worthwhile set)

+

Selected for high value, clean mapping to already-tested read functions, and zero session dependency. All monetary fields are returned as both integer cents and a derived USD number.

+ + + + + + + + + +
ToolInputBacked byReturns
list_ai_toolsaiTools + accessTiers (direct)Active tool catalog with tiers & monthly cost
get_user_cost_profileemail, month?fetchProfileDataInternalUser, active licenses, Claude cost breakdown + last sync
get_claude_spend_summarymonth?loadDashboardKpisOrg Claude KPIs: MTD total, MoM delta, projection, workspaces over cap, today estimate
list_claude_workspacesloadWorkspaceListPer-workspace cost, cap, utilization %, today estimate
get_budget_statusfiscalYear?getActiveBudget + getBudgetWithCosts + buildBudgetForecastAnnual budget, per-period planned/billed/actual, forecast & on-track/at-risk
get_copilot_usage_summarysince?, until?copilotUsageMetrics + copilotBillingSnapshots (direct)Seats, latest billing cost, usage & acceptance rate over range
list_recent_sync_eventssourceType?, limit?syncEvents + loadSyncStatusRecent sync activity & Claude-spend freshness (health view)
+

Domain coverage: licenses, per-user Claude cost, org Claude spend, Claude workspaces, budgets, GitHub Copilot, and pipeline health.

+ +

Deliberately out of scope for v1

+
    +
  • No write/mutation tools. Read-only removes the largest risk surface; mutations would need to honor the agent deny-list philosophy and per-action auth.
  • +
  • No raw API-key or PII exposure. get_user_cost_profile returns costs and license metadata, never decrypted keys (unlike the admin CSV export).
  • +
  • Copilot admin actions (getCopilotOverview/getCopilotSeats) are skipped — they call requireAdmin() internally, so a bearer-auth caller would just get “Unauthorized”. We query the tables directly instead.
  • +
+ +

5. Testing & QA

+
    +
  • tests/unit/mcp/format.test.ts — cents→USD rounding, result envelope shape.
  • +
  • tests/unit/mcp/auth.test.ts — missing secret → reject, wrong token → reject, correct → AuthInfo, constant-time path.
  • +
  • tests/unit/mcp/data.test.ts — each assembly function with mocked db/loaders; asserts shaping & USD conversion & not-found handling.
  • +
  • tests/unit/mcp/tools.test.ts — registers tools against a fake server, invokes each handler, asserts content envelope & isError on failures.
  • +
  • Gate: pnpm typecheck (strict, no any), pnpm lint (zero warnings), pnpm test, pnpm build. Then /simplify.
  • +
+ +

6. Self-critique — challenge & improvements

+ +
+

Q: mcp-handler@1.1.0 drags in redis, commander, chalk. Is that acceptable weight for a serverless app?

+

A: Yes for v1 — it's the Vercel-blessed path and Redis stays dormant in stateless Streamable HTTP mode. Mitigation that survives this decision: all domain logic is decoupled into src/lib/mcp/* and unit-tested independently of mcp-handler, so swapping to the raw SDK transport later is a route-file change, not a rewrite.

+
+ +
+

Q: Zod v4 + SDK 1.26 has known schema quirks. Risk?

+

A: Bounded by keeping inputs to flat z.string()/z.number()/.optional() — no discriminated unions, no nested objects. Month/fiscal-year validated with the same regexes the app already uses (/^\d{4}-(0[1-9]|1[0-2])$/).

+
+ +
+

Q: Shared secret instead of full OAuth 2.1 — is that good enough?

+

A: For an internal, read-only, single-tenant tool it matches the app's existing bearer-secret convention (PROFILE_API_SECRET, CRON_SECRET) and operators already manage these. OAuth is the documented upgrade path; withMcpAuth keeps that door open without reworking the tools.

+
+ +
+

Q: Could this leak sensitive data to an AI client?

+

A: Improvement applied: no decrypted API keys, no password hashes, no invite tokens. get_user_cost_profile returns only the same surface as the existing /api/profile route. Per-user lookup is by exact email, returning a clean “not found” rather than enumerable data.

+
+ +
+

Q: Server actions in budget.ts are "use server". Safe to call from a route?

+

A: Yes — the read actions used (getActiveBudget, getBudgetWithCosts, getBudgetForecast, fetchActualByPeriod) contain no requireAdmin() and no client boundary is crossed; they run as ordinary server functions.

+
+ +
+

Q: What if MCP_SERVER_SECRET is forgotten in production?

+

A: Improvement applied: dormant-by-default. Unset → every call 401s with a one-time server warning. Optional in the env schema so existing deployments keep booting; the feature simply stays off until a secret is provisioned.

+
+ +
+

Q: Errors inside a tool handler?

+

A: Every handler is wrapped to return { isError: true, content: [...] } with a safe message instead of throwing a raw protocol error, so a bad email or empty dataset degrades gracefully.

+
+ +

7. Client configuration (for the morning)

+
// Claude Desktop / Code MCP config
+{
+  "mcpServers": {
+    "ai-developer-hub": {
+      "type": "http",
+      "url": "https://<your-hub-host>/api/mcp/mcp",
+      "headers": { "Authorization": "Bearer <MCP_SERVER_SECRET>" }
+    }
+  }
+}
+ + +
+ + diff --git a/specs/035-scenario-calculators/api-threshold-implementation-plan.html b/specs/035-scenario-calculators/api-threshold-implementation-plan.html new file mode 100644 index 00000000..979bf3ae --- /dev/null +++ b/specs/035-scenario-calculators/api-threshold-implementation-plan.html @@ -0,0 +1,484 @@ + + + + + + Implementation Plan — API threshold (keep light keys metered) · 035 + + + +
+ +
+
+ Implementation plan + spec/035-scenario-calculators + Increment · pending approval +
+

API threshold — keep light keys on metered API

+

+ A focused enhancement to the shipped API → Subscription calculator + (original plan, prototype). + Today the right-sized scenario forces every key onto a flat seat — even someone burning $3/mo gets a $25 + Standard seat, which costs more than leaving them on the API. This adds a lower + API threshold: below it, a key stays on pay-as-you-go metered API. It mirrors the existing + Premium threshold and defaults to $25 — the price of a Standard seat, i.e. the + break-even point below which a seat can never pay off. +

+

+ The result is a genuine three-band right-sizing — API · Standard · Premium — and a + right-sized total that is now provably ≤ the metered baseline. +

+

Drafted 2026-06-09 · author: T. Studer · feature 035 increment · estimate: ~0.5–1 dev day

+
+ +
+ Scope. This is an increment to a merged feature (PR #113). It touches the pure engine, the + client controls/table, and the unit tests. No DB schema change, no new route, no new dependency. It + does change the calculator's default headline figures — see §6 Regression anchors. +
+ +

Contents

+ + + +

1 · Goal & scope

+ +
+

In scope

+
    +
  • A new API threshold input (slider) in the calculator's assumptions panel, + default $25 (= the live Standard seat price).
  • +
  • Engine support for a third seat outcome — "api" — meaning keep this key metered; + its right-sized cost is the user's own API spend, not a seat price.
  • +
  • The right-sized scenario, its card, bar, verdict, KPI, and the per-user table all become + three-way (API · Standard · Premium).
  • +
  • Updated unit tests + new regression anchors locking the new default figures.
  • +
+

Out of scope

+
    +
  • Any DB schema change — still read-only over existing tables.
  • +
  • Applying the API floor to the All → Standard / All → Premium + scenarios — those stay deliberately naïve as contrast baselines (see §9).
  • +
  • Re-deriving the prototype's editorial numbers — the standalone prototype.html may be + refreshed later; not required for this increment.
  • +
+
+ + +

2 · The three-band model

+ +

+ Each key's representative monthly spend (usageForUser under the chosen basis) now falls into one of three + bands, decided by two thresholds. With both thresholds at their break-even defaults: +

+ + +
+ $0 + ↑ API threshold ($25) + ↑ Premium threshold ($125) + $300+ +
+ + + + + + + + + + +
BandCondition (monthly spend U)Right-sized cost for that key
APIU < apiThreshold= U (stays metered — no seat). A $25 seat can't beat a $3 burn, so don't migrate them.
StandardapiThreshold ≤ U < premiumThresholdstandardCents
PremiumU ≥ premiumThresholdpremiumCents
+ +
+ Why $25 is the right default. The API threshold defaults to the Standard seat price. Below + that, the cheapest seat already costs more than the key's entire metered bill — converting it is pure waste. Pairing + this with the Premium threshold's existing $125 default (the Premium seat price) makes the right-sized scenario the + true cost-minimising assignment: every key lands on whichever of {metered API, Standard, Premium} is cheapest + for it. Consequently right-sized ≤ baseline always (at default thresholds) — the previous version + could come out above baseline on a light-skewed population. +
+ +

Boundaries are inclusive of the seat, matching the existing Premium convention: U == apiThreshold + ⇒ Standard (tie goes to the seat); U == premiumThreshold ⇒ Premium.

+ + +

3 · Engine changes

+

File: src/lib/scenarios/api-subscription.ts — pure, no React/db. Three small edits.

+ +

3.1 Types — add the band + the input

+
export type SeatTier = "api" | "standard" | "premium";   // was: "standard" | "premium"
+
+export type ScenarioInputs = {
+  standardCents: number;
+  premiumCents: number;
+  premiumThresholdCents: number;       // usage >= ⇒ Premium
+  apiThresholdCents: number;           // usage <  ⇒ stay on metered API
+  basis: UsageBasis;
+  population: Population;
+};
+
+export type ScenarioResult = {
+  rows: ScenarioRow[];
+  count: number;
+  baselineCents: number;
+  allStandardCents: number;
+  allPremiumCents: number;
+  rightSizedCents: number;
+  premiumCount: number;
+  standardCount: number;
+  apiCount: number;                    // keys kept metered
+};
+ +

3.2 mapSeat — three-way, Premium-first so any threshold order is sane

+
export function mapSeat(
+  usageCents: number,
+  inputs: ScenarioInputs,
+): { tier: SeatTier; seatCents: number } {
+  if (usageCents >= inputs.premiumThresholdCents)
+    return { tier: "premium", seatCents: inputs.premiumCents };
+  if (usageCents < inputs.apiThresholdCents)
+    return { tier: "api", seatCents: usageCents };   // metered — pays its own burn
+  return { tier: "standard", seatCents: inputs.standardCents };
+}
+

Setting seatCents = usageCents for the API band is the key trick: the per-user + deltaCents (= seatCents − usageCents) becomes 0 (the table shows “—”, no change), and the + seatCents column still sums to rightSizedCents in the footer.

+ +

3.3 computeScenarios — tally the third band

+
let premiumCount = 0;  let apiCount = 0;
+// inside pool.map → after const { tier, seatCents } = mapSeat(...)
+if (tier === "premium") premiumCount += 1;
+else if (tier === "api") apiCount += 1;
+// rightSizedCents += seatCents  ← unchanged; API rows add their own usage
+
+return {
+  /* …unchanged… */
+  premiumCount,
+  apiCount,
+  standardCount: count - premiumCount - apiCount,
+};
+

baselineCents, allStandardCents, allPremiumCents, usageForUser and + classifyMonths are untouched. The All → Standard / All → Premium + totals stay naïve by design.

+ + +

4 · UI changes

+

File: src/app/scenarios/api-subscription/api-subscription-client.tsx.

+ +

4.1 New state + input wiring

+
const [apiThresholdDollars, setApiThresholdDollars] = useState(
+  Math.round(dataset.defaultStandardCents / 100),   // $25 on live data
+);
+// fold into the existing useMemo ScenarioInputs:
+apiThresholdCents: Math.max(0, Math.round(apiThresholdDollars * 100)),
+// reset(): setApiThresholdDollars(Math.round(dataset.defaultStandardCents / 100));
+ +

4.2 The control — a second slider, paired with the Premium one

+

To keep the 4-column instrument panel, group both sliders in one “Seat thresholds” cell: the API + floor stacked above the Premium ceiling, with a three-way mix readout below. Two native + <input type="range">s (no new dependency — consistent with the original decision). The API slider: + min 0 · max 100 · step 5, accent-filled, mono readout, aria-label="API threshold, keep keys below + this metered".

+
// readout under the pair — replaces the old 2-way line
+<span className="text-faint">{result.apiCount}</span> API ·
+<span className="text-ink">{result.standardCount}</span> Standard ·
+<span className="text-ink">{result.premiumCount}</span> Premium
+ +

4.3 Clamp — API floor must not exceed the Premium ceiling

+
function onApiThreshold(v: number) {
+  setApiThresholdDollars(Math.min(v, thresholdDollars));        // can't pass Premium
+}
+function onPremiumThreshold(v: number) {
+  setThresholdDollars(v);
+  if (apiThresholdDollars > v) setApiThresholdDollars(v);       // drag the floor down with it
+}
+ +

4.4 Right-sized scenario card / bar / verdict / KPI

+
    +
  • Scenario card & bar (the “Threshold mix” / “Right-sized” entry): barSub + and mix become {apiCount}A · {standardCount}S · {premiumCount}P and + {premiumCount} Premium · {standardCount} Standard · {apiCount} metered API.
  • +
  • Verdict line: “Moving {count} keys to {premiumCount} Premium + {standardCount} + Standard seats and keeping {apiCount} light keys metered costs {rightSized}/mo — {phrase} + the {baseline}/mo API run-rate.” The save/cost tone logic is unchanged.
  • +
  • KPI #4 “Heavy users ≥ threshold” → repurpose to the three-way split, or keep it and + add the mix to the threshold readout. Minimal: leave the KPI, surface apiCount in the readout (4.2).
  • +
+ +

4.5 Per-user table

+
    +
  • SeatPill gains an "api" variant — a muted/dashed pill labelled + API (greyscale, distinct from the outlined Standard/Premium pills). Keep it in the Nothing palette + (text-faint / dashed border-input).
  • +
  • Seat $/mo cell for API rows shows the metered figure (= API basis) so the + footer total still equals rightSizedCents; the Δ seat − API cell shows “—” (delta 0).
  • +
  • Footer mix string: {premiumCount}P / {standardCount}S / {apiCount} API.
  • +
  • Sort key "tier": rank api < standard < premium (e.g. 0/1/2) so the + column sorts cleanly across all three.
  • +
+ + +

5 · Defaults, clamping & data

+ + + + + + + + +
ConcernDecision
Default valueapiThresholdCents = dataset.defaultStandardCents ($25 on live data). “Reset + to live” restores it alongside the seat prices. Independent of the Standard price input thereafter (same + pattern the Premium threshold already follows).
Data / schemaNone. defaultStandardCents already rides on ApiSubscriptionDataset + from access_tiers — no query change in queries.ts.
Ordering invariantUI clamps apiThreshold ≤ premiumThreshold (§4.3). The engine is robust + regardless (Premium-first), so a bad order can never produce a negative/empty count.
Zero floorapiThreshold = 0 ⇒ no key is ever kept metered ⇒ exactly the pre-increment + behaviour. The feature is a strict superset; $0 reproduces the old numbers.
+ + +

6 · Regression anchors (these change)

+
+ Heads-up: the default headline numbers move. Because the API threshold defaults to $25 (not $0), the + shipped tests asserting rightSizedCents === 207500 (all) and === 177500 (active) + will fail and must be updated. This is the intended behaviour change, recomputed on the same + 2026-06-09 live fixture (avg of Mar/Apr/May complete months): +
+ + + + + + + + + + + +
PopulationMix (API · Std · Prem)Right-sized /movs metered baseline
All 47 keys16 · 22 · 9177591 ($1,775.91) was 207500baseline 304140−$1,265.49/mo (42%) (was 32%)
Active 4316 · 20 · 7147591 ($1,475.91) was 177500baseline 241620−$940.29/mo (39%)
+

Verified by re-running the engine logic over the existing REAL[] fixture in + tests/unit/scenarios/api-subscription.test.ts. baselineCents, + allStandardCents (117500), allPremiumCents (587500) and premiumCount (9 / 7) are + unchanged — only the right-sized split and total move. The 38 old “Standard” keys now split 22 Standard + 16 + metered API; the 16 metered keys contribute $100.91/mo of real spend (not 16 × $25 = $400).

+ +

New / updated test cases

+
    +
  • mapSeat band boundaries: 2499 ⇒ api (seatCents 2499), + 2500 ⇒ standard, 12499 ⇒ standard, 12500 ⇒ premium — all at + apiThresholdCents 2500.
  • +
  • computeScenarios default anchors → { apiCount:16, standardCount:22, premiumCount:9, + rightSizedCents:177591 }; active → {16, 20, 7, 147591}.
  • +
  • apiThresholdCents: 0 reproduces the legacy figures (207500 / 177500) + — proves the superset property.
  • +
  • API row invariants: tier==="api" ⇒ seatCents === usageCents ⇒ deltaCents === 0; footer + Σ seatCents === rightSizedCents.
  • +
  • Update baseInputs in the test file to include apiThresholdCents (set 0 where a + test means to preserve old behaviour, 2500 where it asserts the new defaults).
  • +
+ + +

7 · File manifest

+ + + + + + + + + + + + + + + + + +
FileChange
src/lib/scenarios/api-subscription.tseditSeatTier + "api"; apiThresholdCents on ScenarioInputs; + apiCount on ScenarioResult; three-way mapSeat; tally in + computeScenarios.
src/app/scenarios/api-subscription/api-subscription-client.tsxeditAPI-threshold state + slider, clamp, three-way readouts (card/bar/verdict/KPI/table footer), API + SeatPill variant, tier sort ranking.
tests/unit/scenarios/api-subscription.test.tseditbaseInputs + new band/anchor/superset cases (§6).
src/lib/scenarios/queries.tsnoneNo change — defaultStandardCents already supplied.
src/lib/scenarios/types.tsnoneNo change — dataset shape unchanged.
specs/035-scenario-calculators/prototype.htmloptOptional later refresh to show the three-band split; not required.
CLAUDE.mdedit“Recent Changes” line for the increment.
+ + +

8 · Phasing

+ +
+

Phase 1 — Engine + tests (TDD)

~0.25 day
+
    +
  • Add the type changes (§3.1); update baseInputs & write the new anchor/boundary/superset + tests (§6) first — they should fail.
  • +
  • Implement three-way mapSeat + the apiCount tally (§3.2–3.3) until green.
  • +
  • pnpm test tests/unit/scenarios — anchors 177591 / 147591 pass.
  • +
+
+ +
+

Phase 2 — Client UI

~0.25–0.5 day
+
    +
  • State + input + clamp (§4.1, 4.3); paired-slider control (§4.2).
  • +
  • Three-way readouts: card mix/barSub, verdict, KPI, table footer (§4.4).
  • +
  • API SeatPill variant + tier sort ranking + “—” delta / metered seat cell (§4.5).
  • +
+
+ +
+

Phase 3 — Polish & gates

~0.1 day
+
    +
  • a11y: slider aria-label, keyboard reach, SR-sane mix readout.
  • +
  • Design-token pass on the API pill / band viz (Nothing greyscale; no hardcoded hex).
  • +
  • pnpm lint (zero warnings), pnpm typecheck, pnpm format.
  • +
+
+ + +

9 · Risks & decisions

+ + + + + + + + + + +
TopicDecision / mitigation
Default figures shiftIntended. Documented in §6 with recomputed anchors; the $0 + superset test pins the legacy numbers so the change is auditable.
Should All→Standard adopt the floor?No. Those two cards are naïve “everyone on one + seat” baselines — their job is to show the waste the right-sized mix avoids. Only the right-sized scenario gets the + floor.
Threshold orderingUI clamps api ≤ premium; engine is Premium-first so it never breaks + even if fed an inverted pair (defensive).
“API” seat cost = usageMakes delta 0 and keeps the footer total honest. The Seat $/mo cell + duplicating the API-basis cell is acceptable signal (“no change”); a “metered” text label is an alternative.
Capacity caveat still holdsThe all-Standard warning (“a $25 seat may not cover a $287 user”) is + unaffected; the API floor is about the bottom of the distribution, not the top.
Naming“API threshold” (keep-below-metered) reads symmetrically with “Premium threshold” + (promote-above). Confirm the label wording with the user if “metered floor” is preferred.
+ + +

10 · Acceptance checklist

+
    +
  • An API threshold control appears, defaulting to $25 (live Standard price); “Reset to + live” restores it.
  • +
  • Keys below the threshold render as API seats, contribute their metered spend (not a seat + price) to the right-sized total, and show a “—” delta.
  • +
  • Right-sized card/bar/verdict/KPI/table all read three-way (API · Standard · Premium).
  • +
  • Dragging the API threshold up moves keys API→Standard and re-totals live; it can never exceed the + Premium threshold.
  • +
  • Default view reproduces the §6 anchors (47 → 16·22·9 → $1,775.91/mo; active → 16·20·7 → + $1,475.91/mo); apiThreshold=0 reproduces the legacy $2,075 / $1,775.
  • +
  • lint / typecheck / format clean; unit tests green with the new + anchors.
  • +
+ +
+ AI Developer Hub · spec/035-scenario-calculators · API-threshold increment · drafted 2026-06-09 · companions: + implementation-plan.html · prototype.html +
+ +
+ + diff --git a/specs/035-scenario-calculators/implementation-notes.html b/specs/035-scenario-calculators/implementation-notes.html new file mode 100644 index 00000000..317ac65b --- /dev/null +++ b/specs/035-scenario-calculators/implementation-notes.html @@ -0,0 +1,287 @@ + + + + + + Implementation Notes — API threshold (035 increment) + + + +
+
+
+ Implementation notes + 035 · API threshold + Status: implemented · gates green · review pass +
+

Implementation notes — API threshold

+

+ What diverged from, or was interpreted beyond, the plan + while building it. A running log; updated as the work proceeds. +

+

Started 2026-06-09 · feature 035 increment · branch 036-budget-forecast-simulation (worktree api-subscription-calc)

+
+ +

1 · Design decisions (spec was ambiguous / left to judgement)

+ +
+

Decision · control layout

+

Both thresholds share one "Seat thresholds" cell

+

The plan offered the API floor as "a second slider, paired with the Premium one" but left the exact panel layout + open. I kept the 4-column instrument panel by stacking the API-floor slider above the Premium-ceiling slider in the + cell that previously held the Premium slider alone, with a single three-way API · Standard · Premium + readout below the pair. No new grid column; the cell just grows taller.

+
+ +
+

Decision · label wording

+

Slider labelled Keep on API < (not "API threshold")

+

Reads symmetrically against the existing Premium threshold ≥ and states the behaviour directly + (keys below stay on API). The plan flagged the wording as unconfirmed — see open questions.

+
+ +
+

Decision · "api" seat cost

+

A metered key's seatCents = its own usage

+

So its per-user deltaCents is exactly 0 (the table shows "—", i.e. no change), and the + seatCents column still foots to rightSizedCents. This is the plan's stated trick; I kept it + rather than special-casing API rows out of the footer total.

+
+ +
+

Decision · slider range

+

API slider is $0–$100, step $5

+

The Premium slider spans $0–$300; the API floor only needs the low end (its sole job is to catch keys + cheaper than a seat). $25 default is reachable at step 5. The UI clamps the floor to ≤ the Premium ceiling.

+
+ +
+

Decision · default sourcing

+

Default = live Standard seat price, then independent

+

Initialised from dataset.defaultStandardCents ($25 on live data) and restored by "Reset to live", but + thereafter a free, independent control — mirroring how the Premium threshold relates to the Premium price.

+
+ +

2 · Deviations (intentional departures)

+ +
+

Deviation · test anchors

+

Default-input regression anchors were rewritten, not preserved

+

Because the API threshold defaults to $25 (not 0), the shipped tests asserting + rightSizedCents === 207500 (all) / 177500 (active) no longer describe the default. I + rewrote those two tests to the new three-band anchors (177591 = 16·22·9; + 147591 = 16·20·7) and added a separate apiThresholdCents: 0 "superset" test that + pins the old 207500 / 177500 figures. Net: the legacy numbers are still asserted, just under the input + that produces them. This is the intended behaviour change called out in plan §6.

+
+ +
+

Deviation · scope guard

+

The API floor applies only to the right-sized scenario

+

All → Standard and All → Premium stay deliberately naïve (everyone on one seat) as contrast + baselines. The floor is the whole point of the right-sized column, so applying it elsewhere would erase the + comparison the page exists to make.

+
+ +

3 · Tradeoffs (alternatives considered)

+ +
+

Tradeoff · control widget

+

Two native range sliders vs a dual-thumb Radix slider

+

A single dual-thumb range would express the API–Premium band most directly, but @radix-ui/react-slider + isn't installed and the original 035 decision was explicitly "native range, no new dependency, reads as a mechanical + instrument." Two stacked native sliders honour that and avoid a dependency; the cost is two thumbs instead of one + band. Revisit only if a true band control is wanted.

+
+ +
+

Tradeoff · "Seat $/mo" cell for API rows

+

Show the metered dollar amount vs a "metered" text label

+

For an API-retained key the Seat $/mo cell shows its metered figure — which duplicates the "API basis" column. The + alternative (a "metered" word) reads cleaner but breaks the column's footer sum. I kept the number so the column + keeps footing to rightSizedCents; the "—" in the Δ column already signals "no change". Easy to flip if + you prefer the label.

+
+ +
+

Tradeoff · mapSeat ordering

+

Premium-first branch order

+

Checking premium before the API floor makes the function robust to an inverted threshold pair (floor + above ceiling) — it can never emit a negative or impossible count even though the UI also clamps to prevent that + state. Costs nothing; buys defensiveness.

+
+ +

4 · Open questions (please confirm / revise)

+ +
+

Question · wording

+

Is Keep on API < the label you want?

+

Alternatives: "API threshold", "Metered floor", "Min spend for a seat". Happy to change — it's a one-line edit.

+
+ +
+

Question · spec folder vs new number

+

Both the plan and these notes live under specs/035-scenario-calculators/

+

Filed as a 035 increment beside the original plan/prototype. If you'd rather track this as its own numbered spec, + say so and I'll move them.

+
+ +
+

Question · prototype refresh

+

The standalone prototype.html still shows the old two-band numbers

+

Left untouched (it's the editorial reference, not shipped). Want it refreshed to the three-band split, or is the + app + this notes file enough?

+
+ +

5 · Verification & /simplify outcome

+ +
+

Gates (run twice — after implementation, and again after cleanup)

+ + + + + + + +
GateResult
pnpm test (scenario suite)22 passed
pnpm typecheckclean
pnpm lintNo ESLint warnings or errors (—max-warnings 0)
+

Regression anchors observed exactly: all-47 right-sized = 177591 (16 API · 22 Standard · + 9 Premium); active-43 = 147591 (16 · 20 · 7); apiThreshold:0 superset = 207500. + A 4-way adversarial review (engine math, test coverage, UI completeness, quality/a11y/tokens) returned pass on all + four dimensions; the one minor nit (a baseInputs comment) was applied.

+ +

Environment note (not a code issue)

+

pnpm lint prints two benign Next.js CLI notices: a next lint deprecation message, and a + "multiple lockfiles / inferred workspace root" warning — the worktree has its own pnpm-lock.yaml + alongside the repo-root one. ESLint itself is clean. Flagged only so the extra console text isn't a surprise.

+ +

Browser verification (Playwright + agent session, worktree on :3001)

+

Drove the live page against the preview DB — whose data matches the test fixture, so the on-screen figures equal + the unit anchors:

+ + + + + + + + + + + +
StateReadoutRight-sized verdict
Default ($25 floor / $125)16 API · 22 Standard · 9 Premium$1,776/mo · 42% cut · 16 API pills in table
API floor → $00 API · 38 Standard · 9 Premium$2,075/mo · 32% cut · verdict correctly omits the "keeping N keys" clause
API floor → $10036 API · 2 Standard · 9 Premium$2,408/mo · 36 API pills
Premium → $20 (clamp test)15 API · 0 Standard · 32 PremiumAPI floor auto-followed 100 → 20 ✓ · cost state turns red (+34%)
+

One console message: a React hydration warning about caret-color: transparent on the + unchanged PriceField number inputs (std-price / prem-price) — these + are outside this diff, so the warning is pre-existing/environmental (browser-injected inline style), not introduced + by the API-threshold change. Not fixed here (out of scope).

+ +

/simplify — applied

+
    +
  • Extracted <ThresholdSlider> — the API and Premium slider blocks were ~45 lines + of near-identical JSX; now one local component, called twice. (Flagged independently by the reuse + simplification + passes.)
  • +
  • SEAT_PILL lookup recordSeatPill no longer special-cases + tier === "api"; all three tiers render from one Record<SeatTier, {className,label}>, + making the tier set first-class. (Altitude + simplification.)
  • +
  • TIER_SORT_ORDER constant — replaced the nested-ternary magic numbers in the table + sort with a named Record<SeatTier, number> (api 0 · standard 1 · premium 2). (Altitude + + simplification.)
  • +
+ +

/simplify — skipped (with reason)

+
    +
  • Swap SeatPill for the shared Badge — would change the deliberate "Nothing" mono/pill + visuals on pre-existing code; out of this increment's intent.
  • +
  • Hoist clamp() into lib/utils — touches files outside the diff; the inline + Math.min is already minimal.
  • +
  • A formatSeatMix() helper — the three readouts use intentionally different orderings + and separators (bar 16A·22S·9P, footer 9P/22S/16A, prose); unifying adds parameters, not + clarity.
  • +
  • Engine-side validateScenarioInputs() — the floor≤ceiling invariant is a UI concern; + mapSeat is already Premium-first and robust to any ordering, so this would be gold-plating.
  • +
+
+ +

6 · AI review (Copilot) — addressed across two passes

+
+

Pass 1 raised two (both fair, both fixed):

+
    +
  • mapSeat JSDoc said "cheapest viable option" — but the function is purely + threshold-based; away from the default thresholds a tier can be assigned even when another would cost less. + Reworded the doc to state the three threshold rules explicitly and to note that the cost-minimising reading only + holds at the break-even defaults.
  • +
  • Verdict sentence was internally inconsistent for apiCount > 0 — "Moving 47 users + to 9 Premium + 22 Standard seats and keeping 16 metered" reads as 47 → 31 seats. Reworded to a true partition via + a small joinParts() helper, omitting empty groups. Verified in-browser across all three mixes: + default → "9 Premium, 22 Standard, and 16 kept on metered API"; API floor $0 → "9 Premium and 38 Standard"; + Standard-empty clamp → "32 Premium and 15 kept on metered API" (each sums to 47).
  • +
+

Pass 2 (re-review of the pass-1 fix) caught two follow-ons, both fixed:

+
    +
  • Stale field doc — the premiumThresholdCents JSDoc still said "otherwise + Standard", which the new API tier makes wrong. Reworded to "below it the key is Standard or, under + apiThresholdCents, the metered API tier (see mapSeat)".
  • +
  • Empty-population copy — when count === 0 (population = active with no active keys, + reachable via the toggle) the verdict rendered dangling "— —". The breakdown interjection is now conditional + (a verdictLead string); verified: count 0 → "Right-sizing the 0 API users costs …", + count 1 → singular "user".
  • +
+

Pass 3 (re-review of pass 2) — two more, both fixed:

+
    +
  • Seat $/mo precision mismatch — API rows carry cents-precise metered spend, but the "Seat $/mo" + cell used whole-dollar formatUSD0 while "API basis" used formatCurrency — so the same + value could read $24 vs $23.82 beside a delta. API rows now use + formatCurrency too; verified in-browser the two cells match exactly (e.g. $23.82 = $23.82, Δ —). + Whole-dollar seat prices keep formatUSD0.
  • +
  • Misleading sort commentTIER_SORT_ORDER was annotated "cheapest → priciest", + but it's a tier-escalation order (API → Standard → Premium), not a cost order. Reworded to say so and cross-ref + mapSeat.
  • +
+

After pass 3 the loop is converging on increasingly minor points (display precision, comment wording), + each typically about the previous fix. All six findings across three passes are fixed, replied, and resolved; CI is + green on every commit. Concluding the review loop here — the PR is open for human review.

+
+ +
+ AI Developer Hub · 035 API-threshold increment · notes drafted 2026-06-09 · implemented, gates green, reviewed, simplified. +
+ +
+ + diff --git a/specs/035-scenario-calculators/implementation-plan.html b/specs/035-scenario-calculators/implementation-plan.html new file mode 100644 index 00000000..fe1925bc --- /dev/null +++ b/specs/035-scenario-calculators/implementation-plan.html @@ -0,0 +1,530 @@ + + + + + + Implementation Plan — Scenarios section & API→Subscription calculator (035) + + + +
+ +
+
+ Implementation plan + spec/035-scenario-calculators + Draft — pending approval +
+

Scenarios section & the API → Subscription calculator

+

+ Turn the standalone cost prototype into a real, live-data feature of the AI Developer Hub: a new + Scenarios nav section whose first inhabitant is the API → Subscription + migration calculator. The section is built as an extensible registry so a second (and third) + calculator can be added later without touching the first. +

+

+ The interactive reference — same math, same controls — lives in + prototype.html. That doc is the what; this is the + how, wired into the app's real architecture (Server Components → server actions → Drizzle, + shadcn primitives, design tokens). +

+

Drafted 2026-06-09 · author: T. Studer · branch: 035-scenario-calculators · estimate: ~3–4 dev days

+
+ +
+ Reference prototype. prototype.html is the validated single-file + version generated from live Neon data (project broad-shadow-82397229, window 27 Feb – 9 Jun 2026). + Its numbers were cross-checked against anthropic_usage_metrics and a Node re-computation. The app version + must reproduce those figures exactly when fed the same data. +
+ +

Contents

+ + + +

1 · Goal & scope

+ +
+

In scope

+
    +
  • A new top-level Scenarios section (sidebar entry, admin-only) at /scenarios.
  • +
  • An index page listing available calculators, driven by a registry — so adding calculator #2 is a one-line registry entry + its own route.
  • +
  • The first calculator at /scenarios/api-subscription, fed by live data (Anthropic usage + license assignments + seat prices), reproducing the prototype's KPIs, scenario cards, comparison bars, and per-user mapping table.
  • +
  • A pure, tested calculation module shared by server (initial render) and client (live re-compute on control changes).
  • +
+

Out of scope (this spec)

+
    +
  • Any DB schema change — the feature is read-only over existing tables.
  • +
  • The second calculator itself (only the seams that let it slot in).
  • +
  • Persisting/sharing saved scenario presets, CSV/PDF export — noted as follow-ups, not built now.
  • +
  • Currency conversion (USD→CHF) — see risks; deferred unless requested.
  • +
+
+ + +

2 · What the first calculator does

+ +

+ Every user holding a Claude Console (Anthropic API) license key is a metered, pay-as-you-go + consumer. The calculator maps each one onto a flat Claude Standard or Premium + seat and compares the projected bill against today's API spend, under four scenarios: +

+
    +
  • Metered API (baseline) — sum of each user's actual API spend on the chosen basis.
  • +
  • All → Standard — every user on the cheapest seat.
  • +
  • All → Premium — every user on the top seat.
  • +
  • Right-sized — users whose API spend ≥ a threshold get Premium; the rest get Standard.
  • +
+

Live, recomputing controls: Standard $/mo, Premium $/mo, Premium threshold (slider), + usage basis (avg of complete months / latest / peak / specific month), and + population (all keys vs. active only). Sortable per-user table with a + Δ seat − API column (green = seat cheaper than burn, clay = seat over-pays a light key).

+ +
+ Numbers it must hit on current data (all 47 keys, avg of complete months Mar/Apr/May 2026, + threshold = $125): baseline $3,041/mo, all-Standard $1,175/mo, + all-Premium $5,875/mo, right-sized $2,075/mo (9 Premium + 38 Standard) → + $966/mo (32%) saving vs. API. These are the regression anchors for the unit tests. +
+ + +

3 · Data sources (no schema change)

+ + + + + + + + + + + + + + + + + + + + + +
TableRole in the calculator
license_assignmentsThe population. API users = rows where tool_id = the Claude Console tool + and api_key_encrypted IS NOT NULL (47 today; 43 active). One row per user (dedupe by + row_number(), preferring active + latest assigned_at). Carries status, + workspace, and the internal boost tier (context only).
anthropic_usage_metricsThe spend. computed_cost_cents per user/day/model, summed by + to_char(date,'YYYY-MM') into a per-user monthly map.
access_tiers + ai_toolsThe seat prices. The Claude subscription tool's two active tiers supply the + defaults: Standard Seat = 2500¢, Premium Seat = 12500¢. Read live (lowest active + tier = Standard, highest = Premium) so config edits flow through automatically.
usersDisplay name, email, status, discipline (for the table + any future grouping).
+ +

Tool resolution — don't hardcode IDs

+

The prototype hardcoded tool ids (Claude Console = 2, Claude = 3). The app must resolve by + vendor + name to survive reseeded/other environments:

+
// API tool: vendor 'Anthropic', name 'Claude Console'  (the one whose assignments carry api keys)
+// Seat tool: vendor 'Anthropic', name 'Claude'          (its access_tiers = Standard/Premium)
+ +

Complete vs. partial months

+

The prototype baked in "Mar/Apr/May are complete." The app computes it: a month is complete when its last + calendar day is strictly before today (UTC) and the dataset's earliest date is on/before that month's first + day (so a month the org joined mid-way isn't treated as full). The current month is always partial. This + matches the standing fact that Anthropic's cost_report only returns complete UTC days. The default basis + averages the complete months; partial months are shown in the table (muted, "MTD") but excluded from the run-rate.

+ +

Validated aggregation query (reference)

+
WITH console AS (
+  SELECT la.user_id, la.status, la.workspace, at.name AS tier_name,
+         row_number() OVER (PARTITION BY la.user_id
+           ORDER BY (la.status='active') DESC, la.assigned_at DESC) rn
+  FROM license_assignments la
+  JOIN access_tiers at ON at.id = la.tier_id
+  WHERE la.tool_id = :claudeConsoleToolId
+    AND la.api_key_encrypted IS NOT NULL
+)
+SELECT u.id, u.name, u.email, u.discipline, c.status, c.workspace, c.tier_name,
+       to_char(m.date,'YYYY-MM') AS month, SUM(m.computed_cost_cents) AS cents
+FROM console c
+JOIN users u ON u.id = c.user_id
+LEFT JOIN anthropic_usage_metrics m ON m.user_id = c.user_id
+WHERE c.rn = 1
+GROUP BY u.id, u.name, u.email, u.discipline, c.status, c.workspace, c.tier_name, 2
+ORDER BY u.name;
+

Implemented with the Drizzle query builder in lib/scenarios/queries.ts; raw SQL shown only for clarity.

+ + +

4 · Architecture & routing

+ +
+
/scenarios index — registry-driven cards (server component) + │ + └── /scenarios/api-subscription page.tsx (server) → requireAdmin + load dataset + │ + └── api-subscription-client.tsx "use client" — controls + live recompute + │ imports + ▼ + lib/scenarios/api-subscription.ts pure calc engine (no React, no db) + lib/scenarios/queries.ts Drizzle reads (server only) + lib/scenarios/registry.ts calculator catalogue (slug/title/status) +
+
+ +

The split mirrors the existing /claude feature: a server page.tsx does + requireAdmin(), awaits the dataset via a "use server" action, and hands plain serializable + data to a "use client" component. The novelty is the pure engine in lib/ + (no "use server", no React) so the same functions run on the server for first paint and in the + browser on every slider drag — guaranteeing the displayed totals can't drift from the tested ones.

+ +
+
+

Why a registry, not just two routes

+

The user wants more calculators later. A registry.ts list is the single source of truth for the + index cards, the optional section tab strip, and "coming soon" placeholders. Adding calculator #2 = + append one entry + create its route folder. No edits to calculator #1.

+
+
+

Caching

+

The dataset query is wrapped in unstable_cache tagged scenarios:api-subscription, + revalidated by the Anthropic usage sync (same hook that already revalidates /claude) and on a short + TTL. The page is dynamic (admin-gated) so it's never statically cached at the route level.

+
+
+ + +

5 · The pure calculation engine

+ +

File: src/lib/scenarios/api-subscription.ts. No imports from db, react, or + next — only types and plain math. Fully unit-tested.

+ +
// ---- inputs the UI controls map onto ----
+export type UsageBasis =
+  | "avgComplete"      // mean of complete months (default)
+  | "latestComplete"   // most recent complete month
+  | "peakComplete"     // max single complete month
+  | { month: string }; // a specific 'YYYY-MM'
+
+export type ScenarioInputs = {
+  standardCents: number;
+  premiumCents: number;
+  premiumThresholdCents: number;  // usage >= threshold ⇒ Premium
+  basis: UsageBasis;
+  population: "all" | "active";
+};
+
+// ---- per-user shape coming out of queries.ts ----
+export type ApiUser = {
+  userId: number; name: string; email: string;
+  status: "active" | "inactive";
+  workspace: string | null; internalTier: string | null;
+  monthly: Record<string, number>;   // 'YYYY-MM' -> cents
+};
+
+export type ApiSubscriptionDataset = {
+  users: ApiUser[];
+  completeMonths: string[];           // sorted asc
+  partialMonths: string[];
+  defaultStandardCents: number;       // from access_tiers
+  defaultPremiumCents: number;
+  generatedAt: string;                // ISO; stamped by caller
+};
+
+// ---- pure functions (the testable core) ----
+export function usageForUser(u: ApiUser, ds: ApiSubscriptionDataset, basis: UsageBasis): number;
+export function mapSeat(usageCents: number, i: ScenarioInputs):
+  { tier: "standard" | "premium"; seatCents: number };
+export function computeScenarios(ds: ApiSubscriptionDataset, i: ScenarioInputs): {
+  rows: Array<{ user: ApiUser; usageCents: number; tier: "standard" | "premium";
+                seatCents: number; deltaCents: number }>;
+  baselineCents: number;
+  allStandardCents: number;
+  allPremiumCents: number;
+  rightSizedCents: number;
+  premiumCount: number;
+  standardCount: number;
+};
+ +

The client component holds only the ScenarioInputs in React state; everything rendered (cards, bars, + table, verdict line) is derived by calling computeScenarios(dataset, inputs) on each render. No duplicated + arithmetic in JSX.

+ + +

6 · Extensibility contract (future calculators)

+ +
// src/lib/scenarios/registry.ts
+export type ScenarioStatus = "live" | "soon";
+export type ScenarioMeta = {
+  slug: string;            // URL segment under /scenarios
+  title: string;
+  blurb: string;           // one line for the index card
+  icon: LucideIcon;
+  status: ScenarioStatus;
+};
+
+export const SCENARIOS: ScenarioMeta[] = [
+  { slug: "api-subscription", title: "API → Subscription migration",
+    blurb: "Map metered Anthropic API users onto flat Standard/Premium seats.",
+    icon: ArrowLeftRight, status: "live" },
+  { slug: "budget-forecast", title: "Budget / Cost Forecast Simulation",
+    blurb: "Project spend forward and simulate budget outcomes.",
+    icon: LineChart, status: "soon" },   // calculator #2 — stubbed now, built later
+];
+ +

A new calculator slots in by: (1) appending a ScenarioMeta; (2) adding + src/app/scenarios/<slug>/page.tsx + client; (3) optionally its own loader in + queries.ts and pure module. The index page and tab strip render straight off SCENARIOS; + status: "soon" entries render as non-clickable "coming soon" cards.

+ + +

7 · Phasing (file-level)

+ +
+

Phase 0 — Section scaffolding

~0.5 day
+
    +
  • Add { title: "Scenarios", href: "/scenarios", roles: ["admin"] } to navItems in + src/components/app-sidebar.tsx (place after Reports).
  • +
  • Create src/lib/scenarios/registry.ts with the ScenarioMeta type + one live entry.
  • +
  • Create src/app/scenarios/page.tsx — server component, requireAdmin(), renders a + grid of shadcn Cards from SCENARIOS (live → Link; soon → disabled).
  • +
  • Optional src/components/scenarios/scenario-tabs.tsx (mirrors claude-tabs.tsx) — + only rendered once ≥2 live calculators exist.
  • +
+
+ +
+

Phase 1 — Data layer

~1 day
+
    +
  • src/lib/scenarios/types.ts — the dataset & input types (or co-locate in the engine file).
  • +
  • src/lib/scenarios/queries.tsgetApiSubscriptionDataset(): resolve the two tools + by vendor+name, run the aggregation query via Drizzle, classify complete/partial months, read seat-price defaults + from access_tiers, return ApiSubscriptionDataset. Guard the empty/zero-tool cases.
  • +
  • src/actions/scenarios.ts"use server"; requireAdmin() then the + cached loader (unstable_cache, tag scenarios:api-subscription).
  • +
  • Integration test seam: a fixture dataset object reused by unit tests + the client storybook-ish manual check.
  • +
+
+ +
+

Phase 2 — Calculator page & UI

~1.5 days
+
    +
  • src/lib/scenarios/api-subscription.ts — the pure engine (§5). Write tests first (TDD anchors from §2).
  • +
  • Threshold control: a styled native <input type="range"> (accent fill, mono readout). + @radix-ui/react-slider is not installed; a native range adds no dependency and reads as a mechanical + instrument — on-brand for Nothing. Revisit a Radix slider only if richer keyboard semantics are needed.
  • +
  • src/app/scenarios/api-subscription/page.tsx — server: requireAdmin(), await + action, compute the default scenario server-side for first paint, pass dataset + defaults to client.
  • +
  • src/app/scenarios/api-subscription/api-subscription-client.tsx"use client": + controls → ScenarioInputs state → computeScenarios() → KPI strip, four scenario + Cards, comparison bars (segmented-bar or simple token-styled divs), verdict line, and the + sortable per-user Table with Sparkline month trends and the Δ column.
  • +
  • Empty state (no API users / no usage yet) and loading via Suspense + LoadingState.
  • +
+
+ +
+

Phase 3 — Polish & a11y

~0.5 day
+
    +
  • Design-token pass (no hardcoded hex; use text-ink, muted-foreground, + destructive, etc.). Mono uppercase labels to match the house dashboard look — not the + prototype's standalone editorial palette.
  • +
  • Capacity caveat: badge/tooltip on users whose API basis spend ≫ a Standard seat under the + all-Standard scenario ("a $25 seat may not cover this workload").
  • +
  • Keyboard + SR labels on slider/select/toggle; column-sort buttons announce direction; responsive + table scroll on mobile.
  • +
  • pnpm lint (zero warnings), pnpm typecheck, pnpm format.
  • +
+
+ +
+

Phase 4 — Tests

~0.5 day
+
    +
  • tests/unit/scenarios/api-subscription.test.tsusageForUser (each basis), + mapSeat (threshold boundary: usage == threshold ⇒ Premium), computeScenarios + totals against the §2 fixture anchors (3041 / 1175 / 5875 / 2075).
  • +
  • Month-classification unit test (current month partial; mid-join month partial).
  • +
  • Optional tests/integration/scenarios/dataset.test.tsgetApiSubscriptionDataset() + against the real DB (gated like other integration tests).
  • +
  • Optional Playwright smoke: /scenarios shows the card; calculator renders 47 rows; + moving the threshold changes the right-sized total.
  • +
+
+ + +

8 · File manifest

+ + + + + + + + + + + + + + + + + +
FilePurpose
src/app/scenarios/page.tsxnewSection index; registry-driven calculator cards.
src/app/scenarios/api-subscription/page.tsxnewCalculator server page — auth, dataset load, first-paint scenario.
src/app/scenarios/api-subscription/api-subscription-client.tsxnewInteractive client UI (controls, cards, bars, table).
src/lib/scenarios/registry.tsnewCalculator catalogue (slug/title/blurb/icon/status).
src/lib/scenarios/types.tsnewDataset & input types (or fold into engine).
src/lib/scenarios/queries.tsnewgetApiSubscriptionDataset() — Drizzle reads, month classification, seat-price defaults.
src/lib/scenarios/api-subscription.tsnewPure calc engine (basis, mapSeat, computeScenarios).
src/actions/scenarios.tsnew"use server" wrapper: requireAdmin + cached loader.
src/components/scenarios/scenario-tabs.tsxoptSection tab strip — when ≥2 live calculators.
src/components/app-sidebar.tsxeditAdd the Scenarios nav item (admin).
tests/unit/scenarios/api-subscription.test.tsnewEngine unit tests with regression anchors.
CLAUDE.mdedit"Recent Changes" line for 035 (auto-gen convention).
+ + +

9 · Design & component mapping

+ + + + + + + + + + + + +
Prototype elementApp implementation
KPI stripReuse the kpi-strip pattern from components/claude, or shadcn Cards with mono labels.
Controls (prices / threshold / basis / population)Input (number), styled native range, Select, and a two-button segmented toggle (mechanical control, not switch).
Four scenario cards + verdictshadcn Card + Badge; verdict is a token-styled callout.
Horizontal comparison barscomponents/ui/segmented-bar (already in the kit).
Per-month mini-trendcomponents/ui/sparkline.
Per-user table (sortable, Δ column)shadcn Table; sort via local state or TanStack Table (already a dep). Money via formatCurrency() from lib/utils.
Standalone editorial theme (Fraunces/clay/ivory)Dropped — the app page uses the Nothing design system (spec 028 tokens): font-mono ALL-CAPS labels, text-ink/muted-foreground/faint greyscale hierarchy, red destructive as the single interrupt, SegmentedBar for the comparison viz, optics/spacing over borders. The editorial look stays in the prototype only.
+ + +

10 · Risks & decisions

+ + + + + + + + + + + + + +
TopicDecision / mitigation
Seat prices — live vs editableBoth. Default the price inputs from access_tiers (live), but keep them editable for what-if modelling. A "reset to live" affordance restores defaults.
Capacity realism"All → Standard" can look deceptively cheap; a $25 seat won't cover a $287/mo user. Surface a per-user warning + a footnote. The right-sized scenario is the honest default.
Partial monthsComputed dynamically (current month + mid-join months excluded from run-rate). Aligns with Anthropic complete-UTC-day behaviour.
CurrencyFigures are USD as billed by Anthropic; formatCurrency() drives display. No FX conversion this spec (open question below).
Tool resolutionResolve by vendor+name, not hardcoded ids; fail soft to an empty state if the Claude/Claude Console tools aren't present.
Role & data sensitivityAdmin-only (matches /claude, /reports). No API-key material is read — only aggregate spend & assignment metadata.
Performance~47 users × few months — one indexed aggregation. Cached; negligible.
Number parityShared pure engine + unit anchors prevent client/server drift and lock the prototype's figures.
+ + +

11 · Decisions (resolved)

+
+
    +
  • Design system: Nothing (spec 028 tokens) — confirmed via /nothing-design.
  • +
  • Calculator #2 = Budget / Cost Forecast Simulation — stubbed now as a status:"soon" registry card; built in a later spec.
  • +
  • Seat prices — live defaults from access_tiers, editable for what-if, with a "reset to live" affordance.
  • +
  • Default population — all 47 keys; toggle to active-only.
  • +
  • Currency — USD via formatCurrency(); CHF/FX is a follow-up.
  • +
  • Default threshold — $125 (Premium break-even).
  • +
  • Saved presets / export — deferred to a follow-up.
  • +
+
+ + +

12 · Acceptance checklist

+
    +
  • Scenarios appears in the admin sidebar; non-admins are redirected.
  • +
  • /scenarios lists the API→Subscription calculator (registry-driven).
  • +
  • /scenarios/api-subscription renders all 47 live API users with correct per-month spend.
  • +
  • Default view reproduces the §2 anchor figures exactly.
  • +
  • Slider / price / basis / population controls recompute cards, bars, verdict, and table live.
  • +
  • Δ column colours and capacity warnings behave as specified.
  • +
  • lint / typecheck / format clean; unit tests green with regression anchors.
  • +
  • Adding a second calculator requires no edits to the first (registry + new route only).
  • +
+ +
+ AI Developer Hub · spec/035-scenario-calculators · implementation plan · drafted 2026-06-09 · companion: prototype.html +
+ +
+ + diff --git a/specs/035-scenario-calculators/prototype.html b/specs/035-scenario-calculators/prototype.html new file mode 100644 index 00000000..beb81ff6 --- /dev/null +++ b/specs/035-scenario-calculators/prototype.html @@ -0,0 +1,557 @@ + + + + + +API → Subscription Migration Model · AI Developer Hub + + + + + + +
+ +
+
+
+
Cost Model · Anthropic API → Claude Seats
+

If every API key became a subscription seat.

+
+
+
+

Every user holding a Claude Console (Anthropic API) key is a pay‑as‑you‑go consumer. This model maps each one onto a flat Standard or Premium Claude seat and compares the bill against today's metered API spend. Every input below is live — set seat prices, slide the heavy‑user threshold, and the scenarios recompute.

+
+ +
+ + +
+
+ +
$
+
+
+ +
$
+
+
+
$125
+ +
+
+
+ + + +
+ + +
+
+
+ +
+ + +
+
01

Scenarios

+
+ +
+
+ + +
+
02

Per‑user mapping

+
+ + + + + + + + + + + + + + + + + +
User ▲▼Status ▲▼Mar ▲▼Apr ▲▼May ▲▼Jun·MTD ▲▼API basis ▲▼Seat ▲▼Seat $/mo ▲▼Δ seat − API ▲▼
+
+
+ + +
+

What you're looking at

+

The population is the 47 Claude Console license assignments that carry an Anthropic API key (tool Claude Console). These are the metered, pay‑as‑you‑go users — distinct from the Claude subscription tool, whose two seat tiers (Standard $25, Premium $125) supply the default prices here.

+ +

Where the money comes from

+

API spend is the real computed_cost_cents recorded per user per day in anthropic_usage_metrics, summed by month. Data runs 27 Feb → 9 Jun 2026. February (2 days) and June (partial) are excluded from the run‑rate; Mar/Apr/May are the only complete months, so the default basis averages them.

+ +

How a seat is assigned

+

A user is mapped to Premium when their API basis spend is at or above the threshold slider, otherwise Standard. Raising the threshold pushes more people onto cheaper Standard seats. The right‑sized scenario bills each user their mapped seat; all‑Standard and all‑Premium ignore the threshold.

+ +

Reading Δ seat − API

+

For each user, Δ = seat price − their API spend. Green means the flat seat is cheaper than what they burn on the API (a saving); clay means the seat costs more than their metered usage — typically light or dormant keys.

+ +

Caveats

+

Figures are in USD as billed by Anthropic; seat prices are whatever you type. A flat seat assumes comparable capability/quota to the user's API usage — verify that a Standard seat actually covers a heavy user's workload before migrating. Currency conversion, taxes, and annual‑commit discounts are not modelled. 4 keys are inactive and only count when the population toggle is set to “All”.

+
+ +
+
+ + + + diff --git a/specs/036-budget-forecast-simulation/implementation-notes.html b/specs/036-budget-forecast-simulation/implementation-notes.html new file mode 100644 index 00000000..c8d85eb3 --- /dev/null +++ b/specs/036-budget-forecast-simulation/implementation-notes.html @@ -0,0 +1,225 @@ + + + + + + Implementation Notes — Budget / Cost Forecast Simulation (036) + + + +
+ +
+
Implementation notes spec/036-budget-forecast-simulation running log
+

Implementation notes — Budget / Cost Forecast Simulation

+

A running record of where the build diverges from or interprets the spec / plan: design decisions on ambiguous points, intentional deviations, tradeoffs considered, and open questions for review. Newest entries are appended under each heading as work proceeds.

+

Started 2026-06-09 · branch 036-budget-forecast-simulation · companion: implementation-plan.html · prototype.html

+
+ + +

Design decisions (ambiguous spots, choices made)

+ +
+

Historical actuals are NOT decomposed per tool data model

+

The plan shows per-tool growth, which implies per-tool history. But the schema can't reliably attribute + billed_costs invoices to individual tools (only a free-text description), and mixing real + metered API cost with synthesized seat costs would be dishonest. Decision: elapsed periods use the budget's + real combined actual (billed + running) straight from the Budget Report's periodsWithActual; + per-tool decomposition applies to the forecast only. The engine's ForecastDataset.periods[] + carries actualCents per period; tools carry current state (seats0, burn0, + prices) but no per-period history.

+

Consequence: the "Include" toggle scopes the forecast, not history. "Spent to date" is the real budget + actual and is independent of which tools are toggled. The stacked monthly bars show one Actual block for elapsed + periods and a per-tool stack for forecast periods.

+
+ +
+

Editable modeled ceiling, defaulting to the live ceiling UX

+

With all five tools in scope (per review), the all-in portfolio runs well above the live $42,000 budget, + which was effectively sized for the Anthropic API line alone. Decision: the ceiling the verdict compares + against is an editable assumption defaulting to the live budget ceiling, so an owner can model an approved + portfolio budget and still reach a green ("under") state via the levers. The live ceiling stays visible in the KPI + strip for reference. This is modelling only — it never writes back to the budget (that's an explicit follow-up).

+
+ +
+

Engine generalised to the budget's real period grid engine

+

The prototype hardcoded 12 monthly periods. The app's engine (src/lib/scenarios/budget-forecast.ts) + iterates budget_periods as loaded, so it works for a monthly or quarterly budget. Growth offsets + are k = periodIndex − lastElapsedIndex; values are per-period.

+
+ +
+

Data-layer resolution specifics queries

+
    +
  • burn0 (metered API) = round( Σ(each user's average spend across completeMonths) ÷ user count ), + reusing getApiSubscriptionDataset(). The divisor is the full user count (active + inactive) so it reads + as cents-per-user-per-period.
  • +
  • Copilot seats0/price come from the latest copilot_billing_snapshots row + (totalSeats / seatCostCents), falling back to active-assignment count + the Business tier.
  • +
  • Claude seats: stdPrice/premPrice = lowest/highest active tier by cost; + premShare0 = active assignments on the highest tier ÷ seats0.
  • +
  • elapsed = period endDate (a 'YYYY-MM-DD' string) strictly < today's UTC date string.
  • +
  • Tools resolve by vendor+name and are silently skipped if absent — a reseeded DB just shows fewer tool rows.
  • +
+
+ + +

Deviations (intentional departures from the spec)

+ +
+

Prototype default scope (3 tools) ≠ app default (5 tools) scope

+

Per review, the live app defaults to all five tools on. The prototype and the unit-test fixture isolate + three (API · Copilot · Cursor) so the regression anchors stay stable as live data moves. The engine math is + identical; only the default include set differs. The §3 anchor figures in the plan describe the + 3-tool fixture, not the live 5-tool default view.

+
+ +
+

Per-tool "FY total" column dropped; planned line corrected UI

+

The build agent's first pass (a) reconstructed per-tool FY totals as currentMonthlyCost × elapsedCount + + forecast — a fabricated per-tool history that didn't foot to the real combined actual; and (b) drew the + chart's "planned" line by reusing plan.cumulative, duplicating the plan line. Both were corrected in + integration: the per-tool table now shows forecast periods + a forecast subtotal with an honest + Planned (budget) footer row (real plannedAmountCents per remaining period), and the chart's + planned staircase is the true cumulative of periods[].plannedCents across the whole year.

+
+ + +

Tradeoffs (alternatives weighed)

+ +
+

Pure engine authored directly; dependent files fanned out via workflow process

+

The engine is the shared type contract every other file depends on, and it's already verified in the prototype. + Writing it in one coherent pass (rather than distributing it) removes type-drift risk; the data layer, client UI, + and tests are then built in parallel against the locked contract, and integration/typecheck/PR stay in the main + loop where they're deterministic.

+
+ +
+

Reconcile actuals via the Budget Report, not a fresh query reuse

+

Anthropic spend appears as both finalized billed_costs and metered + anthropic_workspace_costs — summing both double-counts. Reusing the report's + periodsWithActual (actual = billed + running) gives one definition of period actuals that + already agrees with the Budget Report to the cent, instead of re-deriving (and risking drift from) it.

+
+ + +

Open questions (resolved 2026-06-09)

+ +
+

Editable ceiling framing confirmed

+

Confirmed by the user: keep the ceiling editable, defaulting to the live budget ceiling, so an owner can + model an approved portfolio budget and reach a green state via the levers. No change needed — shipped as built.

+
+ +
+

burn0 basis for the metered API line confirmed

+

Confirmed by the user: burn0 = average across complete months ÷ active API users (the + sum-of-per-user-complete-month-averages basis the loader uses). No change needed — shipped as built.

+
+ + +

Review pass (adversarial workflow → fixes)

+ +
+

9 findings; all addressed 3 reviewers

+
    +
  • Editable ceiling applied inconsistently (medium/certain) — preset comparison rows & ghost lines scored + against the live ceiling while tiles/custom used the edited one. Fixed: presetInputs now spreads + inputs.ceilingCents and depends on it, so tiles, ghost lines, and the table agree.
  • +
  • Copilot baseline collapsed multi-org to one snapshot & ignored connection status (high/likely). + Fixed: filter status='active' connections and sum the latest snapshot per connection (blended + seat price).
  • +
  • OLS band fit over zero-actual elapsed periods (medium) — diverged from buildBudgetForecast. + Fixed: fit only on elapsed periods with actualCents > 0 (anchor still sums all elapsed); two + new unit tests lock it.
  • +
  • Breach could read as a future breach when already over (low). Fixed: breachIsFuture + gate — copy says "already over the ceiling" for a historical breach; the breach dot only sits on the forecast line.
  • +
  • Current in-progress period dropped / "matches to the cent" overclaim (medium). Resolved by + documentation: the current period is deliberately projected (not counted as partial actual); the loader + docstring & footnote now say completed periods match the Budget tab and "spent to date" trails by the open + period. (See open question on partial-period anchoring.)
  • +
  • OLS band had no legend/tooltip + orphaned bandFill config (low). Fixed: removed the dead + config key; added a caption under the chart explaining the shaded band.
  • +
  • Charts lacked a text alternative (low). Fixed: each chart wrapped in role="img" with a + summarising aria-label; the tables remain the accessible data source.
  • +
  • Stacked-bar legend reused chart-1 for both "Actual" and the first tool (low). Fixed: historical + "Actual" bars now use muted-foreground, distinct from the chart-1..5 tool ramp.
  • +
  • Burn-cap slider conflated undefined with $0 (low). Fixed: slider max = "No cap" (stored as + undefined → engine leaves the line uncapped); undefined no longer displays as "$0".
  • +
  • elapsed boundary string-vs-Date compare (low) — kept intentionally: a period is elapsed only once fully + past, so the in-progress period (incl. its end day) is projected, consistent with the history/forecast split.
  • +
+
+ +
+

PR #114 AI review round copilot + vercel

+
    +
  • GitHub Copilot Code Review — one inline comment: the footer's + new Date(generatedAt).toLocaleString() is timezone-dependent and risks a React hydration mismatch + (server vs browser). Fixed (commit 6b8e89b): render a deterministic + YYYY-MM-DD HH:MM UTC string sliced from the ISO timestamp; replied on the thread.
  • +
  • Vercel Agent Review — completed NEUTRAL, no actionable inline comments.
  • +
+
+ + +

Verification (what was checked)

+ +
+

Status green

+
    +
  • Unit tests: 31/31 pass (tests/unit/scenarios/budget-forecast.test.ts) — engine math, breach/cumulative/topDriver, trend band (incl. the zero-actual fit filter), presets. (One agent-authored anchor had a transposed digit, 9.4111929.41192; fixed.)
  • +
  • Typecheck: clean (the only tsc errors are pre-existing, unrelated mcp-handler module-resolution issues on main).
  • +
  • Lint: clean, zero warnings on all new/changed files.
  • +
  • Server render: GET /scenarios/budget-forecast returns 200 with the real component markers (verdict, KPIs, chart, scenario tabs) and no error/empty-state markers — the data layer resolves a live budget and the initial render doesn't throw.
  • +
+

Interactive Recharts behaviour (band stacking, control re-compute) leans on the shared pattern with the shipping + Budget Report chart plus the adversarial review pass; a manual visual pass in a browser is still worthwhile before merge.

+
+ +
+ AI Developer Hub · spec/036-budget-forecast-simulation · implementation notes · running log started 2026-06-09 +
+ +
+ + diff --git a/specs/036-budget-forecast-simulation/implementation-plan.html b/specs/036-budget-forecast-simulation/implementation-plan.html new file mode 100644 index 00000000..841df055 --- /dev/null +++ b/specs/036-budget-forecast-simulation/implementation-plan.html @@ -0,0 +1,505 @@ + + + + + + Implementation Plan — Budget / Cost Forecast Simulation (036) + + + +
+ +
+
+ Implementation plan + spec/036-budget-forecast-simulation + Draft — pending approval +
+

Budget / Cost Forecast Simulation

+

+ Build calculator #2 in the Scenarios section — the one stubbed as + status:"soon" back in spec 035. It anchors on the fiscal year's actual spend to + date, then projects the remaining periods forward under explicit, per-tool, per-tier + growth assumptions — so a budget owner can compare three (or N) futures against the + $42,000 ceiling, see exactly when each one breaches, and find the lever that pulls it back under. +

+

+ The validated interactive reference — same engine, same controls, same chart — lives in + prototype.html. That doc is the what; this is the how, + wired into the app's real architecture (Server Components → server actions → Drizzle, Recharts, shadcn + primitives, Nothing design tokens) and reusing the seams 035 already laid down. +

+

Drafted 2026-06-09 · author: T. Studer · branch: 036-budget-forecast-simulation · estimate: ~4–5 dev days

+
+ +
+ Reference prototype. prototype.html is the single-file version, + seeded from live Neon data (project broad-shadow-82397229, FY2026, actuals Jan–May). Its projection + engine — seatsAt / toolCostAt / project — is the exact shape the app's pure + module must reproduce. The numbers it lands (below) are the regression anchors for the unit tests. +
+ +

Contents

+ + + +

1 · Goal & scope

+ +
+

In scope

+
    +
  • Flip the registry entry budget-forecast from "soon""live" and ship the route /scenarios/budget-forecast (admin-only), reusing 035's section scaffolding.
  • +
  • A portfolio forecast anchored on the active budget's actual spend to date, projecting the remaining periods of the fiscal year forward.
  • +
  • Per-tool, per-tier growth levers: a row per AI tool (metered API, Claude seats, Copilot, Cursor, MS Copilot), each with a growth model (Flat / +N seats/period / +%/period), tier-mix, and — for the metered line — a per-user burn-growth % and a burn cap.
  • +
  • All five tools in scope by default (per review); each stays individually toggleable.
  • +
  • Three pre-built scenarios (Conservative / Expected / Aggressive) plus a live-editable Custom plan, all drawn together for comparison.
  • +
  • An editable modeled ceiling that defaults to the live budget ceiling — so the full portfolio can be tested against an adjusted target (the live $42k was sized for the API line alone).
  • +
  • An OLS trend bandforecast.ts's regression on historical actuals, drawn behind the assumption lines so the deliberate plan reads against what the trend alone implies.
  • +
  • Outputs: KPI strip, a cumulative burn-up chart (scenarios + trend band + ceiling + planned staircase + breach marker), per-tool stacked monthly bars, a scenario-comparison table, and a per-tool month-by-month detail table.
  • +
  • A pure, tested projection module shared by server (first paint) and client (live recompute) — same discipline as api-subscription.ts.
  • +
+

Out of scope (this spec)

+
    +
  • No DB schema change for v1 — read-only over existing budget, cost, and assignment tables.
  • +
  • Persisting / sharing saved scenarios (needs a table) — designed-for, deferred. See open questions.
  • +
  • Writing a scenario back to budget_periods.planned_amount_cents — a follow-up (it's a write path + audit concern).
  • +
  • Sensitivity / tornado analysis, per-discipline rollout — noted in the prototype's "ideas", built later.
  • +
  • Currency conversion (USD→CHF) — figures stay USD via formatCurrency(), matching 035.
  • +
+
+ + +

2 · What the calculator does

+ +

The active fiscal year is split into elapsed periods (which have real spend) and remaining periods + (which we project). The forecast never re-models the past — the cumulative line always starts from + where the budget actually stands today. This mirrors buildBudgetForecast() in + src/lib/forecast.ts, but swaps its single OLS regression for explicit, per-tool growth levers the user controls.

+ +
+
+

Two cost models, one engine

+
    +
  • Seat tools (Claude seats, Copilot, Cursor, MS Copilot): cost(p) = seats(p) × tierPrice.
  • +
  • Metered tool (Anthropic API / Claude Console): cost(p) = users(p) × burnPerUser(p) — because its cost is consumption, not a flat seat.
  • +
  • Growth per tool: Flat, +N seats/period (linear), or +r%/period (compounding).
  • +
+
+
+

Tiers & usage are levers too

+
    +
  • Premium share slider for Claude seats — shifts people between $25 Standard and $125 Premium without changing headcount.
  • +
  • Burn cap $/user on the metered line — drop it to simulate moving heavy API users onto flat seats (the play the API → Subscription calculator sizes).
  • +
  • Include toggle per tool — choose the forecast scope (which tools count against this budget).
  • +
+
+
+ +

Selecting a pre-built scenario loads its full parameter set into the controls; nudging anything makes it a + Custom plan. The three presets stay drawn on the chart as faint reference lines, so the user always + compares against them. The verdict line and KPI tiles flip green (under ceiling) or + clay (over, with a breach period) live.

+ + +

3 · Regression anchors (current data)

+ +
+ Numbers the engine must hit on the prototype's seed fixture (FY2026, $42,000 ceiling, actuals + Jan–May, scope isolated to API · Copilot · Cursor — the frozen unit-test fixture): + + + + + + + + + +
ScenarioYear-endΔ vs ceilingBreachesTop driver
Actuals to date (Jan–May)$18,25143% of budget
Conservative — hold the line$49,544+$7,544NovClaude Console (API)
Expected — steady adoption$59,324+$17,324OctClaude Console (API)
Aggressive — org-wide rollout$113,571+$71,571AugClaude Console (API)
Mitigated custom (burn cap $55/user + sunset Copilot)$41,144−$856neverClaude Console (API)
+
+ +

As in 035: the app derives actuals from live data (which moves), so the unit tests assert against a + frozen fixture — the prototype's seed — not the live DB. The honest headline the data tells is that + every plausible growth path overshoots a ceiling that was sized for the API line alone; the calculator's job + is to quantify the overage, name the driver, date the breach, and let the user test mitigations back to green.

+ +

Per review, the live app now defaults to all five tools in scope (the fixture above isolates + three so the unit assertions stay stable as live data moves), and the modeled ceiling is editable, + defaulting to the live $42k. With all five on, the all-in portfolio runs well above $42k — the editable ceiling lets an + owner model an approved portfolio budget, and the per-tool levers still pull a Custom plan back under it.

+ + +

4 · Data sources & reuse

+ + + + + + + + + + + + +
Table / moduleRole in the forecast
annual_budgets (active)The ceiling (total_amount_cents), fiscal_year, and period_type (monthly / quarterly — the projection granularity).
budget_periodsThe period grid: labels, start_date/end_date (→ elapsed vs remaining), and planned_amount_cents (the faint planned staircase on the chart).
billed_costsInvoiced actuals per closed period (Anthropic, GitHub, Cursor, Anysphere…). Spine of "spent to date".
anthropic_workspace_costsMetered running cost per day → the current/incomplete period's actual + the API run-rate baseline. (Reuse getRunningCostsForPeriod() in lib/budget-utils.ts.)
license_assignments + access_tiers + ai_toolsCurrent seat counts per tool/tier and tier prices — the starting state and growth base for every seat line. Resolve tools by vendor+name (don't hardcode ids), same rule as 035.
anthropic_usage_metricsPer-user API burn → derive burn/user and the heavy-user distribution that powers the burn-cap / migrate-to-seats lever.
copilot_billing_snapshotsCopilot seat trend (95 → 71 and falling) + seat_cost_cents — seeds the Copilot line and its default decline.
+ +
+
+

Reuse, don't rebuild

+
    +
  • getBudgetReportData() / buildBudgetForecast() already assemble budget + periodsWithActual (billed + running per period). The forecast loader can lean on the same orchestration for the actuals spine.
  • +
  • getApiSubscriptionDataset() (035) already returns per-user API spend — reuse it verbatim for the metered line's current state and the migration mitigation.
  • +
  • forecast.ts's olsRegression drives the trend band (v1) — fit on historical actuals, projected over the remaining periods.
  • +
+
+
+

Schema

+

v1 is read-only — no migration. The two "ideas" that need writes (save named scenarios; push a + scenario into planned_amount_cents) are deferred; if approved for v1 they'd add a single + forecast_scenarios table (JSON params + label + budget_id) reviewable on its own.

+
+
+ + +

5 · Reconciling "actuals to date"

+ +
+ The one subtlety worth getting right. Anthropic spend appears twice: as finalized + billed_costs invoices and as metered anthropic_workspace_costs. Summing both + double-counts. The Budget Report's rule is Actual = billed + running, where running is meant for + the open period. The forecast must adopt one consistent definition of period actuals and reuse it, so the cumulative + line agrees with the Budget Report to the cent. +
+ +

Decision: reuse the report's periodsWithActual as the single source of period actuals. + Elapsed periods (end date < today) contribute their computed Actual to "spent to date"; the current partial period + is shown but its forward portion is projected, not doubled. Projection then begins at the first period whose + start date is on/after today — identical to buildBudgetForecast()'s split.

+ + +

6 · Architecture & routing

+ +
+
/scenarios index — registry cards (budget-forecast now "live") + │ + └── /scenarios/budget-forecast page.tsx (server) → requireAdmin + load dataset + │ + └── budget-forecast-client.tsx "use client" — controls + live recompute + Recharts + │ imports + ▼ + lib/scenarios/budget-forecast.ts pure projection engine (no React, no db) + lib/scenarios/budget-forecast-queries.ts Drizzle reads + reuse report/035 loaders + lib/scenarios/registry.ts flip budget-forecast → "live" +
+
+ +

Identical shape to api-subscription: a server page.tsx does requireAdmin(), + awaits the dataset via a "use server" action (cached, tagged + scenarios:budget-forecast, revalidated by the same sync hooks that touch budget/usage data), computes the + default (Expected) scenario server-side for first paint, and hands plain serializable data to the client. The + pure engine in lib/ runs on both sides so chart totals can't drift from the tested ones.

+ + +

7 · The pure projection engine

+ +

File: src/lib/scenarios/budget-forecast.ts. No imports from db, react, or + next. This is the prototype's project() formalised — generalised to the budget's real period + grid (monthly or quarterly) instead of a hardcoded 12 months.

+ +
// ---- one tool's current state + history, from queries.ts ----
+export type ForecastTool = {
+  key: string; label: string; vendor: string;
+  kind: "metered" | "seat" | "claudeSeats";
+  actualByPeriod: number[];        // cents, elapsed periods (index-aligned to budget_periods)
+  seats0: number;                  // current seat / key count
+  burn0?: number;                  // metered: cents per user / period
+  price?: number;                  // seat tools: cents per seat
+  stdPrice?: number; premPrice?: number;  // claudeSeats: the two tiers
+};
+
+// ---- per-tool levers the UI binds to ----
+export type ToolParams = {
+  include: boolean;
+  model: "flat" | "linear" | "compound";
+  val: number;                     // seats/period (linear) or %/period (compound)
+  burnPct?: number;                // metered: burn growth %/period
+  burnCap?: number;                // metered: max cents/user/period
+  premShare?: number;              // claudeSeats: 0..1 Premium fraction
+};
+
+export type ForecastInputs = Record<string, ToolParams>;   // keyed by tool.key
+
+export type ForecastDataset = {
+  ceilingCents: number;
+  periods: { label: string; planned: number; elapsed: boolean }[];
+  lastElapsedIndex: number;        // projection starts after this
+  tools: ForecastTool[];
+  generatedAt: string;
+};
+
+// ---- pure functions (the testable core; mirrors prototype) ----
+export function seatsAt(s0: number, m: ToolParams["model"], val: number, k: number): number;
+export function toolCostAt(t: ForecastTool, p: ToolParams, k: number): number;  // k = periods after now
+
+export type ScenarioResult = {
+  perTool: Record<string, number[]>;   // cents per period
+  total: number[]; cumulative: number[];
+  yearEndCents: number;
+  breachIndex: number;                 // -1 if never
+  peakRunRateCents: number;
+  topDriver: string;
+};
+export function projectForecast(ds: ForecastDataset, inputs: ForecastInputs): ScenarioResult;
+
+// ---- the three named scenarios, as parameter sets ----
+export const FORECAST_PRESETS: Record<"conservative"|"expected"|"aggressive",
+  { label: string; tag: string; inputs: ForecastInputs }>;
+ +

The client holds only ForecastInputs + the active scenario name in state; KPIs, chart series, bars, + tables, and the verdict are all derived by calling projectForecast() — no arithmetic duplicated in JSX. + The three preset lines are computed once from FORECAST_PRESETS and memoised.

+ + +

8 · The burn-up chart (Recharts)

+ +

The prototype hand-rolls SVG; the app uses Recharts (already a dependency, already wrapped by the + Budget Report's ForecastCumulativeChart and shadcn's ChartContainer). One + ComposedChart over the period axis:

+
    +
  • Line × 4 — actuals (solid, elapsed periods only), then Conservative / Expected / Aggressive ghost lines + the bold Your plan line over the remaining periods. Colour the active line by over/under.
  • +
  • ReferenceLine at ceilingCents (red dashed, labelled) + a faint planned staircase via a stepped Line.
  • +
  • ReferenceArea shading the forecast region; ReferenceDot on the breach period with an "✕ breach" label.
  • +
  • OLS trend band (v1) — an Area between the regression upper/lower bounds (from forecast.ts's olsRegression on historical actuals), low-opacity, drawn behind the assumption lines.
  • +
  • Stacked monthly bars → a second small BarChart (stacked by tool), or shadcn stacked bars; reuse the chart colour ramp from design tokens (chart-1..5), not the prototype's editorial palette.
  • +
+

Tooltip and legend come from shadcn ChartTooltip/ChartLegend for token-consistent styling and a11y.

+ + +

9 · Phasing (file-level)

+ +
+

Phase 0 — Registry flip & route stub

~0.25 day
+
    +
  • In src/lib/scenarios/registry.ts, change budget-forecast status: "soon""live".
  • +
  • Create src/app/scenarios/budget-forecast/page.tsx (server, requireAdmin()) + an empty client shell so the index card becomes clickable.
  • +
+
+ +
+

Phase 1 — Data layer

~1.25 days
+
    +
  • src/lib/scenarios/budget-forecast-queries.tsgetBudgetForecastDataset(): load active budget + periods; build the actuals spine from the report's periodsWithActual (§5); resolve each tool's current seats/price/burn via license_assignments/access_tiers/copilot_billing_snapshots/anthropic_usage_metrics (reusing 035's loader for the API line); classify elapsed vs remaining periods.
  • +
  • src/actions/scenarios.ts — add loadBudgetForecastDataset() ("use server", requireAdmin(), unstable_cache tag scenarios:budget-forecast).
  • +
  • Handle empty/edge cases: no active budget, quarterly period type, fewer than the standard tool set, zero-price (bundled) tools.
  • +
+
+ +
+

Phase 2 — Projection engine + tests

~1 day
+
    +
  • src/lib/scenarios/budget-forecast.ts — port the prototype's pure functions; generalise the period loop to budget_periods length & granularity. Write tests first against the §3 anchors.
  • +
  • Encode FORECAST_PRESETS (Conservative / Expected / Aggressive) exactly as the prototype.
  • +
+
+ +
+

Phase 3 — Client UI

~1.5 days
+
    +
  • src/app/scenarios/budget-forecast/budget-forecast-client.tsx — scenario tabs (presets + Custom); per-tool control rows (Select model, number Input, native range for burn-cap / premium-share, Checkbox include); KPI strip; the Recharts burn-up chart (§8); stacked bars; scenario-comparison + per-tool detail Tables; verdict callout.
  • +
  • First-paint scenario computed server-side; reset-to-Expected affordance (mirrors 035's "reset to live").
  • +
  • Suspense + LoadingState; empty state when no active budget exists.
  • +
+
+ +
+

Phase 4 — Polish, a11y & tests

~0.75 day
+
    +
  • Design-token pass — Nothing tokens only (text-ink, muted-foreground, faint, destructive, success, chart-1..5); mono ALL-CAPS labels; not the prototype's editorial palette.
  • +
  • Keyboard + SR labels on every control; chart has an accessible summary; breach state announced.
  • +
  • tests/unit/scenarios/budget-forecast.test.tsseatsAt (each model), toolCostAt (metered cap boundary; claudeSeats mix), projectForecast totals + breach index against §3 anchors. Quarterly-period unit case.
  • +
  • Optional Playwright smoke: card live → chart renders → switching scenario + dragging burn-cap moves the year-end & flips the verdict colour.
  • +
  • pnpm lint (zero warnings), pnpm typecheck, pnpm format; CLAUDE.md "Recent Changes" line.
  • +
+
+ + +

10 · File manifest

+ + + + + + + + + + + + + + +
FilePurpose
src/app/scenarios/budget-forecast/page.tsxnewServer page — auth, dataset load, first-paint scenario.
src/app/scenarios/budget-forecast/budget-forecast-client.tsxnewInteractive client UI (tabs, controls, chart, bars, tables, verdict).
src/lib/scenarios/budget-forecast.tsnewPure projection engine (seatsAt, toolCostAt, projectForecast, presets).
src/lib/scenarios/budget-forecast-queries.tsnewgetBudgetForecastDataset() — budget + actuals spine + per-tool current state.
src/actions/scenarios.tseditAdd cached loadBudgetForecastDataset() server action.
src/lib/scenarios/registry.tseditFlip budget-forecast"live".
src/components/scenarios/scenario-tabs.tsxoptNow renders the section tab strip (≥2 live calculators — the trigger 035 anticipated).
tests/unit/scenarios/budget-forecast.test.tsnewEngine unit tests with §3 regression anchors.
CLAUDE.mdedit"Recent Changes" line for 036.
+ + +

11 · Design & component mapping

+ + + + + + + + + + + + +
Prototype elementApp implementation
KPI strip (ceiling / spent / projected / over-under)shadcn Cards with mono labels; over/under coloured via destructive / success tokens.
Scenario tabs (3 presets + Custom)Token-styled segmented buttons; selecting loads preset params into state. Mirrors the population toggle pattern in 035.
Per-tool control rowsSelect (model), number Input, styled native range (burn-cap, premium-share), Checkbox (include). No new deps.
SVG burn-up chartRecharts ComposedChart — lines + ReferenceLine (ceiling) + ReferenceArea (forecast) + ReferenceDot (breach), via shadcn ChartContainer. (§8)
Stacked monthly barsRecharts stacked BarChart with chart-1..5 token ramp.
Comparison + detail tablesshadcn Table; money via formatCurrency() / formatUSD0().
Editorial theme (Fraunces / clay / ivory)Dropped — app uses the Nothing design system (spec 028 tokens): greyscale hierarchy, single red destructive interrupt, mono caps. Editorial look stays in the prototype only.
+ + +

12 · Risks & decisions

+ + + + + + + + + + + + + + +
TopicDecision / mitigation
Actuals double-count (billed + running)Reuse the Budget Report's periodsWithActual as the single source of period actuals; project only periods starting on/after today. (§5)
Ceiling vs portfolio scopeThe $42k budget was effectively sized for the API line; the full portfolio overshoots it. Make scope explicit via per-tool Include toggles; default scope = currently-invoiced tools (API · Copilot · Cursor), Claude seats & MS Copilot off. The UI handles both green and red verdicts.
Period granularityEngine iterates the budget's real budget_periods (monthly or quarterly), not a hardcoded 12 — growth values are per-period. Covered by a unit test.
Unbounded growthLinear/compound can run away; document it and add an optional headcount cap (S-curve) as a fast-follow. v1 trusts the operator + shows the absurd number honestly.
Metered burn realismBurn/user is derived from complete months only (same complete-vs-partial rule as 035 / Anthropic complete-UTC-day reporting); the burn cap doubles as the migrate-to-seats lever.
Number parityShared pure engine + §3 anchors prevent client/server drift and lock the prototype's figures.
Tool resolutionBy vendor+name, never hardcoded ids; fail soft to a partial tool set / empty state.
Role & sensitivityAdmin-only (matches /reports, /claude, /scenarios). Aggregates only — no API-key material read.
PerformanceOne budget, ~12 periods, ~5 tools, ~50 API users — a couple of indexed aggregations, cached. Negligible.
+ + +

13 · Decisions (resolved)

+
+
    +
  • Default forecast scope = all five tools on — API · Claude seats · Copilot · Cursor · MS Copilot; each individually toggleable. The headline is the true all-in run-rate.
  • +
  • OLS trend band — in v1. Regression on historical actuals (forecast.ts), drawn behind the assumption lines.
  • +
  • Editable modeled ceiling — defaults to the live budget ceiling, editable so the all-in portfolio can be modelled against an adjusted target (the live $42k was sized for the API line alone). The live ceiling stays visible in the stamp/KPI.
  • +
  • Save & name scenarios — deferred to a follow-up (needs a forecast_scenarios table + write path).
  • +
  • "Apply scenario to the budget plan" (write planned_amount_cents) — follow-up; it crosses from modelling into editing the budget of record.
  • +
  • By precedent (035): Nothing design system; admin-only; USD via formatCurrency(); pure-engine + fixture anchors; registry-driven routing.
  • +
+
+ + +

14 · Acceptance checklist

+
    +
  • /scenarios shows Budget / Cost Forecast Simulation as a live, clickable card; a section tab strip appears now that two calculators are live.
  • +
  • /scenarios/budget-forecast renders the active FY budget, actuals-to-date matching the Budget Report to the cent, and the three preset lines + Your plan.
  • +
  • Default view reproduces the §3 anchor figures exactly on the frozen fixture.
  • +
  • Scenario tabs load presets; per-tool controls (model / growth / burn-cap / premium-share / include) recompute KPIs, chart, bars, tables, and verdict live.
  • +
  • Burn-up chart shows the ceiling, forecast shading, planned staircase, and a breach marker; the verdict + KPIs flip green/red correctly and a mitigated custom plan can return under budget.
  • +
  • Engine handles a quarterly budget and a missing-budget empty state.
  • +
  • lint / typecheck / format clean; unit tests green with regression anchors.
  • +
  • No schema change; feature is read-only over existing tables.
  • +
+ +
+ AI Developer Hub · spec/036-budget-forecast-simulation · implementation plan · drafted 2026-06-09 · companion: prototype.html +
+ +
+ + diff --git a/specs/036-budget-forecast-simulation/prototype.html b/specs/036-budget-forecast-simulation/prototype.html new file mode 100644 index 00000000..b438788a --- /dev/null +++ b/specs/036-budget-forecast-simulation/prototype.html @@ -0,0 +1,776 @@ + + + + + +Budget / Cost Forecast Simulation · AI Developer Hub + + + + + + +
+ +
+
+
+
Cost Model · FY2026 Budget Forecast
+

How the year ends, under three futures.

+
+
+
+

Five months of FY2026 are on the books. This model anchors on actual spend to date, then projects the rest of the year forward — letting you grow each tool's seats, tiers, and usage independently. Pick a scenario, bend the assumptions, and watch the cumulative burn cross (or clear) the $42,000 ceiling.

+
+ +
+ + +
+
01

Pick a future

+

Three pre-built scenarios set every assumption below at once. Selecting one loads it into the controls; nudge anything and you're editing a Custom plan. All three stay drawn on the chart so you always compare against them.

+
+ + +
+
+ Growth assumptions · Jun → Dec 2026 + +
+
+
+ +
+
+ + +
+
02

Cumulative burn vs ceiling

+
+ +
+
+ +
+
Monthly spend by tool — Your plan
+
+
+
+
+
+ + +
+
03

Scenario comparison

+
+ + + + + + + + + + + +
ScenarioYear-end totalΔ vs $42k ceiling% of ceilingBudget breachedPeak run-rateTop driver
+
+
+ + +
+
04

Your plan · month by month

+
+ + + + +
+
+
+ + +
+
05

How it works & what's modelled

+
+

Anchored on actuals, not guesses

+

Jan–May are real: metered API cost from anthropic_usage_metrics / anthropic_workspace_costs, plus invoiced seat costs from billed_costs. The forecast only touches Jun–Dec, so the line always starts from where the budget actually stands today. This mirrors buildBudgetForecast() in src/lib/forecast.ts — but swaps its single linear regression for explicit, per-tool growth levers.

+ +

Each tool grows on its own terms

+

Seat-based tools (Claude seats, Copilot, Cursor) project as seats(m) × tier price. The metered API line projects as users(m) × burn/user(m), because its cost is consumption, not a flat seat. Growth per tool is Flat, +N seats/mo (linear) or +r%/mo (compounding).

+ +

Tiers are levers too

+

For Claude seats you set the Premium share — shifting people between $25 Standard and $125 Premium reshapes the curve without changing headcount. The API line carries a burn cap $/user: drop it to simulate moving heavy API users onto flat seats (the exact play the API → Subscription scenario sizes).

+ +

Reading the chart

+

The solid dark line is actual cumulative spend through May. From there, three faint lines trace the pre-built scenarios and the bold line is your plan — green under the ceiling, clay over. The red dashed line is the $42,000 ceiling; the faint staircase is the admin's planned spend. A ✕ marks the month a line breaches budget.

+ +

Scope is selectable

+

Toggle a tool's Include box to add or drop it from the forecast. The default scope is the tools currently invoiced to this budget (API · Copilot · Cursor). Claude subscription seats and the bundled Microsoft Copilot start off — flip them on to model folding them into this budget.

+ +

Caveats

+

Figures are USD as billed. Growth is an assumption, not a prediction — the tool's job is to make the assumption explicit and its consequences legible. Annual-commit discounts, FX, taxes, and mid-year price changes are not modelled. Seat counts and prices are live from Neon; project them, don't trust them blindly.

+
+
+ +
+
06

Ideas & where this goes next

+
+
    +
  • Save & name scenarios v1 — persist a scenario (its per-tool assumptions) so a budget owner can revisit "Q3 board case" vs "frozen hiring" later, and share a link. A thin forecast_scenarios table, or just URL-encoded state.
  • +
  • Write a scenario back to the budget plan v1 — one click to push a chosen scenario's monthly totals into budget_periods.planned_amount_cents, turning a what-if into the actual plan the Budget Report charts against.
  • +
  • Migrate-to-seats mitigation links 035 — instead of a raw burn cap, let the user say "move the top N API users to flat Premium seats in month M" and pull the seat math straight from the API → Subscription calculator. Two scenarios, one shared engine.
  • +
  • Confidence band v2 — overlay the existing OLS regression forecast as a shaded ± band behind the assumption-driven line, so you see model-implied drift vs your deliberate plan.
  • +
  • Sensitivity / tornado v2 — "which lever moves year-end most?" Rank each assumption by its $ impact so owners know where to focus (today it's almost always API burn growth).
  • +
  • Per-team / per-discipline rollout v2 — drive seat growth off the disciplines model (spec 032) and headcount, so "onboard the 14-person design team to Claude" is a single input, not a guessed seat count.
  • +
  • Alert wiring v2 — when a saved scenario projects a breach, pre-arm anthropic_alert_state thresholds at the breach month so the forecast and the live alerting agree on "danger".
  • +
  • Headcount cap / S-curve growth backlog — bound linear/compound growth by a total addressable headcount so aggressive scenarios saturate realistically instead of running to infinity.
  • +
+
+
+ +
+
+ + + + diff --git a/src/actions/budget-extensions.ts b/src/actions/budget-extensions.ts new file mode 100644 index 00000000..1b3f5dd5 --- /dev/null +++ b/src/actions/budget-extensions.ts @@ -0,0 +1,414 @@ +"use server"; + +import { db } from "@/lib/db"; +import { + annualBudgets, + budgetPeriods, + budgetExtensions, + budgetExtensionPeriodAllocations, + changeHistory, +} from "@/lib/db/schema"; +import { eq, sql } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { requireAdmin } from "@/lib/auth-helpers"; +import { + createBudgetExtensionSchema, + updateBudgetExtensionSchema, + deleteBudgetExtensionSchema, +} from "@/lib/validators"; +import { recordCreation, recordUpdate } from "@/actions/history"; +import type { ActionResult } from "@/types"; +import { z } from "zod"; + +type AllocationInput = z.infer< + typeof createBudgetExtensionSchema +>["allocation"]; + +type ResolveResult = + | { ok: true; byPeriodId: Record } + | { ok: false; error: string }; + +/** + * Translate the user-chosen allocation mode into a map of period id → delta. + * The deltas sum to the extension's amountCents when the user chose a real + * allocation, or sum to 0 when they chose "unallocated" (the ceiling rises + * but no period is touched). + */ +function resolveAllocations( + amountCents: number, + allocation: AllocationInput, + periods: { id: number; endDate: string }[], + effectiveDate: string +): ResolveResult { + switch (allocation.mode) { + case "unallocated": + return { ok: true, byPeriodId: {} }; + + case "single_period": { + const exists = periods.find((p) => p.id === allocation.periodId); + if (!exists) return { ok: false, error: "Period not found in budget" }; + return { ok: true, byPeriodId: { [allocation.periodId]: amountCents } }; + } + + case "distribute_remaining": { + // "Remaining" = periods whose endDate >= effectiveDate. Falls back to + // all periods if effectiveDate is after every period's end (a late-dated + // extension landing past the final period — backdated extensions already + // match every period via the filter). + const remaining = periods.filter((p) => p.endDate >= effectiveDate); + const target = remaining.length > 0 ? remaining : periods; + if (target.length === 0) { + return { ok: false, error: "Budget has no periods to distribute into" }; + } + const per = Math.trunc(amountCents / target.length); + const remainder = amountCents - per * target.length; + const byPeriodId: Record = {}; + target.forEach((p, idx) => { + // Push the rounding remainder onto the first period so the sum is exact. + byPeriodId[p.id] = per + (idx === 0 ? remainder : 0); + }); + return { ok: true, byPeriodId }; + } + + case "custom": { + const sumCents = allocation.allocations.reduce( + (s, a) => s + a.amountCents, + 0 + ); + if (sumCents !== amountCents) { + return { + ok: false, + error: `Custom allocations sum to ${sumCents}; must equal extension amount ${amountCents}`, + }; + } + const validIds = new Set(periods.map((p) => p.id)); + const byPeriodId: Record = {}; + for (const a of allocation.allocations) { + if (!validIds.has(a.periodId)) { + return { ok: false, error: "Allocation references a period not in this budget" }; + } + byPeriodId[a.periodId] = (byPeriodId[a.periodId] ?? 0) + a.amountCents; + } + return { ok: true, byPeriodId }; + } + } +} + +export async function createBudgetExtension( + input: unknown +): Promise> { + const admin = await requireAdmin(); + if (!admin) return { success: false, error: "Unauthorized" }; + + const parsed = createBudgetExtensionSchema.safeParse(input); + if (!parsed.success) { + return { + success: false, + error: "Validation failed", + fieldErrors: parsed.error.flatten().fieldErrors, + }; + } + const data = parsed.data; + + // Load budget + periods (lightweight — we don't need billed costs here). + const budget = await db.query.annualBudgets.findFirst({ + where: eq(annualBudgets.id, data.budgetId), + with: { + periods: { + orderBy: (p, { asc }) => [asc(p.periodIndex)], + }, + }, + }); + if (!budget) return { success: false, error: "Budget not found" }; + if (budget.status === "archived") { + return { success: false, error: "Archived budgets cannot be modified" }; + } + + // Effective date must fall within the fiscal year (lexical compare works + // because the year prefix dominates an ISO date). + if (!data.effectiveDate.startsWith(`${budget.fiscalYear}-`)) { + return { + success: false, + error: "Effective date must fall within the fiscal year", + }; + } + + // Resolve the chosen allocation mode into per-period deltas. + const resolved = resolveAllocations( + data.amountCents, + data.allocation, + budget.periods.map((p) => ({ id: p.id, endDate: p.endDate })), + data.effectiveDate + ); + if (!resolved.ok) return { success: false, error: resolved.error }; + + // Guard: total allocations after this change must remain ≤ new ceiling + // and the new ceiling cannot go ≤ 0. + const newCeiling = budget.totalAmountCents + data.amountCents; + if (newCeiling <= 0) { + return { success: false, error: "Ceiling cannot drop to zero or below" }; + } + const newAllocTotal = budget.periods.reduce( + (sumCents, p) => + sumCents + + p.plannedAmountCents + + (resolved.byPeriodId[p.id] ?? 0), + 0 + ); + if (newAllocTotal > newCeiling) { + return { + success: false, + error: "Per-period allocations would exceed the new ceiling", + }; + } + // Guard: no period's planned amount may go negative (relevant for + // reductions). plannedAmountCents has a CHECK NOT NULL but no >= 0 + // constraint; the app keeps it non-negative as an invariant. + for (const p of budget.periods) { + const next = p.plannedAmountCents + (resolved.byPeriodId[p.id] ?? 0); + if (next < 0) { + return { + success: false, + error: `Period ${p.periodLabel} would have a negative planned amount`, + }; + } + } + + let extensionId: number = 0; + + await db.transaction(async (tx) => { + // 1. Insert the extension row. + const [ext] = await tx + .insert(budgetExtensions) + .values({ + budgetId: data.budgetId, + amountCents: data.amountCents, + reason: data.reason, + description: data.description ?? null, + category: data.category, + linkedToolId: data.linkedToolId ?? null, + effectiveDate: data.effectiveDate, + createdBy: Number(admin.id), + }) + .returning({ id: budgetExtensions.id }); + extensionId = ext.id; + + // 2. Bump the live ceiling. + await tx + .update(annualBudgets) + .set({ totalAmountCents: newCeiling, updatedAt: new Date() }) + .where(eq(annualBudgets.id, data.budgetId)); + + // 3. Write per-period allocation rows + bump plannedAmountCents. + for (const [periodIdStr, amt] of Object.entries(resolved.byPeriodId)) { + if (amt === 0) continue; + const periodId = Number(periodIdStr); + await tx + .insert(budgetExtensionPeriodAllocations) + .values({ extensionId: ext.id, periodId, amountCents: amt }); + await tx + .update(budgetPeriods) + .set({ + plannedAmountCents: sql`${budgetPeriods.plannedAmountCents} + ${amt}`, + updatedAt: new Date(), + }) + .where(eq(budgetPeriods.id, periodId)); + } + }); + + // 4. History (outside tx, matching the createBudget pattern). + await recordCreation("budget_extension", extensionId, Number(admin.id)); + + revalidatePath("/"); + revalidatePath("/budget"); + revalidatePath(`/budget/${data.budgetId}`); + revalidatePath("/budget/history"); + revalidatePath("/reports"); + revalidatePath("/reports/budget"); + + return { success: true, data: { id: extensionId } }; +} + +export async function updateBudgetExtension( + input: unknown +): Promise { + const admin = await requireAdmin(); + if (!admin) return { success: false, error: "Unauthorized" }; + + const parsed = updateBudgetExtensionSchema.safeParse(input); + if (!parsed.success) { + return { + success: false, + error: "Validation failed", + fieldErrors: parsed.error.flatten().fieldErrors, + }; + } + const data = parsed.data; + + const existing = await db.query.budgetExtensions.findFirst({ + where: eq(budgetExtensions.id, data.extensionId), + with: { budget: true }, + }); + if (!existing) return { success: false, error: "Extension not found" }; + if (existing.budget.status === "archived") { + return { success: false, error: "Archived budgets cannot be modified" }; + } + + // Build a partial patch of only the fields that changed, for the audit log. + const patch: Record = {}; + const changes: Record = {}; + + if (data.reason !== undefined && data.reason !== existing.reason) { + patch.reason = data.reason; + changes.reason = { old: existing.reason, new: data.reason }; + } + if (data.description !== undefined && data.description !== existing.description) { + patch.description = data.description; + changes.description = { old: existing.description, new: data.description }; + } + if (data.category !== undefined && data.category !== existing.category) { + patch.category = data.category; + changes.category = { old: existing.category, new: data.category }; + } + if ( + data.linkedToolId !== undefined && + data.linkedToolId !== existing.linkedToolId + ) { + patch.linkedToolId = data.linkedToolId; + changes.linkedToolId = { + old: existing.linkedToolId, + new: data.linkedToolId, + }; + } + + if (Object.keys(patch).length === 0) { + // Nothing to do — treat as success so the UI can close without error. + return { success: true, data: undefined }; + } + + patch.updatedAt = new Date(); + await db + .update(budgetExtensions) + .set(patch) + .where(eq(budgetExtensions.id, data.extensionId)); + + await recordUpdate( + "budget_extension", + data.extensionId, + Number(admin.id), + changes + ); + + revalidatePath("/budget"); + revalidatePath(`/budget/${existing.budgetId}`); + + return { success: true, data: undefined }; +} + +export async function deleteBudgetExtension( + input: unknown +): Promise { + const admin = await requireAdmin(); + if (!admin) return { success: false, error: "Unauthorized" }; + + const parsed = deleteBudgetExtensionSchema.safeParse(input); + if (!parsed.success) return { success: false, error: "Validation failed" }; + + const existing = await db.query.budgetExtensions.findFirst({ + where: eq(budgetExtensions.id, parsed.data.extensionId), + with: { + allocations: true, + budget: true, + }, + }); + if (!existing) return { success: false, error: "Extension not found" }; + if (existing.budget.status === "archived") { + return { success: false, error: "Archived budgets cannot be modified" }; + } + + // Symmetric guard with createBudgetExtension: refuse the reversal if it + // would drive any period's planned amount below zero. Can happen when a + // user manually lowered plannedAmountCents (via updateBudgetAllocations) + // *after* the extension was created — the original allocation amount is + // no longer fully recoverable. + if (existing.allocations.length > 0) { + const affected = await db.query.budgetPeriods.findMany({ + where: (p, { inArray }) => + inArray( + p.id, + existing.allocations.map((a) => a.periodId) + ), + columns: { id: true, periodLabel: true, plannedAmountCents: true }, + }); + const byId = new Map(affected.map((p) => [p.id, p])); + for (const alloc of existing.allocations) { + const p = byId.get(alloc.periodId); + if (!p) continue; + if (p.plannedAmountCents - alloc.amountCents < 0) { + return { + success: false, + error: `Cannot delete: ${p.periodLabel} planned amount has been manually lowered and the reversal would go negative. Edit the period allocation first.`, + }; + } + } + } + + await db.transaction(async (tx) => { + // Reverse each per-period allocation. + for (const alloc of existing.allocations) { + await tx + .update(budgetPeriods) + .set({ + plannedAmountCents: sql`${budgetPeriods.plannedAmountCents} - ${alloc.amountCents}`, + updatedAt: new Date(), + }) + .where(eq(budgetPeriods.id, alloc.periodId)); + } + // Reverse the ceiling bump. + await tx + .update(annualBudgets) + .set({ + totalAmountCents: sql`${annualBudgets.totalAmountCents} - ${existing.amountCents}`, + updatedAt: new Date(), + }) + .where(eq(annualBudgets.id, existing.budgetId)); + // Cascade FK removes allocation rows. + await tx + .delete(budgetExtensions) + .where(eq(budgetExtensions.id, existing.id)); + }); + + // Record the deletion with a full snapshot of the row + allocations as the + // previousValue, matching the deleteBilledCost pattern in src/actions/budget.ts. + // (recordStatusChange isn't right here — budget_extensions has no status + // column, so an "active → deleted" transition would imply a field that + // doesn't exist.) + await db.insert(changeHistory).values({ + entityType: "budget_extension", + entityId: existing.id, + changeType: "deleted", + previousValue: JSON.stringify({ + budgetId: existing.budgetId, + amountCents: existing.amountCents, + reason: existing.reason, + description: existing.description, + category: existing.category, + linkedToolId: existing.linkedToolId, + effectiveDate: existing.effectiveDate, + allocations: existing.allocations.map((a) => ({ + periodId: a.periodId, + amountCents: a.amountCents, + })), + }), + changedBy: Number(admin.id), + }); + + revalidatePath("/"); + revalidatePath("/budget"); + revalidatePath(`/budget/${existing.budgetId}`); + revalidatePath("/budget/history"); + revalidatePath("/reports"); + revalidatePath("/reports/budget"); + + return { success: true, data: undefined }; +} diff --git a/src/actions/budget.ts b/src/actions/budget.ts index e7ca872e..6b2d4fa3 100644 --- a/src/actions/budget.ts +++ b/src/actions/budget.ts @@ -4,6 +4,7 @@ import { db } from "@/lib/db"; import { annualBudgets, budgetPeriods, + budgetExtensions, licenseAssignments, aiTools, billedCosts, @@ -15,7 +16,6 @@ import { requireAdmin } from "@/lib/auth-helpers"; import { budgetSchema, budgetAllocationSchema, - updateBudgetTotalSchema, billedCostSchema, updateBilledCostSchema, deleteBilledCostSchema, @@ -61,7 +61,14 @@ export async function createBudget( // Create budget const [budget] = await tx .insert(annualBudgets) - .values({ fiscalYear, totalAmountCents, periodType }) + .values({ + fiscalYear, + totalAmountCents, + // At creation, the live ceiling IS the original baseline. + // Extensions later mutate totalAmountCents but never originalAmountCents. + originalAmountCents: totalAmountCents, + periodType, + }) .returning({ id: annualBudgets.id }); budgetId = budget.id; @@ -207,59 +214,13 @@ export async function updateBudgetAllocations( return { success: true, data: undefined }; } -export async function updateBudgetTotal( - input: unknown -): Promise> { - const admin = await requireAdmin(); - if (!admin) return { success: false, error: "Unauthorized" }; - - const parsed = updateBudgetTotalSchema.safeParse(input); - if (!parsed.success) { - return { success: false, error: "Validation failed" }; - } - - const { budgetId, totalAmountCents } = parsed.data; - - const budget = await db.query.annualBudgets.findFirst({ - where: and( - eq(annualBudgets.id, budgetId), - eq(annualBudgets.status, "active") - ), - with: { periods: true }, - }); - if (!budget) { - return { success: false, error: "Active budget not found" }; - } - - const currentAllocations = budget.periods.reduce( - (sum, p) => sum + p.plannedAmountCents, - 0 - ); - if (totalAmountCents < currentAllocations) { - return { - success: false, - error: "New total cannot be less than existing allocations", - }; - } - - await db - .update(annualBudgets) - .set({ totalAmountCents, updatedAt: new Date() }) - .where(eq(annualBudgets.id, budgetId)); - - await recordUpdate("annual_budget", budgetId, Number(admin.id), { - totalAmountCents: { - old: budget.totalAmountCents, - new: totalAmountCents, - }, - }); - - revalidatePath("/budget"); - revalidatePath(`/budget/${budgetId}`); - revalidatePath("/reports"); - revalidatePath("/reports/budget"); - return { success: true, data: undefined }; -} +// NOTE (spec 026): the former `updateBudgetTotal` action was removed. It +// mutated `totalAmountCents` directly without touching `originalAmountCents`, +// which would silently break the `total = original + Σ extensions` invariant +// that the budget hero's "baseline + extended" tag relies on. It had no UI or +// test callers left. Ceiling changes now go exclusively through +// `createBudgetExtension` / `deleteBudgetExtension`, which keep the invariant +// and leave an audit trail with a reason. export async function archiveBudget(input: { id: number; @@ -317,10 +278,34 @@ export async function getBudgetById(id: number) { }); } -export async function getBudgets() { - return db.query.annualBudgets.findMany({ - orderBy: (b, { desc }) => [desc(b.fiscalYear)], - }); +/** + * List all budgets (active + archived) augmented with an extension summary + * — count and net delta per budget. Used by the budget history page. + */ +export async function getBudgets(): Promise< + (AnnualBudget & { extensionCount: number; extensionNetCents: number })[] +> { + const [budgets, extensions] = await Promise.all([ + db.query.annualBudgets.findMany({ + orderBy: (b, { desc }) => [desc(b.fiscalYear)], + }), + db + .select({ + budgetId: budgetExtensions.budgetId, + count: count(), + netCents: sum(budgetExtensions.amountCents).mapWith(Number), + }) + .from(budgetExtensions) + .groupBy(budgetExtensions.budgetId), + ]); + const byBudget = new Map( + extensions.map((e) => [e.budgetId, { count: e.count, net: e.netCents ?? 0 }]) + ); + return budgets.map((b) => ({ + ...b, + extensionCount: byBudget.get(b.id)?.count ?? 0, + extensionNetCents: byBudget.get(b.id)?.net ?? 0, + })); } // US5: Expected spend calculation for a budget period (based on active license assignments) @@ -522,6 +507,14 @@ export async function getBudgetWithCosts( billedCosts: true, }, }, + extensions: { + orderBy: (e, { desc }) => [desc(e.effectiveDate), desc(e.id)], + with: { + allocations: true, + linkedTool: { columns: { name: true } }, + creator: { columns: { name: true } }, + }, + }, }, }); @@ -555,6 +548,15 @@ export async function getBudgetWithCosts( ) ); + // Sum extension allocations per period for the "+€X from extension" sub-label + const extensionByPeriod: Record = {}; + for (const ext of budget.extensions) { + for (const a of ext.allocations) { + extensionByPeriod[a.periodId] = + (extensionByPeriod[a.periodId] ?? 0) + a.amountCents; + } + } + const periodsWithCosts = budget.periods.map((period) => { const periodStart = new Date(period.startDate); const periodEnd = new Date(period.endDate); @@ -578,12 +580,21 @@ export async function getBudgetWithCosts( expectedSpendCents, billedTotalCents, billedEntries: period.billedCosts, + extensionAmountCents: extensionByPeriod[period.id] ?? 0, }; }); return { ...budget, periods: periodsWithCosts, + // Destructure the joined rows out so the raw `linkedTool` / `creator` + // objects don't ride along into the RSC payload — the flattened names + // are the type contract (BudgetExtensionWithAllocations). + extensions: budget.extensions.map(({ linkedTool, creator, ...e }) => ({ + ...e, + linkedToolName: linkedTool?.name ?? null, + createdByName: creator.name, + })), }; } diff --git a/src/actions/dashboard.ts b/src/actions/dashboard.ts index 52d1dfea..40f5563e 100644 --- a/src/actions/dashboard.ts +++ b/src/actions/dashboard.ts @@ -98,6 +98,8 @@ export interface AdminDashboardData { sync: SyncStatus; activity: DashboardActivityItem[]; budgetCeilingCents: number; + /** The originally approved ceiling — equal to budgetCeilingCents when not extended. */ + budgetOriginalCeilingCents: number; billedYtdCents: number; } @@ -173,6 +175,7 @@ export async function getAdminDashboardData(): Promise s + p.billedCents, 0); const budgetCeilingCents = activeBudget?.totalAmountCents ?? 0; + const budgetOriginalCeilingCents = activeBudget?.originalAmountCents ?? 0; const budgetRemainingCents = budgetCeilingCents - billedYtdCents; const utilizationPct = budgetCeilingCents > 0 ? (billedYtdCents / budgetCeilingCents) * 100 : 0; @@ -330,6 +333,7 @@ export async function getAdminDashboardData(): Promise getApiSubscriptionDataset(), + ["scenarios:api-subscription:v1"], + { tags: ["scenarios:api-subscription"], revalidate: 3600 }, +); + +export async function loadApiSubscriptionDataset(): Promise { + const admin = await requireAdmin(); + if (!admin) throw new Error("Unauthorized"); + return loadCached(); +} + +// The forecast draws on both the budget data and the Anthropic usage that feeds +// the metered API line, so it shares the api-subscription revalidation tag in +// addition to its own. +const loadForecastCached = unstable_cache( + () => getBudgetForecastDataset(), + ["scenarios:budget-forecast:v1"], + { + tags: ["scenarios:budget-forecast", "scenarios:api-subscription"], + revalidate: 3600, + }, +); + +export async function loadBudgetForecastDataset(): Promise { + const admin = await requireAdmin(); + if (!admin) throw new Error("Unauthorized"); + return loadForecastCached(); +} diff --git a/src/app/api/mcp/[transport]/route.ts b/src/app/api/mcp/[transport]/route.ts new file mode 100644 index 00000000..049d5546 --- /dev/null +++ b/src/app/api/mcp/[transport]/route.ts @@ -0,0 +1,30 @@ +/** + * MCP server endpoint (Model Context Protocol over Streamable HTTP). + * + * Mounts the Hub's read-only tools at `/api/mcp/mcp`. Auth is the shared + * `MCP_SERVER_SECRET` bearer token enforced by `withMcpAuth`; when the secret + * is unset the server rejects every request (see src/lib/mcp/auth.ts). + * + * This route is excluded from the NextAuth middleware matcher so unauthenticated + * clients receive a clean 401 instead of a redirect to /login. + */ + +import { createMcpHandler, withMcpAuth } from "mcp-handler"; + +import { registerHubTools } from "@/lib/mcp/tools"; +import { verifyMcpToken } from "@/lib/mcp/auth"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 60; + +const handler = createMcpHandler( + (server) => { + registerHubTools(server); + }, + {}, + { basePath: "/api/mcp" }, +); + +const authHandler = withMcpAuth(handler, verifyMcpToken, { required: true }); + +export { authHandler as GET, authHandler as POST, authHandler as DELETE }; diff --git a/src/app/budget/[id]/page.tsx b/src/app/budget/[id]/page.tsx index 9ea4fc92..bd82b26d 100644 --- a/src/app/budget/[id]/page.tsx +++ b/src/app/budget/[id]/page.tsx @@ -1,6 +1,7 @@ import { notFound } from "next/navigation"; import { auth } from "@/lib/auth"; import { getBudgetWithCosts } from "@/actions/budget"; +import { getTools } from "@/actions/tools"; import { getRunningCostsForPeriod } from "@/lib/budget-utils"; import type { RunningCostsResult } from "@/lib/budget-utils"; import { BudgetDetailClient } from "../components/budget-detail-client"; @@ -21,9 +22,10 @@ export default async function BudgetDetailPage({ const budget = await getBudgetWithCosts(budgetId); if (!budget) notFound(); - const runningCostsResults = await Promise.all( - budget.periods.map((p) => getRunningCostsForPeriod(p.id)) - ); + const [runningCostsResults, allTools] = await Promise.all([ + Promise.all(budget.periods.map((p) => getRunningCostsForPeriod(p.id))), + getTools(), + ]); const runningCosts: Record = {}; budget.periods.forEach((p, i) => { const result = runningCostsResults[i]; @@ -31,6 +33,9 @@ export default async function BudgetDetailPage({ runningCosts[p.id] = result; } }); + const tools = allTools + .filter((t) => t.status === "active") + .map((t) => ({ id: t.id, name: t.name })); return ( @@ -38,6 +43,7 @@ export default async function BudgetDetailPage({ budget={budget} isAdmin={isAdmin} runningCosts={runningCosts} + tools={tools} showBreadcrumb /> diff --git a/src/app/budget/budget-table.tsx b/src/app/budget/budget-table.tsx index ca0e00eb..a7475ab0 100644 --- a/src/app/budget/budget-table.tsx +++ b/src/app/budget/budget-table.tsx @@ -1,7 +1,7 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { formatCurrency } from "@/lib/utils"; +import { formatCurrency, formatVariance } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { DataTable, arrayIncludesFilterFn } from "@/components/data-table"; import { DataTableColumnHeader } from "@/components/data-table-column-header"; @@ -12,6 +12,8 @@ interface BudgetRow { fiscalYear: number; totalAmountCents: number; status: string; + extensionCount: number; + extensionNetCents: number; } const columns: ColumnDef[] = [ @@ -27,6 +29,27 @@ const columns: ColumnDef[] = [ header: ({ column }) => , cell: ({ row }) => formatCurrency(row.getValue("totalAmountCents")), }, + { + id: "extensions", + header: ({ column }) => ( + + ), + accessorFn: (row) => row.extensionCount, + cell: ({ row }) => { + const count = row.original.extensionCount; + const net = row.original.extensionNetCents; + if (count === 0) + return ; + return ( + + {count} + + {formatVariance(net)} + + + ); + }, + }, { accessorKey: "status", header: ({ column }) => , diff --git a/src/app/budget/components/budget-detail-client.tsx b/src/app/budget/components/budget-detail-client.tsx index 1b49b57c..1d474f40 100644 --- a/src/app/budget/components/budget-detail-client.tsx +++ b/src/app/budget/components/budget-detail-client.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { StatusText, useInlineStatus } from "@/components/ui/status-text"; import { @@ -9,20 +9,37 @@ import { updateBilledCost, updateBudgetAllocations, } from "@/actions/budget"; -import type { BilledCost, BudgetWithCosts } from "@/types"; +import { + createBudgetExtension, + deleteBudgetExtension, +} from "@/actions/budget-extensions"; +import type { + AiTool, + BilledCost, + BudgetExtensionWithAllocations, + BudgetWithCosts, +} from "@/types"; import type { RunningCostsResult } from "@/lib/budget-utils"; import { BudgetDetailHeader } from "./budget-detail-header"; import { BudgetHealthHero } from "./budget-health-hero"; +import { BudgetExtensionsCard } from "./budget-extensions-card"; import { PastMonthSpotlight } from "./past-month-spotlight"; import { PeriodAllocationsTable } from "./period-allocations-table"; import { + AddExtensionDialog, BilledCostDialog, DeleteBilledCostDialog, + DeleteExtensionDialog, } from "./dialogs"; import { makeEmptyBilledCostForm, type BilledCostFormState, } from "./dialogs/billed-cost-form"; +import { + extensionFormToActionInput, + makeEmptyExtensionForm, + type ExtensionFormState, +} from "./dialogs/extension-form"; import { Card, CardContent, @@ -34,6 +51,8 @@ interface Props { budget: BudgetWithCosts; isAdmin: boolean; runningCosts?: Record; + /** Active tools available for the "Linked tool" picker in the extension dialog. */ + tools?: Pick[]; /** Render the breadcrumb above the title. Suppress on the canonical active-budget landing (/budget). */ showBreadcrumb?: boolean; } @@ -42,18 +61,48 @@ export function BudgetDetailClient({ budget, isAdmin, runningCosts = {}, + tools = [], showBreadcrumb = true, }: Props) { const router = useRouter(); const allocStatus = useInlineStatus(); const billedStatus = useInlineStatus(); const deleteStatus = useInlineStatus(); + // Two channels for extension feedback: errors render inside the open + // dialog (the page-level status would be hidden behind the modal overlay); + // success renders in the extensions card header after the dialog closes. + const extensionStatus = useInlineStatus(); + const extensionDialogStatus = useInlineStatus(); const periods = budget.periods; const isArchived = budget.status === "archived"; const [allocations, setAllocations] = useState>( Object.fromEntries(periods.map((p) => [p.id, p.plannedAmountCents])) ); + // Re-sync `allocations` whenever the server reports new period planned + // amounts. Without this, an extension that bumps plannedAmountCents (via + // createBudgetExtension) would leave the local input state stale, and the + // next "Save allocations" click would silently write the pre-extension + // values back. + // + // The trigger is the per-period planned values themselves, not + // budget.updatedAt — only extension create/delete and archiveBudget bump + // annual_budgets.updated_at, while updateBudgetAllocations and billed-cost + // CRUD do not. Hashing the period values catches every case where the + // server-side planned amount changed, including future actions that don't + // touch annual_budgets. + const periodsKey = periods + .map((p) => `${p.id}:${p.plannedAmountCents}`) + .join("|"); + useEffect(() => { + setAllocations( + Object.fromEntries(periods.map((p) => [p.id, p.plannedAmountCents])) + ); + // `periods` is intentionally re-read at effect-fire-time; the dep is the + // value-hash so unrelated re-renders don't cause loops or blow away + // unsaved local edits. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [periodsKey]); const [saving, setSaving] = useState(false); const [addDialogOpen, setAddDialogOpen] = useState(false); @@ -74,6 +123,16 @@ export function BudgetDetailClient({ const [deleteEntry, setDeleteEntry] = useState(null); const [deleteSaving, setDeleteSaving] = useState(false); + // Budget extensions (spec 026) + const [extensionDialogOpen, setExtensionDialogOpen] = useState(false); + const [extensionForm, setExtensionForm] = useState( + makeEmptyExtensionForm + ); + const [extensionSaving, setExtensionSaving] = useState(false); + const [extensionDeleteTarget, setExtensionDeleteTarget] = + useState(null); + const [extensionDeleteSaving, setExtensionDeleteSaving] = useState(false); + async function handleSave() { setSaving(true); const result = await updateBudgetAllocations({ @@ -184,6 +243,47 @@ export function BudgetDetailClient({ ); } + function openExtensionDialog() { + setExtensionForm(makeEmptyExtensionForm()); + extensionDialogStatus.clear(); + setExtensionDialogOpen(true); + } + + async function handleSubmitExtension() { + const converted = extensionFormToActionInput(extensionForm, budget.id); + if (!converted.ok) { + extensionDialogStatus.error(converted.error); + return; + } + setExtensionSaving(true); + const result = await createBudgetExtension(converted.input); + setExtensionSaving(false); + if (result.success) { + setExtensionDialogOpen(false); + setExtensionForm(makeEmptyExtensionForm()); + extensionStatus.ok("EXTENSION ADDED"); + router.refresh(); + } else { + extensionDialogStatus.error(result.error); + } + } + + async function handleDeleteExtension() { + if (!extensionDeleteTarget) return; + setExtensionDeleteSaving(true); + const result = await deleteBudgetExtension({ + extensionId: extensionDeleteTarget.id, + }); + setExtensionDeleteSaving(false); + if (result.success) { + setExtensionDeleteTarget(null); + extensionStatus.ok("EXTENSION DELETED"); + router.refresh(); + } else { + extensionDialogStatus.error(result.error); + } + } + return (
+ { + extensionDialogStatus.clear(); + setExtensionDeleteTarget(e); + }} + statusSlot={} + /> +
@@ -256,6 +368,29 @@ export function BudgetDetailClient({ onConfirm={handleDeleteBilledCost} saving={deleteSaving} /> + + + + { + if (!open) setExtensionDeleteTarget(null); + }} + extension={extensionDeleteTarget} + onConfirm={handleDeleteExtension} + saving={extensionDeleteSaving} + status={extensionDialogStatus.status} + />
); } diff --git a/src/app/budget/components/budget-extensions-card.tsx b/src/app/budget/components/budget-extensions-card.tsx new file mode 100644 index 00000000..fe0a266f --- /dev/null +++ b/src/app/budget/components/budget-extensions-card.tsx @@ -0,0 +1,180 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { formatCurrency, formatVariance } from "@/lib/utils"; +import type { BudgetExtensionWithAllocations } from "@/types"; +import { Plus, Trash2 } from "lucide-react"; +import { CATEGORY_LABEL } from "./dialogs/extension-form"; + +interface Props { + extensions: BudgetExtensionWithAllocations[]; + isAdmin: boolean; + isArchived: boolean; + onAdd: () => void; + onDelete: (ext: BudgetExtensionWithAllocations) => void; + /** Inline status text rendered in the card header (Nothing replacement for toasts). */ + statusSlot?: ReactNode; +} + +export function BudgetExtensionsCard({ + extensions, + isAdmin, + isArchived, + onAdd, + onDelete, + statusSlot, +}: Props) { + // For non-admin viewers with no extensions, hide the card entirely — + // there's nothing meaningful to show and it'd just be empty chrome. + if (extensions.length === 0 && !isAdmin) return null; + + const net = extensions.reduce((s, e) => s + e.amountCents, 0); + const canEdit = isAdmin && !isArchived; + + return ( + + +
+
+ Budget extensions + + {extensions.length} + + {statusSlot} +
+ {extensions.length > 0 && ( +
+

+ Net extended +

+

+ {formatVariance(net)} +

+
+ )} +
+ + Mid-year changes to the annual ceiling. Each extension records why + the budget moved and (optionally) which tool it funds. + +
+ + {extensions.length === 0 ? ( +
+ No extensions yet for this budget. + {canEdit && " Add one when the ceiling needs to move."} +
+ ) : ( +
+ {extensions.map((e, idx) => ( + onDelete(e)} + /> + ))} +
+ )} + + {canEdit && ( + + )} +
+
+ ); +} + +function ExtensionRow({ + extension, + first, + canEdit, + onDelete, +}: { + extension: BudgetExtensionWithAllocations; + first: boolean; + canEdit: boolean; + onDelete: () => void; +}) { + const isReduction = extension.amountCents < 0; + const allocationSummary = + extension.allocations.length === 0 + ? "Unallocated" + : extension.allocations.length === 1 + ? "Allocated to 1 period" + : `Distributed across ${extension.allocations.length} periods`; + + return ( +
+
+
+ {extension.reason} + + {CATEGORY_LABEL[extension.category]} + + {extension.linkedToolName && ( + {extension.linkedToolName} + )} +
+ {extension.description && ( +

+ {extension.description} +

+ )} +
+ Added{" "} + + {new Date(extension.createdAt).toISOString().slice(0, 10)} + {" "} + by {extension.createdByName}{" "} + · Effective{" "} + + {extension.effectiveDate} + {" "} + · {allocationSummary} +
+
+
+

+ {formatVariance(extension.amountCents)} +

+ {canEdit && ( + + )} +
+
+ ); +} diff --git a/src/app/budget/components/budget-health-hero.tsx b/src/app/budget/components/budget-health-hero.tsx index 5227bbe1..e9463aa6 100644 --- a/src/app/budget/components/budget-health-hero.tsx +++ b/src/app/budget/components/budget-health-hero.tsx @@ -217,6 +217,19 @@ export function BudgetHealthHero({ budget, runningCosts, allocations }: Props) {

{formatCurrency(ceiling)}

+ {budget.originalAmountCents !== ceiling && ( +

+ {formatCurrency(budget.originalAmountCents)} baseline + {/* Sign rendered inline so the badge doesn't carry its own '-' twice */} + {ceiling > budget.originalAmountCents ? "+" : "−"} + + + {formatCurrency(Math.abs(ceiling - budget.originalAmountCents))}{" "} + {ceiling > budget.originalAmountCents ? "extended" : "reduced"} + + +

+ )} {unallocated !== 0 && (

{unallocated > 0 diff --git a/src/app/budget/components/dialogs/add-extension-dialog.tsx b/src/app/budget/components/dialogs/add-extension-dialog.tsx new file mode 100644 index 00000000..f62ef0d4 --- /dev/null +++ b/src/app/budget/components/dialogs/add-extension-dialog.tsx @@ -0,0 +1,382 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + StatusText, + type InlineStatusState, +} from "@/components/ui/status-text"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { formatCurrency } from "@/lib/utils"; +import type { AiTool, BudgetWithCosts } from "@/types"; +import { + CATEGORY_OPTIONS, + parseExtensionCents, + previewDistributeRemaining, + type AllocationMode, + type ExtensionFormState, +} from "./extension-form"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + form: ExtensionFormState; + onFormChange: (next: ExtensionFormState) => void; + budget: BudgetWithCosts; + tools: Pick[]; + onSubmit: () => void; + saving: boolean; + /** Inline submit feedback — rendered in the footer (no toasts in the Nothing system). */ + status?: InlineStatusState; +} + +const ALLOCATION_OPTIONS: { + value: AllocationMode; + label: string; + description: string; +}[] = [ + { + value: "distribute_remaining", + label: "Distribute across remaining periods", + description: + "Split the amount evenly across periods that haven't ended yet.", + }, + { + value: "single_period", + label: "Add to a single period", + description: "Useful for one-off costs (e.g. an annual license).", + }, + { + value: "unallocated", + label: "Leave unallocated", + description: "Raises the ceiling only; allocate manually later.", + }, +]; + +// Special select sentinel for "no linked tool" — Radix Select forbids `value=""`. +const NO_LINKED_TOOL = "__none__"; + +export function AddExtensionDialog({ + open, + onOpenChange, + form, + onFormChange, + budget, + tools, + onSubmit, + saving, + status, +}: Props) { + const set = ( + key: K, + value: ExtensionFormState[K] + ) => onFormChange({ ...form, [key]: value }); + + // Live preview uses the same parsing as the submit path (parseExtensionCents) + // so the preview can never accept a value the server will reject. + const parsedPreview = parseExtensionCents(form); + const signedCents = parsedPreview.ok ? parsedPreview.cents : 0; + const nextCeiling = budget.totalAmountCents + signedCents; + const currentAllocations = budget.periods.reduce( + (s, p) => s + p.plannedAmountCents, + 0 + ); + + // Mirror the server's distribute_remaining math (resolveAllocations) so the + // preview blurb reflects the actual per-period write — including the remainder + // dumped onto the first period when the amount doesn't divide evenly. + const remainingPeriods = budget.periods.filter( + (p) => p.endDate >= form.effectiveDate + ); + const distributeTargets = + remainingPeriods.length > 0 ? remainingPeriods : budget.periods; + const distributeTargetCount = distributeTargets.length; + const dist = + form.allocationMode === "distribute_remaining" && signedCents !== 0 + ? previewDistributeRemaining(signedCents, distributeTargetCount) + : null; + + const submitDisabled = + saving || + !form.reason.trim() || + !form.amountDollars || + !form.effectiveDate || + (form.allocationMode === "single_period" && !form.singlePeriodId); + + return ( +

+ + + Add budget extension + + Record a change to the annual ceiling. Use a negative (reduction) for + unwinding a prior bump. + + + +
+
+ + set("reason", e.target.value)} + maxLength={120} + placeholder="e.g. Add Claude API for engineering team" + /> +
+ +
+
+ +
+ + set("amountDollars", e.target.value)} + placeholder="0.00" + /> +
+
+ +
+ + set("effectiveDate", e.target.value)} + /> +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +