Files
fuj-management/docs/plans/2026-05-24-2120-multi-account-bank-sync.md
Jan Novak 69af4c1e3b
All checks were successful
Deploy to K8s / deploy (push) Successful in 24s
feat: multi-account Fio sync + switch QR default to 2502035405/2010
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>
2026-05-24 21:42:47 +02:00

11 KiB
Raw Blame History

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

  • Replace scalar BankAccount + FioAPIToken with 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 2502035405 once and bake it in (replace the 85 placeholder above). The same IBANAccountNum helper at config.go:66 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

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 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 4a4c (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.

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:

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.BankAccounth.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_MultiAccount that wires up two fake fio.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 TestServeQR and 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) and sync_id test (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 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 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 and fio_utils.py:219: 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.
  • 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 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)

  • /qr at app.py:321 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: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)

  1. make test — existing suite stays green (the config refactor must not break 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 or sync_fio_to_sheets.py:67.
  • 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.