From 35f7a47af373b509d8c7c4c1ff6ef4ae9a22f2ce Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Tue, 3 Feb 2026 21:52:31 -0600 Subject: [PATCH] add simple stripe billing implementation for quotas --- Dockerfile.hold | 25 +- deploy/quotas.yaml | 16 +- docker-compose.yml | 6 +- pkg/appview/handlers/subscription.go | 317 ++++++++++++++++++ pkg/appview/routes/routes.go | 5 + pkg/appview/templates/pages/settings.html | 9 + .../templates/partials/subscription_info.html | 60 ++++ pkg/atproto/lexicon.go | 5 +- pkg/hold/billing/billing.go | 18 +- pkg/hold/billing/billing_stub.go | 2 +- pkg/hold/billing/config.go | 211 ++---------- pkg/hold/billing/config_test.go | 275 ++++++++------- pkg/hold/config.go | 8 + pkg/hold/pds/crew.go | 3 +- pkg/hold/server.go | 15 + themes/seamark/embed.go | 2 +- 16 files changed, 644 insertions(+), 333 deletions(-) create mode 100644 pkg/appview/handlers/subscription.go create mode 100644 pkg/appview/templates/partials/subscription_info.html diff --git a/Dockerfile.hold b/Dockerfile.hold index 81ad5da..080dec6 100644 --- a/Dockerfile.hold +++ b/Dockerfile.hold @@ -1,5 +1,9 @@ FROM docker.io/golang:1.25.4-trixie AS builder +# Build argument to enable Stripe billing integration +# Usage: docker build --build-arg BILLING_ENABLED=true -f Dockerfile.hold . +ARG BILLING_ENABLED=false + ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && \ @@ -13,11 +17,22 @@ RUN go mod download COPY . . -RUN CGO_ENABLED=1 go build \ - -ldflags="-s -w -linkmode external -extldflags '-static'" \ - -tags sqlite_omit_load_extension \ - -trimpath \ - -o atcr-hold ./cmd/hold +# Conditionally add billing tag based on build arg +RUN if [ "$BILLING_ENABLED" = "true" ]; then \ + echo "Building with Stripe billing support"; \ + CGO_ENABLED=1 go build \ + -ldflags="-s -w -linkmode external -extldflags '-static'" \ + -tags "sqlite_omit_load_extension,billing" \ + -trimpath \ + -o atcr-hold ./cmd/hold; \ + else \ + echo "Building without billing support"; \ + CGO_ENABLED=1 go build \ + -ldflags="-s -w -linkmode external -extldflags '-static'" \ + -tags sqlite_omit_load_extension \ + -trimpath \ + -o atcr-hold ./cmd/hold; \ + fi # ========================================== # Stage 2: Minimal FROM scratch runtime diff --git a/deploy/quotas.yaml b/deploy/quotas.yaml index 907fd77..e2bac39 100644 --- a/deploy/quotas.yaml +++ b/deploy/quotas.yaml @@ -6,7 +6,11 @@ # Each tier has a quota limit specified in human-readable format. # Supported units: B, KB, MB, GB, TB, PB (case-insensitive) tiers: - # Entry-level crew - suitable for new or casual users + # Entry-level crew - starter tier for new users (free) + swabbie: + quota: 2GB + + # Standard crew - for regular users deckhand: quota: 5GB @@ -15,17 +19,17 @@ tiers: quota: 10GB # Senior crew - for power users or trusted contributors - quartermaster: - quota: 50GB + #quartermaster: + # quota: 50GB # You can add custom tiers with any name: - # unlimited_crew: + # admiral: # quota: 1TB defaults: # Default tier assigned to new crew members who don't have an explicit tier. # This tier must exist in the tiers section above. - new_crew_tier: deckhand + new_crew_tier: swabbie # Notes: # - The hold captain (owner) always has unlimited quota regardless of tiers. @@ -33,3 +37,5 @@ defaults: # - If a crew member's tier doesn't exist in config, they fall back to the default. # - Quota is calculated per-user by summing unique blob sizes (deduplicated). # - Quota is checked when pushing manifests (after blobs are already uploaded). +# - Billing configuration (Stripe prices, descriptions) goes in a separate +# top-level "billing:" section. See billing documentation for details. diff --git a/docker-compose.yml b/docker-compose.yml index 442d6c7..ae1b83d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,6 +60,10 @@ services: HOLD_SERVER_PUBLIC: false HOLD_REGISTRATION_ALLOW_ALL_CREW: true HOLD_SERVER_TEST_MODE: true + # Stripe billing (only used with -tags billing) + STRIPE_SECRET_KEY: sk_test_ + STRIPE_PUBLISHABLE_KEY: pk_test_ + STRIPE_WEBHOOK_SECRET: whsec_ # Logging HOLD_LOG_LEVEL: debug # Log shipping (uncomment to enable) @@ -78,6 +82,7 @@ services: dockerfile: Dockerfile.dev args: AIR_CONFIG: .air.hold.toml + BILLING_ENABLED: "true" image: atcr-hold-dev:latest container_name: atcr-hold ports: @@ -89,7 +94,6 @@ services: - go-mod-cache:/go/pkg/mod # PDS data (carstore SQLite + signing keys) - atcr-hold:/var/lib/atcr-hold - - ./deploy/quotas.yaml:/app/quotas.yaml:ro restart: unless-stopped dns: - 8.8.8.8 diff --git a/pkg/appview/handlers/subscription.go b/pkg/appview/handlers/subscription.go new file mode 100644 index 0000000..bbe568e --- /dev/null +++ b/pkg/appview/handlers/subscription.go @@ -0,0 +1,317 @@ +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"` +} + +// 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.renderError(w, "Unauthorized") + 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 { + slog.Warn("Failed to get profile for subscription", "did", user.DID, "error", err) + h.renderError(w, "Failed to load profile") + return + } + + // Determine hold endpoint + holdDID := h.DefaultHoldDID + if profile != nil && profile.DefaultHold != "" { + holdDID = profile.DefaultHold + } + + if holdDID == "" { + h.renderError(w, "No default hold configured") + return + } + + // Resolve hold DID to endpoint + holdEndpoint := atproto.ResolveHoldURL(holdDID) + if holdEndpoint == "" { + slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID) + h.renderError(w, "Failed to resolve hold") + 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.renderError(w, "Failed to connect to hold") + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + slog.Warn("Hold returned error for subscription", "status", resp.StatusCode) + h.renderError(w, "Hold does not support billing") + return + } + + var info SubscriptionInfo + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + slog.Warn("Failed to decode subscription info", "error", err) + h.renderError(w, "Invalid response from hold") + return + } + + // 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) renderError(w http.ResponseWriter, message string) { + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, `
%s
`, 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", 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", + } + 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", 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", 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) +} diff --git a/pkg/appview/routes/routes.go b/pkg/appview/routes/routes.go index 657d144..5a37b8b 100644 --- a/pkg/appview/routes/routes.go +++ b/pkg/appview/routes/routes.go @@ -143,6 +143,11 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) { r.Get("/api/storage", (&uihandlers.StorageHandler{BaseUIHandler: base}).ServeHTTP) r.Post("/api/profile/default-hold", (&uihandlers.UpdateDefaultHoldHandler{BaseUIHandler: base}).ServeHTTP) + // Subscription management + r.Get("/api/subscription", (&uihandlers.SubscriptionHandler{BaseUIHandler: base}).ServeHTTP) + r.Get("/settings/subscription/checkout", (&uihandlers.SubscriptionCheckoutHandler{BaseUIHandler: base}).ServeHTTP) + r.Get("/settings/subscription/portal", (&uihandlers.SubscriptionPortalHandler{BaseUIHandler: base}).ServeHTTP) + r.Delete("/api/tags", (&uihandlers.DeleteTagHandler{BaseUIHandler: base}).ServeHTTP) r.Delete("/api/manifests", (&uihandlers.DeleteManifestHandler{BaseUIHandler: base}).ServeHTTP) r.Post("/api/avatar", (&uihandlers.UploadAvatarHandler{BaseUIHandler: base}).ServeHTTP) diff --git a/pkg/appview/templates/pages/settings.html b/pkg/appview/templates/pages/settings.html index 45396b5..1e85816 100644 --- a/pkg/appview/templates/pages/settings.html +++ b/pkg/appview/templates/pages/settings.html @@ -40,6 +40,15 @@ + +
+

Subscription

+

Manage your storage tier and billing.

+
+

{{ icon "loader-2" "size-4 animate-spin" }} Loading subscription info...

+
+
+

Default Hold

diff --git a/pkg/appview/templates/partials/subscription_info.html b/pkg/appview/templates/partials/subscription_info.html new file mode 100644 index 0000000..2067117 --- /dev/null +++ b/pkg/appview/templates/partials/subscription_info.html @@ -0,0 +1,60 @@ +{{ define "subscription_info" }} +{{ if .Error }} +
+ {{ icon "alert-circle" "size-5" }} {{ .Error }} +
+{{ else if not .PaymentsEnabled }} +
+ {{ icon "info" "size-5" }} This hold does not support online payments. Contact the hold operator to upgrade your plan. +
+{{ else }} + +
+
+ Current Tier: + {{ .CurrentTier }} +
+ {{ if and .CrewTier (ne .CrewTier .CurrentTier) }} +
+ Crew Record Tier: + {{ .CrewTier }}(pending sync) +
+ {{ end }} + {{ if .SubscriptionID }} +
+ Billing: + {{ .BillingInterval }} +
+ Manage Billing + {{ end }} +
+ + +{{ if .Tiers }} +

Available Plans

+
+ {{ range .Tiers }} +
+ {{ if .IsCurrent }}Current{{ end }} +
{{ .Name }}
+
{{ .QuotaFormatted }}
+ {{ if .Description }} +
{{ .Description }}
+ {{ end }} +
+
{{ .PriceFormatted }}
+ {{ if not .IsCurrent }} + {{ if or .PriceCentsMonthly .PriceCentsYearly }} + {{ if $.SubscriptionID }} + Change Plan + {{ else }} + Upgrade + {{ end }} + {{ end }} + {{ end }} +
+ {{ end }} +
+{{ end }} +{{ end }} +{{ end }} diff --git a/pkg/atproto/lexicon.go b/pkg/atproto/lexicon.go index 7f30ac1..d5df353 100644 --- a/pkg/atproto/lexicon.go +++ b/pkg/atproto/lexicon.go @@ -593,8 +593,9 @@ type CrewRecord struct { Member string `json:"member" cborgen:"member"` Role string `json:"role" cborgen:"role"` Permissions []string `json:"permissions" cborgen:"permissions"` - Tier string `json:"tier,omitempty" cborgen:"tier,omitempty"` // Optional tier for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster') - AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp + Tier string `json:"tier,omitempty" cborgen:"tier,omitempty"` // Optional tier for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster') + Plankowner bool `json:"plankowner,omitempty" cborgen:"plankowner,omitempty"` // Early adopter flag - gets plankowner_crew_tier for free + AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp } // LayerRecord represents metadata about a container layer stored in the hold diff --git a/pkg/hold/billing/billing.go b/pkg/hold/billing/billing.go index 432dd23..d325aac 100644 --- a/pkg/hold/billing/billing.go +++ b/pkg/hold/billing/billing.go @@ -48,14 +48,30 @@ type cachedCustomer struct { const customerCacheTTL = 10 * time.Minute // New creates a new billing manager with Stripe integration. -func New(quotaMgr *quota.Manager, holdPublicURL string) *Manager { +// configPath is the path to the hold config YAML file (for billing config parsing). +func New(quotaMgr *quota.Manager, holdPublicURL string, configPath string) *Manager { stripeKey := os.Getenv("STRIPE_SECRET_KEY") if stripeKey != "" { stripe.Key = stripeKey } + billingCfg, err := LoadBillingConfig(configPath) + if err != nil { + slog.Warn("Failed to load billing config", "error", err) + } + + // Validate billing tier names against quota tiers + if billingCfg != nil && billingCfg.Enabled { + for tierName := range billingCfg.Tiers { + if quotaMgr.GetTierLimit(tierName) == nil && tierName != quotaMgr.GetDefaultTier() { + slog.Warn("Billing tier has no matching quota tier", "tier", tierName) + } + } + } + return &Manager{ quotaMgr: quotaMgr, + billingCfg: billingCfg, holdPublicURL: holdPublicURL, stripeKey: stripeKey, webhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), diff --git a/pkg/hold/billing/billing_stub.go b/pkg/hold/billing/billing_stub.go index 4820893..95bea08 100644 --- a/pkg/hold/billing/billing_stub.go +++ b/pkg/hold/billing/billing_stub.go @@ -16,7 +16,7 @@ type Manager struct{} // New creates a new no-op billing manager. // This is used when the billing build tag is not set. -func New(_ *quota.Manager, _ string) *Manager { +func New(_ *quota.Manager, _ string, _ string) *Manager { return &Manager{} } diff --git a/pkg/hold/billing/config.go b/pkg/hold/billing/config.go index d95fc9b..43c320f 100644 --- a/pkg/hold/billing/config.go +++ b/pkg/hold/billing/config.go @@ -7,13 +7,10 @@ import ( "os" "go.yaml.in/yaml/v4" - - "atcr.io/pkg/hold" ) // BillingConfig holds billing/Stripe settings parsed from the hold config YAML. -// The billing fields live in the same YAML file as the hold config, but are -// ignored by atcr.io's parser (Go YAML ignores unknown fields by default). +// The billing section is a top-level key in the YAML file, separate from quota. type BillingConfig struct { Enabled bool Currency string @@ -29,39 +26,23 @@ type BillingConfig struct { // BillingTierConfig holds Stripe pricing for a single tier. type BillingTierConfig struct { - Description string - StripePriceMonthly string - StripePriceYearly string -} - -// --- internal YAML structs for parsing the extended hold config --- - -// extendedHoldConfig mirrors the hold config but only the quota section. -type extendedHoldConfig struct { - Quota extendedQuotaConfig `yaml:"quota"` -} - -type extendedQuotaConfig struct { - Tiers map[string]extendedTierConfig `yaml:"tiers"` - Defaults extendedDefaults `yaml:"defaults"` - Billing rawBillingConfig `yaml:"billing"` -} - -type extendedTierConfig struct { Description string `yaml:"description,omitempty"` StripePriceMonthly string `yaml:"stripe_price_monthly,omitempty"` StripePriceYearly string `yaml:"stripe_price_yearly,omitempty"` } -type extendedDefaults struct { - PlankOwnerCrewTier string `yaml:"plankowner_crew_tier,omitempty"` +// billingYAML is the top-level YAML structure for extracting the billing section. +type billingYAML struct { + Billing rawBillingConfig `yaml:"billing"` } type rawBillingConfig struct { - Enabled bool `yaml:"enabled"` - Currency string `yaml:"currency,omitempty"` - SuccessURL string `yaml:"success_url,omitempty"` - CancelURL string `yaml:"cancel_url,omitempty"` + Enabled bool `yaml:"enabled"` + Currency string `yaml:"currency,omitempty"` + SuccessURL string `yaml:"success_url,omitempty"` + CancelURL string `yaml:"cancel_url,omitempty"` + PlankOwnerCrewTier string `yaml:"plankowner_crew_tier,omitempty"` + Tiers map[string]BillingTierConfig `yaml:"tiers,omitempty"` } // LoadBillingConfig reads the hold config YAML and extracts billing fields. @@ -87,30 +68,26 @@ func LoadBillingConfig(configPath string) (*BillingConfig, error) { // Returns (nil, nil) if billing is not enabled. // Returns (nil, err) if billing is enabled but misconfigured. func parseBillingConfig(data []byte) (*BillingConfig, error) { - var ext extendedHoldConfig - if err := yaml.Unmarshal(data, &ext); err != nil { + var raw billingYAML + if err := yaml.Unmarshal(data, &raw); err != nil { return nil, fmt.Errorf("failed to parse config: %w", err) } - if !ext.Quota.Billing.Enabled { + if !raw.Billing.Enabled { return nil, nil } cfg := &BillingConfig{ Enabled: true, - Currency: ext.Quota.Billing.Currency, - SuccessURL: ext.Quota.Billing.SuccessURL, - CancelURL: ext.Quota.Billing.CancelURL, - PlankOwnerCrewTier: ext.Quota.Defaults.PlankOwnerCrewTier, - Tiers: make(map[string]BillingTierConfig, len(ext.Quota.Tiers)), + Currency: raw.Billing.Currency, + SuccessURL: raw.Billing.SuccessURL, + CancelURL: raw.Billing.CancelURL, + PlankOwnerCrewTier: raw.Billing.PlankOwnerCrewTier, + Tiers: raw.Billing.Tiers, } - for name, tier := range ext.Quota.Tiers { - cfg.Tiers[name] = BillingTierConfig{ - Description: tier.Description, - StripePriceMonthly: tier.StripePriceMonthly, - StripePriceYearly: tier.StripePriceYearly, - } + if cfg.Tiers == nil { + cfg.Tiers = make(map[string]BillingTierConfig) } // Validate: billing enabled but no tiers have any Stripe prices configured @@ -153,151 +130,3 @@ func (c *BillingConfig) GetTierByPriceID(priceID string) string { } return "" } - -// ExampleHoldYAML generates a complete hold config example including billing fields. -// It calls hold.ExampleYAML() for the base config, then injects billing-specific -// fields into the YAML node tree before re-marshalling. -func ExampleHoldYAML() ([]byte, error) { - base, err := hold.ExampleYAML() - if err != nil { - return nil, fmt.Errorf("failed to generate base hold config: %w", err) - } - - var doc yaml.Node - if err := yaml.Unmarshal(base, &doc); err != nil { - return nil, fmt.Errorf("failed to parse base hold config: %w", err) - } - - // doc is DocumentNode -> Content[0] is the root MappingNode - if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 { - return nil, fmt.Errorf("unexpected YAML structure") - } - root := doc.Content[0] - - // Find the "quota" mapping inside root - quotaNode := findMappingValue(root, "quota") - if quotaNode == nil { - return nil, fmt.Errorf("quota section not found in base config") - } - - // Inject billing fields into tier entries - tiersNode := findMappingValue(quotaNode, "tiers") - if tiersNode != nil { - injectTierBillingFields(tiersNode) - } - - // Inject plankowner_crew_tier into defaults - defaultsNode := findMappingValue(quotaNode, "defaults") - if defaultsNode != nil { - injectPlankOwnerDefault(defaultsNode) - } - - // Inject billing section under quota - injectBillingSection(quotaNode) - - return yaml.Marshal(&doc) -} - -// findMappingValue finds a value node in a YAML mapping by key. -func findMappingValue(mapping *yaml.Node, key string) *yaml.Node { - if mapping.Kind != yaml.MappingNode { - return nil - } - for i := 0; i < len(mapping.Content)-1; i += 2 { - if mapping.Content[i].Value == key { - return mapping.Content[i+1] - } - } - return nil -} - -// injectTierBillingFields adds description and stripe_price fields to each tier entry. -func injectTierBillingFields(tiersNode *yaml.Node) { - if tiersNode.Kind != yaml.MappingNode { - return - } - - examples := map[string]struct { - description string - monthly string - yearly string - }{ - "bosun": {"Standard tier — recommended for most users.", "price_bosun_monthly_id", "price_bosun_yearly_id"}, - "deckhand": {"Starter tier — free for new crew members.", "", ""}, - "quartermaster": {"Professional tier — for power users and teams.", "price_qm_monthly_id", "price_qm_yearly_id"}, - } - - for i := 0; i < len(tiersNode.Content)-1; i += 2 { - tierKey := tiersNode.Content[i].Value - tierVal := tiersNode.Content[i+1] - if tierVal.Kind != yaml.MappingNode { - continue - } - - ex, ok := examples[tierKey] - if !ok { - continue - } - - // Add description - tierVal.Content = append(tierVal.Content, - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "description", HeadComment: "Human-readable tier description (used in billing UI)."}, - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ex.description}, - ) - - // Add stripe prices if applicable - if ex.monthly != "" { - tierVal.Content = append(tierVal.Content, - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "stripe_price_monthly", HeadComment: "Stripe Price ID for monthly billing."}, - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ex.monthly}, - ) - } - if ex.yearly != "" { - tierVal.Content = append(tierVal.Content, - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "stripe_price_yearly", HeadComment: "Stripe Price ID for yearly billing (optional)."}, - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ex.yearly}, - ) - } - } -} - -// injectPlankOwnerDefault adds plankowner_crew_tier to the defaults section. -func injectPlankOwnerDefault(defaultsNode *yaml.Node) { - if defaultsNode.Kind != yaml.MappingNode { - return - } - defaultsNode.Content = append(defaultsNode.Content, - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "plankowner_crew_tier", HeadComment: "Tier granted to early crew members (plankowners). Ignored by base hold service."}, - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "bosun"}, - ) -} - -// injectBillingSection adds the billing subsection under quota. -func injectBillingSection(quotaNode *yaml.Node) { - if quotaNode.Kind != yaml.MappingNode { - return - } - - billing := &yaml.Node{ - Kind: yaml.MappingNode, - Tag: "!!map", - } - billing.Content = append(billing.Content, - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "enabled"}, - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: "false"}, - - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "currency", HeadComment: "ISO 4217 currency code for Stripe charges."}, - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "usd"}, - - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "success_url", HeadComment: "Redirect URL after successful checkout. {hold_url} is replaced at runtime."}, - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "{hold_url}/billing/success"}, - - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "cancel_url", HeadComment: "Redirect URL when checkout is cancelled."}, - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "{hold_url}/billing/cancel"}, - ) - - quotaNode.Content = append(quotaNode.Content, - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "billing", HeadComment: "Stripe billing settings. Ignored by base hold service (seamark.dev only)."}, - billing, - ) -} diff --git a/pkg/hold/billing/config_test.go b/pkg/hold/billing/config_test.go index 6fedfab..990d772 100644 --- a/pkg/hold/billing/config_test.go +++ b/pkg/hold/billing/config_test.go @@ -1,28 +1,17 @@ +//go:build billing + package billing import ( "os" "path/filepath" "testing" - - "go.yaml.in/yaml/v4" - - "atcr.io/pkg/hold/quota" ) -// yamlUnmarshal is a thin wrapper to avoid shadowing the yaml package import. -func yamlUnmarshal(data []byte, v any) error { - return yaml.Unmarshal(data, v) -} - func TestParseBillingConfig_Disabled(t *testing.T) { yaml := []byte(` -quota: - tiers: - deckhand: - quota: 5GB - billing: - enabled: false +billing: + enabled: false `) cfg, err := parseBillingConfig(yaml) if err != nil { @@ -51,24 +40,19 @@ quota: func TestParseBillingConfig_Enabled(t *testing.T) { yaml := []byte(` -quota: +billing: + enabled: true + currency: usd + success_url: "{hold_url}/billing/success" + cancel_url: "{hold_url}/billing/cancel" + plankowner_crew_tier: bosun tiers: deckhand: - quota: 5GB description: Starter tier bosun: - quota: 50GB description: Standard tier stripe_price_monthly: price_bosun_monthly stripe_price_yearly: price_bosun_yearly - defaults: - new_crew_tier: deckhand - plankowner_crew_tier: bosun - billing: - enabled: true - currency: usd - success_url: "{hold_url}/billing/success" - cancel_url: "{hold_url}/billing/cancel" `) cfg, err := parseBillingConfig(yaml) if err != nil { @@ -118,13 +102,9 @@ quota: func TestParseBillingConfig_EnabledButNoPrices(t *testing.T) { yaml := []byte(` -quota: - tiers: - deckhand: - quota: 5GB - billing: - enabled: true - currency: usd +billing: + enabled: true + currency: usd `) cfg, err := parseBillingConfig(yaml) if err == nil { @@ -195,14 +175,12 @@ func TestLoadBillingConfig_FromFile(t *testing.T) { path := filepath.Join(dir, "config.yaml") content := ` -quota: +billing: + enabled: true + currency: usd tiers: bosun: - quota: 50GB stripe_price_monthly: price_test - billing: - enabled: true - currency: usd ` if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatal(err) @@ -220,113 +198,160 @@ quota: } } -// holdQuotaWrapper mirrors the hold config structure just enough to extract -// the quota section for testing. This avoids importing the full hold package. -type holdQuotaWrapper struct { - Quota quota.Config `yaml:"quota"` -} - -// TestExampleHoldYAMLRoundTrip verifies that the generated example config -// can be parsed by both atcr.io's quota parser and seamark.dev's billing parser. -// This catches silent breakage if atcr.io renames or restructures the quota section. -func TestExampleHoldYAMLRoundTrip(t *testing.T) { - yamlBytes, err := ExampleHoldYAML() +func TestParseBillingConfig_TopLevelTiers(t *testing.T) { + yaml := []byte(` +billing: + enabled: true + currency: usd + tiers: + deckhand: + description: "Starter tier" + bosun: + description: "Standard tier" + stripe_price_monthly: price_bosun_m + stripe_price_yearly: price_bosun_y + quartermaster: + description: "Pro tier" + stripe_price_monthly: price_qm_m +`) + cfg, err := parseBillingConfig(yaml) if err != nil { - t.Fatalf("ExampleHoldYAML failed: %v", err) + t.Fatalf("unexpected error: %v", err) + } + if cfg == nil { + t.Fatal("expected non-nil config") + } + if len(cfg.Tiers) != 3 { + t.Errorf("expected 3 tiers, got %d", len(cfg.Tiers)) } - // Verify atcr.io's quota parser can read the quota section. - // The full hold config nests tiers under "quota:", so we parse with - // a wrapper struct (same as hold.Config does) then use NewManagerFromConfig. - var wrapper holdQuotaWrapper - if err := yamlUnmarshal(yamlBytes, &wrapper); err != nil { - t.Fatalf("failed to parse generated config for quota: %v", err) - } - - quotaMgr, err := quota.NewManagerFromConfig(&wrapper.Quota) - if err != nil { - t.Fatalf("quota.NewManagerFromConfig failed: %v", err) - } - if !quotaMgr.IsEnabled() { - t.Error("expected quotas to be enabled in generated config") - } - if quotaMgr.TierCount() != 3 { - t.Errorf("expected 3 quota tiers, got %d", quotaMgr.TierCount()) - } - if quotaMgr.GetDefaultTier() != "deckhand" { - t.Errorf("expected default tier 'deckhand', got %q", quotaMgr.GetDefaultTier()) - } - - // The generated example has billing.enabled: false, so parseBillingConfig - // returns nil. Enable it to verify the billing fields were injected correctly. - // Use the full "billing:\n...enabled:" pattern to avoid replacing admin.enabled. - enabledYAML := replaceOnce(string(yamlBytes), "billing:\n enabled: false", "billing:\n enabled: true") - - billingCfg, err := parseBillingConfig([]byte(enabledYAML)) - if err != nil { - t.Fatalf("parseBillingConfig failed on generated config: %v", err) - } - if billingCfg == nil { - t.Fatal("expected non-nil billing config after enabling") - } - - // Verify billing fields were injected into the YAML - if billingCfg.Currency != "usd" { - t.Errorf("expected currency 'usd', got %q", billingCfg.Currency) - } - if billingCfg.PlankOwnerCrewTier != "bosun" { - t.Errorf("expected plankowner_crew_tier 'bosun', got %q", billingCfg.PlankOwnerCrewTier) - } - - // Verify tier-level billing fields - bosun := billingCfg.GetTierPricing("bosun") + bosun := cfg.GetTierPricing("bosun") if bosun == nil { - t.Fatal("expected bosun billing tier") + t.Fatal("expected bosun tier") } - if bosun.StripePriceMonthly == "" { - t.Error("expected bosun to have stripe_price_monthly") + if bosun.Description != "Standard tier" { + t.Errorf("expected description 'Standard tier', got %q", bosun.Description) } - if bosun.Description == "" { - t.Error("expected bosun to have description") + if bosun.StripePriceMonthly != "price_bosun_m" { + t.Errorf("expected monthly price 'price_bosun_m', got %q", bosun.StripePriceMonthly) + } + if bosun.StripePriceYearly != "price_bosun_y" { + t.Errorf("expected yearly price 'price_bosun_y', got %q", bosun.StripePriceYearly) } - qm := billingCfg.GetTierPricing("quartermaster") + qm := cfg.GetTierPricing("quartermaster") if qm == nil { - t.Fatal("expected quartermaster billing tier") + t.Fatal("expected quartermaster tier") } - if qm.StripePriceMonthly == "" { - t.Error("expected quartermaster to have stripe_price_monthly") + if qm.StripePriceMonthly != "price_qm_m" { + t.Errorf("expected monthly price 'price_qm_m', got %q", qm.StripePriceMonthly) } - // Deckhand is the free tier — no Stripe prices expected - deckhand := billingCfg.GetTierPricing("deckhand") + deckhand := cfg.GetTierPricing("deckhand") if deckhand == nil { - t.Fatal("expected deckhand billing tier entry") + t.Fatal("expected deckhand tier") + } + if deckhand.Description != "Starter tier" { + t.Errorf("expected description 'Starter tier', got %q", deckhand.Description) } if deckhand.StripePriceMonthly != "" { - t.Error("expected no stripe_price_monthly for deckhand") - } - - // Verify the price ID reverse lookup works - if billingCfg.GetTierByPriceID(bosun.StripePriceMonthly) != "bosun" { - t.Error("GetTierByPriceID failed for bosun monthly price") + t.Error("expected no monthly price for deckhand") } } -// replaceOnce replaces the first occurrence of old with new in s. -func replaceOnce(s, old, new string) string { - i := indexOf(s, old) - if i < 0 { - return s +func TestParseBillingConfig_PlankOwnerCrewTier(t *testing.T) { + yaml := []byte(` +billing: + enabled: true + currency: usd + plankowner_crew_tier: bosun + tiers: + bosun: + stripe_price_monthly: price_bosun_m +`) + cfg, err := parseBillingConfig(yaml) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg == nil { + t.Fatal("expected non-nil config") + } + if cfg.PlankOwnerCrewTier != "bosun" { + t.Errorf("expected plankowner_crew_tier 'bosun', got %q", cfg.PlankOwnerCrewTier) } - return s[:i] + new + s[i+len(old):] } -func indexOf(s, substr string) int { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return i - } +func TestParseBillingConfig_IgnoresQuotaSection(t *testing.T) { + // Billing parser should work even if quota section is missing entirely + yaml := []byte(` +billing: + enabled: true + currency: usd + tiers: + bosun: + stripe_price_monthly: price_bosun_m +`) + cfg, err := parseBillingConfig(yaml) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg == nil { + t.Fatal("expected non-nil config") + } + + // Also works with quota present but unrelated + yaml2 := []byte(` +quota: + tiers: + swabbie: + quota: 1GB +billing: + enabled: true + currency: usd + tiers: + bosun: + stripe_price_monthly: price_bosun_m +`) + cfg2, err := parseBillingConfig(yaml2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg2 == nil { + t.Fatal("expected non-nil config") + } + // Billing should only see its own tiers, not quota tiers + if cfg2.GetTierPricing("swabbie") != nil { + t.Error("billing should not contain quota-only tiers") + } +} + +func TestParseBillingConfig_EmptyTiers(t *testing.T) { + // Billing enabled with explicit empty tiers + yaml := []byte(` +billing: + enabled: true + currency: usd + tiers: {} +`) + cfg, err := parseBillingConfig(yaml) + if err == nil { + t.Error("expected error when billing enabled with empty tiers") + } + if cfg != nil { + t.Error("expected nil config on error") + } + + // Billing enabled with tiers omitted entirely + yaml2 := []byte(` +billing: + enabled: true + currency: usd +`) + cfg2, err := parseBillingConfig(yaml2) + if err == nil { + t.Error("expected error when billing enabled with no tiers") + } + if cfg2 != nil { + t.Error("expected nil config on error") } - return -1 } diff --git a/pkg/hold/config.go b/pkg/hold/config.go index 5babb76..2135342 100644 --- a/pkg/hold/config.go +++ b/pkg/hold/config.go @@ -34,8 +34,13 @@ type Config struct { Database DatabaseConfig `yaml:"database" comment:"Embedded PDS database settings."` Admin AdminConfig `yaml:"admin" comment:"Admin panel settings."` Quota quota.Config `yaml:"quota" comment:"Storage quota tiers. Empty disables quota enforcement."` + configPath string `yaml:"-"` // internal: path to YAML file for subsystem config loading } +// ConfigPath returns the path to the YAML configuration file used to load this config. +// Subsystems (e.g. billing) use this to re-read the same file for extended fields. +func (c *Config) ConfigPath() string { return c.configPath } + // AdminConfig defines admin panel settings type AdminConfig struct { // Enable the web-based admin panel. @@ -234,6 +239,9 @@ func LoadConfig(yamlPath string) (*Config, error) { cfg.Database.KeyPath = filepath.Join(cfg.Database.Path, "signing.key") } + // Store config path for subsystem config loading (e.g. billing) + cfg.configPath = yamlPath + // Build distribution storage config from struct fields cfg.Storage.distStorage = buildStorageConfigFromFields(cfg.Storage) diff --git a/pkg/hold/pds/crew.go b/pkg/hold/pds/crew.go index a5c33f4..39a0e1c 100644 --- a/pkg/hold/pds/crew.go +++ b/pkg/hold/pds/crew.go @@ -189,7 +189,8 @@ func (p *HoldPDS) UpdateCrewMemberTier(ctx context.Context, memberDID, tier stri Role: existing.Role, Permissions: existing.Permissions, Tier: tier, - AddedAt: existing.AddedAt, // Preserve original add time + Plankowner: existing.Plankowner, // Preserve early adopter flag + AddedAt: existing.AddedAt, // Preserve original add time } rkey := atproto.CrewRecordKey(memberDID) diff --git a/pkg/hold/server.go b/pkg/hold/server.go index 03be0b3..82ce3db 100644 --- a/pkg/hold/server.go +++ b/pkg/hold/server.go @@ -11,6 +11,7 @@ import ( "time" "atcr.io/pkg/hold/admin" + "atcr.io/pkg/hold/billing" "atcr.io/pkg/hold/gc" "atcr.io/pkg/hold/oci" "atcr.io/pkg/hold/pds" @@ -199,6 +200,20 @@ func NewHoldServer(cfg *Config) (*HoldServer, error) { } } + // Initialize billing manager (compile-time optional via -tags billing) + billingMgr := billing.New(s.QuotaManager, cfg.Server.PublicURL, cfg.ConfigPath()) + if billingMgr.Enabled() { + slog.Info("Billing enabled (Stripe integration active)") + } else { + slog.Info("Billing disabled (not compiled or not configured)") + } + + // Register billing endpoints (if configured and PDS available) + if s.PDS != nil && billingMgr.Enabled() { + billingHandler := billing.NewXRPCHandler(billingMgr, s.PDS, http.DefaultClient) + billingHandler.RegisterHandlers(r) + } + s.Router = r return s, nil diff --git a/themes/seamark/embed.go b/themes/seamark/embed.go index 639b216..14a454f 100644 --- a/themes/seamark/embed.go +++ b/themes/seamark/embed.go @@ -29,7 +29,7 @@ func branding() *appview.BrandingOverrides { } return &appview.BrandingOverrides{ - PublicFS: prefixFS{sub: pubSub}, + PublicFS: prefixFS{sub: pubSub}, ExtraCSS: themeCSS, } }