A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add simple stripe billing implementation for quotas

+636 -325
+20 -5
Dockerfile.hold
··· 1 1 FROM docker.io/golang:1.25.4-trixie AS builder 2 2 3 + # Build argument to enable Stripe billing integration 4 + # Usage: docker build --build-arg BILLING_ENABLED=true -f Dockerfile.hold . 5 + ARG BILLING_ENABLED=false 6 + 3 7 ENV DEBIAN_FRONTEND=noninteractive 4 8 5 9 RUN apt-get update && \ ··· 13 17 14 18 COPY . . 15 19 16 - RUN CGO_ENABLED=1 go build \ 17 - -ldflags="-s -w -linkmode external -extldflags '-static'" \ 18 - -tags sqlite_omit_load_extension \ 19 - -trimpath \ 20 - -o atcr-hold ./cmd/hold 20 + # Conditionally add billing tag based on build arg 21 + RUN if [ "$BILLING_ENABLED" = "true" ]; then \ 22 + echo "Building with Stripe billing support"; \ 23 + CGO_ENABLED=1 go build \ 24 + -ldflags="-s -w -linkmode external -extldflags '-static'" \ 25 + -tags "sqlite_omit_load_extension,billing" \ 26 + -trimpath \ 27 + -o atcr-hold ./cmd/hold; \ 28 + else \ 29 + echo "Building without billing support"; \ 30 + CGO_ENABLED=1 go build \ 31 + -ldflags="-s -w -linkmode external -extldflags '-static'" \ 32 + -tags sqlite_omit_load_extension \ 33 + -trimpath \ 34 + -o atcr-hold ./cmd/hold; \ 35 + fi 21 36 22 37 # ========================================== 23 38 # Stage 2: Minimal FROM scratch runtime
+11 -5
deploy/quotas.yaml
··· 6 6 # Each tier has a quota limit specified in human-readable format. 7 7 # Supported units: B, KB, MB, GB, TB, PB (case-insensitive) 8 8 tiers: 9 - # Entry-level crew - suitable for new or casual users 9 + # Entry-level crew - starter tier for new users (free) 10 + swabbie: 11 + quota: 2GB 12 + 13 + # Standard crew - for regular users 10 14 deckhand: 11 15 quota: 5GB 12 16 ··· 15 19 quota: 10GB 16 20 17 21 # Senior crew - for power users or trusted contributors 18 - quartermaster: 19 - quota: 50GB 22 + #quartermaster: 23 + # quota: 50GB 20 24 21 25 # You can add custom tiers with any name: 22 - # unlimited_crew: 26 + # admiral: 23 27 # quota: 1TB 24 28 25 29 defaults: 26 30 # Default tier assigned to new crew members who don't have an explicit tier. 27 31 # This tier must exist in the tiers section above. 28 - new_crew_tier: deckhand 32 + new_crew_tier: swabbie 29 33 30 34 # Notes: 31 35 # - The hold captain (owner) always has unlimited quota regardless of tiers. ··· 33 37 # - If a crew member's tier doesn't exist in config, they fall back to the default. 34 38 # - Quota is calculated per-user by summing unique blob sizes (deduplicated). 35 39 # - Quota is checked when pushing manifests (after blobs are already uploaded). 40 + # - Billing configuration (Stripe prices, descriptions) goes in a separate 41 + # top-level "billing:" section. See billing documentation for details.
+5 -1
docker-compose.yml
··· 60 60 HOLD_SERVER_PUBLIC: false 61 61 HOLD_REGISTRATION_ALLOW_ALL_CREW: true 62 62 HOLD_SERVER_TEST_MODE: true 63 + # Stripe billing (only used with -tags billing) 64 + STRIPE_SECRET_KEY: sk_test_ 65 + STRIPE_PUBLISHABLE_KEY: pk_test_ 66 + STRIPE_WEBHOOK_SECRET: whsec_ 63 67 # Logging 64 68 HOLD_LOG_LEVEL: debug 65 69 # Log shipping (uncomment to enable) ··· 78 82 dockerfile: Dockerfile.dev 79 83 args: 80 84 AIR_CONFIG: .air.hold.toml 85 + BILLING_ENABLED: "true" 81 86 image: atcr-hold-dev:latest 82 87 container_name: atcr-hold 83 88 ports: ··· 89 94 - go-mod-cache:/go/pkg/mod 90 95 # PDS data (carstore SQLite + signing keys) 91 96 - atcr-hold:/var/lib/atcr-hold 92 - - ./deploy/quotas.yaml:/app/quotas.yaml:ro 93 97 restart: unless-stopped 94 98 dns: 95 99 - 8.8.8.8
+317
pkg/appview/handlers/subscription.go
··· 1 + package handlers 2 + 3 + import ( 4 + "bytes" 5 + "encoding/json" 6 + "fmt" 7 + "log/slog" 8 + "net/http" 9 + 10 + "atcr.io/pkg/appview/middleware" 11 + "atcr.io/pkg/appview/storage" 12 + "atcr.io/pkg/atproto" 13 + "atcr.io/pkg/auth" 14 + ) 15 + 16 + // SubscriptionInfo mirrors the hold's billing.SubscriptionInfo for JSON decoding. 17 + type SubscriptionInfo struct { 18 + UserDID string `json:"userDid"` 19 + CurrentTier string `json:"currentTier"` 20 + CrewTier string `json:"crewTier,omitempty"` 21 + CurrentUsage int64 `json:"currentUsage"` 22 + CurrentLimit *int64 `json:"currentLimit,omitempty"` 23 + PaymentsEnabled bool `json:"paymentsEnabled"` 24 + Tiers []TierInfo `json:"tiers"` 25 + SubscriptionID string `json:"subscriptionId,omitempty"` 26 + BillingInterval string `json:"billingInterval,omitempty"` 27 + Error string `json:"error,omitempty"` 28 + } 29 + 30 + // TierInfo mirrors the hold's billing.TierInfo. 31 + type TierInfo struct { 32 + ID string `json:"id"` 33 + Name string `json:"name"` 34 + Description string `json:"description,omitempty"` 35 + QuotaBytes int64 `json:"quotaBytes"` 36 + QuotaFormatted string `json:"quotaFormatted"` 37 + PriceCentsMonthly int `json:"priceCentsMonthly,omitempty"` 38 + PriceCentsYearly int `json:"priceCentsYearly,omitempty"` 39 + PriceFormatted string `json:"-"` // computed in handler, e.g., "$5/month" 40 + IsCurrent bool `json:"isCurrent,omitempty"` 41 + } 42 + 43 + // SubscriptionHandler returns subscription info as HTML for HTMX. 44 + type SubscriptionHandler struct { 45 + BaseUIHandler 46 + } 47 + 48 + func (h *SubscriptionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 49 + user := middleware.GetUser(r) 50 + if user == nil { 51 + h.renderError(w, "Unauthorized") 52 + return 53 + } 54 + 55 + // Get user's default hold 56 + client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 57 + profile, err := storage.GetProfile(r.Context(), client) 58 + if err != nil { 59 + slog.Warn("Failed to get profile for subscription", "did", user.DID, "error", err) 60 + h.renderError(w, "Failed to load profile") 61 + return 62 + } 63 + 64 + // Determine hold endpoint 65 + holdDID := h.DefaultHoldDID 66 + if profile != nil && profile.DefaultHold != "" { 67 + holdDID = profile.DefaultHold 68 + } 69 + 70 + if holdDID == "" { 71 + h.renderError(w, "No default hold configured") 72 + return 73 + } 74 + 75 + // Resolve hold DID to endpoint 76 + holdEndpoint := atproto.ResolveHoldURL(holdDID) 77 + if holdEndpoint == "" { 78 + slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID) 79 + h.renderError(w, "Failed to resolve hold") 80 + return 81 + } 82 + 83 + // Fetch subscription info from hold (public endpoint, no auth needed) 84 + subURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.getSubscriptionInfo?userDid=%s", holdEndpoint, user.DID) 85 + resp, err := http.Get(subURL) 86 + if err != nil { 87 + slog.Warn("Failed to fetch subscription info", "url", subURL, "error", err) 88 + h.renderError(w, "Failed to connect to hold") 89 + return 90 + } 91 + defer resp.Body.Close() 92 + 93 + if resp.StatusCode != http.StatusOK { 94 + slog.Warn("Hold returned error for subscription", "status", resp.StatusCode) 95 + h.renderError(w, "Hold does not support billing") 96 + return 97 + } 98 + 99 + var info SubscriptionInfo 100 + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { 101 + slog.Warn("Failed to decode subscription info", "error", err) 102 + h.renderError(w, "Invalid response from hold") 103 + return 104 + } 105 + 106 + // Format prices for display 107 + // Note: -1 means "has price, fetch from Stripe" (placeholder from hold) 108 + for i := range info.Tiers { 109 + tier := &info.Tiers[i] 110 + hasMonthly := tier.PriceCentsMonthly != 0 111 + hasYearly := tier.PriceCentsYearly != 0 112 + 113 + switch { 114 + case hasMonthly && tier.PriceCentsMonthly > 0: 115 + tier.PriceFormatted = fmt.Sprintf("$%d/month", tier.PriceCentsMonthly/100) 116 + case hasYearly && tier.PriceCentsYearly > 0: 117 + tier.PriceFormatted = fmt.Sprintf("$%d/year", tier.PriceCentsYearly/100) 118 + case hasMonthly || hasYearly: 119 + // Has price but we don't know the amount (-1 sentinel) 120 + tier.PriceFormatted = "Paid" 121 + default: 122 + tier.PriceFormatted = "Free" 123 + } 124 + } 125 + 126 + // Render the subscription info 127 + h.renderInfo(w, info) 128 + } 129 + 130 + func (h *SubscriptionHandler) renderInfo(w http.ResponseWriter, info SubscriptionInfo) { 131 + w.Header().Set("Content-Type", "text/html") 132 + if err := h.Templates.ExecuteTemplate(w, "subscription_info", info); err != nil { 133 + slog.Error("Failed to render subscription template", "error", err) 134 + h.renderError(w, "Failed to render template") 135 + } 136 + } 137 + 138 + func (h *SubscriptionHandler) renderError(w http.ResponseWriter, message string) { 139 + w.Header().Set("Content-Type", "text/html") 140 + 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) 141 + } 142 + 143 + // SubscriptionCheckoutHandler redirects to hold's Stripe checkout. 144 + type SubscriptionCheckoutHandler struct { 145 + BaseUIHandler 146 + } 147 + 148 + func (h *SubscriptionCheckoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 149 + user := middleware.GetUser(r) 150 + if user == nil { 151 + http.Redirect(w, r, "/auth/oauth/login?return_to=/settings", http.StatusFound) 152 + return 153 + } 154 + 155 + tier := r.URL.Query().Get("tier") 156 + if tier == "" { 157 + http.Error(w, "tier parameter required", http.StatusBadRequest) 158 + return 159 + } 160 + 161 + // Get user's default hold 162 + client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 163 + profile, err := storage.GetProfile(r.Context(), client) 164 + if err != nil { 165 + http.Error(w, "Failed to load profile", http.StatusInternalServerError) 166 + return 167 + } 168 + 169 + holdDID := h.DefaultHoldDID 170 + if profile != nil && profile.DefaultHold != "" { 171 + holdDID = profile.DefaultHold 172 + } 173 + 174 + if holdDID == "" { 175 + http.Error(w, "No default hold configured", http.StatusBadRequest) 176 + return 177 + } 178 + 179 + // Resolve hold endpoint 180 + holdEndpoint := atproto.ResolveHoldURL(holdDID) 181 + if holdEndpoint == "" { 182 + http.Error(w, "Failed to resolve hold", http.StatusInternalServerError) 183 + return 184 + } 185 + 186 + // Get service token for the hold 187 + serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint) 188 + if err != nil { 189 + slog.Warn("Failed to get service token for checkout", "did", user.DID, "holdDid", holdDID, "error", err) 190 + http.Error(w, "Failed to authenticate with hold", http.StatusInternalServerError) 191 + return 192 + } 193 + 194 + // Call hold's checkout endpoint 195 + checkoutURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.createCheckoutSession", holdEndpoint) 196 + reqBody := map[string]string{ 197 + "tier": tier, 198 + "returnUrl": h.SiteURL + "/settings", 199 + } 200 + bodyBytes, _ := json.Marshal(reqBody) 201 + 202 + req, err := http.NewRequestWithContext(r.Context(), "POST", checkoutURL, bytes.NewReader(bodyBytes)) 203 + if err != nil { 204 + http.Error(w, "Failed to create request", http.StatusInternalServerError) 205 + return 206 + } 207 + req.Header.Set("Authorization", "Bearer "+serviceToken) 208 + req.Header.Set("Content-Type", "application/json") 209 + req.Header.Set("X-User-DID", user.DID) 210 + 211 + httpClient := &http.Client{} 212 + resp, err := httpClient.Do(req) 213 + if err != nil { 214 + slog.Warn("Failed to call checkout endpoint", "error", err) 215 + http.Error(w, "Failed to create checkout session", http.StatusInternalServerError) 216 + return 217 + } 218 + defer resp.Body.Close() 219 + 220 + if resp.StatusCode != http.StatusOK { 221 + http.Error(w, "Hold returned error", resp.StatusCode) 222 + return 223 + } 224 + 225 + var checkoutResp struct { 226 + CheckoutURL string `json:"checkoutUrl"` 227 + } 228 + if err := json.NewDecoder(resp.Body).Decode(&checkoutResp); err != nil { 229 + http.Error(w, "Invalid response from hold", http.StatusInternalServerError) 230 + return 231 + } 232 + 233 + // Redirect to Stripe checkout 234 + http.Redirect(w, r, checkoutResp.CheckoutURL, http.StatusFound) 235 + } 236 + 237 + // SubscriptionPortalHandler redirects to hold's Stripe billing portal. 238 + type SubscriptionPortalHandler struct { 239 + BaseUIHandler 240 + } 241 + 242 + func (h *SubscriptionPortalHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 243 + user := middleware.GetUser(r) 244 + if user == nil { 245 + http.Redirect(w, r, "/auth/oauth/login?return_to=/settings", http.StatusFound) 246 + return 247 + } 248 + 249 + // Get user's default hold 250 + client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher) 251 + profile, err := storage.GetProfile(r.Context(), client) 252 + if err != nil { 253 + http.Error(w, "Failed to load profile", http.StatusInternalServerError) 254 + return 255 + } 256 + 257 + holdDID := h.DefaultHoldDID 258 + if profile != nil && profile.DefaultHold != "" { 259 + holdDID = profile.DefaultHold 260 + } 261 + 262 + if holdDID == "" { 263 + http.Error(w, "No default hold configured", http.StatusBadRequest) 264 + return 265 + } 266 + 267 + // Resolve hold endpoint 268 + holdEndpoint := atproto.ResolveHoldURL(holdDID) 269 + if holdEndpoint == "" { 270 + http.Error(w, "Failed to resolve hold", http.StatusInternalServerError) 271 + return 272 + } 273 + 274 + // Get service token 275 + serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint) 276 + if err != nil { 277 + slog.Warn("Failed to get service token for portal", "did", user.DID, "holdDid", holdDID, "error", err) 278 + http.Error(w, "Failed to authenticate with hold", http.StatusInternalServerError) 279 + return 280 + } 281 + 282 + // Call hold's portal endpoint 283 + portalURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.getBillingPortalUrl?returnUrl=%s/settings", holdEndpoint, h.SiteURL) 284 + 285 + req, err := http.NewRequestWithContext(r.Context(), "GET", portalURL, nil) 286 + if err != nil { 287 + http.Error(w, "Failed to create request", http.StatusInternalServerError) 288 + return 289 + } 290 + req.Header.Set("Authorization", "Bearer "+serviceToken) 291 + req.Header.Set("X-User-DID", user.DID) 292 + 293 + httpClient := &http.Client{} 294 + resp, err := httpClient.Do(req) 295 + if err != nil { 296 + slog.Warn("Failed to call portal endpoint", "error", err) 297 + http.Error(w, "Failed to get billing portal", http.StatusInternalServerError) 298 + return 299 + } 300 + defer resp.Body.Close() 301 + 302 + if resp.StatusCode != http.StatusOK { 303 + http.Error(w, "Hold returned error", resp.StatusCode) 304 + return 305 + } 306 + 307 + var portalResp struct { 308 + PortalURL string `json:"portalUrl"` 309 + } 310 + if err := json.NewDecoder(resp.Body).Decode(&portalResp); err != nil { 311 + http.Error(w, "Invalid response from hold", http.StatusInternalServerError) 312 + return 313 + } 314 + 315 + // Redirect to Stripe portal 316 + http.Redirect(w, r, portalResp.PortalURL, http.StatusFound) 317 + }
+5
pkg/appview/routes/routes.go
··· 143 143 r.Get("/api/storage", (&uihandlers.StorageHandler{BaseUIHandler: base}).ServeHTTP) 144 144 r.Post("/api/profile/default-hold", (&uihandlers.UpdateDefaultHoldHandler{BaseUIHandler: base}).ServeHTTP) 145 145 146 + // Subscription management 147 + r.Get("/api/subscription", (&uihandlers.SubscriptionHandler{BaseUIHandler: base}).ServeHTTP) 148 + r.Get("/settings/subscription/checkout", (&uihandlers.SubscriptionCheckoutHandler{BaseUIHandler: base}).ServeHTTP) 149 + r.Get("/settings/subscription/portal", (&uihandlers.SubscriptionPortalHandler{BaseUIHandler: base}).ServeHTTP) 150 + 146 151 r.Delete("/api/tags", (&uihandlers.DeleteTagHandler{BaseUIHandler: base}).ServeHTTP) 147 152 r.Delete("/api/manifests", (&uihandlers.DeleteManifestHandler{BaseUIHandler: base}).ServeHTTP) 148 153 r.Post("/api/avatar", (&uihandlers.UploadAvatarHandler{BaseUIHandler: base}).ServeHTTP)
+9
pkg/appview/templates/pages/settings.html
··· 40 40 </div> 41 41 </section> 42 42 43 + <!-- Subscription Section --> 44 + <section class="card bg-base-100 shadow-sm p-6 space-y-4"> 45 + <h2 class="text-xl font-semibold">Subscription</h2> 46 + <p class="text-base-content/70">Manage your storage tier and billing.</p> 47 + <div id="subscription-info" hx-get="/api/subscription" hx-trigger="load" hx-swap="innerHTML"> 48 + <p class="flex items-center gap-2">{{ icon "loader-2" "size-4 animate-spin" }} Loading subscription info...</p> 49 + </div> 50 + </section> 51 + 43 52 <!-- Default Hold Section --> 44 53 <section class="card bg-base-100 shadow-sm p-6 space-y-4"> 45 54 <h2 class="text-xl font-semibold">Default Hold</h2>
+60
pkg/appview/templates/partials/subscription_info.html
··· 1 + {{ define "subscription_info" }} 2 + {{ if .Error }} 3 + <div class="alert alert-error"> 4 + {{ icon "alert-circle" "size-5" }} {{ .Error }} 5 + </div> 6 + {{ else if not .PaymentsEnabled }} 7 + <div class="alert alert-info"> 8 + {{ icon "info" "size-5" }} This hold does not support online payments. Contact the hold operator to upgrade your plan. 9 + </div> 10 + {{ else }} 11 + <!-- Current Plan --> 12 + <div class="bg-base-200 p-4 rounded-lg mb-4"> 13 + <div class="flex justify-between py-2 border-b border-base-300"> 14 + <span class="text-base-content/70">Current Tier:</span> 15 + <span class="font-bold capitalize">{{ .CurrentTier }}</span> 16 + </div> 17 + {{ if and .CrewTier (ne .CrewTier .CurrentTier) }} 18 + <div class="flex justify-between items-center py-2 border border-warning bg-warning/10 rounded-lg px-2 my-1"> 19 + <span class="text-base-content/70">Crew Record Tier:</span> 20 + <span class="font-bold capitalize">{{ .CrewTier }}<span class="text-xs text-warning ml-2">(pending sync)</span></span> 21 + </div> 22 + {{ end }} 23 + {{ if .SubscriptionID }} 24 + <div class="flex justify-between py-2"> 25 + <span class="text-base-content/70">Billing:</span> 26 + <span class="font-bold">{{ .BillingInterval }}</span> 27 + </div> 28 + <a href="/settings/subscription/portal" class="btn btn-outline btn-primary mt-4">Manage Billing</a> 29 + {{ end }} 30 + </div> 31 + 32 + <!-- Available Tiers --> 33 + {{ if .Tiers }} 34 + <h3 class="font-semibold">Available Plans</h3> 35 + <div class="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-4 mt-4"> 36 + {{ range .Tiers }} 37 + <div class="border rounded-lg p-5 bg-base-200 relative flex flex-col{{ if .IsCurrent }} border-primary border-2{{ else }} border-base-300{{ end }}"> 38 + {{ if .IsCurrent }}<span class="badge badge-primary badge-sm absolute -top-2 right-4">Current</span>{{ end }} 39 + <div class="text-xl font-bold capitalize mb-2">{{ .Name }}</div> 40 + <div class="text-2xl font-bold text-primary">{{ .QuotaFormatted }}</div> 41 + {{ if .Description }} 42 + <div class="text-sm text-base-content/70 mb-4">{{ .Description }}</div> 43 + {{ end }} 44 + <div class="flex-1"></div> 45 + <div class="text-base-content/70 my-2">{{ .PriceFormatted }}</div> 46 + {{ if not .IsCurrent }} 47 + {{ if or .PriceCentsMonthly .PriceCentsYearly }} 48 + {{ if $.SubscriptionID }} 49 + <a href="/settings/subscription/portal" class="btn btn-primary w-full">Change Plan</a> 50 + {{ else }} 51 + <a href="/settings/subscription/checkout?tier={{ .ID }}" class="btn btn-primary w-full">Upgrade</a> 52 + {{ end }} 53 + {{ end }} 54 + {{ end }} 55 + </div> 56 + {{ end }} 57 + </div> 58 + {{ end }} 59 + {{ end }} 60 + {{ end }}
+3 -2
pkg/atproto/lexicon.go
··· 593 593 Member string `json:"member" cborgen:"member"` 594 594 Role string `json:"role" cborgen:"role"` 595 595 Permissions []string `json:"permissions" cborgen:"permissions"` 596 - Tier string `json:"tier,omitempty" cborgen:"tier,omitempty"` // Optional tier for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster') 597 - AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp 596 + Tier string `json:"tier,omitempty" cborgen:"tier,omitempty"` // Optional tier for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster') 597 + Plankowner bool `json:"plankowner,omitempty" cborgen:"plankowner,omitempty"` // Early adopter flag - gets plankowner_crew_tier for free 598 + AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp 598 599 } 599 600 600 601 // LayerRecord represents metadata about a container layer stored in the hold
+17 -1
pkg/hold/billing/billing.go
··· 48 48 const customerCacheTTL = 10 * time.Minute 49 49 50 50 // New creates a new billing manager with Stripe integration. 51 - func New(quotaMgr *quota.Manager, holdPublicURL string) *Manager { 51 + // configPath is the path to the hold config YAML file (for billing config parsing). 52 + func New(quotaMgr *quota.Manager, holdPublicURL string, configPath string) *Manager { 52 53 stripeKey := os.Getenv("STRIPE_SECRET_KEY") 53 54 if stripeKey != "" { 54 55 stripe.Key = stripeKey 55 56 } 56 57 58 + billingCfg, err := LoadBillingConfig(configPath) 59 + if err != nil { 60 + slog.Warn("Failed to load billing config", "error", err) 61 + } 62 + 63 + // Validate billing tier names against quota tiers 64 + if billingCfg != nil && billingCfg.Enabled { 65 + for tierName := range billingCfg.Tiers { 66 + if quotaMgr.GetTierLimit(tierName) == nil && tierName != quotaMgr.GetDefaultTier() { 67 + slog.Warn("Billing tier has no matching quota tier", "tier", tierName) 68 + } 69 + } 70 + } 71 + 57 72 return &Manager{ 58 73 quotaMgr: quotaMgr, 74 + billingCfg: billingCfg, 59 75 holdPublicURL: holdPublicURL, 60 76 stripeKey: stripeKey, 61 77 webhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
+1 -1
pkg/hold/billing/billing_stub.go
··· 16 16 17 17 // New creates a new no-op billing manager. 18 18 // This is used when the billing build tag is not set. 19 - func New(_ *quota.Manager, _ string) *Manager { 19 + func New(_ *quota.Manager, _ string, _ string) *Manager { 20 20 return &Manager{} 21 21 } 22 22
+20 -191
pkg/hold/billing/config.go
··· 7 7 "os" 8 8 9 9 "go.yaml.in/yaml/v4" 10 - 11 - "atcr.io/pkg/hold" 12 10 ) 13 11 14 12 // BillingConfig holds billing/Stripe settings parsed from the hold config YAML. 15 - // The billing fields live in the same YAML file as the hold config, but are 16 - // ignored by atcr.io's parser (Go YAML ignores unknown fields by default). 13 + // The billing section is a top-level key in the YAML file, separate from quota. 17 14 type BillingConfig struct { 18 15 Enabled bool 19 16 Currency string ··· 29 26 30 27 // BillingTierConfig holds Stripe pricing for a single tier. 31 28 type BillingTierConfig struct { 32 - Description string 33 - StripePriceMonthly string 34 - StripePriceYearly string 35 - } 36 - 37 - // --- internal YAML structs for parsing the extended hold config --- 38 - 39 - // extendedHoldConfig mirrors the hold config but only the quota section. 40 - type extendedHoldConfig struct { 41 - Quota extendedQuotaConfig `yaml:"quota"` 42 - } 43 - 44 - type extendedQuotaConfig struct { 45 - Tiers map[string]extendedTierConfig `yaml:"tiers"` 46 - Defaults extendedDefaults `yaml:"defaults"` 47 - Billing rawBillingConfig `yaml:"billing"` 48 - } 49 - 50 - type extendedTierConfig struct { 51 29 Description string `yaml:"description,omitempty"` 52 30 StripePriceMonthly string `yaml:"stripe_price_monthly,omitempty"` 53 31 StripePriceYearly string `yaml:"stripe_price_yearly,omitempty"` 54 32 } 55 33 56 - type extendedDefaults struct { 57 - PlankOwnerCrewTier string `yaml:"plankowner_crew_tier,omitempty"` 34 + // billingYAML is the top-level YAML structure for extracting the billing section. 35 + type billingYAML struct { 36 + Billing rawBillingConfig `yaml:"billing"` 58 37 } 59 38 60 39 type rawBillingConfig struct { 61 - Enabled bool `yaml:"enabled"` 62 - Currency string `yaml:"currency,omitempty"` 63 - SuccessURL string `yaml:"success_url,omitempty"` 64 - CancelURL string `yaml:"cancel_url,omitempty"` 40 + Enabled bool `yaml:"enabled"` 41 + Currency string `yaml:"currency,omitempty"` 42 + SuccessURL string `yaml:"success_url,omitempty"` 43 + CancelURL string `yaml:"cancel_url,omitempty"` 44 + PlankOwnerCrewTier string `yaml:"plankowner_crew_tier,omitempty"` 45 + Tiers map[string]BillingTierConfig `yaml:"tiers,omitempty"` 65 46 } 66 47 67 48 // LoadBillingConfig reads the hold config YAML and extracts billing fields. ··· 87 68 // Returns (nil, nil) if billing is not enabled. 88 69 // Returns (nil, err) if billing is enabled but misconfigured. 89 70 func parseBillingConfig(data []byte) (*BillingConfig, error) { 90 - var ext extendedHoldConfig 91 - if err := yaml.Unmarshal(data, &ext); err != nil { 71 + var raw billingYAML 72 + if err := yaml.Unmarshal(data, &raw); err != nil { 92 73 return nil, fmt.Errorf("failed to parse config: %w", err) 93 74 } 94 75 95 - if !ext.Quota.Billing.Enabled { 76 + if !raw.Billing.Enabled { 96 77 return nil, nil 97 78 } 98 79 99 80 cfg := &BillingConfig{ 100 81 Enabled: true, 101 - Currency: ext.Quota.Billing.Currency, 102 - SuccessURL: ext.Quota.Billing.SuccessURL, 103 - CancelURL: ext.Quota.Billing.CancelURL, 104 - PlankOwnerCrewTier: ext.Quota.Defaults.PlankOwnerCrewTier, 105 - Tiers: make(map[string]BillingTierConfig, len(ext.Quota.Tiers)), 82 + Currency: raw.Billing.Currency, 83 + SuccessURL: raw.Billing.SuccessURL, 84 + CancelURL: raw.Billing.CancelURL, 85 + PlankOwnerCrewTier: raw.Billing.PlankOwnerCrewTier, 86 + Tiers: raw.Billing.Tiers, 106 87 } 107 88 108 - for name, tier := range ext.Quota.Tiers { 109 - cfg.Tiers[name] = BillingTierConfig{ 110 - Description: tier.Description, 111 - StripePriceMonthly: tier.StripePriceMonthly, 112 - StripePriceYearly: tier.StripePriceYearly, 113 - } 89 + if cfg.Tiers == nil { 90 + cfg.Tiers = make(map[string]BillingTierConfig) 114 91 } 115 92 116 93 // Validate: billing enabled but no tiers have any Stripe prices configured ··· 153 130 } 154 131 return "" 155 132 } 156 - 157 - // ExampleHoldYAML generates a complete hold config example including billing fields. 158 - // It calls hold.ExampleYAML() for the base config, then injects billing-specific 159 - // fields into the YAML node tree before re-marshalling. 160 - func ExampleHoldYAML() ([]byte, error) { 161 - base, err := hold.ExampleYAML() 162 - if err != nil { 163 - return nil, fmt.Errorf("failed to generate base hold config: %w", err) 164 - } 165 - 166 - var doc yaml.Node 167 - if err := yaml.Unmarshal(base, &doc); err != nil { 168 - return nil, fmt.Errorf("failed to parse base hold config: %w", err) 169 - } 170 - 171 - // doc is DocumentNode -> Content[0] is the root MappingNode 172 - if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 { 173 - return nil, fmt.Errorf("unexpected YAML structure") 174 - } 175 - root := doc.Content[0] 176 - 177 - // Find the "quota" mapping inside root 178 - quotaNode := findMappingValue(root, "quota") 179 - if quotaNode == nil { 180 - return nil, fmt.Errorf("quota section not found in base config") 181 - } 182 - 183 - // Inject billing fields into tier entries 184 - tiersNode := findMappingValue(quotaNode, "tiers") 185 - if tiersNode != nil { 186 - injectTierBillingFields(tiersNode) 187 - } 188 - 189 - // Inject plankowner_crew_tier into defaults 190 - defaultsNode := findMappingValue(quotaNode, "defaults") 191 - if defaultsNode != nil { 192 - injectPlankOwnerDefault(defaultsNode) 193 - } 194 - 195 - // Inject billing section under quota 196 - injectBillingSection(quotaNode) 197 - 198 - return yaml.Marshal(&doc) 199 - } 200 - 201 - // findMappingValue finds a value node in a YAML mapping by key. 202 - func findMappingValue(mapping *yaml.Node, key string) *yaml.Node { 203 - if mapping.Kind != yaml.MappingNode { 204 - return nil 205 - } 206 - for i := 0; i < len(mapping.Content)-1; i += 2 { 207 - if mapping.Content[i].Value == key { 208 - return mapping.Content[i+1] 209 - } 210 - } 211 - return nil 212 - } 213 - 214 - // injectTierBillingFields adds description and stripe_price fields to each tier entry. 215 - func injectTierBillingFields(tiersNode *yaml.Node) { 216 - if tiersNode.Kind != yaml.MappingNode { 217 - return 218 - } 219 - 220 - examples := map[string]struct { 221 - description string 222 - monthly string 223 - yearly string 224 - }{ 225 - "bosun": {"Standard tier — recommended for most users.", "price_bosun_monthly_id", "price_bosun_yearly_id"}, 226 - "deckhand": {"Starter tier — free for new crew members.", "", ""}, 227 - "quartermaster": {"Professional tier — for power users and teams.", "price_qm_monthly_id", "price_qm_yearly_id"}, 228 - } 229 - 230 - for i := 0; i < len(tiersNode.Content)-1; i += 2 { 231 - tierKey := tiersNode.Content[i].Value 232 - tierVal := tiersNode.Content[i+1] 233 - if tierVal.Kind != yaml.MappingNode { 234 - continue 235 - } 236 - 237 - ex, ok := examples[tierKey] 238 - if !ok { 239 - continue 240 - } 241 - 242 - // Add description 243 - tierVal.Content = append(tierVal.Content, 244 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "description", HeadComment: "Human-readable tier description (used in billing UI)."}, 245 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ex.description}, 246 - ) 247 - 248 - // Add stripe prices if applicable 249 - if ex.monthly != "" { 250 - tierVal.Content = append(tierVal.Content, 251 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "stripe_price_monthly", HeadComment: "Stripe Price ID for monthly billing."}, 252 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ex.monthly}, 253 - ) 254 - } 255 - if ex.yearly != "" { 256 - tierVal.Content = append(tierVal.Content, 257 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "stripe_price_yearly", HeadComment: "Stripe Price ID for yearly billing (optional)."}, 258 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ex.yearly}, 259 - ) 260 - } 261 - } 262 - } 263 - 264 - // injectPlankOwnerDefault adds plankowner_crew_tier to the defaults section. 265 - func injectPlankOwnerDefault(defaultsNode *yaml.Node) { 266 - if defaultsNode.Kind != yaml.MappingNode { 267 - return 268 - } 269 - defaultsNode.Content = append(defaultsNode.Content, 270 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "plankowner_crew_tier", HeadComment: "Tier granted to early crew members (plankowners). Ignored by base hold service."}, 271 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "bosun"}, 272 - ) 273 - } 274 - 275 - // injectBillingSection adds the billing subsection under quota. 276 - func injectBillingSection(quotaNode *yaml.Node) { 277 - if quotaNode.Kind != yaml.MappingNode { 278 - return 279 - } 280 - 281 - billing := &yaml.Node{ 282 - Kind: yaml.MappingNode, 283 - Tag: "!!map", 284 - } 285 - billing.Content = append(billing.Content, 286 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "enabled"}, 287 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: "false"}, 288 - 289 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "currency", HeadComment: "ISO 4217 currency code for Stripe charges."}, 290 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "usd"}, 291 - 292 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "success_url", HeadComment: "Redirect URL after successful checkout. {hold_url} is replaced at runtime."}, 293 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "{hold_url}/billing/success"}, 294 - 295 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "cancel_url", HeadComment: "Redirect URL when checkout is cancelled."}, 296 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "{hold_url}/billing/cancel"}, 297 - ) 298 - 299 - quotaNode.Content = append(quotaNode.Content, 300 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "billing", HeadComment: "Stripe billing settings. Ignored by base hold service (seamark.dev only)."}, 301 - billing, 302 - ) 303 - }
+142 -117
pkg/hold/billing/config_test.go
··· 1 + //go:build billing 2 + 1 3 package billing 2 4 3 5 import ( 4 6 "os" 5 7 "path/filepath" 6 8 "testing" 7 - 8 - "go.yaml.in/yaml/v4" 9 - 10 - "atcr.io/pkg/hold/quota" 11 9 ) 12 10 13 - // yamlUnmarshal is a thin wrapper to avoid shadowing the yaml package import. 14 - func yamlUnmarshal(data []byte, v any) error { 15 - return yaml.Unmarshal(data, v) 16 - } 17 - 18 11 func TestParseBillingConfig_Disabled(t *testing.T) { 19 12 yaml := []byte(` 20 - quota: 21 - tiers: 22 - deckhand: 23 - quota: 5GB 24 - billing: 25 - enabled: false 13 + billing: 14 + enabled: false 26 15 `) 27 16 cfg, err := parseBillingConfig(yaml) 28 17 if err != nil { ··· 51 40 52 41 func TestParseBillingConfig_Enabled(t *testing.T) { 53 42 yaml := []byte(` 54 - quota: 43 + billing: 44 + enabled: true 45 + currency: usd 46 + success_url: "{hold_url}/billing/success" 47 + cancel_url: "{hold_url}/billing/cancel" 48 + plankowner_crew_tier: bosun 55 49 tiers: 56 50 deckhand: 57 - quota: 5GB 58 51 description: Starter tier 59 52 bosun: 60 - quota: 50GB 61 53 description: Standard tier 62 54 stripe_price_monthly: price_bosun_monthly 63 55 stripe_price_yearly: price_bosun_yearly 64 - defaults: 65 - new_crew_tier: deckhand 66 - plankowner_crew_tier: bosun 67 - billing: 68 - enabled: true 69 - currency: usd 70 - success_url: "{hold_url}/billing/success" 71 - cancel_url: "{hold_url}/billing/cancel" 72 56 `) 73 57 cfg, err := parseBillingConfig(yaml) 74 58 if err != nil { ··· 118 102 119 103 func TestParseBillingConfig_EnabledButNoPrices(t *testing.T) { 120 104 yaml := []byte(` 121 - quota: 122 - tiers: 123 - deckhand: 124 - quota: 5GB 125 - billing: 126 - enabled: true 127 - currency: usd 105 + billing: 106 + enabled: true 107 + currency: usd 128 108 `) 129 109 cfg, err := parseBillingConfig(yaml) 130 110 if err == nil { ··· 195 175 path := filepath.Join(dir, "config.yaml") 196 176 197 177 content := ` 198 - quota: 178 + billing: 179 + enabled: true 180 + currency: usd 199 181 tiers: 200 182 bosun: 201 - quota: 50GB 202 183 stripe_price_monthly: price_test 203 - billing: 204 - enabled: true 205 - currency: usd 206 184 ` 207 185 if err := os.WriteFile(path, []byte(content), 0644); err != nil { 208 186 t.Fatal(err) ··· 220 198 } 221 199 } 222 200 223 - // holdQuotaWrapper mirrors the hold config structure just enough to extract 224 - // the quota section for testing. This avoids importing the full hold package. 225 - type holdQuotaWrapper struct { 226 - Quota quota.Config `yaml:"quota"` 227 - } 228 - 229 - // TestExampleHoldYAMLRoundTrip verifies that the generated example config 230 - // can be parsed by both atcr.io's quota parser and seamark.dev's billing parser. 231 - // This catches silent breakage if atcr.io renames or restructures the quota section. 232 - func TestExampleHoldYAMLRoundTrip(t *testing.T) { 233 - yamlBytes, err := ExampleHoldYAML() 201 + func TestParseBillingConfig_TopLevelTiers(t *testing.T) { 202 + yaml := []byte(` 203 + billing: 204 + enabled: true 205 + currency: usd 206 + tiers: 207 + deckhand: 208 + description: "Starter tier" 209 + bosun: 210 + description: "Standard tier" 211 + stripe_price_monthly: price_bosun_m 212 + stripe_price_yearly: price_bosun_y 213 + quartermaster: 214 + description: "Pro tier" 215 + stripe_price_monthly: price_qm_m 216 + `) 217 + cfg, err := parseBillingConfig(yaml) 234 218 if err != nil { 235 - t.Fatalf("ExampleHoldYAML failed: %v", err) 219 + t.Fatalf("unexpected error: %v", err) 236 220 } 237 - 238 - // Verify atcr.io's quota parser can read the quota section. 239 - // The full hold config nests tiers under "quota:", so we parse with 240 - // a wrapper struct (same as hold.Config does) then use NewManagerFromConfig. 241 - var wrapper holdQuotaWrapper 242 - if err := yamlUnmarshal(yamlBytes, &wrapper); err != nil { 243 - t.Fatalf("failed to parse generated config for quota: %v", err) 221 + if cfg == nil { 222 + t.Fatal("expected non-nil config") 223 + } 224 + if len(cfg.Tiers) != 3 { 225 + t.Errorf("expected 3 tiers, got %d", len(cfg.Tiers)) 244 226 } 245 227 246 - quotaMgr, err := quota.NewManagerFromConfig(&wrapper.Quota) 247 - if err != nil { 248 - t.Fatalf("quota.NewManagerFromConfig failed: %v", err) 228 + bosun := cfg.GetTierPricing("bosun") 229 + if bosun == nil { 230 + t.Fatal("expected bosun tier") 249 231 } 250 - if !quotaMgr.IsEnabled() { 251 - t.Error("expected quotas to be enabled in generated config") 232 + if bosun.Description != "Standard tier" { 233 + t.Errorf("expected description 'Standard tier', got %q", bosun.Description) 252 234 } 253 - if quotaMgr.TierCount() != 3 { 254 - t.Errorf("expected 3 quota tiers, got %d", quotaMgr.TierCount()) 235 + if bosun.StripePriceMonthly != "price_bosun_m" { 236 + t.Errorf("expected monthly price 'price_bosun_m', got %q", bosun.StripePriceMonthly) 255 237 } 256 - if quotaMgr.GetDefaultTier() != "deckhand" { 257 - t.Errorf("expected default tier 'deckhand', got %q", quotaMgr.GetDefaultTier()) 238 + if bosun.StripePriceYearly != "price_bosun_y" { 239 + t.Errorf("expected yearly price 'price_bosun_y', got %q", bosun.StripePriceYearly) 258 240 } 259 241 260 - // The generated example has billing.enabled: false, so parseBillingConfig 261 - // returns nil. Enable it to verify the billing fields were injected correctly. 262 - // Use the full "billing:\n...enabled:" pattern to avoid replacing admin.enabled. 263 - enabledYAML := replaceOnce(string(yamlBytes), "billing:\n enabled: false", "billing:\n enabled: true") 264 - 265 - billingCfg, err := parseBillingConfig([]byte(enabledYAML)) 266 - if err != nil { 267 - t.Fatalf("parseBillingConfig failed on generated config: %v", err) 242 + qm := cfg.GetTierPricing("quartermaster") 243 + if qm == nil { 244 + t.Fatal("expected quartermaster tier") 268 245 } 269 - if billingCfg == nil { 270 - t.Fatal("expected non-nil billing config after enabling") 246 + if qm.StripePriceMonthly != "price_qm_m" { 247 + t.Errorf("expected monthly price 'price_qm_m', got %q", qm.StripePriceMonthly) 271 248 } 272 249 273 - // Verify billing fields were injected into the YAML 274 - if billingCfg.Currency != "usd" { 275 - t.Errorf("expected currency 'usd', got %q", billingCfg.Currency) 250 + deckhand := cfg.GetTierPricing("deckhand") 251 + if deckhand == nil { 252 + t.Fatal("expected deckhand tier") 253 + } 254 + if deckhand.Description != "Starter tier" { 255 + t.Errorf("expected description 'Starter tier', got %q", deckhand.Description) 276 256 } 277 - if billingCfg.PlankOwnerCrewTier != "bosun" { 278 - t.Errorf("expected plankowner_crew_tier 'bosun', got %q", billingCfg.PlankOwnerCrewTier) 257 + if deckhand.StripePriceMonthly != "" { 258 + t.Error("expected no monthly price for deckhand") 279 259 } 260 + } 280 261 281 - // Verify tier-level billing fields 282 - bosun := billingCfg.GetTierPricing("bosun") 283 - if bosun == nil { 284 - t.Fatal("expected bosun billing tier") 262 + func TestParseBillingConfig_PlankOwnerCrewTier(t *testing.T) { 263 + yaml := []byte(` 264 + billing: 265 + enabled: true 266 + currency: usd 267 + plankowner_crew_tier: bosun 268 + tiers: 269 + bosun: 270 + stripe_price_monthly: price_bosun_m 271 + `) 272 + cfg, err := parseBillingConfig(yaml) 273 + if err != nil { 274 + t.Fatalf("unexpected error: %v", err) 285 275 } 286 - if bosun.StripePriceMonthly == "" { 287 - t.Error("expected bosun to have stripe_price_monthly") 276 + if cfg == nil { 277 + t.Fatal("expected non-nil config") 288 278 } 289 - if bosun.Description == "" { 290 - t.Error("expected bosun to have description") 279 + if cfg.PlankOwnerCrewTier != "bosun" { 280 + t.Errorf("expected plankowner_crew_tier 'bosun', got %q", cfg.PlankOwnerCrewTier) 291 281 } 282 + } 292 283 293 - qm := billingCfg.GetTierPricing("quartermaster") 294 - if qm == nil { 295 - t.Fatal("expected quartermaster billing tier") 284 + func TestParseBillingConfig_IgnoresQuotaSection(t *testing.T) { 285 + // Billing parser should work even if quota section is missing entirely 286 + yaml := []byte(` 287 + billing: 288 + enabled: true 289 + currency: usd 290 + tiers: 291 + bosun: 292 + stripe_price_monthly: price_bosun_m 293 + `) 294 + cfg, err := parseBillingConfig(yaml) 295 + if err != nil { 296 + t.Fatalf("unexpected error: %v", err) 296 297 } 297 - if qm.StripePriceMonthly == "" { 298 - t.Error("expected quartermaster to have stripe_price_monthly") 298 + if cfg == nil { 299 + t.Fatal("expected non-nil config") 299 300 } 300 301 301 - // Deckhand is the free tier — no Stripe prices expected 302 - deckhand := billingCfg.GetTierPricing("deckhand") 303 - if deckhand == nil { 304 - t.Fatal("expected deckhand billing tier entry") 302 + // Also works with quota present but unrelated 303 + yaml2 := []byte(` 304 + quota: 305 + tiers: 306 + swabbie: 307 + quota: 1GB 308 + billing: 309 + enabled: true 310 + currency: usd 311 + tiers: 312 + bosun: 313 + stripe_price_monthly: price_bosun_m 314 + `) 315 + cfg2, err := parseBillingConfig(yaml2) 316 + if err != nil { 317 + t.Fatalf("unexpected error: %v", err) 305 318 } 306 - if deckhand.StripePriceMonthly != "" { 307 - t.Error("expected no stripe_price_monthly for deckhand") 319 + if cfg2 == nil { 320 + t.Fatal("expected non-nil config") 308 321 } 309 - 310 - // Verify the price ID reverse lookup works 311 - if billingCfg.GetTierByPriceID(bosun.StripePriceMonthly) != "bosun" { 312 - t.Error("GetTierByPriceID failed for bosun monthly price") 322 + // Billing should only see its own tiers, not quota tiers 323 + if cfg2.GetTierPricing("swabbie") != nil { 324 + t.Error("billing should not contain quota-only tiers") 313 325 } 314 326 } 315 327 316 - // replaceOnce replaces the first occurrence of old with new in s. 317 - func replaceOnce(s, old, new string) string { 318 - i := indexOf(s, old) 319 - if i < 0 { 320 - return s 328 + func TestParseBillingConfig_EmptyTiers(t *testing.T) { 329 + // Billing enabled with explicit empty tiers 330 + yaml := []byte(` 331 + billing: 332 + enabled: true 333 + currency: usd 334 + tiers: {} 335 + `) 336 + cfg, err := parseBillingConfig(yaml) 337 + if err == nil { 338 + t.Error("expected error when billing enabled with empty tiers") 321 339 } 322 - return s[:i] + new + s[i+len(old):] 323 - } 340 + if cfg != nil { 341 + t.Error("expected nil config on error") 342 + } 324 343 325 - func indexOf(s, substr string) int { 326 - for i := 0; i <= len(s)-len(substr); i++ { 327 - if s[i:i+len(substr)] == substr { 328 - return i 329 - } 344 + // Billing enabled with tiers omitted entirely 345 + yaml2 := []byte(` 346 + billing: 347 + enabled: true 348 + currency: usd 349 + `) 350 + cfg2, err := parseBillingConfig(yaml2) 351 + if err == nil { 352 + t.Error("expected error when billing enabled with no tiers") 330 353 } 331 - return -1 354 + if cfg2 != nil { 355 + t.Error("expected nil config on error") 356 + } 332 357 }
+8
pkg/hold/config.go
··· 34 34 Database DatabaseConfig `yaml:"database" comment:"Embedded PDS database settings."` 35 35 Admin AdminConfig `yaml:"admin" comment:"Admin panel settings."` 36 36 Quota quota.Config `yaml:"quota" comment:"Storage quota tiers. Empty disables quota enforcement."` 37 + configPath string `yaml:"-"` // internal: path to YAML file for subsystem config loading 37 38 } 39 + 40 + // ConfigPath returns the path to the YAML configuration file used to load this config. 41 + // Subsystems (e.g. billing) use this to re-read the same file for extended fields. 42 + func (c *Config) ConfigPath() string { return c.configPath } 38 43 39 44 // AdminConfig defines admin panel settings 40 45 type AdminConfig struct { ··· 233 238 if cfg.Database.KeyPath == "" && cfg.Database.Path != "" { 234 239 cfg.Database.KeyPath = filepath.Join(cfg.Database.Path, "signing.key") 235 240 } 241 + 242 + // Store config path for subsystem config loading (e.g. billing) 243 + cfg.configPath = yamlPath 236 244 237 245 // Build distribution storage config from struct fields 238 246 cfg.Storage.distStorage = buildStorageConfigFromFields(cfg.Storage)
+2 -1
pkg/hold/pds/crew.go
··· 189 189 Role: existing.Role, 190 190 Permissions: existing.Permissions, 191 191 Tier: tier, 192 - AddedAt: existing.AddedAt, // Preserve original add time 192 + Plankowner: existing.Plankowner, // Preserve early adopter flag 193 + AddedAt: existing.AddedAt, // Preserve original add time 193 194 } 194 195 195 196 rkey := atproto.CrewRecordKey(memberDID)
+15
pkg/hold/server.go
··· 11 11 "time" 12 12 13 13 "atcr.io/pkg/hold/admin" 14 + "atcr.io/pkg/hold/billing" 14 15 "atcr.io/pkg/hold/gc" 15 16 "atcr.io/pkg/hold/oci" 16 17 "atcr.io/pkg/hold/pds" ··· 197 198 slog.Info("Registering admin panel routes") 198 199 s.adminUI.RegisterRoutes(r) 199 200 } 201 + } 202 + 203 + // Initialize billing manager (compile-time optional via -tags billing) 204 + billingMgr := billing.New(s.QuotaManager, cfg.Server.PublicURL, cfg.ConfigPath()) 205 + if billingMgr.Enabled() { 206 + slog.Info("Billing enabled (Stripe integration active)") 207 + } else { 208 + slog.Info("Billing disabled (not compiled or not configured)") 209 + } 210 + 211 + // Register billing endpoints (if configured and PDS available) 212 + if s.PDS != nil && billingMgr.Enabled() { 213 + billingHandler := billing.NewXRPCHandler(billingMgr, s.PDS, http.DefaultClient) 214 + billingHandler.RegisterHandlers(r) 200 215 } 201 216 202 217 s.Router = r
+1 -1
themes/seamark/embed.go
··· 29 29 } 30 30 31 31 return &appview.BrandingOverrides{ 32 - PublicFS: prefixFS{sub: pubSub}, 32 + PublicFS: prefixFS{sub: pubSub}, 33 33 ExtraCSS: themeCSS, 34 34 } 35 35 }