Convert billing to 1-to-100 credits

This commit is contained in:
stringadmin
2026-06-08 15:46:28 +08:00
parent 855fdfc4ff
commit fdd408d06b
8 changed files with 98 additions and 53 deletions
+38 -25
View File
@@ -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. * transactions, and key-lease pre-authorization.
* *
* Money conventions: * Unit conventions:
* - balance: cents (分, 1/100 CNY) — stored in users.balance_cents and enterprises.balance_cents * - payment_orders.amount_cents / packages.price_cents: cash amount in CNY cents.
* - prices: mills (厘, 1/1000 CNY) — stored in model_prices.*_mills * - users.balance_cents / enterprises.balance_cents / transactions.amount_cents:
* - cost calculation: mills → convert to cents at deduction time (divide by 10, floor) * credit units, where 100 units = 1 platform credit.
* - transactions: cents — amount_cents, balance_after_cents * - model_prices.*_mills: CNY mills. 1 CNY = 100 credits, so 1 mill = 10 credit units.
* *
* Flow: * Flow:
* - Enterprise admin recharges enterprise pool → distributes to employee users * - 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 * - Personal users recharge their own users.balance_cents directly
*/ */
const { pool, withTransaction } = require("./db"); const { pool, withTransaction } = require("./db");
const { calculateCostMills, getModelPrice } = require("./pricing"); 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) { 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))); 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) { async function recordEnterpriseCreditLedger(client, entry) {
const enterpriseId = entry?.enterpriseId || null; const enterpriseId = entry?.enterpriseId || null;
const userId = entry?.userId || null; const userId = entry?.userId || null;
@@ -114,10 +129,6 @@ async function getEnterpriseName(enterpriseId) {
return rows[0] ? rows[0].name : null; return rows[0] ? rows[0].name : null;
} }
function millsToCents(mills) {
return Math.floor(mills / 10);
}
// ── Atomic balance helpers ─────────────────────────────────────────── // ── Atomic balance helpers ───────────────────────────────────────────
async function atomicDeductUserBalance(client, userId, amountCents) { async function atomicDeductUserBalance(client, userId, amountCents) {
@@ -167,7 +178,7 @@ async function preauthorizeCall(userId, provider) {
const { rows } = await pool.query( 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 FROM api_call_logs
WHERE provider = $1 WHERE provider = $1
AND status = 'success' AND status = 'success'
@@ -185,10 +196,9 @@ async function preauthorizeCall(userId, provider) {
const bufferedEstimate = Math.ceil(estimatedCostCents * 1.2); const bufferedEstimate = Math.ceil(estimatedCostCents * 1.2);
if (balanceCents < bufferedEstimate) { if (balanceCents < bufferedEstimate) {
const credits = Math.floor(balanceCents / 100);
return { return {
authorized: false, 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" }; return { success: true, costCents: 0, deductionType: "none", message: "No pricing" };
} }
const costCents = millsToCents(costMills); const costCents = millsToCreditUnits(costMills);
if (costCents <= 0) { 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); const billingState = await getUserBillingState(userId);
@@ -408,7 +418,7 @@ async function tryDeductFromUserBalance(userId, enterpriseId, amountCents, ledge
userId, userId,
-amountCents, -amountCents,
newBal, newBal,
`API 调用扣费 ${Math.ceil(amountCents / 100)} 积分`, `API 调用扣费 ${formatCreditsFromCents(amountCents)} 积分`,
], ],
); );
@@ -429,16 +439,15 @@ async function tryDeductFromUserBalance(userId, enterpriseId, amountCents, ledge
if (newBalanceCents == null) { if (newBalanceCents == null) {
const currentBalance = await getUserBalanceCents(userId); const currentBalance = await getUserBalanceCents(userId);
const credits = Math.floor((currentBalance || 0) / 100);
return { return {
success: false, success: false,
message: `积分不足 (当前 ${credits} 积分,需要 ${Math.ceil(amountCents / 100)} 积分)`, message: `积分不足 (当前 ${formatCreditsFromCents(currentBalance || 0)} 积分,需要 ${formatCreditsFromCents(amountCents)} 积分)`,
}; };
} }
return { return {
success: true, 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, userId,
-diffCents, -diffCents,
newBal, newBal,
`API 预估差额扣费 ${Math.ceil(diffCents / 100)} 积分`, `API 预估差额扣费 ${formatCreditsFromCents(diffCents)} 积分`,
], ],
); );
} }
@@ -503,7 +512,7 @@ async function settleLease(leaseId, actualCostCents) {
userId, userId,
refundCents, refundCents,
newBal, newBal,
`API 预估差额退回 ${Math.ceil(refundCents / 100)} 积分`, `API 预估差额退回 ${formatCreditsFromCents(refundCents)} 积分`,
], ],
); );
} }
@@ -628,7 +637,7 @@ async function distributeCredits(enterpriseId, targetUserId, amountCents, adminU
targetUserId, targetUserId,
amountCents, amountCents,
newUserBal, newUserBal,
`从企业池获得 ${Math.floor(amountCents / 100)} 积分`, `从企业池获得 ${formatCreditsFromCents(amountCents)} 积分`,
adminUserId, adminUserId,
], ],
); );
@@ -761,4 +770,8 @@ module.exports = {
preauthorizeCall, preauthorizeCall,
settleLease, settleLease,
forceSettleLease, forceSettleLease,
creditsToCreditUnits,
cashCentsToCreditUnits,
millsToCreditUnits,
formatCreditsFromCents,
}; };
+7 -5
View File
@@ -1,7 +1,7 @@
const fs = require("node:fs"); const fs = require("node:fs");
const { AlipaySdk } = require("alipay-sdk"); const { AlipaySdk } = require("alipay-sdk");
const { pool, withTransaction } = require("./db"); const { pool, withTransaction } = require("./db");
const { creditBalance, creditUserBalance, activatePackage } = require("./billing"); const { cashCentsToCreditUnits, creditBalance, creditUserBalance, activatePackage, formatCreditsFromCents } = require("./billing");
let alipayInstance = null; let alipayInstance = null;
@@ -130,17 +130,19 @@ async function handlePaymentSuccess(orderNo, tradeNo) {
); );
if (order.type === "personal_recharge" && order.user_id) { if (order.type === "personal_recharge" && order.user_id) {
const creditUnits = cashCentsToCreditUnits(order.amount_cents);
await creditUserBalance( await creditUserBalance(
order.user_id, order.user_id,
order.amount_cents, creditUnits,
`支付宝充值 ${Math.floor(order.amount_cents / 100)} 积分`, `支付宝充值 ${formatCreditsFromCents(creditUnits)} 积分`,
orderNo, orderNo,
); );
} else if (order.type === "recharge") { } else if (order.type === "recharge") {
const creditUnits = cashCentsToCreditUnits(order.amount_cents);
await creditBalance( await creditBalance(
order.enterprise_id, order.enterprise_id,
order.amount_cents, creditUnits,
`支付宝充值 ${Math.floor(order.amount_cents / 100)} 积分`, `支付宝充值 ${formatCreditsFromCents(creditUnits)} 积分`,
orderNo, orderNo,
); );
} else if (order.type === "package" && order.package_id) { } else if (order.type === "package" && order.package_id) {
+7 -5
View File
@@ -2,7 +2,7 @@ const _crypto = require("node:crypto");
const fs = require("node:fs"); const fs = require("node:fs");
const WxPay = require("wechatpay-node-v3"); const WxPay = require("wechatpay-node-v3");
const { pool, withTransaction } = require("./db"); const { pool, withTransaction } = require("./db");
const { creditBalance, creditUserBalance, activatePackage } = require("./billing"); const { cashCentsToCreditUnits, creditBalance, creditUserBalance, activatePackage, formatCreditsFromCents } = require("./billing");
let wxPayInstance = null; let wxPayInstance = null;
@@ -140,17 +140,19 @@ async function handlePaymentSuccess(orderNo, transactionId) {
); );
if (order.type === "personal_recharge" && order.user_id) { if (order.type === "personal_recharge" && order.user_id) {
const creditUnits = cashCentsToCreditUnits(order.amount_cents);
await creditUserBalance( await creditUserBalance(
order.user_id, order.user_id,
order.amount_cents, creditUnits,
`微信充值 ${Math.floor(order.amount_cents / 100)} 积分`, `微信充值 ${formatCreditsFromCents(creditUnits)} 积分`,
orderNo, orderNo,
); );
} else if (order.type === "recharge") { } else if (order.type === "recharge") {
const creditUnits = cashCentsToCreditUnits(order.amount_cents);
await creditBalance( await creditBalance(
order.enterprise_id, order.enterprise_id,
order.amount_cents, creditUnits,
`微信充值 ${Math.floor(order.amount_cents / 100)} 积分`, `微信充值 ${formatCreditsFromCents(creditUnits)} 积分`,
orderNo, orderNo,
); );
} else if (order.type === "package" && order.package_id) { } else if (order.type === "package" && order.package_id) {
+1 -1
View File
@@ -176,7 +176,7 @@ async function getAverageCostCents(provider) {
const { rows } = await pool.query( const { rows } = await pool.query(
` `
SELECT CAST(ROUND(AVG(CASE 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 ELSE 0
END)::numeric) AS INTEGER) AS avg_cents END)::numeric) AS INTEGER) AS avg_cents
FROM api_call_logs FROM api_call_logs
+21 -6
View File
@@ -6,6 +6,8 @@ const {
listModelPrices, listModelPrices,
loadPriceCache, loadPriceCache,
creditUserBalance, creditUserBalance,
creditsToCreditUnits,
formatCreditsFromCents,
pool, pool,
validateUsername, validateUsername,
validatePassword, validatePassword,
@@ -156,14 +158,18 @@ function registerAdminRoutes(router) {
router.post("/admin/users/:id/credit", requireAuth, requireAdmin, async (req, res) => { router.post("/admin/users/:id/credit", requireAuth, requireAdmin, async (req, res) => {
const targetUserId = Number(req.params.id); const targetUserId = Number(req.params.id);
const { amountCents } = req.body; const { amountCredits, amountCents } = req.body;
if (!amountCents || amountCents <= 0) return res.status(400).json({ error: "积分必须大于 0" }); const creditUnits =
amountCredits !== undefined && amountCredits !== null && amountCredits !== ""
? creditsToCreditUnits(amountCredits)
: Number(amountCents);
if (!creditUnits || creditUnits <= 0) return res.status(400).json({ error: "积分必须大于 0" });
try { try {
const newBalance = await creditUserBalance( const newBalance = await creditUserBalance(
targetUserId, targetUserId,
amountCents, creditUnits,
`管理员 ${req.user.username} 发放 ${Math.floor(amountCents / 100)} 积分`, `管理员 ${req.user.username} 发放 ${formatCreditsFromCents(creditUnits)} 积分`,
); );
res.json({ success: true, newBalanceCents: newBalance }); res.json({ success: true, newBalanceCents: newBalance });
} catch (err) { } catch (err) {
@@ -547,6 +553,8 @@ function registerAdminRoutes(router) {
name, name,
description = "", description = "",
priceCents, priceCents,
credits,
amountCredits,
creditsCents = 0, creditsCents = 0,
imageQuota = 0, imageQuota = 0,
videoQuota = 0, videoQuota = 0,
@@ -572,7 +580,9 @@ function registerAdminRoutes(router) {
name, name,
description, description,
Number(priceCents), Number(priceCents),
Number(creditsCents || 0), credits !== undefined || amountCredits !== undefined
? creditsToCreditUnits(credits ?? amountCredits)
: Number(creditsCents || 0),
Number(imageQuota || 0), Number(imageQuota || 0),
Number(videoQuota || 0), Number(videoQuota || 0),
Number(textQuota || 0), Number(textQuota || 0),
@@ -599,6 +609,8 @@ function registerAdminRoutes(router) {
name, name,
description, description,
priceCents, priceCents,
credits,
amountCredits,
creditsCents, creditsCents,
imageQuota, imageQuota,
videoQuota, videoQuota,
@@ -623,7 +635,10 @@ function registerAdminRoutes(router) {
updates.push(`price_cents = $${idx++}`); updates.push(`price_cents = $${idx++}`);
params.push(Number(priceCents)); 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++}`); updates.push(`credits_cents = $${idx++}`);
params.push(Number(creditsCents)); params.push(Number(creditsCents));
} }
+4
View File
@@ -32,6 +32,8 @@ const {
getUserEnterpriseId, getUserEnterpriseId,
getEnterpriseName, getEnterpriseName,
preauthorizeCall, preauthorizeCall,
creditsToCreditUnits,
formatCreditsFromCents,
} = require("../billing"); } = require("../billing");
const wechatPay = require("../paymentWechat"); const wechatPay = require("../paymentWechat");
const alipay = require("../paymentAlipay"); const alipay = require("../paymentAlipay");
@@ -793,6 +795,8 @@ module.exports = {
getUserEnterpriseId, getUserEnterpriseId,
getEnterpriseName, getEnterpriseName,
preauthorizeCall, preauthorizeCall,
creditsToCreditUnits,
formatCreditsFromCents,
wechatPay, wechatPay,
alipay, alipay,
crypto, crypto,
+17 -8
View File
@@ -2,6 +2,7 @@ const {
requireAuth, requireAuth,
requireEnterpriseAdmin, requireEnterpriseAdmin,
distributeCredits, distributeCredits,
creditsToCreditUnits,
getEnterpriseFinancials, getEnterpriseFinancials,
getEnterpriseName, getEnterpriseName,
pool, pool,
@@ -302,25 +303,33 @@ function registerEnterpriseRoutes(router) {
}); });
router.post("/enterprise/distribute", requireAuth, requireEnterpriseAdmin, async (req, res) => { router.post("/enterprise/distribute", requireAuth, requireEnterpriseAdmin, async (req, res) => {
const { userId, amountCents, distributions } = req.body; const { userId, amountCredits, amountCents, distributions } = req.body;
try { try {
if (distributions && Array.isArray(distributions)) { if (distributions && Array.isArray(distributions)) {
for (const d of 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 return res
.status(400) .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 }); res.json({ success: true, count: distributions.length });
} else if (userId && amountCents) { } else if (userId && (amountCredits || amountCents)) {
if (amountCents <= 0) return res.status(400).json({ error: "分发积分必须大于0" }); 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( const result = await distributeCredits(
req.user.enterpriseId, req.user.enterpriseId,
userId, userId,
amountCents, creditUnits,
req.user.id, req.user.id,
); );
res.json({ success: true, ...result }); res.json({ success: true, ...result });
@@ -349,7 +358,7 @@ function registerEnterpriseRoutes(router) {
u.username, u.username,
u.balance_cents AS current_balance_cents, u.balance_cents AS current_balance_cents,
COUNT(acl.id) AS total_calls, 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 MAX(acl.created_at) AS last_active
FROM users u FROM users u
LEFT JOIN api_call_logs acl ON acl.user_id = u.id AND acl.status = 'success' LEFT JOIN api_call_logs acl ON acl.user_id = u.id AND acl.status = 'success'
+3 -3
View File
@@ -136,7 +136,7 @@ function registerUserRoutes(router) {
CASE CASE
WHEN billing_refunded = 1 THEN 0 WHEN billing_refunded = 1 THEN 0
WHEN cost_cents > 0 THEN cost_cents 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 WHEN status = 'completed' AND type = 'video' THEN 500
ELSE 0 ELSE 0
END END
@@ -162,7 +162,7 @@ function registerUserRoutes(router) {
resolution = params.resolution || params.quality || params.ratio || null; resolution = params.resolution || params.quality || params.ratio || null;
if (row.status === "completed") { if (row.status === "completed") {
if (row.type === "image") { if (row.type === "image") {
estimatedCents = 20; estimatedCents = 2000;
} else if (row.type === "video") { } else if (row.type === "video") {
const dur = params.duration || 5; const dur = params.duration || 5;
const res = String(params.resolution || params.quality || "").toUpperCase(); const res = String(params.resolution || params.quality || "").toUpperCase();
@@ -209,7 +209,7 @@ function registerUserRoutes(router) {
CASE CASE
WHEN billing_refunded = 1 THEN 0 WHEN billing_refunded = 1 THEN 0
WHEN cost_cents > 0 THEN cost_cents 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 WHEN status = 'completed' AND type = 'video' THEN 500
ELSE 0 ELSE 0
END END