All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
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>
137 lines
3.5 KiB
Go
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
|
|
}
|