package main // ECB reference rates for the pricing picker's local-currency ≈ display // (docs/pricing-bands.md § Local-currency display). Billing stays EUR; the // local figure is a labelled estimate. This file is the whole third-party // surface: home fetches the ECB's daily XML (free, no key, ~30 currencies) // on a timer and serves a tiny cached JSON — the BROWSER never talks to a // third party (its CSP stays pinned to this origin). // // Availability beats freshness: a fetch failure keeps the last-good snapshot // (reference rates move ~daily; a stale ≈ is still honest, and the label // says "at today's rate" of the snapshot's own date). Before the first // successful fetch the endpoint serves an empty rate set and the client // simply shows no ≈ figure. import ( "encoding/xml" "io" "log" "net/http" "sync" "time" ) const ecbRatesURL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml" // fxCache is the last-good snapshot. Zero value ready (empty until fetched). type fxCache struct { mu sync.Mutex date string // the ECB's own quote date, e.g. "2026-07-17" rates map[string]float64 // currency code → units per EUR } // ecbEnvelope is just enough of the ECB feed's XML to read the rate cubes. type ecbEnvelope struct { Cube struct { Day struct { Time string `xml:"time,attr"` Rates []struct { Currency string `xml:"currency,attr"` Rate float64 `xml:"rate,attr"` } `xml:"Cube"` } `xml:"Cube"` } `xml:"Cube"` } // refresh fetches the feed once, replacing the snapshot only on success. func (c *fxCache) refresh(client *http.Client, feedURL string) error { resp, err := client.Get(feedURL) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return errHTTP(resp.StatusCode) } var env ecbEnvelope if err := xml.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&env); err != nil { return err } rates := map[string]float64{} for _, r := range env.Cube.Day.Rates { if r.Currency != "" && r.Rate > 0 { rates[r.Currency] = r.Rate } } if len(rates) == 0 { return errEmptyFeed } c.mu.Lock() c.date, c.rates = env.Cube.Day.Time, rates c.mu.Unlock() return nil } type fxErr string func (e fxErr) Error() string { return string(e) } const errEmptyFeed = fxErr("fx: feed parsed but held no rates") func errHTTP(code int) error { return fxErr("fx: feed answered HTTP " + http.StatusText(code)) } // fxLoop refreshes the snapshot at boot and then twice a day (the feed // updates once each business day; a missed pass self-heals on the next). func (s *Server) fxLoop(feedURL string) { client := &http.Client{Timeout: 30 * time.Second} for { if err := s.fx.refresh(client, feedURL); err != nil { log.Printf("fx: refresh: %v (serving the last-good snapshot)", err) } time.Sleep(12 * time.Hour) } } // fxHandler: GET /api/fx — the cached snapshot, verbatim. Public: reference // rates are public data and the response identifies nobody. func (s *Server) fxHandler(w http.ResponseWriter, r *http.Request) { s.fx.mu.Lock() date, rates := s.fx.date, s.fx.rates s.fx.mu.Unlock() if rates == nil { rates = map[string]float64{} } // A day's cache: clients re-check on load; the snapshot moves ~daily. w.Header().Set("Cache-Control", "public, max-age=21600") writeJSON(w, http.StatusOK, map[string]any{"base": "EUR", "date": date, "rates": rates}) }