返回 DeepSeek-Reasonix
money.go
根目录 / internal / billing / money.go
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
256 lines GO