# 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: ``` ## HTTP Endpoints All billing routes live on the **appview** and only register when built with `-tags billing`. | Route | Auth | Description | |-------|------|-------------| | `POST /api/stripe/webhook` | Stripe signature | Handle subscription lifecycle events from Stripe | | `GET /settings/subscription/checkout` | OAuth session | Redirect to a Stripe Checkout session for the selected tier | | `GET /settings/subscription/portal` | OAuth session | Redirect to the Stripe billing portal for the current customer | The settings page's "Subscription" panel is rendered server-side via the HTMX `/settings/billing` tab, which calls `BillingManager.GetSubscriptionInfo(userDID)` internally — there is no standalone JSON endpoint for it. ## 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 appview stripe listen --forward-to localhost:5000/api/stripe/webhook ``` 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 appview with billing export STRIPE_SECRET_KEY=sk_test_xxx export STRIPE_WEBHOOK_SECRET=whsec_xxx # from 'stripe listen' go run -tags billing ./cmd/appview serve --config config-appview.yaml # Terminal 2: Forward webhooks stripe listen --forward-to localhost:5000/api/stripe/webhook # 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 appview with billing enabled (`-tags billing`) 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 → appview updates crew tier on the user's hold 7. Refresh settings to see new tier ## Webhook Events The canonical list of subscribed events lives in `pkg/billing/events.go` as the `SubscribedEvents` slice. Use it when configuring a Stripe Dashboard webhook endpoint or auditing an existing one. | Event | Action | |-------|--------| | `checkout.session.completed` | No-op (subscription.created does the tier work) | | `customer.subscription.created` | Set crew tier from price ID | | `customer.subscription.updated` | Update tier; handles `past_due` (keep), `unpaid` / `incomplete_expired` (downgrade), `incomplete` (await) | | `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 only (Stripe Smart Retries handle retry + customer email) | | `charge.dispute.created` | Log only (Stripe emails the account owner by default) | ## 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-appview.com/api/stripe/webhook` - 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 appview 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 appview logs for webhook processing errors - Verify price ID in `quotas.yaml` matches Stripe - Ensure `billing.enabled: true` in appview config - Confirm appview was built with `-tags billing` (otherwise `/api/stripe/webhook` returns 404) ### "Billing not enabled" error - Build with `-tags billing` - Set `billing.enabled: true` in `quotas.yaml` - Ensure `STRIPE_SECRET_KEY` is set