// Package money is the platform's shared monetary value type. One rule, enforced // by the type system: every amount in the system is an integer count of minor // currency units (öre / cents) — never a float, never "kronor as a decimal". // // Why a named type and not bare int64? Two int64s can be a price and a quantity; // the compiler can't tell them apart. money.Cents makes "this is money" explicit // and stops a quantity being added to a price by accident. The underlying type // is int64, so arithmetic between Cents values is ordinary integer math and JSON // encodes identically to the int64 wire shape that already exists. // // Floating point is permitted in exactly one situation: ingesting a price from // an external source that already shipped a float (a facet value, a third-party // product feed). That conversion is lossy by definition — the float was imprecise // before we saw it — so it is isolated to FromFloatMajor, named loudly, and used // only at module edges. Everywhere else, parse from the original decimal STRING // with ParseCents (exact) or stay in Cents. // // Minor-unit exponent: this package assumes 2 decimal places (1 unit = 1/100), // which covers every currency in this codebase (SEK/NOK/DKK/EUR/USD). JPY-style // zero-decimal currencies would need a per-currency exponent; deferred until a // real consumer needs it (see doc rule: lift only what a consumer needs). package money import ( "errors" "fmt" "math" "strconv" "strings" ) // Cents is an integer count of minor currency units (öre / cents). Positive, // negative (refunds / discounts), and zero are all valid. type Cents int64 // minorPerMajor is the number of minor units in one major unit (2 decimals). const minorPerMajor = 100 // ErrParse is the sentinel wrapped by ParseCents failures, for errors.Is. var ErrParse = errors.New("money: parse") // ParseCents converts a decimal MAJOR-unit string ("149.95", "-12", "0.05") // into Cents using integer arithmetic only — no strconv.ParseFloat, no float // multiply, so "149.95" → 14995 exactly with no representation error. // // Accepted: optional leading sign, digits, an optional single '.' with at most // two fractional digits. Rejected (returns ErrParse): empty, multiple dots, // more than two fractional digits (silent truncation of a third decimal is // exactly the kind of money bug this package exists to prevent), or any // non-digit byte. A bare "." sides ("149." / ".95") are accepted as 14900 / 5. func ParseCents(s string) (Cents, error) { in := strings.TrimSpace(s) if in == "" { return 0, fmt.Errorf("%w: empty string", ErrParse) } neg := false switch in[0] { case '-': neg = true in = in[1:] case '+': in = in[1:] } if in == "" { return 0, fmt.Errorf("%w: sign with no digits in %q", ErrParse, s) } intStr, fracStr, hasDot := strings.Cut(in, ".") if hasDot { if strings.IndexByte(fracStr, '.') >= 0 { return 0, fmt.Errorf("%w: multiple decimal points in %q", ErrParse, s) } if len(fracStr) > 2 { return 0, fmt.Errorf("%w: %q has more than 2 decimal places (minor unit is 1/%d)", ErrParse, s, minorPerMajor) } } if intStr == "" { intStr = "0" } // Pad the fractional part to exactly 2 digits: "5" → "50", "" → "00". switch len(fracStr) { case 0: fracStr = "00" case 1: fracStr += "0" } major, err := strconv.ParseInt(intStr, 10, 64) if err != nil { return 0, fmt.Errorf("%w: integer part of %q: %v", ErrParse, s, err) } frac, err := strconv.ParseInt(fracStr, 10, 64) if err != nil { return 0, fmt.Errorf("%w: fractional part of %q: %v", ErrParse, s, err) } // Overflow guard: major * 100 + frac must fit int64. if major > (math.MaxInt64-frac)/minorPerMajor { return 0, fmt.Errorf("%w: %q overflows int64 minor units", ErrParse, s) } c := Cents(major*minorPerMajor + frac) if neg { c = -c } return c, nil } // FromFloatMajor converts a MAJOR-unit float (149.95) to Cents, rounding // half-away-from-zero. THIS IS THE ONLY LOSSY ENTRY POINT and exists solely for // edges where an upstream already handed us a float64 (a finder facet value, a // third-party feed). Never use it on a value you have as a string — ParseCents // is exact. Never use it in a compute path — stay in Cents. func FromFloatMajor(f float64) Cents { scaled := f * minorPerMajor if scaled >= 0 { return Cents(int64(scaled + 0.5)) } return Cents(int64(scaled - 0.5)) } // String formats Cents as a major-unit decimal with two places: 14995 → // "149.95", -5 → "-0.05". Integer math only. func (c Cents) String() string { n := int64(c) sign := "" if n < 0 { sign = "-" n = -n } return fmt.Sprintf("%s%d.%02d", sign, n/minorPerMajor, n%minorPerMajor) } // Int64 returns the raw minor-unit count, for the few low-level call sites // (proto, legacy arithmetic helpers) that still take int64. Prefer staying in // Cents; this is the explicit escape hatch. func (c Cents) Int64() int64 { return int64(c) } // Mul scales an amount by an integer quantity — a price times a line quantity // is still money. Kept as a method so callers don't have to write the // Cents(qty) conversion that bare `c * Cents(qty)` would need (and which reads // like cents², a nonsense unit). func (c Cents) Mul(qty int64) Cents { return c * Cents(qty) }