diff --git a/src/lib/csv-parser.test.ts b/src/lib/csv-parser.test.ts index 8eaf4f5..46facdc 100644 --- a/src/lib/csv-parser.test.ts +++ b/src/lib/csv-parser.test.ts @@ -94,15 +94,90 @@ describe('detectReportType', () => { }); it('handles case-insensitive headers', () => { - const headers = ['Date', 'AIC_QUANTITY', 'AIC_Gross_Amount', 'username']; + const headers = ['Date', 'MODEL', 'Exceeds_Quota', 'TOTAL_monthly_quota']; expect(detectReportType(headers)).toBe(REPORT_TYPES.PREMIUM_REQUEST); }); it('handles headers with whitespace padding', () => { - const headers = [' date ', ' aic_quantity ', ' aic_gross_amount', 'username']; + const headers = [' date ', ' model ', ' exceeds_quota', 'total_monthly_quota ']; expect(detectReportType(headers)).toBe(REPORT_TYPES.PREMIUM_REQUEST); }); + it('detects the summarized usage report, which omits username and workflow_path', () => { + const headers = [ + 'date', + 'product', + 'sku', + 'quantity', + 'unit_type', + 'applied_cost_per_quantity', + 'gross_amount', + 'discount_amount', + 'net_amount', + 'organization', + 'repository', + 'cost_center_name', + ]; + expect(detectReportType(headers)).toBe(REPORT_TYPES.USAGE_REPORT); + }); + + it('detects a premium request report that has no aic_ columns', () => { + const headers = [ + 'date', + 'username', + 'product', + 'sku', + 'model', + 'quantity', + 'unit_type', + 'applied_cost_per_quantity', + 'gross_amount', + 'discount_amount', + 'net_amount', + 'exceeds_quota', + 'total_monthly_quota', + 'organization', + 'cost_center_name', + ]; + expect(detectReportType(headers)).toBe(REPORT_TYPES.PREMIUM_REQUEST); + }); + + it('does not mistake a Copilot report for a metered usage report', () => { + const headers = [ + 'date', + 'username', + 'product', + 'sku', + 'model', + 'quantity', + 'unit_type', + 'applied_cost_per_quantity', + 'gross_amount', + 'discount_amount', + 'net_amount', + 'exceeds_quota', + 'total_monthly_quota', + 'organization', + 'cost_center_name', + 'total_input_tokens', + 'total_output_tokens', + 'total_cache_creation_tokens', + 'total_cache_read_tokens', + ]; + expect(detectReportType(headers)).toBe(REPORT_TYPES.TOKEN_USAGE); + }); + + it('detects the org-level seat activity export, which has no Organization column', () => { + const headers = [ + 'report time', + 'login', + 'last authenticated at', + 'last activity at', + 'last surface used', + ]; + expect(detectReportType(headers)).toBe(REPORT_TYPES.COPILOT_SEAT_ACTIVITY); + }); + it('throws on unknown headers', () => { expect(() => detectReportType(['foo', 'bar', 'baz'])).toThrow('Unknown report type'); }); @@ -895,3 +970,58 @@ describe('cross-report: all 6 report types are uniquely detectable', () => { } }); }); + +// ─── Billing Format Drift Regressions ────────────────────────────────────────── + +describe('parseCSV — billing format drift', () => { + it('parses a summarized metered usage report end to end', () => { + const csv = [ + '"date","product","sku","quantity","unit_type","applied_cost_per_quantity","gross_amount","discount_amount","net_amount","organization","repository","cost_center_name"', + '"2026-02-01","actions","actions_linux","1200","minutes","0.008","9.6","0","9.6","acme-platform","acme-platform/api",""', + '"2026-02-02","actions","actions_macos","30","minutes","0.08","2.4","0","2.4","acme-platform","acme-platform/ios","engineering"', + ].join('\n'); + + const report = parseCSV(csv, 'summarized.csv'); + + expect(report.type).toBe(REPORT_TYPES.USAGE_REPORT); + expect(report.rowCount).toBe(2); + expect(report.dateRange).toEqual({ start: '2026-02-01', end: '2026-02-02' }); + + const rows = report.rows as UsageReportRow[]; + expect(rows[0].quantity).toBe(1200); + expect(rows[0].repository).toBe('acme-platform/api'); + // Columns absent from the summarized export fall back to empty strings. + expect(rows[0].username).toBe(''); + expect(rows[0].workflowPath).toBe(''); + }); + + it('parses AI-credit billed Copilot rows', () => { + const csv = [ + '"date","username","product","sku","model","quantity","unit_type","applied_cost_per_quantity","gross_amount","discount_amount","net_amount","exceeds_quota","total_monthly_quota","organization","cost_center_name","aic_quantity","aic_gross_amount"', + '"2026-07-01","gray-oak","copilot","copilot_ai_credit","Claude Opus 4.6","250","ai-credits","0.01","2.5","0","2.5","False","1000","alder-labs","","250","2.5"', + ].join('\n'); + + const report = parseCSV(csv, 'ai-credits.csv'); + + expect(report.type).toBe(REPORT_TYPES.PREMIUM_REQUEST); + const rows = report.rows as PremiumRequestRow[]; + expect(rows[0].sku).toBe('copilot_ai_credit'); + expect(rows[0].unitType).toBe('ai-credits'); + expect(rows[0].quantity).toBe(250); + expect(rows[0].aicQuantity).toBe(250); + }); + + it('parses an org-level seat activity export with no Organization column', () => { + const csv = [ + 'Report Time,Login,Last Authenticated At,Last Activity At,Last Surface Used', + '2026-03-28T06:54:33Z,val-wynn,2026-02-25T10:12:31Z,2026-02-07T17:13:59Z,vscode/1.112.0', + ].join('\n'); + + const report = parseCSV(csv, 'seat-activity-org.csv'); + + expect(report.type).toBe(REPORT_TYPES.COPILOT_SEAT_ACTIVITY); + const rows = report.rows as CopilotSeatActivityRow[]; + expect(rows[0].login).toBe('val-wynn'); + expect(rows[0].organization).toBe(''); + }); +}); diff --git a/src/lib/csv-parser.ts b/src/lib/csv-parser.ts index c46ab1e..7bda287 100644 --- a/src/lib/csv-parser.ts +++ b/src/lib/csv-parser.ts @@ -16,24 +16,32 @@ import type { } from './types'; import { REPORT_TYPES } from './types'; -/** CSV header → report type mapping. Order matters — check most specific first. */ -const HEADER_SIGNATURES: Record = { - [REPORT_TYPES.TOKEN_USAGE]: ['total_input_tokens', 'total_output_tokens'], - [REPORT_TYPES.PREMIUM_REQUEST]: ['aic_quantity', 'aic_gross_amount'], - [REPORT_TYPES.USAGE_REPORT]: ['repository', 'workflow_path'], - [REPORT_TYPES.GHAS_ACTIVE_COMMITTERS]: ['user login', 'organization / repository', 'last pushed date'], - [REPORT_TYPES.COPILOT_SEAT_ACTIVITY]: ['report time', 'last authenticated at', 'last activity at', 'last surface used'], - [REPORT_TYPES.ENTERPRISE_MEMBERS]: ['github com login', 'license type', 'github com enterprise roles', 'total user accounts'], - [REPORT_TYPES.DORMANT_USERS]: ['login', 'role', '2fa_enabled?', 'outside_collaborator'], -}; +/** + * CSV header signatures → report type. Evaluated in array order, most specific + * first, because several reports share the billing column prefix. + * + * Signatures list only columns that are structurally guaranteed for that report. + * The metered usage report ships in two flavours — detailed (31 days, includes + * `username` and `workflow_path`) and summarized (up to a year, omits both) — so + * it keys off `repository`, which no Copilot report has. + */ +const HEADER_SIGNATURES: ReadonlyArray = [ + [REPORT_TYPES.TOKEN_USAGE, ['total_input_tokens', 'total_output_tokens']], + [REPORT_TYPES.PREMIUM_REQUEST, ['model', 'exceeds_quota', 'total_monthly_quota']], + [REPORT_TYPES.USAGE_REPORT, ['date', 'product', 'sku', 'net_amount', 'repository']], + [REPORT_TYPES.GHAS_ACTIVE_COMMITTERS, ['user login', 'organization / repository', 'last pushed date']], + [REPORT_TYPES.COPILOT_SEAT_ACTIVITY, ['report time', 'last authenticated at', 'last activity at', 'last surface used']], + [REPORT_TYPES.ENTERPRISE_MEMBERS, ['github com login', 'license type', 'github com enterprise roles', 'total user accounts']], + [REPORT_TYPES.DORMANT_USERS, ['login', 'role', '2fa_enabled?', 'outside_collaborator']], +]; /** Detect report type from CSV headers */ export function detectReportType(headers: string[]): ReportType { const lowerHeaders = headers.map((h) => h.toLowerCase().trim()); - for (const [type, signatures] of Object.entries(HEADER_SIGNATURES)) { + for (const [type, signatures] of HEADER_SIGNATURES) { if (signatures.every((sig) => lowerHeaders.includes(sig))) { - return type as ReportType; + return type; } } @@ -61,7 +69,7 @@ function mapPremiumRequestRow(raw: Record): PremiumRequestRow { sku: (raw['sku'] ?? '') as PremiumRequestSku, model: raw['model'] ?? '', quantity: parseNum(raw['quantity']), - unitType: (raw['unit_type'] ?? '') as 'requests' | 'ai-units', + unitType: (raw['unit_type'] ?? '') as 'requests' | 'ai-units' | 'ai-credits', appliedCostPerQuantity: parseNum(raw['applied_cost_per_quantity']), grossAmount: parseNum(raw['gross_amount']), discountAmount: parseNum(raw['discount_amount']), @@ -84,7 +92,7 @@ function mapTokenUsageRow(raw: Record): TokenUsageRow { sku: (raw['sku'] ?? '') as PremiumRequestSku, model: raw['model'] ?? '', quantity: parseNum(raw['quantity']), - unitType: (raw['unit_type'] ?? '') as 'requests' | 'ai-units', + unitType: (raw['unit_type'] ?? '') as 'requests' | 'ai-units' | 'ai-credits', appliedCostPerQuantity: parseNum(raw['applied_cost_per_quantity']), grossAmount: parseNum(raw['gross_amount']), discountAmount: parseNum(raw['discount_amount']), diff --git a/src/lib/report-schema.ts b/src/lib/report-schema.ts index b47414f..8d12dbc 100644 --- a/src/lib/report-schema.ts +++ b/src/lib/report-schema.ts @@ -414,7 +414,10 @@ export const PRODUCT_METRIC_OPTIONS: Record = { copilot: [ { key: 'grossAmount', label: 'Spend', isCurrency: true }, { key: 'seats', label: 'Seats', isCurrency: false, valueField: 'quantity', rowFilter: (r) => r.unitType === 'user-months' }, - { key: 'usage', label: 'Usage (PRUs)', isCurrency: false, valueField: 'quantity', rowFilter: (r) => r.unitType === 'requests' }, + // Anything not billed per seat is consumption. Matching on "not user-months" + // rather than an allow-list keeps this working across the PRU → AI credits + // billing change, which introduced new unit types (ai-units, ai-credits). + { key: 'usage', label: 'Usage', isCurrency: false, valueField: 'quantity', rowFilter: (r) => r.unitType !== 'user-months' }, ], spark: [ { key: 'grossAmount', label: 'Spend', isCurrency: true }, diff --git a/src/lib/types.ts b/src/lib/types.ts index ac45db3..64be0fc 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -52,7 +52,10 @@ export type PremiumRequestSku = | 'spark_premium_request' | 'copilot_ai_unit' | 'coding_agent_ai_unit' - | 'spark_ai_unit'; + | 'spark_ai_unit' + | 'copilot_ai_credit' + | 'coding_agent_ai_credit' + | 'spark_ai_credit'; // ─── Usage Report Product & SKU ──────────────────────────────────────────────── @@ -97,7 +100,14 @@ export type UsageSku = | (string & {}); /** Unit types for metered usage */ -export type UsageUnitType = 'minutes' | 'gigabyte-hours' | 'gigabytes' | 'requests' | 'user-months' | 'ai-units'; +export type UsageUnitType = + | 'minutes' + | 'gigabyte-hours' + | 'gigabytes' + | 'requests' + | 'user-months' + | 'ai-units' + | 'ai-credits'; // ─── CSV Column Name Mappings (raw header → camelCase) ───────────────────────── @@ -201,8 +211,8 @@ interface BaseCopilotReportRow { /** ISO date string (YYYY-MM-DD) */ model: CopilotModelValue; /** Number of premium requests or AI units consumed */ quantity: number; - /** "requests" for premium request billing, "ai-units" for AIU billing */ - unitType: 'requests' | 'ai-units'; + /** "requests" for PRU billing, "ai-units"/"ai-credits" for AI credit billing */ + unitType: 'requests' | 'ai-units' | 'ai-credits'; /** Cost per request (typically 0.04) */ appliedCostPerQuantity: number; /** Total cost before discounts */