server+home: Stripe Billing Portal — self-serve cancel/card/invoices per space (#688)
Manage on a paid space's row → home proxy → cloud mints a Billing Portal session for that space's own Stripe customer (ownership-gated like checkout; 409 not_subscribed on free spaces). Per-space by construction: every space is its own Stripe customer, so no cross-space portal exists — deliberately. LCHAT_STRIPE_PORTAL_CONFIG optionally pins the portal configuration.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
9 files changed,
+252
−0
billing.go+90 −0changelog/2026-07-11-manage-subscription.md+4 −0cloud.go+4 −0cloud_billing_test.go+56 −0cloud_db.go+12 −0docs/cloud-provisioner.md+14 −0home/main.go+2 −0home/provision.go+38 −0home/web/static/home.js+32 −0
diff --git a/billing.go b/billing.go| index e829015..0d24c07 100644 |
| --- a/billing.go |
| +++ b/billing.go |
| @@ -57,6 +57,7 @@ type stripeBilling struct { |
| key string // secret (restricted) API key |
| webhookSecret string // signing secret for the webhook endpoint |
| tiers []billingTier // the price book, in display order |
| + portalConfig string // optional Billing Portal configuration id |
| client *http.Client |
| } |
| @@ -172,6 +173,95 @@ func (s *cloudServer) checkoutHandler(w http.ResponseWriter, r *http.Request) { |
| writeJSON(w, http.StatusOK, map[string]any{"url": checkoutURL}) |
| } |
| +// portalHandler: POST /api/instances/{host}/portal — mint a Stripe Billing |
| +// Portal session for one UPGRADED space, so its payer can cancel, change |
| +// card, or read invoices on Stripe's hosted page. Same gates as checkout: |
| +// caller token + ledger ownership. Portals are per-space by construction — |
| +// each space is its own Stripe customer (checkout sends no customer identity, |
| +// so there is nothing to merge across spaces, deliberately). |
| +func (s *cloudServer) portalHandler(w http.ResponseWriter, r *http.Request) { |
| + callerID := r.Context().Value(callerKey{}).(string) |
| + if s.billing == nil { |
| + writeControlErr(w, http.StatusNotImplemented, "billing_unavailable", "this provisioner does not offer upgrades") |
| + return |
| + } |
| + host := strings.ToLower(strings.TrimSpace(r.PathValue("host"))) |
| + var req struct { |
| + Requester string `json:"requester"` |
| + ReturnURL string `json:"return_url"` |
| + } |
| + if err := json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req); err != nil { |
| + writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") |
| + return |
| + } |
| + req.Requester = strings.TrimSpace(req.Requester) |
| + if req.Requester == "" || !validRedirectURL(req.ReturnURL) { |
| + writeControlErr(w, http.StatusBadRequest, "bad_request", "requester and return_url required") |
| + return |
| + } |
| + id, owned, err := s.db.spaceOwned(callerID, req.Requester, host) |
| + if err != nil { |
| + writeControlErr(w, http.StatusInternalServerError, "portal_failed", "db error") |
| + return |
| + } |
| + if !owned { |
| + writeControlErr(w, http.StatusNotFound, "not_found", "no such space for this account") |
| + return |
| + } |
| + customer, err := s.db.spaceCustomer(id) |
| + if err != nil { |
| + writeControlErr(w, http.StatusInternalServerError, "portal_failed", "db error") |
| + return |
| + } |
| + if customer == "" { |
| + writeControlErr(w, http.StatusConflict, "not_subscribed", "this space has no subscription to manage") |
| + return |
| + } |
| + portalURL, err := s.billing.createPortalSession(customer, req.ReturnURL) |
| + if err != nil { |
| + log.Printf("cloud: portal for %s (id %d): %v", host, id, err) |
| + writeControlErr(w, http.StatusBadGateway, "portal_failed", "couldn't open the billing page — please try again") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"url": portalURL}) |
| +} |
| + |
| +// createPortalSession asks Stripe for a hosted Billing Portal page. The |
| +// optional portal configuration id (LCHAT_STRIPE_PORTAL_CONFIG) pins which |
| +// dashboard-defined feature set the portal shows; unset uses Stripe's default. |
| +func (b *stripeBilling) createPortalSession(customer, returnURL string) (string, error) { |
| + form := url.Values{} |
| + form.Set("customer", customer) |
| + form.Set("return_url", returnURL) |
| + if b.portalConfig != "" { |
| + form.Set("configuration", b.portalConfig) |
| + } |
| + req, err := http.NewRequest("POST", b.apiBase+"/v1/billing_portal/sessions", strings.NewReader(form.Encode())) |
| + if err != nil { |
| + return "", err |
| + } |
| + req.Header.Set("Authorization", "Bearer "+b.key) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + resp, err := b.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 |
| +} |
| + |
| // validRedirectURL admits the http(s) URLs a checkout may bounce back to. The |
| // caller is a trusted home server, so this is belt-and-suspenders, not policy. |
| func validRedirectURL(raw string) bool { |
diff --git a/changelog/2026-07-11-manage-subscription.md b/changelog/2026-07-11-manage-subscription.md| new file mode 100644 |
| index 0000000..2254586 |
| --- /dev/null |
| +++ b/changelog/2026-07-11-manage-subscription.md |
| @@ -0,0 +1,4 @@ |
| +🤖 Upgraded spaces now have a Manage button on your Eleven home page — cancel, |
| +change your card, or read your invoices, all on Stripe's secure page. Each |
| +space's subscription is managed on its own: we keep no billing identity that |
| +links your spaces together, so there's nothing shared to leak. |
diff --git a/cloud.go b/cloud.go| index 828c356..d71dc36 100644 |
| --- a/cloud.go |
| +++ b/cloud.go |
| @@ -95,6 +95,7 @@ func (s *cloudServer) handler() http.Handler { |
| // Billing (optional — 501 unless Stripe is configured; see billing.go). The |
| // webhook is signature-gated rather than token-gated: Stripe calls it. |
| mux.HandleFunc("POST /api/instances/{host}/checkout", s.callerAuth(s.checkoutHandler)) |
| + mux.HandleFunc("POST /api/instances/{host}/portal", s.callerAuth(s.portalHandler)) |
| mux.HandleFunc("POST /api/stripe/webhook", s.stripeWebhookHandler) |
| // Admin surface: mint / list / revoke caller tokens at runtime (mirrors the |
| // push relay's admin API). |
| @@ -425,6 +426,9 @@ func runCloud(args []string) { |
| if err != nil { |
| die("cloud: %v", err) |
| } |
| + if billing != nil { |
| + billing.portalConfig = os.Getenv("LCHAT_STRIPE_PORTAL_CONFIG") |
| + } |
| s := &cloudServer{ |
| db: db, |
diff --git a/cloud_billing_test.go b/cloud_billing_test.go| index f1ab232..5e829c5 100644 |
| --- a/cloud_billing_test.go |
| +++ b/cloud_billing_test.go |
| @@ -472,3 +472,59 @@ func TestCreateBudgetCircuitBreaker(t *testing.T) { |
| t.Errorf("post-delete create: %d, want 503 (budget must not reset)", r.StatusCode) |
| } |
| } |
| + |
| +// TestPortalSession pins the manage path: only an upgraded space's owner gets |
| +// a portal link, minted for that space's own Stripe customer. |
| +func TestPortalSession(t *testing.T) { |
| + stripe := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.URL.Path != "/v1/billing_portal/sessions" { |
| + w.WriteHeader(http.StatusNotFound) |
| + return |
| + } |
| + r.ParseForm() |
| + if r.PostForm.Get("customer") != "cus_9" || r.PostForm.Get("return_url") == "" { |
| + t.Errorf("portal form = %v", r.PostForm) |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"url": "https://billing.stripe.com/p/session_x"}) |
| + })) |
| + t.Cleanup(stripe.Close) |
| + |
| + srv, s, _ := billingTestCloud(t) |
| + s.billing.apiBase = stripe.URL |
| + tok := mintCaller(t, srv, "home") |
| + host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) |
| + |
| + post := func(requester string) *http.Response { |
| + body, _ := json.Marshal(map[string]string{"requester": requester, "return_url": "https://home.example.com/"}) |
| + req, _ := http.NewRequest("POST", srv.URL+"/api/instances/"+host+"/portal", bytes.NewReader(body)) |
| + req.Header.Set("Authorization", "Bearer "+tok) |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { resp.Body.Close() }) |
| + return resp |
| + } |
| + |
| + // Not upgraded yet → 409 not_subscribed; wrong owner → 404. |
| + if r := post("user-1"); r.StatusCode != http.StatusConflict { |
| + t.Fatalf("unsubscribed portal: %d, want 409", r.StatusCode) |
| + } |
| + if r := post("someone-else"); r.StatusCode != http.StatusNotFound { |
| + t.Fatalf("wrong owner: %d, want 404", r.StatusCode) |
| + } |
| + |
| + // Upgrade it, then the portal link mints against its customer. |
| + label, _, _ := strings.Cut(host, ".") |
| + id, _ := spaceID(label) |
| + if err := s.db.markSpacePaid(id, "cus_9", "sub_9", "family"); err != nil { |
| + t.Fatal(err) |
| + } |
| + resp := post("user-1") |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("portal: %d, want 200", resp.StatusCode) |
| + } |
| + if u, _ := decodeBody(t, resp)["url"].(string); !strings.Contains(u, "billing.stripe.com") { |
| + t.Errorf("url = %q", u) |
| + } |
| +} |
diff --git a/cloud_db.go b/cloud_db.go| index 59c7aa0..994683d 100644 |
| --- a/cloud_db.go |
| +++ b/cloud_db.go |
| @@ -493,6 +493,18 @@ func (db *cloudDB) spaceBillingByID(id int64) (m meterRow, err error) { |
| return m, err |
| } |
| +// spaceCustomer reads a space's opaque Stripe customer id ("" = never paid). |
| +func (db *cloudDB) spaceCustomer(id int64) (string, error) { |
| + var c *string |
| + if err := db.sql.QueryRow(`SELECT stripe_customer_id FROM spaces WHERE id = ?`, id).Scan(&c); err != nil { |
| + return "", err |
| + } |
| + if c == nil { |
| + return "", nil |
| + } |
| + return *c, nil |
| +} |
| + |
| // setQuotaBytes records what the cloud last pushed into an instance's env. |
| func (db *cloudDB) setQuotaBytes(id, quota int64) error { |
| _, err := db.sql.Exec(`UPDATE spaces SET quota_bytes = ? WHERE id = ?`, quota, id) |
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md| index 903ed71..87cc94e 100644 |
| --- a/docs/cloud-provisioner.md |
| +++ b/docs/cloud-provisioner.md |
| @@ -167,6 +167,9 @@ POST /api/instances/{host}/checkout bearer: caller token |
| {requester, tier, success_url, cancel_url} |
| 200 {url} the hosted Stripe Checkout page |
| 400 unknown tier | 404 not yours | 409 already_upgraded | 501 billing off |
| +POST /api/instances/{host}/portal bearer: caller token {requester, return_url} |
| + 200 {url} the hosted Billing Portal (cancel/card/invoices) |
| + 404 not yours | 409 not_subscribed | 501 billing off |
| POST /api/stripe/webhook signature-gated (Stripe calls it; see Billing) |
| POST /api/admin/tokens bearer: admin token {label} → {id, token} (shown once) |
| GET /api/admin/tokens list (never the token values) |
| @@ -194,6 +197,8 @@ DELETE /api/spaces/{host} X-Ceremony-Proof: fresh passkey ceremony ONLY |
| → relays cloud's 200 / 404 |
| POST /api/spaces/{host}/checkout bearer: device link token {tier} |
| → relays cloud's 200 {url} / 400 / 404 / 409 / 501; return URLs point back at home |
| +POST /api/spaces/{host}/portal bearer: device link token |
| + → relays cloud's 200 {url} / 404 / 409 / 501 (Manage on a paid space's row) |
| ``` |
| Creation and listing are link-token-authed (additive/reads) and creation is |
| @@ -389,6 +394,15 @@ UI shows nothing. The Stripe-side webhook endpoint must subscribe to |
| `checkout.session.completed` and `customer.subscription.*`, pointed at |
| `https://<cloud host>/api/stripe/webhook`. |
| +**Managing a subscription** is Stripe's hosted **Billing Portal**: Manage on a |
| +paid space's row → home → cloud mints a portal session for that space's own |
| +Stripe customer (same ownership gate as checkout). Portals are per-space by |
| +construction — every space is a separate Stripe customer, deliberately (no |
| +cross-space identity exists to merge). `LCHAT_STRIPE_PORTAL_CONFIG` optionally |
| +pins a portal configuration id; unset uses the account's default (which must |
| +exist — save the portal settings once in the Stripe dashboard, or create a |
| +configuration via the API). |
| + |
| ## Enabling it on a box |
| ```sh |
diff --git a/home/main.go b/home/main.go| index 57421b2..386b6c3 100644 |
| --- a/home/main.go |
| +++ b/home/main.go |
| @@ -248,6 +248,8 @@ func (s *Server) handler() http.Handler { |
| // Upgrading a space (a Stripe Checkout round trip) is additive, so it takes |
| // the link token; rate-limited because each call mints a checkout session. |
| mux.HandleFunc("POST /api/spaces/{host}/checkout", s.rateLimited(s.checkoutSpaceHandler)) |
| + // Managing an existing subscription (Stripe's hosted Billing Portal). |
| + mux.HandleFunc("POST /api/spaces/{host}/portal", s.rateLimited(s.portalSpaceHandler)) |
| // Devices: list with a link token; revoke only with a fresh ceremony. The |
| // asymmetry is deliberate — a stolen link token must never reduce access. |
diff --git a/home/provision.go b/home/provision.go| index 1af7643..f86f288 100644 |
| --- a/home/provision.go |
| +++ b/home/provision.go |
| @@ -257,6 +257,44 @@ func (s *Server) checkoutSpaceHandler(w http.ResponseWriter, r *http.Request) { |
| } |
| } |
| +// portalSpaceHandler: POST /api/spaces/{host}/portal — a Billing Portal link |
| +// for a space this user created and pays for. Link-token auth (reading and |
| +// managing your own subscription is not destructive of the SPACE; the portal |
| +// itself is Stripe-hosted and Stripe-authenticated beyond this handoff). |
| +func (s *Server) portalSpaceHandler(w http.ResponseWriter, r *http.Request) { |
| + uid, ok := s.linkUser(r) |
| + if !ok { |
| + http.Error(w, "unauthorized", http.StatusUnauthorized) |
| + return |
| + } |
| + if s.cloud == nil { |
| + writeJSON(w, http.StatusNotImplemented, map[string]any{ |
| + "error": "unavailable", "message": "this home server does not offer upgrades", |
| + }) |
| + return |
| + } |
| + host := r.PathValue("host") |
| + payload, _ := json.Marshal(map[string]string{ |
| + "requester": uid, |
| + "return_url": s.origin + "/", |
| + }) |
| + status, body, err := s.cloud.do("POST", "/api/instances/"+url.PathEscape(host)+"/portal", payload) |
| + if err != nil { |
| + writeJSON(w, http.StatusBadGateway, map[string]any{ |
| + "error": "portal_failed", "message": "could not reach the space provisioner", |
| + }) |
| + return |
| + } |
| + switch status { |
| + case http.StatusOK, http.StatusNotFound, http.StatusConflict, http.StatusNotImplemented: |
| + writeJSON(w, status, body) |
| + default: |
| + writeJSON(w, http.StatusBadGateway, map[string]any{ |
| + "error": "portal_failed", "message": "couldn't open the billing page", |
| + }) |
| + } |
| +} |
| + |
| // createSpace forwards one provisioning request; the user id is the quota key |
| // (opaque to the provisioner — random, no email or name anywhere). |
| func (c *cloudProvisioner) createSpace(requester string) (int, map[string]any, error) { |
diff --git a/home/web/static/home.js b/home/web/static/home.js| index d2e7db5..8e07402 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -492,6 +492,17 @@ function serverRow(s, sortable) { |
| up.setAttribute("aria-label", "Upgrade " + labelOf(s)); |
| up.addEventListener("click", () => openUpgrade(s)); |
| li.append(up); |
| + } else if (ledger && ledger.entitlement === "paid") { |
| + // Manage the subscription behind this space — Stripe's hosted Billing |
| + // Portal (cancel, change card, invoices). Per-space by design: each |
| + // space is its own Stripe customer, so there's no cross-space portal. |
| + const manage = document.createElement("button"); |
| + manage.type = "button"; |
| + manage.className = "row-upgrade"; |
| + manage.textContent = "Manage"; |
| + manage.setAttribute("aria-label", "Manage the subscription for " + labelOf(s)); |
| + manage.addEventListener("click", () => openPortal(hostOf(s.url), manage)); |
| + li.append(manage); |
| } |
| // Archive: a quiet box glyph that reveals on hover (like the grip). One tap |
| @@ -716,6 +727,27 @@ async function startUpgrade(host, tier, box) { |
| } |
| } |
| +// openPortal hands off to Stripe's Billing Portal for one space — cancel, |
| +// change card, invoices. Same-tab round trip; the portal's Return link comes |
| +// back here. |
| +async function openPortal(host, btn) { |
| + btn.disabled = true; |
| + vaultStatus("Opening your billing page…", "busy"); |
| + try { |
| + const res = await fetch("/api/spaces/" + encodeURIComponent(host) + "/portal", { |
| + method: "POST", |
| + headers: { Authorization: "Bearer " + state.token }, |
| + }); |
| + const body = await res.json().catch(() => ({})); |
| + if (!res.ok) throw new Error(body.message || body.error || `Couldn't open the billing page (HTTP ${res.status}).`); |
| + if (!body.url) throw new Error("The billing link didn't come back — try again."); |
| + location.href = body.url; |
| + } catch (e) { |
| + vaultStatus(e.message || "Couldn't open the billing page.", "error"); |
| + btn.disabled = false; |
| + } |
| +} |
| + |
| // ---- create a new space ------------------------------------------------------- |
| // requestSpace is the real create call, fired by the fader the moment it snaps |