Skip to content

Commit 4f880e9

Browse files
asizikovCopilot
andcommitted
test: add billing aggregation coverage
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9c55687 commit 4f880e9

10 files changed

Lines changed: 503 additions & 12 deletions
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { describe, expect, it } from 'vitest'
2+
import type { TokenUsageRecord } from '../parser'
3+
import { calculateAicDiscountAmount, calculateSavingsDifference } from '../../views/billingComparison'
4+
import { CostCenterAggregator } from './costCenterAggregator'
5+
import { DailyUsageAggregator } from './dailyUsageAggregator'
6+
import { ModelUsageAggregator } from './modelUsageAggregator'
7+
import { OrganizationAggregator } from './organizationAggregator'
8+
import { UserUsageAggregator } from './userUsageAggregator'
9+
10+
function createRecord(overrides: Partial<TokenUsageRecord> = {}): TokenUsageRecord {
11+
return {
12+
date: '2026-03-01',
13+
username: 'mona',
14+
product: 'copilot',
15+
sku: 'copilot_ai_unit',
16+
model: 'GPT-5',
17+
quantity: 100,
18+
unit_type: 'ai-units',
19+
applied_cost_per_quantity: 0.01,
20+
gross_amount: 1,
21+
discount_amount: 0,
22+
net_amount: 1,
23+
exceeds_quota: false,
24+
total_monthly_quota: 300,
25+
organization: 'octo',
26+
cost_center_name: 'Cats',
27+
aic_quantity: 100,
28+
aic_gross_amount: 1,
29+
aic_net_amount: 0.4,
30+
has_aic_quantity: true,
31+
has_aic_gross_amount: true,
32+
...overrides,
33+
}
34+
}
35+
36+
function aggregate(records: TokenUsageRecord[]) {
37+
const daily = new DailyUsageAggregator()
38+
const users = new UserUsageAggregator()
39+
const organizations = new OrganizationAggregator()
40+
const costCenters = new CostCenterAggregator()
41+
const models = new ModelUsageAggregator()
42+
const aggregators = [daily, users, organizations, costCenters, models]
43+
44+
records.forEach((record) => {
45+
aggregators.forEach((aggregator) => aggregator.accumulate(record))
46+
})
47+
48+
return {
49+
daily: daily.result(),
50+
users: users.result(),
51+
organizations: organizations.result(),
52+
costCenters: costCenters.result(),
53+
models: models.result(),
54+
}
55+
}
56+
57+
function sum(values: number[]): number {
58+
return values.reduce((total, value) => total + value, 0)
59+
}
60+
61+
describe('derived AIC discount aggregation', () => {
62+
it('derives organization AIC discount as gross minus net', () => {
63+
const result = aggregate([createRecord({ aic_gross_amount: 1.5, aic_net_amount: 0.6 })])
64+
65+
expect(result.organizations.organizations).toEqual([
66+
expect.objectContaining({
67+
organization: 'octo',
68+
aicGrossAmount: 1.5,
69+
aicNetAmount: 0.6,
70+
aicDiscountAmount: 0.9,
71+
}),
72+
])
73+
})
74+
75+
it('derives cost-center AIC discount from totals gross minus net', () => {
76+
const result = aggregate([createRecord({ aic_gross_amount: 2, aic_net_amount: 0.5 })])
77+
const costCenter = result.costCenters.costCenters[0]
78+
79+
expect(calculateAicDiscountAmount(costCenter.totals.aicGrossAmount, costCenter.totals.aicNetAmount)).toBeCloseTo(1.5)
80+
})
81+
82+
it('keeps AIC gross and net totals consistent across organization, cost center, user, daily, and model aggregators', () => {
83+
const result = aggregate([
84+
createRecord({ aic_gross_amount: 1, aic_net_amount: 0 }),
85+
createRecord({ date: '2026-03-02', username: 'hubot', model: 'GPT-4.1', cost_center_name: 'Dogs', aic_gross_amount: 2, aic_net_amount: 2 }),
86+
createRecord({ date: '2026-03-02', username: 'mona', model: 'Code Review model', aic_gross_amount: 3, aic_net_amount: 1.2 }),
87+
])
88+
89+
const dailyGross = sum(result.daily.dailyData.map((day) => day.aicGrossAmount))
90+
const dailyNet = sum(result.daily.dailyData.map((day) => day.aicNetAmount))
91+
const userGross = sum(result.users.users.map((user) => user.totals.aicGrossAmount))
92+
const userNet = sum(result.users.users.map((user) => user.totals.aicNetAmount))
93+
const organizationGross = sum(result.organizations.organizations.map((organization) => organization.aicGrossAmount))
94+
const organizationNet = sum(result.organizations.organizations.map((organization) => organization.aicNetAmount))
95+
const costCenterGross = sum(result.costCenters.costCenters.map((costCenter) => costCenter.totals.aicGrossAmount))
96+
const costCenterNet = sum(result.costCenters.costCenters.map((costCenter) => costCenter.totals.aicNetAmount))
97+
const modelGross = sum(Object.values(result.models.totalsByModel).map((model) => model.aicGrossAmount))
98+
const modelNet = sum(Object.values(result.models.totalsByModel).map((model) => model.aicNetAmount))
99+
100+
expect(dailyGross).toBeCloseTo(6)
101+
expect(dailyNet).toBeCloseTo(3.2)
102+
expect(userGross).toBeCloseTo(dailyGross)
103+
expect(userNet).toBeCloseTo(dailyNet)
104+
expect(organizationGross).toBeCloseTo(dailyGross)
105+
expect(organizationNet).toBeCloseTo(dailyNet)
106+
expect(costCenterGross).toBeCloseTo(dailyGross)
107+
expect(costCenterNet).toBeCloseTo(dailyNet)
108+
expect(modelGross).toBeCloseTo(dailyGross)
109+
expect(modelNet).toBeCloseTo(dailyNet)
110+
})
111+
112+
it('derives displayed savings from PRU net minus AIC net totals', () => {
113+
const result = aggregate([
114+
createRecord({
115+
unit_type: 'requests',
116+
sku: 'copilot_premium_request',
117+
quantity: 10,
118+
gross_amount: 4,
119+
discount_amount: 1,
120+
net_amount: 3,
121+
aic_quantity: 100,
122+
aic_gross_amount: 1,
123+
aic_net_amount: 0.25,
124+
}),
125+
])
126+
const organization = result.organizations.organizations[0]
127+
128+
expect(calculateSavingsDifference(organization.netAmount, organization.aicNetAmount)).toBeCloseTo(2.75)
129+
})
130+
})
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { CostCenterAggregator } from './costCenterAggregator'
3+
import { DailyUsageAggregator } from './dailyUsageAggregator'
4+
import { ModelUsageAggregator } from './modelUsageAggregator'
5+
import { OrganizationAggregator } from './organizationAggregator'
6+
import { ProductUsageAggregator } from './productUsageAggregator'
7+
import { UserUsageAggregator } from './userUsageAggregator'
8+
import type { TokenUsageRecord } from '../parser'
9+
10+
function createRecord(overrides: Partial<TokenUsageRecord> = {}): TokenUsageRecord {
11+
return {
12+
date: '2026-03-01',
13+
username: 'mona',
14+
product: 'copilot',
15+
sku: 'copilot_premium_request',
16+
model: 'GPT-5',
17+
quantity: 10,
18+
unit_type: 'requests',
19+
applied_cost_per_quantity: 0.1,
20+
gross_amount: 1,
21+
discount_amount: 0.2,
22+
net_amount: 0.8,
23+
exceeds_quota: false,
24+
total_monthly_quota: 300,
25+
organization: 'octo',
26+
cost_center_name: 'Cats',
27+
aic_quantity: 0,
28+
aic_gross_amount: 0,
29+
aic_net_amount: 0,
30+
has_aic_quantity: true,
31+
has_aic_gross_amount: true,
32+
...overrides,
33+
}
34+
}
35+
36+
function createFixture(): TokenUsageRecord[] {
37+
return [
38+
createRecord(),
39+
createRecord({
40+
model: 'Code Review model',
41+
quantity: 5,
42+
gross_amount: 2,
43+
discount_amount: 0.5,
44+
net_amount: 1.5,
45+
}),
46+
createRecord({
47+
username: 'hubot',
48+
model: 'Coding Agent model',
49+
quantity: 8,
50+
gross_amount: 4,
51+
discount_amount: 1,
52+
net_amount: 3,
53+
total_monthly_quota: 1000,
54+
cost_center_name: 'Dogs',
55+
}),
56+
createRecord({
57+
date: '2026-03-02',
58+
username: 'hubot',
59+
model: 'GPT-4.1',
60+
quantity: 2,
61+
gross_amount: 1,
62+
discount_amount: 0.25,
63+
net_amount: 0.75,
64+
total_monthly_quota: 1000,
65+
organization: '',
66+
cost_center_name: null,
67+
}),
68+
createRecord({
69+
date: '2026-03-02',
70+
unit_type: 'ai-units',
71+
sku: 'copilot_ai_unit',
72+
quantity: 100,
73+
applied_cost_per_quantity: 0.01,
74+
gross_amount: 1,
75+
discount_amount: 0,
76+
net_amount: 1,
77+
aic_quantity: 100,
78+
aic_gross_amount: 1,
79+
aic_net_amount: 0.6,
80+
}),
81+
]
82+
}
83+
84+
function createAiOnlyFixture(): TokenUsageRecord[] {
85+
return [
86+
createRecord({
87+
date: '2026-03-03',
88+
unit_type: 'ai-units',
89+
sku: 'copilot_ai_unit',
90+
quantity: 50,
91+
applied_cost_per_quantity: 0.01,
92+
gross_amount: 0.5,
93+
discount_amount: 0,
94+
net_amount: 0.5,
95+
aic_quantity: 50,
96+
aic_gross_amount: 0.5,
97+
aic_net_amount: 0.2,
98+
}),
99+
]
100+
}
101+
102+
function sumNumbers(values: number[]): number {
103+
return values.reduce((total, value) => total + value, 0)
104+
}
105+
106+
function expectNetEquation(totals: { grossAmount: number; discountAmount: number; netAmount: number }): void {
107+
expect(totals.grossAmount - totals.discountAmount).toBeCloseTo(totals.netAmount)
108+
}
109+
110+
function aggregate(records: TokenUsageRecord[]) {
111+
const daily = new DailyUsageAggregator()
112+
const users = new UserUsageAggregator()
113+
const organizations = new OrganizationAggregator()
114+
const costCenters = new CostCenterAggregator()
115+
const models = new ModelUsageAggregator()
116+
const products = new ProductUsageAggregator()
117+
const aggregators = [daily, users, organizations, costCenters, models, products]
118+
119+
records.forEach((record) => {
120+
aggregators.forEach((aggregator) => aggregator.accumulate(record))
121+
})
122+
123+
return {
124+
daily: daily.result(),
125+
users: users.result(),
126+
organizations: organizations.result(),
127+
costCenters: costCenters.result(),
128+
models: models.result(),
129+
products: products.result(),
130+
}
131+
}
132+
133+
describe('PRU gross/net/discount aggregation', () => {
134+
it('keeps aggregated request gross minus discount equal to net across PRU aggregators', () => {
135+
const result = aggregate(createFixture())
136+
137+
result.daily.dailyData.forEach(expectNetEquation)
138+
result.users.users.forEach((user) => {
139+
expectNetEquation(user.totals)
140+
Object.values(user.daily).forEach((day) => {
141+
expectNetEquation(day)
142+
Object.values(day.models).forEach(expectNetEquation)
143+
})
144+
})
145+
result.organizations.organizations.forEach(expectNetEquation)
146+
result.costCenters.costCenters.forEach((costCenter) => {
147+
expectNetEquation(costCenter.totals)
148+
Object.values(costCenter.totalsByModel).forEach(expectNetEquation)
149+
})
150+
Object.values(result.models.totalsByModel).forEach(expectNetEquation)
151+
Object.values(result.models.byModel).flat().forEach(expectNetEquation)
152+
})
153+
154+
it('keeps PRU totals at zero for AI-unit rows in every aggregator', () => {
155+
const result = aggregate(createAiOnlyFixture())
156+
157+
expect(result.daily.dailyData).toEqual([
158+
expect.objectContaining({ requests: 0, grossAmount: 0, discountAmount: 0, netAmount: 0 }),
159+
])
160+
expect(result.users.users[0].totals).toEqual(expect.objectContaining({ requests: 0, grossAmount: 0, discountAmount: 0, netAmount: 0 }))
161+
expect(result.organizations.organizations[0]).toEqual(expect.objectContaining({ requests: 0, grossAmount: 0, discountAmount: 0, netAmount: 0 }))
162+
expect(result.costCenters.costCenters[0].totals).toEqual(expect.objectContaining({ requests: 0, grossAmount: 0, discountAmount: 0, netAmount: 0 }))
163+
expect(result.models.totalsByModel['GPT-5']).toEqual(expect.objectContaining({ requests: 0, grossAmount: 0, discountAmount: 0, netAmount: 0 }))
164+
expect(result.products.products[0].totals).toEqual(expect.objectContaining({ requests: 0, grossAmount: 0, netAmount: 0 }))
165+
})
166+
167+
it('keeps daily, user, and organization request totals aligned for the same fixture', () => {
168+
const result = aggregate(createFixture())
169+
170+
const dailyGross = sumNumbers(result.daily.dailyData.map((day) => day.grossAmount))
171+
const dailyNet = sumNumbers(result.daily.dailyData.map((day) => day.netAmount))
172+
const dailyDiscount = sumNumbers(result.daily.dailyData.map((day) => day.discountAmount))
173+
const userGross = sumNumbers(result.users.users.map((user) => user.totals.grossAmount))
174+
const userNet = sumNumbers(result.users.users.map((user) => user.totals.netAmount))
175+
const userDiscount = sumNumbers(result.users.users.map((user) => user.totals.discountAmount))
176+
177+
expect({ dailyGross, dailyNet, dailyDiscount }).toEqual({
178+
dailyGross: userGross,
179+
dailyNet: userNet,
180+
dailyDiscount: userDiscount,
181+
})
182+
183+
expect(result.organizations.organizations).toEqual([
184+
expect.objectContaining({
185+
organization: 'octo',
186+
requests: 23,
187+
grossAmount: 7,
188+
discountAmount: 1.7,
189+
netAmount: 5.3,
190+
}),
191+
])
192+
})
193+
194+
it('keeps cost-center totals aligned with per-model and per-user rollups', () => {
195+
const result = aggregate(createFixture())
196+
const cats = result.costCenters.costCenters.find((costCenter) => costCenter.costCenterName === 'Cats')
197+
198+
expect(cats).toBeDefined()
199+
expect(cats?.totals).toEqual(expect.objectContaining({ requests: 15, grossAmount: 3, discountAmount: 0.7, netAmount: 2.3 }))
200+
expect(sumNumbers(Object.values(cats?.totalsByModel ?? {}).map((totals) => totals.grossAmount))).toBeCloseTo(cats!.totals.grossAmount)
201+
expect(sumNumbers(Object.values(cats?.totalsByModel ?? {}).map((totals) => totals.netAmount))).toBeCloseTo(cats!.totals.netAmount)
202+
expect(sumNumbers(Object.values(cats?.totalsByUser ?? {}).map((totals) => totals.grossAmount))).toBeCloseTo(cats!.totals.grossAmount)
203+
expect(sumNumbers(Object.values(cats?.totalsByUser ?? {}).map((totals) => totals.netAmount))).toBeCloseTo(cats!.totals.netAmount)
204+
})
205+
206+
it('keeps user totals aligned with daily totals and model totals aligned with per-day values', () => {
207+
const result = aggregate(createFixture())
208+
const mona = result.users.users.find((user) => user.username === 'mona')
209+
210+
expect(mona).toBeDefined()
211+
expect(sumNumbers(Object.values(mona?.daily ?? {}).map((day) => day.grossAmount))).toBeCloseTo(mona!.totals.grossAmount)
212+
expect(sumNumbers(Object.values(mona?.daily ?? {}).map((day) => day.netAmount))).toBeCloseTo(mona!.totals.netAmount)
213+
expect(sumNumbers(result.models.byModel['GPT-5'].map((day) => day.grossAmount))).toBeCloseTo(result.models.totalsByModel['GPT-5'].grossAmount)
214+
expect(sumNumbers(result.models.byModel['GPT-5'].map((day) => day.netAmount))).toBeCloseTo(result.models.totalsByModel['GPT-5'].netAmount)
215+
})
216+
217+
it('keeps product totals aligned with per-model breakdowns and uses friendly labels', () => {
218+
const result = aggregate(createFixture())
219+
220+
expect(result.products.products.map((product) => product.product)).toEqual([
221+
'Copilot Cloud Agent',
222+
'Copilot',
223+
'Code Review',
224+
])
225+
226+
result.products.products.forEach((product) => {
227+
expect(sumNumbers(Object.values(product.models).map((model) => model.grossAmount))).toBeCloseTo(product.totals.grossAmount)
228+
expect(sumNumbers(Object.values(product.models).map((model) => model.netAmount))).toBeCloseTo(product.totals.netAmount)
229+
})
230+
231+
const copilot = result.products.products.find((product) => product.product === 'Copilot')
232+
expect(Object.keys(copilot?.models ?? {})).toEqual(['GPT-5', 'GPT-4.1'])
233+
})
234+
235+
it('sorts results by current implementation rules and only skips blank organization or cost center where required', () => {
236+
const result = aggregate(createFixture())
237+
238+
expect(result.daily.dailyData.map((day) => day.date)).toEqual(['2026-03-01', '2026-03-02'])
239+
expect(result.users.users.map((user) => user.username)).toEqual(['hubot', 'mona'])
240+
expect(result.organizations.organizations.map((organization) => organization.organization)).toEqual(['octo'])
241+
expect(result.costCenters.costCenters.map((costCenter) => costCenter.costCenterName)).toEqual(['Cats', 'Dogs'])
242+
expect(result.models.models).toEqual(['Code Review model', 'Coding Agent model', 'GPT-4.1', 'GPT-5'])
243+
244+
const hubot = result.users.users.find((user) => user.username === 'hubot')
245+
expect(hubot?.totals).toEqual(expect.objectContaining({ requests: 10, grossAmount: 5, discountAmount: 1.25, netAmount: 3.75 }))
246+
expect(hubot?.organizations).toEqual(['octo'])
247+
expect(hubot?.costCenters).toEqual(['Dogs'])
248+
})
249+
})

0 commit comments

Comments
 (0)