A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
81
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 }