Files

52 lines
5.7 KiB
JavaScript
Raw Permalink Normal View History

'use strict';
// 모델별 사용량, 사용자별 원화 정산·청구(마진), 선결제 충전 관리. data/billing.json
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const settings = require('./settings');
const fx = require('./fx');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const FILE = path.join(DATA_ROOT, 'billing.json');
function load() { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (_) { return { charges: [] }; } }
function save(b) { fs.mkdirSync(DATA_ROOT, { recursive: true }); const tmp = FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(b, null, 1)); fs.renameSync(tmp, FILE); }
function addCharge({ userId, amountKrw, memo, by }) {
const b = load(); const c = { id: crypto.randomBytes(6).toString('hex'), userId, amountKrw: Math.round(+amountKrw), memo: String(memo || ''), by, at: new Date().toISOString() };
if (!c.userId || !Number.isFinite(c.amountKrw) || c.amountKrw === 0) throw Object.assign(new Error('사용자와 금액을 확인하세요'), { status: 400 });
b.charges.push(c); save(b); return c;
}
function removeCharge(id) { const b = load(); const i = b.charges.findIndex((c) => c.id === id); if (i < 0) throw Object.assign(new Error('충전 내역이 없습니다'), { status: 404 }); const [c] = b.charges.splice(i, 1); save(b); return c; }
/** 모든 사이트의 명령을 모델별·사용자별·일자별로 집계 (원화는 명령 실행일 환율 적용) */
async function summarize(sites, users) {
const s = settings.load(); const margin = (s.marginPct || 0) / 100;
const byModel = {}; const byUser = {}; const rateCache = {}; const rateErrors = [];
const rateFor = async (date) => { if (rateCache[date]) return rateCache[date]; try { rateCache[date] = await fx.getRate(date); } catch (e) { rateErrors.push(`${date}: ${e.message}`); rateCache[date] = { date, rate: 0, mid: 0, spreadPct: 0, source: '조회 실패' }; } return rateCache[date]; };
for (const site of sites) {
const uid = site.ownerId || '(소유자 없음)';
byUser[uid] = byUser[uid] || { userId: uid, sites: 0, commands: 0, tokens: 0, usd: 0, krw: 0, charge: 0, byDay: {} };
byUser[uid].sites += 1;
for (const c of site.commands || []) {
if (!c.usage) continue;
const model = c.model || (c.modelUsage && Object.keys(c.modelUsage)[0]) || 'claude-opus-5-5'; // 모델 기록 이전 명령은 당시 기본 모델로 집계
// v1.4.5: 모델별 토큰은 이번 실행분 modelUsage(costSource 가 있는 기록)만 쓴다 — 그 전 기록의 modelUsage 는 세션 누적이라 c.usage 로 센다
const mu = (c.costSource && c.modelUsage && c.modelUsage[String(model).replace(/^[a-z]+:/, '')]) || null;
const m = byModel[model] = byModel[model] || { model, calls: 0, input: 0, output: 0, cacheCreate: 0, cacheRead: 0, tokens: 0, usd: 0, estimatedCalls: 0 };
m.calls += 1; if (c.userKey) m.userKeyCalls = (m.userKeyCalls || 0) + 1; m.input += mu ? mu.inputTokens : c.usage.input; m.output += mu ? mu.outputTokens : c.usage.output; m.cacheCreate += mu ? mu.cacheCreationInputTokens : c.usage.cacheCreate; m.cacheRead += mu ? mu.cacheReadInputTokens : c.usage.cacheRead; m.tokens += c.usage.total; m.usd += c.costUsd || 0; if (c.usageEstimated) m.estimatedCalls += 1;
const day = fx.kstDate(c.startedAt || c.finishedAt || Date.now()); const r = await rateFor(day);
// v1.3.17 (B28): 작업이 끝날 때 확정된 금액(chargeKrw·chargeRate)이 있으면 그 값을 쓴다 — 지금 마진·환율로 다시 계산하면
// 지난 작업 금액이 설정 변경에 따라 바뀌어 캐시 원장과 맞지 않았다. 확정 값이 없는 옛 기록만 지금 기준으로 추정한다.
const fixed = Number.isFinite(c.chargeKrw); const rate = fixed && Number.isFinite(c.chargeRate) ? c.chargeRate : r.rate;
const krw = c.userKey ? 0 : (c.costUsd || 0) * rate; const charge = c.userKey ? 0 : (fixed ? c.chargeKrw : krw * (1 + margin));
const u = byUser[uid]; u.commands += 1; u.tokens += c.usage.total; u.usd += c.costUsd || 0; u.krw += krw; u.charge += charge;
u.byDay[day] = u.byDay[day] || { day, rate: r.rate, source: r.source, usd: 0, krw: 0, charge: 0, commands: 0 }; u.byDay[day].usd += c.costUsd || 0; u.byDay[day].krw += krw; u.byDay[day].charge += charge; u.byDay[day].commands += 1;
}
}
const b = load(); const userMap = Object.fromEntries((users || []).map((u) => [u.id, u]));
for (const u of users || []) byUser[u.id] = byUser[u.id] || { userId: u.id, sites: 0, commands: 0, tokens: 0, usd: 0, krw: 0, charge: 0, byDay: {} };
const rows = Object.values(byUser).map((u) => { const charges = b.charges.filter((c) => c.userId === u.userId); const prepaid = charges.reduce((a, c) => a + c.amountKrw, 0); const info = userMap[u.userId] || {}; return { ...u, name: info.name || '', role: info.role || '', pendingApproval: !!info.pendingApproval /* v1.5.27 */, accountType: info.role ? (info.role === 'admin' || info.accountType !== 'subdomain' ? 'domain' : 'subdomain') : '', prepaid, balance: prepaid - u.charge, charges, byDay: Object.values(u.byDay).sort((a, b2) => b2.day.localeCompare(a.day)) }; }).sort((a, b2) => b2.charge - a.charge);
const today = await rateFor(fx.kstDate());
return { models: Object.values(byModel).sort((a, b2) => b2.usd - a.usd), users: rows, marginPct: s.marginPct, fx: { mode: s.fx.mode, spreadPct: s.fx.spreadPct, manualRate: s.fx.manualRate, today }, rateErrors, totals: rows.reduce((a, u) => ({ usd: a.usd + u.usd, krw: a.krw + u.krw, charge: a.charge + u.charge, prepaid: a.prepaid + u.prepaid, tokens: a.tokens + u.tokens }), { usd: 0, krw: 0, charge: 0, prepaid: 0, tokens: 0 }) };
}
module.exports = { summarize, addCharge, removeCharge, load };