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
51 changes: 51 additions & 0 deletions app/components/BillingCreditsViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,42 @@
are hidden until GitHub starts returning attributed data.
</div>
</v-alert>
<v-alert
v-if="unmatchedBillingUsernamesList.length > 0"
type="warning"
variant="tonal"
density="comfortable"
class="mx-3 mt-3 mb-2"
icon="mdi-account-question-outline"
>
<div class="font-weight-medium mb-1">
{{ unmatchedBillingUsernamesList.length }} billing username<span v-if="unmatchedBillingUsernamesList.length !== 1">s</span>
with spend did not match the loaded Metrics API logins
</div>
<div class="text-body-2 mb-2">
This can happen on EMU enterprises when GitHub's metrics and billing feeds use
different handles for the same person. Configure <code>NUXT_BILLING_USER_ALIASES</code>
to map billing usernames to metrics logins.
</div>
<v-expansion-panels variant="accordion">
<v-expansion-panel>
<v-expansion-panel-title class="text-body-2">
Show unmatched billing usernames
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-chip
v-for="username in unmatchedBillingUsernamesList"
:key="username"
size="small"
variant="tonal"
class="ma-1"
>
{{ username }}
</v-chip>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-alert>
<v-row v-if="perUserRows.length > 0 && !noPerUserAttribution" dense class="px-3 mt-2">
<v-col cols="12" md="6">
<v-card variant="outlined">
Expand Down Expand Up @@ -516,13 +552,15 @@ export default defineComponent({
interface BillingAgg { credits: number; grossAmount: number; netAmount: number; models: Set<string>; display: string }
const billingByLogin = reactive(new Map<string, BillingAgg>());
const loadedLogins = reactive(new Set<string>());
const unmatchedBillingUsernames = reactive(new Set<string>());
const perUserLoading = ref(false);

// When the user switches month or toggles month view, drop cached
// per-user roll-ups so the visible page re-fetches against the new window.
watch([selectedMonth, monthView, () => props.queryParams.since, () => props.queryParams.until], () => {
billingByLogin.clear();
loadedLogins.clear();
unmatchedBillingUsernames.clear();
});

async function loadBillingForLogins(logins: string[]): Promise<void> {
Expand Down Expand Up @@ -552,10 +590,15 @@ export default defineComponent({
const qp: Record<string, string> = { ...parent, logins: chunk.join(',') };
try {
const resp = await $fetch<BillingCreditsResponse>('/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<string>(), display: u };
prev.credits += Number.isFinite(it.netQuantity) ? it.netQuantity : 0;
prev.credits += Number.isFinite(it.discountQuantity) ? it.discountQuantity : 0;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -832,6 +882,7 @@ export default defineComponent({
errorReason, headers,
perUserRows, perUserHeaders,
perUserLoading, loadedLoginsCount, onTableOptions, noPerUserAttribution,
unmatchedBillingUsernamesList,
topSpendersChartData, topSpendersChartOptions,
topTokensChartData, topTokensChartOptions,
dataSourceBadge,
Expand Down
25 changes: 17 additions & 8 deletions server/api/billing-credits-by-user.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -189,37 +193,42 @@ export default defineEventHandler(async (event): Promise<BillingCreditsResponse>
});

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<void> {
const params = new URLSearchParams({ ...forwardParams, user: login });
async function fetchOne(metricsLogin: string, billingUsername: string): Promise<void> {
const params = new URLSearchParams({ ...forwardParams, user: billingUsername });
const url = `${apiUrl}?${params.toString()}`;
try {
const resp = await $fetch<BillingCreditsResponse>(url, { headers: billingHeaders });
timePeriod = resp.timePeriod || timePeriod;
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<void> {
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({
Expand Down
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;
unmatchedBillingUsernames?: string[];
usageItems: BillingUsageItem[];
}

Expand Down
29 changes: 27 additions & 2 deletions server/services/billing-credit-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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',
Expand All @@ -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;
Expand Down Expand Up @@ -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,
};
}
Expand Down
39 changes: 39 additions & 0 deletions shared/utils/billing-user-identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export type BillingUserAliases = Record<string, string>;

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<string, unknown>;
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];
}
46 changes: 46 additions & 0 deletions tests/billing-credit-reader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {

beforeEach(() => {
mockQuery.mockReset();
delete process.env.NUXT_BILLING_USER_ALIASES;
});

describe('resolveWindow', () => {
Expand Down Expand Up @@ -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: [
Expand Down
Loading