From e7f0627dc990b489de573d5d7bd454050736d124 Mon Sep 17 00:00:00 2001 From: john Date: Wed, 5 Aug 2026 09:52:13 +0800 Subject: [PATCH] fix(billing): prefer Goose session cost over token estimate Billing fell back to inflated token list-price estimates when Finish frames carried inline accumulatedCost, causing ~70x overcharge vs DeepSeek. Always fetch session accumulated_cost first; add Aug 4+ compensation script. Co-authored-by: Cursor --- billing-token-state.mjs | 12 +- billing-token-state.test.mjs | 27 ++- ...sate-billing-token-estimate-overcharge.mjs | 223 ++++++++++++++++++ 3 files changed, 254 insertions(+), 8 deletions(-) create mode 100644 scripts/compensate-billing-token-estimate-overcharge.mjs diff --git a/billing-token-state.mjs b/billing-token-state.mjs index ca6a4da..5408fef 100644 --- a/billing-token-state.mjs +++ b/billing-token-state.mjs @@ -38,15 +38,16 @@ export function enrichTokenStateForBilling( env = process.env, ) { const state = normalizeTokenState(tokenStateRaw); - if (state.accumulatedCost != null && Number(state.accumulatedCost) >= 0) { - return state; - } const sessionUsd = pickSessionAccumulatedCost(sessionCost); if (sessionUsd != null) { return { ...state, accumulatedCost: sessionUsd }; } + if (state.accumulatedCost != null && Number(state.accumulatedCost) >= 0) { + return state; + } + const estimatedUsd = estimateAccumulatedCostUsd(state, loadCostEstimateConfig(env)); if (estimatedUsd != null) { return { ...state, accumulatedCost: estimatedUsd }; @@ -61,9 +62,6 @@ export async function resolveBillingTokenState( env = process.env, ) { const state = normalizeTokenState(tokenStateRaw); - if (state.accumulatedCost != null && Number(state.accumulatedCost) >= 0) { - return state; - } if (typeof fetchSession === 'function' && sessionId) { try { @@ -71,7 +69,7 @@ export async function resolveBillingTokenState( const enriched = enrichTokenStateForBilling(state, { sessionCost: session }, env); if (enriched.accumulatedCost != null) return enriched; } catch { - // Best-effort: fall through to token estimate. + // Best-effort: fall through to inline cost / token estimate. } } diff --git a/billing-token-state.test.mjs b/billing-token-state.test.mjs index 98a3295..7c437ed 100644 --- a/billing-token-state.test.mjs +++ b/billing-token-state.test.mjs @@ -23,7 +23,16 @@ test('estimateAccumulatedCostUsd uses DeepSeek-ish defaults', () => { assert.equal(estimate, 0.281); }); -test('enrichTokenStateForBilling prefers upstream cost over estimate', () => { +test('enrichTokenStateForBilling prefers session cost over Finish inline cost', () => { + const enriched = enrichTokenStateForBilling( + { accumulatedInputTokens: 1000, accumulatedOutputTokens: 100, accumulatedCost: 0.82 }, + { sessionCost: { accumulated_cost: 0.0067 } }, + { H5_USE_BACKEND_COST: '1' }, + ); + assert.equal(enriched.accumulatedCost, 0.0067); +}); + +test('enrichTokenStateForBilling keeps Finish inline cost when session cost missing', () => { const enriched = enrichTokenStateForBilling( { accumulatedInputTokens: 1000, accumulatedOutputTokens: 100, accumulatedCost: 0.05 }, {}, @@ -78,6 +87,22 @@ test('resolveBillingTokenState fetches session before estimating', async () => { assert.equal(resolved.accumulatedCost, 0.42); }); +test('resolveBillingTokenState replaces inflated Finish cost with session cost', async () => { + const resolved = await resolveBillingTokenState( + { + accumulatedInputTokens: 280030, + accumulatedOutputTokens: 5797, + accumulatedCost: 0.082, + }, + { + sessionId: '20260804_19', + fetchSession: async () => ({ accumulated_cost: 0.006738208 }), + }, + { H5_USE_BACKEND_COST: '1' }, + ); + assert.equal(resolved.accumulatedCost, 0.006738208); +}); + test('enriched cost drives 1.2x billing instead of flat fallback', () => { const previous = { lastInputTokens: 224853, lastOutputTokens: 6685, lastAccumulatedCost: null }; const tokenState = enrichTokenStateForBilling( diff --git a/scripts/compensate-billing-token-estimate-overcharge.mjs b/scripts/compensate-billing-token-estimate-overcharge.mjs new file mode 100644 index 0000000..1069a73 --- /dev/null +++ b/scripts/compensate-billing-token-estimate-overcharge.mjs @@ -0,0 +1,223 @@ +#!/usr/bin/env node +/** + * Compensate users overcharged when billing fell back to token estimate + * instead of Goose accumulated_cost (DeepSeek cache-aware upstream cost). + * + * Usage: + * node scripts/compensate-billing-token-estimate-overcharge.mjs + * node scripts/compensate-billing-token-estimate-overcharge.mjs --since=2026-08-04 + * node scripts/compensate-billing-token-estimate-overcharge.mjs --since=2026-08-04 --apply + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import mysql from 'mysql2/promise'; +import pg from 'pg'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } +} + +loadEnvFile(path.join(root, '.env')); + +const apply = process.argv.includes('--apply'); +const sinceArg = process.argv.find((a) => a.startsWith('--since=')); +const sinceDate = sinceArg ? sinceArg.slice('--since='.length) : '2026-08-04'; +const startMs = new Date(`${sinceDate}T00:00:00+08:00`).getTime(); +const DEDupe_NOTE_PREFIX = `补偿:Token估价超扣(${sinceDate}起)`; + +function loadBillingConfig() { + const marginMultiplier = Number(process.env.H5_MARGIN_MULTIPLIER ?? 1); + return { + useBackendCost: process.env.H5_USE_BACKEND_COST === '1', + usdCnyRate: Number(process.env.H5_USD_CNY_RATE ?? 7.2), + marginMultiplier: Number.isFinite(marginMultiplier) && marginMultiplier > 0 ? marginMultiplier : 1, + minBillCents: Number(process.env.H5_MIN_BILL_CENTS ?? 1), + }; +} + +function correctTotalCents(gooseCostUsd, config) { + if (gooseCostUsd == null || gooseCostUsd <= 0) return 0; + return Math.max( + config.minBillCents, + Math.ceil(gooseCostUsd * config.usdCnyRate * 100 * config.marginMultiplier), + ); +} + +function resolvePgUrl() { + if (process.env.GOOSE_SESSION_DB_URL) return process.env.GOOSE_SESSION_DB_URL; + const host = process.env.GOOSE_SESSION_PG_HOST ?? '127.0.0.1'; + const port = process.env.GOOSE_SESSION_PG_PORT ?? '5432'; + const db = process.env.GOOSE_SESSION_PG_DATABASE ?? 'memind_sessions'; + const user = process.env.GOOSE_SESSION_PG_USER ?? 'john'; + return `postgresql://${user}@${host}:${port}/${db}`; +} + +async function main() { + if (!process.env.DATABASE_URL) { + console.error('DATABASE_URL is not configured'); + process.exit(1); + } + + const config = loadBillingConfig(); + const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 4 }); + const pgClient = new pg.Client({ connectionString: resolvePgUrl() }); + await pgClient.connect(); + + try { + const [records] = await pool.query( + `SELECT r.user_id, u.username, u.status, r.agent_session_id, r.cost_cents + FROM h5_usage_records r + JOIN h5_users u ON u.id = r.user_id + WHERE r.created_at >= ? + ORDER BY r.created_at ASC`, + [startMs], + ); + + const bySession = new Map(); + for (const row of records) { + const sid = row.agent_session_id; + if (!bySession.has(sid)) { + bySession.set(sid, { + userId: row.user_id, + username: row.username, + status: row.status, + sinceStart: 0, + all: 0, + }); + } + bySession.get(sid).sinceStart += Number(row.cost_cents); + } + + const sessionIds = [...bySession.keys()]; + if (sessionIds.length === 0) { + console.log('No usage records since', sinceDate); + return; + } + + const [allRows] = await pool.query( + `SELECT agent_session_id, SUM(cost_cents) AS total + FROM h5_usage_records + WHERE agent_session_id IN (?) + GROUP BY agent_session_id`, + [sessionIds], + ); + for (const row of allRows) { + bySession.get(row.agent_session_id).all = Number(row.total); + } + + const gooseRes = await pgClient.query( + `SELECT id, accumulated_cost FROM sessions WHERE id = ANY($1::text[])`, + [sessionIds], + ); + const gooseCostBySession = new Map( + gooseRes.rows.map((row) => [row.id, Number(row.accumulated_cost)]), + ); + + const byUser = new Map(); + for (const [sid, session] of bySession) { + const correct = correctTotalCents(gooseCostBySession.get(sid), config); + const overcharge = Math.max(0, session.all - correct); + const refund = Math.min(session.sinceStart, overcharge); + if (refund <= 0) continue; + + if (!byUser.has(session.userId)) { + byUser.set(session.userId, { + userId: session.userId, + username: session.username, + status: session.status, + refundCents: 0, + sessions: [], + }); + } + const user = byUser.get(session.userId); + user.refundCents += refund; + user.sessions.push({ sid, refund, chargedSinceStart: session.sinceStart, correctTotal: correct }); + } + + const users = [...byUser.values()].sort((a, b) => b.refundCents - a.refundCents); + const totalRefund = users.reduce((sum, user) => sum + user.refundCents, 0); + + console.log('Billing config:', config); + console.log('Since:', sinceDate, `(${startMs})`); + console.log('Affected users:', users.length); + console.log('Total refund:', `¥${(totalRefund / 100).toFixed(2)}`, `(${totalRefund} cents)`); + console.log(''); + + for (const user of users) { + console.log(`- ${user.username}: ¥${(user.refundCents / 100).toFixed(2)} (${user.sessions.length} sessions)`); + } + + if (!apply) { + console.log(''); + console.log('Dry run only. Re-run with --apply to execute.'); + return; + } + + const now = Date.now(); + for (const user of users) { + const note = `${DEDupe_NOTE_PREFIX} ¥${(user.refundCents / 100).toFixed(2)}`; + const [existing] = await pool.query( + `SELECT id FROM h5_billing_ledger + WHERE user_id = ? AND type = 'adjust' AND note = ? + LIMIT 1`, + [user.userId, note], + ); + if (existing.length) { + console.log(`SKIP ${user.username}: already compensated (${note})`); + continue; + } + + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + await conn.query( + `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at) + VALUES (?, ?, 0, ?) + ON DUPLICATE KEY UPDATE + balance_cents = balance_cents + VALUES(balance_cents), + updated_at = VALUES(updated_at)`, + [user.userId, user.refundCents, now], + ); + await conn.query( + `INSERT INTO h5_billing_ledger + (user_id, type, amount_cents, tokens, note, operator_id, created_at) + VALUES (?, 'adjust', ?, 0, ?, NULL, ?)`, + [user.userId, user.refundCents, note, now], + ); + if (user.status === 'suspended') { + await conn.query(`UPDATE h5_users SET status = 'active', updated_at = ? WHERE id = ?`, [ + now, + user.userId, + ]); + } + await conn.commit(); + console.log(`APPLIED ${user.username}: +¥${(user.refundCents / 100).toFixed(2)}`); + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + } + } finally { + await pgClient.end(); + await pool.end(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});