Files
at-container-registry/docs/BILLING.md
T
2026-05-18 22:10:20 -05:00

243 lines
7.4 KiB
Markdown

# 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