diff --git a/src/components/UploadPage.tsx b/src/components/UploadPage.tsx
index c444f3b..773f556 100644
--- a/src/components/UploadPage.tsx
+++ b/src/components/UploadPage.tsx
@@ -117,12 +117,12 @@ export function UploadPage({
Drop your CSV here or click to browse
- premiumRequestUsageReport_*.csv
+ April or May PRU vs usage-based billing CSV
>
)}
- Accepted: .csv files from the Premium Request Usage report
+ Accepted: April and May PRU vs usage-based billing reports
diff --git a/src/pipeline/parser.test.ts b/src/pipeline/parser.test.ts
index afa5ffa..334cc22 100644
--- a/src/pipeline/parser.test.ts
+++ b/src/pipeline/parser.test.ts
@@ -7,8 +7,10 @@ import {
parseNormalizedTokenUsageRecord,
parseTokenUsageHeader,
parseTokenUsageRecord,
+ UnsupportedNativeAiCreditsReportError,
UnsupportedReportVersionError,
validateHeader,
+ validateSupportedReportRecord,
} from './parser'
const FULL_HEADER = [
@@ -759,3 +761,62 @@ describe('validateHeader', () => {
expect(() => validateHeader(header)).toThrow(InvalidReportError)
})
})
+
+describe('validateSupportedReportRecord', () => {
+ it('throws a clear error for the native AI Credits report format', () => {
+ const header = parseTokenUsageHeader(HEADER_WITHOUT_EXCEEDS_QUOTA)
+ const record = parseTokenUsageRecord(
+ buildRow([
+ '5/29/26',
+ '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(() => validateSupportedReportRecord(header, record)).toThrow(UnsupportedNativeAiCreditsReportError)
+ expect(() => validateSupportedReportRecord(header, record)).toThrow(
+ 'currently supports PRU vs usage-based billing reports generated for the April and May billing periods',
+ )
+ })
+
+ it('accepts PRU report rows when exceeds_quota is absent', () => {
+ 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(() => validateSupportedReportRecord(header, record)).not.toThrow()
+ })
+})
diff --git a/src/pipeline/parser.ts b/src/pipeline/parser.ts
index b60d775..7e9e96f 100644
--- a/src/pipeline/parser.ts
+++ b/src/pipeline/parser.ts
@@ -138,6 +138,17 @@ export class UnsupportedReportVersionError extends Error {
}
}
+export class UnsupportedNativeAiCreditsReportError extends Error {
+ constructor() {
+ super(
+ `This billing preview app currently supports PRU vs usage-based billing reports generated for ` +
+ `the April and May billing periods. Reports generated on or after June 1 use AI Credits as ` +
+ `the primary unit and are not supported yet.`,
+ )
+ this.name = 'UnsupportedNativeAiCreditsReportError'
+ }
+}
+
export function validateHeader(header: TokenUsageHeader): void {
const missingBase = BASE_BILLING_COLUMNS.filter((col) => !(col in header.index))
if (missingBase.length > 0) {
@@ -150,6 +161,15 @@ export function validateHeader(header: TokenUsageHeader): void {
}
}
+export function validateSupportedReportRecord(header: TokenUsageHeader, record: TokenUsageRecord): void {
+ const lacksExceedsQuota = !('exceeds_quota' in header.index)
+ const usesNativeAiCreditsUnit = record.unit_type === 'ai-credits' && record.sku.endsWith('_ai_credit')
+
+ if (lacksExceedsQuota && usesNativeAiCreditsUnit) {
+ throw new UnsupportedNativeAiCreditsReportError()
+ }
+}
+
function stripBom(s: string): string {
return s.replace(/^\uFEFF/, '')
}
diff --git a/src/pipeline/runPipeline.test.ts b/src/pipeline/runPipeline.test.ts
index 43b5fa4..1bbdb09 100644
--- a/src/pipeline/runPipeline.test.ts
+++ b/src/pipeline/runPipeline.test.ts
@@ -24,8 +24,27 @@ const HEADER = [
'aic_gross_amount',
].join(',')
-function createCsv(rows: string[][]): File {
- const body = [HEADER, ...rows.map((row) => row.join(','))].join('\n')
+const NATIVE_AI_CREDITS_HEADER = [
+ '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 createCsv(rows: string[][], header = HEADER): File {
+ const body = [header, ...rows.map((row) => row.join(','))].join('\n')
return new File([body], 'usage.csv', { type: 'text/csv' })
}
@@ -45,7 +64,36 @@ class CaptureAggregator implements Aggregator {
+describe('runPipeline', () => {
+ it('rejects native AI Credits reports before processing rows', async () => {
+ const file = createCsv([
+ [
+ '5/29/26',
+ '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',
+ ],
+ ], NATIVE_AI_CREDITS_HEADER)
+ const aggregator = new CaptureAggregator()
+
+ await expect(runPipeline(file, [aggregator])).rejects.toThrow(
+ 'currently supports PRU vs usage-based billing reports generated for the April and May billing periods',
+ )
+ expect(aggregator.result()).toEqual([])
+ })
+
it('filters and normalizes known normalization window rows before AIC allocation', async () => {
const file = createCsv([
['2026-04-25', 'mona', 'copilot', 'copilot_premium_request', 'GPT-5', '0', 'requests', '0.04', '0', '0', '0', 'False', '300', '', '', '0', '0'],
diff --git a/src/pipeline/runPipeline.ts b/src/pipeline/runPipeline.ts
index 08dbb7e..d5ce7ea 100644
--- a/src/pipeline/runPipeline.ts
+++ b/src/pipeline/runPipeline.ts
@@ -3,19 +3,31 @@ import { createAicIncludedCreditsAllocator, type AicIncludedCreditsOverrides } f
import {
parseTokenUsageHeader,
parseNormalizedTokenUsageRecord,
+ parseTokenUsageRecord,
+ validateSupportedReportRecord,
validateHeader,
type TokenUsageHeader,
type TokenUsageRecord,
} from './parser'
import { streamLines, type StreamProgress } from './streamer'
-async function validateFileHeader(file: File): Promise {
+async function validateFileFormat(file: File): Promise {
+ let header: TokenUsageHeader | null = null
+
for await (const line of streamLines(file)) {
const trimmed = line.trimEnd()
- if (trimmed) {
- validateHeader(parseTokenUsageHeader(trimmed))
- return
+ if (!trimmed) {
+ continue
}
+
+ if (!header) {
+ header = parseTokenUsageHeader(trimmed)
+ validateHeader(header)
+ continue
+ }
+
+ validateSupportedReportRecord(header, parseTokenUsageRecord(trimmed, header))
+ return
}
}
@@ -67,7 +79,7 @@ export async function runPipeline(
options?: PipelineOptions,
): Promise {
const { includedCreditsOverrides = {}, progressResolution = 500, onProgress } = options ?? {}
- await validateFileHeader(file)
+ await validateFileFormat(file)
let lastProgressStage: PipelineProgress['stage'] | null = null
let lastProgressPercent = -1
let lastProgressTimestamp = 0