diff --git a/internal/money/money.go b/internal/money/money.go new file mode 100644 index 0000000..b25a248 --- /dev/null +++ b/internal/money/money.go @@ -0,0 +1,89 @@ +// Package money represents amounts as int64 minor units + a currency code. +// CNY/USD = 分 (1e-2), USDT = micro (1e-6). No float, no "元 string" (v2). +package money + +import ( + "errors" + "fmt" + "math" + "strconv" + "strings" +) + +var ErrUnknownCurrency = errors.New("money: unknown currency") + +var exponents = map[string]int{ + "CNY": 2, + "USD": 2, + "USDT": 6, +} + +// Exponent returns the number of decimal places for a currency. +func Exponent(currency string) (int, bool) { + e, ok := exponents[strings.ToUpper(currency)] + return e, ok +} + +// Format renders minor units as a decimal string, trailing zeros trimmed. +func Format(minor int64, currency string) (string, error) { + exp, ok := Exponent(currency) + if !ok { + return "", ErrUnknownCurrency + } + if exp == 0 { + return strconv.FormatInt(minor, 10), nil + } + neg := minor < 0 + if neg { + minor = -minor + } + div := int64(math.Pow10(exp)) + whole := minor / div + frac := minor % div + s := fmt.Sprintf("%d.%0*d", whole, exp, frac) + s = strings.TrimRight(s, "0") + s = strings.TrimRight(s, ".") + if neg { + s = "-" + s + } + return s, nil +} + +// Parse converts a decimal string to minor units for the currency. It rejects +// values with more fractional digits than the currency allows. +func Parse(s, currency string) (int64, error) { + exp, ok := Exponent(currency) + if !ok { + return 0, ErrUnknownCurrency + } + s = strings.TrimSpace(s) + neg := strings.HasPrefix(s, "-") + s = strings.TrimPrefix(s, "-") + intPart, fracPart := s, "" + if i := strings.IndexByte(s, '.'); i >= 0 { + intPart, fracPart = s[:i], s[i+1:] + } + if len(fracPart) > exp { + return 0, fmt.Errorf("money.Parse: %q exceeds %d dp for %s", s, exp, currency) + } + if intPart == "" && fracPart == "" { + return 0, fmt.Errorf("money.Parse: empty %q", s) + } + whole, err := strconv.ParseInt("0"+intPart, 10, 64) + if err != nil { + return 0, fmt.Errorf("money.Parse int %q: %w", s, err) + } + fracPart += strings.Repeat("0", exp-len(fracPart)) + var frac int64 + if fracPart != "" { + frac, err = strconv.ParseInt(fracPart, 10, 64) + if err != nil { + return 0, fmt.Errorf("money.Parse frac %q: %w", s, err) + } + } + minor := whole*int64(math.Pow10(exp)) + frac + if neg { + minor = -minor + } + return minor, nil +} diff --git a/internal/money/money_test.go b/internal/money/money_test.go new file mode 100644 index 0000000..d99044f --- /dev/null +++ b/internal/money/money_test.go @@ -0,0 +1,47 @@ +package money_test + +import ( + "testing" + + "github.com/wangjia/pay/internal/money" +) + +func TestParseFormatRoundTrip(t *testing.T) { + cases := []struct { + s, cur string + minor int64 + }{ + {"12.3417", "USDT", 12341700}, + {"0.01", "CNY", 1}, + {"29.99", "USD", 2999}, + {"199.99", "CNY", 19999}, + {"1", "USDT", 1000000}, + } + for _, c := range cases { + got, err := money.Parse(c.s, c.cur) + if err != nil || got != c.minor { + t.Fatalf("Parse(%q,%s)=%d,%v want %d", c.s, c.cur, got, err, c.minor) + } + back, err := money.Format(c.minor, c.cur) + if err != nil { + t.Fatalf("Format(%d,%s): %v", c.minor, c.cur, err) + } + // round-trip 回同一 minor(去尾零后可能字符串不同,再 Parse 校验) + again, _ := money.Parse(back, c.cur) + if again != c.minor { + t.Fatalf("round-trip %d → %q → %d", c.minor, back, again) + } + } +} + +func TestParseErrors(t *testing.T) { + if _, err := money.Parse("1.00", "JPYX"); err == nil { + t.Fatal("未知币种应报错") + } + if _, err := money.Parse("0.001", "CNY"); err == nil { + t.Fatal("超精度(CNY 3 位小数)应报错") + } + if _, err := money.Parse("abc", "CNY"); err == nil { + t.Fatal("非数字应报错") + } +}