Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 31 additions & 13 deletions app/components/BillingCreditsViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -225,19 +225,20 @@
<v-card-title class="text-subtitle-1">
Top spenders by net cost
<span class="text-caption text-medium-emphasis ml-2">
(loaded {{ loadedLoginsCount }} of {{ perUserRows.length }} users<span v-if="loadedLoginsCount < perUserRows.length">; sort by $ or page through to load more</span>)
(top {{ topSpendersCount }} globally)
</span>
</v-card-title>
<v-card-subtitle class="text-caption text-medium-emphasis pb-2">
Source: Billing API (<code>ai_credit/usage</code> per user) · {{ rangeLabel }}
Source: Billing CSV database · {{ rangeLabel }}
</v-card-subtitle>
<v-card-text>
<div v-if="topSpendersChartData" style="height: 280px">
<v-progress-linear v-if="topSpendersPending" indeterminate color="indigo" class="mb-2" />
<div v-else-if="topSpendersChartData" style="height: 280px">
<Bar :data="topSpendersChartData" :options="topSpendersChartOptions" />
</div>
<v-alert v-else type="info" variant="tonal" density="compact">
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.
</v-alert>
</v-card-text>
</v-card>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -496,6 +498,23 @@ export default defineComponent({
},
});

const topSpendersQuery = computed<Record<string, string>>(() => ({
...billingQuery.value,
limit: '10',
metric: 'netAmount',
}));
const {
data: topSpendersData,
pending: topSpendersPending,
} = await useFetch<TopBillingUsersResponse>('/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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
133 changes: 133 additions & 0 deletions server/api/billing-credits-top-users.get.ts
Original file line number Diff line number Diff line change
@@ -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<TopBillingUsersResponse> => {
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<string, { user: string; credits: number; grossAmount: number; netAmount: number; models: Set<string> }>();
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<string>() };
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,
})),
};
}
83 changes: 83 additions & 0 deletions server/services/billing-credit-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<TopBillingUsersResponse> {
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.
Expand Down
26 changes: 26 additions & 0 deletions tests/billing-credit-reader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
decideSource,
aggregateForBilling,
aggregateForBillingByUser,
aggregateTopBillingUsers,
subtractRanges,
findBillingCsvGaps,
} from '../server/services/billing-credit-reader';
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading