//go:build billing package billing import ( "context" "database/sql" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "os" "strings" "sync" "time" "atcr.io/pkg/appview/db" "atcr.io/pkg/appview/holdclient" "github.com/bluesky-social/indigo/atproto/atcrypto" "github.com/stripe/stripe-go/v84" portalsession "github.com/stripe/stripe-go/v84/billingportal/session" "github.com/stripe/stripe-go/v84/checkout/session" "github.com/stripe/stripe-go/v84/customer" "github.com/stripe/stripe-go/v84/price" "github.com/stripe/stripe-go/v84/subscription" "github.com/stripe/stripe-go/v84/webhook" ) // Manager handles Stripe billing and pushes tier updates to managed holds. type Manager struct { cfg *Config privateKey *atcrypto.PrivateKeyP256 appviewDID string managedHolds []string baseURL string stripeKey string webhookSecret string // db is the writable appview database, used for Stripe webhook idempotency. db *sql.DB // Captain checker: bypasses billing for hold owners captainChecker CaptainChecker // Active-hold checker: gates paid features on the user being on a managed hold activeHoldChecker ActiveHoldChecker // Customer cache: DID → Stripe customer customerCache map[string]*cachedCustomer customerCacheMu sync.RWMutex // Price cache: Stripe price ID → unit amount in cents priceCache map[string]*cachedPrice priceCacheMu sync.RWMutex // Hold tier cache: holdDID → tier list holdTierCache map[string]*cachedHoldTiers holdTierCacheMu sync.RWMutex } type cachedHoldTiers struct { tiers []holdclient.HoldTierInfo expiresAt time.Time } type cachedCustomer struct { customer *stripe.Customer expiresAt time.Time } type cachedPrice struct { unitAmount int64 expiresAt time.Time } const customerCacheTTL = 10 * time.Minute const priceCacheTTL = 1 * time.Hour // New creates a new billing manager with Stripe integration. // Env vars STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET take precedence over config values. // database is the writable appview DB, used for Stripe webhook idempotency. func New(cfg *Config, privateKey *atcrypto.PrivateKeyP256, appviewDID string, managedHolds []string, baseURL string, database *sql.DB) *Manager { stripeKey := os.Getenv("STRIPE_SECRET_KEY") if stripeKey == "" { stripeKey = cfg.StripeSecretKey } if stripeKey != "" { stripe.Key = stripeKey } webhookSecret := os.Getenv("STRIPE_WEBHOOK_SECRET") if webhookSecret == "" { webhookSecret = cfg.WebhookSecret } return &Manager{ cfg: cfg, privateKey: privateKey, appviewDID: appviewDID, managedHolds: managedHolds, baseURL: baseURL, stripeKey: stripeKey, webhookSecret: webhookSecret, db: database, customerCache: make(map[string]*cachedCustomer), priceCache: make(map[string]*cachedPrice), holdTierCache: make(map[string]*cachedHoldTiers), } } // SetCaptainChecker sets a callback that checks if a user is a hold captain. // Captains bypass all billing feature gates. func (m *Manager) SetCaptainChecker(fn CaptainChecker) { m.captainChecker = fn } func (m *Manager) isCaptain(userDID string) bool { return m.captainChecker != nil && userDID != "" && m.captainChecker(userDID) } // SetActiveHoldChecker sets a callback that reports whether a user's active // (default) hold is a managed hold. Paid features are gated on this. func (m *Manager) SetActiveHoldChecker(fn ActiveHoldChecker) { m.activeHoldChecker = fn } // onManagedHold reports whether the user's active hold qualifies for paid // features. With no checker wired (e.g. tests), it defaults to true to stay // backward-compatible. func (m *Manager) onManagedHold(userDID string) bool { if m.activeHoldChecker == nil { return true } return m.activeHoldChecker(userDID) } // WebhookConfigured reports whether a Stripe webhook signing secret is set. // Used at startup to fail closed: an empty secret makes webhooks forgeable. func (m *Manager) WebhookConfigured() bool { return m.webhookSecret != "" } // Enabled returns true if billing is properly configured. func (m *Manager) Enabled() bool { return m.cfg != nil && m.stripeKey != "" && len(m.cfg.Tiers) > 0 } // GetWebhookLimits returns webhook limits for a user based on their subscription tier. // Returns (maxWebhooks, allTriggers). Defaults to the lowest tier's limits. // Hold captains get unlimited webhooks with all triggers. func (m *Manager) GetWebhookLimits(userDID string) (int, bool) { if m.isCaptain(userDID) { return -1, true // unlimited } if !m.Enabled() { return 1, false } // Paid features require an active managed hold. if !m.onManagedHold(userDID) { return m.cfg.Tiers[0].MaxWebhooks, m.cfg.Tiers[0].WebhookAllTriggers } info, err := m.GetSubscriptionInfo(userDID) if err != nil || info == nil { return m.cfg.Tiers[0].MaxWebhooks, m.cfg.Tiers[0].WebhookAllTriggers } rank := info.TierRank if rank >= 0 && rank < len(m.cfg.Tiers) { return m.cfg.Tiers[rank].MaxWebhooks, m.cfg.Tiers[rank].WebhookAllTriggers } return m.cfg.Tiers[0].MaxWebhooks, m.cfg.Tiers[0].WebhookAllTriggers } // HasAIAdvisor returns whether a user has access to the AI Image Advisor based on their subscription tier. // Hold captains always have access. func (m *Manager) HasAIAdvisor(userDID string) bool { if m.isCaptain(userDID) { return true } if !m.Enabled() { return false } // Paid features require an active managed hold. Fall back to the free tier's // setting rather than a hard false, matching GetWebhookLimits: an off-managed // user should land on free-tier entitlements, not below them. (These differ // only if a deployment turns AIAdvisor on for tier 0.) if !m.onManagedHold(userDID) { return m.cfg.Tiers[0].AIAdvisor } info, err := m.GetSubscriptionInfo(userDID) if err != nil || info == nil { return m.cfg.Tiers[0].AIAdvisor } rank := info.TierRank if rank >= 0 && rank < len(m.cfg.Tiers) { return m.cfg.Tiers[rank].AIAdvisor } return m.cfg.Tiers[0].AIAdvisor } // GetSupporterBadge returns the supporter badge tier name for a user based on their subscription. // Returns the tier name if the user's current tier has supporter badges enabled, empty string otherwise. // Hold captains get a "Captain" badge. func (m *Manager) GetSupporterBadge(userDID string) string { if m.isCaptain(userDID) { return "Captain" } if !m.Enabled() { return "" } // Paid features require an active managed hold. if !m.onManagedHold(userDID) { return "" } info, err := m.GetSubscriptionInfo(userDID) if err != nil || info == nil { return "" } for _, tier := range info.Tiers { if tier.ID == info.CurrentTier && tier.SupporterBadge { return info.CurrentTier } } return "" } // GetSubscriptionInfo returns subscription and tier information for a user. // Hold captains see a special "Captain" tier with all features unlocked. func (m *Manager) GetSubscriptionInfo(userDID string) (*SubscriptionInfo, error) { if m.isCaptain(userDID) { return &SubscriptionInfo{ UserDID: userDID, CurrentTier: "Captain", TierRank: -1, // above all configured tiers Tiers: []TierInfo{{ ID: "Captain", Name: "Captain", Description: "Hold operator", Features: []string{"Unlimited storage", "Unlimited webhooks", "All webhook triggers", "Scan on push"}, Rank: -1, MaxWebhooks: -1, WebhookAllTriggers: true, SupporterBadge: true, IsCurrent: true, }}, }, nil } if !m.Enabled() { return nil, ErrBillingDisabled } // Paid features require an active managed hold. Off-managed users still see // the tier list (for context) but with payments disabled, which drives the // billing UI to hide itself. onManaged := m.onManagedHold(userDID) info := &SubscriptionInfo{ UserDID: userDID, PaymentsEnabled: onManaged, CurrentTier: m.cfg.Tiers[0].Name, // default to lowest TierRank: 0, } // Build tier list with live Stripe prices info.Tiers = make([]TierInfo, len(m.cfg.Tiers)) for i, tier := range m.cfg.Tiers { // Dynamic features: hold-derived first, then webhook limits, then static config features := m.aggregateHoldFeatures(i) features = append(features, webhookFeatures(tier.MaxWebhooks, tier.WebhookAllTriggers)...) features = append(features, aiAdvisorFeatures(tier.AIAdvisor)...) if tier.SupporterBadge { features = append(features, "Supporter badge") } features = append(features, tier.Features...) info.Tiers[i] = TierInfo{ ID: tier.Name, Name: tier.Name, Description: tier.Description, Features: features, Rank: i, MaxWebhooks: tier.MaxWebhooks, WebhookAllTriggers: tier.WebhookAllTriggers, SupporterBadge: tier.SupporterBadge, } if tier.StripePriceMonthly != "" { if amount, err := m.fetchPrice(tier.StripePriceMonthly); err == nil { info.Tiers[i].PriceCentsMonthly = int(amount) } } if tier.StripePriceYearly != "" { if amount, err := m.fetchPrice(tier.StripePriceYearly); err == nil { info.Tiers[i].PriceCentsYearly = int(amount) } } } if userDID == "" || !onManaged { return info, nil } // Find Stripe customer for this user cust, err := m.findCustomerByDID(userDID) if err != nil { slog.Debug("No Stripe customer found", "userDID", userDID, "error", err) return info, nil } info.CustomerID = cust.ID // Find active subscription params := &stripe.SubscriptionListParams{} params.Filters.AddFilter("customer", "", cust.ID) params.Filters.AddFilter("status", "", "active") iter := subscription.List(params) // Use the first active subscription only. if iter.Next() { sub := iter.Subscription() info.SubscriptionID = sub.ID if sub.Items != nil && len(sub.Items.Data) > 0 { priceID := sub.Items.Data[0].Price.ID tierName, tierRank := m.cfg.GetTierByPriceID(priceID) if tierName != "" { info.CurrentTier = tierName info.TierRank = tierRank } if sub.Items.Data[0].Price.Recurring != nil { switch sub.Items.Data[0].Price.Recurring.Interval { case stripe.PriceRecurringIntervalMonth: info.BillingInterval = "monthly" case stripe.PriceRecurringIntervalYear: info.BillingInterval = "yearly" } } } } // Mark current tier for i := range info.Tiers { info.Tiers[i].IsCurrent = info.Tiers[i].ID == info.CurrentTier } return info, nil } // HasActiveSubscription reports whether the user has an active Stripe // subscription, regardless of which hold they're on. Unlike GetSubscriptionInfo // this is NOT gated on managed-hold membership: it's used to warn a user who // switched to a self-hosted hold that they're still being charged. func (m *Manager) HasActiveSubscription(userDID string) bool { if !m.Enabled() || userDID == "" { return false } cust, err := m.findCustomerByDID(userDID) if err != nil { return false } params := &stripe.SubscriptionListParams{} params.Filters.AddFilter("customer", "", cust.ID) params.Filters.AddFilter("status", "", "active") return subscription.List(params).Next() } // CreateCheckoutSession creates a Stripe checkout session for a subscription. func (m *Manager) CreateCheckoutSession(r *http.Request, userDID, userHandle string, req *CheckoutSessionRequest) (*CheckoutSessionResponse, error) { if !m.Enabled() { return nil, ErrBillingDisabled } // Find the tier config rank := m.cfg.TierRank(req.Tier) if rank < 0 { return nil, fmt.Errorf("unknown tier: %s", req.Tier) } tierCfg := m.cfg.Tiers[rank] // Determine price ID: prefer monthly so Stripe upsell can offer yearly toggle, // fall back to yearly if no monthly price exists. var priceID string if req.Interval == "yearly" && tierCfg.StripePriceYearly != "" { priceID = tierCfg.StripePriceYearly } else if tierCfg.StripePriceMonthly != "" { priceID = tierCfg.StripePriceMonthly } else if tierCfg.StripePriceYearly != "" { priceID = tierCfg.StripePriceYearly } if priceID == "" { return nil, fmt.Errorf("tier %s has no Stripe price configured", req.Tier) } // Get or create Stripe customer cust, err := m.getOrCreateCustomer(userDID, userHandle) if err != nil { return nil, fmt.Errorf("failed to get/create customer: %w", err) } // Build success/cancel URLs successURL := strings.ReplaceAll(m.cfg.SuccessURL, "{base_url}", m.baseURL) cancelURL := strings.ReplaceAll(m.cfg.CancelURL, "{base_url}", m.baseURL) params := &stripe.CheckoutSessionParams{ Customer: stripe.String(cust.ID), Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), LineItems: []*stripe.CheckoutSessionLineItemParams{ { Price: stripe.String(priceID), Quantity: stripe.Int64(1), }, }, SuccessURL: stripe.String(successURL), CancelURL: stripe.String(cancelURL), } s, err := session.New(params) if err != nil { return nil, fmt.Errorf("failed to create checkout session: %w", err) } return &CheckoutSessionResponse{ CheckoutURL: s.URL, SessionID: s.ID, }, nil } // GetBillingPortalURL creates a Stripe billing portal session. func (m *Manager) GetBillingPortalURL(userDID, returnURL string) (*BillingPortalResponse, error) { if !m.Enabled() { return nil, ErrBillingDisabled } cust, err := m.findCustomerByDID(userDID) if err != nil { return nil, fmt.Errorf("no billing account found") } params := &stripe.BillingPortalSessionParams{ Customer: stripe.String(cust.ID), ReturnURL: stripe.String(returnURL), } s, err := portalsession.New(params) if err != nil { return nil, fmt.Errorf("failed to create portal session: %w", err) } return &BillingPortalResponse{PortalURL: s.URL}, nil } // ErrWebhookSignature wraps signature/parse failures. These are client errors: // the caller (not Stripe) sent a bad request, so the HTTP handler returns 400 // and Stripe should not retry. var ErrWebhookSignature = errors.New("webhook signature verification failed") // ErrWebhookProcessing wraps transient processing failures (DB, tier push to a // hold). The HTTP handler returns 5xx so Stripe retries; idempotency makes the // retry safe. var ErrWebhookProcessing = errors.New("webhook processing failed") // HandleWebhook processes a Stripe webhook event. // On subscription changes, it pushes tier updates to all managed holds. // // Returns ErrWebhookSignature (-> 400, no retry) for bad signatures/parse, or a // wrapped ErrWebhookProcessing (-> 5xx, Stripe retries) when applying the event // failed. On success the event is recorded for idempotency so redeliveries are // no-ops. func (m *Manager) HandleWebhook(r *http.Request) error { if !m.Enabled() { return ErrBillingDisabled } // Fail closed: an empty secret makes signatures forgeable (Stripe HMACs with // the empty key). Startup also guards this; this is defense in depth. if m.webhookSecret == "" { return fmt.Errorf("%w: webhook secret not configured", ErrWebhookProcessing) } body, err := io.ReadAll(r.Body) if err != nil { return fmt.Errorf("%w: read body: %v", ErrWebhookProcessing, err) } event, err := webhook.ConstructEvent(body, r.Header.Get("Stripe-Signature"), m.webhookSecret) if err != nil { return fmt.Errorf("%w: %v", ErrWebhookSignature, err) } // Idempotency: skip events we've already successfully processed so a Stripe // redelivery (or replay of a validly-signed event) is a no-op. if m.db != nil && event.ID != "" { seen, err := db.StripeEventSeen(m.db, event.ID) if err != nil { return fmt.Errorf("%w: idempotency check: %v", ErrWebhookProcessing, err) } if seen { slog.Debug("Ignoring already-processed Stripe event", "eventID", event.ID, "type", event.Type) return nil } } var procErr error switch event.Type { case EventCheckoutSessionCompleted: procErr = m.handleCheckoutCompleted(event) case EventSubscriptionCreated, EventSubscriptionUpdated, EventSubscriptionDeleted, EventSubscriptionPaused, EventSubscriptionResumed: procErr = m.handleSubscriptionChange(event) case EventInvoicePaymentFailed: procErr = m.handleInvoicePaymentFailed(event) case EventChargeDisputeCreated: procErr = m.handleChargeDisputeCreated(event) default: slog.Debug("Ignoring Stripe event", "type", event.Type) } if procErr != nil { // Not recorded as processed: Stripe will retry and we'll re-apply. return fmt.Errorf("%w: %v", ErrWebhookProcessing, procErr) } // Record success (idempotency + per-customer ordering). Best-effort: a record // failure shouldn't fail an already-applied event, but log it. if m.db != nil && event.ID != "" { if err := db.RecordStripeEvent(m.db, event.ID, subscriptionEventCustomerID(event), int64(event.Created)); err != nil { slog.Warn("Failed to record processed Stripe event", "eventID", event.ID, "error", err) } } return nil } // subscriptionEventCustomerID extracts the Stripe customer ID from a // subscription lifecycle event, or "" for other event types. Only subscription // events use per-customer ordering, so other types are recorded with no // customer (dedup still works on event_id). func subscriptionEventCustomerID(event stripe.Event) string { switch event.Type { case EventSubscriptionCreated, EventSubscriptionUpdated, EventSubscriptionDeleted, EventSubscriptionPaused, EventSubscriptionResumed: var sub stripe.Subscription if err := json.Unmarshal(event.Data.Raw, &sub); err == nil && sub.Customer != nil { return sub.Customer.ID } } return "" } // handleCheckoutCompleted processes a checkout.session.completed event. func (m *Manager) handleCheckoutCompleted(event stripe.Event) error { var cs stripe.CheckoutSession if err := json.Unmarshal(event.Data.Raw, &cs); err != nil { slog.Error("Failed to parse checkout session", "error", err) // Malformed payload: don't ask Stripe to retry a bad event. return nil } slog.Info("Checkout completed", "customerID", cs.Customer.ID, "subscriptionID", cs.Subscription.ID) // The subscription.created event will handle the tier update return nil } // handleSubscriptionChange processes subscription lifecycle events. func (m *Manager) handleSubscriptionChange(event stripe.Event) error { var sub stripe.Subscription if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { slog.Error("Failed to parse subscription", "error", err) // Malformed payload: don't ask Stripe to retry a bad event. return nil } // Ordering guard: ignore an event older than the newest one we've already // applied for this customer (Stripe does not guarantee delivery order, so a // stale active could otherwise re-grant a canceled tier). if m.db != nil && sub.Customer != nil && sub.Customer.ID != "" { latest, err := db.LatestStripeEventCreatedForCustomer(m.db, sub.Customer.ID) if err != nil { return fmt.Errorf("ordering check: %w", err) } if latest > 0 && int64(event.Created) < latest { slog.Warn("Ignoring stale out-of-order subscription event", "customerID", sub.Customer.ID, "subscriptionID", sub.ID, "eventCreated", event.Created, "latestApplied", latest, ) return nil } } // The ordering guard above already treats a nil customer as possible; this // used to dereference it regardless and panic. if sub.Customer == nil || sub.Customer.ID == "" { slog.Warn("Subscription event carries no customer", "subscriptionID", sub.ID) return nil } // Get user DID from customer metadata. A failed lookup must be retried: // returning nil here records the event as processed, Stripe answers 200 and // never redelivers, and a paid upgrade is lost to a transient API error. userDID, err := m.getCustomerDID(sub.Customer.ID) if err != nil { return fmt.Errorf("resolve customer DID: %w", err) } if userDID == "" { // A conclusion, not a failure: redelivery cannot conjure a user_did. slog.Warn("No user DID found for Stripe customer", "customerID", sub.Customer.ID) return nil } // Determine new tier from subscription var tierName string var tierRank int switch sub.Status { case stripe.SubscriptionStatusActive, stripe.SubscriptionStatusTrialing: if sub.Items != nil && len(sub.Items.Data) > 0 { tierName, tierRank = m.resolveTier(sub.Items.Data[0].Price) } case stripe.SubscriptionStatusPastDue: // Stripe is retrying the card via Smart Retries (typically 1-3 weeks). // Keep tier unchanged; customer receives dunning emails from Stripe. // A later subscription.updated with unpaid will downgrade. slog.Warn("Subscription past due, keeping tier during dunning", "userDID", userDID, "subscriptionID", sub.ID, "customerID", sub.Customer.ID, ) return nil case stripe.SubscriptionStatusUnpaid, stripe.SubscriptionStatusCanceled, stripe.SubscriptionStatusPaused, stripe.SubscriptionStatusIncompleteExpired: // Dunning exhausted, cleanly canceled, paused without payment method, // or initial payment never completed within the 23-hour window. tierName = m.cfg.Tiers[0].Name tierRank = 0 case stripe.SubscriptionStatusIncomplete: // 23-hour window for initial payment to confirm. Don't grant tier yet: // a later subscription.updated will fire with active or incomplete_expired. slog.Info("Subscription incomplete, awaiting initial payment", "userDID", userDID, "subscriptionID", sub.ID, ) return nil default: slog.Debug("Ignoring subscription status", "status", sub.Status, "subscriptionID", sub.ID) return nil } if tierName == "" { slog.Warn("Could not resolve tier from subscription", "priceID", sub.Items.Data[0].Price.ID, "productID", priceProductID(sub.Items.Data[0].Price), "subscriptionID", sub.ID, "customerID", sub.Customer.ID, ) return nil } slog.Info("Pushing tier update to managed holds", "userDID", userDID, "tierName", tierName, "tierRank", tierRank, "event", event.Type, ) // Push tier update to all managed holds synchronously. If any hold fails // (after retries) we return an error so the webhook 5xxs and Stripe retries // the whole event; idempotency makes that retry safe. This closes the // "paid but never received tier" hole the old fire-and-forget goroutine had. // // Bound the whole push so a hung hold can't block the webhook past Stripe's // delivery timeout (which would pile up goroutines and trigger redelivery). ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := holdclient.UpdateCrewTierOnAllHolds( ctx, m.managedHolds, userDID, tierRank, m.privateKey, m.appviewDID, ); err != nil { return fmt.Errorf("push tier to managed holds: %w", err) } // Invalidate customer cache m.customerCacheMu.Lock() delete(m.customerCache, userDID) m.customerCacheMu.Unlock() return nil } // resolveTier maps a subscription's price onto a configured tier. // // The product is tried first and the price ID is the fallback. That ordering is // what makes grandfathering work: a tier's prices change over its life, its // product does not, and Stripe leaves existing subscribers on the price they // signed up at forever. Matching on price alone would drop those subscribers // out of their tier the moment a new price was introduced — the opposite of // what a price change is meant to do. // // The fallback keeps configs that predate stripe_product resolving unchanged. func (m *Manager) resolveTier(price *stripe.Price) (string, int) { if price == nil { return "", -1 } if name, rank := m.cfg.GetTierByProductID(priceProductID(price)); name != "" { return name, rank } return m.cfg.GetTierByPriceID(price.ID) } // priceProductID returns the product ID behind a price, or "" if absent. // Stripe sends price.product as a bare ID string unless it is expanded, which // stripe-go unmarshals into a Product carrying only that ID. func priceProductID(price *stripe.Price) string { if price == nil || price.Product == nil { return "" } return price.Product.ID } // handleInvoicePaymentFailed logs a failed invoice payment. // No tier change: handleSubscriptionChange reacts to the subsequent // past_due → unpaid transition. Stripe Smart Retries handle retry cadence // and customer-facing dunning emails. func (m *Manager) handleInvoicePaymentFailed(event stripe.Event) error { var inv stripe.Invoice if err := json.Unmarshal(event.Data.Raw, &inv); err != nil { slog.Error("Failed to parse invoice", "error", err) return nil } customerID := "" userDID := "" if inv.Customer != nil { customerID = inv.Customer.ID // Log-only: a lookup failure here is not worth failing the webhook over, // and the entry is still useful without the DID. userDID, _ = m.getCustomerDID(customerID) } nextAttempt := int64(0) if inv.NextPaymentAttempt != 0 { nextAttempt = inv.NextPaymentAttempt } slog.Warn("Stripe invoice payment failed", "userDID", userDID, "customerID", customerID, "invoiceID", inv.ID, "subscriptionID", subscriptionIDFromInvoice(&inv), "amountDue", inv.AmountDue, "currency", inv.Currency, "attemptCount", inv.AttemptCount, "nextAttempt", nextAttempt, ) return nil } // handleChargeDisputeCreated logs a new chargeback. // No tier action: disputes can be won, and downgrade-then-revert is worse UX // than waiting for resolution. Stripe emails the account owner by default // (Dashboard → Settings → Team & security → Notifications → "Disputes and // inquiries"). func (m *Manager) handleChargeDisputeCreated(event stripe.Event) error { var dispute stripe.Dispute if err := json.Unmarshal(event.Data.Raw, &dispute); err != nil { slog.Error("Failed to parse dispute", "error", err) return nil } customerID := "" userDID := "" if dispute.Charge != nil && dispute.Charge.Customer != nil { customerID = dispute.Charge.Customer.ID // Log-only, as above. userDID, _ = m.getCustomerDID(customerID) } evidenceDueBy := int64(0) if dispute.EvidenceDetails != nil { evidenceDueBy = dispute.EvidenceDetails.DueBy } slog.Warn("Stripe chargeback opened", "userDID", userDID, "customerID", customerID, "disputeID", dispute.ID, "chargeID", chargeIDFromDispute(&dispute), "amount", dispute.Amount, "currency", dispute.Currency, "reason", dispute.Reason, "status", dispute.Status, "evidenceDueBy", evidenceDueBy, ) return nil } // subscriptionIDFromInvoice returns the subscription ID on an invoice, or "". // In stripe-go v84+, the subscription lives under Invoice.Parent.SubscriptionDetails. func subscriptionIDFromInvoice(inv *stripe.Invoice) string { if inv == nil || inv.Parent == nil || inv.Parent.SubscriptionDetails == nil { return "" } if inv.Parent.SubscriptionDetails.Subscription == nil { return "" } return inv.Parent.SubscriptionDetails.Subscription.ID } // chargeIDFromDispute returns the charge ID on a dispute, or "". func chargeIDFromDispute(d *stripe.Dispute) string { if d == nil || d.Charge == nil { return "" } return d.Charge.ID } // getOrCreateCustomer finds or creates a Stripe customer for a DID. func (m *Manager) getOrCreateCustomer(userDID, userHandle string) (*stripe.Customer, error) { // Check cache m.customerCacheMu.RLock() if cached, ok := m.customerCache[userDID]; ok && time.Now().Before(cached.expiresAt) { m.customerCacheMu.RUnlock() return cached.customer, nil } m.customerCacheMu.RUnlock() // Search Stripe. Uses the uncached variant: the cache was just checked. cust, err := m.searchCustomerByDID(userDID) if err == nil { m.cacheCustomer(userDID, cust) return cust, nil } // Create new customer params := &stripe.CustomerParams{ Params: stripe.Params{ Metadata: map[string]string{ "user_did": userDID, }, }, } if userHandle != "" { params.Name = stripe.String(userHandle) } cust, err = customer.New(params) if err != nil { return nil, fmt.Errorf("failed to create Stripe customer: %w", err) } m.cacheCustomer(userDID, cust) return cust, nil } // findCustomerByDID returns the Stripe customer whose metadata carries this DID, // preferring the shared customer cache over a live search. // // The cache matters because this is now on a hot path: the webhook dispatcher // consults GetWebhookLimits on every delivery, which lands here via // GetSubscriptionInfo. Searching Stripe per dispatch would mean a rate-limited // Search API call for every push and every scan record of every user who has a // webhook configured. func (m *Manager) findCustomerByDID(userDID string) (*stripe.Customer, error) { m.customerCacheMu.RLock() if cached, ok := m.customerCache[userDID]; ok && time.Now().Before(cached.expiresAt) { m.customerCacheMu.RUnlock() return cached.customer, nil } m.customerCacheMu.RUnlock() cust, err := m.searchCustomerByDID(userDID) if err != nil { return nil, err } m.cacheCustomer(userDID, cust) return cust, nil } // searchCustomerByDID searches Stripe for a customer with matching DID metadata, // bypassing the cache. Callers should prefer findCustomerByDID. func (m *Manager) searchCustomerByDID(userDID string) (*stripe.Customer, error) { // DIDs reaching here are OAuth-validated (the DID grammar forbids quotes), // but escape defensively so the query's safety doesn't silently depend on a // validator several layers away. Stripe search escapes ' and \ with a // backslash. escaped := strings.NewReplacer(`\`, `\\`, `'`, `\'`).Replace(userDID) params := &stripe.CustomerSearchParams{ SearchParams: stripe.SearchParams{ Query: fmt.Sprintf("metadata['user_did']:'%s'", escaped), }, } iter := customer.Search(params) for iter.Next() { return iter.Customer(), nil } return nil, fmt.Errorf("customer not found for DID %s", userDID) } // getCustomerDID retrieves the user DID from a Stripe customer's metadata. // Returns ("", nil) when the customer exists but carries no user_did, and // ("", err) when the lookup itself failed. Callers acting on the result must // distinguish the two: the first is a conclusion, the second is a Stripe // outage, and treating an outage as "not our customer" silently drops the // subscription change with no redelivery. func (m *Manager) getCustomerDID(customerID string) (string, error) { cust, err := customer.Get(customerID, nil) if err != nil { slog.Error("Failed to get customer", "customerID", customerID, "error", err) return "", fmt.Errorf("get customer %s: %w", customerID, err) } return cust.Metadata["user_did"], nil } // cacheCustomer stores a customer in the in-memory cache. func (m *Manager) cacheCustomer(userDID string, cust *stripe.Customer) { m.customerCacheMu.Lock() m.customerCache[userDID] = &cachedCustomer{ customer: cust, expiresAt: time.Now().Add(customerCacheTTL), } m.customerCacheMu.Unlock() } const holdTierCacheTTL = 30 * time.Minute // RefreshHoldTiers queries all managed holds for their tier definitions and // caches the results. It runs once immediately (with retries for holds that are // not ready yet) and then periodically until ctx is cancelled. // // This runs on every instance rather than under a lease. holdTierCache is // per-process memory and refreshHoldTiersOnce issues read-only ListTiers calls, // so there is no shared state to serialise and nothing to race on. Electing one // refresher would leave every other instance with an empty cache, which // aggregateHoldFeatures reports as "no hold data" — the same reasoning that // keeps the hold health worker unleased. // // It only returns when ctx is cancelled, so start it with `go` and hand it a // context that shutdown closes. func (m *Manager) RefreshHoldTiers(ctx context.Context) { if !m.Enabled() || len(m.managedHolds) == 0 { return } // On startup, retry a few times with backoff in case holds aren't ready yet. // This is common in docker-compose where appview starts before the hold. const maxRetries = 5 const initialDelay = 3 * time.Second for attempt := range maxRetries { m.refreshHoldTiersOnce(ctx) // Check if all managed holds are cached m.holdTierCacheMu.RLock() allCached := len(m.holdTierCache) == len(m.managedHolds) m.holdTierCacheMu.RUnlock() if allCached { break } if attempt < maxRetries-1 { delay := initialDelay * time.Duration(1<= len(cached.tiers) { continue } totalHolds++ tier := cached.tiers[rank] if minQuota < 0 || tier.QuotaBytes < minQuota { minQuota = tier.QuotaBytes } if tier.QuotaBytes > maxQuota { maxQuota = tier.QuotaBytes } if tier.ScanOnPush { scanCount++ } } if totalHolds == 0 { return nil } var features []string // Storage feature if minQuota == maxQuota { features = append(features, formatBytes(minQuota)+" storage") } else { features = append(features, formatBytes(minQuota)+"-"+formatBytes(maxQuota)+" storage") } // Scan on push feature if scanCount == totalHolds { features = append(features, "Scan on push") } else if scanCount*2 >= totalHolds { features = append(features, "Scan on push (most regions)") } else if scanCount > 0 { features = append(features, "Scan on push (some regions)") } return features } // webhookFeatures generates feature bullet strings for webhook limits. func webhookFeatures(maxWebhooks int, allTriggers bool) []string { var features []string switch { case maxWebhooks < 0: features = append(features, "Unlimited webhooks") case maxWebhooks == 1: features = append(features, "1 webhook") case maxWebhooks > 1: features = append(features, fmt.Sprintf("%d webhooks", maxWebhooks)) } if allTriggers { features = append(features, "All webhook triggers") } return features } // aiAdvisorFeatures generates feature bullet strings for AI advisor access. func aiAdvisorFeatures(enabled bool) []string { if enabled { return []string{"AI Image Advisor"} } return nil } // formatBytes formats bytes as a human-readable string (e.g. "5.0 GB"). func formatBytes(b int64) string { const unit = 1024 if b < unit { return fmt.Sprintf("%d B", b) } div, exp := int64(unit), 0 for n := b / unit; n >= unit; n /= unit { div *= unit exp++ } units := []string{"KB", "MB", "GB", "TB", "PB"} return fmt.Sprintf("%.1f %s", float64(b)/float64(div), units[exp]) } // GetFirstTierWithAllTriggers returns the name of the lowest-rank tier that has // webhook_all_triggers enabled. Returns empty string if none found. func (m *Manager) GetFirstTierWithAllTriggers() string { if !m.Enabled() { return "" } for _, tier := range m.cfg.Tiers { if tier.WebhookAllTriggers { return tier.Name } } return "" } // fetchPrice returns the unit amount in cents for a Stripe price ID, using a cache. func (m *Manager) fetchPrice(priceID string) (int64, error) { m.priceCacheMu.RLock() if cached, ok := m.priceCache[priceID]; ok && time.Now().Before(cached.expiresAt) { m.priceCacheMu.RUnlock() return cached.unitAmount, nil } m.priceCacheMu.RUnlock() p, err := price.Get(priceID, nil) if err != nil { slog.Warn("Failed to fetch Stripe price", "priceID", priceID, "error", err) return 0, err } m.priceCacheMu.Lock() m.priceCache[priceID] = &cachedPrice{ unitAmount: p.UnitAmount, expiresAt: time.Now().Add(priceCacheTTL), } m.priceCacheMu.Unlock() return p.UnitAmount, nil }