# 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