server: /donate checkout — pay-what-you-want, floor €1.10 (donate.go) (#1288)
The marketing site's /donate page mints one-time Checkout Sessions through cloud, mirroring the Enterprise page's deliberate shape: own key (LCHAT_STRIPE_DONATE_KEY — off until set, sandbox until the live flip), CORS pinned to the marketing origin, fixed redirects, global mint limit, amounts bounded server-side (€1.10 floor so the card fee never eats the gift; Stripe's 8-digit cap).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+268
−1
cloud.go+14 −1donate.go+162 −0donate_test.go+92 −0
diff --git a/cloud.go b/cloud.go| index 81ba8e6..e495ef3 100644 |
| --- a/cloud.go |
| +++ b/cloud.go |
| @@ -106,7 +106,10 @@ type cloudServer struct { |
| // enterprise is the Enterprise Edition page's public checkout |
| // (enterprise.go) — its own Stripe key, deliberately separate from |
| // billing's so it can run against the sandbox; nil = off. |
| - enterprise *enterpriseCheckout |
| + enterprise *enterpriseCheckout |
| + // donate is the /donate page's public checkout (donate.go) — same shape, |
| + // its own key, off until LCHAT_STRIPE_DONATE_KEY is set. |
| + donate *donateCheckout |
| storageCaps map[string]int64 // tier key (+ "free") → storage cap pushed to instances; empty = no enforcement |
| // seatCaps holds the per-seat entries of LCHAT_CLOUD_STORAGE_CAPS |
| // ("business=11GB/seat"): cap = value × the space's live head count, |
| @@ -168,6 +171,9 @@ func (s *cloudServer) handler() http.Handler { |
| // the caller, so there is no token to gate with; amounts are compiled in. |
| mux.HandleFunc("POST /api/enterprise/checkout", s.enterpriseCheckoutHandler) |
| mux.HandleFunc("OPTIONS /api/enterprise/checkout", s.enterprisePreflightHandler) |
| + // The /donate page's checkout (donate.go): same public posture. |
| + mux.HandleFunc("POST /api/donate/checkout", s.donateCheckoutHandler) |
| + mux.HandleFunc("OPTIONS /api/donate/checkout", s.donatePreflightHandler) |
| // Usage ingest (#857): meter-token-gated, not caller-gated — an instance |
| // can only ever report as itself. |
| mux.HandleFunc("POST /api/usage", s.usageHandler) |
| @@ -704,6 +710,12 @@ func runCloud(args []string) { |
| envOr("LCHAT_STRIPE_API_BASE", "https://api.stripe.com"), |
| envOr("LCHAT_ENTERPRISE_ORIGIN", "https://elevenmessenger.com"), |
| ) |
| + // The /donate page's checkout: off until its own key is set (donate.go). |
| + donate := newDonateCheckout( |
| + os.Getenv("LCHAT_STRIPE_DONATE_KEY"), |
| + envOr("LCHAT_STRIPE_API_BASE", "https://api.stripe.com"), |
| + envOr("LCHAT_DONATE_ORIGIN", "https://elevenmessenger.com"), |
| + ) |
| poolCosts, err := parsePoolCosts(os.Getenv("LCHAT_POOL_COSTS")) |
| if err != nil { |
| die("cloud: LCHAT_POOL_COSTS: %v", err) |
| @@ -723,6 +735,7 @@ func runCloud(args []string) { |
| reapMax: *reapMax, |
| billing: billing, |
| enterprise: enterprise, |
| + donate: donate, |
| storageCaps: storageCaps, |
| seatCaps: seatCaps, |
| replicationClasses: replicationClasses, |
diff --git a/donate.go b/donate.go| new file mode 100644 |
| index 0000000..c131600 |
| --- /dev/null |
| +++ b/donate.go |
| @@ -0,0 +1,162 @@ |
| +package main |
| + |
| +// 🤖 donate.go — checkout for the /donate page on the marketing site. Eleven |
| +// has no funding; the page says so and asks nothing — but if someone wants |
| +// to back the ideas (defiance of big tech, of over-complexity in software |
| +// distribution; proceeds into the 11 factor principles and affordable, |
| +// top-tier software), this mints the Stripe Checkout Session that takes |
| +// their money. Same deliberate shape as the Enterprise page's checkout |
| +// (enterprise.go): its own Stripe key (LCHAT_STRIPE_DONATE_KEY, sandbox |
| +// until the explicit live flip), CORS pinned to the marketing origin, fixed |
| +// server-side redirects, a global mint rate limit, and amounts constrained |
| +// server-side. Pay what you want, floor €1.10 — the elevens' smallest coin. |
| + |
| +import ( |
| + "encoding/json" |
| + "fmt" |
| + "io" |
| + "log" |
| + "net/http" |
| + "net/url" |
| + "strconv" |
| + "strings" |
| + "time" |
| +) |
| + |
| +const ( |
| + // Minor units (cents/pence — identical numerals in every currency, like |
| + // the Enterprise price list; we don't do exchange rates). |
| + donateMinMinor = 110 // €1.10 — below this Stripe's fixed fee eats the gift |
| + donateMaxMinor = 99_999_999 // Stripe's 8-digit single-payment cap |
| +) |
| + |
| +var donateCurrencies = map[string]bool{"eur": true, "usd": true, "gbp": true} |
| + |
| +// donateCheckout holds the page's Stripe config. nil on cloudServer = the |
| +// feature is off and the endpoint answers 501. |
| +type donateCheckout struct { |
| + key string |
| + apiBase string |
| + origin string |
| + client *http.Client |
| + limiter mintLimiter |
| +} |
| + |
| +func newDonateCheckout(key, apiBase, origin string) *donateCheckout { |
| + key = strings.TrimSpace(key) |
| + if key == "" { |
| + return nil |
| + } |
| + return &donateCheckout{ |
| + key: key, |
| + apiBase: apiBase, |
| + origin: strings.TrimRight(origin, "/"), |
| + client: &http.Client{Timeout: 20 * time.Second}, |
| + limiter: mintLimiter{max: 30, window: time.Minute}, |
| + } |
| +} |
| + |
| +// donateCORS stamps the CORS headers; single marketing origin, never *. |
| +func (s *cloudServer) donateCORS(w http.ResponseWriter) bool { |
| + if s.donate == nil { |
| + return false |
| + } |
| + h := w.Header() |
| + h.Set("Access-Control-Allow-Origin", s.donate.origin) |
| + h.Set("Vary", "Origin") |
| + return true |
| +} |
| + |
| +// donatePreflightHandler: OPTIONS /api/donate/checkout. |
| +func (s *cloudServer) donatePreflightHandler(w http.ResponseWriter, r *http.Request) { |
| + if !s.donateCORS(w) { |
| + w.WriteHeader(http.StatusNotImplemented) |
| + return |
| + } |
| + h := w.Header() |
| + h.Set("Access-Control-Allow-Methods", "POST") |
| + h.Set("Access-Control-Allow-Headers", "Content-Type") |
| + h.Set("Access-Control-Max-Age", "86400") |
| + w.WriteHeader(http.StatusNoContent) |
| +} |
| + |
| +// donateCheckoutHandler: POST /api/donate/checkout — mint a one-time |
| +// Checkout Session. Body: {"amount_minor": n, "currency": "eur"}. |
| +func (s *cloudServer) donateCheckoutHandler(w http.ResponseWriter, r *http.Request) { |
| + if !s.donateCORS(w) { |
| + writeControlErr(w, http.StatusNotImplemented, "unavailable", "donations are not configured") |
| + return |
| + } |
| + d := s.donate |
| + if !d.limiter.allow(time.Now()) { |
| + writeControlErr(w, http.StatusTooManyRequests, "slow_down", "the till is busy — try again in a minute") |
| + return |
| + } |
| + var req struct { |
| + AmountMinor int `json:"amount_minor"` |
| + Currency string `json:"currency"` |
| + } |
| + if err := json.NewDecoder(io.LimitReader(r.Body, 4<<10)).Decode(&req); err != nil { |
| + writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") |
| + return |
| + } |
| + cur := strings.ToLower(strings.TrimSpace(req.Currency)) |
| + if !donateCurrencies[cur] { |
| + writeControlErr(w, http.StatusBadRequest, "bad_request", "currency must be eur, usd or gbp") |
| + return |
| + } |
| + if req.AmountMinor < donateMinMinor { |
| + writeControlErr(w, http.StatusBadRequest, "bad_request", "the smallest donation is 1.10 — below that the card fee eats the gift") |
| + return |
| + } |
| + if req.AmountMinor > donateMaxMinor { |
| + writeControlErr(w, http.StatusBadRequest, "over_stripe_cap", "Stripe cannot carry this much money in one payment — split it, or talk to us") |
| + return |
| + } |
| + checkoutURL, err := d.createSession(cur, req.AmountMinor) |
| + if err != nil { |
| + log.Printf("cloud: donate checkout (%s %d): %v", cur, req.AmountMinor, err) |
| + writeControlErr(w, http.StatusBadGateway, "checkout_failed", "couldn't start the checkout — please try again") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"url": checkoutURL}) |
| +} |
| + |
| +// createSession builds the hosted page: one inline price_data line item, no |
| +// customer identity, fixed redirects back to /donate/. |
| +func (d *donateCheckout) createSession(currency string, amountMinor int) (string, error) { |
| + form := url.Values{} |
| + form.Set("mode", "payment") |
| + form.Set("line_items[0][price_data][currency]", currency) |
| + form.Set("line_items[0][price_data][product_data][name]", "Donation to Eleven") |
| + form.Set("line_items[0][price_data][unit_amount]", strconv.Itoa(amountMinor)) |
| + form.Set("line_items[0][quantity]", "1") |
| + form.Set("metadata[donation]", "1") |
| + form.Set("success_url", d.origin+"/donate/?thanks=1") |
| + form.Set("cancel_url", d.origin+"/donate/") |
| + |
| + req, err := http.NewRequest("POST", d.apiBase+"/v1/checkout/sessions", strings.NewReader(form.Encode())) |
| + if err != nil { |
| + return "", err |
| + } |
| + req.Header.Set("Authorization", "Bearer "+d.key) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + resp, err := d.client.Do(req) |
| + if err != nil { |
| + return "", err |
| + } |
| + defer resp.Body.Close() |
| + var body struct { |
| + URL string `json:"url"` |
| + Error struct { |
| + Message string `json:"message"` |
| + } `json:"error"` |
| + } |
| + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil { |
| + return "", fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode) |
| + } |
| + if resp.StatusCode != http.StatusOK || body.URL == "" { |
| + return "", fmt.Errorf("stripe: HTTP %d: %s", resp.StatusCode, body.Error.Message) |
| + } |
| + return body.URL, nil |
| +} |
diff --git a/donate_test.go b/donate_test.go| new file mode 100644 |
| index 0000000..e0a507f |
| --- /dev/null |
| +++ b/donate_test.go |
| @@ -0,0 +1,92 @@ |
| +package main |
| + |
| +// 🤖 donate_test.go — the /donate checkout (donate.go): mint path, amount |
| +// bounds, CORS scoping, and the off-until-keyed posture. |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func donateTestCloud(t *testing.T) (*httptest.Server, *cloudServer, *url.Values) { |
| + t.Helper() |
| + stripe, captured := fakeStripe(t) |
| + srv, s := newTestCloud(t, okControl(t)) |
| + s.donate = newDonateCheckout("sk_test_donate", stripe.URL, "https://elevenmessenger.com") |
| + return srv, s, captured |
| +} |
| + |
| +func postDonate(t *testing.T, srv *httptest.Server, body map[string]any) *http.Response { |
| + t.Helper() |
| + raw, _ := json.Marshal(body) |
| + resp, err := http.Post(srv.URL+"/api/donate/checkout", "application/json", bytes.NewReader(raw)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { resp.Body.Close() }) |
| + return resp |
| +} |
| + |
| +func TestDonateCheckoutMintsSession(t *testing.T) { |
| + srv, _, captured := donateTestCloud(t) |
| + |
| + resp := postDonate(t, srv, map[string]any{"amount_minor": 1100, "currency": "eur"}) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("status = %d, want 200", resp.StatusCode) |
| + } |
| + if u, _ := decodeBody(t, resp)["url"].(string); !strings.Contains(u, "checkout.stripe.com") { |
| + t.Errorf("url = %q", u) |
| + } |
| + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://elevenmessenger.com" { |
| + t.Errorf("CORS origin = %q", got) |
| + } |
| + f := *captured |
| + if f.Get("mode") != "payment" || f.Get("line_items[0][price_data][unit_amount]") != "1100" || |
| + f.Get("line_items[0][price_data][currency]") != "eur" { |
| + t.Errorf("form = %v", f) |
| + } |
| + if !strings.HasSuffix(f.Get("success_url"), "/donate/?thanks=1") || !strings.HasSuffix(f.Get("cancel_url"), "/donate/") { |
| + t.Errorf("redirects = %q / %q", f.Get("success_url"), f.Get("cancel_url")) |
| + } |
| + for k := range f { |
| + if strings.Contains(k, "customer") || strings.Contains(k, "email") { |
| + t.Errorf("donation form leaks a customer field: %s", k) |
| + } |
| + } |
| +} |
| + |
| +func TestDonateCheckoutValidation(t *testing.T) { |
| + srv, _, _ := donateTestCloud(t) |
| + for name, body := range map[string]map[string]any{ |
| + "below the €1.10 floor": {"amount_minor": 109, "currency": "eur"}, |
| + "zero": {"amount_minor": 0, "currency": "eur"}, |
| + "over the Stripe cap": {"amount_minor": 100_000_000, "currency": "eur"}, |
| + "bad currency": {"amount_minor": 1100, "currency": "chf"}, |
| + } { |
| + if resp := postDonate(t, srv, body); resp.StatusCode != http.StatusBadRequest { |
| + t.Errorf("%s: status = %d, want 400", name, resp.StatusCode) |
| + } |
| + } |
| +} |
| + |
| +func TestDonateCheckoutUnconfigured(t *testing.T) { |
| + srv, s, _ := donateTestCloud(t) |
| + s.donate = nil |
| + if resp := postDonate(t, srv, map[string]any{"amount_minor": 1100, "currency": "eur"}); resp.StatusCode != http.StatusNotImplemented { |
| + t.Fatalf("status = %d, want 501 when unkeyed", resp.StatusCode) |
| + } |
| + req, _ := http.NewRequest("OPTIONS", srv.URL+"/api/donate/checkout", nil) |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusNotImplemented { |
| + t.Errorf("preflight unkeyed: %d, want 501", resp.StatusCode) |
| + } |
| +} |