eleven / messenger Public

server: pay-before-create — checkout + claim endpoints (cloud + home) (#1329)

* server: seed a new space's display name through the create chain

The create flow will ask for the name up front (pay-gated create, next
slice), so the whole chain learns to carry it: home POST /api/spaces {name}
→ cloud → controld → add-instance → LCHAT_GROUP_NAME in the instance env →
the instance names itself at first boot (applySeededName, seed-only — an
already-named space ignores it, and the setup conversation already offers
'Keep …' for a pre-set name). Sanitized at the control seam (control chars
would inject env-file lines) and capped at 60 everywhere the instance caps
it. Operator parity: add-instance --group-name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* server: pay-before-create — checkout + claim endpoints (cloud + home)

The paid branch of the tier-picker create flow. Nothing is provisioned until
the money is real: checkout-create reserves a pending allocation (entitlement
'pending' — no quota, invisible to sweeps, pruned once its session can no
longer complete) and mints a Checkout Session bound to it; claim-create
verifies the session with Stripe and only then runs the normal provisioning
tail, carrying the chosen name (which never goes to Stripe — it waits on our
row). Idempotent claims, no rollback of paid rows, setup link parked in
memory for a lost-response retry. Home forwards both with the same gates as
the free create (member gate + proof of work on the mint).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a6ba1c7a611d57fa9b3a4e826215ffbaada9943b parent 5df3d4a
7 files changed, +898 −9
  • billing.go +292 −9
  • cloud.go +17 −0
  • cloud_billing_test.go +288 −0
  • cloud_db.go +90 −0
  • home/main.go +5 −0
  • home/provision.go +153 −0
  • home/provision_test.go +53 −0
diff --git a/billing.go b/billing.go
index 50af797..d3af66f 100644
--- a/billing.go
+++ b/billing.go
@@ -295,7 +295,7 @@ 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, tier, band, price, req.SuccessURL, req.CancelURL)
+ _, checkoutURL, err := s.billing.createCheckoutSession(id, tier, band, price, req.SuccessURL, req.CancelURL, false)
if err != nil {
log.Printf("cloud: checkout for %s (id %d, tier %s, band %s): %v", host, id, tier.Key, band, err)
writeControlErr(w, http.StatusBadGateway, "checkout_failed", "couldn't start the checkout — please try again")
@@ -304,6 +304,240 @@ func (s *cloudServer) checkoutHandler(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"url": checkoutURL})
}
+// ---- pay-before-create -----------------------------------------------------------
+//
+// The create flow's paid branch: nothing is provisioned until the money is
+// real. checkout-create reserves a pending allocation (entitlement 'pending' —
+// no quota, no sweeps, prunable) and mints a Checkout Session pointing at it;
+// claim-create verifies the session with Stripe and only then runs the normal
+// provisioning tail. The webhook needs no new arm: checkout.session.completed
+// marks the pending row paid by client_reference_id exactly as it does an
+// upgrade, and the push helpers already skip host-less rows. A paid row is
+// never rolled back on a provisioning failure — the claim just retries.
+
+// storeClaimLink parks a freshly-minted setup link under its session id;
+// takeClaimLink retrieves it without consuming (a flaky network may retry the
+// same claim more than once). Entries expire after an hour — past that the
+// admin link recovery path is the operator's, as with any lost setup link.
+func (s *cloudServer) storeClaimLink(session, link string) {
+ s.claimLinksMu.Lock()
+ defer s.claimLinksMu.Unlock()
+ if s.claimLinks == nil {
+ s.claimLinks = map[string]claimLink{}
+ }
+ for k, v := range s.claimLinks {
+ if time.Since(v.At) > time.Hour {
+ delete(s.claimLinks, k)
+ }
+ }
+ s.claimLinks[session] = claimLink{Link: link, At: time.Now()}
+}
+
+func (s *cloudServer) takeClaimLink(session string) string {
+ s.claimLinksMu.Lock()
+ defer s.claimLinksMu.Unlock()
+ v, ok := s.claimLinks[session]
+ if !ok || time.Since(v.At) > time.Hour {
+ return ""
+ }
+ return v.Link
+}
+
+// checkoutCreateHandler: POST /api/instances/checkout-create — start a paid
+// space's life as a Checkout Session bound to a pending allocation. Same
+// validation surface as the upgrade checkout plus the create-time extras
+// (name); the fleet create budget counts these, or session spam would
+// sidestep the breaker.
+func (s *cloudServer) checkoutCreateHandler(w http.ResponseWriter, r *http.Request) {
+ callerID := r.Context().Value(callerKey{}).(string)
+ if s.billing == nil {
+ writeControlErr(w, http.StatusNotImplemented, "billing_unavailable", "this provisioner does not offer paid spaces")
+ return
+ }
+ var req struct {
+ Requester string `json:"requester"`
+ Tier string `json:"tier"`
+ Band string `json:"band"`
+ Name string `json:"name"`
+ SuccessURL string `json:"success_url"`
+ CancelURL string `json:"cancel_url"`
+ }
+ if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&req); err != nil {
+ writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
+ return
+ }
+ req.Requester = strings.TrimSpace(req.Requester)
+ if req.Requester == "" || len(req.Requester) > 128 || !validRedirectURL(req.SuccessURL) || !validRedirectURL(req.CancelURL) {
+ writeControlErr(w, http.StatusBadRequest, "bad_request", "requester, success_url and cancel_url required")
+ return
+ }
+ req.Name = strings.TrimSpace(req.Name)
+ if len(req.Name) > 60 {
+ writeControlErr(w, http.StatusBadRequest, "bad_request", "name must be at most 60 characters")
+ return
+ }
+ tier, ok := s.billing.tier(req.Tier)
+ if !ok {
+ writeControlErr(w, http.StatusBadRequest, "bad_request", "unknown tier")
+ return
+ }
+ band := strings.ToLower(strings.TrimSpace(req.Band))
+ if band == "" {
+ band = bandBase
+ }
+ price, _, ok := bandPrice(tier, band)
+ if !ok {
+ writeControlErr(w, http.StatusBadRequest, "bad_request", "unknown band for this tier")
+ return
+ }
+ // The same trailing-24h breaker as the free path — a pending allocation IS
+ // a create for budget purposes.
+ if s.createBudget > 0 {
+ if n, err := s.db.createsSince(time.Now().Add(-24 * time.Hour).Unix()); err == nil && n >= s.createBudget {
+ log.Printf("cloud: creation budget exhausted (%d/%d in 24h) — refusing paid create", n, s.createBudget)
+ writeControlErr(w, http.StatusServiceUnavailable, "at_capacity", spaceUnavailableMsg)
+ return
+ }
+ }
+ // Housekeeping on the way in: allocations whose checkout expired long ago
+ // (sessions die at 24h; 48h leaves margin) go quietly.
+ if n, err := s.db.prunePendingCreates(time.Now().Add(-48 * time.Hour).Unix()); err == nil && n > 0 {
+ log.Printf("cloud: pruned %d expired pending create(s)", n)
+ }
+ id, err := s.db.allocatePendingPaidSpace(callerID, req.Requester, req.Name)
+ if err != nil {
+ writeControlErr(w, http.StatusInternalServerError, "checkout_failed", "db error")
+ return
+ }
+ session, checkoutURL, err := s.billing.createCheckoutSession(id, tier, band, price, req.SuccessURL, req.CancelURL, true)
+ if err != nil {
+ s.db.deleteSpaceByID(id) // nothing bought yet — roll the allocation back
+ log.Printf("cloud: checkout-create (id %d, tier %s, band %s): %v", id, tier.Key, band, err)
+ writeControlErr(w, http.StatusBadGateway, "checkout_failed", "couldn't start the checkout — please try again")
+ return
+ }
+ if err := s.db.setCheckoutSession(id, session); err != nil {
+ log.Printf("cloud: record session for id %d: %v", id, err) // audit trail only — the claim re-derives everything from Stripe
+ }
+ log.Printf("cloud: checkout-create session for id %d (tier %s, band %s, caller %.8s…)", id, tier.Key, band, callerID)
+ writeJSON(w, http.StatusOK, map[string]any{"url": checkoutURL})
+}
+
+// claimCreateHandler: POST /api/instances/claim-create — turn a completed
+// pay-first checkout into a live space. Everything trusted comes from Stripe
+// (the caller only names a session); ownership is the pending row's
+// (caller, requester) pair. Idempotent: a provisioned row answers 200 with its
+// host (and the parked setup link when the first response was lost); a
+// provisioning failure keeps the paid row so a retry finishes the job — money
+// is never rolled back.
+func (s *cloudServer) claimCreateHandler(w http.ResponseWriter, r *http.Request) {
+ callerID := r.Context().Value(callerKey{}).(string)
+ if s.billing == nil {
+ writeControlErr(w, http.StatusNotImplemented, "billing_unavailable", "this provisioner does not offer paid spaces")
+ return
+ }
+ var req struct {
+ Requester string `json:"requester"`
+ SessionID string `json:"session_id"`
+ }
+ if err := json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req); err != nil {
+ writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
+ return
+ }
+ req.Requester = strings.TrimSpace(req.Requester)
+ req.SessionID = strings.TrimSpace(req.SessionID)
+ if req.Requester == "" || req.SessionID == "" {
+ writeControlErr(w, http.StatusBadRequest, "bad_request", "requester and session_id required")
+ return
+ }
+ sess, err := s.billing.retrieveCheckoutSession(req.SessionID)
+ if err != nil {
+ log.Printf("cloud: claim-create retrieve %s: %v", req.SessionID, err)
+ writeControlErr(w, http.StatusBadGateway, "claim_failed", "couldn't confirm the payment — please try again")
+ return
+ }
+ // Only a session WE minted for a pending create may provision: the marker
+ // keeps an upgrade session (same shape, real payment) from ever creating a
+ // second space.
+ id, perr := strconv.ParseInt(sess.ClientReferenceID, 10, 64)
+ if perr != nil || sess.Metadata["pending_create"] != "1" {
+ writeControlErr(w, http.StatusNotFound, "not_found", "no pending space for this checkout")
+ return
+ }
+ row, ok, err := s.db.pendingSpaceByID(id)
+ if err != nil {
+ writeControlErr(w, http.StatusInternalServerError, "claim_failed", "db error")
+ return
+ }
+ if !ok || row.CallerID != callerID || row.Requester != req.Requester {
+ writeControlErr(w, http.StatusNotFound, "not_found", "no pending space for this checkout")
+ return
+ }
+ if row.Host != "" {
+ // Already provisioned (an earlier claim finished) — answer with what
+ // exists. The parked setup link rides along when we still hold it.
+ out := map[string]any{"host": row.Host, "url": "https://" + row.Host}
+ if link := s.takeClaimLink(req.SessionID); link != "" {
+ out["setup_link"] = link
+ }
+ writeJSON(w, http.StatusOK, out)
+ return
+ }
+ if sess.PaymentStatus != "paid" {
+ writeControlErr(w, http.StatusPaymentRequired, "not_paid", "the checkout hasn't completed — finish paying first")
+ return
+ }
+ // Record what the checkout bought. The webhook usually got here first
+ // (markSpacePaid is idempotent either way); doing it ourselves means the
+ // claim never waits on webhook delivery.
+ if err := s.db.markSpacePaid(id, sess.Customer, sess.Subscription, sess.Metadata["tier"], sess.Metadata["band"]); err != nil && err != sql.ErrNoRows {
+ writeControlErr(w, http.StatusInternalServerError, "claim_failed", "db error")
+ return
+ }
+ // The normal provisioning tail — but no rollback on failure: the payment
+ // is real, so the row (entitlement paid, host NULL) survives every error
+ // below and the next claim retries the whole tail.
+ hostID, err := s.spaceHostID(r.Context(), id)
+ if err != nil {
+ log.Printf("cloud: claim-create id ledger for %d: %v", id, err)
+ writeControlErr(w, http.StatusServiceUnavailable, "claim_failed", claimRetryMsg)
+ return
+ }
+ slug, err := spaceSlug(hostID)
+ if err != nil {
+ log.Printf("cloud: claim-create slug for %d: %v", id, err)
+ writeControlErr(w, http.StatusInternalServerError, "claim_failed", claimRetryMsg)
+ return
+ }
+ host := slug + "." + s.baseDomain
+ status, body, err := s.controlCreate(host, entPaid, row.PendingName)
+ if err != nil || status != http.StatusCreated {
+ log.Printf("cloud: claim-create control for %s (id %d): status %d, %v", host, id, status, err)
+ writeControlErr(w, http.StatusBadGateway, "claim_failed", claimRetryMsg)
+ return
+ }
+ if err := s.db.finalizeSpace(id, host); err != nil {
+ log.Printf("cloud: claim-create finalize %s (id %d): %v", host, id, err)
+ }
+ log.Printf("cloud: claim-create provisioned %s (id %d, caller %.8s…)", host, id, callerID)
+ s.pushQuota(id, host, entPaid, sess.Metadata["tier"])
+ if s.usageURL != "" {
+ s.pushMeterToken(id, host)
+ }
+ if link, _ := body["setup_link"].(string); link != "" {
+ s.storeClaimLink(req.SessionID, link)
+ }
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "host": host,
+ "url": body["url"],
+ "setup_link": body["setup_link"],
+ })
+}
+
+// claimRetryMsg is the one voice for every retriable claim failure: the money
+// is safe, the space is coming, nothing needs undoing.
+const claimRetryMsg = "your payment is confirmed but the space couldn't be set up just now — try again in a moment"
+
// portalHandler: POST /api/instances/{host}/portal — mint a Stripe Billing
// Portal session for one UPGRADED space, so its payer can cancel, change
// card, or read invoices on Stripe's hosted page. Same gates as checkout:
@@ -445,8 +679,11 @@ func validRedirectURL(raw string) bool {
// ledger id rides in client_reference_id (routes checkout.session.completed)
// and in the subscription's metadata (routes every later subscription event);
// the tier + band keys ride in the session metadata so completion records what
-// was bought. price is the band-resolved Stripe price id.
-func (b *stripeBilling) createCheckoutSession(spaceID int64, tier billingTier, band, price, successURL, cancelURL string) (string, error) {
+// was bought. price is the band-resolved Stripe price id. pendingCreate marks
+// a pay-first session (the row is an allocation, not a space yet) — the claim
+// path demands the marker so an upgrade session can never provision anything.
+// Returns the session's id and hosted-page URL.
+func (b *stripeBilling) createCheckoutSession(spaceID int64, tier billingTier, band, price, successURL, cancelURL string, pendingCreate bool) (sessionID, sessionURL string, err error) {
id := strconv.FormatInt(spaceID, 10)
form := url.Values{}
form.Set("mode", "subscription")
@@ -455,6 +692,9 @@ func (b *stripeBilling) createCheckoutSession(spaceID int64, tier billingTier, b
form.Set("client_reference_id", id)
form.Set("metadata[tier]", tier.Key)
form.Set("metadata[band]", band)
+ if pendingCreate {
+ form.Set("metadata[pending_create]", "1")
+ }
form.Set("subscription_data[metadata][space_id]", id)
form.Set("subscription_data[metadata][tier]", tier.Key)
form.Set("subscription_data[metadata][band]", band)
@@ -477,31 +717,74 @@ func (b *stripeBilling) createCheckoutSession(spaceID int64, tier billingTier, b
req, err := http.NewRequest("POST", b.apiBase+"/v1/checkout/sessions", strings.NewReader(form.Encode()))
if err != nil {
- return "", err
+ return "", "", err
}
req.Header.Set("Authorization", "Bearer "+b.key)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := b.client.Do(req)
if err != nil {
- return "", err
+ return "", "", err
}
defer resp.Body.Close()
var body struct {
+ ID string `json:"id"`
URL string `json:"url"`
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
- return "", fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode)
+ return "", "", fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode)
}
if resp.StatusCode != http.StatusOK {
- return "", fmt.Errorf("stripe: HTTP %d: %s", resp.StatusCode, body.Error.Message)
+ return "", "", fmt.Errorf("stripe: HTTP %d: %s", resp.StatusCode, body.Error.Message)
}
if body.URL == "" {
- return "", fmt.Errorf("stripe: session created but no url in response")
+ return "", "", fmt.Errorf("stripe: session created but no url in response")
}
- return body.URL, nil
+ return body.ID, body.URL, nil
+}
+
+// stripeCheckoutSession is the slice of a retrieved Checkout Session the claim
+// path needs: is it paid, which allocation is it for, and what did it buy.
+type stripeCheckoutSession struct {
+ ID string `json:"id"`
+ Mode string `json:"mode"`
+ PaymentStatus string `json:"payment_status"`
+ ClientReferenceID string `json:"client_reference_id"`
+ Customer string `json:"customer"`
+ Subscription string `json:"subscription"`
+ Metadata map[string]string `json:"metadata"`
+}
+
+// retrieveCheckoutSession reads one session back from Stripe — the claim's
+// authority. The caller only ever supplies the session ID; everything trusted
+// (payment state, which row, what was bought) comes signed by this fetch.
+func (b *stripeBilling) retrieveCheckoutSession(sessionID string) (stripeCheckoutSession, error) {
+ var sess stripeCheckoutSession
+ req, err := http.NewRequest("GET", b.apiBase+"/v1/checkout/sessions/"+url.PathEscape(sessionID), nil)
+ if err != nil {
+ return sess, err
+ }
+ req.Header.Set("Authorization", "Bearer "+b.key)
+ resp, err := b.client.Do(req)
+ if err != nil {
+ return sess, err
+ }
+ defer resp.Body.Close()
+ var body struct {
+ stripeCheckoutSession
+ Error struct {
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+ if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
+ return sess, fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode)
+ }
+ if resp.StatusCode != http.StatusOK || body.ID == "" {
+ return sess, fmt.Errorf("stripe: HTTP %d: %s", resp.StatusCode, body.Error.Message)
+ }
+ return body.stripeCheckoutSession, nil
}
// ---- webhook -------------------------------------------------------------------
diff --git a/cloud.go b/cloud.go
index 70382aa..f90e177 100644
--- a/cloud.go
+++ b/cloud.go
@@ -27,6 +27,7 @@ import (
sqids "github.com/sqids/sqids-go"
"lchat/internal/idalloc"
+ "sync"
)
// spaceUnavailableMsg is the member-facing "we're full" message, relayed
@@ -124,6 +125,18 @@ type cloudServer struct {
aiRates map[string]aiRates
aiAllowanceEUR float64 // LCHAT_CLOUD_AI_ALLOWANCE_EUR
aiPoolEUR float64 // LCHAT_CLOUD_AI_POOL_EUR
+ // claimLinks parks a pay-first create's one-time setup link between the
+ // provision and the redirect landing (billing.go claimCreateHandler) so a
+ // claim retry after a dropped response can still hand it over. Memory only,
+ // short-lived, single process — the link is never persisted anywhere.
+ claimLinksMu sync.Mutex
+ claimLinks map[string]claimLink
+}
+
+// claimLink is one parked setup link (claimLinks); At bounds its life.
+type claimLink struct {
+ Link string
+ At time.Time
}
func (s *cloudServer) handler() http.Handler {
@@ -135,6 +148,10 @@ func (s *cloudServer) handler() http.Handler {
// Billing (optional — 501 unless Stripe is configured; see billing.go). The
// webhook is signature-gated rather than token-gated: Stripe calls it.
mux.HandleFunc("POST /api/instances/{host}/checkout", s.callerAuth(s.checkoutHandler))
+ // Pay-before-create (billing.go): mint a checkout for a space that doesn't
+ // exist yet, then provision it once the payment confirms.
+ mux.HandleFunc("POST /api/instances/checkout-create", s.callerAuth(s.checkoutCreateHandler))
+ mux.HandleFunc("POST /api/instances/claim-create", s.callerAuth(s.claimCreateHandler))
// Claiming a demo (demo.go) — additive, so caller-token gated like creation.
mux.HandleFunc("POST /api/instances/{host}/claim", s.callerAuth(s.claimDemoHandler))
// The countdown banner's live "still a demo?" check (demo.go) — read-only,
diff --git a/cloud_billing_test.go b/cloud_billing_test.go
index 9643a93..d7f6cdc 100644
--- a/cloud_billing_test.go
+++ b/cloud_billing_test.go
@@ -556,6 +556,294 @@ func TestPortalSession(t *testing.T) {
}
}
+// ---- pay-before-create -----------------------------------------------------------
+
+// payFirstRig: a cloud with billing pointed at a Stripe stub that mints
+// checkout sessions (capturing the form) and serves one retrievable session
+// (mutable — tests shape it per step), plus a control that records the last
+// create and can be told to fail n times.
+func payFirstRig(t *testing.T) (srv *httptest.Server, s *cloudServer, form *url.Values, sess *map[string]any, lastCreate *controlCreateReq, ctlFails *int) {
+ t.Helper()
+ form = &url.Values{}
+ sess = &map[string]any{}
+ stripe := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == "POST" && r.URL.Path == "/v1/checkout/sessions":
+ r.ParseForm()
+ *form = r.PostForm
+ writeJSON(w, http.StatusOK, map[string]any{
+ "id": "cs_pf_1", "url": "https://checkout.stripe.com/c/pay/cs_pf_1",
+ })
+ case r.Method == "GET" && strings.HasPrefix(r.URL.Path, "/v1/checkout/sessions/"):
+ writeJSON(w, http.StatusOK, *sess)
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+ t.Cleanup(stripe.Close)
+ lastCreate = &controlCreateReq{}
+ ctlFails = new(int)
+ ctl := fakeControl(t, func(w http.ResponseWriter, r *http.Request) {
+ var req controlCreateReq
+ json.NewDecoder(r.Body).Decode(&req)
+ *lastCreate = req
+ if *ctlFails > 0 {
+ *ctlFails--
+ writeControlErr(w, http.StatusInternalServerError, "create_failed", "boom")
+ return
+ }
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "host": req.Host, "url": "https://" + req.Host, "setup_link": "https://" + req.Host + "/join/setup#PF",
+ })
+ })
+ srv, s = newTestCloud(t, ctl)
+ s.billing = &stripeBilling{
+ apiBase: stripe.URL, key: "sk_test", webhookSecret: "whsec_test",
+ tiers: []billingTier{{Key: "family", Name: "Family", Label: "€5.50/month", Price: "price_family_m"}},
+ client: http.DefaultClient,
+ }
+ return
+}
+
+// postCloud posts a JSON body to a caller-token-gated cloud endpoint.
+func postCloud(t *testing.T, srv *httptest.Server, tok, path string, body map[string]string) *http.Response {
+ t.Helper()
+ payload, _ := json.Marshal(body)
+ req, _ := http.NewRequest("POST", srv.URL+path, bytes.NewReader(payload))
+ req.Header.Set("Authorization", "Bearer "+tok)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { resp.Body.Close() })
+ return resp
+}
+
+// paidSessionFor shapes the stub's retrievable session as a completed pay-first
+// checkout for ledger row id.
+func paidSessionFor(id int64) map[string]any {
+ return map[string]any{
+ "id": "cs_pf_1", "mode": "subscription", "payment_status": "paid",
+ "client_reference_id": fmt.Sprintf("%d", id),
+ "customer": "cus_pf", "subscription": "sub_pf",
+ "metadata": map[string]string{"pending_create": "1", "tier": "family", "band": "base"},
+ }
+}
+
+func TestCheckoutCreateMintsPendingSession(t *testing.T) {
+ srv, s, form, _, _, _ := payFirstRig(t)
+ tok := mintCaller(t, srv, "home")
+
+ r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{
+ "requester": "user-1", "tier": "family", "name": " Book Club ",
+ "success_url": "https://home.example.com/?paidcreate={CHECKOUT_SESSION_ID}",
+ "cancel_url": "https://home.example.com/",
+ })
+ if r.StatusCode != http.StatusOK {
+ t.Fatalf("checkout-create: %d, want 200", r.StatusCode)
+ }
+ if u, _ := decodeBody(t, r)["url"].(string); !strings.Contains(u, "checkout.stripe.com") {
+ t.Errorf("url = %q", u)
+ }
+ f := *form
+ if f.Get("metadata[pending_create]") != "1" || f.Get("client_reference_id") != "1" || f.Get("line_items[0][price]") != "price_family_m" {
+ t.Errorf("session form = %v", f)
+ }
+ // No name in Stripe's metadata — it stays in OUR ledger, on the pending row.
+ for k, vs := range f {
+ for _, v := range vs {
+ if strings.Contains(v, "Book Club") {
+ t.Errorf("space name leaked to Stripe via %s=%q", k, v)
+ }
+ }
+ }
+ row, ok, err := s.db.pendingSpaceByID(1)
+ if err != nil || !ok {
+ t.Fatalf("pending row: %v %v", ok, err)
+ }
+ if row.Entitlement != entPending || row.PendingName != "Book Club" || row.Host != "" {
+ t.Errorf("row = %+v, want a pending, host-less allocation named Book Club", row)
+ }
+
+ // Validation: an oversized name and an unknown tier never reach Stripe.
+ if r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{
+ "requester": "user-1", "tier": "family", "name": strings.Repeat("x", 61),
+ "success_url": "https://h/", "cancel_url": "https://h/",
+ }); r.StatusCode != http.StatusBadRequest {
+ t.Errorf("61-char name: %d, want 400", r.StatusCode)
+ }
+ if r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{
+ "requester": "user-1", "tier": "platinum",
+ "success_url": "https://h/", "cancel_url": "https://h/",
+ }); r.StatusCode != http.StatusBadRequest {
+ t.Errorf("unknown tier: %d, want 400", r.StatusCode)
+ }
+}
+
+func TestClaimCreateProvisions(t *testing.T) {
+ srv, s, _, sess, lastCreate, _ := payFirstRig(t)
+ tok := mintCaller(t, srv, "home")
+ if r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{
+ "requester": "user-1", "tier": "family", "name": "Book Club",
+ "success_url": "https://h/?paidcreate={CHECKOUT_SESSION_ID}", "cancel_url": "https://h/",
+ }); r.StatusCode != http.StatusOK {
+ t.Fatalf("checkout-create: %d", r.StatusCode)
+ }
+ *sess = paidSessionFor(1)
+
+ r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{
+ "requester": "user-1", "session_id": "cs_pf_1",
+ })
+ if r.StatusCode != http.StatusCreated {
+ t.Fatalf("claim: %d, want 201", r.StatusCode)
+ }
+ body := decodeBody(t, r)
+ host, _ := body["host"].(string)
+ if host == "" || !strings.HasSuffix(host, ".example.com") {
+ t.Fatalf("host = %q", host)
+ }
+ if link, _ := body["setup_link"].(string); !strings.HasPrefix(link, "https://"+host+"/join/setup#") {
+ t.Errorf("setup_link = %q", link)
+ }
+ // The chosen name rode into the create; the row is a finalized paid space.
+ if lastCreate.Name != "Book Club" || lastCreate.Entitlement != entPaid {
+ t.Errorf("control saw %+v, want name Book Club, entitlement paid", *lastCreate)
+ }
+ row, _, _ := s.db.pendingSpaceByID(1)
+ if row.Host != host || row.Entitlement != entPaid {
+ t.Errorf("row after claim = %+v", row)
+ }
+
+ // Idempotent: a second claim answers 200 with the host and the parked link.
+ r2 := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{
+ "requester": "user-1", "session_id": "cs_pf_1",
+ })
+ if r2.StatusCode != http.StatusOK {
+ t.Fatalf("re-claim: %d, want 200", r2.StatusCode)
+ }
+ b2 := decodeBody(t, r2)
+ if b2["host"] != host || b2["setup_link"] == nil {
+ t.Errorf("re-claim body = %v, want host + parked setup_link", b2)
+ }
+}
+
+func TestClaimCreateGates(t *testing.T) {
+ srv, _, _, sess, _, _ := payFirstRig(t)
+ tok := mintCaller(t, srv, "home")
+ if r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{
+ "requester": "user-1", "tier": "family",
+ "success_url": "https://h/", "cancel_url": "https://h/",
+ }); r.StatusCode != http.StatusOK {
+ t.Fatalf("checkout-create: %d", r.StatusCode)
+ }
+
+ // Unpaid session → 402, nothing provisioned.
+ unpaid := paidSessionFor(1)
+ unpaid["payment_status"] = "unpaid"
+ *sess = unpaid
+ if r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{
+ "requester": "user-1", "session_id": "cs_pf_1",
+ }); r.StatusCode != http.StatusPaymentRequired {
+ t.Errorf("unpaid claim: %d, want 402", r.StatusCode)
+ }
+
+ // Someone else's requester → 404 (ownership is the pending row's pair).
+ *sess = paidSessionFor(1)
+ if r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{
+ "requester": "someone-else", "session_id": "cs_pf_1",
+ }); r.StatusCode != http.StatusNotFound {
+ t.Errorf("wrong requester: %d, want 404", r.StatusCode)
+ }
+
+ // A session without the pending_create marker (an upgrade checkout) can
+ // never provision — even fully paid.
+ upgrade := paidSessionFor(1)
+ upgrade["metadata"] = map[string]string{"tier": "family", "band": "base"}
+ *sess = upgrade
+ if r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{
+ "requester": "user-1", "session_id": "cs_pf_1",
+ }); r.StatusCode != http.StatusNotFound {
+ t.Errorf("upgrade-session claim: %d, want 404", r.StatusCode)
+ }
+}
+
+func TestClaimCreateRetriesAfterControlFailure(t *testing.T) {
+ srv, s, _, sess, _, ctlFails := payFirstRig(t)
+ tok := mintCaller(t, srv, "home")
+ if r := postCloud(t, srv, tok, "/api/instances/checkout-create", map[string]string{
+ "requester": "user-1", "tier": "family", "name": "Book Club",
+ "success_url": "https://h/", "cancel_url": "https://h/",
+ }); r.StatusCode != http.StatusOK {
+ t.Fatalf("checkout-create: %d", r.StatusCode)
+ }
+ *sess = paidSessionFor(1)
+
+ // First claim hits a control failure: 502, but the PAID row survives —
+ // money is never rolled back.
+ *ctlFails = 1
+ if r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{
+ "requester": "user-1", "session_id": "cs_pf_1",
+ }); r.StatusCode != http.StatusBadGateway {
+ t.Fatalf("claim with failing control: %d, want 502", r.StatusCode)
+ }
+ row, ok, _ := s.db.pendingSpaceByID(1)
+ if !ok || row.Entitlement != entPaid || row.Host != "" {
+ t.Fatalf("row after failed claim = %+v (ok=%v), want kept, paid, host-less", row, ok)
+ }
+
+ // The retry finishes the job.
+ if r := postCloud(t, srv, tok, "/api/instances/claim-create", map[string]string{
+ "requester": "user-1", "session_id": "cs_pf_1",
+ }); r.StatusCode != http.StatusCreated {
+ t.Errorf("retry claim: %d, want 201", r.StatusCode)
+ }
+}
+
+func TestPrunePendingCreates(t *testing.T) {
+ _, s, _, _, _, _ := payFirstRig(t)
+ old := time.Now().Add(-72 * time.Hour).Unix()
+
+ stale, err := s.db.allocatePendingPaidSpace("c1", "user-1", "Stale")
+ if err != nil {
+ t.Fatal(err)
+ }
+ paidStuck, err := s.db.allocatePendingPaidSpace("c1", "user-1", "Paid but unclaimed")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.db.markSpacePaid(paidStuck, "cus_1", "sub_1", "family", ""); err != nil {
+ t.Fatal(err)
+ }
+ fresh, err := s.db.allocatePendingPaidSpace("c1", "user-1", "Fresh")
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Backdate the first two past the cutoff.
+ for _, id := range []int64{stale, paidStuck} {
+ if _, err := s.db.sql.Exec(`UPDATE spaces SET created_at = ? WHERE id = ?`, old, id); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ n, err := s.db.prunePendingCreates(time.Now().Add(-48 * time.Hour).Unix())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n != 1 {
+ t.Errorf("pruned %d, want 1 (only the stale pending row)", n)
+ }
+ if _, ok, _ := s.db.pendingSpaceByID(stale); ok {
+ t.Error("stale pending row survived the prune")
+ }
+ // A paid-but-unclaimed row is money — never pruned; a fresh pending stays.
+ if _, ok, _ := s.db.pendingSpaceByID(paidStuck); !ok {
+ t.Error("paid row was pruned")
+ }
+ if _, ok, _ := s.db.pendingSpaceByID(fresh); !ok {
+ t.Error("fresh pending row was pruned")
+ }
+}
+
// ---- destroy cancels the subscription ------------------------------------------
// destroyRig builds a cloud whose control answers create + delete and whose
diff --git a/cloud_db.go b/cloud_db.go
index f07f831..a86faa5 100644
--- a/cloud_db.go
+++ b/cloud_db.go
@@ -327,6 +327,18 @@ ALTER TABLE spaces ADD COLUMN paid_until INTEGER NOT NULL DEFAULT 0;`); err != n
return err
}
}
+ // Pay-before-create (the tier picker in the create flow): the Checkout
+ // Session bound to a pending allocation, and the name chosen at create —
+ // held here (never in Stripe metadata) until the claim provisions it.
+ if has, err := db.spacesHasColumn("checkout_session_id"); err != nil {
+ return err
+ } else if !has {
+ if _, err := db.sql.Exec(`
+ALTER TABLE spaces ADD COLUMN checkout_session_id TEXT;
+ALTER TABLE spaces ADD COLUMN pending_name TEXT NOT NULL DEFAULT ''`); err != nil {
+ return err
+ }
+ }
return nil
}
@@ -503,6 +515,79 @@ func (db *cloudDB) allocateDemoSpace(callerID, requester string) (int64, error)
return id, tx.Commit()
}
+// allocatePendingPaidSpace reserves a pay-first row (the tier picker's paid
+// branch): entitlement 'pending' keeps it outside the free quota and every
+// sweep, and the chosen display name waits here for the claim. It still feeds
+// creates_log — minting checkouts is a create for the budget breaker's
+// purposes, or session spam would sidestep it.
+func (db *cloudDB) allocatePendingPaidSpace(callerID, requester, name string) (int64, error) {
+ tx, err := db.sql.Begin()
+ if err != nil {
+ return 0, err
+ }
+ defer tx.Rollback()
+ now := time.Now().Unix()
+ res, err := tx.Exec(
+ `INSERT INTO spaces(host, caller_id, requester, created_at, entitlement, pending_name) VALUES(NULL, ?, ?, ?, ?, ?)`,
+ callerID, requester, now, entPending, name,
+ )
+ if err != nil {
+ return 0, err
+ }
+ if _, err := tx.Exec(`INSERT INTO creates_log(created_at) VALUES(?)`, now); err != nil {
+ return 0, err
+ }
+ id, err := res.LastInsertId()
+ if err != nil {
+ return 0, err
+ }
+ return id, tx.Commit()
+}
+
+// setCheckoutSession binds the Stripe Checkout Session to its pending row —
+// audit and debugging; the claim's authority is the session Stripe returns.
+func (db *cloudDB) setCheckoutSession(id int64, session string) error {
+ _, err := db.sql.Exec(`UPDATE spaces SET checkout_session_id = ? WHERE id = ?`, session, id)
+ return err
+}
+
+// pendingSpace reads the claim path's view of one allocation: who it belongs
+// to, whether it's provisioned yet, and the name chosen at create.
+type pendingSpace struct {
+ ID int64
+ CallerID string
+ Requester string
+ Host string // "" until provisioned
+ Entitlement string
+ PendingName string
+}
+
+func (db *cloudDB) pendingSpaceByID(id int64) (pendingSpace, bool, error) {
+ p := pendingSpace{ID: id}
+ err := db.sql.QueryRow(
+ `SELECT caller_id, requester, COALESCE(host, ''), entitlement, pending_name FROM spaces WHERE id = ?`, id,
+ ).Scan(&p.CallerID, &p.Requester, &p.Host, &p.Entitlement, &p.PendingName)
+ if err == sql.ErrNoRows {
+ return pendingSpace{}, false, nil
+ }
+ if err != nil {
+ return pendingSpace{}, false, err
+ }
+ return p, true, nil
+}
+
+// prunePendingCreates drops pending allocations whose checkout can no longer
+// complete (Stripe sessions expire after 24h; the cutoff arrives well past
+// that). A PAID row is never pending, so money is never pruned.
+func (db *cloudDB) prunePendingCreates(cutoff int64) (int64, error) {
+ res, err := db.sql.Exec(
+ `DELETE FROM spaces WHERE entitlement = ? AND host IS NULL AND created_at <= ?`, entPending, cutoff)
+ if err != nil {
+ return 0, err
+ }
+ return res.RowsAffected()
+}
+
// countDemos counts every live demo row, pending creates included — the
// live-demo cap must see a create the moment it's reserved, not once it's up.
func (db *cloudDB) countDemos() (int, error) {
@@ -683,6 +768,11 @@ const (
entFree = "free"
entPaid = "paid"
entDemo = "demo" // 24h try-it spaces (docs/demo-spaces.md, demo.go)
+ // entPending marks a pay-first allocation awaiting its checkout: not a
+ // space yet (host NULL, never listed, never quota-counted, invisible to
+ // the reaper's free-only sweep) — it either becomes paid at claim time or
+ // is pruned once its checkout can no longer complete.
+ entPending = "pending"
)
// spaceEntitlement reads one space's entitlement flag.
diff --git a/home/main.go b/home/main.go
index 2104055..6f90a9c 100644
--- a/home/main.go
+++ b/home/main.go
@@ -468,6 +468,11 @@ func (s *Server) handler() http.Handler {
// server doesn't offer it (public — it reveals only whether the feature is on).
mux.HandleFunc("GET /api/features", s.featuresHandler)
mux.HandleFunc("POST /api/spaces", s.rateLimited(s.createSpaceHandler))
+ // Pay-before-create (provision.go): mint the Checkout for a paid space,
+ // then claim it into existence once Stripe confirms. Both rate-limited —
+ // one mints sessions, the other dials Stripe + can provision.
+ mux.HandleFunc("POST /api/spaces/checkout-create", s.rateLimited(s.checkoutCreateHandler))
+ mux.HandleFunc("POST /api/spaces/claim-create", s.rateLimited(s.claimCreateHandler))
// The proof-of-work challenge behind creation (cheap to mint; rate-limited
// with the same budget as the creates it gates).
mux.HandleFunc("POST /api/spaces/challenge", s.rateLimited(s.challengeHandler))
diff --git a/home/provision.go b/home/provision.go
index 27ab55c..558dbda 100644
--- a/home/provision.go
+++ b/home/provision.go
@@ -406,6 +406,159 @@ func (s *Server) deleteSpaceHandler(w http.ResponseWriter, r *http.Request) {
}
}
+// checkoutCreateHandler: POST /api/spaces/checkout-create {name, tier, band,
+// region, challenge, nonce} — start a PAID space: the provisioner mints a
+// Stripe Checkout link bound to a pending allocation, and the space is only
+// provisioned once the payment confirms (claimCreateHandler). Same gates as
+// the free create — link token, the member gate, and the proof of work: a
+// checkout mint is priced like a create, not like a read.
+func (s *Server) checkoutCreateHandler(w http.ResponseWriter, r *http.Request) {
+ uid, ok := s.linkUser(r)
+ if !ok {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ if s.cloud == nil {
+ writeJSON(w, http.StatusNotImplemented, map[string]any{
+ "error": "unavailable", "message": "this home server does not offer space creation",
+ })
+ return
+ }
+ if _, _, hasBlob, err := s.store.getBlob(uid); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]any{
+ "error": "create_failed", "message": "could not check your account",
+ })
+ return
+ } else if !hasBlob {
+ writeJSON(w, http.StatusForbidden, map[string]any{
+ "error": "not_eligible",
+ "message": "You need at least one Eleven space before you can create a new one.",
+ })
+ return
+ }
+ var req struct {
+ Region string `json:"region"`
+ Name string `json:"name"`
+ Tier string `json:"tier"`
+ Band string `json:"band"`
+ Challenge string `json:"challenge"`
+ Nonce string `json:"nonce"`
+ }
+ _ = json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req)
+ if s.pow != nil {
+ if req.Challenge == "" {
+ writeJSON(w, http.StatusForbidden, map[string]any{
+ "error": "pow_required", "message": "This home server asks for a little proof of work first — try again.",
+ })
+ return
+ }
+ if err := s.pow.verify(uid, req.Challenge, req.Nonce); err != nil {
+ writeJSON(w, http.StatusForbidden, map[string]any{
+ "error": "pow_failed", "message": "The proof of work didn't check out — try again.",
+ })
+ return
+ }
+ }
+ if strings.TrimSpace(req.Tier) == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]any{
+ "error": "bad_request", "message": "tier required",
+ })
+ return
+ }
+ name := strings.TrimSpace(req.Name)
+ if len(name) > 60 {
+ writeJSON(w, http.StatusBadRequest, map[string]any{
+ "error": "bad_request", "message": "The space name can be at most 60 characters.",
+ })
+ return
+ }
+ // Stripe substitutes {CHECKOUT_SESSION_ID} into the redirect, which is how
+ // the landing page knows what to claim; the region rides along so the claim
+ // dials the same cloud that minted the session.
+ successURL := s.origin + "/?paidcreate={CHECKOUT_SESSION_ID}"
+ if region := strings.TrimSpace(req.Region); region != "" {
+ successURL += "&pcregion=" + url.QueryEscape(region)
+ }
+ payload, _ := json.Marshal(map[string]string{
+ "requester": uid,
+ "tier": req.Tier,
+ "band": req.Band,
+ "name": name,
+ "success_url": successURL,
+ "cancel_url": s.origin + "/",
+ })
+ status, body, err := s.cloudForRegion(req.Region).do("POST", "/api/instances/checkout-create", payload)
+ if err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]any{
+ "error": "checkout_failed", "message": "could not reach the space provisioner",
+ })
+ return
+ }
+ switch status {
+ case http.StatusOK, http.StatusBadRequest, http.StatusNotImplemented, http.StatusServiceUnavailable:
+ writeJSON(w, status, body)
+ default:
+ writeJSON(w, http.StatusBadGateway, map[string]any{
+ "error": "checkout_failed", "message": "couldn't start the checkout — please try again",
+ })
+ }
+}
+
+// claimCreateHandler: POST /api/spaces/claim-create {session_id, region} —
+// finish a pay-first create after the Stripe redirect (or any later visit:
+// the claim is idempotent downstream). Link-token auth: the session id is
+// worthless to anyone but the pending row's owner, which the provisioner
+// re-checks against its ledger.
+func (s *Server) claimCreateHandler(w http.ResponseWriter, r *http.Request) {
+ uid, ok := s.linkUser(r)
+ if !ok {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ if s.cloud == nil {
+ writeJSON(w, http.StatusNotImplemented, map[string]any{
+ "error": "unavailable", "message": "this home server does not offer space creation",
+ })
+ return
+ }
+ var req struct {
+ Region string `json:"region"`
+ SessionID string `json:"session_id"`
+ }
+ _ = json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req)
+ if strings.TrimSpace(req.SessionID) == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]any{
+ "error": "bad_request", "message": "session_id required",
+ })
+ return
+ }
+ payload, _ := json.Marshal(map[string]string{
+ "requester": uid,
+ "session_id": strings.TrimSpace(req.SessionID),
+ })
+ status, body, err := s.cloudForRegion(req.Region).do("POST", "/api/instances/claim-create", payload)
+ if err != nil {
+ writeJSON(w, http.StatusBadGateway, map[string]any{
+ "error": "claim_failed", "message": "could not reach the space provisioner",
+ })
+ return
+ }
+ switch status {
+ case http.StatusOK, http.StatusCreated, http.StatusPaymentRequired, http.StatusNotFound, http.StatusNotImplemented:
+ writeJSON(w, status, body)
+ default:
+ // Keep the provisioner's message when it has one — "payment confirmed,
+ // try again in a moment" must reach the person waiting.
+ msg, _ := body["message"].(string)
+ if msg == "" {
+ msg = "the space couldn't be set up just now — try again in a moment"
+ }
+ writeJSON(w, http.StatusBadGateway, map[string]any{
+ "error": "claim_failed", "message": msg,
+ })
+ }
+}
+
// 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
diff --git a/home/provision_test.go b/home/provision_test.go
index 2d04389..1e5261e 100644
--- a/home/provision_test.go
+++ b/home/provision_test.go
@@ -454,3 +454,56 @@ func TestCreateSpaceForwardsName(t *testing.T) {
t.Errorf("61-char name: %d, want 400", r.StatusCode)
}
}
+
+// The pay-first pair forwards with the session-id template in the redirect and
+// relays the provisioner's answers (including the claim's retriable message).
+func TestCheckoutCreateAndClaimForward(t *testing.T) {
+ ts, s := newTestServer(t)
+ uid, token := seedMember(t, s)
+ cloud, captured, capturedBody := fakeCloud(t, http.StatusOK, map[string]any{
+ "url": "https://checkout.stripe.com/c/pay/cs_1",
+ })
+ s.cloud = newCloudProvisioner(cloud.URL, "caller-token")
+
+ post := func(path, body string) *http.Response {
+ req, _ := http.NewRequest("POST", ts.URL+path, strings.NewReader(body))
+ 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
+ }
+
+ if r := post("/api/spaces/checkout-create", `{"tier":"family","band":"base","name":"Book Club"}`); r.StatusCode != http.StatusOK {
+ t.Fatalf("checkout-create: %d, want 200", r.StatusCode)
+ }
+ if captured.URL.Path != "/api/instances/checkout-create" {
+ t.Errorf("forwarded to %s", captured.URL.Path)
+ }
+ var sent map[string]string
+ json.Unmarshal(*capturedBody, &sent)
+ if sent["requester"] != uid || sent["tier"] != "family" || sent["name"] != "Book Club" {
+ t.Errorf("forwarded body = %v", sent)
+ }
+ if !strings.Contains(sent["success_url"], "?paidcreate={CHECKOUT_SESSION_ID}") {
+ t.Errorf("success_url = %q, want the session-id template", sent["success_url"])
+ }
+
+ // Missing tier → 400 before any dial; claim without a session likewise.
+ if r := post("/api/spaces/checkout-create", `{"name":"X"}`); r.StatusCode != http.StatusBadRequest {
+ t.Errorf("no tier: %d, want 400", r.StatusCode)
+ }
+ if r := post("/api/spaces/claim-create", `{}`); r.StatusCode != http.StatusBadRequest {
+ t.Errorf("no session: %d, want 400", r.StatusCode)
+ }
+
+ if r := post("/api/spaces/claim-create", `{"session_id":"cs_1"}`); r.StatusCode != http.StatusOK {
+ t.Fatalf("claim relay: %d, want 200", r.StatusCode)
+ }
+ json.Unmarshal(*capturedBody, &sent)
+ if sent["requester"] != uid || sent["session_id"] != "cs_1" {
+ t.Errorf("claim body = %v", sent)
+ }
+}