eleven / messenger Public

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>
Paul Campbell d43055443e6c00c7f69843b283f6c0147a970c18 parent 84a567b
9 files changed, +252 −0
  • billing.go +90 −0
  • changelog/2026-07-11-manage-subscription.md +4 −0
  • cloud.go +4 −0
  • cloud_billing_test.go +56 −0
  • cloud_db.go +12 −0
  • docs/cloud-provisioner.md +14 −0
  • home/main.go +2 −0
  • home/provision.go +38 −0
  • home/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