Files
claude-code-usage-dashboard/server/billing-calculator.ts
2025-08-27 23:44:51 +08:00

285 lines
8.3 KiB
TypeScript

import { db } from './database';
import { apiClient } from './api-client';
export interface UserData {
id: string;
name: string;
usage: {
total: {
cost: number;
tokens: number;
inputTokens: number;
outputTokens: number;
cacheCreateTokens: number;
cacheReadTokens: number;
requests: number;
formattedCost: string;
};
};
[key: string]: any;
}
export interface PeriodInfo {
index: number;
startSnapshotId: number | null;
startAt: string | null;
endAt: string | null;
isCurrent: boolean;
}
export interface UserRanking {
id: string;
name: string;
cost: number;
share: number;
isMe: boolean;
rawStart: UserData | null;
rawEnd: UserData | null;
periodTokens: number;
periodRequests: number;
}
export interface PeriodSummary {
period: PeriodInfo;
totals: {
totalCost: number;
userCount: number;
};
ranking: UserRanking[];
}
export interface UserDetail {
id: string;
name: string;
startCost: number;
endCost: number;
deltaCost: number;
raw: {
start: UserData | null;
end: UserData | null;
};
}
function mapFromDataArray(data: UserData[]): Map<string, UserData> {
const m = new Map<string, UserData>();
for (const u of data ?? []) m.set(u.id, u);
return m;
}
function computePeriodDelta(startData: UserData[], endData: UserData[], meId?: string) {
const start = mapFromDataArray(startData);
const end = mapFromDataArray(endData);
const ids = new Set<string>([...Array.from(start.keys()), ...Array.from(end.keys())]);
const users: UserRanking[] = [];
for (const id of Array.from(ids)) {
const endU = end.get(id);
if (!endU) continue; // 删除用户:不计入
const startU = start.get(id);
const startCost = Number(startU?.usage?.total?.cost ?? 0);
const endCost = Number(endU?.usage?.total?.cost ?? 0);
const startTokens = Number(startU?.usage?.total?.tokens ?? 0);
const endTokens = Number(endU?.usage?.total?.tokens ?? 0);
const startRequests = Number(startU?.usage?.total?.requests ?? 0);
const endRequests = Number(endU?.usage?.total?.requests ?? 0);
let delta = endCost - startCost;
if (!Number.isFinite(delta) || delta < 0) delta = 0;
let deltaTokens = endTokens - startTokens;
if (!Number.isFinite(deltaTokens) || deltaTokens < 0) deltaTokens = 0;
let deltaRequests = endRequests - startRequests;
if (!Number.isFinite(deltaRequests) || deltaRequests < 0) deltaRequests = 0;
users.push({
id: meId === id ? id : '', // Only include ID for current user
name: endU.name || 'User',
cost: +delta.toFixed(6),
share: 0, // will be calculated below
isMe: meId === id,
rawStart: start.get(id) ?? null,
rawEnd: endU,
periodTokens: deltaTokens,
periodRequests: deltaRequests
});
}
const totalCost = users.reduce((s, u) => s + u.cost, 0);
for (const u of users) u.share = totalCost > 0 ? u.cost / totalCost : 0;
users.sort((a, b) => b.cost - a.cost);
const me = meId ? users.find(u => u.id === meId) : null;
return { users, totalCost: +totalCost.toFixed(6), me };
}
export class BillingCalculator {
async getPeriods(): Promise<PeriodInfo[]> {
const snapshots = db.getSnapshots();
const periods: PeriodInfo[] = [];
if (snapshots.length === 0) {
// No snapshots case: only current period from beginning
periods.push({
index: 0,
startSnapshotId: null,
startAt: null,
endAt: null,
isCurrent: true
});
} else {
// Add current period (last snapshot to now)
const lastSnapshot = snapshots[snapshots.length - 1];
if (lastSnapshot) {
periods.push({
index: snapshots.length,
startSnapshotId: lastSnapshot.id,
startAt: lastSnapshot.created_at,
endAt: null,
isCurrent: true
});
}
// Add first historical period: from beginning to first snapshot
const firstSnapshot = snapshots[0];
if (firstSnapshot) {
periods.push({
index: 0,
startSnapshotId: null,
startAt: null,
endAt: firstSnapshot.created_at,
isCurrent: false
});
}
// Add other historical periods: from snapshot to snapshot
for (let i = 1; i < snapshots.length; i++) {
const startSnapshot = snapshots[i - 1];
const endSnapshot = snapshots[i];
if (startSnapshot && endSnapshot) {
periods.push({
index: i,
startSnapshotId: startSnapshot.id,
startAt: startSnapshot.created_at,
endAt: endSnapshot.created_at,
isCurrent: false
});
}
}
}
return periods;
}
async getPeriodSummary(periodIndex: number, meId?: string): Promise<PeriodSummary> {
const periods = await this.getPeriods();
const period = periods.find(p => p.index === periodIndex);
if (!period) {
throw new Error(`Period ${periodIndex} not found`);
}
let startData: UserData[] = [];
let endData: UserData[] = [];
if (period.index === 0) {
// First historical period: start from 0, end with first snapshot
if (period.endAt) {
// There is a snapshot - find it and use it as endData
const snapshots = db.getSnapshots();
const endSnapshot = snapshots.find(s => s.created_at === period.endAt);
if (endSnapshot) {
// Handle double JSON encoding issue
let rawData = endSnapshot.raw_json;
if (typeof rawData === 'string' && rawData.startsWith('"')) {
rawData = JSON.parse(rawData);
}
endData = JSON.parse(rawData);
}
} else {
// No snapshots case: start from 0, end with current data
endData = await apiClient.getCurrentCosts();
}
} else if (period.isCurrent) {
// Current period: start from latest snapshot data, end with current data
const startSnapshot = db.getSnapshotById(period.startSnapshotId!);
if (startSnapshot) {
// Handle double JSON encoding issue
let rawData = startSnapshot.raw_json;
if (typeof rawData === 'string' && rawData.startsWith('"')) {
rawData = JSON.parse(rawData);
}
startData = JSON.parse(rawData);
}
endData = await apiClient.getCurrentCosts();
} else {
// Historical period: both start and end from snapshots
const startSnapshot = db.getSnapshotById(period.startSnapshotId!);
if (startSnapshot) {
// Handle double JSON encoding issue
let rawData = startSnapshot.raw_json;
if (typeof rawData === 'string' && rawData.startsWith('"')) {
rawData = JSON.parse(rawData);
}
startData = JSON.parse(rawData);
}
const snapshots = db.getSnapshots();
const endSnapshotIndex = snapshots.findIndex(s => s.created_at === period.endAt);
if (endSnapshotIndex !== -1 && snapshots[endSnapshotIndex]) {
// Handle double JSON encoding issue
let rawData = snapshots[endSnapshotIndex]!.raw_json;
if (typeof rawData === 'string' && rawData.startsWith('"')) {
rawData = JSON.parse(rawData);
}
endData = JSON.parse(rawData);
}
}
const result = computePeriodDelta(startData, endData, meId);
// Filter out users with zero activity (cost, requests, and tokens all 0)
const activeUsers = result.users.filter(u =>
u.cost > 0 || u.periodTokens > 0 || u.periodRequests > 0
);
return {
period: {
...period,
endAt: period.endAt || new Date().toISOString()
},
totals: {
totalCost: result.totalCost,
userCount: activeUsers.length
},
ranking: activeUsers
};
}
async getUserDetail(periodIndex: number, meId: string): Promise<UserDetail> {
const summary = await this.getPeriodSummary(periodIndex, meId);
const me = summary.ranking.find(u => u.id === meId);
if (!me) {
throw new Error('User not found in this period (possibly deleted)');
}
return {
id: me.id,
name: me.name,
startCost: Number(me.rawStart?.usage?.total?.cost ?? 0),
endCost: Number(me.rawEnd?.usage?.total?.cost ?? 0),
deltaCost: me.cost,
raw: {
start: me.rawStart,
end: me.rawEnd
}
};
}
}
export const billingCalculator = new BillingCalculator();