mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-10 04:06:06 +00:00
begin billing
This commit is contained in:
+238
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user