mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-31 23:36:57 +00:00
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:
co-authored by
Claude Opus 5
parent
12c55ed560
commit
2b71be59f7
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user