163 lines
6.3 KiB
Go
163 lines
6.3 KiB
Go
// 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"
|
|
}
|
|
// After the leading sign-strip above, the integer part must NOT contain a
|
|
// second sign character. strconv.ParseInt accepts a leading '-' or '+',
|
|
// which is how the previous "--1" bug silently flipped the sign (strip
|
|
// first '-', re-parse "-1" as -1, then `if neg { c = -c }` flipped it back
|
|
// to +100 — the wrong sign on the wrong input). Reject any second sign
|
|
// anywhere in the integer part: every malformed-sign input
|
|
// ("--1", "+-1", "-+1", "++1") now errors with ErrParse.
|
|
if strings.ContainsAny(intStr, "+-") {
|
|
return 0, fmt.Errorf("%w: %q has multiple signs in integer part", ErrParse, s)
|
|
}
|
|
// Pad the fractional part to exactly 2 digits: "5" → "50", "" → "00".
|
|
switch len(fracStr) {
|
|
case 0:
|
|
fracStr = "00"
|
|
case 1:
|
|
fracStr += "0"
|
|
}
|
|
// The fractional part must also be sign-free. A leading '-' on the
|
|
// fractional string ("-" alone, "-0") was previously parsed by ParseInt
|
|
// as 0 after the pad-to-2 above, which silently accepted inputs like
|
|
// ".-", "1.-", "1.-0". The fractional part is conventionally
|
|
// non-negative (the leading sign applies to the whole number); reject
|
|
// any sign anywhere in fracStr.
|
|
if strings.ContainsAny(fracStr, "+-") {
|
|
return 0, fmt.Errorf("%w: %q has a sign in fractional part", ErrParse, s)
|
|
}
|
|
|
|
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) }
|