🎯 什麼情境該想到我

當你「要正確表示與計算金額、還得處理幣別」的時候。

⚙️ 怎麼用(步驟 / 公式)

意圖:用一個專門的值物件表示金額與幣別,避免浮點誤差與幣別混算。

做法要點:

  • 內部用整數(最小貨幣單位,如「分」)保存金額,別用浮點數,避免累積誤差。
  • 一併帶著幣別;不同幣別不可直接相加,換算需經匯率。
  • 提供分配(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 amount 重構過來的手法
特例「免費」「未定價」的表達

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 別用浮點型別存錢;這正是本模式要避免的坑。

  • 混算不同幣別要先換算,並定義好進位規則。

  • 是值物件的特例,故通常不可變、依值相等。

  • ★★ 絕不用 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),而且要寫進測試。

🔗 相關工具