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// πŸ€– HTTP surface for paid access levels (subscriptions.go). Admin: link the
4// Stripe account, set/clear a level's price. Member: subscribe, change level,
5// cancel, open the billing portal. Public: the Stripe Connect webhook (its
6// signature is the auth). All member/admin routes are token-gated; the whole
7// feature is dormant when no payment provider is configured.
8
9import (
10 "context"
11 "encoding/json"
12 "io"
13 "net/http"
14 "regexp"
15 "strings"
16 "time"
17)
18
19// stripeAccountIDRe accepts both Stripe account id shapes: classic "acct_…"
20// and the newer unprefixed opaque ids β€” alphanumeric/underscore, sane length.
21var stripeAccountIDRe = regexp.MustCompile(`^[A-Za-z0-9_]{8,64}$`)
22
23// supportedCurrency gates prices to 2-decimal currencies the UI renders. The
24// whole system treats amounts as minor units (cents/100); a zero-decimal
25// currency (JPY, KRW…) would be charged 100Γ— at Stripe. Widen only alongside
26// zero-decimal handling.
27func supportedCurrency(code string) bool {
28 switch strings.ToLower(strings.TrimSpace(code)) {
29 case "usd", "eur", "gbp":
30 return true
31 }
32 return false
33}
34
35// origin reconstructs this instance's public origin for return URLs.
36func (s *Server) origin(r *http.Request) string {
37 scheme := "https"
38 // The router terminates TLS and proxies over a unix socket, so r.TLS is nil
39 // in production; trust the forwarded proto, defaulting to https.
40 if r.TLS == nil && !strings.HasPrefix(r.Host, "localhost") && r.Header.Get("X-Forwarded-Proto") == "http" {
41 scheme = "http"
42 }
43 return scheme + "://" + r.Host
44}
45
46// postBillingAccount (admin) links the space's Stripe connected account,
47// where members' payments land (the admin is merchant of record). Classic
48// accounts are "acct_…", but Stripe's newer accounts carry unprefixed opaque
49// ids (seen live 2026-07-18: a fresh live connect returned a bare 32-char
50// id) β€” so validate shape, never a prefix.
51// The id arrives from the OAuth return leg (completeOAuthReturn in shell.js β€”
52// the broker exchanged the code and forwarded only the account id), posted
53// under the admin's own session so a callback alone can never link a space.
54func (s *Server) postBillingAccount(w http.ResponseWriter, r *http.Request) {
55 var body struct {
56 Token string `json:"token"`
57 Account string `json:"account"`
58 }
59 if json.NewDecoder(r.Body).Decode(&body) != nil {
60 httpError(w, http.StatusBadRequest, "bad request")
61 return
62 }
63 if _, ok := s.tierAdmin(w, body.Token); !ok {
64 return
65 }
66 acct := strings.TrimSpace(body.Account)
67 // The id comes from Stripe via the broker (the paste box is gone), so the
68 // only job here is rejecting something that plainly isn't an account id.
69 if acct != "" && !stripeAccountIDRe.MatchString(acct) {
70 httpError(w, http.StatusBadRequest, "that doesn't look like a Stripe account id")
71 return
72 }
73 if err := s.db.setConfig("stripe_connect_account", acct); err != nil {
74 httpError(w, http.StatusInternalServerError, "db error")
75 return
76 }
77 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
78}
79
80// getBillingStatus (admin) reports whether paid levels can be sold here: is a
81// provider configured at all, and is the space's connected account linked.
82func (s *Server) getBillingStatus(w http.ResponseWriter, r *http.Request) {
83 if _, ok := s.tierAdmin(w, r.URL.Query().Get("token")); !ok {
84 return
85 }
86 acct, _, _ := s.db.getConfig("stripe_connect_account")
87 _, platform := s.pay.(*stripeConnect) // a real provider is wired at the platform level
88 writeJSON(w, http.StatusOK, map[string]any{
89 "platform": platform, // Stripe keys present on this box
90 "available": s.pay != nil && s.pay.Configured(), // AND this space linked an account
91 "account": acct != "",
92 "account_id": acct, // shown to admins ("connected as acct_…"); not a secret
93 })
94}
95
96// postTierPrice (admin) sets or clears a level's subscription price. cents=0
97// clears it (admin/invite-only again). When a provider is configured and no
98// provider_ref is supplied, a Stripe Price is minted on the admin's account so
99// the admin never touches the dashboard.
100func (s *Server) postTierPrice(w http.ResponseWriter, r *http.Request) {
101 var body struct {
102 Token string `json:"token"`
103 Cents int `json:"cents"`
104 Currency string `json:"currency"`
105 Interval string `json:"interval"`
106 ProviderRef string `json:"provider_ref"`
107 }
108 if json.NewDecoder(r.Body).Decode(&body) != nil {
109 httpError(w, http.StatusBadRequest, "bad request")
110 return
111 }
112 if _, ok := s.tierAdmin(w, body.Token); !ok {
113 return
114 }
115 tierID := r.PathValue("id")
116 if !s.db.tierExists(tierID) {
117 httpError(w, http.StatusBadRequest, "no such level")
118 return
119 }
120 if body.Cents == 0 { // clear the price β†’ not sellable
121 if err := s.db.setTierPrice(tierID, 0, "", "", ""); err != nil {
122 httpError(w, http.StatusInternalServerError, "db error")
123 return
124 }
125 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
126 return
127 }
128 // Only 2-decimal currencies we render β€” cents are minor units everywhere
129 // (price/100 in the UI, unit_amount at Stripe). A zero-decimal currency like
130 // JPY would be charged 100Γ— (Stripe reads unit_amount as whole yen), so gate
131 // to the allowlist rather than accept any 3-letter code.
132 if body.Cents < 0 || body.Cents > 100_000_00 ||
133 (body.Interval != "month" && body.Interval != "year" && body.Interval != "once") ||
134 !supportedCurrency(body.Currency) {
135 httpError(w, http.StatusBadRequest, "bad price")
136 return
137 }
138 // Lifetime levels floor at 11.00 (docs/managed-payments.md Β§ Lifetime):
139 // one payment carries the fixed card fee and the forever promise β€” priced
140 // too low it's a fee sink and a support burden. Subscriptions' floors
141 // arrive with the phase-3 repricing.
142 if body.Interval == "once" && body.Cents < 1100 {
143 httpError(w, http.StatusBadRequest, "lifetime access starts at 11.00")
144 return
145 }
146 ref := strings.TrimSpace(body.ProviderRef)
147 if ref == "" && s.pay != nil && s.pay.Configured() {
148 created, err := s.pay.CreatePrice(r.Context(), s.db.tierName(tierID), body.Cents, body.Currency, body.Interval)
149 if err != nil {
150 httpError(w, http.StatusBadGateway, "couldn't set up the price with the payment provider")
151 return
152 }
153 ref = created
154 }
155 if err := s.db.setTierPrice(tierID, body.Cents, strings.ToLower(body.Currency), body.Interval, ref); err != nil {
156 httpError(w, http.StatusInternalServerError, "db error")
157 return
158 }
159 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
160}
161
162// postTierSubscribe (member) starts a checkout for a sellable level. Returns a
163// hosted payment URL to send the member to; entitlement is granted by the
164// webhook once payment completes.
165func (s *Server) postTierSubscribe(w http.ResponseWriter, r *http.Request) {
166 var body struct {
167 Token string `json:"token"`
168 }
169 if json.NewDecoder(r.Body).Decode(&body) != nil {
170 httpError(w, http.StatusBadRequest, "bad request")
171 return
172 }
173 uid, ok := s.memberFor(w, body.Token)
174 if !ok {
175 return
176 }
177 if s.pay == nil || !s.pay.Configured() {
178 httpError(w, http.StatusServiceUnavailable, "subscriptions aren't available on this space")
179 return
180 }
181 tierID := r.PathValue("id")
182 cents, _, interval, ref, sellable := s.db.tierPrice(tierID)
183 if !sellable || ref == "" {
184 httpError(w, http.StatusBadRequest, "this level isn't for sale")
185 return
186 }
187 // Never open a second checkout for a member who already has a subscription
188 // here β€” a fresh Checkout would create a duplicate live subscription and
189 // double-charge. An 'ending' (cancel-at-period-end) sub resumes; an active
190 // one is already theirs.
191 if ent, ok := s.db.memberEntitlement(tierID, uid); ok && ent.ExtSub != "" {
192 httpError(w, http.StatusConflict, "you already have a subscription to this level")
193 return
194 }
195 // They must hold a keypair so they can be granted the level key on
196 // activation β€” otherwise they'd pay and see nothing until they publish one.
197 if s.db.userPublicKey(uid) == "" {
198 httpError(w, http.StatusConflict, "your account is still setting up β€” try again in a moment")
199 return
200 }
201 origin := s.origin(r)
202 u, err := s.pay.Checkout(r.Context(), CheckoutReq{
203 TierID: tierID, PriceRef: ref, UserRef: uid,
204 Once: interval == "once", AmountMinor: cents,
205 SuccessURL: origin + "/?subscribed=1",
206 CancelURL: origin + "/",
207 })
208 if err != nil {
209 httpError(w, http.StatusBadGateway, "couldn't start checkout β€” try again")
210 return
211 }
212 writeJSON(w, http.StatusOK, map[string]any{"url": u})
213}
214
215// postTierChange (member) switches an existing subscription to a different
216// level's price in place (prorated). Body names the target level.
217func (s *Server) postTierChange(w http.ResponseWriter, r *http.Request) {
218 var body struct {
219 Token string `json:"token"`
220 ToTier string `json:"to_tier"`
221 }
222 if json.NewDecoder(r.Body).Decode(&body) != nil {
223 httpError(w, http.StatusBadRequest, "bad request")
224 return
225 }
226 uid, ok := s.memberFor(w, body.Token)
227 if !ok {
228 return
229 }
230 if s.pay == nil || !s.pay.Configured() {
231 httpError(w, http.StatusServiceUnavailable, "subscriptions aren't available on this space")
232 return
233 }
234 fromTier := r.PathValue("id")
235 ent, have := s.db.memberEntitlement(fromTier, uid)
236 if !have || ent.ExtSub == "" {
237 httpError(w, http.StatusBadRequest, "you don't have a subscription to change")
238 return
239 }
240 if ent.Lifetime {
241 httpError(w, http.StatusBadRequest, "this is a one-time purchase β€” buy the other level outright instead of switching")
242 return
243 }
244 _, _, toInterval, ref, sellable := s.db.tierPrice(body.ToTier)
245 if !sellable || ref == "" {
246 httpError(w, http.StatusBadRequest, "that level isn't for sale")
247 return
248 }
249 // A subscription can't be switched ONTO a one-time price (SwitchLevel
250 // updates the sub's item; a non-recurring price can't be a sub item).
251 if toInterval == "once" {
252 httpError(w, http.StatusBadRequest, "that level is a one-time purchase β€” buy it outright instead of switching")
253 return
254 }
255 if err := s.pay.SwitchLevel(r.Context(), ent.ExtSub, ref, body.ToTier); err != nil {
256 httpError(w, http.StatusBadGateway, "couldn't change your plan β€” try again")
257 return
258 }
259 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
260}
261
262// postTierCancelSub (member) cancels their subscription at period end β€” access
263// continues until the paid period runs out (the webhook flips it to "ending").
264func (s *Server) postTierCancelSub(w http.ResponseWriter, r *http.Request) {
265 var body struct {
266 Token string `json:"token"`
267 }
268 if json.NewDecoder(r.Body).Decode(&body) != nil {
269 httpError(w, http.StatusBadRequest, "bad request")
270 return
271 }
272 uid, ok := s.memberFor(w, body.Token)
273 if !ok {
274 return
275 }
276 if s.pay == nil || !s.pay.Configured() {
277 httpError(w, http.StatusServiceUnavailable, "subscriptions aren't available on this space")
278 return
279 }
280 ent, have := s.db.memberEntitlement(r.PathValue("id"), uid)
281 if !have || ent.ExtSub == "" {
282 httpError(w, http.StatusBadRequest, "no subscription to cancel")
283 return
284 }
285 if ent.Lifetime {
286 httpError(w, http.StatusBadRequest, "this is a one-time purchase β€” nothing renews, nothing to cancel")
287 return
288 }
289 if err := s.pay.Cancel(r.Context(), ent.ExtSub); err != nil {
290 httpError(w, http.StatusBadGateway, "couldn't cancel β€” try again")
291 return
292 }
293 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
294}
295
296// postTierResume (member) un-cancels a subscription still in its paid period β€”
297// clears cancel-at-period-end so it keeps renewing, no new charge. This is what
298// the "Resume" control does; it must never open a fresh checkout (which would
299// create a second, double-charging subscription).
300func (s *Server) postTierResume(w http.ResponseWriter, r *http.Request) {
301 var body struct {
302 Token string `json:"token"`
303 }
304 if json.NewDecoder(r.Body).Decode(&body) != nil {
305 httpError(w, http.StatusBadRequest, "bad request")
306 return
307 }
308 uid, ok := s.memberFor(w, body.Token)
309 if !ok {
310 return
311 }
312 if s.pay == nil || !s.pay.Configured() {
313 httpError(w, http.StatusServiceUnavailable, "subscriptions aren't available on this space")
314 return
315 }
316 ent, have := s.db.memberEntitlement(r.PathValue("id"), uid)
317 if !have || ent.ExtSub == "" {
318 httpError(w, http.StatusBadRequest, "no subscription to resume")
319 return
320 }
321 if ent.Lifetime {
322 httpError(w, http.StatusBadRequest, "this is a one-time purchase β€” it never ends, so there's nothing to resume")
323 return
324 }
325 if err := s.pay.Resume(r.Context(), ent.ExtSub); err != nil {
326 httpError(w, http.StatusBadGateway, "couldn't resume β€” try again")
327 return
328 }
329 writeJSON(w, http.StatusOK, map[string]any{"ok": true})
330}
331
332// postTierPortal (member) returns a Stripe Billing Portal URL for managing the
333// card / invoices / cancellation of their subscription to this level.
334func (s *Server) postTierPortal(w http.ResponseWriter, r *http.Request) {
335 var body struct {
336 Token string `json:"token"`
337 }
338 if json.NewDecoder(r.Body).Decode(&body) != nil {
339 httpError(w, http.StatusBadRequest, "bad request")
340 return
341 }
342 uid, ok := s.memberFor(w, body.Token)
343 if !ok {
344 return
345 }
346 if s.pay == nil || !s.pay.Configured() {
347 httpError(w, http.StatusServiceUnavailable, "subscriptions aren't available on this space")
348 return
349 }
350 ent, have := s.db.memberEntitlement(r.PathValue("id"), uid)
351 if !have || ent.ExtCustomer == "" {
352 httpError(w, http.StatusBadRequest, "no subscription to manage")
353 return
354 }
355 if ent.Lifetime {
356 httpError(w, http.StatusBadRequest, "this is a one-time purchase β€” there's no ongoing billing to manage")
357 return
358 }
359 u, err := s.pay.PortalURL(r.Context(), ent.ExtCustomer, s.origin(r)+"/")
360 if err != nil || u == "" {
361 httpError(w, http.StatusBadGateway, "couldn't open billing β€” try again")
362 return
363 }
364 writeJSON(w, http.StatusOK, map[string]any{"url": u})
365}
366
367// stripeConnectWebhook (public β€” the signature is the auth) drives entitlement
368// off the payment provider's subscription events. Reuses the tier machinery:
369// on active, assignTier + a key prod; on lapse/cancel past grace, unassignTier.
370func (s *Server) stripeConnectWebhook(w http.ResponseWriter, r *http.Request) {
371 if s.pay == nil || !s.pay.Configured() {
372 httpError(w, http.StatusNotImplemented, "billing not configured")
373 return
374 }
375 payload, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
376 if err != nil {
377 httpError(w, http.StatusBadRequest, "unreadable body")
378 return
379 }
380 events, err := s.pay.ParseWebhook(r.Header.Get("Stripe-Signature"), payload)
381 if err != nil {
382 httpError(w, http.StatusBadRequest, "bad signature")
383 return
384 }
385 now := time.Now().Unix()
386 // One fold at a time: the ordering decision (read the watermark, then
387 // write the row) spans separate statements, and Stripe delivers webhooks
388 // concurrently β€” two racing events for one sub could both pass the
389 // watermark check and fold in the wrong order.
390 s.subFold.Lock()
391 defer s.subFold.Unlock()
392 failed := false
393 for _, ev := range events {
394 if err := s.applyWebhookEvent(r.Context(), ev, now); err != nil {
395 failed = true
396 }
397 }
398 if failed {
399 // A member may have paid but not been activated β€” 500 so Stripe retries
400 // (a swallowed error under a 200 would strand the payment).
401 httpError(w, http.StatusInternalServerError, "event processing failed")
402 return
403 }
404 writeJSON(w, http.StatusOK, map[string]any{"received": true})
405}
406
407// applyWebhookEvent resolves the event to (tier, member) and folds it in,
408// returning an error only for a genuine processing failure (so the caller can
409// let the provider retry). The guards defend the money path against Connect's
410// realities β€” cross-account forgery, at-least-once + reordered delivery, and
411// events for members/subs that no longer match our records:
412//
413// - identity: resolve (tier,user) from event metadata, falling back to the
414// recorded subscription; require BOTH the tier and the user to still exist.
415// - ordering: ignore an event older than the last one folded for this sub
416// (Stripe redelivers and can reorder).
417// - no-resurrect: only a checkout may CREATE a membership; a bare
418// update/delete for a sub we no longer track is dropped (so a redelivered
419// pre-cancellation "active" can't revive a revoked membership).
420// - revoke safety: a lapse/cancel revokes ONLY if the event's subscription is
421// still the one we have on file (a superseded sub's late delete can't kick
422// a member who has since re-subscribed), and it keeps any admin/invite grant
423// underneath (revokeEntitlement is source-aware).
424func (s *Server) applyWebhookEvent(ctx context.Context, ev SubEvent, now int64) error {
425 // A checkout receipt carries no lifecycle data, and Stripe may deliver it
426 // before, after, or INSTEAD OF the subscription events (delivery is
427 // unordered and retries are finite). Resolve the receipt to the sub's
428 // current state, so a receipt alone can never plant a degenerate
429 // active/paid_until=0 row (invisible to every sweep), and a receipt
430 // redelivered after a cancel+revoke can't resurrect the membership.
431 if ev.Checkout && ev.ExtSub != "" && s.pay != nil {
432 cur, found, err := s.pay.SubState(ctx, ev.ExtSub)
433 if err != nil {
434 return err // transient β€” the webhook 500s and the provider retries
435 }
436 if !found {
437 return nil // the sub is gone at the provider; grant nothing
438 }
439 ev.Status = cur.Status
440 ev.CurrentPeriodEnd = cur.CurrentPeriodEnd
441 ev.CancelAtPeriodEnd = cur.CancelAtPeriodEnd
442 // Still Checkout=true: fetched state is out-of-band truth, not an
443 // ordered event β€” it must not advance the per-sub watermark.
444 }
445 // Lifetime purchases have no provider state to resolve, so their
446 // no-resurrect guard is the refund tombstone: a refund/chargeback marks
447 // the payment intent dead before revoking (revocation deletes the row and
448 // its watermark), and any grant for a dead intent β€” a redelivery, however
449 // late β€” is dropped.
450 if ev.Lifetime {
451 if ev.Deleted {
452 if err := s.db.markPaymentRevoked(ev.ExtSub); err != nil {
453 return err
454 }
455 } else if s.db.isPaymentRevoked(ev.ExtSub) {
456 return nil
457 }
458 }
459 tierID, userID := ev.TierRef, ev.UserRef
460 bySub, haveBySub := s.db.entitlementBySub(ev.ExtSub)
461 if tierID == "" || userID == "" {
462 if haveBySub {
463 tierID, userID = bySub.TierID, bySub.UserID
464 }
465 }
466 if tierID == "" || userID == "" || !s.db.tierExists(tierID) || !s.db.userExists(userID) {
467 return nil // nothing actionable β€” ack, don't retry
468 }
469 // Ordering: drop an event strictly older than the last one folded for this
470 // subscription (Stripe redelivers and can reorder).
471 if haveBySub && ev.Created > 0 && bySub.EventTS > 0 && ev.Created < bySub.EventTS {
472 return nil
473 }
474 // When this event's subscription isn't one we track, decide before touching
475 // anything (applySubEvent would overwrite the recorded ext_sub):
476 // - member already holds a DIFFERENT current subscription here β†’ this event
477 // is for a superseded/replaced sub; ignore it (a late delete for the old
478 // sub must not kick a member who re-subscribed).
479 // - member holds none here β†’ only a genuine new-subscription signal may
480 // CREATE the membership; a bare update/delete is a stale redelivery that
481 // would resurrect a revoked membership, so drop it.
482 if !haveBySub {
483 if _, haveCur := s.db.memberEntitlement(tierID, userID); haveCur {
484 return nil
485 } else if !ev.AllowCreate {
486 return nil
487 }
488 }
489 // A level switch (SwitchLevel re-pointed the sub's metadata) moves the
490 // membership: drop the old level before the new one takes over.
491 if haveBySub && bySub.TierID != tierID {
492 if _, err := s.db.revokeEntitlement(bySub.TierID, userID); err != nil {
493 return err
494 }
495 s.prodTiersChanged(userID)
496 }
497 return s.foldAndSettle(ev, tierID, userID, now)
498}
499
500// foldAndSettle folds a normalized event and settles access: grant + key prod
501// when the membership is active, revoke otherwise (source-aware β€” an
502// admin/invite grant underneath survives). Shared by the webhook path and the
503// sweep's repair pass.
504func (s *Server) foldAndSettle(ev SubEvent, tierID, userID string, now int64) error {
505 active, err := applySubEvent(s.db, ev, tierID, userID, now)
506 if err != nil {
507 return err // transient β€” let Stripe retry (webhook responds 500)
508 }
509 if active {
510 if err := s.db.assignTier(tierID, userID, "stripe"); err != nil {
511 return err
512 }
513 s.prodTierHolders(tierID, userID) // an online holder wraps the level key to them
514 s.prodTiersChanged(userID)
515 return nil
516 }
517 // Not active (past grace, or an outright cancel/deletion) β€” revoke, keeping
518 // any admin/invite grant underneath (revokeEntitlement is source-aware).
519 if _, err := s.db.revokeEntitlement(tierID, userID); err != nil {
520 return err
521 }
522 s.prodTiersChanged(userID)
523 return nil
524}
525
526// memberFor resolves a token to a member user id, writing the error otherwise.
527func (s *Server) memberFor(w http.ResponseWriter, token string) (string, bool) {
528 if !s.tokenOK(token) {
529 httpError(w, http.StatusForbidden, "not a member")
530 return "", false
531 }
532 uid, ok := s.db.deviceTokenUser(token)
533 if !ok {
534 httpError(w, http.StatusForbidden, "no user for token")
535 return "", false
536 }
537 return uid, true
538}
539