diff --git a/Makefile b/Makefile index 4c521d4..1f1efe2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help fees match web web-py web-debug web-go go-build go-test go-test-all go-parity go-run go-lint capture-fixtures image run sync sync-2026 test test-v docs +.PHONY: help fees match web web-py web-debug web-go go-build go-test go-test-all go-parity go-run go-sync-debug go-lint capture-fixtures image run sync sync-2026 test test-v docs export PYTHONPATH := scripts:$(PYTHONPATH) VENV := .venv @@ -27,6 +27,7 @@ help: @echo " make go-parity - Run Go parity tests (requires -tags=parity fixture corpus)" @echo " make go-test-all - Run both unit and parity tests" @echo " make go-lint - Run golangci-lint on Go code" + @echo " make go-sync-debug [DAYS=N] - Dry-run Go sync with Fio debug logs and txn table (default DAYS=30)" @echo " make capture-fixtures - Regenerate parity fixture corpus from live Python" @echo " make image - Build Python OCI container image" @echo " make run - Run the built Python Docker image locally" @@ -91,6 +92,10 @@ capture-fixtures: $(PYTHON) go-run: go-build ./$(GO_BIN) $(ARGS) +DAYS ?= 30 +go-sync-debug: go-build + LOG_LEVEL=DEBUG ./$(GO_BIN) sync -dry-run -print-fio-table -days $(DAYS) + go-lint: cd $(GO_SRC) && golangci-lint run ./... diff --git a/docs/plans/2026-05-07-1321-fuj-sync-print-fio-table.md b/docs/plans/2026-05-07-1321-fuj-sync-print-fio-table.md new file mode 100644 index 0000000..5f4dd53 --- /dev/null +++ b/docs/plans/2026-05-07-1321-fuj-sync-print-fio-table.md @@ -0,0 +1,29 @@ +# Add `--print-fio-table` debug flag to `fuj sync` + +## Context + +The Go port of `fuj sync --dry-run` currently prints only the **new** +transactions — i.e. rows that will be appended to the payments sheet after +deduping against existing Sync IDs (see [sync.go:125-129](../../go/internal/services/banksync/sync.go#L125-L129)). +When debugging Fio sync issues ("why isn't transaction X showing up?", +"is the dedup working?"), there's no way to see what Fio actually +returned versus what got filtered as a duplicate. + +This change adds a `--print-fio-table` flag that, **only when combined +with `--dry-run`**, prints an aligned table of every Fio transaction in +the window with each row marked `NEW` (would be appended) or `DUP` +(already in sheet, skipped). The flag is silently ignored without +`--dry-run`, so it can't accidentally fire during a real sync. + +## Decisions + +- Flag name: `--print-fio-table` (specific, not generic `--verbose`). +- Columns: `DATE | AMOUNT | SENDER | VS | MESSAGE | BANKID | STATUS`, + with MESSAGE truncated and STATUS = `NEW` / `DUP`. +- Scope: only effective when `--dry-run` is also set. + +## Files modified + +- [go/cmd/fuj/main.go](../../go/cmd/fuj/main.go) — new flag + SyncOpts field +- [go/internal/services/banksync/sync.go](../../go/internal/services/banksync/sync.go) — SyncOpts struct + refactored step 4 +- [go/internal/services/banksync/debug.go](../../go/internal/services/banksync/debug.go) — printFioTable helper (new) diff --git a/go/cmd/fuj/main.go b/go/cmd/fuj/main.go index ebe8d0d..dfa21a3 100644 --- a/go/cmd/fuj/main.go +++ b/go/cmd/fuj/main.go @@ -11,6 +11,7 @@ import ( "fuj-management/go/internal/services/banksync" "fuj-management/go/internal/services/membership" "fuj-management/go/internal/web" + "log/slog" "os" "time" ) @@ -28,6 +29,9 @@ func main() { os.Exit(2) } + // Honour LOG_LEVEL for slog calls in any package (e.g. internal/io/fio debug logs). + slog.SetDefault(logging.New(os.Getenv("LOG_LEVEL"))) + cmd, args := os.Args[1], os.Args[2:] switch cmd { @@ -135,8 +139,9 @@ func syncCmd(args []string) { toStr := fs.String("to", "", "end date YYYY-MM-DD") sort := fs.Bool("sort", true, "sort sheet by date after appending") dryRun := fs.Bool("dry-run", false, "print planned writes without modifying the sheet") + printFioTable := fs.Bool("print-fio-table", false, "with --dry-run: print aligned table of every Fio transaction with NEW/DUP status") fs.Usage = func() { - fmt.Fprintln(os.Stderr, "usage: fuj sync [--days N] [--from YYYY-MM-DD --to YYYY-MM-DD] [--sort] [--dry-run]") + fmt.Fprintln(os.Stderr, "usage: fuj sync [--days N] [--from YYYY-MM-DD --to YYYY-MM-DD] [--sort] [--dry-run] [--print-fio-table]") fs.PrintDefaults() } if err := fs.Parse(args); err != nil { @@ -154,7 +159,7 @@ func syncCmd(args []string) { } fioCli := fio.New(cfg.FioAPIToken, config.IBANAccountNum(cfg.BankAccount), nil) - opts := banksync.SyncOpts{Days: *days, Sort: *sort, DryRun: *dryRun} + opts := banksync.SyncOpts{Days: *days, Sort: *sort, DryRun: *dryRun, PrintFioTable: *printFioTable} if *fromStr != "" && *toStr != "" { opts.From, err = time.Parse("2006-01-02", *fromStr) if err != nil { diff --git a/go/internal/io/fio/api.go b/go/internal/io/fio/api.go index 26a8172..be32f91 100644 --- a/go/internal/io/fio/api.go +++ b/go/internal/io/fio/api.go @@ -5,7 +5,9 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net/http" + "strings" "time" ) @@ -25,6 +27,9 @@ func (c *apiClient) FetchTransactions(ctx context.Context, from, to time.Time) ( const layout = "2006-01-02" url := fmt.Sprintf("https://fioapi.fio.cz/v1/rest/periods/%s/%s/%s/transactions.json", c.token, from.Format(layout), to.Format(layout)) + slog.Debug("fio api: GET", + "url", strings.Replace(url, c.token, "****", 1), + "from", from.Format(layout), "to", to.Format(layout)) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -35,6 +40,7 @@ func (c *apiClient) FetchTransactions(ctx context.Context, from, to time.Time) ( return nil, err } defer resp.Body.Close() + slog.Debug("fio api: response", "status", resp.StatusCode) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("fio api: HTTP %d", resp.StatusCode) } @@ -42,7 +48,9 @@ func (c *apiClient) FetchTransactions(ctx context.Context, from, to time.Time) ( if err != nil { return nil, err } - return parseAPIResponse(body) + txns, err := parseAPIResponse(body) + slog.Debug("fio api: parsed", "body_bytes", len(body), "parsed_count", len(txns)) + return txns, err } // fioAPIResponse is the top-level envelope from the Fio JSON API. diff --git a/go/internal/io/fio/client.go b/go/internal/io/fio/client.go index db40d54..2230ed5 100644 --- a/go/internal/io/fio/client.go +++ b/go/internal/io/fio/client.go @@ -4,6 +4,7 @@ package fio import ( "context" + "log/slog" "net/http" "time" ) @@ -36,7 +37,9 @@ func New(token, accountNum string, hc httpDoer) Client { hc = http.DefaultClient } if token != "" { + slog.Debug("fio: client selected", "type", "api") return &apiClient{token: token, hc: hc} } + slog.Debug("fio: client selected", "type", "transparent", "account_num", accountNum) return &transparentClient{accountNum: accountNum, hc: hc} } diff --git a/go/internal/io/fio/fio_test.go b/go/internal/io/fio/fio_test.go index 7a3e38c..84d0281 100644 --- a/go/internal/io/fio/fio_test.go +++ b/go/internal/io/fio/fio_test.go @@ -97,6 +97,9 @@ func TestParseCzechDate(t *testing.T) { {"10/04/2026", "2026-04-10"}, {"7.5.2026", "2026-05-07"}, // non-padded — real Fio transparent page format {"3.12.2025", "2025-12-03"}, // non-padded single-digit day, double-digit month + {"07.05.26", "2026-05-07"}, // padded 2-digit year — current Fio transparent page format + {"7.5.26", "2026-05-07"}, // non-padded 2-digit year + {"07/05/26", "2026-05-07"}, // slash variant {"", ""}, {"invalid", ""}, } diff --git a/go/internal/io/fio/transparent.go b/go/internal/io/fio/transparent.go index 29ff7df..31184d7 100644 --- a/go/internal/io/fio/transparent.go +++ b/go/internal/io/fio/transparent.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "log/slog" "net/http" "regexp" "strings" @@ -28,6 +29,10 @@ func (c *transparentClient) FetchTransactions(ctx context.Context, from, to time from.Format("2.1.2006"), to.Format("2.1.2006"), ) + slog.Debug("fio transparent: GET", + "url", url, + "from", from.Format("2006-01-02"), "to", to.Format("2006-01-02")) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err @@ -37,6 +42,7 @@ func (c *transparentClient) FetchTransactions(ctx context.Context, from, to time return nil, err } defer resp.Body.Close() + slog.Debug("fio transparent: response", "status", resp.StatusCode) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("fio transparent: HTTP %d", resp.StatusCode) } @@ -44,6 +50,7 @@ func (c *transparentClient) FetchTransactions(ctx context.Context, from, to time if err != nil { return nil, err } + slog.Debug("fio transparent: body read", "body_bytes", len(body)) return parseTransparentHTML(body) } @@ -63,6 +70,7 @@ func parseTransparentHTML(body []byte) ([]Transaction, error) { rows := extractSecondTableRows(body) var txns []Transaction + var droppedBadDate, droppedNonpositive int for _, row := range rows { col := func(i int) string { if i < len(row) { @@ -72,7 +80,12 @@ func parseTransparentHTML(body []byte) ([]Transaction, error) { } dateStr := parseCzechDate(col(tColDate)) amount := parseCzechAmount(col(tColAmount)) - if dateStr == "" || amount <= 0 { + if dateStr == "" { + droppedBadDate++ + continue + } + if amount <= 0 { + droppedNonpositive++ continue } txns = append(txns, Transaction{ @@ -86,6 +99,11 @@ func parseTransparentHTML(body []byte) ([]Transaction, error) { BankID: "", // not available on HTML path }) } + slog.Debug("fio transparent: parsed", + "raw_rows", len(rows), + "kept", len(txns), + "dropped_bad_date", droppedBadDate, + "dropped_nonpositive_amount", droppedNonpositive) return txns, nil } @@ -191,7 +209,10 @@ func hasClass(t ghtml.Token, cls string) bool { // Returns "" on parse error. func parseCzechDate(s string) string { s = strings.TrimSpace(s) - for _, layout := range []string{"2.1.2006", "02.01.2006", "2/1/2006", "02/01/2006"} { + for _, layout := range []string{ + "2.1.2006", "02.01.2006", "2/1/2006", "02/01/2006", + "2.1.06", "02.01.06", "2/1/06", "02/01/06", + } { if t, err := time.Parse(layout, s); err == nil { return t.Format("2006-01-02") } diff --git a/go/internal/services/banksync/fio_table.go b/go/internal/services/banksync/fio_table.go new file mode 100644 index 0000000..0fbb2e6 --- /dev/null +++ b/go/internal/services/banksync/fio_table.go @@ -0,0 +1,32 @@ +package banksync + +import ( + "fmt" + "io" + "text/tabwriter" + + "fuj-management/go/internal/io/fio" +) + +func printFioTable(w io.Writer, txns []fio.Transaction, syncIDs []string, existing map[string]bool) { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "DATE\tAMOUNT\tSENDER\tVS\tMESSAGE\tBANKID\tSTATUS") + for i, tx := range txns { + status := "NEW" + if existing[syncIDs[i]] { + status = "DUP" + } + fmt.Fprintf(tw, "%s\t%.2f\t%s\t%s\t%s\t%s\t%s\n", + tx.Date, tx.Amount, tx.Sender, tx.VS, + truncRunes(tx.Message, 40), tx.BankID, status) + } + _ = tw.Flush() +} + +func truncRunes(s string, n int) string { + rs := []rune(s) + if len(rs) <= n { + return s + } + return string(rs[:n-1]) + "…" +} diff --git a/go/internal/services/banksync/sync.go b/go/internal/services/banksync/sync.go index a036c2e..7a5fe6a 100644 --- a/go/internal/services/banksync/sync.go +++ b/go/internal/services/banksync/sync.go @@ -6,6 +6,7 @@ import ( "fmt" "fuj-management/go/internal/domain/synch" "fuj-management/go/internal/io/fio" + "os" "strings" "time" ) @@ -27,10 +28,11 @@ type sheetsWriter interface { // SyncOpts controls the date window and sort behaviour. type SyncOpts struct { - Days int // look-back window when From/To are zero - From, To time.Time // explicit window (overrides Days) - Sort bool // sort the sheet by Date after appending - DryRun bool // print planned writes without modifying the sheet + Days int // look-back window when From/To are zero + From, To time.Time // explicit window (overrides Days) + Sort bool // sort the sheet by Date after appending + DryRun bool // print planned writes without modifying the sheet + PrintFioTable bool // with DryRun: print every fetched Fio txn with NEW/DUP status } // SyncToSheets fetches Fio transactions and appends new ones to the payments sheet. @@ -92,14 +94,14 @@ func SyncToSheets( from.Format("2006-01-02"), to.Format("2006-01-02"), len(txns)) } - // 4. Append new rows. - var newRows [][]any - for _, tx := range txns { + // 4a. Compute Sync IDs for every fetched txn (shared by table-print and row-build). + syncIDs := make([]string, len(txns)) + for i, tx := range txns { currency := tx.Currency if currency == "" { currency = "CZK" } - id := synch.GenerateSyncID(synch.Transaction{ + syncIDs[i] = synch.GenerateSyncID(synch.Transaction{ Date: tx.Date, Amount: tx.Amount, Currency: currency, @@ -108,13 +110,23 @@ func SyncToSheets( Message: tx.Message, BankID: tx.BankID, }) - if existingIDs[id] { + } + + // 4b. Optional debug table (dry-run only; suppress when nothing was fetched). + if opts.DryRun && opts.PrintFioTable && len(txns) > 0 { + printFioTable(os.Stdout, txns, syncIDs, existingIDs) + } + + // 4c. Build new rows. + var newRows [][]any + for i, tx := range txns { + if existingIDs[syncIDs[i]] { continue } newRows = append(newRows, []any{ tx.Date, tx.Amount, "", "", "", "", // manual fix, Person, Purpose, Inferred Amount - tx.Sender, tx.VS, tx.Message, tx.BankID, id, + tx.Sender, tx.VS, tx.Message, tx.BankID, syncIDs[i], }) }