mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-04-20 08:30:29 +00:00
239 lines
6.7 KiB
Markdown
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
|