Skip to content

Commit 9cdb490

Browse files
authored
Merge pull request #142 from github/asizikov/native-ui-mode
Add usage-based billing native UI mode
2 parents 7cee69d + f07eb55 commit 9cdb490

27 files changed

Lines changed: 1253 additions & 526 deletions

src/App.tsx

Lines changed: 104 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,19 @@ import { CostCenterAggregator, type CostCenterResult } from './pipeline/aggregat
2626
import { OrganizationAggregator, type OrganizationResult } from './pipeline/aggregators/organizationAggregator'
2727
import { UserUsageAggregator, type UserUsageResult } from './pipeline/aggregators/userUsageAggregator'
2828
import {
29-
BUSINESS_MONTHLY_AIC_INCLUDED_CREDITS,
30-
ENTERPRISE_MONTHLY_AIC_INCLUDED_CREDITS,
3129
calculateLicenseSummary,
3230
inferReportPlanScope,
3331
type AicIncludedCreditsOverrides,
3432
} from './pipeline/aicIncludedCredits'
33+
import { resolveIncludedCreditsPolicy } from './pipeline/includedCreditsPolicy'
3534
import { PRODUCT_BUDGET_COPILOT, PRODUCT_BUDGET_COPILOT_CLOUD_AGENT, PRODUCT_BUDGET_SPARK } from './pipeline/productClassification'
3635
import { runPipeline } from './pipeline/runPipeline'
3736
import type { ReportFormatMetadata } from './pipeline/reportAdapters'
3837
import { runBudgetSimulation, type BudgetSimulationResult } from './utils/budgetSimulation'
3938
import { EMPTY_BUDGET_VALUES, getDefaultBudgetValues, getUserSpendSegmentsByUsername, type BudgetField, type BudgetValues } from './utils/costManagementBudgets'
4039
import { calculateIndividualPlanUpgradeRecommendation, getIndividualLicenseMonthlyCost } from './utils/individualPlanUpgrade'
4140
import { normalizeSeatCount } from './utils/seatCounts'
41+
import { getReportMode, isNativeAiCreditsMode } from './utils/reportMode'
4242
import { useAppVersionCheck } from './hooks/useAppVersionCheck'
4343

