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>
This commit is contained in:
Evan Jarrett
2026-08-09 21:14:58 -05:00
co-authored by Claude Opus 5
parent 12c55ed560
commit 2b71be59f7
16 changed files with 497 additions and 12 deletions
+14
View File
@@ -613,6 +613,20 @@ func UpdateUserRegistryDomain(db DBTX, did string, registryDomain string) error
return err
}
// GetUserDefaultHoldDID returns a user's explicitly-set default hold DID from
// the cached profile, or "" if unset. Unlike GetUserHoldDID it does NOT fall
// back to a manifest's hold_endpoint (which may be a URL, not a DID): callers
// gating on managed-hold membership need the clean default-hold signal where
// "" means the user falls back to the operator's primary managed hold.
func GetUserDefaultHoldDID(db DBTX, did string) string {
var holdDID sql.NullString
_ = db.QueryRow(`SELECT default_hold_did FROM users WHERE did = ?`, did).Scan(&holdDID)
if holdDID.Valid {
return holdDID.String
}
return ""
}
// GetUserHoldDID returns the hold DID for a user. Uses cached default_hold_did
// if available, otherwise falls back to the most recent manifest's hold_endpoint.
func GetUserHoldDID(db DBTX, did string) string {
+11
View File
@@ -3,6 +3,7 @@ package handlers
import (
"database/sql"
"html/template"
"slices"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/holdhealth"
@@ -53,3 +54,13 @@ type BaseUIHandler struct {
BillingEnabled bool // True when the billing build is compiled in and Stripe is configured
SourceURL string // Source code URL for the footer "Source" link
}
// IsManagedHold reports whether a hold DID is one of the appview's managed
// holds. An empty holdDID counts as managed: the user has no explicit default
// hold and falls back to the operator's primary managed hold.
func (h *BaseUIHandler) IsManagedHold(holdDID string) bool {
if holdDID == "" {
return true
}
return slices.Contains(h.ManagedHolds, holdDID)
}
@@ -0,0 +1,80 @@
//go:build billing
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/billing"
)
func checkoutTestManager(managedHolds []string) *billing.Manager {
cfg := &billing.Config{
StripeSecretKey: "sk_test_dummy",
WebhookSecret: "whsec_dummy",
Tiers: []billing.BillingTierConfig{{Name: "free", MaxWebhooks: 1}, {Name: "bosun", MaxWebhooks: 5}},
}
return billing.New(cfg, nil, "did:web:appview", managedHolds, "https://appview", nil)
}
func TestSubscriptionCheckout_GatedOnManagedHold(t *testing.T) {
conn, err := db.InitDB(":memory:", db.LibsqlConfig{})
if err != nil {
t.Fatalf("init db: %v", err)
}
defer conn.Close()
const managed = "did:web:managed-hold"
mgr := checkoutTestManager([]string{managed})
mkUser := func(didSuffix, defaultHold string) *db.User {
did := "did:plc:" + didSuffix
if err := db.UpsertUser(conn, &db.User{DID: did, Handle: didSuffix + ".test", PDSEndpoint: "https://pds", LastSeen: time.Now()}); err != nil {
t.Fatalf("upsert user: %v", err)
}
if defaultHold != "" {
if err := db.UpdateUserDefaultHold(conn, did, defaultHold); err != nil {
t.Fatalf("set default hold: %v", err)
}
}
return &db.User{DID: did, Handle: didSuffix + ".test"}
}
h := &SubscriptionCheckoutHandler{BaseUIHandler: BaseUIHandler{
BillingManager: mgr,
ManagedHolds: []string{managed},
DB: conn,
}}
// Self-hosted default hold → checkout rejected with 403 before any Stripe call.
t.Run("self-hosted -> 403", func(t *testing.T) {
user := mkUser("selfhosted", "did:web:someones-own-hold")
req := middleware.WithUser(httptest.NewRequest(http.MethodGet, "/settings/subscription/checkout?tier=bosun", nil), user)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403", rr.Code)
}
})
// Managed default hold → passes the hold gate. With no tier param it falls
// through to the 400 tier-required check (so we never reach Stripe), proving
// the gate doesn't block managed users.
t.Run("managed -> passes gate (400 on missing tier)", func(t *testing.T) {
user := mkUser("managed", managed)
req := middleware.WithUser(httptest.NewRequest(http.MethodGet, "/settings/subscription/checkout", nil), user)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code == http.StatusForbidden {
t.Errorf("managed user wrongly blocked with 403")
}
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (missing tier, i.e. passed the hold gate)", rr.Code)
}
})
}
+8 -2
View File
@@ -121,9 +121,15 @@ func (h *ImageAdvisorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
return
}
// Check billing access
// Check billing access. Paid features require a managed default hold, so a
// paid user on a self-hosted hold also fails here — show them the right
// message (switch holds) rather than "upgrade".
if h.BillingManager != nil && !h.BillingManager.HasAIAdvisor(user.DID) {
h.renderResults(w, imageAdvisorData{Error: "upgrade_required"})
errCode := "upgrade_required"
if !h.IsManagedHold(db.GetUserDefaultHoldDID(h.DB, user.DID)) {
errCode = "managed_hold_required"
}
h.renderResults(w, imageAdvisorData{Error: errCode})
return
}
+37
View File
@@ -0,0 +1,37 @@
package handlers
import "testing"
func TestIsManagedHold(t *testing.T) {
h := &BaseUIHandler{ManagedHolds: []string{"did:web:hold01", "did:web:hold02"}}
cases := []struct {
name string
holdDID string
want bool
}{
{"empty defaults to managed", "", true},
{"managed member", "did:web:hold01", true},
{"other managed member", "did:web:hold02", true},
{"self-hosted", "did:web:someones-own-hold", false},
{"unknown", "did:plc:abc123", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := h.IsManagedHold(tc.holdDID); got != tc.want {
t.Errorf("IsManagedHold(%q) = %v, want %v", tc.holdDID, got, tc.want)
}
})
}
}
// With no managed holds configured, only the empty (fallback) hold is managed.
func TestIsManagedHold_NoManagedHolds(t *testing.T) {
h := &BaseUIHandler{}
if !h.IsManagedHold("") {
t.Error("empty hold should count as managed (operator fallback)")
}
if h.IsManagedHold("did:web:anything") {
t.Error("no holds configured: a concrete hold should not be managed")
}
}
+35 -3
View File
@@ -101,6 +101,12 @@ type settingsPageData struct {
EligibleHolds []HoldDisplay
WebhooksData webhooksTemplateData
Subscription SubscriptionDisplay
// SelfHostedHold is true when the user's active hold is self-hosted while the
// deployment has billing enabled — paid features (and billing) don't apply.
SelfHostedHold bool
// SelfHostedActivePlan is true when SelfHostedHold AND the user still has an
// active Stripe subscription, so they're being charged without paid features.
SelfHostedActivePlan bool
}
// ServeHTTP redirects /settings to /settings/user.
@@ -143,11 +149,24 @@ func (h *SettingsHandler) ServeTab(tab string) http.HandlerFunc {
).WithRobots("noindex").
WithSiteName(h.ClientShortName)
// Billing is a managed-hold concept: hide it for users whose active hold
// is self-hosted (paid features require a managed hold). An unset default
// hold means the operator's primary managed hold, so it counts as managed.
//
// Read the resolved default_hold_did rather than profile.DefaultHold. The
// profile field is the raw record value and may be a URL-form reference;
// jetstream resolves it to a DID on the way into the DB, and the
// server-side entitlement gate reads that resolved value. Comparing the
// raw form here against managed DIDs would tell a managed user they are
// self-hosted while their entitlements say otherwise.
activeHoldDID := db.GetUserDefaultHoldDID(h.DB, user.DID)
showBilling := h.BillingEnabled && h.IsManagedHold(activeHoldDID)
data := settingsPageData{
PageData: NewPageData(r, &h.BaseUIHandler),
Meta: meta,
ActiveTab: tab,
Tabs: settingsTabs(h.BillingEnabled),
Tabs: settingsTabs(showBilling),
Profile: settingsProfile{
Handle: user.Handle,
DID: user.DID,
@@ -168,8 +187,16 @@ func (h *SettingsHandler) ServeTab(tab string) http.HandlerFunc {
switch tab {
case "storage":
data.ActiveHold, data.OtherHolds, data.MemberHolds, data.EligibleHolds = h.buildHoldsData(r.Context(), user.DID, profile.DefaultHold)
// On a self-hosted hold, billing/paid features don't apply. If the
// user still has an active plan, surface a cancel/manage banner.
if h.BillingEnabled && !h.IsManagedHold(activeHoldDID) {
data.SelfHostedHold = true
if h.BillingManager != nil {
data.SelfHostedActivePlan = h.BillingManager.HasActiveSubscription(user.DID)
}
}
case "billing":
data.Subscription = h.buildSubscriptionDisplay(user.DID)
data.Subscription = h.buildSubscriptionDisplay(user.DID, activeHoldDID)
case "webhooks":
data.WebhooksData = h.buildWebhooksData(user.DID)
}
@@ -294,10 +321,15 @@ func (h *SettingsHandler) buildWebhooksData(userDID string) webhooksTemplateData
}
// buildSubscriptionDisplay fetches subscription info for SSR in the settings page.
func (h *SettingsHandler) buildSubscriptionDisplay(userDID string) SubscriptionDisplay {
// Billing is hidden for users whose active (default) hold is self-hosted: paid
// features require a managed hold.
func (h *SettingsHandler) buildSubscriptionDisplay(userDID, defaultHold string) SubscriptionDisplay {
if h.BillingManager == nil || !h.BillingManager.Enabled() {
return SubscriptionDisplay{HideBilling: true}
}
if !h.IsManagedHold(defaultHold) {
return SubscriptionDisplay{HideBilling: true}
}
info, err := h.BillingManager.GetSubscriptionInfo(userDID)
if err != nil {
+11
View File
@@ -4,6 +4,7 @@ import (
"log/slog"
"net/http"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/billing"
)
@@ -49,6 +50,16 @@ func (h *SubscriptionCheckoutHandler) ServeHTTP(w http.ResponseWriter, r *http.R
return
}
// Paid features (and therefore checkout) require a managed default hold.
// The UI hides the button, but the route is directly reachable — gate it so
// a self-hosted user can't pay for features they won't receive. Read the
// primary DB so a just-switched hold is reflected immediately. The portal
// route stays open so a subscriber on a self-hosted hold can still cancel.
if !h.IsManagedHold(db.GetUserDefaultHoldDID(h.DB, user.DID)) {
http.Error(w, "Billing is only available on managed holds", http.StatusForbidden)
return
}
tier := r.URL.Query().Get("tier")
if tier == "" {
http.Error(w, "tier parameter required", http.StatusBadRequest)
+9 -3
View File
@@ -552,11 +552,17 @@ func (p *Processor) ProcessSailorProfile(ctx context.Context, did string, record
return nil
}
// Convert hold URL/DID to canonical DID
// Convert hold URL/DID to canonical DID. On failure, cache the raw reference
// rather than leaving default_hold_did empty: "" means "operator default"
// (managed) to the billing gate, so a self-hosted user whose hold is briefly
// unresolvable must not fall through to managed. A managed hold's DID never
// fails resolution (DIDs return as-is), so only unreachable URL-form refs land
// here, and a raw non-DID value correctly reads as non-managed (fail closed).
holdDID, err := atproto.ResolveHoldDID(ctx, profileRecord.DefaultHold)
if err != nil {
slog.Warn("Invalid hold reference in profile", "component", "processor", "did", did, "default_hold", profileRecord.DefaultHold, "error", err)
return nil
slog.Warn("Invalid hold reference in profile; caching raw value (fails closed for billing)",
"component", "processor", "did", did, "default_hold", profileRecord.DefaultHold, "error", err)
holdDID = profileRecord.DefaultHold
}
// Cache default hold DID on the user record
@@ -0,0 +1,62 @@
package jetstream
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/atproto"
)
// When a profile's default hold can't be resolved, the processor must cache the
// raw reference (non-empty) rather than leaving default_hold_did = "". The
// billing gate treats "" as the operator's managed default, so an empty value
// for a genuinely self-hosted (but unreachable) hold would fail OPEN. A raw,
// non-managed value reads as non-managed → fails closed.
func TestProcessSailorProfile_UnresolvableHold_CachesRawValue(t *testing.T) {
database, err := db.InitDB(":memory:", db.LibsqlConfig{})
if err != nil {
t.Fatalf("init db: %v", err)
}
defer database.Close()
const did = "did:plc:failclosed"
if err := db.UpsertUser(database, &db.User{
DID: did, Handle: "fc.test", PDSEndpoint: "https://pds", LastSeen: time.Now(),
}); err != nil {
t.Fatalf("upsert user: %v", err)
}
// A hold whose .well-known/atproto-did returns 404 → ResolveHoldDID fails
// deterministically without depending on external DNS/network.
holdSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
defer holdSrv.Close()
record, err := json.Marshal(atproto.SailorProfileRecord{
Type: "io.atcr.sailor.profile",
DefaultHold: holdSrv.URL, // URL-form, unresolvable → resolution fails
CreatedAt: time.Now().UTC(),
})
if err != nil {
t.Fatalf("marshal profile: %v", err)
}
p := NewProcessor(database, false, nil)
if err := p.ProcessSailorProfile(context.Background(), did, record, nil); err != nil {
t.Fatalf("ProcessSailorProfile: %v", err)
}
got := db.GetUserDefaultHoldDID(database, did)
if got == "" {
t.Fatal("default_hold_did is empty — would fail OPEN for a self-hosted user")
}
if got != holdSrv.URL {
t.Errorf("default_hold_did = %q, want raw value %q", got, holdSrv.URL)
}
}
+12
View File
@@ -12,6 +12,7 @@ import (
"net/url"
"os"
"os/signal"
"slices"
"strings"
"syscall"
"time"
@@ -276,6 +277,17 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer,
isCaptain, _ := db.IsHoldCaptain(roDB, userDID, managedHolds)
return isCaptain
})
// Paid features require the user's active (default) hold to be a managed
// hold. An unset default hold means they use the operator's primary
// managed hold, so it counts as managed. Read the primary DB (not roDB):
// a hold switch writes default_hold_did to the primary, and the replica
// may lag, so reading roDB could keep paid features alive briefly after a
// switch to self-hosted.
primaryDB := s.Database
s.BillingManager.SetActiveHoldChecker(func(userDID string) bool {
holdDID := db.GetUserDefaultHoldDID(primaryDB, userDID)
return holdDID == "" || slices.Contains(managedHolds, holdDID)
})
}
if s.BillingManager.Enabled() {
// Fail closed: an empty Stripe webhook secret makes webhooks forgeable
@@ -4,6 +4,11 @@
{{ icon "sparkles" "size-4" }}
<span>AI Image Advisor is a paid feature. <a href="/settings/billing" class="link link-hover font-semibold underline">Upgrade your plan</a> to unlock image analysis.</span>
</div>
{{ else if eq .Error "managed_hold_required" }}
<div class="alert alert-info text-sm">
{{ icon "sparkles" "size-4" }}
<span>AI Image Advisor is available on managed holds. <a href="/settings/storage" class="link link-hover font-semibold underline">Switch your default hold</a> to a managed hold to use it.</span>
</div>
{{ else if .Error }}
<div class="alert alert-warning text-sm">
{{ icon "alert-triangle" "size-4" }}
@@ -1,4 +1,19 @@
{{ define "settings-panel-storage" }}
{{ if .SelfHostedActivePlan }}
<div role="alert" class="alert alert-warning mb-4">
{{ icon "alert-triangle" "size-5 shrink-0" }}
<div>
<p class="font-medium">You're on a self-hosted hold but still have an active plan</p>
<p class="text-sm">Paid features apply only on managed holds, so your plan is currently inactive. You can manage or cancel it anytime.</p>
</div>
<a class="btn btn-sm" href="/settings/subscription/portal">Manage plan</a>
</div>
{{ else if .SelfHostedHold }}
<div class="alert mb-4">
{{ icon "server" "size-5 shrink-0" }}
<span>You're on a self-hosted hold, so there's nothing to bill. Paid features apply on managed holds.</span>
</div>
{{ end }}
{{ if or .MemberHolds .EligibleHolds }}
<div class="grid grid-cols-1 {{ if .OtherHolds }}lg:grid-cols-2{{ end }} gap-4">
<div class="space-y-4">
+65 -4
View File
@@ -45,6 +45,9 @@ type Manager struct {
// 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
@@ -118,6 +121,22 @@ 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 {
@@ -139,6 +158,11 @@ func (m *Manager) GetWebhookLimits(userDID string) (int, bool) {
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
@@ -161,6 +185,14 @@ func (m *Manager) HasAIAdvisor(userDID string) bool {
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
@@ -184,6 +216,11 @@ func (m *Manager) GetSupporterBadge(userDID string) string {
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 ""
@@ -224,10 +261,16 @@ func (m *Manager) GetSubscriptionInfo(userDID string) (*SubscriptionInfo, error)
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,
CurrentTier: m.cfg.Tiers[0].Name, // default to lowest
TierRank: 0,
UserDID: userDID,
PaymentsEnabled: onManaged,
CurrentTier: m.cfg.Tiers[0].Name, // default to lowest
TierRank: 0,
}
// Build tier list with live Stripe prices
@@ -263,7 +306,7 @@ func (m *Manager) GetSubscriptionInfo(userDID string) (*SubscriptionInfo, error)
}
}
if userDID == "" {
if userDID == "" || !onManaged {
return info, nil
}
@@ -313,6 +356,24 @@ func (m *Manager) GetSubscriptionInfo(userDID string) (*SubscriptionInfo, error)
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() {
+6
View File
@@ -25,6 +25,9 @@ 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 }
@@ -54,6 +57,9 @@ 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
+121
View File
@@ -0,0 +1,121 @@
//go:build billing
package billing
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func testManager(t *testing.T) *Manager {
t.Helper()
cfg := &Config{
StripeSecretKey: "sk_test_dummy",
WebhookSecret: "whsec_dummy",
Tiers: []BillingTierConfig{
{Name: "free", MaxWebhooks: 1, WebhookAllTriggers: false, AIAdvisor: false, SupporterBadge: false},
{Name: "bosun", MaxWebhooks: 5, WebhookAllTriggers: true, AIAdvisor: true, SupporterBadge: true},
},
}
// nil privateKey/db are fine: the gated paths under test never touch them.
return New(cfg, nil, "did:web:appview", []string{"did:web:hold"}, "https://appview", nil)
}
// Off-managed users (not captains) get free-tier entitlements, and these paths
// short-circuit before any Stripe call.
func TestEntitlements_OffManagedHold_GetsFreeTier(t *testing.T) {
m := testManager(t)
m.SetActiveHoldChecker(func(string) bool { return false }) // self-hosted
if m.HasAIAdvisor("did:plc:user") {
t.Error("HasAIAdvisor should be false off a managed hold")
}
if max, all := m.GetWebhookLimits("did:plc:user"); max != 1 || all {
t.Errorf("GetWebhookLimits = (%d,%v), want (1,false)", max, all)
}
if badge := m.GetSupporterBadge("did:plc:user"); badge != "" {
t.Errorf("GetSupporterBadge = %q, want \"\"", badge)
}
}
// Captains bypass the gate entirely (this short-circuits before onManagedHold).
func TestEntitlements_Captain_BypassesGate(t *testing.T) {
m := testManager(t)
m.SetCaptainChecker(func(string) bool { return true })
m.SetActiveHoldChecker(func(string) bool { return false }) // even off-managed
if !m.HasAIAdvisor("did:plc:cap") {
t.Error("captain should have AI advisor")
}
if max, all := m.GetWebhookLimits("did:plc:cap"); max != -1 || !all {
t.Errorf("captain GetWebhookLimits = (%d,%v), want (-1,true)", max, all)
}
if badge := m.GetSupporterBadge("did:plc:cap"); badge != "Captain" {
t.Errorf("captain GetSupporterBadge = %q, want \"Captain\"", badge)
}
}
func TestWebhookConfigured(t *testing.T) {
m := testManager(t)
if !m.WebhookConfigured() {
t.Error("expected WebhookConfigured true when secret set")
}
cfg := &Config{StripeSecretKey: "sk_test_dummy", Tiers: []BillingTierConfig{{Name: "free", MaxWebhooks: 1}}}
m2 := New(cfg, nil, "did:web:appview", nil, "https://appview", nil)
if m2.WebhookConfigured() {
t.Error("expected WebhookConfigured false when secret empty")
}
}
// Empty webhook secret fails closed with a processing error (not a signature
// error), so the HTTP layer returns 5xx rather than silently accepting.
func TestHandleWebhook_EmptySecret_FailsClosed(t *testing.T) {
cfg := &Config{StripeSecretKey: "sk_test_dummy", Tiers: []BillingTierConfig{{Name: "free", MaxWebhooks: 1}}}
m := New(cfg, nil, "did:web:appview", nil, "https://appview", nil)
req := httptest.NewRequest(http.MethodPost, "/api/stripe/webhook", strings.NewReader("{}"))
err := m.HandleWebhook(req)
if !errors.Is(err, ErrWebhookProcessing) {
t.Fatalf("expected ErrWebhookProcessing, got %v", err)
}
}
// A bad signature is a client error (400, no Stripe retry).
func TestHandleWebhook_BadSignature(t *testing.T) {
m := testManager(t)
req := httptest.NewRequest(http.MethodPost, "/api/stripe/webhook", strings.NewReader("{}"))
req.Header.Set("Stripe-Signature", "t=123,v1=deadbeef")
err := m.HandleWebhook(req)
if !errors.Is(err, ErrWebhookSignature) {
t.Fatalf("expected ErrWebhookSignature, got %v", err)
}
}
// The HTTP handler maps signature errors to 400 and processing errors to 500.
func TestHandleStripeWebhook_StatusCodes(t *testing.T) {
t.Run("bad signature -> 400", func(t *testing.T) {
m := testManager(t)
req := httptest.NewRequest(http.MethodPost, "/api/stripe/webhook", strings.NewReader("{}"))
req.Header.Set("Stripe-Signature", "t=123,v1=deadbeef")
rr := httptest.NewRecorder()
m.handleStripeWebhook(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rr.Code)
}
})
t.Run("processing error -> 500", func(t *testing.T) {
cfg := &Config{StripeSecretKey: "sk_test_dummy", Tiers: []BillingTierConfig{{Name: "free", MaxWebhooks: 1}}}
m := New(cfg, nil, "did:web:appview", nil, "https://appview", nil) // empty secret
req := httptest.NewRequest(http.MethodPost, "/api/stripe/webhook", strings.NewReader("{}"))
rr := httptest.NewRecorder()
m.handleStripeWebhook(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", rr.Code)
}
})
}
+6
View File
@@ -13,6 +13,12 @@ var ErrBillingDisabled = errors.New("billing not enabled")
// Used to bypass billing feature gates for hold operators.
type CaptainChecker func(userDID string) bool
// ActiveHoldChecker returns true if a user's active (default) hold is one of the
// appview's managed holds. Paid features require this; a user on a self-hosted
// hold gets free-tier entitlements. An empty default hold counts as managed
// (the user falls back to the operator's primary managed hold).
type ActiveHoldChecker func(userDID string) bool
// SubscriptionInfo contains subscription information for a user.
type SubscriptionInfo struct {
UserDID string `json:"userDid"`