Fix date-as-serial bug + cache FX lookups

append_row.py:
- Switch valueInputOption from USER_ENTERED to RAW.
- coerce() the row per-column: amount / amount_hkd / fx_rate become floats,
  everything else stays a string. Combined with RAW, dates ("2026-04-20") no
  longer get auto-parsed into Sheets date serials (e.g. 46153), while amounts
  still land as proper numeric cells so SUM/AVERAGE keep working.

fx_convert.py:
- Cache frankfurter.app responses in ~/.cache/autoacct/fx_cache.json (atomic
  write via .tmp + replace). Keyed by "<currency>_<date>". ECB historical
  rates are immutable, so an indefinite TTL is safe. Measured locally:
  cache hit 52ms vs cache miss 470ms (~9x).

setup.md: troubleshooting entries for pre-existing serial-date rows and for
the FX cache location.

Auth path also verified end-to-end via pure bash + openssl + curl (JWT
sign → token exchange → Sheets API 404 on bogus ID), proving the wire
format is correct independent of the Python client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 12:01:19 +08:00
parent c6f9530b5f
commit 3df80bbc63
3 changed files with 72 additions and 4 deletions

View File

@@ -40,6 +40,23 @@ COLUMNS = [
"line_items", "raw_ocr", "note", "receipt", "logged_at",
]
# Columns stored as numbers in Sheets. Everything else stays as a string —
# including `date` / `fx_date`, which USER_ENTERED would otherwise auto-parse
# into a Sheets serial-date integer (e.g. 46153) that displays as a raw number
# when the column has no date format applied.
NUMERIC_COLS = {"amount", "amount_hkd", "fx_rate"}
def coerce(col: str, val):
if val == "" or val is None:
return ""
if col in NUMERIC_COLS:
try:
return float(val)
except (TypeError, ValueError):
return str(val)
return str(val)
def load_config() -> dict:
if not CONFIG_PATH.exists():
@@ -60,7 +77,7 @@ def main() -> int:
datetime.now(timezone.utc).isoformat(timespec="seconds"),
)
values = [str(row.get(col, "")) for col in COLUMNS]
values = [coerce(col, row.get(col, "")) for col in COLUMNS]
creds = Credentials.from_service_account_file(
cfg["service_account_path"],
@@ -73,7 +90,10 @@ def main() -> int:
.append(
spreadsheetId=normalize_sheet_id(cfg["sheet_id"]),
range=f"{cfg['worksheet']}!A1",
valueInputOption="USER_ENTERED",
# RAW (not USER_ENTERED) so Sheets stores values as their JSON
# types: strings stay strings (no date auto-parsing), floats stay
# numbers. `coerce()` above puts the right type into each column.
valueInputOption="RAW",
insertDataOption="INSERT_ROWS",
body={"values": [values]},
)