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
36 changes: 23 additions & 13 deletions app/components/BillingCreditsViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,7 @@ import {
Legend,
} from 'chart.js';
import { PALETTE } from '@/utils/chartPlugins';
import { buildPerUserBillingLazyLoadRequest, type BillingTableOptions } from '@/utils/billingPerUserLazyLoad';
import type { BillingCreditsResponse, BillingUsageItem } from '../../server/api/billing-credits.get';
import { buildDataSourceBadge } from '#shared/utils/data-source-badge';

Expand Down Expand Up @@ -525,16 +526,23 @@ export default defineComponent({
loadedLogins.clear();
});

async function loadBillingForLogins(logins: string[]): Promise<void> {
const needed = logins.filter(l => l && !loadedLogins.has(l.toLowerCase()));
async function loadBillingForLogins(
logins: string[],
sortQuery: Record<string, string> = {},
serverSorted = false,
): Promise<void> {
const needed = serverSorted ? logins.filter(Boolean) : logins.filter(l => l && !loadedLogins.has(l.toLowerCase()));
if (needed.length === 0) return;
// Mark as "in-flight" up front so concurrent page changes don't double-fetch.
for (const l of needed) loadedLogins.add(l.toLowerCase());
if (!serverSorted) {
for (const l of needed) loadedLogins.add(l.toLowerCase());
}
perUserLoading.value = true;
try {
// Chunk to the endpoint's per-call cap (50).
for (let i = 0; i < needed.length; i += 50) {
const chunk = needed.slice(i, i + 50);
const chunks = serverSorted
? [needed]
: Array.from({ length: Math.ceil(needed.length / 50) }, (_, i) => needed.slice(i * 50, i * 50 + 50));
for (const chunk of chunks) {
const parent = { ...(props.queryParams || {}) };
if (monthView.value) {
delete parent.since;
Expand All @@ -549,9 +557,12 @@ export default defineComponent({
delete parent.month;
delete parent.day;
}
const qp: Record<string, string> = { ...parent, logins: chunk.join(',') };
const qp: Record<string, string> = { ...parent, ...sortQuery, logins: chunk.join(',') };
try {
const resp = await $fetch<BillingCreditsResponse>('/api/billing-credits-by-user', { query: qp });
for (const u of resp.users ?? []) {
loadedLogins.add(u.toLowerCase());
}
for (const it of resp.usageItems ?? []) {
const u = (it.user || '').trim();
if (!u) continue;
Expand All @@ -563,6 +574,7 @@ export default defineComponent({
prev.netAmount += Number.isFinite(it.netAmount) ? it.netAmount : 0;
if (it.model) prev.models.add(it.model);
billingByLogin.set(key, prev);
loadedLogins.add(key);
}
} catch (err) {
// Don't unmark — failed fetches stay "loaded" so we don't retry
Expand All @@ -578,12 +590,10 @@ export default defineComponent({
// Called by v-data-table @update:options on initial mount, page change,
// and sort change. We use page + itemsPerPage + the currently-sorted
// `perUserRows` view to know which logins are visible.
function onTableOptions(opts: { page: number; itemsPerPage: number }): void {
const allRows = perUserRows.value;
if (allRows.length === 0) return;
const start = (opts.page - 1) * opts.itemsPerPage;
const visible = allRows.slice(start, start + opts.itemsPerPage).map(r => r.user);
void loadBillingForLogins(visible);
function onTableOptions(opts: BillingTableOptions): void {
const request = buildPerUserBillingLazyLoadRequest(perUserRows.value, opts);
if (request.logins.length === 0) return;
void loadBillingForLogins(request.logins, request.query, request.serverSorted);
}

const items = computed<BillingUsageItem[]>(() => data.value?.usageItems ?? []);
Expand Down
77 changes: 77 additions & 0 deletions app/utils/billingPerUserLazyLoad.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
export interface PerUserBillingLazyRow {
user: string;
credits: number;
grossAmount: number;
netAmount: number;
tokens: number;
models: number;
}

export interface BillingTableOptions {
page: number;
itemsPerPage: number;
sortBy?: { key: string; order?: string | boolean }[];
}

export interface BillingLazyLoadRequest {
logins: string[];
query: Record<string, string>;
serverSorted: boolean;
}

const SORT_KEYS = new Set(['user', 'credits', 'tokens', 'grossAmount', 'netAmount', 'models']);
const SERVER_SORT_KEYS = new Set(['credits', 'grossAmount', 'netAmount', 'models']);

export function buildPerUserBillingLazyLoadRequest(
rows: PerUserBillingLazyRow[],
opts: BillingTableOptions,
): BillingLazyLoadRequest {
const sort = normalizeSort(opts.sortBy);
const page = Math.max(1, Number(opts.page) || 1);
const itemsPerPage = Math.max(1, Number(opts.itemsPerPage) || 25);
const query = {
page: String(page),
itemsPerPage: String(itemsPerPage),
sortKey: sort.key,
sortOrder: sort.order,
};

if (SERVER_SORT_KEYS.has(sort.key)) {
return {
logins: rows.map(r => r.user).filter(Boolean),
query,
serverSorted: true,
};
}

const start = (page - 1) * itemsPerPage;
const sortedRows = sortRows(rows, sort);
return {
logins: sortedRows.slice(start, start + itemsPerPage).map(r => r.user).filter(Boolean),
query,
serverSorted: false,
};
}

function normalizeSort(sortBy: BillingTableOptions['sortBy']): { key: string; order: 'asc' | 'desc' } {
const first = sortBy?.[0];
const key = first && SORT_KEYS.has(first.key) ? first.key : 'user';
const order = first?.order === 'desc' || first?.order === false ? 'desc' : 'asc';
return { key, order };
}

function sortRows(
rows: PerUserBillingLazyRow[],
sort: { key: string; order: 'asc' | 'desc' },
): PerUserBillingLazyRow[] {
const direction = sort.order === 'desc' ? -1 : 1;
return [...rows].sort((a, b) => {
const av = a[sort.key as keyof PerUserBillingLazyRow];
const bv = b[sort.key as keyof PerUserBillingLazyRow];
const cmp = typeof av === 'string' || typeof bv === 'string'
? String(av).localeCompare(String(bv))
: Number(av) - Number(bv);
if (cmp !== 0) return cmp * direction;
return a.user.localeCompare(b.user);
});
}
77 changes: 69 additions & 8 deletions server/api/billing-credits-by-user.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import mockBilling from '../../public/mock-data/billing-credits.json';

const CONCURRENCY = 8;
const MAX_LOGINS_PER_CALL = 50;
const SORT_KEYS = new Set(['user', 'credits', 'grossAmount', 'netAmount', 'models']);

export default defineEventHandler(async (event): Promise<BillingCreditsResponse> => {
const logger = console;
Expand All @@ -65,18 +66,13 @@ export default defineEventHandler(async (event): Promise<BillingCreditsResponse>
}

const requestedLogins = parseLogins(query.logins);
const sortOptions = parseSortOptions(query);
if (requestedLogins.length === 0) {
throw createError({
statusCode: 400,
statusMessage: 'Missing ?logins= query param. Pass a comma-separated list of GitHub logins (max 50 per call). Enumerate the seat list via /api/seats.',
});
}
if (requestedLogins.length > MAX_LOGINS_PER_CALL) {
throw createError({
statusCode: 400,
statusMessage: `Too many logins (${requestedLogins.length}); cap is ${MAX_LOGINS_PER_CALL} per call. Split the request into multiple pages.`,
});
}

// ── DB-first read path (Phase B) ───────────────────────────────────────────
// Same coverage check as /api/billing-credits: if a completed CSV ingest
Expand Down Expand Up @@ -109,7 +105,7 @@ export default defineEventHandler(async (event): Promise<BillingCreditsResponse>
return await aggregateForBillingByUser(dbEnterprise, window, requestedLogins, {
model: query.model ? String(query.model) : undefined,
sku: query.sku ? String(query.sku) : undefined,
});
}, sortOptions);
}
setResponseHeader(event, 'X-Data-Source', 'live');
setResponseHeader(event, 'X-Data-Source-Reason', decision.reason);
Expand Down Expand Up @@ -173,6 +169,13 @@ export default defineEventHandler(async (event): Promise<BillingCreditsResponse>
throw createError({ statusCode: 400, statusMessage: String(err instanceof Error ? err.message : err) });
}

if (requestedLogins.length > MAX_LOGINS_PER_CALL) {
throw createError({
statusCode: 400,
statusMessage: `Too many logins (${requestedLogins.length}); cap is ${MAX_LOGINS_PER_CALL} per live call. Use DB-backed billing CSV ingest for server-sorted pages across larger user lists.`,
});
}

// Forward documented filter params (year/month/day/model/product/cost_center_id)
const forwardParams: Record<string, string> = {};
for (const key of ['year', 'month', 'day', 'model', 'product', 'cost_center_id']) {
Expand Down Expand Up @@ -228,11 +231,13 @@ export default defineEventHandler(async (event): Promise<BillingCreditsResponse>
});
}

const liveItems = sortAndPageLiveItems(tagged, sortOptions);
return {
timePeriod,
organization: orgSlug,
enterprise: entSlug,
usageItems: tagged,
users: Array.from(new Set(liveItems.map(it => it.user).filter((u): u is string => !!u))),
usageItems: liveItems,
} as BillingCreditsResponse;
});

Expand All @@ -243,3 +248,59 @@ function parseLogins(raw: unknown): string[] {
str.split(',').map(s => s.trim()).filter(Boolean)
));
}

function parseSortOptions(query: Record<string, unknown>): { sortKey?: string; sortOrder?: 'asc' | 'desc'; offset?: number; limit?: number } {
const sortKey = typeof query.sortKey === 'string' && SORT_KEYS.has(query.sortKey) ? query.sortKey : undefined;
if (!sortKey) return {};
const sortOrder = query.sortOrder === 'desc' ? 'desc' : 'asc';
const page = Math.max(1, Number(query.page) || 1);
const itemsPerPage = Math.max(1, Math.min(50, Number(query.itemsPerPage) || 25));
return {
...(sortKey ? { sortKey, sortOrder } : {}),
offset: (page - 1) * itemsPerPage,
limit: itemsPerPage,
};
}

function sortAndPageLiveItems(
items: BillingUsageItem[],
sort: { sortKey?: string; sortOrder?: 'asc' | 'desc'; offset?: number; limit?: number },
): BillingUsageItem[] {
if (!sort.sortKey) return items;
const direction = sort.sortOrder === 'desc' ? -1 : 1;
const byUser = new Map<string, BillingUsageItem[]>();
for (const item of items) {
if (!item.user) continue;
const key = item.user.toLowerCase();
byUser.set(key, [...(byUser.get(key) || []), item]);
}
const users = [...byUser.entries()].sort(([a, aItems], [b, bItems]) => {
const av = userSortValue(a, aItems, sort.sortKey!);
const bv = userSortValue(b, bItems, sort.sortKey!);
const cmp = typeof av === 'string' || typeof bv === 'string'
? String(av).localeCompare(String(bv))
: Number(av) - Number(bv);
if (cmp !== 0) return cmp * direction;
return a.localeCompare(b);
});
const offset = sort.offset ?? 0;
const limit = sort.limit ?? users.length;
const pageUsers = new Set(users.slice(offset, offset + limit).map(([u]) => u));
return items.filter(item => item.user && pageUsers.has(item.user.toLowerCase()));
}

function userSortValue(login: string, items: BillingUsageItem[], key: string): string | number {
switch (key) {
case 'credits':
return items.reduce((sum, item) => sum + (item.netQuantity || 0) + (item.discountQuantity || 0), 0);
case 'grossAmount':
return items.reduce((sum, item) => sum + (item.grossAmount || 0), 0);
case 'netAmount':
return items.reduce((sum, item) => sum + (item.netAmount || 0), 0);
case 'models':
return new Set(items.map(item => item.model).filter(Boolean)).size;
case 'user':
default:
return login;
}
}
1 change: 1 addition & 0 deletions server/api/billing-credits.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export interface BillingCreditsResponse {
organization?: string;
enterprise?: string;
user?: string;
users?: string[];
usageItems: BillingUsageItem[];
}

Expand Down
36 changes: 35 additions & 1 deletion server/services/billing-credit-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ export async function aggregateForBillingByUser(
window: BillingWindow,
logins: string[],
filters: AggregateFilters = {},
sort: AggregateByUserSortOptions = {},
): Promise<BillingCreditsResponse> {
if (logins.length === 0) {
return { timePeriod: window.timePeriod, enterprise, usageItems: [] };
Expand Down Expand Up @@ -306,6 +307,12 @@ export async function aggregateForBillingByUser(
push('sku', filters.sku);
push('model', filters.model);

const order = buildByUserOrder(sort);
if (sort.limit !== undefined) params.push(sort.limit);
const limitClause = sort.limit !== undefined ? `LIMIT $${params.length}` : '';
if (sort.offset !== undefined) params.push(sort.offset);
const offsetClause = sort.offset !== undefined ? `OFFSET $${params.length}` : '';

const sql = `
SELECT
username,
Expand All @@ -321,7 +328,9 @@ export async function aggregateForBillingByUser(
FROM billing_credit_usage
WHERE ${conds.join(' AND ')}
GROUP BY username, product, sku, model, unit_type
ORDER BY username, product, sku, model
ORDER BY ${order}, username, product, sku, model
${limitClause}
${offsetClause}
`;

const { rows } = await pool.query(sql, params);
Expand All @@ -333,10 +342,35 @@ export async function aggregateForBillingByUser(
return {
timePeriod: window.timePeriod,
enterprise,
users: Array.from(new Set(usageItems.map(it => it.user).filter((u): u is string => !!u))),
usageItems,
};
}

export interface AggregateByUserSortOptions {
sortKey?: string;
sortOrder?: 'asc' | 'desc';
offset?: number;
limit?: number;
}

function buildByUserOrder(sort: AggregateByUserSortOptions): string {
const direction = sort.sortOrder === 'desc' ? 'DESC' : 'ASC';
switch (sort.sortKey) {
case 'credits':
return `SUM(quantity) ${direction}`;
case 'grossAmount':
return `SUM(gross_amount) ${direction}`;
case 'netAmount':
return `SUM(net_amount) ${direction}`;
case 'models':
return `COUNT(DISTINCT model) ${direction}`;
case 'user':
default:
return `LOWER(username) ${direction}`;
}
}

/**
* Shared row-shape projection. Centralized so both the aggregate and
* per-user paths derive `discountQuantity` / `netQuantity` identically.
Expand Down
20 changes: 20 additions & 0 deletions tests/billing-credit-reader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,26 @@ describe('aggregateForBillingByUser', () => {
expect(sql).not.toContain(evilModel);
expect(params).toContain(evilModel);
});

it('can order and page per-user aggregates by net spend descending', async () => {
mockQuery.mockResolvedValueOnce({ rows: [] });
await aggregateForBillingByUser('ent', {
startDate: '2026-06-01', endDate: '2026-06-30', timePeriod: { year: 2026, month: 6 },
}, ['alice', 'bob', 'carol'], {}, {
sortKey: 'netAmount',
sortOrder: 'desc',
offset: 0,
limit: 2,
});

const [sql, params] = mockQuery.mock.calls[0]!;
expect(sql).toMatch(/ORDER BY[\s\S]*SUM\(net_amount\)[\s\S]*DESC/i);
expect(sql).toMatch(/LIMIT \$\d+/);
expect(sql).toMatch(/OFFSET \$\d+/);
expect(params[3]).toEqual(['alice', 'bob', 'carol']);
expect(params).toContain(2);
expect(params).toContain(0);
});
});

describe('edge cases', () => {
Expand Down
Loading
Loading