mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 01:04:15 +00:00
add simple stripe billing implementation for quotas
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user