Files
at-container-registry/pkg/appview/handlers/subscription.go
T

338 lines
10 KiB
Go

package handlers
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/appview/storage"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth"
)
// SubscriptionInfo mirrors the hold's billing.SubscriptionInfo for JSON decoding.
type SubscriptionInfo struct {
UserDID string `json:"userDid"`
CurrentTier string `json:"currentTier"`
CrewTier string `json:"crewTier,omitempty"`
CurrentUsage int64 `json:"currentUsage"`
CurrentLimit *int64 `json:"currentLimit,omitempty"`
PaymentsEnabled bool `json:"paymentsEnabled"`
Tiers []TierInfo `json:"tiers"`
SubscriptionID string `json:"subscriptionId,omitempty"`
BillingInterval string `json:"billingInterval,omitempty"`
Error string `json:"error,omitempty"`
HideBilling bool `json:"-"` // hide entire section (no billing support)
HoldDisplayName string `json:"-"` // human-readable hold name for display
}
// TierInfo mirrors the hold's billing.TierInfo.
type TierInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
QuotaBytes int64 `json:"quotaBytes"`
QuotaFormatted string `json:"quotaFormatted"`
PriceCentsMonthly int `json:"priceCentsMonthly,omitempty"`
PriceCentsYearly int `json:"priceCentsYearly,omitempty"`
PriceFormatted string `json:"-"` // computed in handler, e.g., "$5/month"
IsCurrent bool `json:"isCurrent,omitempty"`
}
// SubscriptionHandler returns subscription info as HTML for HTMX.
type SubscriptionHandler struct {
BaseUIHandler
}
func (h *SubscriptionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
h.renderHidden(w)
return
}
// Use hold_did query param if provided (for previewing other holds),
// otherwise fall back to the user's saved default hold from their profile.
holdDID := r.URL.Query().Get("hold_did")
if holdDID == "" {
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
profile, err := storage.GetProfile(r.Context(), client)
if err != nil {
slog.Warn("Failed to get profile for subscription", "did", user.DID, "error", err)
h.renderHidden(w)
return
}
holdDID = h.DefaultHoldDID
if profile != nil && profile.DefaultHold != "" {
holdDID = profile.DefaultHold
}
}
if holdDID == "" {
h.renderHidden(w)
return
}
// Resolve hold DID to endpoint
holdEndpoint := atproto.ResolveHoldURL(holdDID)
if holdEndpoint == "" {
slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID)
h.renderHidden(w)
return
}
// Fetch subscription info from hold (public endpoint, no auth needed)
subURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.getSubscriptionInfo?userDid=%s", holdEndpoint, user.DID)
resp, err := http.Get(subURL)
if err != nil {
slog.Warn("Failed to fetch subscription info", "url", subURL, "error", err)
h.renderHidden(w)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
slog.Warn("Hold returned error for subscription", "status", resp.StatusCode)
h.renderHidden(w)
return
}
var info SubscriptionInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
slog.Warn("Failed to decode subscription info", "error", err)
h.renderHidden(w)
return
}
if !info.PaymentsEnabled {
h.renderHidden(w)
return
}
// Set hold display name so users know which hold the subscription applies to
info.HoldDisplayName = deriveDisplayName(holdDID)
// Format prices for display
// Note: -1 means "has price, fetch from Stripe" (placeholder from hold)
for i := range info.Tiers {
tier := &info.Tiers[i]
hasMonthly := tier.PriceCentsMonthly != 0
hasYearly := tier.PriceCentsYearly != 0
switch {
case hasMonthly && tier.PriceCentsMonthly > 0:
tier.PriceFormatted = fmt.Sprintf("$%d/month", tier.PriceCentsMonthly/100)
case hasYearly && tier.PriceCentsYearly > 0:
tier.PriceFormatted = fmt.Sprintf("$%d/year", tier.PriceCentsYearly/100)
case hasMonthly || hasYearly:
// Has price but we don't know the amount (-1 sentinel)
tier.PriceFormatted = "Paid"
default:
tier.PriceFormatted = "Free"
}
}
// Render the subscription info
h.renderInfo(w, info)
}
func (h *SubscriptionHandler) renderInfo(w http.ResponseWriter, info SubscriptionInfo) {
w.Header().Set("Content-Type", "text/html")
if err := h.Templates.ExecuteTemplate(w, "subscription_info", info); err != nil {
slog.Error("Failed to render subscription template", "error", err)
h.renderError(w, "Failed to render template")
}
}
func (h *SubscriptionHandler) renderHidden(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/html")
info := SubscriptionInfo{HideBilling: true}
if err := h.Templates.ExecuteTemplate(w, "subscription_info", info); err != nil {
slog.Error("Failed to render hidden subscription template", "error", err)
}
}
func (h *SubscriptionHandler) renderError(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<div class="alert alert-error"><svg class="icon size-5" aria-hidden="true"><use href="/icons.svg#alert-circle"></use></svg> %s</div>`, message)
}
// SubscriptionCheckoutHandler redirects to hold's 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
}
tier := r.URL.Query().Get("tier")
if tier == "" {
http.Error(w, "tier parameter required", http.StatusBadRequest)
return
}
// Get user's default hold
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
profile, err := storage.GetProfile(r.Context(), client)
if err != nil {
http.Error(w, "Failed to load profile", http.StatusInternalServerError)
return
}
holdDID := h.DefaultHoldDID
if profile != nil && profile.DefaultHold != "" {
holdDID = profile.DefaultHold
}
if holdDID == "" {
http.Error(w, "No default hold configured", http.StatusBadRequest)
return
}
// Resolve hold endpoint
holdEndpoint := atproto.ResolveHoldURL(holdDID)
if holdEndpoint == "" {
http.Error(w, "Failed to resolve hold", http.StatusInternalServerError)
return
}
// Get service token for the hold
serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint)
if err != nil {
slog.Warn("Failed to get service token for checkout", "did", user.DID, "holdDid", holdDID, "error", err)
http.Error(w, "Failed to authenticate with hold", http.StatusInternalServerError)
return
}
// Call hold's checkout endpoint
checkoutURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.createCheckoutSession", holdEndpoint)
reqBody := map[string]string{
"tier": tier,
"returnUrl": h.SiteURL + "/settings#storage",
}
bodyBytes, _ := json.Marshal(reqBody)
req, err := http.NewRequestWithContext(r.Context(), "POST", checkoutURL, bytes.NewReader(bodyBytes))
if err != nil {
http.Error(w, "Failed to create request", http.StatusInternalServerError)
return
}
req.Header.Set("Authorization", "Bearer "+serviceToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-DID", user.DID)
httpClient := &http.Client{}
resp, err := httpClient.Do(req)
if err != nil {
slog.Warn("Failed to call checkout endpoint", "error", err)
http.Error(w, "Failed to create checkout session", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
http.Error(w, "Hold returned error", resp.StatusCode)
return
}
var checkoutResp struct {
CheckoutURL string `json:"checkoutUrl"`
}
if err := json.NewDecoder(resp.Body).Decode(&checkoutResp); err != nil {
http.Error(w, "Invalid response from hold", http.StatusInternalServerError)
return
}
// Redirect to Stripe checkout
http.Redirect(w, r, checkoutResp.CheckoutURL, http.StatusFound)
}
// SubscriptionPortalHandler redirects to hold's 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
}
// Get user's default hold
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
profile, err := storage.GetProfile(r.Context(), client)
if err != nil {
http.Error(w, "Failed to load profile", http.StatusInternalServerError)
return
}
holdDID := h.DefaultHoldDID
if profile != nil && profile.DefaultHold != "" {
holdDID = profile.DefaultHold
}
if holdDID == "" {
http.Error(w, "No default hold configured", http.StatusBadRequest)
return
}
// Resolve hold endpoint
holdEndpoint := atproto.ResolveHoldURL(holdDID)
if holdEndpoint == "" {
http.Error(w, "Failed to resolve hold", http.StatusInternalServerError)
return
}
// Get service token
serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint)
if err != nil {
slog.Warn("Failed to get service token for portal", "did", user.DID, "holdDid", holdDID, "error", err)
http.Error(w, "Failed to authenticate with hold", http.StatusInternalServerError)
return
}
// Call hold's portal endpoint
portalURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.getBillingPortalUrl?returnUrl=%s/settings%%23storage", holdEndpoint, h.SiteURL)
req, err := http.NewRequestWithContext(r.Context(), "GET", portalURL, nil)
if err != nil {
http.Error(w, "Failed to create request", http.StatusInternalServerError)
return
}
req.Header.Set("Authorization", "Bearer "+serviceToken)
req.Header.Set("X-User-DID", user.DID)
httpClient := &http.Client{}
resp, err := httpClient.Do(req)
if err != nil {
slog.Warn("Failed to call portal endpoint", "error", err)
http.Error(w, "Failed to get billing portal", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
http.Error(w, "Hold returned error", resp.StatusCode)
return
}
var portalResp struct {
PortalURL string `json:"portalUrl"`
}
if err := json.NewDecoder(resp.Body).Decode(&portalResp); err != nil {
http.Error(w, "Invalid response from hold", http.StatusInternalServerError)
return
}
// Redirect to Stripe portal
http.Redirect(w, r, portalResp.PortalURL, http.StatusFound)
}