货币处理规范

概述

本文档描述系统中货币金额的存储、传输和转换规则,特别是零小数货币(如日元 JPY)的处理方式。


货币分类

零小数货币(Zero Decimal Currencies)

定义位置: app/Models/Currency.php:16

const ZeroDecimalCurrencies = ['BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF'];

这些货币没有小数位,最小单位就是整数货币单位本身。

常规货币

USD、CNY 等货币有小数位(分/cents),系统以"分"为单位存储。


数据库存储规则

核心原则

统一使用"最小货币单位"存储

货币类型货币数据库值实际金额说明
常规货币USD100$1.00存储分(cents)
常规货币CNY100¥1.00存储分
零小数货币JPY100¥100存储日元(无小数)
零小数货币KRW100₩100存储韩元(无小数)

数据库字段类型

所有金额字段使用 integer 类型:

// 示例:product_options 表
$table->integer('price')->nullable();

// 示例:orders 表
$table->integer('amount');
$table->integer('init_amount');

前端传参规则

API 请求

前端传递的金额值应与数据库存储值一致,即"最小货币单位":

货币前端传参含义
USD1000$10.00
CNY1000¥10.00
JPY1000¥1000
KRW1000₩1000

验证规则示例

// app/Http/Controllers/Api/Artist/ProductManageController.php
'options.*.price' => 'required|numeric|min:0',

汇率转换

转换服务

文件: app/Service/RateService.php

核心逻辑

public function amountExchange($amount, $source_code, $target_code, $type = 'ceil'): int
{
    if ($source_code === $target_code) {
        return $amount;
    }

    // 从零小数货币转出时:乘以 100(归一化到"分"的概念)
    if (in_array(strtoupper($source_code), Currency::ZeroDecimalCurrencies)) {
        $amount = $amount * 100;
    }

    // 转入零小数货币时:除以 100(从"分"转回整数单位)
    if (in_array(strtoupper($target_code), Currency::ZeroDecimalCurrencies)) {
        $amount = $amount / 100;
    }

    $rate = $this->getRate($source_code, $target_code);
    $amount = $this->amountExchangeByRate($amount, $rate, $type);

    return $amount;
}

转换示例

转换方向输入处理过程输出
JPY → USD¥10001000 × 100 = 100000 → 汇率转换~667 cents ($6.67)
USD → JPY1000 cents ($10)汇率转换 → ÷ 100~¥1500
CNY → JPY1000 分 (¥10)汇率转换 → ÷ 100~¥200
JPY → CNY¥1000× 100 → 汇率转换~50 分 (¥0.50)

取整方式

  • ceil: 向上取整(默认,用于收款)
  • floor: 向下取整(用于钱包抵扣等场景)
  • round: 四舍五入

支付网关处理

Stripe

文件: app/Service/Payment/StripeCheckoutSessionService.php

Stripe 的 unit_amount 参数规范与系统存储一致:

  • USD: 100 = $1.00(分)
  • JPY: 100 = ¥100(日元)
$params = [
    'line_items' => [[
        'price_data' => [
            'currency' => $retAmount->currency->code,
            'unit_amount' => $unitAmount,  // 直接使用数据库值
        ],
    ]],
];

无需额外转换,直接传递数据库值即可。

Alipay(支付宝)

文件: app/Service/Payment/ProductPaymentService.php

支付宝的 total_amount 参数以"元"为单位:

$params = [
    'total_amount' => (string) ($retAmount->amount / 100),
];

注意:支付宝主要支持 CNY,此处的 / 100 是将"分"转换为"元"。


Currency 模型属性

is_zero_decimal

文件: app/Models/Currency.php

protected $appends = ['is_zero_decimal'];

public function getIsZeroDecimalAttribute()
{
    return $this->isZeroDecimal();
}

public function isZeroDecimal()
{
    return in_array(strtoupper($this->code), self::ZeroDecimalCurrencies);
}

API 响应中会自动包含 is_zero_decimal 字段:

{
  "id": 3,
  "code": "JPY",
  "symbol": "¥",
  "is_zero_decimal": true
}

前端可根据此字段决定是否显示小数位。


零小数货币取整处理

ZeroDecimalCurrencyCeil 阶段

文件: app/Service/Stripe/StripeCalcAmount.php

在支付计算流程的最后阶段,对零小数货币进行向上取整:

public function ZeroDecimalCurrencyCeil()
{
    $amount_before = $this->getRetAmount();
    $amount_after = $this->getRetAmount();

    if (! $amount_before->currency->is_zero_decimal) {
        return $this;
    }

    $amount_after->amount = ceil($amount_before->amount);
    // ...
}

这确保了汇率转换后可能产生的小数被正确处理。


前端显示建议

金额格式化

function formatAmount(amount, currency) {
  if (currency.is_zero_decimal) {
    // 零小数货币:直接显示整数
    return `${currency.symbol}${amount}`;
  } else {
    // 常规货币:除以 100 显示
    return `${currency.symbol}${(amount / 100).toFixed(2)}`;
  }
}

// 示例
formatAmount(1000, { symbol: '$', is_zero_decimal: false });  // "$10.00"
formatAmount(1000, { symbol: '¥', is_zero_decimal: true });   // "¥1000"

金额输入

function parseAmount(input, currency) {
  if (currency.is_zero_decimal) {
    // 零小数货币:直接使用输入值
    return Math.round(parseFloat(input));
  } else {
    // 常规货币:乘以 100 转换为分
    return Math.round(parseFloat(input) * 100);
  }
}

// 示例
parseAmount("10.00", { is_zero_decimal: false });  // 1000 (cents)
parseAmount("1000", { is_zero_decimal: true });    // 1000 (yen)

总结对照表

项目USD/CNYJPY/KRW
数据库存储乘以 100(分)不乘以 100(整数单位)
前端传参乘以 100(分)不乘以 100(整数单位)
Stripe unit_amount直接使用直接使用
汇率转换(转出)正常计算先 ×100 归一化
汇率转换(转入)正常计算结果 ÷100
is_zero_decimalfalsetrue
前端显示÷100 显示直接显示

版本历史

  • v1.0 (2025-12-31): 初始版本