Files
Jan Novak f87adeff9f
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
feat(go/fio): debug logging via slog at LOG_LEVEL=DEBUG
Wires slog.SetDefault to honour LOG_LEVEL in all CLI commands and adds
debug logs on the Fio fetch path so a silent "fetched 0 transaction(s)"
can be diagnosed without code changes:

- fio.New: which client variant (api/transparent) was selected
- apiClient: GET URL (token redacted as ****), HTTP status, body bytes,
  parsed transaction count
- transparentClient: GET URL, HTTP status, body bytes, plus parser
  stats (raw rows from second table, kept, dropped_bad_date,
  dropped_nonpositive_amount)

Also suppresses the --print-fio-table block when zero transactions were
fetched, so the bare header no longer prints under that condition.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 13:59:22 +02:00

137 lines
3.5 KiB
Go

package fio
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
)
// httpDoer is the subset of *http.Client used by both Fio impls.
type httpDoer interface {
Do(*http.Request) (*http.Response, error)
}
// apiClient fetches transactions from the Fio REST API (JSON).
// Ports scripts/fio_utils.py fetch_transactions_api.
type apiClient struct {
token string
hc httpDoer
}
func (c *apiClient) FetchTransactions(ctx context.Context, from, to time.Time) ([]Transaction, error) {
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 {
return nil, err
}
resp, err := c.hc.Do(req)
if err != nil {
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)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
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.
type fioAPIResponse struct {
AccountStatement struct {
TransactionList struct {
Transaction []map[string]json.RawMessage `json:"transaction"`
} `json:"transactionList"`
} `json:"accountStatement"`
}
func parseAPIResponse(body []byte) ([]Transaction, error) {
var resp fioAPIResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("fio api: parse JSON: %w", err)
}
var txns []Transaction
for _, raw := range resp.AccountStatement.TransactionList.Transaction {
amount := colFloat(raw, "column1")
if amount <= 0 {
continue // skip outgoing
}
dateRaw := colString(raw, "column0")
dateStr := ""
if len(dateRaw) >= 10 {
dateStr = dateRaw[:10]
}
txns = append(txns, Transaction{
Date: dateStr,
Amount: amount,
Sender: colString(raw, "column10"),
Message: colString(raw, "column16"),
VS: colString(raw, "column5"),
KS: colString(raw, "column4"),
SS: colString(raw, "column6"),
UserID: colString(raw, "column7"),
SenderAccount: colString(raw, "column2"),
BankID: colString(raw, "column22"),
Currency: colStringOr(raw, "column14", "CZK"),
})
}
return txns, nil
}
// colString extracts {"value":…} as a string from a column map.
func colString(m map[string]json.RawMessage, col string) string {
raw, ok := m[col]
if !ok {
return ""
}
var cell struct {
Value *string `json:"value"`
}
if json.Unmarshal(raw, &cell) != nil || cell.Value == nil {
return ""
}
return *cell.Value
}
// colStringOr is colString with a fallback value.
func colStringOr(m map[string]json.RawMessage, col, fallback string) string {
if v := colString(m, col); v != "" {
return v
}
return fallback
}
// colFloat extracts {"value":…} as a float64 from a column map.
// Returns 0 on any error (null column, non-numeric value).
func colFloat(m map[string]json.RawMessage, col string) float64 {
raw, ok := m[col]
if !ok {
return 0
}
var cell struct {
Value *float64 `json:"value"`
}
if json.Unmarshal(raw, &cell) != nil || cell.Value == nil {
return 0
}
return *cell.Value
}