package main // ๐Ÿค– HTTP surface for paid access levels (subscriptions.go). Admin: link the // Stripe account, set/clear a level's price. Member: subscribe, change level, // cancel, open the billing portal. Public: the Stripe Connect webhook (its // signature is the auth). All member/admin routes are token-gated; the whole // feature is dormant when no payment provider is configured. import ( "context" "encoding/json" "io" "net/http" "regexp" "strings" "time" ) // stripeAccountIDRe accepts both Stripe account id shapes: classic "acct_โ€ฆ" // and the newer unprefixed opaque ids โ€” alphanumeric/underscore, sane length. var stripeAccountIDRe = regexp.MustCompile(`^[A-Za-z0-9_]{8,64}$`) // supportedCurrency gates prices to 2-decimal currencies the UI renders. The // whole system treats amounts as minor units (cents/100); a zero-decimal // currency (JPY, KRWโ€ฆ) would be charged 100ร— at Stripe. Widen only alongside // zero-decimal handling. func supportedCurrency(code string) bool { switch strings.ToLower(strings.TrimSpace(code)) { case "usd", "eur", "gbp": return true } return false } // origin reconstructs this instance's public origin for return URLs. func (s *Server) origin(r *http.Request) string { scheme := "https" // The router terminates TLS and proxies over a unix socket, so r.TLS is nil // in production; trust the forwarded proto, defaulting to https. if r.TLS == nil && !strings.HasPrefix(r.Host, "localhost") && r.Header.Get("X-Forwarded-Proto") == "http" { scheme = "http" } return scheme + "://" + r.Host } // postBillingAccount (admin) links the space's Stripe connected account, // where members' payments land (the admin is merchant of record). Classic // accounts are "acct_โ€ฆ", but Stripe's newer accounts carry unprefixed opaque // ids (seen live 2026-07-18: a fresh live connect returned a bare 32-char // id) โ€” so validate shape, never a prefix. // The id arrives from the OAuth return leg (completeOAuthReturn in shell.js โ€” // the broker exchanged the code and forwarded only the account id), posted // under the admin's own session so a callback alone can never link a space. func (s *Server) postBillingAccount(w http.ResponseWriter, r *http.Request) { var body struct { Token string `json:"token"` Account string `json:"account"` } if json.NewDecoder(r.Body).Decode(&body) != nil { httpError(w, http.StatusBadRequest, "bad request") return } if _, ok := s.tierAdmin(w, body.Token); !ok { return } acct := strings.TrimSpace(body.Account) // The id comes from Stripe via the broker (the paste box is gone), so the // only job here is rejecting something that plainly isn't an account id. if acct != "" && !stripeAccountIDRe.MatchString(acct) { httpError(w, http.StatusBadRequest, "that doesn't look like a Stripe account id") return } if err := s.db.setConfig("stripe_connect_account", acct); err != nil { httpError(w, http.StatusInternalServerError, "db error") return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // getBillingStatus (admin) reports whether paid levels can be sold here: is a // provider configured at all, and is the space's connected account linked. func (s *Server) getBillingStatus(w http.ResponseWriter, r *http.Request) { if _, ok := s.tierAdmin(w, r.URL.Query().Get("token")); !ok { return } acct, _, _ := s.db.getConfig("stripe_connect_account") _, platform := s.pay.(*stripeConnect) // a real provider is wired at the platform level writeJSON(w, http.StatusOK, map[string]any{ "platform": platform, // Stripe keys present on this box "available": s.pay != nil && s.pay.Configured(), // AND this space linked an account "account": acct != "", "account_id": acct, // shown to admins ("connected as acct_โ€ฆ"); not a secret }) } // postTierPrice (admin) sets or clears a level's subscription price. cents=0 // clears it (admin/invite-only again). When a provider is configured and no // provider_ref is supplied, a Stripe Price is minted on the admin's account so // the admin never touches the dashboard. func (s *Server) postTierPrice(w http.ResponseWriter, r *http.Request) { var body struct { Token string `json:"token"` Cents int `json:"cents"` Currency string `json:"currency"` Interval string `json:"interval"` ProviderRef string `json:"provider_ref"` } if json.NewDecoder(r.Body).Decode(&body) != nil { httpError(w, http.StatusBadRequest, "bad request") return } if _, ok := s.tierAdmin(w, body.Token); !ok { return } tierID := r.PathValue("id") if !s.db.tierExists(tierID) { httpError(w, http.StatusBadRequest, "no such level") return } if body.Cents == 0 { // clear the price โ†’ not sellable if err := s.db.setTierPrice(tierID, 0, "", "", ""); err != nil { httpError(w, http.StatusInternalServerError, "db error") return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) return } // Only 2-decimal currencies we render โ€” cents are minor units everywhere // (price/100 in the UI, unit_amount at Stripe). A zero-decimal currency like // JPY would be charged 100ร— (Stripe reads unit_amount as whole yen), so gate // to the allowlist rather than accept any 3-letter code. if body.Cents < 0 || body.Cents > 100_000_00 || (body.Interval != "month" && body.Interval != "year" && body.Interval != "once") || !supportedCurrency(body.Currency) { httpError(w, http.StatusBadRequest, "bad price") return } // Lifetime levels floor at 11.00 (docs/managed-payments.md ยง Lifetime): // one payment carries the fixed card fee and the forever promise โ€” priced // too low it's a fee sink and a support burden. Subscriptions' floors // arrive with the phase-3 repricing. if body.Interval == "once" && body.Cents < 1100 { httpError(w, http.StatusBadRequest, "lifetime access starts at 11.00") return } ref := strings.TrimSpace(body.ProviderRef) if ref == "" && s.pay != nil && s.pay.Configured() { created, err := s.pay.CreatePrice(r.Context(), s.db.tierName(tierID), body.Cents, body.Currency, body.Interval) if err != nil { httpError(w, http.StatusBadGateway, "couldn't set up the price with the payment provider") return } ref = created } if err := s.db.setTierPrice(tierID, body.Cents, strings.ToLower(body.Currency), body.Interval, ref); err != nil { httpError(w, http.StatusInternalServerError, "db error") return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // postTierSubscribe (member) starts a checkout for a sellable level. Returns a // hosted payment URL to send the member to; entitlement is granted by the // webhook once payment completes. func (s *Server) postTierSubscribe(w http.ResponseWriter, r *http.Request) { var body struct { Token string `json:"token"` } if json.NewDecoder(r.Body).Decode(&body) != nil { httpError(w, http.StatusBadRequest, "bad request") return } uid, ok := s.memberFor(w, body.Token) if !ok { return } if s.pay == nil || !s.pay.Configured() { httpError(w, http.StatusServiceUnavailable, "subscriptions aren't available on this space") return } tierID := r.PathValue("id") cents, _, interval, ref, sellable := s.db.tierPrice(tierID) if !sellable || ref == "" { httpError(w, http.StatusBadRequest, "this level isn't for sale") return } // Never open a second checkout for a member who already has a subscription // here โ€” a fresh Checkout would create a duplicate live subscription and // double-charge. An 'ending' (cancel-at-period-end) sub resumes; an active // one is already theirs. if ent, ok := s.db.memberEntitlement(tierID, uid); ok && ent.ExtSub != "" { httpError(w, http.StatusConflict, "you already have a subscription to this level") return } // They must hold a keypair so they can be granted the level key on // activation โ€” otherwise they'd pay and see nothing until they publish one. if s.db.userPublicKey(uid) == "" { httpError(w, http.StatusConflict, "your account is still setting up โ€” try again in a moment") return } origin := s.origin(r) u, err := s.pay.Checkout(r.Context(), CheckoutReq{ TierID: tierID, PriceRef: ref, UserRef: uid, Once: interval == "once", AmountMinor: cents, SuccessURL: origin + "/?subscribed=1", CancelURL: origin + "/", }) if err != nil { httpError(w, http.StatusBadGateway, "couldn't start checkout โ€” try again") return } writeJSON(w, http.StatusOK, map[string]any{"url": u}) } // postTierChange (member) switches an existing subscription to a different // level's price in place (prorated). Body names the target level. func (s *Server) postTierChange(w http.ResponseWriter, r *http.Request) { var body struct { Token string `json:"token"` ToTier string `json:"to_tier"` } if json.NewDecoder(r.Body).Decode(&body) != nil { httpError(w, http.StatusBadRequest, "bad request") return } uid, ok := s.memberFor(w, body.Token) if !ok { return } if s.pay == nil || !s.pay.Configured() { httpError(w, http.StatusServiceUnavailable, "subscriptions aren't available on this space") return } fromTier := r.PathValue("id") ent, have := s.db.memberEntitlement(fromTier, uid) if !have || ent.ExtSub == "" { httpError(w, http.StatusBadRequest, "you don't have a subscription to change") return } if ent.Lifetime { httpError(w, http.StatusBadRequest, "this is a one-time purchase โ€” buy the other level outright instead of switching") return } _, _, toInterval, ref, sellable := s.db.tierPrice(body.ToTier) if !sellable || ref == "" { httpError(w, http.StatusBadRequest, "that level isn't for sale") return } // A subscription can't be switched ONTO a one-time price (SwitchLevel // updates the sub's item; a non-recurring price can't be a sub item). if toInterval == "once" { httpError(w, http.StatusBadRequest, "that level is a one-time purchase โ€” buy it outright instead of switching") return } if err := s.pay.SwitchLevel(r.Context(), ent.ExtSub, ref, body.ToTier); err != nil { httpError(w, http.StatusBadGateway, "couldn't change your plan โ€” try again") return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // postTierCancelSub (member) cancels their subscription at period end โ€” access // continues until the paid period runs out (the webhook flips it to "ending"). func (s *Server) postTierCancelSub(w http.ResponseWriter, r *http.Request) { var body struct { Token string `json:"token"` } if json.NewDecoder(r.Body).Decode(&body) != nil { httpError(w, http.StatusBadRequest, "bad request") return } uid, ok := s.memberFor(w, body.Token) if !ok { return } if s.pay == nil || !s.pay.Configured() { httpError(w, http.StatusServiceUnavailable, "subscriptions aren't available on this space") return } ent, have := s.db.memberEntitlement(r.PathValue("id"), uid) if !have || ent.ExtSub == "" { httpError(w, http.StatusBadRequest, "no subscription to cancel") return } if ent.Lifetime { httpError(w, http.StatusBadRequest, "this is a one-time purchase โ€” nothing renews, nothing to cancel") return } if err := s.pay.Cancel(r.Context(), ent.ExtSub); err != nil { httpError(w, http.StatusBadGateway, "couldn't cancel โ€” try again") return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // postTierResume (member) un-cancels a subscription still in its paid period โ€” // clears cancel-at-period-end so it keeps renewing, no new charge. This is what // the "Resume" control does; it must never open a fresh checkout (which would // create a second, double-charging subscription). func (s *Server) postTierResume(w http.ResponseWriter, r *http.Request) { var body struct { Token string `json:"token"` } if json.NewDecoder(r.Body).Decode(&body) != nil { httpError(w, http.StatusBadRequest, "bad request") return } uid, ok := s.memberFor(w, body.Token) if !ok { return } if s.pay == nil || !s.pay.Configured() { httpError(w, http.StatusServiceUnavailable, "subscriptions aren't available on this space") return } ent, have := s.db.memberEntitlement(r.PathValue("id"), uid) if !have || ent.ExtSub == "" { httpError(w, http.StatusBadRequest, "no subscription to resume") return } if ent.Lifetime { httpError(w, http.StatusBadRequest, "this is a one-time purchase โ€” it never ends, so there's nothing to resume") return } if err := s.pay.Resume(r.Context(), ent.ExtSub); err != nil { httpError(w, http.StatusBadGateway, "couldn't resume โ€” try again") return } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // postTierPortal (member) returns a Stripe Billing Portal URL for managing the // card / invoices / cancellation of their subscription to this level. func (s *Server) postTierPortal(w http.ResponseWriter, r *http.Request) { var body struct { Token string `json:"token"` } if json.NewDecoder(r.Body).Decode(&body) != nil { httpError(w, http.StatusBadRequest, "bad request") return } uid, ok := s.memberFor(w, body.Token) if !ok { return } if s.pay == nil || !s.pay.Configured() { httpError(w, http.StatusServiceUnavailable, "subscriptions aren't available on this space") return } ent, have := s.db.memberEntitlement(r.PathValue("id"), uid) if !have || ent.ExtCustomer == "" { httpError(w, http.StatusBadRequest, "no subscription to manage") return } if ent.Lifetime { httpError(w, http.StatusBadRequest, "this is a one-time purchase โ€” there's no ongoing billing to manage") return } u, err := s.pay.PortalURL(r.Context(), ent.ExtCustomer, s.origin(r)+"/") if err != nil || u == "" { httpError(w, http.StatusBadGateway, "couldn't open billing โ€” try again") return } writeJSON(w, http.StatusOK, map[string]any{"url": u}) } // stripeConnectWebhook (public โ€” the signature is the auth) drives entitlement // off the payment provider's subscription events. Reuses the tier machinery: // on active, assignTier + a key prod; on lapse/cancel past grace, unassignTier. func (s *Server) stripeConnectWebhook(w http.ResponseWriter, r *http.Request) { if s.pay == nil || !s.pay.Configured() { httpError(w, http.StatusNotImplemented, "billing not configured") return } payload, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { httpError(w, http.StatusBadRequest, "unreadable body") return } events, err := s.pay.ParseWebhook(r.Header.Get("Stripe-Signature"), payload) if err != nil { httpError(w, http.StatusBadRequest, "bad signature") return } now := time.Now().Unix() // One fold at a time: the ordering decision (read the watermark, then // write the row) spans separate statements, and Stripe delivers webhooks // concurrently โ€” two racing events for one sub could both pass the // watermark check and fold in the wrong order. s.subFold.Lock() defer s.subFold.Unlock() failed := false for _, ev := range events { if err := s.applyWebhookEvent(r.Context(), ev, now); err != nil { failed = true } } if failed { // A member may have paid but not been activated โ€” 500 so Stripe retries // (a swallowed error under a 200 would strand the payment). httpError(w, http.StatusInternalServerError, "event processing failed") return } writeJSON(w, http.StatusOK, map[string]any{"received": true}) } // applyWebhookEvent resolves the event to (tier, member) and folds it in, // returning an error only for a genuine processing failure (so the caller can // let the provider retry). The guards defend the money path against Connect's // realities โ€” cross-account forgery, at-least-once + reordered delivery, and // events for members/subs that no longer match our records: // // - identity: resolve (tier,user) from event metadata, falling back to the // recorded subscription; require BOTH the tier and the user to still exist. // - ordering: ignore an event older than the last one folded for this sub // (Stripe redelivers and can reorder). // - no-resurrect: only a checkout may CREATE a membership; a bare // update/delete for a sub we no longer track is dropped (so a redelivered // pre-cancellation "active" can't revive a revoked membership). // - revoke safety: a lapse/cancel revokes ONLY if the event's subscription is // still the one we have on file (a superseded sub's late delete can't kick // a member who has since re-subscribed), and it keeps any admin/invite grant // underneath (revokeEntitlement is source-aware). func (s *Server) applyWebhookEvent(ctx context.Context, ev SubEvent, now int64) error { // A checkout receipt carries no lifecycle data, and Stripe may deliver it // before, after, or INSTEAD OF the subscription events (delivery is // unordered and retries are finite). Resolve the receipt to the sub's // current state, so a receipt alone can never plant a degenerate // active/paid_until=0 row (invisible to every sweep), and a receipt // redelivered after a cancel+revoke can't resurrect the membership. if ev.Checkout && ev.ExtSub != "" && s.pay != nil { cur, found, err := s.pay.SubState(ctx, ev.ExtSub) if err != nil { return err // transient โ€” the webhook 500s and the provider retries } if !found { return nil // the sub is gone at the provider; grant nothing } ev.Status = cur.Status ev.CurrentPeriodEnd = cur.CurrentPeriodEnd ev.CancelAtPeriodEnd = cur.CancelAtPeriodEnd // Still Checkout=true: fetched state is out-of-band truth, not an // ordered event โ€” it must not advance the per-sub watermark. } // Lifetime purchases have no provider state to resolve, so their // no-resurrect guard is the refund tombstone: a refund/chargeback marks // the payment intent dead before revoking (revocation deletes the row and // its watermark), and any grant for a dead intent โ€” a redelivery, however // late โ€” is dropped. if ev.Lifetime { if ev.Deleted { if err := s.db.markPaymentRevoked(ev.ExtSub); err != nil { return err } } else if s.db.isPaymentRevoked(ev.ExtSub) { return nil } } tierID, userID := ev.TierRef, ev.UserRef bySub, haveBySub := s.db.entitlementBySub(ev.ExtSub) if tierID == "" || userID == "" { if haveBySub { tierID, userID = bySub.TierID, bySub.UserID } } if tierID == "" || userID == "" || !s.db.tierExists(tierID) || !s.db.userExists(userID) { return nil // nothing actionable โ€” ack, don't retry } // Ordering: drop an event strictly older than the last one folded for this // subscription (Stripe redelivers and can reorder). if haveBySub && ev.Created > 0 && bySub.EventTS > 0 && ev.Created < bySub.EventTS { return nil } // When this event's subscription isn't one we track, decide before touching // anything (applySubEvent would overwrite the recorded ext_sub): // - member already holds a DIFFERENT current subscription here โ†’ this event // is for a superseded/replaced sub; ignore it (a late delete for the old // sub must not kick a member who re-subscribed). // - member holds none here โ†’ only a genuine new-subscription signal may // CREATE the membership; a bare update/delete is a stale redelivery that // would resurrect a revoked membership, so drop it. if !haveBySub { if _, haveCur := s.db.memberEntitlement(tierID, userID); haveCur { return nil } else if !ev.AllowCreate { return nil } } // A level switch (SwitchLevel re-pointed the sub's metadata) moves the // membership: drop the old level before the new one takes over. if haveBySub && bySub.TierID != tierID { if _, err := s.db.revokeEntitlement(bySub.TierID, userID); err != nil { return err } s.prodTiersChanged(userID) } return s.foldAndSettle(ev, tierID, userID, now) } // foldAndSettle folds a normalized event and settles access: grant + key prod // when the membership is active, revoke otherwise (source-aware โ€” an // admin/invite grant underneath survives). Shared by the webhook path and the // sweep's repair pass. func (s *Server) foldAndSettle(ev SubEvent, tierID, userID string, now int64) error { active, err := applySubEvent(s.db, ev, tierID, userID, now) if err != nil { return err // transient โ€” let Stripe retry (webhook responds 500) } if active { if err := s.db.assignTier(tierID, userID, "stripe"); err != nil { return err } s.prodTierHolders(tierID, userID) // an online holder wraps the level key to them s.prodTiersChanged(userID) return nil } // Not active (past grace, or an outright cancel/deletion) โ€” revoke, keeping // any admin/invite grant underneath (revokeEntitlement is source-aware). if _, err := s.db.revokeEntitlement(tierID, userID); err != nil { return err } s.prodTiersChanged(userID) return nil } // memberFor resolves a token to a member user id, writing the error otherwise. func (s *Server) memberFor(w http.ResponseWriter, token string) (string, bool) { if !s.tokenOK(token) { httpError(w, http.StatusForbidden, "not a member") return "", false } uid, ok := s.db.deviceTokenUser(token) if !ok { httpError(w, http.StatusForbidden, "no user for token") return "", false } return uid, true }