Skip to content

Commit 0e93440

Browse files
asizikovCopilot
andcommitted
fix: resolve policy from report period
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9cbca5d commit 0e93440

4 files changed

Lines changed: 115 additions & 13 deletions

File tree

src/pipeline/aicIncludedCredits.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
22
import {
33
BUSINESS_MONTHLY_AIC_INCLUDED_CREDITS,
44
BUSINESS_MONTHLY_QUOTA,
5+
calculateAicIncludedCreditsContext,
56
calculateAicIncludedCreditsPool,
67
calculateLicenseSummary,
78
ENTERPRISE_MONTHLY_AIC_INCLUDED_CREDITS,
@@ -51,13 +52,41 @@ const HEADER = [
5152
'aic_quantity',
5253
'aic_gross_amount',
5354
].join(',')
55+
const NATIVE_AI_CREDITS_HEADER = [
56+
'date',
57+
'username',
58+
'product',
59+
'sku',
60+
'model',
61+
'quantity',
62+
'unit_type',
63+
'applied_cost_per_quantity',
64+
'gross_amount',
65+
'discount_amount',
66+
'net_amount',
67+
'total_monthly_quota',
68+
'organization',
69+
'cost_center_name',
70+
'aic_quantity',
71+
'aic_gross_amount',
72+
].join(',')
73+
const NATIVE_AI_CREDITS_REPORT_METADATA = {
74+
format: 'native-ai-credits',
75+
label: 'Native AI Credits report',
76+
supported: false,
77+
} as const
5478
const UNKNOWN_HIGH_MONTHLY_QUOTA = 2147483647
5579

5680
function createCsv(rows: string[][]): File {
5781
const body = [HEADER, ...rows.map((row) => row.join(','))].join('\n')
5882
return new File([body], 'usage.csv', { type: 'text/csv' })
5983
}
6084

85+
function createNativeCsv(rows: string[][]): File {
86+
const body = [NATIVE_AI_CREDITS_HEADER, ...rows.map((row) => row.join(','))].join('\n')
87+
return new File([body], 'usage.csv', { type: 'text/csv' })
88+
}
89+
6190
function createRecord(overrides: Partial<TokenUsageRecord> = {}): TokenUsageRecord {
6291
return {
6392
date: '2026-03-01',
@@ -325,6 +354,26 @@ describe('AIC included credit tiering and pool sizing', () => {
325354
)
326355
})
327356

357+
it('derives native report periods before selecting native included credit policies', async () => {
358+
const summerFile = createNativeCsv([
359+
['2026-08-31', 'mona', 'copilot', 'copilot_ai_credit', 'GPT-5', '10', 'ai-credits', '0.01', '0.10', '0', '0.10', '3900', 'example-org', 'Cost Center A', '10', '0.10'],
360+
])
361+
const standardFile = createNativeCsv([
362+
['2026-09-01', 'mona', 'copilot', 'copilot_ai_credit', 'GPT-5', '10', 'ai-credits', '0.01', '0.10', '0', '0.10', '3900', 'example-org', 'Cost Center A', '10', '0.10'],
363+
])
364+
365+
await expect(calculateAicIncludedCreditsContext(summerFile, {}, {
366+
reportMetadata: NATIVE_AI_CREDITS_REPORT_METADATA,
367+
})).resolves.toMatchObject({
368+
organizationIncludedCreditsPool: 7000,
369+
})
370+
await expect(calculateAicIncludedCreditsContext(standardFile, {}, {
371+
reportMetadata: NATIVE_AI_CREDITS_REPORT_METADATA,
372+
})).resolves.toMatchObject({
373+
organizationIncludedCreditsPool: 3900,
374+
})
375+
})
376+
328377
it('uses the maximum quota seen for the same user before applying individual-plan classification', async () => {
329378
const file = createCsv([
330379
['2026-03-01', 'mona', 'copilot', 'copilot_ai_credit', 'GPT-5', '10', 'ai-credits', '0.01', '0.10', '0', '0.10', 'False', '300', 'octo', 'Cats', '10', '0.10'],

src/pipeline/aicIncludedCredits.ts

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,15 @@ import {
77
} from './parser'
88
import {
99
TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY,
10+
resolveIncludedCreditsPolicy,
1011
type IncludedCreditsPolicy,
1112
type OrganizationIncludedCreditTier,
1213
type PlanIdentity,
14+
type ReportPeriod,
1315
} from './includedCreditsPolicy'
16+
import { isValidIsoDate } from './isoDate'
1417
import { streamLines, type StreamProgress } from './streamer'
18+
import type { ReportFormatMetadata } from './reportAdapters'
1519

1620
type IndividualPlan = 'pro-student' | 'pro-plus'
1721

@@ -87,6 +91,7 @@ export type LicenseSummary = {
8791
export interface AicIncludedCreditsProgressOptions {
8892
onProgress?: (progress: StreamProgress) => void
8993
includedCreditsPolicy?: IncludedCreditsPolicy
94+
reportMetadata?: ReportFormatMetadata
9095
}
9196

9297
type ReportScopeUser = {
@@ -224,6 +229,31 @@ export function getIndividualMonthlyAicIncludedCredits(
224229
return findIndividualIncludedCreditsPlan(totalMonthlyQuota, reportPlanScope)?.monthlyIncludedCredits ?? 0
225230
}
226231

232+
function includeDateInReportPeriod(reportPeriod: ReportPeriod, rawDate: string): ReportPeriod {
233+
const date = rawDate.trim()
234+
if (!isValidIsoDate(date)) return reportPeriod
235+
236+
return {
237+
startDate: !reportPeriod.startDate || date < reportPeriod.startDate ? date : reportPeriod.startDate,
238+
endDate: !reportPeriod.endDate || date > reportPeriod.endDate ? date : reportPeriod.endDate,
239+
}
240+
}
241+
242+
function resolvePolicyForContext(
243+
options: AicIncludedCreditsProgressOptions | undefined,
244+
reportPeriod: ReportPeriod,
245+
): IncludedCreditsPolicy {
246+
if (options?.includedCreditsPolicy) {
247+
return options.includedCreditsPolicy
248+
}
249+
250+
if (options?.reportMetadata) {
251+
return resolveIncludedCreditsPolicy(options.reportMetadata, reportPeriod)
252+
}
253+
254+
return TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY
255+
}
256+
227257
export function calculateLicenseSummary(
228258
users: Array<{ totalMonthlyQuota: number } & ReportScopeUser>,
229259
policy: IncludedCreditsPolicy = TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY,
@@ -274,10 +304,10 @@ export async function calculateAicIncludedCreditsContext(
274304
overrides: AicIncludedCreditsOverrides = {},
275305
options?: AicIncludedCreditsProgressOptions,
276306
): Promise<AicIncludedCreditsContext> {
277-
const includedCreditsPolicy = options?.includedCreditsPolicy ?? TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY
278307
let header: TokenUsageHeader | null = null
279-
const quotasByUser = new Map<string, number>()
308+
const quotaCandidatesByUser = new Map<string, Set<number>>()
280309
let hasOrganizationContext = false
310+
let reportPeriod: ReportPeriod = {}
281311

282312
for await (const line of streamLines(file, options)) {
283313
const trimmed = line.trimEnd()
@@ -291,22 +321,30 @@ export async function calculateAicIncludedCreditsContext(
291321
const record = parseNormalizedTokenUsageRecord(trimmed, header)
292322
if (!record) continue
293323

324+
reportPeriod = includeDateInReportPeriod(reportPeriod, record.date)
325+
294326
const username = record.username.trim()
295327
if (!username) continue
296328

297329
if (record.organization.trim() || (record.cost_center_name?.trim() ?? '')) {
298330
hasOrganizationContext = true
299331
}
300332

301-
const currentQuota = quotasByUser.get(username) ?? 0
302-
quotasByUser.set(username, selectKnownMonthlyQuota(
303-
currentQuota,
304-
record.total_monthly_quota,
305-
includedCreditsPolicy,
306-
))
333+
const quotaCandidates = quotaCandidatesByUser.get(username) ?? new Set<number>()
334+
quotaCandidates.add(record.total_monthly_quota)
335+
quotaCandidatesByUser.set(username, quotaCandidates)
307336
}
308337

309-
const reportPlanScope = inferReportPlanScope(quotasByUser.size, hasOrganizationContext)
338+
const includedCreditsPolicy = resolvePolicyForContext(options, reportPeriod)
339+
const quotasByUser = new Map<string, number>()
340+
quotaCandidatesByUser.forEach((quotaCandidates, username) => {
341+
quotasByUser.set(username, Array.from(quotaCandidates).reduce(
342+
(currentQuota, candidateQuota) => selectKnownMonthlyQuota(currentQuota, candidateQuota, includedCreditsPolicy),
343+
0,
344+
))
345+
})
346+
347+
const reportPlanScope = inferReportPlanScope(quotaCandidatesByUser.size, hasOrganizationContext)
310348
if (reportPlanScope === 'individual') {
311349
const quota = quotasByUser.values().next().value ?? 0
312350
return {

src/pipeline/runPipeline.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ describe('runPipeline', () => {
132132
expect(aggregator.result()).toEqual([])
133133
})
134134

135-
it('rejects native AI Credits reports before processing rows', async () => {
135+
it('rejects native AI Credits reports before aggregator calls', async () => {
136136
const file = createCsv([
137137
[
138138
'5/29/26',
@@ -186,6 +186,23 @@ describe('runPipeline', () => {
186186
})
187187
})
188188

189+
it('keeps transition-period allocation for supported reports after the native policy boundary', async () => {
190+
const file = createCsv([
191+
['2026-09-01', 'mona', 'copilot', 'copilot_ai_credit', 'GPT-5', '3000', 'ai-credits', '0.01', '30.00', '0', '30.00', 'False', '300', 'example-org', 'Cost Center A', '3000', '30.00'],
192+
])
193+
const aggregator = new CaptureAggregator()
194+
195+
await runPipeline(file, [aggregator])
196+
197+
expect(aggregator.result()).toEqual([
198+
expect.objectContaining({
199+
username: 'mona',
200+
total_monthly_quota: 300,
201+
aic_net_amount: 0,
202+
}),
203+
])
204+
})
205+
189206
it('emits weighted progress for analysis and processing stages', async () => {
190207
const file = createCsv([
191208
['2026-03-01', 'mona', 'copilot', 'copilot_ai_credit', 'GPT-5', '10', 'ai-credits', '0.01', '0.10', '0', '0.10', 'False', '300', 'octo', 'Cost Center A', '10', '0.10'],

src/pipeline/runPipeline.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { Aggregator } from './aggregators/base'
22
import { createAicIncludedCreditsAllocator, type AicIncludedCreditsOverrides } from './aicIncludedCredits'
3-
import { resolveIncludedCreditsPolicy } from './includedCreditsPolicy'
43
import {
54
InvalidReportError,
65
parseTokenUsageHeader,
@@ -93,7 +92,6 @@ export async function runPipeline(
9392
const { includedCreditsOverrides = {}, progressResolution = 500, onProgress } = options ?? {}
9493
const reportAdapter = await validateFileFormat(file)
9594
const reportMetadata = reportAdapter.metadata
96-
const includedCreditsPolicy = resolveIncludedCreditsPolicy(reportMetadata)
9795
let lastProgressStage: PipelineProgress['stage'] | null = null
9896
let lastProgressPercent = -1
9997
let lastProgressTimestamp = 0
@@ -140,7 +138,7 @@ export async function runPipeline(
140138
}
141139

142140
const aicIncludedCreditAllocator = await createAicIncludedCreditsAllocator(file, includedCreditsOverrides, {
143-
includedCreditsPolicy,
141+
reportMetadata,
144142
onProgress: (streamProgress) => {
145143
emitProgress('analyzing', 0, streamProgress)
146144
},

0 commit comments

Comments
 (0)