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
8 changes: 2 additions & 6 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -358,11 +358,6 @@ function App() {
const handleApplyBudgetSimulation = useCallback(async () => {
const file = currentFileRef.current
if (!file) return
if (isNativeAiCreditsReport) {
setBudgetSimulation(null)
setBudgetSimulationError('Budget simulation is not available for native AI Credits reports yet.')
return
}

const budgetReportUsers = userUsage?.users ?? []
const hasBudgetOrganizationContext = budgetReportUsers.some((user) => user.organizations.length > 0 || user.costCenters.length > 0)
Expand Down Expand Up @@ -427,6 +422,7 @@ function App() {
},
},
resolveIncludedCreditOverrides(seatOverrides),
{ reportMetadata: reportMetadata ?? undefined },
)

if (simulationId !== latestSimulationIdRef.current) return
Expand All @@ -448,7 +444,7 @@ function App() {
budgetValues.productCopilot,
budgetValues.productSpark,
budgetValues.user,
isNativeAiCreditsReport,
reportMetadata,
resolveIncludedCreditOverrides,
seatOverrides,
userUsage,
Expand Down
54 changes: 54 additions & 0 deletions src/utils/budgetSimulation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,35 @@ const HEADER = [
'aic_gross_amount',
].join(',')

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[][]): File {
const body = [HEADER, ...rows.map((row) => row.join(','))].join('\n')
return new File([body], 'usage.csv', { type: 'text/csv' })
}

function createNativeAiCreditsCsv(rows: string[][]): File {
const body = [NATIVE_AI_CREDITS_HEADER, ...rows.map((row) => row.join(','))].join('\n')
return new File([body], 'native-usage.csv', { type: 'text/csv' })
}

function createRecord(overrides: Partial<TokenUsageRecord>): TokenUsageRecord {
const quantity = overrides.quantity ?? 0

Expand Down Expand Up @@ -471,4 +495,34 @@ describe('runBudgetSimulation', () => {
adjustedDailyGrossCostByDate: [{ date: '2026-04-25', amount: 0.5 }],
})
})

it('uses native AI Credits report parsing and policy context when report metadata is provided', async () => {
const file = createNativeAiCreditsCsv([
['6/1/26', 'mona', 'copilot', 'copilot_ai_credit', 'GPT-5', '100', 'ai-credits', '0.01', '1.00', '0', '1.00', '0', 'example-org', 'Cost Center A', '', ''],
])

await expect(runBudgetSimulation(
file,
{ accountBudgetUsd: 0.5 },
{},
{
reportMetadata: {
format: 'native-ai-credits',
label: 'Native AI Credits report',
},
},
)).resolves.toEqual({
totalBill: 0.5,
blockedUsers: 1,
blockedRequests: 0,
blockedIncludedCreditsAic: 0,
allowedAicQuantity: 50,
budgetExhausted: true,
firstUserBlockedDate: null,
accountBlockedDate: '2026-06-01',
productBlockedDates: {},
adjustedDailyNetCostByDate: [{ date: '2026-06-01', amount: 0.5 }],
adjustedDailyGrossCostByDate: [{ date: '2026-06-01', amount: 0.5 }],
})
})
})
16 changes: 13 additions & 3 deletions src/utils/budgetSimulation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { calculateAicIncludedCreditsContext, getUsageMonthKey, type AicIncludedCreditsContext, type AicIncludedCreditsOverrides } from '../pipeline/aicIncludedCredits'
import { getAicUsageMetrics, getUsageMetrics, parseNormalizedTokenUsageRecord, parseTokenUsageHeader, type TokenUsageHeader, type TokenUsageRecord } from '../pipeline/parser'
import { getAicUsageMetrics, getUsageMetrics, parseNativeAiCreditsUsageRecord, parseNormalizedTokenUsageRecord, parseTokenUsageHeader, type TokenUsageHeader, type TokenUsageRecord } from '../pipeline/parser'
import type { ReportFormatMetadata } from '../pipeline/reportAdapters'
import { getProductBudgetName, isNonCopilotCodeReviewUsage, NON_COPILOT_CODE_REVIEW_USER_LABEL, type ProductBudgetName } from '../pipeline/productClassification'
import { streamLines } from '../pipeline/streamer'
import type { UserSpendSegmentId } from './userSpendSegments'
Expand All @@ -26,6 +27,10 @@ export type BudgetSimulationOptions = {
productBudgetsUsd?: Partial<Record<ProductBudgetName, number>>
}

