// Package money is the one place a currency amount is represented. Whole micro-dollars, never a // float: a float64 cannot hold 0.1, and an accounting system that drifts by a rounding step per // operation drifts in the same direction every time. package money import ( "encoding/json" "fmt" "math/big" "regexp" "strings" ) // decimal is the accepted syntax: an optional sign, digits, an optional fraction, an optional // exponent. Nothing else is an amount of money. var decimal = regexp.MustCompile(`^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$`) // MicroUSD is a whole number of millionths of a dollar. Signed, because a ledger has debits. type MicroUSD int64 // PerUSD is the scale. const PerUSD = 1_000_000 // maxAmountLen bounds the text form. Sixty-four characters is more than any real amount and far // less than a denial of service. const maxAmountLen = 64 // UnmarshalJSON converts a decimal from the wire exactly, rounding UP (toward +infinity). // // The direction is a decision: the engine's own ledger is a lower bound (unified backlog row 78), // so a cost rounded down undercharges the account by construction, every time, the same way. func (m *MicroUSD) UnmarshalJSON(b []byte) error { s := strings.TrimSpace(string(b)) // The JSON literal null is judged BEFORE the value is unquoted, and that order is the whole of // PD-79: unquoting first made the STRING "null" indistinguishable from the literal, so a figure // the sender could not compute arrived as a real zero — "the attempt cost nothing" on the // settlement path. The literal leaves the value untouched, because what absence MEANS is the // caller's business (the seam binds this to a pointer), not a number this package invents. if s == "null" { return nil } // A quoted amount is unquoted by encoding/json, not by trimming quote characters: the word null, // the empty string and an escaped digit all come out as what they are, and each then meets the // one syntax check below. Everything ParseUSD refuses stays refused — an empty figure is not a // zero figure. if strings.HasPrefix(s, `"`) { if err := json.Unmarshal(b, &s); err != nil { return fmt.Errorf("money: %w", err) } } v, err := ParseUSD(s) if err != nil { return err } *m = v return nil } // ParseUSD reads a decimal number of dollars ("5", "0.75", "1e-6") as micro-dollars, rounding UP: // -1.9999999 is -1999999, not -2000000. Exact — the text becomes a rational, never a float. func ParseUSD(s string) (MicroUSD, error) { s = strings.TrimSpace(s) // An amount of money is short. Without a bound the rational parse is superlinear in the input: // a two-megabyte run of nines takes seconds and puts itself in the error message. if len(s) > maxAmountLen { return 0, fmt.Errorf("money: amount is %d characters long", len(s)) } // big.Rat also accepts "0x10" and "1/3". Neither is an amount of money anybody meant to type, // and both would be read silently — so the accepted syntax is stated here rather than inherited. if !decimal.MatchString(s) { return 0, fmt.Errorf("money: %q is not a decimal amount", s) } r, ok := new(big.Rat).SetString(s) if !ok { return 0, fmt.Errorf("money: %q is not a number", s) } r.Mul(r, big.NewRat(PerUSD, 1)) q, rem := new(big.Int).QuoRem(r.Num(), r.Denom(), new(big.Int)) if rem.Sign() > 0 { // QuoRem truncates toward zero, which is already the ceiling for negatives q.Add(q, big.NewInt(1)) } if !q.IsInt64() { return 0, fmt.Errorf("money: %q does not fit in micro-USD", s) } return MicroUSD(q.Int64()), nil } // USD renders the amount as a decimal string. // // ⚠ ITS AUDIENCE CHANGED ON 05.09 AND THE OLD COMMENT HERE IS NOW FALSE. It used to read «for the // admin CLI only: money reaches no response, screen or INFO log», and that was the blanket // prohibition the OWNER revoked (D39.196 §2): an account's balance, an order's ceiling and its hold // go out to a buyer in dollars. What did NOT change is the other half and it is not this function's // to enforce: an INFO log gets no amount in any form (PD-99), and model prices, stage and call costs // and the shape of our own spending stay inside (ПТ-33/ПТ-35). The wire renders whole micro-USD as // integers rather than through this; what still comes through here is the admin CLI and the engine's // own `--ceiling-usd`/`--accept-rebill` arguments. func (m MicroUSD) USD() string { // big.Rat, not integer arithmetic on the parts: it is already the type this package parses with, // and it removes the case that made the hand-written version subtle — MinInt64, whose negation // overflows back to itself. Verified identical on the whole range including both extremes. return new(big.Rat).SetFrac64(int64(m), PerUSD).FloatString(6) }