Skip to content

Commit 55628b4

Browse files
authored
Merge pull request #158 from RHSplinter/update-expected-excess-cost
Update cost estimation for partial month
2 parents ce26b6c + 5bf558a commit 55628b4

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
@@ -902,7 +902,9 @@ export function getExpectedExcessCost(data: CopilotUsageData[], plan: string = C
902902
if (!lastDate) return 0;
903903

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

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

914916
let totalExpectedCost = 0;
915917

918+
if (isPartialMonth) {
919+
const remainingDaysInMonth = totalDaysInMonth - daysElapsed;
920+
921+
Object.values(userDataMap).forEach(userItems => {
922+
if (!userItems.length) return;
923+
924+
// Group by date (YYYY-MM-DD), sorted ascending.
925+
// We keep the same "exclude latest usage day" averaging pattern to avoid partial-day skew.
926+
const byDate: Record<string, CopilotUsageData[]> = {};
927+
userItems.forEach(item => {
928+
const date = item.timestamp.toISOString().split('T')[0];
929+
if (!byDate[date]) byDate[date] = [];
930+
byDate[date].push(item);
931+
});
932+
const sortedDates = Object.keys(byDate).sort();
933+
934+
let datesForAverage = sortedDates.slice(0, -1);
935+
if (datesForAverage.length === 0) {
936+
datesForAverage = sortedDates;
937+
}
938+
if (datesForAverage.length === 0) return;
939+
940+
const historicalModelTotals: Record<string, number> = {};
941+
datesForAverage.forEach(date => {
942+
byDate[date].forEach(item => {
943+
historicalModelTotals[item.model] = (historicalModelTotals[item.model] || 0) + item.requestsUsed;
944+
});
945+
});
946+
947+
const currentModelTotals: Record<string, number> = {};
948+
userItems.forEach(item => {
949+
currentModelTotals[item.model] = (currentModelTotals[item.model] || 0) + item.requestsUsed;
950+
});
951+
952+
const projectedModelTotals: Record<string, number> = {};
953+
const averagingDays = datesForAverage.length;
954+
Object.entries(currentModelTotals).forEach(([model, currentTotal]) => {
955+
const historicalTotal = historicalModelTotals[model] || 0;
956+
const dailyAverage = historicalTotal / averagingDays;
957+
projectedModelTotals[model] = currentTotal + dailyAverage * remainingDaysInMonth;
958+
});
959+
960+
const projectedMonthlyTotal = Object.values(projectedModelTotals).reduce((sum, value) => sum + value, 0);
961+
const projectedExcess = projectedMonthlyTotal - planLimit;
962+
if (projectedExcess <= 0 || projectedMonthlyTotal <= 0) return;
963+
964+
// Allocate only the projected amount above the free plan quota across models,
965+
// then apply each model multiplier to compute cost.
966+
Object.entries(projectedModelTotals).forEach(([model, projectedTotalForModel]) => {
967+
const multiplier = getModelMultiplier(model);
968+
if (multiplier === 0) return;
969+
970+
const modelShare = projectedTotalForModel / projectedMonthlyTotal;
971+
const projectedExcessForModel = projectedExcess * modelShare;
972+
totalExpectedCost += projectedExcessForModel * multiplier * EXCESS_REQUEST_COST;
973+
});
974+
});
975+
976+
return totalExpectedCost;
977+
}
978+
916979
Object.values(userDataMap).forEach(userItems => {
917980
// Only process users who have reached the plan limit
918981
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)