This commit is contained in:
mats committed 2026-06-28 17:52:49 +02:00
1 parent e706f7d907
commit 21bcb63440
42 files changed
+5903

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
// 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) }
+129
View File
@@ -0,0 +1,129 @@
package money
import (
"errors"
"testing"
)
func TestParseCentsExact(t *testing.T) {
cases := []struct {
in string
want Cents
}{
{"149.95", 14995},
{"149.9", 14990},
{"149", 14900},
{"149.", 14900},
{".95", 95},
{"0.05", 5},
{"0", 0},
{"0.00", 0},
{"-12.34", -1234},
{"-0.05", -5},
{"+7.50", 750},
{" 10.10 ", 1010},
{"1000000.00", 100000000},
// The classic float-representation trap: 0.1+0.2 etc. Parsed from the
// string these are exact; ParseFloat would have drifted.
{"0.10", 10},
{"0.20", 20},
{"0.30", 30},
}
for _, c := range cases {
got, err := ParseCents(c.in)
if err != nil {
t.Errorf("ParseCents(%q) unexpected error: %v", c.in, err)
continue
}
if got != c.want {
t.Errorf("ParseCents(%q) = %d, want %d", c.in, got, c.want)
}
}
}
func TestParseCentsRejects(t *testing.T) {
bad := []string{
"",
" ",
"-",
"+",
"abc",
"12.3.4", // multiple dots
"149.955", // 3 decimal places — would silently truncate
"1.234", // 3 decimal places
"12,34", // comma not a decimal separator here
"1e3", // no scientific notation; integer-only parse
"12 34", // internal space
"$10.00", // currency symbol
}
for _, s := range bad {
if _, err := ParseCents(s); err == nil {
t.Errorf("ParseCents(%q) = nil error, want rejection", s)
} else if !errors.Is(err, ErrParse) {
t.Errorf("ParseCents(%q) error %v not wrapping ErrParse", s, err)
}
}
}
func TestStringRoundTrip(t *testing.T) {
cases := []struct {
c Cents
want string
}{
{14995, "149.95"},
{14900, "149.00"},
{5, "0.05"},
{50, "0.50"},
{0, "0.00"},
{-1234, "-12.34"},
{-5, "-0.05"},
{100000000, "1000000.00"},
}
for _, tc := range cases {
if got := tc.c.String(); got != tc.want {
t.Errorf("Cents(%d).String() = %q, want %q", tc.c, got, tc.want)
}
// Round-trip: String -> ParseCents -> same Cents.
back, err := ParseCents(tc.c.String())
if err != nil {
t.Errorf("round-trip ParseCents(%q): %v", tc.c.String(), err)
continue
}
if back != tc.c {
t.Errorf("round-trip Cents(%d) -> %q -> %d", tc.c, tc.c.String(), back)
}
}
}
func TestFromFloatMajor(t *testing.T) {
cases := []struct {
f float64
want Cents
}{
{149.95, 14995},
{149.0, 14900},
{0.05, 5},
{0.005, 1}, // half-away-from-zero rounds up at the boundary
{0.004, 0},
{-12.34, -1234},
{-0.005, -1},
}
for _, c := range cases {
if got := FromFloatMajor(c.f); got != c.want {
t.Errorf("FromFloatMajor(%v) = %d, want %d", c.f, got, c.want)
}
}
}
func TestArithmeticStaysInteger(t *testing.T) {
// Cents arithmetic is plain integer math — associativity / exactness that
// float prices famously lack.
unit := Cents(1099) // 10.99
var total Cents
for i := 0; i < 3; i++ {
total += unit
}
if total != 3297 {
t.Errorf("3 * 10.99 = %s, want 32.97", total)
}
}