begin billing

This commit is contained in:
Evan Jarrett
2026-02-03 20:54:35 -06:00
parent 6a52175d70
commit 5d3b6c2047
9 changed files with 1774 additions and 0 deletions
+238
View File
@@ -0,0 +1,238 @@
# 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
+1
View File
@@ -31,6 +31,7 @@ require (
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
github.com/stretchr/testify v1.11.1
github.com/stripe/stripe-go/v84 v84.3.0
github.com/whyrusleeping/cbor-gen v0.3.1
github.com/yuin/goldmark v1.7.16
go.opentelemetry.io/otel v1.40.0
+4
View File
@@ -403,6 +403,10 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stripe/stripe-go/v84 v84.1.0 h1:9KW8Fm3csWsPNqBJCgdEZBM9pRNaqpESHIw+eXp8A0k=
github.com/stripe/stripe-go/v84 v84.1.0/go.mod h1:kjXh3OrF4PT16qz7z9Q5yqYAZ1mJmu8g8f4Z1sOHBfc=
github.com/stripe/stripe-go/v84 v84.3.0 h1:77HH+ro7yzmyyF7Xkbkj6y5QtnU1WWHC6t2y4mq0Wvk=
github.com/stripe/stripe-go/v84 v84.3.0/go.mod h1:Z4gcKw1zl4geDG2+cjpSaJES9jaohGX6n7FP8/kHIqw=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
+549
View File
@@ -0,0 +1,549 @@
//go:build billing
package billing
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
"github.com/stripe/stripe-go/v84"
portalsession "github.com/stripe/stripe-go/v84/billingportal/session"
"github.com/stripe/stripe-go/v84/checkout/session"
"github.com/stripe/stripe-go/v84/customer"
"github.com/stripe/stripe-go/v84/price"
"github.com/stripe/stripe-go/v84/subscription"
"github.com/stripe/stripe-go/v84/webhook"
"atcr.io/pkg/hold/quota"
)
// Manager handles Stripe billing integration.
type Manager struct {
quotaMgr *quota.Manager
billingCfg *BillingConfig
holdPublicURL string
stripeKey string
webhookSecret string
publishableKey string
// In-memory cache for customer lookups (DID -> customer)
customerCache map[string]*cachedCustomer
customerCacheMu sync.RWMutex
}
type cachedCustomer struct {
customer *stripe.Customer
expiresAt time.Time
}
const customerCacheTTL = 10 * time.Minute
// New creates a new billing manager with Stripe integration.
func New(quotaMgr *quota.Manager, holdPublicURL string) *Manager {
stripeKey := os.Getenv("STRIPE_SECRET_KEY")
if stripeKey != "" {
stripe.Key = stripeKey
}
return &Manager{
quotaMgr: quotaMgr,
holdPublicURL: holdPublicURL,
stripeKey: stripeKey,
webhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"),
publishableKey: os.Getenv("STRIPE_PUBLISHABLE_KEY"),
customerCache: make(map[string]*cachedCustomer),
}
}
// Enabled returns true if billing is properly configured.
func (m *Manager) Enabled() bool {
return m.billingCfg != nil && m.billingCfg.Enabled && m.stripeKey != ""
}
// GetSubscriptionInfo returns subscription and quota information for a user.
func (m *Manager) GetSubscriptionInfo(userDID string) (*SubscriptionInfo, error) {
if !m.Enabled() {
return nil, ErrBillingDisabled
}
info := &SubscriptionInfo{
UserDID: userDID,
PaymentsEnabled: true,
Tiers: m.buildTierList(userDID),
}
// Try to find existing customer
cust, err := m.findCustomerByDID(userDID)
if err != nil {
slog.Debug("No Stripe customer found for user", "userDid", userDID)
} else if cust != nil {
info.CustomerID = cust.ID
// Get active subscription if any (check all nil pointers)
if cust.Subscriptions != nil && len(cust.Subscriptions.Data) > 0 {
sub := cust.Subscriptions.Data[0]
info.SubscriptionID = sub.ID
// Safely access subscription items
if sub.Items != nil && len(sub.Items.Data) > 0 && sub.Items.Data[0].Price != nil {
info.CurrentTier = m.billingCfg.GetTierByPriceID(sub.Items.Data[0].Price.ID)
if sub.Items.Data[0].Price.Recurring != nil {
switch sub.Items.Data[0].Price.Recurring.Interval {
case stripe.PriceRecurringIntervalMonth:
info.BillingInterval = "monthly"
case stripe.PriceRecurringIntervalYear:
info.BillingInterval = "yearly"
}
}
}
}
}
// If no subscription, use default tier
if info.CurrentTier == "" {
info.CurrentTier = m.quotaMgr.GetDefaultTier()
}
// Get quota limit for current tier
limit := m.quotaMgr.GetTierLimit(info.CurrentTier)
info.CurrentLimit = limit
// Mark current tier in tier list
for i := range info.Tiers {
if info.Tiers[i].ID == info.CurrentTier {
info.Tiers[i].IsCurrent = true
}
}
return info, nil
}
// buildTierList creates the list of available tiers by merging quota limits
// from the quota manager with billing metadata from the billing config.
func (m *Manager) buildTierList(userDID string) []TierInfo {
quotaTiers := m.quotaMgr.ListTiers()
if len(quotaTiers) == 0 {
return nil
}
result := make([]TierInfo, 0, len(quotaTiers))
for _, qt := range quotaTiers {
var quotaBytes int64
if qt.Limit != nil {
quotaBytes = *qt.Limit
}
// Capitalize tier ID for display name (e.g., "swabbie" -> "Swabbie")
name := strings.ToUpper(qt.Key[:1]) + qt.Key[1:]
tier := TierInfo{
ID: qt.Key,
Name: name,
QuotaBytes: quotaBytes,
QuotaFormatted: quota.FormatHumanBytes(quotaBytes),
}
// Merge billing metadata if available
if bt := m.billingCfg.GetTierPricing(qt.Key); bt != nil {
tier.Description = bt.Description
// Fetch actual prices from Stripe
if bt.StripePriceMonthly != "" {
if p, err := price.Get(bt.StripePriceMonthly, nil); err == nil && p != nil {
tier.PriceCentsMonthly = int(p.UnitAmount)
} else {
slog.Debug("Failed to fetch monthly price", "priceId", bt.StripePriceMonthly, "error", err)
tier.PriceCentsMonthly = -1
}
}
if bt.StripePriceYearly != "" {
if p, err := price.Get(bt.StripePriceYearly, nil); err == nil && p != nil {
tier.PriceCentsYearly = int(p.UnitAmount)
} else {
slog.Debug("Failed to fetch yearly price", "priceId", bt.StripePriceYearly, "error", err)
tier.PriceCentsYearly = -1
}
}
}
result = append(result, tier)
}
// Sort tiers by quota size (ascending)
sort.Slice(result, func(i, j int) bool {
return result[i].QuotaBytes < result[j].QuotaBytes
})
return result
}
// CreateCheckoutSession creates a Stripe checkout session for subscription.
func (m *Manager) CreateCheckoutSession(r *http.Request, req *CheckoutSessionRequest) (*CheckoutSessionResponse, error) {
if !m.Enabled() {
return nil, ErrBillingDisabled
}
// Get user DID from request context (set by auth middleware)
userDID := r.Header.Get("X-User-DID")
if userDID == "" {
return nil, errors.New("user not authenticated")
}
// Get tier config
tierCfg := m.billingCfg.GetTierPricing(req.Tier)
if tierCfg == nil {
return nil, fmt.Errorf("tier not found: %s", req.Tier)
}
// Determine price ID - prefer requested interval, fall back to what's available
var priceID string
switch req.Interval {
case "monthly":
priceID = tierCfg.StripePriceMonthly
case "yearly":
priceID = tierCfg.StripePriceYearly
default:
// No interval specified - prefer monthly, fall back to yearly
if tierCfg.StripePriceMonthly != "" {
priceID = tierCfg.StripePriceMonthly
} else {
priceID = tierCfg.StripePriceYearly
}
}
if priceID == "" {
return nil, fmt.Errorf("tier %s has no Stripe price configured", req.Tier)
}
// Get or create customer
cust, err := m.getOrCreateCustomer(userDID)
if err != nil {
return nil, fmt.Errorf("failed to get/create customer: %w", err)
}
// Build success/cancel URLs
successURL := strings.ReplaceAll(m.billingCfg.SuccessURL, "{hold_url}", m.holdPublicURL)
cancelURL := strings.ReplaceAll(m.billingCfg.CancelURL, "{hold_url}", m.holdPublicURL)
if req.ReturnURL != "" {
successURL = req.ReturnURL + "?success=true"
cancelURL = req.ReturnURL + "?cancelled=true"
}
// Create checkout session
params := &stripe.CheckoutSessionParams{
Customer: stripe.String(cust.ID),
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(priceID),
Quantity: stripe.Int64(1),
},
},
SuccessURL: stripe.String(successURL),
CancelURL: stripe.String(cancelURL),
}
sess, err := session.New(params)
if err != nil {
return nil, fmt.Errorf("failed to create checkout session: %w", err)
}
return &CheckoutSessionResponse{
CheckoutURL: sess.URL,
SessionID: sess.ID,
}, nil
}
// GetBillingPortalURL returns a URL to the Stripe billing portal.
func (m *Manager) GetBillingPortalURL(userDID string, returnURL string) (*BillingPortalResponse, error) {
if !m.Enabled() {
return nil, ErrBillingDisabled
}
// Find existing customer
cust, err := m.findCustomerByDID(userDID)
if err != nil || cust == nil {
return nil, errors.New("no billing account found")
}
if returnURL == "" {
returnURL = m.holdPublicURL
}
params := &stripe.BillingPortalSessionParams{
Customer: stripe.String(cust.ID),
ReturnURL: stripe.String(returnURL),
}
sess, err := portalsession.New(params)
if err != nil {
return nil, fmt.Errorf("failed to create portal session: %w", err)
}
return &BillingPortalResponse{
PortalURL: sess.URL,
}, nil
}
// HandleWebhook processes a Stripe webhook event.
func (m *Manager) HandleWebhook(r *http.Request) (*WebhookEvent, error) {
if !m.Enabled() {
return nil, ErrBillingDisabled
}
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, fmt.Errorf("failed to read request body: %w", err)
}
// Verify webhook signature
event, err := webhook.ConstructEvent(body, r.Header.Get("Stripe-Signature"), m.webhookSecret)
if err != nil {
return nil, fmt.Errorf("failed to verify webhook signature: %w", err)
}
result := &WebhookEvent{
Type: string(event.Type),
}
switch event.Type {
case "checkout.session.completed":
var sess stripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
return nil, fmt.Errorf("failed to parse checkout session: %w", err)
}
result.CustomerID = sess.Customer.ID
result.SubscriptionID = sess.Subscription.ID
result.Status = "active"
// Fetch customer to get DID from metadata
result.UserDID = m.getCustomerDID(sess.Customer.ID)
// Get subscription to find the price/tier
if sess.Subscription != nil && sess.Subscription.ID != "" {
if sub, err := m.getSubscription(sess.Subscription.ID); err == nil && sub != nil {
if len(sub.Items.Data) > 0 {
result.PriceID = sub.Items.Data[0].Price.ID
result.NewTier = m.billingCfg.GetTierByPriceID(result.PriceID)
}
}
}
if result.UserDID != "" && result.NewTier != "" {
slog.Info("Checkout completed",
"userDid", result.UserDID,
"tier", result.NewTier,
"subscriptionId", result.SubscriptionID,
)
}
case "customer.subscription.created", "customer.subscription.updated":
var sub stripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
return nil, fmt.Errorf("failed to parse subscription: %w", err)
}
result.SubscriptionID = sub.ID
result.CustomerID = sub.Customer.ID
result.Status = string(sub.Status)
if len(sub.Items.Data) > 0 {
result.PriceID = sub.Items.Data[0].Price.ID
result.NewTier = m.billingCfg.GetTierByPriceID(result.PriceID)
}
// Fetch customer to get DID from metadata (webhook doesn't include expanded customer)
result.UserDID = m.getCustomerDID(sub.Customer.ID)
// If we have user DID and new tier, this signals that crew tier should be updated
if result.UserDID != "" && result.NewTier != "" && sub.Status == stripe.SubscriptionStatusActive {
slog.Info("Subscription activated",
"userDid", result.UserDID,
"tier", result.NewTier,
"subscriptionId", result.SubscriptionID,
)
}
case "customer.subscription.deleted", "customer.subscription.paused":
var sub stripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
return nil, fmt.Errorf("failed to parse subscription: %w", err)
}
result.SubscriptionID = sub.ID
result.CustomerID = sub.Customer.ID
if event.Type == "customer.subscription.deleted" {
result.Status = "cancelled"
} else {
result.Status = "paused"
}
// Fetch customer to get DID from metadata
result.UserDID = m.getCustomerDID(sub.Customer.ID)
// Set tier to default (downgrade on cancellation/pause)
result.NewTier = m.quotaMgr.GetDefaultTier()
if result.UserDID != "" {
slog.Info("Subscription inactive, downgrading to default tier",
"userDid", result.UserDID,
"tier", result.NewTier,
"status", result.Status,
)
}
case "customer.subscription.resumed":
var sub stripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
return nil, fmt.Errorf("failed to parse subscription: %w", err)
}
result.SubscriptionID = sub.ID
result.CustomerID = sub.Customer.ID
result.Status = "active"
if len(sub.Items.Data) > 0 {
result.PriceID = sub.Items.Data[0].Price.ID
result.NewTier = m.billingCfg.GetTierByPriceID(result.PriceID)
}
// Fetch customer to get DID from metadata
result.UserDID = m.getCustomerDID(sub.Customer.ID)
if result.UserDID != "" && result.NewTier != "" {
slog.Info("Subscription resumed, restoring tier",
"userDid", result.UserDID,
"tier", result.NewTier,
)
}
}
return result, nil
}
// getOrCreateCustomer finds or creates a Stripe customer for the given DID.
func (m *Manager) getOrCreateCustomer(userDID string) (*stripe.Customer, error) {
// Check cache first
m.customerCacheMu.RLock()
if cached, ok := m.customerCache[userDID]; ok && time.Now().Before(cached.expiresAt) {
m.customerCacheMu.RUnlock()
return cached.customer, nil
}
m.customerCacheMu.RUnlock()
// Try to find existing customer
cust, err := m.findCustomerByDID(userDID)
if err == nil && cust != nil {
m.cacheCustomer(userDID, cust)
return cust, nil
}
// Create new customer
params := &stripe.CustomerParams{
Metadata: map[string]string{
"user_did": userDID,
"hold_did": m.holdPublicURL, // Not actually a DID but useful for tracking
},
}
cust, err = customer.New(params)
if err != nil {
return nil, fmt.Errorf("failed to create customer: %w", err)
}
m.cacheCustomer(userDID, cust)
return cust, nil
}
// findCustomerByDID searches Stripe for a customer with the given DID in metadata.
func (m *Manager) findCustomerByDID(userDID string) (*stripe.Customer, error) {
// Check cache first
m.customerCacheMu.RLock()
if cached, ok := m.customerCache[userDID]; ok && time.Now().Before(cached.expiresAt) {
m.customerCacheMu.RUnlock()
return cached.customer, nil
}
m.customerCacheMu.RUnlock()
// Search Stripe by metadata
params := &stripe.CustomerSearchParams{
SearchParams: stripe.SearchParams{
Query: fmt.Sprintf("metadata['user_did']:'%s'", userDID),
},
}
params.AddExpand("data.subscriptions")
iter := customer.Search(params)
if iter.Next() {
cust := iter.Customer()
m.cacheCustomer(userDID, cust)
return cust, nil
}
if err := iter.Err(); err != nil {
return nil, err
}
return nil, nil // Not found
}
// cacheCustomer adds a customer to the in-memory cache.
func (m *Manager) cacheCustomer(userDID string, cust *stripe.Customer) {
m.customerCacheMu.Lock()
defer m.customerCacheMu.Unlock()
m.customerCache[userDID] = &cachedCustomer{
customer: cust,
expiresAt: time.Now().Add(customerCacheTTL),
}
}
// InvalidateCustomerCache removes a customer from the cache.
func (m *Manager) InvalidateCustomerCache(userDID string) {
m.customerCacheMu.Lock()
defer m.customerCacheMu.Unlock()
delete(m.customerCache, userDID)
}
// getCustomerDID fetches a customer by ID and returns the user_did from metadata.
func (m *Manager) getCustomerDID(customerID string) string {
if customerID == "" {
return ""
}
cust, err := customer.Get(customerID, nil)
if err != nil {
slog.Debug("Failed to fetch customer", "customerId", customerID, "error", err)
return ""
}
if cust.Metadata != nil {
return cust.Metadata["user_did"]
}
return ""
}
// getSubscription fetches a subscription by ID.
func (m *Manager) getSubscription(subscriptionID string) (*stripe.Subscription, error) {
if subscriptionID == "" {
return nil, nil
}
params := &stripe.SubscriptionParams{}
params.AddExpand("items.data.price")
return subscription.Get(subscriptionID, params)
}
+60
View File
@@ -0,0 +1,60 @@
//go:build !billing
package billing
import (
"net/http"
"github.com/go-chi/chi/v5"
"atcr.io/pkg/hold/pds"
"atcr.io/pkg/hold/quota"
)
// Manager is a no-op billing manager when billing is not compiled in.
type Manager struct{}
// New creates a new no-op billing manager.
// This is used when the billing build tag is not set.
func New(_ *quota.Manager, _ string) *Manager {
return &Manager{}
}
// Enabled returns false when billing is not compiled in.
func (m *Manager) Enabled() bool {
return false
}
// RegisterHandlers is a no-op when billing is not compiled in.
func (m *Manager) RegisterHandlers(_ chi.Router) {}
// GetSubscriptionInfo returns an error when billing is not compiled in.
func (m *Manager) GetSubscriptionInfo(_ string) (*SubscriptionInfo, error) {
return nil, ErrBillingDisabled
}
// CreateCheckoutSession returns an error when billing is not compiled in.
func (m *Manager) CreateCheckoutSession(_ *http.Request, _ *CheckoutSessionRequest) (*CheckoutSessionResponse, error) {
return nil, ErrBillingDisabled
}
// GetBillingPortalURL returns an error when billing is not compiled in.
func (m *Manager) GetBillingPortalURL(_ string, _ string) (*BillingPortalResponse, error) {
return nil, ErrBillingDisabled
}
// HandleWebhook returns an error when billing is not compiled in.
func (m *Manager) HandleWebhook(_ *http.Request) (*WebhookEvent, error) {
return nil, ErrBillingDisabled
}
// XRPCHandler is a no-op handler when billing is not compiled in.
type XRPCHandler struct{}
// NewXRPCHandler creates a new no-op XRPC handler.
func NewXRPCHandler(_ *Manager, _ *pds.HoldPDS, _ *http.Client) *XRPCHandler {
return &XRPCHandler{}
}
// RegisterHandlers is a no-op when billing is not compiled in.
func (h *XRPCHandler) RegisterHandlers(_ chi.Router) {}
+303
View File
@@ -0,0 +1,303 @@
//go:build billing
package billing
import (
"fmt"
"os"
"go.yaml.in/yaml/v4"
"atcr.io/pkg/hold"
)
// BillingConfig holds billing/Stripe settings parsed from the hold config YAML.
// The billing fields live in the same YAML file as the hold config, but are
// ignored by atcr.io's parser (Go YAML ignores unknown fields by default).
type BillingConfig struct {
Enabled bool
Currency string
SuccessURL string
CancelURL string
// Tier-level billing info keyed by tier name (same keys as quota tiers).
Tiers map[string]BillingTierConfig
// Tier assigned to plankowner crew members.
PlankOwnerCrewTier string
}
// BillingTierConfig holds Stripe pricing for a single tier.
type BillingTierConfig struct {
Description string
StripePriceMonthly string
StripePriceYearly string
}
// --- internal YAML structs for parsing the extended hold config ---
// extendedHoldConfig mirrors the hold config but only the quota section.
type extendedHoldConfig struct {
Quota extendedQuotaConfig `yaml:"quota"`
}
type extendedQuotaConfig struct {
Tiers map[string]extendedTierConfig `yaml:"tiers"`
Defaults extendedDefaults `yaml:"defaults"`
Billing rawBillingConfig `yaml:"billing"`
}
type extendedTierConfig struct {
Description string `yaml:"description,omitempty"`
StripePriceMonthly string `yaml:"stripe_price_monthly,omitempty"`
StripePriceYearly string `yaml:"stripe_price_yearly,omitempty"`
}
type extendedDefaults struct {
PlankOwnerCrewTier string `yaml:"plankowner_crew_tier,omitempty"`
}
type rawBillingConfig struct {
Enabled bool `yaml:"enabled"`
Currency string `yaml:"currency,omitempty"`
SuccessURL string `yaml:"success_url,omitempty"`
CancelURL string `yaml:"cancel_url,omitempty"`
}
// LoadBillingConfig reads the hold config YAML and extracts billing fields.
// Returns (nil, nil) if the file is missing or billing is not enabled.
// Returns (nil, err) if the file exists with billing enabled but is misconfigured.
func LoadBillingConfig(configPath string) (*BillingConfig, error) {
if configPath == "" {
return nil, nil
}
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to read config: %w", err)
}
return parseBillingConfig(data)
}
// parseBillingConfig extracts billing fields from hold config YAML bytes.
// Returns (nil, nil) if billing is not enabled.
// Returns (nil, err) if billing is enabled but misconfigured.
func parseBillingConfig(data []byte) (*BillingConfig, error) {
var ext extendedHoldConfig
if err := yaml.Unmarshal(data, &ext); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
if !ext.Quota.Billing.Enabled {
return nil, nil
}
cfg := &BillingConfig{
Enabled: true,
Currency: ext.Quota.Billing.Currency,
SuccessURL: ext.Quota.Billing.SuccessURL,
CancelURL: ext.Quota.Billing.CancelURL,
PlankOwnerCrewTier: ext.Quota.Defaults.PlankOwnerCrewTier,
Tiers: make(map[string]BillingTierConfig, len(ext.Quota.Tiers)),
}
for name, tier := range ext.Quota.Tiers {
cfg.Tiers[name] = BillingTierConfig{
Description: tier.Description,
StripePriceMonthly: tier.StripePriceMonthly,
StripePriceYearly: tier.StripePriceYearly,
}
}
// Validate: billing enabled but no tiers have any Stripe prices configured
hasAnyPrice := false
for _, tier := range cfg.Tiers {
if tier.StripePriceMonthly != "" || tier.StripePriceYearly != "" {
hasAnyPrice = true
break
}
}
if !hasAnyPrice {
return nil, fmt.Errorf("billing is enabled but no tiers have Stripe prices configured")
}
return cfg, nil
}
// GetTierPricing returns billing info for a tier, or nil if not found.
func (c *BillingConfig) GetTierPricing(tierKey string) *BillingTierConfig {
if c == nil {
return nil
}
t, ok := c.Tiers[tierKey]
if !ok {
return nil
}
return &t
}
// GetTierByPriceID finds the tier key that contains the given Stripe price ID.
// Returns empty string if no match.
func (c *BillingConfig) GetTierByPriceID(priceID string) string {
if c == nil || priceID == "" {
return ""
}
for key, tier := range c.Tiers {
if tier.StripePriceMonthly == priceID || tier.StripePriceYearly == priceID {
return key
}
}
return ""
}
// ExampleHoldYAML generates a complete hold config example including billing fields.
// It calls hold.ExampleYAML() for the base config, then injects billing-specific
// fields into the YAML node tree before re-marshalling.
func ExampleHoldYAML() ([]byte, error) {
base, err := hold.ExampleYAML()
if err != nil {
return nil, fmt.Errorf("failed to generate base hold config: %w", err)
}
var doc yaml.Node
if err := yaml.Unmarshal(base, &doc); err != nil {
return nil, fmt.Errorf("failed to parse base hold config: %w", err)
}
// doc is DocumentNode -> Content[0] is the root MappingNode
if doc.Kind != yaml.DocumentNode || len(doc.Content) == 0 {
return nil, fmt.Errorf("unexpected YAML structure")
}
root := doc.Content[0]
// Find the "quota" mapping inside root
quotaNode := findMappingValue(root, "quota")
if quotaNode == nil {
return nil, fmt.Errorf("quota section not found in base config")
}
// Inject billing fields into tier entries
tiersNode := findMappingValue(quotaNode, "tiers")
if tiersNode != nil {
injectTierBillingFields(tiersNode)
}
// Inject plankowner_crew_tier into defaults
defaultsNode := findMappingValue(quotaNode, "defaults")
if defaultsNode != nil {
injectPlankOwnerDefault(defaultsNode)
}
// Inject billing section under quota
injectBillingSection(quotaNode)
return yaml.Marshal(&doc)
}
// findMappingValue finds a value node in a YAML mapping by key.
func findMappingValue(mapping *yaml.Node, key string) *yaml.Node {
if mapping.Kind != yaml.MappingNode {
return nil
}
for i := 0; i < len(mapping.Content)-1; i += 2 {
if mapping.Content[i].Value == key {
return mapping.Content[i+1]
}
}
return nil
}
// injectTierBillingFields adds description and stripe_price fields to each tier entry.
func injectTierBillingFields(tiersNode *yaml.Node) {
if tiersNode.Kind != yaml.MappingNode {
return
}
examples := map[string]struct {
description string
monthly string
yearly string
}{
"bosun": {"Standard tier — recommended for most users.", "price_bosun_monthly_id", "price_bosun_yearly_id"},
"deckhand": {"Starter tier — free for new crew members.", "", ""},
"quartermaster": {"Professional tier — for power users and teams.", "price_qm_monthly_id", "price_qm_yearly_id"},
}
for i := 0; i < len(tiersNode.Content)-1; i += 2 {
tierKey := tiersNode.Content[i].Value
tierVal := tiersNode.Content[i+1]
if tierVal.Kind != yaml.MappingNode {
continue
}
ex, ok := examples[tierKey]
if !ok {
continue
}
// Add description
tierVal.Content = append(tierVal.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "description", HeadComment: "Human-readable tier description (used in billing UI)."},
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ex.description},
)
// Add stripe prices if applicable
if ex.monthly != "" {
tierVal.Content = append(tierVal.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "stripe_price_monthly", HeadComment: "Stripe Price ID for monthly billing."},
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ex.monthly},
)
}
if ex.yearly != "" {
tierVal.Content = append(tierVal.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "stripe_price_yearly", HeadComment: "Stripe Price ID for yearly billing (optional)."},
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ex.yearly},
)
}
}
}
// injectPlankOwnerDefault adds plankowner_crew_tier to the defaults section.
func injectPlankOwnerDefault(defaultsNode *yaml.Node) {
if defaultsNode.Kind != yaml.MappingNode {
return
}
defaultsNode.Content = append(defaultsNode.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "plankowner_crew_tier", HeadComment: "Tier granted to early crew members (plankowners). Ignored by base hold service."},
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "bosun"},
)
}
// injectBillingSection adds the billing subsection under quota.
func injectBillingSection(quotaNode *yaml.Node) {
if quotaNode.Kind != yaml.MappingNode {
return
}
billing := &yaml.Node{
Kind: yaml.MappingNode,
Tag: "!!map",
}
billing.Content = append(billing.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "enabled"},
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: "false"},
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "currency", HeadComment: "ISO 4217 currency code for Stripe charges."},
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "usd"},
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "success_url", HeadComment: "Redirect URL after successful checkout. {hold_url} is replaced at runtime."},
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "{hold_url}/billing/success"},
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "cancel_url", HeadComment: "Redirect URL when checkout is cancelled."},
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "{hold_url}/billing/cancel"},
)
quotaNode.Content = append(quotaNode.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "billing", HeadComment: "Stripe billing settings. Ignored by base hold service (seamark.dev only)."},
billing,
)
}
+332
View File
@@ -0,0 +1,332 @@
package billing
import (
"os"
"path/filepath"
"testing"
"go.yaml.in/yaml/v4"
"atcr.io/pkg/hold/quota"
)
// yamlUnmarshal is a thin wrapper to avoid shadowing the yaml package import.
func yamlUnmarshal(data []byte, v any) error {
return yaml.Unmarshal(data, v)
}
func TestParseBillingConfig_Disabled(t *testing.T) {
yaml := []byte(`
quota:
tiers:
deckhand:
quota: 5GB
billing:
enabled: false
`)
cfg, err := parseBillingConfig(yaml)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg != nil {
t.Error("expected nil config when billing disabled")
}
}
func TestParseBillingConfig_NoBillingSection(t *testing.T) {
yaml := []byte(`
quota:
tiers:
deckhand:
quota: 5GB
`)
cfg, err := parseBillingConfig(yaml)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg != nil {
t.Error("expected nil config when no billing section")
}
}
func TestParseBillingConfig_Enabled(t *testing.T) {
yaml := []byte(`
quota:
tiers:
deckhand:
quota: 5GB
description: Starter tier
bosun:
quota: 50GB
description: Standard tier
stripe_price_monthly: price_bosun_monthly
stripe_price_yearly: price_bosun_yearly
defaults:
new_crew_tier: deckhand
plankowner_crew_tier: bosun
billing:
enabled: true
currency: usd
success_url: "{hold_url}/billing/success"
cancel_url: "{hold_url}/billing/cancel"
`)
cfg, err := parseBillingConfig(yaml)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg == nil {
t.Fatal("expected non-nil config")
}
if !cfg.Enabled {
t.Error("expected Enabled=true")
}
if cfg.Currency != "usd" {
t.Errorf("expected currency 'usd', got %q", cfg.Currency)
}
if cfg.PlankOwnerCrewTier != "bosun" {
t.Errorf("expected plankowner_crew_tier 'bosun', got %q", cfg.PlankOwnerCrewTier)
}
if cfg.SuccessURL != "{hold_url}/billing/success" {
t.Errorf("unexpected success_url: %q", cfg.SuccessURL)
}
// Check tier pricing
bosun := cfg.GetTierPricing("bosun")
if bosun == nil {
t.Fatal("expected bosun tier pricing")
}
if bosun.StripePriceMonthly != "price_bosun_monthly" {
t.Errorf("expected bosun monthly price 'price_bosun_monthly', got %q", bosun.StripePriceMonthly)
}
if bosun.StripePriceYearly != "price_bosun_yearly" {
t.Errorf("expected bosun yearly price 'price_bosun_yearly', got %q", bosun.StripePriceYearly)
}
if bosun.Description != "Standard tier" {
t.Errorf("expected bosun description 'Standard tier', got %q", bosun.Description)
}
// Deckhand has no prices
deckhand := cfg.GetTierPricing("deckhand")
if deckhand == nil {
t.Fatal("expected deckhand tier pricing entry")
}
if deckhand.StripePriceMonthly != "" {
t.Error("expected no monthly price for deckhand")
}
}
func TestParseBillingConfig_EnabledButNoPrices(t *testing.T) {
yaml := []byte(`
quota:
tiers:
deckhand:
quota: 5GB
billing:
enabled: true
currency: usd
`)
cfg, err := parseBillingConfig(yaml)
if err == nil {
t.Error("expected error when billing enabled but no prices configured")
}
if cfg != nil {
t.Error("expected nil config on error")
}
}
func TestGetTierByPriceID(t *testing.T) {
cfg := &BillingConfig{
Tiers: map[string]BillingTierConfig{
"deckhand": {},
"bosun": {StripePriceMonthly: "price_m", StripePriceYearly: "price_y"},
},
}
if got := cfg.GetTierByPriceID("price_m"); got != "bosun" {
t.Errorf("expected 'bosun' for monthly price, got %q", got)
}
if got := cfg.GetTierByPriceID("price_y"); got != "bosun" {
t.Errorf("expected 'bosun' for yearly price, got %q", got)
}
if got := cfg.GetTierByPriceID("price_unknown"); got != "" {
t.Errorf("expected empty for unknown price, got %q", got)
}
if got := cfg.GetTierByPriceID(""); got != "" {
t.Errorf("expected empty for empty price, got %q", got)
}
// nil receiver
var nilCfg *BillingConfig
if got := nilCfg.GetTierByPriceID("price_m"); got != "" {
t.Errorf("expected empty from nil config, got %q", got)
}
}
func TestGetTierPricing_NilConfig(t *testing.T) {
var cfg *BillingConfig
if cfg.GetTierPricing("anything") != nil {
t.Error("expected nil from nil config")
}
}
func TestLoadBillingConfig_MissingFile(t *testing.T) {
cfg, err := LoadBillingConfig("/nonexistent/config.yaml")
if err != nil {
t.Fatalf("expected no error for missing file, got: %v", err)
}
if cfg != nil {
t.Error("expected nil config for missing file")
}
}
func TestLoadBillingConfig_EmptyPath(t *testing.T) {
cfg, err := LoadBillingConfig("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg != nil {
t.Error("expected nil config for empty path")
}
}
func TestLoadBillingConfig_FromFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
content := `
quota:
tiers:
bosun:
quota: 50GB
stripe_price_monthly: price_test
billing:
enabled: true
currency: usd
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
cfg, err := LoadBillingConfig(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg == nil {
t.Fatal("expected non-nil config")
}
if cfg.GetTierByPriceID("price_test") != "bosun" {
t.Error("expected bosun tier for price_test")
}
}
// holdQuotaWrapper mirrors the hold config structure just enough to extract
// the quota section for testing. This avoids importing the full hold package.
type holdQuotaWrapper struct {
Quota quota.Config `yaml:"quota"`
}
// TestExampleHoldYAMLRoundTrip verifies that the generated example config
// can be parsed by both atcr.io's quota parser and seamark.dev's billing parser.
// This catches silent breakage if atcr.io renames or restructures the quota section.
func TestExampleHoldYAMLRoundTrip(t *testing.T) {
yamlBytes, err := ExampleHoldYAML()
if err != nil {
t.Fatalf("ExampleHoldYAML failed: %v", err)
}
// Verify atcr.io's quota parser can read the quota section.
// The full hold config nests tiers under "quota:", so we parse with
// a wrapper struct (same as hold.Config does) then use NewManagerFromConfig.
var wrapper holdQuotaWrapper
if err := yamlUnmarshal(yamlBytes, &wrapper); err != nil {
t.Fatalf("failed to parse generated config for quota: %v", err)
}
quotaMgr, err := quota.NewManagerFromConfig(&wrapper.Quota)
if err != nil {
t.Fatalf("quota.NewManagerFromConfig failed: %v", err)
}
if !quotaMgr.IsEnabled() {
t.Error("expected quotas to be enabled in generated config")
}
if quotaMgr.TierCount() != 3 {
t.Errorf("expected 3 quota tiers, got %d", quotaMgr.TierCount())
}
if quotaMgr.GetDefaultTier() != "deckhand" {
t.Errorf("expected default tier 'deckhand', got %q", quotaMgr.GetDefaultTier())
}
// The generated example has billing.enabled: false, so parseBillingConfig
// returns nil. Enable it to verify the billing fields were injected correctly.
// Use the full "billing:\n...enabled:" pattern to avoid replacing admin.enabled.
enabledYAML := replaceOnce(string(yamlBytes), "billing:\n enabled: false", "billing:\n enabled: true")
billingCfg, err := parseBillingConfig([]byte(enabledYAML))
if err != nil {
t.Fatalf("parseBillingConfig failed on generated config: %v", err)
}
if billingCfg == nil {
t.Fatal("expected non-nil billing config after enabling")
}
// Verify billing fields were injected into the YAML
if billingCfg.Currency != "usd" {
t.Errorf("expected currency 'usd', got %q", billingCfg.Currency)
}
if billingCfg.PlankOwnerCrewTier != "bosun" {
t.Errorf("expected plankowner_crew_tier 'bosun', got %q", billingCfg.PlankOwnerCrewTier)
}
// Verify tier-level billing fields
bosun := billingCfg.GetTierPricing("bosun")
if bosun == nil {
t.Fatal("expected bosun billing tier")
}
if bosun.StripePriceMonthly == "" {
t.Error("expected bosun to have stripe_price_monthly")
}
if bosun.Description == "" {
t.Error("expected bosun to have description")
}
qm := billingCfg.GetTierPricing("quartermaster")
if qm == nil {
t.Fatal("expected quartermaster billing tier")
}
if qm.StripePriceMonthly == "" {
t.Error("expected quartermaster to have stripe_price_monthly")
}
// Deckhand is the free tier — no Stripe prices expected
deckhand := billingCfg.GetTierPricing("deckhand")
if deckhand == nil {
t.Fatal("expected deckhand billing tier entry")
}
if deckhand.StripePriceMonthly != "" {
t.Error("expected no stripe_price_monthly for deckhand")
}
// Verify the price ID reverse lookup works
if billingCfg.GetTierByPriceID(bosun.StripePriceMonthly) != "bosun" {
t.Error("GetTierByPriceID failed for bosun monthly price")
}
}
// replaceOnce replaces the first occurrence of old with new in s.
func replaceOnce(s, old, new string) string {
i := indexOf(s, old)
if i < 0 {
return s
}
return s[:i] + new + s[i+len(old):]
}
func indexOf(s, substr string) int {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}
+222
View File
@@ -0,0 +1,222 @@
//go:build billing
package billing
import (
"encoding/json"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"atcr.io/pkg/hold/pds"
)
// XRPCHandler handles billing-related XRPC endpoints.
type XRPCHandler struct {
manager *Manager
pdsServer *pds.HoldPDS
httpClient *http.Client
}
// NewXRPCHandler creates a new billing XRPC handler.
func NewXRPCHandler(manager *Manager, pdsServer *pds.HoldPDS, httpClient *http.Client) *XRPCHandler {
return &XRPCHandler{
manager: manager,
pdsServer: pdsServer,
httpClient: httpClient,
}
}
// RegisterHandlers registers billing XRPC endpoints on the router.
func (m *Manager) RegisterHandlers(r chi.Router) {
// This is a no-op for the Manager itself
// Use NewXRPCHandler and call its RegisterHandlers method
}
// RegisterHandlers registers billing endpoints on the router.
func (h *XRPCHandler) RegisterHandlers(r chi.Router) {
if !h.manager.Enabled() {
slog.Info("Billing endpoints disabled (not configured)")
return
}
slog.Info("Registering billing XRPC endpoints")
// Public endpoint - get subscription info (auth optional for tiers list)
r.Get("/xrpc/io.atcr.hold.getSubscriptionInfo", h.HandleGetSubscriptionInfo)
// Authenticated endpoints
r.Group(func(r chi.Router) {
r.Use(h.requireAuth)
r.Post("/xrpc/io.atcr.hold.createCheckoutSession", h.HandleCreateCheckoutSession)
r.Get("/xrpc/io.atcr.hold.getBillingPortalUrl", h.HandleGetBillingPortalURL)
})
// Stripe webhook (authenticated by Stripe signature)
r.Post("/xrpc/io.atcr.hold.stripeWebhook", h.HandleStripeWebhook)
}
// requireAuth is middleware that validates user authentication.
func (h *XRPCHandler) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Use the same auth validation as other hold endpoints
user, err := pds.ValidateDPoPRequest(r, h.httpClient)
if err != nil {
// Try service token
user, err = pds.ValidateServiceToken(r, h.pdsServer.DID(), h.httpClient)
}
if err != nil {
respondError(w, http.StatusUnauthorized, "authentication required")
return
}
// Store user DID in header for handlers
r.Header.Set("X-User-DID", user.DID)
next.ServeHTTP(w, r)
})
}
// HandleGetSubscriptionInfo returns subscription and quota information.
// GET /xrpc/io.atcr.hold.getSubscriptionInfo?userDid=did:plc:xxx
func (h *XRPCHandler) HandleGetSubscriptionInfo(w http.ResponseWriter, r *http.Request) {
userDID := r.URL.Query().Get("userDid")
// If no userDID provided, try to get from auth
if userDID == "" {
// Try to authenticate (optional)
user, err := pds.ValidateDPoPRequest(r, h.httpClient)
if err != nil {
user, _ = pds.ValidateServiceToken(r, h.pdsServer.DID(), h.httpClient)
}
if user != nil {
userDID = user.DID
}
}
info, err := h.manager.GetSubscriptionInfo(userDID)
if err != nil {
if err == ErrBillingDisabled {
// Return basic info with payments disabled
respondJSON(w, http.StatusOK, &SubscriptionInfo{
UserDID: userDID,
PaymentsEnabled: false,
Tiers: h.manager.buildTierList(userDID),
})
return
}
respondError(w, http.StatusInternalServerError, err.Error())
return
}
// Get current usage and crew tier from PDS quota stats
if userDID != "" {
stats, err := h.pdsServer.GetQuotaForUserWithTier(r.Context(), userDID, h.manager.quotaMgr)
if err == nil {
info.CurrentUsage = stats.TotalSize
info.CrewTier = stats.Tier // tier from local crew record (what's actually enforced)
info.CurrentLimit = stats.Limit
// If no subscription but crew has a tier, show that as current
if info.SubscriptionID == "" && info.CrewTier != "" {
info.CurrentTier = info.CrewTier
}
}
}
// Mark which tier is actually current (use crew tier if available, otherwise subscription tier)
effectiveTier := info.CurrentTier
if info.CrewTier != "" {
effectiveTier = info.CrewTier
}
for i := range info.Tiers {
info.Tiers[i].IsCurrent = info.Tiers[i].ID == effectiveTier
}
respondJSON(w, http.StatusOK, info)
}
// HandleCreateCheckoutSession creates a Stripe checkout session.
// POST /xrpc/io.atcr.hold.createCheckoutSession
func (h *XRPCHandler) HandleCreateCheckoutSession(w http.ResponseWriter, r *http.Request) {
var req CheckoutSessionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Tier == "" {
respondError(w, http.StatusBadRequest, "tier is required")
return
}
resp, err := h.manager.CreateCheckoutSession(r, &req)
if err != nil {
slog.Error("Failed to create checkout session", "error", err)
respondError(w, http.StatusInternalServerError, err.Error())
return
}
respondJSON(w, http.StatusOK, resp)
}
// HandleGetBillingPortalURL returns a URL to the Stripe billing portal.
// GET /xrpc/io.atcr.hold.getBillingPortalUrl?returnUrl=https://...
func (h *XRPCHandler) HandleGetBillingPortalURL(w http.ResponseWriter, r *http.Request) {
userDID := r.Header.Get("X-User-DID")
returnURL := r.URL.Query().Get("returnUrl")
resp, err := h.manager.GetBillingPortalURL(userDID, returnURL)
if err != nil {
slog.Error("Failed to get billing portal URL", "error", err, "userDid", userDID)
respondError(w, http.StatusInternalServerError, err.Error())
return
}
respondJSON(w, http.StatusOK, resp)
}
// HandleStripeWebhook processes Stripe webhook events.
// POST /xrpc/io.atcr.hold.stripeWebhook
func (h *XRPCHandler) HandleStripeWebhook(w http.ResponseWriter, r *http.Request) {
event, err := h.manager.HandleWebhook(r)
if err != nil {
slog.Error("Failed to process webhook", "error", err)
respondError(w, http.StatusBadRequest, err.Error())
return
}
// If we have a tier update, apply it to the crew record
if event.UserDID != "" && event.NewTier != "" {
if err := h.pdsServer.UpdateCrewMemberTier(r.Context(), event.UserDID, event.NewTier); err != nil {
slog.Error("Failed to update crew tier", "error", err, "userDid", event.UserDID, "tier", event.NewTier)
// Don't fail the webhook - Stripe will retry
} else {
slog.Info("Updated crew tier from subscription",
"userDid", event.UserDID,
"tier", event.NewTier,
"event", event.Type,
)
}
// Invalidate customer cache since subscription changed
h.manager.InvalidateCustomerCache(event.UserDID)
}
// Return 200 to acknowledge receipt
respondJSON(w, http.StatusOK, map[string]string{"received": "true"})
}
// respondJSON writes a JSON response.
func respondJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("Failed to encode JSON response", "error", err)
}
}
// respondError writes a JSON error response.
func respondError(w http.ResponseWriter, status int, message string) {
respondJSON(w, status, map[string]string{"error": message})
}
+65
View File
@@ -0,0 +1,65 @@
// Package billing provides optional Stripe billing integration for hold services.
// This package uses build tags to conditionally compile Stripe support.
// Build with -tags billing to enable Stripe integration.
package billing
import "errors"
// ErrBillingDisabled is returned when billing operations are attempted
// but billing is not enabled (either not compiled in or disabled at runtime).
var ErrBillingDisabled = errors.New("billing not enabled")
// SubscriptionInfo contains subscription and quota information for a user.
type SubscriptionInfo struct {
UserDID string `json:"userDid"`
CurrentTier string `json:"currentTier"` // tier from Stripe subscription (or default)
CrewTier string `json:"crewTier,omitempty"` // tier from local crew record (what's actually enforced)
CurrentUsage int64 `json:"currentUsage"` // bytes used
CurrentLimit *int64 `json:"currentLimit,omitempty"` // nil = unlimited
PaymentsEnabled bool `json:"paymentsEnabled"` // whether online payments are available
Tiers []TierInfo `json:"tiers"` // available tiers
SubscriptionID string `json:"subscriptionId,omitempty"` // Stripe subscription ID if active
CustomerID string `json:"customerId,omitempty"` // Stripe customer ID if exists
BillingInterval string `json:"billingInterval,omitempty"` // "monthly" or "yearly"
}
// TierInfo describes a single tier available for subscription.
type TierInfo struct {
ID string `json:"id"` // tier key (e.g., "deckhand", "bosun")
Name string `json:"name"` // display name (same as ID if not specified)
Description string `json:"description,omitempty"` // human-readable description
QuotaBytes int64 `json:"quotaBytes"` // quota limit in bytes
QuotaFormatted string `json:"quotaFormatted"` // human-readable quota (e.g., "5 GB")
PriceCentsMonthly int `json:"priceCentsMonthly,omitempty"` // monthly price in cents (0 = free)
PriceCentsYearly int `json:"priceCentsYearly,omitempty"` // yearly price in cents (0 = not available)
IsCurrent bool `json:"isCurrent,omitempty"` // whether this is user's current tier
}
// CheckoutSessionRequest is the request to create a Stripe checkout session.
type CheckoutSessionRequest struct {
Tier string `json:"tier"` // tier to subscribe to
Interval string `json:"interval,omitempty"` // "monthly" or "yearly" (default: monthly)
ReturnURL string `json:"returnUrl,omitempty"` // URL to return to after checkout
}
// CheckoutSessionResponse is the response with the Stripe checkout URL.
type CheckoutSessionResponse struct {
CheckoutURL string `json:"checkoutUrl"`
SessionID string `json:"sessionId"`
}
// BillingPortalResponse is the response with the Stripe billing portal URL.
type BillingPortalResponse struct {
PortalURL string `json:"portalUrl"`
}
// WebhookEvent represents a processed Stripe webhook event.
type WebhookEvent struct {
Type string `json:"type"` // e.g., "customer.subscription.updated"
CustomerID string `json:"customerId"` // Stripe customer ID
UserDID string `json:"userDid"` // user's DID from customer metadata
SubscriptionID string `json:"subscriptionId,omitempty"` // Stripe subscription ID
PriceID string `json:"priceId,omitempty"` // Stripe price ID
NewTier string `json:"newTier,omitempty"` // resolved tier name
Status string `json:"status,omitempty"` // subscription status
}