Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -897,7 +897,9 @@ export function getExpectedExcessCost(data: CopilotUsageData[], plan: string = C
if (!lastDate) return 0;

const lastDateObj = new Date(lastDate);
const daysElapsed = lastDateObj.getDate();
const totalDaysInMonth = new Date(lastDateObj.getFullYear(), lastDateObj.getMonth() + 1, 0).getDate();
const isPartialMonth = daysElapsed < totalDaysInMonth;

// Group all items by user
const userDataMap: Record<string, CopilotUsageData[]> = {};
Expand All @@ -908,6 +910,67 @@ export function getExpectedExcessCost(data: CopilotUsageData[], plan: string = C

let totalExpectedCost = 0;

if (isPartialMonth) {
const remainingDaysInMonth = totalDaysInMonth - daysElapsed;

Object.values(userDataMap).forEach(userItems => {
if (!userItems.length) return;

// Group by date (YYYY-MM-DD), sorted ascending.
// We keep the same "exclude latest usage day" averaging pattern to avoid partial-day skew.
const byDate: Record<string, CopilotUsageData[]> = {};
userItems.forEach(item => {
const date = item.timestamp.toISOString().split('T')[0];
if (!byDate[date]) byDate[date] = [];
byDate[date].push(item);
});
const sortedDates = Object.keys(byDate).sort();

let datesForAverage = sortedDates.slice(0, -1);
if (datesForAverage.length === 0) {
datesForAverage = sortedDates;
}
if (datesForAverage.length === 0) return;

const historicalModelTotals: Record<string, number> = {};
datesForAverage.forEach(date => {
byDate[date].forEach(item => {
historicalModelTotals[item.model] = (historicalModelTotals[item.model] || 0) + item.requestsUsed;
});
});

const currentModelTotals: Record<string, number> = {};
userItems.forEach(item => {
currentModelTotals[item.model] = (currentModelTotals[item.model] || 0) + item.requestsUsed;
});

const projectedModelTotals: Record<string, number> = {};
const averagingDays = datesForAverage.length;
Object.entries(currentModelTotals).forEach(([model, currentTotal]) => {
const historicalTotal = historicalModelTotals[model] || 0;
const dailyAverage = historicalTotal / averagingDays;
projectedModelTotals[model] = currentTotal + dailyAverage * remainingDaysInMonth;
});

const projectedMonthlyTotal = Object.values(projectedModelTotals).reduce((sum, value) => sum + value, 0);
const projectedExcess = projectedMonthlyTotal - planLimit;
if (projectedExcess <= 0 || projectedMonthlyTotal <= 0) return;

// Allocate only the projected amount above the free plan quota across models,
// then apply each model multiplier to compute cost.
Object.entries(projectedModelTotals).forEach(([model, projectedTotalForModel]) => {
const multiplier = getModelMultiplier(model);
if (multiplier === 0) return;

const modelShare = projectedTotalForModel / projectedMonthlyTotal;
const projectedExcessForModel = projectedExcess * modelShare;
totalExpectedCost += projectedExcessForModel * multiplier * EXCESS_REQUEST_COST;
});
});

return totalExpectedCost;
}

Object.values(userDataMap).forEach(userItems => {
// Only process users who have reached the plan limit
const totalRequests = userItems.reduce((sum, item) => sum + item.requestsUsed, 0);
Expand Down
92 changes: 92 additions & 0 deletions src/test/expected-excess-cost-projection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest';
import {
COPILOT_PLANS,
type CopilotUsageData,
getExpectedExcessCost,
} from '../lib/utils';

describe('getExpectedExcessCost projection behavior', () => {
it('treats full March date-only data as full month (no projection path)', () => {
const data: CopilotUsageData[] = [];

// Full March (31 days), but total remains below 300.
// If incorrectly treated as partial month (30 elapsed), this would project above 300 and return > 0.
for (let day = 1; day <= 31; day++) {
data.push({
timestamp: new Date(`2026-03-${String(day).padStart(2, '0')}`),
user: 'user-march',
model: 'Claude Sonnet 4.6',
requestsUsed: 9.6,
exceedsQuota: false,
totalMonthlyQuota: '300',
});
}

const expectedCost = getExpectedExcessCost(data, COPILOT_PLANS.BUSINESS);
expect(expectedCost).toBe(0);
});

it('projects excess cost for partial-month data even when users are currently below quota', () => {
const data: CopilotUsageData[] = [];

// June has 30 days. By June 10 this user is below 300, but projection exceeds it.
for (let day = 1; day <= 10; day++) {
data.push({
timestamp: new Date(`2026-06-${String(day).padStart(2, '0')}T10:00:00Z`),
user: 'user-a',
model: 'Claude Sonnet 4.6',
requestsUsed: 20,
exceedsQuota: false,
totalMonthlyQuota: '300',
});
}

const expectedCost = getExpectedExcessCost(data, COPILOT_PLANS.BUSINESS);

// Projected total: 200 current + (20/day * 20 remaining days) = 600
// Projected excess over free quota: 600 - 300 = 300
// Multiplier 1, cost $0.04/request => 300 * 1 * 0.04 = 12
expect(expectedCost).toBeCloseTo(12, 6);
});

it('does not charge for projected usage when projected total stays within free quota', () => {
const data: CopilotUsageData[] = [];

// June has 30 days. 10/day over first 10 days projects to exactly 300.
for (let day = 1; day <= 10; day++) {
data.push({
timestamp: new Date(`2026-06-${String(day).padStart(2, '0')}T10:00:00Z`),
user: 'user-b',
model: 'Claude Sonnet 4.6',
requestsUsed: 10,
exceedsQuota: false,
totalMonthlyQuota: '300',
});
}

const expectedCost = getExpectedExcessCost(data, COPILOT_PLANS.BUSINESS);
expect(expectedCost).toBe(0);
});

it('keeps existing full-month behavior for users already above quota', () => {
const data: CopilotUsageData[] = [];

// Full June coverage; user is already above 300 and has paid-model usage throughout.
for (let day = 1; day <= 30; day++) {
data.push({
timestamp: new Date(`2026-06-${String(day).padStart(2, '0')}T10:00:00Z`),
user: 'user-c',
model: 'Claude Sonnet 4.6',
requestsUsed: 20,
exceedsQuota: day > 15,
totalMonthlyQuota: '300',
});
}

const expectedCost = getExpectedExcessCost(data, COPILOT_PLANS.BUSINESS);

// Existing algorithm: budget exhaustion at day 15, remaining 15 days at 20/day.
// Cost = 15 * 20 * 0.04 = 12
expect(expectedCost).toBeCloseTo(12, 6);
});
});
Loading