mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-01 07:46:55 +00:00
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>
119 lines
3.3 KiB
Go
119 lines
3.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"atcr.io/pkg/appview/db"
|
|
"atcr.io/pkg/appview/middleware"
|
|
"atcr.io/pkg/billing"
|
|
)
|
|
|
|
// SubscriptionDisplay is the template-friendly subscription data.
|
|
type SubscriptionDisplay struct {
|
|
UserDID string
|
|
CurrentTier string
|
|
PaymentsEnabled bool
|
|
Tiers []TierDisplay
|
|
SubscriptionID string
|
|
BillingInterval string
|
|
HideBilling bool
|
|
}
|
|
|
|
// TierDisplay is a template-friendly tier.
|
|
type TierDisplay struct {
|
|
ID string
|
|
Name string
|
|
Description string
|
|
Features []string
|
|
PriceCentsMonthly int
|
|
PriceCentsYearly int
|
|
PriceMonthly string // e.g. "$5/mo"
|
|
PriceYearly string // e.g. "$50/yr"
|
|
IsCurrent bool
|
|
}
|
|
|
|
// SubscriptionCheckoutHandler redirects to Stripe checkout.
|
|
type SubscriptionCheckoutHandler struct {
|
|
BaseUIHandler
|
|
}
|
|
|
|
func (h *SubscriptionCheckoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
user := middleware.GetUser(r)
|
|
if user == nil {
|
|
http.Redirect(w, r, "/auth/oauth/login?return_to=/settings%23storage", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
if h.BillingManager == nil || !h.BillingManager.Enabled() {
|
|
http.Error(w, "Billing not available", http.StatusNotFound)
|
|
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)
|
|
return
|
|
}
|
|
|
|
interval := r.URL.Query().Get("interval")
|
|
if interval == "" {
|
|
interval = "monthly"
|
|
}
|
|
|
|
resp, err := h.BillingManager.CreateCheckoutSession(r, user.DID, user.Handle, &billing.CheckoutSessionRequest{
|
|
Tier: tier,
|
|
Interval: interval,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("Failed to create checkout session", "did", user.DID, "tier", tier, "error", err)
|
|
http.Error(w, "Failed to create checkout session", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, resp.CheckoutURL, http.StatusFound)
|
|
}
|
|
|
|
// SubscriptionPortalHandler redirects to Stripe billing portal.
|
|
type SubscriptionPortalHandler struct {
|
|
BaseUIHandler
|
|
}
|
|
|
|
func (h *SubscriptionPortalHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
user := middleware.GetUser(r)
|
|
if user == nil {
|
|
http.Redirect(w, r, "/auth/oauth/login?return_to=/settings%23storage", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
if h.BillingManager == nil || !h.BillingManager.Enabled() {
|
|
http.Error(w, "Billing not available", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
scheme := "https"
|
|
if r.TLS == nil {
|
|
scheme = "http"
|
|
}
|
|
returnURL := scheme + "://" + h.SiteURL + "/settings/billing"
|
|
|
|
resp, err := h.BillingManager.GetBillingPortalURL(user.DID, returnURL)
|
|
if err != nil {
|
|
slog.Warn("Failed to get billing portal URL", "did", user.DID, "error", err)
|
|
http.Error(w, "Failed to get billing portal", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, resp.PortalURL, http.StatusFound)
|
|
}
|