eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package 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
25import (
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.
48type 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.
86type 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.
96const bandBase = "base"
97
98var 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.
102type 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.
123func (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.
136func (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.
152func 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.
167func (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.
202func 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.
258func (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.
353func (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
367func (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.
382func (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.
470func (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.
578const 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).
586func (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).
639func (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".
680func (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.
712func 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.
726func (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.
790type 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.
803func (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.
836func (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.
899const 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).
906func (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.
950func (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.
988func (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.
1071func 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