diff --git a/pkg/appview/db/queries.go b/pkg/appview/db/queries.go index 9dff56a..76f8166 100644 --- a/pkg/appview/db/queries.go +++ b/pkg/appview/db/queries.go @@ -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 { diff --git a/pkg/appview/handlers/base.go b/pkg/appview/handlers/base.go index 2d6ac92..9bd9a0f 100644 --- a/pkg/appview/handlers/base.go +++ b/pkg/appview/handlers/base.go @@ -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) +} diff --git a/pkg/appview/handlers/checkout_gate_test.go b/pkg/appview/handlers/checkout_gate_test.go new file mode 100644 index 0000000..f915300 --- /dev/null +++ b/pkg/appview/handlers/checkout_gate_test.go @@ -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) + } + }) +} diff --git a/pkg/appview/handlers/image_advisor.go b/pkg/appview/handlers/image_advisor.go index 2462db3..4bd6dfc 100644 --- a/pkg/appview/handlers/image_advisor.go +++ b/pkg/appview/handlers/image_advisor.go @@ -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 } diff --git a/pkg/appview/handlers/managed_hold_test.go b/pkg/appview/handlers/managed_hold_test.go new file mode 100644 index 0000000..eed76ba --- /dev/null +++ b/pkg/appview/handlers/managed_hold_test.go @@ -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") + } +} diff --git a/pkg/appview/handlers/settings.go b/pkg/appview/handlers/settings.go index d53705a..6ebd92b 100644 --- a/pkg/appview/handlers/settings.go +++ b/pkg/appview/handlers/settings.go @@ -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 { diff --git a/pkg/appview/handlers/subscription.go b/pkg/appview/handlers/subscription.go index 8443317..4a39619 100644 --- a/pkg/appview/handlers/subscription.go +++ b/pkg/appview/handlers/subscription.go @@ -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) diff --git a/pkg/appview/jetstream/processor.go b/pkg/appview/jetstream/processor.go index 877ff27..3eff352 100644 --- a/pkg/appview/jetstream/processor.go +++ b/pkg/appview/jetstream/processor.go @@ -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 diff --git a/pkg/appview/jetstream/profile_failclosed_test.go b/pkg/appview/jetstream/profile_failclosed_test.go new file mode 100644 index 0000000..dea7e29 --- /dev/null +++ b/pkg/appview/jetstream/profile_failclosed_test.go @@ -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) + } +} diff --git a/pkg/appview/server.go b/pkg/appview/server.go index 3a8bb51..ca6e256 100644 --- a/pkg/appview/server.go +++ b/pkg/appview/server.go @@ -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 diff --git a/pkg/appview/templates/partials/image-advisor-results.html b/pkg/appview/templates/partials/image-advisor-results.html index cc7f500..7fde8c8 100644 --- a/pkg/appview/templates/partials/image-advisor-results.html +++ b/pkg/appview/templates/partials/image-advisor-results.html @@ -4,6 +4,11 @@ {{ icon "sparkles" "size-4" }} AI Image Advisor is a paid feature. Upgrade your plan to unlock image analysis. +{{ else if eq .Error "managed_hold_required" }} +
+ {{ icon "sparkles" "size-4" }} + AI Image Advisor is available on managed holds. Switch your default hold to a managed hold to use it. +
{{ else if .Error }}
{{ icon "alert-triangle" "size-4" }} diff --git a/pkg/appview/templates/partials/settings-panel-storage.html b/pkg/appview/templates/partials/settings-panel-storage.html index 855bfe8..33df41a 100644 --- a/pkg/appview/templates/partials/settings-panel-storage.html +++ b/pkg/appview/templates/partials/settings-panel-storage.html @@ -1,4 +1,19 @@ {{ define "settings-panel-storage" }} +{{ if .SelfHostedActivePlan }} + +{{ else if .SelfHostedHold }} +
+ {{ icon "server" "size-5 shrink-0" }} + You're on a self-hosted hold, so there's nothing to bill. Paid features apply on managed holds. +
+{{ end }} {{ if or .MemberHolds .EligibleHolds }}
diff --git a/pkg/billing/billing.go b/pkg/billing/billing.go index 7329ce1..b345228 100644 --- a/pkg/billing/billing.go +++ b/pkg/billing/billing.go @@ -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() { diff --git a/pkg/billing/billing_stub.go b/pkg/billing/billing_stub.go index 142806e..bffd320 100644 --- a/pkg/billing/billing_stub.go +++ b/pkg/billing/billing_stub.go @@ -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 diff --git a/pkg/billing/gate_test.go b/pkg/billing/gate_test.go new file mode 100644 index 0000000..70e0ccc --- /dev/null +++ b/pkg/billing/gate_test.go @@ -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) + } + }) +} diff --git a/pkg/billing/types.go b/pkg/billing/types.go index 6096bac..efa12c6 100644 --- a/pkg/billing/types.go +++ b/pkg/billing/types.go @@ -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"`