All checks were successful
Deploy to K8s / deploy (push) Successful in 24s
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>
270 lines
11 KiB
Markdown
270 lines
11 KiB
Markdown
# 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.
|