Files
at-container-registry/pkg/billing/billing.go
T
Evan JarrettandClaude Opus 5 6510c16dd4 webhooks: enforce the entitlement at dispatch time
The webhook limit was only checked at creation, so losing entitlement (a
hold switch or a plan downgrade) left previously-created webhooks firing
paid behavior forever.

  - Dispatcher takes a WebhookLimiter, consulted on every dispatch. It
    caps the list to the current allowance, keeping the oldest N to match
    what the creation gate would have permitted, and masks paid trigger
    bits.
  - GetWebhooksForUser orders by created_at ASC, id ASC so that cap is
    deterministic. ListWebhooks gets the same tiebreak: it feeds the
    settings UI, and without it the list a user sees could disagree with
    the one the dispatcher truncates.
  - webhooks.FreeTriggerMask is shared by the creation gate and the
    dispatch backstop so the two cannot drift.

Capping is logged when it actually truncates. The webhooks stay visible in
settings, so from the user's side delivery would otherwise just stop with
no signal — and the same line is the only evidence if the limiter itself
degraded, since a billing lookup failure falls back to free-tier limits
and would quietly demote a paying user mid-dispatch.

Two cost fixes, both because this puts the entitlement lookup on a hot
path it was never on before:

findCustomerByDID now consults the customer cache instead of always
issuing a Stripe customer search. GetWebhookLimits reaches it via
GetSubscriptionInfo on every delivery, so uncached it meant a
rate-limited Search API call for every push and every scan record of
every user with a webhook configured.

DispatchForQuota checks whether the user has any quota webhook at all
before fetching the allowance. The original code filtered first precisely
so the common path (no quota webhooks) did no work; taking the allowance
up front would have spent the expensive lookup on every push. The cap
itself is still computed over the full list, since the count limit spans
all webhook types.

Note DeliverTest is deliberately not capped: it is an explicit,
user-initiated "send test" from the settings page, not automatic delivery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 21:14:58 -05:00

1102 lines
33 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
}
}
// Get user DID from customer metadata
userDID := m.getCustomerDID(sub.Customer.ID)
if userDID == "" {
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 {
priceID := sub.Items.Data[0].Price.ID
tierName, tierRank = m.cfg.GetTierByPriceID(priceID)
}
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)
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
}
// 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
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
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.
func (m *Manager) getCustomerDID(customerID string) string {
cust, err := customer.Get(customerID, nil)
if err != nil {
slog.Error("Failed to get customer", "customerID", customerID, "error", err)
return ""
}
return cust.Metadata["user_did"]
}
// 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
}