Files
fuj-management/docs/plans/2026-05-06-0928-go-m2-5-money-parse-czk.md
Jan Novak d24d20553a 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>
2026-05-06 09:38:28 +02:00

7.3 KiB
Raw Blame History

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-<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.1M2.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:

  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

// 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 + ErrInvalidAmount
  • go/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 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:

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.