diff --git a/app/components/BillingCreditsViewer.vue b/app/components/BillingCreditsViewer.vue index 2bdb783d..3591f06c 100644 --- a/app/components/BillingCreditsViewer.vue +++ b/app/components/BillingCreditsViewer.vue @@ -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'; @@ -525,16 +526,23 @@ export default defineComponent({ loadedLogins.clear(); }); - async function loadBillingForLogins(logins: string[]): Promise { - const needed = logins.filter(l => l && !loadedLogins.has(l.toLowerCase())); + async function loadBillingForLogins( + logins: string[], + sortQuery: Record = {}, + serverSorted = false, + ): Promise { + 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; @@ -549,9 +557,12 @@ export default defineComponent({ delete parent.month; delete parent.day; } - const qp: Record = { ...parent, logins: chunk.join(',') }; + const qp: Record = { ...parent, ...sortQuery, logins: chunk.join(',') }; try { const resp = await $fetch('/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; @@ -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 @@ -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(() => data.value?.usageItems ?? []); diff --git a/app/utils/billingPerUserLazyLoad.ts b/app/utils/billingPerUserLazyLoad.ts new file mode 100644 index 00000000..26bf72ce --- /dev/null +++ b/app/utils/billingPerUserLazyLoad.ts @@ -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; + 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); + }); +} diff --git a/server/api/billing-credits-by-user.get.ts b/server/api/billing-credits-by-user.get.ts index 089c9108..ef7ef78f 100644 --- a/server/api/billing-credits-by-user.get.ts +++ b/server/api/billing-credits-by-user.get.ts @@ -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 => { const logger = console; @@ -65,18 +66,13 @@ export default defineEventHandler(async (event): Promise } 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 @@ -109,7 +105,7 @@ export default defineEventHandler(async (event): Promise 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); @@ -173,6 +169,13 @@ export default defineEventHandler(async (event): Promise 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 = {}; for (const key of ['year', 'month', 'day', 'model', 'product', 'cost_center_id']) { @@ -228,11 +231,13 @@ export default defineEventHandler(async (event): Promise }); } + 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; }); @@ -243,3 +248,59 @@ function parseLogins(raw: unknown): string[] { str.split(',').map(s => s.trim()).filter(Boolean) )); } + +function parseSortOptions(query: Record): { 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(); + 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; + } +} diff --git a/server/api/billing-credits.get.ts b/server/api/billing-credits.get.ts index 68b3d6c1..277df40b 100644 --- a/server/api/billing-credits.get.ts +++ b/server/api/billing-credits.get.ts @@ -64,6 +64,7 @@ export interface BillingCreditsResponse { organization?: string; enterprise?: string; user?: string; + users?: string[]; usageItems: BillingUsageItem[]; } diff --git a/server/services/billing-credit-reader.ts b/server/services/billing-credit-reader.ts index 15b41295..97ec081a 100644 --- a/server/services/billing-credit-reader.ts +++ b/server/services/billing-credit-reader.ts @@ -278,6 +278,7 @@ export async function aggregateForBillingByUser( window: BillingWindow, logins: string[], filters: AggregateFilters = {}, + sort: AggregateByUserSortOptions = {}, ): Promise { if (logins.length === 0) { return { timePeriod: window.timePeriod, enterprise, usageItems: [] }; @@ -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, @@ -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); @@ -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. diff --git a/tests/billing-credit-reader.spec.ts b/tests/billing-credit-reader.spec.ts index 15cca240..95159403 100644 --- a/tests/billing-credit-reader.spec.ts +++ b/tests/billing-credit-reader.spec.ts @@ -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', () => { diff --git a/tests/billing-credits.db-first.spec.ts b/tests/billing-credits.db-first.spec.ts index 34c704e9..d26a20a7 100644 --- a/tests/billing-credits.db-first.spec.ts +++ b/tests/billing-credits.db-first.spec.ts @@ -245,6 +245,40 @@ describe('GET /api/billing-credits-by-user — DB-first branch', () => { expect(getHeader('X-Data-Source')).toBe('db'); }); + it('forwards table sort and page options to the DB per-user aggregate', async () => { + setQuery({ + scope: 'enterprise', + githubEnt: 'ent-x', + year: '2026', + month: '6', + logins: 'alice,bob,carol', + sortKey: 'netAmount', + sortOrder: 'desc', + page: '1', + itemsPerPage: '2', + }); + mockDecide.mockResolvedValueOnce({ source: 'db', reason: 'covered', lastIngestAt: null, jobId: 9 }); + mockAggregateByUser.mockResolvedValueOnce({ + timePeriod: { year: 2026, month: 6 }, + enterprise: 'ent-x', + users: ['carol', 'bob'], + usageItems: [], + }); + + const result = await byUserHandler({} as any); + + const args = mockAggregateByUser.mock.calls[0]!; + expect(args[2]).toEqual(['alice', 'bob', 'carol']); + expect(args[4]).toEqual({ + sortKey: 'netAmount', + sortOrder: 'desc', + offset: 0, + limit: 2, + }); + expect(result.users).toEqual(['carol', 'bob']); + expect(((globalThis as any).$fetch as any).mock.calls).toHaveLength(0); + }); + it('falls through to fan-out when decideSource returns live', async () => { setQuery({ scope: 'enterprise', githubEnt: 'ent-x', year: '2026', month: '6', logins: 'alice' }); mockDecide.mockResolvedValueOnce({ source: 'live', reason: 'not covered', lastIngestAt: null, jobId: null }); diff --git a/tests/billing-per-user-lazy-load.spec.ts b/tests/billing-per-user-lazy-load.spec.ts new file mode 100644 index 00000000..916594f4 --- /dev/null +++ b/tests/billing-per-user-lazy-load.spec.ts @@ -0,0 +1,47 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; + +import { buildPerUserBillingLazyLoadRequest } from '../app/utils/billingPerUserLazyLoad'; + +describe('buildPerUserBillingLazyLoadRequest', () => { + it('requests rows in the table sort order for client-owned sorts', () => { + const rows = [ + { user: 'carol', credits: 0, grossAmount: 0, netAmount: 0, tokens: 30, models: 0 }, + { user: 'alice', credits: 0, grossAmount: 0, netAmount: 0, tokens: 10, models: 0 }, + { user: 'bob', credits: 0, grossAmount: 0, netAmount: 0, tokens: 20, models: 0 }, + ]; + + const req = buildPerUserBillingLazyLoadRequest(rows, { + page: 1, + itemsPerPage: 2, + sortBy: [{ key: 'tokens', order: 'desc' }], + }); + + expect(req.serverSorted).toBe(false); + expect(req.logins).toEqual(['carol', 'bob']); + expect(req.query).toMatchObject({ sortKey: 'tokens', sortOrder: 'desc' }); + }); + + it('sends spend sorts to the server so it can return the spend-ordered page', () => { + const rows = [ + { user: 'alice', credits: 0, grossAmount: 0, netAmount: 0, tokens: 0, models: 0 }, + { user: 'bob', credits: 0, grossAmount: 0, netAmount: 0, tokens: 0, models: 0 }, + { user: 'carol', credits: 0, grossAmount: 0, netAmount: 0, tokens: 0, models: 0 }, + ]; + + const req = buildPerUserBillingLazyLoadRequest(rows, { + page: 1, + itemsPerPage: 2, + sortBy: [{ key: 'netAmount', order: 'desc' }], + }); + + expect(req.serverSorted).toBe(true); + expect(req.logins).toEqual(['alice', 'bob', 'carol']); + expect(req.query).toEqual({ + page: '1', + itemsPerPage: '2', + sortKey: 'netAmount', + sortOrder: 'desc', + }); + }); +});