+
+
- 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();
+ });
+});