From 97f94afa024f2039c27c72253f33a950e89fa5e0 Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Thu, 8 Oct 2026 11:34:20 +0200 Subject: [PATCH] fix: treat VS as a string on read and write Go stringified sheet cells with fmt.Sprint, so a numeric VS read with UNFORMATTED_VALUE was rendered as 2.609767878e+09. Text columns of the payments sheet now go through sheets.CellString, which never uses exponent form; bank inference uses the same helper. Both sync implementations now append VS as a text cell so Sheets keeps it a string and leading zeros survive. Existing rows are not rewritten. Python already read VS as a digit string; a regression test pins that. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++ go/internal/io/sheets/cell.go | 33 +++++++++++++++++ go/internal/io/sheets/cell_test.go | 36 +++++++++++++++++++ go/internal/services/banksync/infer.go | 2 +- go/internal/services/banksync/sync.go | 5 +-- go/internal/services/banksync/sync_test.go | 4 +++ go/internal/services/membership/sources.go | 4 +-- .../services/membership/sources_test.go | 12 +++++-- legacy/scripts/sync_fio_to_sheets.py | 12 ++++++- legacy/tests/test_match_payments.py | 23 +++++++++++- legacy/tests/test_sync_fio.py | 33 +++++++++++++++++ 11 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 go/internal/io/sheets/cell.go create mode 100644 go/internal/io/sheets/cell_test.go create mode 100644 legacy/tests/test_sync_fio.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 921322c..9d93e7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026-10-08 11:34 CEST — fix: VS is always a string, on read and on write + +- Go rendered numeric sheet cells with `fmt.Sprint`, so a numeric VS came out as `2.609767878e+09`. Text columns of the payments sheet now go through `sheets.CellString` (plain digits); bank inference uses it too. +- Both sync implementations now append VS as a text cell (leading apostrophe under `USER_ENTERED`), so Sheets no longer stores it as a number or drops leading zeros. Existing rows are unchanged. +- Key files: `go/internal/io/sheets/cell.go`, `go/internal/services/membership/sources.go`, `go/internal/services/banksync/{sync,infer}.go`, `legacy/scripts/sync_fio_to_sheets.py`. + ## 2026-10-08 11:33 CEST — refactor: move Python implementation to legacy/, Go is primary - Go (`go/`) is now the primary implementation. The original Python version moved to `legacy/` (`app.py`, `scripts/`, `templates/`, `tests/`, `pyproject.toml`, `uv.lock`, `build/`) and is considered legacy; new features are still built in both. diff --git a/go/internal/io/sheets/cell.go b/go/internal/io/sheets/cell.go new file mode 100644 index 0000000..ba94c4e --- /dev/null +++ b/go/internal/io/sheets/cell.go @@ -0,0 +1,33 @@ +package sheets + +import ( + "fmt" + "strconv" +) + +// CellString renders a cell read with UNFORMATTED_VALUE as text. +// Numeric cells arrive as float64; fmt.Sprint would print large ones in +// exponent form ("2.609767878e+09"), which corrupts identifiers such as VS +// or Bank ID. Mirrors get_str in legacy/scripts/match_payments.py. +func CellString(v any) string { + switch x := v.(type) { + case nil: + return "" + case string: + return x + case float64: + return strconv.FormatFloat(x, 'f', -1, 64) + } + return fmt.Sprint(v) +} + +// TextCell marks s to be stored as text when written with USER_ENTERED: +// a leading apostrophe stops Sheets from parsing digits as a number (which +// would drop leading zeros). The apostrophe is not part of the stored value. +// Empty stays empty. Mirrors as_text_cell in legacy/scripts/sync_fio_to_sheets.py. +func TextCell(s string) string { + if s == "" { + return "" + } + return "'" + s +} diff --git a/go/internal/io/sheets/cell_test.go b/go/internal/io/sheets/cell_test.go new file mode 100644 index 0000000..73e898d --- /dev/null +++ b/go/internal/io/sheets/cell_test.go @@ -0,0 +1,36 @@ +package sheets + +import "testing" + +func TestTextCell(t *testing.T) { + if got := TextCell("0308"); got != "'0308" { + t.Errorf(`TextCell("0308"): got %q`, got) + } + if got := TextCell(""); got != "" { + t.Errorf(`TextCell(""): got %q`, got) + } +} + +func TestCellString(t *testing.T) { + cases := []struct { + name string + in any + want string + }{ + {"nil", nil, ""}, + {"string", "fee", "fee"}, + {"string with leading zeros", "0308", "0308"}, + {"small integer float", 123.0, "123"}, + {"large integer float", 2609767878.0, "2609767878"}, + {"eleven digit float", 26123456789.0, "26123456789"}, + {"fractional float", 12.5, "12.5"}, + {"bool", true, "true"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := CellString(tc.in); got != tc.want { + t.Errorf("CellString(%v): want %q, got %q", tc.in, tc.want, got) + } + }) + } +} diff --git a/go/internal/services/banksync/infer.go b/go/internal/services/banksync/infer.go index 0eaf21a..85cb209 100644 --- a/go/internal/services/banksync/infer.go +++ b/go/internal/services/banksync/infer.go @@ -94,7 +94,7 @@ func InferPayments( if idx < 0 || idx >= len(row) { return "" } - return strings.TrimSpace(fmt.Sprint(row[idx])) + return strings.TrimSpace(sheets.CellString(row[idx])) } // Skip rule: any of manual fix / Person / Purpose non-empty → leave alone diff --git a/go/internal/services/banksync/sync.go b/go/internal/services/banksync/sync.go index 887f65d..7a20961 100644 --- a/go/internal/services/banksync/sync.go +++ b/go/internal/services/banksync/sync.go @@ -6,6 +6,7 @@ import ( "fmt" "fuj-management/go/internal/domain/synch" "fuj-management/go/internal/io/fio" + "fuj-management/go/internal/io/sheets" "os" "strings" "time" @@ -130,7 +131,7 @@ func SyncToSheets( newRows = append(newRows, []any{ tx.Date, tx.Amount, "", "", "", "", // manual fix, Person, Purpose, Inferred Amount - tx.Sender, tx.VS, tx.Message, tx.BankID, syncIDs[i], + tx.Sender, sheets.TextCell(tx.VS), tx.Message, tx.BankID, syncIDs[i], }) } @@ -141,7 +142,7 @@ func SyncToSheets( if opts.DryRun { for _, row := range newRows { fmt.Printf("Dry run: would append date=%v amount=%v sender=%v vs=%v message=%v\n", - row[0], row[1], row[6], row[7], row[8]) + row[0], row[1], row[6], strings.TrimPrefix(fmt.Sprint(row[7]), "'"), row[8]) } if opts.Sort { fmt.Println("Dry run: would sort by date") diff --git a/go/internal/services/banksync/sync_test.go b/go/internal/services/banksync/sync_test.go index 823d605..2f3935b 100644 --- a/go/internal/services/banksync/sync_test.go +++ b/go/internal/services/banksync/sync_test.go @@ -38,6 +38,10 @@ func TestSyncToSheets_EmptySheet(t *testing.T) { if syncID, ok := rows[0][10].(string); !ok || len(syncID) != 64 { t.Errorf("expected 64-char hex sync ID, got %v", rows[0][10]) } + // VS (index 7) is written as text so Sheets keeps it a string. + if vs, _ := rows[0][7].(string); vs != "'123" { + t.Errorf("expected VS written as text cell '123, got %v", rows[0][7]) + } } func TestSyncToSheets_Dedup(t *testing.T) { diff --git a/go/internal/services/membership/sources.go b/go/internal/services/membership/sources.go index c2f9cca..7b35887 100644 --- a/go/internal/services/membership/sources.go +++ b/go/internal/services/membership/sources.go @@ -402,7 +402,7 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) { if i < 0 || i >= len(row) { return "" } - return fmt.Sprint(row[i]) + return sheets.CellString(row[i]) } // getRaw returns row[i] without stringifying — needed for FormatDate to @@ -425,7 +425,7 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) { amount := parseFloat(amountRaw) var inferredAmount *float64 - if iv := getVal(row, idxInferred); iv != "" && iv != "" { + if iv := getVal(row, idxInferred); iv != "" { if f := parseFloat(iv); f != 0 { inferredAmount = &f } diff --git a/go/internal/services/membership/sources_test.go b/go/internal/services/membership/sources_test.go index ed12cf1..93ae81f 100644 --- a/go/internal/services/membership/sources_test.go +++ b/go/internal/services/membership/sources_test.go @@ -123,6 +123,8 @@ func TestLoadTransactions(t *testing.T) { {"Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"}, {"2026-04-01", 700.0, "", "Alice", "2026-04", "", "Alice Bank", "", "fee", "", "abc"}, {46147.0, 500.0, "", "", "", "", "Bob Bank", "", "platba", "", "def"}, // 46147 serial-day = 2026-05-05 + // Numeric VS / Bank ID cells arrive as float64 and must stay plain digits. + {"2026-05-06", 500.0, "", "", "", "", "Carol Bank", 2609767878.0, "platba", 26123456789.0, "ghi"}, }, }} s := buildSources(t, &attendance.Fake{}, sh) @@ -131,8 +133,8 @@ func TestLoadTransactions(t *testing.T) { if err != nil { t.Fatal(err) } - if len(txns) != 2 { - t.Fatalf("want 2 transactions, got %d", len(txns)) + if len(txns) != 3 { + t.Fatalf("want 3 transactions, got %d", len(txns)) } if txns[0].Person != "Alice" { t.Errorf("txn[0].Person: want Alice, got %q", txns[0].Person) @@ -146,6 +148,12 @@ func TestLoadTransactions(t *testing.T) { if txns[1].Date != "2026-05-05" { t.Errorf("txn[1].Date (numeric serial-day): want 2026-05-05, got %q", txns[1].Date) } + if txns[2].VS != "2609767878" { + t.Errorf("txn[2].VS (numeric cell): want 2609767878, got %q", txns[2].VS) + } + if txns[2].BankID != "26123456789" { + t.Errorf("txn[2].BankID (numeric cell): want 26123456789, got %q", txns[2].BankID) + } } func TestLoadExceptions(t *testing.T) { diff --git a/legacy/scripts/sync_fio_to_sheets.py b/legacy/scripts/sync_fio_to_sheets.py index ca2388d..7fee367 100644 --- a/legacy/scripts/sync_fio_to_sheets.py +++ b/legacy/scripts/sync_fio_to_sheets.py @@ -59,6 +59,16 @@ def get_sheets_service(credentials_path: str): return _SHEETS_SERVICE +def as_text_cell(value) -> str: + """Mark a value to be stored as text when written with USER_ENTERED. + + A leading apostrophe stops Sheets from parsing digits as a number (which + would drop leading zeros); it is not part of the stored value. Empty stays empty. + """ + s = "" if value is None else str(value) + return f"'{s}" if s else "" + + def generate_sync_id(tx: dict) -> str: """Generate a unique SHA-256 hash for a transaction. @@ -209,7 +219,7 @@ def sync_to_sheets( "", # Purpose "", # Inferred Amount tx.get("sender", ""), - tx.get("vs", ""), + as_text_cell(tx.get("vs", "")), tx.get("message", ""), tx.get("bank_id", ""), sync_id, diff --git a/legacy/tests/test_match_payments.py b/legacy/tests/test_match_payments.py index 0787eb7..096b7b6 100644 --- a/legacy/tests/test_match_payments.py +++ b/legacy/tests/test_match_payments.py @@ -1,6 +1,7 @@ import unittest +from unittest.mock import MagicMock, patch -from scripts.match_payments import canonical_member_key, reconcile +from scripts.match_payments import canonical_member_key, fetch_sheet_data, reconcile class TestCanonicalMemberKey(unittest.TestCase): @@ -67,3 +68,23 @@ class TestReconcileTolerantPersonMatching(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class TestFetchSheetDataVS(unittest.TestCase): + """Numeric VS cells arrive as floats (UNFORMATTED_VALUE) and must stay plain digit strings.""" + + def _fetch(self, vs_cell): + service = MagicMock() + service.spreadsheets().values().get().execute.return_value = {"values": [ + ["Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", + "Sender", "VS", "Message", "Bank ID", "Sync ID"], + ["2026-04-15", 750, "", "", "", "", "Test Sender", vs_cell, "fee", "", "abc"], + ]} + with patch("scripts.match_payments.get_sheets_service", return_value=service): + return fetch_sheet_data("sheet-id", "creds.json") + + def test_numeric_vs_is_plain_digit_string(self): + self.assertEqual(self._fetch(2609767878.0)[0]["vs"], "2609767878") + + def test_string_vs_is_kept(self): + self.assertEqual(self._fetch("0308")[0]["vs"], "0308") diff --git a/legacy/tests/test_sync_fio.py b/legacy/tests/test_sync_fio.py new file mode 100644 index 0000000..262c280 --- /dev/null +++ b/legacy/tests/test_sync_fio.py @@ -0,0 +1,33 @@ +import contextlib +import io +import unittest +from unittest.mock import MagicMock, patch + +from scripts.sync_fio_to_sheets import COLUMN_LABELS, as_text_cell, sync_to_sheets + + +class TestAsTextCell(unittest.TestCase): + def test_digits_get_text_marker(self): + self.assertEqual(as_text_cell("0308"), "'0308") + self.assertEqual(as_text_cell(123), "'123") + + def test_empty_stays_empty(self): + self.assertEqual(as_text_cell(""), "") + self.assertEqual(as_text_cell(None), "") + + +class TestSyncWritesVSAsText(unittest.TestCase): + def test_appended_row_has_text_vs(self): + service = MagicMock() + sheet = service.spreadsheets.return_value + sheet.values.return_value.get.return_value.execute.return_value = {"values": [COLUMN_LABELS]} + tx = {"date": "2026-04-10", "amount": 750.0, "sender": "Test Sender", + "vs": "0308", "message": "fee", "bank_id": "111"} + with patch("scripts.sync_fio_to_sheets.get_sheets_service", return_value=service), \ + patch("scripts.sync_fio_to_sheets.fetch_transactions_all", return_value=[tx]), \ + contextlib.redirect_stdout(io.StringIO()): + sync_to_sheets("sheet-id", "creds.json", days=30) + + body = sheet.values.return_value.append.call_args.kwargs["body"] + self.assertEqual(len(body["values"]), 1) + self.assertEqual(body["values"][0][7], "'0308")