# 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.md` per project CLAUDE.md > (`docs/plans/YYYY-MM-DD-HHMM-.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](../../srv/personal/fuj-management/docs/plans/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](../../srv/personal/fuj-management/scripts/infer_payments.py#L17-L45), the Czech-locale amount parser used at [scripts/infer_payments.py:124](../../srv/personal/fuj-management/scripts/infer_payments.py#L124) 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) ```py 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: 1. Empty / None → 0, no error. 2. `"1.500"` (single dot, no comma) is parsed as **1.5**, not 1500. The heuristic intentionally treats a lone dot as decimal. 3. `"1.500,00"` → 1500.0 (comma wins, dots are thousand seps). 4. `"1.500.000"` → 1500000.0 (multiple dots → all thousand seps). 5. `"1 500"` / `"1 500.00"` / `"500 Kč"` → spaces stripped. 6. Garbage → 0, no error in Python. 7. 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 ```go // 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: ```go 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: ```sh 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` + `ErrInvalidAmount` - `go/internal/domain/money/money_test.go` — table-driven tests No existing Go files need editing. ## Verification ```sh 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 `any` input — leave for M4.2 IO adapter. - Hooking into the Tier-1 parity runner — that comes with M3.5 (`-tags=parity` build constraint). M2.5 just needs unit tests. - Any callsite migration — `infer_payments.py` keeps using its own Python function until M4.8. ## Progress tracker + changelog After the commit lands: - Tick `M2.5` in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](../../srv/personal/fuj-management/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.