Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/components/UploadPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,12 @@ export function UploadPage({
<UploadIcon size={40} className="fill-fg-muted" aria-hidden />
</div>
<h3 className="m-0 font-semibold text-fg-default text-[18px] sm:text-[20px]">Drop your CSV here or click to browse</h3>
<p className="m-0 text-fg-muted text-[14px]">premiumRequestUsageReport_*.csv</p>
<p className="m-0 text-fg-muted text-[14px]">April or May PRU vs usage-based billing CSV</p>
</>
)}
</div>
</section>
<p className="mt-4 mb-0 text-fg-muted text-[13px]">Accepted: .csv files from the Premium Request Usage report</p>
<p className="mt-4 mb-0 text-fg-muted text-[13px]">Accepted: April and May PRU vs usage-based billing reports</p>
<section className="mt-6 px-6 py-5 text-left border border-border-default rounded-lg bg-bg-muted" aria-label="Privacy notice">
<h4 className="m-0 mb-3 flex items-center gap-2 text-[16px] text-fg-default">
<LockIcon size={16} className="text-app-savings-fg" aria-hidden />
Expand Down
61 changes: 61 additions & 0 deletions src/pipeline/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import {
parseNormalizedTokenUsageRecord,
parseTokenUsageHeader,
parseTokenUsageRecord,
UnsupportedNativeAiCreditsReportError,
UnsupportedReportVersionError,
validateHeader,
validateSupportedReportRecord,
} from './parser'

const FULL_HEADER = [
Expand Down Expand Up @@ -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()
})
})
20 changes: 20 additions & 0 deletions src/pipeline/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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/, '')
}
Expand Down
54 changes: 51 additions & 3 deletions src/pipeline/runPipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
}

Expand All @@ -45,7 +64,36 @@ class CaptureAggregator implements Aggregator<TokenUsageRecord, TokenUsageRecord
}
}

describe('runPipeline progress', () => {
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'],
Expand Down
22 changes: 17 additions & 5 deletions src/pipeline/runPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
async function validateFileFormat(file: File): Promise<void> {
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
}
}

Expand Down Expand Up @@ -67,7 +79,7 @@ export async function runPipeline(
options?: PipelineOptions,
): Promise<PipelineResult> {
const { includedCreditsOverrides = {}, progressResolution = 500, onProgress } = options ?? {}
await validateFileHeader(file)
await validateFileFormat(file)
let lastProgressStage: PipelineProgress['stage'] | null = null
let lastProgressPercent = -1
let lastProgressTimestamp = 0
Expand Down