export type BudgetSimulationRunOptions = {
reportMetadata?: ReportFormatMetadata
}

type BudgetSimulationContext = Pick<AicIncludedCreditsContext, 'reportPlanScope' | 'organizationIncludedCreditsPool' | 'individualMonthlyIncludedCredits'>
type BudgetSimulationState = {
remainingAccountBudget: number
Expand Down Expand Up @@ -378,8 +383,11 @@ export async function runBudgetSimulation(
file: File,
options: BudgetSimulationOptions,
includedCreditsOverrides: AicIncludedCreditsOverrides = {},
runOptions: BudgetSimulationRunOptions = {},
): Promise<BudgetSimulationResult> {
const context = await calculateAicIncludedCreditsContext(file, includedCreditsOverrides)
const context = await calculateAicIncludedCreditsContext(file, includedCreditsOverrides, {
reportMetadata: runOptions.reportMetadata,
})
const state = createBudgetSimulationState(options, context)
let header: TokenUsageHeader | null = null

Expand All @@ -392,7 +400,9 @@ export async function runBudgetSimulation(
continue
}

const record = parseNormalizedTokenUsageRecord(trimmed, header)
const record = runOptions.reportMetadata?.format === 'native-ai-credits'
? parseNativeAiCreditsUsageRecord(trimmed, header)
: parseNormalizedTokenUsageRecord(trimmed, header)
if (!record) continue

simulateBudgetRecord(state, record, context)
Expand Down
89 changes: 89 additions & 0 deletions src/views/CostManagementView.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'

import type { BudgetSimulationResult } from '../utils/budgetSimulation'
import { EMPTY_BUDGET_VALUES, type BudgetValues } from '../utils/costManagementBudgets'
import { CostManagementView } from './CostManagementView'

const baseBudgetValues: BudgetValues = {
...EMPTY_BUDGET_VALUES,
account: '1',
}

const baseBudgetSimulation: BudgetSimulationResult = {
totalBill: 0.4,
blockedUsers: 1,
blockedRequests: 7,
blockedIncludedCreditsAic: 0,
allowedAicQuantity: 40,
budgetExhausted: true,
firstUserBlockedDate: null,
accountBlockedDate: '2026-06-01',
productBlockedDates: {},
adjustedDailyNetCostByDate: [],
adjustedDailyGrossCostByDate: [],
}

function renderCostManagementView(overrides: Partial<Parameters<typeof CostManagementView>[0]> = {}): string {
return renderToStaticMarkup(createElement(CostManagementView, {
budgetValues: baseBudgetValues,
isIndividualReport: false,
currentPruBill: 0,
currentPruGrossAmount: 0,
currentPruDiscountAmount: 0,
currentPruQuantity: 0,
currentAicBill: 1,
currentAicGrossAmount: 1,
currentAicDiscountAmount: 0,
currentAicQuantity: 100,
includedAicPoolSize: 0,
dailyUsageData: [],
budgetSimulation: null,
budgetSimulationError: null,
isApplyingBudgetSimulation: false,
onBudgetValueChange: vi.fn(),
onApplyBudgetSimulation: vi.fn(),
showOrganizationPromotionalDataDisclaimer: false,
...overrides,
}))
}

describe('CostManagementView', () => {
it('shows budget controls for native usage-based billing reports', () => {
const html = renderCostManagementView({
reportMode: 'native-ai-credits',
})

expect(html).toContain('Set USD budgets and preview how they would affect usage-based billing for this report.')
expect(html).toContain('Account-level budget')
expect(html).toContain('Apply')
expect(html).not.toContain('Budget simulation is not available')
expect(html).not.toContain('native AI Credits reports yet')
expect(html).not.toContain('PRU')
})

it('uses AI Credits result labels instead of PRU labels for native reports', () => {
const html = renderCostManagementView({
reportMode: 'native-ai-credits',
budgetSimulation: baseBudgetSimulation,
})

expect(html).toContain('Blocked AI Credits')
expect(html).toContain('60')
expect(html).toContain('Simulated AI Credits additional usage spend')
expect(html).not.toContain('Blocked PRUs')
expect(html).not.toContain('later requests')
})

it('keeps PRU blocked-usage labeling for transition-period reports', () => {
const html = renderCostManagementView({
reportMode: 'transition-period-billing-preview',
budgetSimulation: baseBudgetSimulation,
})

expect(html).toContain('Blocked PRUs')
expect(html).toContain('later requests')
expect(html).not.toContain('Blocked AI Credits')
})
})
Loading