mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-03 00:36:56 +00:00
243 lines
7.4 KiB
Markdown
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
|