eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// ECB reference rates for the pricing picker's local-currency ≈ display
4// (docs/pricing-bands.md § Local-currency display). Billing stays EUR; the
5// local figure is a labelled estimate. This file is the whole third-party
6// surface: home fetches the ECB's daily XML (free, no key, ~30 currencies)
7// on a timer and serves a tiny cached JSON — the BROWSER never talks to a
8// third party (its CSP stays pinned to this origin).
9//
10// Availability beats freshness: a fetch failure keeps the last-good snapshot
11// (reference rates move ~daily; a stale ≈ is still honest, and the label
12// says "at today's rate" of the snapshot's own date). Before the first
13// successful fetch the endpoint serves an empty rate set and the client
14// simply shows no ≈ figure.
15
16import (
17 "encoding/xml"
18 "io"
19 "log"
20 "net/http"
21 "sync"
22 "time"
23)
24
25const ecbRatesURL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml"
26
27// fxCache is the last-good snapshot. Zero value ready (empty until fetched).
28type fxCache struct {
29 mu sync.Mutex
30 date string // the ECB's own quote date, e.g. "2026-07-17"
31 rates map[string]float64 // currency code → units per EUR
32}
33
34// ecbEnvelope is just enough of the ECB feed's XML to read the rate cubes.
35type ecbEnvelope struct {
36 Cube struct {
37 Day struct {
38 Time string `xml:"time,attr"`
39 Rates []struct {
40 Currency string `xml:"currency,attr"`
41 Rate float64 `xml:"rate,attr"`
42 } `xml:"Cube"`
43 } `xml:"Cube"`
44 } `xml:"Cube"`
45}
46
47// refresh fetches the feed once, replacing the snapshot only on success.
48func (c *fxCache) refresh(client *http.Client, feedURL string) error {
49 resp, err := client.Get(feedURL)
50 if err != nil {
51 return err
52 }
53 defer resp.Body.Close()
54 if resp.StatusCode != http.StatusOK {
55 return errHTTP(resp.StatusCode)
56 }
57 var env ecbEnvelope
58 if err := xml.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&env); err != nil {
59 return err
60 }
61 rates := map[string]float64{}
62 for _, r := range env.Cube.Day.Rates {
63 if r.Currency != "" && r.Rate > 0 {
64 rates[r.Currency] = r.Rate
65 }
66 }
67 if len(rates) == 0 {
68 return errEmptyFeed
69 }
70 c.mu.Lock()
71 c.date, c.rates = env.Cube.Day.Time, rates
72 c.mu.Unlock()
73 return nil
74}
75
76type fxErr string
77
78func (e fxErr) Error() string { return string(e) }
79
80const errEmptyFeed = fxErr("fx: feed parsed but held no rates")
81
82func errHTTP(code int) error { return fxErr("fx: feed answered HTTP " + http.StatusText(code)) }
83
84// fxLoop refreshes the snapshot at boot and then twice a day (the feed
85// updates once each business day; a missed pass self-heals on the next).
86func (s *Server) fxLoop(feedURL string) {
87 client := &http.Client{Timeout: 30 * time.Second}
88 for {
89 if err := s.fx.refresh(client, feedURL); err != nil {
90 log.Printf("fx: refresh: %v (serving the last-good snapshot)", err)
91 }
92 time.Sleep(12 * time.Hour)
93 }
94}
95
96// fxHandler: GET /api/fx — the cached snapshot, verbatim. Public: reference
97// rates are public data and the response identifies nobody.
98func (s *Server) fxHandler(w http.ResponseWriter, r *http.Request) {
99 s.fx.mu.Lock()
100 date, rates := s.fx.date, s.fx.rates
101 s.fx.mu.Unlock()
102 if rates == nil {
103 rates = map[string]float64{}
104 }
105 // A day's cache: clients re-check on load; the snapshot moves ~daily.
106 w.Header().Set("Cache-Control", "public, max-age=21600")
107 writeJSON(w, http.StatusOK, map[string]any{"base": "EUR", "date": date, "rates": rates})
108}
109