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

@@ -13,21 +13,59 @@ from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.request
from datetime import date
from pathlib import Path
# Cache file for frankfurter responses. ECB reference rates for past dates
# never change, so an indefinite TTL is safe; same-day rates also stabilize
# once published. Keyed by "<currency>_<on_date>" → {rate, fx_date}.
_CACHE_DIR = Path(
os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
) / "autoacct"
_CACHE_PATH = _CACHE_DIR / "fx_cache.json"
def _load_cache() -> dict:
if not _CACHE_PATH.exists():
return {}
try:
return json.loads(_CACHE_PATH.read_text())
except (json.JSONDecodeError, OSError):
return {}
def _save_cache(cache: dict) -> None:
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
tmp = _CACHE_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(cache, indent=2, sort_keys=True))
tmp.replace(_CACHE_PATH)
def fetch_rate(currency: str, on_date: str) -> tuple[float, str]:
currency = currency.upper()
if currency == "HKD":
return 1.0, on_date
cache = _load_cache()
key = f"{currency}_{on_date}"
if key in cache:
entry = cache[key]
return float(entry["rate"]), entry["fx_date"]
url = f"https://api.frankfurter.dev/v1/{on_date}?from={currency}&to=HKD"
req = urllib.request.Request(url, headers={"User-Agent": "AutoACCT/1.0"})
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
rate = data["rates"]["HKD"]
return float(rate), data["date"]
rate = float(data["rates"]["HKD"])
fx_date_resolved = data["date"]
cache[key] = {"rate": rate, "fx_date": fx_date_resolved}
_save_cache(cache)
return rate, fx_date_resolved
def main() -> int: