🎯 什麼情境該想到我
當你「要正確表示與計算金額、還得處理幣別」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:用一個專門的值物件表示金額與幣別,避免浮點誤差與幣別混算。
做法要點:
- 內部用整數(最小貨幣單位,如「分」)保存金額,別用浮點數,避免累積誤差。
- 一併帶著幣別;不同幣別不可直接相加,換算需經匯率。
- 提供分配(allocate)方法處理除不盡的尾差,確保各份加總仍等於原額、一分不差。
★★ 第一條規則:絕不用浮點數
// ★★ 為什麼 float64 在金額上是錯的
0.1 + 0.2 == 0.3 // ★ false(IEEE 754 無法精確表示 0.1)
// → ★★ 一萬筆交易累積下來,對帳會差幾分錢
// → ★ 而「差幾分錢」在金融系統裡是一個必須解釋的事故
// ✅ ★ 整數的最小單位
type Money struct {
amount int64 // ★ 以「最小單位」計(TWD 是元、USD 是分)
currency string
}★★ int64 的範圍足夠嗎? int64 最大約 9.22 × 10¹⁸ —
以「分」計可表示約 92 兆美元。★ 對絕大多數系統夠用;
★ 加密貨幣(18 位小數)或超高精度需求要用 big.Int 或 decimal 函式庫。
★★ 第二條:最小單位不是「都兩位小數」
★ ISO 4217 的 minor unit 不統一:
TWD 0(台幣沒有分) JPY 0 KRW 0
USD 2 EUR 2 CNY 2
★★ BHD 3(巴林第納爾) TND 3 KWD 3 JOD 3
★★★ CLF 4(智利 UF)
→ ★ 所以「amount 存分、顯示除以 100」是錯的
// ★★ 必須有一張精度表,而且它是資料,不是常數
var minorUnits = map[string]int{"TWD": 0, "JPY": 0, "USD": 2, "BHD": 3}
func (m Money) String() string {
d := minorUnits[m.currency] // ★ 依幣別決定小數位
…
}★★ 這是實務上最常見的金額 bug:把日圓當成「有分」,
於是 1000 日圓顯示成 10.00 JPY。
★★ 第三條:不同幣別不可運算
func (m Money) Add(o Money) (Money, error) {
if m.currency != o.currency {
return Money{}, fmt.Errorf("%w: %s + %s", ErrCurrencyMismatch, m.currency, o.currency)
}
return Money{m.amount + o.amount, m.currency}, nil
}★★ 回傳 error 而不是 panic,因為幣別可能來自外部資料。
⚠️ 常見的偷懶:讓 Add 不檢查幣別 → 台幣加美金,數字看起來很合理。
★★ 第四條:分帳(allocation)—— 這是這一頁最重要的一段
★ 問題:100 元分給 3 個人
100 / 3 = 33.33… → ★ 每人 33 元 → ★★ 總和 99 元,少了 1 元
→ ★★ 而「錢憑空消失」在會計上是不可接受的
// ★★ Fowler 的 allocate:把餘數「逐個發給前面的人」
func (m Money) Allocate(ratios []int) []Money {
total := 0
for _, r := range ratios { total += r }
out := make([]Money, len(ratios))
remainder := m.amount
for i, r := range ratios {
share := m.amount * int64(r) / int64(total) // ★ 整數除法,向下取整
out[i] = Money{share, m.currency}
remainder -= share
}
// ★★ 把餘數逐一加回去 —— 保證總和等於原值
for i := int64(0); i < remainder; i++ {
out[i].amount++
}
return out
}
// ★ Allocate(100, [1,1,1]) → [34, 33, 33] ⇒ 總和 = 100 ✅★★ 「總和必須等於原值」是這個函式唯一重要的性質,
★ 而它是一個完美的 property-based test 目標:
// ★★ 對任意金額與任意比例,斷言總和不變
func TestAllocatePreservesTotal(t *testing.T) { … }⚠️ 餘數給誰是業務決定:給前面的人、給最大份的人、還是隨機輪替(★ 避免同一個人一直吃虧)。
★★ 第五條:匯率轉換不是 Money 的責任
// ❌★★ Money 自己知道匯率
func (m Money) To(cur string) Money { … } // ★ 哪一天的匯率?哪個來源?買價賣價?
// ✅ ★ 一個明確的轉換服務
type Exchange interface {
Convert(ctx context.Context, m Money, to string, at time.Time) (Money, error)
}★★ 四個必須明確的參數:★ 時間點(歷史對帳要用當時的匯率)、
★ 來源、★ 買價/賣價/中價、★ 以及捨入規則。
→ ★ 這些都是業務決定,不屬於一個值物件。
★★ 儲存與傳輸
-- ★ 儲存:兩欄(見 [[內嵌值]])
amount bigint NOT NULL,
currency char(3) NOT NULL,
CHECK (currency ~ '^[A-Z]{3}$')
-- ★★ 不要用 numeric/decimal 存「元」再在應用層轉 —— ★ 兩邊的捨入規則會不一致// ★ 傳輸:JSON 用字串或整數,★★ 絕不用 number(float)
{"amount": "1999", "currency": "TWD"} // ✅ 字串(最安全)
{"amount_minor": 1999, "currency": "TWD"} // ✅ 整數 + 明確的欄位名
{"amount": 19.99} // ★★ 錯:JS 的 number 是 float64★ 兄弟辨析
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
別用浮點型別存錢;這正是本模式要避免的坑。
-
混算不同幣別要先換算,並定義好進位規則。
-
是值物件的特例,故通常不可變、依值相等。
-
★★ 絕不用
float64存金額。0.1+0.2 != 0.3,累積下來對帳會差錢。 -
★★ 最小單位不是「都兩位小數」:TWD/JPY/KRW 是 0,BHD/KWD 是 3,CLF 是 4。把日圓當成有分是最常見的金額 bug。
-
★★ 不同幣別不可運算,要回 error 不是 panic。 不檢查的話「台幣加美金」數字看起來很合理。
-
★★ 分帳必須保證「總和等於原值」(
Allocate(100,[1,1,1]) → [34,33,33])。餘數給誰是業務決定。 -
★★ 匯率轉換不屬於 Money。 需要時間點、來源、買賣中價、捨入規則——那些是業務決定。
-
★ 儲存用
bigint最小單位 +char(3)幣別,不要用 numeric 存「元」再在應用層轉(兩邊捨入規則會不一致)。 -
★ JSON 傳輸用字串或明確命名的整數,絕不用 number。
-
★
int64以分計可表示約 92 兆美元,對多數系統夠用;加密貨幣要用big.Int或 decimal。 -
捨入規則要明確且一致(banker’s rounding vs half-up),而且要寫進測試。