Files
at-container-registry/pkg/billing/billing_stub.go
T
Evan JarrettandClaude Opus 5 2b71be59f7 billing: require a managed hold for paid features
Entitlements were keyed on the Stripe subscription alone, so a subscriber
who switched to a self-hosted hold kept paying for features the appview
cannot deliver, and could still reach checkout.

  - billing.ActiveHoldChecker and Manager.onManagedHold gate every
    entitlement. An empty default hold counts as managed: the user has no
    explicit preference and falls back to the operator's primary managed
    hold.
  - The checker reads the primary DB, not the read replica. A hold switch
    writes default_hold_did to the primary, and replica lag would keep
    paid features alive after a switch away.
  - db.GetUserDefaultHoldDID is the clean default-hold signal, unlike
    GetUserHoldDID which falls back to a manifest hold_endpoint (a URL,
    not a DID).
  - Jetstream fails closed: an unresolvable hold reference is cached raw
    rather than left empty, since an empty value reads as managed.
  - UI: the billing tab is hidden on self-hosted, a cancel/manage banner
    appears when a self-hosted user still has an active plan, the image
    advisor returns managed_hold_required instead of upgrade_required,
    and the checkout route returns 403. The portal stays open so existing
    subscribers can still cancel.

Two consistency fixes fall out of wiring this up:

The settings UI reads the resolved default_hold_did rather than the raw
profile.DefaultHold. The profile field is the record value as written and
may be a URL-form reference; jetstream resolves it to a DID on the way
into the DB, and the server-side gate reads that resolved value. Comparing
the raw form against managed DIDs would show the "you are self-hosted"
banner and hide billing from a user whose entitlements say otherwise.

HasAIAdvisor falls back to the free tier's AIAdvisor setting when
off-managed instead of a hard false, matching GetWebhookLimits. Losing a
managed hold should drop a user to free-tier entitlements, not below them.

BEHAVIOR CHANGE for existing paying users on self-hosted holds: they lose
the AI advisor, supporter badge and paid webhook limits as soon as this
deploys, while Stripe keeps charging them. The only notice is the banner
on /settings/storage, which they have to visit to see. Decide on a
migration (notification, or a one-time reconciliation over active
subscriptions) before shipping this.

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

97 lines
3.1 KiB
Go

//go:build !billing
package billing
import (
"database/sql"
"net/http"
"github.com/bluesky-social/indigo/atproto/atcrypto"
"github.com/go-chi/chi/v5"
)
// Manager is a no-op billing manager when billing is not compiled in.
type Manager struct {
captainChecker CaptainChecker
}
// New creates a no-op billing manager.
func New(_ *Config, _ *atcrypto.PrivateKeyP256, _ string, _ []string, _ string, _ *sql.DB) *Manager {
return &Manager{}
}
// SetCaptainChecker sets a callback that checks if a user is a hold captain.
func (m *Manager) SetCaptainChecker(fn CaptainChecker) {
m.captainChecker = fn
}
// SetActiveHoldChecker is a no-op when billing is not compiled in.
func (m *Manager) SetActiveHoldChecker(_ ActiveHoldChecker) {}
// WebhookConfigured returns false when billing is not compiled in.
func (m *Manager) WebhookConfigured() bool { return false }
// Enabled returns false when billing is not compiled in.
func (m *Manager) Enabled() bool { return false }
// GetWebhookLimits returns default limits when billing is not compiled in.
// Hold captains get unlimited webhooks with all triggers.
func (m *Manager) GetWebhookLimits(userDID string) (int, bool) {
if m.captainChecker != nil && userDID != "" && m.captainChecker(userDID) {
return -1, true
}
return 1, false
}
// HasAIAdvisor returns whether a user has access to the AI Image Advisor.
// Hold captains always have access. Default is false when billing is not compiled in.
func (m *Manager) HasAIAdvisor(userDID string) bool {
if m.captainChecker != nil && userDID != "" && m.captainChecker(userDID) {
return true
}
return false
}
// GetSubscriptionInfo returns an error when billing is not compiled in.
func (m *Manager) GetSubscriptionInfo(_ string) (*SubscriptionInfo, error) {
return nil, ErrBillingDisabled
}
// HasActiveSubscription returns false when billing is not compiled in.
func (m *Manager) HasActiveSubscription(_ string) bool { return false }
// CreateCheckoutSession returns an error when billing is not compiled in.
func (m *Manager) CreateCheckoutSession(_ *http.Request, _, _ string, _ *CheckoutSessionRequest) (*CheckoutSessionResponse, error) {
return nil, ErrBillingDisabled
}
// GetBillingPortalURL returns an error when billing is not compiled in.
func (m *Manager) GetBillingPortalURL(_ string, _ string) (*BillingPortalResponse, error) {
return nil, ErrBillingDisabled
}
// HandleWebhook returns an error when billing is not compiled in.
func (m *Manager) HandleWebhook(_ *http.Request) error {
return ErrBillingDisabled
}
// GetSupporterBadge returns empty string when billing is not compiled in.
// Hold captains get a "Captain" badge.
func (m *Manager) GetSupporterBadge(userDID string) string {
if m.captainChecker != nil && userDID != "" && m.captainChecker(userDID) {
return "Captain"
}
return ""
}
// GetFirstTierWithAllTriggers returns empty string when billing is not compiled in.
func (m *Manager) GetFirstTierWithAllTriggers() string {
return ""
}
// RegisterRoutes is a no-op when billing is not compiled in.
func (m *Manager) RegisterRoutes(_ chi.Router) {}
// RefreshHoldTiers is a no-op when billing is not compiled in.
func (m *Manager) RefreshHoldTiers() {}