diff --git a/app/components/BillingCreditsViewer.vue b/app/components/BillingCreditsViewer.vue index 2bdb783d..f925020b 100644 --- a/app/components/BillingCreditsViewer.vue +++ b/app/components/BillingCreditsViewer.vue @@ -219,6 +219,42 @@ are hidden until GitHub starts returning attributed data. + +
+ {{ unmatchedBillingUsernamesList.length }} billing usernames + with spend did not match the loaded Metrics API logins +
+
+ This can happen on EMU enterprises when GitHub's metrics and billing feeds use + different handles for the same person. Configure NUXT_BILLING_USER_ALIASES + to map billing usernames to metrics logins. +
+ + + + Show unmatched billing usernames + + + + {{ username }} + + + + +
@@ -516,6 +552,7 @@ export default defineComponent({ interface BillingAgg { credits: number; grossAmount: number; netAmount: number; models: Set; display: string } const billingByLogin = reactive(new Map()); const loadedLogins = reactive(new Set()); + const unmatchedBillingUsernames = reactive(new Set()); const perUserLoading = ref(false); // When the user switches month or toggles month view, drop cached @@ -523,6 +560,7 @@ export default defineComponent({ watch([selectedMonth, monthView, () => props.queryParams.since, () => props.queryParams.until], () => { billingByLogin.clear(); loadedLogins.clear(); + unmatchedBillingUsernames.clear(); }); async function loadBillingForLogins(logins: string[]): Promise { @@ -552,10 +590,15 @@ export default defineComponent({ const qp: Record = { ...parent, logins: chunk.join(',') }; try { const resp = await $fetch('/api/billing-credits-by-user', { query: qp }); + for (const username of resp.unmatchedBillingUsernames ?? []) { + const trimmed = username.trim(); + if (trimmed) unmatchedBillingUsernames.add(trimmed); + } for (const it of resp.usageItems ?? []) { const u = (it.user || '').trim(); if (!u) continue; const key = u.toLowerCase(); + unmatchedBillingUsernames.delete(u); const prev = billingByLogin.get(key) || { credits: 0, grossAmount: 0, netAmount: 0, models: new Set(), display: u }; prev.credits += Number.isFinite(it.netQuantity) ? it.netQuantity : 0; prev.credits += Number.isFinite(it.discountQuantity) ? it.discountQuantity : 0; @@ -646,6 +689,9 @@ export default defineComponent({ }); }); const loadedLoginsCount = computed(() => loadedLogins.size); + const unmatchedBillingUsernamesList = computed(() => + [...unmatchedBillingUsernames].sort((a, b) => a.localeCompare(b)) + ); // True when we've loaded at least one page of users AND the aggregate // totals show non-zero spend AND zero per-user attribution has come back. @@ -683,6 +729,10 @@ export default defineComponent({ billingByLogin.set(key, prev); loadedLogins.add(key); } + for (const username of data.value?.unmatchedBillingUsernames ?? []) { + const trimmed = username.trim(); + if (trimmed) unmatchedBillingUsernames.add(trimmed); + } }); // Distinguish "our admin gate" 403 from "GitHub billing API" 403 from @@ -832,6 +882,7 @@ export default defineComponent({ errorReason, headers, perUserRows, perUserHeaders, perUserLoading, loadedLoginsCount, onTableOptions, noPerUserAttribution, + unmatchedBillingUsernamesList, topSpendersChartData, topSpendersChartOptions, topTokensChartData, topTokensChartOptions, dataSourceBadge, diff --git a/server/api/billing-credits-by-user.get.ts b/server/api/billing-credits-by-user.get.ts index 089c9108..fea5f605 100644 --- a/server/api/billing-credits-by-user.get.ts +++ b/server/api/billing-credits-by-user.get.ts @@ -35,6 +35,10 @@ import { resolveWindow, aggregateForBillingByUser, } from '../services/billing-credit-reader'; +import { + billingUsernamesForMetricsLogins, + parseBillingUserAliases, +} from '../../shared/utils/billing-user-identity'; // eslint-disable-next-line @typescript-eslint/no-explicit-any import mockBilling from '../../public/mock-data/billing-credits.json'; @@ -189,13 +193,14 @@ export default defineEventHandler(async (event): Promise }); const tagged: BillingUsageItem[] = []; + const aliases = parseBillingUserAliases(process.env.NUXT_BILLING_USER_ALIASES); let timePeriod: BillingCreditsResponse['timePeriod'] = { year: 0, month: 0 }; let orgSlug: string | undefined; let entSlug: string | undefined; let failures = 0; - async function fetchOne(login: string): Promise { - const params = new URLSearchParams({ ...forwardParams, user: login }); + async function fetchOne(metricsLogin: string, billingUsername: string): Promise { + const params = new URLSearchParams({ ...forwardParams, user: billingUsername }); const url = `${apiUrl}?${params.toString()}`; try { const resp = await $fetch(url, { headers: billingHeaders }); @@ -203,23 +208,27 @@ export default defineEventHandler(async (event): Promise orgSlug = resp.organization || orgSlug; entSlug = resp.enterprise || entSlug; for (const it of resp.usageItems ?? []) { - tagged.push({ ...it, user: login }); + tagged.push({ ...it, user: metricsLogin }); } } catch (err) { failures++; - logger.warn(`billing-credits-by-user: ${login} fan-out failed`, err); + logger.warn(`billing-credits-by-user: ${billingUsername} fan-out failed`, err); } } + const fetchTargets = requestedLogins.flatMap(login => + billingUsernamesForMetricsLogins([login], aliases).map(billingUsername => ({ login, billingUsername })) + ); + let cursor = 0; async function worker(): Promise { - while (cursor < requestedLogins.length) { + while (cursor < fetchTargets.length) { const i = cursor++; - const login = requestedLogins[i]; - if (login) await fetchOne(login); + const target = fetchTargets[i]; + if (target) await fetchOne(target.login, target.billingUsername); } } - await Promise.all(Array.from({ length: Math.min(CONCURRENCY, requestedLogins.length) }, () => worker())); + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, fetchTargets.length) }, () => worker())); if (failures > 0 && tagged.length === 0) { throw createError({ diff --git a/server/api/billing-credits.get.ts b/server/api/billing-credits.get.ts index 68b3d6c1..16bb8dbd 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; + unmatchedBillingUsernames?: string[]; usageItems: BillingUsageItem[]; } diff --git a/server/services/billing-credit-reader.ts b/server/services/billing-credit-reader.ts index 15b41295..61a815d4 100644 --- a/server/services/billing-credit-reader.ts +++ b/server/services/billing-credit-reader.ts @@ -30,6 +30,11 @@ import { getPool } from '../storage/db'; import type { BillingCreditsResponse, BillingUsageItem } from '../api/billing-credits.get'; +import { + billingUsernamesForMetricsLogins, + normalizeBillingUsername, + parseBillingUserAliases, +} from '../../shared/utils/billing-user-identity'; export interface CoverageDecision { source: 'db' | 'live'; @@ -284,6 +289,8 @@ export async function aggregateForBillingByUser( } const pool = getPool(); + const aliases = parseBillingUserAliases(process.env.NUXT_BILLING_USER_ALIASES); + const matchedBillingUsernames = billingUsernamesForMetricsLogins(logins, aliases); const conds: string[] = [ 'enterprise = $1', @@ -294,7 +301,7 @@ export async function aggregateForBillingByUser( enterprise, window.startDate, window.endDate, - logins.map(l => l.toLowerCase()), + matchedBillingUsernames, ]; const push = (col: string, val: string | undefined) => { if (val === undefined || val === '') return; @@ -327,12 +334,30 @@ export async function aggregateForBillingByUser( const { rows } = await pool.query(sql, params); const usageItems: BillingUsageItem[] = rows.map(r => ({ ...mapAggregateRowToItem(r), - user: r.username || undefined, + user: r.username ? normalizeBillingUsername(r.username, aliases) : undefined, })); + const unmatchedConds = [...conds]; + const unmatchedParams = [...params]; + unmatchedConds[2] = 'LOWER(username) <> ALL($4::text[])'; + unmatchedConds.push('(quantity <> 0 OR gross_amount <> 0 OR net_amount <> 0)'); + const unmatchedSql = ` + SELECT DISTINCT username + FROM billing_credit_usage + WHERE ${unmatchedConds.join(' AND ')} + AND username IS NOT NULL + AND username <> '' + ORDER BY username + `; + const unmatchedResult = await pool.query(unmatchedSql, unmatchedParams); + const unmatchedBillingUsernames = (unmatchedResult?.rows ?? []) + .map((r: { username?: string }) => (r.username || '').trim()) + .filter(Boolean); + return { timePeriod: window.timePeriod, enterprise, + ...(unmatchedBillingUsernames.length ? { unmatchedBillingUsernames } : {}), usageItems, }; } diff --git a/shared/utils/billing-user-identity.ts b/shared/utils/billing-user-identity.ts new file mode 100644 index 00000000..a2f74d19 --- /dev/null +++ b/shared/utils/billing-user-identity.ts @@ -0,0 +1,39 @@ +export type BillingUserAliases = Record; + +function canonicalUserKey(value: string): string { + return value.trim().toLowerCase(); +} + +export function parseBillingUserAliases(raw: string | undefined): BillingUserAliases { + if (!raw?.trim()) return {}; + try { + const parsed = JSON.parse(raw) as Record; + const aliases: BillingUserAliases = {}; + for (const [billingUsername, metricsLogin] of Object.entries(parsed)) { + if (typeof metricsLogin !== 'string') continue; + const billingKey = canonicalUserKey(billingUsername); + const metricsKey = canonicalUserKey(metricsLogin); + if (billingKey && metricsKey) aliases[billingKey] = metricsKey; + } + return aliases; + } catch { + return {}; + } +} + +export function normalizeBillingUsername(username: string, aliases: BillingUserAliases): string { + const key = canonicalUserKey(username); + return aliases[key] || key; +} + +export function billingUsernamesForMetricsLogins( + logins: string[], + aliases: BillingUserAliases, +): string[] { + const metricsKeys = new Set(logins.map(canonicalUserKey).filter(Boolean)); + const usernames = new Set(metricsKeys); + for (const [billingUsername, metricsLogin] of Object.entries(aliases)) { + if (metricsKeys.has(metricsLogin)) usernames.add(billingUsername); + } + return [...usernames]; +} diff --git a/tests/billing-credit-reader.spec.ts b/tests/billing-credit-reader.spec.ts index 15cca240..60ecbcbb 100644 --- a/tests/billing-credit-reader.spec.ts +++ b/tests/billing-credit-reader.spec.ts @@ -28,6 +28,7 @@ import { beforeEach(() => { mockQuery.mockReset(); + delete process.env.NUXT_BILLING_USER_ALIASES; }); describe('resolveWindow', () => { @@ -259,6 +260,51 @@ describe('aggregateForBillingByUser', () => { expect(mockQuery).not.toHaveBeenCalled(); }); + it('includes configured EMU billing aliases in the DB username filter and normalizes returned users', async () => { + process.env.NUXT_BILLING_USER_ALIASES = JSON.stringify({ + readable_emu: 'opaquehash_emu', + }); + mockQuery + .mockResolvedValueOnce({ + rows: [{ + username: 'readable_emu', + product: 'copilot', + sku: 'copilot_ai_credit', + model: 'gpt-4o', + unit_type: 'credits', + price_per_unit: 0.01, + gross_quantity: 100, + gross_amount: 1, + discount_amount: 0, + net_amount: 1, + }], + }) + .mockResolvedValueOnce({ rows: [] }); + + const resp = await aggregateForBillingByUser('ent', { + startDate: '2026-06-01', endDate: '2026-06-30', timePeriod: { year: 2026, month: 6 }, + }, ['opaquehash_emu']); + + expect(mockQuery.mock.calls[0]![1][3]).toEqual(['opaquehash_emu', 'readable_emu']); + expect(resp.usageItems[0]!.user).toBe('opaquehash_emu'); + }); + + it('surfaces billing usernames with spend that are not matched by requested metrics logins or aliases', async () => { + process.env.NUXT_BILLING_USER_ALIASES = JSON.stringify({ + readable_emu: 'opaquehash_emu', + }); + mockQuery + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ username: 'unmapped_emu' }] }); + + const resp = await aggregateForBillingByUser('ent', { + startDate: '2026-06-01', endDate: '2026-06-30', timePeriod: { year: 2026, month: 6 }, + }, ['opaquehash_emu']); + + expect(resp.unmatchedBillingUsernames).toEqual(['unmapped_emu']); + expect(mockQuery.mock.calls[1]![0]).toMatch(/LOWER\(username\) <> ALL/); + }); + it('groups by username and tags each item with the user field (case-insensitive match)', async () => { mockQuery.mockResolvedValueOnce({ rows: [