- io/attendance: CSV-over-public-URL client + Fake for adult/junior tabs - io/drive: Drive v3 modifiedTime client + Fake - io/sheets: Sheets v4 client (GetValues/AppendValues/BatchUpdateValues/ WriteHeader/SortByDateColumn) + Fake with call-capture - io/cache: Drive-modifiedTime-gated FileCache; two TTL knobs; atomic writes; generic Get[T]; Python-compatible JSON format; Flush() - io/fio: Client interface backed by Fio REST API (apiClient) and HTML scraper (transparentClient); Fake; testdata fixtures - membership/sources: NewSources wires attendance CSV + Sheets + cache into LoadAdults/LoadJuniors/LoadTransactions/LoadExceptions; Czech month parsing + merged-month maps - banksync: SyncToSheets (SHA-256 dedup, optional sort) and InferPayments ([?] review prefix, dry-run) — tested with fakes - cmd/fuj: sync and infer subcommands wired; fees and reconcile use real NewSources; go.mod gains google.golang.org/api + x/net - gofumpt extra-rules applied across all packages; lint clean Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
16 KiB
Plan: Go rewrite — M4 IO layer behind interfaces
Companion to 2026-05-03-2349-go-backend-rewrite.md and 2026-05-03-2349-go-backend-rewrite-progress.md.
Context
M1–M3 are merged: skeleton + tooling, every pure-domain function ported and
parity-tested against PII-scrubbed fixtures, and the fuj fees / fuj reconcile subcommands wired but stubbed (membership.NewStubSources()
returns ErrIOPending for every loader). M4's job is to replace that stub
with real IO: read attendance CSVs, read the payments sheet + exceptions
tab, fetch Drive modifiedTime for cache gating, fetch Fio bank
transactions, and append/update rows on the payments sheet — all behind
narrow Go interfaces that have in-memory fakes for tests.
Once M4 lands, fuj fees, fuj reconcile, fuj sync, and fuj infer all
work end-to-end against the real Google Sheets and the real Fio account, and
M5 can start porting the JSON API on top of that IO.
User-confirmed scope choices for this milestone:
- No live integration tests. Fakes-only at unit level; live verification deferred to manual smoke during M7.
- Three PRs (sheets/drive/cache → fio/sync → infer), one per major area, each independently reviewable.
- Attendance stays on CSV-via-public-URL — matches Python, no extra service-account grant needed.
Approach
Layering
internal/io/ ← raw, narrow clients (one per external system)
sheets/ ← typed wrapper around google.golang.org/api/sheets/v4
drive/ ← Drive v3, only ModifiedTime
attendance/ ← CSV-via-public-URL fetcher (no auth, no Sheets API)
fio/ ← FioClient interface + apiClient + transparentClient
cache/ ← FileCache: modifiedTime gate + two-TTL fallback + atomic write
internal/services/membership/ ← already exists; M4 adds adapters that satisfy
AttendanceLoader / TransactionLoader / ExceptionLoader
by composing io/sheets + io/drive + io/cache + io/attendance.
internal/services/banksync/ ← new: SyncToSheets (M4.7) + InferPayments (M4.8)
composing fio + sheets + attendance loaders.
The existing interfaces in go/internal/services/membership/loader.go
(AttendanceLoader, TransactionLoader, ExceptionLoader, Sources) are
the seam — M4 adds a NewSources(cfg config.Config) (Sources, error)
constructor next to NewStubSources(), and cmd/fuj/main.go swaps the
stub for it.
Auth — service-account only
Drop the OAuth+token.pickle path entirely (the production already uses a
service account; the fallback only existed because the original Python
script ran from a developer laptop). Sheets and Drive both authenticate via
option.WithCredentialsFile(cfg.CredentialsPath) plus
option.WithScopes(...). Single shared *http.Client per backend with a
10s timeout (matches DRIVE_TIMEOUT).
Cache shape
Match Python's wire format so the tmp/*_cache.json directory is shared
safely while both backends run side-by-side:
{ "modifiedTime": "<RFC3339>", "data": <list|object>, "cachedAt": "<RFC3339>" }
Improvements over Python:
- Atomic write: marshal →
os.WriteFile(path+".tmp", ..., 0o600)→os.Rename. Python's plain truncate-write stays as-is until M8. - The two TTLs (
CacheTTLandCacheAPICheckTTL) live inconfig.Configalready; only theCacheDirfield is new.
The four cache keys mirror Python's CACHE_SHEET_MAP:
attendance_regular, attendance_juniors, exceptions_dict,
payments_transactions → maps to either AttendanceSheetID or
PaymentsSheetID.
When Drive fails, fall back to a synthetic key
fmt.Sprintf("ttl-5m-%d", time.Now().Unix()/300) so cache still keys
deterministically per 5-min bucket (same as Python).
Fio: two impls behind one interface
type Client interface {
FetchTransactions(ctx context.Context, from, to time.Time) ([]Transaction, error)
}
apiClient (when cfg.FioAPIToken != "") hits
https://fioapi.fio.cz/v1/rest/periods/{token}/{from}/{to}/transactions.json,
unmarshals via a typed struct, and maps column0..column22 to fields per
scripts/fio_utils.py. Negative-amount rows
dropped (matches Python).
transparentClient (fallback) GETs
https://ib.fio.cz/ib/transparent?a={accountNum}&f={DD.MM.YYYY}&t={DD.MM.YYYY}
and walks the response with golang.org/x/net/html token visitor, counting
<table class="table"> tags and grabbing rows from the second one
(skipping <thead>). bank_id, currency, user_id, sender_account
are empty (matches Python — known limitation).
accountNum is derived from cfg.BankAccount by stripping the IBAN prefix
(CZ85 2010 0000 0028 0035 9168 → 2800359168); add a small helper in
config for this since both the API URL and the transparent URL need it.
Fakes
In-memory fakes live next to each real impl: sheets/fake.go,
drive/fake.go, fio/fake.go, attendance/fake.go,
cache/fake.go (a passthrough). All exported as Fake so tests do
sheets.NewFake(rows) and inject. The membership-adapter tests use these
fakes plus a couple of new raw-bytes fixtures under
go/internal/io/<pkg>/testdata/:
sheets/testdata/payments_minimal.json— 2D-string array shaped likevalues.getwould return.sheets/testdata/exceptions_minimal.json— same, for the exceptions tab.attendance/testdata/adults_minimal.csv— small adult attendance CSV.attendance/testdata/juniors_minimal.csv— small junior CSV.fio/testdata/api_response.json— captured Fio API JSON shape.fio/testdata/transparent.html— captured transparent-page HTML.
Existing M3 domain fixtures under go/tests/fixtures/ stay where they are
and continue to drive parity tests; they aren't reused for IO-layer tests
because they're at the wrong layer (post-parse domain types).
Tasks (mapped to tracker)
Same 8 sub-milestones as the tracker, grouped into 3 PRs.
PR 1 — sheets / drive / cache + membership wiring (M4.1, M4.2, M4.3, M4.6)
- Add deps in go/go.mod:
google.golang.org/api/{sheets/v4,drive/v3,option},golang.org/x/oauth2/google(transitively pulled),golang.org/x/net/html. internal/io/sheets/:client.go—Clientstruct holding*sheets.Service; methodsGetValues(ctx, spreadsheetID, a1Range string) ([][]any, error),AppendValues(ctx, spreadsheetID, a1Range string, rows [][]any) error,BatchUpdateValues(ctx, spreadsheetID, updates []ValueRange) error,SortByColumn(ctx, spreadsheetID, sheetGID int64, columnIndex int) error.fake.go— exportedFakewith seedableValues map[string][][]any.
internal/io/drive/:client.go—Client.ModifiedTime(ctx, fileID string) (string, error)usingdrive.New(...).Files.Get(fileID).Fields("modifiedTime").SupportsAllDrives(true).fake.gowith seedableTimes map[string]string.
internal/io/attendance/(new — public-URL CSV):client.go—Client.FetchAdults(ctx) ([][]string, error)andFetchJuniors(ctx) ([][]string, error)usinghttp.Getonhttps://docs.google.com/spreadsheets/d/{ID}/export?format=csv&gid={GID}, decoded viaencoding/csv.- Add
AttendanceAdultSheetGID = "0"constant ininternal/config.
internal/io/cache/:filecache.go—FileCachewithGet(ctx, key string, fetch func(ctx) (any, error)) (any, error)wired throughDrive.ModifiedTimeand the two TTL knobs. Atomic write via tmp-file + rename.- Cache key → sheet ID map mirrors Python's
CACHE_SHEET_MAP.
internal/services/membership/sources.go(new file in existing package):realSources struct { sheets *sheets.Client; drive *drive.Client; attendance *attendance.Client; cache *cache.FileCache }.- Constructor
NewSources(ctx, cfg) (Sources, error)builds all clients. LoadAdultsreads cached attendance CSV, runs throughdomain/fees.CalculateFee+ merged-month logic (port of scripts/attendance.pyget_members_with_fees), returns[]reconcile.Member.LoadTransactionsreads payments sheet rows via cache, parses to[]reconcile.Transaction(port of match_payments.py:208fetch_sheet_data).LoadExceptionsreads'exceptions'!A2:Dvia cache, buildsmap[ExceptionKey]Exception(port ofmatch_payments.py:266).
- Add
LoadJuniorsto theAttendanceLoaderinterface (Python infer pulls both adult + junior member lists; needed for M4.8). - Wire into cmd/fuj/main.go: replace
membership.NewStubSources()infeesCmdandreconcileCmdwithmembership.NewSources(ctx, cfg). - Tests (default tag, no live IO):
sheets/client_test.go,drive/client_test.go,cache/filecache_test.go— exercise fakes + parsing logic with testdata fixtures.membership/sources_test.go— adapter tests with sheets/drive/cache fakes verify CSV→Member, rows→Transaction, exceptions tab → map.
- Config additions:
CacheDir(defaulttmprelative to$PWD, overridable viaCACHE_DIRenv),DriveTimeout(default 10s). - Manual verification:
make go-build && go run ./cmd/fuj feesand... reconcileprint real reports against the live sheet (with valid.secret/...credentials.json). - CHANGELOG entry; tick M4.1, M4.2, M4.3, M4.6 in the progress tracker.
PR 2 — fio + bank sync (M4.4, M4.5, M4.7)
internal/io/fio/:client.go—Clientinterface,Transactionstruct.api.go—apiClientimpl + URL builder + JSON struct definitions foraccountStatement.transactionList.transaction[].column{N}.value.transparent.go—transparentClientimpl usinggolang.org/x/net/htmltoken visitor; helper functionsparseCzechAmount(NBSP/space strip + comma→dot) andparseCzechDate(DD.MM.YYYY / DD/MM/YYYY).fake.go.New(cfg) Clientchooses impl based oncfg.FioAPIToken.accountNum(iban)helper ininternal/configstrips IBAN prefix.
internal/services/banksync/sync.go(new package):SyncToSheets(ctx, cfg, fio Client, sheets *sheets.Client, opts SyncOpts) (added int, err error).- Reads existing rows via
sheets.GetValues(... "A1:K"), validates header againstCOLUMN_LABELS, writes header if missing, buildsexistingIDsfrom column K (Sync ID). - Computes date window: explicit
from/toornow - days*24h(default 30d). - For each fetched tx, computes
domain/synch.GenerateSyncID, skips if present, otherwise builds row in COLUMN_LABELS order with empty manual/person/purpose/inferred slots. sheets.AppendValues(... "A2", rows).- Optional sort:
sheets.SortByColumn(... gid, 0)— sheet GID resolved once viaspreadsheets.Get.
- Wire
fuj syncsubcommand incmd/fuj/main.go:- Flags:
--days N(default 30),--from YYYY-MM-DD,--to YYYY-MM-DD,--sort(default true matchingmake sync-2026). - Replace the M4-stub error path.
- Flags:
- Tests (default tag):
banksync/sync_test.gowith fakes — verify header insertion, dedup against existing sync IDs, multi-row append, sort call. - Manual verification: dry-run sync against the real Fio account in a
throwaway test sheet; or visually verify
--from --towindow in stdout with a no-write flag (only if cheap to add — otherwise skip per the "no live integration tests" decision). - CHANGELOG entry; tick M4.4, M4.5, M4.7.
PR 3 — infer (M4.8)
internal/services/banksync/infer.go:InferPayments(ctx, cfg, sheets *sheets.Client, attendanceLoader, juniorLoader, opts InferOpts) (updated int, err error).- Reads payments sheet
A1:Zwith case-insensitive header lookup. - Required columns:
Person, Purpose, Inferred Amount. Optional input:Date, Amount, Sender, Message, VS, manual fix. - Skip rule (matches scripts/infer_payments.py:127):
non-empty
manual fixORPersonORPurpose→ leave row alone. - Member list = union of
LoadAdults+LoadJuniorsdeduped viadomain/matching.CanonicalKey(already exists from M2). - For each empty row: build tx dict, call
domain/matching.InferTransactionDetails, prefix[?]if confidence == "review", emit aValueRangeupdate with R1C1 rangeR{i}C{personCol+1}:R{i}C{amountCol+1}. - Single
sheets.BatchUpdateValuescall for all updates.
- Wire
fuj infersubcommand: flags--dry-run(prints planned updates, no API write). - Tests (default tag):
banksync/infer_test.go— fixture rows, verify skip rule, verify[?]prefix on review matches, verify batchUpdate payload shape, verify--dry-runis no-op. - CHANGELOG entry; tick M4.8 → milestone gate ✅.
Critical files
To modify:
- go/internal/services/membership/loader.go — add
LoadJuniorstoAttendanceLoader, addNewSources. - go/cmd/fuj/main.go — swap stub for real sources, add
sync/infersubcommands. - go/internal/config/config.go — add
CacheDir,DriveTimeout,AttendanceAdultSheetGIDconstant, IBAN→account-num helper. - go/go.mod /
go.sum— google APIs +x/net/html. - docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md — tick M4.x boxes after each PR.
- CHANGELOG.md — entry per PR.
To create:
go/internal/io/{sheets,drive,attendance,fio,cache}/{client,fake,*_test}.gogo/internal/io/{sheets,attendance,fio}/testdata/*go/internal/services/membership/sources.go(+sources_test.go)go/internal/services/banksync/{sync,infer}.go(+ tests)
Reused existing helpers
domain/fees.CalculateFee/CalculateJuniorFee— fee math (M2.3, M2.4).domain/matching.{BuildNameVariants,MatchMembers,InferTransactionDetails,FormatDate,CanonicalKey}— match logic (M2.7–M2.9).domain/synch.GenerateSyncID— dedup hash (M2.6).domain/reconcile.{Member,Transaction,Exception,ExceptionKey}— domain types.domain/czech.{Normalize,ParseMonthReferences}— used inside the attendance/exceptions parsers.domain/money.ParseCZK— for parsing transparent-scrape amounts.
Verification
End-to-end checks once all three PRs land:
make go-build && make go-lint && make go-test— clean.make go-parity— M3 fixtures still pass (no domain regressions)../bin/fuj fees— prints adult fee report matching Pythonmake fees(visual diff acceptable for now; byte-equality enforced in M5)../bin/fuj reconcile— prints balance report comparable to scripts/match_payments.pyprint_balance_report../bin/fuj sync --days 7— appends new Fio rows to the payments sheet (run with a real but recent date window; verify by counting added rows and confirming no duplicates on a second run)../bin/fuj infer --dry-run— prints planned Person/Purpose/Inferred Amount updates without modifying the sheet. Then./bin/fuj inferapplies them; second run is a no-op (skip rule).- Cache check: delete
tmp/*_cache.json, runfuj fees, verify file appears withmodifiedTimematching Drive. Re-run within 5 min; verify no Drive call (debug log). - Cross-process cache safety: while
make web-pyis running, runfuj reconcile; verify Python's cache file isn't corrupted and Go reads the same data.
Gate (per tracker):
go test -tags=integration ./internal/io/...round-trips against test sheet; default-tag tests run on fakes.
Per the user's scope decision, the integration-test gate is downgraded to "default-tag tests on fakes" only. Live verification is deferred to manual smoke during M7's parallel-run watch period. The progress tracker's M4 gate line will be amended in PR 1.