diff --git a/app/components/BillingCreditsViewer.vue b/app/components/BillingCreditsViewer.vue index 2bdb783d..f5dd5ba3 100644 --- a/app/components/BillingCreditsViewer.vue +++ b/app/components/BillingCreditsViewer.vue @@ -225,19 +225,20 @@ Top spenders by net cost - (loaded {{ loadedLoginsCount }} of {{ perUserRows.length }} users; sort by $ or page through to load more) + (top {{ topSpendersCount }} globally) - Source: Billing API (ai_credit/usage per user) · {{ rangeLabel }} + Source: Billing CSV database · {{ rangeLabel }} -
+ +
- No per-user spend loaded yet — page through the table below to - populate billing for visible users. + Global top spenders are available after Billing CSV data has + been ingested for this period. @@ -397,6 +398,7 @@ import { } from 'chart.js'; import { PALETTE } from '@/utils/chartPlugins'; import type { BillingCreditsResponse, BillingUsageItem } from '../../server/api/billing-credits.get'; +import type { TopBillingUsersResponse } from '../../server/services/billing-credit-reader'; import { buildDataSourceBadge } from '#shared/utils/data-source-badge'; ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend); @@ -496,6 +498,23 @@ export default defineComponent({ }, }); + const topSpendersQuery = computed>(() => ({ + ...billingQuery.value, + limit: '10', + metric: 'netAmount', + })); + const { + data: topSpendersData, + pending: topSpendersPending, + } = await useFetch('/api/billing-credits-top-users', { + query: topSpendersQuery, + server: false, + watch: [topSpendersQuery], + }).catch(() => ({ + data: { value: null }, + pending: { value: false }, + })); + // Per-user token totals (and the canonical user list) come from // /api/user-metrics. We do NOT fan out billing on initial load — instead, // the per-user table emits @update:options with the visible page's logins @@ -719,24 +738,23 @@ export default defineComponent({ ]; const topSpendersChartData = computed(() => { - // Sort by netAmount desc and drop $0 rows so enterprises with no - // per-user attribution (or pages we haven't lazy-loaded yet) don't - // render a chart full of zero bars labelled "top spenders". - const withSpend = perUserRows.value.filter(r => r.netAmount > 0); + // Server-side top-N from the full DB-backed billing dataset. Do not rank + // the lazy-loaded table rows here; that is only a partial client cache. + const withSpend = (topSpendersData.value?.users ?? []).filter(r => r.netAmount > 0); if (withSpend.length === 0) return null; - const top = [...withSpend].sort((a, b) => b.netAmount - a.netAmount).slice(0, 10); return { - labels: top.map(r => r.user), + labels: withSpend.map(r => r.user), datasets: [ { label: 'Net spend (USD)', - data: top.map(r => +r.netAmount.toFixed(2)), + data: withSpend.map(r => +r.netAmount.toFixed(2)), backgroundColor: PALETTE?.[0]?.bg ?? '#3f51b5', borderRadius: 4, }, ], }; }); + const topSpendersCount = computed(() => topSpendersData.value?.users?.length || 10); const topSpendersChartOptions = { responsive: true, @@ -832,7 +850,7 @@ export default defineComponent({ errorReason, headers, perUserRows, perUserHeaders, perUserLoading, loadedLoginsCount, onTableOptions, noPerUserAttribution, - topSpendersChartData, topSpendersChartOptions, + topSpendersChartData, topSpendersChartOptions, topSpendersPending, topSpendersCount, topTokensChartData, topTokensChartOptions, dataSourceBadge, userDetailLogin, userDetailQueryParams, openUserDetail, diff --git a/server/api/billing-credits-top-users.get.ts b/server/api/billing-credits-top-users.get.ts new file mode 100644 index 00000000..dc5b3deb --- /dev/null +++ b/server/api/billing-credits-top-users.get.ts @@ -0,0 +1,133 @@ +/** + * GET /api/billing-credits-top-users — admin only + * + * Returns a global top-N per-user billing ranking from the local + * billing_credit_usage table. This endpoint is intentionally DB-only in real + * mode because GitHub's live AI-credit billing JSON cannot rank all users. + */ + +import { Options } from '@/model/Options'; +import { requireUsageAdmin } from '../utils/usage-admin'; +import { + aggregateTopBillingUsers, + decideSource, + resolveWindow, + type TopBillingUsersResponse, +} from '../services/billing-credit-reader'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +import mockBilling from '../../public/mock-data/billing-credits.json'; +import type { BillingCreditsResponse } from './billing-credits.get'; + +const DEFAULT_LIMIT = 10; +const MAX_LIMIT = 50; + +export default defineEventHandler(async (event): Promise => { + const query = getQuery(event); + const options = Options.fromQuery(query); + const config = useRuntimeConfig(event); + + if (!options.isDataMocked) { + await requireUsageAdmin(event); + } + + const limit = parseLimit(query.limit); + const metric = parseMetric(query.metric); + + const window = resolveWindow({ + year: query.year ? Number(query.year) : undefined, + month: query.month ? Number(query.month) : undefined, + day: query.day ? Number(query.day) : undefined, + since: query.since ? String(query.since) : undefined, + until: query.until ? String(query.until) : undefined, + }); + + if (options.isDataMocked) { + return topUsersFromMock(mockBilling as BillingCreditsResponse, limit, metric); + } + + const billingEnterprise = ((config.billingEnterprise as string | undefined) || '').trim(); + const dbEnterprise = billingEnterprise + || (options.scope === 'enterprise' ? options.githubEnt : '') + || ''; + if (!dbEnterprise) { + throw createError({ + statusCode: 409, + statusMessage: 'Top spenders requires DB-backed enterprise billing data.', + data: { reason: 'top-users-requires-db' }, + }); + } + + const decision = await decideSource(dbEnterprise, window.startDate, window.endDate); + if (decision.source !== 'db') { + setResponseHeader(event, 'X-Data-Source', 'live'); + setResponseHeader(event, 'X-Data-Source-Reason', decision.reason); + throw createError({ + statusCode: 409, + statusMessage: + `No ingested billing data covers ${window.startDate} → ${window.endDate}. ` + + `Top spenders requires DB-backed enterprise billing data.`, + data: { reason: 'top-users-requires-db', window }, + }); + } + + setResponseHeader(event, 'X-Data-Source', 'db'); + if (decision.lastIngestAt) { + setResponseHeader(event, 'X-Data-Source-Synced-At', decision.lastIngestAt); + } + setResponseHeader(event, 'X-Data-Source-Reason', decision.reason); + + return await aggregateTopBillingUsers(dbEnterprise, window, { + limit, + metric, + model: query.model ? String(query.model) : undefined, + sku: query.sku ? String(query.sku) : undefined, + }); +}); + +function parseLimit(value: unknown): number { + const raw = Array.isArray(value) ? value[0] : value; + const n = raw === undefined ? DEFAULT_LIMIT : Number(raw); + if (!Number.isFinite(n)) return DEFAULT_LIMIT; + return Math.max(1, Math.min(Math.trunc(n), MAX_LIMIT)); +} + +function parseMetric(value: unknown): 'netAmount' | 'grossAmount' | 'credits' { + const raw = Array.isArray(value) ? value[0] : value; + if (raw === 'grossAmount' || raw === 'credits') return raw; + return 'netAmount'; +} + +function topUsersFromMock( + mock: BillingCreditsResponse, + limit: number, + metric: 'netAmount' | 'grossAmount' | 'credits', +): TopBillingUsersResponse { + const byUser = new Map }>(); + for (const item of mock.usageItems ?? []) { + const user = (item.user || '').trim(); + if (!user) continue; + const key = user.toLowerCase(); + const agg = byUser.get(key) || { user, credits: 0, grossAmount: 0, netAmount: 0, models: new Set() }; + agg.credits += Number.isFinite(item.netQuantity) ? item.netQuantity : 0; + agg.credits += Number.isFinite(item.discountQuantity) ? item.discountQuantity : 0; + agg.grossAmount += Number.isFinite(item.grossAmount) ? item.grossAmount : 0; + agg.netAmount += Number.isFinite(item.netAmount) ? item.netAmount : 0; + if (item.model) agg.models.add(item.model); + byUser.set(key, agg); + } + const metricKey = metric; + return { + timePeriod: mock.timePeriod, + enterprise: mock.enterprise || '', + users: [...byUser.values()] + .sort((a, b) => b[metricKey] - a[metricKey] || a.user.localeCompare(b.user)) + .slice(0, limit) + .map(u => ({ + user: u.user, + credits: u.credits, + grossAmount: u.grossAmount, + netAmount: u.netAmount, + models: u.models.size, + })), + }; +} diff --git a/server/services/billing-credit-reader.ts b/server/services/billing-credit-reader.ts index 15b41295..d95a0803 100644 --- a/server/services/billing-credit-reader.ts +++ b/server/services/billing-credit-reader.ts @@ -65,6 +65,25 @@ export interface AggregateFilters { model?: string; } +export interface TopBillingUser { + user: string; + credits: number; + grossAmount: number; + netAmount: number; + models: number; +} + +export interface TopBillingUsersResponse { + timePeriod: { year?: number; month?: number; day?: number }; + enterprise: string; + users: TopBillingUser[]; +} + +export interface TopBillingUsersOptions extends AggregateFilters { + limit?: number; + metric?: 'netAmount' | 'grossAmount' | 'credits'; +} + /** * Resolve `?year=&month=&day=` OR `?since=&until=` query params into an * inclusive date window. Mirrors how GitHub's billing endpoint interprets @@ -337,6 +356,70 @@ export async function aggregateForBillingByUser( }; } +/** + * Global top-N billing users for the requested window. Unlike + * `aggregateForBillingByUser`, this intentionally has no login filter so the + * database can rank every attributed user in one grouped query. + */ +export async function aggregateTopBillingUsers( + enterprise: string, + window: BillingWindow, + options: TopBillingUsersOptions = {}, +): Promise { + const pool = getPool(); + const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 10), 50)); + const metric = options.metric ?? 'netAmount'; + const orderExpr = metric === 'grossAmount' + ? 'SUM(gross_amount)' + : metric === 'credits' + ? 'SUM(quantity)' + : 'SUM(net_amount)'; + + const conds: string[] = [ + 'enterprise = $1', + 'date BETWEEN $2::date AND $3::date', + "COALESCE(username, '') <> ''", + ]; + const params: unknown[] = [enterprise, window.startDate, window.endDate]; + const push = (col: string, val: string | undefined) => { + if (val === undefined || val === '') return; + params.push(val); + conds.push(`${col} = $${params.length}`); + }; + push('organization', options.organization); + push('repository', options.repository); + push('sku', options.sku); + push('model', options.model); + params.push(limit); + + const sql = ` + SELECT + username, + SUM(quantity)::float8 AS credits, + SUM(gross_amount)::float8 AS gross_amount, + SUM(net_amount)::float8 AS net_amount, + COUNT(DISTINCT NULLIF(model, ''))::int AS models + FROM billing_credit_usage + WHERE ${conds.join(' AND ')} + GROUP BY username + ORDER BY ${orderExpr} DESC, username ASC + LIMIT $${params.length} + `; + + const { rows } = await pool.query(sql, params); + return { + timePeriod: window.timePeriod, + enterprise, + users: rows.map(r => ({ + user: String(r.username), + credits: Number(r.credits || 0), + grossAmount: Number(r.gross_amount || 0), + netAmount: Number(r.net_amount || 0), + models: Number(r.models || 0), + })), + }; +} + /** * Shared row-shape projection. Centralized so both the aggregate and * per-user paths derive `discountQuantity` / `netQuantity` identically. diff --git a/tests/billing-credit-reader.spec.ts b/tests/billing-credit-reader.spec.ts index 15cca240..cbe3a110 100644 --- a/tests/billing-credit-reader.spec.ts +++ b/tests/billing-credit-reader.spec.ts @@ -22,6 +22,7 @@ import { decideSource, aggregateForBilling, aggregateForBillingByUser, + aggregateTopBillingUsers, subtractRanges, findBillingCsvGaps, } from '../server/services/billing-credit-reader'; @@ -319,6 +320,31 @@ describe('aggregateForBillingByUser', () => { }); }); +describe('aggregateTopBillingUsers', () => { + it('ranks the top users across the full enterprise dataset without a login filter', async () => { + mockQuery.mockResolvedValueOnce({ + rows: [ + { username: 'zoe', credits: 70, gross_amount: 7, net_amount: 7, models: 2 }, + { username: 'alice', credits: 50, gross_amount: 5, net_amount: 5, models: 1 }, + ], + }); + + const resp = await aggregateTopBillingUsers('ent', { + startDate: '2026-06-01', endDate: '2026-06-30', timePeriod: { year: 2026, month: 6 }, + }, { limit: 2 }); + + expect(resp.users.map(u => u.user)).toEqual(['zoe', 'alice']); + expect(resp.users[0]).toMatchObject({ credits: 70, grossAmount: 7, netAmount: 7, models: 2 }); + + const [sql, params] = mockQuery.mock.calls[0]!; + expect(sql).toMatch(/GROUP BY username/); + expect(sql).toMatch(/ORDER BY SUM\(net_amount\) DESC/); + expect(sql).toMatch(/LIMIT \$4/); + expect(sql).not.toMatch(/LOWER\(username\) = ANY/); + expect(params).toEqual(['ent', '2026-06-01', '2026-06-30', 2]); + }); +}); + describe('edge cases', () => { it('decideSource: prefers the most recent completed job when multiple cover the window (LIMIT 1 + ORDER BY)', async () => { // We only verify the SQL — pg's executor handles ORDER BY DESC LIMIT 1. diff --git a/tests/billing-credits.db-first.spec.ts b/tests/billing-credits.db-first.spec.ts index 34c704e9..ac756b89 100644 --- a/tests/billing-credits.db-first.spec.ts +++ b/tests/billing-credits.db-first.spec.ts @@ -53,10 +53,12 @@ vi.mock('../server/utils/usage-admin', () => ({ const mockDecide = vi.fn(); const mockAggregate = vi.fn(); const mockAggregateByUser = vi.fn(); +const mockAggregateTopUsers = vi.fn(); vi.mock('../server/services/billing-credit-reader', () => ({ decideSource: (...a: any[]) => mockDecide(...a), aggregateForBilling: (...a: any[]) => mockAggregate(...a), aggregateForBillingByUser: (...a: any[]) => mockAggregateByUser(...a), + aggregateTopBillingUsers: (...a: any[]) => mockAggregateTopUsers(...a), resolveWindow: (input: { year?: number; month?: number; day?: number; since?: string; until?: string }) => { if (input.since && input.until) { return { startDate: input.since, endDate: input.until, timePeriod: {} }; @@ -73,6 +75,7 @@ vi.mock('../server/services/billing-credit-reader', () => ({ import billingHandler from '../server/api/billing-credits.get'; import byUserHandler from '../server/api/billing-credits-by-user.get'; +import topUsersHandler from '../server/api/billing-credits-top-users.get'; beforeEach(() => { vi.clearAllMocks(); @@ -264,3 +267,44 @@ describe('GET /api/billing-credits-by-user — DB-first branch', () => { expect(result.usageItems[0]!.user).toBe('alice'); }); }); + +describe('GET /api/billing-credits-top-users — DB-only top-N branch', () => { + it('serves global top users from DB without requiring logins', async () => { + setQuery({ scope: 'enterprise', githubEnt: 'ent-x', year: '2026', month: '6', limit: '2' }); + mockDecide.mockResolvedValueOnce({ source: 'db', reason: 'covered', lastIngestAt: null, jobId: 10 }); + mockAggregateTopUsers.mockResolvedValueOnce({ + timePeriod: { year: 2026, month: 6 }, + enterprise: 'ent-x', + users: [ + { user: 'zoe', credits: 70, grossAmount: 7, netAmount: 7, models: 2 }, + { user: 'alice', credits: 50, grossAmount: 5, netAmount: 5, models: 1 }, + ], + }); + + const result = await topUsersHandler({} as any); + + expect(mockDecide).toHaveBeenCalledWith('ent-x', '2026-06-01', '2026-06-30'); + expect(mockAggregateTopUsers).toHaveBeenCalledWith('ent-x', { + startDate: '2026-06-01', + endDate: '2026-06-30', + timePeriod: { year: 2026, month: 6 }, + }, { limit: 2, metric: 'netAmount', model: undefined, sku: undefined }); + expect(result.users.map(u => u.user)).toEqual(['zoe', 'alice']); + expect(getHeader('X-Data-Source')).toBe('db'); + }); + + it('rejects top users when no DB ingest covers the requested window', async () => { + setQuery({ scope: 'enterprise', githubEnt: 'ent-x', year: '2026', month: '6' }); + mockDecide.mockResolvedValueOnce({ + source: 'live', reason: 'no completed ingest job covers window', lastIngestAt: null, jobId: null, + }); + + let caught: any = null; + try { await topUsersHandler({} as any); } catch (e) { caught = e; } + + expect(caught).toBeTruthy(); + expect(caught.statusCode).toBe(409); + expect(caught.data?.reason).toBe('top-users-requires-db'); + expect(mockAggregateTopUsers).not.toHaveBeenCalled(); + }); +});