Deploy to K8s / deploy (push) Successful in 20s
Go (go/) becomes the primary implementation. The original Python version moves to legacy/ and is kept runnable; new features are still built in both (see the Dual implementation rule in CLAUDE.md). - git mv app.py, scripts/, templates/, tests/, pyproject.toml, uv.lock and build/ into legacy/ - Makefile: Python targets point at legacy/, venv in legacy/.venv, CREDENTIALS_PATH exported for the root .secret/ file - CI and make image build the Python image with legacy/ as context; the Makefile is no longer copied into the image - legacy/.dockerignore moved to the context root so it takes effect - CLAUDE.md, docs/README.md, Go comments, fixture README, capture script and VS Code launch config updated to the new layout Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
137 lines
3.6 KiB
Go
137 lines
3.6 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 legacy/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
|
|
}
|