package main // πŸ€– Paid access levels β€” member subscriptions to a space's access levels // (docs/community-and-tiers.md layer 3). This is the third *assigner* of a // tier, beside admin and invite: a member pays, and the same tier machinery // (assignTier + a tier-key grant on activation; unassignTier on revocation) // carries them in and out. Nothing here is a decryption key β€” money is roster // metadata (uid ↔ tier ↔ paid-until); the E2EE message content stays opaque. // // Money flows via Stripe CONNECT to the SPACE ADMIN's connected account (the // admin is merchant of record); the platform takes a 5.5% application fee. The // payment backend is a seam (PayProvider); stripeConnect is one implementation. // NB: docs/managed-payments.md (2026-07-22) redesigns this surface β€” managed // payouts become the default provider and this BYO path becomes a // Business-tier feature at 5.5%; the seam below is where it lands. // With no provider configured the feature is dormant β€” a level carries no // price, so members see no subscribe path. The entitlement STATE MACHINE // (applySubEvent) is provider-independent and unit-tested on its own; providers // only ever hand it normalized SubEvents. import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" ) var errBillingOff = errors.New("paid access levels are not available on this space") // The grace window after a subscription lapses (payment fails) before access is // finally revoked β€” the 30 days promised in docs/community-and-tiers.md. A // VOLUNTARY cancel gets no bonus grace: access ends exactly at the period the // member already paid for. Overridable in tests. const defaultBillingGrace = 30 * 24 * time.Hour var billingGrace = defaultBillingGrace // The platform commission on member subscriptions on the bring-your-own-Stripe // rail β€” the suggested rate (5.5%), charged on top of the admin's own Stripe // fees. Pay-what-you-want configuration replaces this constant later // (docs/managed-payments.md phase 3); until then everyone pays the suggestion. const applicationFeePercent = "5.5" // feePermille mirrors applicationFeePercent for integer money math β€” keep the // two in step (pinned by TestOnceFeeMatchesPercent). const feePermille = 55 // onceFeeMinor is the platform fee on a lifetime purchase, in minor units β€” // payment mode has no application_fee_percent, so the same rate is applied as // a fixed amount at mint time. Integer floor: rounding favours the admin. func onceFeeMinor(amountMinor int) int { return amountMinor * feePermille / 1000 } // SubEvent is a payment provider's subscription state, normalized. A provider's // ParseWebhook produces these; applySubEvent is the only consumer, so adding a // provider never touches the state machine. type SubEvent struct { ExtSub string // provider subscription id ExtCustomer string // provider customer id PriceRef string // provider price id (which level β€” resolved to a tier) UserRef string // OUR user id (set as subscription metadata at checkout) TierRef string // OUR tier id (also in metadata β€” the direct route) Status string // "active" | "past_due" | "canceled" CurrentPeriodEnd int64 // unix seconds the current paid period runs to CancelAtPeriodEnd bool // member asked to cancel; access ends at CurrentPeriodEnd Deleted bool // the subscription is fully gone (revoke now) Created int64 // the provider event's `created` time (event ordering) AllowCreate bool // a genuine "new subscription" signal (checkout completed, // or subscription.created) β€” the ONLY kind that may CREATE a // membership, so a bare update/delete can't resurrect a revoked one Checkout bool // a checkout receipt: a creation signal carrying NO lifecycle // data (no period end, no cancel state) β€” must never advance the // per-sub ordering watermark (see applySubEvent) // Lifetime: a one-time purchase event (docs/managed-payments.md Β§ Lifetime). // A grant (AllowCreate) IS the full truth β€” no SubState resolution, no // lifecycle follows; Deleted on a Lifetime event is a refund/chargeback, // which revokes immediately. ExtSub carries the payment intent id. Lifetime bool } // CheckoutReq is what a member's subscribe needs from the provider. type CheckoutReq struct { TierID string PriceRef string UserRef string // our uid β€” rides as subscription metadata for the webhook SuccessURL string CancelURL string // Once marks a lifetime purchase: mode=payment instead of subscription. // Payment mode has no percent-based splits, so AmountMinor (the level's // price) lets the provider compute its fixed fee/transfer server-side. Once bool AmountMinor int } // PayProvider is the payment backend seam. The no-op provider (Configured() // false) keeps billing dormant until real credentials are wired. type PayProvider interface { Configured() bool // SubState fetches a subscription's CURRENT state from the provider β€” // found=false when the provider no longer knows it. Resolves checkout // receipts (which carry no lifecycle data) and repairs rows whose // lifecycle events were lost (see runBillingSweep). SubState(ctx context.Context, extSub string) (SubEvent, bool, error) Checkout(ctx context.Context, req CheckoutReq) (url string, err error) Cancel(ctx context.Context, extSub string) error // Resume un-cancels a subscription still in its paid period (clears // cancel_at_period_end) β€” the correct "Resume" for an ending membership, // so it never starts a second subscription that double-charges. Resume(ctx context.Context, extSub string) error // SwitchLevel moves an existing subscription to a different level's price // (upgrade/downgrade in place, prorated), re-pointing its metadata so the // next webhook routes to the new level. SwitchLevel(ctx context.Context, extSub, newPriceRef, newTierRef string) error // CreatePrice mints a recurring Price on the admin's account so setting a // level's price needs no trip to the Stripe dashboard. Returns its id. CreatePrice(ctx context.Context, name string, cents int, currency, interval string) (string, error) PortalURL(ctx context.Context, extCustomer, returnURL string) (string, error) // ParseWebhook verifies a provider callback and normalizes it. nil (no // error) for events we ignore. ParseWebhook(sig string, body []byte) ([]SubEvent, error) } // noPay is the dormant provider β€” everything reports "not configured". type noPay struct{} func (noPay) Configured() bool { return false } func (noPay) Checkout(context.Context, CheckoutReq) (string, error) { return "", errBillingOff } func (noPay) Cancel(context.Context, string) error { return errBillingOff } func (noPay) Resume(context.Context, string) error { return errBillingOff } func (noPay) SwitchLevel(context.Context, string, string, string) error { return errBillingOff } func (noPay) CreatePrice(context.Context, string, int, string, string) (string, error) { return "", errBillingOff } func (noPay) PortalURL(context.Context, string, string) (string, error) { return "", errBillingOff } func (noPay) ParseWebhook(string, []byte) ([]SubEvent, error) { return nil, errBillingOff } func (noPay) SubState(context.Context, string) (SubEvent, bool, error) { return SubEvent{}, false, errBillingOff } // newPayProvider wires the payment backend from env. Tri-state, like // newStripeBilling: all-empty β†’ dormant (noPay); a partial config β†’ dormant // with a warning (a half-config must never half-work); fully set β†’ the // provider. Managed payments (docs/managed-payments.md) take precedence when // their creds are present β€” managed is the default provider wherever it's // deployed, and BYO Connect is the fallback. The per-space account (Express // or connected, config table) still gates actual selling β€” so even with // platform creds present, an unlinked space can't sell. func newPayProvider(db *DB) PayProvider { mkey := strings.TrimSpace(os.Getenv("LCHAT_STRIPE_MANAGED_KEY")) mwh := strings.TrimSpace(os.Getenv("LCHAT_STRIPE_MANAGED_WEBHOOK_SECRET")) if mkey != "" || mwh != "" { if mkey == "" || mwh == "" { 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)") return noPay{} } return &stripePlatform{ db: db, key: mkey, webhookSecret: mwh, apiBase: strings.TrimRight(envOr("LCHAT_STRIPE_API_BASE", "https://api.stripe.com"), "/"), client: &http.Client{Timeout: 20 * time.Second}, } } key := strings.TrimSpace(os.Getenv("LCHAT_STRIPE_CONNECT_KEY")) wh := strings.TrimSpace(os.Getenv("LCHAT_STRIPE_CONNECT_WEBHOOK_SECRET")) if key == "" && wh == "" { return noPay{} } if key == "" || wh == "" { 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)") return noPay{} } return &stripeConnect{ db: db, key: key, webhookSecret: wh, apiBase: strings.TrimRight(envOr("LCHAT_STRIPE_API_BASE", "https://api.stripe.com"), "/"), client: &http.Client{Timeout: 20 * time.Second}, } } // applySubEvent folds one normalized subscription event into the member's // entitlement and returns whether the membership should be ACTIVE (access // granted) right now. The caller grants/revokes keys off that. Pure over the // clock (now passed in) so it's unit-testable. // // Rules (docs/community-and-tiers.md Β§ revocation): // - active, not cancelling β†’ paid through CurrentPeriodEnd, no end in sight. // - active, cancel-at-period-end β†’ keep access until CurrentPeriodEnd, then // revoke (a voluntary cancel gets no bonus grace). // - past_due (payment failed) β†’ keep access through the 30-day grace from the // last paid-until, then revoke. // - deleted/canceled outright β†’ revoke on the next sweep. func applySubEvent(db *DB, ev SubEvent, tierID, userID string, now int64) (active bool, err error) { e := Entitlement{TierID: tierID, UserID: userID, ExtSub: ev.ExtSub, ExtCustomer: ev.ExtCustomer} switch { case ev.Lifetime && (ev.Deleted || ev.Status == "canceled"): // A refund/chargeback un-buys a lifetime purchase: revoke immediately β€” // grace windows exist to survive a failing card, and nothing is failing. e.Status = "canceled" e.Lifetime = true e.GraceDeadline = now active = false case ev.Lifetime: // Paid once, in forever: no period end, no grace clock. The lifetime // flag exempts the row from every renewal-shaped sweep (db.go). e.Status = "active" e.Lifetime = true active = true case ev.Deleted || ev.Status == "canceled": e.Status = "canceled" e.PaidUntil = ev.CurrentPeriodEnd e.GraceDeadline = now // revoke on the next sweep active = false case ev.Status == "past_due": e.Status = "past_due" paidUntil := ev.CurrentPeriodEnd if paidUntil == 0 { if prev, ok := db.memberEntitlement(tierID, userID); ok && prev.PaidUntil > 0 { paidUntil = prev.PaidUntil } else { paidUntil = now } } e.PaidUntil = paidUntil e.GraceDeadline = paidUntil + int64(billingGrace.Seconds()) active = now < e.GraceDeadline case ev.CancelAtPeriodEnd: e.Status = "canceled" e.PaidUntil = ev.CurrentPeriodEnd e.GraceDeadline = ev.CurrentPeriodEnd active = now < ev.CurrentPeriodEnd default: // active, healthy e.Status = "active" e.PaidUntil = ev.CurrentPeriodEnd // checkout.session.completed carries no period end (0). If a later-numbered // but earlier-arriving .updated already stored the real renewal date, don't // stomp it back to 0 (the Membership screen would show a blank date). if e.PaidUntil == 0 { if prev, ok := db.memberEntitlement(tierID, userID); ok && prev.PaidUntil > 0 { e.PaidUntil = prev.PaidUntil } } e.GraceDeadline = 0 active = true } // A checkout receipt must not advance the per-sub ordering watermark: Stripe // stamps checkout.session.completed AFTER customer.subscription.created but // can deliver it FIRST β€” if the receipt claimed the watermark, the created // event (the only one carrying the real period end) would be dropped as // stale and paid_until stuck at 0 (blank renewal date, and the silent-lapse // backstop in entitlementsDueForRevoke never applies). if !ev.Checkout { e.EventTS = ev.Created } if err := db.setEntitlement(e); err != nil { return false, err } return active, nil } // runBillingSweep revokes paid memberships whose grace window has elapsed β€” // dropping the tier key + membership (unassignTier), the same service-level // revocation an admin does by hand. Mirrors miniapps.go's sweep shape: query, // per-item action tolerant of errors, returns a count so tests call it // directly. No-op when nothing is due. func (s *Server) runBillingSweep(now int64) int { due, err := s.db.entitlementsDueForRevoke(now) if err != nil { return 0 } n := 0 for _, e := range due { // revokeEntitlement drops the tier key + membership for a pure-stripe // membership, or just clears the sub tracking when an admin/invite grant // sits underneath (that access must survive a payment lapse). if _, err := s.db.revokeEntitlement(e.TierID, e.UserID); err != nil { continue } n++ s.prodTiersChanged(e.UserID) // their tabs learn their access ended } s.repairEntitlements(now) return n } // repairEntitlements re-fetches provider truth for rows stuck ACTIVE with no // period end. Those rows lost their lifecycle events (e.g. only a checkout // receipt ever arrived β€” the pre-#846 watermark bug β€” or the instance slept // past the provider's webhook retry window); neither revoke clause in // entitlementsDueForRevoke can ever match them, so without this they'd hold // access forever. Capped per sweep so a bad day can't turn the sweep into a // provider crawl. func (s *Server) repairEntitlements(now int64) { if s.pay == nil || !s.pay.Configured() { return } broken, err := s.db.entitlementsNeedingRepair(20) if err != nil { return } for _, e := range broken { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) cur, found, err := s.pay.SubState(ctx, e.ExtSub) cancel() if err != nil { continue // transient β€” the next sweep retries } s.subFold.Lock() if !found { if _, err := s.db.revokeEntitlement(e.TierID, e.UserID); err == nil { s.prodTiersChanged(e.UserID) } } else { // Out-of-band truth, not an ordered event: fold without advancing // the per-sub watermark, exactly like a checkout receipt. The row's // sub id is what we fetched by β€” keep it authoritative. cur.ExtSub = e.ExtSub cur.Checkout = true s.foldAndSettle(cur, e.TierID, e.UserID, now) } s.subFold.Unlock() } } // startBillingSweep runs the graceβ†’revoke sweep hourly for the instance's life. func (s *Server) startBillingSweep() { go func() { t := time.NewTicker(time.Hour) defer t.Stop() for range t.C { s.runBillingSweep(time.Now().Unix()) } }() } // ---- Stripe Connect provider ------------------------------------------------- // stripeConnect charges on the space admin's connected account (Stripe-Account // header), with the platform's application fee on top. Hand-rolled like // billing.go β€” the surface is a few form-encoded POSTs plus the shared // verifyStripeSignature. The connected account id lives in the space's config // (set at onboarding); with none set, nothing sells. type stripeConnect struct { db *DB key string // platform secret key webhookSecret string apiBase string client *http.Client } // connectAccount is the space admin's Stripe connected account id ("acct_…"), // set when they link their account. Empty β†’ the space can't sell yet. func (p *stripeConnect) connectAccount() string { v, _, _ := p.db.getConfig("stripe_connect_account") return v } func (p *stripeConnect) Configured() bool { return p.connectAccount() != "" } // post form-encodes to the Stripe API on the admin's connected account. func (p *stripeConnect) post(ctx context.Context, path string, form url.Values) (map[string]any, error) { acct := p.connectAccount() if acct == "" { return nil, errBillingOff } req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+path, strings.NewReader(form.Encode())) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+p.key) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Stripe-Account", acct) // charge on the admin's account resp, err := p.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var body map[string]any if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil { return nil, fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode) } if resp.StatusCode != http.StatusOK { msg := "" if e, ok := body["error"].(map[string]any); ok { msg, _ = e["message"].(string) } return nil, fmt.Errorf("stripe: HTTP %d: %s", resp.StatusCode, msg) } return body, nil } func (p *stripeConnect) Checkout(ctx context.Context, r CheckoutReq) (string, error) { form := url.Values{} form.Set("line_items[0][price]", r.PriceRef) form.Set("line_items[0][quantity]", "1") form.Set("success_url", r.SuccessURL) form.Set("cancel_url", r.CancelURL) form.Set("metadata[user_ref]", r.UserRef) form.Set("metadata[tier_ref]", r.TierID) if r.Once { // Lifetime purchase: one payment, no subscription. Payment mode takes // the platform fee as a fixed amount, computed from the same percent. // The payment intent carries the routing metadata too, so a later // refund/chargeback (charge events reference the intent, not the // session) can find its way back to this membership. form.Set("mode", "payment") form.Set("payment_intent_data[application_fee_amount]", strconv.Itoa(onceFeeMinor(r.AmountMinor))) form.Set("payment_intent_data[metadata][user_ref]", r.UserRef) form.Set("payment_intent_data[metadata][tier_ref]", r.TierID) } else { form.Set("mode", "subscription") // The platform fee, and the metadata that routes every later // webhook back to (this member, this level). form.Set("subscription_data[application_fee_percent]", applicationFeePercent) form.Set("subscription_data[metadata][user_ref]", r.UserRef) form.Set("subscription_data[metadata][tier_ref]", r.TierID) } body, err := p.post(ctx, "/v1/checkout/sessions", form) if err != nil { return "", err } u, _ := body["url"].(string) if u == "" { return "", fmt.Errorf("stripe: session created but no url") } return u, nil } func (p *stripeConnect) Cancel(ctx context.Context, extSub string) error { // Cancel at period end: access continues until the paid period runs out, no // further charge. (Stripe emits customer.subscription.updated with // cancel_at_period_end=true, which our webhook turns into the ending state.) form := url.Values{} form.Set("cancel_at_period_end", "true") _, err := p.post(ctx, "/v1/subscriptions/"+url.PathEscape(extSub), form) return err } func (p *stripeConnect) Resume(ctx context.Context, extSub string) error { // Un-cancel: clear cancel_at_period_end on the still-live subscription. No new // subscription, no new charge β€” the member simply keeps the one they have. form := url.Values{} form.Set("cancel_at_period_end", "false") _, err := p.post(ctx, "/v1/subscriptions/"+url.PathEscape(extSub), form) return err } func (p *stripeConnect) SwitchLevel(ctx context.Context, extSub, newPriceRef, newTierRef string) error { // Fetch the current item id (Stripe needs it to replace the price), then // update the subscription's single item to the new price with proration, // and re-point the metadata so the resulting webhook routes to the new // level. (One item per membership subscription, by construction.) sub, err := p.get(ctx, "/v1/subscriptions/"+url.PathEscape(extSub)) if err != nil { return err } itemID := "" if items, ok := sub["items"].(map[string]any); ok { if data, ok := items["data"].([]any); ok && len(data) > 0 { if it, ok := data[0].(map[string]any); ok { itemID, _ = it["id"].(string) } } } if itemID == "" { return fmt.Errorf("stripe: subscription %s has no item to switch", extSub) } form := url.Values{} form.Set("items[0][id]", itemID) form.Set("items[0][price]", newPriceRef) form.Set("proration_behavior", "create_prorations") form.Set("metadata[tier_ref]", newTierRef) _, err = p.post(ctx, "/v1/subscriptions/"+url.PathEscape(extSub), form) return err } // get reads a Stripe object on the connected account. func (p *stripeConnect) get(ctx context.Context, path string) (map[string]any, error) { acct := p.connectAccount() if acct == "" { return nil, errBillingOff } req, err := http.NewRequestWithContext(ctx, "GET", p.apiBase+path, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+p.key) req.Header.Set("Stripe-Account", acct) resp, err := p.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var body map[string]any if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil { return nil, fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("stripe: HTTP %d", resp.StatusCode) } return body, nil } func (p *stripeConnect) PortalURL(ctx context.Context, extCustomer, returnURL string) (string, error) { form := url.Values{} form.Set("customer", extCustomer) form.Set("return_url", returnURL) body, err := p.post(ctx, "/v1/billing_portal/sessions", form) if err != nil { return "", err } u, _ := body["url"].(string) return u, nil } // ParseWebhook verifies the Connect webhook signature (shared with billing.go) // and normalizes checkout.session.completed + customer.subscription.* into // SubEvents. Unknown events β†’ nil, nil (acknowledged, never retried). func (p *stripeConnect) ParseWebhook(sig string, body []byte) ([]SubEvent, error) { if !verifyStripeSignature(body, sig, p.webhookSecret, time.Now()) { return nil, errors.New("webhook signature verification failed") } var ev struct { Type string `json:"type"` Account string `json:"account"` // the connected account this event is for Created int64 `json:"created"` Data struct { Object json.RawMessage `json:"object"` } `json:"data"` } if err := json.Unmarshal(body, &ev); err != nil { return nil, err } // A Connect platform endpoint receives EVERY connected account's events, all // signed with the one platform secret β€” so a valid signature is not proof the // event is ours. Bind to THIS space's connected account, or an attacker who // onboards their own account could forge metadata to grant free access. Drop // (ack, don't retry) anything for another account. if acct := p.connectAccount(); acct == "" || ev.Account != acct { return nil, nil } switch { case ev.Type == "checkout.session.completed": var o struct { Mode string `json:"mode"` Customer string `json:"customer"` Subscription string `json:"subscription"` PaymentIntent string `json:"payment_intent"` Metadata map[string]string `json:"metadata"` } if err := json.Unmarshal(ev.Data.Object, &o); err != nil { return nil, nil } // A payment-mode session with tier routing is a LIFETIME purchase β€” // the one event is the whole truth (no lifecycle follows), so it is // not a receipt to resolve (Checkout=false) and its id is the payment // intent. Payment-mode sessions without tier_ref are someone else's // commerce; dropped. if o.Mode == "payment" { if o.Metadata["tier_ref"] == "" || o.PaymentIntent == "" { return nil, nil } return []SubEvent{{ ExtSub: o.PaymentIntent, ExtCustomer: o.Customer, UserRef: o.Metadata["user_ref"], TierRef: o.Metadata["tier_ref"], Status: "active", Created: ev.Created, AllowCreate: true, Lifetime: true, }}, nil } if o.Mode != "subscription" { return nil, nil } return []SubEvent{{ ExtSub: o.Subscription, ExtCustomer: o.Customer, UserRef: o.Metadata["user_ref"], TierRef: o.Metadata["tier_ref"], Status: "active", Created: ev.Created, AllowCreate: true, Checkout: true, }}, nil case ev.Type == "charge.refunded" || ev.Type == "charge.dispute.created": return lifetimeRevokeEvent(ev.Data.Object, ev.Created) case strings.HasPrefix(ev.Type, "customer.subscription."): var o subObject if err := json.Unmarshal(ev.Data.Object, &o); err != nil { return nil, err } se := o.subEvent() se.Deleted = ev.Type == "customer.subscription.deleted" se.Created = ev.Created se.AllowCreate = ev.Type == "customer.subscription.created" return []SubEvent{se}, nil } return nil, nil } // lifetimeRevokeEvent normalizes a refund/dispute into the event that un-buys // a lifetime purchase. Both charge.refunded and charge.dispute.created carry // the payment intent plus the metadata stamped at checkout; anything without // tier routing is other commerce on the same account (subscription refunds // resolve through their own lifecycle) β€” dropped. Shared by both providers. func lifetimeRevokeEvent(raw json.RawMessage, created int64) ([]SubEvent, error) { var o struct { PaymentIntent string `json:"payment_intent"` Metadata map[string]string `json:"metadata"` } if err := json.Unmarshal(raw, &o); err != nil { return nil, nil } if o.PaymentIntent == "" || o.Metadata["tier_ref"] == "" { return nil, nil } return []SubEvent{{ ExtSub: o.PaymentIntent, UserRef: o.Metadata["user_ref"], TierRef: o.Metadata["tier_ref"], Status: "canceled", Created: created, Deleted: true, Lifetime: true, }}, nil } // subObject is the slice of a provider subscription we act on β€” the same // shape arrives in webhook payloads and from a SubState fetch. type subObject struct { ID string `json:"id"` Customer string `json:"customer"` Status string `json:"status"` CancelAtPeriodEnd bool `json:"cancel_at_period_end"` CurrentPeriodEnd int64 `json:"current_period_end"` Metadata map[string]string `json:"metadata"` Items struct { Data []struct { CurrentPeriodEnd int64 `json:"current_period_end"` } `json:"data"` } `json:"items"` } // subEvent normalizes the object; the caller stamps the event-only fields // (Created, Deleted, AllowCreate). func (o *subObject) subEvent() SubEvent { cpe := o.CurrentPeriodEnd if cpe == 0 && len(o.Items.Data) > 0 { // newer API: period end moved to the item cpe = o.Items.Data[0].CurrentPeriodEnd } // Stripe's active|trialing keep access; past_due keeps it too but starts // the grace clock; canceled/unpaid end it. status := "canceled" switch o.Status { case "active", "trialing": status = "active" case "past_due", "unpaid": status = "past_due" } return SubEvent{ ExtSub: o.ID, ExtCustomer: o.Customer, UserRef: o.Metadata["user_ref"], TierRef: o.Metadata["tier_ref"], Status: status, CurrentPeriodEnd: cpe, CancelAtPeriodEnd: o.CancelAtPeriodEnd, } } // SubState fetches the subscription's current truth from Stripe. Checkout // receipts are resolved through this (see applyWebhookEvent): a receipt // carries no lifecycle data, and trusting one blind could plant an active // row with no period end β€” invisible to every sweep β€” or resurrect a // membership whose cancellation already folded. func (p *stripeConnect) SubState(ctx context.Context, extSub string) (SubEvent, bool, error) { acct := p.connectAccount() if acct == "" { return SubEvent{}, false, errBillingOff } req, err := http.NewRequestWithContext(ctx, "GET", p.apiBase+"/v1/subscriptions/"+url.PathEscape(extSub), nil) if err != nil { return SubEvent{}, false, err } req.Header.Set("Authorization", "Bearer "+p.key) req.Header.Set("Stripe-Account", acct) resp, err := p.client.Do(req) if err != nil { return SubEvent{}, false, err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return SubEvent{}, false, nil // Stripe no longer knows this sub } var o subObject if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&o); err != nil { return SubEvent{}, false, fmt.Errorf("stripe: HTTP %d (unparseable body)", resp.StatusCode) } if resp.StatusCode != http.StatusOK { return SubEvent{}, false, fmt.Errorf("stripe: HTTP %d", resp.StatusCode) } return o.subEvent(), true, nil } func (p *stripeConnect) CreatePrice(ctx context.Context, name string, cents int, currency, interval string) (string, error) { // A recurring price with an inline product (product_data), so one call // makes both. The connected account owns them. form := url.Values{} form.Set("unit_amount", strconv.Itoa(cents)) form.Set("currency", strings.ToLower(currency)) if interval != "once" { // a lifetime level's Price is one-time form.Set("recurring[interval]", interval) } form.Set("product_data[name]", name) body, err := p.post(ctx, "/v1/prices", form) if err != nil { return "", err } id, _ := body["id"].(string) if id == "" { return "", fmt.Errorf("stripe: price created but no id") } return id, nil }