fix:统一系统货币单位规则
This commit is contained in:
1 parent
f3b4be9b2a
commit
cfc6b2589a
4 files changed
+67
-12
No files matched your search
@@ -272,11 +272,49 @@ wallets 表包含 `frozen_balance` 字段,用于支付中/退款中的金额
|
||||
| 套餐价格 | **CNY(人民币)** | 管理后台创建套餐时设置的价格为人民币,用户购买套餐使用人民币支付 |
|
||||
| 充值/支付 | **CNY(人民币)** | 用户通过支付渠道(支付宝/微信/Stripe)充值为人民币,履约时转换为 USD 入账钱包 |
|
||||
|
||||
**换算规则**:
|
||||
- 管理后台支付设置中配置 CNY → USD 兑换比例(`payment_exchange_rate_cny_to_usd`,默认 0.14)
|
||||
- 充值履约时调用 `billing.ConvertCNYToUSD(ctx, cnyAmount)` 将 CNY 金额转换为 USD 入账钱包
|
||||
- 计费引擎全程使用 USD,无需换算
|
||||
- 前端展示规则:钱包余额/交易记录/计费用 `$`(USD),套餐价格/充值金额/订单金额用 `¥`(CNY)
|
||||
#### 三层币种固定规则(强约束,写新代码必须遵守)
|
||||
|
||||
币种**按层固定**,不允许在层内混用。判断一个金额用什么币种,只看它属于哪一层,不需要逐字段记忆:
|
||||
|
||||
| 层 | 表前缀 | 币种 | 说明 |
|
||||
|----|--------|------|------|
|
||||
| **订单 / 支付层** | `ord_` | **永远 CNY** | 充值订单、套餐订单、退款,金额一律人民币。`ord_orders.currency` 默认值必须为 `CNY` |
|
||||
| **钱包 / 计费 / 账本层** | `bil_` | **永远 USD** | 钱包余额、冻结余额、累计充值、交易流水、计费记录、用量、预扣/结算/退款,一律美元 |
|
||||
| **套餐定价** | `pln_` | **CNY** | 套餐卖的是 token 额度(`monthly_quota_tokens`),价格用人民币,不与 USD 钱包直接比较 |
|
||||
| **租户等级阈值** | `tnt_tenant_level_configs.cumulative_recharge_threshold` | **USD** | 与 `bil_wallets.cumulative_recharge`(USD)同币种比较 |
|
||||
|
||||
**唯一换汇点**:CNY → USD 的转换**只允许发生在充值履约**(`payment.FulfillOrder` 的 `recharge` 分支,调用 `billing.ConvertCNYToUSD`)。除此之外任何地方都不得做币种换算——套餐购买不入钱包不换算,计费引擎全程 USD 不换算。
|
||||
|
||||
**新增带金额字段的代码前,先确认它属于哪一层**,按上表取币种;跨层传递金额(如订单金额入钱包)必须经过唯一换汇点。
|
||||
|
||||
#### 汇率规则
|
||||
|
||||
- 只维护**单一方向**汇率 `payment_exchange_rate_cny_to_usd`(管理后台支付设置配置,默认 0.14)。需要反向(USD→CNY)时**取倒数**,禁止再配置第二个独立汇率,避免两个汇率漂移导致往返不闭合。
|
||||
- **充值时必须快照汇率**:履约入账时除了写入换算后的 USD,还要持久化「原始 CNY 金额 + 当时汇率 + 入账 USD」,使历史换算可重建。汇率配置变更不得影响已完成订单的对账。
|
||||
- **退款认原始 CNY 金额**:退款按订单原始人民币金额退回支付渠道,**禁止**用钱包当前 USD 余额按当前汇率反向折算(防止汇率波动产生套利/亏损)。
|
||||
|
||||
#### 前端展示规则
|
||||
|
||||
- 钱包余额 / 交易流水 / 计费 / 用量 / Token 单价 → `$`(USD)
|
||||
- 套餐价格 / 充值金额 / 订单金额 / 退款金额 → `¥`(CNY)
|
||||
- ⚠️ 营收类指标(`total_cost`、`revenue` 等来自 `bil_usage_logs` 的统计)是 **USD**,用 `$`,不要因为面向中国区运营就标成 `¥`。需要人民币营收报表时,在**出报表时**按快照汇率折算,账本本身仍存 USD。
|
||||
|
||||
#### 人民币对账 / 导出规则
|
||||
|
||||
判断要不要存 CNY,只看**这笔钱有没有真的以人民币发生过**,两种对账答案相反:
|
||||
|
||||
| 对账类型 | CNY 是否真实发生 | 做法 |
|
||||
|----------|------------------|------|
|
||||
| **现金对账**(充值 / 退款,对支付宝/微信/银行流水) | ✅ 真实发生 | 在**充值订单/充值交易**上快照「原始 CNY 金额 + 当时汇率 + 入账 USD」,CNY 是真相、USD 是派生 |
|
||||
| **消费对账**(AI 用量 / 计费扣费) | ❌ 从未发生(逐笔均为 USD 计算) | **禁止**在 `bil_usage_logs` 等高频写入表加 CNY 快照字段,导出时折算 |
|
||||
|
||||
**为什么消费记录不加 CNY 快照字段**:① 没有真实人民币事件可对,存进去是虚构事实;② `bil_usage_logs` 是全系统写入量最大的表,加派生列存储代价大;③ 逐行存 CNY 再 SUM 会累积舍入误差;④ 制造与 USD 列冲突的第二真相源,反而新增对账问题。
|
||||
|
||||
**消费需要展示/导出人民币时**:先按 USD 聚合(`SUM(total_cost)`),**最后乘一次汇率**得到 CNY;报表头注明「所用汇率 + 折算口径 + 仅供参考,非实际支付/开票金额」。汇率用统一的报表汇率(或 `payment_exchange_rate_cny_to_usd` 取倒数),不得逐行各取各的。
|
||||
|
||||
**发票 / 税务口径**:合法可开票的人民币金额是用户**实际支付的充值额**(真实 CNY),绝不是「消费额 × 汇率」。租户人民币对账单/发票一律从充值记录出,精确到分、与支付渠道流水一一对应。
|
||||
|
||||
> 决策规则:**钱真的以人民币动过 → 存真实 CNY 快照(充值/退款);没动过、只是想看人民币 → 导出时对聚合 USD 折算一次,不落库。**
|
||||
|
||||
## 数据库表前缀定义
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ func processAutoRenew(ctx context.Context, subscriptionID, tenantID, planID int6
|
||||
PlanId: planID,
|
||||
Amount: 0, // will be filled from plan price,
|
||||
FinalAmount: 0,
|
||||
Currency: "USD",
|
||||
Currency: "CNY", // 套餐价为 CNY,订单层一律 CNY(见 CLAUDE.md 三层币种固定规则)
|
||||
PaymentChannel: "auto_renew",
|
||||
Status: "pending",
|
||||
Description: fmt.Sprintf("Auto-renewal for subscription #%d", subscriptionID),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- +goose Up
|
||||
-- 修正订单层币种:按 CLAUDE.md「三层币种固定规则」,ord_ 订单层一律 CNY(充值/套餐均为人民币)。
|
||||
-- 原 DEFAULT 'USD' 与业务语义相反,且 auto_renew 历史续费订单被错误标记为 USD。
|
||||
|
||||
-- 1. 修正默认值:USD -> CNY
|
||||
ALTER TABLE ord_orders ALTER COLUMN currency SET DEFAULT 'CNY';
|
||||
|
||||
-- 2. 补全字段注释,明确单位
|
||||
COMMENT ON COLUMN ord_orders.currency IS '货币(订单层一律 CNY)';
|
||||
|
||||
-- 3. 回填历史错误数据:订单层不应存在 USD,统一修正为 CNY
|
||||
UPDATE ord_orders SET currency = 'CNY' WHERE currency = 'USD';
|
||||
|
||||
-- +goose Down
|
||||
-- 回滚:恢复原默认值与注释(历史回填的数据不再逆转)
|
||||
ALTER TABLE ord_orders ALTER COLUMN currency SET DEFAULT 'USD';
|
||||
COMMENT ON COLUMN ord_orders.currency IS '货币';
|
||||
@@ -43,8 +43,8 @@ function calcGrowth(current: number, previous: number): { value: number; positiv
|
||||
|
||||
function formatMoney(val: any) {
|
||||
const n = Number(val)
|
||||
if (!n && n !== 0) return '¥0.00'
|
||||
return '¥' + n.toFixed(2)
|
||||
if (!n && n !== 0) return '$0.00'
|
||||
return '$' + n.toFixed(2)
|
||||
}
|
||||
|
||||
function formatNumber(val: any) {
|
||||
@@ -234,16 +234,16 @@ function renderTrendChart() {
|
||||
|
||||
trendChart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
||||
legend: { data: ['收入 (¥)', '请求数'], bottom: 0 },
|
||||
legend: { data: ['收入 ($)', '请求数'], bottom: 0 },
|
||||
grid: { left: 60, right: 60, top: 15, bottom: 36 },
|
||||
xAxis: { type: 'category', data: dates, axisLabel: { fontSize: 11 } },
|
||||
yAxis: [
|
||||
{ type: 'value', name: '收入 (¥)', axisLabel: { formatter: (v: number) => '¥' + v } },
|
||||
{ type: 'value', name: '收入 ($)', axisLabel: { formatter: (v: number) => '$' + v } },
|
||||
{ type: 'value', name: '请求数', splitLine: { show: false } },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '收入 (¥)',
|
||||
name: '收入 ($)',
|
||||
type: 'bar',
|
||||
data: revenues,
|
||||
barWidth: 12,
|
||||
@@ -310,7 +310,7 @@ function renderModelChart() {
|
||||
modelChart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
grid: { left: 130, right: 50, top: 10, bottom: 20 },
|
||||
xAxis: { type: 'value', name: '费用 (¥)' },
|
||||
xAxis: { type: 'value', name: '费用 ($)' },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: names,
|
||||
|
||||
Reference in new issue
Block a user