| 1 | package main |
| 2 | |
| 3 | // Billing rails for cloud-provisioned spaces (Stripe Checkout + webhooks). |
| 4 | // |
| 5 | // A small price book of tiers (Family / Friends / Business — config, not code), |
| 6 | // each a Stripe subscription price that upgrades one space. The entitlement + |
| 7 | // tier land on the space's ledger row: paid spaces don't count against the |
| 8 | // creation quota (allocateSpace), are never reaped, and per-tier powers arrive |
| 9 | // with #561's limits. No metering yet (that's #562). |
| 10 | // |
| 11 | // Privacy invariants (see docs/cloud-provisioner.md § Billing): |
| 12 | // - We store two opaque Stripe ids, a flag, a tier key, and a period end. |
| 13 | // Never an email, name, address, or card detail — Stripe holds those, |
| 14 | // and the operator can see them in Stripe's dashboard. Paying is not |
| 15 | // anonymous; the invariant is that OUR stores carry no billing identity. |
| 16 | // - Checkout Sessions are created WITHOUT customer details; the only thing we |
| 17 | // tell Stripe about a space is its numeric ledger id (client_reference_id + |
| 18 | // subscription metadata), which is what routes webhook events back to it. |
| 19 | // - The webhook is the one unauthenticated-by-token public endpoint here; its |
| 20 | // gate is the signature check (HMAC over the raw payload, à la Stripe). |
| 21 | // |
| 22 | // No Stripe SDK: the surface we use is one form-encoded POST and one HMAC |
| 23 | // verification, which isn't worth a dependency in a single-binary repo. |
| 24 | |
| 25 | import ( |
| 26 | "crypto/hmac" |
| 27 | "crypto/sha256" |
| 28 | "database/sql" |
| 29 | "encoding/hex" |
| 30 | "encoding/json" |
| 31 | "fmt" |
| 32 | "io" |
| 33 | "log" |
| 34 | "net/http" |
| 35 | "net/url" |
| 36 | "slices" |
| 37 | "strconv" |
| 38 | "strings" |
| 39 | "time" |
| 40 | ) |
| 41 | |
| 42 | // billingTier is one purchasable upgrade. key routes (stored in the ledger, |
| 43 | // carried in Stripe metadata); name + label are what members see on the picker |
| 44 | // ("Friends — $4/month") and the badge (name); price is the Stripe price id. |
| 45 | // The display strings live in config, verbatim — the server never asks Stripe |
| 46 | // what a price costs, so a Stripe outage can't take provisioning down with it; |
| 47 | // the checkout charges the real price regardless of what the label claims. |
| 48 | type billingTier struct { |
| 49 | Key string `json:"key"` |
| 50 | Name string `json:"name"` |
| 51 | Label string `json:"label"` |
| 52 | Price string `json:"price"` |
| 53 | // NetEURYear is what one subscription nets per year after fees — the free |
| 54 | // pool's revenue input (docs/free-pool.md), keeping the standing property |
| 55 | // that the server never asks Stripe what a price costs. Optional and |
| 56 | // additive: entries without it contribute nothing to the pool budget. |
| 57 | NetEURYear float64 `json:"net_eur_year"` |
| 58 | // EUR is the sticker amount as a number (the label repeated, machine- |
| 59 | // readably) — the picker's local-currency ≈ display converts it client-side |
| 60 | // (docs/pricing-bands.md § Local-currency display). Optional: entries |
| 61 | // without it simply show no ≈ figure. |
| 62 | EUR float64 `json:"eur,omitempty"` |
| 63 | // Business marks a business-directed tier: its prices are quoted ex-VAT |
| 64 | // (created with tax_behavior=exclusive) and its checkout collects a VAT ID |
| 65 | // so EU businesses get the reverse charge (docs/pricing-bands.md § VAT). |
| 66 | Business bool `json:"business,omitempty"` |
| 67 | // PerSeat marks a per-seat tier (docs/tier-economics.md § Per-seat): its |
| 68 | // price (and each band's) is per person per year, the checkout charges |
| 69 | // quantity = the space's live non-agent member count, and the renewal |
| 70 | // true-up (meter.go) re-counts before each renewal so the next invoice |
| 71 | // bills the real team size. NetEURYear/EUR are likewise per seat. |
| 72 | PerSeat bool `json:"per_seat,omitempty"` |
| 73 | // MinSeats floors a per-seat tier's billed quantity — the included-members |
| 74 | // identity (docs/tier-economics.md): Community's €0.11/member with |
| 75 | // MinSeats 100 IS the €11 minimum covering the first 100 members, so the |
| 76 | // floor needs no separate Stripe Price and bands scale it for free. |
| 77 | MinSeats int `json:"min_seats,omitempty"` |
| 78 | // Bands are the honor prices for this tier (docs/pricing-bands.md): the |
| 79 | // same product at supporter/reduced/solidarity multipliers, each its own |
| 80 | // Stripe Price. The tier's own label/price IS the base band. Optional and |
| 81 | // backward-compatible: a tier without bands behaves exactly as before. |
| 82 | Bands map[string]billingBand `json:"bands,omitempty"` |
| 83 | } |
| 84 | |
| 85 | // billingBand is one non-base honor price of a tier. |
| 86 | type billingBand struct { |
| 87 | Label string `json:"label"` |
| 88 | Price string `json:"price"` |
| 89 | NetEURYear float64 `json:"net_eur_year"` |
| 90 | EUR float64 `json:"eur,omitempty"` |
| 91 | } |
| 92 | |
| 93 | // bandBase names the band every tier has implicitly (its top-level price); |
| 94 | // bandKeys are the only non-base bands the book may define — a fixed, small |
| 95 | // vocabulary keeps ledger rows and telemetry legible. |
| 96 | const bandBase = "base" |
| 97 | |
| 98 | var bandKeys = []string{"supporter", "reduced", "solidarity"} |
| 99 | |
| 100 | // stripeBilling holds the cloud service's Stripe configuration. nil on a |
| 101 | // deployment that doesn't offer upgrades — billing stays strictly additive. |
| 102 | type stripeBilling struct { |
| 103 | apiBase string // https://api.stripe.com (tests point it at a stub) |
| 104 | key string // secret (restricted) API key |
| 105 | webhookSecret string // signing secret for the webhook endpoint |
| 106 | tiers []billingTier // the price book, in display order |
| 107 | portalConfig string // optional Billing Portal configuration id |
| 108 | // portalConfigs optionally picks a Billing Portal configuration per band |
| 109 | // (LCHAT_STRIPE_PORTAL_CONFIGS, JSON {band: id}) — band changes go through |
| 110 | // the home surface, never the portal, so each band's portal only offers |
| 111 | // switches inside that band. Falls back to portalConfig. |
| 112 | portalConfigs map[string]string |
| 113 | // automaticTax opts Checkout Sessions into Stripe Tax |
| 114 | // (LCHAT_STRIPE_AUTOMATIC_TAX=1). An operator knob rather than always-on: |
| 115 | // it requires every live Price to resolve a tax_behavior, so it flips on |
| 116 | // with the band-price rollout, not with a code deploy |
| 117 | // (docs/pricing-bands.md § VAT). |
| 118 | automaticTax bool |
| 119 | client *http.Client |
| 120 | } |
| 121 | |
| 122 | // tier looks a purchasable tier up by key. |
| 123 | func (b *stripeBilling) tier(key string) (billingTier, bool) { |
| 124 | for _, t := range b.tiers { |
| 125 | if t.Key == key { |
| 126 | return t, true |
| 127 | } |
| 128 | } |
| 129 | return billingTier{}, false |
| 130 | } |
| 131 | |
| 132 | // tierBandByPrice maps a Stripe price id back to its (tier, band) — how a |
| 133 | // Billing Portal plan switch reaches the ledger (the subscription's PRICE is |
| 134 | // the truth; its metadata still carries what the original checkout bought). |
| 135 | // Still a flat scan over a few dozen rows. |
| 136 | func (b *stripeBilling) tierBandByPrice(priceID string) (billingTier, string, bool) { |
| 137 | for _, t := range b.tiers { |
| 138 | if t.Price == priceID { |
| 139 | return t, bandBase, true |
| 140 | } |
| 141 | for band, bd := range t.Bands { |
| 142 | if bd.Price == priceID { |
| 143 | return t, band, true |
| 144 | } |
| 145 | } |
| 146 | } |
| 147 | return billingTier{}, "", false |
| 148 | } |
| 149 | |
| 150 | // bandPrice resolves the Stripe price id + net revenue for one band of a tier. |
| 151 | // The empty band means base. ok=false: the tier doesn't offer that band. |
| 152 | func bandPrice(t billingTier, band string) (price string, net float64, ok bool) { |
| 153 | if band == "" || band == bandBase { |
| 154 | return t.Price, t.NetEURYear, true |
| 155 | } |
| 156 | bd, ok := t.Bands[band] |
| 157 | if !ok { |
| 158 | return "", 0, false |
| 159 | } |
| 160 | return bd.Price, bd.NetEURYear, true |
| 161 | } |
| 162 | |
| 163 | // publicTiers is what listings expose: everything a picker needs. (Price ids |
| 164 | // aren't secret, but clients have no use for them — keys route instead.) |
| 165 | // Bands ride along as {band: {label, eur}} so the picker can render the honor |
| 166 | // row without ever seeing a price id. |
| 167 | func (b *stripeBilling) publicTiers() []map[string]any { |
| 168 | out := make([]map[string]any, 0, len(b.tiers)) |
| 169 | for _, t := range b.tiers { |
| 170 | entry := map[string]any{"key": t.Key, "name": t.Name, "label": t.Label} |
| 171 | if t.EUR > 0 { |
| 172 | entry["eur"] = t.EUR |
| 173 | } |
| 174 | if t.Business { |
| 175 | entry["business"] = true |
| 176 | } |
| 177 | if t.PerSeat { |
| 178 | entry["per_seat"] = true |
| 179 | } |
| 180 | if t.MinSeats > 0 { |
| 181 | entry["min_seats"] = t.MinSeats |
| 182 | } |
| 183 | if len(t.Bands) > 0 { |
| 184 | bands := map[string]any{} |
| 185 | for band, bd := range t.Bands { |
| 186 | be := map[string]any{"label": bd.Label} |
| 187 | if bd.EUR > 0 { |
| 188 | be["eur"] = bd.EUR |
| 189 | } |
| 190 | bands[band] = be |
| 191 | } |
| 192 | entry["bands"] = bands |
| 193 | } |
| 194 | out = append(out, entry) |
| 195 | } |
| 196 | return out |
| 197 | } |
| 198 | |
| 199 | // newStripeBilling wires billing from config. All-empty → nil (billing off); |
| 200 | // partial or malformed config is a deployment mistake and errors rather than |
| 201 | // half-working. pricesJSON is a JSON array of billingTier. |
| 202 | func newStripeBilling(key, webhookSecret, pricesJSON, apiBase string) (*stripeBilling, error) { |
| 203 | if key == "" && webhookSecret == "" && pricesJSON == "" { |
| 204 | return nil, nil |
| 205 | } |
| 206 | if key == "" || webhookSecret == "" || pricesJSON == "" { |
| 207 | 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)") |
| 208 | } |
| 209 | var tiers []billingTier |
| 210 | if err := json.Unmarshal([]byte(pricesJSON), &tiers); err != nil { |
| 211 | return nil, fmt.Errorf("LCHAT_STRIPE_PRICES: %w (want a JSON array of {key,name,label,price})", err) |
| 212 | } |
| 213 | if len(tiers) == 0 { |
| 214 | return nil, fmt.Errorf("LCHAT_STRIPE_PRICES: no tiers configured") |
| 215 | } |
| 216 | seen := map[string]bool{} |
| 217 | seenPrice := map[string]bool{} |
| 218 | for _, t := range tiers { |
| 219 | if t.Key == "" || t.Name == "" || t.Label == "" || t.Price == "" { |
| 220 | return nil, fmt.Errorf("LCHAT_STRIPE_PRICES: tier %+v is missing a field (need key, name, label, price)", t) |
| 221 | } |
| 222 | if seen[t.Key] { |
| 223 | return nil, fmt.Errorf("LCHAT_STRIPE_PRICES: duplicate tier key %q", t.Key) |
| 224 | } |
| 225 | seen[t.Key] = true |
| 226 | // A price id may appear once in the whole book — tierBandByPrice is how |
| 227 | // webhooks attribute a subscription, so ambiguity here would misledger. |
| 228 | if seenPrice[t.Price] { |
| 229 | return nil, fmt.Errorf("LCHAT_STRIPE_PRICES: price %q appears twice", t.Price) |
| 230 | } |
| 231 | seenPrice[t.Price] = true |
| 232 | for band, bd := range t.Bands { |
| 233 | if band == bandBase || !slices.Contains(bandKeys, band) { |
| 234 | 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) |
| 235 | } |
| 236 | if bd.Label == "" || bd.Price == "" { |
| 237 | return nil, fmt.Errorf("LCHAT_STRIPE_PRICES: tier %q band %q needs label and price", t.Key, band) |
| 238 | } |
| 239 | if seenPrice[bd.Price] { |
| 240 | return nil, fmt.Errorf("LCHAT_STRIPE_PRICES: price %q appears twice", bd.Price) |
| 241 | } |
| 242 | seenPrice[bd.Price] = true |
| 243 | } |
| 244 | } |
| 245 | return &stripeBilling{ |
| 246 | apiBase: strings.TrimRight(apiBase, "/"), |
| 247 | key: key, |
| 248 | webhookSecret: webhookSecret, |
| 249 | tiers: tiers, |
| 250 | client: &http.Client{Timeout: 30 * time.Second}, |
| 251 | }, nil |
| 252 | } |
| 253 | |
| 254 | // checkoutHandler: POST /api/instances/{host}/checkout — mint a Stripe Checkout |
| 255 | // link to upgrade one space. Caller-token gated like every provisioning route, |
| 256 | // and ownership-checked against the ledger exactly like deletion: only the |
| 257 | // (caller, requester) pair that created a space can start a checkout for it. |
| 258 | func (s *cloudServer) checkoutHandler(w http.ResponseWriter, r *http.Request) { |
| 259 | callerID := r.Context().Value(callerKey{}).(string) |
| 260 | if s.billing == nil { |
| 261 | writeControlErr(w, http.StatusNotImplemented, "billing_unavailable", "this provisioner does not offer upgrades") |
| 262 | return |
| 263 | } |
| 264 | host := strings.ToLower(strings.TrimSpace(r.PathValue("host"))) |
| 265 | var req struct { |
| 266 | Requester string `json:"requester"` |
| 267 | Tier string `json:"tier"` |
| 268 | Band string `json:"band"` |
| 269 | SuccessURL string `json:"success_url"` |
| 270 | CancelURL string `json:"cancel_url"` |
| 271 | } |
| 272 | if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&req); err != nil { |
| 273 | writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") |
| 274 | return |
| 275 | } |
| 276 | req.Requester = strings.TrimSpace(req.Requester) |
| 277 | if req.Requester == "" || !validRedirectURL(req.SuccessURL) || !validRedirectURL(req.CancelURL) { |
| 278 | writeControlErr(w, http.StatusBadRequest, "bad_request", "requester, success_url and cancel_url required") |
| 279 | return |
| 280 | } |
| 281 | tier, ok := s.billing.tier(req.Tier) |
| 282 | if !ok { |
| 283 | writeControlErr(w, http.StatusBadRequest, "bad_request", "unknown tier") |
| 284 | return |
| 285 | } |
| 286 | // The honor band (docs/pricing-bands.md): whatever band arrives is |
| 287 | // honoured, no eligibility test, ever. Default base; a band the book |
| 288 | // doesn't offer for this tier is a client error, not a fallback — silently |
| 289 | // charging base against a reduced-price link would betray the promise. |
| 290 | band := strings.ToLower(strings.TrimSpace(req.Band)) |
| 291 | if band == "" { |
| 292 | band = bandBase |
| 293 | } |
| 294 | price, _, ok := bandPrice(tier, band) |
| 295 | if !ok { |
| 296 | writeControlErr(w, http.StatusBadRequest, "bad_request", "unknown band for this tier") |
| 297 | return |
| 298 | } |
| 299 | id, owned, err := s.db.spaceOwned(callerID, req.Requester, host) |
| 300 | if err != nil { |
| 301 | writeControlErr(w, http.StatusInternalServerError, "checkout_failed", "db error") |
| 302 | return |
| 303 | } |
| 304 | if !owned { |
| 305 | writeControlErr(w, http.StatusNotFound, "not_found", "no such space for this account") |
| 306 | return |
| 307 | } |
| 308 | if ent, err := s.db.spaceEntitlement(id); err != nil { |
| 309 | writeControlErr(w, http.StatusInternalServerError, "checkout_failed", "db error") |
| 310 | return |
| 311 | } else if ent != entFree { |
| 312 | writeControlErr(w, http.StatusConflict, "already_upgraded", "this space is already upgraded") |
| 313 | return |
| 314 | } |
| 315 | // Per-seat tiers charge quantity = the space's live head count, counted |
| 316 | // server-side (non-agent members — bots are free), never self-declared. |
| 317 | // Fail closed when the count is unavailable: quietly charging one seat |
| 318 | // would betray the sticker. |
| 319 | seats := 1 |
| 320 | if tier.PerSeat { |
| 321 | st, err := s.controlStats(host) |
| 322 | if err != nil { |
| 323 | log.Printf("cloud: checkout for %s (tier %s): seat count unavailable: %v", host, tier.Key, err) |
| 324 | writeControlErr(w, http.StatusBadGateway, "checkout_failed", "couldn't count the space's members — please try again") |
| 325 | return |
| 326 | } |
| 327 | seats = max(st.People, tier.MinSeats, 1) |
| 328 | } |
| 329 | _, checkoutURL, err := s.billing.createCheckoutSession(id, tier, band, price, seats, req.SuccessURL, req.CancelURL, false) |
| 330 | if err != nil { |
| 331 | log.Printf("cloud: checkout for %s (id %d, tier %s, band %s): %v", host, id, tier.Key, band, err) |
| 332 | writeControlErr(w, http.StatusBadGateway, "checkout_failed", "couldn't start the checkout — please try again") |
| 333 | return |
| 334 | } |
| 335 | writeJSON(w, http.StatusOK, map[string]any{"url": checkoutURL}) |
| 336 | } |
| 337 | |
| 338 | // ---- pay-before-create ----------------------------------------------------------- |
| 339 | // |
| 340 | // The create flow's paid branch: nothing is provisioned until the money is |
| 341 | // real. checkout-create reserves a pending allocation (entitlement 'pending' — |
| 342 | // no quota, no sweeps, prunable) and mints a Checkout Session pointing at it; |
| 343 | // claim-create verifies the session with Stripe and only then runs the normal |
| 344 | // provisioning tail. The webhook needs no new arm: checkout.session.completed |
| 345 | // marks the pending row paid by client_reference_id exactly as it does an |
| 346 | // upgrade, and the push helpers already skip host-less rows. A paid row is |
| 347 | // never rolled back on a provisioning failure — the claim just retries. |
| 348 | |
| 349 | // storeClaimLink parks a freshly-minted setup link under its session id; |
| 350 | // takeClaimLink retrieves it without consuming (a flaky network may retry the |
| 351 | // same claim more than once). Entries expire after an hour — past that the |
| 352 | // admin link recovery path is the operator's, as with any lost setup link. |
| 353 | func (s *cloudServer) storeClaimLink(session, link string) { |
| 354 | s.claimLinksMu.Lock() |
| 355 | defer s.claimLinksMu.Unlock() |
| 356 | if s.claimLinks == nil { |
| 357 | s.claimLinks = map[string]claimLink{} |
| 358 | } |
| 359 | for k, v := range s.claimLinks { |
| 360 | if time.Since(v.At) > time.Hour { |
| 361 | delete(s.claimLinks, k) |
| 362 | } |
| 363 | } |
| 364 | s.claimLinks[session] = claimLink{Link: link, At: time.Now()} |
| 365 | } |
| 366 | |
| 367 | func (s *cloudServer) takeClaimLink(session string) string { |
| 368 | s.claimLinksMu.Lock() |
| 369 | defer s.claimLinksMu.Unlock() |
| 370 | v, ok := s.claimLinks[session] |
| 371 | if !ok || time.Since(v.At) > time.Hour { |
| 372 | return "" |
| 373 | } |
| 374 | return v.Link |
| 375 | } |
| 376 | |
| 377 | // checkoutCreateHandler: POST /api/instances/checkout-create — start a paid |
| 378 | // space's life as a Checkout Session bound to a pending allocation. Same |
| 379 | // validation surface as the upgrade checkout plus the create-time extras |
| 380 | // (name); the fleet create budget counts these, or session spam would |
| 381 | // sidestep the breaker. |
| 382 | func (s *cloudServer) checkoutCreateHandler(w http.ResponseWriter, r *http.Request) { |
| 383 | callerID := r.Context().Value(callerKey{}).(string) |
| 384 | if s.billing == nil { |
| 385 | writeControlErr(w, http.StatusNotImplemented, "billing_unavailable", "this provisioner does not offer paid spaces") |
| 386 | return |
| 387 | } |
| 388 | var req struct { |
| 389 | Requester string `json:"requester"` |
| 390 | Tier string `json:"tier"` |
| 391 | Band string `json:"band"` |
| 392 | Name string `json:"name"` |
| 393 | SuccessURL string `json:"success_url"` |
| 394 | CancelURL string `json:"cancel_url"` |
| 395 | } |
| 396 | if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&req); err != nil { |
| 397 | writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") |
| 398 | return |
| 399 | } |
| 400 | req.Requester = strings.TrimSpace(req.Requester) |
| 401 | if req.Requester == "" || len(req.Requester) > 128 || !validRedirectURL(req.SuccessURL) || !validRedirectURL(req.CancelURL) { |
| 402 | writeControlErr(w, http.StatusBadRequest, "bad_request", "requester, success_url and cancel_url required") |
| 403 | return |
| 404 | } |
| 405 | req.Name = strings.TrimSpace(req.Name) |
| 406 | if len(req.Name) > 60 { |
| 407 | writeControlErr(w, http.StatusBadRequest, "bad_request", "name must be at most 60 characters") |
| 408 | return |
| 409 | } |
| 410 | tier, ok := s.billing.tier(req.Tier) |
| 411 | if !ok { |
| 412 | writeControlErr(w, http.StatusBadRequest, "bad_request", "unknown tier") |
| 413 | return |
| 414 | } |
| 415 | band := strings.ToLower(strings.TrimSpace(req.Band)) |
| 416 | if band == "" { |
| 417 | band = bandBase |
| 418 | } |
| 419 | price, _, ok := bandPrice(tier, band) |
| 420 | if !ok { |
| 421 | writeControlErr(w, http.StatusBadRequest, "bad_request", "unknown band for this tier") |
| 422 | return |
| 423 | } |
| 424 | // The same trailing-24h breaker as the free path — a pending allocation IS |
| 425 | // a create for budget purposes. |
| 426 | if s.createBudget > 0 { |
| 427 | if n, err := s.db.createsSince(time.Now().Add(-24 * time.Hour).Unix()); err == nil && n >= s.createBudget { |
| 428 | log.Printf("cloud: creation budget exhausted (%d/%d in 24h) — refusing paid create", n, s.createBudget) |
| 429 | writeControlErr(w, http.StatusServiceUnavailable, "at_capacity", spaceUnavailableMsg) |
| 430 | return |
| 431 | } |
| 432 | } |
| 433 | // Housekeeping on the way in: allocations whose checkout expired long ago |
| 434 | // (sessions die at 24h; 48h leaves margin) go quietly. |
| 435 | if n, err := s.db.prunePendingCreates(time.Now().Add(-48 * time.Hour).Unix()); err == nil && n > 0 { |
| 436 | log.Printf("cloud: pruned %d expired pending create(s)", n) |
| 437 | } |
| 438 | id, err := s.db.allocatePendingPaidSpace(callerID, req.Requester, req.Name) |
| 439 | if err != nil { |
| 440 | writeControlErr(w, http.StatusInternalServerError, "checkout_failed", "db error") |
| 441 | return |
| 442 | } |
| 443 | // A pay-first space has exactly its creator until people join, so per-seat |
| 444 | // tiers open at one seat (or the tier's included-members floor — a paid |
| 445 | // community create charges its €11 minimum up front); growth settles at |
| 446 | // the renewal true-up. |
| 447 | session, checkoutURL, err := s.billing.createCheckoutSession(id, tier, band, price, max(tier.MinSeats, 1), req.SuccessURL, req.CancelURL, true) |
| 448 | if err != nil { |
| 449 | s.db.deleteSpaceByID(id) // nothing bought yet — roll the allocation back |
| 450 | log.Printf("cloud: checkout-create (id %d, tier %s, band %s): %v", id, tier.Key, band, err) |
| 451 | writeControlErr(w, http.StatusBadGateway, "checkout_failed", "couldn't start the checkout — please try again") |
| 452 | return |
| 453 | } |
| 454 | if err := s.db.setCheckoutSession(id, session); err != nil { |
| 455 | log.Printf("cloud: record session for id %d: %v", id, err) // audit trail only — the claim re-derives everything from Stripe |
| 456 | } |
| 457 | log.Printf("cloud: checkout-create session for id %d (tier %s, band %s, caller %.8s…)", id, tier.Key, band, callerID) |
| 458 | // The session id rides back so the client can park a marker BEFORE the |
| 459 | // redirect — a tab that dies on Stripe's page still finishes next visit. |
| 460 | writeJSON(w, http.StatusOK, map[string]any{"url": checkoutURL, "session_id": session}) |
| 461 | } |
| 462 | |
| 463 | // claimCreateHandler: POST /api/instances/claim-create — turn a completed |
| 464 | // pay-first checkout into a live space. Everything trusted comes from Stripe |
| 465 | // (the caller only names a session); ownership is the pending row's |
| 466 | // (caller, requester) pair. Idempotent: a provisioned row answers 200 with its |
| 467 | // host (and the parked setup link when the first response was lost); a |
| 468 | // provisioning failure keeps the paid row so a retry finishes the job — money |
| 469 | // is never rolled back. |
| 470 | func (s *cloudServer) claimCreateHandler(w http.ResponseWriter, r *http.Request) { |
| 471 | callerID := r.Context().Value(callerKey{}).(string) |
| 472 | if s.billing == nil { |
| 473 | writeControlErr(w, http.StatusNotImplemented, "billing_unavailable", "this provisioner does not offer paid spaces") |
| 474 | return |
| 475 | } |
| 476 | var req struct { |
| 477 | Requester string `json:"requester"` |
| 478 | SessionID string `json:"session_id"` |
| 479 | } |
| 480 | if err := json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req); err != nil { |
| 481 | writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") |
| 482 | return |
| 483 | } |
| 484 | req.Requester = strings.TrimSpace(req.Requester) |
| 485 | req.SessionID = strings.TrimSpace(req.SessionID) |
| 486 | if req.Requester == "" || req.SessionID == "" { |
| 487 | writeControlErr(w, http.StatusBadRequest, "bad_request", "requester and session_id required") |
| 488 | return |
| 489 | } |
| 490 | sess, err := s.billing.retrieveCheckoutSession(req.SessionID) |
| 491 | if err != nil { |
| 492 | log.Printf("cloud: claim-create retrieve %s: %v", req.SessionID, err) |
| 493 | writeControlErr(w, http.StatusBadGateway, "claim_failed", "couldn't confirm the payment — please try again") |
| 494 | return |
| 495 | } |
| 496 | // Only a session WE minted for a pending create may provision: the marker |
| 497 | // keeps an upgrade session (same shape, real payment) from ever creating a |
| 498 | // second space. |
| 499 | id, perr := strconv.ParseInt(sess.ClientReferenceID, 10, 64) |
| 500 | if perr != nil || sess.Metadata["pending_create"] != "1" { |
| 501 | writeControlErr(w, http.StatusNotFound, "not_found", "no pending space for this checkout") |
| 502 | return |
| 503 | } |
| 504 | row, ok, err := s.db.pendingSpaceByID(id) |
| 505 | if err != nil { |
| 506 | writeControlErr(w, http.StatusInternalServerError, "claim_failed", "db error") |
| 507 | return |
| 508 | } |
| 509 | if !ok || row.CallerID != callerID || row.Requester != req.Requester { |
| 510 | writeControlErr(w, http.StatusNotFound, "not_found", "no pending space for this checkout") |
| 511 | return |
| 512 | } |
| 513 | if row.Host != "" { |
| 514 | // Already provisioned (an earlier claim finished) — answer with what |
| 515 | // exists. The parked setup link rides along when we still hold it. |
| 516 | out := map[string]any{"host": row.Host, "url": "https://" + row.Host} |
| 517 | if link := s.takeClaimLink(req.SessionID); link != "" { |
| 518 | out["setup_link"] = link |
| 519 | } |
| 520 | writeJSON(w, http.StatusOK, out) |
| 521 | return |
| 522 | } |
| 523 | if sess.PaymentStatus != "paid" { |
| 524 | writeControlErr(w, http.StatusPaymentRequired, "not_paid", "the checkout hasn't completed — finish paying first") |
| 525 | return |
| 526 | } |
| 527 | // Record what the checkout bought. The webhook usually got here first |
| 528 | // (markSpacePaid is idempotent either way); doing it ourselves means the |
| 529 | // claim never waits on webhook delivery. |
| 530 | if err := s.db.markSpacePaid(id, sess.Customer, sess.Subscription, sess.Metadata["tier"], sess.Metadata["band"]); err != nil && err != sql.ErrNoRows { |
| 531 | writeControlErr(w, http.StatusInternalServerError, "claim_failed", "db error") |
| 532 | return |
| 533 | } |
| 534 | // The normal provisioning tail — but no rollback on failure: the payment |
| 535 | // is real, so the row (entitlement paid, host NULL) survives every error |
| 536 | // below and the next claim retries the whole tail. |
| 537 | hostID, err := s.spaceHostID(r.Context(), id) |
| 538 | if err != nil { |
| 539 | log.Printf("cloud: claim-create id ledger for %d: %v", id, err) |
| 540 | writeControlErr(w, http.StatusServiceUnavailable, "claim_failed", claimRetryMsg) |
| 541 | return |
| 542 | } |
| 543 | slug, err := spaceSlug(hostID) |
| 544 | if err != nil { |
| 545 | log.Printf("cloud: claim-create slug for %d: %v", id, err) |
| 546 | writeControlErr(w, http.StatusInternalServerError, "claim_failed", claimRetryMsg) |
| 547 | return |
| 548 | } |
| 549 | host := slug + "." + s.baseDomain |
| 550 | status, body, err := s.controlCreate(host, entPaid, row.PendingName) |
| 551 | if err != nil || status != http.StatusCreated { |
| 552 | log.Printf("cloud: claim-create control for %s (id %d): status %d, %v", host, id, status, err) |
| 553 | writeControlErr(w, http.StatusBadGateway, "claim_failed", claimRetryMsg) |
| 554 | return |
| 555 | } |
| 556 | if err := s.db.finalizeSpace(id, host); err != nil { |
| 557 | log.Printf("cloud: claim-create finalize %s (id %d): %v", host, id, err) |
| 558 | } |
| 559 | log.Printf("cloud: claim-create provisioned %s (id %d, caller %.8s…)", host, id, callerID) |
| 560 | // One person so far (the creator) — the meter sweep grows a per-seat quota |
| 561 | // as members join. |
| 562 | s.pushQuota(id, host, entPaid, sess.Metadata["tier"], 1) |
| 563 | if s.usageURL != "" { |
| 564 | s.pushMeterToken(id, host) |
| 565 | } |
| 566 | if link, _ := body["setup_link"].(string); link != "" { |
| 567 | s.storeClaimLink(req.SessionID, link) |
| 568 | } |
| 569 | writeJSON(w, http.StatusCreated, map[string]any{ |
| 570 | "host": host, |
| 571 | "url": body["url"], |
| 572 | "setup_link": body["setup_link"], |
| 573 | }) |
| 574 | } |
| 575 | |
| 576 | // claimRetryMsg is the one voice for every retriable claim failure: the money |
| 577 | // is safe, the space is coming, nothing needs undoing. |
| 578 | const claimRetryMsg = "your payment is confirmed but the space couldn't be set up just now — try again in a moment" |
| 579 | |
| 580 | // portalHandler: POST /api/instances/{host}/portal — mint a Stripe Billing |
| 581 | // Portal session for one UPGRADED space, so its payer can cancel, change |
| 582 | // card, or read invoices on Stripe's hosted page. Same gates as checkout: |
| 583 | // caller token + ledger ownership. Portals are per-space by construction — |
| 584 | // each space is its own Stripe customer (checkout sends no customer identity, |
| 585 | // so there is nothing to merge across spaces, deliberately). |
| 586 | func (s *cloudServer) portalHandler(w http.ResponseWriter, r *http.Request) { |
| 587 | callerID := r.Context().Value(callerKey{}).(string) |
| 588 | if s.billing == nil { |
| 589 | writeControlErr(w, http.StatusNotImplemented, "billing_unavailable", "this provisioner does not offer upgrades") |
| 590 | return |
| 591 | } |
| 592 | host := strings.ToLower(strings.TrimSpace(r.PathValue("host"))) |
| 593 | var req struct { |
| 594 | Requester string `json:"requester"` |
| 595 | ReturnURL string `json:"return_url"` |
| 596 | } |
| 597 | if err := json.NewDecoder(io.LimitReader(r.Body, 8<<10)).Decode(&req); err != nil { |
| 598 | writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") |
| 599 | return |
| 600 | } |
| 601 | req.Requester = strings.TrimSpace(req.Requester) |
| 602 | if req.Requester == "" || !validRedirectURL(req.ReturnURL) { |
| 603 | writeControlErr(w, http.StatusBadRequest, "bad_request", "requester and return_url required") |
| 604 | return |
| 605 | } |
| 606 | id, owned, err := s.db.spaceOwned(callerID, req.Requester, host) |
| 607 | if err != nil { |
| 608 | writeControlErr(w, http.StatusInternalServerError, "portal_failed", "db error") |
| 609 | return |
| 610 | } |
| 611 | if !owned { |
| 612 | writeControlErr(w, http.StatusNotFound, "not_found", "no such space for this account") |
| 613 | return |
| 614 | } |
| 615 | customer, band, err := s.db.spaceCustomer(id) |
| 616 | if err != nil { |
| 617 | writeControlErr(w, http.StatusInternalServerError, "portal_failed", "db error") |
| 618 | return |
| 619 | } |
| 620 | if customer == "" { |
| 621 | writeControlErr(w, http.StatusConflict, "not_subscribed", "this space has no subscription to manage") |
| 622 | return |
| 623 | } |
| 624 | portalURL, err := s.billing.createPortalSession(customer, band, req.ReturnURL) |
| 625 | if err != nil { |
| 626 | log.Printf("cloud: portal for %s (id %d): %v", host, id, err) |
| 627 | writeControlErr(w, http.StatusBadGateway, "portal_failed", "couldn't open the billing page — please try again") |
| 628 | return |
| 629 | } |
| 630 | writeJSON(w, http.StatusOK, map[string]any{"url": portalURL}) |
| 631 | } |
| 632 | |
| 633 | // createPortalSession asks Stripe for a hosted Billing Portal page. The |
| 634 | // optional portal configuration id (LCHAT_STRIPE_PORTAL_CONFIG) pins which |
| 635 | // dashboard-defined feature set the portal shows; unset uses Stripe's default. |
| 636 | // With bands, LCHAT_STRIPE_PORTAL_CONFIGS picks per-band (a portal only ever |
| 637 | // offers plan switches inside the customer's own band — band changes are the |
| 638 | // home surface's job, where they can be messaged). |
| 639 | func (b *stripeBilling) createPortalSession(customer, band, returnURL string) (string, error) { |
| 640 | form := url.Values{} |
| 641 | form.Set("customer", customer) |
| 642 | form.Set("return_url", returnURL) |
| 643 | if cfg, ok := b.portalConfigs[band]; ok && cfg != "" { |
| 644 | form.Set("configuration", cfg) |
| 645 | } else if b.portalConfig != "" { |
| 646 | form.Set("configuration", b.portalConfig) |
| 647 | } |
| 648 | req, err := http.NewRequest("POST", b.apiBase+"/v1/billing_portal/sessions", strings.NewReader(form.Encode())) |
| 649 | if err != nil { |
| 650 | return "", err |
| 651 | } |
| 652 | req.Header.Set("Authorization", "Bearer "+b.key) |
| 653 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 654 | resp, err := b.client.Do(req) |
| 655 | if err != nil { |
| 656 | return "", err |
| 657 | } |
| 658 | defer resp.Body.Close() |
| 659 | var body struct { |
| 660 | URL string `json:"url"` |
| 661 | Error struct { |
| 662 | Message string `json:"message"` |
| 663 | } `json:"error"` |
| 664 | } |
| 665 | if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil { |
| 666 | return "", fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode) |
| 667 | } |
| 668 | if resp.StatusCode != http.StatusOK || body.URL == "" { |
| 669 | return "", fmt.Errorf("stripe: HTTP %d: %s", resp.StatusCode, body.Error.Message) |
| 670 | } |
| 671 | return body.URL, nil |
| 672 | } |
| 673 | |
| 674 | // cancelSubscription ends a subscription NOW (not at period end): it runs only |
| 675 | // when the space it pays for is being destroyed, and billing to the period edge |
| 676 | // for a space that no longer exists would be worse than the early cut-off. A |
| 677 | // subscription Stripe no longer knows (resource_missing) or has already |
| 678 | // canceled counts as success — the goal is "not billing", not "we did the |
| 679 | // canceling". |
| 680 | func (b *stripeBilling) cancelSubscription(subscriptionID string) error { |
| 681 | req, err := http.NewRequest("DELETE", b.apiBase+"/v1/subscriptions/"+url.PathEscape(subscriptionID), nil) |
| 682 | if err != nil { |
| 683 | return err |
| 684 | } |
| 685 | req.Header.Set("Authorization", "Bearer "+b.key) |
| 686 | resp, err := b.client.Do(req) |
| 687 | if err != nil { |
| 688 | return err |
| 689 | } |
| 690 | defer resp.Body.Close() |
| 691 | var body struct { |
| 692 | Status string `json:"status"` |
| 693 | Error struct { |
| 694 | Code string `json:"code"` |
| 695 | Message string `json:"message"` |
| 696 | } `json:"error"` |
| 697 | } |
| 698 | if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil { |
| 699 | return fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode) |
| 700 | } |
| 701 | if resp.StatusCode == http.StatusOK && body.Status == "canceled" { |
| 702 | return nil |
| 703 | } |
| 704 | if body.Error.Code == "resource_missing" { |
| 705 | return nil |
| 706 | } |
| 707 | return fmt.Errorf("stripe: HTTP %d: %s", resp.StatusCode, body.Error.Message) |
| 708 | } |
| 709 | |
| 710 | // validRedirectURL admits the http(s) URLs a checkout may bounce back to. The |
| 711 | // caller is a trusted home server, so this is belt-and-suspenders, not policy. |
| 712 | func validRedirectURL(raw string) bool { |
| 713 | u, err := url.Parse(raw) |
| 714 | return err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" |
| 715 | } |
| 716 | |
| 717 | // createCheckoutSession asks Stripe for a hosted Checkout page. The space's |
| 718 | // ledger id rides in client_reference_id (routes checkout.session.completed) |
| 719 | // and in the subscription's metadata (routes every later subscription event); |
| 720 | // the tier + band keys ride in the session metadata so completion records what |
| 721 | // was bought. price is the band-resolved Stripe price id; seats is 1 on flat |
| 722 | // tiers and the live head count on per-seat ones. pendingCreate marks a |
| 723 | // pay-first session (the row is an allocation, not a space yet) — the claim |
| 724 | // path demands the marker so an upgrade session can never provision anything. |
| 725 | // Returns the session's id and hosted-page URL. |
| 726 | func (b *stripeBilling) createCheckoutSession(spaceID int64, tier billingTier, band, price string, seats int, successURL, cancelURL string, pendingCreate bool) (sessionID, sessionURL string, err error) { |
| 727 | id := strconv.FormatInt(spaceID, 10) |
| 728 | form := url.Values{} |
| 729 | form.Set("mode", "subscription") |
| 730 | form.Set("line_items[0][price]", price) |
| 731 | form.Set("line_items[0][quantity]", strconv.Itoa(seats)) |
| 732 | form.Set("client_reference_id", id) |
| 733 | form.Set("metadata[tier]", tier.Key) |
| 734 | form.Set("metadata[band]", band) |
| 735 | if pendingCreate { |
| 736 | form.Set("metadata[pending_create]", "1") |
| 737 | } |
| 738 | form.Set("subscription_data[metadata][space_id]", id) |
| 739 | form.Set("subscription_data[metadata][tier]", tier.Key) |
| 740 | form.Set("subscription_data[metadata][band]", band) |
| 741 | form.Set("success_url", successURL) |
| 742 | form.Set("cancel_url", cancelURL) |
| 743 | // Promotion codes: lets a dashboard-minted code (e.g. an operator comp — |
| 744 | // a discounted space is entitlement-paid, so it stops counting against the |
| 745 | // free-space quota) ride the normal checkout instead of needing a side door. |
| 746 | form.Set("allow_promotion_codes", "true") |
| 747 | // Stripe Tax (docs/pricing-bands.md § VAT): consumer Prices are created |
| 748 | // tax-inclusive (the sticker is the total), Business tiers ex-VAT — so a |
| 749 | // Business checkout also collects an optional VAT ID for the EU reverse |
| 750 | // charge. Off until the operator flips the knob alongside the band Prices. |
| 751 | if b.automaticTax { |
| 752 | form.Set("automatic_tax[enabled]", "true") |
| 753 | if tier.Business { |
| 754 | form.Set("tax_id_collection[enabled]", "true") |
| 755 | } |
| 756 | } |
| 757 | |
| 758 | req, err := http.NewRequest("POST", b.apiBase+"/v1/checkout/sessions", strings.NewReader(form.Encode())) |
| 759 | if err != nil { |
| 760 | return "", "", err |
| 761 | } |
| 762 | req.Header.Set("Authorization", "Bearer "+b.key) |
| 763 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 764 | resp, err := b.client.Do(req) |
| 765 | if err != nil { |
| 766 | return "", "", err |
| 767 | } |
| 768 | defer resp.Body.Close() |
| 769 | var body struct { |
| 770 | ID string `json:"id"` |
| 771 | URL string `json:"url"` |
| 772 | Error struct { |
| 773 | Message string `json:"message"` |
| 774 | } `json:"error"` |
| 775 | } |
| 776 | if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil { |
| 777 | return "", "", fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode) |
| 778 | } |
| 779 | if resp.StatusCode != http.StatusOK { |
| 780 | return "", "", fmt.Errorf("stripe: HTTP %d: %s", resp.StatusCode, body.Error.Message) |
| 781 | } |
| 782 | if body.URL == "" { |
| 783 | return "", "", fmt.Errorf("stripe: session created but no url in response") |
| 784 | } |
| 785 | return body.ID, body.URL, nil |
| 786 | } |
| 787 | |
| 788 | // stripeCheckoutSession is the slice of a retrieved Checkout Session the claim |
| 789 | // path needs: is it paid, which allocation is it for, and what did it buy. |
| 790 | type stripeCheckoutSession struct { |
| 791 | ID string `json:"id"` |
| 792 | Mode string `json:"mode"` |
| 793 | PaymentStatus string `json:"payment_status"` |
| 794 | ClientReferenceID string `json:"client_reference_id"` |
| 795 | Customer string `json:"customer"` |
| 796 | Subscription string `json:"subscription"` |
| 797 | Metadata map[string]string `json:"metadata"` |
| 798 | } |
| 799 | |
| 800 | // retrieveCheckoutSession reads one session back from Stripe — the claim's |
| 801 | // authority. The caller only ever supplies the session ID; everything trusted |
| 802 | // (payment state, which row, what was bought) comes signed by this fetch. |
| 803 | func (b *stripeBilling) retrieveCheckoutSession(sessionID string) (stripeCheckoutSession, error) { |
| 804 | var sess stripeCheckoutSession |
| 805 | req, err := http.NewRequest("GET", b.apiBase+"/v1/checkout/sessions/"+url.PathEscape(sessionID), nil) |
| 806 | if err != nil { |
| 807 | return sess, err |
| 808 | } |
| 809 | req.Header.Set("Authorization", "Bearer "+b.key) |
| 810 | resp, err := b.client.Do(req) |
| 811 | if err != nil { |
| 812 | return sess, err |
| 813 | } |
| 814 | defer resp.Body.Close() |
| 815 | var body struct { |
| 816 | stripeCheckoutSession |
| 817 | Error struct { |
| 818 | Message string `json:"message"` |
| 819 | } `json:"error"` |
| 820 | } |
| 821 | if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil { |
| 822 | return sess, fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode) |
| 823 | } |
| 824 | if resp.StatusCode != http.StatusOK || body.ID == "" { |
| 825 | return sess, fmt.Errorf("stripe: HTTP %d: %s", resp.StatusCode, body.Error.Message) |
| 826 | } |
| 827 | return body.stripeCheckoutSession, nil |
| 828 | } |
| 829 | |
| 830 | // trueUpSeats sets a subscription's quantity to seats with |
| 831 | // proration_behavior=none: no mid-cycle charge or credit, the NEXT invoice |
| 832 | // bills the new count — the whole dropoff/growth policy in one Stripe knob |
| 833 | // (docs/tier-economics.md § Per-seat). Reads the subscription first for its |
| 834 | // item id and current quantity; a matching quantity is a no-op (changed |
| 835 | // false), so the sweep can call this repeatedly without touching Stripe state. |
| 836 | func (b *stripeBilling) trueUpSeats(subscriptionID string, seats int) (changed bool, err error) { |
| 837 | req, err := http.NewRequest("GET", b.apiBase+"/v1/subscriptions/"+subscriptionID, nil) |
| 838 | if err != nil { |
| 839 | return false, err |
| 840 | } |
| 841 | req.Header.Set("Authorization", "Bearer "+b.key) |
| 842 | resp, err := b.client.Do(req) |
| 843 | if err != nil { |
| 844 | return false, err |
| 845 | } |
| 846 | defer resp.Body.Close() |
| 847 | var sub struct { |
| 848 | Items struct { |
| 849 | Data []struct { |
| 850 | ID string `json:"id"` |
| 851 | Quantity int64 `json:"quantity"` |
| 852 | } `json:"data"` |
| 853 | } `json:"items"` |
| 854 | Error struct { |
| 855 | Message string `json:"message"` |
| 856 | } `json:"error"` |
| 857 | } |
| 858 | if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&sub); err != nil { |
| 859 | return false, fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode) |
| 860 | } |
| 861 | if resp.StatusCode != http.StatusOK || len(sub.Items.Data) == 0 { |
| 862 | return false, fmt.Errorf("stripe: HTTP %d: %s", resp.StatusCode, sub.Error.Message) |
| 863 | } |
| 864 | item := sub.Items.Data[0] |
| 865 | if item.Quantity == int64(seats) { |
| 866 | return false, nil |
| 867 | } |
| 868 | form := url.Values{} |
| 869 | form.Set("items[0][id]", item.ID) |
| 870 | form.Set("items[0][quantity]", strconv.Itoa(seats)) |
| 871 | form.Set("proration_behavior", "none") |
| 872 | upd, err := http.NewRequest("POST", b.apiBase+"/v1/subscriptions/"+subscriptionID, strings.NewReader(form.Encode())) |
| 873 | if err != nil { |
| 874 | return false, err |
| 875 | } |
| 876 | upd.Header.Set("Authorization", "Bearer "+b.key) |
| 877 | upd.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 878 | resp2, err := b.client.Do(upd) |
| 879 | if err != nil { |
| 880 | return false, err |
| 881 | } |
| 882 | defer resp2.Body.Close() |
| 883 | if resp2.StatusCode != http.StatusOK { |
| 884 | var body struct { |
| 885 | Error struct { |
| 886 | Message string `json:"message"` |
| 887 | } `json:"error"` |
| 888 | } |
| 889 | _ = json.NewDecoder(io.LimitReader(resp2.Body, 1<<20)).Decode(&body) |
| 890 | return false, fmt.Errorf("stripe: HTTP %d: %s", resp2.StatusCode, body.Error.Message) |
| 891 | } |
| 892 | return true, nil |
| 893 | } |
| 894 | |
| 895 | // ---- webhook ------------------------------------------------------------------- |
| 896 | |
| 897 | // stripeSigTolerance bounds how stale a webhook's signed timestamp may be — |
| 898 | // Stripe's own recommended replay window. |
| 899 | const stripeSigTolerance = 5 * time.Minute |
| 900 | |
| 901 | // stripeWebhookHandler: POST /api/stripe/webhook — the events that move a |
| 902 | // space's entitlement. Public (Stripe's servers call it); the signature IS the |
| 903 | // auth. Unknown event types 200 so the endpoint's Stripe-side config can list |
| 904 | // more than we handle; a DB failure 500s so Stripe retries; an event for a |
| 905 | // space we can't find 200s (retrying can never make it findable). |
| 906 | func (s *cloudServer) stripeWebhookHandler(w http.ResponseWriter, r *http.Request) { |
| 907 | if s.billing == nil { |
| 908 | writeControlErr(w, http.StatusNotImplemented, "billing_unavailable", "billing is not configured") |
| 909 | return |
| 910 | } |
| 911 | payload, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) |
| 912 | if err != nil { |
| 913 | writeControlErr(w, http.StatusBadRequest, "bad_request", "unreadable body") |
| 914 | return |
| 915 | } |
| 916 | if !verifyStripeSignature(payload, r.Header.Get("Stripe-Signature"), s.billing.webhookSecret, time.Now()) { |
| 917 | writeControlErr(w, http.StatusBadRequest, "bad_signature", "webhook signature verification failed") |
| 918 | return |
| 919 | } |
| 920 | var ev struct { |
| 921 | Type string `json:"type"` |
| 922 | Data struct { |
| 923 | Object json.RawMessage `json:"object"` |
| 924 | } `json:"data"` |
| 925 | } |
| 926 | if err := json.Unmarshal(payload, &ev); err != nil { |
| 927 | writeControlErr(w, http.StatusBadRequest, "bad_request", "invalid event JSON") |
| 928 | return |
| 929 | } |
| 930 | |
| 931 | switch { |
| 932 | case ev.Type == "checkout.session.completed": |
| 933 | err = s.handleCheckoutCompleted(ev.Data.Object) |
| 934 | case strings.HasPrefix(ev.Type, "customer.subscription."): |
| 935 | err = s.handleSubscriptionEvent(ev.Type, ev.Data.Object) |
| 936 | default: |
| 937 | // Not ours — acknowledged, never retried. |
| 938 | } |
| 939 | if err != nil { |
| 940 | log.Printf("cloud: webhook %s: %v", ev.Type, err) |
| 941 | writeControlErr(w, http.StatusInternalServerError, "webhook_failed", "event processing failed") |
| 942 | return |
| 943 | } |
| 944 | writeJSON(w, http.StatusOK, map[string]any{"received": true}) |
| 945 | } |
| 946 | |
| 947 | // handleCheckoutCompleted flips a space to paid the moment its checkout |
| 948 | // finishes, recording the opaque customer + subscription ids that route every |
| 949 | // later event. |
| 950 | func (s *cloudServer) handleCheckoutCompleted(raw json.RawMessage) error { |
| 951 | var obj struct { |
| 952 | Mode string `json:"mode"` |
| 953 | ClientReferenceID string `json:"client_reference_id"` |
| 954 | Customer string `json:"customer"` |
| 955 | Subscription string `json:"subscription"` |
| 956 | Metadata map[string]string `json:"metadata"` |
| 957 | } |
| 958 | if err := json.Unmarshal(raw, &obj); err != nil { |
| 959 | return err |
| 960 | } |
| 961 | if obj.Mode != "subscription" { |
| 962 | return nil // not an upgrade checkout |
| 963 | } |
| 964 | id, err := strconv.ParseInt(obj.ClientReferenceID, 10, 64) |
| 965 | if err != nil { |
| 966 | log.Printf("cloud: webhook checkout.session.completed without a space reference (%q) — ignoring", obj.ClientReferenceID) |
| 967 | return nil |
| 968 | } |
| 969 | switch err := s.db.markSpacePaid(id, obj.Customer, obj.Subscription, obj.Metadata["tier"], obj.Metadata["band"]); err { |
| 970 | case nil: |
| 971 | log.Printf("cloud: space id %d upgraded (checkout complete, tier %s, band %s)", id, obj.Metadata["tier"], obj.Metadata["band"]) |
| 972 | s.pushQuotaByID(id) // raise the space's storage cap to its new tier |
| 973 | s.pushReplicationByID(id) // move it onto its tier's durability rung |
| 974 | return nil |
| 975 | case sql.ErrNoRows: |
| 976 | log.Printf("cloud: webhook checkout for unknown space id %d — ignoring", id) |
| 977 | return nil // deleted since checkout began; retrying won't resurrect it |
| 978 | default: |
| 979 | return err |
| 980 | } |
| 981 | } |
| 982 | |
| 983 | // handleSubscriptionEvent tracks the subscription for the life of the upgrade: |
| 984 | // updates refresh the paid flag + period end, deletion drops the space back to |
| 985 | // free. The reaper never sees paid spaces (spacesOlderThan filters them), and a |
| 986 | // lapsed space just becomes an ordinary free one — lapse handling beyond that |
| 987 | // (backup-link mail via Stripe) is later work, by design. |
| 988 | func (s *cloudServer) handleSubscriptionEvent(evType string, raw json.RawMessage) error { |
| 989 | var obj struct { |
| 990 | ID string `json:"id"` |
| 991 | Status string `json:"status"` |
| 992 | CancelAtPeriodEnd bool `json:"cancel_at_period_end"` |
| 993 | Metadata map[string]string `json:"metadata"` |
| 994 | // Stripe API versions ≥2025-03-31 moved current_period_end from the |
| 995 | // subscription to its items; accept either shape. |
| 996 | CurrentPeriodEnd int64 `json:"current_period_end"` |
| 997 | Items struct { |
| 998 | Data []struct { |
| 999 | CurrentPeriodEnd int64 `json:"current_period_end"` |
| 1000 | Quantity int64 `json:"quantity"` |
| 1001 | Price struct { |
| 1002 | ID string `json:"id"` |
| 1003 | } `json:"price"` |
| 1004 | } `json:"data"` |
| 1005 | } `json:"items"` |
| 1006 | } |
| 1007 | if err := json.Unmarshal(raw, &obj); err != nil { |
| 1008 | return err |
| 1009 | } |
| 1010 | |
| 1011 | id, err := strconv.ParseInt(obj.Metadata["space_id"], 10, 64) |
| 1012 | if err != nil { |
| 1013 | var ok bool |
| 1014 | if id, ok, err = s.db.findSpaceBySubscription(obj.ID); err != nil { |
| 1015 | return err |
| 1016 | } else if !ok { |
| 1017 | log.Printf("cloud: webhook %s for unknown subscription %s — ignoring", evType, obj.ID) |
| 1018 | return nil |
| 1019 | } |
| 1020 | } |
| 1021 | |
| 1022 | ent := entFree |
| 1023 | if evType != "customer.subscription.deleted" { |
| 1024 | // past_due keeps access through Stripe's dunning window — losing the |
| 1025 | // space the day a card expires would be hostile; unpaid/canceled ends it. |
| 1026 | switch obj.Status { |
| 1027 | case "active", "trialing", "past_due": |
| 1028 | ent = entPaid |
| 1029 | } |
| 1030 | } |
| 1031 | paidUntil := obj.CurrentPeriodEnd |
| 1032 | // The subscription's current PRICE names the tier and band — a Billing |
| 1033 | // Portal plan switch changes the price but never the checkout-time |
| 1034 | // metadata, so the price is the truth and metadata only ever seeded the |
| 1035 | // first value. |
| 1036 | tier, band := "", "" |
| 1037 | seats := 0 |
| 1038 | for _, item := range obj.Items.Data { |
| 1039 | if item.CurrentPeriodEnd > paidUntil { |
| 1040 | paidUntil = item.CurrentPeriodEnd |
| 1041 | } |
| 1042 | if t, b, ok := s.billing.tierBandByPrice(item.Price.ID); ok { |
| 1043 | tier, band = t.Key, b |
| 1044 | // The billed head count, from Stripe's own item — the ledger's |
| 1045 | // seats always mirror what the subscription actually charges |
| 1046 | // (0 on flat tiers; the true-up compares against this). |
| 1047 | if t.PerSeat { |
| 1048 | seats = int(item.Quantity) |
| 1049 | } |
| 1050 | } |
| 1051 | } |
| 1052 | |
| 1053 | switch err := s.db.setEntitlementTier(id, ent, tier, band, seats, paidUntil, obj.CancelAtPeriodEnd); err { |
| 1054 | case nil: |
| 1055 | 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) |
| 1056 | s.pushQuotaByID(id) // entitlement/tier moved — re-apply the matching cap |
| 1057 | s.pushReplicationByID(id) // and the matching durability rung (lapse = back to free's) |
| 1058 | return nil |
| 1059 | case sql.ErrNoRows: |
| 1060 | log.Printf("cloud: webhook %s for unknown space id %d — ignoring", evType, id) |
| 1061 | return nil |
| 1062 | default: |
| 1063 | return err |
| 1064 | } |
| 1065 | } |
| 1066 | |
| 1067 | // verifyStripeSignature checks a Stripe-Signature header against the raw |
| 1068 | // payload: HMAC-SHA256(secret, "<t>.<payload>") must match one of the v1 |
| 1069 | // signatures, and t must be within the replay tolerance of now. Pure (now is a |
| 1070 | // parameter) so tests can pin time. |
| 1071 | func verifyStripeSignature(payload []byte, header, secret string, now time.Time) bool { |
| 1072 | var ts string |
| 1073 | var sigs [][]byte |
| 1074 | for _, part := range strings.Split(header, ",") { |
| 1075 | k, v, ok := strings.Cut(strings.TrimSpace(part), "=") |
| 1076 | if !ok { |
| 1077 | continue |
| 1078 | } |
| 1079 | switch k { |
| 1080 | case "t": |
| 1081 | ts = v |
| 1082 | case "v1": |
| 1083 | if sig, err := hex.DecodeString(v); err == nil { |
| 1084 | sigs = append(sigs, sig) |
| 1085 | } |
| 1086 | } |
| 1087 | } |
| 1088 | t, err := strconv.ParseInt(ts, 10, 64) |
| 1089 | if err != nil || len(sigs) == 0 { |
| 1090 | return false |
| 1091 | } |
| 1092 | if d := now.Sub(time.Unix(t, 0)); d > stripeSigTolerance || d < -stripeSigTolerance { |
| 1093 | return false |
| 1094 | } |
| 1095 | mac := hmac.New(sha256.New, []byte(secret)) |
| 1096 | mac.Write([]byte(ts)) |
| 1097 | mac.Write([]byte(".")) |
| 1098 | mac.Write(payload) |
| 1099 | want := mac.Sum(nil) |
| 1100 | for _, sig := range sigs { |
| 1101 | if hmac.Equal(sig, want) { |
| 1102 | return true |
| 1103 | } |
| 1104 | } |
| 1105 | return false |
| 1106 | } |
| 1107 | |