Merge pull request 'feat(go): add --print-fio-table debug flag to fuj sync' (#14) from feat/fuj-sync-print-fio-table into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s

Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
2026-05-07 12:13:19 +00:00
9 changed files with 134 additions and 16 deletions

View File

@@ -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) export PYTHONPATH := scripts:$(PYTHONPATH)
VENV := .venv VENV := .venv
@@ -27,6 +27,7 @@ help:
@echo " make go-parity - Run Go parity tests (requires -tags=parity fixture corpus)" @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-test-all - Run both unit and parity tests"
@echo " make go-lint - Run golangci-lint on Go code" @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 capture-fixtures - Regenerate parity fixture corpus from live Python"
@echo " make image - Build Python OCI container image" @echo " make image - Build Python OCI container image"
@echo " make run - Run the built Python Docker image locally" @echo " make run - Run the built Python Docker image locally"
@@ -91,6 +92,10 @@ capture-fixtures: $(PYTHON)
go-run: go-build go-run: go-build
./$(GO_BIN) $(ARGS) ./$(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: go-lint:
cd $(GO_SRC) && golangci-lint run ./... cd $(GO_SRC) && golangci-lint run ./...

View File

@@ -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)

View File

@@ -11,6 +11,7 @@ import (
"fuj-management/go/internal/services/banksync" "fuj-management/go/internal/services/banksync"
"fuj-management/go/internal/services/membership" "fuj-management/go/internal/services/membership"
"fuj-management/go/internal/web" "fuj-management/go/internal/web"
"log/slog"
"os" "os"
"time" "time"
) )
@@ -28,6 +29,9 @@ func main() {
os.Exit(2) 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:] cmd, args := os.Args[1], os.Args[2:]
switch cmd { switch cmd {
@@ -135,8 +139,9 @@ func syncCmd(args []string) {
toStr := fs.String("to", "", "end date YYYY-MM-DD") toStr := fs.String("to", "", "end date YYYY-MM-DD")
sort := fs.Bool("sort", true, "sort sheet by date after appending") sort := fs.Bool("sort", true, "sort sheet by date after appending")
dryRun := fs.Bool("dry-run", false, "print planned writes without modifying the sheet") 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() { 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() fs.PrintDefaults()
} }
if err := fs.Parse(args); err != nil { 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) 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 != "" { if *fromStr != "" && *toStr != "" {
opts.From, err = time.Parse("2006-01-02", *fromStr) opts.From, err = time.Parse("2006-01-02", *fromStr)
if err != nil { if err != nil {

View File

@@ -5,7 +5,9 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log/slog"
"net/http" "net/http"
"strings"
"time" "time"
) )
@@ -25,6 +27,9 @@ func (c *apiClient) FetchTransactions(ctx context.Context, from, to time.Time) (
const layout = "2006-01-02" const layout = "2006-01-02"
url := fmt.Sprintf("https://fioapi.fio.cz/v1/rest/periods/%s/%s/%s/transactions.json", url := fmt.Sprintf("https://fioapi.fio.cz/v1/rest/periods/%s/%s/%s/transactions.json",
c.token, from.Format(layout), to.Format(layout)) 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) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
@@ -35,6 +40,7 @@ func (c *apiClient) FetchTransactions(ctx context.Context, from, to time.Time) (
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
slog.Debug("fio api: response", "status", resp.StatusCode)
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fio api: HTTP %d", resp.StatusCode) 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 { if err != nil {
return nil, err 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. // fioAPIResponse is the top-level envelope from the Fio JSON API.

View File

@@ -4,6 +4,7 @@ package fio
import ( import (
"context" "context"
"log/slog"
"net/http" "net/http"
"time" "time"
) )
@@ -36,7 +37,9 @@ func New(token, accountNum string, hc httpDoer) Client {
hc = http.DefaultClient hc = http.DefaultClient
} }
if token != "" { if token != "" {
slog.Debug("fio: client selected", "type", "api")
return &apiClient{token: token, hc: hc} return &apiClient{token: token, hc: hc}
} }
slog.Debug("fio: client selected", "type", "transparent", "account_num", accountNum)
return &transparentClient{accountNum: accountNum, hc: hc} return &transparentClient{accountNum: accountNum, hc: hc}
} }

View File

@@ -97,6 +97,9 @@ func TestParseCzechDate(t *testing.T) {
{"10/04/2026", "2026-04-10"}, {"10/04/2026", "2026-04-10"},
{"7.5.2026", "2026-05-07"}, // non-padded — real Fio transparent page format {"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 {"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", ""}, {"invalid", ""},
} }

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"log/slog"
"net/http" "net/http"
"regexp" "regexp"
"strings" "strings"
@@ -28,6 +29,10 @@ func (c *transparentClient) FetchTransactions(ctx context.Context, from, to time
from.Format("2.1.2006"), from.Format("2.1.2006"),
to.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) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -37,6 +42,7 @@ func (c *transparentClient) FetchTransactions(ctx context.Context, from, to time
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
slog.Debug("fio transparent: response", "status", resp.StatusCode)
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fio transparent: HTTP %d", resp.StatusCode) 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 { if err != nil {
return nil, err return nil, err
} }
slog.Debug("fio transparent: body read", "body_bytes", len(body))
return parseTransparentHTML(body) return parseTransparentHTML(body)
} }
@@ -63,6 +70,7 @@ func parseTransparentHTML(body []byte) ([]Transaction, error) {
rows := extractSecondTableRows(body) rows := extractSecondTableRows(body)
var txns []Transaction var txns []Transaction
var droppedBadDate, droppedNonpositive int
for _, row := range rows { for _, row := range rows {
col := func(i int) string { col := func(i int) string {
if i < len(row) { if i < len(row) {
@@ -72,7 +80,12 @@ func parseTransparentHTML(body []byte) ([]Transaction, error) {
} }
dateStr := parseCzechDate(col(tColDate)) dateStr := parseCzechDate(col(tColDate))
amount := parseCzechAmount(col(tColAmount)) amount := parseCzechAmount(col(tColAmount))
if dateStr == "" || amount <= 0 { if dateStr == "" {
droppedBadDate++
continue
}
if amount <= 0 {
droppedNonpositive++
continue continue
} }
txns = append(txns, Transaction{ txns = append(txns, Transaction{
@@ -86,6 +99,11 @@ func parseTransparentHTML(body []byte) ([]Transaction, error) {
BankID: "", // not available on HTML path 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 return txns, nil
} }
@@ -191,7 +209,10 @@ func hasClass(t ghtml.Token, cls string) bool {
// Returns "" on parse error. // Returns "" on parse error.
func parseCzechDate(s string) string { func parseCzechDate(s string) string {
s = strings.TrimSpace(s) 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 { if t, err := time.Parse(layout, s); err == nil {
return t.Format("2006-01-02") return t.Format("2006-01-02")
} }

View File

@@ -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]) + "…"
}

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"fuj-management/go/internal/domain/synch" "fuj-management/go/internal/domain/synch"
"fuj-management/go/internal/io/fio" "fuj-management/go/internal/io/fio"
"os"
"strings" "strings"
"time" "time"
) )
@@ -27,10 +28,11 @@ type sheetsWriter interface {
// SyncOpts controls the date window and sort behaviour. // SyncOpts controls the date window and sort behaviour.
type SyncOpts struct { type SyncOpts struct {
Days int // look-back window when From/To are zero Days int // look-back window when From/To are zero
From, To time.Time // explicit window (overrides Days) From, To time.Time // explicit window (overrides Days)
Sort bool // sort the sheet by Date after appending Sort bool // sort the sheet by Date after appending
DryRun bool // print planned writes without modifying the sheet 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. // 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)) from.Format("2006-01-02"), to.Format("2006-01-02"), len(txns))
} }
// 4. Append new rows. // 4a. Compute Sync IDs for every fetched txn (shared by table-print and row-build).
var newRows [][]any syncIDs := make([]string, len(txns))
for _, tx := range txns { for i, tx := range txns {
currency := tx.Currency currency := tx.Currency
if currency == "" { if currency == "" {
currency = "CZK" currency = "CZK"
} }
id := synch.GenerateSyncID(synch.Transaction{ syncIDs[i] = synch.GenerateSyncID(synch.Transaction{
Date: tx.Date, Date: tx.Date,
Amount: tx.Amount, Amount: tx.Amount,
Currency: currency, Currency: currency,
@@ -108,13 +110,23 @@ func SyncToSheets(
Message: tx.Message, Message: tx.Message,
BankID: tx.BankID, 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 continue
} }
newRows = append(newRows, []any{ newRows = append(newRows, []any{
tx.Date, tx.Amount, tx.Date, tx.Amount,
"", "", "", "", // manual fix, Person, Purpose, Inferred 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],
}) })
} }