add simple stripe billing implementation for quotas

This commit is contained in:
Evan Jarrett
2026-02-03 21:52:31 -06:00
parent 5d3b6c2047
commit 35f7a47af3
16 changed files with 644 additions and 333 deletions
+20 -5
View File
@@ -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
+11 -5
View File
@@ -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.
+5 -1
View File
@@ -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
+317
View File
@@ -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, `<div class="alert alert-error"><svg class="icon size-5" aria-hidden="true"><use href="/icons.svg#alert-circle"></use></svg> %s</div>`, message)
}
// SubscriptionCheckoutHandler redirects to hold's Stripe checkout.
type SubscriptionCheckoutHandler struct {
BaseUIHandler
}
func (h *SubscriptionCheckoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Redirect(w, r, "/auth/oauth/login?return_to=/settings", 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)
}
+5
View File
@@ -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)
@@ -40,6 +40,15 @@
</div>
</section>
<!-- Subscription Section -->
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
<h2 class="text-xl font-semibold">Subscription</h2>
<p class="text-base-content/70">Manage your storage tier and billing.</p>
<div id="subscription-info" hx-get="/api/subscription" hx-trigger="load" hx-swap="innerHTML">
<p class="flex items-center gap-2">{{ icon "loader-2" "size-4 animate-spin" }} Loading subscription info...</p>
</div>
</section>
<!-- Default Hold Section -->
<section class="card bg-base-100 shadow-sm p-6 space-y-4">
<h2 class="text-xl font-semibold">Default Hold</h2>
@@ -0,0 +1,60 @@
{{ define "subscription_info" }}
{{ if .Error }}
<div class="alert alert-error">
{{ icon "alert-circle" "size-5" }} {{ .Error }}
</div>
{{ else if not .PaymentsEnabled }}
<div class="alert alert-info">
{{ icon "info" "size-5" }} This hold does not support online payments. Contact the hold operator to upgrade your plan.
</div>
{{ else }}
<!-- Current Plan -->
<div class="bg-base-200 p-4 rounded-lg mb-4">
<div class="flex justify-between py-2 border-b border-base-300">
<span class="text-base-content/70">Current Tier:</span>
<span class="font-bold capitalize">{{ .CurrentTier }}</span>
</div>
{{ if and .CrewTier (ne .CrewTier .CurrentTier) }}
<div class="flex justify-between items-center py-2 border border-warning bg-warning/10 rounded-lg px-2 my-1">
<span class="text-base-content/70">Crew Record Tier:</span>
<span class="font-bold capitalize">{{ .CrewTier }}<span class="text-xs text-warning ml-2">(pending sync)</span></span>
</div>
{{ end }}
{{ if .SubscriptionID }}
<div class="flex justify-between py-2">
<span class="text-base-content/70">Billing:</span>
<span class="font-bold">{{ .BillingInterval }}</span>
</div>
<a href="/settings/subscription/portal" class="btn btn-outline btn-primary mt-4">Manage Billing</a>
{{ end }}
</div>
<!-- Available Tiers -->
{{ if .Tiers }}
<h3 class="font-semibold">Available Plans</h3>
<div class="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-4 mt-4">
{{ range .Tiers }}
<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 }}">
{{ if .IsCurrent }}<span class="badge badge-primary badge-sm absolute -top-2 right-4">Current</span>{{ end }}
<div class="text-xl font-bold capitalize mb-2">{{ .Name }}</div>
<div class="text-2xl font-bold text-primary">{{ .QuotaFormatted }}</div>
{{ if .Description }}
<div class="text-sm text-base-content/70 mb-4">{{ .Description }}</div>
{{ end }}
<div class="flex-1"></div>
<div class="text-base-content/70 my-2">{{ .PriceFormatted }}</div>
{{ if not .IsCurrent }}
{{ if or .PriceCentsMonthly .PriceCentsYearly }}
{{ if $.SubscriptionID }}
<a href="/settings/subscription/portal" class="btn btn-primary w-full">Change Plan</a>
{{ else }}
<a href="/settings/subscription/checkout?tier={{ .ID }}" class="btn btn-primary w-full">Upgrade</a>
{{ end }}
{{ end }}
{{ end }}
</div>
{{ end }}
</div>
{{ end }}
{{ end }}
{{ end }}
+3 -2
View File
@@ -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
+17 -1
View File
@@ -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"),
+1 -1
View File
@@ -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{}
}
+20 -191
View File
@@ -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,
)
}
+150 -125
View File
@@ -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
}
+8
View File
@@ -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)
+2 -1
View File
@@ -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)
+15
View File
@@ -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
+1 -1
View File
@@ -29,7 +29,7 @@ func branding() *appview.BrandingOverrides {
}
return &appview.BrandingOverrides{
PublicFS: prefixFS{sub: pubSub},
PublicFS: prefixFS{sub: pubSub},
ExtraCSS: themeCSS,
}
}