4444
type Status = 'idle' | 'processing' | 'done'
@@ -116,25 +116,37 @@ function App() {
116116
includedCreditsOverrides: AicIncludedCreditsOverrides = {},
117117
onProgress?: (progressInfo: { rowsProcessed: number; progressPercent: number }) => void,
118118
) => {
119-
const statsAggregator = new QuickStatsAggregator()
120-
const contextAggregator = new ReportContextAggregator()
121-
const dailyAggregator = new DailyUsageAggregator()
122-
const modelAggregator = new ModelUsageAggregator()
123-
const productAggregator = new ProductUsageAggregator()
124-
const costCenterAggregator = new CostCenterAggregator()
125-
const orgAggregator = new OrganizationAggregator()
126-
const userAggregator = new UserUsageAggregator()
127-
128-
const pipelineResult = await runPipeline(file, [
129-
statsAggregator,
130-
contextAggregator,
131-
dailyAggregator,
132-
modelAggregator,
133-
productAggregator,
134-
costCenterAggregator,
135-
orgAggregator,
136-
userAggregator,
137-
], {
119+
let statsAggregator!: QuickStatsAggregator
120+
let contextAggregator!: ReportContextAggregator
121+
let dailyAggregator!: DailyUsageAggregator
122+
let modelAggregator!: ModelUsageAggregator
123+
let productAggregator!: ProductUsageAggregator
124+
let costCenterAggregator!: CostCenterAggregator
125+
let orgAggregator!: OrganizationAggregator
126+
let userAggregator!: UserUsageAggregator
127+
128+
const pipelineResult = await runPipeline(file, (reportMetadata) => {
129+
statsAggregator = new QuickStatsAggregator()
130+
contextAggregator = new ReportContextAggregator()
131+
dailyAggregator = new DailyUsageAggregator(reportMetadata)
132+
modelAggregator = new ModelUsageAggregator(reportMetadata)
133+
productAggregator = new ProductUsageAggregator(reportMetadata)
134+
costCenterAggregator = new CostCenterAggregator(reportMetadata)
135+
orgAggregator = new OrganizationAggregator(reportMetadata)
136+
userAggregator = new UserUsageAggregator(reportMetadata)
137+
138+
return [
139+
statsAggregator,
140+
contextAggregator,
141+
dailyAggregator,
142+
modelAggregator,
143+
productAggregator,
144+
costCenterAggregator,
145+
orgAggregator,
146+
userAggregator,
147+
]
148+
}, {
149+
enableNativeAiCreditsProcessing: true,
138150
includedCreditsOverrides,
139151
progressResolution: 500,
140152
onProgress,
@@ -156,8 +168,16 @@ function App() {
156168
}
157169
}, [])
158170

171+
const reportMode = getReportMode(reportMetadata)
172+
const isNativeAiCreditsReport = isNativeAiCreditsMode(reportMode)
173+
const rangeStart = reportContext?.startDate ?? null
174+
const rangeEnd = reportContext?.endDate ?? null
175+
const includedCreditsPolicy = resolveIncludedCreditsPolicy(reportMode, { startDate: rangeStart, endDate: rangeEnd })
176+
const showOrganizationPromotionalDataDisclaimer = reportMode === 'transition-period-billing-preview'
177+
|| includedCreditsPolicy.id === 'native-ai-credits-summer-promo'
178+
159179
const getDefaultSeatCounts = useCallback(() => {
160-
const summary = calculateLicenseSummary(userUsage?.users ?? [])
180+
const summary = calculateLicenseSummary(userUsage?.users ?? [], includedCreditsPolicy)
161181
return {
162182
business: normalizeSeatCount(
163183
summary.rows.find((row) => row.label === 'Copilot Business')?.users ?? 0,
@@ -168,7 +188,7 @@ function App() {
168188
0,
169189
),
170190
}
171-
}, [userUsage])
191+
}, [includedCreditsPolicy, userUsage])
172192

173193
const resetReportState = useCallback(({ status, fileName }: { status: Status; fileName: string | null }) => {
174194
setStatus(status)
@@ -339,6 +359,11 @@ function App() {
339359
const handleApplyBudgetSimulation = useCallback(async () => {
340360
const file = currentFileRef.current
341361
if (!file) return
362+
if (isNativeAiCreditsReport) {
363+
setBudgetSimulation(null)
364+
setBudgetSimulationError('Budget simulation is not available for native AI Credits reports yet.')
365+
return
366+
}
342367

343368
const budgetReportUsers = userUsage?.users ?? []
344369
const hasBudgetOrganizationContext = budgetReportUsers.some((user) => user.organizations.length > 0 || user.costCenters.length > 0)
@@ -424,6 +449,7 @@ function App() {
424449
budgetValues.productCopilot,
425450
budgetValues.productSpark,
426451
budgetValues.user,
452+
isNativeAiCreditsReport,
427453
resolveIncludedCreditOverrides,
428454
seatOverrides,
429455
userUsage,
@@ -485,8 +511,6 @@ function App() {
485511

486512
const hasReport = status === 'done' && fileName !== null && reportMetadata !== null
487513
const showSeatConfirmation = hasReport && seatConfirmationPending
488-
const rangeStart = reportContext?.startDate ?? null
489-
const rangeEnd = reportContext?.endDate ?? null
490514
const reportUsers = userUsage?.users ?? []
491515
const hasOrganizationContext = reportUsers.some((user) => user.organizations.length > 0 || user.costCenters.length > 0)
492516
const reportPlanScope = inferReportPlanScope(reportUsers.length, hasOrganizationContext)
@@ -505,15 +529,18 @@ function App() {
505529
? { business: effectiveBusinessSeats, enterprise: effectiveEnterpriseSeats }
506530
: undefined
507531
const includedAicPoolSize = reportPlanScope === 'organization'
508-
? (effectiveBusinessSeats * BUSINESS_MONTHLY_AIC_INCLUDED_CREDITS) + (effectiveEnterpriseSeats * ENTERPRISE_MONTHLY_AIC_INCLUDED_CREDITS)
509-
: calculateLicenseSummary(reportUsers).totalIncludedAic
532+
? (effectiveBusinessSeats * includedCreditsPolicy.organizationPlans.business.monthlyIncludedCredits) + (effectiveEnterpriseSeats * includedCreditsPolicy.organizationPlans.enterprise.monthlyIncludedCredits)
533+
: calculateLicenseSummary(reportUsers, includedCreditsPolicy).totalIncludedAic
510534

511535
const selectedUser = individualUser
512536
?? (selectedUsername && userUsage
513537
? userUsage.users.find((user) => user.username === selectedUsername) ?? null
514538
: null)
515539
const canShowSpendInsights = Boolean(userUsage) && !isIndividualReport && reportUsers.length > 1
516-
const visibleActiveView = activeView === 'spendInsights' && !canShowSpendInsights ? 'overview' : activeView
540+
const visibleActiveView = (activeView === 'spendInsights' && !canShowSpendInsights)
541+
|| (isNativeAiCreditsReport && (activeView === 'guide' || activeView === 'faq'))
542+
? 'overview'
543+
: activeView
517544
const userNavActive = isIndividualReport
518545
? visibleActiveView === 'userDetails'
519546
: visibleActiveView === 'users' || visibleActiveView === 'userDetails'
@@ -704,25 +731,29 @@ function App() {
704731
<span className="whitespace-nowrap overflow-hidden text-ellipsis max-sm:sr-only">Cost Management</span>
705732
</button>
706733

707-
<hr className="border-0 border-t border-border-default my-[6px]" />
708-
709-
<button
710-
type="button"
711-
className={`${sidebarItemBase} ${visibleActiveView === 'guide' ? sidebarActive : sidebarInactive}`}
712-
onClick={() => setActiveView('guide')}
713-
>
714-
<InfoIcon size={18} className="shrink-0" aria-hidden />
715-
<span className="whitespace-nowrap overflow-hidden text-ellipsis max-sm:sr-only">Report Format</span>
716-
</button>
717-
718-
<button
719-
type="button"
720-
className={`${sidebarItemBase} ${visibleActiveView === 'faq' ? sidebarActive : sidebarInactive}`}
721-
onClick={() => setActiveView('faq')}
722-
>
723-
<QuestionIcon size={18} className="shrink-0" aria-hidden />
724-
<span className="whitespace-nowrap overflow-hidden text-ellipsis max-sm:sr-only">FAQ</span>
725-
</button>
734+
{!isNativeAiCreditsReport && (
735+
<>
736+
<hr className="border-0 border-t border-border-default my-[6px]" />
737+
738+
<button
739+
type="button"
740+
className={`${sidebarItemBase} ${visibleActiveView === 'guide' ? sidebarActive : sidebarInactive}`}
741+
onClick={() => setActiveView('guide')}
742+
>
743+
<InfoIcon size={18} className="shrink-0" aria-hidden />
744+
<span className="whitespace-nowrap overflow-hidden text-ellipsis max-sm:sr-only">Report Format</span>
745+
</button>
746+
747+
<button
748+
type="button"
749+
className={`${sidebarItemBase} ${visibleActiveView === 'faq' ? sidebarActive : sidebarInactive}`}
750+
onClick={() => setActiveView('faq')}
751+
>
752+
<QuestionIcon size={18} className="shrink-0" aria-hidden />
753+
<span className="whitespace-nowrap overflow-hidden text-ellipsis max-sm:sr-only">FAQ</span>
754+
</button>
755+
</>
756+
)}
726757
</nav>
727758
</aside>
728759

@@ -740,6 +771,8 @@ function App() {
740771
reportPlanScope={reportPlanScope}
741772
upgradeRecommendation={individualUpgradeRecommendation}
742773
onAdjustSeatCounts={reportPlanScope === 'organization' && !isIndividualReport ? () => setActiveView('users') : undefined}
774+
reportMode={reportMode}
775+
showOrganizationPromotionalDataDisclaimer={showOrganizationPromotionalDataDisclaimer}
743776
/>
744777
) : visibleActiveView === 'models' ? (
745778
modelUsage && modelUsage.models.length > 0 ? (
@@ -749,6 +782,8 @@ function App() {
749782
isIndividualReport={isIndividualReport}
750783
rangeStart={rangeStart}
751784
rangeEnd={rangeEnd}
785+
reportMode={reportMode}
786+
showOrganizationPromotionalDataDisclaimer={showOrganizationPromotionalDataDisclaimer}
752787
/>
753788
</div>
754789
) : null
@@ -764,6 +799,8 @@ function App() {
764799
setSelectedUsername(username)
765800
setActiveView('userDetails')
766801
}}
802+
reportMode={reportMode}
803+
includedCreditsPolicy={includedCreditsPolicy}
767804
/>
768805
</div>
769806
) : visibleActiveView === 'userDetails' || (visibleActiveView === 'users' && isIndividualReport) ? (
@@ -774,16 +811,24 @@ function App() {
774811
showUsersBreadcrumb={!isIndividualReport}
775812
rangeStart={rangeStart}
776813
rangeEnd={rangeEnd}
814+
reportMode={reportMode}
815+
includedCreditsPolicy={includedCreditsPolicy}
816+
showOrganizationPromotionalDataDisclaimer={showOrganizationPromotionalDataDisclaimer}
777817
onBackToUsers={isIndividualReport ? undefined : () => setActiveView('users')}
778818
/>
779819
</div>
780820
) : visibleActiveView === 'costCenters' ? (
781821
<div className={viewContentClasses}>
782-
<CostCentersView data={costCenters ?? { costCenters: [] }} rangeStart={rangeStart} />
822+
<CostCentersView
823+
data={costCenters ?? { costCenters: [] }}
824+
rangeStart={rangeStart}
825+
reportMode={reportMode}
826+
showOrganizationPromotionalDataDisclaimer={showOrganizationPromotionalDataDisclaimer}
827+
/>
783828
</div>
784829
) : visibleActiveView === 'products' ? (
785830
<div className={viewContentClasses}>
786-
<ProductsView data={productUsage ?? { products: [] }} />
831+
<ProductsView data={productUsage ?? { products: [] }} reportMode={reportMode} />
787832
</div>
788833
) : visibleActiveView === 'spendInsights' ? (
789834
<div className={viewContentClasses}>
@@ -818,19 +863,26 @@ function App() {
818863
isApplyingBudgetSimulation={isApplyingBudgetSimulation}
819864
onBudgetValueChange={handleBudgetValueChange}
820865
onApplyBudgetSimulation={handleApplyBudgetSimulation}
866+
reportMode={reportMode}
867+
showOrganizationPromotionalDataDisclaimer={showOrganizationPromotionalDataDisclaimer}
821868
/>
822869
</div>
823-
) : visibleActiveView === 'guide' ? (
870+
) : !isNativeAiCreditsReport && visibleActiveView === 'guide' ? (
824871
<div className={viewContentClasses}>
825872
<ReportGuideView />
826873
</div>
827-
) : visibleActiveView === 'faq' ? (
874+
) : !isNativeAiCreditsReport && visibleActiveView === 'faq' ? (
828875
<div className={viewContentClasses}>
829876
<FaqView />
830877
</div>
831878
) : (
832879
<div className={viewContentClasses}>
833-
<OrganizationsView data={orgs ?? { organizations: [] }} rangeStart={rangeStart} />
880+
<OrganizationsView
881+
data={orgs ?? { organizations: [] }}
882+
rangeStart={rangeStart}
883+
reportMode={reportMode}
884+
showOrganizationPromotionalDataDisclaimer={showOrganizationPromotionalDataDisclaimer}
885+
/>
834886
</div>
835887
)}
836888
</main>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { createElement } from 'react'
2+
import { renderToStaticMarkup } from 'react-dom/server'
3+
import { describe, expect, it } from 'vitest'
4+
5+
import { ProductUsageTable, type ProductUsageTableProduct } from './ProductUsageTable'
6+
7+
const PRODUCTS: ProductUsageTableProduct[] = [
8+
{
9+
product: 'Copilot',
10+
totals: {
11+
requests: 10,
12+
aicQuantity: 25,
13+
netAmount: 0.4,
14+
aicNetAmount: 0.25,
15+
},
16+
models: {
17+
'GPT-5.2': {
18+
requests: 10,
19+
aicQuantity: 25,
20+
netAmount: 0.4,
21+
aicNetAmount: 0.25,
22+
},
23+
},
24+
},
25+
]
26+
27+
describe('ProductUsageTable', () => {
28+
it('keeps transition-period PRU comparison columns by default', () => {
29+
const html = renderToStaticMarkup(createElement(ProductUsageTable, { products: PRODUCTS }))
30+
31+
expect(html).toContain('PRUs')
32+
expect(html).toContain('PRU net cost')
33+
expect(html).toContain('Difference')
34+
expect(html).toContain('AIC net cost')
35+
})
36+
37+
it('renders native AI Credits columns without PRU comparison labels', () => {
38+
const html = renderToStaticMarkup(createElement(ProductUsageTable, {
39+
products: PRODUCTS,
40+
reportMode: 'native-ai-credits',
41+
}))
42+
43+
expect(html).not.toContain('PRUs')
44+
expect(html).not.toContain('PRU net cost')
45+
expect(html).not.toContain('Difference')
46+
expect(html).toContain('AICs')
47+
expect(html).toContain('AIC net cost')
48+
})
49+
})

0 commit comments

Comments
 (0)