Add second Fio account (CZ0820100000002502035405 / 2502035405/2010). Both accounts are fetched on every sync run and combined before dedup, so the payments sheet accumulates transactions from either account. QR codes now default to the new account. Go: - config.go: hardcoded Accounts/LoadedAccount slice replaces scalar BankAccount + FioAPIToken; Config.BankAccount renamed QRAccount; per-account tokens via FIO_API_TOKEN_NEW / FIO_API_TOKEN_OLD - banksync.SyncToSheets: accepts []fio.Client, loops to combine txns - cmd/fuj/main.go: buildFioClients helper; both sync call sites updated - html_handler + build_adults/juniors: use Config.QRAccount - New TestSyncToSheets_MultiAccount covers cross-account dedup Python: - config.py: ACCOUNTS list + LOADED_ACCOUNTS (tokens from env) - fio_utils.py: fetch_transactions_for (per-account) + fetch_transactions_all (loops all accounts) - sync_fio_to_sheets.py: uses fetch_transactions_all Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
11 KiB
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/2010stays active so historical fees paid to it still reconcile. - QR codes generated by
/qrshould 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_idformula unchanged. - Old account: kept syncing in parallel; only QR target changes.
Go implementation
1. Config — hardcoded Accounts list, derived QRAccount
-
Replace scalar
BankAccount+FioAPITokenwith a hardcoded slice and a primary pointer. Tokens stay env-driven via per-account env names.// 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
2502035405once and bake it in (replace the85placeholder above). The sameIBANAccountNumhelper at config.go:66 keeps producingAcctNum, but having it on the struct avoids re-parsing at every call site. -
Configstruct:- Drop
BankAccountandFioAPITokenfields. - Add
QRAccount string(the IBAN ofAccounts[i].Primary == true). - Add
LoadedAccounts []LoadedAccountwhere each entry pairs anAccountwith the token resolved fromos.Getenv(account.TokenEnv).
- Drop
-
Load()reads eachTokenEnvand 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
Change SyncToSheets to accept a slice of clients instead of one:
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
FetchTransactionson each client; tag eachfio.Transactionwith the account it came from for logging only (don't write it to the sheet). Concatenate into one slicetxns. - Steps 4a–4c (sync_id calc, debug table, row build, dedup) are unchanged —
the combined
txnsslice flows through the same code path. Existingsync_idcollisions 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.
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 — server
BankSyncaction. - go/cmd/fuj/main.go:200 —
fuj syncCLI.
Helper to add somewhere in cmd/fuj/main.go or a new
internal/services/banksync/clients.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 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
and html_handler.go:135:
swap h.apiHandler.Config.BankAccount → h.apiHandler.Config.QRAccount.
API DTOs that surface the payment account in JSON: build_adults.go:141 and build_juniors.go:137 — same swap.
The fields on the API structs at adults.go:40 and juniors.go:39 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:
add a test
TestSyncToSheets_MultiAccountthat wires up two fakefio.Clients (via the existing 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
TestServeQRand qr_test.goTestQRBuildSPD— expected SPD strings now contain the new IBAN by default. - Update any config tests asserting
cfg.BankAccountdirectly to usecfg.QRAccountand the newLoadedAccountsslice. (Grep forBankAccountundergo/after the rename.) - Existing fio parser tests (fio_test.go) and sync_id test (synch_test.go) need no changes — they don't know about the account list.
6. Verification (Go)
cd go && go build ./... && go test ./...— all green.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../build/fuj serverthen visithttp://localhost:8080/qr?amount=700&message=test— QR payload SPD must contain the new IBANCZ??...2502035405.- 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_ACCOUNTconstant at scripts/config.py:25 with a hardcoded list plus a derived primary: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 defaultUse 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 parallelLOADED_ACCOUNTSlist (or attach to the dicts) so other modules don't all re-read env.
2. scripts/fio_utils.py
- fio_utils.py:106 and
fio_utils.py:219: drop the hardcoded
"2800359168"default foraccount_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 viaFIO_API_TOKENif the account dict has a token, otherwise the transparent scraper at fio_utils.py:105.- Add
fetch_transactions_all(date_from, date_to, accounts=None)that loops overaccounts(defaultconfig.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 tofio_utils.fetch_transactions_all(...). The dedup at sync_fio_to_sheets.py:62 and append at sync_fio_to_sheets.py:248 need no changes — same as in Go, the combined list flows through unchanged.
4. app.py (QR + template defaults)
/qrat app.py:321 already readsBANK_ACCOUNTfrom config; since the config default now points at the new IBAN, this is a zero-line change inapp.py.- The
BANK_ACCOUNTpassed into templates at app.py:180, app.py:204, app.py:255, app.py:291 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)
make test— existing suite stays green (the config refactor must not break tests/test_app.py).make sync-2026against the real sheet, dry-run first if the script supports it, then real run — confirm rows from both accounts arrive.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:
feat/multi-account-bank-sync-go— Go change end-to-end.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_idhash inputs at domain/synch/synch.go:14 or sync_fio_to_sheets.py:67. - No UI surface for switching the QR account per-request (the existing
?account=query param on/qrkeeps working as an override). - No backfill / dedup pass over historical rows.