eleven / messenger Public

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>
Paul Campbell f066ead030e4d4f1403ded6990b2124379db2e70 parent 8ececd0
8 files changed, +266 −4
  • billing.go +36 −0
  • changelog/2026-07-24-destroy-cancels-subscription.md +4 −0
  • cloud.go +24 −0
  • cloud_billing_test.go +164 −0
  • cloud_db.go +14 −0
  • home/provision.go +8 −1
  • home/web/pages/index.html +3 −1
  • home/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 =