diff --git a/src/pipeline/parser.test.ts b/src/pipeline/parser.test.ts index 334cc22..b6a977f 100644 --- a/src/pipeline/parser.test.ts +++ b/src/pipeline/parser.test.ts @@ -693,7 +693,7 @@ describe('validateHeader', () => { }) it('throws UnsupportedReportVersionError when only aic columns are missing', () => { - const legacyHeader = [ + const preAicHeader = [ 'date', 'username', 'product', @@ -710,12 +710,12 @@ describe('validateHeader', () => { 'organization', 'cost_center_name', ].join(',') - const header = parseTokenUsageHeader(legacyHeader) + const header = parseTokenUsageHeader(preAicHeader) expect(() => validateHeader(header)).toThrow(UnsupportedReportVersionError) }) it('throws UnsupportedReportVersionError when only one aic column is missing', () => { - const legacyHeader = [ + const preAicHeader = [ 'date', 'username', 'product', @@ -733,7 +733,7 @@ describe('validateHeader', () => { 'cost_center_name', 'aic_quantity', ].join(',') - const header = parseTokenUsageHeader(legacyHeader) + const header = parseTokenUsageHeader(preAicHeader) expect(() => validateHeader(header)).toThrow(UnsupportedReportVersionError) }) diff --git a/src/pipeline/parser.ts b/src/pipeline/parser.ts index 7e9e96f..18a535a 100644 --- a/src/pipeline/parser.ts +++ b/src/pipeline/parser.ts @@ -161,11 +161,15 @@ export function validateHeader(header: TokenUsageHeader): void { } } -export function validateSupportedReportRecord(header: TokenUsageHeader, record: TokenUsageRecord): void { +export function hasNativeAiCreditsReportSignature(header: TokenUsageHeader, record: TokenUsageRecord): boolean { const lacksExceedsQuota = !('exceeds_quota' in header.index) const usesNativeAiCreditsUnit = record.unit_type === 'ai-credits' && record.sku.endsWith('_ai_credit') - if (lacksExceedsQuota && usesNativeAiCreditsUnit) { + return lacksExceedsQuota && usesNativeAiCreditsUnit +} + +export function validateSupportedReportRecord(header: TokenUsageHeader, record: TokenUsageRecord): void { + if (hasNativeAiCreditsReportSignature(header, record)) { throw new UnsupportedNativeAiCreditsReportError() } } diff --git a/src/pipeline/reportAdapters.test.ts b/src/pipeline/reportAdapters.test.ts new file mode 100644 index 0000000..93d7c51 --- /dev/null +++ b/src/pipeline/reportAdapters.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest' + +import { + InvalidReportError, + UnsupportedNativeAiCreditsReportError, + UnsupportedReportVersionError, + parseTokenUsageHeader, + parseTokenUsageRecord, +} from './parser' +import { + detectReportFormat, + selectUsageReportAdapter, + validateUsageReportFirstRecord, + validateUsageReportHeader, +} from './reportAdapters' + +const TRANSITION_PERIOD_HEADER = [ + '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', +].join(',') + +const HEADER_WITHOUT_EXCEEDS_QUOTA = [ + 'date', + 'username', + 'product', + 'sku', + 'model', + 'quantity', + 'unit_type', + 'applied_cost_per_quantity', + 'gross_amount', + 'discount_amount', + 'net_amount', + 'total_monthly_quota', + 'organization', + 'cost_center_name', + 'aic_quantity', + 'aic_gross_amount', +].join(',') + +function buildRow(values: string[]): string { + return values.join(',') +} + +describe('usage report adapters', () => { + it('detects and selects the Transition Period Billing Preview adapter for current preview reports', () => { + const header = parseTokenUsageHeader(TRANSITION_PERIOD_HEADER) + const record = parseTokenUsageRecord( + buildRow([ + '2026-05-29', + 'mona', + 'copilot', + 'copilot_premium_request', + 'Auto: Claude Haiku 4.5', + '2', + 'requests', + '0.04', + '0.08', + '0', + '0.08', + 'False', + '300', + 'example-org', + 'Cost Center A', + '20', + '0.20', + ]), + header, + ) + + expect(detectReportFormat(header, record)).toBe('transition-period-billing-preview') + expect(selectUsageReportAdapter(header, record).metadata).toMatchObject({ + format: 'transition-period-billing-preview', + supported: true, + }) + expect(() => validateUsageReportFirstRecord(header, record)).not.toThrow() + }) + + it('keeps missing-exceeds premium request rows on the Transition Period Billing Preview adapter', () => { + const header = parseTokenUsageHeader(HEADER_WITHOUT_EXCEEDS_QUOTA) + const record = parseTokenUsageRecord( + buildRow([ + '2026-05-29', + 'mona', + 'copilot', + 'copilot_premium_request', + 'Auto: Claude Haiku 4.5', + '2', + 'requests', + '0.04', + '0.08', + '0', + '0.08', + '300', + 'example-org', + 'Cost Center A', + '20', + '0.20', + ]), + header, + ) + + expect(detectReportFormat(header, record)).toBe('transition-period-billing-preview') + expect(selectUsageReportAdapter(header, record).metadata.format).toBe('transition-period-billing-preview') + expect(() => validateUsageReportFirstRecord(header, record)).not.toThrow() + }) + + it('detects native AI Credits reports and routes them to an unsupported adapter', () => { + const header = parseTokenUsageHeader(HEADER_WITHOUT_EXCEEDS_QUOTA) + const record = parseTokenUsageRecord( + buildRow([ + '2026-06-01', + 'mona', + 'copilot', + 'copilot_ai_credit', + 'Auto: Claude Haiku 4.5', + '96.9990345', + 'ai-credits', + '0.01', + '0.969990345', + '0', + '0.969990345', + '3900', + 'example-org', + '', + '96.9990345', + '0.969990345', + ]), + header, + ) + + expect(detectReportFormat(header, record)).toBe('native-ai-credits') + expect(selectUsageReportAdapter(header, record).metadata).toMatchObject({ + format: 'native-ai-credits', + supported: false, + }) + expect(() => validateUsageReportFirstRecord(header, record)).toThrow(UnsupportedNativeAiCreditsReportError) + }) + + it('fails clearly for malformed billing headers before adapter selection', () => { + const header = parseTokenUsageHeader('foo,bar,baz') + + expect(() => validateUsageReportHeader(header)).toThrow(InvalidReportError) + }) + + it('fails clearly for pre-AIC report headers before adapter selection', () => { + const header = parseTokenUsageHeader([ + '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', + ].join(',')) + + expect(() => validateUsageReportHeader(header)).toThrow(UnsupportedReportVersionError) + }) +}) diff --git a/src/pipeline/reportAdapters.ts b/src/pipeline/reportAdapters.ts new file mode 100644 index 0000000..0c02506 --- /dev/null +++ b/src/pipeline/reportAdapters.ts @@ -0,0 +1,82 @@ +import { + UnsupportedNativeAiCreditsReportError, + hasNativeAiCreditsReportSignature, + validateHeader as validateTokenUsageHeader, + validateSupportedReportRecord, + type TokenUsageHeader, + type TokenUsageRecord, +} from './parser' + +export type ReportFormat = 'transition-period-billing-preview' | 'native-ai-credits' + +export type ReportFormatMetadata = { + format: ReportFormat + label: string + supported: boolean +} + +export interface UsageReportAdapter { + metadata: ReportFormatMetadata + validateHeader(header: TokenUsageHeader): void + validateFirstRecord(header: TokenUsageHeader, record: TokenUsageRecord): void +} + +const TRANSITION_PERIOD_BILLING_PREVIEW_REPORT_ADAPTER: UsageReportAdapter = { + metadata: { + format: 'transition-period-billing-preview', + label: 'Transition Period Billing Preview report', + supported: true, + }, + validateHeader(header) { + validateTokenUsageHeader(header) + }, + validateFirstRecord(header, record) { + validateSupportedReportRecord(header, record) + }, +} + +const NATIVE_AI_CREDITS_REPORT_ADAPTER: UsageReportAdapter = { + metadata: { + format: 'native-ai-credits', + label: 'Native AI Credits report', + supported: false, + }, + validateHeader(header) { + validateTokenUsageHeader(header) + }, + validateFirstRecord() { + throw new UnsupportedNativeAiCreditsReportError() + }, +} + +const REPORT_ADAPTERS: Record = { + 'transition-period-billing-preview': TRANSITION_PERIOD_BILLING_PREVIEW_REPORT_ADAPTER, + 'native-ai-credits': NATIVE_AI_CREDITS_REPORT_ADAPTER, +} + +export function validateUsageReportHeader(header: TokenUsageHeader): void { + validateTokenUsageHeader(header) +} + +export function detectReportFormat(header: TokenUsageHeader, firstRecord: TokenUsageRecord): ReportFormat { + // This intentionally mirrors the existing preflight check: format detection only samples the first data row. + if (hasNativeAiCreditsReportSignature(header, firstRecord)) { + return 'native-ai-credits' + } + + return 'transition-period-billing-preview' +} + +export function selectUsageReportAdapter(header: TokenUsageHeader, firstRecord: TokenUsageRecord): UsageReportAdapter { + return REPORT_ADAPTERS[detectReportFormat(header, firstRecord)] +} + +export function validateUsageReportFirstRecord( + header: TokenUsageHeader, + firstRecord: TokenUsageRecord, +): UsageReportAdapter { + const adapter = selectUsageReportAdapter(header, firstRecord) + adapter.validateHeader(header) + adapter.validateFirstRecord(header, firstRecord) + return adapter +} diff --git a/src/pipeline/runPipeline.test.ts b/src/pipeline/runPipeline.test.ts index 1bbdb09..bb4fc71 100644 --- a/src/pipeline/runPipeline.test.ts +++ b/src/pipeline/runPipeline.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { Aggregator } from './aggregators/base' -import type { TokenUsageHeader, TokenUsageRecord } from './parser' +import { InvalidReportError, UnsupportedReportVersionError, type TokenUsageHeader, type TokenUsageRecord } from './parser' import { runPipeline } from './runPipeline' const HEADER = [ @@ -65,6 +65,47 @@ class CaptureAggregator implements Aggregator { + it('accepts a valid header-only report', async () => { + const aggregator = new CaptureAggregator() + + await expect(runPipeline(createCsv([]), [aggregator])).resolves.toEqual({ + reportRowCount: 0, + processedRowCount: 0, + }) + expect(aggregator.result()).toEqual([]) + }) + + it('rejects a malformed header-only report', async () => { + const aggregator = new CaptureAggregator() + + await expect(runPipeline(createCsv([], 'foo,bar,baz'), [aggregator])).rejects.toThrow(InvalidReportError) + expect(aggregator.result()).toEqual([]) + }) + + it('rejects a pre-AIC header-only report', async () => { + const header = [ + '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', + ].join(',') + const aggregator = new CaptureAggregator() + + await expect(runPipeline(createCsv([], header), [aggregator])).rejects.toThrow(UnsupportedReportVersionError) + expect(aggregator.result()).toEqual([]) + }) + it('rejects native AI Credits reports before processing rows', async () => { const file = createCsv([ [ diff --git a/src/pipeline/runPipeline.ts b/src/pipeline/runPipeline.ts index d5ce7ea..320c9b9 100644 --- a/src/pipeline/runPipeline.ts +++ b/src/pipeline/runPipeline.ts @@ -4,11 +4,10 @@ import { parseTokenUsageHeader, parseNormalizedTokenUsageRecord, parseTokenUsageRecord, - validateSupportedReportRecord, - validateHeader, type TokenUsageHeader, type TokenUsageRecord, } from './parser' +import { validateUsageReportFirstRecord, validateUsageReportHeader } from './reportAdapters' import { streamLines, type StreamProgress } from './streamer' async function validateFileFormat(file: File): Promise { @@ -22,11 +21,11 @@ async function validateFileFormat(file: File): Promise { if (!header) { header = parseTokenUsageHeader(trimmed) - validateHeader(header) + validateUsageReportHeader(header) continue } - validateSupportedReportRecord(header, parseTokenUsageRecord(trimmed, header)) + validateUsageReportFirstRecord(header, parseTokenUsageRecord(trimmed, header)) return } }