From 5d3b6c204750ed74dde5931bcbfaa93645017d1d Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Tue, 3 Feb 2026 20:51:52 -0600 Subject: [PATCH] begin billing --- docs/BILLING.md | 238 ++++++++++++++ go.mod | 1 + go.sum | 4 + pkg/hold/billing/billing.go | 549 +++++++++++++++++++++++++++++++ pkg/hold/billing/billing_stub.go | 60 ++++ pkg/hold/billing/config.go | 303 +++++++++++++++++ pkg/hold/billing/config_test.go | 332 +++++++++++++++++++ pkg/hold/billing/handlers.go | 222 +++++++++++++ pkg/hold/billing/types.go | 65 ++++ 9 files changed, 1774 insertions(+) create mode 100644 docs/BILLING.md create mode 100644 pkg/hold/billing/billing.go create mode 100644 pkg/hold/billing/billing_stub.go create mode 100644 pkg/hold/billing/config.go create mode 100644 pkg/hold/billing/config_test.go create mode 100644 pkg/hold/billing/handlers.go create mode 100644 pkg/hold/billing/types.go diff --git a/docs/BILLING.md b/docs/BILLING.md new file mode 100644 index 0000000..7a3e598 --- /dev/null +++ b/docs/BILLING.md @@ -0,0 +1,238 @@ +# Hold Service Billing Integration + +Optional Stripe billing integration for hold services. Allows hold operators to charge for storage tiers via subscriptions. + +## Overview + +- **Compile-time optional**: Build with `-tags billing` to enable Stripe support +- **Hold owns billing**: Each hold operator has their own Stripe account +- **AppView aggregates UI**: Fetches subscription info from holds, displays in settings +- **Customer-DID mapping**: DIDs stored in Stripe customer metadata (no extra database) + +## Architecture + +``` +User → AppView Settings UI → Hold XRPC endpoints → Stripe + ↓ + Stripe webhook → Hold → Update crew tier +``` + +## Building with Billing Support + +```bash +# Without billing (default) +go build ./cmd/hold + +# With billing +go build -tags billing ./cmd/hold + +# Docker with billing +docker build --build-arg BILLING_ENABLED=true -f Dockerfile.hold . +``` + +## Configuration + +### Environment Variables + +```bash +# Required for billing +STRIPE_SECRET_KEY=sk_live_xxx # or sk_test_xxx for testing +STRIPE_WEBHOOK_SECRET=whsec_xxx # from Stripe Dashboard or CLI + +# Optional +STRIPE_PUBLISHABLE_KEY=pk_live_xxx # for client-side (not currently used) +``` + +### quotas.yaml + +```yaml +tiers: + swabbie: + quota: 2GB + description: "Starter storage" + # No stripe_price = free tier + + deckhand: + quota: 5GB + description: "Standard storage" + stripe_price_yearly: price_xxx # Price ID from Stripe + + bosun: + quota: 10GB + description: "Mid-level storage" + stripe_price_monthly: price_xxx + stripe_price_yearly: price_xxx + +defaults: + new_crew_tier: swabbie + plankowner_crew_tier: deckhand # Early adopters get this free + +billing: + enabled: true + currency: usd + success_url: "{hold_url}/billing/success" + cancel_url: "{hold_url}/billing/cancel" +``` + +### Stripe Price IDs + +Use **Price IDs** (`price_xxx`), not Product IDs (`prod_xxx`). + +To find Price IDs: +1. Stripe Dashboard → Products → Select product +2. Look at Pricing section +3. Copy the Price ID + +Or via API: +```bash +curl https://api.stripe.com/v1/prices?product=prod_xxx \ + -u sk_test_xxx: +``` + +## XRPC Endpoints + +| Endpoint | Auth | Description | +|----------|------|-------------| +| `GET /xrpc/io.atcr.hold.getSubscriptionInfo` | Optional | Get tiers and user's current subscription | +| `POST /xrpc/io.atcr.hold.createCheckoutSession` | Required | Create Stripe checkout URL | +| `GET /xrpc/io.atcr.hold.getBillingPortalUrl` | Required | Get Stripe billing portal URL | +| `POST /xrpc/io.atcr.hold.stripeWebhook` | Stripe sig | Handle subscription events | + +## Local Development + +### Stripe CLI Setup + +The Stripe CLI forwards webhooks to localhost: + +```bash +# Install +brew install stripe/stripe-cli/stripe +# Or: https://stripe.com/docs/stripe-cli + +# Login +stripe login + +# Forward webhooks to local hold +stripe listen --forward-to localhost:8080/xrpc/io.atcr.hold.stripeWebhook +``` + +The CLI outputs a webhook signing secret: +``` +Ready! Your webhook signing secret is whsec_xxxxxxxxxxxxx +``` + +Use that as `STRIPE_WEBHOOK_SECRET` for local dev. + +### Running Locally + +```bash +# Terminal 1: Run hold with billing +export STRIPE_SECRET_KEY=sk_test_xxx +export STRIPE_WEBHOOK_SECRET=whsec_xxx # from 'stripe listen' +export HOLD_PUBLIC_URL=http://localhost:8080 +export STORAGE_DRIVER=filesystem +export HOLD_DATABASE_DIR=/tmp/hold-test +go run -tags billing ./cmd/hold + +# Terminal 2: Forward webhooks +stripe listen --forward-to localhost:8080/xrpc/io.atcr.hold.stripeWebhook + +# Terminal 3: Trigger test events +stripe trigger checkout.session.completed +stripe trigger customer.subscription.created +stripe trigger customer.subscription.updated +stripe trigger customer.subscription.paused +stripe trigger customer.subscription.resumed +stripe trigger customer.subscription.deleted +``` + +### Testing the Flow + +1. Start hold with billing enabled +2. Start Stripe CLI webhook forwarding +3. Navigate to AppView settings page +4. Click "Upgrade" on a tier +5. Complete Stripe checkout (use test card `4242 4242 4242 4242`) +6. Webhook fires → hold updates crew tier +7. Refresh settings to see new tier + +## Webhook Events + +The hold handles these Stripe events: + +| Event | Action | +|-------|--------| +| `checkout.session.completed` | Create/update subscription, set tier | +| `customer.subscription.created` | Set crew tier from price ID | +| `customer.subscription.updated` | Update crew tier if price changed | +| `customer.subscription.paused` | Downgrade to free tier | +| `customer.subscription.resumed` | Restore tier from subscription price | +| `customer.subscription.deleted` | Downgrade to free tier | +| `invoice.payment_failed` | Log warning (tier unchanged until canceled) | + +## Plankowners (Grandfathering) + +Early adopters can be marked as "plankowners" to get a paid tier for free: + +```json +{ + "$type": "io.atcr.hold.crew", + "member": "did:plc:xxx", + "tier": "deckhand", + "plankowner": true, + "permissions": ["blob:read", "blob:write"], + "addedAt": "2025-01-01T00:00:00Z" +} +``` + +Plankowners: +- Get `plankowner_crew_tier` (e.g., deckhand) without paying +- Still see upgrade options in UI if they want to support +- Can upgrade to higher tiers normally + +## Customer-DID Mapping + +DIDs are stored in Stripe customer metadata: + +```json +{ + "metadata": { + "user_did": "did:plc:xxx", + "hold_did": "did:web:hold.example.com" + } +} +``` + +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. + +## Production Checklist + +- [ ] Create Stripe products and prices in live mode +- [ ] Set `STRIPE_SECRET_KEY` to live key (`sk_live_xxx`) +- [ ] Configure webhook endpoint in Stripe Dashboard: + - URL: `https://your-hold.com/xrpc/io.atcr.hold.stripeWebhook` + - Events: `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.paused`, `customer.subscription.resumed`, `customer.subscription.deleted`, `invoice.payment_failed` +- [ ] Set `STRIPE_WEBHOOK_SECRET` from Dashboard webhook settings +- [ ] Update `quotas.yaml` with live price IDs +- [ ] Build hold with `-tags billing` +- [ ] Test with a real payment (can refund immediately) + +## Troubleshooting + +### Webhook signature verification failed +- Ensure `STRIPE_WEBHOOK_SECRET` matches the webhook endpoint in Stripe Dashboard +- For local dev, use the secret from `stripe listen` output + +### Customer not found +- Customer is created on first checkout +- Check Stripe Dashboard → Customers for the DID in metadata + +### Tier not updating after payment +- Check hold logs for webhook processing errors +- Verify price ID in `quotas.yaml` matches Stripe +- Ensure `billing.enabled: true` in config + +### "Billing not enabled" error +- Build with `-tags billing` +- Set `billing.enabled: true` in `quotas.yaml` +- Ensure `STRIPE_SECRET_KEY` is set diff --git a/go.mod b/go.mod index 827da37..ff4377b 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef github.com/stretchr/testify v1.11.1 + github.com/stripe/stripe-go/v84 v84.3.0 github.com/whyrusleeping/cbor-gen v0.3.1 github.com/yuin/goldmark v1.7.16 go.opentelemetry.io/otel v1.40.0 diff --git a/go.sum b/go.sum index 493c73c..f6d4474 100644 --- a/go.sum +++ b/go.sum @@ -403,6 +403,10 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stripe/stripe-go/v84 v84.1.0 h1:9KW8Fm3csWsPNqBJCgdEZBM9pRNaqpESHIw+eXp8A0k= +github.com/stripe/stripe-go/v84 v84.1.0/go.mod h1:kjXh3OrF4PT16qz7z9Q5yqYAZ1mJmu8g8f4Z1sOHBfc= +github.com/stripe/stripe-go/v84 v84.3.0 h1:77HH+ro7yzmyyF7Xkbkj6y5QtnU1WWHC6t2y4mq0Wvk= +github.com/stripe/stripe-go/v84 v84.3.0/go.mod h1:Z4gcKw1zl4geDG2+cjpSaJES9jaohGX6n7FP8/kHIqw= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= diff --git a/pkg/hold/billing/billing.go b/pkg/hold/billing/billing.go new file mode 100644 index 0000000..432dd23 --- /dev/null +++ b/pkg/hold/billing/billing.go @@ -0,0 +1,549 @@ +//go:build billing + +package billing + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "sort" + "strings" + "sync" + "time" + + "github.com/stripe/stripe-go/v84" + portalsession "github.com/stripe/stripe-go/v84/billingportal/session" + "github.com/stripe/stripe-go/v84/checkout/session" + "github.com/stripe/stripe-go/v84/customer" + "github.com/stripe/stripe-go/v84/price" + "github.com/stripe/stripe-go/v84/subscription" + "github.com/stripe/stripe-go/v84/webhook" + + "atcr.io/pkg/hold/quota" +) + +// Manager handles Stripe billing integration. +type Manager struct { + quotaMgr *quota.Manager + billingCfg *BillingConfig + holdPublicURL string + stripeKey string + webhookSecret string + publishableKey string + + // In-memory cache for customer lookups (DID -> customer) + customerCache map[string]*cachedCustomer + customerCacheMu sync.RWMutex +} + +type cachedCustomer struct { + customer *stripe.Customer + expiresAt time.Time +} + +const customerCacheTTL = 10 * time.Minute + +// New creates a new billing manager with Stripe integration. +func New(quotaMgr *quota.Manager, holdPublicURL string) *Manager { + stripeKey := os.Getenv("STRIPE_SECRET_KEY") + if stripeKey != "" { + stripe.Key = stripeKey + } + + return &Manager{ + quotaMgr: quotaMgr, + holdPublicURL: holdPublicURL, + stripeKey: stripeKey, + webhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), + publishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"), + customerCache: make(map[string]*cachedCustomer), + } +} + +// Enabled returns true if billing is properly configured. +func (m *Manager) Enabled() bool { + return m.billingCfg != nil && m.billingCfg.Enabled && m.stripeKey != "" +} + +// GetSubscriptionInfo returns subscription and quota information for a user. +func (m *Manager) GetSubscriptionInfo(userDID string) (*SubscriptionInfo, error) { + if !m.Enabled() { + return nil, ErrBillingDisabled + } + + info := &SubscriptionInfo{ + UserDID: userDID, + PaymentsEnabled: true, + Tiers: m.buildTierList(userDID), + } + + // Try to find existing customer + cust, err := m.findCustomerByDID(userDID) + if err != nil { + slog.Debug("No Stripe customer found for user", "userDid", userDID) + } else if cust != nil { + info.CustomerID = cust.ID + + // Get active subscription if any (check all nil pointers) + if cust.Subscriptions != nil && len(cust.Subscriptions.Data) > 0 { + sub := cust.Subscriptions.Data[0] + info.SubscriptionID = sub.ID + + // Safely access subscription items + if sub.Items != nil && len(sub.Items.Data) > 0 && sub.Items.Data[0].Price != nil { + info.CurrentTier = m.billingCfg.GetTierByPriceID(sub.Items.Data[0].Price.ID) + + if sub.Items.Data[0].Price.Recurring != nil { + switch sub.Items.Data[0].Price.Recurring.Interval { + case stripe.PriceRecurringIntervalMonth: + info.BillingInterval = "monthly" + case stripe.PriceRecurringIntervalYear: + info.BillingInterval = "yearly" + } + } + } + } + } + + // If no subscription, use default tier + if info.CurrentTier == "" { + info.CurrentTier = m.quotaMgr.GetDefaultTier() + } + + // Get quota limit for current tier + limit := m.quotaMgr.GetTierLimit(info.CurrentTier) + info.CurrentLimit = limit + + // Mark current tier in tier list + for i := range info.Tiers { + if info.Tiers[i].ID == info.CurrentTier { + info.Tiers[i].IsCurrent = true + } + } + + return info, nil +} + +// buildTierList creates the list of available tiers by merging quota limits +// from the quota manager with billing metadata from the billing config. +func (m *Manager) buildTierList(userDID string) []TierInfo { + quotaTiers := m.quotaMgr.ListTiers() + if len(quotaTiers) == 0 { + return nil + } + + result := make([]TierInfo, 0, len(quotaTiers)) + for _, qt := range quotaTiers { + var quotaBytes int64 + if qt.Limit != nil { + quotaBytes = *qt.Limit + } + + // Capitalize tier ID for display name (e.g., "swabbie" -> "Swabbie") + name := strings.ToUpper(qt.Key[:1]) + qt.Key[1:] + + tier := TierInfo{ + ID: qt.Key, + Name: name, + QuotaBytes: quotaBytes, + QuotaFormatted: quota.FormatHumanBytes(quotaBytes), + } + + // Merge billing metadata if available + if bt := m.billingCfg.GetTierPricing(qt.Key); bt != nil { + tier.Description = bt.Description + + // Fetch actual prices from Stripe + if bt.StripePriceMonthly != "" { + if p, err := price.Get(bt.StripePriceMonthly, nil); err == nil && p != nil { + tier.PriceCentsMonthly = int(p.UnitAmount) + } else { + slog.Debug("Failed to fetch monthly price", "priceId", bt.StripePriceMonthly, "error", err) + tier.PriceCentsMonthly = -1 + } + } + if bt.StripePriceYearly != "" { + if p, err := price.Get(bt.StripePriceYearly, nil); err == nil && p != nil { + tier.PriceCentsYearly = int(p.UnitAmount) + } else { + slog.Debug("Failed to fetch yearly price", "priceId", bt.StripePriceYearly, "error", err) + tier.PriceCentsYearly = -1 + } + } + } + + result = append(result, tier) + } + + // Sort tiers by quota size (ascending) + sort.Slice(result, func(i, j int) bool { + return result[i].QuotaBytes < result[j].QuotaBytes + }) + + return result +} + +// CreateCheckoutSession creates a Stripe checkout session for subscription. +func (m *Manager) CreateCheckoutSession(r *http.Request, req *CheckoutSessionRequest) (*CheckoutSessionResponse, error) { + if !m.Enabled() { + return nil, ErrBillingDisabled + } + + // Get user DID from request context (set by auth middleware) + userDID := r.Header.Get("X-User-DID") + if userDID == "" { + return nil, errors.New("user not authenticated") + } + + // Get tier config + tierCfg := m.billingCfg.GetTierPricing(req.Tier) + if tierCfg == nil { + return nil, fmt.Errorf("tier not found: %s", req.Tier) + } + + // Determine price ID - prefer requested interval, fall back to what's available + var priceID string + switch req.Interval { + case "monthly": + priceID = tierCfg.StripePriceMonthly + case "yearly": + priceID = tierCfg.StripePriceYearly + default: + // No interval specified - prefer monthly, fall back to yearly + if tierCfg.StripePriceMonthly != "" { + priceID = tierCfg.StripePriceMonthly + } else { + priceID = tierCfg.StripePriceYearly + } + } + + if priceID == "" { + return nil, fmt.Errorf("tier %s has no Stripe price configured", req.Tier) + } + + // Get or create customer + cust, err := m.getOrCreateCustomer(userDID) + if err != nil { + return nil, fmt.Errorf("failed to get/create customer: %w", err) + } + + // Build success/cancel URLs + successURL := strings.ReplaceAll(m.billingCfg.SuccessURL, "{hold_url}", m.holdPublicURL) + cancelURL := strings.ReplaceAll(m.billingCfg.CancelURL, "{hold_url}", m.holdPublicURL) + + if req.ReturnURL != "" { + successURL = req.ReturnURL + "?success=true" + cancelURL = req.ReturnURL + "?cancelled=true" + } + + // Create checkout session + params := &stripe.CheckoutSessionParams{ + Customer: stripe.String(cust.ID), + Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), + LineItems: []*stripe.CheckoutSessionLineItemParams{ + { + Price: stripe.String(priceID), + Quantity: stripe.Int64(1), + }, + }, + SuccessURL: stripe.String(successURL), + CancelURL: stripe.String(cancelURL), + } + + sess, err := session.New(params) + if err != nil { + return nil, fmt.Errorf("failed to create checkout session: %w", err) + } + + return &CheckoutSessionResponse{ + CheckoutURL: sess.URL, + SessionID: sess.ID, + }, nil +} + +// GetBillingPortalURL returns a URL to the Stripe billing portal. +func (m *Manager) GetBillingPortalURL(userDID string, returnURL string) (*BillingPortalResponse, error) { + if !m.Enabled() { + return nil, ErrBillingDisabled + } + + // Find existing customer + cust, err := m.findCustomerByDID(userDID) + if err != nil || cust == nil { + return nil, errors.New("no billing account found") + } + + if returnURL == "" { + returnURL = m.holdPublicURL + } + + params := &stripe.BillingPortalSessionParams{ + Customer: stripe.String(cust.ID), + ReturnURL: stripe.String(returnURL), + } + + sess, err := portalsession.New(params) + if err != nil { + return nil, fmt.Errorf("failed to create portal session: %w", err) + } + + return &BillingPortalResponse{ + PortalURL: sess.URL, + }, nil +} + +// HandleWebhook processes a Stripe webhook event. +func (m *Manager) HandleWebhook(r *http.Request) (*WebhookEvent, error) { + if !m.Enabled() { + return nil, ErrBillingDisabled + } + + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, fmt.Errorf("failed to read request body: %w", err) + } + + // Verify webhook signature + event, err := webhook.ConstructEvent(body, r.Header.Get("Stripe-Signature"), m.webhookSecret) + if err != nil { + return nil, fmt.Errorf("failed to verify webhook signature: %w", err) + } + + result := &WebhookEvent{ + Type: string(event.Type), + } + + switch event.Type { + case "checkout.session.completed": + var sess stripe.CheckoutSession + if err := json.Unmarshal(event.Data.Raw, &sess); err != nil { + return nil, fmt.Errorf("failed to parse checkout session: %w", err) + } + + result.CustomerID = sess.Customer.ID + result.SubscriptionID = sess.Subscription.ID + result.Status = "active" + + // Fetch customer to get DID from metadata + result.UserDID = m.getCustomerDID(sess.Customer.ID) + + // Get subscription to find the price/tier + if sess.Subscription != nil && sess.Subscription.ID != "" { + if sub, err := m.getSubscription(sess.Subscription.ID); err == nil && sub != nil { + if len(sub.Items.Data) > 0 { + result.PriceID = sub.Items.Data[0].Price.ID + result.NewTier = m.billingCfg.GetTierByPriceID(result.PriceID) + } + } + } + + if result.UserDID != "" && result.NewTier != "" { + slog.Info("Checkout completed", + "userDid", result.UserDID, + "tier", result.NewTier, + "subscriptionId", result.SubscriptionID, + ) + } + + case "customer.subscription.created", "customer.subscription.updated": + var sub stripe.Subscription + if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { + return nil, fmt.Errorf("failed to parse subscription: %w", err) + } + + result.SubscriptionID = sub.ID + result.CustomerID = sub.Customer.ID + result.Status = string(sub.Status) + + if len(sub.Items.Data) > 0 { + result.PriceID = sub.Items.Data[0].Price.ID + result.NewTier = m.billingCfg.GetTierByPriceID(result.PriceID) + } + + // Fetch customer to get DID from metadata (webhook doesn't include expanded customer) + result.UserDID = m.getCustomerDID(sub.Customer.ID) + + // If we have user DID and new tier, this signals that crew tier should be updated + if result.UserDID != "" && result.NewTier != "" && sub.Status == stripe.SubscriptionStatusActive { + slog.Info("Subscription activated", + "userDid", result.UserDID, + "tier", result.NewTier, + "subscriptionId", result.SubscriptionID, + ) + } + + case "customer.subscription.deleted", "customer.subscription.paused": + var sub stripe.Subscription + if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { + return nil, fmt.Errorf("failed to parse subscription: %w", err) + } + + result.SubscriptionID = sub.ID + result.CustomerID = sub.Customer.ID + if event.Type == "customer.subscription.deleted" { + result.Status = "cancelled" + } else { + result.Status = "paused" + } + + // Fetch customer to get DID from metadata + result.UserDID = m.getCustomerDID(sub.Customer.ID) + + // Set tier to default (downgrade on cancellation/pause) + result.NewTier = m.quotaMgr.GetDefaultTier() + + if result.UserDID != "" { + slog.Info("Subscription inactive, downgrading to default tier", + "userDid", result.UserDID, + "tier", result.NewTier, + "status", result.Status, + ) + } + + case "customer.subscription.resumed": + var sub stripe.Subscription + if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { + return nil, fmt.Errorf("failed to parse subscription: %w", err) + } + + result.SubscriptionID = sub.ID + result.CustomerID = sub.Customer.ID + result.Status = "active" + + if len(sub.Items.Data) > 0 { + result.PriceID = sub.Items.Data[0].Price.ID + result.NewTier = m.billingCfg.GetTierByPriceID(result.PriceID) + } + + // Fetch customer to get DID from metadata + result.UserDID = m.getCustomerDID(sub.Customer.ID) + + if result.UserDID != "" && result.NewTier != "" { + slog.Info("Subscription resumed, restoring tier", + "userDid", result.UserDID, + "tier", result.NewTier, + ) + } + } + + return result, nil +} + +// getOrCreateCustomer finds or creates a Stripe customer for the given DID. +func (m *Manager) getOrCreateCustomer(userDID string) (*stripe.Customer, error) { + // Check cache first + m.customerCacheMu.RLock() + if cached, ok := m.customerCache[userDID]; ok && time.Now().Before(cached.expiresAt) { + m.customerCacheMu.RUnlock() + return cached.customer, nil + } + m.customerCacheMu.RUnlock() + + // Try to find existing customer + cust, err := m.findCustomerByDID(userDID) + if err == nil && cust != nil { + m.cacheCustomer(userDID, cust) + return cust, nil + } + + // Create new customer + params := &stripe.CustomerParams{ + Metadata: map[string]string{ + "user_did": userDID, + "hold_did": m.holdPublicURL, // Not actually a DID but useful for tracking + }, + } + + cust, err = customer.New(params) + if err != nil { + return nil, fmt.Errorf("failed to create customer: %w", err) + } + + m.cacheCustomer(userDID, cust) + return cust, nil +} + +// findCustomerByDID searches Stripe for a customer with the given DID in metadata. +func (m *Manager) findCustomerByDID(userDID string) (*stripe.Customer, error) { + // Check cache first + m.customerCacheMu.RLock() + if cached, ok := m.customerCache[userDID]; ok && time.Now().Before(cached.expiresAt) { + m.customerCacheMu.RUnlock() + return cached.customer, nil + } + m.customerCacheMu.RUnlock() + + // Search Stripe by metadata + params := &stripe.CustomerSearchParams{ + SearchParams: stripe.SearchParams{ + Query: fmt.Sprintf("metadata['user_did']:'%s'", userDID), + }, + } + params.AddExpand("data.subscriptions") + + iter := customer.Search(params) + if iter.Next() { + cust := iter.Customer() + m.cacheCustomer(userDID, cust) + return cust, nil + } + + if err := iter.Err(); err != nil { + return nil, err + } + + return nil, nil // Not found +} + +// cacheCustomer adds a customer to the in-memory cache. +func (m *Manager) cacheCustomer(userDID string, cust *stripe.Customer) { + m.customerCacheMu.Lock() + defer m.customerCacheMu.Unlock() + + m.customerCache[userDID] = &cachedCustomer{ + customer: cust, + expiresAt: time.Now().Add(customerCacheTTL), + } +} + +// InvalidateCustomerCache removes a customer from the cache. +func (m *Manager) InvalidateCustomerCache(userDID string) { + m.customerCacheMu.Lock() + defer m.customerCacheMu.Unlock() + + delete(m.customerCache, userDID) +} + +// getCustomerDID fetches a customer by ID and returns the user_did from metadata. +func (m *Manager) getCustomerDID(customerID string) string { + if customerID == "" { + return "" + } + + cust, err := customer.Get(customerID, nil) + if err != nil { + slog.Debug("Failed to fetch customer", "customerId", customerID, "error", err) + return "" + } + + if cust.Metadata != nil { + return cust.Metadata["user_did"] + } + return "" +} + +// getSubscription fetches a subscription by ID. +func (m *Manager) getSubscription(subscriptionID string) (*stripe.Subscription, error) { + if subscriptionID == "" { + return nil, nil + } + + params := &stripe.SubscriptionParams{} + params.AddExpand("items.data.price") + + return subscription.Get(subscriptionID, params) +} diff --git a/pkg/hold/billing/billing_stub.go b/pkg/hold/billing/billing_stub.go new file mode 100644 index 0000000..4820893 --- /dev/null +++ b/pkg/hold/billing/billing_stub.go @@ -0,0 +1,60 @@ +//go:build !billing + +package billing + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + + "atcr.io/pkg/hold/pds" + "atcr.io/pkg/hold/quota" +) + +// Manager is a no-op billing manager when billing is not compiled in. +type Manager struct{} + +// New creates a new no-op billing manager. +// This is used when the billing build tag is not set. +func New(_ *quota.Manager, _ string) *Manager { + return &Manager{} +} + +// Enabled returns false when billing is not compiled in. +func (m *Manager) Enabled() bool { + return false +} + +// RegisterHandlers is a no-op when billing is not compiled in. +func (m *Manager) RegisterHandlers(_ chi.Router) {} + +// GetSubscriptionInfo returns an error when billing is not compiled in. +func (m *Manager) GetSubscriptionInfo(_ string) (*SubscriptionInfo, error) { + return nil, ErrBillingDisabled +} + +// CreateCheckoutSession returns an error when billing is not compiled in. +func (m *Manager) CreateCheckoutSession(_ *http.Request, _ *CheckoutSessionRequest) (*CheckoutSessionResponse, error) { + return nil, ErrBillingDisabled +} + +// GetBillingPortalURL returns an error when billing is not compiled in. +func (m *Manager) GetBillingPortalURL(_ string, _ string) (*BillingPortalResponse, error) { + return nil, ErrBillingDisabled +} + +// HandleWebhook returns an error when billing is not compiled in. +func (m *Manager) HandleWebhook(_ *http.Request) (*WebhookEvent, error) { + return nil, ErrBillingDisabled +} + +// XRPCHandler is a no-op handler when billing is not compiled in. +type XRPCHandler struct{} + +// NewXRPCHandler creates a new no-op XRPC handler. +func NewXRPCHandler(_ *Manager, _ *pds.HoldPDS, _ *http.Client) *XRPCHandler { + return &XRPCHandler{} +} + +// RegisterHandlers is a no-op when billing is not compiled in. +func (h *XRPCHandler) RegisterHandlers(_ chi.Router) {} diff --git a/pkg/hold/billing/config.go b/pkg/hold/billing/config.go new file mode 100644 index 0000000..d95fc9b --- /dev/null +++ b/pkg/hold/billing/config.go @@ -0,0 +1,303 @@ +//go:build billing + +package billing + +import ( + "fmt" + "os" + + "go.yaml.in/yaml/v4" + + "atcr.io/pkg/hold" +) + +// BillingConfig holds billing/Stripe settings parsed from the hold config YAML. +// The billing fields live in the same YAML file as the hold config, but are +// ignored by atcr.io's parser (Go YAML ignores unknown fields by default). +type BillingConfig struct { + Enabled bool + Currency string + SuccessURL string + CancelURL string + + // Tier-level billing info keyed by tier name (same keys as quota tiers). + Tiers map[string]BillingTierConfig + + // Tier assigned to plankowner crew members. + PlankOwnerCrewTier string +} + +// BillingTierConfig holds Stripe pricing for a single tier. +type BillingTierConfig struct { + Description string + StripePriceMonthly string + StripePriceYearly string +} + +// --- internal YAML structs for parsing the extended hold config --- + +// extendedHoldConfig mirrors the hold config but only the quota section. +type extendedHoldConfig struct { + Quota extendedQuotaConfig `yaml:"quota"` +} + +type extendedQuotaConfig struct { + Tiers map[string]extendedTierConfig `yaml:"tiers"` + Defaults extendedDefaults `yaml:"defaults"` + Billing rawBillingConfig `yaml:"billing"` +} + +type extendedTierConfig struct { + Description string `yaml:"description,omitempty"` + StripePriceMonthly string `yaml:"stripe_price_monthly,omitempty"` + StripePriceYearly string `yaml:"stripe_price_yearly,omitempty"` +} + +type extendedDefaults struct { + PlankOwnerCrewTier string `yaml:"plankowner_crew_tier,omitempty"` +} + +type rawBillingConfig struct { + Enabled bool `yaml:"enabled"` + Currency string `yaml:"currency,omitempty"` + SuccessURL string `yaml:"success_url,omitempty"` + CancelURL string `yaml:"cancel_url,omitempty"` +} + +// LoadBillingConfig reads the hold config YAML and extracts billing fields. +// Returns (nil, nil) if the file is missing or billing is not enabled. +// Returns (nil, err) if the file exists with billing enabled but is misconfigured. +func LoadBillingConfig(configPath string) (*BillingConfig, error) { + if configPath == "" { + return nil, nil + } + + data, err := os.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read config: %w", err) + } + + return parseBillingConfig(data) +} + +// parseBillingConfig extracts billing fields from hold config YAML bytes. +// Returns (nil, nil) if billing is not enabled. +// Returns (nil, err) if billing is enabled but misconfigured. +func parseBillingConfig(data []byte) (*BillingConfig, error) { + var ext extendedHoldConfig + if err := yaml.Unmarshal(data, &ext); err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + + if !ext.Quota.Billing.Enabled { + return nil, nil + } + + cfg := &BillingConfig{ + Enabled: true, + Currency: ext.Quota.Billing.Currency, + SuccessURL: ext.Quota.Billing.SuccessURL, + CancelURL: ext.Quota.Billing.CancelURL, + PlankOwnerCrewTier: ext.Quota.Defaults.PlankOwnerCrewTier, + Tiers: make(map[string]BillingTierConfig, len(ext.Quota.Tiers)), + } + + for name, tier := range ext.Quota.Tiers { + cfg.Tiers[name] = BillingTierConfig{ + Description: tier.Description, + StripePriceMonthly: tier.StripePriceMonthly, + StripePriceYearly: tier.StripePriceYearly, + } + } + + // Validate: billing enabled but no tiers have any Stripe prices configured + hasAnyPrice := false + for _, tier := range cfg.Tiers { + if tier.StripePriceMonthly != "" || tier.StripePriceYearly != "" { + hasAnyPrice = true + break + } + } + if !hasAnyPrice { + return nil, fmt.Errorf("billing is enabled but no tiers have Stripe prices configured") + } + + return cfg, nil +} + +// GetTierPricing returns billing info for a tier, or nil if not found. +func (c *BillingConfig) GetTierPricing(tierKey string) *BillingTierConfig { + if c == nil { + return nil + } + t, ok := c.Tiers[tierKey] + if !ok { + return nil + } + return &t +} + +// GetTierByPriceID finds the tier key that contains the given Stripe price ID. +// Returns empty string if no match. +func (c *BillingConfig) GetTierByPriceID(priceID string) string { + if c == nil || priceID == "" { + return "" + } + for key, tier := range c.Tiers { + if tier.StripePriceMonthly == priceID || tier.StripePriceYearly == priceID { + return key + } + } + return "" +} + +// ExampleHoldYAML generates a complete hold config example including billing fields. +// It calls hold.ExampleYAML() for the base config, then injects billing-specific +// fields into the YAML node tree before re-marshalling. +func ExampleHoldYAML() ([]byte, error) { + base, err := hold.ExampleYAML() + if err != nil { + return nil, fmt.Errorf("failed to generate base hold config: %w", err) + } + + var doc yaml.Node + if err := yaml.Unmarshal(base, &doc); err != nil { + return nil, fmt.Errorf("failed to parse base hold config: %w", err) + } + + // doc is DocumentNode -> Content[0] is the root MappingNode + if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 { + return nil, fmt.Errorf("unexpected YAML structure") + } + root := doc.Content[0] + + // Find the "quota" mapping inside root + quotaNode := findMappingValue(root, "quota") + if quotaNode == nil { + return nil, fmt.Errorf("quota section not found in base config") + } + + // Inject billing fields into tier entries + tiersNode := findMappingValue(quotaNode, "tiers") + if tiersNode != nil { + injectTierBillingFields(tiersNode) + } + + // Inject plankowner_crew_tier into defaults + defaultsNode := findMappingValue(quotaNode, "defaults") + if defaultsNode != nil { + injectPlankOwnerDefault(defaultsNode) + } + + // Inject billing section under quota + injectBillingSection(quotaNode) + + return yaml.Marshal(&doc) +} + +// findMappingValue finds a value node in a YAML mapping by key. +func findMappingValue(mapping *yaml.Node, key string) *yaml.Node { + if mapping.Kind != yaml.MappingNode { + return nil + } + for i := 0; i < len(mapping.Content)-1; i += 2 { + if mapping.Content[i].Value == key { + return mapping.Content[i+1] + } + } + return nil +} + +// injectTierBillingFields adds description and stripe_price fields to each tier entry. +func injectTierBillingFields(tiersNode *yaml.Node) { + if tiersNode.Kind != yaml.MappingNode { + return + } + + examples := map[string]struct { + description string + monthly string + yearly string + }{ + "bosun": {"Standard tier — recommended for most users.", "price_bosun_monthly_id", "price_bosun_yearly_id"}, + "deckhand": {"Starter tier — free for new crew members.", "", ""}, + "quartermaster": {"Professional tier — for power users and teams.", "price_qm_monthly_id", "price_qm_yearly_id"}, + } + + for i := 0; i < len(tiersNode.Content)-1; i += 2 { + tierKey := tiersNode.Content[i].Value + tierVal := tiersNode.Content[i+1] + if tierVal.Kind != yaml.MappingNode { + continue + } + + ex, ok := examples[tierKey] + if !ok { + continue + } + + // Add description + tierVal.Content = append(tierVal.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "description", HeadComment: "Human-readable tier description (used in billing UI)."}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ex.description}, + ) + + // Add stripe prices if applicable + if ex.monthly != "" { + tierVal.Content = append(tierVal.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "stripe_price_monthly", HeadComment: "Stripe Price ID for monthly billing."}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ex.monthly}, + ) + } + if ex.yearly != "" { + tierVal.Content = append(tierVal.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "stripe_price_yearly", HeadComment: "Stripe Price ID for yearly billing (optional)."}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ex.yearly}, + ) + } + } +} + +// injectPlankOwnerDefault adds plankowner_crew_tier to the defaults section. +func injectPlankOwnerDefault(defaultsNode *yaml.Node) { + if defaultsNode.Kind != yaml.MappingNode { + return + } + defaultsNode.Content = append(defaultsNode.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "plankowner_crew_tier", HeadComment: "Tier granted to early crew members (plankowners). Ignored by base hold service."}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "bosun"}, + ) +} + +// injectBillingSection adds the billing subsection under quota. +func injectBillingSection(quotaNode *yaml.Node) { + if quotaNode.Kind != yaml.MappingNode { + return + } + + billing := &yaml.Node{ + Kind: yaml.MappingNode, + Tag: "!!map", + } + billing.Content = append(billing.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "enabled"}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: "false"}, + + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "currency", HeadComment: "ISO 4217 currency code for Stripe charges."}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "usd"}, + + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "success_url", HeadComment: "Redirect URL after successful checkout. {hold_url} is replaced at runtime."}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "{hold_url}/billing/success"}, + + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "cancel_url", HeadComment: "Redirect URL when checkout is cancelled."}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "{hold_url}/billing/cancel"}, + ) + + quotaNode.Content = append(quotaNode.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "billing", HeadComment: "Stripe billing settings. Ignored by base hold service (seamark.dev only)."}, + billing, + ) +} diff --git a/pkg/hold/billing/config_test.go b/pkg/hold/billing/config_test.go new file mode 100644 index 0000000..6fedfab --- /dev/null +++ b/pkg/hold/billing/config_test.go @@ -0,0 +1,332 @@ +package billing + +import ( + "os" + "path/filepath" + "testing" + + "go.yaml.in/yaml/v4" + + "atcr.io/pkg/hold/quota" +) + +// yamlUnmarshal is a thin wrapper to avoid shadowing the yaml package import. +func yamlUnmarshal(data []byte, v any) error { + return yaml.Unmarshal(data, v) +} + +func TestParseBillingConfig_Disabled(t *testing.T) { + yaml := []byte(` +quota: + tiers: + deckhand: + quota: 5GB + billing: + enabled: false +`) + cfg, err := parseBillingConfig(yaml) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg != nil { + t.Error("expected nil config when billing disabled") + } +} + +func TestParseBillingConfig_NoBillingSection(t *testing.T) { + yaml := []byte(` +quota: + tiers: + deckhand: + quota: 5GB +`) + cfg, err := parseBillingConfig(yaml) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg != nil { + t.Error("expected nil config when no billing section") + } +} + +func TestParseBillingConfig_Enabled(t *testing.T) { + yaml := []byte(` +quota: + tiers: + deckhand: + quota: 5GB + description: Starter tier + bosun: + quota: 50GB + description: Standard tier + stripe_price_monthly: price_bosun_monthly + stripe_price_yearly: price_bosun_yearly + defaults: + new_crew_tier: deckhand + plankowner_crew_tier: bosun + billing: + enabled: true + currency: usd + success_url: "{hold_url}/billing/success" + cancel_url: "{hold_url}/billing/cancel" +`) + cfg, err := parseBillingConfig(yaml) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg == nil { + t.Fatal("expected non-nil config") + } + + if !cfg.Enabled { + t.Error("expected Enabled=true") + } + if cfg.Currency != "usd" { + t.Errorf("expected currency 'usd', got %q", cfg.Currency) + } + if cfg.PlankOwnerCrewTier != "bosun" { + t.Errorf("expected plankowner_crew_tier 'bosun', got %q", cfg.PlankOwnerCrewTier) + } + if cfg.SuccessURL != "{hold_url}/billing/success" { + t.Errorf("unexpected success_url: %q", cfg.SuccessURL) + } + + // Check tier pricing + bosun := cfg.GetTierPricing("bosun") + if bosun == nil { + t.Fatal("expected bosun tier pricing") + } + if bosun.StripePriceMonthly != "price_bosun_monthly" { + t.Errorf("expected bosun monthly price 'price_bosun_monthly', got %q", bosun.StripePriceMonthly) + } + if bosun.StripePriceYearly != "price_bosun_yearly" { + t.Errorf("expected bosun yearly price 'price_bosun_yearly', got %q", bosun.StripePriceYearly) + } + if bosun.Description != "Standard tier" { + t.Errorf("expected bosun description 'Standard tier', got %q", bosun.Description) + } + + // Deckhand has no prices + deckhand := cfg.GetTierPricing("deckhand") + if deckhand == nil { + t.Fatal("expected deckhand tier pricing entry") + } + if deckhand.StripePriceMonthly != "" { + t.Error("expected no monthly price for deckhand") + } +} + +func TestParseBillingConfig_EnabledButNoPrices(t *testing.T) { + yaml := []byte(` +quota: + tiers: + deckhand: + quota: 5GB + billing: + enabled: true + currency: usd +`) + cfg, err := parseBillingConfig(yaml) + if err == nil { + t.Error("expected error when billing enabled but no prices configured") + } + if cfg != nil { + t.Error("expected nil config on error") + } +} + +func TestGetTierByPriceID(t *testing.T) { + cfg := &BillingConfig{ + Tiers: map[string]BillingTierConfig{ + "deckhand": {}, + "bosun": {StripePriceMonthly: "price_m", StripePriceYearly: "price_y"}, + }, + } + + if got := cfg.GetTierByPriceID("price_m"); got != "bosun" { + t.Errorf("expected 'bosun' for monthly price, got %q", got) + } + if got := cfg.GetTierByPriceID("price_y"); got != "bosun" { + t.Errorf("expected 'bosun' for yearly price, got %q", got) + } + if got := cfg.GetTierByPriceID("price_unknown"); got != "" { + t.Errorf("expected empty for unknown price, got %q", got) + } + if got := cfg.GetTierByPriceID(""); got != "" { + t.Errorf("expected empty for empty price, got %q", got) + } + + // nil receiver + var nilCfg *BillingConfig + if got := nilCfg.GetTierByPriceID("price_m"); got != "" { + t.Errorf("expected empty from nil config, got %q", got) + } +} + +func TestGetTierPricing_NilConfig(t *testing.T) { + var cfg *BillingConfig + if cfg.GetTierPricing("anything") != nil { + t.Error("expected nil from nil config") + } +} + +func TestLoadBillingConfig_MissingFile(t *testing.T) { + cfg, err := LoadBillingConfig("/nonexistent/config.yaml") + if err != nil { + t.Fatalf("expected no error for missing file, got: %v", err) + } + if cfg != nil { + t.Error("expected nil config for missing file") + } +} + +func TestLoadBillingConfig_EmptyPath(t *testing.T) { + cfg, err := LoadBillingConfig("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg != nil { + t.Error("expected nil config for empty path") + } +} + +func TestLoadBillingConfig_FromFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + content := ` +quota: + tiers: + bosun: + quota: 50GB + stripe_price_monthly: price_test + billing: + enabled: true + currency: usd +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := LoadBillingConfig(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg == nil { + t.Fatal("expected non-nil config") + } + if cfg.GetTierByPriceID("price_test") != "bosun" { + t.Error("expected bosun tier for price_test") + } +} + +// holdQuotaWrapper mirrors the hold config structure just enough to extract +// the quota section for testing. This avoids importing the full hold package. +type holdQuotaWrapper struct { + Quota quota.Config `yaml:"quota"` +} + +// TestExampleHoldYAMLRoundTrip verifies that the generated example config +// can be parsed by both atcr.io's quota parser and seamark.dev's billing parser. +// This catches silent breakage if atcr.io renames or restructures the quota section. +func TestExampleHoldYAMLRoundTrip(t *testing.T) { + yamlBytes, err := ExampleHoldYAML() + if err != nil { + t.Fatalf("ExampleHoldYAML failed: %v", err) + } + + // Verify atcr.io's quota parser can read the quota section. + // The full hold config nests tiers under "quota:", so we parse with + // a wrapper struct (same as hold.Config does) then use NewManagerFromConfig. + var wrapper holdQuotaWrapper + if err := yamlUnmarshal(yamlBytes, &wrapper); err != nil { + t.Fatalf("failed to parse generated config for quota: %v", err) + } + + quotaMgr, err := quota.NewManagerFromConfig(&wrapper.Quota) + if err != nil { + t.Fatalf("quota.NewManagerFromConfig failed: %v", err) + } + if !quotaMgr.IsEnabled() { + t.Error("expected quotas to be enabled in generated config") + } + if quotaMgr.TierCount() != 3 { + t.Errorf("expected 3 quota tiers, got %d", quotaMgr.TierCount()) + } + if quotaMgr.GetDefaultTier() != "deckhand" { + t.Errorf("expected default tier 'deckhand', got %q", quotaMgr.GetDefaultTier()) + } + + // The generated example has billing.enabled: false, so parseBillingConfig + // returns nil. Enable it to verify the billing fields were injected correctly. + // Use the full "billing:\n...enabled:" pattern to avoid replacing admin.enabled. + enabledYAML := replaceOnce(string(yamlBytes), "billing:\n enabled: false", "billing:\n enabled: true") + + billingCfg, err := parseBillingConfig([]byte(enabledYAML)) + if err != nil { + t.Fatalf("parseBillingConfig failed on generated config: %v", err) + } + if billingCfg == nil { + t.Fatal("expected non-nil billing config after enabling") + } + + // Verify billing fields were injected into the YAML + if billingCfg.Currency != "usd" { + t.Errorf("expected currency 'usd', got %q", billingCfg.Currency) + } + if billingCfg.PlankOwnerCrewTier != "bosun" { + t.Errorf("expected plankowner_crew_tier 'bosun', got %q", billingCfg.PlankOwnerCrewTier) + } + + // Verify tier-level billing fields + bosun := billingCfg.GetTierPricing("bosun") + if bosun == nil { + t.Fatal("expected bosun billing tier") + } + if bosun.StripePriceMonthly == "" { + t.Error("expected bosun to have stripe_price_monthly") + } + if bosun.Description == "" { + t.Error("expected bosun to have description") + } + + qm := billingCfg.GetTierPricing("quartermaster") + if qm == nil { + t.Fatal("expected quartermaster billing tier") + } + if qm.StripePriceMonthly == "" { + t.Error("expected quartermaster to have stripe_price_monthly") + } + + // Deckhand is the free tier — no Stripe prices expected + deckhand := billingCfg.GetTierPricing("deckhand") + if deckhand == nil { + t.Fatal("expected deckhand billing tier entry") + } + if deckhand.StripePriceMonthly != "" { + t.Error("expected no stripe_price_monthly for deckhand") + } + + // Verify the price ID reverse lookup works + if billingCfg.GetTierByPriceID(bosun.StripePriceMonthly) != "bosun" { + t.Error("GetTierByPriceID failed for bosun monthly price") + } +} + +// replaceOnce replaces the first occurrence of old with new in s. +func replaceOnce(s, old, new string) string { + i := indexOf(s, old) + if i < 0 { + return s + } + return s[:i] + new + s[i+len(old):] +} + +func indexOf(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} diff --git a/pkg/hold/billing/handlers.go b/pkg/hold/billing/handlers.go new file mode 100644 index 0000000..551cea4 --- /dev/null +++ b/pkg/hold/billing/handlers.go @@ -0,0 +1,222 @@ +//go:build billing + +package billing + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + + "atcr.io/pkg/hold/pds" +) + +// XRPCHandler handles billing-related XRPC endpoints. +type XRPCHandler struct { + manager *Manager + pdsServer *pds.HoldPDS + httpClient *http.Client +} + +// NewXRPCHandler creates a new billing XRPC handler. +func NewXRPCHandler(manager *Manager, pdsServer *pds.HoldPDS, httpClient *http.Client) *XRPCHandler { + return &XRPCHandler{ + manager: manager, + pdsServer: pdsServer, + httpClient: httpClient, + } +} + +// RegisterHandlers registers billing XRPC endpoints on the router. +func (m *Manager) RegisterHandlers(r chi.Router) { + // This is a no-op for the Manager itself + // Use NewXRPCHandler and call its RegisterHandlers method +} + +// RegisterHandlers registers billing endpoints on the router. +func (h *XRPCHandler) RegisterHandlers(r chi.Router) { + if !h.manager.Enabled() { + slog.Info("Billing endpoints disabled (not configured)") + return + } + + slog.Info("Registering billing XRPC endpoints") + + // Public endpoint - get subscription info (auth optional for tiers list) + r.Get("/xrpc/io.atcr.hold.getSubscriptionInfo", h.HandleGetSubscriptionInfo) + + // Authenticated endpoints + r.Group(func(r chi.Router) { + r.Use(h.requireAuth) + r.Post("/xrpc/io.atcr.hold.createCheckoutSession", h.HandleCreateCheckoutSession) + r.Get("/xrpc/io.atcr.hold.getBillingPortalUrl", h.HandleGetBillingPortalURL) + }) + + // Stripe webhook (authenticated by Stripe signature) + r.Post("/xrpc/io.atcr.hold.stripeWebhook", h.HandleStripeWebhook) +} + +// requireAuth is middleware that validates user authentication. +func (h *XRPCHandler) requireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Use the same auth validation as other hold endpoints + user, err := pds.ValidateDPoPRequest(r, h.httpClient) + if err != nil { + // Try service token + user, err = pds.ValidateServiceToken(r, h.pdsServer.DID(), h.httpClient) + } + if err != nil { + respondError(w, http.StatusUnauthorized, "authentication required") + return + } + + // Store user DID in header for handlers + r.Header.Set("X-User-DID", user.DID) + next.ServeHTTP(w, r) + }) +} + +// HandleGetSubscriptionInfo returns subscription and quota information. +// GET /xrpc/io.atcr.hold.getSubscriptionInfo?userDid=did:plc:xxx +func (h *XRPCHandler) HandleGetSubscriptionInfo(w http.ResponseWriter, r *http.Request) { + userDID := r.URL.Query().Get("userDid") + + // If no userDID provided, try to get from auth + if userDID == "" { + // Try to authenticate (optional) + user, err := pds.ValidateDPoPRequest(r, h.httpClient) + if err != nil { + user, _ = pds.ValidateServiceToken(r, h.pdsServer.DID(), h.httpClient) + } + if user != nil { + userDID = user.DID + } + } + + info, err := h.manager.GetSubscriptionInfo(userDID) + if err != nil { + if err == ErrBillingDisabled { + // Return basic info with payments disabled + respondJSON(w, http.StatusOK, &SubscriptionInfo{ + UserDID: userDID, + PaymentsEnabled: false, + Tiers: h.manager.buildTierList(userDID), + }) + return + } + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + + // Get current usage and crew tier from PDS quota stats + if userDID != "" { + stats, err := h.pdsServer.GetQuotaForUserWithTier(r.Context(), userDID, h.manager.quotaMgr) + if err == nil { + info.CurrentUsage = stats.TotalSize + info.CrewTier = stats.Tier // tier from local crew record (what's actually enforced) + info.CurrentLimit = stats.Limit + + // If no subscription but crew has a tier, show that as current + if info.SubscriptionID == "" && info.CrewTier != "" { + info.CurrentTier = info.CrewTier + } + } + } + + // Mark which tier is actually current (use crew tier if available, otherwise subscription tier) + effectiveTier := info.CurrentTier + if info.CrewTier != "" { + effectiveTier = info.CrewTier + } + for i := range info.Tiers { + info.Tiers[i].IsCurrent = info.Tiers[i].ID == effectiveTier + } + + respondJSON(w, http.StatusOK, info) +} + +// HandleCreateCheckoutSession creates a Stripe checkout session. +// POST /xrpc/io.atcr.hold.createCheckoutSession +func (h *XRPCHandler) HandleCreateCheckoutSession(w http.ResponseWriter, r *http.Request) { + var req CheckoutSessionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.Tier == "" { + respondError(w, http.StatusBadRequest, "tier is required") + return + } + + resp, err := h.manager.CreateCheckoutSession(r, &req) + if err != nil { + slog.Error("Failed to create checkout session", "error", err) + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + + respondJSON(w, http.StatusOK, resp) +} + +// HandleGetBillingPortalURL returns a URL to the Stripe billing portal. +// GET /xrpc/io.atcr.hold.getBillingPortalUrl?returnUrl=https://... +func (h *XRPCHandler) HandleGetBillingPortalURL(w http.ResponseWriter, r *http.Request) { + userDID := r.Header.Get("X-User-DID") + returnURL := r.URL.Query().Get("returnUrl") + + resp, err := h.manager.GetBillingPortalURL(userDID, returnURL) + if err != nil { + slog.Error("Failed to get billing portal URL", "error", err, "userDid", userDID) + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + + respondJSON(w, http.StatusOK, resp) +} + +// HandleStripeWebhook processes Stripe webhook events. +// POST /xrpc/io.atcr.hold.stripeWebhook +func (h *XRPCHandler) HandleStripeWebhook(w http.ResponseWriter, r *http.Request) { + event, err := h.manager.HandleWebhook(r) + if err != nil { + slog.Error("Failed to process webhook", "error", err) + respondError(w, http.StatusBadRequest, err.Error()) + return + } + + // If we have a tier update, apply it to the crew record + if event.UserDID != "" && event.NewTier != "" { + if err := h.pdsServer.UpdateCrewMemberTier(r.Context(), event.UserDID, event.NewTier); err != nil { + slog.Error("Failed to update crew tier", "error", err, "userDid", event.UserDID, "tier", event.NewTier) + // Don't fail the webhook - Stripe will retry + } else { + slog.Info("Updated crew tier from subscription", + "userDid", event.UserDID, + "tier", event.NewTier, + "event", event.Type, + ) + } + + // Invalidate customer cache since subscription changed + h.manager.InvalidateCustomerCache(event.UserDID) + } + + // Return 200 to acknowledge receipt + respondJSON(w, http.StatusOK, map[string]string{"received": "true"}) +} + +// respondJSON writes a JSON response. +func respondJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(v); err != nil { + slog.Error("Failed to encode JSON response", "error", err) + } +} + +// respondError writes a JSON error response. +func respondError(w http.ResponseWriter, status int, message string) { + respondJSON(w, status, map[string]string{"error": message}) +} diff --git a/pkg/hold/billing/types.go b/pkg/hold/billing/types.go new file mode 100644 index 0000000..7536e31 --- /dev/null +++ b/pkg/hold/billing/types.go @@ -0,0 +1,65 @@ +// Package billing provides optional Stripe billing integration for hold services. +// This package uses build tags to conditionally compile Stripe support. +// Build with -tags billing to enable Stripe integration. +package billing + +import "errors" + +// ErrBillingDisabled is returned when billing operations are attempted +// but billing is not enabled (either not compiled in or disabled at runtime). +var ErrBillingDisabled = errors.New("billing not enabled") + +// SubscriptionInfo contains subscription and quota information for a user. +type SubscriptionInfo struct { + UserDID string `json:"userDid"` + CurrentTier string `json:"currentTier"` // tier from Stripe subscription (or default) + CrewTier string `json:"crewTier,omitempty"` // tier from local crew record (what's actually enforced) + CurrentUsage int64 `json:"currentUsage"` // bytes used + CurrentLimit *int64 `json:"currentLimit,omitempty"` // nil = unlimited + PaymentsEnabled bool `json:"paymentsEnabled"` // whether online payments are available + Tiers []TierInfo `json:"tiers"` // available tiers + SubscriptionID string `json:"subscriptionId,omitempty"` // Stripe subscription ID if active + CustomerID string `json:"customerId,omitempty"` // Stripe customer ID if exists + BillingInterval string `json:"billingInterval,omitempty"` // "monthly" or "yearly" +} + +// TierInfo describes a single tier available for subscription. +type TierInfo struct { + ID string `json:"id"` // tier key (e.g., "deckhand", "bosun") + Name string `json:"name"` // display name (same as ID if not specified) + Description string `json:"description,omitempty"` // human-readable description + QuotaBytes int64 `json:"quotaBytes"` // quota limit in bytes + QuotaFormatted string `json:"quotaFormatted"` // human-readable quota (e.g., "5 GB") + PriceCentsMonthly int `json:"priceCentsMonthly,omitempty"` // monthly price in cents (0 = free) + PriceCentsYearly int `json:"priceCentsYearly,omitempty"` // yearly price in cents (0 = not available) + IsCurrent bool `json:"isCurrent,omitempty"` // whether this is user's current tier +} + +// CheckoutSessionRequest is the request to create a Stripe checkout session. +type CheckoutSessionRequest struct { + Tier string `json:"tier"` // tier to subscribe to + Interval string `json:"interval,omitempty"` // "monthly" or "yearly" (default: monthly) + ReturnURL string `json:"returnUrl,omitempty"` // URL to return to after checkout +} + +// CheckoutSessionResponse is the response with the Stripe checkout URL. +type CheckoutSessionResponse struct { + CheckoutURL string `json:"checkoutUrl"` + SessionID string `json:"sessionId"` +} + +// BillingPortalResponse is the response with the Stripe billing portal URL. +type BillingPortalResponse struct { + PortalURL string `json:"portalUrl"` +} + +// WebhookEvent represents a processed Stripe webhook event. +type WebhookEvent struct { + Type string `json:"type"` // e.g., "customer.subscription.updated" + CustomerID string `json:"customerId"` // Stripe customer ID + UserDID string `json:"userDid"` // user's DID from customer metadata + SubscriptionID string `json:"subscriptionId,omitempty"` // Stripe subscription ID + PriceID string `json:"priceId,omitempty"` // Stripe price ID + NewTier string `json:"newTier,omitempty"` // resolved tier name + Status string `json:"status,omitempty"` // subscription status +}