Files
at-container-registry/pkg/billing/billing.go
T
Evan JarrettandClaude Opus 5 8c9a85826d appview: stop leasing the billing tier refresh, and let it be cancelled
6a7ddb8 moved RefreshHoldTiers under a lease with the comment "one-shot; the
lease is released when it returns". It never returns: past the startup retries
it sits on a 30-minute ticker forever. Three consequences, all observed on a
two-instance run against one database:

The billing-tiers lease is never released on a clean stop, so it survives to
its TTL and the replacement instance waits a full minute for a worker the old
one is no longer running. Worse, the goroutine stays in the shutdown WaitGroup,
so "Timed out waiting for leased workers to stop" now fires on EVERY clean
shutdown. The other four leases released in 12ms and the warning fired anyway.
A warning that is always present cannot report the case it exists for, which is
the jetstream lease genuinely failing to release.

And the lease was the wrong tool regardless. The commit justified it as
"RefreshHoldTiers writes tier state derived from Stripe" that instances would
race on. It writes holdTierCache, a per-process map, from read-only ListTiers
calls; there is no shared state anywhere in the path. Electing one refresher
means every other instance keeps an empty cache forever, so
aggregateHoldFeatures reports "no hold data" on all but one — a regression that
only appears at the scale the lease was added to support. This is the hold
health worker's situation exactly, and that one was deliberately left unleased
in the same commit.

So it runs on every instance again, with a context. The retry backoff was
time.Sleep for up to 45s total against an unreachable hold; it and the ticker
now select on ctx.Done, and ListTiers gets the context instead of
context.Background. LeaseBillingTiers is gone rather than left as a dead name.

Verified: with the backoff restored to time.Sleep the new test fails on its own
2s deadline rather than hanging, which is how a shutdown regression here should
present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011rmjvU2gSRL9wFnmqVsWaF
2026-08-25 16:34:26 -05:00

1177 lines
36 KiB
Go

//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<<attempt) // 3s, 6s, 12s, 24s
slog.Info("Some managed holds not yet reachable, retrying",
"attempt", attempt+1, "maxRetries", maxRetries, "retryIn", delay)
// Not time.Sleep: the backoff runs up to 45s in total with an
// unreachable hold, and shutdown must not have to wait it out.
select {
case <-time.After(delay):
case <-ctx.Done():
return
}
}
}
ticker := time.NewTicker(holdTierCacheTTL)
defer ticker.Stop()
for {
select {
case <-ticker.C:
m.refreshHoldTiersOnce(ctx)
case <-ctx.Done():
return
}
}
}
func (m *Manager) refreshHoldTiersOnce(ctx context.Context) {
for _, holdDID := range m.managedHolds {
resp, err := holdclient.ListTiers(ctx, holdDID)
if err != nil {
slog.Warn("Failed to fetch tiers from hold", "holdDID", holdDID, "error", err)
continue
}
m.holdTierCacheMu.Lock()
m.holdTierCache[holdDID] = &cachedHoldTiers{
tiers: resp.Tiers,
expiresAt: time.Now().Add(holdTierCacheTTL),
}
m.holdTierCacheMu.Unlock()
slog.Debug("Cached tier data from hold", "holdDID", holdDID, "tierCount", len(resp.Tiers))
}
}
// aggregateHoldFeatures generates dynamic feature strings for a tier rank
// by aggregating data from all cached managed holds.
// Returns nil if no hold data is available.
func (m *Manager) aggregateHoldFeatures(rank int) []string {
m.holdTierCacheMu.RLock()
defer m.holdTierCacheMu.RUnlock()
if len(m.holdTierCache) == 0 {
return nil
}
var (
minQuota int64 = -1
maxQuota int64
scanCount int
totalHolds int
)
for _, cached := range m.holdTierCache {
if time.Now().After(cached.expiresAt) {
continue
}
if rank >= 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
}