feat(go): IO layer behind interfaces (M4)
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
- io/attendance: CSV-over-public-URL client + Fake for adult/junior tabs - io/drive: Drive v3 modifiedTime client + Fake - io/sheets: Sheets v4 client (GetValues/AppendValues/BatchUpdateValues/ WriteHeader/SortByDateColumn) + Fake with call-capture - io/cache: Drive-modifiedTime-gated FileCache; two TTL knobs; atomic writes; generic Get[T]; Python-compatible JSON format; Flush() - io/fio: Client interface backed by Fio REST API (apiClient) and HTML scraper (transparentClient); Fake; testdata fixtures - membership/sources: NewSources wires attendance CSV + Sheets + cache into LoadAdults/LoadJuniors/LoadTransactions/LoadExceptions; Czech month parsing + merged-month maps - banksync: SyncToSheets (SHA-256 dedup, optional sort) and InferPayments ([?] review prefix, dry-run) — tested with fakes - cmd/fuj: sync and infer subcommands wired; fees and reconcile use real NewSources; go.mod gains google.golang.org/api + x/net - gofumpt extra-rules applied across all packages; lint clean Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
128
go/internal/io/fio/api.go
Normal file
128
go/internal/io/fio/api.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package fio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"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))
|
||||
|
||||
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()
|
||||
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
|
||||
}
|
||||
return parseAPIResponse(body)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user