A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

begin billing

+1774
+238
docs/BILLING.md
··· 1 + # Hold Service Billing Integration 2 + 3 + Optional Stripe billing integration for hold services. Allows hold operators to charge for storage tiers via subscriptions. 4 + 5 + ## Overview 6 + 7 + - **Compile-time optional**: Build with `-tags billing` to enable Stripe support 8 + - **Hold owns billing**: Each hold operator has their own Stripe account 9 + - **AppView aggregates UI**: Fetches subscription info from holds, displays in settings 10 + - **Customer-DID mapping**: DIDs stored in Stripe customer metadata (no extra database) 11 + 12 + ## Architecture 13 + 14 + ``` 15 + User → AppView Settings UI → Hold XRPC endpoints → Stripe 16 + 17 + Stripe webhook → Hold → Update crew tier 18 + ``` 19 + 20 + ## Building with Billing Support 21 + 22 + ```bash 23 + # Without billing (default) 24 + go build ./cmd/hold 25 + 26 + # With billing 27 + go build -tags billing ./cmd/hold 28 + 29 + # Docker with billing 30 + docker build --build-arg BILLING_ENABLED=true -f Dockerfile.hold . 31 + ``` 32 + 33 + ## Configuration 34 + 35 + ### Environment Variables 36 + 37 + ```bash 38 + # Required for billing 39 + STRIPE_SECRET_KEY=sk_live_xxx # or sk_test_xxx for testing 40 + STRIPE_WEBHOOK_SECRET=whsec_xxx # from Stripe Dashboard or CLI 41 + 42 + # Optional 43 + STRIPE_PUBLISHABLE_KEY=pk_live_xxx # for client-side (not currently used) 44 + ``` 45 + 46 + ### quotas.yaml 47 + 48 + ```yaml 49 + tiers: 50 + swabbie: 51 + quota: 2GB 52 + description: "Starter storage" 53 + # No stripe_price = free tier 54 + 55 + deckhand: 56 + quota: 5GB 57 + description: "Standard storage" 58 + stripe_price_yearly: price_xxx # Price ID from Stripe 59 + 60 + bosun: 61 + quota: 10GB 62 + description: "Mid-level storage" 63 + stripe_price_monthly: price_xxx 64 + stripe_price_yearly: price_xxx 65 + 66 + defaults: 67 + new_crew_tier: swabbie 68 + plankowner_crew_tier: deckhand # Early adopters get this free 69 + 70 + billing: 71 + enabled: true 72 + currency: usd 73 + success_url: "{hold_url}/billing/success" 74 + cancel_url: "{hold_url}/billing/cancel" 75 + ``` 76 + 77 + ### Stripe Price IDs 78 + 79 + Use **Price IDs** (`price_xxx`), not Product IDs (`prod_xxx`). 80 + 81 + To find Price IDs: 82 + 1. Stripe Dashboard → Products → Select product 83 + 2. Look at Pricing section 84 + 3. Copy the Price ID 85 + 86 + Or via API: 87 + ```bash 88 + curl https://api.stripe.com/v1/prices?product=prod_xxx \ 89 + -u sk_test_xxx: 90 + ``` 91 + 92 + ## XRPC Endpoints 93 + 94 + | Endpoint | Auth | Description | 95 + |----------|------|-------------| 96 + | `GET /xrpc/io.atcr.hold.getSubscriptionInfo` | Optional | Get tiers and user's current subscription | 97 + | `POST /xrpc/io.atcr.hold.createCheckoutSession` | Required | Create Stripe checkout URL | 98 + | `GET /xrpc/io.atcr.hold.getBillingPortalUrl` | Required | Get Stripe billing portal URL | 99 + | `POST /xrpc/io.atcr.hold.stripeWebhook` | Stripe sig | Handle subscription events | 100 + 101 + ## Local Development 102 + 103 + ### Stripe CLI Setup 104 + 105 + The Stripe CLI forwards webhooks to localhost: 106 + 107 + ```bash 108 + # Install 109 + brew install stripe/stripe-cli/stripe 110 + # Or: https://stripe.com/docs/stripe-cli 111 + 112 + # Login 113 + stripe login 114 + 115 + # Forward webhooks to local hold 116 + stripe listen --forward-to localhost:8080/xrpc/io.atcr.hold.stripeWebhook 117 + ``` 118 + 119 + The CLI outputs a webhook signing secret: 120 + ``` 121 + Ready! Your webhook signing secret is whsec_xxxxxxxxxxxxx 122 + ``` 123 + 124 + Use that as `STRIPE_WEBHOOK_SECRET` for local dev. 125 + 126 + ### Running Locally 127 + 128 + ```bash 129 + # Terminal 1: Run hold with billing 130 + export STRIPE_SECRET_KEY=sk_test_xxx 131 + export STRIPE_WEBHOOK_SECRET=whsec_xxx # from 'stripe listen' 132 + export HOLD_PUBLIC_URL=http://localhost:8080 133 + export STORAGE_DRIVER=filesystem 134 + export HOLD_DATABASE_DIR=/tmp/hold-test 135 + go run -tags billing ./cmd/hold 136 + 137 + # Terminal 2: Forward webhooks 138 + stripe listen --forward-to localhost:8080/xrpc/io.atcr.hold.stripeWebhook 139 + 140 + # Terminal 3: Trigger test events 141 + stripe trigger checkout.session.completed 142 + stripe trigger customer.subscription.created 143 + stripe trigger customer.subscription.updated 144 + stripe trigger customer.subscription.paused 145 + stripe trigger customer.subscription.resumed 146 + stripe trigger customer.subscription.deleted 147 + ``` 148 + 149 + ### Testing the Flow 150 + 151 + 1. Start hold with billing enabled 152 + 2. Start Stripe CLI webhook forwarding 153 + 3. Navigate to AppView settings page 154 + 4. Click "Upgrade" on a tier 155 + 5. Complete Stripe checkout (use test card `4242 4242 4242 4242`) 156 + 6. Webhook fires → hold updates crew tier 157 + 7. Refresh settings to see new tier 158 + 159 + ## Webhook Events 160 + 161 + The hold handles these Stripe events: 162 + 163 + | Event | Action | 164 + |-------|--------| 165 + | `checkout.session.completed` | Create/update subscription, set tier | 166 + | `customer.subscription.created` | Set crew tier from price ID | 167 + | `customer.subscription.updated` | Update crew tier if price changed | 168 + | `customer.subscription.paused` | Downgrade to free tier | 169 + | `customer.subscription.resumed` | Restore tier from subscription price | 170 + | `customer.subscription.deleted` | Downgrade to free tier | 171 + | `invoice.payment_failed` | Log warning (tier unchanged until canceled) | 172 + 173 + ## Plankowners (Grandfathering) 174 + 175 + Early adopters can be marked as "plankowners" to get a paid tier for free: 176 + 177 + ```json 178 + { 179 + "$type": "io.atcr.hold.crew", 180 + "member": "did:plc:xxx", 181 + "tier": "deckhand", 182 + "plankowner": true, 183 + "permissions": ["blob:read", "blob:write"], 184 + "addedAt": "2025-01-01T00:00:00Z" 185 + } 186 + ``` 187 + 188 + Plankowners: 189 + - Get `plankowner_crew_tier` (e.g., deckhand) without paying 190 + - Still see upgrade options in UI if they want to support 191 + - Can upgrade to higher tiers normally 192 + 193 + ## Customer-DID Mapping 194 + 195 + DIDs are stored in Stripe customer metadata: 196 + 197 + ```json 198 + { 199 + "metadata": { 200 + "user_did": "did:plc:xxx", 201 + "hold_did": "did:web:hold.example.com" 202 + } 203 + } 204 + ``` 205 + 206 + The hold uses an in-memory cache (10 min TTL) to reduce Stripe API calls. On webhook events, the cache is invalidated for the affected customer. 207 + 208 + ## Production Checklist 209 + 210 + - [ ] Create Stripe products and prices in live mode 211 + - [ ] Set `STRIPE_SECRET_KEY` to live key (`sk_live_xxx`) 212 + - [ ] Configure webhook endpoint in Stripe Dashboard: 213 + - URL: `https://your-hold.com/xrpc/io.atcr.hold.stripeWebhook` 214 + - Events: `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.paused`, `customer.subscription.resumed`, `customer.subscription.deleted`, `invoice.payment_failed` 215 + - [ ] Set `STRIPE_WEBHOOK_SECRET` from Dashboard webhook settings 216 + - [ ] Update `quotas.yaml` with live price IDs 217 + - [ ] Build hold with `-tags billing` 218 + - [ ] Test with a real payment (can refund immediately) 219 + 220 + ## Troubleshooting 221 + 222 + ### Webhook signature verification failed 223 + - Ensure `STRIPE_WEBHOOK_SECRET` matches the webhook endpoint in Stripe Dashboard 224 + - For local dev, use the secret from `stripe listen` output 225 + 226 + ### Customer not found 227 + - Customer is created on first checkout 228 + - Check Stripe Dashboard → Customers for the DID in metadata 229 + 230 + ### Tier not updating after payment 231 + - Check hold logs for webhook processing errors 232 + - Verify price ID in `quotas.yaml` matches Stripe 233 + - Ensure `billing.enabled: true` in config 234 + 235 + ### "Billing not enabled" error 236 + - Build with `-tags billing` 237 + - Set `billing.enabled: true` in `quotas.yaml` 238 + - Ensure `STRIPE_SECRET_KEY` is set
+1
go.mod
··· 31 31 github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c 32 32 github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef 33 33 github.com/stretchr/testify v1.11.1 34 + github.com/stripe/stripe-go/v84 v84.3.0 34 35 github.com/whyrusleeping/cbor-gen v0.3.1 35 36 github.com/yuin/goldmark v1.7.16 36 37 go.opentelemetry.io/otel v1.40.0
+4
go.sum
··· 403 403 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 404 404 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 405 405 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 406 + github.com/stripe/stripe-go/v84 v84.1.0 h1:9KW8Fm3csWsPNqBJCgdEZBM9pRNaqpESHIw+eXp8A0k= 407 + github.com/stripe/stripe-go/v84 v84.1.0/go.mod h1:kjXh3OrF4PT16qz7z9Q5yqYAZ1mJmu8g8f4Z1sOHBfc= 408 + github.com/stripe/stripe-go/v84 v84.3.0 h1:77HH+ro7yzmyyF7Xkbkj6y5QtnU1WWHC6t2y4mq0Wvk= 409 + github.com/stripe/stripe-go/v84 v84.3.0/go.mod h1:Z4gcKw1zl4geDG2+cjpSaJES9jaohGX6n7FP8/kHIqw= 406 410 github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 407 411 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 408 412 github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
+549
pkg/hold/billing/billing.go
··· 1 + //go:build billing 2 + 3 + package billing 4 + 5 + import ( 6 + "encoding/json" 7 + "errors" 8 + "fmt" 9 + "io" 10 + "log/slog" 11 + "net/http" 12 + "os" 13 + "sort" 14 + "strings" 15 + "sync" 16 + "time" 17 + 18 + "github.com/stripe/stripe-go/v84" 19 + portalsession "github.com/stripe/stripe-go/v84/billingportal/session" 20 + "github.com/stripe/stripe-go/v84/checkout/session" 21 + "github.com/stripe/stripe-go/v84/customer" 22 + "github.com/stripe/stripe-go/v84/price" 23 + "github.com/stripe/stripe-go/v84/subscription" 24 + "github.com/stripe/stripe-go/v84/webhook" 25 + 26 + "atcr.io/pkg/hold/quota" 27 + ) 28 + 29 + // Manager handles Stripe billing integration. 30 + type Manager struct { 31 + quotaMgr *quota.Manager 32 + billingCfg *BillingConfig 33 + holdPublicURL string 34 + stripeKey string 35 + webhookSecret string 36 + publishableKey string 37 + 38 + // In-memory cache for customer lookups (DID -> customer) 39 + customerCache map[string]*cachedCustomer 40 + customerCacheMu sync.RWMutex 41 + } 42 + 43 + type cachedCustomer struct { 44 + customer *stripe.Customer 45 + expiresAt time.Time 46 + } 47 + 48 + const customerCacheTTL = 10 * time.Minute 49 + 50 + // New creates a new billing manager with Stripe integration. 51 + func New(quotaMgr *quota.Manager, holdPublicURL string) *Manager { 52 + stripeKey := os.Getenv("STRIPE_SECRET_KEY") 53 + if stripeKey != "" { 54 + stripe.Key = stripeKey 55 + } 56 + 57 + return &Manager{ 58 + quotaMgr: quotaMgr, 59 + holdPublicURL: holdPublicURL, 60 + stripeKey: stripeKey, 61 + webhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), 62 + publishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"), 63 + customerCache: make(map[string]*cachedCustomer), 64 + } 65 + } 66 + 67 + // Enabled returns true if billing is properly configured. 68 + func (m *Manager) Enabled() bool { 69 + return m.billingCfg != nil && m.billingCfg.Enabled && m.stripeKey != "" 70 + } 71 + 72 + // GetSubscriptionInfo returns subscription and quota information for a user. 73 + func (m *Manager) GetSubscriptionInfo(userDID string) (*SubscriptionInfo, error) { 74 + if !m.Enabled() { 75 + return nil, ErrBillingDisabled 76 + } 77 + 78 + info := &SubscriptionInfo{ 79 + UserDID: userDID, 80 + PaymentsEnabled: true, 81 + Tiers: m.buildTierList(userDID), 82 + } 83 + 84 + // Try to find existing customer 85 + cust, err := m.findCustomerByDID(userDID) 86 + if err != nil { 87 + slog.Debug("No Stripe customer found for user", "userDid", userDID) 88 + } else if cust != nil { 89 + info.CustomerID = cust.ID 90 + 91 + // Get active subscription if any (check all nil pointers) 92 + if cust.Subscriptions != nil && len(cust.Subscriptions.Data) > 0 { 93 + sub := cust.Subscriptions.Data[0] 94 + info.SubscriptionID = sub.ID 95 + 96 + // Safely access subscription items 97 + if sub.Items != nil && len(sub.Items.Data) > 0 && sub.Items.Data[0].Price != nil { 98 + info.CurrentTier = m.billingCfg.GetTierByPriceID(sub.Items.Data[0].Price.ID) 99 + 100 + if sub.Items.Data[0].Price.Recurring != nil { 101 + switch sub.Items.Data[0].Price.Recurring.Interval { 102 + case stripe.PriceRecurringIntervalMonth: 103 + info.BillingInterval = "monthly" 104 + case stripe.PriceRecurringIntervalYear: 105 + info.BillingInterval = "yearly" 106 + } 107 + } 108 + } 109 + } 110 + } 111 + 112 + // If no subscription, use default tier 113 + if info.CurrentTier == "" { 114 + info.CurrentTier = m.quotaMgr.GetDefaultTier() 115 + } 116 + 117 + // Get quota limit for current tier 118 + limit := m.quotaMgr.GetTierLimit(info.CurrentTier) 119 + info.CurrentLimit = limit 120 + 121 + // Mark current tier in tier list 122 + for i := range info.Tiers { 123 + if info.Tiers[i].ID == info.CurrentTier { 124 + info.Tiers[i].IsCurrent = true 125 + } 126 + } 127 + 128 + return info, nil 129 + } 130 + 131 + // buildTierList creates the list of available tiers by merging quota limits 132 + // from the quota manager with billing metadata from the billing config. 133 + func (m *Manager) buildTierList(userDID string) []TierInfo { 134 + quotaTiers := m.quotaMgr.ListTiers() 135 + if len(quotaTiers) == 0 { 136 + return nil 137 + } 138 + 139 + result := make([]TierInfo, 0, len(quotaTiers)) 140 + for _, qt := range quotaTiers { 141 + var quotaBytes int64 142 + if qt.Limit != nil { 143 + quotaBytes = *qt.Limit 144 + } 145 + 146 + // Capitalize tier ID for display name (e.g., "swabbie" -> "Swabbie") 147 + name := strings.ToUpper(qt.Key[:1]) + qt.Key[1:] 148 + 149 + tier := TierInfo{ 150 + ID: qt.Key, 151 + Name: name, 152 + QuotaBytes: quotaBytes, 153 + QuotaFormatted: quota.FormatHumanBytes(quotaBytes), 154 + } 155 + 156 + // Merge billing metadata if available 157 + if bt := m.billingCfg.GetTierPricing(qt.Key); bt != nil { 158 + tier.Description = bt.Description 159 + 160 + // Fetch actual prices from Stripe 161 + if bt.StripePriceMonthly != "" { 162 + if p, err := price.Get(bt.StripePriceMonthly, nil); err == nil && p != nil { 163 + tier.PriceCentsMonthly = int(p.UnitAmount) 164 + } else { 165 + slog.Debug("Failed to fetch monthly price", "priceId", bt.StripePriceMonthly, "error", err) 166 + tier.PriceCentsMonthly = -1 167 + } 168 + } 169 + if bt.StripePriceYearly != "" { 170 + if p, err := price.Get(bt.StripePriceYearly, nil); err == nil && p != nil { 171 + tier.PriceCentsYearly = int(p.UnitAmount) 172 + } else { 173 + slog.Debug("Failed to fetch yearly price", "priceId", bt.StripePriceYearly, "error", err) 174 + tier.PriceCentsYearly = -1 175 + } 176 + } 177 + } 178 + 179 + result = append(result, tier) 180 + } 181 + 182 + // Sort tiers by quota size (ascending) 183 + sort.Slice(result, func(i, j int) bool { 184 + return result[i].QuotaBytes < result[j].QuotaBytes 185 + }) 186 + 187 + return result 188 + } 189 + 190 + // CreateCheckoutSession creates a Stripe checkout session for subscription. 191 + func (m *Manager) CreateCheckoutSession(r *http.Request, req *CheckoutSessionRequest) (*CheckoutSessionResponse, error) { 192 + if !m.Enabled() { 193 + return nil, ErrBillingDisabled 194 + } 195 + 196 + // Get user DID from request context (set by auth middleware) 197 + userDID := r.Header.Get("X-User-DID") 198 + if userDID == "" { 199 + return nil, errors.New("user not authenticated") 200 + } 201 + 202 + // Get tier config 203 + tierCfg := m.billingCfg.GetTierPricing(req.Tier) 204 + if tierCfg == nil { 205 + return nil, fmt.Errorf("tier not found: %s", req.Tier) 206 + } 207 + 208 + // Determine price ID - prefer requested interval, fall back to what's available 209 + var priceID string 210 + switch req.Interval { 211 + case "monthly": 212 + priceID = tierCfg.StripePriceMonthly 213 + case "yearly": 214 + priceID = tierCfg.StripePriceYearly 215 + default: 216 + // No interval specified - prefer monthly, fall back to yearly 217 + if tierCfg.StripePriceMonthly != "" { 218 + priceID = tierCfg.StripePriceMonthly 219 + } else { 220 + priceID = tierCfg.StripePriceYearly 221 + } 222 + } 223 + 224 + if priceID == "" { 225 + return nil, fmt.Errorf("tier %s has no Stripe price configured", req.Tier) 226 + } 227 + 228 + // Get or create customer 229 + cust, err := m.getOrCreateCustomer(userDID) 230 + if err != nil { 231 + return nil, fmt.Errorf("failed to get/create customer: %w", err) 232 + } 233 + 234 + // Build success/cancel URLs 235 + successURL := strings.ReplaceAll(m.billingCfg.SuccessURL, "{hold_url}", m.holdPublicURL) 236 + cancelURL := strings.ReplaceAll(m.billingCfg.CancelURL, "{hold_url}", m.holdPublicURL) 237 + 238 + if req.ReturnURL != "" { 239 + successURL = req.ReturnURL + "?success=true" 240 + cancelURL = req.ReturnURL + "?cancelled=true" 241 + } 242 + 243 + // Create checkout session 244 + params := &stripe.CheckoutSessionParams{ 245 + Customer: stripe.String(cust.ID), 246 + Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), 247 + LineItems: []*stripe.CheckoutSessionLineItemParams{ 248 + { 249 + Price: stripe.String(priceID), 250 + Quantity: stripe.Int64(1), 251 + }, 252 + }, 253 + SuccessURL: stripe.String(successURL), 254 + CancelURL: stripe.String(cancelURL), 255 + } 256 + 257 + sess, err := session.New(params) 258 + if err != nil { 259 + return nil, fmt.Errorf("failed to create checkout session: %w", err) 260 + } 261 + 262 + return &CheckoutSessionResponse{ 263 + CheckoutURL: sess.URL, 264 + SessionID: sess.ID, 265 + }, nil 266 + } 267 + 268 + // GetBillingPortalURL returns a URL to the Stripe billing portal. 269 + func (m *Manager) GetBillingPortalURL(userDID string, returnURL string) (*BillingPortalResponse, error) { 270 + if !m.Enabled() { 271 + return nil, ErrBillingDisabled 272 + } 273 + 274 + // Find existing customer 275 + cust, err := m.findCustomerByDID(userDID) 276 + if err != nil || cust == nil { 277 + return nil, errors.New("no billing account found") 278 + } 279 + 280 + if returnURL == "" { 281 + returnURL = m.holdPublicURL 282 + } 283 + 284 + params := &stripe.BillingPortalSessionParams{ 285 + Customer: stripe.String(cust.ID), 286 + ReturnURL: stripe.String(returnURL), 287 + } 288 + 289 + sess, err := portalsession.New(params) 290 + if err != nil { 291 + return nil, fmt.Errorf("failed to create portal session: %w", err) 292 + } 293 + 294 + return &BillingPortalResponse{ 295 + PortalURL: sess.URL, 296 + }, nil 297 + } 298 + 299 + // HandleWebhook processes a Stripe webhook event. 300 + func (m *Manager) HandleWebhook(r *http.Request) (*WebhookEvent, error) { 301 + if !m.Enabled() { 302 + return nil, ErrBillingDisabled 303 + } 304 + 305 + body, err := io.ReadAll(r.Body) 306 + if err != nil { 307 + return nil, fmt.Errorf("failed to read request body: %w", err) 308 + } 309 + 310 + // Verify webhook signature 311 + event, err := webhook.ConstructEvent(body, r.Header.Get("Stripe-Signature"), m.webhookSecret) 312 + if err != nil { 313 + return nil, fmt.Errorf("failed to verify webhook signature: %w", err) 314 + } 315 + 316 + result := &WebhookEvent{ 317 + Type: string(event.Type), 318 + } 319 + 320 + switch event.Type { 321 + case "checkout.session.completed": 322 + var sess stripe.CheckoutSession 323 + if err := json.Unmarshal(event.Data.Raw, &sess); err != nil { 324 + return nil, fmt.Errorf("failed to parse checkout session: %w", err) 325 + } 326 + 327 + result.CustomerID = sess.Customer.ID 328 + result.SubscriptionID = sess.Subscription.ID 329 + result.Status = "active" 330 + 331 + // Fetch customer to get DID from metadata 332 + result.UserDID = m.getCustomerDID(sess.Customer.ID) 333 + 334 + // Get subscription to find the price/tier 335 + if sess.Subscription != nil && sess.Subscription.ID != "" { 336 + if sub, err := m.getSubscription(sess.Subscription.ID); err == nil && sub != nil { 337 + if len(sub.Items.Data) > 0 { 338 + result.PriceID = sub.Items.Data[0].Price.ID 339 + result.NewTier = m.billingCfg.GetTierByPriceID(result.PriceID) 340 + } 341 + } 342 + } 343 + 344 + if result.UserDID != "" && result.NewTier != "" { 345 + slog.Info("Checkout completed", 346 + "userDid", result.UserDID, 347 + "tier", result.NewTier, 348 + "subscriptionId", result.SubscriptionID, 349 + ) 350 + } 351 + 352 + case "customer.subscription.created", "customer.subscription.updated": 353 + var sub stripe.Subscription 354 + if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { 355 + return nil, fmt.Errorf("failed to parse subscription: %w", err) 356 + } 357 + 358 + result.SubscriptionID = sub.ID 359 + result.CustomerID = sub.Customer.ID 360 + result.Status = string(sub.Status) 361 + 362 + if len(sub.Items.Data) > 0 { 363 + result.PriceID = sub.Items.Data[0].Price.ID 364 + result.NewTier = m.billingCfg.GetTierByPriceID(result.PriceID) 365 + } 366 + 367 + // Fetch customer to get DID from metadata (webhook doesn't include expanded customer) 368 + result.UserDID = m.getCustomerDID(sub.Customer.ID) 369 + 370 + // If we have user DID and new tier, this signals that crew tier should be updated 371 + if result.UserDID != "" && result.NewTier != "" && sub.Status == stripe.SubscriptionStatusActive { 372 + slog.Info("Subscription activated", 373 + "userDid", result.UserDID, 374 + "tier", result.NewTier, 375 + "subscriptionId", result.SubscriptionID, 376 + ) 377 + } 378 + 379 + case "customer.subscription.deleted", "customer.subscription.paused": 380 + var sub stripe.Subscription 381 + if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { 382 + return nil, fmt.Errorf("failed to parse subscription: %w", err) 383 + } 384 + 385 + result.SubscriptionID = sub.ID 386 + result.CustomerID = sub.Customer.ID 387 + if event.Type == "customer.subscription.deleted" { 388 + result.Status = "cancelled" 389 + } else { 390 + result.Status = "paused" 391 + } 392 + 393 + // Fetch customer to get DID from metadata 394 + result.UserDID = m.getCustomerDID(sub.Customer.ID) 395 + 396 + // Set tier to default (downgrade on cancellation/pause) 397 + result.NewTier = m.quotaMgr.GetDefaultTier() 398 + 399 + if result.UserDID != "" { 400 + slog.Info("Subscription inactive, downgrading to default tier", 401 + "userDid", result.UserDID, 402 + "tier", result.NewTier, 403 + "status", result.Status, 404 + ) 405 + } 406 + 407 + case "customer.subscription.resumed": 408 + var sub stripe.Subscription 409 + if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { 410 + return nil, fmt.Errorf("failed to parse subscription: %w", err) 411 + } 412 + 413 + result.SubscriptionID = sub.ID 414 + result.CustomerID = sub.Customer.ID 415 + result.Status = "active" 416 + 417 + if len(sub.Items.Data) > 0 { 418 + result.PriceID = sub.Items.Data[0].Price.ID 419 + result.NewTier = m.billingCfg.GetTierByPriceID(result.PriceID) 420 + } 421 + 422 + // Fetch customer to get DID from metadata 423 + result.UserDID = m.getCustomerDID(sub.Customer.ID) 424 + 425 + if result.UserDID != "" && result.NewTier != "" { 426 + slog.Info("Subscription resumed, restoring tier", 427 + "userDid", result.UserDID, 428 + "tier", result.NewTier, 429 + ) 430 + } 431 + } 432 + 433 + return result, nil 434 + } 435 + 436 + // getOrCreateCustomer finds or creates a Stripe customer for the given DID. 437 + func (m *Manager) getOrCreateCustomer(userDID string) (*stripe.Customer, error) { 438 + // Check cache first 439 + m.customerCacheMu.RLock() 440 + if cached, ok := m.customerCache[userDID]; ok && time.Now().Before(cached.expiresAt) { 441 + m.customerCacheMu.RUnlock() 442 + return cached.customer, nil 443 + } 444 + m.customerCacheMu.RUnlock() 445 + 446 + // Try to find existing customer 447 + cust, err := m.findCustomerByDID(userDID) 448 + if err == nil && cust != nil { 449 + m.cacheCustomer(userDID, cust) 450 + return cust, nil 451 + } 452 + 453 + // Create new customer 454 + params := &stripe.CustomerParams{ 455 + Metadata: map[string]string{ 456 + "user_did": userDID, 457 + "hold_did": m.holdPublicURL, // Not actually a DID but useful for tracking 458 + }, 459 + } 460 + 461 + cust, err = customer.New(params) 462 + if err != nil { 463 + return nil, fmt.Errorf("failed to create customer: %w", err) 464 + } 465 + 466 + m.cacheCustomer(userDID, cust) 467 + return cust, nil 468 + } 469 + 470 + // findCustomerByDID searches Stripe for a customer with the given DID in metadata. 471 + func (m *Manager) findCustomerByDID(userDID string) (*stripe.Customer, error) { 472 + // Check cache first 473 + m.customerCacheMu.RLock() 474 + if cached, ok := m.customerCache[userDID]; ok && time.Now().Before(cached.expiresAt) { 475 + m.customerCacheMu.RUnlock() 476 + return cached.customer, nil 477 + } 478 + m.customerCacheMu.RUnlock() 479 + 480 + // Search Stripe by metadata 481 + params := &stripe.CustomerSearchParams{ 482 + SearchParams: stripe.SearchParams{ 483 + Query: fmt.Sprintf("metadata['user_did']:'%s'", userDID), 484 + }, 485 + } 486 + params.AddExpand("data.subscriptions") 487 + 488 + iter := customer.Search(params) 489 + if iter.Next() { 490 + cust := iter.Customer() 491 + m.cacheCustomer(userDID, cust) 492 + return cust, nil 493 + } 494 + 495 + if err := iter.Err(); err != nil { 496 + return nil, err 497 + } 498 + 499 + return nil, nil // Not found 500 + } 501 + 502 + // cacheCustomer adds a customer to the in-memory cache. 503 + func (m *Manager) cacheCustomer(userDID string, cust *stripe.Customer) { 504 + m.customerCacheMu.Lock() 505 + defer m.customerCacheMu.Unlock() 506 + 507 + m.customerCache[userDID] = &cachedCustomer{ 508 + customer: cust, 509 + expiresAt: time.Now().Add(customerCacheTTL), 510 + } 511 + } 512 + 513 + // InvalidateCustomerCache removes a customer from the cache. 514 + func (m *Manager) InvalidateCustomerCache(userDID string) { 515 + m.customerCacheMu.Lock() 516 + defer m.customerCacheMu.Unlock() 517 + 518 + delete(m.customerCache, userDID) 519 + } 520 + 521 + // getCustomerDID fetches a customer by ID and returns the user_did from metadata. 522 + func (m *Manager) getCustomerDID(customerID string) string { 523 + if customerID == "" { 524 + return "" 525 + } 526 + 527 + cust, err := customer.Get(customerID, nil) 528 + if err != nil { 529 + slog.Debug("Failed to fetch customer", "customerId", customerID, "error", err) 530 + return "" 531 + } 532 + 533 + if cust.Metadata != nil { 534 + return cust.Metadata["user_did"] 535 + } 536 + return "" 537 + } 538 + 539 + // getSubscription fetches a subscription by ID. 540 + func (m *Manager) getSubscription(subscriptionID string) (*stripe.Subscription, error) { 541 + if subscriptionID == "" { 542 + return nil, nil 543 + } 544 + 545 + params := &stripe.SubscriptionParams{} 546 + params.AddExpand("items.data.price") 547 + 548 + return subscription.Get(subscriptionID, params) 549 + }
+60
pkg/hold/billing/billing_stub.go
··· 1 + //go:build !billing 2 + 3 + package billing 4 + 5 + import ( 6 + "net/http" 7 + 8 + "github.com/go-chi/chi/v5" 9 + 10 + "atcr.io/pkg/hold/pds" 11 + "atcr.io/pkg/hold/quota" 12 + ) 13 + 14 + // Manager is a no-op billing manager when billing is not compiled in. 15 + type Manager struct{} 16 + 17 + // New creates a new no-op billing manager. 18 + // This is used when the billing build tag is not set. 19 + func New(_ *quota.Manager, _ string) *Manager { 20 + return &Manager{} 21 + } 22 + 23 + // Enabled returns false when billing is not compiled in. 24 + func (m *Manager) Enabled() bool { 25 + return false 26 + } 27 + 28 + // RegisterHandlers is a no-op when billing is not compiled in. 29 + func (m *Manager) RegisterHandlers(_ chi.Router) {} 30 + 31 + // GetSubscriptionInfo returns an error when billing is not compiled in. 32 + func (m *Manager) GetSubscriptionInfo(_ string) (*SubscriptionInfo, error) { 33 + return nil, ErrBillingDisabled 34 + } 35 + 36 + // CreateCheckoutSession returns an error when billing is not compiled in. 37 + func (m *Manager) CreateCheckoutSession(_ *http.Request, _ *CheckoutSessionRequest) (*CheckoutSessionResponse, error) { 38 + return nil, ErrBillingDisabled 39 + } 40 + 41 + // GetBillingPortalURL returns an error when billing is not compiled in. 42 + func (m *Manager) GetBillingPortalURL(_ string, _ string) (*BillingPortalResponse, error) { 43 + return nil, ErrBillingDisabled 44 + } 45 + 46 + // HandleWebhook returns an error when billing is not compiled in. 47 + func (m *Manager) HandleWebhook(_ *http.Request) (*WebhookEvent, error) { 48 + return nil, ErrBillingDisabled 49 + } 50 + 51 + // XRPCHandler is a no-op handler when billing is not compiled in. 52 + type XRPCHandler struct{} 53 + 54 + // NewXRPCHandler creates a new no-op XRPC handler. 55 + func NewXRPCHandler(_ *Manager, _ *pds.HoldPDS, _ *http.Client) *XRPCHandler { 56 + return &XRPCHandler{} 57 + } 58 + 59 + // RegisterHandlers is a no-op when billing is not compiled in. 60 + func (h *XRPCHandler) RegisterHandlers(_ chi.Router) {}
+303
pkg/hold/billing/config.go
··· 1 + //go:build billing 2 + 3 + package billing 4 + 5 + import ( 6 + "fmt" 7 + "os" 8 + 9 + "go.yaml.in/yaml/v4" 10 + 11 + "atcr.io/pkg/hold" 12 + ) 13 + 14 + // BillingConfig holds billing/Stripe settings parsed from the hold config YAML. 15 + // The billing fields live in the same YAML file as the hold config, but are 16 + // ignored by atcr.io's parser (Go YAML ignores unknown fields by default). 17 + type BillingConfig struct { 18 + Enabled bool 19 + Currency string 20 + SuccessURL string 21 + CancelURL string 22 + 23 + // Tier-level billing info keyed by tier name (same keys as quota tiers). 24 + Tiers map[string]BillingTierConfig 25 + 26 + // Tier assigned to plankowner crew members. 27 + PlankOwnerCrewTier string 28 + } 29 + 30 + // BillingTierConfig holds Stripe pricing for a single tier. 31 + type BillingTierConfig struct { 32 + Description string 33 + StripePriceMonthly string 34 + StripePriceYearly string 35 + } 36 + 37 + // --- internal YAML structs for parsing the extended hold config --- 38 + 39 + // extendedHoldConfig mirrors the hold config but only the quota section. 40 + type extendedHoldConfig struct { 41 + Quota extendedQuotaConfig `yaml:"quota"` 42 + } 43 + 44 + type extendedQuotaConfig struct { 45 + Tiers map[string]extendedTierConfig `yaml:"tiers"` 46 + Defaults extendedDefaults `yaml:"defaults"` 47 + Billing rawBillingConfig `yaml:"billing"` 48 + } 49 + 50 + type extendedTierConfig struct { 51 + Description string `yaml:"description,omitempty"` 52 + StripePriceMonthly string `yaml:"stripe_price_monthly,omitempty"` 53 + StripePriceYearly string `yaml:"stripe_price_yearly,omitempty"` 54 + } 55 + 56 + type extendedDefaults struct { 57 + PlankOwnerCrewTier string `yaml:"plankowner_crew_tier,omitempty"` 58 + } 59 + 60 + type rawBillingConfig struct { 61 + Enabled bool `yaml:"enabled"` 62 + Currency string `yaml:"currency,omitempty"` 63 + SuccessURL string `yaml:"success_url,omitempty"` 64 + CancelURL string `yaml:"cancel_url,omitempty"` 65 + } 66 + 67 + // LoadBillingConfig reads the hold config YAML and extracts billing fields. 68 + // Returns (nil, nil) if the file is missing or billing is not enabled. 69 + // Returns (nil, err) if the file exists with billing enabled but is misconfigured. 70 + func LoadBillingConfig(configPath string) (*BillingConfig, error) { 71 + if configPath == "" { 72 + return nil, nil 73 + } 74 + 75 + data, err := os.ReadFile(configPath) 76 + if err != nil { 77 + if os.IsNotExist(err) { 78 + return nil, nil 79 + } 80 + return nil, fmt.Errorf("failed to read config: %w", err) 81 + } 82 + 83 + return parseBillingConfig(data) 84 + } 85 + 86 + // parseBillingConfig extracts billing fields from hold config YAML bytes. 87 + // Returns (nil, nil) if billing is not enabled. 88 + // Returns (nil, err) if billing is enabled but misconfigured. 89 + func parseBillingConfig(data []byte) (*BillingConfig, error) { 90 + var ext extendedHoldConfig 91 + if err := yaml.Unmarshal(data, &ext); err != nil { 92 + return nil, fmt.Errorf("failed to parse config: %w", err) 93 + } 94 + 95 + if !ext.Quota.Billing.Enabled { 96 + return nil, nil 97 + } 98 + 99 + cfg := &BillingConfig{ 100 + Enabled: true, 101 + Currency: ext.Quota.Billing.Currency, 102 + SuccessURL: ext.Quota.Billing.SuccessURL, 103 + CancelURL: ext.Quota.Billing.CancelURL, 104 + PlankOwnerCrewTier: ext.Quota.Defaults.PlankOwnerCrewTier, 105 + Tiers: make(map[string]BillingTierConfig, len(ext.Quota.Tiers)), 106 + } 107 + 108 + for name, tier := range ext.Quota.Tiers { 109 + cfg.Tiers[name] = BillingTierConfig{ 110 + Description: tier.Description, 111 + StripePriceMonthly: tier.StripePriceMonthly, 112 + StripePriceYearly: tier.StripePriceYearly, 113 + } 114 + } 115 + 116 + // Validate: billing enabled but no tiers have any Stripe prices configured 117 + hasAnyPrice := false 118 + for _, tier := range cfg.Tiers { 119 + if tier.StripePriceMonthly != "" || tier.StripePriceYearly != "" { 120 + hasAnyPrice = true 121 + break 122 + } 123 + } 124 + if !hasAnyPrice { 125 + return nil, fmt.Errorf("billing is enabled but no tiers have Stripe prices configured") 126 + } 127 + 128 + return cfg, nil 129 + } 130 + 131 + // GetTierPricing returns billing info for a tier, or nil if not found. 132 + func (c *BillingConfig) GetTierPricing(tierKey string) *BillingTierConfig { 133 + if c == nil { 134 + return nil 135 + } 136 + t, ok := c.Tiers[tierKey] 137 + if !ok { 138 + return nil 139 + } 140 + return &t 141 + } 142 + 143 + // GetTierByPriceID finds the tier key that contains the given Stripe price ID. 144 + // Returns empty string if no match. 145 + func (c *BillingConfig) GetTierByPriceID(priceID string) string { 146 + if c == nil || priceID == "" { 147 + return "" 148 + } 149 + for key, tier := range c.Tiers { 150 + if tier.StripePriceMonthly == priceID || tier.StripePriceYearly == priceID { 151 + return key 152 + } 153 + } 154 + return "" 155 + } 156 + 157 + // ExampleHoldYAML generates a complete hold config example including billing fields. 158 + // It calls hold.ExampleYAML() for the base config, then injects billing-specific 159 + // fields into the YAML node tree before re-marshalling. 160 + func ExampleHoldYAML() ([]byte, error) { 161 + base, err := hold.ExampleYAML() 162 + if err != nil { 163 + return nil, fmt.Errorf("failed to generate base hold config: %w", err) 164 + } 165 + 166 + var doc yaml.Node 167 + if err := yaml.Unmarshal(base, &doc); err != nil { 168 + return nil, fmt.Errorf("failed to parse base hold config: %w", err) 169 + } 170 + 171 + // doc is DocumentNode -> Content[0] is the root MappingNode 172 + if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 { 173 + return nil, fmt.Errorf("unexpected YAML structure") 174 + } 175 + root := doc.Content[0] 176 + 177 + // Find the "quota" mapping inside root 178 + quotaNode := findMappingValue(root, "quota") 179 + if quotaNode == nil { 180 + return nil, fmt.Errorf("quota section not found in base config") 181 + } 182 + 183 + // Inject billing fields into tier entries 184 + tiersNode := findMappingValue(quotaNode, "tiers") 185 + if tiersNode != nil { 186 + injectTierBillingFields(tiersNode) 187 + } 188 + 189 + // Inject plankowner_crew_tier into defaults 190 + defaultsNode := findMappingValue(quotaNode, "defaults") 191 + if defaultsNode != nil { 192 + injectPlankOwnerDefault(defaultsNode) 193 + } 194 + 195 + // Inject billing section under quota 196 + injectBillingSection(quotaNode) 197 + 198 + return yaml.Marshal(&doc) 199 + } 200 + 201 + // findMappingValue finds a value node in a YAML mapping by key. 202 + func findMappingValue(mapping *yaml.Node, key string) *yaml.Node { 203 + if mapping.Kind != yaml.MappingNode { 204 + return nil 205 + } 206 + for i := 0; i < len(mapping.Content)-1; i += 2 { 207 + if mapping.Content[i].Value == key { 208 + return mapping.Content[i+1] 209 + } 210 + } 211 + return nil 212 + } 213 + 214 + // injectTierBillingFields adds description and stripe_price fields to each tier entry. 215 + func injectTierBillingFields(tiersNode *yaml.Node) { 216 + if tiersNode.Kind != yaml.MappingNode { 217 + return 218 + } 219 + 220 + examples := map[string]struct { 221 + description string 222 + monthly string 223 + yearly string 224 + }{ 225 + "bosun": {"Standard tier — recommended for most users.", "price_bosun_monthly_id", "price_bosun_yearly_id"}, 226 + "deckhand": {"Starter tier — free for new crew members.", "", ""}, 227 + "quartermaster": {"Professional tier — for power users and teams.", "price_qm_monthly_id", "price_qm_yearly_id"}, 228 + } 229 + 230 + for i := 0; i < len(tiersNode.Content)-1; i += 2 { 231 + tierKey := tiersNode.Content[i].Value 232 + tierVal := tiersNode.Content[i+1] 233 + if tierVal.Kind != yaml.MappingNode { 234 + continue 235 + } 236 + 237 + ex, ok := examples[tierKey] 238 + if !ok { 239 + continue 240 + } 241 + 242 + // Add description 243 + tierVal.Content = append(tierVal.Content, 244 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "description", HeadComment: "Human-readable tier description (used in billing UI)."}, 245 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ex.description}, 246 + ) 247 + 248 + // Add stripe prices if applicable 249 + if ex.monthly != "" { 250 + tierVal.Content = append(tierVal.Content, 251 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "stripe_price_monthly", HeadComment: "Stripe Price ID for monthly billing."}, 252 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ex.monthly}, 253 + ) 254 + } 255 + if ex.yearly != "" { 256 + tierVal.Content = append(tierVal.Content, 257 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "stripe_price_yearly", HeadComment: "Stripe Price ID for yearly billing (optional)."}, 258 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ex.yearly}, 259 + ) 260 + } 261 + } 262 + } 263 + 264 + // injectPlankOwnerDefault adds plankowner_crew_tier to the defaults section. 265 + func injectPlankOwnerDefault(defaultsNode *yaml.Node) { 266 + if defaultsNode.Kind != yaml.MappingNode { 267 + return 268 + } 269 + defaultsNode.Content = append(defaultsNode.Content, 270 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "plankowner_crew_tier", HeadComment: "Tier granted to early crew members (plankowners). Ignored by base hold service."}, 271 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "bosun"}, 272 + ) 273 + } 274 + 275 + // injectBillingSection adds the billing subsection under quota. 276 + func injectBillingSection(quotaNode *yaml.Node) { 277 + if quotaNode.Kind != yaml.MappingNode { 278 + return 279 + } 280 + 281 + billing := &yaml.Node{ 282 + Kind: yaml.MappingNode, 283 + Tag: "!!map", 284 + } 285 + billing.Content = append(billing.Content, 286 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "enabled"}, 287 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: "false"}, 288 + 289 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "currency", HeadComment: "ISO 4217 currency code for Stripe charges."}, 290 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "usd"}, 291 + 292 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "success_url", HeadComment: "Redirect URL after successful checkout. {hold_url} is replaced at runtime."}, 293 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "{hold_url}/billing/success"}, 294 + 295 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "cancel_url", HeadComment: "Redirect URL when checkout is cancelled."}, 296 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "{hold_url}/billing/cancel"}, 297 + ) 298 + 299 + quotaNode.Content = append(quotaNode.Content, 300 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "billing", HeadComment: "Stripe billing settings. Ignored by base hold service (seamark.dev only)."}, 301 + billing, 302 + ) 303 + }
+332
pkg/hold/billing/config_test.go
··· 1 + package billing 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + 8 + "go.yaml.in/yaml/v4" 9 + 10 + "atcr.io/pkg/hold/quota" 11 + ) 12 + 13 + // yamlUnmarshal is a thin wrapper to avoid shadowing the yaml package import. 14 + func yamlUnmarshal(data []byte, v any) error { 15 + return yaml.Unmarshal(data, v) 16 + } 17 + 18 + func TestParseBillingConfig_Disabled(t *testing.T) { 19 + yaml := []byte(` 20 + quota: 21 + tiers: 22 + deckhand: 23 + quota: 5GB 24 + billing: 25 + enabled: false 26 + `) 27 + cfg, err := parseBillingConfig(yaml) 28 + if err != nil { 29 + t.Fatalf("unexpected error: %v", err) 30 + } 31 + if cfg != nil { 32 + t.Error("expected nil config when billing disabled") 33 + } 34 + } 35 + 36 + func TestParseBillingConfig_NoBillingSection(t *testing.T) { 37 + yaml := []byte(` 38 + quota: 39 + tiers: 40 + deckhand: 41 + quota: 5GB 42 + `) 43 + cfg, err := parseBillingConfig(yaml) 44 + if err != nil { 45 + t.Fatalf("unexpected error: %v", err) 46 + } 47 + if cfg != nil { 48 + t.Error("expected nil config when no billing section") 49 + } 50 + } 51 + 52 + func TestParseBillingConfig_Enabled(t *testing.T) { 53 + yaml := []byte(` 54 + quota: 55 + tiers: 56 + deckhand: 57 + quota: 5GB 58 + description: Starter tier 59 + bosun: 60 + quota: 50GB 61 + description: Standard tier 62 + stripe_price_monthly: price_bosun_monthly 63 + stripe_price_yearly: price_bosun_yearly 64 + defaults: 65 + new_crew_tier: deckhand 66 + plankowner_crew_tier: bosun 67 + billing: 68 + enabled: true 69 + currency: usd 70 + success_url: "{hold_url}/billing/success" 71 + cancel_url: "{hold_url}/billing/cancel" 72 + `) 73 + cfg, err := parseBillingConfig(yaml) 74 + if err != nil { 75 + t.Fatalf("unexpected error: %v", err) 76 + } 77 + if cfg == nil { 78 + t.Fatal("expected non-nil config") 79 + } 80 + 81 + if !cfg.Enabled { 82 + t.Error("expected Enabled=true") 83 + } 84 + if cfg.Currency != "usd" { 85 + t.Errorf("expected currency 'usd', got %q", cfg.Currency) 86 + } 87 + if cfg.PlankOwnerCrewTier != "bosun" { 88 + t.Errorf("expected plankowner_crew_tier 'bosun', got %q", cfg.PlankOwnerCrewTier) 89 + } 90 + if cfg.SuccessURL != "{hold_url}/billing/success" { 91 + t.Errorf("unexpected success_url: %q", cfg.SuccessURL) 92 + } 93 + 94 + // Check tier pricing 95 + bosun := cfg.GetTierPricing("bosun") 96 + if bosun == nil { 97 + t.Fatal("expected bosun tier pricing") 98 + } 99 + if bosun.StripePriceMonthly != "price_bosun_monthly" { 100 + t.Errorf("expected bosun monthly price 'price_bosun_monthly', got %q", bosun.StripePriceMonthly) 101 + } 102 + if bosun.StripePriceYearly != "price_bosun_yearly" { 103 + t.Errorf("expected bosun yearly price 'price_bosun_yearly', got %q", bosun.StripePriceYearly) 104 + } 105 + if bosun.Description != "Standard tier" { 106 + t.Errorf("expected bosun description 'Standard tier', got %q", bosun.Description) 107 + } 108 + 109 + // Deckhand has no prices 110 + deckhand := cfg.GetTierPricing("deckhand") 111 + if deckhand == nil { 112 + t.Fatal("expected deckhand tier pricing entry") 113 + } 114 + if deckhand.StripePriceMonthly != "" { 115 + t.Error("expected no monthly price for deckhand") 116 + } 117 + } 118 + 119 + func TestParseBillingConfig_EnabledButNoPrices(t *testing.T) { 120 + yaml := []byte(` 121 + quota: 122 + tiers: 123 + deckhand: 124 + quota: 5GB 125 + billing: 126 + enabled: true 127 + currency: usd 128 + `) 129 + cfg, err := parseBillingConfig(yaml) 130 + if err == nil { 131 + t.Error("expected error when billing enabled but no prices configured") 132 + } 133 + if cfg != nil { 134 + t.Error("expected nil config on error") 135 + } 136 + } 137 + 138 + func TestGetTierByPriceID(t *testing.T) { 139 + cfg := &BillingConfig{ 140 + Tiers: map[string]BillingTierConfig{ 141 + "deckhand": {}, 142 + "bosun": {StripePriceMonthly: "price_m", StripePriceYearly: "price_y"}, 143 + }, 144 + } 145 + 146 + if got := cfg.GetTierByPriceID("price_m"); got != "bosun" { 147 + t.Errorf("expected 'bosun' for monthly price, got %q", got) 148 + } 149 + if got := cfg.GetTierByPriceID("price_y"); got != "bosun" { 150 + t.Errorf("expected 'bosun' for yearly price, got %q", got) 151 + } 152 + if got := cfg.GetTierByPriceID("price_unknown"); got != "" { 153 + t.Errorf("expected empty for unknown price, got %q", got) 154 + } 155 + if got := cfg.GetTierByPriceID(""); got != "" { 156 + t.Errorf("expected empty for empty price, got %q", got) 157 + } 158 + 159 + // nil receiver 160 + var nilCfg *BillingConfig 161 + if got := nilCfg.GetTierByPriceID("price_m"); got != "" { 162 + t.Errorf("expected empty from nil config, got %q", got) 163 + } 164 + } 165 + 166 + func TestGetTierPricing_NilConfig(t *testing.T) { 167 + var cfg *BillingConfig 168 + if cfg.GetTierPricing("anything") != nil { 169 + t.Error("expected nil from nil config") 170 + } 171 + } 172 + 173 + func TestLoadBillingConfig_MissingFile(t *testing.T) { 174 + cfg, err := LoadBillingConfig("/nonexistent/config.yaml") 175 + if err != nil { 176 + t.Fatalf("expected no error for missing file, got: %v", err) 177 + } 178 + if cfg != nil { 179 + t.Error("expected nil config for missing file") 180 + } 181 + } 182 + 183 + func TestLoadBillingConfig_EmptyPath(t *testing.T) { 184 + cfg, err := LoadBillingConfig("") 185 + if err != nil { 186 + t.Fatalf("unexpected error: %v", err) 187 + } 188 + if cfg != nil { 189 + t.Error("expected nil config for empty path") 190 + } 191 + } 192 + 193 + func TestLoadBillingConfig_FromFile(t *testing.T) { 194 + dir := t.TempDir() 195 + path := filepath.Join(dir, "config.yaml") 196 + 197 + content := ` 198 + quota: 199 + tiers: 200 + bosun: 201 + quota: 50GB 202 + stripe_price_monthly: price_test 203 + billing: 204 + enabled: true 205 + currency: usd 206 + ` 207 + if err := os.WriteFile(path, []byte(content), 0644); err != nil { 208 + t.Fatal(err) 209 + } 210 + 211 + cfg, err := LoadBillingConfig(path) 212 + if err != nil { 213 + t.Fatalf("unexpected error: %v", err) 214 + } 215 + if cfg == nil { 216 + t.Fatal("expected non-nil config") 217 + } 218 + if cfg.GetTierByPriceID("price_test") != "bosun" { 219 + t.Error("expected bosun tier for price_test") 220 + } 221 + } 222 + 223 + // holdQuotaWrapper mirrors the hold config structure just enough to extract 224 + // the quota section for testing. This avoids importing the full hold package. 225 + type holdQuotaWrapper struct { 226 + Quota quota.Config `yaml:"quota"` 227 + } 228 + 229 + // TestExampleHoldYAMLRoundTrip verifies that the generated example config 230 + // can be parsed by both atcr.io's quota parser and seamark.dev's billing parser. 231 + // This catches silent breakage if atcr.io renames or restructures the quota section. 232 + func TestExampleHoldYAMLRoundTrip(t *testing.T) { 233 + yamlBytes, err := ExampleHoldYAML() 234 + if err != nil { 235 + t.Fatalf("ExampleHoldYAML failed: %v", err) 236 + } 237 + 238 + // Verify atcr.io's quota parser can read the quota section. 239 + // The full hold config nests tiers under "quota:", so we parse with 240 + // a wrapper struct (same as hold.Config does) then use NewManagerFromConfig. 241 + var wrapper holdQuotaWrapper 242 + if err := yamlUnmarshal(yamlBytes, &wrapper); err != nil { 243 + t.Fatalf("failed to parse generated config for quota: %v", err) 244 + } 245 + 246 + quotaMgr, err := quota.NewManagerFromConfig(&wrapper.Quota) 247 + if err != nil { 248 + t.Fatalf("quota.NewManagerFromConfig failed: %v", err) 249 + } 250 + if !quotaMgr.IsEnabled() { 251 + t.Error("expected quotas to be enabled in generated config") 252 + } 253 + if quotaMgr.TierCount() != 3 { 254 + t.Errorf("expected 3 quota tiers, got %d", quotaMgr.TierCount()) 255 + } 256 + if quotaMgr.GetDefaultTier() != "deckhand" { 257 + t.Errorf("expected default tier 'deckhand', got %q", quotaMgr.GetDefaultTier()) 258 + } 259 + 260 + // The generated example has billing.enabled: false, so parseBillingConfig 261 + // returns nil. Enable it to verify the billing fields were injected correctly. 262 + // Use the full "billing:\n...enabled:" pattern to avoid replacing admin.enabled. 263 + enabledYAML := replaceOnce(string(yamlBytes), "billing:\n enabled: false", "billing:\n enabled: true") 264 + 265 + billingCfg, err := parseBillingConfig([]byte(enabledYAML)) 266 + if err != nil { 267 + t.Fatalf("parseBillingConfig failed on generated config: %v", err) 268 + } 269 + if billingCfg == nil { 270 + t.Fatal("expected non-nil billing config after enabling") 271 + } 272 + 273 + // Verify billing fields were injected into the YAML 274 + if billingCfg.Currency != "usd" { 275 + t.Errorf("expected currency 'usd', got %q", billingCfg.Currency) 276 + } 277 + if billingCfg.PlankOwnerCrewTier != "bosun" { 278 + t.Errorf("expected plankowner_crew_tier 'bosun', got %q", billingCfg.PlankOwnerCrewTier) 279 + } 280 + 281 + // Verify tier-level billing fields 282 + bosun := billingCfg.GetTierPricing("bosun") 283 + if bosun == nil { 284 + t.Fatal("expected bosun billing tier") 285 + } 286 + if bosun.StripePriceMonthly == "" { 287 + t.Error("expected bosun to have stripe_price_monthly") 288 + } 289 + if bosun.Description == "" { 290 + t.Error("expected bosun to have description") 291 + } 292 + 293 + qm := billingCfg.GetTierPricing("quartermaster") 294 + if qm == nil { 295 + t.Fatal("expected quartermaster billing tier") 296 + } 297 + if qm.StripePriceMonthly == "" { 298 + t.Error("expected quartermaster to have stripe_price_monthly") 299 + } 300 + 301 + // Deckhand is the free tier — no Stripe prices expected 302 + deckhand := billingCfg.GetTierPricing("deckhand") 303 + if deckhand == nil { 304 + t.Fatal("expected deckhand billing tier entry") 305 + } 306 + if deckhand.StripePriceMonthly != "" { 307 + t.Error("expected no stripe_price_monthly for deckhand") 308 + } 309 + 310 + // Verify the price ID reverse lookup works 311 + if billingCfg.GetTierByPriceID(bosun.StripePriceMonthly) != "bosun" { 312 + t.Error("GetTierByPriceID failed for bosun monthly price") 313 + } 314 + } 315 + 316 + // replaceOnce replaces the first occurrence of old with new in s. 317 + func replaceOnce(s, old, new string) string { 318 + i := indexOf(s, old) 319 + if i < 0 { 320 + return s 321 + } 322 + return s[:i] + new + s[i+len(old):] 323 + } 324 + 325 + func indexOf(s, substr string) int { 326 + for i := 0; i <= len(s)-len(substr); i++ { 327 + if s[i:i+len(substr)] == substr { 328 + return i 329 + } 330 + } 331 + return -1 332 + }
+222
pkg/hold/billing/handlers.go
··· 1 + //go:build billing 2 + 3 + package billing 4 + 5 + import ( 6 + "encoding/json" 7 + "log/slog" 8 + "net/http" 9 + 10 + "github.com/go-chi/chi/v5" 11 + 12 + "atcr.io/pkg/hold/pds" 13 + ) 14 + 15 + // XRPCHandler handles billing-related XRPC endpoints. 16 + type XRPCHandler struct { 17 + manager *Manager 18 + pdsServer *pds.HoldPDS 19 + httpClient *http.Client 20 + } 21 + 22 + // NewXRPCHandler creates a new billing XRPC handler. 23 + func NewXRPCHandler(manager *Manager, pdsServer *pds.HoldPDS, httpClient *http.Client) *XRPCHandler { 24 + return &XRPCHandler{ 25 + manager: manager, 26 + pdsServer: pdsServer, 27 + httpClient: httpClient, 28 + } 29 + } 30 + 31 + // RegisterHandlers registers billing XRPC endpoints on the router. 32 + func (m *Manager) RegisterHandlers(r chi.Router) { 33 + // This is a no-op for the Manager itself 34 + // Use NewXRPCHandler and call its RegisterHandlers method 35 + } 36 + 37 + // RegisterHandlers registers billing endpoints on the router. 38 + func (h *XRPCHandler) RegisterHandlers(r chi.Router) { 39 + if !h.manager.Enabled() { 40 + slog.Info("Billing endpoints disabled (not configured)") 41 + return 42 + } 43 + 44 + slog.Info("Registering billing XRPC endpoints") 45 + 46 + // Public endpoint - get subscription info (auth optional for tiers list) 47 + r.Get("/xrpc/io.atcr.hold.getSubscriptionInfo", h.HandleGetSubscriptionInfo) 48 + 49 + // Authenticated endpoints 50 + r.Group(func(r chi.Router) { 51 + r.Use(h.requireAuth) 52 + r.Post("/xrpc/io.atcr.hold.createCheckoutSession", h.HandleCreateCheckoutSession) 53 + r.Get("/xrpc/io.atcr.hold.getBillingPortalUrl", h.HandleGetBillingPortalURL) 54 + }) 55 + 56 + // Stripe webhook (authenticated by Stripe signature) 57 + r.Post("/xrpc/io.atcr.hold.stripeWebhook", h.HandleStripeWebhook) 58 + } 59 + 60 + // requireAuth is middleware that validates user authentication. 61 + func (h *XRPCHandler) requireAuth(next http.Handler) http.Handler { 62 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 63 + // Use the same auth validation as other hold endpoints 64 + user, err := pds.ValidateDPoPRequest(r, h.httpClient) 65 + if err != nil { 66 + // Try service token 67 + user, err = pds.ValidateServiceToken(r, h.pdsServer.DID(), h.httpClient) 68 + } 69 + if err != nil { 70 + respondError(w, http.StatusUnauthorized, "authentication required") 71 + return 72 + } 73 + 74 + // Store user DID in header for handlers 75 + r.Header.Set("X-User-DID", user.DID) 76 + next.ServeHTTP(w, r) 77 + }) 78 + } 79 + 80 + // HandleGetSubscriptionInfo returns subscription and quota information. 81 + // GET /xrpc/io.atcr.hold.getSubscriptionInfo?userDid=did:plc:xxx 82 + func (h *XRPCHandler) HandleGetSubscriptionInfo(w http.ResponseWriter, r *http.Request) { 83 + userDID := r.URL.Query().Get("userDid") 84 + 85 + // If no userDID provided, try to get from auth 86 + if userDID == "" { 87 + // Try to authenticate (optional) 88 + user, err := pds.ValidateDPoPRequest(r, h.httpClient) 89 + if err != nil { 90 + user, _ = pds.ValidateServiceToken(r, h.pdsServer.DID(), h.httpClient) 91 + } 92 + if user != nil { 93 + userDID = user.DID 94 + } 95 + } 96 + 97 + info, err := h.manager.GetSubscriptionInfo(userDID) 98 + if err != nil { 99 + if err == ErrBillingDisabled { 100 + // Return basic info with payments disabled 101 + respondJSON(w, http.StatusOK, &SubscriptionInfo{ 102 + UserDID: userDID, 103 + PaymentsEnabled: false, 104 + Tiers: h.manager.buildTierList(userDID), 105 + }) 106 + return 107 + } 108 + respondError(w, http.StatusInternalServerError, err.Error()) 109 + return 110 + } 111 + 112 + // Get current usage and crew tier from PDS quota stats 113 + if userDID != "" { 114 + stats, err := h.pdsServer.GetQuotaForUserWithTier(r.Context(), userDID, h.manager.quotaMgr) 115 + if err == nil { 116 + info.CurrentUsage = stats.TotalSize 117 + info.CrewTier = stats.Tier // tier from local crew record (what's actually enforced) 118 + info.CurrentLimit = stats.Limit 119 + 120 + // If no subscription but crew has a tier, show that as current 121 + if info.SubscriptionID == "" && info.CrewTier != "" { 122 + info.CurrentTier = info.CrewTier 123 + } 124 + } 125 + } 126 + 127 + // Mark which tier is actually current (use crew tier if available, otherwise subscription tier) 128 + effectiveTier := info.CurrentTier 129 + if info.CrewTier != "" { 130 + effectiveTier = info.CrewTier 131 + } 132 + for i := range info.Tiers { 133 + info.Tiers[i].IsCurrent = info.Tiers[i].ID == effectiveTier 134 + } 135 + 136 + respondJSON(w, http.StatusOK, info) 137 + } 138 + 139 + // HandleCreateCheckoutSession creates a Stripe checkout session. 140 + // POST /xrpc/io.atcr.hold.createCheckoutSession 141 + func (h *XRPCHandler) HandleCreateCheckoutSession(w http.ResponseWriter, r *http.Request) { 142 + var req CheckoutSessionRequest 143 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 144 + respondError(w, http.StatusBadRequest, "invalid request body") 145 + return 146 + } 147 + 148 + if req.Tier == "" { 149 + respondError(w, http.StatusBadRequest, "tier is required") 150 + return 151 + } 152 + 153 + resp, err := h.manager.CreateCheckoutSession(r, &req) 154 + if err != nil { 155 + slog.Error("Failed to create checkout session", "error", err) 156 + respondError(w, http.StatusInternalServerError, err.Error()) 157 + return 158 + } 159 + 160 + respondJSON(w, http.StatusOK, resp) 161 + } 162 + 163 + // HandleGetBillingPortalURL returns a URL to the Stripe billing portal. 164 + // GET /xrpc/io.atcr.hold.getBillingPortalUrl?returnUrl=https://... 165 + func (h *XRPCHandler) HandleGetBillingPortalURL(w http.ResponseWriter, r *http.Request) { 166 + userDID := r.Header.Get("X-User-DID") 167 + returnURL := r.URL.Query().Get("returnUrl") 168 + 169 + resp, err := h.manager.GetBillingPortalURL(userDID, returnURL) 170 + if err != nil { 171 + slog.Error("Failed to get billing portal URL", "error", err, "userDid", userDID) 172 + respondError(w, http.StatusInternalServerError, err.Error()) 173 + return 174 + } 175 + 176 + respondJSON(w, http.StatusOK, resp) 177 + } 178 + 179 + // HandleStripeWebhook processes Stripe webhook events. 180 + // POST /xrpc/io.atcr.hold.stripeWebhook 181 + func (h *XRPCHandler) HandleStripeWebhook(w http.ResponseWriter, r *http.Request) { 182 + event, err := h.manager.HandleWebhook(r) 183 + if err != nil { 184 + slog.Error("Failed to process webhook", "error", err) 185 + respondError(w, http.StatusBadRequest, err.Error()) 186 + return 187 + } 188 + 189 + // If we have a tier update, apply it to the crew record 190 + if event.UserDID != "" && event.NewTier != "" { 191 + if err := h.pdsServer.UpdateCrewMemberTier(r.Context(), event.UserDID, event.NewTier); err != nil { 192 + slog.Error("Failed to update crew tier", "error", err, "userDid", event.UserDID, "tier", event.NewTier) 193 + // Don't fail the webhook - Stripe will retry 194 + } else { 195 + slog.Info("Updated crew tier from subscription", 196 + "userDid", event.UserDID, 197 + "tier", event.NewTier, 198 + "event", event.Type, 199 + ) 200 + } 201 + 202 + // Invalidate customer cache since subscription changed 203 + h.manager.InvalidateCustomerCache(event.UserDID) 204 + } 205 + 206 + // Return 200 to acknowledge receipt 207 + respondJSON(w, http.StatusOK, map[string]string{"received": "true"}) 208 + } 209 + 210 + // respondJSON writes a JSON response. 211 + func respondJSON(w http.ResponseWriter, status int, v any) { 212 + w.Header().Set("Content-Type", "application/json") 213 + w.WriteHeader(status) 214 + if err := json.NewEncoder(w).Encode(v); err != nil { 215 + slog.Error("Failed to encode JSON response", "error", err) 216 + } 217 + } 218 + 219 + // respondError writes a JSON error response. 220 + func respondError(w http.ResponseWriter, status int, message string) { 221 + respondJSON(w, status, map[string]string{"error": message}) 222 + }
+65
pkg/hold/billing/types.go
··· 1 + // Package billing provides optional Stripe billing integration for hold services. 2 + // This package uses build tags to conditionally compile Stripe support. 3 + // Build with -tags billing to enable Stripe integration. 4 + package billing 5 + 6 + import "errors" 7 + 8 + // ErrBillingDisabled is returned when billing operations are attempted 9 + // but billing is not enabled (either not compiled in or disabled at runtime). 10 + var ErrBillingDisabled = errors.New("billing not enabled") 11 + 12 + // SubscriptionInfo contains subscription and quota information for a user. 13 + type SubscriptionInfo struct { 14 + UserDID string `json:"userDid"` 15 + CurrentTier string `json:"currentTier"` // tier from Stripe subscription (or default) 16 + CrewTier string `json:"crewTier,omitempty"` // tier from local crew record (what's actually enforced) 17 + CurrentUsage int64 `json:"currentUsage"` // bytes used 18 + CurrentLimit *int64 `json:"currentLimit,omitempty"` // nil = unlimited 19 + PaymentsEnabled bool `json:"paymentsEnabled"` // whether online payments are available 20 + Tiers []TierInfo `json:"tiers"` // available tiers 21 + SubscriptionID string `json:"subscriptionId,omitempty"` // Stripe subscription ID if active 22 + CustomerID string `json:"customerId,omitempty"` // Stripe customer ID if exists 23 + BillingInterval string `json:"billingInterval,omitempty"` // "monthly" or "yearly" 24 + } 25 + 26 + // TierInfo describes a single tier available for subscription. 27 + type TierInfo struct { 28 + ID string `json:"id"` // tier key (e.g., "deckhand", "bosun") 29 + Name string `json:"name"` // display name (same as ID if not specified) 30 + Description string `json:"description,omitempty"` // human-readable description 31 + QuotaBytes int64 `json:"quotaBytes"` // quota limit in bytes 32 + QuotaFormatted string `json:"quotaFormatted"` // human-readable quota (e.g., "5 GB") 33 + PriceCentsMonthly int `json:"priceCentsMonthly,omitempty"` // monthly price in cents (0 = free) 34 + PriceCentsYearly int `json:"priceCentsYearly,omitempty"` // yearly price in cents (0 = not available) 35 + IsCurrent bool `json:"isCurrent,omitempty"` // whether this is user's current tier 36 + } 37 + 38 + // CheckoutSessionRequest is the request to create a Stripe checkout session. 39 + type CheckoutSessionRequest struct { 40 + Tier string `json:"tier"` // tier to subscribe to 41 + Interval string `json:"interval,omitempty"` // "monthly" or "yearly" (default: monthly) 42 + ReturnURL string `json:"returnUrl,omitempty"` // URL to return to after checkout 43 + } 44 + 45 + // CheckoutSessionResponse is the response with the Stripe checkout URL. 46 + type CheckoutSessionResponse struct { 47 + CheckoutURL string `json:"checkoutUrl"` 48 + SessionID string `json:"sessionId"` 49 + } 50 + 51 + // BillingPortalResponse is the response with the Stripe billing portal URL. 52 + type BillingPortalResponse struct { 53 + PortalURL string `json:"portalUrl"` 54 + } 55 + 56 + // WebhookEvent represents a processed Stripe webhook event. 57 + type WebhookEvent struct { 58 + Type string `json:"type"` // e.g., "customer.subscription.updated" 59 + CustomerID string `json:"customerId"` // Stripe customer ID 60 + UserDID string `json:"userDid"` // user's DID from customer metadata 61 + SubscriptionID string `json:"subscriptionId,omitempty"` // Stripe subscription ID 62 + PriceID string `json:"priceId,omitempty"` // Stripe price ID 63 + NewTier string `json:"newTier,omitempty"` // resolved tier name 64 + Status string `json:"status,omitempty"` // subscription status 65 + }