| 1 | // Package billing models wallet balances, fixed-point money, and cost quotes. |
| 2 | // It is a utility-layer package (stdlib only). |
| 3 | package billing |
| 4 | |
| 5 | import ( |
| 6 | "fmt" |
| 7 | "math" |
| 8 | "math/big" |
| 9 | "strings" |
| 10 | ) |
| 11 | |
| 12 | // Money is a currency-tagged amount carried as a decimal string on the wire. |
| 13 | // Arithmetic uses fixed-point int64 units (1e9 fractional digits). |
| 14 | type Money struct { |
| 15 | Amount string `json:"amount"` |
| 16 | Currency string `json:"currency"` |
| 17 | } |
| 18 | |
| 19 | // amountScale is 1e9: enough for per-token rates down to ~1e-15 currency units |
| 20 | // when aggregated over typical request sizes. |
| 21 | const amountScale int64 = 1_000_000_000 |
| 22 | |
| 23 | // Amount is a fixed-point currency magnitude in 1e-9 units of the currency. |
| 24 | type Amount int64 |
| 25 | |
| 26 | // Zero is the zero amount. |
| 27 | const Zero Amount = 0 |
| 28 | |
| 29 | // NewAmountFromFloat converts a float64 amount to fixed-point. Prefer ParseAmount |
| 30 | // or NewAmountFromString for values that originate as decimals. |
| 31 | func NewAmountFromFloat(v float64) Amount { |
| 32 | if math.IsNaN(v) || math.IsInf(v, 0) { |
| 33 | return Zero |
| 34 | } |
| 35 | r := new(big.Rat).SetFloat64(v) |
| 36 | if r == nil { |
| 37 | return Zero |
| 38 | } |
| 39 | return amountFromRat(r) |
| 40 | } |
| 41 | |
| 42 | // NewAmountFromString parses a decimal amount string. |
| 43 | func NewAmountFromString(s string) (Amount, error) { |
| 44 | s = strings.TrimSpace(s) |
| 45 | if s == "" { |
| 46 | return Zero, nil |
| 47 | } |
| 48 | r := new(big.Rat) |
| 49 | if _, ok := r.SetString(s); !ok { |
| 50 | return Zero, fmt.Errorf("billing: invalid amount %q", s) |
| 51 | } |
| 52 | return amountFromRat(r), nil |
| 53 | } |
| 54 | |
| 55 | // ParseAmount is an alias for NewAmountFromString. |
| 56 | func ParseAmount(s string) (Amount, error) { return NewAmountFromString(s) } |
| 57 | |
| 58 | func amountFromRat(r *big.Rat) Amount { |
| 59 | if r == nil { |
| 60 | return Zero |
| 61 | } |
| 62 | scaled := new(big.Rat).Mul(r, big.NewRat(amountScale, 1)) |
| 63 | // Round half away from zero. |
| 64 | num := new(big.Int).Set(scaled.Num()) |
| 65 | den := new(big.Int).Set(scaled.Denom()) |
| 66 | half := new(big.Int).Rsh(den, 1) |
| 67 | if scaled.Sign() >= 0 { |
| 68 | num.Add(num, half) |
| 69 | } else { |
| 70 | num.Sub(num, half) |
| 71 | } |
| 72 | quot := new(big.Int).Quo(num, den) |
| 73 | if !quot.IsInt64() { |
| 74 | if scaled.Sign() >= 0 { |
| 75 | return Amount(math.MaxInt64) |
| 76 | } |
| 77 | return Amount(math.MinInt64) |
| 78 | } |
| 79 | return Amount(quot.Int64()) |
| 80 | } |
| 81 | |
| 82 | // String returns a trimmed decimal representation. |
| 83 | func (a Amount) String() string { |
| 84 | neg := a < 0 |
| 85 | v := int64(a) |
| 86 | if neg { |
| 87 | v = -v |
| 88 | } |
| 89 | whole := v / amountScale |
| 90 | frac := v % amountScale |
| 91 | s := fmt.Sprintf("%d.%09d", whole, frac) |
| 92 | s = strings.TrimRight(s, "0") |
| 93 | s = strings.TrimRight(s, ".") |
| 94 | if s == "" || s == "-" { |
| 95 | s = "0" |
| 96 | } |
| 97 | if neg && s != "0" { |
| 98 | return "-" + s |
| 99 | } |
| 100 | return s |
| 101 | } |
| 102 | |
| 103 | // Float64 approximates the amount for legacy float fields only. |
| 104 | func (a Amount) Float64() float64 { |
| 105 | return float64(a) / float64(amountScale) |
| 106 | } |
| 107 | |
| 108 | // Add returns a+b. Same-currency only at the Money layer. |
| 109 | func (a Amount) Add(b Amount) Amount { |
| 110 | sum := int64(a) + int64(b) |
| 111 | // Saturate on overflow rather than wrap. |
| 112 | if (b > 0 && sum < int64(a)) || (b < 0 && sum > int64(a)) { |
| 113 | if b > 0 { |
| 114 | return Amount(math.MaxInt64) |
| 115 | } |
| 116 | return Amount(math.MinInt64) |
| 117 | } |
| 118 | return Amount(sum) |
| 119 | } |
| 120 | |
| 121 | // MulRate multiplies by a float rate for a pricing calculation. |
| 122 | func (a Amount) MulRate(rate float64) Amount { |
| 123 | if a == 0 || rate == 0 || math.IsNaN(rate) || math.IsInf(rate, 0) { |
| 124 | return Zero |
| 125 | } |
| 126 | r := new(big.Rat).SetFloat64(rate) |
| 127 | if r == nil { |
| 128 | return Zero |
| 129 | } |
| 130 | ar := big.NewRat(int64(a), amountScale) |
| 131 | return amountFromRat(ar.Mul(ar, r)) |
| 132 | } |
| 133 | |
| 134 | // MoneyOf builds a Money value. |
| 135 | func MoneyOf(amount Amount, currency string) Money { |
| 136 | return Money{Amount: amount.String(), Currency: NormalizeCurrency(currency)} |
| 137 | } |
| 138 | |
| 139 | // ParseMoney parses amount+currency into fixed-point Money. |
| 140 | func ParseMoney(amount, currency string) (Money, Amount, error) { |
| 141 | a, err := NewAmountFromString(amount) |
| 142 | if err != nil { |
| 143 | return Money{}, Zero, err |
| 144 | } |
| 145 | cur := NormalizeCurrency(currency) |
| 146 | return MoneyOf(a, cur), a, nil |
| 147 | } |
| 148 | |
| 149 | // AmountValue returns the fixed-point amount, or zero on parse error. |
| 150 | func (m Money) AmountValue() Amount { |
| 151 | a, err := NewAmountFromString(m.Amount) |
| 152 | if err != nil { |
| 153 | return Zero |
| 154 | } |
| 155 | return a |
| 156 | } |
| 157 | |
| 158 | // IsZero reports whether the money amount is empty or zero. |
| 159 | func (m Money) IsZero() bool { |
| 160 | return m.AmountValue() == Zero |
| 161 | } |
| 162 | |
| 163 | // Float64 is a legacy adapter. Prefer Amount strings for aggregation. |
| 164 | func (m Money) Float64() float64 { return m.AmountValue().Float64() } |
| 165 | |
| 166 | // NormalizeCurrency maps symbols and aliases to ISO-4217 codes when known. |
| 167 | // Unknown three-letter codes pass through uppercased; empty stays empty. |
| 168 | func NormalizeCurrency(currency string) string { |
| 169 | value := strings.TrimSpace(currency) |
| 170 | if value == "" { |
| 171 | return "" |
| 172 | } |
| 173 | switch strings.ToUpper(value) { |
| 174 | case "CNY", "RMB", "CNH", "YUAN", "RENMINBI": |
| 175 | return "CNY" |
| 176 | case "USD", "US$", "DOLLAR", "DOLLARS": |
| 177 | return "USD" |
| 178 | case "EUR", "EURO", "EUROS": |
| 179 | return "EUR" |
| 180 | case "GBP", "POUND", "POUNDS", "STERLING": |
| 181 | return "GBP" |
| 182 | case "JPY", "YEN": |
| 183 | return "JPY" |
| 184 | } |
| 185 | switch value { |
| 186 | case "¥", "¥": |
| 187 | return "CNY" |
| 188 | case "$": |
| 189 | return "USD" |
| 190 | case "€": |
| 191 | return "EUR" |
| 192 | case "£": |
| 193 | return "GBP" |
| 194 | } |
| 195 | if len(value) == 3 { |
| 196 | allAlpha := true |
| 197 | for _, r := range value { |
| 198 | if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') { |
| 199 | allAlpha = false |
| 200 | break |
| 201 | } |
| 202 | } |
| 203 | if allAlpha { |
| 204 | return strings.ToUpper(value) |
| 205 | } |
| 206 | } |
| 207 | return value |
| 208 | } |
| 209 | |
| 210 | // CurrencySymbol returns a compact display symbol for an ISO code or symbol. |
| 211 | func CurrencySymbol(currency string) string { |
| 212 | switch NormalizeCurrency(currency) { |
| 213 | case "CNY", "JPY": |
| 214 | return "¥" |
| 215 | case "USD": |
| 216 | return "$" |
| 217 | case "EUR": |
| 218 | return "€" |
| 219 | case "GBP": |
| 220 | return "£" |
| 221 | case "": |
| 222 | return "¥" |
| 223 | default: |
| 224 | code := NormalizeCurrency(currency) |
| 225 | if len(code) == 3 { |
| 226 | return code + " " |
| 227 | } |
| 228 | return currency |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | // SameCurrency reports whether two codes normalize equal and non-empty. |
| 233 | func SameCurrency(a, b string) bool { |
| 234 | na, nb := NormalizeCurrency(a), NormalizeCurrency(b) |
| 235 | return na != "" && na == nb |
| 236 | } |
| 237 | |
| 238 | // AddMoney adds two Money values of the same currency. Mixed currencies error. |
| 239 | func AddMoney(a, b Money) (Money, error) { |
| 240 | ca, cb := NormalizeCurrency(a.Currency), NormalizeCurrency(b.Currency) |
| 241 | if ca == "" { |
| 242 | ca = cb |
| 243 | } |
| 244 | if cb == "" { |
| 245 | cb = ca |
| 246 | } |
| 247 | if ca == "" && cb == "" { |
| 248 | return Money{Amount: "0", Currency: ""}, nil |
| 249 | } |
| 250 | if ca != cb { |
| 251 | return Money{}, fmt.Errorf("billing: cannot add %s and %s", ca, cb) |
| 252 | } |
| 253 | sum := a.AmountValue().Add(b.AmountValue()) |
| 254 | return MoneyOf(sum, ca), nil |
| 255 | } |
| 256 |