diff --git a/CLAUDE.md b/CLAUDE.md index 48bd85c..6891401 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# AI Developer Hub Development Guidelines +# AI Developer Hub Development Guidelines Auto-generated from all feature plans. Last updated: 2026-03-02 @@ -51,6 +51,7 @@ Auto-generated from all feature plans. Last updated: 2026-03-02 - 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 (023-ingestion-history) - Neon PostgreSQL (serverless) via `@neondatabase/serverless` — 1 new table, 2 new enums (023-ingestion-history) - TypeScript 5.9.3 (strict mode) + Next.js 15.5.12 (App Router), Drizzle ORM 0.45.1, React 19.2.4 (025-running-api-costs) +- Neon PostgreSQL (serverless) via `@neondatabase/serverless` — 1 new table (`anthropic_plan_connections`), 4 modified tables (`anthropic_usage_metrics`, `anthropic_sync_status`, `anthropic_workspaces`, `anthropic_workspace_costs`), `sync_events` gains optional column (026-multiple-api-plans) - **Language**: TypeScript 5.x (strict mode), Node.js LTS - **Framework**: Next.js 15 (App Router, Server Components, Server Actions) @@ -113,9 +114,9 @@ pnpm lighthouse # Lighthouse CI - Server Components by default — `"use client"` only when client interactivity needed ## Recent Changes +- 026-multiple-api-plans: Added TypeScript 5.9.3 (strict mode) + Next.js 15.5.12 (App Router), React 19.2.4, Drizzle ORM 0.45.1, NextAuth 5.0.0-beta.30, shadcn/ui (new-york), Zod 4.3.6, Sonner (toasts), Lucide React - 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/specs/026-multiple-api-plans/checklists/requirements.md b/specs/026-multiple-api-plans/checklists/requirements.md new file mode 100644 index 0000000..cff78ad --- /dev/null +++ b/specs/026-multiple-api-plans/checklists/requirements.md @@ -0,0 +1,37 @@ +# Specification Quality Checklist: Multiple Claude API Plan Connections + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-27 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass validation. Spec is ready for `/speckit.plan`. +- Clarification session 2026-03-27: 2 questions asked, 2 answered (env var migration strategy, historical data association). +- Sync framework integration clarified via user input: fully integrated, no separate per-plan sync UI. +- Budget view isolation explicitly called out in FR-011 and SC-006. diff --git a/specs/026-multiple-api-plans/contracts/api-contracts.md b/specs/026-multiple-api-plans/contracts/api-contracts.md new file mode 100644 index 0000000..6b0be77 --- /dev/null +++ b/specs/026-multiple-api-plans/contracts/api-contracts.md @@ -0,0 +1,171 @@ +# API Contracts: Multiple Claude API Plan Connections + +**Feature**: 026-multiple-api-plans +**Date**: 2026-03-27 + +## Server Actions (plan-connections.ts) + +### `getPlanConnections()` + +Returns all plan connections for the organization. + +**Auth**: Admin only +**Input**: None +**Output**: +```typescript +{ + success: true, + data: { + id: number + label: string + adminApiKeyHint: string // masked, e.g. "sk-a••••••••1234" + status: "active" | "disconnected" + createdAt: string // ISO datetime + disconnectedAt: string | null + }[] +} | { success: false, error: string } +``` + +--- + +### `addPlanConnection(data)` + +Adds a new plan connection. + +**Auth**: Admin only +**Input**: +```typescript +{ + label: string // 1-200 chars, trimmed + adminApiKey: string // plaintext, will be encrypted +} +``` +**Validation**: +- Label: non-empty, max 200 chars +- Admin API key: non-empty, must not match an existing active connection's hint +- Active connection count must be < 10 +- API key validity check: call Anthropic `/v1/organizations/workspaces?limit=1` to verify + +**Output**: +```typescript +{ success: true, data: { id: number, label: string } } +| { success: false, error: string } +``` + +--- + +### `updatePlanConnectionLabel(id, label)` + +Updates a plan connection's label. + +**Auth**: Admin only +**Input**: +```typescript +{ + id: number + label: string // 1-200 chars, trimmed +} +``` +**Output**: +```typescript +{ success: true } +| { success: false, error: string } +``` + +--- + +### `disconnectPlanConnection(id)` + +Soft-deletes a plan connection by setting status to 'disconnected'. + +**Auth**: Admin only +**Input**: `{ id: number }` +**Validation**: Connection must exist and be active. Cannot disconnect if it's the only active connection. +**Output**: +```typescript +{ success: true } +| { success: false, error: string } +``` + +--- + +## Modified Server Actions + +### `getUserCostData(userId, month?)` — anthropic-usage.ts + +**Change**: Return type gains optional `planLabel` field on daily breakdown for admin callers. + +**Extended output** (admin view only): +```typescript +CostData & { + planLabel?: string // populated when caller is admin, null for self-view +} +``` + +--- + +### `getGlobalCostDashboard(month, planConnectionId?)` — anthropic-usage.ts + +**Change**: Accepts optional `planConnectionId` filter. When provided, returns data for that plan only. When omitted, aggregates across all active plans. + +**Extended input**: `planConnectionId?: number` +**Extended output**: `workspaceBreakdown` entries gain `planLabel` field: +```typescript +{ + workspaceId: string | null + name: string + planLabel: string // NEW + planConnectionId: number // NEW + totalCents: number + dailyTotals: { date: string, costCents: number }[] +} +``` + +--- + +## Modified Sync Interfaces + +### `withSyncLock(params, fn)` — framework.ts + +**Extended params**: +```typescript +interface WithSyncLockParams { + sourceType: SyncSourceType + triggeredBy?: number + operationType?: SyncOperationType + backfillStartDate?: Date + planConnectionId?: number // NEW: included in advisory lock hash +} +``` + +--- + +### Sync source `run()` functions + +Both `anthropic-usage.ts` and `anthropic-workspace.ts` `run()` functions gain: + +**Extended options**: +```typescript +interface RunOptions { + force?: boolean + backfillStartDate?: Date + planConnectionId?: number // NEW: sync specific plan only +} +``` + +When `planConnectionId` is omitted, the runner iterates all active plans. + +--- + +### `fetchOrgApiKeys(adminApiKey)` — anthropic-keys.ts + +**Change**: Accept explicit `adminApiKey` parameter instead of reading from env var. + +**Before**: `fetchOrgApiKeys(): Promise` (reads `process.env.ANTHROPIC_ADMIN_API_KEY`) +**After**: `fetchOrgApiKeys(adminApiKey: string): Promise` + +Same change applies to: +- `fetchAnthropicUsage(adminApiKey, startingAt, endingAt, apiKeyIds?)` +- `fetchWorkspaces(adminApiKey)` +- `fetchCostReport(adminApiKey, startingAt, endingAt)` +- `checkAnthropicStatus(adminApiKey?)` — optional for backward compat during auto-import diff --git a/specs/026-multiple-api-plans/data-model.md b/specs/026-multiple-api-plans/data-model.md new file mode 100644 index 0000000..fbf4e3d --- /dev/null +++ b/specs/026-multiple-api-plans/data-model.md @@ -0,0 +1,138 @@ +# Data Model: Multiple Claude API Plan Connections + +**Feature**: 026-multiple-api-plans +**Date**: 2026-03-27 + +## New Entities + +### `anthropic_plan_connections` + +Represents a connected Claude API plan with its encrypted admin API key. + +| Field | Type | Constraints | Description | +|-------|------|-------------|-------------| +| id | serial | PK | Auto-incrementing identifier | +| label | varchar(200) | NOT NULL | Human-readable name (e.g., "Engineering Plan") | +| adminApiKeyEncrypted | varchar(700) | NOT NULL | AES-256-GCM encrypted admin API key (same pattern as `licenseAssignments.apiKeyEncrypted`) | +| adminApiKeyHint | varchar(20) | NOT NULL | Masked key for display (e.g., "sk-a••••••••1234") | +| status | enum | NOT NULL, default 'active' | `active` or `disconnected` | +| disconnectedAt | timestamp | nullable | When the plan was disconnected | +| createdAt | timestamp | NOT NULL, default now() | Creation timestamp | +| updatedAt | timestamp | NOT NULL, default now() | Last update timestamp | +| createdBy | integer | FK → users.id | Admin who added the connection | + +**Indexes**: +- Unique: `adminApiKeyHint` WHERE `status = 'active'` (prevent duplicate active connections) +- Index: `status` + +**Enum**: `anthropic_plan_status` = `['active', 'disconnected']` + +**State transitions**: +- `active` → `disconnected` (admin removes plan; sets `disconnectedAt`) +- No transition back — reconnecting creates a new row + +--- + +## Modified Entities + +### `anthropic_usage_metrics` — Add plan association + +| New Field | Type | Constraints | Description | +|-----------|------|-------------|-------------| +| planConnectionId | integer | NOT NULL, FK → anthropic_plan_connections.id | Which plan this usage was sourced from | + +**Index changes**: +- Drop existing unique index on `(user_id, date, model)` +- Add unique index on `(user_id, date, model, plan_connection_id)` +- Update composite index: `(user_id, date)` → `(user_id, date, plan_connection_id)` + +**Migration**: Backfill all existing rows with the auto-imported first plan connection's ID. + +--- + +### `anthropic_sync_status` — Add plan scoping + +| New Field | Type | Constraints | Description | +|-----------|------|-------------|-------------| +| planConnectionId | integer | NOT NULL, default 0 | Which plan this sync status tracks (0 = legacy sentinel compatibility) | + +**Index changes**: +- Drop existing unique index on `user_id` +- Add unique index on `(user_id, plan_connection_id)` + +**Migration**: Backfill all existing rows with the auto-imported first plan connection's ID. The sentinel row (userId=0) gets the first plan's ID. + +--- + +### `anthropic_workspaces` — Add plan association + +| New Field | Type | Constraints | Description | +|-----------|------|-------------|-------------| +| planConnectionId | integer | NOT NULL, FK → anthropic_plan_connections.id | Which plan owns this workspace | + +**Index changes**: +- Drop existing unique index on `workspace_id WHERE workspace_id IS NOT NULL` +- Add unique index on `(workspace_id, plan_connection_id) WHERE workspace_id IS NOT NULL` +- Drop existing unique index on `is_default WHERE is_default = true` +- Add unique index on `(plan_connection_id, is_default) WHERE is_default = true` (one default per plan) + +**Migration**: Backfill all existing rows with the auto-imported first plan connection's ID. + +--- + +### `anthropic_workspace_costs` — Add plan association + +| New Field | Type | Constraints | Description | +|-----------|------|-------------|-------------| +| planConnectionId | integer | NOT NULL, FK → anthropic_plan_connections.id | Which plan this cost belongs to | + +**Index changes**: +- Drop existing unique index on `(workspace_id, date) WHERE workspace_id IS NOT NULL` +- Add unique index on `(workspace_id, date, plan_connection_id) WHERE workspace_id IS NOT NULL` +- Drop existing unique index on `date WHERE workspace_id IS NULL` +- Add unique index on `(date, plan_connection_id) WHERE workspace_id IS NULL` + +**Migration**: Backfill all existing rows with the auto-imported first plan connection's ID. + +--- + +### `sync_events` — Add optional plan tracking + +| New Field | Type | Constraints | Description | +|-----------|------|-------------|-------------| +| planConnectionId | integer | nullable, FK → anthropic_plan_connections.id | Which plan this sync event was for (null = non-plan sync sources like GitHub) | + +**No index changes** — existing indexes sufficient. The column is nullable because non-Anthropic sync sources don't have plans. + +--- + +## Entity Relationship Summary + +``` +anthropic_plan_connections (1) ──→ (N) anthropic_usage_metrics +anthropic_plan_connections (1) ──→ (N) anthropic_workspaces +anthropic_plan_connections (1) ──→ (N) anthropic_workspace_costs +anthropic_plan_connections (1) ──→ (N) anthropic_sync_status +anthropic_plan_connections (1) ──→ (N) sync_events (nullable) +users (1) ──→ (N) anthropic_plan_connections (via createdBy) +``` + +## Migration Strategy + +1. Create `anthropic_plan_connections` table with enum +2. Insert first plan row from `ANTHROPIC_ADMIN_API_KEY` env var (if set and table is empty) +3. Add `plan_connection_id` columns (nullable initially) to all modified tables +4. Backfill all existing rows with the first plan's ID +5. Set columns to NOT NULL +6. Drop old unique indexes, create new composite indexes +7. Add foreign key constraints + +**Rollback**: Drop new columns, recreate original indexes. The `anthropic_plan_connections` table can remain without impact. + +## Validation Rules + +- **Label**: 1–200 characters, trimmed, non-empty +- **Admin API Key**: Non-empty string, encrypted before storage +- **Key uniqueness**: Validated by checking `adminApiKeyHint` against active connections before insert +- **Max connections**: Application-level check (≤ 10 active connections) +- **Status transitions**: Only `active` → `disconnected` allowed (enforced in server action) diff --git a/specs/026-multiple-api-plans/plan.md b/specs/026-multiple-api-plans/plan.md new file mode 100644 index 0000000..66a2196 --- /dev/null +++ b/specs/026-multiple-api-plans/plan.md @@ -0,0 +1,90 @@ +# Implementation Plan: Multiple Claude API Plan Connections + +**Branch**: `026-multiple-api-plans` | **Date**: 2026-03-27 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/026-multiple-api-plans/spec.md` + +## Summary + +Extend the application from supporting a single Claude API plan connection (via the `ANTHROPIC_ADMIN_API_KEY` environment variable) to supporting multiple database-backed plan connections. Each plan stores its own encrypted admin API key and label. The sync framework iterates all active plans, resolving user API keys across plans and tracking usage per plan. User-facing profile pages remain unchanged; admin views gain plan labels for cost attribution. Budget views are completely unaffected. Existing data is migrated to the auto-imported first plan. + +## Technical Context + +**Language/Version**: TypeScript 5.9.3 (strict mode) +**Primary Dependencies**: Next.js 15.5.12 (App Router), React 19.2.4, Drizzle ORM 0.45.1, NextAuth 5.0.0-beta.30, shadcn/ui (new-york), Zod 4.3.6, Sonner (toasts), Lucide React +**Storage**: Neon PostgreSQL (serverless) via `@neondatabase/serverless` — 1 new table (`anthropic_plan_connections`), 4 modified tables (`anthropic_usage_metrics`, `anthropic_sync_status`, `anthropic_workspaces`, `anthropic_workspace_costs`), `sync_events` gains optional column +**Testing**: Vitest (unit/integration), Playwright (e2e) +**Target Platform**: Web application (Node.js server + browser client) +**Project Type**: Web service (Next.js App Router) +**Performance Goals**: Sync completes within existing timeouts; no added latency to profile page loads +**Constraints**: Encryption uses existing AES-256-GCM via `API_KEY_ENCRYPTION_SECRET`; max 10 plans per organization +**Scale/Scope**: ~4 modified pages, ~6 modified server-side modules, 1 new table, 1 migration script + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Type-Safe Code Quality | PASS | All new code in TypeScript strict mode. New table schemas use Drizzle typed definitions. Zod validation for plan connection inputs. | +| II. UX Consistency | PASS | Uses existing shadcn/ui components (Card, Dialog, Input, Button, Badge). Plan management UI follows existing integrations page patterns. | +| III. Performance Budgets | PASS | No new routes that would affect LCP/INP/CLS. Plan list is admin-only, low cardinality (max 10). Sync iteration adds O(n) where n ≤ 10. | +| IV. Accessibility-First | PASS | All new UI elements use shadcn/ui primitives with built-in keyboard navigation, focus management, and ARIA attributes. | +| V. Simplicity & Maintainability | PASS | Extends existing patterns (encryption, sync framework, server actions) rather than introducing new abstractions. No new dependencies. | + +**Post-Phase 1 Re-check**: All gates still pass. No new violations introduced by data model or contract design. + +## Project Structure + +### Documentation (this feature) + +```text +specs/026-multiple-api-plans/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output +│ └── api-contracts.md # Server action contracts +└── tasks.md # Phase 2 output (/speckit.tasks) +``` + +### Source Code (repository root) + +```text +src/ +├── app/ +│ ├── settings/integrations/ # Extended: plan connections management UI +│ ├── claude/ # Extended: multi-plan workspace aggregation +│ ├── users/[id]/ # Extended: plan label on cost section +│ └── api/sync/ # Extended: plan-aware sync routes +├── actions/ +│ ├── plan-connections.ts # NEW: CRUD server actions for plan connections +│ └── anthropic-usage.ts # Modified: plan-aware cost queries +├── components/ +│ ├── settings/ +│ │ └── plan-connections-card.tsx # NEW: plan management UI component +│ └── claude/ +│ └── global-metrics-client.tsx # Modified: plan filter support +├── lib/ +│ ├── db/ +│ │ └── schema.ts # Modified: new table + column additions +│ ├── sync/ +│ │ ├── sources/ +│ │ │ ├── anthropic-usage.ts # Modified: accept planConnectionId +│ │ │ └── anthropic-workspace.ts # Modified: accept planConnectionId +│ │ └── framework.ts # Modified: optional planConnectionId in params +│ ├── anthropic-sync.ts # Modified: iterate plans, plan-aware resolution +│ ├── anthropic-keys.ts # Modified: accept admin key parameter +│ └── profile-data.ts # Modified: join plan label for admin view +└── types/ + └── index.ts # Modified: plan-related type additions + +drizzle/ +└── XXXX_add_plan_connections.sql # Migration: new table + column additions + backfill +``` + +**Structure Decision**: Follows existing Next.js App Router structure. New files are minimal (1 server action file, 1 component). Most changes are modifications to existing modules to accept and propagate `planConnectionId`. + +## Complexity Tracking + +No constitution violations — no entries needed. diff --git a/specs/026-multiple-api-plans/quickstart.md b/specs/026-multiple-api-plans/quickstart.md new file mode 100644 index 0000000..50f1e0e --- /dev/null +++ b/specs/026-multiple-api-plans/quickstart.md @@ -0,0 +1,70 @@ +# Quickstart: Multiple Claude API Plan Connections + +**Feature**: 026-multiple-api-plans +**Date**: 2026-03-27 + +## Prerequisites + +- Node.js LTS, pnpm installed +- Neon PostgreSQL database accessible +- `API_KEY_ENCRYPTION_SECRET` env var set (existing requirement) +- `ANTHROPIC_ADMIN_API_KEY` env var set (will be auto-imported as first plan) + +## Setup + +```bash +# 1. Checkout feature branch +git checkout 026-multiple-api-plans + +# 2. Install dependencies (no new packages needed) +pnpm install + +# 3. Generate and apply migration +pnpm db:generate +pnpm db:migrate + +# 4. Start dev server +pnpm dev +``` + +## Verify Migration + +After running `pnpm db:migrate`: + +1. Check that `anthropic_plan_connections` table exists with one row (auto-imported from env var) +2. Check that `anthropic_usage_metrics` rows have `plan_connection_id` populated +3. Check that `anthropic_workspaces` rows have `plan_connection_id` populated + +## Test Plan Connections + +1. Navigate to `/settings/integrations` +2. See the auto-imported plan in the connections list +3. Click "Add Plan" to add a second connection with a different Anthropic admin API key +4. Verify both plans appear with their labels and "Connected" status + +## Test Sync + +1. Navigate to `/claude` dashboard +2. Trigger a manual sync — should iterate both plans +3. Check sync events show separate entries per plan +4. Verify workspace costs aggregate across plans + +## Test User Profile + +1. Assign a user an API key that belongs to Plan B +2. Run sync +3. View user's profile — should show usage data (no plan label visible) +4. View user's admin detail page — should show plan label next to usage + +## Key Files to Watch + +| Area | File | What Changed | +|------|------|-------------| +| Schema | `src/lib/db/schema.ts` | New table + column additions | +| Migration | `drizzle/XXXX_add_plan_connections.sql` | DDL + backfill | +| Plan CRUD | `src/actions/plan-connections.ts` | New server actions | +| Sync | `src/lib/anthropic-sync.ts` | Plan iteration loop | +| Sync | `src/lib/anthropic-keys.ts` | Accept admin key param | +| Profile | `src/lib/profile-data.ts` | Join plan label | +| UI | `src/app/settings/integrations/` | Plan management card | +| Dashboard | `src/components/claude/global-metrics-client.tsx` | Plan filter | diff --git a/specs/026-multiple-api-plans/research.md b/specs/026-multiple-api-plans/research.md new file mode 100644 index 0000000..713599d --- /dev/null +++ b/specs/026-multiple-api-plans/research.md @@ -0,0 +1,74 @@ +# Research: Multiple Claude API Plan Connections + +**Feature**: 026-multiple-api-plans +**Date**: 2026-03-27 + +## R1: Admin API Key Storage Migration (env var → database) + +**Decision**: Store plan admin API keys encrypted in a new `anthropic_plan_connections` database table using the existing AES-256-GCM encryption (`src/lib/crypto.ts`). Auto-import the current `ANTHROPIC_ADMIN_API_KEY` env var as the first plan on initial migration. + +**Rationale**: The existing `encryptApiKey()`/`decryptApiKey()` functions use AES-256-GCM with scrypt key derivation and the `API_KEY_ENCRYPTION_SECRET` env var. This same pattern is already proven for `licenseAssignments.apiKeyEncrypted`. Reusing it avoids introducing new crypto dependencies and keeps a consistent security posture. + +**Alternatives considered**: +- Keep env vars per plan (e.g., `ANTHROPIC_ADMIN_API_KEY_1`): Rejected — doesn't scale, requires redeployment for changes, no admin UI control. +- Store keys in a secrets manager (Vault, AWS SSM): Over-engineered for current scale; can be adopted later without schema changes. + +## R2: Sync Framework Extension Strategy + +**Decision**: Extend the existing `withSyncLock()` / `syncEvents` / `makeCronSyncRoute()` framework to iterate all active plans. Each plan sync gets its own sync event. The advisory lock hash includes the plan ID to allow independent per-plan locking. + +**Rationale**: The framework already supports `sourceType`-based advisory locks via FNV-32 hashing. Adding `planId` to the hash key (e.g., `hash("anthropic_api_usage:plan_42")`) gives independent locks per plan without changing the lock mechanism. The `syncEvents` table gains an optional `planConnectionId` column for audit trail. + +**Alternatives considered**: +- Create a separate sync framework for plans: Rejected — violates DRY, the existing framework is well-designed. +- Use a single lock across all plans: Rejected — a slow plan would block all others. + +## R3: Schema Extension Pattern for Existing Tables + +**Decision**: Add a non-nullable `planConnectionId` foreign key to `anthropicUsageMetrics`, `anthropicWorkspaces`, `anthropicWorkspaceCosts`, and `anthropicSyncStatus`. Update unique constraints to include `planConnectionId`. Migration backfills existing rows with the auto-imported first plan's ID. + +**Rationale**: A non-nullable FK enforces data integrity — every usage record must belong to a plan. The migration creates the first plan from the env var, then updates all existing rows to reference it. This is cleaner than nullable FKs with null-handling throughout the codebase. + +**Alternatives considered**: +- Nullable `planConnectionId` with "legacy" data: Rejected — adds null-check complexity everywhere, clarification confirmed no null references. +- Separate tables per plan: Rejected — complicates queries, violates existing patterns. + +## R4: API Key Resolution Across Plans + +**Decision**: During sync, iterate all active plan connections. For each plan, decrypt its admin API key, call `fetchOrgApiKeys()` with that key, then resolve user API keys against that plan's org keys. Cache the resolved `planConnectionId` alongside `resolvedApiKeyId` in `anthropicSyncStatus`. + +**Rationale**: The existing `resolveApiKeyId()` function matches user keys by suffix against org keys. Since each plan has its own set of org keys, running resolution per-plan naturally finds the right match. Caching the plan association avoids re-resolution on subsequent syncs. + +**Alternatives considered**: +- Resolve against all plans' keys in a single pass: Possible but harder to attribute — would need to track which plan each org key came from. Per-plan iteration is simpler and clearer. +- Require admins to manually assign users to plans: Rejected — adds unnecessary friction, automatic resolution is better UX. + +## R5: UI Integration for Plan Management + +**Decision**: Extend the existing `/settings/integrations` page with a plan connections management section. Add plan label display to the admin user detail page's cost section. No changes to the user-facing profile page (plan source is transparent). + +**Rationale**: The integrations page already has a `ClaudeCodeStatusCard` for Anthropic status. Expanding it to show a list of connected plans with add/edit/remove actions is a natural extension. The profile page stays unchanged per spec (SC-002). + +**Alternatives considered**: +- New dedicated `/settings/claude-plans` page: Unnecessary — integrations page is the right home. +- Show plan info on user profile: Rejected — spec says plan source is transparent to end users. + +## R6: Global Claude Dashboard Multi-Plan Aggregation + +**Decision**: Extend the global Claude metrics dashboard to aggregate workspace costs across all plans. Add a plan filter dropdown alongside the existing workspace and month selectors. Workspaces include their plan label for disambiguation when multiple plans have workspaces with the same name. + +**Rationale**: The existing `GlobalCostDashboardData` type already supports `workspaceBreakdown` arrays. Adding `planLabel` to each workspace entry and a top-level plan filter is minimal change. + +**Alternatives considered**: +- Separate dashboard per plan: Rejected — admins need a unified view. +- No plan filter (always aggregated): Insufficient — admins need to drill into individual plan costs. + +## R7: Sentinel Row Redesign for Multi-Plan Sync + +**Decision**: Replace the single sentinel row (userId=0) locking pattern in `anthropicSyncStatus` with the framework's `withSyncLock()` advisory locks. The sentinel row pattern becomes per-plan by adding `planConnectionId` to the sync status table. + +**Rationale**: The sentinel row was a custom lock before the sync framework existed. Now that `withSyncLock()` provides PostgreSQL advisory locks, the sentinel pattern is redundant for global locking. Per-plan sync status still needs rows to track `lastSyncStartedAt`, `syncedDays`, etc., so the table stays but keyed by (userId, planConnectionId). + +**Alternatives considered**: +- Keep sentinel pattern with multiple sentinels per plan: Works but mixes two locking strategies. +- New dedicated lock table: Over-engineered — advisory locks in the framework are sufficient. diff --git a/specs/026-multiple-api-plans/spec.md b/specs/026-multiple-api-plans/spec.md new file mode 100644 index 0000000..7833d7f --- /dev/null +++ b/specs/026-multiple-api-plans/spec.md @@ -0,0 +1,149 @@ +# Feature Specification: Multiple Claude API Plan Connections + +**Feature Branch**: `026-multiple-api-plans` +**Created**: 2026-03-27 +**Status**: Draft +**Input**: User description: "The app supports one Claude API plan connection to pull the API usage for users. It should be extended to support multiple connected API plans. The goal is to show API usage on the profile and user page, without affecting the budget views." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Admin Connects Additional API Plans (Priority: P1) + +An admin needs to connect the organization to multiple Claude API plans (e.g., separate plans for different departments, projects, or billing accounts). Currently, the system supports only one plan connection. The admin navigates to the Claude API settings area and adds a second (or third, etc.) API plan by providing the admin API key and a human-readable label for each plan. Each connected plan is listed with its label, connection status, and last sync time. + +**Why this priority**: Without the ability to connect multiple plans, no other feature in this spec can function. This is the foundational capability. + +**Independent Test**: Can be fully tested by an admin adding two or more API plan connections and verifying each appears in the settings list with correct status indicators. + +**Acceptance Scenarios**: + +1. **Given** an admin with one existing connected API plan, **When** the admin adds a second plan with a valid admin API key and label, **Then** both plans appear in the connected plans list with their respective labels and "Connected" status. +2. **Given** an admin viewing the connected plans list, **When** the admin removes a plan connection, **Then** the plan is disconnected, its historical usage data is retained, and it no longer appears as active. +3. **Given** an admin adding a new plan, **When** the provided API key is invalid or duplicates an existing connection, **Then** the system displays a clear error message and does not create the connection. + +--- + +### User Story 2 - User API Keys Resolve Across Multiple Plans (Priority: P1) + +When a user has a personal API key assigned (via license assignment), the system must resolve that key against all connected plans during sync. A user's API key may belong to any one of the connected plans. The sync process checks each plan to find the matching key and pulls usage data accordingly. Usage data on the profile page and user detail page reflects the correct plan source. + +**Why this priority**: This is equally critical to Story 1 — users must see their usage data regardless of which plan their key belongs to. Without this, multi-plan support has no user-facing value. + +**Independent Test**: Can be tested by assigning API keys from two different plans to two different users, running a sync, and verifying each user sees their correct usage data on their profile page. + +**Acceptance Scenarios**: + +1. **Given** two connected plans and a user whose API key belongs to Plan B, **When** the usage sync runs, **Then** the user's usage metrics are correctly fetched from Plan B and displayed on their profile. +2. **Given** a user whose API key does not match any connected plan, **When** the sync runs, **Then** the system logs a warning and the user's profile shows no usage data (with an appropriate message). +3. **Given** a completed sync across multiple plans, **When** a user views their profile page, **Then** usage data is displayed identically to the current single-plan experience — the plan source is transparent to the end user. + +--- + +### User Story 3 - Aggregated Usage Across Plans on Admin User Page (Priority: P2) + +An admin viewing a specific user's detail page sees the user's Claude API usage. If the organization has multiple plans and a user's key resolves to one specific plan, the admin can see which plan the usage comes from. The admin user page shows a plan label alongside usage data for clarity. + +**Why this priority**: Provides admin visibility into which plan is driving costs for each user, enabling better cost allocation decisions. + +**Independent Test**: Can be tested by viewing a user's detail page after syncing multiple plans and verifying the plan label appears next to the usage breakdown. + +**Acceptance Scenarios**: + +1. **Given** a user with usage data from Plan A, **When** an admin views that user's detail page, **Then** the usage breakdown includes the plan label (e.g., "Engineering Plan") alongside the cost and token data. +2. **Given** a user with no Claude API assignment, **When** an admin views that user's detail page, **Then** the Claude usage section shows an appropriate empty state. + +--- + +### User Story 4 - Multi-Plan Workspace Cost Aggregation (Priority: P2) + +The existing Claude global metrics dashboard (admin view) aggregates workspace costs across all connected plans. Each plan's workspaces are synced independently, and the dashboard shows combined or per-plan views of workspace-level costs. + +**Why this priority**: Admins need a holistic view of Claude API spend across all plans for budgeting and governance purposes. + +**Independent Test**: Can be tested by connecting two plans, syncing workspace data, and verifying the global Claude dashboard shows costs from both plans. + +**Acceptance Scenarios**: + +1. **Given** two connected plans each with workspace cost data, **When** an admin views the Claude global metrics dashboard, **Then** the total cost reflects the sum across both plans. +2. **Given** two connected plans, **When** an admin views workspace-level breakdowns, **Then** workspaces are grouped or labeled by their parent plan for disambiguation. + +--- + +### User Story 5 - Sync Iterates All Active Plans (Priority: P3) + +The existing sync framework (cron jobs, manual sync triggers, sync events tracking) is extended to iterate through all active plan connections. Each plan sync produces its own sync event record. If one plan's sync fails, the remaining plans continue unaffected. Sync status is displayed through the existing sync information UI — no additional per-plan sync status display is needed outside of the existing sync events. + +**Why this priority**: Operational reliability — the sync must gracefully handle multiple plans without requiring new sync infrastructure or UI. + +**Independent Test**: Can be tested by connecting two plans (one valid, one with a bad key), triggering a sync, and verifying the healthy plan syncs successfully while the failed plan's error is captured in sync events. + +**Acceptance Scenarios**: + +1. **Given** two active plan connections, **When** a scheduled or manual sync triggers, **Then** the system iterates through all active plans using the existing sync framework, creating sync event records for each. +2. **Given** a plan with an expired or revoked API key, **When** sync runs, **Then** that plan's sync event records the failure while other plans sync normally. +3. **Given** an admin viewing the existing sync status/history, **When** multiple plans have been synced, **Then** the sync events reflect each plan's sync outcome without requiring a separate per-plan sync status view. + +--- + +### Edge Cases + +- What happens when two plans have overlapping API key IDs? (API key IDs are globally unique within Anthropic, so this should not occur — but the system should detect and warn if it does.) +- How does the system handle a plan being disconnected while a sync is in progress? (The sync should complete for already-started plans; the disconnected plan is skipped on next run.) +- What happens when the same user has API keys in multiple connected plans? (Currently each user has one API key via license assignment. The system resolves against the first matching plan. Supporting multiple keys per user is out of scope.) +- What is the maximum number of connected plans? (Reasonable limit of 10 plans, sufficient for any organization's needs.) +- How does re-connecting a previously disconnected plan work? (Admin can add the same plan again; historical data from the previous connection is retained if the plan identifier matches.) + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST support connecting multiple Claude API plans simultaneously, each identified by a unique admin API key and a user-provided label. +- **FR-002**: System MUST store each plan connection with its label, encrypted admin API key, connection status (active/disconnected), and sync metadata. +- **FR-003**: System MUST resolve user API keys against all active connected plans during usage sync, matching the key to the correct plan. +- **FR-004**: System MUST track which plan each user's usage data originates from, associating usage metrics with a specific plan connection. +- **FR-005**: System MUST display user Claude API usage on the profile page and user detail page without requiring the user to know which plan their key belongs to. +- **FR-006**: System MUST show the plan label on the admin user detail page alongside usage data for cost attribution. +- **FR-007**: System MUST sync workspace metadata and costs independently for each connected plan. +- **FR-008**: System MUST integrate multi-plan syncing into the existing sync framework (sync events, sync locks, cron handlers), with each plan's sync outcome recorded as standard sync events — no separate per-plan sync status UI required. +- **FR-009**: System MUST allow admins to add, edit labels, and remove plan connections. +- **FR-010**: System MUST retain historical usage data when a plan is disconnected (soft delete). +- **FR-011**: System MUST NOT alter how budget views calculate or display costs — budget views remain driven by invoice/billed cost data only. +- **FR-012**: System MUST validate that a new plan connection's admin API key is not already in use by another active connection. +- **FR-013**: System MUST support manual sync triggers per-plan and for all plans simultaneously. +- **FR-014**: System MUST auto-import the existing environment-variable-based admin API key as the first plan connection on initial migration when no database plan connections exist. +- **FR-015**: System MUST migrate all existing historical usage metrics and workspace cost data to be associated with the auto-imported first plan connection, leaving no unassociated records. + +### Key Entities + +- **API Plan Connection**: Represents a connected Claude API plan. Attributes: label, encrypted admin API key, status (active/disconnected), creation date, last sync timestamps. One organization can have many plan connections. +- **Usage Metrics (extended)**: Existing per-user/per-day/per-model usage records, now additionally associated with the plan connection they were sourced from. +- **Workspace (extended)**: Existing workspace records, now additionally associated with the plan connection they belong to. +- **Sync Status (extended)**: Existing sync tracking, now scoped per plan connection rather than as a singleton. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Admins can connect and manage up to 10 Claude API plans within 2 minutes per plan. +- **SC-002**: User profile pages display correct usage data regardless of which connected plan their API key belongs to, with no visible difference from the current single-plan experience. +- **SC-003**: Admin user detail pages clearly identify the source plan for each user's usage data. +- **SC-004**: Workspace cost data from all connected plans is aggregated on the global Claude metrics dashboard with per-plan disambiguation. +- **SC-005**: A sync failure on one plan does not prevent other plans from syncing successfully. +- **SC-006**: Budget views (annual budgets, budget periods, billed costs) remain completely unaffected — zero changes to budget calculation or display. +- **SC-007**: Historical usage data is preserved when a plan connection is removed, ensuring no data loss. + +## Clarifications + +### Session 2026-03-27 + +- Q: What happens to the existing `ANTHROPIC_ADMIN_API_KEY` environment variable when multiple plans are supported? → A: Auto-import — on first run, if the env var exists and no database plan connections exist, the system automatically creates a plan connection from it. The env var can be removed afterward at the admin's convenience. +- Q: How should existing historical usage and workspace cost data (which has no plan reference) be handled after migration? → A: A migration script associates all existing data with the auto-imported first plan connection. No null plan references in the data model. + +## Assumptions + +- Each user has at most one Claude API key assigned (via license assignment). Supporting multiple keys per user is out of scope. +- Anthropic API key IDs are globally unique across organizations/plans. +- The existing encrypted API key storage mechanism in license assignments is sufficient and does not need changes. +- The current admin API key (used for org-level access in sync) will be stored per plan connection rather than as a single environment variable. On first run, the existing `ANTHROPIC_ADMIN_API_KEY` env var is auto-imported as the first plan connection if no DB plans exist yet. +- Plan connections are organization-wide settings managed only by admins. +- The automatic cron sync will iterate through all active plan connections. diff --git a/specs/026-multiple-api-plans/tasks.md b/specs/026-multiple-api-plans/tasks.md new file mode 100644 index 0000000..ed4b7d6 --- /dev/null +++ b/specs/026-multiple-api-plans/tasks.md @@ -0,0 +1,248 @@ +# Tasks: Multiple Claude API Plan Connections + +**Input**: Design documents from `/specs/026-multiple-api-plans/` +**Prerequisites**: plan.md, spec.md, data-model.md, contracts/api-contracts.md, research.md, quickstart.md + +**Tests**: Not explicitly requested — test tasks omitted. + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Schema changes, new table, migration, and shared type definitions + +- [x] T001 Add `anthropic_plan_status` enum and `anthropic_plan_connections` table to Drizzle schema in `src/lib/db/schema.ts` +- [x] T002 Add `planConnectionId` column (nullable initially) to `anthropic_usage_metrics`, `anthropic_sync_status`, `anthropic_workspaces`, `anthropic_workspace_costs`, and `sync_events` tables in `src/lib/db/schema.ts` +- [x] T003 Add plan-connection-related TypeScript types to `src/types/index.ts` (PlanConnection, PlanConnectionStatus, extended CostData with planLabel, extended GlobalCostDashboardData with planLabel/planConnectionId on workspace breakdown) +- [x] T004 Generate Drizzle migration files with `pnpm db:generate` + +**Checkpoint**: Schema definitions complete, migration files generated. Ready for foundational work. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Migration execution, auto-import logic, and refactored API functions that ALL user stories depend on + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [x] T005 Write migration SQL to create `anthropic_plan_connections` table, auto-import `ANTHROPIC_ADMIN_API_KEY` env var as first plan connection (encrypt with existing `encryptApiKey()` from `src/lib/crypto.ts`, generate hint with `maskApiKey()`), backfill all existing rows in modified tables with the first plan's ID, then set `planConnectionId` columns to NOT NULL, drop old unique indexes, and create new composite indexes — in `drizzle/` migration file +- [x] T006 Refactor `fetchOrgApiKeys()` in `src/lib/anthropic-keys.ts` to accept explicit `adminApiKey: string` parameter instead of reading `process.env.ANTHROPIC_ADMIN_API_KEY` +- [x] T007 [P] Refactor `fetchAnthropicUsage()` in `src/lib/anthropic-sync.ts` to accept explicit `adminApiKey: string` parameter instead of reading env var +- [x] T008 [P] Refactor `fetchWorkspaces()` and `fetchCostReport()` in `src/lib/sync/sources/anthropic-workspace.ts` to accept explicit `adminApiKey: string` parameter instead of reading env var +- [x] T009 [P] Refactor `checkAnthropicStatus()` in `src/actions/anthropic-status.ts` to accept optional `adminApiKey?: string` parameter (falls back to env var for backward compat during auto-import) +- [x] T010 Add `planConnectionId?: number` to `WithSyncLockParams` in `src/lib/sync/framework.ts` — include it in FNV-32 advisory lock hash computation and store in `sync_events` row on insert +- [x] T011 Add helper function `getActivePlanConnections()` in `src/actions/plan-connections.ts` that queries all active plan connections and decrypts their admin API keys (used by sync orchestration) +- [x] T012 Apply migration with `pnpm db:migrate` and verify auto-import of env var key + +**Checkpoint**: Foundation ready — all API functions accept plan-specific keys, sync framework supports plan IDs, migration applied. User story implementation can now begin. + +--- + +## Phase 3: User Story 1 — Admin Connects Additional API Plans (Priority: P1) 🎯 MVP + +**Goal**: Admins can add, view, edit labels, and disconnect multiple Claude API plan connections via the integrations settings page. + +**Independent Test**: Navigate to `/settings/integrations`, add two plan connections with valid admin API keys and labels, verify both appear with correct status. Edit a label, disconnect one, verify soft delete. + +### Implementation for User Story 1 + +- [x] T013 [US1] Create Zod validation schemas for plan connection inputs (label: 1–200 chars trimmed, adminApiKey: non-empty) in `src/lib/validators.ts` +- [x] T014 [US1] Implement `getPlanConnections()` server action in `src/actions/plan-connections.ts` — returns all connections with id, label, adminApiKeyHint, status, createdAt, disconnectedAt (admin-only auth check) +- [x] T015 [P] [US1] Implement `addPlanConnection(data)` server action in `src/actions/plan-connections.ts` — validates input, checks active count < 10, checks hint uniqueness among active connections, verifies API key via `checkAnthropicStatus(adminApiKey)`, encrypts key, inserts row, returns new connection +- [x] T016 [P] [US1] Implement `updatePlanConnectionLabel(id, label)` server action in `src/actions/plan-connections.ts` — validates label, updates row +- [x] T017 [P] [US1] Implement `disconnectPlanConnection(id)` server action in `src/actions/plan-connections.ts` — validates connection exists and is active, prevents disconnecting if it's the only active connection, sets status to 'disconnected' and disconnectedAt timestamp +- [x] T018 [US1] Create `PlanConnectionsCard` client component in `src/components/settings/plan-connections-card.tsx` — displays list of plan connections with label, masked key hint, status badge (Connected/Disconnected), created date; includes "Add Plan" button opening a dialog with label + API key inputs; edit label inline; disconnect button with confirmation +- [x] T019 [US1] Integrate `PlanConnectionsCard` into `/settings/integrations` page in `src/app/settings/integrations/page.tsx` — add below or replace existing `ClaudeCodeStatusCard`, pass plan connections data from server action +- [x] T020 [US1] Update `ClaudeCodeStatusCard` in `src/app/settings/integrations/claude-code-status-card.tsx` to show connection status based on active plan connections count rather than env var check — or replace with `PlanConnectionsCard` + +**Checkpoint**: Admin can fully manage plan connections via UI. MVP deliverable. + +--- + +## Phase 4: User Story 2 — User API Keys Resolve Across Multiple Plans (Priority: P1) + +**Goal**: During sync, user API keys are resolved against all connected plans. Usage data is fetched per-plan and stored with plan association. Profile page displays usage transparently. + +**Independent Test**: Assign API keys from two different plans to two users, run sync, verify each user's profile page shows correct usage data sourced from their respective plan. + +### Implementation for User Story 2 + +- [x] T021 [US2] Refactor `resolveAllMappings()` in `src/lib/anthropic-sync.ts` to accept `adminApiKey: string` and `planConnectionId: number` — call `fetchOrgApiKeys(adminApiKey)` for that specific plan, resolve user keys against that plan's org keys, cache `planConnectionId` in `anthropicSyncStatus.resolvedApiKeyId` alongside the key ID +- [x] T022 [US2] Refactor `runAnthropicSyncCore()` in `src/lib/anthropic-sync.ts` to iterate all active plan connections — for each plan: decrypt admin key, call `resolveAllMappings(adminApiKey, planConnectionId)`, fetch usage via `fetchAnthropicUsage(adminApiKey, ...)`, store results with `planConnectionId` in `anthropicUsageMetrics` +- [x] T023 [US2] Update `batchUpsertUsageRows()` in `src/lib/anthropic-sync.ts` to include `planConnectionId` in upsert composite key `(userId, date, model, planConnectionId)` and in the inserted/updated data +- [x] T024 [US2] Update `syncSingleUser()` in `src/lib/anthropic-sync.ts` to look up the user's resolved plan connection and use that plan's admin key for the sync +- [x] T025 [US2] Update the `run()` function in `src/lib/sync/sources/anthropic-usage.ts` to accept optional `planConnectionId` in `RunOptions` — when omitted, iterate all plans; when specified, sync that plan only. Pass `planConnectionId` to `withSyncLock()` for independent per-plan advisory locks +- [x] T026 [US2] Update `fetchUserCostDataInternal()` in `src/lib/profile-data.ts` to query `anthropicUsageMetrics` filtering by active plan connections (exclude disconnected plans' data from user view), aggregating across all plans transparently +- [x] T027 [US2] Verify profile page (`src/app/profile/page.tsx`) displays usage correctly without changes — the transparent aggregation in T026 should make this work without UI modifications + +**Checkpoint**: Users see their correct usage data regardless of which plan their key belongs to. Sync resolves across all plans. + +--- + +## Phase 5: User Story 3 — Aggregated Usage on Admin User Page (Priority: P2) + +**Goal**: Admin user detail page shows which plan each user's usage comes from, displaying the plan label alongside cost data. + +**Independent Test**: View a user's detail page after syncing with multiple plans; verify plan label appears next to usage breakdown. + +### Implementation for User Story 3 + +- [x] T028 [US3] Extend `fetchUserCostDataInternal()` in `src/lib/profile-data.ts` to join `anthropic_plan_connections` table and return `planLabel` when caller is admin — add to return type +- [x] T029 [US3] Update `getUserCostData()` server action in `src/actions/anthropic-usage.ts` to pass caller role context so `fetchUserCostDataInternal()` knows whether to include `planLabel` +- [x] T030 [US3] Update `CostTrackingSection` component in `src/components/profile/cost-tracking-section.tsx` to accept and display optional `planLabel` prop — show as a subtle badge or label next to the monthly total when present +- [x] T031 [US3] Update `AdminCostSection` in `src/components/profile/admin-cost-section.tsx` to pass `planLabel` from cost data to `CostTrackingSection` +- [x] T032 [US3] Update user detail page data fetching in `src/app/users/[id]/page.tsx` to pass plan label through to the cost section component + +**Checkpoint**: Admins see plan attribution on user detail pages. No change to user self-view. + +--- + +## Phase 6: User Story 4 — Multi-Plan Workspace Cost Aggregation (Priority: P2) + +**Goal**: Global Claude metrics dashboard aggregates workspace costs across all connected plans with plan filter and disambiguation. + +**Independent Test**: Connect two plans, sync workspace data, verify dashboard shows costs from both plans with per-plan filtering. + +### Implementation for User Story 4 + +- [x] T033 [US4] Update the `run()` function in `src/lib/sync/sources/anthropic-workspace.ts` to accept optional `planConnectionId` in `RunOptions` — when omitted iterate all plans; for each plan: decrypt admin key, call `fetchWorkspaces(adminApiKey)` and `fetchCostReport(adminApiKey, ...)`, store results with `planConnectionId` in `anthropicWorkspaces` and `anthropicWorkspaceCosts` +- [x] T034 [US4] Update workspace upsert logic in `src/lib/sync/sources/anthropic-workspace.ts` to use composite unique keys including `planConnectionId` — update the partial unique index conflict clauses for both named and default workspaces +- [x] T035 [US4] Extend `getGlobalCostDashboard()` server action in `src/actions/anthropic-usage.ts` to accept optional `planConnectionId` filter — when provided filter by plan, when omitted aggregate across all active plans. Add `planLabel` and `planConnectionId` to each workspace breakdown entry +- [x] T036 [US4] Extend `getWorkspaceList()` server action to include `planLabel` for each workspace by joining `anthropic_plan_connections` +- [x] T037 [US4] Add plan filter dropdown to `GlobalMetricsClient` in `src/components/claude/global-metrics-client.tsx` — populate from active plan connections, add "All Plans" default option, re-fetch dashboard data when plan filter changes +- [x] T038 [US4] Update workspace breakdown display in `GlobalMetricsClient` to show plan label alongside workspace name for disambiguation when multiple plans are active + +**Checkpoint**: Dashboard shows aggregated costs with plan filtering. Workspace names disambiguated by plan. + +--- + +## Phase 7: User Story 5 — Sync Iterates All Active Plans (Priority: P3) + +**Goal**: Sync framework gracefully iterates all plans with per-plan error isolation and sync event tracking. + +**Independent Test**: Connect two plans (one valid, one with bad key), trigger sync, verify healthy plan syncs and failed plan's error is captured in sync events. + +### Implementation for User Story 5 + +- [x] T039 [US5] Update the Anthropic usage cron route in `src/app/api/sync/anthropic-usage/route.ts` to support optional `planConnectionId` query parameter — pass to `run()` for plan-specific manual triggers +- [x] T040 [P] [US5] Update the Anthropic workspace costs cron route in `src/app/api/sync/anthropic-api-costs/route.ts` to support optional `planConnectionId` query parameter +- [x] T041 [US5] Add error isolation in `runAnthropicSyncCore()` in `src/lib/anthropic-sync.ts` — wrap each plan's sync iteration in try/catch so one plan's failure doesn't abort the remaining plans; collect per-plan errors in sync summary +- [x] T042 [US5] Update `SyncSummary` type in `src/lib/anthropic-sync.ts` to include per-plan results (plan label, synced users, errors) alongside the existing aggregate totals +- [x] T043 [US5] Add manual per-plan sync trigger to `PlanConnectionsCard` in `src/components/settings/plan-connections-card.tsx` — small sync icon button per plan that calls the sync endpoint with `planConnectionId` + +**Checkpoint**: Sync is fully plan-aware with error isolation. All user stories functional. + +--- + +## Phase 8: Polish & Cross-Cutting Concerns + +**Purpose**: Cleanup, edge cases, and validation + +- [x] T044 [P] Add edge case handling for duplicate API key hint detection across plans in `addPlanConnection()` in `src/actions/plan-connections.ts` — warn if a key hint matches across different plans (potential cross-plan key collision) +- [x] T045 [P] Update `syncAnthropicUsage()` and `syncAllAnthropicUsage()` manual trigger actions in `src/actions/anthropic-usage.ts` to work with the multi-plan sync orchestration +- [x] T046 Run `pnpm typecheck` and fix any TypeScript errors across all modified files +- [x] T047 Run `pnpm lint` and fix any ESLint warnings across all modified files +- [x] T048 Run quickstart.md validation — verify migration, plan connections UI, sync, profile page, and dashboard all work end-to-end + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — can start immediately +- **Foundational (Phase 2)**: Depends on Phase 1 — BLOCKS all user stories +- **US1 (Phase 3)**: Depends on Phase 2 — no dependencies on other stories +- **US2 (Phase 4)**: Depends on Phase 2 — benefits from US1 for plan data but can use DB seeding +- **US3 (Phase 5)**: Depends on Phase 2 + US2 (needs usage data with plan associations) +- **US4 (Phase 6)**: Depends on Phase 2 — independent of US1/US2/US3 +- **US5 (Phase 7)**: Depends on Phase 2 + US2 (needs multi-plan sync core from T022) +- **Polish (Phase 8)**: Depends on all desired user stories being complete + +### User Story Dependencies + +- **US1 (P1)**: Independent — only needs foundational phase +- **US2 (P1)**: Independent — only needs foundational phase (core sync refactoring) +- **US3 (P2)**: Depends on US2 (needs planConnectionId in usage metrics) +- **US4 (P2)**: Independent — only needs foundational phase (workspace sync refactoring) +- **US5 (P3)**: Depends on US2 (builds on multi-plan sync iteration from T022) + +### Within Each User Story + +- Server actions before UI components +- Data layer changes before display layer +- Core logic before integration points + +### Parallel Opportunities + +- T007, T008, T009 can run in parallel (independent API function refactors) +- T015, T016, T017 can run in parallel (independent server actions) +- US1 and US2 can start in parallel after Phase 2 +- US4 can run in parallel with US1/US2/US3 +- T039, T040 can run in parallel (independent route updates) +- T044, T045 can run in parallel (independent polish tasks) + +--- + +## Parallel Example: Phase 2 (Foundational) + +```bash +# After T005 (migration) and T006 (fetchOrgApiKeys refactor): +# Launch parallel API function refactors: +Task T007: "Refactor fetchAnthropicUsage() in src/lib/anthropic-sync.ts" +Task T008: "Refactor fetchWorkspaces/fetchCostReport in src/lib/sync/sources/anthropic-workspace.ts" +Task T009: "Refactor checkAnthropicStatus() in src/actions/anthropic-status.ts" +``` + +## Parallel Example: User Story 1 + +```bash +# After T013 (validators) and T014 (getPlanConnections): +# Launch parallel CRUD server actions: +Task T015: "Implement addPlanConnection() in src/actions/plan-connections.ts" +Task T016: "Implement updatePlanConnectionLabel() in src/actions/plan-connections.ts" +Task T017: "Implement disconnectPlanConnection() in src/actions/plan-connections.ts" +``` + +--- + +## Implementation Strategy + +### MVP First (User Stories 1 + 2) + +1. Complete Phase 1: Setup (schema + types) +2. Complete Phase 2: Foundational (migration + API refactors) +3. Complete Phase 3: US1 — Admin can manage plan connections +4. Complete Phase 4: US2 — Sync resolves across plans, profiles work +5. **STOP and VALIDATE**: Both P1 stories independently testable +6. Deploy/demo — core multi-plan functionality is live + +### Incremental Delivery + +1. Setup + Foundational → Foundation ready +2. US1 → Plan management UI live (MVP increment 1) +3. US2 → Multi-plan sync working, profiles correct (MVP increment 2) +4. US3 → Admin sees plan labels on user pages +5. US4 → Dashboard aggregates across plans +6. US5 → Sync error isolation + per-plan triggers +7. Polish → Edge cases, type/lint checks, e2e validation + +--- + +## Notes + +- Commit after each task or logical group (user requested frequent commits) +- Use subagents for parallel tasks where marked [P] +- No new dependencies needed — all built on existing patterns +- Budget views (`annualBudgets`, `budgetPeriods`, `billedCosts`) are NOT touched — FR-011/SC-006 +- Max 10 active plan connections enforced at application level +- The `ANTHROPIC_ADMIN_API_KEY` env var is auto-imported once and can be removed afterward diff --git a/src/actions/anthropic-global.ts b/src/actions/anthropic-global.ts index 85ceb8a..db26ce3 100644 --- a/src/actions/anthropic-global.ts +++ b/src/actions/anthropic-global.ts @@ -7,6 +7,7 @@ import { anthropicWorkspaces, anthropicWorkspaceLimits, anthropicOrgConfig, + anthropicPlanConnections, anthropicSyncStatus, syncEvents, } from "@/lib/db/schema"; @@ -18,6 +19,7 @@ import type { GlobalCostDashboardData, WorkspaceListItem, OrgCreditsStatus, + PlanConnectionListItem, } from "@/types"; import { run as runAnthropicSync } from "@/lib/sync/sources/anthropic-workspace"; @@ -27,37 +29,61 @@ import { run as runAnthropicSync } from "@/lib/sync/sources/anthropic-workspace" const monthSchema = z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/); -async function _getGlobalCostDashboard(month: string): Promise { +async function _getGlobalCostDashboard( + month: string, + planConnectionId?: number +): Promise { const startDate = `${month}-01`; const endDate = format(endOfMonth(parseISO(`${month}-01`)), "yyyy-MM-dd"); - // Fetch all workspace cost rows for the month + // Build cost query with optional plan filter + const costConditions = [ + sql`${anthropicWorkspaceCosts.date} >= ${startDate}::date AND ${anthropicWorkspaceCosts.date} <= ${endDate}::date`, + ]; + if (planConnectionId != null) { + costConditions.push( + sql`${anthropicWorkspaceCosts.planConnectionId} = ${planConnectionId}` + ); + } + const costRows = await db .select() .from(anthropicWorkspaceCosts) - .where( - sql`${anthropicWorkspaceCosts.date} >= ${startDate}::date AND ${anthropicWorkspaceCosts.date} <= ${endDate}::date` - ); + .where(sql.join(costConditions, sql` AND `)); - // Fetch only the fields needed to build the workspace name map + // Fetch workspace metadata with plan labels const workspaceRows = await db - .select({ workspaceId: anthropicWorkspaces.workspaceId, name: anthropicWorkspaces.name }) - .from(anthropicWorkspaces); + .select({ + workspaceId: anthropicWorkspaces.workspaceId, + name: anthropicWorkspaces.name, + planConnectionId: anthropicWorkspaces.planConnectionId, + planLabel: anthropicPlanConnections.label, + }) + .from(anthropicWorkspaces) + .innerJoin( + anthropicPlanConnections, + eq(anthropicWorkspaces.planConnectionId, anthropicPlanConnections.id) + ); - // Build workspace map (workspaceId -> name) - const workspaceMap = new Map(); + // Build workspace map (workspaceId:planId -> {name, planLabel, planConnectionId}) + const workspaceMap = new Map(); for (const ws of workspaceRows) { - workspaceMap.set(ws.workspaceId, ws.name); + const key = `${ws.workspaceId ?? "null"}:${ws.planConnectionId}`; + workspaceMap.set(key, { + name: ws.name, + planLabel: ws.planLabel, + planConnectionId: ws.planConnectionId, + }); } - // Group cost rows by workspaceId + // Group cost rows by workspaceId:planConnectionId const byWorkspace = new Map< - string | null, + string, { date: string; costCents: number }[] >(); for (const row of costRows) { - const key = row.workspaceId; + const key = `${row.workspaceId ?? "null"}:${row.planConnectionId}`; if (!byWorkspace.has(key)) { byWorkspace.set(key, []); } @@ -75,15 +101,20 @@ async function _getGlobalCostDashboard(month: string): Promise sum + d.costCents, 0); - // Build workspace breakdown + // Build workspace breakdown with plan labels const workspaceBreakdown: GlobalCostDashboardData["workspaceBreakdown"] = []; - for (const [workspaceId, days] of byWorkspace.entries()) { - const name = workspaceMap.get(workspaceId) ?? (workspaceId === null ? "Default Workspace" : workspaceId); + for (const [compositeKey, days] of byWorkspace.entries()) { + const wsInfo = workspaceMap.get(compositeKey); + const [wsId] = compositeKey.split(":"); + const workspaceId = wsId === "null" ? null : wsId; + const name = wsInfo?.name ?? (workspaceId === null ? "Default Workspace" : workspaceId); const sortedDays = [...days].sort((a, b) => a.date.localeCompare(b.date)); const totalCents = sortedDays.reduce((sum, d) => sum + d.costCents, 0); workspaceBreakdown.push({ workspaceId, name, + planLabel: wsInfo?.planLabel, + planConnectionId: wsInfo?.planConnectionId, totalCents, dailyTotals: sortedDays, }); @@ -94,7 +125,8 @@ async function _getGlobalCostDashboard(month: string): Promise { const admin = await requireAdmin(); if (!admin) { @@ -105,9 +137,13 @@ export async function getGlobalCostDashboard( ? month : format(new Date(), "yyyy-MM"); + const cacheKey = planConnectionId + ? `anthropic-global-cost-dashboard:${targetMonth}:plan_${planConnectionId}` + : `anthropic-global-cost-dashboard:${targetMonth}`; + return unstable_cache( - () => _getGlobalCostDashboard(targetMonth), - ["anthropic-global-cost-dashboard", targetMonth], + () => _getGlobalCostDashboard(targetMonth, planConnectionId), + [cacheKey], { tags: ["anthropic-workspace-costs"] } )(); } diff --git a/src/actions/anthropic-status.ts b/src/actions/anthropic-status.ts index 6ecb099..6a1b63d 100644 --- a/src/actions/anthropic-status.ts +++ b/src/actions/anthropic-status.ts @@ -15,12 +15,14 @@ type AnthropicStatusResult = | { success: true; data: AnthropicStatusData } | { success: false; error: string }; -export async function checkAnthropicStatus(): Promise { +export async function checkAnthropicStatus( + adminApiKey?: string +): Promise { const admin = await requireAdmin(); if (!admin) return { success: false, error: "Unauthorized" }; const lastCheckedAt = new Date().toISOString(); - const apiKey = process.env.ANTHROPIC_ADMIN_API_KEY; + const apiKey = adminApiKey ?? process.env.ANTHROPIC_ADMIN_API_KEY; if (!apiKey) { return { diff --git a/src/actions/anthropic-usage.ts b/src/actions/anthropic-usage.ts index a95a0ec..5c8b249 100644 --- a/src/actions/anthropic-usage.ts +++ b/src/actions/anthropic-usage.ts @@ -55,7 +55,10 @@ export async function getUserCostData( }; } - return fetchUserCostDataInternal(userId, month); + const isAdmin = session.user.role === "admin"; + return fetchUserCostDataInternal(userId, month, { + includePlanLabel: isAdmin && callerId !== userId, + }); } // --------------------------------------------------------------------------- @@ -180,6 +183,36 @@ export async function syncAllAnthropicUsage(): Promise< } } +// --------------------------------------------------------------------------- +// syncAllAnthropicUsageForPlan — admin-only manual sync for a specific plan +// --------------------------------------------------------------------------- + +export async function syncAllAnthropicUsageForPlan( + planConnectionId: number +): Promise< + ActionResult<{ eventId: number }> +> { + const admin = await requireAdmin(); + if (!admin) return { success: false, error: "Unauthorized" }; + + try { + const { eventId } = await runAnthropicUsageSource(Number(admin.id), { + planConnectionId, + }); + + revalidatePath("/settings/integrations"); + return { success: true, data: { eventId } }; + } catch (err) { + return { + success: false, + error: + err instanceof Error + ? err.message + : "Failed to sync Anthropic usage data for plan", + }; + } +} + // --------------------------------------------------------------------------- // recalculateUnresolvedCosts — admin-only repricing of unresolved rows // --------------------------------------------------------------------------- diff --git a/src/actions/plan-connections.ts b/src/actions/plan-connections.ts new file mode 100644 index 0000000..df78689 --- /dev/null +++ b/src/actions/plan-connections.ts @@ -0,0 +1,159 @@ +"use server"; + +import { db } from "@/lib/db"; +import { anthropicPlanConnections } from "@/lib/db/schema"; +import { eq } from "drizzle-orm"; +import { requireAdmin } from "@/lib/auth-helpers"; +import { encryptApiKey, maskApiKey } from "@/lib/crypto"; +import { checkAnthropicStatus } from "@/actions/anthropic-status"; +import { getActivePlanCount } from "@/lib/plan-connections"; +import { revalidatePath } from "next/cache"; +import type { ActionResult, PlanConnectionListItem } from "@/types"; + +// --------------------------------------------------------------------------- +// getPlanConnections — admin-only list of all connections +// --------------------------------------------------------------------------- + +export async function getPlanConnections(): Promise< + ActionResult +> { + const admin = await requireAdmin(); + if (!admin) return { success: false, error: "Unauthorized" }; + + const connections = await db + .select({ + id: anthropicPlanConnections.id, + label: anthropicPlanConnections.label, + adminApiKeyHint: anthropicPlanConnections.adminApiKeyHint, + status: anthropicPlanConnections.status, + createdAt: anthropicPlanConnections.createdAt, + disconnectedAt: anthropicPlanConnections.disconnectedAt, + }) + .from(anthropicPlanConnections) + .orderBy(anthropicPlanConnections.createdAt); + + return { success: true, data: connections }; +} + +// --------------------------------------------------------------------------- +// addPlanConnection — admin-only +// --------------------------------------------------------------------------- + +export async function addPlanConnection(data: { + label: string; + adminApiKey: string; +}): Promise> { + const admin = await requireAdmin(); + if (!admin) return { success: false, error: "Unauthorized" }; + + const label = data.label.trim(); + if (!label || label.length > 200) { + return { success: false, error: "Label must be between 1 and 200 characters." }; + } + if (!data.adminApiKey.trim()) { + return { success: false, error: "Admin API key is required." }; + } + + const count = await getActivePlanCount(); + if (count >= 10) { + return { success: false, error: "Maximum of 10 active plan connections reached." }; + } + + // Check for duplicate key hint + const hint = maskApiKey(data.adminApiKey); + const existingHint = await db.query.anthropicPlanConnections.findFirst({ + where: eq(anthropicPlanConnections.adminApiKeyHint, hint), + }); + if (existingHint && existingHint.status === "active") { + return { success: false, error: "This API key is already connected to an active plan." }; + } + + // Verify the API key works + const statusCheck = await checkAnthropicStatus(data.adminApiKey); + if (!statusCheck.success || !statusCheck.data.connected) { + return { success: false, error: "API key validation failed. Please check the key and try again." }; + } + + // Encrypt and store + const encrypted = await encryptApiKey(data.adminApiKey); + const [created] = await db + .insert(anthropicPlanConnections) + .values({ + label, + adminApiKeyEncrypted: encrypted, + adminApiKeyHint: hint, + createdBy: Number(admin.id), + }) + .returning({ id: anthropicPlanConnections.id, label: anthropicPlanConnections.label }); + + revalidatePath("/settings/integrations"); + return { success: true, data: created }; +} + +// --------------------------------------------------------------------------- +// updatePlanConnectionLabel — admin-only +// --------------------------------------------------------------------------- + +export async function updatePlanConnectionLabel( + id: number, + label: string +): Promise { + const admin = await requireAdmin(); + if (!admin) return { success: false, error: "Unauthorized" }; + + const trimmed = label.trim(); + if (!trimmed || trimmed.length > 200) { + return { success: false, error: "Label must be between 1 and 200 characters." }; + } + + const existing = await db.query.anthropicPlanConnections.findFirst({ + where: eq(anthropicPlanConnections.id, id), + }); + if (!existing) return { success: false, error: "Plan connection not found." }; + if (existing.label === trimmed) return { success: true, data: undefined }; + + await db + .update(anthropicPlanConnections) + .set({ label: trimmed, updatedAt: new Date() }) + .where(eq(anthropicPlanConnections.id, id)); + + revalidatePath("/settings/integrations"); + return { success: true, data: undefined }; +} + +// --------------------------------------------------------------------------- +// disconnectPlanConnection — admin-only soft delete +// --------------------------------------------------------------------------- + +export async function disconnectPlanConnection( + id: number +): Promise { + const admin = await requireAdmin(); + if (!admin) return { success: false, error: "Unauthorized" }; + + const [existing, count] = await Promise.all([ + db.query.anthropicPlanConnections.findFirst({ + where: eq(anthropicPlanConnections.id, id), + }), + getActivePlanCount(), + ]); + + if (!existing || existing.status !== "active") { + return { success: false, error: "Plan connection not found or already disconnected." }; + } + if (count <= 1) { + return { success: false, error: "Cannot disconnect the only active plan connection." }; + } + + await db + .update(anthropicPlanConnections) + .set({ + status: "disconnected", + disconnectedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(anthropicPlanConnections.id, id)); + + revalidatePath("/settings/integrations"); + return { success: true, data: undefined }; +} diff --git a/src/app/claude/page.tsx b/src/app/claude/page.tsx index e95df61..82b8776 100644 --- a/src/app/claude/page.tsx +++ b/src/app/claude/page.tsx @@ -8,6 +8,7 @@ import { getWorkspaceList, getOrgConfig, } from "@/actions/anthropic-global"; +import { getPlanConnections } from "@/actions/plan-connections"; import { GlobalMetricsClient } from "@/components/claude/global-metrics-client"; import { WorkspaceBudgetList } from "@/components/claude/workspace-budget-list"; import { OrgCreditsPanel } from "@/components/claude/org-credits-panel"; @@ -42,12 +43,15 @@ export default async function ClaudePage() { } const creditsStatus = { available: false as const, reason: "not_exposed_by_api" as const }; - const [dashboardData, workspaceList, orgConfig] = await Promise.all([ + const [dashboardData, workspaceList, orgConfig, planResult] = await Promise.all([ getGlobalCostDashboard(currentMonth), getWorkspaceList(), getOrgConfig(), + getPlanConnections(), ]); + const planConnections = planResult.success ? planResult.data : []; + return (
@@ -64,6 +68,7 @@ export default async function ClaudePage() { availableMonths={availableMonths} initialMonth={currentMonth} lastSyncedAt={lastSyncedAt} + planConnections={planConnections} /> diff --git a/src/app/settings/integrations/page.tsx b/src/app/settings/integrations/page.tsx index 1a7e128..9077f60 100644 --- a/src/app/settings/integrations/page.tsx +++ b/src/app/settings/integrations/page.tsx @@ -1,26 +1,24 @@ import { requireAdmin } from "@/lib/auth-helpers"; import { redirect } from "next/navigation"; import { getActiveGitHubConnection } from "@/actions/github"; -import { checkAnthropicStatus } from "@/actions/anthropic-status"; +import { getPlanConnections } from "@/actions/plan-connections"; import { GitHubIntegrationClient } from "./github-integration-client"; -import { ClaudeCodeStatusCard } from "./claude-code-status-card"; +import { PlanConnectionsCard } from "@/components/settings/plan-connections-card"; export default async function IntegrationsPage() { const admin = await requireAdmin(); if (!admin) redirect("/settings/appearance"); // Fetch independent data in parallel - const [connectionResult, anthropicResult] = await Promise.all([ + const [connectionResult, planResult] = await Promise.all([ getActiveGitHubConnection(), - checkAnthropicStatus(), + getPlanConnections(), ]); const connection = connectionResult.success ? connectionResult.data.connection : null; - const anthropicStatus = anthropicResult.success - ? anthropicResult.data - : { connected: false, workspaceName: null, lastCheckedAt: new Date().toISOString() }; + const planConnections = planResult.success ? planResult.data : []; return (
@@ -33,11 +31,7 @@ export default async function IntegrationsPage() { - +
); } diff --git a/src/components/claude/global-metrics-client.tsx b/src/components/claude/global-metrics-client.tsx index 54ebed6..195f733 100644 --- a/src/components/claude/global-metrics-client.tsx +++ b/src/components/claude/global-metrics-client.tsx @@ -25,7 +25,7 @@ import { import { MonthPicker } from "@/components/profile/month-picker"; import { getGlobalCostDashboard } from "@/actions/anthropic-global"; import { formatDistanceToNow } from "date-fns"; -import type { GlobalCostDashboardData } from "@/types"; +import type { GlobalCostDashboardData, PlanConnectionListItem } from "@/types"; import { formatCurrency } from "@/lib/utils"; type GlobalMetricsClientProps = { @@ -33,9 +33,11 @@ type GlobalMetricsClientProps = { availableMonths: string[]; initialMonth: string; lastSyncedAt: Date | null; + planConnections?: PlanConnectionListItem[]; }; const ALL_WORKSPACES = "__all__"; +const ALL_PLANS = "__all__"; const chartConfig = { cost: { label: "Cost (USD)", color: "var(--chart-1)" }, @@ -46,21 +48,37 @@ export function GlobalMetricsClient({ availableMonths, initialMonth, lastSyncedAt, + planConnections, }: GlobalMetricsClientProps) { const [dashboardData, setDashboardData] = useState(initialData); const [selectedMonth, setSelectedMonth] = useState(initialMonth); const [selectedWorkspace, setSelectedWorkspace] = useState(ALL_WORKSPACES); + const [selectedPlan, setSelectedPlan] = useState(ALL_PLANS); const [isPending, startTransition] = useTransition(); - function handleMonthChange(newMonth: string) { - setSelectedMonth(newMonth); - setSelectedWorkspace(ALL_WORKSPACES); + const activePlans = planConnections?.filter((p) => p.status === "active") ?? []; + const showPlanFilter = activePlans.length > 1; + + function fetchDashboard(month: string, planId?: string) { + const parsedPlanId = planId && planId !== ALL_PLANS ? Number(planId) : undefined; startTransition(async () => { - const data = await getGlobalCostDashboard(newMonth); + const data = await getGlobalCostDashboard(month, parsedPlanId); setDashboardData(data); }); } + function handleMonthChange(newMonth: string) { + setSelectedMonth(newMonth); + setSelectedWorkspace(ALL_WORKSPACES); + fetchDashboard(newMonth, selectedPlan); + } + + function handlePlanChange(planId: string) { + setSelectedPlan(planId); + setSelectedWorkspace(ALL_WORKSPACES); + fetchDashboard(selectedMonth, planId); + } + const { displayDailyTotals, displayTotal } = useMemo(() => { if (selectedWorkspace === ALL_WORKSPACES) { return { @@ -95,20 +113,40 @@ export function GlobalMetricsClient({ onChange={handleMonthChange} months={availableMonths} /> + {showPlanFilter && ( + + )} {lastSyncedAt && ( diff --git a/src/components/profile/cost-tracking-section.tsx b/src/components/profile/cost-tracking-section.tsx index 480f0bf..4a86035 100644 --- a/src/components/profile/cost-tracking-section.tsx +++ b/src/components/profile/cost-tracking-section.tsx @@ -7,6 +7,7 @@ import { MonthPicker } from "./month-picker"; import { formatCurrency, getCurrentMonth } from "@/lib/utils"; import { getUserCostData } from "@/actions/anthropic-usage"; import { CostChart } from "@/components/cost-chart"; +import { Badge } from "@/components/ui/badge"; import { DollarSign, Info, AlertTriangle } from "lucide-react"; import { toast } from "sonner"; import type { CostData } from "@/types"; @@ -134,7 +135,14 @@ export function CostTrackingSection({ {/* Monthly total */}
-

Monthly Total

+
+

Monthly Total

+ {costData.planLabel && ( + + {costData.planLabel} + + )} +

{formatCurrency(costData.monthlyTotalCents)}

diff --git a/src/components/settings/plan-connections-card.tsx b/src/components/settings/plan-connections-card.tsx new file mode 100644 index 0000000..7440c3e --- /dev/null +++ b/src/components/settings/plan-connections-card.tsx @@ -0,0 +1,314 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { BrainCircuit, Plus, Pencil, Unplug, RefreshCw } from "lucide-react"; +import { toast } from "sonner"; +import { + addPlanConnection, + updatePlanConnectionLabel, + disconnectPlanConnection, + getPlanConnections, +} from "@/actions/plan-connections"; +import { syncAllAnthropicUsageForPlan } from "@/actions/anthropic-usage"; +import { formatDateTime } from "@/lib/utils"; +import type { PlanConnectionListItem } from "@/types"; + +interface PlanConnectionsCardProps { + initialConnections: PlanConnectionListItem[]; +} + +export function PlanConnectionsCard({ + initialConnections, +}: PlanConnectionsCardProps) { + const [connections, setConnections] = + useState(initialConnections); + const [isPending, startTransition] = useTransition(); + const [dialogOpen, setDialogOpen] = useState(false); + const [newLabel, setNewLabel] = useState(""); + const [newApiKey, setNewApiKey] = useState(""); + const [editingId, setEditingId] = useState(null); + const [editLabel, setEditLabel] = useState(""); + const [syncingPlanId, setSyncingPlanId] = useState(null); + + const activeCount = connections.filter((c) => c.status === "active").length; + + async function refreshConnections() { + const result = await getPlanConnections(); + if (result.success) setConnections(result.data); + } + + function handleAdd() { + startTransition(async () => { + const result = await addPlanConnection({ + label: newLabel, + adminApiKey: newApiKey, + }); + if (result.success) { + toast.success(`Plan "${result.data.label}" connected.`); + setDialogOpen(false); + setNewLabel(""); + setNewApiKey(""); + await refreshConnections(); + } else { + toast.error(result.error); + } + }); + } + + function handleUpdateLabel(id: number) { + startTransition(async () => { + const result = await updatePlanConnectionLabel(id, editLabel); + if (result.success) { + toast.success("Label updated."); + setEditingId(null); + await refreshConnections(); + } else { + toast.error(result.error); + } + }); + } + + function handleSync(id: number, label: string) { + setSyncingPlanId(id); + startTransition(async () => { + const result = await syncAllAnthropicUsageForPlan(id); + setSyncingPlanId(null); + if (result.success) { + toast.success(`Sync started for "${label}".`); + } else { + toast.error(result.error); + } + }); + } + + function handleDisconnect(id: number) { + startTransition(async () => { + const result = await disconnectPlanConnection(id); + if (result.success) { + toast.success("Plan disconnected."); + await refreshConnections(); + } else { + toast.error(result.error); + } + }); + } + + return ( + + +
+ + + Claude API Plans + +
+ 0 ? "default" : "secondary"}> + {activeCount} Active + + + + + + + + Connect Claude API Plan + + Add an Anthropic admin API key to connect a new plan. + The key will be encrypted and stored securely. + + +
+
+ + setNewLabel(e.target.value)} + maxLength={200} + /> +
+
+ + setNewApiKey(e.target.value)} + /> +
+
+ + + +
+
+
+
+
+ + {connections.length === 0 ? ( +

+ No plan connections configured. Add one to enable Claude API cost tracking. +

+ ) : ( +
+ {connections.map((conn) => ( +
+
+ {editingId === conn.id ? ( +
+ setEditLabel(e.target.value)} + className="h-7 w-48" + maxLength={200} + onKeyDown={(e) => { + if (e.key === "Enter") handleUpdateLabel(conn.id); + if (e.key === "Escape") setEditingId(null); + }} + /> + + +
+ ) : ( +
+ {conn.label} + {conn.status === "active" && ( + + )} +
+ )} +
+ {conn.adminApiKeyHint} + Added {formatDateTime(conn.createdAt.toString())} +
+
+
+ {conn.status === "active" ? ( + <> + Connected + + + + + + + + Disconnect plan? + + This will stop syncing usage data from "{conn.label}". + Historical data will be preserved. + + + + Cancel + handleDisconnect(conn.id)} + > + Disconnect + + + + + + ) : ( + Disconnected + )} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/lib/anthropic-keys.ts b/src/lib/anthropic-keys.ts index d2fa43b..8e8f72e 100644 --- a/src/lib/anthropic-keys.ts +++ b/src/lib/anthropic-keys.ts @@ -16,21 +16,14 @@ export const orgApiKeysResponseSchema = z.object({ export type OrgApiKey = z.infer; -export async function fetchOrgApiKeys(): Promise { - const adminKey = process.env.ANTHROPIC_ADMIN_API_KEY; - if (!adminKey) { - throw new Error( - "ANTHROPIC_ADMIN_API_KEY environment variable is not set" - ); - } - +export async function fetchOrgApiKeys(adminApiKey: string): Promise { const url = "https://api.anthropic.com/v1/organizations/api_keys?status=active&limit=100"; const response = await fetch(url, { method: "GET", headers: { - "x-api-key": adminKey, + "x-api-key": adminApiKey, "anthropic-version": ANTHROPIC_API_VERSION, "Content-Type": "application/json", }, diff --git a/src/lib/anthropic-sync.ts b/src/lib/anthropic-sync.ts index f757ff4..2f4642a 100644 --- a/src/lib/anthropic-sync.ts +++ b/src/lib/anthropic-sync.ts @@ -2,11 +2,13 @@ import { db } from "@/lib/db"; import { anthropicUsageMetrics, anthropicSyncStatus, + anthropicPlanConnections, licenseAssignments, aiTools, } from "@/lib/db/schema"; import { eq, and, sql, desc, isNotNull, inArray } from "drizzle-orm"; import { decryptApiKey } from "@/lib/crypto"; +import { getActivePlanConnections } from "@/lib/plan-connections"; import { fetchOrgApiKeys, resolveApiKeyId } from "@/lib/anthropic-keys"; import { resolveModelPricing, @@ -19,9 +21,6 @@ import { z } from "zod"; // Constants // --------------------------------------------------------------------------- -const LOCK_USER_ID = 0; -const LOCK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes — stale lock threshold -const LOCK_COOLDOWN_MS = 60 * 1000; // 60 seconds — minimum between syncs const DEFAULT_BACKFILL_DAYS = 31; // --------------------------------------------------------------------------- @@ -76,12 +75,11 @@ export const anthropicToolFilter = sql`(${aiTools.vendor} ILIKE '%anthropic%' OR // --------------------------------------------------------------------------- export async function fetchAnthropicUsage( + adminApiKey: string, startingAt: string, endingAt: string, apiKeyIds?: string[] ): Promise> { - const adminKey = process.env.ANTHROPIC_ADMIN_API_KEY; - if (!adminKey) throw new Error("ANTHROPIC_ADMIN_API_KEY is not set"); // Build query string manually — URLSearchParams encodes [] as %5B%5D // which the Anthropic API does not recognise for array parameters. @@ -112,7 +110,7 @@ export async function fetchAnthropicUsage( const res = await fetch(`${baseUrl}?${query}`, { headers: { - "x-api-key": adminKey, + "x-api-key": adminApiKey, "anthropic-version": ANTHROPIC_API_VERSION, "Content-Type": "application/json", }, @@ -136,12 +134,18 @@ export async function fetchAnthropicUsage( // Helper: resolve all api_key_id → userId mappings // --------------------------------------------------------------------------- -export async function resolveAllMappings(): Promise> { +export async function resolveAllMappings( + adminApiKey: string, + planConnectionId: number +): Promise> { const mapping = new Map(); - // Get existing cached mappings + // Get existing cached mappings for this plan const existing = await db.query.anthropicSyncStatus.findMany({ - where: isNotNull(anthropicSyncStatus.resolvedApiKeyId), + where: and( + isNotNull(anthropicSyncStatus.resolvedApiKeyId), + eq(anthropicSyncStatus.planConnectionId, planConnectionId) + ), }); for (const row of existing) { if (row.resolvedApiKeyId) { @@ -166,46 +170,41 @@ export async function resolveAllMappings(): Promise> { ) ); - // Build a set of existing sync status rows with their timestamps for staleness check + // Build a set of existing sync status rows for staleness check const existingSyncMap = new Map( existing.map((row) => [row.userId, row]) ); // Re-resolve if: no cached mapping, or the assignment was updated after the last resolve + const mappedUserIds = new Set(mapping.values()); const needsResolve = usersWithKeys.filter((u) => { - if (!mapping.has(u.userId.toString()) && !Array.from(mapping.values()).includes(u.userId)) { - // No cached mapping for this user + if (!mappedUserIds.has(u.userId)) { return true; } - // Check if key was updated since we last resolved const syncRow = existingSyncMap.get(u.userId); if (syncRow?.resolvedApiKeyId && u.keyUpdatedAt > (syncRow.lastSyncCompletedAt ?? new Date(0))) { return true; } return false; }); - const mappedUserIds = new Set(mapping.values()); - const unmapped = needsResolve; - if (unmapped.length > 0) { - // Fetch org API keys to resolve - const orgKeys = await fetchOrgApiKeys(); + if (needsResolve.length > 0) { + const orgKeys = await fetchOrgApiKeys(adminApiKey); - for (const u of unmapped) { + for (const u of needsResolve) { if (!u.apiKeyEncrypted) continue; try { const decrypted = await decryptApiKey(u.apiKeyEncrypted); const apiKeyId = resolveApiKeyId(decrypted, orgKeys); if (apiKeyId) { mapping.set(apiKeyId, u.userId); - // Upsert sync status with resolved ID - await db - .insert(anthropicSyncStatus) - .values({ userId: u.userId, resolvedApiKeyId: apiKeyId }) - .onConflictDoUpdate({ - target: [anthropicSyncStatus.userId], - set: { resolvedApiKeyId: apiKeyId }, - }); + // Upsert sync status with resolved ID and plan + await db.execute(sql` + INSERT INTO anthropic_sync_status (user_id, resolved_api_key_id, plan_connection_id) + VALUES (${u.userId}, ${apiKeyId}, ${planConnectionId}) + ON CONFLICT (user_id, plan_connection_id) + DO UPDATE SET resolved_api_key_id = ${apiKeyId} + `); } } catch (err) { console.error(`Failed to resolve API key for user ${u.userId}:`, err); @@ -248,7 +247,8 @@ function computeSyncWindow(latestDateStr: string | null): { startingAt: string; export function prepareUsageRow( userId: number, bucketDate: string, - result: z.infer + result: z.infer, + planConnectionId: number ) { const model = result.model; if (!model) return null; @@ -277,6 +277,7 @@ export function prepareUsageRow( outputTokens: result.output_tokens, computedCostCents: costCents, pricingResolved: resolved, + planConnectionId, updatedAt: new Date(), }; } @@ -300,6 +301,7 @@ export async function batchUpsertUsageRows( anthropicUsageMetrics.userId, anthropicUsageMetrics.date, anthropicUsageMetrics.model, + anthropicUsageMetrics.planConnectionId, ], set: { uncachedInputTokens: sql`excluded.uncached_input_tokens`, @@ -321,93 +323,68 @@ export async function batchUpsertUsageRows( export async function runAnthropicSyncCore(): Promise { const summary: SyncSummary = { syncedUsers: 0, skippedUsers: 0, syncedDays: 0, errors: [] }; - // Ensure sentinel row exists (userId=0) for status tracking - await db - .insert(anthropicSyncStatus) - .values({ userId: LOCK_USER_ID }) - .onConflictDoNothing({ target: [anthropicSyncStatus.userId] }); + const plans = await getActivePlanConnections(); - // Mark sync start on sentinel row - await db - .update(anthropicSyncStatus) - .set({ lastSyncStartedAt: new Date(), lastSyncError: null }) - .where(eq(anthropicSyncStatus.userId, LOCK_USER_ID)); + if (plans.length === 0) { + summary.errors.push({ userId: 0, error: "No active plan connections found" }); + return summary; + } - try { - // Resolve all mappings - const apiKeyToUser = await resolveAllMappings(); - if (apiKeyToUser.size === 0) { - return { syncedUsers: 0, skippedUsers: 0, syncedDays: 0, errors: [{ userId: 0, error: "No users with resolved API keys" }] }; - } + for (const plan of plans) { + try { + const adminApiKey = plan.adminApiKey; - // Incremental sync: start from the latest stored date (or 31 days back) - // This handles month boundaries correctly — if cron was down, it backfills - const oldestLatest = await db - .select({ maxDate: sql`MAX(${anthropicUsageMetrics.date})` }) - .from(anthropicUsageMetrics); + // Resolve all mappings for this plan + const apiKeyToUser = await resolveAllMappings(adminApiKey, plan.id); + if (apiKeyToUser.size === 0) { + summary.skippedUsers++; + continue; + } - const { startingAt, endingAt } = computeSyncWindow(oldestLatest[0]?.maxDate ?? null); + // Incremental sync: start from the latest stored date for this plan + const oldestLatest = await db + .select({ maxDate: sql`MAX(${anthropicUsageMetrics.date})` }) + .from(anthropicUsageMetrics) + .where(eq(anthropicUsageMetrics.planConnectionId, plan.id)); - // Fetch all org usage in one call - const response = await fetchAnthropicUsage(startingAt, endingAt); + const { startingAt, endingAt } = computeSyncWindow(oldestLatest[0]?.maxDate ?? null); - // Track which users received data and unique synced days - const usersWithData = new Set(); - const syncedDates = new Set(); + // Fetch all org usage for this plan + const response = await fetchAnthropicUsage(adminApiKey, startingAt, endingAt); - // Collect all rows for batch upsert - const pendingRows: NonNullable>[] = []; + const usersWithData = new Set(); + const syncedDates = new Set(); + const pendingRows: NonNullable>[] = []; - for (const bucket of response.data) { - const bucketDate = bucket.starting_at.split("T")[0]; + for (const bucket of response.data) { + const bucketDate = bucket.starting_at.split("T")[0]; - for (const result of bucket.results) { - const apiKeyId = result.api_key_id; - const model = result.model; - if (!apiKeyId || !model) continue; + for (const result of bucket.results) { + const apiKeyId = result.api_key_id; + const model = result.model; + if (!apiKeyId || !model) continue; - const userId = apiKeyToUser.get(apiKeyId); - if (!userId) continue; + const userId = apiKeyToUser.get(apiKeyId); + if (!userId) continue; - usersWithData.add(userId); - syncedDates.add(bucketDate); + usersWithData.add(userId); + syncedDates.add(bucketDate); - const row = prepareUsageRow(userId, bucketDate, result); - if (row) pendingRows.push(row); + const row = prepareUsageRow(userId, bucketDate, result, plan.id); + if (row) pendingRows.push(row); + } } - } - // Batch upsert all collected rows - await batchUpsertUsageRows(pendingRows); + await batchUpsertUsageRows(pendingRows); - // Batch update sync status for all users that received data - const userIds = Array.from(usersWithData); - if (userIds.length > 0) { - await db - .update(anthropicSyncStatus) - .set({ lastSyncCompletedAt: new Date(), lastSyncError: null }) - .where(inArray(anthropicSyncStatus.userId, userIds)); + summary.syncedUsers += usersWithData.size; + summary.syncedDays += syncedDates.size; + summary.skippedUsers += new Set(apiKeyToUser.values()).size - usersWithData.size; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + console.error(`Anthropic sync failed for plan "${plan.label}":`, errorMsg); + summary.errors.push({ userId: 0, error: `Plan "${plan.label}": ${errorMsg}` }); } - - // Mark completion on sentinel row (including syncedDays) - const totalSyncedDays = syncedDates.size; - await db - .update(anthropicSyncStatus) - .set({ lastSyncCompletedAt: new Date(), lastSyncError: null, syncedDays: totalSyncedDays }) - .where(eq(anthropicSyncStatus.userId, LOCK_USER_ID)); - - summary.syncedUsers = usersWithData.size; - summary.syncedDays = totalSyncedDays; - summary.skippedUsers = new Set(apiKeyToUser.values()).size - usersWithData.size; - } catch (err) { - const errorMsg = err instanceof Error ? err.message : String(err); - console.error("Anthropic sync failed:", errorMsg); - // Set error on sentinel row only - await db - .update(anthropicSyncStatus) - .set({ lastSyncError: errorMsg.slice(0, 500) }) - .where(eq(anthropicSyncStatus.userId, LOCK_USER_ID)); - summary.errors.push({ userId: 0, error: errorMsg }); } return summary; @@ -418,49 +395,7 @@ export async function runAnthropicSyncCore(): Promise { // --------------------------------------------------------------------------- export async function runAnthropicSync(): Promise { - // Ensure sentinel row exists - await db - .insert(anthropicSyncStatus) - .values({ userId: LOCK_USER_ID }) - .onConflictDoNothing({ target: [anthropicSyncStatus.userId] }); - - // Atomic lock acquisition: only proceed if the row is not locked or lock is stale - const now = new Date(); - const staleThreshold = new Date(now.getTime() - LOCK_TIMEOUT_MS); - const cooldownThreshold = new Date(now.getTime() - LOCK_COOLDOWN_MS); - - const [lockAcquired] = await db - .update(anthropicSyncStatus) - .set({ lastSyncStartedAt: now, lastSyncError: null }) - .where( - and( - eq(anthropicSyncStatus.userId, LOCK_USER_ID), - sql`( - ${anthropicSyncStatus.lastSyncStartedAt} IS NULL - OR ( - ${anthropicSyncStatus.lastSyncCompletedAt} IS NOT NULL - AND ${anthropicSyncStatus.lastSyncCompletedAt} >= ${anthropicSyncStatus.lastSyncStartedAt} - AND ${anthropicSyncStatus.lastSyncCompletedAt} < ${cooldownThreshold} - ) - OR ( - ${anthropicSyncStatus.lastSyncCompletedAt} IS NOT NULL - AND ${anthropicSyncStatus.lastSyncCompletedAt} >= ${anthropicSyncStatus.lastSyncStartedAt} - AND ${anthropicSyncStatus.lastSyncStartedAt} < ${cooldownThreshold} - ) - OR ( - (${anthropicSyncStatus.lastSyncCompletedAt} IS NULL - OR ${anthropicSyncStatus.lastSyncCompletedAt} < ${anthropicSyncStatus.lastSyncStartedAt}) - AND ${anthropicSyncStatus.lastSyncStartedAt} < ${staleThreshold} - ) - )` - ) - ) - .returning(); - - if (!lockAcquired) { - return { syncedUsers: 0, skippedUsers: 0, syncedDays: 0, errors: [{ userId: 0, error: "Sync already in progress or completed recently" }] }; - } - + // Locking is now handled by the sync framework (withSyncLock in anthropic-usage source) return runAnthropicSyncCore(); } @@ -469,64 +404,82 @@ export async function runAnthropicSync(): Promise { // --------------------------------------------------------------------------- export async function syncSingleUser(userId: number): Promise<{ syncedDays: number; latestDate: string | null }> { - // Get or create sync status - let syncStatus = await db.query.anthropicSyncStatus.findFirst({ - where: eq(anthropicSyncStatus.userId, userId), + // Find the user's resolved plan by checking sync status + const existingSyncStatus = await db.query.anthropicSyncStatus.findFirst({ + where: and( + eq(anthropicSyncStatus.userId, userId), + isNotNull(anthropicSyncStatus.planConnectionId) + ), }); - if (!syncStatus) { - const [created] = await db - .insert(anthropicSyncStatus) - .values({ userId }) - .returning(); - syncStatus = created; - } + // Get the user's API key assignment + const [assignment] = await db + .select({ + apiKeyEncrypted: licenseAssignments.apiKeyEncrypted, + }) + .from(licenseAssignments) + .innerJoin(aiTools, eq(licenseAssignments.toolId, aiTools.id)) + .where( + and( + eq(licenseAssignments.userId, userId), + eq(licenseAssignments.status, "active"), + isNotNull(licenseAssignments.apiKeyEncrypted), + anthropicToolFilter + ) + ) + .limit(1); - // Mark sync start time - await db - .update(anthropicSyncStatus) - .set({ lastSyncStartedAt: new Date(), lastSyncError: null }) - .where(eq(anthropicSyncStatus.userId, userId)); + if (!assignment?.apiKeyEncrypted) { + throw new Error("No API key configured for this user"); + } - try { - // Resolve API key ID if not cached - if (!syncStatus.resolvedApiKeyId) { - const [assignment] = await db - .select({ - apiKeyEncrypted: licenseAssignments.apiKeyEncrypted, - }) - .from(licenseAssignments) - .innerJoin(aiTools, eq(licenseAssignments.toolId, aiTools.id)) - .where( - and( - eq(licenseAssignments.userId, userId), - eq(licenseAssignments.status, "active"), - isNotNull(licenseAssignments.apiKeyEncrypted), - anthropicToolFilter - ) - ) - .limit(1); - - if (!assignment?.apiKeyEncrypted) { - throw new Error("No API key configured for this user"); - } + // If we have a cached plan, use it. Otherwise, resolve across all plans. + let resolvedPlanId: number | null = existingSyncStatus?.planConnectionId ?? null; + let resolvedApiKeyId = existingSyncStatus?.resolvedApiKeyId ?? null; + let adminApiKey: string | null = null; + + if (resolvedPlanId && resolvedApiKeyId) { + // Use cached plan + const plan = await db.query.anthropicPlanConnections.findFirst({ + where: and( + eq(anthropicPlanConnections.id, resolvedPlanId), + eq(anthropicPlanConnections.status, "active") + ), + }); + if (plan) { + adminApiKey = await decryptApiKey(plan.adminApiKeyEncrypted); + } + } - const decrypted = await decryptApiKey(assignment.apiKeyEncrypted); - const orgKeys = await fetchOrgApiKeys(); - const apiKeyId = resolveApiKeyId(decrypted, orgKeys); - if (!apiKeyId) { - throw new Error("Could not resolve API key ID from org keys"); + // If no cached plan or plan was disconnected, resolve across all active plans + if (!adminApiKey) { + const plans = await getActivePlanConnections(); + const decryptedUserKey = await decryptApiKey(assignment.apiKeyEncrypted); + + for (const plan of plans) { + const orgKeys = await fetchOrgApiKeys(plan.adminApiKey); + const keyId = resolveApiKeyId(decryptedUserKey, orgKeys); + if (keyId) { + resolvedPlanId = plan.id; + resolvedApiKeyId = keyId; + adminApiKey = plan.adminApiKey; + // Cache the resolution + await db.execute(sql` + INSERT INTO anthropic_sync_status (user_id, resolved_api_key_id, plan_connection_id) + VALUES (${userId}, ${keyId}, ${plan.id}) + ON CONFLICT (user_id, plan_connection_id) + DO UPDATE SET resolved_api_key_id = ${keyId} + `); + break; } - - await db - .update(anthropicSyncStatus) - .set({ resolvedApiKeyId: apiKeyId }) - .where(eq(anthropicSyncStatus.userId, userId)); - - syncStatus = { ...syncStatus, resolvedApiKeyId: apiKeyId }; } + } - // Determine start date + if (!adminApiKey || !resolvedApiKeyId || !resolvedPlanId) { + throw new Error("Could not resolve API key ID from any active plan"); + } + + try { const latestRow = await db.query.anthropicUsageMetrics.findFirst({ where: eq(anthropicUsageMetrics.userId, userId), orderBy: desc(anthropicUsageMetrics.date), @@ -534,50 +487,30 @@ export async function syncSingleUser(userId: number): Promise<{ syncedDays: numb const { startingAt, endingAt } = computeSyncWindow(latestRow?.date ?? null); - // Fetch filtered by this user's API key - const response = await fetchAnthropicUsage(startingAt, endingAt, [syncStatus.resolvedApiKeyId!]); + const response = await fetchAnthropicUsage(adminApiKey, startingAt, endingAt, [resolvedApiKeyId]); - // Collect all rows for batch upsert const pendingRows: NonNullable>[] = []; let syncedDays = 0; let latestDate: string | null = null; for (const bucket of response.data) { const bucketDate = bucket.starting_at.split("T")[0]; - for (const result of bucket.results) { - const row = prepareUsageRow(userId, bucketDate, result); + const row = prepareUsageRow(userId, bucketDate, result, resolvedPlanId); if (row) pendingRows.push(row); } - syncedDays++; if (!latestDate || bucketDate > latestDate) { latestDate = bucketDate; } } - // Batch upsert all collected rows await batchUpsertUsageRows(pendingRows); - await db - .update(anthropicSyncStatus) - .set({ - lastSyncCompletedAt: new Date(), - lastSyncError: null, - syncedDays, - }) - .where(eq(anthropicSyncStatus.userId, userId)); - return { syncedDays, latestDate }; } catch (err) { const errorMsg = err instanceof Error ? err.message : String(err); - await db - .update(anthropicSyncStatus) - .set({ - lastSyncError: errorMsg.slice(0, 500), - lastSyncCompletedAt: new Date(), - }) - .where(eq(anthropicSyncStatus.userId, userId)); + console.error(`Single-user sync failed for user ${userId}:`, errorMsg); throw err; } } diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 109edbe..8a96f21 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -85,6 +85,12 @@ export const ingestionChannelEnum = pgEnum("ingestion_channel", [ "bulk", ]); +// Anthropic plan connection enum (026-multiple-api-plans) +export const anthropicPlanStatusEnum = pgEnum("anthropic_plan_status", [ + "active", + "disconnected", +]); + // Sync framework enums (019-invoice-automations) export const syncSourceTypeEnum = pgEnum("sync_source_type", [ "github_copilot_billing", @@ -547,14 +553,18 @@ export const anthropicUsageMetrics = pgTable( .default(0), computedCostCents: integer("computed_cost_cents").notNull().default(0), pricingResolved: boolean("pricing_resolved").notNull().default(true), + planConnectionId: integer("plan_connection_id") + .notNull() + .references(() => anthropicPlanConnections.id), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }, (table) => [ - uniqueIndex("anthropic_usage_metrics_user_date_model_idx").on( + uniqueIndex("anthropic_usage_metrics_user_date_model_plan_idx").on( table.userId, table.date, - table.model + table.model, + table.planConnectionId ), index("anthropic_usage_metrics_user_date_idx").on(table.userId, table.date), index("anthropic_usage_metrics_date_idx").on(table.date), @@ -574,9 +584,15 @@ export const anthropicSyncStatus = pgTable( lastSyncError: varchar("last_sync_error", { length: 500 }), syncedDays: integer("synced_days").notNull().default(0), resolvedApiKeyId: varchar("resolved_api_key_id", { length: 100 }), + planConnectionId: integer("plan_connection_id"), workspaceSyncCompletedAt: timestamp("workspace_sync_completed_at"), }, - (table) => [uniqueIndex("anthropic_sync_status_user_id_idx").on(table.userId)] + (table) => [ + uniqueIndex("anthropic_sync_status_user_plan_idx").on( + table.userId, + table.planConnectionId + ), + ] ); // Sync Sources (019-invoice-automations) @@ -607,6 +623,9 @@ export const syncEvents = pgTable( startedAt: timestamp("started_at").notNull().defaultNow(), completedAt: timestamp("completed_at"), triggeredBy: integer("triggered_by").references(() => users.id), + planConnectionId: integer("plan_connection_id").references( + () => anthropicPlanConnections.id + ), createdCount: integer("created_count").notNull().default(0), updatedCount: integer("updated_count").notNull().default(0), skippedCount: integer("skipped_count").notNull().default(0), @@ -669,15 +688,18 @@ export const anthropicWorkspaces = pgTable( archivedAt: timestamp("archived_at"), anthropicCreatedAt: timestamp("anthropic_created_at"), lastSeenAt: timestamp("last_seen_at").notNull().defaultNow(), + planConnectionId: integer("plan_connection_id") + .notNull() + .references(() => anthropicPlanConnections.id), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }, (table) => [ - uniqueIndex("anthropic_workspaces_workspace_id_idx") - .on(table.workspaceId) + uniqueIndex("anthropic_workspaces_workspace_plan_idx") + .on(table.workspaceId, table.planConnectionId) .where(sql`${table.workspaceId} IS NOT NULL`), - uniqueIndex("anthropic_workspaces_is_default_idx") - .on(table.isDefault) + uniqueIndex("anthropic_workspaces_default_plan_idx") + .on(table.planConnectionId, table.isDefault) .where(sql`${table.isDefault} = true`), index("anthropic_workspaces_archived_idx").on(table.isArchived), ] @@ -691,15 +713,18 @@ export const anthropicWorkspaceCosts = pgTable( workspaceId: varchar("workspace_id", { length: 100 }), date: date("date").notNull(), costCents: integer("cost_cents").notNull(), + planConnectionId: integer("plan_connection_id") + .notNull() + .references(() => anthropicPlanConnections.id), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }, (table) => [ - uniqueIndex("anthropic_workspace_costs_workspace_date_idx") - .on(table.workspaceId, table.date) + uniqueIndex("anthropic_workspace_costs_ws_date_plan_idx") + .on(table.workspaceId, table.date, table.planConnectionId) .where(sql`${table.workspaceId} IS NOT NULL`), - uniqueIndex("anthropic_workspace_costs_default_date_idx") - .on(table.date) + uniqueIndex("anthropic_workspace_costs_default_date_plan_idx") + .on(table.date, table.planConnectionId) .where(sql`${table.workspaceId} IS NULL`), index("anthropic_workspace_costs_date_idx").on(table.date), index("anthropic_workspace_costs_workspace_id_idx").on(table.workspaceId), @@ -741,6 +766,28 @@ export const anthropicOrgConfig = pgTable( ] ); +// Anthropic Plan Connections (026-multiple-api-plans) +export const anthropicPlanConnections = pgTable( + "anthropic_plan_connections", + { + id: serial("id").primaryKey(), + label: varchar("label", { length: 200 }).notNull(), + adminApiKeyEncrypted: varchar("admin_api_key_encrypted", { length: 700 }).notNull(), + adminApiKeyHint: varchar("admin_api_key_hint", { length: 20 }).notNull(), + status: anthropicPlanStatusEnum("status").notNull().default("active"), + disconnectedAt: timestamp("disconnected_at"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + createdBy: integer("created_by").references(() => users.id), + }, + (table) => [ + uniqueIndex("anthropic_plan_connections_hint_active_idx") + .on(table.adminApiKeyHint) + .where(sql`${table.status} = 'active'`), + index("anthropic_plan_connections_status_idx").on(table.status), + ] +); + // Relations export const usersRelations = relations(users, ({ many, one }) => ({ licenseAssignments: many(licenseAssignments), diff --git a/src/lib/plan-connections.ts b/src/lib/plan-connections.ts new file mode 100644 index 0000000..6358bb4 --- /dev/null +++ b/src/lib/plan-connections.ts @@ -0,0 +1,41 @@ +/** + * Internal helpers for plan connections. + * NOT a "use server" module — cannot be invoked as a server action from the client. + */ + +import { db } from "@/lib/db"; +import { anthropicPlanConnections } from "@/lib/db/schema"; +import { eq, sql } from "drizzle-orm"; +import { decryptApiKey } from "@/lib/crypto"; + +/** + * Returns all active plan connections with their decrypted admin API keys. + * Internal use only — never expose decrypted keys to the client. + */ +export async function getActivePlanConnections(): Promise< + { id: number; label: string; adminApiKey: string }[] +> { + const plans = await db + .select() + .from(anthropicPlanConnections) + .where(eq(anthropicPlanConnections.status, "active")); + + return Promise.all( + plans.map(async (plan) => ({ + id: plan.id, + label: plan.label, + adminApiKey: await decryptApiKey(plan.adminApiKeyEncrypted), + })) + ); +} + +/** + * Returns the count of active plan connections. + */ +export async function getActivePlanCount(): Promise { + const [row] = await db + .select({ count: sql`count(*)::int` }) + .from(anthropicPlanConnections) + .where(eq(anthropicPlanConnections.status, "active")); + return row?.count ?? 0; +} diff --git a/src/lib/profile-data.ts b/src/lib/profile-data.ts index 5fad0a1..dcec719 100644 --- a/src/lib/profile-data.ts +++ b/src/lib/profile-data.ts @@ -11,12 +11,13 @@ import { db } from "@/lib/db"; import { anthropicUsageMetrics, + anthropicPlanConnections, licenseAssignments, aiTools, accessTiers, users, } from "@/lib/db/schema"; -import { eq, and, between, isNotNull } from "drizzle-orm"; +import { eq, and, between, isNotNull, inArray, sql } from "drizzle-orm"; import { anthropicToolFilter } from "@/lib/anthropic-sync"; import type { CostData, @@ -30,7 +31,8 @@ import type { export async function fetchUserCostDataInternal( userId: number, - month?: string + month?: string, + opts?: { includePlanLabel?: boolean } ): Promise { // Determine month boundaries (UTC-consistent) const now = new Date(); @@ -94,14 +96,32 @@ export async function fetchUserCostDataInternal( }; } - // Query usage metrics for the user in the date range + // Query usage metrics for the user in the date range, filtered to active plans const metrics = await db - .select() + .select({ + id: anthropicUsageMetrics.id, + userId: anthropicUsageMetrics.userId, + date: anthropicUsageMetrics.date, + model: anthropicUsageMetrics.model, + uncachedInputTokens: anthropicUsageMetrics.uncachedInputTokens, + cacheReadInputTokens: anthropicUsageMetrics.cacheReadInputTokens, + cacheCreationInputTokens: anthropicUsageMetrics.cacheCreationInputTokens, + outputTokens: anthropicUsageMetrics.outputTokens, + computedCostCents: anthropicUsageMetrics.computedCostCents, + pricingResolved: anthropicUsageMetrics.pricingResolved, + planConnectionId: anthropicUsageMetrics.planConnectionId, + planLabel: anthropicPlanConnections.label, + }) .from(anthropicUsageMetrics) + .innerJoin( + anthropicPlanConnections, + eq(anthropicUsageMetrics.planConnectionId, anthropicPlanConnections.id) + ) .where( and( eq(anthropicUsageMetrics.userId, userId), - between(anthropicUsageMetrics.date, startDate, endDate) + between(anthropicUsageMetrics.date, startDate, endDate), + eq(anthropicPlanConnections.status, "active") ) ) .orderBy(anthropicUsageMetrics.date); @@ -168,12 +188,20 @@ export async function fetchUserCostDataInternal( })) .sort((a, b) => a.date.localeCompare(b.date)); + // Determine plan label if admin requested it and there's a single plan + let planLabel: string | undefined; + if (opts?.includePlanLabel && metrics.length > 0) { + const uniqueLabels = [...new Set(metrics.map((m) => m.planLabel))]; + planLabel = uniqueLabels.length === 1 ? uniqueLabels[0] : `${uniqueLabels.length} plans`; + } + return { available: true, monthlyTotalCents, dailyBreakdown, latestDataDate, hasUnresolvedPricing, + ...(planLabel ? { planLabel } : {}), }; } diff --git a/src/lib/sync/framework.ts b/src/lib/sync/framework.ts index da9a779..f983753 100644 --- a/src/lib/sync/framework.ts +++ b/src/lib/sync/framework.ts @@ -39,6 +39,7 @@ export interface WithSyncLockParams { triggeredBy?: number; operationType?: SyncOperationType; backfillStartDate?: Date; + planConnectionId?: number; } // --------------------------------------------------------------------------- @@ -113,7 +114,10 @@ export async function withSyncLock( params: WithSyncLockParams, fn: (eventId: number) => Promise ): Promise<{ eventId: number }> { - const lockId = hashSourceType(params.sourceType); + const lockKey = params.planConnectionId + ? `${params.sourceType}:plan_${params.planConnectionId}` + : params.sourceType; + const lockId = hashSourceType(lockKey); // Try to acquire advisory lock const lockResult = await db.execute( @@ -135,6 +139,7 @@ export async function withSyncLock( ? params.backfillStartDate.toISOString().split("T")[0] : null, triggeredBy: params.triggeredBy ?? null, + planConnectionId: params.planConnectionId ?? null, }) .returning({ id: syncEvents.id }); diff --git a/src/lib/sync/sources/anthropic-usage.ts b/src/lib/sync/sources/anthropic-usage.ts index 32f9eed..0a42975 100644 --- a/src/lib/sync/sources/anthropic-usage.ts +++ b/src/lib/sync/sources/anthropic-usage.ts @@ -6,12 +6,14 @@ import { prepareUsageRow, batchUpsertUsageRows, } from "@/lib/anthropic-sync"; +import { getActivePlanConnections } from "@/lib/plan-connections"; const WINDOW_DAYS = 31; // Anthropic API max per-request window interface RunOptions { force?: boolean; backfillStartDate?: Date; + planConnectionId?: number; } export async function run( @@ -24,8 +26,9 @@ export async function run( triggeredBy, operationType: opts?.backfillStartDate ? "backfill" : "regular", backfillStartDate: opts?.backfillStartDate, + planConnectionId: opts?.planConnectionId, }, - async (eventId) => { + async () => { const counts: SyncCounts = { createdCount: 0, updatedCount: 0, @@ -33,7 +36,7 @@ export async function run( errorCount: 0, }; - // Regular sync — delegate to core sync logic (no inner lock, framework handles locking) + // Regular sync — delegate to core sync logic (iterates all plans internally) if (!opts?.backfillStartDate) { try { const summary = await retryWithBackoff(() => runAnthropicSyncCore()); @@ -56,63 +59,70 @@ export async function run( return counts; } - // Backfill mode — iterate in 31-day windows from startDate to today + // Backfill mode — iterate plans, then 31-day windows per plan try { - const apiKeyToUser = await resolveAllMappings(); - if (apiKeyToUser.size === 0) { - counts.errorMessage = "No users with resolved API keys"; + const plans = await getActivePlanConnections(); + if (plans.length === 0) { + counts.errorMessage = "No active plan connections"; return counts; } - const startDate = opts.backfillStartDate; - const now = new Date(); - now.setUTCHours(0, 0, 0, 0); + for (const plan of plans) { + if (opts?.planConnectionId && plan.id !== opts.planConnectionId) continue; - let windowStart = new Date(startDate); - windowStart.setUTCHours(0, 0, 0, 0); + const apiKeyToUser = await resolveAllMappings(plan.adminApiKey, plan.id); + if (apiKeyToUser.size === 0) continue; - while (windowStart <= now) { - const windowEnd = new Date(windowStart); - windowEnd.setUTCDate(windowEnd.getUTCDate() + WINDOW_DAYS); - if (windowEnd > now) { - windowEnd.setTime(now.getTime()); - windowEnd.setUTCDate(windowEnd.getUTCDate() + 1); - } - - const startingAt = windowStart.toISOString().replace(/\.\d+Z$/, "Z"); - const endingAt = windowEnd.toISOString().replace(/\.\d+Z$/, "Z"); + const startDate = opts.backfillStartDate; + const now = new Date(); + now.setUTCHours(0, 0, 0, 0); - const response = await retryWithBackoff(() => - fetchAnthropicUsage(startingAt, endingAt) - ); + let windowStart = new Date(startDate); + windowStart.setUTCHours(0, 0, 0, 0); - const pendingRows: NonNullable>[] = []; - const syncedDates = new Set(); + while (windowStart <= now) { + const windowEnd = new Date(windowStart); + windowEnd.setUTCDate(windowEnd.getUTCDate() + WINDOW_DAYS); + if (windowEnd > now) { + windowEnd.setTime(now.getTime()); + windowEnd.setUTCDate(windowEnd.getUTCDate() + 1); + } - for (const bucket of response.data) { - const bucketDate = bucket.starting_at.split("T")[0]; - for (const result of bucket.results) { - const apiKeyId = result.api_key_id; - const model = result.model; - if (!apiKeyId || !model) continue; - const userId = apiKeyToUser.get(apiKeyId); - if (!userId) continue; + const startingAt = windowStart.toISOString().replace(/\.\d+Z$/, "Z"); + const endingAt = windowEnd.toISOString().replace(/\.\d+Z$/, "Z"); + + const response = await retryWithBackoff(() => + fetchAnthropicUsage(plan.adminApiKey, startingAt, endingAt) + ); + + const pendingRows: NonNullable>[] = []; + const syncedDates = new Set(); + + for (const bucket of response.data) { + const bucketDate = bucket.starting_at.split("T")[0]; + for (const result of bucket.results) { + const apiKeyId = result.api_key_id; + const model = result.model; + if (!apiKeyId || !model) continue; + const userId = apiKeyToUser.get(apiKeyId); + if (!userId) continue; + + syncedDates.add(bucketDate); + const row = prepareUsageRow(userId, bucketDate, result, plan.id); + if (row) pendingRows.push(row); + } + } - syncedDates.add(bucketDate); - const row = prepareUsageRow(userId, bucketDate, result); - if (row) pendingRows.push(row); + if (pendingRows.length > 0) { + await batchUpsertUsageRows(pendingRows); } - } - if (pendingRows.length > 0) { - await batchUpsertUsageRows(pendingRows); + counts.updatedCount += syncedDates.size; + windowStart = new Date(windowEnd); } - counts.updatedCount += syncedDates.size; - windowStart = new Date(windowEnd); + counts.createdCount += apiKeyToUser.size; } - - counts.createdCount = apiKeyToUser.size; } catch (err) { counts.errorCount++; counts.errorMessage = diff --git a/src/lib/sync/sources/anthropic-workspace.ts b/src/lib/sync/sources/anthropic-workspace.ts index 02a8ded..e12f5ae 100644 --- a/src/lib/sync/sources/anthropic-workspace.ts +++ b/src/lib/sync/sources/anthropic-workspace.ts @@ -1,9 +1,10 @@ import { withSyncLock, retryWithBackoff, type SyncCounts } from "@/lib/sync/framework"; import { db } from "@/lib/db"; -import { anthropicWorkspaceCosts } from "@/lib/db/schema"; -import { sql } from "drizzle-orm"; +import { anthropicWorkspaceCosts, anthropicPlanConnections } from "@/lib/db/schema"; +import { sql, eq } from "drizzle-orm"; import { z } from "zod"; import { ANTHROPIC_API_VERSION } from "@/lib/anthropic-constants"; +import { decryptApiKey } from "@/lib/crypto"; // --------------------------------------------------------------------------- // Types @@ -13,6 +14,7 @@ interface RunOptions { force?: boolean; month?: string; backfillStartDate?: Date; + planConnectionId?: number; } // Zod schemas for Anthropic API responses @@ -57,13 +59,10 @@ const costReportResponseSchema = z.object({ // Helpers // --------------------------------------------------------------------------- -async function fetchWorkspaces(): Promise> { - const adminKey = process.env.ANTHROPIC_ADMIN_API_KEY; - if (!adminKey) throw new Error("ANTHROPIC_ADMIN_API_KEY is not set"); - +async function fetchWorkspaces(adminApiKey: string): Promise> { const res = await fetch("https://api.anthropic.com/v1/organizations/workspaces", { headers: { - "x-api-key": adminKey, + "x-api-key": adminApiKey, "anthropic-version": ANTHROPIC_API_VERSION, }, }); @@ -77,11 +76,10 @@ async function fetchWorkspaces(): Promise[]> { - const adminKey = process.env.ANTHROPIC_ADMIN_API_KEY; - if (!adminKey) throw new Error("ANTHROPIC_ADMIN_API_KEY is not set"); const allBuckets: z.infer[] = []; let page: string | undefined; @@ -98,7 +96,7 @@ async function fetchCostReport( `https://api.anthropic.com/v1/organizations/cost_report?${query}`, { headers: { - "x-api-key": adminKey, + "x-api-key": adminApiKey, "anthropic-version": ANTHROPIC_API_VERSION, }, } @@ -117,8 +115,8 @@ async function fetchCostReport( return allBuckets; } -async function fetchAndUpsertWorkspaces(): Promise { - const response = await retryWithBackoff(() => fetchWorkspaces()); +async function fetchAndUpsertWorkspaces(adminApiKey: string, planConnectionId: number): Promise { + const response = await retryWithBackoff(() => fetchWorkspaces(adminApiKey)); // Use raw SQL to correctly target the partial unique index on workspace_id // (Drizzle generates incorrect WHERE clauses for partial-index ON CONFLICT). @@ -127,13 +125,13 @@ async function fetchAndUpsertWorkspaces(): Promise { const now = new Date(); if (response.data.length > 0) { const valuesSql = sql.join( - response.data.map((ws) => sql`(${ws.id}, ${ws.name}, FALSE, ${ws.is_archived}, ${now}, ${now})`), + response.data.map((ws) => sql`(${ws.id}, ${ws.name}, FALSE, ${ws.is_archived}, ${now}, ${planConnectionId}, ${now})`), sql`, ` ); await db.execute(sql` - INSERT INTO anthropic_workspaces (workspace_id, name, is_default, is_archived, last_seen_at, updated_at) + INSERT INTO anthropic_workspaces (workspace_id, name, is_default, is_archived, last_seen_at, plan_connection_id, updated_at) VALUES ${valuesSql} - ON CONFLICT (workspace_id) WHERE workspace_id IS NOT NULL + ON CONFLICT (workspace_id, plan_connection_id) WHERE workspace_id IS NOT NULL DO UPDATE SET name = EXCLUDED.name, is_default = FALSE, @@ -146,7 +144,7 @@ async function fetchAndUpsertWorkspaces(): Promise { return response.data.length; } -async function fetchAndUpsertWorkspaceCosts(month: string): Promise { +async function fetchAndUpsertWorkspaceCosts(adminApiKey: string, planConnectionId: number, month: string): Promise { // month format: YYYY-MM const startDate = `${month}-01T00:00:00Z`; const endYear = parseInt(month.slice(0, 4)); @@ -156,7 +154,7 @@ async function fetchAndUpsertWorkspaceCosts(month: string): Promise { const endDate = `${nextYear}-${String(nextMonth).padStart(2, "0")}-01T00:00:00Z`; const buckets = await retryWithBackoff(() => - fetchCostReport(startDate, endDate) + fetchCostReport(adminApiKey, startDate, endDate) ); // Flatten all results across time buckets and aggregate per workspace @@ -174,17 +172,16 @@ async function fetchAndUpsertWorkspaceCosts(month: string): Promise { for (const [wsId, costCents] of costByWorkspace) { if (wsId) { await db.execute(sql` - INSERT INTO anthropic_workspace_costs (workspace_id, date, cost_cents) - VALUES (${wsId}, ${dateStr}, ${costCents}) - ON CONFLICT (workspace_id, date) WHERE workspace_id IS NOT NULL + INSERT INTO anthropic_workspace_costs (workspace_id, date, cost_cents, plan_connection_id) + VALUES (${wsId}, ${dateStr}, ${costCents}, ${planConnectionId}) + ON CONFLICT (workspace_id, date, plan_connection_id) WHERE workspace_id IS NOT NULL DO UPDATE SET cost_cents = ${costCents}, updated_at = now() `); } else { - // Default workspace (null workspace_id) await db.execute(sql` - INSERT INTO anthropic_workspace_costs (workspace_id, date, cost_cents) - VALUES (NULL, ${dateStr}, ${costCents}) - ON CONFLICT (date) WHERE workspace_id IS NULL + INSERT INTO anthropic_workspace_costs (workspace_id, date, cost_cents, plan_connection_id) + VALUES (NULL, ${dateStr}, ${costCents}, ${planConnectionId}) + ON CONFLICT (date, plan_connection_id) WHERE workspace_id IS NULL DO UPDATE SET cost_cents = ${costCents}, updated_at = now() `); } @@ -209,6 +206,43 @@ function appendError(counts: SyncCounts, msg: string): void { // Main run function // --------------------------------------------------------------------------- +async function syncSinglePlan( + adminApiKey: string, + planConnectionId: number, + counts: SyncCounts, + opts?: RunOptions +): Promise { + // Non-fatal — cost sync can proceed without workspace metadata + try { + counts.createdCount += await fetchAndUpsertWorkspaces(adminApiKey, planConnectionId); + } catch (err) { + const msg = `Workspace metadata sync failed: ${err instanceof Error ? err.message : String(err)}`; + appendError(counts, msg); + console.warn(`[anthropic-api-costs] ${msg} — continuing with cost sync`); + } + + if (opts?.backfillStartDate) { + const start = opts.backfillStartDate; + const now = new Date(); + const current = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), 1)); + + while (current <= now) { + const month = `${current.getUTCFullYear()}-${String(current.getUTCMonth() + 1).padStart(2, "0")}`; + try { + counts.updatedCount += await fetchAndUpsertWorkspaceCosts(adminApiKey, planConnectionId, month); + } catch (err) { + appendError(counts, `Backfill failed for ${month}: ${err instanceof Error ? err.message : String(err)}`); + } + current.setUTCMonth(current.getUTCMonth() + 1); + } + } else { + const now = new Date(); + const month = opts?.month ?? + `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`; + counts.updatedCount += await fetchAndUpsertWorkspaceCosts(adminApiKey, planConnectionId, month); + } +} + export async function run( triggeredBy?: number, opts?: RunOptions @@ -219,8 +253,9 @@ export async function run( triggeredBy, operationType: opts?.backfillStartDate ? "backfill" : "regular", backfillStartDate: opts?.backfillStartDate, + planConnectionId: opts?.planConnectionId, }, - async (eventId) => { + async () => { const counts: SyncCounts = { createdCount: 0, updatedCount: 0, @@ -228,43 +263,36 @@ export async function run( errorCount: 0, }; - // Non-fatal — cost sync can proceed without workspace metadata - try { - counts.createdCount = await fetchAndUpsertWorkspaces(); - } catch (err) { - const msg = `Workspace metadata sync failed: ${err instanceof Error ? err.message : String(err)}`; - appendError(counts, msg); - console.warn(`[anthropic-api-costs] ${msg} — continuing with cost sync`); + // Determine which plans to sync + let plans: { id: number; adminApiKeyEncrypted: string; label: string }[]; + if (opts?.planConnectionId) { + const plan = await db.query.anthropicPlanConnections.findFirst({ + where: eq(anthropicPlanConnections.id, opts.planConnectionId), + }); + if (!plan || plan.status !== "active") { + appendError(counts, `Plan connection ${opts.planConnectionId} not found or not active`); + return counts; + } + plans = [plan]; + } else { + plans = await db + .select() + .from(anthropicPlanConnections) + .where(eq(anthropicPlanConnections.status, "active")); } - if (opts?.backfillStartDate) { - const start = opts.backfillStartDate; - const now = new Date(); - const current = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), 1)); - const failedMonths: string[] = []; - - while (current <= now) { - const month = `${current.getUTCFullYear()}-${String(current.getUTCMonth() + 1).padStart(2, "0")}`; - try { - counts.updatedCount += await fetchAndUpsertWorkspaceCosts(month); - } catch (err) { - failedMonths.push(month); - appendError(counts, `Backfill failed for ${month}: ${err instanceof Error ? err.message : String(err)}`); - } - current.setUTCMonth(current.getUTCMonth() + 1); - } + if (plans.length === 0) { + counts.skippedCount = 1; + appendError(counts, "No active plan connections found"); + return counts; + } - if (failedMonths.length > 0) { - console.warn(`[anthropic-api-costs] Backfill failed for months: ${failedMonths.join(", ")}`); - } - } else { + for (const plan of plans) { try { - const now = new Date(); - const month = opts?.month ?? - `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`; - counts.updatedCount = await fetchAndUpsertWorkspaceCosts(month); + const adminApiKey = await decryptApiKey(plan.adminApiKeyEncrypted); + await syncSinglePlan(adminApiKey, plan.id, counts, opts); } catch (err) { - appendError(counts, `Cost sync failed: ${err instanceof Error ? err.message : String(err)}`); + appendError(counts, `Plan "${plan.label}" failed: ${err instanceof Error ? err.message : String(err)}`); } } diff --git a/src/lib/validators.ts b/src/lib/validators.ts index cf3a3cc..09e4e0d 100644 --- a/src/lib/validators.ts +++ b/src/lib/validators.ts @@ -409,3 +409,28 @@ export type CreateIngestionFilterInput = z.infer< export type UpdateIngestionFilterInput = z.infer< typeof updateIngestionFilterSchema >; + +// 026-multiple-api-plans: Plan connection validators +export const addPlanConnectionSchema = z.object({ + label: z + .string() + .trim() + .min(1, "Label is required") + .max(200, "Label must be 200 characters or less"), + adminApiKey: z + .string() + .trim() + .min(1, "Admin API key is required"), +}); + +export const updatePlanConnectionLabelSchema = z.object({ + id: z.number().int().positive(), + label: z + .string() + .trim() + .min(1, "Label is required") + .max(200, "Label must be 200 characters or less"), +}); + +export type AddPlanConnectionInput = z.infer; +export type UpdatePlanConnectionLabelInput = z.infer; diff --git a/src/types/index.ts b/src/types/index.ts index 0399733..8494c19 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -19,6 +19,7 @@ import type { anthropicWorkspaceCosts, anthropicWorkspaceLimits, anthropicOrgConfig, + anthropicPlanConnections, } from "@/lib/db/schema"; // Action result type @@ -439,6 +440,7 @@ export type CostData = { dailyBreakdown: DailyBreakdown[]; latestDataDate: string | null; hasUnresolvedPricing: boolean; + planLabel?: string; }; export type ProfileData = { @@ -476,6 +478,8 @@ export interface GlobalCostDashboardData { workspaceBreakdown: { workspaceId: string | null; name: string; + planLabel?: string; + planConnectionId?: number; totalCents: number; dailyTotals: { date: string; costCents: number }[]; }[]; @@ -505,3 +509,17 @@ export interface ActiveAlertsData { } export type OrgCreditsStatus = { available: false; reason: string }; + +// 026-multiple-api-plans types +export type AnthropicPlanConnection = InferSelectModel; +export type NewAnthropicPlanConnection = InferInsertModel; +export type AnthropicPlanStatus = "active" | "disconnected"; + +export interface PlanConnectionListItem { + id: number; + label: string; + adminApiKeyHint: string; + status: AnthropicPlanStatus; + createdAt: Date; + disconnectedAt: Date | null; +}