货币处理规范
概述
本文档描述系统中货币金额的存储、传输和转换规则,特别是零小数货币(如日元 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),系统以"分"为单位存储。
数据库存储规则
核心原则
统一使用"最小货币单位"存储
数据库字段类型
所有金额字段使用 integer 类型:
// 示例:product_options 表
$table->integer('price')->nullable();
// 示例:orders 表
$table->integer('amount');
$table->integer('init_amount');
前端传参规则
API 请求
前端传递的金额值应与数据库存储值一致,即"最小货币单位":
验证规则示例
// 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;
}
转换示例
取整方式
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)
总结对照表
版本历史