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 }