server: destroying a paid space cancels its Stripe subscription first (#1327)
Destroy tore down the instance and deleted the ledger row (the only place the subscription id lived), so Stripe kept charging for a space that no longer existed β with the per-space Manage entry point gone, the payer had no in-product way to stop it. Cancel-first, refuse-on-failure; the home destroy ceremony now says the subscription dies with the space, and home relays the provisioner's actionable failure message instead of flattening it.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
8 files changed,
+266
−4
billing.go+36 −0changelog/2026-07-24-destroy-cancels-subscription.md+4 −0cloud.go+24 −0cloud_billing_test.go+164 −0cloud_db.go+14 −0home/provision.go+8 −1home/web/pages/index.html+3 −1home/web/static/home.js+13 −2
diff --git a/billing.go b/billing.go| index 49a90d3..50af797 100644 |
| --- a/billing.go |
| +++ b/billing.go |
| @@ -398,6 +398,42 @@ func (b *stripeBilling) createPortalSession(customer, band, returnURL string) (s |
| return body.URL, nil |
| } |
| +// cancelSubscription ends a subscription NOW (not at period end): it runs only |
| +// when the space it pays for is being destroyed, and billing to the period edge |
| +// for a space that no longer exists would be worse than the early cut-off. A |
| +// subscription Stripe no longer knows (resource_missing) or has already |
| +// canceled counts as success β the goal is "not billing", not "we did the |
| +// canceling". |
| +func (b *stripeBilling) cancelSubscription(subscriptionID string) error { |
| + req, err := http.NewRequest("DELETE", b.apiBase+"/v1/subscriptions/"+url.PathEscape(subscriptionID), nil) |
| + if err != nil { |
| + return err |
| + } |
| + req.Header.Set("Authorization", "Bearer "+b.key) |
| + resp, err := b.client.Do(req) |
| + if err != nil { |
| + return err |
| + } |
| + defer resp.Body.Close() |
| + var body struct { |
| + Status string `json:"status"` |
| + Error struct { |
| + Code string `json:"code"` |
| + Message string `json:"message"` |
| + } `json:"error"` |
| + } |
| + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil { |
| + return fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode) |
| + } |
| + if resp.StatusCode == http.StatusOK && body.Status == "canceled" { |
| + return nil |
| + } |
| + if body.Error.Code == "resource_missing" { |
| + return nil |
| + } |
| + return fmt.Errorf("stripe: HTTP %d: %s", resp.StatusCode, body.Error.Message) |
| +} |
| + |
| // validRedirectURL admits the http(s) URLs a checkout may bounce back to. The |
| // caller is a trusted home server, so this is belt-and-suspenders, not policy. |
| func validRedirectURL(raw string) bool { |
diff --git a/changelog/2026-07-24-destroy-cancels-subscription.md b/changelog/2026-07-24-destroy-cancels-subscription.md| new file mode 100644 |
| index 0000000..5575459 |
| --- /dev/null |
| +++ b/changelog/2026-07-24-destroy-cancels-subscription.md |
| @@ -0,0 +1,4 @@ |
| +π€ Destroying a paid space now cancels its subscription at the same moment β |
| +no more charges after the space is gone. If the cancellation can't be |
| +confirmed, the space isn't deleted, so nothing can slip through; the destroy |
| +screen also says this up front for spaces with a subscription. |
diff --git a/cloud.go b/cloud.go| index c9c22a0..3572dbb 100644 |
| --- a/cloud.go |
| +++ b/cloud.go |
| @@ -407,6 +407,30 @@ func (s *cloudServer) deleteSpaceHandler(w http.ResponseWriter, r *http.Request) |
| writeControlErr(w, http.StatusNotFound, "not_found", "no such space for this account") |
| return |
| } |
| + // A paid space's subscription dies with it β otherwise Stripe keeps |
| + // charging for a space that no longer exists, and destroying the ledger row |
| + // severs the payer's only route to the Billing Portal (Manage is per-space). |
| + // Cancel-first, and refuse the destroy if the cancel fails, so a retry can |
| + // still reach the subscription. Entitlement gates the attempt: a lapsed |
| + // space keeps its old subscription id, and re-cancelling a canceled |
| + // subscription is a Stripe error, not a no-op. |
| + if ent, subID, err := s.db.spaceSubscription(id); err != nil { |
| + writeControlErr(w, http.StatusInternalServerError, "delete_failed", "db error") |
| + return |
| + } else if ent == entPaid && subID != "" { |
| + if s.billing == nil { |
| + writeControlErr(w, http.StatusConflict, "billing_unavailable", |
| + "this space has an active subscription but billing is not configured β cancel it in Stripe first") |
| + return |
| + } |
| + if err := s.billing.cancelSubscription(subID); err != nil { |
| + log.Printf("cloud: cancel subscription %s for %s (id %d): %v", subID, host, id, err) |
| + writeControlErr(w, http.StatusBadGateway, "cancel_failed", |
| + "couldn't cancel this space's subscription β nothing was deleted; please try again") |
| + return |
| + } |
| + log.Printf("cloud: canceled subscription %s for %s (id %d) ahead of destroy", subID, host, id) |
| + } |
| status, body, err := s.controlDelete(host, false) |
| if err != nil { |
| log.Printf("cloud: control delete for %s: %v", host, err) |
diff --git a/cloud_billing_test.go b/cloud_billing_test.go| index eb6a59c..9643a93 100644 |
| --- a/cloud_billing_test.go |
| +++ b/cloud_billing_test.go |
| @@ -555,3 +555,167 @@ func TestPortalSession(t *testing.T) { |
| t.Errorf("url = %q", u) |
| } |
| } |
| + |
| +// ---- destroy cancels the subscription ------------------------------------------ |
| + |
| +// destroyRig builds a cloud whose control answers create + delete and whose |
| +// Stripe stub records subscription cancels, answering each with the given |
| +// status/body. Returns the cancelled ids and a count of control-side deletes, |
| +// so tests can assert both "Stripe was told" and "nothing was deprovisioned". |
| +func destroyRig(t *testing.T, cancelStatus int, cancelBody map[string]any) (srv *httptest.Server, s *cloudServer, canceled *[]string, ctlDeletes *int) { |
| + t.Helper() |
| + canceled = &[]string{} |
| + ctlDeletes = new(int) |
| + stripe := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.Method == "DELETE" && strings.HasPrefix(r.URL.Path, "/v1/subscriptions/") { |
| + *canceled = append(*canceled, strings.TrimPrefix(r.URL.Path, "/v1/subscriptions/")) |
| + writeJSON(w, cancelStatus, cancelBody) |
| + return |
| + } |
| + w.WriteHeader(http.StatusNotFound) |
| + })) |
| + t.Cleanup(stripe.Close) |
| + ctl := fakeControl(t, func(w http.ResponseWriter, r *http.Request) { |
| + if r.Method == "DELETE" { |
| + *ctlDeletes++ |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| + return |
| + } |
| + var req controlCreateReq |
| + json.NewDecoder(r.Body).Decode(&req) |
| + writeJSON(w, http.StatusCreated, map[string]any{"host": req.Host, "url": "https://" + req.Host}) |
| + }) |
| + srv, s = newTestCloud(t, ctl) |
| + s.billing = &stripeBilling{ |
| + apiBase: stripe.URL, key: "sk_test", webhookSecret: "whsec_test", |
| + tiers: []billingTier{{Key: "friends", Name: "Friends", Label: "$4/month", Price: "price_friends_m"}}, |
| + client: http.DefaultClient, |
| + } |
| + return srv, s, canceled, ctlDeletes |
| +} |
| + |
| +// deleteSpaceReq issues the owner-facing DELETE /api/instances/{host}. |
| +func deleteSpaceReq(t *testing.T, srv *httptest.Server, token, host, requester string) *http.Response { |
| + t.Helper() |
| + req, _ := http.NewRequest("DELETE", srv.URL+"/api/instances/"+host+"?requester="+requester, nil) |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { resp.Body.Close() }) |
| + return resp |
| +} |
| + |
| +// paidSpace creates a space as (tok, requester) and marks it paid with sub_x. |
| +func paidSpace(t *testing.T, srv *httptest.Server, s *cloudServer, tok string) (host string, id int64) { |
| + t.Helper() |
| + host, _ = decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) |
| + label, _, _ := strings.Cut(host, ".") |
| + id, _ = spaceID(label) |
| + if err := s.db.markSpacePaid(id, "cus_x", "sub_x", "friends", ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + return host, id |
| +} |
| + |
| +func TestDestroyCancelsSubscription(t *testing.T) { |
| + srv, s, canceled, ctlDeletes := destroyRig(t, http.StatusOK, map[string]any{"id": "sub_x", "status": "canceled"}) |
| + tok := mintCaller(t, srv, "home") |
| + host, _ := paidSpace(t, srv, s, tok) |
| + |
| + if r := deleteSpaceReq(t, srv, tok, host, "user-1"); r.StatusCode != http.StatusOK { |
| + t.Fatalf("destroy: %d, want 200", r.StatusCode) |
| + } |
| + if len(*canceled) != 1 || (*canceled)[0] != "sub_x" { |
| + t.Errorf("canceled = %v, want [sub_x]", *canceled) |
| + } |
| + if *ctlDeletes != 1 { |
| + t.Errorf("control deletes = %d, want 1", *ctlDeletes) |
| + } |
| + if spaces, err := s.db.listSpaces(mustCallerID(t, s, "home"), "user-1"); err != nil || len(spaces) != 0 { |
| + t.Errorf("spaces after destroy = %v (%v), want none", spaces, err) |
| + } |
| +} |
| + |
| +func TestDestroyBlockedWhenCancelFails(t *testing.T) { |
| + srv, s, canceled, ctlDeletes := destroyRig(t, http.StatusInternalServerError, |
| + map[string]any{"error": map[string]any{"code": "api_error", "message": "boom"}}) |
| + tok := mintCaller(t, srv, "home") |
| + host, _ := paidSpace(t, srv, s, tok) |
| + |
| + // The cancel failed β the whole destroy refuses: no deprovision, ledger row |
| + // kept, so a retry still knows the subscription. |
| + if r := deleteSpaceReq(t, srv, tok, host, "user-1"); r.StatusCode != http.StatusBadGateway { |
| + t.Fatalf("destroy with failing cancel: %d, want 502", r.StatusCode) |
| + } |
| + if len(*canceled) != 1 { |
| + t.Errorf("cancel attempts = %d, want 1", len(*canceled)) |
| + } |
| + if *ctlDeletes != 0 { |
| + t.Errorf("control deletes = %d, want 0 (nothing deprovisioned)", *ctlDeletes) |
| + } |
| + if spaces, err := s.db.listSpaces(mustCallerID(t, s, "home"), "user-1"); err != nil || len(spaces) != 1 { |
| + t.Errorf("spaces after refused destroy = %v (%v), want the row kept", spaces, err) |
| + } |
| +} |
| + |
| +func TestDestroyToleratesGoneSubscription(t *testing.T) { |
| + // Stripe no longer knows the subscription (resource_missing) β the goal is |
| + // "not billing", so the destroy proceeds. |
| + srv, s, canceled, _ := destroyRig(t, http.StatusNotFound, |
| + map[string]any{"error": map[string]any{"code": "resource_missing", "message": "No such subscription"}}) |
| + tok := mintCaller(t, srv, "home") |
| + host, _ := paidSpace(t, srv, s, tok) |
| + |
| + if r := deleteSpaceReq(t, srv, tok, host, "user-1"); r.StatusCode != http.StatusOK { |
| + t.Fatalf("destroy: %d, want 200", r.StatusCode) |
| + } |
| + if len(*canceled) != 1 { |
| + t.Errorf("cancel attempts = %d, want 1", len(*canceled)) |
| + } |
| +} |
| + |
| +func TestDestroySkipsStripeWhenNotPaid(t *testing.T) { |
| + srv, s, canceled, _ := destroyRig(t, http.StatusOK, map[string]any{"status": "canceled"}) |
| + tok := mintCaller(t, srv, "home") |
| + |
| + // A free space: no subscription, no Stripe call. |
| + host, _ := decodeBody(t, createSpace(t, srv, tok, "user-1"))["host"].(string) |
| + if r := deleteSpaceReq(t, srv, tok, host, "user-1"); r.StatusCode != http.StatusOK { |
| + t.Fatalf("destroy free: %d, want 200", r.StatusCode) |
| + } |
| + if len(*canceled) != 0 { |
| + t.Errorf("canceled = %v, want none for a free space", *canceled) |
| + } |
| + |
| + // A lapsed space keeps its old subscription id but is entitlement-free β |
| + // re-cancelling a canceled subscription is a Stripe error, so the gate is |
| + // the entitlement, not the id. |
| + host2, id2 := paidSpace(t, srv, s, tok) |
| + if err := s.db.setEntitlementTier(id2, entFree, "", "", 0, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if r := deleteSpaceReq(t, srv, tok, host2, "user-1"); r.StatusCode != http.StatusOK { |
| + t.Fatalf("destroy lapsed: %d, want 200", r.StatusCode) |
| + } |
| + if len(*canceled) != 0 { |
| + t.Errorf("canceled = %v, want none for a lapsed space", *canceled) |
| + } |
| +} |
| + |
| +func TestDestroyPaidRefusesWithoutBilling(t *testing.T) { |
| + srv, s, _, ctlDeletes := destroyRig(t, http.StatusOK, map[string]any{"status": "canceled"}) |
| + tok := mintCaller(t, srv, "home") |
| + host, _ := paidSpace(t, srv, s, tok) |
| + |
| + // Billing unconfigured but the space has a live subscription: refusing is |
| + // the only move that doesn't leak a charge nobody can reach. |
| + s.billing = nil |
| + if r := deleteSpaceReq(t, srv, tok, host, "user-1"); r.StatusCode != http.StatusConflict { |
| + t.Fatalf("destroy without billing: %d, want 409", r.StatusCode) |
| + } |
| + if *ctlDeletes != 0 { |
| + t.Errorf("control deletes = %d, want 0", *ctlDeletes) |
| + } |
| +} |
diff --git a/cloud_db.go b/cloud_db.go| index 5c4464b..f07f831 100644 |
| --- a/cloud_db.go |
| +++ b/cloud_db.go |
| @@ -873,6 +873,20 @@ func (db *cloudDB) spaceCustomer(id int64) (customer, band string, err error) { |
| return *c, band, nil |
| } |
| +// spaceSubscription reads a space's entitlement plus its opaque Stripe |
| +// subscription id ("" = never subscribed) β what destroy consults to decide |
| +// whether a live subscription must be cancelled first. |
| +func (db *cloudDB) spaceSubscription(id int64) (entitlement, subscriptionID string, err error) { |
| + var sub *string |
| + if err := db.sql.QueryRow(`SELECT entitlement, stripe_subscription_id FROM spaces WHERE id = ?`, id).Scan(&entitlement, &sub); err != nil { |
| + return "", "", err |
| + } |
| + if sub == nil { |
| + return entitlement, "", nil |
| + } |
| + return entitlement, *sub, nil |
| +} |
| + |
| // setQuotaBytes records what the cloud last pushed into an instance's env. |
| func (db *cloudDB) setQuotaBytes(id, quota int64) error { |
| _, err := db.sql.Exec(`UPDATE spaces SET quota_bytes = ? WHERE id = ?`, quota, id) |
diff --git a/home/provision.go b/home/provision.go| index 740bad4..8454945 100644 |
| --- a/home/provision.go |
| +++ b/home/provision.go |
| @@ -382,8 +382,15 @@ func (s *Server) deleteSpaceHandler(w http.ResponseWriter, r *http.Request) { |
| case http.StatusOK, http.StatusNotFound: |
| writeJSON(w, status, body) |
| default: |
| + // Keep the provisioner's member-facing message when it has one β e.g. |
| + // "couldn't cancel this space's subscription β nothing was deleted" is |
| + // actionable in a way a flat "deletion failed" isn't. |
| + msg, _ := body["message"].(string) |
| + if msg == "" { |
| + msg = "space deletion failed" |
| + } |
| writeJSON(w, http.StatusBadGateway, map[string]any{ |
| - "error": "delete_failed", "message": "space deletion failed", |
| + "error": "delete_failed", "message": msg, |
| }) |
| } |
| } |
diff --git a/home/web/pages/index.html b/home/web/pages/index.html| index 0659713..2eedcab 100644 |
| --- a/home/web/pages/index.html |
| +++ b/home/web/pages/index.html |
| @@ -1159,7 +1159,9 @@ |
| <div id="destroy-zone" class="hidden"> |
| <p class="create-sub stern-copy" id="destroy-note">You created this space β deleting just |
| your access keeps it <b>counting toward your space limit</b>, with nobody able to manage |
| - it. Destroying it for everyone frees that slot: every message, photo, and member in it.</p> |
| + it. Destroying it for everyone frees that slot: every message, photo, and member in |
| + it.<span id="destroy-paid" class="hidden"> Its subscription is cancelled with it β |
| + no further charges.</span></p> |
| <button id="destroy-forever" type="button" class="linkish danger-link" disabled>Destroy the space for everyoneβ¦</button> |
| </div> |
| <button id="archive-cancel" type="button" class="wide-quiet">Cancel</button> |
diff --git a/home/web/static/home.js b/home/web/static/home.js| index 6c42a1b..811aa7d 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -3455,6 +3455,10 @@ let sternDestroyable = false; |
| // to the only action. Server truth (the meter sweep records claimed-ness), so |
| // it works exactly where checkSoleAdmin can't: entries with no credentials. |
| let sternShell = false; |
| +// sternPaid: the ledger says this space has a live subscription β destroying |
| +// it also cancels that subscription (the provisioner enforces it), and the |
| +// ceremony copy must say so. |
| +let sternPaid = false; |
| // openArchive shows the archive screen (the archived-spaces list). |
| function openArchive() { |
| @@ -3557,6 +3561,7 @@ async function openArchiveDelete(s) { |
| sternSoleAdmin = false; |
| sternDestroyable = false; |
| sternShell = false; |
| + sternPaid = false; |
| const host = hostOf(s.url); |
| $("stern-name").textContent = labelOf(s); |
| $("stern-host").textContent = host; |
| @@ -3595,6 +3600,7 @@ function checkOwnership(s, host) { |
| const row = (r.spaces || []).find((sp) => sp.host === host); |
| if (row && archiving === s) { |
| sternDestroyable = true; |
| + sternPaid = row.entitlement === "paid"; |
| // An owned space the ledger has SEEN (a sweep ran) but that was never |
| // set up and never joined is an unusable shell β and when the entry |
| // carries no credentials (never enrolled), no live check could ever |
| @@ -3649,14 +3655,18 @@ function updateSternHold() { |
| const destroy = $("destroy-forever"); |
| destroy.className = destroyOnly ? "btn-danger" : "linkish danger-link"; |
| destroy.textContent = destroyOnly ? "Destroy the space for everyone" : "Destroy the space for everyoneβ¦"; |
| + $("destroy-paid").classList.toggle("hidden", !sternPaid); |
| const note = $("sole-admin-note"); |
| + // The subscription line rides every destroy framing for a paid space β the |
| + // one thing a payer needs to hear before the button. |
| + const paidLine = sternPaid ? ["Its subscription is cancelled with it β no further charges. "] : []; |
| if (!sternSoleAdmin && !sternShell) { note.classList.add("hidden"); return; } |
| if (sternShell) { |
| const host = Object.assign(document.createElement("b"), { textContent: $("stern-host").textContent }); |
| const noUndo = Object.assign(document.createElement("b"), { textContent: "This cannot be undone." }); |
| note.replaceChildren( |
| host, " was never set up β it has no members and no way in, but it still ", |
| - "counts toward your space limit. Destroying it frees that slot. ", noUndo, |
| + "counts toward your space limit. Destroying it frees that slot. ", ...paidLine, noUndo, |
| ); |
| } else if (destroyOnly) { |
| const host = Object.assign(document.createElement("b"), { textContent: $("stern-host").textContent }); |
| @@ -3665,7 +3675,8 @@ function updateSternHold() { |
| "This is the only admin key for ", host, " β deleting just your access would ", |
| "leave the space running with no admin, forever, and still counting toward ", |
| "your space limit. You created it, so you can destroy the space for everyone ", |
| - "instead, which frees that slot: every message, photo, and member in it. ", noUndo, |
| + "instead, which frees that slot: every message, photo, and member in it. ", |
| + ...paidLine, noUndo, |
| ); |
| } else { |
| note.textContent = |