eleven / messenger Public

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>
Paul Campbell 8ca6fb079a98edd73ccb8ab17eecbbf4cb9f4f53 parent 37f0601
3 files changed, +268 −1
  • cloud.go +14 −1
  • donate.go +162 −0
  • donate_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)
+ }
+}