From 5bf558aa7ed926451ab7f0ab3eed194776f13135 Mon Sep 17 00:00:00 2001 From: Raymond Splinter Date: Thu, 16 Apr 2026 11:01:53 +0200 Subject: [PATCH] Update cost estimation for partial month --- src/lib/utils.ts | 63 +++++++++++++ .../expected-excess-cost-projection.test.ts | 92 +++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 src/test/expected-excess-cost-projection.test.ts diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 58a452f..8abfe50 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -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 = {}; @@ -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 = {}; + 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 = {}; + datesForAverage.forEach(date => { + byDate[date].forEach(item => { + historicalModelTotals[item.model] = (historicalModelTotals[item.model] || 0) + item.requestsUsed; + }); + }); + + const currentModelTotals: Record = {}; + userItems.forEach(item => { + currentModelTotals[item.model] = (currentModelTotals[item.model] || 0) + item.requestsUsed; + }); + + const projectedModelTotals: Record = {}; + 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); diff --git a/src/test/expected-excess-cost-projection.test.ts b/src/test/expected-excess-cost-projection.test.ts new file mode 100644 index 0000000..6ff3f05 --- /dev/null +++ b/src/test/expected-excess-cost-projection.test.ts @@ -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); + }); +});