From 6bd6a5d5266bdbeac458c9c3d6ab26228c450894 Mon Sep 17 00:00:00 2001 From: Anton Sizikov Date: Sun, 7 Jun 2026 18:02:11 +0200 Subject: [PATCH 1/2] feat: support native budget simulation parsing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/App.tsx | 8 ++--- src/utils/budgetSimulation.test.ts | 54 ++++++++++++++++++++++++++++++ src/utils/budgetSimulation.ts | 16 +++++++-- 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 0bee634..fc5cd31 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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) @@ -427,6 +422,7 @@ function App() { }, }, resolveIncludedCreditOverrides(seatOverrides), + { reportMetadata: reportMetadata ?? undefined }, ) if (simulationId !== latestSimulationIdRef.current) return @@ -448,7 +444,7 @@ function App() { budgetValues.productCopilot, budgetValues.productSpark, budgetValues.user, - isNativeAiCreditsReport, + reportMetadata, resolveIncludedCreditOverrides, seatOverrides, userUsage, diff --git a/src/utils/budgetSimulation.test.ts b/src/utils/budgetSimulation.test.ts index 85eeadd..5be55ee 100644 --- a/src/utils/budgetSimulation.test.ts +++ b/src/utils/budgetSimulation.test.ts @@ -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 { const quantity = overrides.quantity ?? 0 @@ -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 }], + }) + }) }) diff --git a/src/utils/budgetSimulation.ts b/src/utils/budgetSimulation.ts index 5ef2314..fe4c9a4 100644 --- a/src/utils/budgetSimulation.ts +++ b/src/utils/budgetSimulation.ts @@ -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' @@ -26,6 +27,10 @@ export type BudgetSimulationOptions = { productBudgetsUsd?: Partial> } +export type BudgetSimulationRunOptions = { + reportMetadata?: ReportFormatMetadata +} + type BudgetSimulationContext = Pick type BudgetSimulationState = { remainingAccountBudget: number @@ -378,8 +383,11 @@ export async function runBudgetSimulation( file: File, options: BudgetSimulationOptions, includedCreditsOverrides: AicIncludedCreditsOverrides = {}, + runOptions: BudgetSimulationRunOptions = {}, ): Promise { - 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 @@ -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) From 23516e82841b4f3e6c3928d143fbd310e4083da6 Mon Sep 17 00:00:00 2001 From: Anton Sizikov Date: Sun, 7 Jun 2026 18:02:20 +0200 Subject: [PATCH 2/2] feat: enable native budget simulation UI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/views/CostManagementView.test.ts | 89 ++++++++++++++++++++++++++++ src/views/CostManagementView.tsx | 88 ++++++++++----------------- 2 files changed, 121 insertions(+), 56 deletions(-) create mode 100644 src/views/CostManagementView.test.ts diff --git a/src/views/CostManagementView.test.ts b/src/views/CostManagementView.test.ts new file mode 100644 index 0000000..841ff90 --- /dev/null +++ b/src/views/CostManagementView.test.ts @@ -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[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') + }) +}) diff --git a/src/views/CostManagementView.tsx b/src/views/CostManagementView.tsx index 1f312d1..3163367 100644 --- a/src/views/CostManagementView.tsx +++ b/src/views/CostManagementView.tsx @@ -42,14 +42,14 @@ type CostManagementViewProps = { const ACCOUNT_BUDGET_FIELD: { field: BudgetField; label: string; description: string } = { field: 'account', label: 'Account-level budget', - description: 'Controls additional spend only for the current billing period.\nDoes not impact included credits.', + description: 'Controls additional AI Credits spend only for the current billing period.\nDoes not impact included credits.', } const USER_BUDGET_FIELDS: Array<{ field: BudgetField; label: string; description: string }> = [ { field: 'user', label: 'Universal user-level budget', - description: 'Default per-user limit for cumulative AIC gross cost.', + description: 'Default per-user limit for cumulative AI Credits gross cost.', }, { field: 'heavyUser', @@ -67,7 +67,7 @@ const INDIVIDUAL_BUDGET_FIELDS: Array<{ field: BudgetField; label: string; descr { field: 'account', label: 'Additional usage budget', - description: 'Controls additional usage spend only for the current billing period.\nDoes not impact included credits.', + description: 'Controls additional AI Credits usage spend only for the current billing period.\nDoes not impact included credits.', }, ] @@ -75,17 +75,17 @@ const PRODUCT_BUDGET_FIELDS: Array<{ field: BudgetField; label: string; descript { field: 'productCloudAgent', label: PRODUCT_BUDGET_COPILOT_CLOUD_AGENT, - description: 'Applies only to additional AIC spend for Copilot Cloud Agent usage.', + description: 'Applies only to additional AI Credits spend for Copilot Cloud Agent usage.', }, { field: 'productSpark', label: PRODUCT_BUDGET_SPARK, - description: 'Applies only to additional AIC spend for Spark usage.', + description: 'Applies only to additional AI Credits spend for Spark usage.', }, { field: 'productCopilot', label: PRODUCT_BUDGET_COPILOT, - description: 'Applies only to additional AIC spend for Copilot usage.', + description: 'Applies only to additional AI Credits spend for Copilot usage.', }, ] @@ -218,45 +218,21 @@ export function CostManagementView({ currentAicQuantity, licenseAmount, ]) - - if (isNativeAiCredits) { - return ( -
-
-

Cost management

-

Budget simulation is not available for usage-based billing reports yet.

-
- - - -
- Usage-based billing reports already contain AI Credits quantities and costs. Budget controls will be enabled after the simulator can process usage-based billing rows directly. -
-
- ) - } + const blockedUsageValue = budgetSimulation + ? isNativeAiCredits + ? formatAic(Math.max(currentAicQuantity - budgetSimulation.allowedAicQuantity, 0)) + : budgetSimulation.blockedRequests.toLocaleString() + : '0' return (

Cost management

-

Set USD budgets and preview how they would affect the uploaded report.

+

+ {isNativeAiCredits + ? 'Set USD budgets and preview how they would affect usage-based billing for this report.' + : 'Set USD budgets and preview how they would affect the uploaded report.'} +

User-level budgets

- These budgets apply per user to cumulative AIC gross cost. Heavy and Power budgets replace the universal budget for users classified into those groups. + These budgets apply per user to cumulative AI Credits gross cost. Heavy and Power budgets replace the universal budget for users classified into those groups.

- Values are prepopulated from the average AIC gross cost for the spending groups identified in the Spend Insights section. + Values are prepopulated from the average AI Credits gross cost for the spending groups identified in the Spend Insights section.

@@ -348,7 +324,7 @@ export function CostManagementView({
Product-level budgets

- These budgets apply only to AIC additional spend. Included credits can still be used before additional spend blocking starts. + These budgets apply only to AI Credits additional spend. Included credits can still be used before additional spend blocking starts.

@@ -384,8 +360,8 @@ export function CostManagementView({

{isIndividualReport - ? <>The simulation applies the additional usage budget against total paid AIC additional spend after included credits are used. - : <>The simulation applies User-level budgets per user to cumulative AIC gross cost, the account-level budget to total paid AIC additional spend, and product-level budgets to additional spend for each product bucket. The first limit reached blocks later requests for that scope.} + ? <>The simulation applies the additional usage budget against total paid AI Credits additional spend after included credits are used. + : <>The simulation applies User-level budgets per user to cumulative AI Credits gross cost, the account-level budget to total paid AI Credits additional spend, and product-level budgets to additional spend for each product bucket. The first limit reached blocks later {isNativeAiCredits ? 'usage' : 'requests'} for that scope.}