A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
80
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 + }