diff --git a/src/App.tsx b/src/App.tsx index 1a675cc..0bee634 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -125,7 +125,7 @@ function App() { let orgAggregator!: OrganizationAggregator let userAggregator!: UserUsageAggregator - const pipelineResult = await runPipeline(file, (reportMetadata) => { + const pipelineResult = await runPipeline(file, (reportMetadata, includedCreditsPolicy) => { statsAggregator = new QuickStatsAggregator() contextAggregator = new ReportContextAggregator() dailyAggregator = new DailyUsageAggregator(reportMetadata) @@ -133,7 +133,7 @@ function App() { productAggregator = new ProductUsageAggregator(reportMetadata) costCenterAggregator = new CostCenterAggregator(reportMetadata) orgAggregator = new OrganizationAggregator(reportMetadata) - userAggregator = new UserUsageAggregator(reportMetadata) + userAggregator = new UserUsageAggregator(reportMetadata, includedCreditsPolicy) return [ statsAggregator, @@ -522,7 +522,7 @@ function App() { const licenseAmount = reportPlanScope === 'organization' ? organizationLicenseAmount || undefined : individualUser - ? getIndividualLicenseMonthlyCost(individualUser.totalMonthlyQuota) + ? getIndividualLicenseMonthlyCost(individualUser.totalMonthlyQuota, includedCreditsPolicy) : undefined const licenseSeatCounts = reportPlanScope === 'organization' ? { business: effectiveBusinessSeats, enterprise: effectiveEnterpriseSeats } @@ -577,6 +577,7 @@ function App() { ? calculateIndividualPlanUpgradeRecommendation({ totalMonthlyQuota: individualUser.totalMonthlyQuota, currentMonthlyAicAdditionalUsageBillsUsd: monthlyAicAdditionalUsageBills, + includedCreditsPolicy, }) : null diff --git a/src/pipeline/aggregators/userUsageAggregator.ts b/src/pipeline/aggregators/userUsageAggregator.ts index 3928e82..de671aa 100644 --- a/src/pipeline/aggregators/userUsageAggregator.ts +++ b/src/pipeline/aggregators/userUsageAggregator.ts @@ -159,11 +159,14 @@ export class UserUsageAggregator implements Aggregator { expect(TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY.organizationPlans.enterprise.monthlyIncludedCredits).toBe( ENTERPRISE_MONTHLY_AIC_INCLUDED_CREDITS, ) + expect(TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY.individualPlans['pro-student']?.identity).toEqual({ + tier: 'pro-student', + quotaUnit: 'pru', + monthlyQuota: PRO_MONTHLY_QUOTA, + }) + expect(TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY.individualPlans['pro-student']?.monthlyIncludedCredits).toBe( + PRO_MONTHLY_AIC_INCLUDED_CREDITS, + ) + expect(TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY.individualPlans['pro-plus']?.identity).toEqual({ + tier: 'pro-plus', + quotaUnit: 'pru', + monthlyQuota: PRO_PLUS_MONTHLY_QUOTA, + }) + expect(TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY.individualPlans['pro-plus']?.monthlyIncludedCredits).toBe( + PRO_PLUS_MONTHLY_AIC_INCLUDED_CREDITS, + ) + expect('max' in TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY.individualPlans).toBe(false) }) it('keeps native AI Credits policies available without changing default transition-period tiering', () => { @@ -222,6 +240,72 @@ describe('AIC included credit tiering and pool sizing', () => { ) }) + it('classifies post-preview individual quota identities by individual scope', () => { + expect(getIndividualPlanTier(1500, 'individual', NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY)).toBe( + 'pro-student', + ) + expect(getIndividualPlanTier(7000, 'individual', NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY)).toBe( + 'pro-plus', + ) + expect(getIndividualPlanTier(20000, 'individual', NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY)).toBe( + 'max', + ) + expect(getPlanLabel(1500, 'individual', NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY)).toBe( + 'Copilot Pro', + ) + expect(getPlanLabel(7000, 'individual', NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY)).toBe( + 'Copilot Pro+', + ) + expect(getPlanLabel(20000, 'individual', NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY)).toBe( + 'Copilot Max', + ) + expect(getIndividualMonthlyAicIncludedCredits( + 1500, + 'individual', + NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY, + )).toBe(1500) + expect(getIndividualMonthlyAicIncludedCredits( + 7000, + 'individual', + NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY, + )).toBe(7000) + expect(getIndividualMonthlyAicIncludedCredits( + 20000, + 'individual', + NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY, + )).toBe(20000) + expect(getIndividualPlanTier(20000, 'individual', NATIVE_AI_CREDITS_STANDARD_INCLUDED_CREDITS_POLICY)).toBe( + 'max', + ) + expect(getIndividualMonthlyAicIncludedCredits( + 20000, + 'individual', + NATIVE_AI_CREDITS_STANDARD_INCLUDED_CREDITS_POLICY, + )).toBe(20000) + }) + + it('does not apply post-preview individual quota identities to transition-period reports', () => { + expect(getIndividualPlanTier(7000)).toBeNull() + expect(getIndividualPlanTier(3900)).toBeNull() + expect(getIndividualPlanTier(20000)).toBeNull() + expect(getPlanLabel(7000, 'individual')).toBe('Unknown (7,000 PRUs/month)') + }) + + it('keeps native 3900 scope-aware as organization Enterprise instead of individual Pro+', () => { + expect(getIndividualPlanTier(3900, 'individual', NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY)).toBe( + null, + ) + expect(getAicIncludedCreditTier(3900, 'organization', NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY)).toBe( + 'enterprise', + ) + expect(getPlanLabel(3900, 'individual', NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY)).toBe( + 'Unknown (3,900 AI Credits/month)', + ) + expect(getPlanLabel(3900, 'organization', NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY)).toBe( + 'Copilot Enterprise', + ) + }) + it('classifies 300 as Pro/Student for an individual report', () => { expect(getIndividualPlanTier(PRO_MONTHLY_QUOTA)).toBe('pro-student') }) @@ -267,11 +351,22 @@ describe('AIC included credit tiering and pool sizing', () => { expect(getPlanLabel(0)).toBe('Unknown') }) + it('formats unknown native individual quotas as AI Credits per month', () => { + expect(getPlanLabel(1234, 'individual', NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY)).toBe( + 'Unknown (1,234 AI Credits/month)', + ) + expect(getPlanLabel(1234, 'individual', NATIVE_AI_CREDITS_STANDARD_INCLUDED_CREDITS_POLICY)).toBe( + 'Unknown (1,234 AI Credits/month)', + ) + }) + it('selects the maximum known monthly quota while ignoring unknown quota values', () => { expect(selectKnownMonthlyQuota(0, UNKNOWN_HIGH_MONTHLY_QUOTA)).toBe(0) expect(selectKnownMonthlyQuota(BUSINESS_MONTHLY_QUOTA, UNKNOWN_HIGH_MONTHLY_QUOTA)).toBe(BUSINESS_MONTHLY_QUOTA) expect(selectKnownMonthlyQuota(UNKNOWN_HIGH_MONTHLY_QUOTA, ENTERPRISE_MONTHLY_QUOTA)).toBe(ENTERPRISE_MONTHLY_QUOTA) expect(selectKnownMonthlyQuota(BUSINESS_MONTHLY_QUOTA, ENTERPRISE_MONTHLY_QUOTA)).toBe(ENTERPRISE_MONTHLY_QUOTA) + expect(selectKnownMonthlyQuota(0, 10000)).toBe(0) + expect(selectKnownMonthlyQuota(0, 20000, NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY)).toBe(20000) }) it('does not create an organization pool for a single-user Pro/Student report', async () => { @@ -464,6 +559,21 @@ describe('AIC included credit tiering and pool sizing', () => { }) }) + it('summarizes a native summer single-user report as an individual plan', () => { + const summary = calculateLicenseSummary( + [{ totalMonthlyQuota: 7000 }], + NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY, + ) + + expect(summary).toEqual({ + rows: [ + { label: 'Copilot Pro+', users: 1, includedAic: 7000 }, + ], + totalUsers: 1, + totalIncludedAic: 7000, + }) + }) + it('summarizes a single-user report with organization metadata as a business plan', () => { const summary = calculateLicenseSummary([ { totalMonthlyQuota: 300, organizations: ['example-org'], costCenters: ['Cost Center A'] }, @@ -607,6 +717,47 @@ describe('AIC included credit tiering and pool sizing', () => { totalIncludedAic: 10000, }) }) + + it('uses native summer individual plans end-to-end for pipeline allocation and license summaries', async () => { + const file = createNativeCsv([ + ['2026-08-01', 'mona', 'copilot', 'copilot_ai_credit', 'GPT-5', '21000', 'ai-credits', '0.01', '210.00', '0', '210.00', '20000', '', '', '21000', '210.00'], + ]) + const capturedRecords = new CaptureAggregator() + let users!: UserUsageAggregator + let resolvedPolicy!: IncludedCreditsPolicy + + await runPipeline(file, (reportMetadata, includedCreditsPolicy) => { + resolvedPolicy = includedCreditsPolicy + users = new UserUsageAggregator(reportMetadata, includedCreditsPolicy) + return [capturedRecords, users] + }) + + expect(resolvedPolicy).toBe(NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY) + expect(capturedRecords.result()).toEqual([ + expect.objectContaining({ + total_monthly_quota: 20000, + aic_net_amount: 10, + }), + ]) + expect(users.result().users).toEqual([ + expect.objectContaining({ + username: 'mona', + totalMonthlyQuota: 20000, + totals: expect.objectContaining({ + aicQuantity: 21000, + aicGrossAmount: 210, + aicNetAmount: 10, + }), + }), + ]) + expect(calculateLicenseSummary(users.result().users, resolvedPolicy)).toEqual({ + rows: [ + { label: 'Copilot Max', users: 1, includedAic: 20000 }, + ], + totalUsers: 1, + totalIncludedAic: 20000, + }) + }) }) describe('pooled AIC allocation and derived AIC discounts', () => { diff --git a/src/pipeline/aicIncludedCredits.ts b/src/pipeline/aicIncludedCredits.ts index 489cfc9..9bd93f7 100644 --- a/src/pipeline/aicIncludedCredits.ts +++ b/src/pipeline/aicIncludedCredits.ts @@ -10,63 +10,50 @@ import { TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY, resolveIncludedCreditsPolicy, type IncludedCreditsPolicy, + type IndividualIncludedCreditTier, type OrganizationIncludedCreditTier, - type PlanIdentity, type ReportPeriod, } from './includedCreditsPolicy' import { isValidIsoDate } from './isoDate' import { streamLines, type StreamProgress } from './streamer' import type { ReportFormatMetadata } from './reportAdapters' -type IndividualPlan = 'pro-student' | 'pro-plus' +type IndividualIncludedCreditsPlan = NonNullable -type IndividualIncludedCreditsPlans = { - readonly [Tier in IndividualPlan]: { - readonly identity: PlanIdentity - readonly label: string - readonly monthlyIncludedCredits: number +function getIndividualIncludedCreditsPlans(policy: IncludedCreditsPolicy): IndividualIncludedCreditsPlan[] { + return Object.values(policy.individualPlans) + .filter((plan): plan is IndividualIncludedCreditsPlan => plan !== undefined) +} + +function getRequiredIndividualIncludedCreditsPlan( + policy: IncludedCreditsPolicy, + tier: IndividualIncludedCreditTier, +): IndividualIncludedCreditsPlan { + const plan = policy.individualPlans[tier] + if (!plan) { + throw new Error(`Included credits policy ${policy.id} does not define ${tier}.`) } + return plan } -const INDIVIDUAL_INCLUDED_CREDIT_PLANS = { - 'pro-student': { - identity: { - tier: 'pro-student', - quotaUnit: 'pru', - monthlyQuota: 300, - }, - label: 'Copilot Pro/Student', - monthlyIncludedCredits: 1500, - }, - 'pro-plus': { - identity: { - tier: 'pro-plus', - quotaUnit: 'pru', - monthlyQuota: 1500, - }, - label: 'Copilot Pro+', - monthlyIncludedCredits: 7000, - }, -} as const satisfies IndividualIncludedCreditsPlans +const TRANSITION_PERIOD_PRO_PLAN = getRequiredIndividualIncludedCreditsPlan( + TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY, + 'pro-student', +) +const TRANSITION_PERIOD_PRO_PLUS_PLAN = getRequiredIndividualIncludedCreditsPlan( + TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY, + 'pro-plus', +) export const BUSINESS_MONTHLY_QUOTA = TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY.organizationPlans.business.identity.monthlyQuota export const ENTERPRISE_MONTHLY_QUOTA = TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY.organizationPlans.enterprise.identity.monthlyQuota -export const PRO_MONTHLY_QUOTA = INDIVIDUAL_INCLUDED_CREDIT_PLANS['pro-student'].identity.monthlyQuota -export const PRO_PLUS_MONTHLY_QUOTA = INDIVIDUAL_INCLUDED_CREDIT_PLANS['pro-plus'].identity.monthlyQuota -const INDIVIDUAL_KNOWN_MONTHLY_QUOTAS = new Set([ - PRO_MONTHLY_QUOTA, - PRO_PLUS_MONTHLY_QUOTA, -]) -const TRANSITION_PERIOD_KNOWN_MONTHLY_QUOTAS = new Set([ - BUSINESS_MONTHLY_QUOTA, - ENTERPRISE_MONTHLY_QUOTA, - ...INDIVIDUAL_KNOWN_MONTHLY_QUOTAS, -]) +export const PRO_MONTHLY_QUOTA = TRANSITION_PERIOD_PRO_PLAN.identity.monthlyQuota +export const PRO_PLUS_MONTHLY_QUOTA = TRANSITION_PERIOD_PRO_PLUS_PLAN.identity.monthlyQuota export const BUSINESS_MONTHLY_AIC_INCLUDED_CREDITS = TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY.organizationPlans.business.monthlyIncludedCredits export const ENTERPRISE_MONTHLY_AIC_INCLUDED_CREDITS = TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY.organizationPlans.enterprise.monthlyIncludedCredits -export const PRO_MONTHLY_AIC_INCLUDED_CREDITS = INDIVIDUAL_INCLUDED_CREDIT_PLANS['pro-student'].monthlyIncludedCredits -export const PRO_PLUS_MONTHLY_AIC_INCLUDED_CREDITS = INDIVIDUAL_INCLUDED_CREDIT_PLANS['pro-plus'].monthlyIncludedCredits +export const PRO_MONTHLY_AIC_INCLUDED_CREDITS = TRANSITION_PERIOD_PRO_PLAN.monthlyIncludedCredits +export const PRO_PLUS_MONTHLY_AIC_INCLUDED_CREDITS = TRANSITION_PERIOD_PRO_PLUS_PLAN.monthlyIncludedCredits export type AicIncludedCreditsOverrides = { business?: number @@ -75,7 +62,7 @@ export type AicIncludedCreditsOverrides = { export type ReportPlanScope = 'individual' | 'organization' export type AicIncludedCreditTier = OrganizationIncludedCreditTier | null -export type IndividualPlanTier = IndividualPlan | null +export type IndividualPlanTier = IndividualIncludedCreditTier | null export type LicenseSummaryRow = { label: string @@ -102,6 +89,7 @@ type ReportScopeUser = { export type AicIncludedCreditsContext = { reportPlanScope: ReportPlanScope + includedCreditsPolicy: IncludedCreditsPolicy organizationIncludedCreditsPool: number individualMonthlyIncludedCredits: number } @@ -140,10 +128,11 @@ function findOrganizationIncludedCreditsPlan( function findIndividualIncludedCreditsPlan( totalMonthlyQuota: number, reportPlanScope: ReportPlanScope, + policy: IncludedCreditsPolicy, ) { if (reportPlanScope !== 'individual') return null - return Object.values(INDIVIDUAL_INCLUDED_CREDIT_PLANS) + return getIndividualIncludedCreditsPlans(policy) .find((plan) => plan.identity.monthlyQuota === totalMonthlyQuota) ?? null } @@ -153,12 +142,9 @@ export function isKnownMonthlyQuota(totalMonthlyQuota: number): boolean { function isKnownMonthlyQuotaForPolicy(totalMonthlyQuota: number, policy: IncludedCreditsPolicy): boolean { if (!Number.isFinite(totalMonthlyQuota)) return false - if (policy.id === TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY.id) { - return TRANSITION_PERIOD_KNOWN_MONTHLY_QUOTAS.has(totalMonthlyQuota) - } return ( - INDIVIDUAL_KNOWN_MONTHLY_QUOTAS.has(totalMonthlyQuota) + getIndividualIncludedCreditsPlans(policy).some((plan) => plan.identity.monthlyQuota === totalMonthlyQuota) || Object.values(policy.organizationPlans).some((plan) => plan.identity.monthlyQuota === totalMonthlyQuota) ) } @@ -185,7 +171,7 @@ export function getPlanLabel( const organizationPlan = findOrganizationIncludedCreditsPlan(totalMonthlyQuota, reportPlanScope, policy) if (organizationPlan) return organizationPlan.label - const individualPlan = findIndividualIncludedCreditsPlan(totalMonthlyQuota, reportPlanScope) + const individualPlan = findIndividualIncludedCreditsPlan(totalMonthlyQuota, reportPlanScope, policy) if (individualPlan) return individualPlan.label if (totalMonthlyQuota > 0) { @@ -195,9 +181,11 @@ export function getPlanLabel( } function getUnknownQuotaLabel(reportPlanScope: ReportPlanScope, policy: IncludedCreditsPolicy): string { - if (reportPlanScope === 'individual') return 'PRUs/month' + const quotaUnit = reportPlanScope === 'individual' + ? getIndividualIncludedCreditsPlans(policy)[0]?.identity.quotaUnit ?? policy.organizationPlans.business.identity.quotaUnit + : policy.organizationPlans.business.identity.quotaUnit - return policy.organizationPlans.business.identity.quotaUnit === 'aic' ? 'AI Credits/month' : 'PRUs/month' + return quotaUnit === 'aic' ? 'AI Credits/month' : 'PRUs/month' } export function getAicIncludedCreditTier( @@ -211,8 +199,9 @@ export function getAicIncludedCreditTier( export function getIndividualPlanTier( totalMonthlyQuota: number, reportPlanScope: ReportPlanScope = 'individual', + policy: IncludedCreditsPolicy = TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY, ): IndividualPlanTier { - return findIndividualIncludedCreditsPlan(totalMonthlyQuota, reportPlanScope)?.identity.tier ?? null + return findIndividualIncludedCreditsPlan(totalMonthlyQuota, reportPlanScope, policy)?.identity.tier ?? null } export function getMonthlyAicIncludedCredits( @@ -226,8 +215,9 @@ export function getMonthlyAicIncludedCredits( export function getIndividualMonthlyAicIncludedCredits( totalMonthlyQuota: number, reportPlanScope: ReportPlanScope = 'individual', + policy: IncludedCreditsPolicy = TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY, ): number { - return findIndividualIncludedCreditsPlan(totalMonthlyQuota, reportPlanScope)?.monthlyIncludedCredits ?? 0 + return findIndividualIncludedCreditsPlan(totalMonthlyQuota, reportPlanScope, policy)?.monthlyIncludedCredits ?? 0 } function includeDateInReportPeriod(reportPeriod: ReportPeriod, rawDate: string): ReportPeriod { @@ -274,11 +264,11 @@ export function calculateLicenseSummary( const reportPlanScope = inferReportPlanScope(users.length, hasOrganizationContext(users)) if (reportPlanScope === 'individual') { const quota = users[0]?.totalMonthlyQuota ?? 0 - const includedAic = getIndividualMonthlyAicIncludedCredits(quota, reportPlanScope) + const includedAic = getIndividualMonthlyAicIncludedCredits(quota, reportPlanScope, policy) return { rows: users.length === 1 - ? [{ label: getPlanLabel(quota, reportPlanScope), users: 1, includedAic }] + ? [{ label: getPlanLabel(quota, reportPlanScope, policy), users: 1, includedAic }] : [], totalUsers: users.length, totalIncludedAic: includedAic, @@ -362,8 +352,9 @@ export async function calculateAicIncludedCreditsContext( const quota = quotasByUser.values().next().value ?? 0 return { reportPlanScope, + includedCreditsPolicy, organizationIncludedCreditsPool: 0, - individualMonthlyIncludedCredits: getIndividualMonthlyAicIncludedCredits(quota, reportPlanScope), + individualMonthlyIncludedCredits: getIndividualMonthlyAicIncludedCredits(quota, reportPlanScope, includedCreditsPolicy), } } @@ -371,6 +362,7 @@ export async function calculateAicIncludedCreditsContext( return { reportPlanScope, + includedCreditsPolicy, organizationIncludedCreditsPool: overriddenOrganizationIncludedCreditPool ?? Array.from(quotasByUser.values()).reduce( (total, quota) => total + getMonthlyAicIncludedCredits(quota, reportPlanScope, includedCreditsPolicy), 0, @@ -473,6 +465,12 @@ export async function createAicIncludedCreditsAllocator( ): Promise { const includedCreditsContext = await calculateAicIncludedCreditsContext(file, overrides, options) + return createAicIncludedCreditsAllocatorFromContext(includedCreditsContext) +} + +export function createAicIncludedCreditsAllocatorFromContext( + includedCreditsContext: AicIncludedCreditsContext, +): PooledAicIncludedCreditsAllocator | IndividualAicIncludedCreditsAllocator { if (includedCreditsContext.reportPlanScope === 'individual') { return new IndividualAicIncludedCreditsAllocator(includedCreditsContext.individualMonthlyIncludedCredits) } diff --git a/src/pipeline/includedCreditsPolicy.test.ts b/src/pipeline/includedCreditsPolicy.test.ts index 21cc77b..a0e0ffd 100644 --- a/src/pipeline/includedCreditsPolicy.test.ts +++ b/src/pipeline/includedCreditsPolicy.test.ts @@ -36,6 +36,42 @@ describe('resolveIncludedCreditsPolicy', () => { })).toBe(NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY) }) + it('defines post-preview individual plan quota identities and included AI Credits', () => { + expect(NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY.individualPlans['pro-student']).toEqual({ + identity: { + tier: 'pro-student', + quotaUnit: 'aic', + monthlyQuota: 1500, + }, + label: 'Copilot Pro', + monthlyIncludedCredits: 1500, + }) + expect(NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY.individualPlans['pro-plus']).toEqual({ + identity: { + tier: 'pro-plus', + quotaUnit: 'aic', + monthlyQuota: 7000, + }, + label: 'Copilot Pro+', + monthlyIncludedCredits: 7000, + }) + expect(NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY.individualPlans.max).toEqual({ + identity: { + tier: 'max', + quotaUnit: 'aic', + monthlyQuota: 20000, + }, + label: 'Copilot Max', + monthlyIncludedCredits: 20000, + }) + }) + + it('uses the same individual plan identities and allotments for native standard periods', () => { + expect(NATIVE_AI_CREDITS_STANDARD_INCLUDED_CREDITS_POLICY.individualPlans).toEqual( + NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY.individualPlans, + ) + }) + it('uses period start to keep native report periods starting on 2026-08-31 in the summer promo policy', () => { expect(resolveIncludedCreditsPolicy(NATIVE_AI_CREDITS_REPORT_METADATA, { startDate: '2026-08-31', diff --git a/src/pipeline/includedCreditsPolicy.ts b/src/pipeline/includedCreditsPolicy.ts index b145095..13560f7 100644 --- a/src/pipeline/includedCreditsPolicy.ts +++ b/src/pipeline/includedCreditsPolicy.ts @@ -3,6 +3,7 @@ import { isValidIsoDate } from './isoDate' export type QuotaUnit = 'pru' | 'aic' export type OrganizationIncludedCreditTier = 'business' | 'enterprise' +export type IndividualIncludedCreditTier = 'pro-student' | 'pro-plus' | 'max' export type PlanIdentity = { readonly tier: TTier @@ -10,7 +11,7 @@ export type PlanIdentity = { readonly monthlyQuota: number } -export type IncludedCreditsPlan = { +export type IncludedCreditsPlan = { readonly identity: PlanIdentity readonly label: string readonly monthlyIncludedCredits: number @@ -20,6 +21,10 @@ export type OrganizationIncludedCreditPlans = { readonly [Tier in OrganizationIncludedCreditTier]: IncludedCreditsPlan } +export type IndividualIncludedCreditPlans = Partial<{ + readonly [Tier in IndividualIncludedCreditTier]: IncludedCreditsPlan +}> + export type IncludedCreditsPolicyId = | 'transition-period-billing-preview' | 'native-ai-credits-summer-promo' @@ -28,6 +33,7 @@ export type IncludedCreditsPolicyId = export type IncludedCreditsPolicy = { readonly id: IncludedCreditsPolicyId readonly organizationPlans: OrganizationIncludedCreditPlans + readonly individualPlans: IndividualIncludedCreditPlans } export type ReportPeriod = { @@ -37,8 +43,42 @@ export type ReportPeriod = { const COPILOT_BUSINESS_LABEL = 'Copilot Business' const COPILOT_ENTERPRISE_LABEL = 'Copilot Enterprise' +const COPILOT_PRO_TRANSITION_LABEL = 'Copilot Pro/Student' +const COPILOT_PRO_LABEL = 'Copilot Pro' +const COPILOT_PRO_PLUS_LABEL = 'Copilot Pro+' +const COPILOT_MAX_LABEL = 'Copilot Max' const NATIVE_AI_CREDITS_STANDARD_POLICY_START_DATE = '2026-09-01' +const POST_PREVIEW_INDIVIDUAL_INCLUDED_CREDIT_PLANS = { + 'pro-student': { + identity: { + tier: 'pro-student', + quotaUnit: 'aic', + monthlyQuota: 1500, + }, + label: COPILOT_PRO_LABEL, + monthlyIncludedCredits: 1500, + }, + 'pro-plus': { + identity: { + tier: 'pro-plus', + quotaUnit: 'aic', + monthlyQuota: 7000, + }, + label: COPILOT_PRO_PLUS_LABEL, + monthlyIncludedCredits: 7000, + }, + max: { + identity: { + tier: 'max', + quotaUnit: 'aic', + monthlyQuota: 20000, + }, + label: COPILOT_MAX_LABEL, + monthlyIncludedCredits: 20000, + }, +} as const satisfies IndividualIncludedCreditPlans + export const TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY = { id: 'transition-period-billing-preview', organizationPlans: { @@ -61,6 +101,26 @@ export const TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY = { monthlyIncludedCredits: 7000, }, }, + individualPlans: { + 'pro-student': { + identity: { + tier: 'pro-student', + quotaUnit: 'pru', + monthlyQuota: 300, + }, + label: COPILOT_PRO_TRANSITION_LABEL, + monthlyIncludedCredits: 1500, + }, + 'pro-plus': { + identity: { + tier: 'pro-plus', + quotaUnit: 'pru', + monthlyQuota: 1500, + }, + label: COPILOT_PRO_PLUS_LABEL, + monthlyIncludedCredits: 7000, + }, + }, } as const satisfies IncludedCreditsPolicy export const NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY = { @@ -85,6 +145,7 @@ export const NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY = { monthlyIncludedCredits: 7000, }, }, + individualPlans: POST_PREVIEW_INDIVIDUAL_INCLUDED_CREDIT_PLANS, } as const satisfies IncludedCreditsPolicy export const NATIVE_AI_CREDITS_STANDARD_INCLUDED_CREDITS_POLICY = { @@ -109,6 +170,7 @@ export const NATIVE_AI_CREDITS_STANDARD_INCLUDED_CREDITS_POLICY = { monthlyIncludedCredits: 3900, }, }, + individualPlans: POST_PREVIEW_INDIVIDUAL_INCLUDED_CREDIT_PLANS, } as const satisfies IncludedCreditsPolicy function getReportFormat(reportMetadataOrFormat: ReportFormat | ReportFormatMetadata): ReportFormat { diff --git a/src/pipeline/runPipeline.ts b/src/pipeline/runPipeline.ts index e0d7553..879ae3c 100644 --- a/src/pipeline/runPipeline.ts +++ b/src/pipeline/runPipeline.ts @@ -1,5 +1,10 @@ import type { Aggregator } from './aggregators/base' -import { createAicIncludedCreditsAllocator, type AicIncludedCreditsOverrides } from './aicIncludedCredits' +import { + calculateAicIncludedCreditsContext, + createAicIncludedCreditsAllocatorFromContext, + type AicIncludedCreditsOverrides, +} from './aicIncludedCredits' +import type { IncludedCreditsPolicy } from './includedCreditsPolicy' import { InvalidReportError, parseTokenUsageHeader, @@ -63,7 +68,10 @@ export interface PipelineResult { export type PipelineAggregators = | Aggregator[] - | ((reportMetadata: ReportFormatMetadata) => Aggregator[]) + | (( + reportMetadata: ReportFormatMetadata, + includedCreditsPolicy: IncludedCreditsPolicy, + ) => Aggregator[]) const ANALYSIS_PROGRESS_WEIGHT = 0.4 const MIN_PROGRESS_INCREMENT_PERCENT = 1 @@ -100,9 +108,6 @@ export async function runPipeline( } = options ?? {} const reportAdapter = await validateFileFormat(file) const reportMetadata = reportAdapter.metadata - const aggregators = typeof aggregatorsOrFactory === 'function' - ? aggregatorsOrFactory(reportMetadata) - : aggregatorsOrFactory let lastProgressStage: PipelineProgress['stage'] | null = null let lastProgressPercent = -1 let lastProgressTimestamp = 0 @@ -148,12 +153,16 @@ export async function runPipeline( return true } - const aicIncludedCreditAllocator = await createAicIncludedCreditsAllocator(file, includedCreditsOverrides, { + const includedCreditsContext = await calculateAicIncludedCreditsContext(file, includedCreditsOverrides, { reportMetadata, onProgress: (streamProgress) => { emitProgress('analyzing', 0, streamProgress) }, }) + const aggregators = typeof aggregatorsOrFactory === 'function' + ? aggregatorsOrFactory(reportMetadata, includedCreditsContext.includedCreditsPolicy) + : aggregatorsOrFactory + const aicIncludedCreditAllocator = createAicIncludedCreditsAllocatorFromContext(includedCreditsContext) let header: TokenUsageHeader | null = null let reportRowCount = 0 let rowIndex = 0 diff --git a/src/utils/individualPlanUpgrade.test.ts b/src/utils/individualPlanUpgrade.test.ts index eaa1795..23ae12b 100644 --- a/src/utils/individualPlanUpgrade.test.ts +++ b/src/utils/individualPlanUpgrade.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import { PRO_MONTHLY_QUOTA, PRO_PLUS_MONTHLY_QUOTA } from '../pipeline/aicIncludedCredits' +import { + NATIVE_AI_CREDITS_STANDARD_INCLUDED_CREDITS_POLICY, + NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY, +} from '../pipeline/includedCreditsPolicy' import { calculateIndividualPlanUpgradeRecommendation, getIndividualLicenseMonthlyCost, @@ -16,6 +20,26 @@ describe('individual plan upgrade recommendations', () => { expect(getIndividualLicenseMonthlyCost(0)).toBeUndefined() }) + it('returns native summer individual plan license costs', () => { + expect(getIndividualLicenseMonthlyCost( + 1500, + NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY, + )).toBe(PRO_LICENSE_MONTHLY_COST) + expect(getIndividualLicenseMonthlyCost( + 7000, + NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY, + )).toBe(PRO_PLUS_LICENSE_MONTHLY_COST) + expect(getIndividualLicenseMonthlyCost( + 20000, + NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY, + )).toBe(MAX_LICENSE_MONTHLY_COST) + expect(getIndividualLicenseMonthlyCost( + 20000, + NATIVE_AI_CREDITS_STANDARD_INCLUDED_CREDITS_POLICY, + )).toBe(MAX_LICENSE_MONTHLY_COST) + expect(getIndividualLicenseMonthlyCost(1000)).toBeUndefined() + }) + it('recommends Pro+ when extra included AICs exceed the higher subscription cost', () => { const recommendation = calculateIndividualPlanUpgradeRecommendation({ totalMonthlyQuota: PRO_MONTHLY_QUOTA, @@ -96,4 +120,41 @@ describe('individual plan upgrade recommendations', () => { currentMonthlyAicAdditionalUsageBillsUsd: [61], })).toBeNull() }) + + it('does not recommend an upgrade for native summer Max users', () => { + expect(calculateIndividualPlanUpgradeRecommendation({ + totalMonthlyQuota: 20000, + currentMonthlyAicAdditionalUsageBillsUsd: [200], + includedCreditsPolicy: NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY, + })).toBeNull() + }) + + it('uses post-preview individual allotments for native upgrade recommendations', () => { + const recommendation = calculateIndividualPlanUpgradeRecommendation({ + totalMonthlyQuota: 7000, + currentMonthlyAicAdditionalUsageBillsUsd: [100], + includedCreditsPolicy: NATIVE_AI_CREDITS_SUMMER_PROMO_INCLUDED_CREDITS_POLICY, + }) + + expect(recommendation).toEqual({ + currentPlanLabel: 'Pro+', + nextPlanTier: 'max', + nextPlanLabel: 'Max', + currentAdditionalUsageAic: 10000, + currentAdditionalUsageCostUsd: 100, + extraIncludedAic: 13000, + additionalUsageBillReductionUsd: 100, + licenseCostIncreaseUsd: 61, + netSavingsUsd: 39, + upgradedTotalBillUsd: MAX_LICENSE_MONTHLY_COST, + }) + }) + + it('uses post-preview individual allotments for native standard upgrade recommendations', () => { + expect(calculateIndividualPlanUpgradeRecommendation({ + totalMonthlyQuota: 7000, + currentMonthlyAicAdditionalUsageBillsUsd: [61], + includedCreditsPolicy: NATIVE_AI_CREDITS_STANDARD_INCLUDED_CREDITS_POLICY, + })).toBeNull() + }) }) diff --git a/src/utils/individualPlanUpgrade.ts b/src/utils/individualPlanUpgrade.ts index c437b9e..ecfb661 100644 --- a/src/utils/individualPlanUpgrade.ts +++ b/src/utils/individualPlanUpgrade.ts @@ -1,8 +1,11 @@ import { getIndividualPlanTier, - PRO_MONTHLY_AIC_INCLUDED_CREDITS, - PRO_PLUS_MONTHLY_AIC_INCLUDED_CREDITS, } from '../pipeline/aicIncludedCredits' +import { + TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY, + type IncludedCreditsPolicy, + type IndividualIncludedCreditTier, +} from '../pipeline/includedCreditsPolicy' import { AIC_UNIT_PRICE_USD } from './billingConstants' export const PRO_LICENSE_MONTHLY_COST = 10 @@ -11,32 +14,59 @@ export const MAX_LICENSE_MONTHLY_COST = 100 export const MAX_PROMOTIONAL_MONTHLY_AIC_INCLUDED_CREDITS = 20000 type RecommendableIndividualPlan = { - tier: 'pro-student' | 'pro-plus' | 'max' + tier: IndividualIncludedCreditTier label: string monthlyLicenseCostUsd: number monthlyIncludedAic: number } -const RECOMMENDABLE_INDIVIDUAL_PLANS: RecommendableIndividualPlan[] = [ - { - tier: 'pro-student', +const RECOMMENDABLE_INDIVIDUAL_PLAN_ORDER = ['pro-student', 'pro-plus', 'max'] as const satisfies readonly IndividualIncludedCreditTier[] + +const INDIVIDUAL_PLAN_METADATA: Record = { + 'pro-student': { label: 'Pro', monthlyLicenseCostUsd: PRO_LICENSE_MONTHLY_COST, - monthlyIncludedAic: PRO_MONTHLY_AIC_INCLUDED_CREDITS, }, - { - tier: 'pro-plus', + 'pro-plus': { label: 'Pro+', monthlyLicenseCostUsd: PRO_PLUS_LICENSE_MONTHLY_COST, - monthlyIncludedAic: PRO_PLUS_MONTHLY_AIC_INCLUDED_CREDITS, }, - { - tier: 'max', + max: { label: 'Max', monthlyLicenseCostUsd: MAX_LICENSE_MONTHLY_COST, - monthlyIncludedAic: MAX_PROMOTIONAL_MONTHLY_AIC_INCLUDED_CREDITS, }, -] +} + +function getRecommendableIndividualPlans(policy: IncludedCreditsPolicy): RecommendableIndividualPlan[] { + const policyPlans = RECOMMENDABLE_INDIVIDUAL_PLAN_ORDER.flatMap((tier) => { + const plan = policy.individualPlans[tier] + if (!plan) return [] + + return [{ + tier, + label: INDIVIDUAL_PLAN_METADATA[tier].label, + monthlyLicenseCostUsd: INDIVIDUAL_PLAN_METADATA[tier].monthlyLicenseCostUsd, + monthlyIncludedAic: plan.monthlyIncludedCredits, + }] + }) + + if (policy.id !== TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY.id || policy.individualPlans.max) { + return policyPlans + } + + return [ + ...policyPlans, + { + tier: 'max', + label: INDIVIDUAL_PLAN_METADATA.max.label, + monthlyLicenseCostUsd: INDIVIDUAL_PLAN_METADATA.max.monthlyLicenseCostUsd, + monthlyIncludedAic: MAX_PROMOTIONAL_MONTHLY_AIC_INCLUDED_CREDITS, + }, + ] +} export type IndividualPlanUpgradeRecommendation = { currentPlanLabel: string @@ -51,21 +81,24 @@ export type IndividualPlanUpgradeRecommendation = { upgradedTotalBillUsd: number } -export function getIndividualLicenseMonthlyCost(totalMonthlyQuota: number): number | undefined { - const planTier = getIndividualPlanTier(totalMonthlyQuota, 'individual') - if (planTier === 'pro-plus') return PRO_PLUS_LICENSE_MONTHLY_COST - if (planTier === 'pro-student') return PRO_LICENSE_MONTHLY_COST - return undefined +export function getIndividualLicenseMonthlyCost( + totalMonthlyQuota: number, + includedCreditsPolicy: IncludedCreditsPolicy = TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY, +): number | undefined { + const planTier = getIndividualPlanTier(totalMonthlyQuota, 'individual', includedCreditsPolicy) + return planTier ? INDIVIDUAL_PLAN_METADATA[planTier].monthlyLicenseCostUsd : undefined } export function calculateIndividualPlanUpgradeRecommendation({ totalMonthlyQuota, currentMonthlyAicAdditionalUsageBillsUsd, + includedCreditsPolicy = TRANSITION_PERIOD_INCLUDED_CREDITS_POLICY, }: { totalMonthlyQuota: number currentMonthlyAicAdditionalUsageBillsUsd: number[] + includedCreditsPolicy?: IncludedCreditsPolicy }): IndividualPlanUpgradeRecommendation | null { - const planTier = getIndividualPlanTier(totalMonthlyQuota, 'individual') + const planTier = getIndividualPlanTier(totalMonthlyQuota, 'individual', includedCreditsPolicy) if (!planTier || currentMonthlyAicAdditionalUsageBillsUsd.length === 0) { return null } @@ -75,14 +108,15 @@ export function calculateIndividualPlanUpgradeRecommendation({ return null } - const currentPlanIndex = RECOMMENDABLE_INDIVIDUAL_PLANS.findIndex((plan) => plan.tier === planTier) - const currentPlan = RECOMMENDABLE_INDIVIDUAL_PLANS[currentPlanIndex] + const recommendableIndividualPlans = getRecommendableIndividualPlans(includedCreditsPolicy) + const currentPlanIndex = recommendableIndividualPlans.findIndex((plan) => plan.tier === planTier) + const currentPlan = recommendableIndividualPlans[currentPlanIndex] if (!currentPlan) { return null } const currentAdditionalUsageAic = currentAicAdditionalUsageBillUsd / AIC_UNIT_PRICE_USD - const recommendation = RECOMMENDABLE_INDIVIDUAL_PLANS + const recommendation = recommendableIndividualPlans .slice(currentPlanIndex + 1) .map((targetPlan) => { const extraIncludedAic = targetPlan.monthlyIncludedAic - currentPlan.monthlyIncludedAic