Files
at-container-registry/pkg/billing/billing.go
T
Evan JarrettandClaude Opus 5 3263156067 billing: key entitlements on the Stripe product, not the price
Tier resolution matched a subscription's price ID against the configured
stripe_price_monthly/stripe_price_yearly, which inverts what a price change
is supposed to do. Stripe prices are immutable, so changing what a tier costs
means creating a new price, and Stripe never migrates existing subscribers off
the old one. Updating the config to the new price IDs therefore un-tiers
precisely the subscribers a price change is meant to leave alone.

They did not even drop cleanly to free. An unresolved tier logs a warning,
returns nil, and the event is recorded in stripe_processed_events -- so Stripe
answers 200, never redelivers, and a later dashboard Resend is swallowed by the
idempotency check. Reproduced against the sandbox: a subscription on a price
the config does not list granted nothing, and the event could not be replayed
afterwards.

A tier has one product and many prices over its life, so the product is the
durable key for an entitlement. Tiers gain a stripe_product field, and
resolution tries the product first, falling back to the price IDs so configs
without it keep working unchanged. Checkout still keys on price -- that
direction has to name a specific price to charge.

Verified live: a subscription on a price created outside the config, under the
Pro product, resolved to tierName=Pro tierRank=2 and landed on the hold. The
same shape with an unknown product produced the silent no-op before.

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

1157 lines
35 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 aren't ready yet) and then
// periodically in the background.
// Safe to call from a goroutine.
func (m *Manager) RefreshHoldTiers() {
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()
// 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)
time.Sleep(delay)
}
}
ticker := time.NewTicker(holdTierCacheTTL)
defer ticker.Stop()
for range ticker.C {
m.refreshHoldTiersOnce()
}
}
func (m *Manager) refreshHoldTiersOnce() {
for _, holdDID := range m.managedHolds {
resp, err := holdclient.ListTiers(context.Background(), 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
}