From fdd408d06bcdf612612f0157cb2f8e1e52ac6b8d Mon Sep 17 00:00:00 2001 From: stringadmin Date: Mon, 8 Jun 2026 15:46:28 +0800 Subject: [PATCH] Convert billing to 1-to-100 credits --- src/billing.js | 63 ++++++++++++++++++++++++---------------- src/paymentAlipay.js | 12 ++++---- src/paymentWechat.js | 12 ++++---- src/pricing.js | 2 +- src/routes/admin.js | 27 +++++++++++++---- src/routes/context.js | 4 +++ src/routes/enterprise.js | 25 +++++++++++----- src/routes/user.js | 6 ++-- 8 files changed, 98 insertions(+), 53 deletions(-) diff --git a/src/billing.js b/src/billing.js index 37c5a90..7e6f0c9 100644 --- a/src/billing.js +++ b/src/billing.js @@ -1,29 +1,44 @@ /** - * Billing module — handles balance deduction (in cents), package quotas, + * Billing module — handles balance deduction, package quotas, * transactions, and key-lease pre-authorization. * - * Money conventions: - * - balance: cents (分, 1/100 CNY) — stored in users.balance_cents and enterprises.balance_cents - * - prices: mills (厘, 1/1000 CNY) — stored in model_prices.*_mills - * - cost calculation: mills → convert to cents at deduction time (divide by 10, floor) - * - transactions: cents — amount_cents, balance_after_cents + * Unit conventions: + * - payment_orders.amount_cents / packages.price_cents: cash amount in CNY cents. + * - users.balance_cents / enterprises.balance_cents / transactions.amount_cents: + * credit units, where 100 units = 1 platform credit. + * - model_prices.*_mills: CNY mills. 1 CNY = 100 credits, so 1 mill = 10 credit units. * * Flow: * - Enterprise admin recharges enterprise pool → distributes to employee users - * - API deductions come from users.balance_cents (per-user) + * - API deductions come from users.balance_cents (per-user credit balance) * - Personal users recharge their own users.balance_cents directly */ const { pool, withTransaction } = require("./db"); const { calculateCostMills, getModelPrice } = require("./pricing"); -const IMAGE_GENERATION_FLAT_COST_CENTS = 20; +const CREDIT_UNITS_PER_CREDIT = 100; +const CREDIT_UNITS_PER_CNY_CENT = 100; +const CREDIT_UNITS_PER_CNY_MILL = 10; +const IMAGE_GENERATION_FLAT_COST_CENTS = 20 * CREDIT_UNITS_PER_CREDIT; + +function creditsToCreditUnits(credits) { + return Math.max(0, Math.round(Number(credits || 0) * CREDIT_UNITS_PER_CREDIT)); +} function formatCreditsFromCents(amountCents) { - const value = Number(amountCents || 0) / 100; + const value = Number(amountCents || 0) / CREDIT_UNITS_PER_CREDIT; return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2))); } +function cashCentsToCreditUnits(amountCents) { + return Math.max(0, Math.round(Number(amountCents || 0) * CREDIT_UNITS_PER_CNY_CENT)); +} + +function millsToCreditUnits(mills) { + return Math.max(0, Math.round(Number(mills || 0) * CREDIT_UNITS_PER_CNY_MILL)); +} + async function recordEnterpriseCreditLedger(client, entry) { const enterpriseId = entry?.enterpriseId || null; const userId = entry?.userId || null; @@ -114,10 +129,6 @@ async function getEnterpriseName(enterpriseId) { return rows[0] ? rows[0].name : null; } -function millsToCents(mills) { - return Math.floor(mills / 10); -} - // ── Atomic balance helpers ─────────────────────────────────────────── async function atomicDeductUserBalance(client, userId, amountCents) { @@ -167,7 +178,7 @@ async function preauthorizeCall(userId, provider) { const { rows } = await pool.query( ` - SELECT COALESCE(CAST(ROUND(AVG(cost_estimate * 100)::numeric) AS INTEGER), 0) AS avg_cents + SELECT COALESCE(CAST(ROUND(AVG(cost_estimate * 10000)::numeric) AS INTEGER), 0) AS avg_cents FROM api_call_logs WHERE provider = $1 AND status = 'success' @@ -185,10 +196,9 @@ async function preauthorizeCall(userId, provider) { const bufferedEstimate = Math.ceil(estimatedCostCents * 1.2); if (balanceCents < bufferedEstimate) { - const credits = Math.floor(balanceCents / 100); return { authorized: false, - message: `账户积分不足,请充值 (当前 ${credits} 积分,预估需要 ${Math.ceil(bufferedEstimate / 100)} 积分)`, + message: `账户积分不足,请充值 (当前 ${formatCreditsFromCents(balanceCents)} 积分,预估需要 ${formatCreditsFromCents(bufferedEstimate)} 积分)`, }; } @@ -205,9 +215,9 @@ async function deductForApiCall(userId, model, promptTokens, completionTokens) { return { success: true, costCents: 0, deductionType: "none", message: "No pricing" }; } - const costCents = millsToCents(costMills); + const costCents = millsToCreditUnits(costMills); if (costCents <= 0) { - return { success: true, costCents: 0, deductionType: "none", message: "Cost below 1 cent" }; + return { success: true, costCents: 0, deductionType: "none", message: "Cost below minimum credit unit" }; } const billingState = await getUserBillingState(userId); @@ -408,7 +418,7 @@ async function tryDeductFromUserBalance(userId, enterpriseId, amountCents, ledge userId, -amountCents, newBal, - `API 调用扣费 ${Math.ceil(amountCents / 100)} 积分`, + `API 调用扣费 ${formatCreditsFromCents(amountCents)} 积分`, ], ); @@ -429,16 +439,15 @@ async function tryDeductFromUserBalance(userId, enterpriseId, amountCents, ledge if (newBalanceCents == null) { const currentBalance = await getUserBalanceCents(userId); - const credits = Math.floor((currentBalance || 0) / 100); return { success: false, - message: `积分不足 (当前 ${credits} 积分,需要 ${Math.ceil(amountCents / 100)} 积分)`, + message: `积分不足 (当前 ${formatCreditsFromCents(currentBalance || 0)} 积分,需要 ${formatCreditsFromCents(amountCents)} 积分)`, }; } return { success: true, - message: `Deducted ${Math.ceil(amountCents / 100)} credits, balance: ${Math.floor(newBalanceCents / 100)} credits`, + message: `Deducted ${formatCreditsFromCents(amountCents)} credits, balance: ${formatCreditsFromCents(newBalanceCents)} credits`, }; } @@ -484,7 +493,7 @@ async function settleLease(leaseId, actualCostCents) { userId, -diffCents, newBal, - `API 预估差额扣费 ${Math.ceil(diffCents / 100)} 积分`, + `API 预估差额扣费 ${formatCreditsFromCents(diffCents)} 积分`, ], ); } @@ -503,7 +512,7 @@ async function settleLease(leaseId, actualCostCents) { userId, refundCents, newBal, - `API 预估差额退回 ${Math.ceil(refundCents / 100)} 积分`, + `API 预估差额退回 ${formatCreditsFromCents(refundCents)} 积分`, ], ); } @@ -628,7 +637,7 @@ async function distributeCredits(enterpriseId, targetUserId, amountCents, adminU targetUserId, amountCents, newUserBal, - `从企业池获得 ${Math.floor(amountCents / 100)} 积分`, + `从企业池获得 ${formatCreditsFromCents(amountCents)} 积分`, adminUserId, ], ); @@ -761,4 +770,8 @@ module.exports = { preauthorizeCall, settleLease, forceSettleLease, + creditsToCreditUnits, + cashCentsToCreditUnits, + millsToCreditUnits, + formatCreditsFromCents, }; diff --git a/src/paymentAlipay.js b/src/paymentAlipay.js index 29c71d7..349fa5c 100644 --- a/src/paymentAlipay.js +++ b/src/paymentAlipay.js @@ -1,7 +1,7 @@ const fs = require("node:fs"); const { AlipaySdk } = require("alipay-sdk"); const { pool, withTransaction } = require("./db"); -const { creditBalance, creditUserBalance, activatePackage } = require("./billing"); +const { cashCentsToCreditUnits, creditBalance, creditUserBalance, activatePackage, formatCreditsFromCents } = require("./billing"); let alipayInstance = null; @@ -130,17 +130,19 @@ async function handlePaymentSuccess(orderNo, tradeNo) { ); if (order.type === "personal_recharge" && order.user_id) { + const creditUnits = cashCentsToCreditUnits(order.amount_cents); await creditUserBalance( order.user_id, - order.amount_cents, - `支付宝充值 ${Math.floor(order.amount_cents / 100)} 积分`, + creditUnits, + `支付宝充值 ${formatCreditsFromCents(creditUnits)} 积分`, orderNo, ); } else if (order.type === "recharge") { + const creditUnits = cashCentsToCreditUnits(order.amount_cents); await creditBalance( order.enterprise_id, - order.amount_cents, - `支付宝充值 ${Math.floor(order.amount_cents / 100)} 积分`, + creditUnits, + `支付宝充值 ${formatCreditsFromCents(creditUnits)} 积分`, orderNo, ); } else if (order.type === "package" && order.package_id) { diff --git a/src/paymentWechat.js b/src/paymentWechat.js index 05374d2..8173224 100644 --- a/src/paymentWechat.js +++ b/src/paymentWechat.js @@ -2,7 +2,7 @@ const _crypto = require("node:crypto"); const fs = require("node:fs"); const WxPay = require("wechatpay-node-v3"); const { pool, withTransaction } = require("./db"); -const { creditBalance, creditUserBalance, activatePackage } = require("./billing"); +const { cashCentsToCreditUnits, creditBalance, creditUserBalance, activatePackage, formatCreditsFromCents } = require("./billing"); let wxPayInstance = null; @@ -140,17 +140,19 @@ async function handlePaymentSuccess(orderNo, transactionId) { ); if (order.type === "personal_recharge" && order.user_id) { + const creditUnits = cashCentsToCreditUnits(order.amount_cents); await creditUserBalance( order.user_id, - order.amount_cents, - `微信充值 ${Math.floor(order.amount_cents / 100)} 积分`, + creditUnits, + `微信充值 ${formatCreditsFromCents(creditUnits)} 积分`, orderNo, ); } else if (order.type === "recharge") { + const creditUnits = cashCentsToCreditUnits(order.amount_cents); await creditBalance( order.enterprise_id, - order.amount_cents, - `微信充值 ${Math.floor(order.amount_cents / 100)} 积分`, + creditUnits, + `微信充值 ${formatCreditsFromCents(creditUnits)} 积分`, orderNo, ); } else if (order.type === "package" && order.package_id) { diff --git a/src/pricing.js b/src/pricing.js index 607fe83..e3bf12d 100644 --- a/src/pricing.js +++ b/src/pricing.js @@ -176,7 +176,7 @@ async function getAverageCostCents(provider) { const { rows } = await pool.query( ` SELECT CAST(ROUND(AVG(CASE - WHEN cost_estimate IS NOT NULL THEN cost_estimate * 100 + WHEN cost_estimate IS NOT NULL THEN cost_estimate * 10000 ELSE 0 END)::numeric) AS INTEGER) AS avg_cents FROM api_call_logs diff --git a/src/routes/admin.js b/src/routes/admin.js index 05f41b4..99a419b 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -6,6 +6,8 @@ const { listModelPrices, loadPriceCache, creditUserBalance, + creditsToCreditUnits, + formatCreditsFromCents, pool, validateUsername, validatePassword, @@ -156,14 +158,18 @@ function registerAdminRoutes(router) { router.post("/admin/users/:id/credit", requireAuth, requireAdmin, async (req, res) => { const targetUserId = Number(req.params.id); - const { amountCents } = req.body; - if (!amountCents || amountCents <= 0) return res.status(400).json({ error: "积分必须大于 0" }); + const { amountCredits, amountCents } = req.body; + const creditUnits = + amountCredits !== undefined && amountCredits !== null && amountCredits !== "" + ? creditsToCreditUnits(amountCredits) + : Number(amountCents); + if (!creditUnits || creditUnits <= 0) return res.status(400).json({ error: "积分必须大于 0" }); try { const newBalance = await creditUserBalance( targetUserId, - amountCents, - `管理员 ${req.user.username} 发放 ${Math.floor(amountCents / 100)} 积分`, + creditUnits, + `管理员 ${req.user.username} 发放 ${formatCreditsFromCents(creditUnits)} 积分`, ); res.json({ success: true, newBalanceCents: newBalance }); } catch (err) { @@ -547,6 +553,8 @@ function registerAdminRoutes(router) { name, description = "", priceCents, + credits, + amountCredits, creditsCents = 0, imageQuota = 0, videoQuota = 0, @@ -572,7 +580,9 @@ function registerAdminRoutes(router) { name, description, Number(priceCents), - Number(creditsCents || 0), + credits !== undefined || amountCredits !== undefined + ? creditsToCreditUnits(credits ?? amountCredits) + : Number(creditsCents || 0), Number(imageQuota || 0), Number(videoQuota || 0), Number(textQuota || 0), @@ -599,6 +609,8 @@ function registerAdminRoutes(router) { name, description, priceCents, + credits, + amountCredits, creditsCents, imageQuota, videoQuota, @@ -623,7 +635,10 @@ function registerAdminRoutes(router) { updates.push(`price_cents = $${idx++}`); params.push(Number(priceCents)); } - if (creditsCents !== undefined) { + if (credits !== undefined || amountCredits !== undefined) { + updates.push(`credits_cents = $${idx++}`); + params.push(creditsToCreditUnits(credits ?? amountCredits)); + } else if (creditsCents !== undefined) { updates.push(`credits_cents = $${idx++}`); params.push(Number(creditsCents)); } diff --git a/src/routes/context.js b/src/routes/context.js index 2a98229..a15138c 100644 --- a/src/routes/context.js +++ b/src/routes/context.js @@ -32,6 +32,8 @@ const { getUserEnterpriseId, getEnterpriseName, preauthorizeCall, + creditsToCreditUnits, + formatCreditsFromCents, } = require("../billing"); const wechatPay = require("../paymentWechat"); const alipay = require("../paymentAlipay"); @@ -793,6 +795,8 @@ module.exports = { getUserEnterpriseId, getEnterpriseName, preauthorizeCall, + creditsToCreditUnits, + formatCreditsFromCents, wechatPay, alipay, crypto, diff --git a/src/routes/enterprise.js b/src/routes/enterprise.js index 88dd914..c5d152f 100644 --- a/src/routes/enterprise.js +++ b/src/routes/enterprise.js @@ -2,6 +2,7 @@ const { requireAuth, requireEnterpriseAdmin, distributeCredits, + creditsToCreditUnits, getEnterpriseFinancials, getEnterpriseName, pool, @@ -302,25 +303,33 @@ function registerEnterpriseRoutes(router) { }); router.post("/enterprise/distribute", requireAuth, requireEnterpriseAdmin, async (req, res) => { - const { userId, amountCents, distributions } = req.body; + const { userId, amountCredits, amountCents, distributions } = req.body; try { if (distributions && Array.isArray(distributions)) { for (const d of distributions) { - if (!d.userId || !d.amountCents || d.amountCents <= 0) { + const creditUnits = + d.amountCredits !== undefined && d.amountCredits !== null && d.amountCredits !== "" + ? creditsToCreditUnits(d.amountCredits) + : Number(d.amountCents); + if (!d.userId || !creditUnits || creditUnits <= 0) { return res .status(400) - .json({ error: "每条分发记录必须包含有效的 userId 和 amountCents" }); + .json({ error: "每条分发记录必须包含有效的 userId 和 amountCredits" }); } - await distributeCredits(req.user.enterpriseId, d.userId, d.amountCents, req.user.id); + await distributeCredits(req.user.enterpriseId, d.userId, creditUnits, req.user.id); } res.json({ success: true, count: distributions.length }); - } else if (userId && amountCents) { - if (amountCents <= 0) return res.status(400).json({ error: "分发积分必须大于0" }); + } else if (userId && (amountCredits || amountCents)) { + const creditUnits = + amountCredits !== undefined && amountCredits !== null && amountCredits !== "" + ? creditsToCreditUnits(amountCredits) + : Number(amountCents); + if (!creditUnits || creditUnits <= 0) return res.status(400).json({ error: "分发积分必须大于0" }); const result = await distributeCredits( req.user.enterpriseId, userId, - amountCents, + creditUnits, req.user.id, ); res.json({ success: true, ...result }); @@ -349,7 +358,7 @@ function registerEnterpriseRoutes(router) { u.username, u.balance_cents AS current_balance_cents, COUNT(acl.id) AS total_calls, - COALESCE(SUM(CASE WHEN acl.cost_estimate IS NOT NULL THEN CAST(ROUND((acl.cost_estimate * 100)::numeric) AS INTEGER) ELSE 0 END), 0) AS total_cost_cents, + COALESCE(SUM(CASE WHEN acl.cost_estimate IS NOT NULL THEN CAST(ROUND((acl.cost_estimate * 10000)::numeric) AS INTEGER) ELSE 0 END), 0) AS total_cost_cents, MAX(acl.created_at) AS last_active FROM users u LEFT JOIN api_call_logs acl ON acl.user_id = u.id AND acl.status = 'success' diff --git a/src/routes/user.js b/src/routes/user.js index 70c36f1..5e0c24e 100644 --- a/src/routes/user.js +++ b/src/routes/user.js @@ -136,7 +136,7 @@ function registerUserRoutes(router) { CASE WHEN billing_refunded = 1 THEN 0 WHEN cost_cents > 0 THEN cost_cents - WHEN status = 'completed' AND type = 'image' THEN 20 + WHEN status = 'completed' AND type = 'image' THEN 2000 WHEN status = 'completed' AND type = 'video' THEN 500 ELSE 0 END @@ -162,7 +162,7 @@ function registerUserRoutes(router) { resolution = params.resolution || params.quality || params.ratio || null; if (row.status === "completed") { if (row.type === "image") { - estimatedCents = 20; + estimatedCents = 2000; } else if (row.type === "video") { const dur = params.duration || 5; const res = String(params.resolution || params.quality || "").toUpperCase(); @@ -209,7 +209,7 @@ function registerUserRoutes(router) { CASE WHEN billing_refunded = 1 THEN 0 WHEN cost_cents > 0 THEN cost_cents - WHEN status = 'completed' AND type = 'image' THEN 20 + WHEN status = 'completed' AND type = 'image' THEN 2000 WHEN status = 'completed' AND type = 'video' THEN 500 ELSE 0 END