mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 12:17:00 +00:00
- diff view gains a Packages tab with added/removed/changed/unchanged package tables and purl-derived type/license/upstream links - captain records verified against the DID's atcr_hold service before caching (processor + batch backfill), preventing forged holds - fix empty-handle updates clobbering cached handles and colliding on the UNIQUE constraint - move fillPrevCIDs into repo.go; DirectRepoOperator is now canonical, repomgr kept as a test oracle - surface read-only crew status in hold selector - reconcile docs
281 lines
11 KiB
Markdown
281 lines
11 KiB
Markdown
# Billing Integration
|
|
|
|
Optional Stripe billing integration. Allows charging for subscription tiers, which map to storage quotas and feature gates on managed holds.
|
|
|
|
## Overview
|
|
|
|
- **Compile-time optional**: Build the appview with `-tags billing` to enable Stripe support
|
|
- **AppView owns billing**: All Stripe interaction (checkout, customer portal, webhook handling) lives in the appview (`pkg/billing/`)
|
|
- **Holds enforce quota**: On a subscription change, the appview pushes a tier update to each managed hold; the hold maps the tier rank to its own quota tier and enforces it
|
|
- **Customer-DID mapping**: User DIDs are stored in Stripe customer metadata (no extra database)
|
|
|
|
## Architecture
|
|
|
|
```
|
|
User → AppView Settings UI → AppView (pkg/billing) → Stripe
|
|
↑
|
|
Stripe webhook → POST /api/stripe/webhook (AppView)
|
|
↓
|
|
io.atcr.hold.updateCrewTier (signed appview token) → Hold → update crew tier / enforce quota
|
|
```
|
|
|
|
The appview is the sole billing authority: it creates checkout and portal sessions, receives Stripe webhooks at `POST /api/stripe/webhook`, and resolves the subscription's price ID to a tier rank. On a subscription change it calls each managed hold's `io.atcr.hold.updateCrewTier` endpoint (`pkg/appview/holdclient/tier_update.go`), authenticated with a short-lived JWT signed by the appview's P-256 key. The hold verifies that token against its configured appview DID and only then updates the crew member's quota tier (`pkg/hold/pds/xrpc.go`, `HandleUpdateCrewTier`). Holds never talk to Stripe and trust nothing but a valid appview-signed token; their job is quota enforcement, not payment.
|
|
|
|
## Building with Billing Support
|
|
|
|
Billing lives entirely in the AppView (`pkg/billing/`). The hold binary does not need a special build tag.
|
|
|
|
```bash
|
|
# AppView without billing (default)
|
|
go build -o bin/atcr-appview ./cmd/appview
|
|
|
|
# AppView with billing
|
|
go build -tags billing -o bin/atcr-appview ./cmd/appview
|
|
|
|
# Docker (Dockerfile.appview does not include -tags billing by default;
|
|
# build locally with the tag if you need billing support)
|
|
go build -tags billing -o bin/atcr-appview ./cmd/appview
|
|
```
|
|
|
|
## 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)
|
|
```
|
|
|
|
### Billing tiers (appview config)
|
|
|
|
Stripe tiers are configured as a **list** under the `billing:` section of the appview config (`pkg/billing/config.go`). Position in the list determines tier rank (0-based, lowest to highest). Billing auto-enables when a Stripe secret key is set and at least one tier is configured.
|
|
|
|
```yaml
|
|
billing:
|
|
# Can also be set via STRIPE_SECRET_KEY env var (takes precedence).
|
|
stripe_secret_key: sk_live_xxx
|
|
# Can also be set via STRIPE_WEBHOOK_SECRET env var (takes precedence).
|
|
webhook_secret: whsec_xxx
|
|
currency: usd
|
|
success_url: "{base_url}/settings/billing"
|
|
cancel_url: "{base_url}/settings/billing"
|
|
tiers:
|
|
- name: Free
|
|
description: Get started with basic storage
|
|
features: []
|
|
stripe_price_monthly: "" # empty = free tier
|
|
stripe_price_yearly: ""
|
|
max_webhooks: 1
|
|
webhook_all_triggers: false
|
|
ai_advisor: false
|
|
supporter_badge: false
|
|
- name: Supporter
|
|
description: Support the project
|
|
stripe_price_yearly: price_xxx
|
|
max_webhooks: 1
|
|
webhook_all_triggers: true
|
|
ai_advisor: true
|
|
supporter_badge: true
|
|
- name: Pro
|
|
description: More storage with scan-on-push
|
|
stripe_price_monthly: price_xxx
|
|
stripe_price_yearly: price_xxx
|
|
max_webhooks: 10
|
|
webhook_all_triggers: true
|
|
ai_advisor: true
|
|
supporter_badge: true
|
|
```
|
|
|
|
### Quota tiers (hold config)
|
|
|
|
Storage quotas are configured separately, in the `quota:` section of each **hold's** config (`pkg/hold/quota/config.go`). These are also a position-ranked list. The appview pushes a tier *rank* to the hold via `updateCrewTier`; the hold maps that rank onto its own quota tier list, so the billing tier names and quota tier names do not need to match (only ranks line up). Real quota tier names are `deckhand`, `bosun`, `quartermaster` (there is no "swabbie" tier).
|
|
|
|
```yaml
|
|
quota:
|
|
tiers:
|
|
- name: free
|
|
quota: 5GB
|
|
scan_on_push: false
|
|
- name: deckhand
|
|
quota: 5GB
|
|
scan_on_push: false
|
|
- name: bosun
|
|
quota: 50GB
|
|
scan_on_push: true
|
|
- name: quartermaster
|
|
quota: 100GB
|
|
scan_on_push: true
|
|
defaults:
|
|
new_crew_tier: deckhand
|
|
```
|
|
|
|
### 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 (planned)
|
|
|
|
`io.atcr.hold.crew` records carry a `plankowner` boolean flag (`Plankowner` on `CrewRecord` in `pkg/atproto/lexicon.go`) intended to mark early adopters:
|
|
|
|
```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"
|
|
}
|
|
```
|
|
|
|
The flag exists on the record, but automated grandfathering behavior is **not implemented**. There is no `plankowner_crew_tier` config field, and nothing currently grants a paid tier for free or treats plankowners differently from other crew members at billing time. Their assigned `tier` is whatever is set on the crew record. Treat this section as a placeholder for future grandfathering logic.
|
|
|
|
## Customer-DID Mapping
|
|
|
|
The user's DID is stored in Stripe customer metadata (set by `getOrCreateCustomer` in `pkg/billing/billing.go`):
|
|
|
|
```json
|
|
{
|
|
"metadata": {
|
|
"user_did": "did:plc:xxx"
|
|
}
|
|
}
|
|
```
|
|
|
|
Only `user_did` is stored. The appview resolves the customer for a DID by searching Stripe customer metadata, and reads `user_did` back from webhook events to know which user to update.
|
|
|
|
The appview uses an in-memory customer cache (10 min TTL) to reduce Stripe API calls. On webhook events, the cache is invalidated for the affected user.
|
|
|
|
## 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 the appview config `billing.tiers` 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 the price ID in the appview config `billing.tiers` matches Stripe
|
|
- Confirm the appview was built with `-tags billing` (otherwise `/api/stripe/webhook` returns 404)
|
|
- Confirm each managed hold has the appview DID configured (so it accepts the signed `updateCrewTier` call) and has matching quota tier ranks
|
|
|
|
### "Billing not enabled" error
|
|
There is no `billing.enabled` flag. Billing auto-enables when all of the following hold (see `Manager.Enabled()` in `pkg/billing/billing.go`):
|
|
- The appview was built with `-tags billing`
|
|
- A Stripe secret key is set (via `STRIPE_SECRET_KEY` env var or `billing.stripe_secret_key`)
|
|
- At least one tier is configured under `billing.tiers`
|