Files
at-container-registry/docs/BILLING.md
Evan Jarrett 5d3b6c2047 begin billing
2026-02-03 20:54:35 -06:00

239 lines
6.7 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:
```
## 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