103 lines
4.7 KiB
Go
103 lines
4.7 KiB
Go
// 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)
|
|
}
|