資損防控:搞定跨境交易系統(tǒng)中金額處理規(guī)范
在跨境交易日益頻繁的當下,資損防控成為支付系統(tǒng)設計的關鍵環(huán)節(jié)。金額處理的規(guī)范性直接關系到資金安全與企業(yè)信譽。本文將深入探討交易系統(tǒng)中金額計算、存儲、傳輸?shù)淖罴褜嵺`,詳細剖析常見資損場景及解決方案。
這篇文章主要講清楚:交易系統(tǒng)(電商、支付等)金額處理常見資損場景,如何構建一個適合跨境支付業(yè)務(中國業(yè)務更不在話下)的Money類,應用Money的最佳實踐,包括計算、存儲、傳輸,目的是在金額處理上減少資損風險。
一、背景
前幾天有讀者私聊我問了幾個問題:
“做國際支付,不同的幣種的最小單位不同,比如人民幣是分,日元是元,那數(shù)據(jù)庫里面應該應該保存整數(shù)還是小數(shù)?”
“從哪里獲取到這個幣種的最小單位是多少?”
“我贊成你說的不應該直接對金額進行加減乘除操作,但我還是不知道怎么做,怎樣才能落地呢?”
以前在公司時,也有兄弟部門因為金額處理不當,導致了好幾萬的資損故障,然后過來問我金額處理的最佳實踐,當時也給他們做過一次相關分享。
二、金額處理場景
從上圖可以看到,對于交易系統(tǒng)而言,一共有下面幾種場景需要做金額處理:
- 接收外部請求。比如商戶下單100元,或用戶轉(zhuǎn)賬1000元。
- 內(nèi)部應用處理。比如計算手續(xù)費等。
- 內(nèi)部應用保存到數(shù)據(jù)庫,從數(shù)據(jù)庫讀取。
- 內(nèi)部應用之間傳輸。
- 發(fā)送給外部系統(tǒng)或銀行渠道。比如向銀行請求扣款100元。
三、金額計算常見誤區(qū)及嚴重后果
對于研發(fā)經(jīng)驗不足的團隊而言,經(jīng)常會犯以下幾種錯誤:
- 沒有定義統(tǒng)一的Money類,各系統(tǒng)間使用BigDecimal、double、long等數(shù)據(jù)類型進行金額處理及存儲。
- 定義了統(tǒng)一的Money類,但是寫代碼時不嚴格遵守,仍然有些代碼使用BigDecimal、double、long等數(shù)據(jù)類型進行金額處理。
- 手動對金額進行加、減、乘、除運算,單位(元與分)換算。
帶來的后果,通常就是資金損失,再細化一下,最常見的情況有下面3種:
1)手動做單位換算導致金額被放大或縮小100倍。
- 比如大家規(guī)定傳的是元,但是其中有位同學忘記了,以為傳的是分,外部渠道要求傳元,就手動乘以100?;蛘叻催^來。
- 還有一種情況,部分幣種比如日元最小單元就是元,假如系統(tǒng)約定傳的是分,外部渠道要求傳元,就可能在網(wǎng)關處理時手動乘以100。
2)1分錢歸屬問題。比如結算給商家,或計算手續(xù)費時,碰到除不盡時,使用四舍五入,還是向零舍入,還是銀行家舍入?這取決于財務策略。
3)精度丟失。在大金額時,double有可能會有精度丟失問題。
四、金額處理原則
直接上答案:
- 制定適用于公司業(yè)務的Money類來統(tǒng)一處理金額。
- 在入口網(wǎng)關接收到請求后,就轉(zhuǎn)換為Money類。
- 所有內(nèi)部應用的金額處理,強制全部使用Money類運算、傳輸,禁止自己手動加減乘除、單位換算(比如元到分)。
- 數(shù)據(jù)庫使用DECIMAL類型保存,保存單位為元。
- 在出口網(wǎng)關外發(fā)時,再根據(jù)外部接口文檔要求,轉(zhuǎn)換成使用指定的單位。有些是元,有些是分(最小貨幣單位)
五、制定Money類
JAVA有制定金額處理規(guī)范JSR 354(Java Specification Request 354),對應的實現(xiàn)包是Java Money API(javax.money),它提供了一套用于處理貨幣和貨幣計算的API。不過我們通常選擇實現(xiàn)自己的Money類,主要是方便,可以自由定制,比如小數(shù)舍入問題。
一個Money類通常包括以下幾個主要方面:
- 通過參數(shù)生成一個Money類。
- 加減乘除處理。
- 比較處理。
- 獲取金額(元)和獲取最小單位金額(元或分)。
下面的代碼由ChatGPT o1模型生成。
提示詞為:
編寫一個Money類,支持跨境支付場景下的多幣種訴求。要求:
1)實現(xiàn)Comparable和Serializable。
2)成員變量幣種使用BigDecimal amount,Currency currency。
3)靜態(tài)方法傳入幣種和數(shù)字返回一個Money類實例。
4)支持:加、減、乘、除、比較大小操作,加減和比較需要判斷幣種相同。
5)默認使用四余五入,但是支持RoundingMode能力。
6)提供getAmount和getAmountMinorUnit方法,前者返回單位元,后者返回幣種的最小單位,通過Currency.getDefaultFractionDigits()和amount計算出來。
生成的 Money 類實現(xiàn)了跨境支付場景下多幣種的高精度金額處理,滿足了以下關鍵需求:
- 實現(xiàn)了 Comparable 和 Serializable 接口,支持排序和序列化。
- 使用 BigDecimal 和 Currency 來表示金額和幣種,確保高精度和標準化。
- 提供了豐富的操作方法,包括加、減、乘、除,并且支持自定義和四舍五入模式。
- 提供了 getAmount 和 getAmountMinorUnit 方法,分別返回單位元和最小單位的金額。
- 確保類的不可變性和線程安全性。
- 根據(jù)幣種自動計算出最小單位,比如人民幣就是分,而日元就是元。
- 只有相同幣種才能做加、減。
- 乘除支持舍入,默認使用四舍五入,但是支持其它舍入方式。
通過這種設計,可以在跨境支付、金融應用等需要高精度、多幣種支持的場景中安全、有效地使用 Money 類進行金額處理。
后面會做拆解說明。
完整代碼如下:
import java.io.Serializable;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Currency;
import java.util.Objects;
/**
* Money類用于表示不同幣種的金額,支持高精度計算和多幣種操作。
* 該類是不可變的(immutable),并且實現(xiàn)了Comparable和Serializable接口。
*/
public final class Money implements Comparable, Serializable {
private static final long serialVersionUID = 1L;
private final BigDecimal amount;
private final Currency currency;
/**
* 私有構造函數(shù),確保通過工廠方法創(chuàng)建實例。
*
* @param amount 金額,單位為元
* @param currency 幣種,非空
*/
private Money(BigDecimal amount, Currency currency) {
if (amount == null) {
throw new IllegalArgumentException("Amount cannot be null.");
}
if (currency == null) {
throw new IllegalArgumentException("Currency cannot be null.");
}
this.amount = amount;
this.currency = currency;
}
/**
* 靜態(tài)工廠方法,通過傳入Currency和BigDecimal金額創(chuàng)建Money實例。
* 默認使用RoundingMode.HALF_UP進行四舍五入。
*
* @param currency 幣種
* @param amount 金額,單位為元
* @return 新的Money實例
*/
public static Money of(Currency currency, BigDecimal amount) {
return of(currency, amount, RoundingMode.HALF_UP);
}
/**
* 靜態(tài)工廠方法,通過傳入Currency和BigDecimal金額創(chuàng)建Money實例。
* 允許指定RoundingMode進行四舍五入。
*
* @param currency 幣種
* @param amount 金額,單位為元
* @param roundingMode 四舍五入模式
* @return 新的Money實例
*/
public static Money of(Currency currency, BigDecimal amount, RoundingMode roundingMode) {
Objects.requireNonNull(currency, "Currency cannot be null.");
Objects.requireNonNull(amount, "Amount cannot be null.");
Objects.requireNonNull(roundingMode, "RoundingMode cannot be null.");
BigDecimal scaledAmount = amount.setScale(
currency.getDefaultFractionDigits(),
roundingMode
);
return new Money(scaledAmount, currency);
}
/**
* 加法操作,返回新的Money實例。
* 僅允許相同幣種的加法操作。
*
* @param other 加數(shù)
* @return 相加后的Money實例
* @throws IllegalArgumentException 如果幣種不一致
*/
public Money add(Money other) {
validateSameCurrency(other);
BigDecimal resultAmount = this.amount.add(other.amount);
return new Money(resultAmount, this.currency);
}
/**
* 減法操作,返回新的Money實例。
* 僅允許相同幣種的減法操作。
*
* @param other 減數(shù)
* @return 相減后的Money實例
* @throws IllegalArgumentException 如果幣種不一致
*/
public Money subtract(Money other) {
validateSameCurrency(other);
BigDecimal resultAmount = this.amount.subtract(other.amount);
return new Money(resultAmount, this.currency);
}
/**
* 乘法操作,使用默認舍入模式(RoundingMode.HALF_UP),返回新的Money實例。
*
* @param multiplier 乘數(shù)
* @return 乘法后的Money實例
* @throws ArithmeticException 如果需要進行舍入但無法進行
* @throws IllegalArgumentException 如果multiplier為null
*/
public Money multiply(BigDecimal multiplier) {
return multiply(multiplier, RoundingMode.HALF_UP);
}
/**
* 乘法操作,返回新的Money實例。
*
* @param multiplier 乘數(shù)
* @param roundingMode 四舍五入模式
* @return 乘法后的Money實例
* @throws ArithmeticException 如果需要進行舍入但沒有指定舍入模式
* @throws IllegalArgumentException 如果multiplier或roundingMode為null
*/
public Money multiply(BigDecimal multiplier, RoundingMode roundingMode) {
Objects.requireNonNull(multiplier, "Multiplier cannot be null.");
Objects.requireNonNull(roundingMode, "RoundingMode cannot be null.");
BigDecimal resultAmount = this.amount.multiply(multiplier)
.setScale(currency.getDefaultFractionDigits(), roundingMode);
return new Money(resultAmount, this.currency);
}
/**
* 除法操作,返回新的Money實例。
*
* @param divisor 除數(shù)
* @param scale 保留的小數(shù)位數(shù)
* @param roundingMode 四舍五入模式
* @return 除法后的Money實例
* @throws ArithmeticException 如果除數(shù)為零或無法精確表示
* @throws IllegalArgumentException 如果divisor或roundingMode為null
*/
public Money divide(BigDecimal divisor, int scale, RoundingMode roundingMode) {
Objects.requireNonNull(divisor, "Divisor cannot be null.");
Objects.requireNonNull(roundingMode, "RoundingMode cannot be null.");
if (divisor.compareTo(BigDecimal.ZERO) == 0) {
throw new ArithmeticException("Division by zero.");
}
BigDecimal resultAmount = this.amount.divide(divisor, scale, roundingMode)
.setScale(currency.getDefaultFractionDigits(), roundingMode);
return new Money(resultAmount, this.currency);
}
/**
* 比較大小,僅允許相同幣種的比較。
*
* @param other 要比較的Money對象
* @return 負數(shù)、零或正數(shù),分別表示小于、等于或大于
* @throws IllegalArgumentException 如果幣種不一致
*/
@Override
public int compareTo(Money other) {
validateSameCurrency(other);
return this.amount.compareTo(other.amount);
}
/**
* 獲取金額,單位為元。
*
* @return 金額
*/
public BigDecimal getAmount() {
return amount;
}
/**
* 獲取最小單位金額,通過Currency.getDefaultFractionDigits()和amount計算。
* 例如,人民幣1元 = 100分,日元1元 = 1元。
*
* @return 最小單位金額
*/
public BigDecimal getAmountMinorUnit() {
int fractionDigits = currency.getDefaultFractionDigits();
return amount.movePointRight(fractionDigits);
}
/**
* 獲取幣種。
*
* @return 幣種
*/
public Currency getCurrency() {
return currency;
}
/**
* 校驗兩個Money對象的幣種是否相同。
*
* @param other 另一個Money對象
* @throws IllegalArgumentException 如果幣種不一致
*/
private void validateSameCurrency(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Currencies do not match.");
}
}
/**
* 重寫equals方法,基于金額和幣種判斷相等。
*
* @param o 其他對象
* @return 是否相等
*/
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Money money = (Money) o;
return amount.equals(money.amount) &&
currency.equals(money.currency);
}
/**
* 重寫hashCode方法,基于金額和幣種生成哈希碼。
*
* @return 哈希碼
*/
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
/**
* 重寫toString方法,格式化輸出幣種和金額。
*
* @return 格式化后的字符串
*/
@Override
public String toString() {
return String.format("%s %s", currency.getCurrencyCode(), amount);
}
}
下面做拆解說明。
5.1. 核心屬性
public final class Money implements Comparable, Serializable {
private static final long serialVersionUID = 1L;
private final BigDecimal amount;
private final Currency currency;
}
5.2. 通過金額數(shù)值和幣種構建一個Money類
/**
* 私有構造函數(shù),確保通過工廠方法創(chuàng)建實例。
*
* @param amount 金額,單位為元
* @param currency 幣種,非空
*/
private Money(BigDecimal amount, Currency currency) {
if (amount == null) {
throw new IllegalArgumentException("Amount cannot be null.");
}
if (currency == null) {
throw new IllegalArgumentException("Currency cannot be null.");
}
this.amount = amount;
this.currency = currency;
}
/**
* 靜態(tài)工廠方法,通過傳入Currency和BigDecimal金額創(chuàng)建Money實例。
* 默認使用RoundingMode.HALF_UP進行四舍五入。
*
* @param currency 幣種
* @param amount 金額,單位為元
* @return 新的Money實例
*/
public static Money of(Currency currency, BigDecimal amount) {
return of(currency, amount, RoundingMode.HALF_UP);
}
/**
* 靜態(tài)工廠方法,通過傳入Currency和BigDecimal金額創(chuàng)建Money實例。
* 允許指定RoundingMode進行四舍五入。
*
* @param currency 幣種
* @param amount 金額,單位為元
* @param roundingMode 四舍五入模式
* @return 新的Money實例
*/
public static Money of(Currency currency, BigDecimal amount, RoundingMode roundingMode) {
Objects.requireNonNull(currency, "Currency cannot be null.");
Objects.requireNonNull(amount, "Amount cannot be null.");
Objects.requireNonNull(roundingMode, "RoundingMode cannot be null.");
BigDecimal scaledAmount = amount.setScale(
currency.getDefaultFractionDigits(),
roundingMode
);
return new Money(scaledAmount, currency);
}
5.3. 加減乘除
1)注意除法有除不盡舍入的問題,需要根據(jù)業(yè)務來指定舍入的模式,建議默認提供四舍五入,但是保留指定模式的能力。具體可以參考:java.math.RoundingMode。
- UP:遠零方向舍入。示例:1.6返回2,-1.6返回-2。
- DOWN:向零方向舍入。示例:1.6返回1,-1.6返回-1。
- CEILING:向上舍入。示例:1.6返回2,-1.6返回-1。
- FLOOR:向下舍入。示例:1.6返回1,-1.6返回-2。
- HALF_UP:四舍五入。示例:1.5返回2,-1.5返回-2。
- HALF_DOWN:五舍六入。示例:1.5返回1,-1.5返回-1,1.6返回2,-1.6返回-2。
- HALF_EVEN:銀行家算法,尾數(shù)小于0.5舍,尾數(shù)大于0.5入,尾數(shù)等于0.5往最終結果是偶數(shù)的方向進。示例:1.51返回2,-1.49返回-1,2.5返回2,3.5返回4(1.5,2.5,3.5,4.5,5.5等這些最終只出現(xiàn)2,4,4,4,6等偶數(shù))。
2)加和減,需要先判斷幣種,只有幣種相同才能做加減。
/**
* 加法操作,返回新的Money實例。
* 僅允許相同幣種的加法操作。
*
* @param other 加數(shù)
* @return 相加后的Money實例
* @throws IllegalArgumentException 如果幣種不一致
*/
public Money add(Money other) {
validateSameCurrency(other);
BigDecimal resultAmount = this.amount.add(other.amount);
return new Money(resultAmount, this.currency);
}
/**
* 減法操作,返回新的Money實例。
* 僅允許相同幣種的減法操作。
*
* @param other 減數(shù)
* @return 相減后的Money實例
* @throws IllegalArgumentException 如果幣種不一致
*/
public Money subtract(Money other) {
validateSameCurrency(other);
BigDecimal resultAmount = this.amount.subtract(other.amount);
return new Money(resultAmount, this.currency);
}
/**
* 乘法操作,使用默認舍入模式(RoundingMode.HALF_UP),返回新的Money實例。
*
* @param multiplier 乘數(shù)
* @return 乘法后的Money實例
* @throws ArithmeticException 如果需要進行舍入但無法進行
* @throws IllegalArgumentException 如果multiplier為null
*/
public Money multiply(BigDecimal multiplier) {
return multiply(multiplier, RoundingMode.HALF_UP);
}
/**
* 乘法操作,返回新的Money實例。
*
* @param multiplier 乘數(shù)
* @param roundingMode 四舍五入模式
* @return 乘法后的Money實例
* @throws ArithmeticException 如果需要進行舍入但沒有指定舍入模式
* @throws IllegalArgumentException 如果multiplier或roundingMode為null
*/
public Money multiply(BigDecimal multiplier, RoundingMode roundingMode) {
Objects.requireNonNull(multiplier, "Multiplier cannot be null.");
Objects.requireNonNull(roundingMode, "RoundingMode cannot be null.");
BigDecimal resultAmount = this.amount.multiply(multiplier)
.setScale(currency.getDefaultFractionDigits(), roundingMode);
return new Money(resultAmount, this.currency);
}
/**
* 除法操作,返回新的Money實例。
*
* @param divisor 除數(shù)
* @param scale 保留的小數(shù)位數(shù)
* @param roundingMode 四舍五入模式
* @return 除法后的Money實例
* @throws ArithmeticException 如果除數(shù)為零或無法精確表示
* @throws IllegalArgumentException 如果divisor或roundingMode為null
*/
public Money divide(BigDecimal divisor, int scale, RoundingMode roundingMode) {
Objects.requireNonNull(divisor, "Divisor cannot be null.");
Objects.requireNonNull(roundingMode, "RoundingMode cannot be null.");
if (divisor.compareTo(BigDecimal.ZERO) == 0) {
throw new ArithmeticException("Division by zero.");
}
BigDecimal resultAmount = this.amount.divide(divisor, scale, roundingMode)
.setScale(currency.getDefaultFractionDigits(), roundingMode);
return new Money(resultAmount, this.currency);
}
5.4. 比較大小
/**
* 比較大小,僅允許相同幣種的比較。
*
* @param other 要比較的Money對象
* @return 負數(shù)、零或正數(shù),分別表示小于、等于或大于
* @throws IllegalArgumentException 如果幣種不一致
*/
@Override
public int compareTo(Money other) {
validateSameCurrency(other);
return this.amount.compareTo(other.amount);
}
5.5. 返回元和分單位的數(shù)字
所有內(nèi)部應用全部使用getAmount(),不允許使用getAmountMinorUnit()。保證內(nèi)部應用大家的語義保持一致。
只有請求外部渠道時,如果渠道要求使用幣種最小單位,才使用getAmountMinorUnit()。
/**
* 獲取金額,單位為元。
*
* @return 金額
*/
public BigDecimal getAmount() {
return amount;
}
/**
* 獲取最小單位金額,通過Currency.getDefaultFractionDigits()和amount計算。
* 例如,人民幣1元 = 100分,日元1元 = 1元。
*
* @return 最小單位金額
*/
public BigDecimal getAmountMinorUnit() {
int fractionDigits = currency.getDefaultFractionDigits();
return amount.movePointRight(fractionDigits);
}
六、Money類實際應用最佳實踐
從接收外部請求開始,到內(nèi)部計算、存儲,最后外發(fā)到渠道,完整實踐說明。
6.1. 接收入口請求
在入口網(wǎng)關處,先轉(zhuǎn)換成Money類,再往后請求。
// 使用外部請求的參數(shù)構建Money類
Money payAmount = Money.of(BigDecimal.valueOf(outRequest.getPayAmount()), outRequest.getCurrency());
// 構建內(nèi)部請求
PayRequest request = new PayRequest();
request.setPayAmount(payAmount);
... ...
// 發(fā)給內(nèi)部應用
payService.pay(request);
6.2. 內(nèi)部應用運算
內(nèi)部所有應用,全部使用Money類流轉(zhuǎn)和計算。
Money payAmount = request.getPayAmount();
Money fee = payAmount.multiply(BigDecimal.valueOf(0.03));
// 其它處理
6.3. 內(nèi)部數(shù)據(jù)庫存儲
Money payAmount = request.getPayAmount();
BigDecimal amount = payAmount.getAmount();
String currency = payAmount.getCurrency().getCurrencyCode();
// 構建DO
Order order = new Order();
order.setAmount(amount);
order.setCurrency(currency);
...
// 保存入庫
saveToDB(order);
6.4. 外發(fā)處理
渠道要求是元,使用:
payAmount.getAmount();
如果要求是分或最小幣種單位,使用:
payAmount.getAmountMinorUnit();
7. 結束語
金額如果處理得不好,帶來的直接后果就是資金損失,哪怕不是今天,早晚也得出事。
如果你是研發(fā)同學,發(fā)現(xiàn)內(nèi)部還沒有使用Money類處理金額,建議早點對內(nèi)部系統(tǒng)做改造。如果你是產(chǎn)品經(jīng)理,建議轉(zhuǎn)給內(nèi)部研發(fā)工程師,避免踩資損的坑。
本文由人人都是產(chǎn)品經(jīng)理作者【隱墨星辰】,微信公眾號:【隱墨星辰】,原創(chuàng)/授權 發(fā)布于人人都是產(chǎn)品經(jīng)理,未經(jīng)許可,禁止轉(zhuǎn)載。
題圖來自Unsplash,基于 CC0 協(xié)議。
mark
資損防控很重要,搞定跨境交易的金額處理規(guī)范,就能更安心地做生意了!