eleven / messenger Public

server+home: billing tiers + paid spaces don't count toward the quota (#649)

The price book becomes config: LCHAT_STRIPE_PRICES is a JSON array of
tiers (key/name/label/price) replacing the single LCHAT_STRIPE_PRICE_ID;
checkout takes a tier key, validated against the book, and the bought
tier is recorded on the ledger row (session + subscription metadata
route it). Display strings live in config verbatim so the server never
queries Stripe at boot — an outage can't block a cloud restart.

allocateSpace now counts only FREE spaces against the per-account quota:
upgrading a space frees its slot, and every extra space carries itself.
Home gains a plan-picker screen (name left, price right, Stripe handoff)
in place of the instant checkout, and the badge shows the tier name.

Changelog entry included — this ships user-visible with the prod deploy.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 264873023ff1f123fb1b7d6407615e35d1951e3e parent 6058ac1
11 files changed, +401 −127
  • .gitignore +1 −0
  • billing.go +91 −28
  • changelog/2026-07-11-space-upgrades.md +9 −0
  • cloud.go +9 −5
  • cloud_billing_test.go +81 −19
  • cloud_db.go +50 −27
  • docs/cloud-provisioner.md +37 −25
  • home/provision.go +20 −8
  • home/provision_test.go +16 −1
  • home/web/pages/index.html +26 −1
  • home/web/static/home.js +61 −13
