fix: VS always a string; months_to_show in Python JSON API #44
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# 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
|
## 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.
|
- 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,7 +94,7 @@ func InferPayments(
|
|||||||
if idx < 0 || idx >= len(row) {
|
if idx < 0 || idx >= len(row) {
|
||||||
return ""
|
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
|
// Skip rule: any of manual fix / Person / Purpose non-empty → leave alone
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"fuj-management/go/internal/domain/synch"
|
"fuj-management/go/internal/domain/synch"
|
||||||
"fuj-management/go/internal/io/fio"
|
"fuj-management/go/internal/io/fio"
|
||||||
|
"fuj-management/go/internal/io/sheets"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -130,7 +131,7 @@ func SyncToSheets(
|
|||||||
newRows = append(newRows, []any{
|
newRows = append(newRows, []any{
|
||||||
tx.Date, tx.Amount,
|
tx.Date, tx.Amount,
|
||||||
"", "", "", "", // manual fix, Person, Purpose, Inferred 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 {
|
if opts.DryRun {
|
||||||
for _, row := range newRows {
|
for _, row := range newRows {
|
||||||
fmt.Printf("Dry run: would append date=%v amount=%v sender=%v vs=%v message=%v\n",
|
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 {
|
if opts.Sort {
|
||||||
fmt.Println("Dry run: would sort by date")
|
fmt.Println("Dry run: would sort by date")
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ func TestSyncToSheets_EmptySheet(t *testing.T) {
|
|||||||
if syncID, ok := rows[0][10].(string); !ok || len(syncID) != 64 {
|
if syncID, ok := rows[0][10].(string); !ok || len(syncID) != 64 {
|
||||||
t.Errorf("expected 64-char hex sync ID, got %v", rows[0][10])
|
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) {
|
func TestSyncToSheets_Dedup(t *testing.T) {
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
|||||||
if i < 0 || i >= len(row) {
|
if i < 0 || i >= len(row) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return fmt.Sprint(row[i])
|
return sheets.CellString(row[i])
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRaw returns row[i] without stringifying — needed for FormatDate to
|
// getRaw returns row[i] without stringifying — needed for FormatDate to
|
||||||
@@ -425,7 +425,7 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
|||||||
amount := parseFloat(amountRaw)
|
amount := parseFloat(amountRaw)
|
||||||
|
|
||||||
var inferredAmount *float64
|
var inferredAmount *float64
|
||||||
if iv := getVal(row, idxInferred); iv != "" && iv != "<nil>" {
|
if iv := getVal(row, idxInferred); iv != "" {
|
||||||
if f := parseFloat(iv); f != 0 {
|
if f := parseFloat(iv); f != 0 {
|
||||||
inferredAmount = &f
|
inferredAmount = &f
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ func TestLoadTransactions(t *testing.T) {
|
|||||||
{"Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"},
|
{"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"},
|
{"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
|
{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)
|
s := buildSources(t, &attendance.Fake{}, sh)
|
||||||
@@ -131,8 +133,8 @@ func TestLoadTransactions(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(txns) != 2 {
|
if len(txns) != 3 {
|
||||||
t.Fatalf("want 2 transactions, got %d", len(txns))
|
t.Fatalf("want 3 transactions, got %d", len(txns))
|
||||||
}
|
}
|
||||||
if txns[0].Person != "Alice" {
|
if txns[0].Person != "Alice" {
|
||||||
t.Errorf("txn[0].Person: want Alice, got %q", txns[0].Person)
|
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" {
|
if txns[1].Date != "2026-05-05" {
|
||||||
t.Errorf("txn[1].Date (numeric serial-day): want 2026-05-05, got %q", txns[1].Date)
|
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) {
|
func TestLoadExceptions(t *testing.T) {
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ def get_sheets_service(credentials_path: str):
|
|||||||
return _SHEETS_SERVICE
|
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:
|
def generate_sync_id(tx: dict) -> str:
|
||||||
"""Generate a unique SHA-256 hash for a transaction.
|
"""Generate a unique SHA-256 hash for a transaction.
|
||||||
|
|
||||||
@@ -209,7 +219,7 @@ def sync_to_sheets(
|
|||||||
"", # Purpose
|
"", # Purpose
|
||||||
"", # Inferred Amount
|
"", # Inferred Amount
|
||||||
tx.get("sender", ""),
|
tx.get("sender", ""),
|
||||||
tx.get("vs", ""),
|
as_text_cell(tx.get("vs", "")),
|
||||||
tx.get("message", ""),
|
tx.get("message", ""),
|
||||||
tx.get("bank_id", ""),
|
tx.get("bank_id", ""),
|
||||||
sync_id,
|
sync_id,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import unittest
|
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):
|
class TestCanonicalMemberKey(unittest.TestCase):
|
||||||
@@ -67,3 +68,23 @@ class TestReconcileTolerantPersonMatching(unittest.TestCase):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.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")
|
||||||
|
|||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user