From 0b8da9fce8eeceb9e441797fc1302df0a1b498fb Mon Sep 17 00:00:00 2001 From: Anton Sizikov Date: Sat, 6 Jun 2026 19:36:28 +0200 Subject: [PATCH 1/3] feat: support native report aggregation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../nativeUsageAggregation.test.ts | 56 ++++- src/pipeline/aggregators/usageMetrics.test.ts | 6 +- .../aggregators/userUsageAggregator.ts | 13 +- src/pipeline/aicIncludedCredits.test.ts | 17 ++ src/pipeline/parser.ts | 5 +- src/pipeline/reportAdapters.test.ts | 55 +++++ src/pipeline/reportAdapters.ts | 14 +- src/pipeline/reportUsageMetrics.test.ts | 13 +- src/pipeline/reportUsageMetrics.ts | 12 +- src/pipeline/runPipeline.test.ts | 204 +++++++++++++++++- src/pipeline/runPipeline.ts | 9 +- 11 files changed, 383 insertions(+), 21 deletions(-) diff --git a/src/pipeline/aggregators/nativeUsageAggregation.test.ts b/src/pipeline/aggregators/nativeUsageAggregation.test.ts index 87c8b7d..9bebfe1 100644 --- a/src/pipeline/aggregators/nativeUsageAggregation.test.ts +++ b/src/pipeline/aggregators/nativeUsageAggregation.test.ts @@ -65,7 +65,7 @@ function nativeRecords(): TokenUsageRecord[] { '250', '50', '200', - '7000', + '3900', 'octodemo', 'Cost Center B', '', @@ -83,7 +83,7 @@ function nativeRecords(): TokenUsageRecord[] { '400', '75', '325', - '7000', + '3900', 'example-org', 'Cost Center A', '4000', @@ -241,4 +241,56 @@ describe('native AI Credits direct aggregator usage', () => { aicNetAmount: 325, }) }) + + it('preserves native Business and Enterprise quota identities for license classification', () => { + const result = aggregate([ + nativeRecord([ + '2026-06-01', + 'test-business-user', + 'copilot', + 'copilot_ai_credit', + 'Auto: GPT-5.3-Codex', + '5.447169000000001', + 'ai-credits', + '0.01', + '0.054471689999999996', + '0.054471689999999996', + '0', + '1900', + 'example-org', + '', + '5.447169000000001', + '0.05447169', + ]), + nativeRecord([ + '2026-06-01', + 'test-enterprise-user', + 'copilot', + 'copilot_ai_credit', + 'Auto: Claude Haiku 4.5', + '42.726213', + 'ai-credits', + '0.01', + '0.4272621300000001', + '0.4272621300000001', + '0', + '3900', + 'example-org', + '', + '42.726213', + '0.4272621300000002', + ]), + ]) + + expect(result.users.users).toEqual([ + expect.objectContaining({ + username: 'test-business-user', + totalMonthlyQuota: 1900, + }), + expect.objectContaining({ + username: 'test-enterprise-user', + totalMonthlyQuota: 3900, + }), + ]) + }) }) diff --git a/src/pipeline/aggregators/usageMetrics.test.ts b/src/pipeline/aggregators/usageMetrics.test.ts index 20ad84d..bb24da3 100644 --- a/src/pipeline/aggregators/usageMetrics.test.ts +++ b/src/pipeline/aggregators/usageMetrics.test.ts @@ -73,9 +73,9 @@ describe('getAggregatorUsageMetrics', () => { gross_amount: 0.125, discount_amount: 0.025, net_amount: 0.1, - aic_quantity: 999, - aic_gross_amount: 999, - aic_net_amount: 999, + aic_quantity: 12.5, + aic_gross_amount: 0.125, + aic_net_amount: 0.1, }) expect(getAggregatorUsageMetrics(record, 'native-ai-credits')).toEqual({ diff --git a/src/pipeline/aggregators/userUsageAggregator.ts b/src/pipeline/aggregators/userUsageAggregator.ts index f12df51..3928e82 100644 --- a/src/pipeline/aggregators/userUsageAggregator.ts +++ b/src/pipeline/aggregators/userUsageAggregator.ts @@ -4,6 +4,11 @@ import { getDisplayModelName } from '../modelLabels' import { getFriendlyProductName } from '../productClassification' import { classifyUserSpendSegments, type UserSpendSegmentId } from '../../utils/userSpendSegments' import { selectKnownMonthlyQuota } from '../aicIncludedCredits' +import { + NATIVE_AI_CREDITS_STANDARD_INCLUDED_CREDITS_POLICY, + TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY, + type IncludedCreditsPolicy, +} from '../includedCreditsPolicy' import type { ReportFormat, ReportFormatMetadata } from '../reportAdapters' import { getAggregatorReportFormat, getAggregatorUsageMetrics } from './usageMetrics' @@ -152,9 +157,13 @@ function ensureProductModel(product: { models: Map }, export class UserUsageAggregator implements Aggregator { private byUser = new Map() private readonly reportFormat: ReportFormat + private readonly quotaPolicy: IncludedCreditsPolicy constructor(reportMetadataOrFormat?: ReportFormat | ReportFormatMetadata) { this.reportFormat = getAggregatorReportFormat(reportMetadataOrFormat) + this.quotaPolicy = this.reportFormat === 'native-ai-credits' + ? NATIVE_AI_CREDITS_STANDARD_INCLUDED_CREDITS_POLICY + : TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY } onHeader(): void { @@ -175,7 +184,7 @@ export class UserUsageAggregator implements Aggregator { }) }) + it('uses native policy quotas and included credits for organization license summaries', () => { + const summary = calculateLicenseSummary([ + { totalMonthlyQuota: 1900, organizations: ['example-org'], costCenters: [] }, + { totalMonthlyQuota: 3900, organizations: ['example-org'], costCenters: ['Cost Center A'] }, + { totalMonthlyQuota: 1000, organizations: ['example-org'], costCenters: ['Cost Center A'] }, + ], NATIVE_AI_CREDITS_STANDARD_INCLUDED_CREDITS_POLICY) + + expect(summary).toEqual({ + rows: [ + { label: 'Copilot Business', users: 1, includedAic: 1900 }, + { label: 'Copilot Enterprise', users: 1, includedAic: 3900 }, + ], + totalUsers: 2, + totalIncludedAic: 5800, + }) + }) + it('summarizes a single-user report as an individual plan', () => { const summary = calculateLicenseSummary([{ totalMonthlyQuota: 1500 }]) diff --git a/src/pipeline/parser.ts b/src/pipeline/parser.ts index 01b28ff..1e8e781 100644 --- a/src/pipeline/parser.ts +++ b/src/pipeline/parser.ts @@ -151,12 +151,15 @@ export class UnsupportedNativeAiCreditsReportError extends Error { } } -export function validateHeader(header: TokenUsageHeader): void { +export function validateBaseHeader(header: TokenUsageHeader): void { const missingBase = BASE_BILLING_COLUMNS.filter((col) => !(col in header.index)) if (missingBase.length > 0) { throw new InvalidReportError() } +} +export function validateHeader(header: TokenUsageHeader): void { + validateBaseHeader(header) const missingAic = REQUIRED_AIC_COLUMNS.filter((col) => !(col in header.index)) if (missingAic.length > 0) { throw new UnsupportedReportVersionError() diff --git a/src/pipeline/reportAdapters.test.ts b/src/pipeline/reportAdapters.test.ts index 5a48691..a0fece0 100644 --- a/src/pipeline/reportAdapters.test.ts +++ b/src/pipeline/reportAdapters.test.ts @@ -53,6 +53,23 @@ const HEADER_WITHOUT_EXCEEDS_QUOTA = [ 'aic_gross_amount', ].join(',') +const NATIVE_AI_CREDITS_HEADER_WITHOUT_ALIASES = [ + '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', +].join(',') + function buildRow(values: string[]): string { return values.join(',') } @@ -211,6 +228,7 @@ describe('usage report adapters', () => { format: 'native-ai-credits', supported: false, }) + expect(() => adapter.validateFirstRecord(header, record)).toThrow(UnsupportedNativeAiCreditsReportError) expect(() => validateUsageReportFirstRecord(header, record)).toThrow(UnsupportedNativeAiCreditsReportError) expect(adapter.parseRecord(row, header)).toMatchObject({ @@ -225,6 +243,43 @@ describe('usage report adapters', () => { }) }) + it('detects native AI Credits reports when alias columns are absent', () => { + const header = parseTokenUsageHeader(NATIVE_AI_CREDITS_HEADER_WITHOUT_ALIASES) + const row = buildRow([ + '2026-06-01', + 'mona', + 'copilot', + 'copilot_ai_credit', + 'Auto: Claude Haiku 4.5', + '42.726213', + 'ai-credits', + '0.01', + '0.4272621300000001', + '0.4272621300000001', + '0', + '3900', + 'example-org', + '', + ]) + const record = parseTokenUsageRecord(row, header) + const adapter = selectUsageReportAdapter(header, record) + + expect(() => validateUsageReportHeader(header)).not.toThrow() + expect(detectReportFormat(header, record)).toBe('native-ai-credits') + expect(adapter.metadata.format).toBe('native-ai-credits') + expect(() => validateUsageReportFirstRecord(header, record, { allowUnsupportedNativeAiCredits: true })).not.toThrow() + expect(adapter.parseRecord(row, header)).toMatchObject({ + date: '2026-06-01', + quantity: 42.726213, + gross_amount: 0.4272621300000001, + discount_amount: 0.4272621300000001, + net_amount: 0, + aic_quantity: 42.726213, + aic_gross_amount: 0.4272621300000001, + aic_net_amount: 0, + }) + }) + it('normalizes native AI Credits dates through the unsupported adapter parser hook', () => { const header = parseTokenUsageHeader(HEADER_WITHOUT_EXCEEDS_QUOTA) const row = buildRow([ diff --git a/src/pipeline/reportAdapters.ts b/src/pipeline/reportAdapters.ts index 655594c..b61761a 100644 --- a/src/pipeline/reportAdapters.ts +++ b/src/pipeline/reportAdapters.ts @@ -3,6 +3,7 @@ import { hasNativeAiCreditsReportSignature, parseNativeAiCreditsUsageRecord, parseNormalizedTokenUsageRecord, + validateBaseHeader, validateHeader as validateTokenUsageHeader, validateSupportedReportRecord, type TokenUsageHeader, @@ -52,7 +53,7 @@ const NATIVE_AI_CREDITS_REPORT_ADAPTER: UsageReportAdapter = { supported: false, }, validateHeader(header) { - validateTokenUsageHeader(header) + validateBaseHeader(header) }, validateFirstRecord() { throw new UnsupportedNativeAiCreditsReportError() @@ -67,11 +68,22 @@ const REPORT_ADAPTERS: Record = { 'native-ai-credits': NATIVE_AI_CREDITS_REPORT_ADAPTER, } +function hasTransitionPeriodAicColumns(header: TokenUsageHeader): boolean { + return 'aic_quantity' in header.index && 'aic_gross_amount' in header.index +} + export function getDefaultSupportedUsageReportAdapter(): UsageReportAdapter { return TRANSITION_PERIOD_BILLING_PREVIEW_REPORT_ADAPTER } export function validateUsageReportHeader(header: TokenUsageHeader): UsageReportAdapter { + validateBaseHeader(header) + + if (!('exceeds_quota' in header.index) && !hasTransitionPeriodAicColumns(header)) { + NATIVE_AI_CREDITS_REPORT_ADAPTER.validateHeader(header) + return NATIVE_AI_CREDITS_REPORT_ADAPTER + } + const adapter = getDefaultSupportedUsageReportAdapter() adapter.validateHeader(header) return adapter diff --git a/src/pipeline/reportUsageMetrics.test.ts b/src/pipeline/reportUsageMetrics.test.ts index cda2e9b..a8c3009 100644 --- a/src/pipeline/reportUsageMetrics.test.ts +++ b/src/pipeline/reportUsageMetrics.test.ts @@ -212,7 +212,7 @@ describe('getReportUsageMetrics', () => { }) }) - it('matches native parsing helper alias fallback behavior when alias columns are blank', () => { + it('uses native-normalized AIC fields so included-credit allocation changes are reflected', () => { const header = parseTokenUsageHeader(NATIVE_AI_CREDITS_HEADER) const row = buildRow([ '5/29/26', @@ -232,11 +232,16 @@ describe('getReportUsageMetrics', () => { '', '', ]) - const rawRecord = parseTokenUsageRecord(row, header) const nativeRecord = parseNativeAiCreditsUsageRecord(row, header) + nativeRecord.aic_net_amount = 0.02 - expect(getReportUsageMetrics(rawRecord, 'native-ai-credits')).toMatchObject({ - aiCredits: getReportUsageMetrics(nativeRecord, 'native-ai-credits').aiCredits, + expect(getReportUsageMetrics(nativeRecord, 'native-ai-credits')).toMatchObject({ + aiCredits: { + quantity: 12.5, + grossAmount: 0.125, + discountAmount: 0.105, + netAmount: 0.02, + }, transitionPeriodComparison: null, }) }) diff --git a/src/pipeline/reportUsageMetrics.ts b/src/pipeline/reportUsageMetrics.ts index 2768766..4ecaf4d 100644 --- a/src/pipeline/reportUsageMetrics.ts +++ b/src/pipeline/reportUsageMetrics.ts @@ -46,12 +46,16 @@ function getTransitionPeriodReportUsageMetrics(record: TokenUsageRecord): Report } function getNativeAiCreditsReportUsageMetrics(record: TokenUsageRecord): ReportUsageMetrics { + const quantity = record.aic_quantity + const grossAmount = record.aic_gross_amount + const netAmount = record.aic_net_amount + return { aiCredits: { - quantity: record.quantity, - grossAmount: record.gross_amount, - discountAmount: record.discount_amount, - netAmount: record.net_amount, + quantity, + grossAmount, + discountAmount: grossAmount - netAmount, + netAmount, }, transitionPeriodComparison: null, } diff --git a/src/pipeline/runPipeline.test.ts b/src/pipeline/runPipeline.test.ts index 6c9a5b9..d83faee 100644 --- a/src/pipeline/runPipeline.test.ts +++ b/src/pipeline/runPipeline.test.ts @@ -51,6 +51,23 @@ const NATIVE_AI_CREDITS_HEADER = [ 'aic_gross_amount', ].join(',') +const NATIVE_AI_CREDITS_HEADER_WITHOUT_ALIASES = [ + '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', +].join(',') + const TRANSITION_PERIOD_REPORT_METADATA = { format: 'transition-period-billing-preview', label: 'Transition Period Billing Preview report', @@ -271,7 +288,7 @@ describe('runPipeline', () => { netAmount: 0, aicQuantity: 35, aicGrossAmount: 350, - aicNetAmount: 280, + aicNetAmount: 0, }), ]) expect(users.result().users).toEqual([ @@ -284,7 +301,7 @@ describe('runPipeline', () => { netAmount: 0, aicQuantity: 25, aicGrossAmount: 250, - aicNetAmount: 200, + aicNetAmount: 0, }), }), expect.objectContaining({ @@ -296,12 +313,193 @@ describe('runPipeline', () => { netAmount: 0, aicQuantity: 10, aicGrossAmount: 100, - aicNetAmount: 80, + aicNetAmount: 0, }), }), ]) }) + it('aggregates native quantity and gross amount columns when alias columns disagree', async () => { + const file = createCsv([ + [ + '2026-06-01', + 'mona', + 'copilot', + 'copilot_ai_credit', + 'Auto: Claude Haiku 4.5', + '42.726213', + 'ai-credits', + '0.01', + '0.4272621300000001', + '0.4272621300000001', + '0', + '3900', + 'example-org', + '', + '999', + '999', + ], + [ + '2026-09-01', + 'hubot', + 'copilot', + 'copilot_ai_credit', + 'Auto: GPT-5.3-Codex', + '5.447169000000001', + 'ai-credits', + '0.01', + '0.054471689999999996', + '0', + '0.054471689999999996', + '1900', + 'example-org', + '', + '888', + '888', + ], + ], NATIVE_AI_CREDITS_HEADER) + let daily!: DailyUsageAggregator + + await runPipeline(file, (reportMetadata) => { + daily = new DailyUsageAggregator(reportMetadata) + return [daily] + }, { + enableNativeAiCreditsProcessing: true, + }) + + expect(daily.result().dailyData).toEqual([ + expect.objectContaining({ + date: '2026-06-01', + requests: 0, + grossAmount: 0, + discountAmount: 0, + netAmount: 0, + aicQuantity: 42.726213, + aicGrossAmount: 0.4272621300000001, + aicNetAmount: 0, + }), + expect.objectContaining({ + date: '2026-09-01', + requests: 0, + grossAmount: 0, + discountAmount: 0, + netAmount: 0, + aicQuantity: 5.447169000000001, + aicGrossAmount: 0.054471689999999996, + aicNetAmount: 0, + }), + ]) + }) + + it('processes native rows when alias columns are absent', async () => { + const file = createCsv([ + [ + '2026-06-01', + 'mona', + 'copilot', + 'copilot_ai_credit', + 'Auto: Claude Haiku 4.5', + '42.726213', + 'ai-credits', + '0.01', + '0.4272621300000001', + '0.4272621300000001', + '0', + '3900', + 'example-org', + '', + ], + [ + '2026-09-01', + 'hubot', + 'copilot', + 'copilot_ai_credit', + 'Auto: GPT-5.3-Codex', + '5.447169000000001', + 'ai-credits', + '0.01', + '0.054471689999999996', + '0', + '0.054471689999999996', + '1900', + 'example-org', + '', + ], + ], NATIVE_AI_CREDITS_HEADER_WITHOUT_ALIASES) + let daily!: DailyUsageAggregator + + const result = await runPipeline(file, (reportMetadata) => { + daily = new DailyUsageAggregator(reportMetadata) + return [daily] + }, { + enableNativeAiCreditsProcessing: true, + }) + + expect(result.reportMetadata).toEqual(NATIVE_AI_CREDITS_REPORT_METADATA) + expect(daily.result().dailyData).toEqual([ + expect.objectContaining({ + date: '2026-06-01', + aicQuantity: 42.726213, + aicGrossAmount: 0.4272621300000001, + aicNetAmount: 0, + }), + expect.objectContaining({ + date: '2026-09-01', + aicQuantity: 5.447169000000001, + aicGrossAmount: 0.054471689999999996, + aicNetAmount: 0, + }), + ]) + }) + + it('constructs aggregators after native report metadata is detected', async () => { + const file = createCsv([ + [ + '5/29/26', + 'mona', + 'copilot', + 'copilot_ai_credit', + 'GPT-5.2', + '10', + 'ai-credits', + '0.01', + '100', + '20', + '80', + '3900', + 'example-org', + 'Cost Center A', + '999', + '999', + ], + ], NATIVE_AI_CREDITS_HEADER) + let daily!: DailyUsageAggregator + let factoryMetadata: unknown = null + + const result = await runPipeline(file, (reportMetadata) => { + factoryMetadata = reportMetadata + daily = new DailyUsageAggregator(reportMetadata) + return [daily] + }, { + enableNativeAiCreditsProcessing: true, + }) + + expect(factoryMetadata).toEqual(NATIVE_AI_CREDITS_REPORT_METADATA) + expect(result.reportMetadata).toEqual(NATIVE_AI_CREDITS_REPORT_METADATA) + expect(daily.result().dailyData).toEqual([ + expect.objectContaining({ + date: '2026-05-29', + requests: 0, + grossAmount: 0, + discountAmount: 0, + netAmount: 0, + aicQuantity: 10, + aicGrossAmount: 100, + aicNetAmount: 0, + }), + ]) + }) + it('selects native summer and September included-credit policies for flagged native reports', async () => { const createNativePolicyCsv = (date: string) => createCsv([ [ diff --git a/src/pipeline/runPipeline.ts b/src/pipeline/runPipeline.ts index d334318..2b43230 100644 --- a/src/pipeline/runPipeline.ts +++ b/src/pipeline/runPipeline.ts @@ -63,6 +63,10 @@ export interface PipelineResult { processedRowCount: number } +export type PipelineAggregators = + | Aggregator[] + | ((reportMetadata: ReportFormatMetadata) => Aggregator[]) + const ANALYSIS_PROGRESS_WEIGHT = 0.4 const MIN_PROGRESS_INCREMENT_PERCENT = 1 const MIN_PROGRESS_EMIT_INTERVAL_MS = 80 @@ -88,7 +92,7 @@ function getNow(): number { export async function runPipeline( file: File, - aggregators: Aggregator[], + aggregatorsOrFactory: PipelineAggregators, options?: PipelineOptions, ): Promise { const { @@ -101,6 +105,9 @@ export async function runPipeline( allowUnsupportedNativeAiCredits: enableNativeAiCreditsProcessing, }) const reportMetadata = reportAdapter.metadata + const aggregators = typeof aggregatorsOrFactory === 'function' + ? aggregatorsOrFactory(reportMetadata) + : aggregatorsOrFactory let lastProgressStage: PipelineProgress['stage'] | null = null let lastProgressPercent = -1 let lastProgressTimestamp = 0 From 9265e9d238accea8f8fac90d6f3e82435a558d3a Mon Sep 17 00:00:00 2001 From: Anton Sizikov Date: Sat, 6 Jun 2026 19:36:39 +0200 Subject: [PATCH 2/3] feat: add usage-based billing UI mode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/App.tsx | 156 ++++++++++----- src/components/ProductUsageTable.test.ts | 49 +++++ src/components/ProductUsageTable.tsx | 53 ++++-- .../ui/BillingProjectionDisclaimer.tsx | 13 +- src/components/ui/BillingTotalsCards.test.ts | 51 +++++ src/components/ui/BillingTotalsCards.tsx | 86 +++++---- src/utils/reportMode.ts | 11 ++ src/views/CostCentersView.tsx | 102 +++++----- src/views/CostManagementView.tsx | 41 ++++ src/views/ModelsView.tsx | 180 ++++++++---------- src/views/OrganizationsView.tsx | 102 +++++----- src/views/OverviewView.tsx | 154 ++++++++------- src/views/ProductsView.tsx | 33 ++-- src/views/UserDetailsView.tsx | 108 +++++------ src/views/UsersView.tsx | 82 ++++---- src/views/nativeBillingCards.test.ts | 140 ++++++++++++++ 16 files changed, 857 insertions(+), 504 deletions(-) create mode 100644 src/components/ProductUsageTable.test.ts create mode 100644 src/components/ui/BillingTotalsCards.test.ts create mode 100644 src/utils/reportMode.ts create mode 100644 src/views/nativeBillingCards.test.ts diff --git a/src/App.tsx b/src/App.tsx index c4d6385..dab1731 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,12 +26,11 @@ import { CostCenterAggregator, type CostCenterResult } from './pipeline/aggregat import { OrganizationAggregator, type OrganizationResult } from './pipeline/aggregators/organizationAggregator' import { UserUsageAggregator, type UserUsageResult } from './pipeline/aggregators/userUsageAggregator' import { - BUSINESS_MONTHLY_AIC_INCLUDED_CREDITS, - ENTERPRISE_MONTHLY_AIC_INCLUDED_CREDITS, calculateLicenseSummary, inferReportPlanScope, type AicIncludedCreditsOverrides, } from './pipeline/aicIncludedCredits' +import { resolveIncludedCreditsPolicy } from './pipeline/includedCreditsPolicy' import { PRODUCT_BUDGET_COPILOT, PRODUCT_BUDGET_COPILOT_CLOUD_AGENT, PRODUCT_BUDGET_SPARK } from './pipeline/productClassification' import { runPipeline } from './pipeline/runPipeline' import type { ReportFormatMetadata } from './pipeline/reportAdapters' @@ -39,6 +38,7 @@ import { runBudgetSimulation, type BudgetSimulationResult } from './utils/budget import { EMPTY_BUDGET_VALUES, getDefaultBudgetValues, getUserSpendSegmentsByUsername, type BudgetField, type BudgetValues } from './utils/costManagementBudgets' import { calculateIndividualPlanUpgradeRecommendation, getIndividualLicenseMonthlyCost } from './utils/individualPlanUpgrade' import { normalizeSeatCount } from './utils/seatCounts' +import { getReportMode, isNativeAiCreditsMode } from './utils/reportMode' import { useAppVersionCheck } from './hooks/useAppVersionCheck' type Status = 'idle' | 'processing' | 'done' @@ -116,25 +116,37 @@ function App() { includedCreditsOverrides: AicIncludedCreditsOverrides = {}, onProgress?: (progressInfo: { rowsProcessed: number; progressPercent: number }) => void, ) => { - const statsAggregator = new QuickStatsAggregator() - const contextAggregator = new ReportContextAggregator() - const dailyAggregator = new DailyUsageAggregator() - const modelAggregator = new ModelUsageAggregator() - const productAggregator = new ProductUsageAggregator() - const costCenterAggregator = new CostCenterAggregator() - const orgAggregator = new OrganizationAggregator() - const userAggregator = new UserUsageAggregator() - - const pipelineResult = await runPipeline(file, [ - statsAggregator, - contextAggregator, - dailyAggregator, - modelAggregator, - productAggregator, - costCenterAggregator, - orgAggregator, - userAggregator, - ], { + let statsAggregator!: QuickStatsAggregator + let contextAggregator!: ReportContextAggregator + let dailyAggregator!: DailyUsageAggregator + let modelAggregator!: ModelUsageAggregator + let productAggregator!: ProductUsageAggregator + let costCenterAggregator!: CostCenterAggregator + let orgAggregator!: OrganizationAggregator + let userAggregator!: UserUsageAggregator + + const pipelineResult = await runPipeline(file, (reportMetadata) => { + statsAggregator = new QuickStatsAggregator() + contextAggregator = new ReportContextAggregator() + dailyAggregator = new DailyUsageAggregator(reportMetadata) + modelAggregator = new ModelUsageAggregator(reportMetadata) + productAggregator = new ProductUsageAggregator(reportMetadata) + costCenterAggregator = new CostCenterAggregator(reportMetadata) + orgAggregator = new OrganizationAggregator(reportMetadata) + userAggregator = new UserUsageAggregator(reportMetadata) + + return [ + statsAggregator, + contextAggregator, + dailyAggregator, + modelAggregator, + productAggregator, + costCenterAggregator, + orgAggregator, + userAggregator, + ] + }, { + enableNativeAiCreditsProcessing: true, includedCreditsOverrides, progressResolution: 500, onProgress, @@ -156,8 +168,16 @@ function App() { } }, []) + const reportMode = getReportMode(reportMetadata) + const isNativeAiCreditsReport = isNativeAiCreditsMode(reportMode) + const rangeStart = reportContext?.startDate ?? null + const rangeEnd = reportContext?.endDate ?? null + const includedCreditsPolicy = resolveIncludedCreditsPolicy(reportMode, { startDate: rangeStart, endDate: rangeEnd }) + const showOrganizationPromotionalDataDisclaimer = reportMode === 'transition-period-billing-preview' + || includedCreditsPolicy.id === 'native-ai-credits-summer-promo' + const getDefaultSeatCounts = useCallback(() => { - const summary = calculateLicenseSummary(userUsage?.users ?? []) + const summary = calculateLicenseSummary(userUsage?.users ?? [], includedCreditsPolicy) return { business: normalizeSeatCount( summary.rows.find((row) => row.label === 'Copilot Business')?.users ?? 0, @@ -168,7 +188,7 @@ function App() { 0, ), } - }, [userUsage]) + }, [includedCreditsPolicy, userUsage]) const resetReportState = useCallback(({ status, fileName }: { status: Status; fileName: string | null }) => { setStatus(status) @@ -339,6 +359,11 @@ 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) @@ -424,6 +449,7 @@ function App() { budgetValues.productCopilot, budgetValues.productSpark, budgetValues.user, + isNativeAiCreditsReport, resolveIncludedCreditOverrides, seatOverrides, userUsage, @@ -485,8 +511,6 @@ function App() { const hasReport = status === 'done' && fileName !== null && reportMetadata !== null const showSeatConfirmation = hasReport && seatConfirmationPending - const rangeStart = reportContext?.startDate ?? null - const rangeEnd = reportContext?.endDate ?? null const reportUsers = userUsage?.users ?? [] const hasOrganizationContext = reportUsers.some((user) => user.organizations.length > 0 || user.costCenters.length > 0) const reportPlanScope = inferReportPlanScope(reportUsers.length, hasOrganizationContext) @@ -505,15 +529,18 @@ function App() { ? { business: effectiveBusinessSeats, enterprise: effectiveEnterpriseSeats } : undefined const includedAicPoolSize = reportPlanScope === 'organization' - ? (effectiveBusinessSeats * BUSINESS_MONTHLY_AIC_INCLUDED_CREDITS) + (effectiveEnterpriseSeats * ENTERPRISE_MONTHLY_AIC_INCLUDED_CREDITS) - : calculateLicenseSummary(reportUsers).totalIncludedAic + ? (effectiveBusinessSeats * includedCreditsPolicy.organizationPlans.business.monthlyIncludedCredits) + (effectiveEnterpriseSeats * includedCreditsPolicy.organizationPlans.enterprise.monthlyIncludedCredits) + : calculateLicenseSummary(reportUsers, includedCreditsPolicy).totalIncludedAic const selectedUser = individualUser ?? (selectedUsername && userUsage ? userUsage.users.find((user) => user.username === selectedUsername) ?? null : null) const canShowSpendInsights = Boolean(userUsage) && !isIndividualReport && reportUsers.length > 1 - const visibleActiveView = activeView === 'spendInsights' && !canShowSpendInsights ? 'overview' : activeView + const visibleActiveView = (activeView === 'spendInsights' && !canShowSpendInsights) + || (isNativeAiCreditsReport && (activeView === 'guide' || activeView === 'faq')) + ? 'overview' + : activeView const userNavActive = isIndividualReport ? visibleActiveView === 'userDetails' : visibleActiveView === 'users' || visibleActiveView === 'userDetails' @@ -704,25 +731,29 @@ function App() { Cost Management -
- - - - + {!isNativeAiCreditsReport && ( + <> +
+ + + + + + )} @@ -740,6 +771,8 @@ function App() { reportPlanScope={reportPlanScope} upgradeRecommendation={individualUpgradeRecommendation} onAdjustSeatCounts={reportPlanScope === 'organization' && !isIndividualReport ? () => setActiveView('users') : undefined} + reportMode={reportMode} + showOrganizationPromotionalDataDisclaimer={showOrganizationPromotionalDataDisclaimer} /> ) : visibleActiveView === 'models' ? ( modelUsage && modelUsage.models.length > 0 ? ( @@ -749,6 +782,8 @@ function App() { isIndividualReport={isIndividualReport} rangeStart={rangeStart} rangeEnd={rangeEnd} + reportMode={reportMode} + showOrganizationPromotionalDataDisclaimer={showOrganizationPromotionalDataDisclaimer} /> ) : null @@ -764,6 +799,8 @@ function App() { setSelectedUsername(username) setActiveView('userDetails') }} + reportMode={reportMode} + includedCreditsPolicy={includedCreditsPolicy} /> ) : visibleActiveView === 'userDetails' || (visibleActiveView === 'users' && isIndividualReport) ? ( @@ -774,16 +811,24 @@ function App() { showUsersBreadcrumb={!isIndividualReport} rangeStart={rangeStart} rangeEnd={rangeEnd} + reportMode={reportMode} + includedCreditsPolicy={includedCreditsPolicy} + showOrganizationPromotionalDataDisclaimer={showOrganizationPromotionalDataDisclaimer} onBackToUsers={isIndividualReport ? undefined : () => setActiveView('users')} /> ) : visibleActiveView === 'costCenters' ? (
- +
) : visibleActiveView === 'products' ? (
- +
) : visibleActiveView === 'spendInsights' ? (
@@ -818,19 +863,26 @@ function App() { isApplyingBudgetSimulation={isApplyingBudgetSimulation} onBudgetValueChange={handleBudgetValueChange} onApplyBudgetSimulation={handleApplyBudgetSimulation} + reportMode={reportMode} + showOrganizationPromotionalDataDisclaimer={showOrganizationPromotionalDataDisclaimer} />
- ) : visibleActiveView === 'guide' ? ( + ) : !isNativeAiCreditsReport && visibleActiveView === 'guide' ? (
- ) : visibleActiveView === 'faq' ? ( + ) : !isNativeAiCreditsReport && visibleActiveView === 'faq' ? (
) : (
- +
)} diff --git a/src/components/ProductUsageTable.test.ts b/src/components/ProductUsageTable.test.ts new file mode 100644 index 0000000..5e6bb78 --- /dev/null +++ b/src/components/ProductUsageTable.test.ts @@ -0,0 +1,49 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' + +import { ProductUsageTable, type ProductUsageTableProduct } from './ProductUsageTable' + +const PRODUCTS: ProductUsageTableProduct[] = [ + { + product: 'Copilot', + totals: { + requests: 10, + aicQuantity: 25, + netAmount: 0.4, + aicNetAmount: 0.25, + }, + models: { + 'GPT-5.2': { + requests: 10, + aicQuantity: 25, + netAmount: 0.4, + aicNetAmount: 0.25, + }, + }, + }, +] + +describe('ProductUsageTable', () => { + it('keeps transition-period PRU comparison columns by default', () => { + const html = renderToStaticMarkup(createElement(ProductUsageTable, { products: PRODUCTS })) + + expect(html).toContain('PRUs') + expect(html).toContain('PRU net cost') + expect(html).toContain('Difference') + expect(html).toContain('AIC net cost') + }) + + it('renders native AI Credits columns without PRU comparison labels', () => { + const html = renderToStaticMarkup(createElement(ProductUsageTable, { + products: PRODUCTS, + reportMode: 'native-ai-credits', + })) + + expect(html).not.toContain('PRUs') + expect(html).not.toContain('PRU net cost') + expect(html).not.toContain('Difference') + expect(html).toContain('AICs') + expect(html).toContain('AIC net cost') + }) +}) diff --git a/src/components/ProductUsageTable.tsx b/src/components/ProductUsageTable.tsx index 8a0986a..d73adcb 100644 --- a/src/components/ProductUsageTable.tsx +++ b/src/components/ProductUsageTable.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react' import { InfoTip } from './InfoTip' import { th, thNum, td, tdNum } from './ui/tableStyles' import { formatAic, formatDifference, formatUsd } from '../utils/format' +import { isNativeAiCreditsMode, type ReportMode } from '../utils/reportMode' export type ProductUsageTableTotals = { requests: number @@ -19,6 +20,7 @@ export type ProductUsageTableProduct = { export type ProductUsageTableProps = { products: ProductUsageTableProduct[] title?: string + reportMode?: ReportMode } const PRODUCT_COLORS = ['#2da44e', '#8b5cf6', '#d4a72c', '#54aeff', '#cf222e', '#fd8c73', '#8b949e'] @@ -27,17 +29,20 @@ function formatInt(n: number): string { return n.toLocaleString(undefined, { maximumFractionDigits: 0 }) } -export function ProductUsageTable({ products, title }: ProductUsageTableProps) { +export function ProductUsageTable({ products, title, reportMode = 'transition-period-billing-preview' }: ProductUsageTableProps) { const [expandedProducts, setExpandedProducts] = useState>( () => new Set(), ) + const isNativeAiCredits = isNativeAiCreditsMode(reportMode) const modelRowsByProduct = useMemo(() => { const entries = products.map((product) => { const modelRows = Object.entries(product.models) .map(([model, totals]) => ({ model, totals })) .sort((a, b) => { - const costDiff = b.totals.netAmount - a.totals.netAmount + const costDiff = isNativeAiCredits + ? b.totals.aicNetAmount - a.totals.aicNetAmount + : b.totals.netAmount - a.totals.netAmount return costDiff !== 0 ? costDiff : a.model.localeCompare(b.model) }) @@ -45,7 +50,7 @@ export function ProductUsageTable({ products, title }: ProductUsageTableProps) { }) return Object.fromEntries(entries) - }, [products]) + }, [products, isNativeAiCredits]) const toggleProduct = (product: string) => { setExpandedProducts((current) => { @@ -70,11 +75,11 @@ export function ProductUsageTable({ products, title }: ProductUsageTableProps) { Product - PRUs - PRU net cost + {!isNativeAiCredits && PRUs} + {!isNativeAiCredits && PRU net cost} AICs AIC net cost - Difference + {!isNativeAiCredits && Difference} @@ -86,6 +91,7 @@ export function ProductUsageTable({ products, title }: ProductUsageTableProps) { isExpanded={expandedProducts.has(product.product)} modelRows={modelRowsByProduct[product.product] ?? []} onToggle={toggleProduct} + isNativeAiCredits={isNativeAiCredits} /> ))} @@ -100,9 +106,10 @@ type ProductRowsProps = { isExpanded: boolean modelRows: Array<{ model: string; totals: ProductUsageTableTotals }> onToggle: (product: string) => void + isNativeAiCredits: boolean } -function ProductRows({ product, productIndex, isExpanded, modelRows, onToggle }: ProductRowsProps) { +function ProductRows({ product, productIndex, isExpanded, modelRows, onToggle, isNativeAiCredits }: ProductRowsProps) { const color = PRODUCT_COLORS[productIndex % PRODUCT_COLORS.length] const productDiff = product.totals.netAmount - product.totals.aicNetAmount @@ -131,15 +138,17 @@ function ProductRows({ product, productIndex, isExpanded, modelRows, onToggle }: )} - {formatInt(product.totals.requests)} - {formatUsd(product.totals.netAmount)} + {!isNativeAiCredits && {formatInt(product.totals.requests)}} + {!isNativeAiCredits && {formatUsd(product.totals.netAmount)}} {formatAic(product.totals.aicQuantity)} {formatUsd(product.totals.aicNetAmount)} - 0 ? 'text-app-savings-fg' : productDiff < 0 ? 'text-fg-danger' : 'text-fg-muted'}`} - > - {formatDifference(productDiff)} - + {!isNativeAiCredits && ( + 0 ? 'text-app-savings-fg' : productDiff < 0 ? 'text-fg-danger' : 'text-fg-muted'}`} + > + {formatDifference(productDiff)} + + )} {isExpanded && @@ -149,15 +158,17 @@ function ProductRows({ product, productIndex, isExpanded, modelRows, onToggle }: return ( {row.model} - {formatInt(row.totals.requests)} - {formatUsd(row.totals.netAmount)} + {!isNativeAiCredits && {formatInt(row.totals.requests)}} + {!isNativeAiCredits && {formatUsd(row.totals.netAmount)}} {formatAic(row.totals.aicQuantity)} {formatUsd(row.totals.aicNetAmount)} - 0 ? 'text-app-savings-fg' : modelDiff < 0 ? 'text-fg-danger' : 'text-fg-muted'}`} - > - {formatDifference(modelDiff)} - + {!isNativeAiCredits && ( + 0 ? 'text-app-savings-fg' : modelDiff < 0 ? 'text-fg-danger' : 'text-fg-muted'}`} + > + {formatDifference(modelDiff)} + + )} ) })} diff --git a/src/components/ui/BillingProjectionDisclaimer.tsx b/src/components/ui/BillingProjectionDisclaimer.tsx index 9cbae56..189838a 100644 --- a/src/components/ui/BillingProjectionDisclaimer.tsx +++ b/src/components/ui/BillingProjectionDisclaimer.tsx @@ -2,9 +2,10 @@ import { InfoIcon } from '@primer/octicons-react' type BillingProjectionDisclaimerProps = { className?: string + showDetails?: boolean } -export function BillingProjectionDisclaimer({ className = '' }: BillingProjectionDisclaimerProps) { +export function BillingProjectionDisclaimer({ className = '', showDetails = true }: BillingProjectionDisclaimerProps) { return (
Estimated projection for illustrative purposes only. Actual usage may differ. -
- Charges are calculated from actual usage emissions processed by the billing platform, separate from the preview - data pipeline. Possible gaps are a reporting issue, not a billing issue. + {showDetails && ( + <> +
+ Charges are calculated from actual usage emissions processed by the billing platform, separate from the preview + data pipeline. Possible gaps are a reporting issue, not a billing issue. + + )}
) diff --git a/src/components/ui/BillingTotalsCards.test.ts b/src/components/ui/BillingTotalsCards.test.ts new file mode 100644 index 0000000..2d25a84 --- /dev/null +++ b/src/components/ui/BillingTotalsCards.test.ts @@ -0,0 +1,51 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' + +import { EXISTING_DISCOUNT_DISCLAIMER } from './ExistingDiscountDisclaimer' +import { BillingTotalsCards } from './BillingTotalsCards' + +const BASE_PROPS = { + pruNetAmount: 0, + pruGrossAmount: 0, + pruDiscountAmount: 0, + pruQuantity: 0, + aicNetAmount: 0, + aicGrossAmount: 0.48, + aicDiscountAmount: 0.48, + aicQuantity: 48.173382, + licenseAmount: 486, + licenseSeatCounts: { + business: 1, + enterprise: 12, + }, +} + +describe('BillingTotalsCards', () => { + it('keeps the usage-based billing title and disclosures for native summer organization reports', () => { + const html = renderToStaticMarkup(createElement(BillingTotalsCards, { + ...BASE_PROPS, + reportMode: 'native-ai-credits', + showExistingDiscountDisclaimer: true, + showOrganizationPromotionalDataDisclaimer: true, + })) + + expect(html).toContain('Usage-based billing (AICs)') + expect(html).toContain(EXISTING_DISCOUNT_DISCLAIMER) + expect(html).toContain('Promotional amounts are used in this simulation.') + expect(html).not.toContain('AI Credits usage') + expect(html).not.toContain('Current billing (PRUs)') + }) + + it('omits the organization promotional note for native standard-period reports', () => { + const html = renderToStaticMarkup(createElement(BillingTotalsCards, { + ...BASE_PROPS, + reportMode: 'native-ai-credits', + showExistingDiscountDisclaimer: true, + showOrganizationPromotionalDataDisclaimer: false, + })) + + expect(html).toContain(EXISTING_DISCOUNT_DISCLAIMER) + expect(html).not.toContain('Promotional amounts are used in this simulation.') + }) +}) diff --git a/src/components/ui/BillingTotalsCards.tsx b/src/components/ui/BillingTotalsCards.tsx index 3a35ba6..b54599c 100644 --- a/src/components/ui/BillingTotalsCards.tsx +++ b/src/components/ui/BillingTotalsCards.tsx @@ -1,6 +1,7 @@ import { appLinks } from '../../config/links' import type { IndividualPlanUpgradeRecommendation } from '../../utils/individualPlanUpgrade' import { formatAic, formatUsd } from '../../utils/format' +import { isNativeAiCreditsMode, type ReportMode } from '../../utils/reportMode' import { ExistingDiscountDisclaimer } from './ExistingDiscountDisclaimer' import { PromotionalDataDisclaimer } from './PromotionalDataDisclaimer' @@ -20,9 +21,11 @@ export type BillingTotalsCardsProps = { } showExistingDiscountDisclaimer?: boolean showPromotionalDataDisclaimer?: boolean + showOrganizationPromotionalDataDisclaimer?: boolean upgradeRecommendation?: IndividualPlanUpgradeRecommendation | null onAdjustSeatCounts?: () => void className?: string + reportMode?: ReportMode } export function BillingTotalsCards({ @@ -38,12 +41,15 @@ export function BillingTotalsCards({ licenseSeatCounts, showExistingDiscountDisclaimer = false, showPromotionalDataDisclaimer = false, + showOrganizationPromotionalDataDisclaimer = showExistingDiscountDisclaimer, upgradeRecommendation = null, onAdjustSeatCounts, className = '', + reportMode = 'transition-period-billing-preview', }: BillingTotalsCardsProps) { const pruTotalAmount = pruNetAmount + (licenseAmount ?? 0) const aicTotalAmount = aicNetAmount + (licenseAmount ?? 0) + const isNativeAiCredits = isNativeAiCreditsMode(reportMode) return (
@@ -65,46 +71,48 @@ export function BillingTotalsCards({ )}

)} -
-
-
Current billing (PRUs)
-
{formatUsd(pruTotalAmount)}
-
{pruQuantity.toLocaleString()} PRUs
-
1 PRU = $0.04
-
-
- Consumed PRUs - {formatUsd(pruGrossAmount)} -
-
- Included PRUs - −{formatUsd(pruDiscountAmount)} -
-
+
+ {!isNativeAiCredits && ( +
+
Current billing (PRUs)
+
{formatUsd(pruTotalAmount)}
+
{pruQuantity.toLocaleString()} PRUs
+
1 PRU = $0.04
+
- Overages - {formatUsd(pruNetAmount)} + Consumed PRUs + {formatUsd(pruGrossAmount)}
- {licenseAmount !== undefined && ( +
+ Included PRUs + −{formatUsd(pruDiscountAmount)} +
+
- License cost - {formatUsd(licenseAmount)} -
- )} - {(licenseAmount !== undefined || showExistingDiscountDisclaimer) && ( -
- {licenseAmount !== undefined && ( -
- Total (license + overages) - {formatUsd(pruTotalAmount)} -
- )} - {showExistingDiscountDisclaimer && } + Overages + {formatUsd(pruNetAmount)}
- )} + {licenseAmount !== undefined && ( +
+ License cost + {formatUsd(licenseAmount)} +
+ )} + {(licenseAmount !== undefined || showExistingDiscountDisclaimer) && ( +
+ {licenseAmount !== undefined && ( +
+ Total (license + overages) + {formatUsd(pruTotalAmount)} +
+ )} + {showExistingDiscountDisclaimer && } +
+ )} +
-
+ )}
Usage-based billing (AICs)
{formatUsd(aicTotalAmount)}
@@ -130,7 +138,7 @@ export function BillingTotalsCards({ {formatUsd(licenseAmount)}
)} - {(licenseAmount !== undefined || showExistingDiscountDisclaimer || showPromotionalDataDisclaimer) && ( + {(licenseAmount !== undefined || showExistingDiscountDisclaimer || showPromotionalDataDisclaimer || showOrganizationPromotionalDataDisclaimer) && (
{licenseAmount !== undefined && (
@@ -138,12 +146,8 @@ export function BillingTotalsCards({ {formatUsd(aicTotalAmount)}
)} - {showExistingDiscountDisclaimer && ( - <> - - - - )} + {showExistingDiscountDisclaimer && } + {showOrganizationPromotionalDataDisclaimer && } {showPromotionalDataDisclaimer && }
)} diff --git a/src/utils/reportMode.ts b/src/utils/reportMode.ts new file mode 100644 index 0000000..ba332f7 --- /dev/null +++ b/src/utils/reportMode.ts @@ -0,0 +1,11 @@ +import type { ReportFormat, ReportFormatMetadata } from '../pipeline/reportAdapters' + +export type ReportMode = ReportFormat + +export function getReportMode(reportMetadata: ReportFormatMetadata | null | undefined): ReportMode { + return reportMetadata?.format ?? 'transition-period-billing-preview' +} + +export function isNativeAiCreditsMode(reportMode: ReportMode): boolean { + return reportMode === 'native-ai-credits' +} diff --git a/src/views/CostCentersView.tsx b/src/views/CostCentersView.tsx index eab02a5..9a3adf8 100644 --- a/src/views/CostCentersView.tsx +++ b/src/views/CostCentersView.tsx @@ -1,11 +1,12 @@ import { useCallback, useMemo, useState } from 'react' import type { ChangeEvent } from 'react' -import { BillingProjectionDisclaimer, ExistingDiscountDisclaimer } from '../components/ui' +import { BillingProjectionDisclaimer, BillingTotalsCards } from '../components/ui' import { th, thNum, td, tdNum } from '../components/ui/tableStyles' import { appLinks } from '../config/links' import type { CostCenterResult, CostCenterUserTotals, CostTotals } from '../pipeline/aggregators/costCenterAggregator' import { calculateAicDiscountAmount, calculateSavingsDifference } from '../utils/billingComparison' import { formatAic, formatDifference, formatUsd } from '../utils/format' +import { isNativeAiCreditsMode, type ReportMode } from '../utils/reportMode' const MAX_DETAIL_ROWS = 20 @@ -31,9 +32,20 @@ function getTopRows(entries: [string, CostTotals | CostCenterUserTotals][]): Cos }) } -export function CostCentersView({ data, rangeStart }: { data: CostCenterResult; rangeStart?: string | null }) { +export function CostCentersView({ + data, + rangeStart, + reportMode = 'transition-period-billing-preview', + showOrganizationPromotionalDataDisclaimer = true, +}: { + data: CostCenterResult + rangeStart?: string | null + reportMode?: ReportMode + showOrganizationPromotionalDataDisclaimer?: boolean +}) { const [selected, setSelected] = useState(data.costCenters[0]?.costCenterName ?? '') const [activeTable, setActiveTable] = useState<'users' | 'models'>('users') + const isNativeAiCredits = isNativeAiCreditsMode(reportMode) const handleSelectChange = useCallback((event: ChangeEvent) => { setSelected(event.target.value) @@ -106,7 +118,7 @@ export function CostCentersView({ data, rangeStart }: { data: CostCenterResult; {selectedCostCenter && totals && ( <> - {hasCosts && periodLabel && ( + {!isNativeAiCredits && hasCosts && periodLabel && (

{savings > 0 ? ( <> @@ -126,59 +138,31 @@ export function CostCentersView({ data, rangeStart }: { data: CostCenterResult;

)} -
-
-
Current billing (PRUs)
-
{formatUsd(totals.netAmount)}
-
{totals.requests.toLocaleString()} PRUs
-
1 PRU = $0.04
-
-
- Consumed PRUs - {formatUsd(totals.grossAmount)} -
-
- Included PRUs - −{formatUsd(totals.discountAmount)} -
-
- Overages - {formatUsd(totals.netAmount)} -
- -
-
-
-
Usage-based billing (AICs)
-
{formatUsd(totals.aicNetAmount)}
-
{formatAic(totals.aicQuantity)} AICs
-
1 AIC = $0.01
-
-
- Consumed AICs - {formatUsd(totals.aicGrossAmount)} -
-
- Included AICs - −{formatUsd(Math.abs(aicDiscountAmount))} -
-
- Additional usage - {formatUsd(totals.aicNetAmount)} -
- -
-
-
- + + {!isNativeAiCredits && } )}
- Pooled included credits are coming + {isNativeAiCredits ? 'Included AI Credits pool' : 'Pooled included credits are coming'}

- Under usage-based billing, included credits will be pooled across all licensed users in your account. No more unused capacity going to waste from idle users. + {isNativeAiCredits + ? 'Included credits are pooled across all licensed users in your account.' + : 'Under usage-based billing, included credits will be pooled across all licensed users in your account. No more unused capacity going to waste from idle users.'}

{activeTable === 'users' ? 'User' : 'Model'} - PRUs - PRU Cost + {!isNativeAiCredits && PRUs} + {!isNativeAiCredits && PRU Cost} AICs AIC Cost - Difference + {!isNativeAiCredits && Difference} @@ -236,13 +220,15 @@ export function CostCentersView({ data, rangeStart }: { data: CostCenterResult; return ( {row.label} - {row.totals.requests.toLocaleString()} - {formatUsd(row.totals.netAmount)} + {!isNativeAiCredits && {row.totals.requests.toLocaleString()}} + {!isNativeAiCredits && {formatUsd(row.totals.netAmount)}} {formatAic(row.totals.aicQuantity)} {formatUsd(row.totals.aicNetAmount)} - 0 ? ' text-app-savings-fg font-semibold' : diff < 0 ? ' text-fg-danger font-semibold' : ''}`}> - {formatDifference(diff)} - + {!isNativeAiCredits && ( + 0 ? ' text-app-savings-fg font-semibold' : diff < 0 ? ' text-fg-danger font-semibold' : ''}`}> + {formatDifference(diff)} + + )} ) })} diff --git a/src/views/CostManagementView.tsx b/src/views/CostManagementView.tsx index b69f2e1..1f312d1 100644 --- a/src/views/CostManagementView.tsx +++ b/src/views/CostManagementView.tsx @@ -8,6 +8,7 @@ import type { BudgetField, BudgetValues } from '../utils/costManagementBudgets' import type { DailyUsageData } from '../pipeline/aggregators/dailyUsageAggregator' import { formatAic, formatUsd } from '../utils/format' import type { IndividualPlanUpgradeRecommendation } from '../utils/individualPlanUpgrade' +import { isNativeAiCreditsMode, type ReportMode } from '../utils/reportMode' import { th, thNum, td, tdNum } from '../components/ui/tableStyles' type CostManagementViewProps = { @@ -34,6 +35,8 @@ type CostManagementViewProps = { isApplyingBudgetSimulation: boolean onBudgetValueChange: (field: BudgetField, value: string) => void onApplyBudgetSimulation: () => void + reportMode?: ReportMode + showOrganizationPromotionalDataDisclaimer?: boolean } const ACCOUNT_BUDGET_FIELD: { field: BudgetField; label: string; description: string } = { @@ -146,7 +149,10 @@ export function CostManagementView({ isApplyingBudgetSimulation, onBudgetValueChange, onApplyBudgetSimulation, + reportMode = 'transition-period-billing-preview', + showOrganizationPromotionalDataDisclaimer = true, }: CostManagementViewProps) { + const isNativeAiCredits = isNativeAiCreditsMode(reportMode) const visibleAccountBudgetFields = isIndividualReport ? INDIVIDUAL_BUDGET_FIELDS : [ACCOUNT_BUDGET_FIELD] const hasVisibleBudgetValue = visibleAccountBudgetFields.some(({ field }) => budgetValues[field].trim() !== '') || (!isIndividualReport && USER_BUDGET_FIELDS.some(({ field }) => budgetValues[field].trim() !== '')) @@ -213,6 +219,39 @@ export function CostManagementView({ 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. +
+
+ ) + } + return (
@@ -233,7 +272,9 @@ export function CostManagementView({ licenseSeatCounts={licenseSeatCounts} showExistingDiscountDisclaimer={!isIndividualReport} showPromotionalDataDisclaimer={isIndividualReport} + showOrganizationPromotionalDataDisclaimer={!isIndividualReport && showOrganizationPromotionalDataDisclaimer} upgradeRecommendation={upgradeRecommendation} + reportMode={reportMode} />
diff --git a/src/views/ModelsView.tsx b/src/views/ModelsView.tsx index 2254f5e..64cfd9a 100644 --- a/src/views/ModelsView.tsx +++ b/src/views/ModelsView.tsx @@ -2,11 +2,12 @@ import { useState, useMemo } from 'react' import { InfoIcon } from '@primer/octicons-react' import type { ModelUsageResult, ModelDailyUsageData, ModelUsageTotals } from '../pipeline/aggregators/modelUsageAggregator' import { DualAxisLineChart, MultiSeriesStackedBarChart } from '../components' -import { BillingProjectionDisclaimer, ExistingDiscountDisclaimer, PromotionalDataDisclaimer } from '../components/ui' +import { BillingProjectionDisclaimer, BillingTotalsCards } from '../components/ui' import { th, thNum, td, tdNum } from '../components/ui/tableStyles' import { calculateAicDiscountAmount, calculateSavingsDifference } from '../utils/billingComparison' import { fillDataForRange } from '../utils/fillDataForRange' import { formatAic, formatUsd } from '../utils/format' +import { isNativeAiCreditsMode, type ReportMode } from '../utils/reportMode' function createEmptyModelDailyUsage(date: string): ModelDailyUsageData { return { @@ -26,6 +27,8 @@ type ModelsViewProps = { isIndividualReport: boolean rangeStart: string | null rangeEnd: string | null + reportMode?: ReportMode + showOrganizationPromotionalDataDisclaimer?: boolean } type ModelDriverRow = { @@ -81,8 +84,16 @@ function getModelDriverSummary(modelUsage: ModelUsageResult): ModelDriverSummary } } -export function ModelsView({ modelUsage, isIndividualReport, rangeStart, rangeEnd }: ModelsViewProps) { +export function ModelsView({ + modelUsage, + isIndividualReport, + rangeStart, + rangeEnd, + reportMode = 'transition-period-billing-preview', + showOrganizationPromotionalDataDisclaimer = true, +}: ModelsViewProps) { const [selectedModel, setSelectedModel] = useState(modelUsage.models[0] ?? '') + const isNativeAiCredits = isNativeAiCreditsMode(reportMode) const modelDriverSummary = useMemo( () => getModelDriverSummary(modelUsage), @@ -122,7 +133,6 @@ export function ModelsView({ modelUsage, isIndividualReport, rangeStart, rangeEn const periodLabel = rangeStart ? new Date(rangeStart + 'T00:00:00').toLocaleString('en-US', { month: 'long', year: 'numeric' }) : null - const showExistingDiscountDisclaimer = !isIndividualReport return (
@@ -189,7 +199,7 @@ export function ModelsView({ modelUsage, isIndividualReport, rangeStart, rangeEn const savings = calculateSavingsDifference(selectedModelTotals.netAmount, selectedModelAicNetAmount) return ( <> - {periodLabel && ( + {!isNativeAiCredits && periodLabel && (

{savings > 0 ? ( <>{selectedModel}'s {periodLabel} usage would cost{' '}{formatUsd(savings)} less under usage-based billing @@ -201,120 +211,96 @@ export function ModelsView({ modelUsage, isIndividualReport, rangeStart, rangeEn

)} -
-
-
Current billing (PRUs)
-
{formatUsd(selectedModelTotals.netAmount)}
-
{selectedModelTotals.requests.toLocaleString()} PRUs
-
1 PRU = $0.04
-
-
- Consumed PRUs - {formatUsd(selectedModelTotals.grossAmount)} -
-
- Included PRUs - −{formatUsd(selectedModelTotals.discountAmount)} -
-
- Overages - {formatUsd(selectedModelTotals.netAmount)} -
- {showExistingDiscountDisclaimer && } -
-
-
-
Usage-based billing (AICs)
-
{formatUsd(selectedModelAicNetAmount)}
-
{formatAic(selectedModelTotals.aicQuantity)} AICs
-
1 AIC = $0.01
-
-
- Consumed AICs - {formatUsd(selectedModelTotals.aicGrossAmount)} -
-
- Included AICs - −{formatUsd(selectedModelAicDiscount)} -
-
- Additional usage - {formatUsd(selectedModelAicNetAmount)} -
- {showExistingDiscountDisclaimer ? : } -
-
-
- + + {!isNativeAiCredits && } ) })()} {selectedModel && filledPerModelDailyData.length > 0 && (
-
- day.date)} - series={[ - { - label: 'Average daily AICs per PRU', - color: '#14b8a6', - data: dailyAverageAicPerRequest.map((day) => day.averageAicPerRequest), - }, - ]} - height={320} - /> -

- Average AICs per PRU for {selectedModel} in the current report period:{' '} - {formatAverageAicPerRequest(overallAverageAicPerRequest)}, average gross per PRU{' '} - {formatUsd(overallAverageAicGrossPerRequest)} -

-

- Note: PRU quantities include billing-period model multipliers, so AICs per PRU should not be read as AICs per actual request when comparing models. -

-
+ {!isNativeAiCredits && ( + <> +
+ day.date)} + series={[ + { + label: 'Average daily AICs per PRU', + color: '#14b8a6', + data: dailyAverageAicPerRequest.map((day) => day.averageAicPerRequest), + }, + ]} + height={320} + /> +

+ Average AICs per PRU for {selectedModel} in the current report period:{' '} + {formatAverageAicPerRequest(overallAverageAicPerRequest)}, average gross per PRU{' '} + {formatUsd(overallAverageAicGrossPerRequest)} +

+

+ Note: PRU quantities include billing-period model multipliers, so AICs per PRU should not be read as AICs per actual request when comparing models. +

+
+ day.date)} + series={[ + { + label: 'PRUs', + color: '#6366f1', + data: filledPerModelDailyData.map((day) => day.requests), + yAxisID: 'y', + }, + { + label: 'AI Credits', + color: '#22c55e', + data: filledPerModelDailyData.map((day) => day.aicQuantity), + yAxisID: 'y1', + }, + ]} + height={320} + /> + + )} day.date)} series={[ { - label: 'PRUs', - color: '#6366f1', - data: filledPerModelDailyData.map((day) => day.requests), + label: 'AIC Gross Cost', + color: '#06b6d4', + data: filledPerModelDailyData.map((day) => day.aicGrossAmount), yAxisID: 'y', }, { - label: 'AI Credits', + label: 'AIC Net Cost', color: '#22c55e', - data: filledPerModelDailyData.map((day) => day.aicQuantity), - yAxisID: 'y1', - }, - ]} - height={320} - /> - day.date)} - series={[ - { - label: 'PRU Gross Cost', - color: '#f59e0b', - data: filledPerModelDailyData.map((day) => day.grossAmount), - yAxisID: 'y', - }, - { - label: 'AIC Gross Cost', - color: '#06b6d4', - data: filledPerModelDailyData.map((day) => day.aicGrossAmount), + data: filledPerModelDailyData.map((day) => day.aicNetAmount), yAxisID: 'y', }, ]} + formatYAsCurrency height={320} />
-

Gross cost is shown before included-credits discounts. Under usage-based billing, included AICs from the account-wide pool will reduce net costs.

+

Gross cost is shown before included-credits discounts. Included AICs from the account-wide pool reduce net costs.

)} diff --git a/src/views/OrganizationsView.tsx b/src/views/OrganizationsView.tsx index aac9dee..01ead49 100644 --- a/src/views/OrganizationsView.tsx +++ b/src/views/OrganizationsView.tsx @@ -1,11 +1,12 @@ import { useCallback, useMemo, useState } from 'react' import type { ChangeEvent } from 'react' -import { BillingProjectionDisclaimer, ExistingDiscountDisclaimer } from '../components/ui' +import { BillingProjectionDisclaimer, BillingTotalsCards } from '../components/ui' import { th, thNum, td, tdNum } from '../components/ui/tableStyles' import { appLinks } from '../config/links' import type { OrganizationResult, OrgTotals, OrgUserTotals } from '../pipeline/aggregators/organizationAggregator' import { calculateAicDiscountAmount, calculateSavingsDifference } from '../utils/billingComparison' import { formatAic, formatDifference, formatUsd } from '../utils/format' +import { isNativeAiCreditsMode, type ReportMode } from '../utils/reportMode' const MAX_DETAIL_ROWS = 20 @@ -30,9 +31,20 @@ function getTopRows(entries: [string, OrgTotals | OrgUserTotals][]): OrgRow[] { }) } -export function OrganizationsView({ data, rangeStart }: { data: OrganizationResult; rangeStart?: string | null }) { +export function OrganizationsView({ + data, + rangeStart, + reportMode = 'transition-period-billing-preview', + showOrganizationPromotionalDataDisclaimer = true, +}: { + data: OrganizationResult + rangeStart?: string | null + reportMode?: ReportMode + showOrganizationPromotionalDataDisclaimer?: boolean +}) { const [selected, setSelected] = useState(data.organizations[0]?.organization ?? '') const [activeTable, setActiveTable] = useState<'users' | 'models'>('users') + const isNativeAiCredits = isNativeAiCreditsMode(reportMode) const handleSelectChange = useCallback((event: ChangeEvent) => { setSelected(event.target.value) @@ -105,7 +117,7 @@ export function OrganizationsView({ data, rangeStart }: { data: OrganizationResu {selectedOrg && totals && ( <> - {hasCosts && periodLabel && ( + {!isNativeAiCredits && hasCosts && periodLabel && (

{savings > 0 ? ( <> @@ -125,59 +137,31 @@ export function OrganizationsView({ data, rangeStart }: { data: OrganizationResu

)} -
-
-
Current billing (PRUs)
-
{formatUsd(totals.netAmount)}
-
{totals.requests.toLocaleString()} PRUs
-
1 PRU = $0.04
-
-
- Consumed PRUs - {formatUsd(totals.grossAmount)} -
-
- Included PRUs - −{formatUsd(totals.discountAmount)} -
-
- Overages - {formatUsd(totals.netAmount)} -
- -
-
-
-
Usage-based billing (AICs)
-
{formatUsd(totals.aicNetAmount)}
-
{formatAic(totals.aicQuantity)} AICs
-
1 AIC = $0.01
-
-
- Consumed AICs - {formatUsd(totals.aicGrossAmount)} -
-
- Included AICs - −{formatUsd(Math.abs(aicDiscountAmount))} -
-
- Additional usage - {formatUsd(totals.aicNetAmount)} -
- -
-
-
- + + {!isNativeAiCredits && } )}
- Pooled included credits are coming + {isNativeAiCredits ? 'Included AI Credits pool' : 'Pooled included credits are coming'}

- Under usage-based billing, included credits will be pooled across all licensed users in your account (not per organization). Included credits are shared across your account-wide pool, not allocated separately to each organization. No more unused capacity going to waste from idle users. + {isNativeAiCredits + ? 'Included credits are shared across your account-wide pool, not allocated separately to each organization.' + : 'Under usage-based billing, included credits will be pooled across all licensed users in your account (not per organization). Included credits are shared across your account-wide pool, not allocated separately to each organization. No more unused capacity going to waste from idle users.'}

{activeTable === 'users' ? 'User' : 'Model'} - PRUs - PRU Cost + {!isNativeAiCredits && PRUs} + {!isNativeAiCredits && PRU Cost} AICs AIC Cost - Difference + {!isNativeAiCredits && Difference} @@ -235,13 +219,15 @@ export function OrganizationsView({ data, rangeStart }: { data: OrganizationResu return ( {row.label} - {row.totals.requests.toLocaleString()} - {formatUsd(row.totals.netAmount)} + {!isNativeAiCredits && {row.totals.requests.toLocaleString()}} + {!isNativeAiCredits && {formatUsd(row.totals.netAmount)}} {formatAic(row.totals.aicQuantity)} {formatUsd(row.totals.aicNetAmount)} - 0 ? ' text-app-savings-fg font-semibold' : diff < 0 ? ' text-fg-danger font-semibold' : ''}`}> - {formatDifference(diff)} - + {!isNativeAiCredits && ( + 0 ? ' text-app-savings-fg font-semibold' : diff < 0 ? ' text-fg-danger font-semibold' : ''}`}> + {formatDifference(diff)} + + )} ) })} diff --git a/src/views/OverviewView.tsx b/src/views/OverviewView.tsx index 25522a0..277583b 100644 --- a/src/views/OverviewView.tsx +++ b/src/views/OverviewView.tsx @@ -7,6 +7,7 @@ import { AIC_UNIT_PRICE_USD } from '../utils/billingConstants' import { fillDataForRange } from '../utils/fillDataForRange' import { formatUsd } from '../utils/format' import type { IndividualPlanUpgradeRecommendation } from '../utils/individualPlanUpgrade' +import { isNativeAiCreditsMode, type ReportMode } from '../utils/reportMode' type OverviewViewProps = { error: string | null @@ -23,6 +24,8 @@ type OverviewViewProps = { reportPlanScope?: ReportPlanScope upgradeRecommendation?: IndividualPlanUpgradeRecommendation | null onAdjustSeatCounts?: () => void + reportMode?: ReportMode + showOrganizationPromotionalDataDisclaimer?: boolean } const CURRENT_AIC_COLOR = '#1a7f37' @@ -54,8 +57,11 @@ export function OverviewView({ reportPlanScope = 'organization', upgradeRecommendation = null, onAdjustSeatCounts, + reportMode = 'transition-period-billing-preview', + showOrganizationPromotionalDataDisclaimer = true, }: OverviewViewProps) { const filledDailyUsageData = fillDataForRange(dailyUsageData, rangeStart, rangeEnd, createEmptyDailyUsage) + const isNativeAiCredits = isNativeAiCreditsMode(reportMode) const overviewTotals = dailyUsageData.reduce( (totals, day) => { @@ -90,6 +96,10 @@ export function OverviewView({ const includedCreditsCardBody = reportPlanScope === 'individual' ? 'Under usage-based billing, your Copilot plan includes AI Credits each month. Usage consumes those included credits first; additional usage is billed only after they are used.' : 'Under usage-based billing, included credits will be pooled across all licensed users in your account. No more unused capacity going to waste from idle users.' + const nativeIncludedCreditsCardTitle = reportPlanScope === 'individual' ? 'Included AI Credits' : 'Included AI Credits pool' + const nativeIncludedCreditsCardBody = reportPlanScope === 'individual' + ? 'Your Copilot plan includes AI Credits each month. Usage consumes those included credits first; additional usage is billed only after they are used.' + : 'Included credits are pooled across all licensed users in your account. Additional usage spend starts after the account-wide included value is used.' const includedCreditsDocsUrl = reportPlanScope === 'individual' ? appLinks.usageBasedBillingForIndividualsDocs : appLinks.aiCreditsForOrganizationsDocs @@ -110,27 +120,29 @@ export function OverviewView({ {dailyUsageData.length > 0 && (
-
-

GitHub Copilot is moving to usage-based billing

-

- Starting June 1, 2026, Copilot usage will be measured in AI Credits (AICs) instead of Premium Requests (PRUs). 1 AIC = $0.01. This is a preview estimate based on your uploaded report. Actual bills under usage-based billing may differ based on model mix and final pricing. -

- {fileName && ( -

- Note: This is a preview estimate based on your uploaded report ({fileName}). Actual bills under usage-based billing may differ based on model mix and final pricing. + {!isNativeAiCredits && ( +

+ {fileName && ( +

+ Note: This is a preview estimate based on your uploaded report ({fileName}). Actual bills under usage-based billing may differ based on model mix and final pricing. +

+ )} + + Learn more about usage-based billing → + +
+ )} - {periodLabel && ( + {!isNativeAiCredits && periodLabel && (

{savings > 0 ? ( <> @@ -163,11 +175,13 @@ export function OverviewView({ licenseSeatCounts={licenseSeatCounts} showExistingDiscountDisclaimer={reportPlanScope !== 'individual'} showPromotionalDataDisclaimer={reportPlanScope === 'individual'} + showOrganizationPromotionalDataDisclaimer={reportPlanScope !== 'individual' && showOrganizationPromotionalDataDisclaimer} upgradeRecommendation={upgradeRecommendation} onAdjustSeatCounts={onAdjustSeatCounts} + reportMode={reportMode} className="mb-3" /> - +

@@ -215,59 +229,63 @@ export function OverviewView({ {includedCreditsDescription}

- day.date)} - series={[ - { - label: 'PRU Gross Cost', - color: '#cf222e', - data: filledDailyUsageData.map((day) => day.grossAmount), - yAxisID: 'y', - }, - { - label: 'AIC Gross Cost', - color: '#54aeff', - data: filledDailyUsageData.map((day) => day.aicGrossAmount), - yAxisID: 'y', - }, - ]} - formatYAsCurrency - height={320} - /> - day.date)} - series={[ - { - label: 'PRU Net Cost', - color: '#cf222e', - data: filledDailyUsageData.reduce((acc, day) => { - acc.push((acc[acc.length - 1] ?? 0) + day.netAmount) - return acc - }, []), - yAxisID: 'y', - }, - { - label: 'AIC Net Cost', - color: '#54aeff', - data: filledDailyUsageData.reduce((acc, day) => { - acc.push((acc[acc.length - 1] ?? 0) + day.aicNetAmount) - return acc - }, []), - yAxisID: 'y', - }, - ]} - formatYAsCurrency - height={320} - /> + {!isNativeAiCredits && ( + <> + day.date)} + series={[ + { + label: 'PRU Gross Cost', + color: '#cf222e', + data: filledDailyUsageData.map((day) => day.grossAmount), + yAxisID: 'y', + }, + { + label: 'AIC Gross Cost', + color: '#54aeff', + data: filledDailyUsageData.map((day) => day.aicGrossAmount), + yAxisID: 'y', + }, + ]} + formatYAsCurrency + height={320} + /> + day.date)} + series={[ + { + label: 'PRU Net Cost', + color: '#cf222e', + data: filledDailyUsageData.reduce((acc, day) => { + acc.push((acc[acc.length - 1] ?? 0) + day.netAmount) + return acc + }, []), + yAxisID: 'y', + }, + { + label: 'AIC Net Cost', + color: '#54aeff', + data: filledDailyUsageData.reduce((acc, day) => { + acc.push((acc[acc.length - 1] ?? 0) + day.aicNetAmount) + return acc + }, []), + yAxisID: 'y', + }, + ]} + formatYAsCurrency + height={320} + /> + + )}
-
PRU = $0.04/request · AIC = $0.01/unit
+
{isNativeAiCredits ? 'AIC = $0.01/unit' : 'PRU = $0.04/request · AIC = $0.01/unit'}
-
-
-
+ {!isNativeAiCredits && ( +
+
+
+
+ {formatUsd(product.totals.netAmount)}
- {formatUsd(product.totals.netAmount)} -
+ )}
@@ -90,7 +97,7 @@ export function ProductsView({ data }: ProductsViewProps) {
- +
) } diff --git a/src/views/UserDetailsView.tsx b/src/views/UserDetailsView.tsx index 93a1b87..29a598e 100644 --- a/src/views/UserDetailsView.tsx +++ b/src/views/UserDetailsView.tsx @@ -6,8 +6,10 @@ import { calculateAicDiscountAmount, calculateSavingsDifference } from '../utils import { fillDataForRange } from '../utils/fillDataForRange' import { formatAic } from '../utils/format' import { getUserSpendSegmentLabel } from '../utils/userSpendSegments' -import { BillingProjectionDisclaimer, ExistingDiscountDisclaimer, PromotionalDataDisclaimer } from '../components/ui' +import { BillingProjectionDisclaimer, BillingTotalsCards } from '../components/ui' import { th, thNum, td, tdNum } from '../components/ui/tableStyles' +import { TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY, type IncludedCreditsPolicy } from '../pipeline/includedCreditsPolicy' +import { isNativeAiCreditsMode, type ReportMode } from '../utils/reportMode' type DailySummaryModelRow = { model: string @@ -64,6 +66,9 @@ export interface UserDetailsViewProps { rangeStart?: string | null rangeEnd?: string | null onBackToUsers?: () => void + reportMode?: ReportMode + includedCreditsPolicy?: IncludedCreditsPolicy + showOrganizationPromotionalDataDisclaimer?: boolean } export function UserDetailsView({ @@ -73,7 +78,11 @@ export function UserDetailsView({ rangeStart, rangeEnd, onBackToUsers, + reportMode = 'transition-period-billing-preview', + includedCreditsPolicy = TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY, + showOrganizationPromotionalDataDisclaimer = true, }: UserDetailsViewProps) { + const isNativeAiCredits = isNativeAiCreditsMode(reportMode) const activeDailyEntries = useMemo(() => { if (!user) return [] return Object.values(user.daily).sort((a, b) => a.date.localeCompare(b.date)) @@ -178,8 +187,8 @@ export function UserDetailsView({ const aicDiscountAmount = user ? calculateAicDiscountAmount(user.totals.aicGrossAmount, user.totals.aicNetAmount) : 0 const savings = user ? calculateSavingsDifference(user.totals.netAmount, user.totals.aicNetAmount) : 0 - const planLabel = user ? getPlanLabel(user.totalMonthlyQuota, reportPlanScope) : null - const showExistingDiscountDisclaimer = reportPlanScope !== 'individual' + const planLabel = user ? getPlanLabel(user.totalMonthlyQuota, reportPlanScope, includedCreditsPolicy) : null + const showExistingDiscountDisclaimer = !isNativeAiCredits && reportPlanScope !== 'individual' const spendSegmentLabel = user && showExistingDiscountDisclaimer ? getUserSpendSegmentLabel(user.spendSegment) : null if (!user) { @@ -232,7 +241,7 @@ export function UserDetailsView({
No usage data for this user.
) : ( <> - {periodLabel && ( + {!isNativeAiCredits && periodLabel && (

{savings > 0 ? ( <> @@ -252,58 +261,30 @@ export function UserDetailsView({

)} -
-
-
Current billing (PRUs)
-
{formatCost(user.totals.netAmount)}
-
{user.totals.requests.toLocaleString()} PRUs
-
1 PRU = $0.04
-
-
- Consumed PRUs - {formatCost(user.totals.grossAmount)} -
-
- Included PRUs - −{formatCost(user.totals.discountAmount)} -
-
- Overages - {formatCost(user.totals.netAmount)} -
- {showExistingDiscountDisclaimer && } -
-
-
-
Usage-based billing (AICs)
-
{formatCost(user.totals.aicNetAmount)}
-
{formatAic(user.totals.aicQuantity)} AICs
-
1 AIC = $0.01
-
-
- Consumed AICs - {formatCost(user.totals.aicGrossAmount)} -
-
- Included AICs - −{formatCost(aicDiscountAmount)} -
-
- Additional usage - {formatCost(user.totals.aicNetAmount)} -
- {showExistingDiscountDisclaimer ? : } -
-
-
- + + {!isNativeAiCredits && }
{productBreakdownRows.length > 0 && ( - + )} - + {!isNativeAiCredits && } + {!isNativeAiCredits && ( + )}
@@ -319,11 +301,13 @@ export function UserDetailsView({ Date Model - PRUs + {!isNativeAiCredits && PRUs} AICs - PRU Net Cost + {isNativeAiCredits && AIC Gross Cost} + {isNativeAiCredits && Included AICs} + {!isNativeAiCredits && PRU Net Cost} AIC Net Cost - Difference + {!isNativeAiCredits && Difference} @@ -339,14 +323,18 @@ export function UserDetailsView({ )} {`- ${row.model}`} - {formatInt(row.requests)} + {!isNativeAiCredits && {formatInt(row.requests)}} {formatAic(row.aicQuantity)} - {formatCost(row.netAmount)} + {isNativeAiCredits && {formatCost(row.aicGrossAmount)}} + {isNativeAiCredits && −{formatCost(row.aicDiscountAmount)}} + {!isNativeAiCredits && {formatCost(row.netAmount)}} {formatCost(row.aicNetAmount)} - 0 ? 'text-app-savings-fg' : diff < 0 ? 'text-app-overspend-fg' : 'text-fg-muted'}`}> - {diff > 0 ? '−' : diff < 0 ? '+' : ''} - {formatCost(Math.abs(diff))} - + {!isNativeAiCredits && ( + 0 ? 'text-app-savings-fg' : diff < 0 ? 'text-app-overspend-fg' : 'text-fg-muted'}`}> + {diff > 0 ? '−' : diff < 0 ? '+' : ''} + {formatCost(Math.abs(diff))} + + )} ) }), diff --git a/src/views/UsersView.tsx b/src/views/UsersView.tsx index c27c26b..ec1b55d 100644 --- a/src/views/UsersView.tsx +++ b/src/views/UsersView.tsx @@ -3,14 +3,14 @@ import type { ChangeEvent } from 'react' import type { UserUsage } from '../pipeline/aggregators/userUsageAggregator' import { type AicIncludedCreditsOverrides, - BUSINESS_MONTHLY_AIC_INCLUDED_CREDITS, - ENTERPRISE_MONTHLY_AIC_INCLUDED_CREDITS, calculateLicenseSummary, inferReportPlanScope, } from '../pipeline/aicIncludedCredits' +import { TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY, type IncludedCreditsPolicy } from '../pipeline/includedCreditsPolicy' import { calculateSavingsDifference } from '../utils/billingComparison' import { InfoTip, ValidationPopover } from '../components/InfoTip' import { formatAic, formatDifference } from '../utils/format' +import { isNativeAiCreditsMode, type ReportMode } from '../utils/reportMode' import { getSeatCountInputError, normalizeSeatCount, parseSeatCountInput } from '../utils/seatCounts' import { Trie } from '../utils/trie' import { th, thBase, thNum, td, tdNum, sortBtn } from '../components/ui/tableStyles' @@ -50,15 +50,25 @@ export interface UsersViewProps { seatOverrides?: SeatOverrides onSeatOverridesChange?: (overrides: SeatOverrides) => void onSelectUser?: (username: string) => void + reportMode?: ReportMode + includedCreditsPolicy?: IncludedCreditsPolicy } -export function UsersView({ users, seatOverrides = {}, onSeatOverridesChange, onSelectUser }: UsersViewProps) { +export function UsersView({ + users, + seatOverrides = {}, + onSeatOverridesChange, + onSelectUser, + reportMode = 'transition-period-billing-preview', + includedCreditsPolicy = TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY, +}: UsersViewProps) { const [query, setQuery] = useState('') const [pageAnchor, setPageAnchor] = useState(null) const [sortKey, setSortKey] = useState('aicQuantity') const [sortDir, setSortDir] = useState('desc') - const licenseSummary = useMemo(() => calculateLicenseSummary(users), [users]) + const isNativeAiCredits = isNativeAiCreditsMode(reportMode) + const licenseSummary = useMemo(() => calculateLicenseSummary(users, includedCreditsPolicy), [users, includedCreditsPolicy]) const reportPlanScope = useMemo( () => inferReportPlanScope(users.length, users.some((user) => user.organizations.length > 0 || user.costCenters.length > 0)), [users], @@ -89,8 +99,8 @@ export function UsersView({ users, seatOverrides = {}, onSeatOverridesChange, on const adjustedSummary = useMemo(() => { if (reportPlanScope === 'individual') return licenseSummary - const bAic = displayBusiness * BUSINESS_MONTHLY_AIC_INCLUDED_CREDITS - const eAic = displayEnterprise * ENTERPRISE_MONTHLY_AIC_INCLUDED_CREDITS + const bAic = displayBusiness * includedCreditsPolicy.organizationPlans.business.monthlyIncludedCredits + const eAic = displayEnterprise * includedCreditsPolicy.organizationPlans.enterprise.monthlyIncludedCredits return { rows: [ { label: 'Copilot Business', users: displayBusiness, includedAic: bAic }, @@ -99,7 +109,7 @@ export function UsersView({ users, seatOverrides = {}, onSeatOverridesChange, on totalUsers: displayBusiness + displayEnterprise, totalIncludedAic: bAic + eAic, } - }, [licenseSummary, displayBusiness, displayEnterprise, reportPlanScope]) + }, [includedCreditsPolicy, licenseSummary, displayBusiness, displayEnterprise, reportPlanScope]) const handleEdit = () => { setDraftBusiness(String(savedBusiness)) @@ -198,7 +208,7 @@ export function UsersView({ users, seatOverrides = {}, onSeatOverridesChange, on

Users

{filteredUsers.length.toLocaleString()} with activity - +
@@ -295,7 +305,7 @@ export function UsersView({ users, seatOverrides = {}, onSeatOverridesChange, on
You can add missing Copilot Business and Copilot Enterprise licenses for accurate bill estimation.

- {displayBusiness > 0 && ( + {!isNativeAiCredits && displayBusiness > 0 && (

Upgrading Copilot Business users to Copilot Enterprise during the promotional period reduces the additional usage cost by $20 per upgrade.

@@ -350,12 +360,14 @@ export function UsersView({ users, seatOverrides = {}, onSeatOverridesChange, on - - - + {!isNativeAiCredits && ( + + + + )} - - - + {!isNativeAiCredits && ( + + + + )} - - - + {!isNativeAiCredits && ( + + + + )} @@ -410,14 +426,16 @@ export function UsersView({ users, seatOverrides = {}, onSeatOverridesChange, on tabIndex={onSelectUser ? 0 : undefined} > {user.username} - {formatInt(user.totals.requests)} + {!isNativeAiCredits && {formatInt(user.totals.requests)}} {formatAic(user.totals.aicQuantity)} {formatInt(user.totals.distinctModels)} - {formatCost(user.totals.netAmount)} + {!isNativeAiCredits && {formatCost(user.totals.netAmount)}} {formatCost(user.totals.aicNetAmount)} - 0 ? 'text-app-savings-fg' : diff < 0 ? 'text-app-overspend-fg' : 'text-fg-muted'}`}> - {formatDifference(diff)} - + {!isNativeAiCredits && ( + 0 ? 'text-app-savings-fg' : diff < 0 ? 'text-app-overspend-fg' : 'text-fg-muted'}`}> + {formatDifference(diff)} + + )} ) })} diff --git a/src/views/nativeBillingCards.test.ts b/src/views/nativeBillingCards.test.ts new file mode 100644 index 0000000..386bccc --- /dev/null +++ b/src/views/nativeBillingCards.test.ts @@ -0,0 +1,140 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' + +import type { CostCenterResult } from '../pipeline/aggregators/costCenterAggregator' +import type { ModelUsageResult } from '../pipeline/aggregators/modelUsageAggregator' +import type { OrganizationResult } from '../pipeline/aggregators/organizationAggregator' +import type { UserUsage } from '../pipeline/aggregators/userUsageAggregator' +import { EXISTING_DISCOUNT_DISCLAIMER } from '../components/ui/ExistingDiscountDisclaimer' +import { CostCentersView } from './CostCentersView' +import { ModelsView } from './ModelsView' +import { OrganizationsView } from './OrganizationsView' +import { UserDetailsView } from './UserDetailsView' + +const nativeTotals = { + requests: 0, + grossAmount: 0, + discountAmount: 0, + netAmount: 0, + aicQuantity: 250.24, + aicGrossAmount: 2.5024, + aicNetAmount: 0, +} + +const modelName = 'Auto: GPT-5.3-Codex' + +function countOccurrences(value: string, search: string): number { + return value.split(search).length - 1 +} + +describe('native billing cards in views', () => { + it('reuses the shared usage-based billing card title and disclosures', () => { + const organizationResult: OrganizationResult = { + organizations: [{ + organization: 'example-org', + userCount: 1, + totals: nativeTotals, + totalsByModel: { [modelName]: nativeTotals }, + totalsByUser: { + mona: { + requests: nativeTotals.requests, + grossAmount: nativeTotals.grossAmount, + netAmount: nativeTotals.netAmount, + aicQuantity: nativeTotals.aicQuantity, + aicGrossAmount: nativeTotals.aicGrossAmount, + aicNetAmount: nativeTotals.aicNetAmount, + }, + }, + }], + } + const costCenterResult: CostCenterResult = { + costCenters: [{ + costCenterName: 'Cost Center A', + userCount: 1, + netCostPerUser: 0, + totals: nativeTotals, + totalsByModel: { [modelName]: nativeTotals }, + totalsByUser: { + mona: { + requests: nativeTotals.requests, + grossAmount: nativeTotals.grossAmount, + netAmount: nativeTotals.netAmount, + aicQuantity: nativeTotals.aicQuantity, + aicGrossAmount: nativeTotals.aicGrossAmount, + aicNetAmount: nativeTotals.aicNetAmount, + }, + }, + }], + } + const modelUsage: ModelUsageResult = { + models: [modelName], + byModel: { + [modelName]: [{ + date: '2026-06-01', + ...nativeTotals, + }], + }, + totalsByModel: { + [modelName]: nativeTotals, + }, + } + const userUsage: UserUsage = { + username: 'mona', + spendSegment: 'typical', + totalMonthlyQuota: 3900, + organizations: ['example-org'], + costCenters: ['Cost Center A'], + daily: { + '2026-06-01': { + date: '2026-06-01', + ...nativeTotals, + models: { + [modelName]: nativeTotals, + }, + }, + }, + products: {}, + totals: { + ...nativeTotals, + distinctModels: 1, + }, + } + + const html = [ + renderToStaticMarkup(createElement(OrganizationsView, { + data: organizationResult, + rangeStart: '2026-06-01', + reportMode: 'native-ai-credits', + showOrganizationPromotionalDataDisclaimer: true, + })), + renderToStaticMarkup(createElement(CostCentersView, { + data: costCenterResult, + rangeStart: '2026-06-01', + reportMode: 'native-ai-credits', + showOrganizationPromotionalDataDisclaimer: true, + })), + renderToStaticMarkup(createElement(ModelsView, { + modelUsage, + isIndividualReport: false, + rangeStart: '2026-06-01', + rangeEnd: '2026-06-01', + reportMode: 'native-ai-credits', + showOrganizationPromotionalDataDisclaimer: true, + })), + renderToStaticMarkup(createElement(UserDetailsView, { + user: userUsage, + rangeStart: '2026-06-01', + rangeEnd: '2026-06-01', + reportMode: 'native-ai-credits', + showOrganizationPromotionalDataDisclaimer: true, + })), + ].join('\n') + + expect(countOccurrences(html, 'Usage-based billing (AICs)')).toBe(4) + expect(countOccurrences(html, EXISTING_DISCOUNT_DISCLAIMER)).toBe(4) + expect(countOccurrences(html, 'Promotional amounts are used in this simulation.')).toBe(4) + expect(html).not.toContain('AI Credits usage') + expect(html).not.toContain('Current billing (PRUs)') + }) +}) From f07eb55856ec6fc099df7b9f7ff0dfe23d9bd510 Mon Sep 17 00:00:00 2001 From: Anton Sizikov Date: Sat, 6 Jun 2026 19:48:57 +0200 Subject: [PATCH 3/3] fix: address usage billing review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/components/ui/BillingTotalsCards.test.ts | 12 ++++++++++++ src/components/ui/BillingTotalsCards.tsx | 4 ++-- src/views/ModelsView.tsx | 2 +- src/views/UserDetailsView.tsx | 4 ++-- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/components/ui/BillingTotalsCards.test.ts b/src/components/ui/BillingTotalsCards.test.ts index 2d25a84..86b30b2 100644 --- a/src/components/ui/BillingTotalsCards.test.ts +++ b/src/components/ui/BillingTotalsCards.test.ts @@ -48,4 +48,16 @@ describe('BillingTotalsCards', () => { expect(html).toContain(EXISTING_DISCOUNT_DISCLAIMER) expect(html).not.toContain('Promotional amounts are used in this simulation.') }) + + it('formats included discount rows with a single explicit minus sign', () => { + const html = renderToStaticMarkup(createElement(BillingTotalsCards, { + ...BASE_PROPS, + pruDiscountAmount: -1, + aicDiscountAmount: -2.34, + })) + + expect(html).toContain('−$1.00') + expect(html).toContain('−$2.34') + expect(html).not.toContain('−$-') + }) }) diff --git a/src/components/ui/BillingTotalsCards.tsx b/src/components/ui/BillingTotalsCards.tsx index b54599c..1f4fb92 100644 --- a/src/components/ui/BillingTotalsCards.tsx +++ b/src/components/ui/BillingTotalsCards.tsx @@ -85,7 +85,7 @@ export function BillingTotalsCards({
Included PRUs - −{formatUsd(pruDiscountAmount)} + −{formatUsd(Math.abs(pruDiscountAmount))}
@@ -125,7 +125,7 @@ export function BillingTotalsCards({
Included AICs - −{formatUsd(aicDiscountAmount)} + −{formatUsd(Math.abs(aicDiscountAmount))}
diff --git a/src/views/ModelsView.tsx b/src/views/ModelsView.tsx index 64cfd9a..b7071e3 100644 --- a/src/views/ModelsView.tsx +++ b/src/views/ModelsView.tsx @@ -279,7 +279,7 @@ export function ModelsView({ )} day.date)} series={[ { diff --git a/src/views/UserDetailsView.tsx b/src/views/UserDetailsView.tsx index 29a598e..ceeb6e9 100644 --- a/src/views/UserDetailsView.tsx +++ b/src/views/UserDetailsView.tsx @@ -304,7 +304,7 @@ export function UserDetailsView({ {!isNativeAiCredits && PRUs} AICs {isNativeAiCredits && AIC Gross Cost} - {isNativeAiCredits && Included AICs} + {isNativeAiCredits && Included Discount} {!isNativeAiCredits && PRU Net Cost} AIC Net Cost {!isNativeAiCredits && Difference} @@ -326,7 +326,7 @@ export function UserDetailsView({ {!isNativeAiCredits && {formatInt(row.requests)}} {formatAic(row.aicQuantity)} {isNativeAiCredits && {formatCost(row.aicGrossAmount)}} - {isNativeAiCredits && −{formatCost(row.aicDiscountAmount)}} + {isNativeAiCredits && −{formatCost(Math.abs(row.aicDiscountAmount))}} {!isNativeAiCredits && {formatCost(row.netAmount)}} {formatCost(row.aicNetAmount)} {!isNativeAiCredits && (