feat(go/M2.5): port domain/money.ParseCZK

Port scripts/infer_payments.py parse_czk_amount to Go as
internal/domain/money.ParseCZK. Preserves the Czech-locale heuristic
(comma = decimal sep; 2+ dots = thousand seps; single dot = decimal)
and returns (float64, error) so callers can opt into Python's
silent-zero contract via v, _ := money.ParseCZK(s).
All expected values verified against live Python on 2026-05-06.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 09:38:28 +02:00
parent fa853780db
commit d24d20553a
3 changed files with 315 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
// Package money ports Czech-locale currency parsing from scripts/infer_payments.py.
package money
import (
"errors"
"strconv"
"strings"
)
// ErrInvalidAmount is returned by ParseCZK when the input cannot be parsed.
var ErrInvalidAmount = errors.New("money: invalid CZK amount")
// ParseCZK parses a Czech-locale amount string and returns the value in CZK
// as a float64. Mirrors scripts/infer_payments.py parse_czk_amount:
//
// - empty input → (0, nil)
// - "Kč"/"CZK" suffixes stripped (case-sensitive, like Python)
// - comma present → comma is decimal sep, dots/spaces are thousand seps
// ("1.500,00" → 1500.0)
// - no comma, 2+ dots → all dots are thousand seps ("1.500.000" → 1500000.0)
// - no comma, ≤1 dot → dot is decimal sep ("1.500" → 1.5)
// - on parse failure → (0, ErrInvalidAmount); callers wanting Python's
// silent-zero behaviour can discard the error: v, _ := ParseCZK(s)
func ParseCZK(s string) (float64, error) {
if s == "" {
return 0, nil
}
s = strings.ReplaceAll(s, "Kč", "")
s = strings.ReplaceAll(s, "CZK", "")
s = strings.TrimSpace(s)
if strings.ContainsRune(s, ',') {
s = strings.ReplaceAll(s, ".", "")
s = strings.ReplaceAll(s, " ", "")
s = strings.ReplaceAll(s, ",", ".")
} else if strings.Count(s, ".") > 1 {
s = strings.ReplaceAll(s, ".", "")
s = strings.ReplaceAll(s, " ", "")
} else {
s = strings.ReplaceAll(s, " ", "")
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, ErrInvalidAmount
}
return v, nil
}

View File

@@ -0,0 +1,67 @@
package money
import (
"testing"
)
func TestParseCZK(t *testing.T) {
t.Parallel()
// All expected outputs verified against live Python implementation on 2026-05-06:
// PYTHONPATH=scripts:. python -c '
// from infer_payments import parse_czk_amount
// for v in [None, "", "0", "500", "500 Kč", "500 CZK",
// "1 500", "1500.00", "1 500.00",
// "1.500,00", "1500,5", "1.500.000",
// "1.500", "abc", " ", "100,5 Kč"]:
// print(repr(v), "->", parse_czk_amount(v))
// '
tests := []struct {
name string
input string
want float64
wantErr bool
}{
{"empty string", "", 0, false},
{"zero string", "0", 0, false},
{"plain integer", "500", 500, false},
{"with Kč suffix", "500 Kč", 500, false},
{"with CZK suffix", "500 CZK", 500, false},
{"space thousand sep", "1 500", 1500, false},
{"dot decimal", "1500.00", 1500, false},
{"space thousands dot decimal", "1 500.00", 1500, false},
{"dot thousand comma decimal", "1.500,00", 1500, false},
{"comma decimal no thousands", "1500,5", 1500.5, false},
{"multiple dot thousand seps", "1.500.000", 1500000, false},
{"single dot is decimal heuristic", "1.500", 1.5, false},
{"comma decimal with Kč", "100,5 Kč", 100.5, false},
{"garbage text", "abc", 0, true},
{"spaces only", " ", 0, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := ParseCZK(tc.input)
if (err != nil) != tc.wantErr {
t.Errorf("ParseCZK(%q) error = %v, wantErr %v", tc.input, err, tc.wantErr)
}
if got != tc.want {
t.Errorf("ParseCZK(%q) = %v, want %v", tc.input, got, tc.want)
}
})
}
}
// TestParseCZKSilentZero documents that discarding the error recovers Python's
// silent-zero behaviour for any garbage input.
func TestParseCZKSilentZero(t *testing.T) {
t.Parallel()
for _, s := range []string{"abc", " ", "Kč", "CZK"} {
v, _ := ParseCZK(s)
if v != 0 {
t.Errorf("ParseCZK(%q) silent-zero: got %v, want 0", s, v)
}
}
}