# Multi-account Fio sync + switch QR target to 2502035405/2010 ## Context The club is opening a second Fio transparent account, **`2502035405/2010`** (IBAN `CZ??...0000002502035405`). Going forward: - **Sync** must pull transactions from **both** accounts into the existing payments sheet — the old `2800359168/2010` stays active so historical fees paid to it still reconcile. - **QR codes** generated by `/qr` should default to the **new** account (`2502035405/2010`), so new payers send money there. The payments sheet schema and `sync_id` hash stay **unchanged** — no "Source Account" column, no migration, no risk of re-appending historical rows. Bank-ID uniqueness from Fio plus the existing hash inputs are good enough to dedupe across two transparent accounts in practice. Implementation order: Go first, then port to Python. Both apps use the same `PaymentsSheetID`, so once Go writes from two accounts the Python code only needs to do the same when invoked. ### Decisions locked in with the user - **Config style:** hardcoded list of accounts in `config.go` / `config.py` (sheet IDs are already hardcoded; tokens stay env-driven). - **Source tagging:** no schema change, `sync_id` formula unchanged. - **Old account:** kept syncing in parallel; only QR target changes. --- ## Go implementation ### 1. Config — hardcoded `Accounts` list, derived `QRAccount` [go/internal/config/config.go](../../go/internal/config/config.go) - Replace scalar `BankAccount` + `FioAPIToken` with a hardcoded slice and a primary pointer. Tokens stay env-driven via per-account env names. ```go // Account describes one Fio bank account we sync from. type Account struct { IBAN string // e.g. "CZ0820100000002502035405" AcctNum string // bare account number, e.g. "2502035405" TokenEnv string // env var name holding the Fio API token (empty token => transparent scraper path) Primary bool // true for the QR-code default account } var Accounts = []Account{ {IBAN: "CZ0820100000002502035405", AcctNum: "2502035405", TokenEnv: "FIO_API_TOKEN_NEW", Primary: true}, {IBAN: "CZ8520100000002800359168", AcctNum: "2800359168", TokenEnv: "FIO_API_TOKEN_OLD"}, } ``` Compute the **real** IBAN check digit for `2502035405` once and bake it in (replace the `85` placeholder above). The same `IBANAccountNum` helper at [config.go:66](../../go/internal/config/config.go#L66) keeps producing `AcctNum`, but having it on the struct avoids re-parsing at every call site. - `Config` struct: - Drop `BankAccount` and `FioAPIToken` fields. - Add `QRAccount string` (the IBAN of `Accounts[i].Primary == true`). - Add `LoadedAccounts []LoadedAccount` where each entry pairs an `Account` with the token resolved from `os.Getenv(account.TokenEnv)`. - `Load()` reads each `TokenEnv` and stores tokens with the account. Logs a warning (info-level) if no token is set — that's the transparent-scraper path, which is fine. ### 2. Sync loop — one `fio.Client` per account, single dedup pass [go/internal/services/banksync/sync.go](../../go/internal/services/banksync/sync.go) Change `SyncToSheets` to accept a slice of clients instead of one: ```go func SyncToSheets( ctx context.Context, spreadsheetID string, fioClients []fio.Client, // was: fioClient fio.Client sh sheetsWriter, opts SyncOpts, ) (int, error) ``` Inside: - Keep steps 1 (read existing IDs) and 2 (date window) as-is. - Step 3 (fetch) becomes a loop: call `FetchTransactions` on each client; tag each `fio.Transaction` with the account it came from for logging only (don't write it to the sheet). Concatenate into one slice `txns`. - Steps 4a–4c (sync_id calc, debug table, row build, dedup) are unchanged — the combined `txns` slice flows through the same code path. Existing `sync_id` collisions across accounts are dropped silently, which is the desired dedup behaviour. - `printFioTable` (dry-run debug) should print an extra column showing the source account so per-account fetches are visible — small change in [fio_table.go](../../go/internal/services/banksync/fio_table.go). Logger output: log fetched count per account and the total combined count. ### 3. Call sites — build clients from the config list Both entry points construct `[]fio.Client`, one per `LoadedAccount`: - [go/cmd/fuj/main.go:91](../../go/cmd/fuj/main.go#L91) — server `BankSync` action. - [go/cmd/fuj/main.go:200](../../go/cmd/fuj/main.go#L200) — `fuj sync` CLI. Helper to add somewhere in `cmd/fuj/main.go` or a new `internal/services/banksync/clients.go`: ```go func clientsFromConfig(cfg config.Config) []fio.Client { out := make([]fio.Client, 0, len(cfg.LoadedAccounts)) for _, a := range cfg.LoadedAccounts { out = append(out, fio.New(a.Token, a.AcctNum, nil)) } return out } ``` The existing factory at [go/internal/io/fio/client.go:35](../../go/internal/io/fio/client.go#L35) already routes per-account between the API path and the transparent scraper based on whether the token is empty — no change there. ### 4. QR endpoint — default to `QRAccount` [go/internal/web/html_handler.go:129](../../go/internal/web/html_handler.go#L129) and [html_handler.go:135](../../go/internal/web/html_handler.go#L135): swap `h.apiHandler.Config.BankAccount` → `h.apiHandler.Config.QRAccount`. API DTOs that surface the payment account in JSON: [build_adults.go:141](../../go/internal/web/api/build_adults.go#L141) and [build_juniors.go:137](../../go/internal/web/api/build_juniors.go#L137) — same swap. The fields on the API structs at [adults.go:40](../../go/internal/web/api/adults.go#L40) and [juniors.go:39](../../go/internal/web/api/juniors.go#L39) keep their JSON names so the Python parity binary doesn't see a schema diff. ### 5. Tests - **New** [go/internal/services/banksync/sync_test.go](../../go/internal/services/banksync/sync_test.go): add a test `TestSyncToSheets_MultiAccount` that wires up two fake `fio.Client`s (via the existing [fake.go](../../go/internal/io/fio/fake.go)), one returning a txn unique to it and one returning a txn that duplicates an existing sync_id. Assert: rows from both accounts get appended, the duplicate is dropped. - **Update** [go/internal/web/html_handler_test.go:207](../../go/internal/web/html_handler_test.go#L207) `TestServeQR` and [qr_test.go](../../go/internal/web/qr_test.go) `TestQRBuildSPD` — expected SPD strings now contain the new IBAN by default. - **Update** any config tests asserting `cfg.BankAccount` directly to use `cfg.QRAccount` and the new `LoadedAccounts` slice. (Grep for `BankAccount` under `go/` after the rename.) - Existing fio parser tests ([fio_test.go](../../go/internal/io/fio/fio_test.go)) and sync_id test ([synch_test.go](../../go/internal/domain/synch/synch_test.go)) need no changes — they don't know about the account list. ### 6. Verification (Go) 1. `cd go && go build ./... && go test ./...` — all green. 2. `FIO_API_TOKEN_NEW="" FIO_API_TOKEN_OLD="" ./build/fuj sync --dry-run --print-fio-table --days 7` — see fetched transactions from **both** accounts, with NEW/DUP status, without writing. 3. `./build/fuj server` then visit `http://localhost:8080/qr?amount=700&message=test` — QR payload SPD must contain the new IBAN `CZ??...2502035405`. 4. Real sync once dry-run looks right: `./build/fuj sync --days 30` — confirm in the Google sheet that rows from the old account still appear and no duplicates are written. --- ## Python port (after Go is merged) The Python app uses the same payments sheet and the same column schema, so the port mirrors the Go change one-for-one. Goal: one round-trip of `make sync-2026` pulls from both accounts. ### 1. `scripts/config.py` - Replace the scalar `BANK_ACCOUNT` constant at [scripts/config.py:25](../../scripts/config.py#L25) with a hardcoded list plus a derived primary: ```python ACCOUNTS = [ {"iban": "CZ0820100000002502035405", "acct_num": "2502035405", "token_env": "FIO_API_TOKEN_NEW", "primary": True}, {"iban": "CZ8520100000002800359168", "acct_num": "2800359168", "token_env": "FIO_API_TOKEN_OLD", "primary": False}, ] BANK_ACCOUNT = next(a["iban"] for a in ACCOUNTS if a["primary"]) # QR / template default ``` Use the real check-digit IBAN for `2502035405` (the same one baked into Go). - Resolve tokens at module load time with `os.environ.get(token_env, "")` and stash them on a parallel `LOADED_ACCOUNTS` list (or attach to the dicts) so other modules don't all re-read env. ### 2. `scripts/fio_utils.py` - [fio_utils.py:106](../../scripts/fio_utils.py#L106) and [fio_utils.py:219](../../scripts/fio_utils.py#L219): drop the hardcoded `"2800359168"` default for `account_id`. Make it a required argument. - `fetch_transactions(date_from, date_to, *, account)` — change signature to take one account dict, return that account's transactions. Internally still routes via `FIO_API_TOKEN` if the account dict has a token, otherwise the transparent scraper at [fio_utils.py:105](../../scripts/fio_utils.py#L105). - Add `fetch_transactions_all(date_from, date_to, accounts=None)` that loops over `accounts` (default `config.LOADED_ACCOUNTS`), calls the per-account fetch, concatenates results. Logs per-account counts. ### 3. `scripts/sync_fio_to_sheets.py` - Where it currently calls `fio_utils.fetch_transactions(...)`, switch to `fio_utils.fetch_transactions_all(...)`. The dedup at [sync_fio_to_sheets.py:62](../../scripts/sync_fio_to_sheets.py#L62) and append at [sync_fio_to_sheets.py:248](../../scripts/sync_fio_to_sheets.py#L248) need no changes — same as in Go, the combined list flows through unchanged. ### 4. `app.py` (QR + template defaults) - `/qr` at [app.py:321](../../app.py#L321) already reads `BANK_ACCOUNT` from config; since the config default now points at the new IBAN, this is a zero-line change in `app.py`. - The `BANK_ACCOUNT` passed into templates at [app.py:180](../../app.py#L180), [app.py:204](../../app.py#L204), [app.py:255](../../app.py#L255), [app.py:291](../../app.py#L291) likewise picks up the new default automatically. ### 5. Tests The Python suite has **no** existing coverage of `fio_utils`, `sync_fio_to_sheets`, or `/qr`. Don't add new tests — keep the port minimal and rely on the Go test suite + manual verification for correctness. ### 6. Verification (Python) 1. `make test` — existing suite stays green (the config refactor must not break [tests/test_app.py](../../tests/test_app.py)). 2. `make sync-2026` against the real sheet, dry-run first if the script supports it, then real run — confirm rows from both accounts arrive. 3. `make web-debug` → open `/qr?amount=700&message=test` → QR payload SPD contains the new IBAN. --- ## Branching Per `CLAUDE.md`: this is a feature, so `feat/multi-account-bank-sync` off `main`. Two MRs: 1. `feat/multi-account-bank-sync-go` — Go change end-to-end. 2. `feat/multi-account-bank-sync-py` — Python port, opened after (1) is merged. Each MR opened via `tea pr create`. CHANGELOG.md entry per MR. ## Out of scope - No sheet schema migration. - No change to `sync_id` hash inputs at [domain/synch/synch.go:14](../../go/internal/domain/synch/synch.go#L14) or [sync_fio_to_sheets.py:67](../../scripts/sync_fio_to_sheets.py#L67). - No UI surface for switching the QR account per-request (the existing `?account=` query param on `/qr` keeps working as an override). - No backfill / dedup pass over historical rows.