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>
11 files changed,
+401
−127
.gitignore+1 −0billing.go+91 −28changelog/2026-07-11-space-upgrades.md+9 −0cloud.go+9 −5cloud_billing_test.go+81 −19cloud_db.go+50 −27docs/cloud-provisioner.md+37 −25home/provision.go+20 −8home/provision_test.go+16 −1home/web/pages/index.html+26 −1home/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, ¬null, &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); |