|
| 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