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

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

Two consistency fixes fall out of wiring this up:

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

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

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

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

63 lines
2.4 KiB
HTML

{{ 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">
{{ template "hold_selector" . }}
{{ if .ActiveHold }}
{{ template "hold_card" .ActiveHold }}
{{ else }}
<div class="card bg-base-200 shadow-sm p-6 text-center text-base-content/60">
No active hold selected. Choose one above.
</div>
{{ end }}
</div>
{{ if .OtherHolds }}
<div>
{{ template "other_holds_table" .OtherHolds }}
</div>
{{ end }}
</div>
{{ else }}
<div class="card bg-base-200 shadow-sm">
{{ template "state-empty" (dict
"Icon" "anchor"
"Title" "No holds configured"
"Subtext" "Push an image to atcr.io and your first hold will appear here."
) }}
</div>
{{ end }}
<section class="card bg-base-200 shadow-sm p-6 space-y-4">
<h2 class="text-xl font-semibold">Storage Preferences</h2>
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox" class="toggle toggle-primary mt-0.5"
hx-post="/api/profile/auto-remove-untagged"
hx-trigger="change"
hx-swap="none"
{{ if .Profile.AutoRemoveUntagged }}checked{{ end }}>
<div>
<span class="font-medium">Automatically remove untagged manifests</span>
<p class="text-sm text-base-content/60 mt-1">
When a tag is overwritten, the old manifest and its layers are cleaned up.
Multi-arch child manifests are preserved.
</p>
</div>
</label>
</section>
{{ end }}