Skip to content

Commit 5bf558a

Browse files
committed
Update cost estimation for partial month
1 parent c900a09 commit 5bf558a

2 files changed

Lines changed: 155 additions & 0 deletions

File tree

src/lib/utils.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -897,7 +897,9 @@ export function getExpectedExcessCost(data: CopilotUsageData[], plan: string = C
897897
if (!lastDate) return 0;
898898

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

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

909911
let totalExpectedCost = 0;
910912

913+
if (isPartialMonth) {
914+
const remainingDaysInMonth = totalDaysInMonth - daysElapsed;
915+
916+
Object.values(userDataMap).forEach(userItems => {
917+
if (!userItems.length) return;
918+
919+
// Group by date (YYYY-MM-DD), sorted ascending.
920+
// We keep the same "exclude latest usage day" averaging pattern to avoid partial-day skew.
921+
const byDate: Record<string, CopilotUsageData[]> = {};
922+
userItems.forEach(item => {
923+
const date = item.timestamp.toISOString().split('T')[0];
924+
if (!byDate[date]) byDate[date] = [];
925+
byDate[date].push(item);
926+
});
927+
const sortedDates = Object.keys(byDate).sort();
928+
929+
let datesForAverage = sortedDates.slice(0, -1);
930+
if (datesForAverage.length === 0) {
931+
datesForAverage = sortedDates;
932+
}
933+
if (datesForAverage.length === 0) return;
934+
935+
const historicalModelTotals: Record<string, number> = {};
936+
datesForAverage.forEach(date => {
937+
byDate[date].forEach(item => {
938+
historicalModelTotals[item.model] = (historicalModelTotals[item.model] || 0) + item.requestsUsed;
939+
});
940+
});
941+
942+
const currentModelTotals: Record<string, number> = {};
943+
userItems.forEach(item => {
944+
currentModelTotals[item.model] = (currentModelTotals[item.model] || 0) + item.requestsUsed;
945+
});
946+
947+
const projectedModelTotals: Record<string, number> = {};
948+
const averagingDays = datesForAverage.length;
949+
Object.entries(currentModelTotals).forEach(([model, currentTotal]) => {
950+
const historicalTotal = historicalModelTotals[model] || 0;
951+
const dailyAverage = historicalTotal / averagingDays;
952+
projectedModelTotals[model] = currentTotal + dailyAverage * remainingDaysInMonth;
953+
});
954+
955+
const projectedMonthlyTotal = Object.values(projectedModelTotals).reduce((sum, value) => sum + value, 0);
956+
const projectedExcess = projectedMonthlyTotal - planLimit;
957+
if (projectedExcess <= 0 || projectedMonthlyTotal <= 0) return;
958+
959+
// Allocate only the projected amount above the free plan quota across models,
960+
// then apply each model multiplier to compute cost.
961+
Object.entries(projectedModelTotals).forEach(([model, projectedTotalForModel]) => {
962+
const multiplier = getModelMultiplier(model);
963+
if (multiplier === 0) return;
964+
965+
const modelShare = projectedTotalForModel / projectedMonthlyTotal;
966+
const projectedExcessForModel = projectedExcess * modelShare;
967+
totalExpectedCost += projectedExcessForModel * multiplier * EXCESS_REQUEST_COST;
968+
});
969+
});
970+
971+
return totalExpectedCost;
972+
}
973+
911974
Object.values(userDataMap).forEach(userItems => {
912975
// Only process users who have reached the plan limit
913976
const totalRequests = userItems.reduce((sum, item) => sum + item.requestsUsed, 0);
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
COPILOT_PLANS,
4+
type CopilotUsageData,
5+
getExpectedExcessCost,
6+
} from '../lib/utils';
7+
8+
describe('getExpectedExcessCost projection behavior', () => {
9+
it('treats full March date-only data as full month (no projection path)', () => {
10+
const data: CopilotUsageData[] = [];
11+
12+
// Full March (31 days), but total remains below 300.
13+
// If incorrectly treated as partial month (30 elapsed), this would project above 300 and return > 0.
14+
for (let day = 1; day <= 31; day++) {
15+
data.push({
16+
timestamp: new Date(`2026-03-${String(day).padStart(2, '0')}`),
17+
user: 'user-march',
18+
model: 'Claude Sonnet 4.6',
19+
requestsUsed: 9.6,
20+
exceedsQuota: false,
21+
totalMonthlyQuota: '300',
22+
});
23+
}
24+
25+
const expectedCost = getExpectedExcessCost(data, COPILOT_PLANS.BUSINESS);
26+
expect(expectedCost).toBe(0);
27+
});
28+
29+
it('projects excess cost for partial-month data even when users are currently below quota', () => {
30+
const data: CopilotUsageData[] = [];
31+
32+
// June has 30 days. By June 10 this user is below 300, but projection exceeds it.
33+
for (let day = 1; day <= 10; day++) {
34+
data.push({
35+
timestamp: new Date(`2026-06-${String(day).padStart(2, '0')}T10:00:00Z`),
36+
user: 'user-a',
37+
model: 'Claude Sonnet 4.6',
38+
requestsUsed: 20,
39+
exceedsQuota: false,
40+
totalMonthlyQuota: '300',
41+
});
42+
}
43+
44+
const expectedCost = getExpectedExcessCost(data, COPILOT_PLANS.BUSINESS);
45+
46+
// Projected total: 200 current + (20/day * 20 remaining days) = 600
47+
// Projected excess over free quota: 600 - 300 = 300
48+
// Multiplier 1, cost $0.04/request => 300 * 1 * 0.04 = 12
49+
expect(expectedCost).toBeCloseTo(12, 6);
50+
});
51+
52+
it('does not charge for projected usage when projected total stays within free quota', () => {
53+
const data: CopilotUsageData[] = [];
54+
55+
// June has 30 days. 10/day over first 10 days projects to exactly 300.
56+
for (let day = 1; day <= 10; day++) {
57+
data.push({
58+
timestamp: new Date(`2026-06-${String(day).padStart(2, '0')}T10:00:00Z`),
59+
user: 'user-b',
60+
model: 'Claude Sonnet 4.6',
61+
requestsUsed: 10,
62+
exceedsQuota: false,
63+
totalMonthlyQuota: '300',
64+
});
65+
}
66+
67+
const expectedCost = getExpectedExcessCost(data, COPILOT_PLANS.BUSINESS);
68+
expect(expectedCost).toBe(0);
69+
});
70+
71+
it('keeps existing full-month behavior for users already above quota', () => {
72+
const data: CopilotUsageData[] = [];
73+
74+
// Full June coverage; user is already above 300 and has paid-model usage throughout.
75+
for (let day = 1; day <= 30; day++) {
76+
data.push({
77+
timestamp: new Date(`2026-06-${String(day).padStart(2, '0')}T10:00:00Z`),
78+
user: 'user-c',
79+
model: 'Claude Sonnet 4.6',
80+
requestsUsed: 20,
81+
exceedsQuota: day > 15,
82+
totalMonthlyQuota: '300',
83+
});
84+
}
85+
86+
const expectedCost = getExpectedExcessCost(data, COPILOT_PLANS.BUSINESS);
87+
88+
// Existing algorithm: budget exhaustion at day 15, remaining 15 days at 20/day.
89+
// Cost = 15 * 20 * 0.04 = 12
90+
expect(expectedCost).toBeCloseTo(12, 6);
91+
});
92+
});

0 commit comments

Comments
 (0)