| 1 | package 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 | |
| 9 | import ( |
| 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. |
| 21 | var 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. |
| 27 | func 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. |
| 36 | func (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. |
| 54 | func (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. |
| 82 | func (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. |
| 100 | func (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. |
| 165 | func (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. |
| 217 | func (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"). |
| 264 | func (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). |
| 300 | func (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. |
| 334 | func (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. |
| 370 | func (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). |
| 424 | func (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. |
| 504 | func (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. |
| 527 | func (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 | |