package main // Billing rails for cloud-provisioned spaces (Stripe Checkout + webhooks). // // 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, a tier key, and a period end. // Never an email, name, address, or card detail — Stripe holds those, // and the operator can see them in Stripe's dashboard. Paying is not // anonymous; the invariant is that OUR stores carry no billing identity. // - 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. // - The webhook is the one unauthenticated-by-token public endpoint here; its // gate is the signature check (HMAC over the raw payload, à la Stripe). // // No Stripe SDK: the surface we use is one form-encoded POST and one HMAC // verification, which isn't worth a dependency in a single-binary repo. import ( "crypto/hmac" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "fmt" "io" "log" "net/http" "net/url" "slices" "strconv" "strings" "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"` // NetEURYear is what one subscription nets per year after fees — the free // pool's revenue input (docs/free-pool.md), keeping the standing property // that the server never asks Stripe what a price costs. Optional and // additive: entries without it contribute nothing to the pool budget. NetEURYear float64 `json:"net_eur_year"` // EUR is the sticker amount as a number (the label repeated, machine- // readably) — the picker's local-currency ≈ display converts it client-side // (docs/pricing-bands.md § Local-currency display). Optional: entries // without it simply show no ≈ figure. EUR float64 `json:"eur,omitempty"` // Business marks a business-directed tier: its prices are quoted ex-VAT // (created with tax_behavior=exclusive) and its checkout collects a VAT ID // so EU businesses get the reverse charge (docs/pricing-bands.md § VAT). Business bool `json:"business,omitempty"` // PerSeat marks a per-seat tier (docs/tier-economics.md § Per-seat): its // price (and each band's) is per person per year, the checkout charges // quantity = the space's live non-agent member count, and the renewal // true-up (meter.go) re-counts before each renewal so the next invoice // bills the real team size. NetEURYear/EUR are likewise per seat. PerSeat bool `json:"per_seat,omitempty"` // MinSeats floors a per-seat tier's billed quantity — the included-members // identity (docs/tier-economics.md): Community's €0.11/member with // MinSeats 100 IS the €11 minimum covering the first 100 members, so the // floor needs no separate Stripe Price and bands scale it for free. MinSeats int `json:"min_seats,omitempty"` // Bands are the honor prices for this tier (docs/pricing-bands.md): the // same product at supporter/reduced/solidarity multipliers, each its own // Stripe Price. The tier's own label/price IS the base band. Optional and // backward-compatible: a tier without bands behaves exactly as before. Bands map[string]billingBand `json:"bands,omitempty"` } // billingBand is one non-base honor price of a tier. type billingBand struct { Label string `json:"label"` Price string `json:"price"` NetEURYear float64 `json:"net_eur_year"` EUR float64 `json:"eur,omitempty"` } // bandBase names the band every tier has implicitly (its top-level price); // bandKeys are the only non-base bands the book may define — a fixed, small // vocabulary keeps ledger rows and telemetry legible. const bandBase = "base" var bandKeys = []string{"supporter", "reduced", "solidarity"} // 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 tiers []billingTier // the price book, in display order portalConfig string // optional Billing Portal configuration id // portalConfigs optionally picks a Billing Portal configuration per band // (LCHAT_STRIPE_PORTAL_CONFIGS, JSON {band: id}) — band changes go through // the home surface, never the portal, so each band's portal only offers // switches inside that band. Falls back to portalConfig. portalConfigs map[string]string // automaticTax opts Checkout Sessions into Stripe Tax // (LCHAT_STRIPE_AUTOMATIC_TAX=1). An operator knob rather than always-on: // it requires every live Price to resolve a tax_behavior, so it flips on // with the band-price rollout, not with a code deploy // (docs/pricing-bands.md § VAT). automaticTax bool 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 } // tierBandByPrice maps a Stripe price id back to its (tier, band) — how a // Billing Portal plan switch reaches the ledger (the subscription's PRICE is // the truth; its metadata still carries what the original checkout bought). // Still a flat scan over a few dozen rows. func (b *stripeBilling) tierBandByPrice(priceID string) (billingTier, string, bool) { for _, t := range b.tiers { if t.Price == priceID { return t, bandBase, true } for band, bd := range t.Bands { if bd.Price == priceID { return t, band, true } } } return billingTier{}, "", false } // bandPrice resolves the Stripe price id + net revenue for one band of a tier. // The empty band means base. ok=false: the tier doesn't offer that band. func bandPrice(t billingTier, band string) (price string, net float64, ok bool) { if band == "" || band == bandBase { return t.Price, t.NetEURYear, true } bd, ok := t.Bands[band] if !ok { return "", 0, false } return bd.Price, bd.NetEURYear, true } // publicTiers is what listings expose: everything a picker needs. (Price ids // aren't secret, but clients have no use for them — keys route instead.) // Bands ride along as {band: {label, eur}} so the picker can render the honor // row without ever seeing a price id. func (b *stripeBilling) publicTiers() []map[string]any { out := make([]map[string]any, 0, len(b.tiers)) for _, t := range b.tiers { entry := map[string]any{"key": t.Key, "name": t.Name, "label": t.Label} if t.EUR > 0 { entry["eur"] = t.EUR } if t.Business { entry["business"] = true } if t.PerSeat { entry["per_seat"] = true } if t.MinSeats > 0 { entry["min_seats"] = t.MinSeats } if len(t.Bands) > 0 { bands := map[string]any{} for band, bd := range t.Bands { be := map[string]any{"label": bd.Label} if bd.EUR > 0 { be["eur"] = bd.EUR } bands[band] = be } entry["bands"] = bands } out = append(out, entry) } return out } // newStripeBilling wires billing from config. All-empty → nil (billing off); // 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 == "" || 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{} seenPrice := 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 // A price id may appear once in the whole book — tierBandByPrice is how // webhooks attribute a subscription, so ambiguity here would misledger. if seenPrice[t.Price] { return nil, fmt.Errorf("LCHAT_STRIPE_PRICES: price %q appears twice", t.Price) } seenPrice[t.Price] = true for band, bd := range t.Bands { if band == bandBase || !slices.Contains(bandKeys, band) { return nil, fmt.Errorf("LCHAT_STRIPE_PRICES: tier %q: unknown band %q (want %v; base is the tier's own price)", t.Key, band, bandKeys) } if bd.Label == "" || bd.Price == "" { return nil, fmt.Errorf("LCHAT_STRIPE_PRICES: tier %q band %q needs label and price", t.Key, band) } if seenPrice[bd.Price] { return nil, fmt.Errorf("LCHAT_STRIPE_PRICES: price %q appears twice", bd.Price) } seenPrice[bd.Price] = true } } return &stripeBilling{ apiBase: strings.TrimRight(apiBase, "/"), key: key, webhookSecret: webhookSecret, tiers: tiers, client: &http.Client{Timeout: 30 * time.Second}, }, nil } // checkoutHandler: POST /api/instances/{host}/checkout — mint a Stripe Checkout // link to upgrade one space. Caller-token gated like every provisioning route, // and ownership-checked against the ledger exactly like deletion: only the // (caller, requester) pair that created a space can start a checkout for it. func (s *cloudServer) checkoutHandler(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 upgrades") return } host := strings.ToLower(strings.TrimSpace(r.PathValue("host"))) var req struct { Requester string `json:"requester"` Tier string `json:"tier"` Band string `json:"band"` 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 == "" || !validRedirectURL(req.SuccessURL) || !validRedirectURL(req.CancelURL) { 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 } // The honor band (docs/pricing-bands.md): whatever band arrives is // honoured, no eligibility test, ever. Default base; a band the book // doesn't offer for this tier is a client error, not a fallback — silently // charging base against a reduced-price link would betray the promise. 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 } id, owned, err := s.db.spaceOwned(callerID, req.Requester, host) if err != nil { writeControlErr(w, http.StatusInternalServerError, "checkout_failed", "db error") return } if !owned { writeControlErr(w, http.StatusNotFound, "not_found", "no such space for this account") return } if ent, err := s.db.spaceEntitlement(id); err != nil { writeControlErr(w, http.StatusInternalServerError, "checkout_failed", "db error") return } else if ent != entFree { writeControlErr(w, http.StatusConflict, "already_upgraded", "this space is already upgraded") return } // Per-seat tiers charge quantity = the space's live head count, counted // server-side (non-agent members — bots are free), never self-declared. // Fail closed when the count is unavailable: quietly charging one seat // would betray the sticker. seats := 1 if tier.PerSeat { st, err := s.controlStats(host) if err != nil { log.Printf("cloud: checkout for %s (tier %s): seat count unavailable: %v", host, tier.Key, err) writeControlErr(w, http.StatusBadGateway, "checkout_failed", "couldn't count the space's members — please try again") return } seats = max(st.People, tier.MinSeats, 1) } _, checkoutURL, err := s.billing.createCheckoutSession(id, tier, band, price, seats, 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") return } 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 } // A pay-first space has exactly its creator until people join, so per-seat // tiers open at one seat (or the tier's included-members floor — a paid // community create charges its €11 minimum up front); growth settles at // the renewal true-up. session, checkoutURL, err := s.billing.createCheckoutSession(id, tier, band, price, max(tier.MinSeats, 1), 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) // The session id rides back so the client can park a marker BEFORE the // redirect — a tab that dies on Stripe's page still finishes next visit. writeJSON(w, http.StatusOK, map[string]any{"url": checkoutURL, "session_id": session}) } // 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) // One person so far (the creator) — the meter sweep grows a per-seat quota // as members join. s.pushQuota(id, host, entPaid, sess.Metadata["tier"], 1) 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: // caller token + ledger ownership. Portals are per-space by construction — // each space is its own Stripe customer (checkout sends no customer identity, // so there is nothing to merge across spaces, deliberately). func (s *cloudServer) portalHandler(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 upgrades") return } host := strings.ToLower(strings.TrimSpace(r.PathValue("host"))) var req struct { Requester string `json:"requester"` ReturnURL string `json:"return_url"` } 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) if req.Requester == "" || !validRedirectURL(req.ReturnURL) { writeControlErr(w, http.StatusBadRequest, "bad_request", "requester and return_url required") return } id, owned, err := s.db.spaceOwned(callerID, req.Requester, host) if err != nil { writeControlErr(w, http.StatusInternalServerError, "portal_failed", "db error") return } if !owned { writeControlErr(w, http.StatusNotFound, "not_found", "no such space for this account") return } customer, band, err := s.db.spaceCustomer(id) if err != nil { writeControlErr(w, http.StatusInternalServerError, "portal_failed", "db error") return } if customer == "" { writeControlErr(w, http.StatusConflict, "not_subscribed", "this space has no subscription to manage") return } portalURL, err := s.billing.createPortalSession(customer, band, req.ReturnURL) if err != nil { log.Printf("cloud: portal for %s (id %d): %v", host, id, err) writeControlErr(w, http.StatusBadGateway, "portal_failed", "couldn't open the billing page — please try again") return } writeJSON(w, http.StatusOK, map[string]any{"url": portalURL}) } // createPortalSession asks Stripe for a hosted Billing Portal page. The // optional portal configuration id (LCHAT_STRIPE_PORTAL_CONFIG) pins which // dashboard-defined feature set the portal shows; unset uses Stripe's default. // With bands, LCHAT_STRIPE_PORTAL_CONFIGS picks per-band (a portal only ever // offers plan switches inside the customer's own band — band changes are the // home surface's job, where they can be messaged). func (b *stripeBilling) createPortalSession(customer, band, returnURL string) (string, error) { form := url.Values{} form.Set("customer", customer) form.Set("return_url", returnURL) if cfg, ok := b.portalConfigs[band]; ok && cfg != "" { form.Set("configuration", cfg) } else if b.portalConfig != "" { form.Set("configuration", b.portalConfig) } req, err := http.NewRequest("POST", b.apiBase+"/v1/billing_portal/sessions", strings.NewReader(form.Encode())) if err != nil { 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 } defer resp.Body.Close() var body struct { 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) } if resp.StatusCode != http.StatusOK || body.URL == "" { return "", fmt.Errorf("stripe: HTTP %d: %s", resp.StatusCode, body.Error.Message) } 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 { u, err := url.Parse(raw) return err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" } // 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); // the tier + band keys ride in the session metadata so completion records what // was bought. price is the band-resolved Stripe price id; seats is 1 on flat // tiers and the live head count on per-seat ones. 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 string, seats int, successURL, cancelURL string, pendingCreate bool) (sessionID, sessionURL string, err error) { id := strconv.FormatInt(spaceID, 10) form := url.Values{} form.Set("mode", "subscription") form.Set("line_items[0][price]", price) form.Set("line_items[0][quantity]", strconv.Itoa(seats)) 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) form.Set("success_url", successURL) form.Set("cancel_url", cancelURL) // Promotion codes: lets a dashboard-minted code (e.g. an operator comp — // a discounted space is entitlement-paid, so it stops counting against the // free-space quota) ride the normal checkout instead of needing a side door. form.Set("allow_promotion_codes", "true") // Stripe Tax (docs/pricing-bands.md § VAT): consumer Prices are created // tax-inclusive (the sticker is the total), Business tiers ex-VAT — so a // Business checkout also collects an optional VAT ID for the EU reverse // charge. Off until the operator flips the knob alongside the band Prices. if b.automaticTax { form.Set("automatic_tax[enabled]", "true") if tier.Business { form.Set("tax_id_collection[enabled]", "true") } } req, err := http.NewRequest("POST", b.apiBase+"/v1/checkout/sessions", strings.NewReader(form.Encode())) if err != nil { 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 } 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) } if resp.StatusCode != http.StatusOK { 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 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 } // trueUpSeats sets a subscription's quantity to seats with // proration_behavior=none: no mid-cycle charge or credit, the NEXT invoice // bills the new count — the whole dropoff/growth policy in one Stripe knob // (docs/tier-economics.md § Per-seat). Reads the subscription first for its // item id and current quantity; a matching quantity is a no-op (changed // false), so the sweep can call this repeatedly without touching Stripe state. func (b *stripeBilling) trueUpSeats(subscriptionID string, seats int) (changed bool, err error) { req, err := http.NewRequest("GET", b.apiBase+"/v1/subscriptions/"+subscriptionID, nil) if err != nil { return false, err } req.Header.Set("Authorization", "Bearer "+b.key) resp, err := b.client.Do(req) if err != nil { return false, err } defer resp.Body.Close() var sub struct { Items struct { Data []struct { ID string `json:"id"` Quantity int64 `json:"quantity"` } `json:"data"` } `json:"items"` Error struct { Message string `json:"message"` } `json:"error"` } if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&sub); err != nil { return false, fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode) } if resp.StatusCode != http.StatusOK || len(sub.Items.Data) == 0 { return false, fmt.Errorf("stripe: HTTP %d: %s", resp.StatusCode, sub.Error.Message) } item := sub.Items.Data[0] if item.Quantity == int64(seats) { return false, nil } form := url.Values{} form.Set("items[0][id]", item.ID) form.Set("items[0][quantity]", strconv.Itoa(seats)) form.Set("proration_behavior", "none") upd, err := http.NewRequest("POST", b.apiBase+"/v1/subscriptions/"+subscriptionID, strings.NewReader(form.Encode())) if err != nil { return false, err } upd.Header.Set("Authorization", "Bearer "+b.key) upd.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp2, err := b.client.Do(upd) if err != nil { return false, err } defer resp2.Body.Close() if resp2.StatusCode != http.StatusOK { var body struct { Error struct { Message string `json:"message"` } `json:"error"` } _ = json.NewDecoder(io.LimitReader(resp2.Body, 1<<20)).Decode(&body) return false, fmt.Errorf("stripe: HTTP %d: %s", resp2.StatusCode, body.Error.Message) } return true, nil } // ---- webhook ------------------------------------------------------------------- // stripeSigTolerance bounds how stale a webhook's signed timestamp may be — // Stripe's own recommended replay window. const stripeSigTolerance = 5 * time.Minute // stripeWebhookHandler: POST /api/stripe/webhook — the events that move a // space's entitlement. Public (Stripe's servers call it); the signature IS the // auth. Unknown event types 200 so the endpoint's Stripe-side config can list // more than we handle; a DB failure 500s so Stripe retries; an event for a // space we can't find 200s (retrying can never make it findable). func (s *cloudServer) stripeWebhookHandler(w http.ResponseWriter, r *http.Request) { if s.billing == nil { writeControlErr(w, http.StatusNotImplemented, "billing_unavailable", "billing is not configured") return } payload, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { writeControlErr(w, http.StatusBadRequest, "bad_request", "unreadable body") return } if !verifyStripeSignature(payload, r.Header.Get("Stripe-Signature"), s.billing.webhookSecret, time.Now()) { writeControlErr(w, http.StatusBadRequest, "bad_signature", "webhook signature verification failed") return } var ev struct { Type string `json:"type"` Data struct { Object json.RawMessage `json:"object"` } `json:"data"` } if err := json.Unmarshal(payload, &ev); err != nil { writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid event JSON") return } switch { case ev.Type == "checkout.session.completed": err = s.handleCheckoutCompleted(ev.Data.Object) case strings.HasPrefix(ev.Type, "customer.subscription."): err = s.handleSubscriptionEvent(ev.Type, ev.Data.Object) default: // Not ours — acknowledged, never retried. } if err != nil { log.Printf("cloud: webhook %s: %v", ev.Type, err) writeControlErr(w, http.StatusInternalServerError, "webhook_failed", "event processing failed") return } writeJSON(w, http.StatusOK, map[string]any{"received": true}) } // handleCheckoutCompleted flips a space to paid the moment its checkout // finishes, recording the opaque customer + subscription ids that route every // 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"` Metadata map[string]string `json:"metadata"` } if err := json.Unmarshal(raw, &obj); err != nil { return err } if obj.Mode != "subscription" { return nil // not an upgrade checkout } id, err := strconv.ParseInt(obj.ClientReferenceID, 10, 64) if err != nil { 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, obj.Metadata["tier"], obj.Metadata["band"]); err { case nil: log.Printf("cloud: space id %d upgraded (checkout complete, tier %s, band %s)", id, obj.Metadata["tier"], obj.Metadata["band"]) s.pushQuotaByID(id) // raise the space's storage cap to its new tier s.pushReplicationByID(id) // move it onto its tier's durability rung return nil case sql.ErrNoRows: log.Printf("cloud: webhook checkout for unknown space id %d — ignoring", id) return nil // deleted since checkout began; retrying won't resurrect it default: return err } } // handleSubscriptionEvent tracks the subscription for the life of the upgrade: // updates refresh the paid flag + period end, deletion drops the space back to // free. The reaper never sees paid spaces (spacesOlderThan filters them), and a // lapsed space just becomes an ordinary free one — lapse handling beyond that // (backup-link mail via Stripe) is later work, by design. func (s *cloudServer) handleSubscriptionEvent(evType string, raw json.RawMessage) error { var obj struct { ID string `json:"id"` Status string `json:"status"` CancelAtPeriodEnd bool `json:"cancel_at_period_end"` Metadata map[string]string `json:"metadata"` // Stripe API versions ≥2025-03-31 moved current_period_end from the // subscription to its items; accept either shape. CurrentPeriodEnd int64 `json:"current_period_end"` Items struct { Data []struct { CurrentPeriodEnd int64 `json:"current_period_end"` Quantity int64 `json:"quantity"` Price struct { ID string `json:"id"` } `json:"price"` } `json:"data"` } `json:"items"` } if err := json.Unmarshal(raw, &obj); err != nil { return err } id, err := strconv.ParseInt(obj.Metadata["space_id"], 10, 64) if err != nil { var ok bool if id, ok, err = s.db.findSpaceBySubscription(obj.ID); err != nil { return err } else if !ok { log.Printf("cloud: webhook %s for unknown subscription %s — ignoring", evType, obj.ID) return nil } } ent := entFree if evType != "customer.subscription.deleted" { // past_due keeps access through Stripe's dunning window — losing the // space the day a card expires would be hostile; unpaid/canceled ends it. switch obj.Status { case "active", "trialing", "past_due": ent = entPaid } } paidUntil := obj.CurrentPeriodEnd // The subscription's current PRICE names the tier and band — a Billing // Portal plan switch changes the price but never the checkout-time // metadata, so the price is the truth and metadata only ever seeded the // first value. tier, band := "", "" seats := 0 for _, item := range obj.Items.Data { if item.CurrentPeriodEnd > paidUntil { paidUntil = item.CurrentPeriodEnd } if t, b, ok := s.billing.tierBandByPrice(item.Price.ID); ok { tier, band = t.Key, b // The billed head count, from Stripe's own item — the ledger's // seats always mirror what the subscription actually charges // (0 on flat tiers; the true-up compares against this). if t.PerSeat { seats = int(item.Quantity) } } } switch err := s.db.setEntitlementTier(id, ent, tier, band, seats, paidUntil, obj.CancelAtPeriodEnd); err { case nil: log.Printf("cloud: space id %d entitlement → %s (tier %s, band %s, seats %d, subscription %s, cancelling=%v)", id, ent, tier, band, seats, obj.Status, obj.CancelAtPeriodEnd) s.pushQuotaByID(id) // entitlement/tier moved — re-apply the matching cap s.pushReplicationByID(id) // and the matching durability rung (lapse = back to free's) return nil case sql.ErrNoRows: log.Printf("cloud: webhook %s for unknown space id %d — ignoring", evType, id) return nil default: return err } } // verifyStripeSignature checks a Stripe-Signature header against the raw // payload: HMAC-SHA256(secret, ".") must match one of the v1 // signatures, and t must be within the replay tolerance of now. Pure (now is a // parameter) so tests can pin time. func verifyStripeSignature(payload []byte, header, secret string, now time.Time) bool { var ts string var sigs [][]byte for _, part := range strings.Split(header, ",") { k, v, ok := strings.Cut(strings.TrimSpace(part), "=") if !ok { continue } switch k { case "t": ts = v case "v1": if sig, err := hex.DecodeString(v); err == nil { sigs = append(sigs, sig) } } } t, err := strconv.ParseInt(ts, 10, 64) if err != nil || len(sigs) == 0 { return false } if d := now.Sub(time.Unix(t, 0)); d > stripeSigTolerance || d < -stripeSigTolerance { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(ts)) mac.Write([]byte(".")) mac.Write(payload) want := mac.Sum(nil) for _, sig := range sigs { if hmac.Equal(sig, want) { return true } } return false }