diff --git a/.gitignore b/.gitignore
index c664576..85d16ec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,3 +31,4 @@ CLAUDE.local.md
ios/LChatCore/.build/
# Per-user Xcode state for the SwiftPM package (scheme mgmt, breakpoints, etc.)
ios/LChatCore/.swiftpm/
+home/home
diff --git a/billing.go b/billing.go
index 1207e68..cc53133 100644
--- a/billing.go
+++ b/billing.go
@@ -2,15 +2,16 @@ package main
// Billing rails for cloud-provisioned spaces (Stripe Checkout + webhooks).
//
-// This is deliberately just the rails — no price book, no tiers, no metering
-// (that's issues #561/#562). One subscription price upgrades one space; the
-// entitlement lands as a flag on the space's ledger row, where the reaper (and
-// later, per-space limits) read it.
+// A small price book of tiers (Family / Friends / Business — config, not code),
+// each a Stripe subscription price that upgrades one space. The entitlement +
+// tier land on the space's ledger row: paid spaces don't count against the
+// creation quota (allocateSpace), are never reaped, and per-tier powers arrive
+// with #561's limits. No metering yet (that's #562).
//
// Privacy invariants (see docs/cloud-provisioner.md § Billing):
-// - We store two opaque Stripe ids, a flag, and a period end. Never an email,
-// name, address, or card detail — Stripe holds those; payment deanonymizes
-// only on Stripe's side (#562's honest residual).
+// - We store two opaque Stripe ids, a flag, a tier key, and a period end.
+// Never an email, name, address, or card detail — Stripe holds those;
+// payment deanonymizes only on Stripe's side (#562's honest residual).
// - Checkout Sessions are created WITHOUT customer details; the only thing we
// tell Stripe about a space is its numeric ledger id (client_reference_id +
// subscription metadata), which is what routes webhook events back to it.
@@ -36,30 +37,81 @@ import (
"time"
)
+// billingTier is one purchasable upgrade. key routes (stored in the ledger,
+// carried in Stripe metadata); name + label are what members see on the picker
+// ("Friends — $4/month") and the badge (name); price is the Stripe price id.
+// The display strings live in config, verbatim — the server never asks Stripe
+// what a price costs, so a Stripe outage can't take provisioning down with it;
+// the checkout charges the real price regardless of what the label claims.
+type billingTier struct {
+ Key string `json:"key"`
+ Name string `json:"name"`
+ Label string `json:"label"`
+ Price string `json:"price"`
+}
+
// stripeBilling holds the cloud service's Stripe configuration. nil on a
// deployment that doesn't offer upgrades — billing stays strictly additive.
type stripeBilling struct {
- apiBase string // https://api.stripe.com (tests point it at a stub)
- key string // secret (restricted) API key
- webhookSecret string // signing secret for the webhook endpoint
- priceID string // the one subscription price (slice 1: no price book)
+ apiBase string // https://api.stripe.com (tests point it at a stub)
+ key string // secret (restricted) API key
+ webhookSecret string // signing secret for the webhook endpoint
+ tiers []billingTier // the price book, in display order
client *http.Client
}
+// tier looks a purchasable tier up by key.
+func (b *stripeBilling) tier(key string) (billingTier, bool) {
+ for _, t := range b.tiers {
+ if t.Key == key {
+ return t, true
+ }
+ }
+ return billingTier{}, false
+}
+
+// publicTiers is what listings expose: everything a picker needs. (Price ids
+// aren't secret, but clients have no use for them — keys route instead.)
+func (b *stripeBilling) publicTiers() []map[string]string {
+ out := make([]map[string]string, 0, len(b.tiers))
+ for _, t := range b.tiers {
+ out = append(out, map[string]string{"key": t.Key, "name": t.Name, "label": t.Label})
+ }
+ return out
+}
+
// newStripeBilling wires billing from config. All-empty → nil (billing off);
-// partial config is a deployment mistake and errors rather than half-working.
-func newStripeBilling(key, webhookSecret, priceID, apiBase string) (*stripeBilling, error) {
- if key == "" && webhookSecret == "" && priceID == "" {
+// partial or malformed config is a deployment mistake and errors rather than
+// half-working. pricesJSON is a JSON array of billingTier.
+func newStripeBilling(key, webhookSecret, pricesJSON, apiBase string) (*stripeBilling, error) {
+ if key == "" && webhookSecret == "" && pricesJSON == "" {
return nil, nil
}
- if key == "" || webhookSecret == "" || priceID == "" {
- return nil, fmt.Errorf("partial Stripe config: LCHAT_STRIPE_SECRET_KEY, LCHAT_STRIPE_WEBHOOK_SECRET and LCHAT_STRIPE_PRICE_ID must all be set (or none)")
+ if key == "" || webhookSecret == "" || pricesJSON == "" {
+ return nil, fmt.Errorf("partial Stripe config: LCHAT_STRIPE_SECRET_KEY, LCHAT_STRIPE_WEBHOOK_SECRET and LCHAT_STRIPE_PRICES must all be set (or none)")
+ }
+ var tiers []billingTier
+ if err := json.Unmarshal([]byte(pricesJSON), &tiers); err != nil {
+ return nil, fmt.Errorf("LCHAT_STRIPE_PRICES: %w (want a JSON array of {key,name,label,price})", err)
+ }
+ if len(tiers) == 0 {
+ return nil, fmt.Errorf("LCHAT_STRIPE_PRICES: no tiers configured")
+ }
+ seen := map[string]bool{}
+ for _, t := range tiers {
+ if t.Key == "" || t.Name == "" || t.Label == "" || t.Price == "" {
+ return nil, fmt.Errorf("LCHAT_STRIPE_PRICES: tier %+v is missing a field (need key, name, label, price)", t)
+ }
+ if seen[t.Key] {
+ return nil, fmt.Errorf("LCHAT_STRIPE_PRICES: duplicate tier key %q", t.Key)
+ }
+ seen[t.Key] = true
}
return &stripeBilling{
apiBase: strings.TrimRight(apiBase, "/"),
key: key,
webhookSecret: webhookSecret,
- priceID: priceID,
+ tiers: tiers,
client: &http.Client{Timeout: 30 * time.Second},
}, nil
}
@@ -77,6 +129,7 @@ func (s *cloudServer) checkoutHandler(w http.ResponseWriter, r *http.Request) {
host := strings.ToLower(strings.TrimSpace(r.PathValue("host")))
var req struct {
Requester string `json:"requester"`
+ Tier string `json:"tier"`
SuccessURL string `json:"success_url"`
CancelURL string `json:"cancel_url"`
}
@@ -89,6 +142,11 @@ func (s *cloudServer) checkoutHandler(w http.ResponseWriter, r *http.Request) {
writeControlErr(w, http.StatusBadRequest, "bad_request", "requester, success_url and cancel_url required")
return
}
+ tier, ok := s.billing.tier(req.Tier)
+ if !ok {
+ writeControlErr(w, http.StatusBadRequest, "bad_request", "unknown tier")
+ return
+ }
id, owned, err := s.db.spaceOwned(callerID, req.Requester, host)
if err != nil {
writeControlErr(w, http.StatusInternalServerError, "checkout_failed", "db error")
@@ -105,9 +163,9 @@ func (s *cloudServer) checkoutHandler(w http.ResponseWriter, r *http.Request) {
writeControlErr(w, http.StatusConflict, "already_upgraded", "this space is already upgraded")
return
}
- checkoutURL, err := s.billing.createCheckoutSession(id, req.SuccessURL, req.CancelURL)
+ checkoutURL, err := s.billing.createCheckoutSession(id, tier, req.SuccessURL, req.CancelURL)
if err != nil {
- log.Printf("cloud: checkout for %s (id %d): %v", host, id, err)
+ log.Printf("cloud: checkout for %s (id %d, tier %s): %v", host, id, tier.Key, err)
writeControlErr(w, http.StatusBadGateway, "checkout_failed", "couldn't start the checkout — please try again")
return
}
@@ -123,15 +181,19 @@ func validRedirectURL(raw string) bool {
// createCheckoutSession asks Stripe for a hosted Checkout page. The space's
// ledger id rides in client_reference_id (routes checkout.session.completed)
-// and in the subscription's metadata (routes every later subscription event).
-func (b *stripeBilling) createCheckoutSession(spaceID int64, successURL, cancelURL string) (string, error) {
+// and in the subscription's metadata (routes every later subscription event);
+// the tier key rides in the session metadata so completion records what was
+// bought.
+func (b *stripeBilling) createCheckoutSession(spaceID int64, tier billingTier, successURL, cancelURL string) (string, error) {
id := strconv.FormatInt(spaceID, 10)
form := url.Values{}
form.Set("mode", "subscription")
- form.Set("line_items[0][price]", b.priceID)
+ form.Set("line_items[0][price]", tier.Price)
form.Set("line_items[0][quantity]", "1")
form.Set("client_reference_id", id)
+ form.Set("metadata[tier]", tier.Key)
form.Set("subscription_data[metadata][space_id]", id)
+ form.Set("subscription_data[metadata][tier]", tier.Key)
form.Set("success_url", successURL)
form.Set("cancel_url", cancelURL)
@@ -221,10 +283,11 @@ func (s *cloudServer) stripeWebhookHandler(w http.ResponseWriter, r *http.Reques
// later event.
func (s *cloudServer) handleCheckoutCompleted(raw json.RawMessage) error {
var obj struct {
- Mode string `json:"mode"`
- ClientReferenceID string `json:"client_reference_id"`
- Customer string `json:"customer"`
- Subscription string `json:"subscription"`
+ Mode string `json:"mode"`
+ ClientReferenceID string `json:"client_reference_id"`
+ Customer string `json:"customer"`
+ Subscription string `json:"subscription"`
+ Metadata map[string]string `json:"metadata"`
}
if err := json.Unmarshal(raw, &obj); err != nil {
return err
@@ -237,9 +300,9 @@ func (s *cloudServer) handleCheckoutCompleted(raw json.RawMessage) error {
log.Printf("cloud: webhook checkout.session.completed without a space reference (%q) — ignoring", obj.ClientReferenceID)
return nil
}
- switch err := s.db.markSpacePaid(id, obj.Customer, obj.Subscription); err {
+ switch err := s.db.markSpacePaid(id, obj.Customer, obj.Subscription, obj.Metadata["tier"]); err {
case nil:
- log.Printf("cloud: space id %d upgraded (checkout complete)", id)
+ log.Printf("cloud: space id %d upgraded (checkout complete, tier %s)", id, obj.Metadata["tier"])
return nil
case sql.ErrNoRows:
log.Printf("cloud: webhook checkout for unknown space id %d — ignoring", id)
diff --git a/changelog/2026-07-11-space-upgrades.md b/changelog/2026-07-11-space-upgrades.md
new file mode 100644
index 0000000..24e8649
--- /dev/null
+++ b/changelog/2026-07-11-space-upgrades.md
@@ -0,0 +1,9 @@
+🤖 You can now upgrade a space. On your Eleven home page, every space you
+created shows a quiet **Upgrade** — pick a plan (Family, Friends, or Business)
+and pay on Stripe's secure page. We never see your card, your name, or your
+email; the only thing recorded on our side is that the space is upgraded.
+
+An upgraded space doesn't count toward your three-space limit — so upgrading
+one makes room to create another — and it's never reclaimed as unused. More
+upgraded-space powers are coming; upgrading now is the best way to support
+Eleven.
diff --git a/cloud.go b/cloud.go
index 04dc9d6..9242e95 100644
--- a/cloud.go
+++ b/cloud.go
@@ -157,7 +157,7 @@ func (s *cloudServer) createSpaceHandler(w http.ResponseWriter, r *http.Request)
}
if overQuota {
writeControlErr(w, http.StatusConflict, "quota_exceeded",
- fmt.Sprintf("this account already has %d spaces (the limit)", s.quota))
+ fmt.Sprintf("this account already has %d free spaces (the limit) — upgrading a space frees its slot", s.quota))
return
}
@@ -297,8 +297,12 @@ func (s *cloudServer) listSpacesHandler(w http.ResponseWriter, r *http.Request)
writeControlErr(w, http.StatusInternalServerError, "list_failed", "db error")
return
}
- // billing tells the client whether an Upgrade affordance would go anywhere.
- writeJSON(w, http.StatusOK, map[string]any{"spaces": spaces, "billing": s.billing != nil})
+ // billing carries the tier picker's price book (or false — no Upgrade UI).
+ billing := any(false)
+ if s.billing != nil {
+ billing = map[string]any{"tiers": s.billing.publicTiers()}
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"spaces": spaces, "billing": billing})
}
// ---- admin: caller tokens ---------------------------------------------------
@@ -395,7 +399,7 @@ func runCloud(args []string) {
billing, err := newStripeBilling(
os.Getenv("LCHAT_STRIPE_SECRET_KEY"),
os.Getenv("LCHAT_STRIPE_WEBHOOK_SECRET"),
- os.Getenv("LCHAT_STRIPE_PRICE_ID"),
+ os.Getenv("LCHAT_STRIPE_PRICES"),
envOr("LCHAT_STRIPE_API_BASE", "https://api.stripe.com"),
)
if err != nil {
@@ -415,7 +419,7 @@ func runCloud(args []string) {
billing: billing,
}
if billing != nil {
- log.Printf("cloud: billing on (price %s)", billing.priceID)
+ log.Printf("cloud: billing on (%d tiers)", len(billing.tiers))
}
go s.reapLoop(time.Duration(*reapIntervalHours) * time.Hour)
ln, where, err := listen(*addr, *socketPath)
diff --git a/cloud_billing_test.go b/cloud_billing_test.go
index 5d741ff..50ab085 100644
--- a/cloud_billing_test.go
+++ b/cloud_billing_test.go
@@ -49,17 +49,22 @@ func billingTestCloud(t *testing.T) (*httptest.Server, *cloudServer, *url.Values
apiBase: stripe.URL,
key: "sk_test_secret",
webhookSecret: "whsec_test_secret",
- priceID: "price_test_123",
- client: http.DefaultClient,
+ tiers: []billingTier{
+ {Key: "family", Name: "Family", Label: "$5/year", Price: "price_family_y"},
+ {Key: "friends", Name: "Friends", Label: "$4/month", Price: "price_friends_m"},
+ {Key: "business", Name: "Business", Label: "$99/month", Price: "price_business_m"},
+ },
+ client: http.DefaultClient,
}
return srv, s, captured
}
-// postCheckout starts a checkout for host as (token, requester).
-func postCheckout(t *testing.T, srv *httptest.Server, token, host, requester string) *http.Response {
+// postCheckout starts a checkout for host as (token, requester), buying tier.
+func postCheckout(t *testing.T, srv *httptest.Server, token, host, requester, tier string) *http.Response {
t.Helper()
body, _ := json.Marshal(map[string]string{
"requester": requester,
+ "tier": tier,
"success_url": "https://home.example.com/?upgraded=" + host,
"cancel_url": "https://home.example.com/",
})
@@ -102,7 +107,7 @@ func TestCheckoutMintsSession(t *testing.T) {
tok := mintCaller(t, srv, "home")
host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string)
- resp := postCheckout(t, srv, tok, host, "user-1")
+ resp := postCheckout(t, srv, tok, host, "user-1", "friends")
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
@@ -111,15 +116,19 @@ func TestCheckoutMintsSession(t *testing.T) {
}
// The session carried the space's ledger id (id 1 — fresh DB) in both
- // routing slots, the one price, and NO customer details of any kind.
+ // routing slots, the chosen tier's price + key, and NO customer details.
f := *captured
- if f.Get("mode") != "subscription" || f.Get("line_items[0][price]") != "price_test_123" {
+ if f.Get("mode") != "subscription" || f.Get("line_items[0][price]") != "price_friends_m" {
t.Errorf("form = %v", f)
}
if f.Get("client_reference_id") != "1" || f.Get("subscription_data[metadata][space_id]") != "1" {
t.Errorf("space id routing: client_reference_id=%q metadata=%q, want 1/1",
f.Get("client_reference_id"), f.Get("subscription_data[metadata][space_id]"))
}
+ if f.Get("metadata[tier]") != "friends" || f.Get("subscription_data[metadata][tier]") != "friends" {
+ t.Errorf("tier routing: session=%q subscription=%q, want friends/friends",
+ f.Get("metadata[tier]"), f.Get("subscription_data[metadata][tier]"))
+ }
if !strings.Contains(f.Get("success_url"), "upgraded="+host) {
t.Errorf("success_url = %q", f.Get("success_url"))
}
@@ -136,16 +145,20 @@ func TestCheckoutGates(t *testing.T) {
host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string)
// No caller token → 401.
- if r := postCheckout(t, srv, "", host, "user-1"); r.StatusCode != http.StatusUnauthorized {
+ if r := postCheckout(t, srv, "", host, "user-1", "friends"); r.StatusCode != http.StatusUnauthorized {
t.Errorf("no token: %d, want 401", r.StatusCode)
}
// Ownership is the gate: someone else's requester id → 404.
- if r := postCheckout(t, srv, tok, host, "someone-else"); r.StatusCode != http.StatusNotFound {
+ if r := postCheckout(t, srv, tok, host, "someone-else", "friends"); r.StatusCode != http.StatusNotFound {
t.Errorf("wrong requester: %d, want 404", r.StatusCode)
}
+ // Unknown tier → 400.
+ if r := postCheckout(t, srv, tok, host, "user-1", "platinum"); r.StatusCode != http.StatusBadRequest {
+ t.Errorf("unknown tier: %d, want 400", r.StatusCode)
+ }
// Missing redirect URLs → 400.
req, _ := http.NewRequest("POST", srv.URL+"/api/instances/"+host+"/checkout",
- strings.NewReader(`{"requester":"user-1"}`))
+ strings.NewReader(`{"requester":"user-1","tier":"friends"}`))
req.Header.Set("Authorization", "Bearer "+tok)
resp, err := http.DefaultClient.Do(req)
if err != nil {
@@ -158,15 +171,15 @@ func TestCheckoutGates(t *testing.T) {
// Already upgraded → 409.
label, _, _ := strings.Cut(host, ".")
id, _ := spaceID(label)
- if err := s.db.markSpacePaid(id, "cus_x", "sub_x"); err != nil {
+ if err := s.db.markSpacePaid(id, "cus_x", "sub_x", "friends"); err != nil {
t.Fatal(err)
}
- if r := postCheckout(t, srv, tok, host, "user-1"); r.StatusCode != http.StatusConflict {
+ if r := postCheckout(t, srv, tok, host, "user-1", "friends"); r.StatusCode != http.StatusConflict {
t.Errorf("already paid: %d, want 409", r.StatusCode)
}
// Billing off → 501 (and the webhook too).
s.billing = nil
- if r := postCheckout(t, srv, tok, host, "user-1"); r.StatusCode != http.StatusNotImplemented {
+ if r := postCheckout(t, srv, tok, host, "user-1", "friends"); r.StatusCode != http.StatusNotImplemented {
t.Errorf("billing off: %d, want 501", r.StatusCode)
}
if r := postWebhook(t, srv, []byte("{}"), "t=1,v1=00"); r.StatusCode != http.StatusNotImplemented {
@@ -223,11 +236,12 @@ func TestWebhookEntitlementLifecycle(t *testing.T) {
t.Fatalf("fresh space entitlement = %q, want free", got.Entitlement)
}
- // Checkout completes → paid, ids recorded.
+ // Checkout completes → paid, ids + tier recorded.
deliver(`{"type":"checkout.session.completed","data":{"object":{
- "mode":"subscription","client_reference_id":"1","customer":"cus_9","subscription":"sub_9"}}}`)
- if got := entitlement(); got.Entitlement != entPaid {
- t.Fatalf("after checkout: entitlement = %q, want paid", got.Entitlement)
+ "mode":"subscription","client_reference_id":"1","customer":"cus_9","subscription":"sub_9",
+ "metadata":{"tier":"family"}}}}`)
+ if got := entitlement(); got.Entitlement != entPaid || got.Tier != "family" {
+ t.Fatalf("after checkout: %+v, want paid/family", got)
}
// A subscription update (routed by metadata) refreshes the period end —
@@ -316,7 +330,7 @@ func TestReaperNeverSeesPaidSpaces(t *testing.T) {
old := now.Add(-20 * 24 * time.Hour).Unix()
paidID := insertSpaceRow(t, db, "paid.11ch.at", old)
freeID := insertSpaceRow(t, db, "free.11ch.at", old)
- if err := db.markSpacePaid(paidID, "cus_1", "sub_1"); err != nil {
+ if err := db.markSpacePaid(paidID, "cus_1", "sub_1", "friends"); err != nil {
t.Fatal(err)
}
@@ -362,13 +376,61 @@ func TestBillingMigrationOverOldDB(t *testing.T) {
}
func TestNewStripeBillingConfig(t *testing.T) {
+ prices := `[{"key":"friends","name":"Friends","label":"$4/month","price":"price_x"}]`
if b, err := newStripeBilling("", "", "", "https://api.stripe.com"); b != nil || err != nil {
t.Errorf("all-empty: (%v, %v), want (nil, nil) — billing off", b, err)
}
if _, err := newStripeBilling("sk_x", "", "", "https://api.stripe.com"); err == nil {
t.Error("partial config must error, not half-work")
}
- if b, err := newStripeBilling("sk_x", "whsec_x", "price_x", "https://api.stripe.com/"); err != nil || b == nil || b.apiBase != "https://api.stripe.com" {
+ if b, err := newStripeBilling("sk_x", "whsec_x", prices, "https://api.stripe.com/"); err != nil || b == nil || b.apiBase != "https://api.stripe.com" || len(b.tiers) != 1 {
t.Errorf("full config: (%+v, %v)", b, err)
}
+ // Malformed price books refuse to start: bad JSON, empty, missing fields, dup keys.
+ for _, bad := range []string{
+ "not-json",
+ "[]",
+ `[{"key":"a","name":"A","label":"$1"}]`,
+ `[{"key":"a","name":"A","label":"$1","price":"p1"},{"key":"a","name":"B","label":"$2","price":"p2"}]`,
+ } {
+ if _, err := newStripeBilling("sk_x", "whsec_x", bad, "https://api.stripe.com"); err == nil {
+ t.Errorf("prices %q accepted, want error", bad)
+ }
+ }
+}
+
+// TestQuotaExemptsPaidSpaces pins the point of the tier system: paid spaces
+// don't count toward the creation quota, so upgrading one frees a slot.
+func TestQuotaExemptsPaidSpaces(t *testing.T) {
+ srv, s, _ := billingTestCloud(t)
+ tok := mintCaller(t, srv, "home")
+
+ // Fill the quota with free spaces.
+ var firstHost string
+ for i := 0; i < s.quota; i++ {
+ resp := createSpace(t, srv, tok, "user-1")
+ if resp.StatusCode != http.StatusCreated {
+ t.Fatalf("create %d: status %d", i, resp.StatusCode)
+ }
+ if i == 0 {
+ firstHost, _ = decodeBody(t, resp)["host"].(string)
+ }
+ }
+ if resp := createSpace(t, srv, tok, "user-1"); resp.StatusCode != http.StatusConflict {
+ t.Fatalf("over quota: status = %d, want 409", resp.StatusCode)
+ }
+
+ // Upgrade the first space → its slot frees → creation works again.
+ label, _, _ := strings.Cut(firstHost, ".")
+ id, _ := spaceID(label)
+ if err := s.db.markSpacePaid(id, "cus_1", "sub_1", "friends"); err != nil {
+ t.Fatal(err)
+ }
+ if resp := createSpace(t, srv, tok, "user-1"); resp.StatusCode != http.StatusCreated {
+ t.Fatalf("after upgrade: status = %d, want 201 (paid space freed a slot)", resp.StatusCode)
+ }
+ // And the limit still holds for the free ones.
+ if resp := createSpace(t, srv, tok, "user-1"); resp.StatusCode != http.StatusConflict {
+ t.Fatalf("refilled quota: status = %d, want 409", resp.StatusCode)
+ }
}
diff --git a/cloud_db.go b/cloud_db.go
index e682e8d..ae47b68 100644
--- a/cloud_db.go
+++ b/cloud_db.go
@@ -110,38 +110,54 @@ DROP TABLE spaces_old;`)
return err
}
-// migrateBilling adds the billing columns to spaces (see billing.go). This is
-// the ENTIRE billing data footprint, by design: two opaque Stripe ids, a flag,
-// and a period end — never an email, name, or card detail (Stripe holds those;
-// see docs/cloud-provisioner.md § Billing).
-func (db *cloudDB) migrateBilling() error {
+// spacesHasColumn reports whether the spaces table already carries a column —
+// the guard that keeps the billing migrations additive and re-runnable.
+func (db *cloudDB) spacesHasColumn(column string) (bool, error) {
rows, err := db.sql.Query(`PRAGMA table_info(spaces)`)
if err != nil {
- return err
+ return false, err
}
- hasBilling := false
+ defer rows.Close()
for rows.Next() {
var cid, notnull, pk int
var name, ctype string
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
- rows.Close()
- return err
+ return false, err
}
- if name == "entitlement" {
- hasBilling = true
+ if name == column {
+ return true, nil
}
}
- rows.Close()
- if hasBilling {
- return nil
- }
- _, err = db.sql.Exec(`
+ return false, rows.Err()
+}
+
+// migrateBilling adds the billing columns to spaces (see billing.go). This is
+// the ENTIRE billing data footprint, by design: two opaque Stripe ids, a flag,
+// a tier key, and a period end — never an email, name, or card detail (Stripe
+// holds those; see docs/cloud-provisioner.md § Billing).
+func (db *cloudDB) migrateBilling() error {
+ if has, err := db.spacesHasColumn("entitlement"); err != nil {
+ return err
+ } else if !has {
+ if _, err := db.sql.Exec(`
ALTER TABLE spaces ADD COLUMN stripe_customer_id TEXT;
ALTER TABLE spaces ADD COLUMN stripe_subscription_id TEXT;
ALTER TABLE spaces ADD COLUMN entitlement TEXT NOT NULL DEFAULT 'free';
-ALTER TABLE spaces ADD COLUMN paid_until INTEGER NOT NULL DEFAULT 0;`)
- return err
+ALTER TABLE spaces ADD COLUMN paid_until INTEGER NOT NULL DEFAULT 0;`); err != nil {
+ return err
+ }
+ }
+ // tier landed one change after the columns above, so it gets its own guard
+ // (a DB migrated in between has entitlement but not tier).
+ if has, err := db.spacesHasColumn("tier"); err != nil {
+ return err
+ } else if !has {
+ if _, err := db.sql.Exec(`ALTER TABLE spaces ADD COLUMN tier TEXT NOT NULL DEFAULT ''`); err != nil {
+ return err
+ }
+ }
+ return nil
}
// mintCallerToken creates a caller token, stores only its hash, and returns the
@@ -215,6 +231,11 @@ func (db *cloudDB) revokeCallerToken(id string) (bool, error) {
// slip past the limit. The row is inserted with a NULL host — the caller turns
// the returned id into a hostname via sqids, then finalizeSpace records it.
// overQuota=true (with a zero id) means the request is at/over the limit.
+//
+// Only FREE spaces count against the quota: a paid space carries itself
+// (per-space pricing, #562), so upgrading one frees its slot. Pending creates
+// (NULL host) are free by default and still count — they hold a slot until
+// they finalize or roll back.
func (db *cloudDB) allocateSpace(callerID, requester string, quota int) (id int64, overQuota bool, err error) {
tx, err := db.sql.Begin()
if err != nil {
@@ -223,7 +244,8 @@ func (db *cloudDB) allocateSpace(callerID, requester string, quota int) (id int6
defer tx.Rollback()
var n int
if err := tx.QueryRow(
- `SELECT COUNT(*) FROM spaces WHERE caller_id = ? AND requester = ?`, callerID, requester,
+ `SELECT COUNT(*) FROM spaces WHERE caller_id = ? AND requester = ? AND entitlement = 'free'`,
+ callerID, requester,
).Scan(&n); err != nil {
return 0, false, err
}
@@ -313,6 +335,7 @@ type Space struct {
URL string `json:"url"`
CreatedAt int64 `json:"created_at"`
Entitlement string `json:"entitlement"`
+ Tier string `json:"tier,omitempty"`
PaidUntil int64 `json:"paid_until,omitempty"`
}
@@ -320,7 +343,7 @@ type Space struct {
// still in flight or one that failed before rollback — not shown).
func (db *cloudDB) listSpaces(callerID, requester string) ([]Space, error) {
rows, err := db.sql.Query(
- `SELECT host, created_at, entitlement, paid_until FROM spaces
+ `SELECT host, created_at, entitlement, tier, paid_until FROM spaces
WHERE caller_id = ? AND requester = ? AND host IS NOT NULL ORDER BY id`,
callerID, requester,
)
@@ -331,7 +354,7 @@ func (db *cloudDB) listSpaces(callerID, requester string) ([]Space, error) {
out := []Space{}
for rows.Next() {
var s Space
- if err := rows.Scan(&s.Host, &s.CreatedAt, &s.Entitlement, &s.PaidUntil); err != nil {
+ if err := rows.Scan(&s.Host, &s.CreatedAt, &s.Entitlement, &s.Tier, &s.PaidUntil); err != nil {
return nil, err
}
s.URL = "https://" + s.Host
@@ -354,13 +377,13 @@ func (db *cloudDB) spaceEntitlement(id int64) (string, error) {
return ent, err
}
-// markSpacePaid records a completed checkout: the opaque Stripe ids plus the
-// paid flag. paid_until is left alone — the subscription event that follows the
-// checkout carries the period end (see handleSubscriptionEvent).
-func (db *cloudDB) markSpacePaid(id int64, customerID, subscriptionID string) error {
+// markSpacePaid records a completed checkout: the opaque Stripe ids, the tier
+// bought, plus the paid flag. paid_until is left alone — the subscription event
+// that follows the checkout carries the period end (see handleSubscriptionEvent).
+func (db *cloudDB) markSpacePaid(id int64, customerID, subscriptionID, tier string) error {
res, err := db.sql.Exec(
- `UPDATE spaces SET stripe_customer_id = ?, stripe_subscription_id = ?, entitlement = ? WHERE id = ?`,
- customerID, subscriptionID, entPaid, id,
+ `UPDATE spaces SET stripe_customer_id = ?, stripe_subscription_id = ?, tier = ?, entitlement = ? WHERE id = ?`,
+ customerID, subscriptionID, tier, entPaid, id,
)
if err != nil {
return err
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md
index b5cbe11..d506f7d 100644
--- a/docs/cloud-provisioner.md
+++ b/docs/cloud-provisioner.md
@@ -137,12 +137,12 @@ POST /api/instances bearer: caller token {requester: <opaque id>}
201 {host, url, setup_link}
409 {error: quota_exceeded} per (caller, requester) quota, default 3
503 {error: at_capacity, message} the box is full
-GET /api/instances?requester=… this requester's spaces (+ entitlement, and
- a top-level billing flag)
+GET /api/instances?requester=… this requester's spaces (+ entitlement/tier,
+ and a top-level billing price book)
POST /api/instances/{host}/checkout bearer: caller token
- {requester, success_url, cancel_url}
+ {requester, tier, success_url, cancel_url}
200 {url} the hosted Stripe Checkout page
- 404 not yours | 409 already_upgraded | 501 billing not configured
+ 400 unknown tier | 404 not yours | 409 already_upgraded | 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)
@@ -168,8 +168,8 @@ GET /api/spaces bearer: device link token
→ the hosts this user provisioned (drives the "Delete space" affordance)
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
- → relays cloud's 200 {url} / 404 / 409 / 501; return URLs point back at home
+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
```
Creation and listing are link-token-authed (additive/reads) and creation is
@@ -247,28 +247,36 @@ window: `LCHAT_TRASH_KEEP_DAYS` (opt-in — 0/unset keeps trash forever) removes
`/data/trash/<host>-<unix>` entries older than that, swept daily. Keep it well
above the reap threshold so a mistaken reap stays recoverable for a while.
-## Billing (Stripe) — the rails, not the price book
+## Billing (Stripe) — tiers, quota exemption, no metering
Optional space upgrades (`billing.go`): a person who created a space can pay a
-subscription to upgrade it. This is deliberately **just the rails** — one price,
-one flag — the tiers, numbers, and metering live in issues #561/#562 and layer
-on later without reworking any of this.
+subscription to upgrade it at one of a small set of **tiers** (Family / Friends /
+Business — config, not code). Per-tier powers (storage, member counts, bots) and
+metering are #561/#562 and layer on later; today a paid space of ANY tier gets
+the same two things.
**The flow.** The home page shows a quiet *Upgrade* on spaces this account
-created (only when the provisioner advertises billing). Tapping it asks home →
-cloud for a Stripe **Checkout Session**; the browser goes to Stripe's hosted
-page and comes back to home (`?upgraded=<host>`). Stripe then confirms through
-the **webhook** — `checkout.session.completed` flips the space's ledger row to
-`paid`, and `customer.subscription.updated/deleted` track it for the life of the
-subscription (`past_due` keeps access through Stripe's dunning window; deletion
-drops the space back to `free`). The row's numeric ledger id rides in the
-session's `client_reference_id` and the subscription's metadata — that's how
-events route back to a space.
-
-**What the flag does today:** a paid space is **never a reap candidate**
-(excluded in the ledger query itself, the strongest of the guards above), and
-the entitlement is returned on space listings so clients can render it. That's
-all — per-space limits read the same flag when #561 lands.
+created (only when the provisioner advertises billing). It opens a plan picker
+(the price book from the listing response); the chosen tier key goes home →
+cloud, which mints a Stripe **Checkout Session** for that tier's price; the
+browser goes to Stripe's hosted page and comes back to home
+(`?upgraded=<host>`). Stripe then confirms through the **webhook** —
+`checkout.session.completed` flips the space's ledger row to `paid` and records
+the tier, and `customer.subscription.updated/deleted` track it for the life of
+the subscription (`past_due` keeps access through Stripe's dunning window;
+deletion drops the space back to `free`). The row's numeric ledger id rides in
+the session's `client_reference_id` and the subscription's metadata, the tier
+key in both metadata blobs — that's how events route back to a space.
+
+**What paying does today:**
+
+- a paid space **doesn't count toward the creation quota** (`allocateSpace`
+ counts only free spaces — upgrading a space frees its slot, and every extra
+ space carries itself);
+- a paid space is **never a reap candidate** (excluded in the ledger query
+ itself, the strongest of the guards above);
+- the entitlement + tier come back on space listings so clients render the
+ badge. Per-space limits read the same columns when #561 lands.
**Privacy (the point):** the ledger stores two opaque Stripe ids
(`stripe_customer_id`, `stripe_subscription_id`), the `entitlement` flag, and
@@ -283,7 +291,11 @@ its gate is Stripe's HMAC signature over the raw payload (±5 min replay window)
```
LCHAT_STRIPE_SECRET_KEY restricted API key (write: Checkout Sessions; read: nothing else needed)
LCHAT_STRIPE_WEBHOOK_SECRET the webhook endpoint's signing secret
-LCHAT_STRIPE_PRICE_ID the one subscription price (test or live)
+LCHAT_STRIPE_PRICES the price book: a JSON array of tiers, e.g.
+ [{"key":"family","name":"Family","label":"$5/year","price":"price_…"}, …]
+ (display strings live here verbatim — the server never
+ asks Stripe what a price costs, so a Stripe outage can't
+ block a cloud restart; the checkout charges the real price)
LCHAT_STRIPE_API_BASE default https://api.stripe.com (tests point it at a stub)
```
diff --git a/home/provision.go b/home/provision.go
index 3c14e57..db783c4 100644
--- a/home/provision.go
+++ b/home/provision.go
@@ -161,11 +161,13 @@ func (s *Server) deleteSpaceHandler(w http.ResponseWriter, r *http.Request) {
}
}
-// checkoutSpaceHandler: POST /api/spaces/{host}/checkout — ask the provisioner
-// for a Stripe Checkout link to upgrade a space this user created. Link-token
-// auth (paying is additive, not destructive — same bar as creating); ownership
-// is enforced downstream by the provisioner's ledger. The success/cancel URLs
-// land back on this page, so the checkout is a same-tab round trip.
+// checkoutSpaceHandler: POST /api/spaces/{host}/checkout {tier} — ask the
+// provisioner for a Stripe Checkout link to upgrade a space this user created,
+// at the chosen tier (validated downstream against the provisioner's price
+// book). Link-token auth (paying is additive, not destructive — same bar as
+// creating); ownership is enforced downstream by the provisioner's ledger. The
+// success/cancel URLs land back on this page, so the checkout is a same-tab
+// round trip.
func (s *Server) checkoutSpaceHandler(w http.ResponseWriter, r *http.Request) {
uid, ok := s.linkUser(r)
if !ok {
@@ -178,9 +180,19 @@ func (s *Server) checkoutSpaceHandler(w http.ResponseWriter, r *http.Request) {
})
return
}
+ var req struct {
+ Tier string `json:"tier"`
+ }
+ if err := json.NewDecoder(io.LimitReader(r.Body, 4<<10)).Decode(&req); err != nil || req.Tier == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]any{
+ "error": "bad_request", "message": "tier required",
+ })
+ return
+ }
host := r.PathValue("host")
payload, _ := json.Marshal(map[string]string{
"requester": uid,
+ "tier": req.Tier,
"success_url": s.origin + "/?upgraded=" + url.QueryEscape(host),
"cancel_url": s.origin + "/",
})
@@ -191,10 +203,10 @@ func (s *Server) checkoutSpaceHandler(w http.ResponseWriter, r *http.Request) {
})
return
}
- // Relay the provisioner's stable codes (200 link, 404 not yours, 409 already
- // upgraded, 501 no billing); flatten anything else.
+ // Relay the provisioner's stable codes (200 link, 400 bad tier, 404 not
+ // yours, 409 already upgraded, 501 no billing); flatten anything else.
switch status {
- case http.StatusOK, http.StatusNotFound, http.StatusConflict, http.StatusNotImplemented:
+ case http.StatusOK, http.StatusBadRequest, http.StatusNotFound, http.StatusConflict, http.StatusNotImplemented:
writeJSON(w, status, body)
default:
writeJSON(w, http.StatusBadGateway, map[string]any{
diff --git a/home/provision_test.go b/home/provision_test.go
index f0a4fea..7624150 100644
--- a/home/provision_test.go
+++ b/home/provision_test.go
@@ -222,7 +222,8 @@ func TestCheckoutSpaceProxies(t *testing.T) {
uid, token := seedUser(t, s)
post := func(tok string) *http.Response {
- req, _ := http.NewRequest("POST", ts.URL+"/api/spaces/a4f.example.com/checkout", nil)
+ req, _ := http.NewRequest("POST", ts.URL+"/api/spaces/a4f.example.com/checkout",
+ strings.NewReader(`{"tier":"friends"}`))
if tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
@@ -267,6 +268,20 @@ func TestCheckoutSpaceProxies(t *testing.T) {
if sent["requester"] != uid {
t.Errorf("requester = %q, want %s", sent["requester"], uid)
}
+ if sent["tier"] != "friends" {
+ t.Errorf("tier = %q, want friends", sent["tier"])
+ }
+ // A missing tier is rejected before the provisioner is ever called.
+ noTier, _ := http.NewRequest("POST", ts.URL+"/api/spaces/a4f.example.com/checkout", nil)
+ noTier.Header.Set("Authorization", "Bearer "+token)
+ ntResp, err := http.DefaultClient.Do(noTier)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ntResp.Body.Close()
+ if ntResp.StatusCode != http.StatusBadRequest {
+ t.Errorf("no tier: %d, want 400", ntResp.StatusCode)
+ }
if !strings.HasPrefix(sent["success_url"], "http://localhost:1748/?upgraded=") ||
!strings.Contains(sent["success_url"], "a4f.example.com") {
t.Errorf("success_url = %q", sent["success_url"])
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index 6e86a74..4d5a8ab 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -230,7 +230,21 @@
@media (hover: hover) { ul.servers li:hover .row-upgrade { opacity: .7; } }
@media (hover: none) { .row-upgrade { opacity: .55; } }
@media (prefers-reduced-motion: reduce) { .row-upgrade { transition: none; } }
- /* The Supporter badge on an upgraded space's row. */
+ /* Upgrade screen: one button per plan — quiet rows, name left, price right. */
+ #upgrade { text-align: center; }
+ #upgrade-tiers { display: flex; flex-direction: column; gap: 8px; margin-top: 14px; }
+ .tier-btn {
+ display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
+ width: 100%; padding: 12px 14px; border-radius: 12px; cursor: pointer;
+ font: inherit; text-align: left;
+ color: var(--ink); background: var(--quiet); border: 1px solid var(--border);
+ }
+ .tier-btn:hover { border-color: var(--accent); }
+ .tier-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+ .tier-btn:disabled { opacity: .5; cursor: default; }
+ .tier-btn .tier-name { font-weight: 700; }
+ .tier-btn .tier-price { color: var(--muted); font-size: .9rem; }
+ /* The tier badge on an upgraded space's row. */
.supporter-badge {
align-self: flex-start; margin-top: 2px;
font-size: .62rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em;
@@ -471,6 +485,17 @@
</div>
</div>
+ <!-- Upgrade a space: pick a plan; the payment itself happens on Stripe's
+ hosted page, so this screen is just the choice + the handoff. -->
+ <div id="upgrade" class="hidden screen">
+ <button id="upgrade-back" class="icon-back" type="button" aria-label="Back to your spaces"><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
+ <h2 class="create-title">Upgrade <span id="upgrade-host"></span></h2>
+ <p class="create-sub">An upgraded space doesn't count toward your space limit.
+ You'll pay on Stripe's secure page — we never see your card, name, or email.</p>
+ <div id="upgrade-tiers" role="group" aria-label="Choose a plan"></div>
+ <p class="status" id="upgrade-status"></p>
+ </div>
+
<!-- The archive: spaces you've set aside, hidden from the main list. Bring one
back (unarchive), or delete it forever behind a stern typed-confirmation —
"delete your access" (held while the space reports this is its ONLY admin
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 581100a..1866a67 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -81,6 +81,7 @@ let spacesEnabled = false;
// badge. null = not fetched yet (fetched lazily, once per sign-in).
let provisioned = null;
let billingEnabled = false;
+let billingTiers = []; // the provisioner's price book: [{key, name, label}]
// A checkout round trip lands back here with ?upgraded=<host>; consumed (once)
// by the first showVault after return.
let upgradedHost = new URLSearchParams(location.search).get("upgraded") || "";
@@ -222,6 +223,7 @@ function signOut() {
provisioned = null; // the ledger read is per-sign-in
$("vault").classList.add("hidden");
$("create").classList.add("hidden");
+ $("upgrade").classList.add("hidden");
$("archive").classList.add("hidden");
archiving = null;
clearUndo();
@@ -312,7 +314,8 @@ function refreshProvisioned() {
provisioned = {}; // in flight — re-renders don't refetch
apiJSON("/api/spaces", { headers: { Authorization: "Bearer " + state.token } })
.then((r) => {
- billingEnabled = !!r.billing;
+ billingTiers = (r.billing && r.billing.tiers) || [];
+ billingEnabled = billingTiers.length > 0;
provisioned = {};
for (const sp of r.spaces || []) provisioned[sp.host] = sp;
if (vault && !$("vault").classList.contains("hidden")) showVault();
@@ -451,7 +454,7 @@ function serverRow(s, sortable) {
if (ledger && ledger.entitlement === "paid") {
const badge = document.createElement("span");
badge.className = "supporter-badge";
- badge.textContent = "Supporter";
+ badge.textContent = tierName(ledger.tier);
main.append(badge);
}
@@ -463,7 +466,7 @@ function serverRow(s, sortable) {
up.className = "row-upgrade";
up.textContent = "Upgrade";
up.setAttribute("aria-label", "Upgrade " + labelOf(s));
- up.addEventListener("click", () => startUpgrade(hostOf(s.url), up));
+ up.addEventListener("click", () => openUpgrade(s));
li.append(up);
}
@@ -623,25 +626,69 @@ async function persistVault(status = vaultStatus) {
// ---- upgrade a space ----------------------------------------------------------
-// startUpgrade begins the checkout round trip for a space this account created:
-// home mints a Stripe Checkout link (ownership enforced by the provisioner's
-// ledger) and we follow it in the same tab — Stripe brings you back here with
-// ?upgraded=<host> when it's done, and Cancel comes straight back too.
-async function startUpgrade(host, btn) {
- btn.disabled = true;
- vaultStatus("Opening checkout…", "busy");
+function upgradeStatus(msg, kind) {
+ const el = $("upgrade-status");
+ el.textContent = msg || "";
+ el.className = "status" + (kind ? " " + kind : "");
+}
+
+// tierName maps a ledger tier key to its member-facing name; spaces upgraded
+// under a tier that's since left the price book still get a badge.
+function tierName(key) {
+ const t = billingTiers.find((x) => x.key === key);
+ return (t && t.name) || "Supporter";
+}
+
+// openUpgrade shows the plan picker for one space. The payment itself is
+// Stripe's hosted page; this screen only chooses the plan and hands off.
+function openUpgrade(s) {
+ $("vault").classList.add("hidden");
+ $("upgrade").classList.remove("hidden");
+ $("upgrade-host").textContent = hostOf(s.url);
+ upgradeStatus("");
+ const box = $("upgrade-tiers");
+ box.textContent = "";
+ for (const t of billingTiers) {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "tier-btn";
+ const name = document.createElement("span");
+ name.className = "tier-name";
+ name.textContent = t.name;
+ const price = document.createElement("span");
+ price.className = "tier-price";
+ price.textContent = t.label;
+ btn.append(name, price);
+ btn.addEventListener("click", () => startUpgrade(hostOf(s.url), t.key, box));
+ box.append(btn);
+ }
+}
+
+function closeUpgrade() {
+ $("upgrade").classList.add("hidden");
+ showVault();
+}
+
+// startUpgrade begins the checkout round trip for the chosen plan: home mints a
+// Stripe Checkout link (ownership enforced by the provisioner's ledger) and we
+// follow it in the same tab — Stripe brings you back here with ?upgraded=<host>
+// when it's done, and Cancel comes straight back too.
+async function startUpgrade(host, tier, box) {
+ for (const b of box.querySelectorAll("button")) b.disabled = true;
+ upgradeStatus("Opening checkout…", "busy");
try {
const res = await fetch("/api/spaces/" + encodeURIComponent(host) + "/checkout", {
method: "POST",
- headers: { Authorization: "Bearer " + state.token },
+ headers: { Authorization: "Bearer " + state.token, "Content-Type": "application/json" },
+ body: JSON.stringify({ tier }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.message || body.error || `Couldn't start the upgrade (HTTP ${res.status}).`);
if (!body.url) throw new Error("The checkout link didn't come back — try again.");
location.href = body.url;
} catch (e) {
- vaultStatus(e.message || "Couldn't start the upgrade.", "error");
- btn.disabled = false;
+ upgradeStatus(e.message || "Couldn't start the upgrade.", "error");
+ for (const b of box.querySelectorAll("button")) b.disabled = false;
}
}
@@ -1110,6 +1157,7 @@ $("create-back").addEventListener("click", closeCreate);
$("create-done").addEventListener("click", closeCreate);
// (Creating a space is the fader now — mounted in openCreate; see fader.js.)
$("archive-back").addEventListener("click", closeArchive);
+$("upgrade-back").addEventListener("click", closeUpgrade);
$("archive-cancel").addEventListener("click", closeArchiveStern);
$("delete-access").addEventListener("click", deleteAccess);
$("destroy-forever").addEventListener("click", destroyForEveryone);