···66# Each tier has a quota limit specified in human-readable format.
77# Supported units: B, KB, MB, GB, TB, PB (case-insensitive)
88tiers:
99- # Entry-level crew - suitable for new or casual users
99+ # Entry-level crew - starter tier for new users (free)
1010+ swabbie:
1111+ quota: 2GB
1212+1313+ # Standard crew - for regular users
1014 deckhand:
1115 quota: 5GB
1216···1519 quota: 10GB
16201721 # Senior crew - for power users or trusted contributors
1818- quartermaster:
1919- quota: 50GB
2222+ #quartermaster:
2323+ # quota: 50GB
20242125 # You can add custom tiers with any name:
2222- # unlimited_crew:
2626+ # admiral:
2327 # quota: 1TB
24282529defaults:
2630 # Default tier assigned to new crew members who don't have an explicit tier.
2731 # This tier must exist in the tiers section above.
2828- new_crew_tier: deckhand
3232+ new_crew_tier: swabbie
29333034# Notes:
3135# - The hold captain (owner) always has unlimited quota regardless of tiers.
···3337# - If a crew member's tier doesn't exist in config, they fall back to the default.
3438# - Quota is calculated per-user by summing unique blob sizes (deduplicated).
3539# - Quota is checked when pushing manifests (after blobs are already uploaded).
4040+# - Billing configuration (Stripe prices, descriptions) goes in a separate
4141+# top-level "billing:" section. See billing documentation for details.
···11+{{ define "subscription_info" }}
22+{{ if .Error }}
33+<div class="alert alert-error">
44+ {{ icon "alert-circle" "size-5" }} {{ .Error }}
55+</div>
66+{{ else if not .PaymentsEnabled }}
77+<div class="alert alert-info">
88+ {{ icon "info" "size-5" }} This hold does not support online payments. Contact the hold operator to upgrade your plan.
99+</div>
1010+{{ else }}
1111+<!-- Current Plan -->
1212+<div class="bg-base-200 p-4 rounded-lg mb-4">
1313+ <div class="flex justify-between py-2 border-b border-base-300">
1414+ <span class="text-base-content/70">Current Tier:</span>
1515+ <span class="font-bold capitalize">{{ .CurrentTier }}</span>
1616+ </div>
1717+ {{ if and .CrewTier (ne .CrewTier .CurrentTier) }}
1818+ <div class="flex justify-between items-center py-2 border border-warning bg-warning/10 rounded-lg px-2 my-1">
1919+ <span class="text-base-content/70">Crew Record Tier:</span>
2020+ <span class="font-bold capitalize">{{ .CrewTier }}<span class="text-xs text-warning ml-2">(pending sync)</span></span>
2121+ </div>
2222+ {{ end }}
2323+ {{ if .SubscriptionID }}
2424+ <div class="flex justify-between py-2">
2525+ <span class="text-base-content/70">Billing:</span>
2626+ <span class="font-bold">{{ .BillingInterval }}</span>
2727+ </div>
2828+ <a href="/settings/subscription/portal" class="btn btn-outline btn-primary mt-4">Manage Billing</a>
2929+ {{ end }}
3030+</div>
3131+3232+<!-- Available Tiers -->
3333+{{ if .Tiers }}
3434+<h3 class="font-semibold">Available Plans</h3>
3535+<div class="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-4 mt-4">
3636+ {{ range .Tiers }}
3737+ <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 }}">
3838+ {{ if .IsCurrent }}<span class="badge badge-primary badge-sm absolute -top-2 right-4">Current</span>{{ end }}
3939+ <div class="text-xl font-bold capitalize mb-2">{{ .Name }}</div>
4040+ <div class="text-2xl font-bold text-primary">{{ .QuotaFormatted }}</div>
4141+ {{ if .Description }}
4242+ <div class="text-sm text-base-content/70 mb-4">{{ .Description }}</div>
4343+ {{ end }}
4444+ <div class="flex-1"></div>
4545+ <div class="text-base-content/70 my-2">{{ .PriceFormatted }}</div>
4646+ {{ if not .IsCurrent }}
4747+ {{ if or .PriceCentsMonthly .PriceCentsYearly }}
4848+ {{ if $.SubscriptionID }}
4949+ <a href="/settings/subscription/portal" class="btn btn-primary w-full">Change Plan</a>
5050+ {{ else }}
5151+ <a href="/settings/subscription/checkout?tier={{ .ID }}" class="btn btn-primary w-full">Upgrade</a>
5252+ {{ end }}
5353+ {{ end }}
5454+ {{ end }}
5555+ </div>
5656+ {{ end }}
5757+</div>
5858+{{ end }}
5959+{{ end }}
6060+{{ end }}
+3-2
pkg/atproto/lexicon.go
···593593 Member string `json:"member" cborgen:"member"`
594594 Role string `json:"role" cborgen:"role"`
595595 Permissions []string `json:"permissions" cborgen:"permissions"`
596596- Tier string `json:"tier,omitempty" cborgen:"tier,omitempty"` // Optional tier for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster')
597597- AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp
596596+ Tier string `json:"tier,omitempty" cborgen:"tier,omitempty"` // Optional tier for quota limits (e.g., 'deckhand', 'bosun', 'quartermaster')
597597+ Plankowner bool `json:"plankowner,omitempty" cborgen:"plankowner,omitempty"` // Early adopter flag - gets plankowner_crew_tier for free
598598+ AddedAt string `json:"addedAt" cborgen:"addedAt"` // RFC3339 timestamp
598599}
599600600601// LayerRecord represents metadata about a container layer stored in the hold
+17-1
pkg/hold/billing/billing.go
···4848const customerCacheTTL = 10 * time.Minute
49495050// New creates a new billing manager with Stripe integration.
5151-func New(quotaMgr *quota.Manager, holdPublicURL string) *Manager {
5151+// configPath is the path to the hold config YAML file (for billing config parsing).
5252+func New(quotaMgr *quota.Manager, holdPublicURL string, configPath string) *Manager {
5253 stripeKey := os.Getenv("STRIPE_SECRET_KEY")
5354 if stripeKey != "" {
5455 stripe.Key = stripeKey
5556 }
56575858+ billingCfg, err := LoadBillingConfig(configPath)
5959+ if err != nil {
6060+ slog.Warn("Failed to load billing config", "error", err)
6161+ }
6262+6363+ // Validate billing tier names against quota tiers
6464+ if billingCfg != nil && billingCfg.Enabled {
6565+ for tierName := range billingCfg.Tiers {
6666+ if quotaMgr.GetTierLimit(tierName) == nil && tierName != quotaMgr.GetDefaultTier() {
6767+ slog.Warn("Billing tier has no matching quota tier", "tier", tierName)
6868+ }
6969+ }
7070+ }
7171+5772 return &Manager{
5873 quotaMgr: quotaMgr,
7474+ billingCfg: billingCfg,
5975 holdPublicURL: holdPublicURL,
6076 stripeKey: stripeKey,
6177 webhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
+1-1
pkg/hold/billing/billing_stub.go
···16161717// New creates a new no-op billing manager.
1818// This is used when the billing build tag is not set.
1919-func New(_ *quota.Manager, _ string) *Manager {
1919+func New(_ *quota.Manager, _ string, _ string) *Manager {
2020 return &Manager{}
2121}
2222
+20-191
pkg/hold/billing/config.go
···77 "os"
8899 "go.yaml.in/yaml/v4"
1010-1111- "atcr.io/pkg/hold"
1210)
13111412// BillingConfig holds billing/Stripe settings parsed from the hold config YAML.
1515-// The billing fields live in the same YAML file as the hold config, but are
1616-// ignored by atcr.io's parser (Go YAML ignores unknown fields by default).
1313+// The billing section is a top-level key in the YAML file, separate from quota.
1714type BillingConfig struct {
1815 Enabled bool
1916 Currency string
···29263027// BillingTierConfig holds Stripe pricing for a single tier.
3128type BillingTierConfig struct {
3232- Description string
3333- StripePriceMonthly string
3434- StripePriceYearly string
3535-}
3636-3737-// --- internal YAML structs for parsing the extended hold config ---
3838-3939-// extendedHoldConfig mirrors the hold config but only the quota section.
4040-type extendedHoldConfig struct {
4141- Quota extendedQuotaConfig `yaml:"quota"`
4242-}
4343-4444-type extendedQuotaConfig struct {
4545- Tiers map[string]extendedTierConfig `yaml:"tiers"`
4646- Defaults extendedDefaults `yaml:"defaults"`
4747- Billing rawBillingConfig `yaml:"billing"`
4848-}
4949-5050-type extendedTierConfig struct {
5129 Description string `yaml:"description,omitempty"`
5230 StripePriceMonthly string `yaml:"stripe_price_monthly,omitempty"`
5331 StripePriceYearly string `yaml:"stripe_price_yearly,omitempty"`
5432}
55335656-type extendedDefaults struct {
5757- PlankOwnerCrewTier string `yaml:"plankowner_crew_tier,omitempty"`
3434+// billingYAML is the top-level YAML structure for extracting the billing section.
3535+type billingYAML struct {
3636+ Billing rawBillingConfig `yaml:"billing"`
5837}
59386039type rawBillingConfig struct {
6161- Enabled bool `yaml:"enabled"`
6262- Currency string `yaml:"currency,omitempty"`
6363- SuccessURL string `yaml:"success_url,omitempty"`
6464- CancelURL string `yaml:"cancel_url,omitempty"`
4040+ Enabled bool `yaml:"enabled"`
4141+ Currency string `yaml:"currency,omitempty"`
4242+ SuccessURL string `yaml:"success_url,omitempty"`
4343+ CancelURL string `yaml:"cancel_url,omitempty"`
4444+ PlankOwnerCrewTier string `yaml:"plankowner_crew_tier,omitempty"`
4545+ Tiers map[string]BillingTierConfig `yaml:"tiers,omitempty"`
6546}
66476748// LoadBillingConfig reads the hold config YAML and extracts billing fields.
···8768// Returns (nil, nil) if billing is not enabled.
8869// Returns (nil, err) if billing is enabled but misconfigured.
8970func parseBillingConfig(data []byte) (*BillingConfig, error) {
9090- var ext extendedHoldConfig
9191- if err := yaml.Unmarshal(data, &ext); err != nil {
7171+ var raw billingYAML
7272+ if err := yaml.Unmarshal(data, &raw); err != nil {
9273 return nil, fmt.Errorf("failed to parse config: %w", err)
9374 }
94759595- if !ext.Quota.Billing.Enabled {
7676+ if !raw.Billing.Enabled {
9677 return nil, nil
9778 }
98799980 cfg := &BillingConfig{
10081 Enabled: true,
101101- Currency: ext.Quota.Billing.Currency,
102102- SuccessURL: ext.Quota.Billing.SuccessURL,
103103- CancelURL: ext.Quota.Billing.CancelURL,
104104- PlankOwnerCrewTier: ext.Quota.Defaults.PlankOwnerCrewTier,
105105- Tiers: make(map[string]BillingTierConfig, len(ext.Quota.Tiers)),
8282+ Currency: raw.Billing.Currency,
8383+ SuccessURL: raw.Billing.SuccessURL,
8484+ CancelURL: raw.Billing.CancelURL,
8585+ PlankOwnerCrewTier: raw.Billing.PlankOwnerCrewTier,
8686+ Tiers: raw.Billing.Tiers,
10687 }
10788108108- for name, tier := range ext.Quota.Tiers {
109109- cfg.Tiers[name] = BillingTierConfig{
110110- Description: tier.Description,
111111- StripePriceMonthly: tier.StripePriceMonthly,
112112- StripePriceYearly: tier.StripePriceYearly,
113113- }
8989+ if cfg.Tiers == nil {
9090+ cfg.Tiers = make(map[string]BillingTierConfig)
11491 }
1159211693 // Validate: billing enabled but no tiers have any Stripe prices configured
···153130 }
154131 return ""
155132}
156156-157157-// ExampleHoldYAML generates a complete hold config example including billing fields.
158158-// It calls hold.ExampleYAML() for the base config, then injects billing-specific
159159-// fields into the YAML node tree before re-marshalling.
160160-func ExampleHoldYAML() ([]byte, error) {
161161- base, err := hold.ExampleYAML()
162162- if err != nil {
163163- return nil, fmt.Errorf("failed to generate base hold config: %w", err)
164164- }
165165-166166- var doc yaml.Node
167167- if err := yaml.Unmarshal(base, &doc); err != nil {
168168- return nil, fmt.Errorf("failed to parse base hold config: %w", err)
169169- }
170170-171171- // doc is DocumentNode -> Content[0] is the root MappingNode
172172- if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 {
173173- return nil, fmt.Errorf("unexpected YAML structure")
174174- }
175175- root := doc.Content[0]
176176-177177- // Find the "quota" mapping inside root
178178- quotaNode := findMappingValue(root, "quota")
179179- if quotaNode == nil {
180180- return nil, fmt.Errorf("quota section not found in base config")
181181- }
182182-183183- // Inject billing fields into tier entries
184184- tiersNode := findMappingValue(quotaNode, "tiers")
185185- if tiersNode != nil {
186186- injectTierBillingFields(tiersNode)
187187- }
188188-189189- // Inject plankowner_crew_tier into defaults
190190- defaultsNode := findMappingValue(quotaNode, "defaults")
191191- if defaultsNode != nil {
192192- injectPlankOwnerDefault(defaultsNode)
193193- }
194194-195195- // Inject billing section under quota
196196- injectBillingSection(quotaNode)
197197-198198- return yaml.Marshal(&doc)
199199-}
200200-201201-// findMappingValue finds a value node in a YAML mapping by key.
202202-func findMappingValue(mapping *yaml.Node, key string) *yaml.Node {
203203- if mapping.Kind != yaml.MappingNode {
204204- return nil
205205- }
206206- for i := 0; i < len(mapping.Content)-1; i += 2 {
207207- if mapping.Content[i].Value == key {
208208- return mapping.Content[i+1]
209209- }
210210- }
211211- return nil
212212-}
213213-214214-// injectTierBillingFields adds description and stripe_price fields to each tier entry.
215215-func injectTierBillingFields(tiersNode *yaml.Node) {
216216- if tiersNode.Kind != yaml.MappingNode {
217217- return
218218- }
219219-220220- examples := map[string]struct {
221221- description string
222222- monthly string
223223- yearly string
224224- }{
225225- "bosun": {"Standard tier — recommended for most users.", "price_bosun_monthly_id", "price_bosun_yearly_id"},
226226- "deckhand": {"Starter tier — free for new crew members.", "", ""},
227227- "quartermaster": {"Professional tier — for power users and teams.", "price_qm_monthly_id", "price_qm_yearly_id"},
228228- }
229229-230230- for i := 0; i < len(tiersNode.Content)-1; i += 2 {
231231- tierKey := tiersNode.Content[i].Value
232232- tierVal := tiersNode.Content[i+1]
233233- if tierVal.Kind != yaml.MappingNode {
234234- continue
235235- }
236236-237237- ex, ok := examples[tierKey]
238238- if !ok {
239239- continue
240240- }
241241-242242- // Add description
243243- tierVal.Content = append(tierVal.Content,
244244- &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "description", HeadComment: "Human-readable tier description (used in billing UI)."},
245245- &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ex.description},
246246- )
247247-248248- // Add stripe prices if applicable
249249- if ex.monthly != "" {
250250- tierVal.Content = append(tierVal.Content,
251251- &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "stripe_price_monthly", HeadComment: "Stripe Price ID for monthly billing."},
252252- &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ex.monthly},
253253- )
254254- }
255255- if ex.yearly != "" {
256256- tierVal.Content = append(tierVal.Content,
257257- &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "stripe_price_yearly", HeadComment: "Stripe Price ID for yearly billing (optional)."},
258258- &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ex.yearly},
259259- )
260260- }
261261- }
262262-}
263263-264264-// injectPlankOwnerDefault adds plankowner_crew_tier to the defaults section.
265265-func injectPlankOwnerDefault(defaultsNode *yaml.Node) {
266266- if defaultsNode.Kind != yaml.MappingNode {
267267- return
268268- }
269269- defaultsNode.Content = append(defaultsNode.Content,
270270- &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "plankowner_crew_tier", HeadComment: "Tier granted to early crew members (plankowners). Ignored by base hold service."},
271271- &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "bosun"},
272272- )
273273-}
274274-275275-// injectBillingSection adds the billing subsection under quota.
276276-func injectBillingSection(quotaNode *yaml.Node) {
277277- if quotaNode.Kind != yaml.MappingNode {
278278- return
279279- }
280280-281281- billing := &yaml.Node{
282282- Kind: yaml.MappingNode,
283283- Tag: "!!map",
284284- }
285285- billing.Content = append(billing.Content,
286286- &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "enabled"},
287287- &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: "false"},
288288-289289- &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "currency", HeadComment: "ISO 4217 currency code for Stripe charges."},
290290- &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "usd"},
291291-292292- &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "success_url", HeadComment: "Redirect URL after successful checkout. {hold_url} is replaced at runtime."},
293293- &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "{hold_url}/billing/success"},
294294-295295- &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "cancel_url", HeadComment: "Redirect URL when checkout is cancelled."},
296296- &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "{hold_url}/billing/cancel"},
297297- )
298298-299299- quotaNode.Content = append(quotaNode.Content,
300300- &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "billing", HeadComment: "Stripe billing settings. Ignored by base hold service (seamark.dev only)."},
301301- billing,
302302- )
303303-}
+142-117
pkg/hold/billing/config_test.go
···11+//go:build billing
22+13package billing
2435import (
46 "os"
57 "path/filepath"
68 "testing"
77-88- "go.yaml.in/yaml/v4"
99-1010- "atcr.io/pkg/hold/quota"
119)
12101313-// yamlUnmarshal is a thin wrapper to avoid shadowing the yaml package import.
1414-func yamlUnmarshal(data []byte, v any) error {
1515- return yaml.Unmarshal(data, v)
1616-}
1717-1811func TestParseBillingConfig_Disabled(t *testing.T) {
1912 yaml := []byte(`
2020-quota:
2121- tiers:
2222- deckhand:
2323- quota: 5GB
2424- billing:
2525- enabled: false
1313+billing:
1414+ enabled: false
2615`)
2716 cfg, err := parseBillingConfig(yaml)
2817 if err != nil {
···51405241func TestParseBillingConfig_Enabled(t *testing.T) {
5342 yaml := []byte(`
5454-quota:
4343+billing:
4444+ enabled: true
4545+ currency: usd
4646+ success_url: "{hold_url}/billing/success"
4747+ cancel_url: "{hold_url}/billing/cancel"
4848+ plankowner_crew_tier: bosun
5549 tiers:
5650 deckhand:
5757- quota: 5GB
5851 description: Starter tier
5952 bosun:
6060- quota: 50GB
6153 description: Standard tier
6254 stripe_price_monthly: price_bosun_monthly
6355 stripe_price_yearly: price_bosun_yearly
6464- defaults:
6565- new_crew_tier: deckhand
6666- plankowner_crew_tier: bosun
6767- billing:
6868- enabled: true
6969- currency: usd
7070- success_url: "{hold_url}/billing/success"
7171- cancel_url: "{hold_url}/billing/cancel"
7256`)
7357 cfg, err := parseBillingConfig(yaml)
7458 if err != nil {
···118102119103func TestParseBillingConfig_EnabledButNoPrices(t *testing.T) {
120104 yaml := []byte(`
121121-quota:
122122- tiers:
123123- deckhand:
124124- quota: 5GB
125125- billing:
126126- enabled: true
127127- currency: usd
105105+billing:
106106+ enabled: true
107107+ currency: usd
128108`)
129109 cfg, err := parseBillingConfig(yaml)
130110 if err == nil {
···195175 path := filepath.Join(dir, "config.yaml")
196176197177 content := `
198198-quota:
178178+billing:
179179+ enabled: true
180180+ currency: usd
199181 tiers:
200182 bosun:
201201- quota: 50GB
202183 stripe_price_monthly: price_test
203203- billing:
204204- enabled: true
205205- currency: usd
206184`
207185 if err := os.WriteFile(path, []byte(content), 0644); err != nil {
208186 t.Fatal(err)
···220198 }
221199}
222200223223-// holdQuotaWrapper mirrors the hold config structure just enough to extract
224224-// the quota section for testing. This avoids importing the full hold package.
225225-type holdQuotaWrapper struct {
226226- Quota quota.Config `yaml:"quota"`
227227-}
228228-229229-// TestExampleHoldYAMLRoundTrip verifies that the generated example config
230230-// can be parsed by both atcr.io's quota parser and seamark.dev's billing parser.
231231-// This catches silent breakage if atcr.io renames or restructures the quota section.
232232-func TestExampleHoldYAMLRoundTrip(t *testing.T) {
233233- yamlBytes, err := ExampleHoldYAML()
201201+func TestParseBillingConfig_TopLevelTiers(t *testing.T) {
202202+ yaml := []byte(`
203203+billing:
204204+ enabled: true
205205+ currency: usd
206206+ tiers:
207207+ deckhand:
208208+ description: "Starter tier"
209209+ bosun:
210210+ description: "Standard tier"
211211+ stripe_price_monthly: price_bosun_m
212212+ stripe_price_yearly: price_bosun_y
213213+ quartermaster:
214214+ description: "Pro tier"
215215+ stripe_price_monthly: price_qm_m
216216+`)
217217+ cfg, err := parseBillingConfig(yaml)
234218 if err != nil {
235235- t.Fatalf("ExampleHoldYAML failed: %v", err)
219219+ t.Fatalf("unexpected error: %v", err)
236220 }
237237-238238- // Verify atcr.io's quota parser can read the quota section.
239239- // The full hold config nests tiers under "quota:", so we parse with
240240- // a wrapper struct (same as hold.Config does) then use NewManagerFromConfig.
241241- var wrapper holdQuotaWrapper
242242- if err := yamlUnmarshal(yamlBytes, &wrapper); err != nil {
243243- t.Fatalf("failed to parse generated config for quota: %v", err)
221221+ if cfg == nil {
222222+ t.Fatal("expected non-nil config")
223223+ }
224224+ if len(cfg.Tiers) != 3 {
225225+ t.Errorf("expected 3 tiers, got %d", len(cfg.Tiers))
244226 }
245227246246- quotaMgr, err := quota.NewManagerFromConfig(&wrapper.Quota)
247247- if err != nil {
248248- t.Fatalf("quota.NewManagerFromConfig failed: %v", err)
228228+ bosun := cfg.GetTierPricing("bosun")
229229+ if bosun == nil {
230230+ t.Fatal("expected bosun tier")
249231 }
250250- if !quotaMgr.IsEnabled() {
251251- t.Error("expected quotas to be enabled in generated config")
232232+ if bosun.Description != "Standard tier" {
233233+ t.Errorf("expected description 'Standard tier', got %q", bosun.Description)
252234 }
253253- if quotaMgr.TierCount() != 3 {
254254- t.Errorf("expected 3 quota tiers, got %d", quotaMgr.TierCount())
235235+ if bosun.StripePriceMonthly != "price_bosun_m" {
236236+ t.Errorf("expected monthly price 'price_bosun_m', got %q", bosun.StripePriceMonthly)
255237 }
256256- if quotaMgr.GetDefaultTier() != "deckhand" {
257257- t.Errorf("expected default tier 'deckhand', got %q", quotaMgr.GetDefaultTier())
238238+ if bosun.StripePriceYearly != "price_bosun_y" {
239239+ t.Errorf("expected yearly price 'price_bosun_y', got %q", bosun.StripePriceYearly)
258240 }
259241260260- // The generated example has billing.enabled: false, so parseBillingConfig
261261- // returns nil. Enable it to verify the billing fields were injected correctly.
262262- // Use the full "billing:\n...enabled:" pattern to avoid replacing admin.enabled.
263263- enabledYAML := replaceOnce(string(yamlBytes), "billing:\n enabled: false", "billing:\n enabled: true")
264264-265265- billingCfg, err := parseBillingConfig([]byte(enabledYAML))
266266- if err != nil {
267267- t.Fatalf("parseBillingConfig failed on generated config: %v", err)
242242+ qm := cfg.GetTierPricing("quartermaster")
243243+ if qm == nil {
244244+ t.Fatal("expected quartermaster tier")
268245 }
269269- if billingCfg == nil {
270270- t.Fatal("expected non-nil billing config after enabling")
246246+ if qm.StripePriceMonthly != "price_qm_m" {
247247+ t.Errorf("expected monthly price 'price_qm_m', got %q", qm.StripePriceMonthly)
271248 }
272249273273- // Verify billing fields were injected into the YAML
274274- if billingCfg.Currency != "usd" {
275275- t.Errorf("expected currency 'usd', got %q", billingCfg.Currency)
250250+ deckhand := cfg.GetTierPricing("deckhand")
251251+ if deckhand == nil {
252252+ t.Fatal("expected deckhand tier")
253253+ }
254254+ if deckhand.Description != "Starter tier" {
255255+ t.Errorf("expected description 'Starter tier', got %q", deckhand.Description)
276256 }
277277- if billingCfg.PlankOwnerCrewTier != "bosun" {
278278- t.Errorf("expected plankowner_crew_tier 'bosun', got %q", billingCfg.PlankOwnerCrewTier)
257257+ if deckhand.StripePriceMonthly != "" {
258258+ t.Error("expected no monthly price for deckhand")
279259 }
260260+}
280261281281- // Verify tier-level billing fields
282282- bosun := billingCfg.GetTierPricing("bosun")
283283- if bosun == nil {
284284- t.Fatal("expected bosun billing tier")
262262+func TestParseBillingConfig_PlankOwnerCrewTier(t *testing.T) {
263263+ yaml := []byte(`
264264+billing:
265265+ enabled: true
266266+ currency: usd
267267+ plankowner_crew_tier: bosun
268268+ tiers:
269269+ bosun:
270270+ stripe_price_monthly: price_bosun_m
271271+`)
272272+ cfg, err := parseBillingConfig(yaml)
273273+ if err != nil {
274274+ t.Fatalf("unexpected error: %v", err)
285275 }
286286- if bosun.StripePriceMonthly == "" {
287287- t.Error("expected bosun to have stripe_price_monthly")
276276+ if cfg == nil {
277277+ t.Fatal("expected non-nil config")
288278 }
289289- if bosun.Description == "" {
290290- t.Error("expected bosun to have description")
279279+ if cfg.PlankOwnerCrewTier != "bosun" {
280280+ t.Errorf("expected plankowner_crew_tier 'bosun', got %q", cfg.PlankOwnerCrewTier)
291281 }
282282+}
292283293293- qm := billingCfg.GetTierPricing("quartermaster")
294294- if qm == nil {
295295- t.Fatal("expected quartermaster billing tier")
284284+func TestParseBillingConfig_IgnoresQuotaSection(t *testing.T) {
285285+ // Billing parser should work even if quota section is missing entirely
286286+ yaml := []byte(`
287287+billing:
288288+ enabled: true
289289+ currency: usd
290290+ tiers:
291291+ bosun:
292292+ stripe_price_monthly: price_bosun_m
293293+`)
294294+ cfg, err := parseBillingConfig(yaml)
295295+ if err != nil {
296296+ t.Fatalf("unexpected error: %v", err)
296297 }
297297- if qm.StripePriceMonthly == "" {
298298- t.Error("expected quartermaster to have stripe_price_monthly")
298298+ if cfg == nil {
299299+ t.Fatal("expected non-nil config")
299300 }
300301301301- // Deckhand is the free tier — no Stripe prices expected
302302- deckhand := billingCfg.GetTierPricing("deckhand")
303303- if deckhand == nil {
304304- t.Fatal("expected deckhand billing tier entry")
302302+ // Also works with quota present but unrelated
303303+ yaml2 := []byte(`
304304+quota:
305305+ tiers:
306306+ swabbie:
307307+ quota: 1GB
308308+billing:
309309+ enabled: true
310310+ currency: usd
311311+ tiers:
312312+ bosun:
313313+ stripe_price_monthly: price_bosun_m
314314+`)
315315+ cfg2, err := parseBillingConfig(yaml2)
316316+ if err != nil {
317317+ t.Fatalf("unexpected error: %v", err)
305318 }
306306- if deckhand.StripePriceMonthly != "" {
307307- t.Error("expected no stripe_price_monthly for deckhand")
319319+ if cfg2 == nil {
320320+ t.Fatal("expected non-nil config")
308321 }
309309-310310- // Verify the price ID reverse lookup works
311311- if billingCfg.GetTierByPriceID(bosun.StripePriceMonthly) != "bosun" {
312312- t.Error("GetTierByPriceID failed for bosun monthly price")
322322+ // Billing should only see its own tiers, not quota tiers
323323+ if cfg2.GetTierPricing("swabbie") != nil {
324324+ t.Error("billing should not contain quota-only tiers")
313325 }
314326}
315327316316-// replaceOnce replaces the first occurrence of old with new in s.
317317-func replaceOnce(s, old, new string) string {
318318- i := indexOf(s, old)
319319- if i < 0 {
320320- return s
328328+func TestParseBillingConfig_EmptyTiers(t *testing.T) {
329329+ // Billing enabled with explicit empty tiers
330330+ yaml := []byte(`
331331+billing:
332332+ enabled: true
333333+ currency: usd
334334+ tiers: {}
335335+`)
336336+ cfg, err := parseBillingConfig(yaml)
337337+ if err == nil {
338338+ t.Error("expected error when billing enabled with empty tiers")
321339 }
322322- return s[:i] + new + s[i+len(old):]
323323-}
340340+ if cfg != nil {
341341+ t.Error("expected nil config on error")
342342+ }
324343325325-func indexOf(s, substr string) int {
326326- for i := 0; i <= len(s)-len(substr); i++ {
327327- if s[i:i+len(substr)] == substr {
328328- return i
329329- }
344344+ // Billing enabled with tiers omitted entirely
345345+ yaml2 := []byte(`
346346+billing:
347347+ enabled: true
348348+ currency: usd
349349+`)
350350+ cfg2, err := parseBillingConfig(yaml2)
351351+ if err == nil {
352352+ t.Error("expected error when billing enabled with no tiers")
330353 }
331331- return -1
354354+ if cfg2 != nil {
355355+ t.Error("expected nil config on error")
356356+ }
332357}
+8
pkg/hold/config.go
···3434 Database DatabaseConfig `yaml:"database" comment:"Embedded PDS database settings."`
3535 Admin AdminConfig `yaml:"admin" comment:"Admin panel settings."`
3636 Quota quota.Config `yaml:"quota" comment:"Storage quota tiers. Empty disables quota enforcement."`
3737+ configPath string `yaml:"-"` // internal: path to YAML file for subsystem config loading
3738}
3939+4040+// ConfigPath returns the path to the YAML configuration file used to load this config.
4141+// Subsystems (e.g. billing) use this to re-read the same file for extended fields.
4242+func (c *Config) ConfigPath() string { return c.configPath }
38433944// AdminConfig defines admin panel settings
4045type AdminConfig struct {
···233238 if cfg.Database.KeyPath == "" && cfg.Database.Path != "" {
234239 cfg.Database.KeyPath = filepath.Join(cfg.Database.Path, "signing.key")
235240 }
241241+242242+ // Store config path for subsystem config loading (e.g. billing)
243243+ cfg.configPath = yamlPath
236244237245 // Build distribution storage config from struct fields
238246 cfg.Storage.distStorage = buildStorageConfigFromFields(cfg.Storage)
+2-1
pkg/hold/pds/crew.go
···189189 Role: existing.Role,
190190 Permissions: existing.Permissions,
191191 Tier: tier,
192192- AddedAt: existing.AddedAt, // Preserve original add time
192192+ Plankowner: existing.Plankowner, // Preserve early adopter flag
193193+ AddedAt: existing.AddedAt, // Preserve original add time
193194 }
194195195196 rkey := atproto.CrewRecordKey(memberDID)