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// πŸ€– Paid access levels β€” member subscriptions to a space's access levels
4// (docs/community-and-tiers.md layer 3). This is the third *assigner* of a
5// tier, beside admin and invite: a member pays, and the same tier machinery
6// (assignTier + a tier-key grant on activation; unassignTier on revocation)
7// carries them in and out. Nothing here is a decryption key β€” money is roster
8// metadata (uid ↔ tier ↔ paid-until); the E2EE message content stays opaque.
9//
10// Money flows via Stripe CONNECT to the SPACE ADMIN's connected account (the
11// admin is merchant of record); the platform takes a 5.5% application fee. The
12// payment backend is a seam (PayProvider); stripeConnect is one implementation.
13// NB: docs/managed-payments.md (2026-07-22) redesigns this surface β€” managed
14// payouts become the default provider and this BYO path becomes a
15// Business-tier feature at 5.5%; the seam below is where it lands.
16// With no provider configured the feature is dormant β€” a level carries no
17// price, so members see no subscribe path. The entitlement STATE MACHINE
18// (applySubEvent) is provider-independent and unit-tested on its own; providers
19// only ever hand it normalized SubEvents.
20
21import (
22 "context"
23 "encoding/json"
24 "errors"
25 "fmt"
26 "io"
27 "net/http"
28 "net/url"
29 "os"
30 "strconv"
31 "strings"
32 "time"
33)
34
35var errBillingOff = errors.New("paid access levels are not available on this space")
36
37// The grace window after a subscription lapses (payment fails) before access is
38// finally revoked β€” the 30 days promised in docs/community-and-tiers.md. A
39// VOLUNTARY cancel gets no bonus grace: access ends exactly at the period the
40// member already paid for. Overridable in tests.
41const defaultBillingGrace = 30 * 24 * time.Hour
42
43var billingGrace = defaultBillingGrace
44
45// The platform commission on member subscriptions on the bring-your-own-Stripe
46// rail β€” the suggested rate (5.5%), charged on top of the admin's own Stripe
47// fees. Pay-what-you-want configuration replaces this constant later
48// (docs/managed-payments.md phase 3); until then everyone pays the suggestion.
49const applicationFeePercent = "5.5"
50
51// feePermille mirrors applicationFeePercent for integer money math β€” keep the
52// two in step (pinned by TestOnceFeeMatchesPercent).
53const feePermille = 55
54
55// onceFeeMinor is the platform fee on a lifetime purchase, in minor units β€”
56// payment mode has no application_fee_percent, so the same rate is applied as
57// a fixed amount at mint time. Integer floor: rounding favours the admin.
58func onceFeeMinor(amountMinor int) int {
59 return amountMinor * feePermille / 1000
60}
61
62// SubEvent is a payment provider's subscription state, normalized. A provider's
63// ParseWebhook produces these; applySubEvent is the only consumer, so adding a
64// provider never touches the state machine.
65type SubEvent struct {
66 ExtSub string // provider subscription id
67 ExtCustomer string // provider customer id
68 PriceRef string // provider price id (which level β€” resolved to a tier)
69 UserRef string // OUR user id (set as subscription metadata at checkout)
70 TierRef string // OUR tier id (also in metadata β€” the direct route)
71 Status string // "active" | "past_due" | "canceled"
72 CurrentPeriodEnd int64 // unix seconds the current paid period runs to
73 CancelAtPeriodEnd bool // member asked to cancel; access ends at CurrentPeriodEnd
74 Deleted bool // the subscription is fully gone (revoke now)
75 Created int64 // the provider event's `created` time (event ordering)
76 AllowCreate bool // a genuine "new subscription" signal (checkout completed,
77 // or subscription.created) β€” the ONLY kind that may CREATE a
78 // membership, so a bare update/delete can't resurrect a revoked one
79 Checkout bool // a checkout receipt: a creation signal carrying NO lifecycle
80 // data (no period end, no cancel state) β€” must never advance the
81 // per-sub ordering watermark (see applySubEvent)
82 // Lifetime: a one-time purchase event (docs/managed-payments.md Β§ Lifetime).
83 // A grant (AllowCreate) IS the full truth β€” no SubState resolution, no
84 // lifecycle follows; Deleted on a Lifetime event is a refund/chargeback,
85 // which revokes immediately. ExtSub carries the payment intent id.
86 Lifetime bool
87}
88
89// CheckoutReq is what a member's subscribe needs from the provider.
90type CheckoutReq struct {
91 TierID string
92 PriceRef string
93 UserRef string // our uid β€” rides as subscription metadata for the webhook
94 SuccessURL string
95 CancelURL string
96 // Once marks a lifetime purchase: mode=payment instead of subscription.
97 // Payment mode has no percent-based splits, so AmountMinor (the level's
98 // price) lets the provider compute its fixed fee/transfer server-side.
99 Once bool
100 AmountMinor int
101}
102
103// PayProvider is the payment backend seam. The no-op provider (Configured()
104// false) keeps billing dormant until real credentials are wired.
105type PayProvider interface {
106 Configured() bool
107 // SubState fetches a subscription's CURRENT state from the provider β€”
108 // found=false when the provider no longer knows it. Resolves checkout
109 // receipts (which carry no lifecycle data) and repairs rows whose
110 // lifecycle events were lost (see runBillingSweep).
111 SubState(ctx context.Context, extSub string) (SubEvent, bool, error)
112 Checkout(ctx context.Context, req CheckoutReq) (url string, err error)
113 Cancel(ctx context.Context, extSub string) error
114 // Resume un-cancels a subscription still in its paid period (clears
115 // cancel_at_period_end) β€” the correct "Resume" for an ending membership,
116 // so it never starts a second subscription that double-charges.
117 Resume(ctx context.Context, extSub string) error
118 // SwitchLevel moves an existing subscription to a different level's price
119 // (upgrade/downgrade in place, prorated), re-pointing its metadata so the
120 // next webhook routes to the new level.
121 SwitchLevel(ctx context.Context, extSub, newPriceRef, newTierRef string) error
122 // CreatePrice mints a recurring Price on the admin's account so setting a
123 // level's price needs no trip to the Stripe dashboard. Returns its id.
124 CreatePrice(ctx context.Context, name string, cents int, currency, interval string) (string, error)
125 PortalURL(ctx context.Context, extCustomer, returnURL string) (string, error)
126 // ParseWebhook verifies a provider callback and normalizes it. nil (no
127 // error) for events we ignore.
128 ParseWebhook(sig string, body []byte) ([]SubEvent, error)
129}
130
131// noPay is the dormant provider β€” everything reports "not configured".
132type noPay struct{}
133
134func (noPay) Configured() bool { return false }
135func (noPay) Checkout(context.Context, CheckoutReq) (string, error) { return "", errBillingOff }
136func (noPay) Cancel(context.Context, string) error { return errBillingOff }
137func (noPay) Resume(context.Context, string) error { return errBillingOff }
138func (noPay) SwitchLevel(context.Context, string, string, string) error { return errBillingOff }
139func (noPay) CreatePrice(context.Context, string, int, string, string) (string, error) {
140 return "", errBillingOff
141}
142func (noPay) PortalURL(context.Context, string, string) (string, error) { return "", errBillingOff }
143func (noPay) ParseWebhook(string, []byte) ([]SubEvent, error) { return nil, errBillingOff }
144func (noPay) SubState(context.Context, string) (SubEvent, bool, error) {
145 return SubEvent{}, false, errBillingOff
146}
147
148// newPayProvider wires the payment backend from env. Tri-state, like
149// newStripeBilling: all-empty β†’ dormant (noPay); a partial config β†’ dormant
150// with a warning (a half-config must never half-work); fully set β†’ the
151// provider. Managed payments (docs/managed-payments.md) take precedence when
152// their creds are present β€” managed is the default provider wherever it's
153// deployed, and BYO Connect is the fallback. The per-space account (Express
154// or connected, config table) still gates actual selling β€” so even with
155// platform creds present, an unlinked space can't sell.
156func newPayProvider(db *DB) PayProvider {
157 mkey := strings.TrimSpace(os.Getenv("LCHAT_STRIPE_MANAGED_KEY"))
158 mwh := strings.TrimSpace(os.Getenv("LCHAT_STRIPE_MANAGED_WEBHOOK_SECRET"))
159 if mkey != "" || mwh != "" {
160 if mkey == "" || mwh == "" {
161 fmt.Fprintln(os.Stderr, "subscriptions: partial managed-payments config β€” set both LCHAT_STRIPE_MANAGED_KEY and LCHAT_STRIPE_MANAGED_WEBHOOK_SECRET (paid access levels stay off)")
162 return noPay{}
163 }
164 return &stripePlatform{
165 db: db,
166 key: mkey,
167 webhookSecret: mwh,
168 apiBase: strings.TrimRight(envOr("LCHAT_STRIPE_API_BASE", "https://api.stripe.com"), "/"),
169 client: &http.Client{Timeout: 20 * time.Second},
170 }
171 }
172 key := strings.TrimSpace(os.Getenv("LCHAT_STRIPE_CONNECT_KEY"))
173 wh := strings.TrimSpace(os.Getenv("LCHAT_STRIPE_CONNECT_WEBHOOK_SECRET"))
174 if key == "" && wh == "" {
175 return noPay{}
176 }
177 if key == "" || wh == "" {
178 fmt.Fprintln(os.Stderr, "subscriptions: partial Stripe Connect config β€” set both LCHAT_STRIPE_CONNECT_KEY and LCHAT_STRIPE_CONNECT_WEBHOOK_SECRET (paid access levels stay off)")
179 return noPay{}
180 }
181 return &stripeConnect{
182 db: db,
183 key: key,
184 webhookSecret: wh,
185 apiBase: strings.TrimRight(envOr("LCHAT_STRIPE_API_BASE", "https://api.stripe.com"), "/"),
186 client: &http.Client{Timeout: 20 * time.Second},
187 }
188}
189
190// applySubEvent folds one normalized subscription event into the member's
191// entitlement and returns whether the membership should be ACTIVE (access
192// granted) right now. The caller grants/revokes keys off that. Pure over the
193// clock (now passed in) so it's unit-testable.
194//
195// Rules (docs/community-and-tiers.md Β§ revocation):
196// - active, not cancelling β†’ paid through CurrentPeriodEnd, no end in sight.
197// - active, cancel-at-period-end β†’ keep access until CurrentPeriodEnd, then
198// revoke (a voluntary cancel gets no bonus grace).
199// - past_due (payment failed) β†’ keep access through the 30-day grace from the
200// last paid-until, then revoke.
201// - deleted/canceled outright β†’ revoke on the next sweep.
202func applySubEvent(db *DB, ev SubEvent, tierID, userID string, now int64) (active bool, err error) {
203 e := Entitlement{TierID: tierID, UserID: userID, ExtSub: ev.ExtSub, ExtCustomer: ev.ExtCustomer}
204 switch {
205 case ev.Lifetime && (ev.Deleted || ev.Status == "canceled"):
206 // A refund/chargeback un-buys a lifetime purchase: revoke immediately β€”
207 // grace windows exist to survive a failing card, and nothing is failing.
208 e.Status = "canceled"
209 e.Lifetime = true
210 e.GraceDeadline = now
211 active = false
212 case ev.Lifetime:
213 // Paid once, in forever: no period end, no grace clock. The lifetime
214 // flag exempts the row from every renewal-shaped sweep (db.go).
215 e.Status = "active"
216 e.Lifetime = true
217 active = true
218 case ev.Deleted || ev.Status == "canceled":
219 e.Status = "canceled"
220 e.PaidUntil = ev.CurrentPeriodEnd
221 e.GraceDeadline = now // revoke on the next sweep
222 active = false
223 case ev.Status == "past_due":
224 e.Status = "past_due"
225 paidUntil := ev.CurrentPeriodEnd
226 if paidUntil == 0 {
227 if prev, ok := db.memberEntitlement(tierID, userID); ok && prev.PaidUntil > 0 {
228 paidUntil = prev.PaidUntil
229 } else {
230 paidUntil = now
231 }
232 }
233 e.PaidUntil = paidUntil
234 e.GraceDeadline = paidUntil + int64(billingGrace.Seconds())
235 active = now < e.GraceDeadline
236 case ev.CancelAtPeriodEnd:
237 e.Status = "canceled"
238 e.PaidUntil = ev.CurrentPeriodEnd
239 e.GraceDeadline = ev.CurrentPeriodEnd
240 active = now < ev.CurrentPeriodEnd
241 default: // active, healthy
242 e.Status = "active"
243 e.PaidUntil = ev.CurrentPeriodEnd
244 // checkout.session.completed carries no period end (0). If a later-numbered
245 // but earlier-arriving .updated already stored the real renewal date, don't
246 // stomp it back to 0 (the Membership screen would show a blank date).
247 if e.PaidUntil == 0 {
248 if prev, ok := db.memberEntitlement(tierID, userID); ok && prev.PaidUntil > 0 {
249 e.PaidUntil = prev.PaidUntil
250 }
251 }
252 e.GraceDeadline = 0
253 active = true
254 }
255 // A checkout receipt must not advance the per-sub ordering watermark: Stripe
256 // stamps checkout.session.completed AFTER customer.subscription.created but
257 // can deliver it FIRST β€” if the receipt claimed the watermark, the created
258 // event (the only one carrying the real period end) would be dropped as
259 // stale and paid_until stuck at 0 (blank renewal date, and the silent-lapse
260 // backstop in entitlementsDueForRevoke never applies).
261 if !ev.Checkout {
262 e.EventTS = ev.Created
263 }
264 if err := db.setEntitlement(e); err != nil {
265 return false, err
266 }
267 return active, nil
268}
269
270// runBillingSweep revokes paid memberships whose grace window has elapsed β€”
271// dropping the tier key + membership (unassignTier), the same service-level
272// revocation an admin does by hand. Mirrors miniapps.go's sweep shape: query,
273// per-item action tolerant of errors, returns a count so tests call it
274// directly. No-op when nothing is due.
275func (s *Server) runBillingSweep(now int64) int {
276 due, err := s.db.entitlementsDueForRevoke(now)
277 if err != nil {
278 return 0
279 }
280 n := 0
281 for _, e := range due {
282 // revokeEntitlement drops the tier key + membership for a pure-stripe
283 // membership, or just clears the sub tracking when an admin/invite grant
284 // sits underneath (that access must survive a payment lapse).
285 if _, err := s.db.revokeEntitlement(e.TierID, e.UserID); err != nil {
286 continue
287 }
288 n++
289 s.prodTiersChanged(e.UserID) // their tabs learn their access ended
290 }
291 s.repairEntitlements(now)
292 return n
293}
294
295// repairEntitlements re-fetches provider truth for rows stuck ACTIVE with no
296// period end. Those rows lost their lifecycle events (e.g. only a checkout
297// receipt ever arrived β€” the pre-#846 watermark bug β€” or the instance slept
298// past the provider's webhook retry window); neither revoke clause in
299// entitlementsDueForRevoke can ever match them, so without this they'd hold
300// access forever. Capped per sweep so a bad day can't turn the sweep into a
301// provider crawl.
302func (s *Server) repairEntitlements(now int64) {
303 if s.pay == nil || !s.pay.Configured() {
304 return
305 }
306 broken, err := s.db.entitlementsNeedingRepair(20)
307 if err != nil {
308 return
309 }
310 for _, e := range broken {
311 ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
312 cur, found, err := s.pay.SubState(ctx, e.ExtSub)
313 cancel()
314 if err != nil {
315 continue // transient β€” the next sweep retries
316 }
317 s.subFold.Lock()
318 if !found {
319 if _, err := s.db.revokeEntitlement(e.TierID, e.UserID); err == nil {
320 s.prodTiersChanged(e.UserID)
321 }
322 } else {
323 // Out-of-band truth, not an ordered event: fold without advancing
324 // the per-sub watermark, exactly like a checkout receipt. The row's
325 // sub id is what we fetched by β€” keep it authoritative.
326 cur.ExtSub = e.ExtSub
327 cur.Checkout = true
328 s.foldAndSettle(cur, e.TierID, e.UserID, now)
329 }
330 s.subFold.Unlock()
331 }
332}
333
334// startBillingSweep runs the grace→revoke sweep hourly for the instance's life.
335func (s *Server) startBillingSweep() {
336 go func() {
337 t := time.NewTicker(time.Hour)
338 defer t.Stop()
339 for range t.C {
340 s.runBillingSweep(time.Now().Unix())
341 }
342 }()
343}
344
345// ---- Stripe Connect provider -------------------------------------------------
346
347// stripeConnect charges on the space admin's connected account (Stripe-Account
348// header), with the platform's application fee on top. Hand-rolled like
349// billing.go β€” the surface is a few form-encoded POSTs plus the shared
350// verifyStripeSignature. The connected account id lives in the space's config
351// (set at onboarding); with none set, nothing sells.
352type stripeConnect struct {
353 db *DB
354 key string // platform secret key
355 webhookSecret string
356 apiBase string
357 client *http.Client
358}
359
360// connectAccount is the space admin's Stripe connected account id ("acct_…"),
361// set when they link their account. Empty β†’ the space can't sell yet.
362func (p *stripeConnect) connectAccount() string {
363 v, _, _ := p.db.getConfig("stripe_connect_account")
364 return v
365}
366
367func (p *stripeConnect) Configured() bool { return p.connectAccount() != "" }
368
369// post form-encodes to the Stripe API on the admin's connected account.
370func (p *stripeConnect) post(ctx context.Context, path string, form url.Values) (map[string]any, error) {
371 acct := p.connectAccount()
372 if acct == "" {
373 return nil, errBillingOff
374 }
375 req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+path, strings.NewReader(form.Encode()))
376 if err != nil {
377 return nil, err
378 }
379 req.Header.Set("Authorization", "Bearer "+p.key)
380 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
381 req.Header.Set("Stripe-Account", acct) // charge on the admin's account
382 resp, err := p.client.Do(req)
383 if err != nil {
384 return nil, err
385 }
386 defer resp.Body.Close()
387 var body map[string]any
388 if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
389 return nil, fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode)
390 }
391 if resp.StatusCode != http.StatusOK {
392 msg := ""
393 if e, ok := body["error"].(map[string]any); ok {
394 msg, _ = e["message"].(string)
395 }
396 return nil, fmt.Errorf("stripe: HTTP %d: %s", resp.StatusCode, msg)
397 }
398 return body, nil
399}
400
401func (p *stripeConnect) Checkout(ctx context.Context, r CheckoutReq) (string, error) {
402 form := url.Values{}
403 form.Set("line_items[0][price]", r.PriceRef)
404 form.Set("line_items[0][quantity]", "1")
405 form.Set("success_url", r.SuccessURL)
406 form.Set("cancel_url", r.CancelURL)
407 form.Set("metadata[user_ref]", r.UserRef)
408 form.Set("metadata[tier_ref]", r.TierID)
409 if r.Once {
410 // Lifetime purchase: one payment, no subscription. Payment mode takes
411 // the platform fee as a fixed amount, computed from the same percent.
412 // The payment intent carries the routing metadata too, so a later
413 // refund/chargeback (charge events reference the intent, not the
414 // session) can find its way back to this membership.
415 form.Set("mode", "payment")
416 form.Set("payment_intent_data[application_fee_amount]", strconv.Itoa(onceFeeMinor(r.AmountMinor)))
417 form.Set("payment_intent_data[metadata][user_ref]", r.UserRef)
418 form.Set("payment_intent_data[metadata][tier_ref]", r.TierID)
419 } else {
420 form.Set("mode", "subscription")
421 // The platform fee, and the metadata that routes every later
422 // webhook back to (this member, this level).
423 form.Set("subscription_data[application_fee_percent]", applicationFeePercent)
424 form.Set("subscription_data[metadata][user_ref]", r.UserRef)
425 form.Set("subscription_data[metadata][tier_ref]", r.TierID)
426 }
427 body, err := p.post(ctx, "/v1/checkout/sessions", form)
428 if err != nil {
429 return "", err
430 }
431 u, _ := body["url"].(string)
432 if u == "" {
433 return "", fmt.Errorf("stripe: session created but no url")
434 }
435 return u, nil
436}
437
438func (p *stripeConnect) Cancel(ctx context.Context, extSub string) error {
439 // Cancel at period end: access continues until the paid period runs out, no
440 // further charge. (Stripe emits customer.subscription.updated with
441 // cancel_at_period_end=true, which our webhook turns into the ending state.)
442 form := url.Values{}
443 form.Set("cancel_at_period_end", "true")
444 _, err := p.post(ctx, "/v1/subscriptions/"+url.PathEscape(extSub), form)
445 return err
446}
447
448func (p *stripeConnect) Resume(ctx context.Context, extSub string) error {
449 // Un-cancel: clear cancel_at_period_end on the still-live subscription. No new
450 // subscription, no new charge β€” the member simply keeps the one they have.
451 form := url.Values{}
452 form.Set("cancel_at_period_end", "false")
453 _, err := p.post(ctx, "/v1/subscriptions/"+url.PathEscape(extSub), form)
454 return err
455}
456
457func (p *stripeConnect) SwitchLevel(ctx context.Context, extSub, newPriceRef, newTierRef string) error {
458 // Fetch the current item id (Stripe needs it to replace the price), then
459 // update the subscription's single item to the new price with proration,
460 // and re-point the metadata so the resulting webhook routes to the new
461 // level. (One item per membership subscription, by construction.)
462 sub, err := p.get(ctx, "/v1/subscriptions/"+url.PathEscape(extSub))
463 if err != nil {
464 return err
465 }
466 itemID := ""
467 if items, ok := sub["items"].(map[string]any); ok {
468 if data, ok := items["data"].([]any); ok && len(data) > 0 {
469 if it, ok := data[0].(map[string]any); ok {
470 itemID, _ = it["id"].(string)
471 }
472 }
473 }
474 if itemID == "" {
475 return fmt.Errorf("stripe: subscription %s has no item to switch", extSub)
476 }
477 form := url.Values{}
478 form.Set("items[0][id]", itemID)
479 form.Set("items[0][price]", newPriceRef)
480 form.Set("proration_behavior", "create_prorations")
481 form.Set("metadata[tier_ref]", newTierRef)
482 _, err = p.post(ctx, "/v1/subscriptions/"+url.PathEscape(extSub), form)
483 return err
484}
485
486// get reads a Stripe object on the connected account.
487func (p *stripeConnect) get(ctx context.Context, path string) (map[string]any, error) {
488 acct := p.connectAccount()
489 if acct == "" {
490 return nil, errBillingOff
491 }
492 req, err := http.NewRequestWithContext(ctx, "GET", p.apiBase+path, nil)
493 if err != nil {
494 return nil, err
495 }
496 req.Header.Set("Authorization", "Bearer "+p.key)
497 req.Header.Set("Stripe-Account", acct)
498 resp, err := p.client.Do(req)
499 if err != nil {
500 return nil, err
501 }
502 defer resp.Body.Close()
503 var body map[string]any
504 if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
505 return nil, fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode)
506 }
507 if resp.StatusCode != http.StatusOK {
508 return nil, fmt.Errorf("stripe: HTTP %d", resp.StatusCode)
509 }
510 return body, nil
511}
512
513func (p *stripeConnect) PortalURL(ctx context.Context, extCustomer, returnURL string) (string, error) {
514 form := url.Values{}
515 form.Set("customer", extCustomer)
516 form.Set("return_url", returnURL)
517 body, err := p.post(ctx, "/v1/billing_portal/sessions", form)
518 if err != nil {
519 return "", err
520 }
521 u, _ := body["url"].(string)
522 return u, nil
523}
524
525// ParseWebhook verifies the Connect webhook signature (shared with billing.go)
526// and normalizes checkout.session.completed + customer.subscription.* into
527// SubEvents. Unknown events β†’ nil, nil (acknowledged, never retried).
528func (p *stripeConnect) ParseWebhook(sig string, body []byte) ([]SubEvent, error) {
529 if !verifyStripeSignature(body, sig, p.webhookSecret, time.Now()) {
530 return nil, errors.New("webhook signature verification failed")
531 }
532 var ev struct {
533 Type string `json:"type"`
534 Account string `json:"account"` // the connected account this event is for
535 Created int64 `json:"created"`
536 Data struct {
537 Object json.RawMessage `json:"object"`
538 } `json:"data"`
539 }
540 if err := json.Unmarshal(body, &ev); err != nil {
541 return nil, err
542 }
543 // A Connect platform endpoint receives EVERY connected account's events, all
544 // signed with the one platform secret β€” so a valid signature is not proof the
545 // event is ours. Bind to THIS space's connected account, or an attacker who
546 // onboards their own account could forge metadata to grant free access. Drop
547 // (ack, don't retry) anything for another account.
548 if acct := p.connectAccount(); acct == "" || ev.Account != acct {
549 return nil, nil
550 }
551 switch {
552 case ev.Type == "checkout.session.completed":
553 var o struct {
554 Mode string `json:"mode"`
555 Customer string `json:"customer"`
556 Subscription string `json:"subscription"`
557 PaymentIntent string `json:"payment_intent"`
558 Metadata map[string]string `json:"metadata"`
559 }
560 if err := json.Unmarshal(ev.Data.Object, &o); err != nil {
561 return nil, nil
562 }
563 // A payment-mode session with tier routing is a LIFETIME purchase β€”
564 // the one event is the whole truth (no lifecycle follows), so it is
565 // not a receipt to resolve (Checkout=false) and its id is the payment
566 // intent. Payment-mode sessions without tier_ref are someone else's
567 // commerce; dropped.
568 if o.Mode == "payment" {
569 if o.Metadata["tier_ref"] == "" || o.PaymentIntent == "" {
570 return nil, nil
571 }
572 return []SubEvent{{
573 ExtSub: o.PaymentIntent, ExtCustomer: o.Customer,
574 UserRef: o.Metadata["user_ref"], TierRef: o.Metadata["tier_ref"],
575 Status: "active", Created: ev.Created, AllowCreate: true, Lifetime: true,
576 }}, nil
577 }
578 if o.Mode != "subscription" {
579 return nil, nil
580 }
581 return []SubEvent{{
582 ExtSub: o.Subscription, ExtCustomer: o.Customer,
583 UserRef: o.Metadata["user_ref"], TierRef: o.Metadata["tier_ref"],
584 Status: "active", Created: ev.Created, AllowCreate: true, Checkout: true,
585 }}, nil
586 case ev.Type == "charge.refunded" || ev.Type == "charge.dispute.created":
587 return lifetimeRevokeEvent(ev.Data.Object, ev.Created)
588 case strings.HasPrefix(ev.Type, "customer.subscription."):
589 var o subObject
590 if err := json.Unmarshal(ev.Data.Object, &o); err != nil {
591 return nil, err
592 }
593 se := o.subEvent()
594 se.Deleted = ev.Type == "customer.subscription.deleted"
595 se.Created = ev.Created
596 se.AllowCreate = ev.Type == "customer.subscription.created"
597 return []SubEvent{se}, nil
598 }
599 return nil, nil
600}
601
602// lifetimeRevokeEvent normalizes a refund/dispute into the event that un-buys
603// a lifetime purchase. Both charge.refunded and charge.dispute.created carry
604// the payment intent plus the metadata stamped at checkout; anything without
605// tier routing is other commerce on the same account (subscription refunds
606// resolve through their own lifecycle) β€” dropped. Shared by both providers.
607func lifetimeRevokeEvent(raw json.RawMessage, created int64) ([]SubEvent, error) {
608 var o struct {
609 PaymentIntent string `json:"payment_intent"`
610 Metadata map[string]string `json:"metadata"`
611 }
612 if err := json.Unmarshal(raw, &o); err != nil {
613 return nil, nil
614 }
615 if o.PaymentIntent == "" || o.Metadata["tier_ref"] == "" {
616 return nil, nil
617 }
618 return []SubEvent{{
619 ExtSub: o.PaymentIntent,
620 UserRef: o.Metadata["user_ref"], TierRef: o.Metadata["tier_ref"],
621 Status: "canceled", Created: created, Deleted: true, Lifetime: true,
622 }}, nil
623}
624
625// subObject is the slice of a provider subscription we act on β€” the same
626// shape arrives in webhook payloads and from a SubState fetch.
627type subObject struct {
628 ID string `json:"id"`
629 Customer string `json:"customer"`
630 Status string `json:"status"`
631 CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
632 CurrentPeriodEnd int64 `json:"current_period_end"`
633 Metadata map[string]string `json:"metadata"`
634 Items struct {
635 Data []struct {
636 CurrentPeriodEnd int64 `json:"current_period_end"`
637 } `json:"data"`
638 } `json:"items"`
639}
640
641// subEvent normalizes the object; the caller stamps the event-only fields
642// (Created, Deleted, AllowCreate).
643func (o *subObject) subEvent() SubEvent {
644 cpe := o.CurrentPeriodEnd
645 if cpe == 0 && len(o.Items.Data) > 0 { // newer API: period end moved to the item
646 cpe = o.Items.Data[0].CurrentPeriodEnd
647 }
648 // Stripe's active|trialing keep access; past_due keeps it too but starts
649 // the grace clock; canceled/unpaid end it.
650 status := "canceled"
651 switch o.Status {
652 case "active", "trialing":
653 status = "active"
654 case "past_due", "unpaid":
655 status = "past_due"
656 }
657 return SubEvent{
658 ExtSub: o.ID, ExtCustomer: o.Customer,
659 UserRef: o.Metadata["user_ref"], TierRef: o.Metadata["tier_ref"],
660 Status: status,
661 CurrentPeriodEnd: cpe,
662 CancelAtPeriodEnd: o.CancelAtPeriodEnd,
663 }
664}
665
666// SubState fetches the subscription's current truth from Stripe. Checkout
667// receipts are resolved through this (see applyWebhookEvent): a receipt
668// carries no lifecycle data, and trusting one blind could plant an active
669// row with no period end β€” invisible to every sweep β€” or resurrect a
670// membership whose cancellation already folded.
671func (p *stripeConnect) SubState(ctx context.Context, extSub string) (SubEvent, bool, error) {
672 acct := p.connectAccount()
673 if acct == "" {
674 return SubEvent{}, false, errBillingOff
675 }
676 req, err := http.NewRequestWithContext(ctx, "GET", p.apiBase+"/v1/subscriptions/"+url.PathEscape(extSub), nil)
677 if err != nil {
678 return SubEvent{}, false, err
679 }
680 req.Header.Set("Authorization", "Bearer "+p.key)
681 req.Header.Set("Stripe-Account", acct)
682 resp, err := p.client.Do(req)
683 if err != nil {
684 return SubEvent{}, false, err
685 }
686 defer resp.Body.Close()
687 if resp.StatusCode == http.StatusNotFound {
688 return SubEvent{}, false, nil // Stripe no longer knows this sub
689 }
690 var o subObject
691 if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&o); err != nil {
692 return SubEvent{}, false, fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode)
693 }
694 if resp.StatusCode != http.StatusOK {
695 return SubEvent{}, false, fmt.Errorf("stripe: HTTP %d", resp.StatusCode)
696 }
697 return o.subEvent(), true, nil
698}
699
700func (p *stripeConnect) CreatePrice(ctx context.Context, name string, cents int, currency, interval string) (string, error) {
701 // A recurring price with an inline product (product_data), so one call
702 // makes both. The connected account owns them.
703 form := url.Values{}
704 form.Set("unit_amount", strconv.Itoa(cents))
705 form.Set("currency", strings.ToLower(currency))
706 if interval != "once" { // a lifetime level's Price is one-time
707 form.Set("recurring[interval]", interval)
708 }
709 form.Set("product_data[name]", name)
710 body, err := p.post(ctx, "/v1/prices", form)
711 if err != nil {
712 return "", err
713 }
714 id, _ := body["id"].(string)
715 if id == "" {
716 return "", fmt.Errorf("stripe: price created but no id")
717 }
718 return id, nil
719}
720