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