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>
7.3 KiB
M2.5 — Port parse_czk_amount to domain/money.ParseCZK
On execution, this plan should be moved to
docs/plans/2026-05-06-0928-go-m2-5-money-parse-czk.mdper project CLAUDE.md (docs/plans/YYYY-MM-DD-HHMM-<slug>.md). Plan mode forces it to live under~/.claude/plans/until then.
Context
Continuing the Go backend rewrite tracked in
2026-05-03-2349-go-backend-rewrite-progress.md.
M2.1–M2.4 are landed. Next leaf-level pure function is
parse_czk_amount from scripts/infer_payments.py:17-45,
the Czech-locale amount parser used at scripts/infer_payments.py:124
when reading the Inferred Amount column out of the payments sheet.
It's a small, isolated string→float helper, but its heuristic for
disambiguating . and , as decimal vs thousand separator is
non-obvious and needs to behave identically in Go to keep parity once
the Go infer pipeline lands in M4.8.
Python behaviour (the spec)
def parse_czk_amount(val) -> float:
if val is None or val == "":
return 0.0
if isinstance(val, (int, float)):
return float(val)
val = str(val)
val = val.replace("Kč", "").replace("CZK", "").strip()
if "," in val:
# 1.500,00 -> 1500.00 — comma is decimal sep
val = val.replace(".", "").replace(" ", "").replace(",", ".")
else:
if val.count(".") > 1:
# 1.500.000 -> 1500000 — multiple dots = thousand sep
val = val.replace(".", "").replace(" ", "")
else:
# "1 500.00" -> "1500.00", "1.500" stays "1.500" (= 1.5)
val = val.replace(" ", "")
try:
return float(val)
except ValueError:
return 0.0
Key behavioural notes for the Go port:
- Empty / None → 0, no error.
"1.500"(single dot, no comma) is parsed as 1.5, not 1500. The heuristic intentionally treats a lone dot as decimal."1.500,00"→ 1500.0 (comma wins, dots are thousand seps)."1.500.000"→ 1500000.0 (multiple dots → all thousand seps)."1 500"/"1 500.00"/"500 Kč"→ spaces stripped.- Garbage → 0, no error in Python.
- Strips literal substrings
"Kč"and"CZK"(case-sensitive in Python).
Approach
Create new package internal/domain/money mirroring the layout of
internal/domain/fees (single-file module + test file alongside).
Signature
// Package money ports Czech-locale currency parsing from
// scripts/infer_payments.py.
package money
// 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 are stripped (case-sensitive, like Python)
// - if input contains ",", comma is the decimal separator and
// dots/spaces are thousand separators ("1.500,00" → 1500.0)
// - else if input contains 2+ dots, all dots are thousand seps
// ("1.500.000" → 1500000.0)
// - else single dot stays as the decimal point ("1.500" → 1.5,
// matching the Python heuristic)
// - on parse failure, returns (0, ErrInvalidAmount). Callers wanting
// Python-equivalent silent-zero behaviour can discard the error.
func ParseCZK(s string) (float64, error)
ErrInvalidAmount is a package-level sentinel:
var ErrInvalidAmount = errors.New("money: invalid CZK amount")
Why (float64, error) instead of mirroring Python's silent zero:
- Go idiom prefers explicit errors.
- The single Python call site doesn't distinguish parse-fail from
empty-input (both → 0), so if we want byte-equal behaviour at the
Go infer site (M4.8), the caller can
v, _ := money.ParseCZK(s)and get exactly the Python result. - Future callers (e.g. user-facing import flows) may want to surface the error.
This matches the precedent set in M2.4 where we used
Expected{Unknown bool} rather than copying the Python "?" sentinel
verbatim — Go-idiomatic surface, parity-preserving semantics.
Polymorphic input?
Python's parse_czk_amount also accepts raw int/float (passed through
unchanged) because Google Sheets API can return numeric cells as
float64 rather than strings. Skip this in Go. The Sheets IO
adapter is M4.2, and that's where the []any → string normalisation
will live. Keeping ParseCZK string-only keeps the leaf function tiny.
Tests
money_test.go mirrors the existing fees_test.go table-driven style,
including the verification comment showing the Python command used to
confirm each expected value:
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))
'
Cases to cover (all numeric outputs verified against the Python output of the snippet above):
| input | expected |
|---|---|
"" |
0 |
"0" |
0 |
"500" |
500 |
"500 Kč" |
500 |
"500 CZK" |
500 |
"1 500" |
1500 |
"1500.00" |
1500 |
"1 500.00" |
1500 |
"1.500,00" |
1500 |
"1500,5" |
1500.5 |
"1.500.000" |
1500000 |
"1.500" |
1.5 (heuristic — single dot = decimal) |
"100,5 Kč" |
100.5 |
"abc" |
0, returns ErrInvalidAmount |
" " |
0, returns ErrInvalidAmount (or 0 nil — confirm against Python; trim leaves "", then float("") raises → Python returns 0; Go test will assert whichever Python actually produces) |
The " " row is the only one that needs the Python verification step
to settle — once verified, lock the behaviour in.
Also add a "documentation example" assertion in the test that
v, _ := ParseCZK(s) recovers the Python silent-zero contract for
every garbage input, so we don't lose that property at the Go infer
call site.
Files to create
go/internal/domain/money/money.go— package +ParseCZK+ErrInvalidAmountgo/internal/domain/money/money_test.go— table-driven tests
No existing Go files need editing.
Verification
cd go && go test ./internal/domain/money/...
make go-lint
make go-build # sanity: nothing else broke
Also run the Python snippet from the Tests section above and diff its output against the test table to confirm parity.
Out of scope (explicit non-goals)
- Polymorphic
anyinput — leave for M4.2 IO adapter. - Hooking into the Tier-1 parity runner — that comes with M3.5
(
-tags=paritybuild constraint). M2.5 just needs unit tests. - Any callsite migration —
infer_payments.pykeeps using its own Python function until M4.8.
Progress tracker + changelog
After the commit lands:
- Tick
M2.5in docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md with the commit SHA, mirroring the M2.4 entry style. - Add a CHANGELOG.md entry at top:
## YYYY-MM-DD HH:MM TZ — feat(go/M2.5): port domain/money.ParseCZK.
Branch: feat/m2-5-money-parse-czk (per CLAUDE.md branch-per-feature
workflow). Push, open MR via tea pr create, leave merge to the user.